@flow-engineer/flow 0.0.1 → 0.2.1

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 (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +611 -0
  3. package/coding-agents/skill.md +353 -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 +40 -0
  7. package/dist/flow-messaging/src/client.js +142 -0
  8. package/dist/flow-messaging/src/index.d.ts +72 -0
  9. package/dist/flow-messaging/src/index.js +605 -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 +22 -0
  35. package/dist/src/names.js +43 -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/account.d.ts +4 -0
  57. package/dist/testing/src/account.js +580 -0
  58. package/dist/testing/src/chat.d.ts +3 -0
  59. package/dist/testing/src/chat.js +117 -0
  60. package/dist/testing/src/check-endpoint.d.ts +31 -0
  61. package/dist/testing/src/check-endpoint.js +158 -0
  62. package/dist/testing/src/chinwag.d.ts +2 -0
  63. package/dist/testing/src/chinwag.js +6 -0
  64. package/dist/testing/src/cli.d.ts +2 -0
  65. package/dist/testing/src/cli.js +592 -0
  66. package/dist/testing/src/client.d.ts +117 -0
  67. package/dist/testing/src/client.js +956 -0
  68. package/dist/testing/src/flow-login.d.ts +143 -0
  69. package/dist/testing/src/flow-login.js +249 -0
  70. package/dist/testing/src/http-agent.d.ts +47 -0
  71. package/dist/testing/src/http-agent.js +155 -0
  72. package/dist/testing/src/index.d.ts +3 -0
  73. package/dist/testing/src/index.js +2 -0
  74. package/dist/testing/src/new.d.ts +13 -0
  75. package/dist/testing/src/new.js +147 -0
  76. package/dist/testing/src/print-run.d.ts +3 -0
  77. package/dist/testing/src/print-run.js +22 -0
  78. package/dist/testing/src/protocol.d.ts +159 -0
  79. package/dist/testing/src/protocol.js +28 -0
  80. package/dist/testing/src/report.d.ts +74 -0
  81. package/dist/testing/src/report.js +90 -0
  82. package/dist/testing/src/show.d.ts +36 -0
  83. package/dist/testing/src/show.js +280 -0
  84. package/dist/testing/src/suite.d.ts +87 -0
  85. package/dist/testing/src/suite.js +198 -0
  86. package/docs/agents.md +85 -0
  87. package/examples/cafe/agent/AGENTS.md +10 -0
  88. package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
  89. package/examples/cafe/src/agent.ts +43 -0
  90. package/examples/cafe/src/channels.ts +20 -0
  91. package/examples/cafe/src/main.ts +30 -0
  92. package/examples/cafe/src/tools.ts +62 -0
  93. package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
  94. package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
  95. package/examples/cafe/tests/tools/get_menu.yaml +14 -0
  96. package/examples/cafe/tests/tools/place_order.yaml +29 -0
  97. package/examples/http-agent/server.mjs +32 -0
  98. package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
  99. package/flow-messaging/README.md +191 -0
  100. package/package.json +63 -8
  101. package/spec/agent-endpoint.openapi.yaml +148 -0
  102. package/spec/nudges.openapi.yaml +167 -0
  103. package/spec/request.schema.json +121 -0
  104. package/spec/response.schema.json +284 -0
@@ -0,0 +1,818 @@
1
+ // An agent: instructions, skills and tools, and one Conversation per person, across every channel
2
+ // they write on. Channels hand it messages; it says whether anything is running, so a host can stop
3
+ // it when it is idle. Its state lives in a database (in memory unless one is given), so a restart
4
+ // carries on where it stopped.
5
+ //
6
+ // Who a person is gets fixed when their message arrives, from what the channel verified, never
7
+ // from anything the model or a tool says: the owner's own key from `resolve`, else the phone
8
+ // number of a phone channel (SMS, WhatsApp, iMessage verify it), else that channel's address alone.
9
+ // Identities with the same key are one person in one conversation.
10
+ //
11
+ // Test people, declared in `testPeople`, can also write from the test playground (`simulate`), a
12
+ // different phone for the same person: same identity, history, lookups and schedules. Each identity
13
+ // remembers whether its latest message came from the playground or the channel's provider, and
14
+ // every send to it (replies, scheduled messages, nudges) goes back the same way.
15
+ import { randomUUID } from "node:crypto";
16
+ import { declaredTemplate, fillTemplate, keywordOf, summarize } from "./channels.js";
17
+ import { Conversation, PROFILE_TOOL, profileTool, transcriptOf, windowClosed } from "./conversation.js";
18
+ import { sqlite } from "./db.js";
19
+ import { emitter } from "./events.js";
20
+ import { businessSchedule, claimDue, summary } from "./schedules.js";
21
+ import { loadInstructions, loadSkills, readAgentFolder, skillTool } from "./skills.js";
22
+ import { Store } from "./store.js";
23
+ import { clock, quietUntil, validZone } from "./time.js";
24
+ import { STAND_IN, toSpec } from "./tools.js";
25
+ import { traces, tracesFromEnv } from "./traces.js";
26
+ const NO_EXTRAS = { reactions: false, status: false };
27
+ const HOUR_MS = 3_600_000;
28
+ /** Provider ids deleted per statement, so each one holds its locks briefly. */
29
+ const PRUNE_BATCH = 1_000;
30
+ export class Agent {
31
+ conversations = new Map();
32
+ options;
33
+ settings;
34
+ store;
35
+ startedAt = Date.now();
36
+ /** Conversations in the store, known once resume() ran. */
37
+ rows = new Map();
38
+ resumed = false;
39
+ arriving = new Map();
40
+ /** Callers waiting for a queued nudge to settle: its first call, and any with the same key. */
41
+ nudgeWaiters = new Map();
42
+ now;
43
+ channelsByName;
44
+ /** Where each identity (channel, address) last wrote from; sends go back the same way. */
45
+ via = new Map();
46
+ playgroundSink = null;
47
+ /** Before this, this runner does not ask for the pruning claim again (it, or another runner, had it). */
48
+ pruneAfter = 0;
49
+ /** `defaultCountryCode`'s digits. */
50
+ countryCode;
51
+ traces;
52
+ constructor(options) {
53
+ this.options = options;
54
+ this.channelsByName = new Map((options.channels ?? []).map((c) => [c.name, c]));
55
+ if (this.channelsByName.size !== (options.channels ?? []).length)
56
+ throw new Error("flow: two channels have the same name");
57
+ // A person's key is "key:<resolve's value>", "phone:<number>" or "<channel>:<address>": a channel
58
+ // named key or phone would make a channel identity read as one of the other two.
59
+ for (const name of ["key", "phone"])
60
+ if (this.channelsByName.has(name))
61
+ throw new Error(`flow: "${name}" is reserved; give the channel another name`);
62
+ this.now = options.now ?? Date.now;
63
+ if (options.fallbackTemplate) {
64
+ const declared = declaredTemplate(options.templates, options.fallbackTemplate);
65
+ if (!declared)
66
+ throw new Error(`flow: fallbackTemplate ${options.fallbackTemplate.name} is not one of the declared templates`);
67
+ fillTemplate(declared, options.fallbackTemplate);
68
+ }
69
+ if (options.quietHours && !validQuietHours(options.quietHours))
70
+ throw new Error('flow: quietHours is { from: "HH:MM", to: "HH:MM" }');
71
+ if (options.defaultCountryCode !== undefined && !/^\+?[1-9]\d{0,2}$/.test(options.defaultCountryCode.trim()))
72
+ throw new Error(`flow: defaultCountryCode is a calling code such as "+91", not "${options.defaultCountryCode}"`);
73
+ this.countryCode = options.defaultCountryCode?.trim().replace(/^\+/, "");
74
+ const tracing = options.traces ?? tracesFromEnv();
75
+ this.traces = tracing ? traces(tracing, { service: options.id ?? "flow", kindOf: (name) => this.channelsByName.get(name)?.kind }) : null;
76
+ const sinks = [...(options.events ?? []), ...(this.traces ? [this.traces.sink] : [])];
77
+ const emit = emitter(sinks);
78
+ if ((options.dir === undefined) === (options.files === undefined))
79
+ throw new Error("flow: give an agent exactly one of dir or files");
80
+ const files = options.files ?? readAgentFolder(options.dir);
81
+ const skills = loadSkills(files);
82
+ // The test cloud's stand-in for get_user_profile counts only for an agent with a profile.
83
+ const tools = (options.tools ?? []).filter((t) => options.profile || !t[STAND_IN]);
84
+ if (options.profile && !tools.some((t) => t.name === PROFILE_TOOL))
85
+ tools.push(profileTool(options.profile));
86
+ if (skills.length && !options.inlineSkills)
87
+ tools.push(skillTool(skills, (id, name) => emit(id, "skill.loaded", { skill: name })));
88
+ if (options.store && (options.database || options.id))
89
+ throw new Error("flow: give an agent a store or a database (and id), not both");
90
+ this.store = options.store ?? new Store(options.database ?? sqlite(), options.id ?? "default");
91
+ this.settings = {
92
+ model: options.model,
93
+ transcribe: options.transcribe,
94
+ tools: new Map(tools.map((t) => [t.name, t])),
95
+ specs: tools.map(toSpec),
96
+ instructions: loadInstructions(files, skills, options.inlineSkills),
97
+ context: options.context,
98
+ contextSinks: sinks.filter((sink) => sink.context === true),
99
+ deadlineMs: options.deadlineMs ?? 60_000,
100
+ partialAfterMs: options.partialAfterMs ?? 5_000,
101
+ debounceMs: options.debounceMs ?? 300,
102
+ maxSteps: options.maxSteps ?? 8,
103
+ historyTokens: options.history?.maxTokens ?? (options.model.contextTokens ? Math.floor(options.model.contextTokens * 0.6) : 50_000),
104
+ toolResultChars: options.history?.toolResultChars ?? 12_000,
105
+ maxRestarts: Number.POSITIVE_INFINITY,
106
+ talk: {
107
+ shareUpdates: options.shareUpdates !== false,
108
+ shareUpdatesInstructions: typeof options.shareUpdates === "string" ? options.shareUpdates : undefined,
109
+ },
110
+ emit,
111
+ store: this.store,
112
+ ready: this.store.migrate(),
113
+ now: this.now,
114
+ timeZone: options.timeZone,
115
+ quietHours: options.quietHours,
116
+ templates: options.templates ?? [],
117
+ fallbackTemplate: options.fallbackTemplate?.name,
118
+ windowClosed: (nudge, to) => this.windowClosed(nudge, to),
119
+ tagChannels: (options.channels ?? []).length > 1,
120
+ links: (id) => this.links(id),
121
+ settle: (target, status, reason) => this.settle(target, status, reason),
122
+ };
123
+ const every = options.scheduleCheckMs ?? 5_000;
124
+ if (every > 0)
125
+ setInterval(() => void this.runDue().catch((error) => console.warn("flow: scheduled messages:", error)), every).unref();
126
+ }
127
+ /**
128
+ * Loads what the database holds and carries on: turns cut off by a restart run again, events not
129
+ * yet handled are handled, background tools that were running are reported as interrupted. A
130
+ * durable database needs this before the first message.
131
+ */
132
+ async resume() {
133
+ await this.settings.ready;
134
+ for (const row of await this.store.allConversations())
135
+ this.rows.set(row.id, row);
136
+ this.resumed = true;
137
+ for (const id of await this.store.unfinished())
138
+ this.open(id);
139
+ }
140
+ /**
141
+ * A message from a person on a channel set up in `channels`. Returns the conversation and the id
142
+ * the agent knows the message by, or null when `intercept` or a keyword answered it. Give the
143
+ * provider's own `messageId`: the same one again (a webhook retried) is not taken twice.
144
+ */
145
+ async message(input) {
146
+ return this.released(await this.take(this.channelOf(input.channel), { ...input, via: "provider" }));
147
+ }
148
+ /**
149
+ * A message from a test person (by their phone number) in the test playground: exactly what
150
+ * message() does for the same person writing on that channel, but its replies go to the
151
+ * playground. Anyone not in `testPeople` is refused.
152
+ */
153
+ async simulate(input) {
154
+ const person = this.testPerson(input.person);
155
+ if (!person)
156
+ throw new Error("flow: only the agent's testPeople can be simulated");
157
+ const adapter = this.channelOf(input.channel);
158
+ return this.released(await this.take(adapter, { channel: input.channel, from: this.testAddress(adapter, person), message: input.message, messageId: input.messageId, via: "playground", person }));
159
+ }
160
+ /** They wrote, so they are there: what was held for their quiet hours goes now, in this turn. */
161
+ async released(taken) {
162
+ if (taken)
163
+ await this.releaseHeld(this.now(), taken.conversationId);
164
+ return taken;
165
+ }
166
+ /**
167
+ * Held nudges whose quiet hours are over (or every one of a conversation whose person just wrote)
168
+ * go on; ones past their expiresAt are settled as expired. Released together, they reach one turn.
169
+ */
170
+ async releaseHeld(now, writer) {
171
+ for (const n of await this.store.heldNudges(writer)) {
172
+ const body = n.body;
173
+ if ((body.expiresAt ?? Infinity) <= now) {
174
+ await this.settle({ nudge: n.id }, "expired", "held past its expiresAt for their quiet hours");
175
+ continue;
176
+ }
177
+ // Held for a closed window (no holdUntil), it waits for their reply.
178
+ if (!writer && (body.holdUntil ?? Infinity) > now)
179
+ continue;
180
+ // Released because they just wrote: their window is open, though their message may not have landed yet.
181
+ const inbox = await this.store.releaseNudge(writer ? { ...n, body: { ...n.body, theyWrote: true } } : n);
182
+ if (inbox !== null)
183
+ this.wake(n.conversationId, inbox);
184
+ }
185
+ }
186
+ /** Sends the traces of finished turns still waiting for the traces endpoint; call it before the process exits. */
187
+ flushTraces() {
188
+ return this.traces?.flush() ?? Promise.resolve();
189
+ }
190
+ /** The templates declared in `templates`. */
191
+ templates() {
192
+ return [...(this.options.templates ?? [])];
193
+ }
194
+ /** The people declared in `testPeople`. */
195
+ testPeople() {
196
+ return [...(this.options.testPeople ?? [])];
197
+ }
198
+ /** A test person's conversation, by their phone number, or null if they have not written yet. */
199
+ async testConversation(phone) {
200
+ await this.settings.ready;
201
+ const person = this.testPerson(phone);
202
+ if (!person)
203
+ return null;
204
+ for (const adapter of this.channelsByName.values()) {
205
+ const known = await this.store.identity(adapter.name, this.testAddress(adapter, person));
206
+ if (known)
207
+ return known.conversationId;
208
+ }
209
+ return (await this.store.conversationByKey(await this.testPersonKey(person, [...this.channelsByName.values()][0])))?.id ?? null;
210
+ }
211
+ /** Where the playground shows what is sent to test people who wrote from it; null detaches it. */
212
+ attachPlayground(sink) {
213
+ this.playgroundSink = sink;
214
+ }
215
+ /** A conversation as the person saw it, from the store: their messages, the agent's, and its tool calls. */
216
+ async transcript(conversationId) {
217
+ await this.settings.ready;
218
+ await this.conversations.get(conversationId)?.stored();
219
+ const only = [...this.channelsByName.keys()][0] ?? "chat";
220
+ return transcriptOf(await this.store.historyWithTimes(conversationId), only);
221
+ }
222
+ /**
223
+ * Forgets a person: a turn in progress stops, and everything kept about them goes (history,
224
+ * inbox, background tools, schedules and their runs, nudges, no-contact rules, identities and
225
+ * the conversation). Their next message starts from nothing.
226
+ */
227
+ async forgetPerson(conversationId) {
228
+ await this.settings.ready;
229
+ const resident = this.conversations.get(conversationId);
230
+ if (resident) {
231
+ resident.close();
232
+ this.conversations.delete(conversationId);
233
+ await resident.stored();
234
+ }
235
+ for (const i of await this.store.identities(conversationId))
236
+ this.via.delete(viaKey(i.channel, i.address));
237
+ this.rows.delete(conversationId);
238
+ await this.store.forgetPerson(conversationId);
239
+ }
240
+ channelOf(name) {
241
+ const adapter = this.channelsByName.get(name);
242
+ if (!adapter)
243
+ throw new Error(`flow: no channel named ${name}`);
244
+ this.mustBeResumed();
245
+ return adapter;
246
+ }
247
+ /** Messages from one address are taken in order, so a new person is made only once. */
248
+ take(adapter, input) {
249
+ const key = viaKey(input.channel, input.from);
250
+ const previous = this.arriving.get(key) ?? Promise.resolve();
251
+ const handled = previous.then(() => this.arrive(adapter, input));
252
+ this.arriving.set(key, handled.catch(() => undefined));
253
+ return handled;
254
+ }
255
+ async arrive(adapter, input) {
256
+ await this.settings.ready;
257
+ const identity = await this.conversationFor(adapter, input.channel, input.from, input.person);
258
+ const conversationId = identity.conversationId;
259
+ if (input.messageId !== undefined && (await this.store.inboundTaken(conversationId, input.channel, input.messageId))) {
260
+ // The provider sent this message before (a webhook retried): it was taken then. Its id is
261
+ // written with what the message stores (its inbox row, a STOP), so a crash keeps neither.
262
+ this.settings.emit(conversationId, "message.duplicate", { messageId: input.messageId, channel: input.channel });
263
+ return { conversationId, messageId: input.messageId };
264
+ }
265
+ if (identity.via !== input.via)
266
+ await this.store.setIdentityVia(input.channel, input.from, input.via);
267
+ this.via.set(viaKey(input.channel, input.from), input.via);
268
+ const conversation = this.open(conversationId);
269
+ const deliver = this.deliverer(adapter, input.from, conversationId);
270
+ conversation.link({ name: input.channel, info: adapter.info, params: adapter.params, instructions: adapter.instructions, address: input.from, deliver, lastMessageAt: null });
271
+ const text = typeof input.message === "string" ? input.message : input.message.text;
272
+ const kind = keywordIn(adapter.keywords, input.message);
273
+ const fixed = this.intercepted(conversationId, text, { keyword: kind, channel: input.channel });
274
+ const answer = kind ? this.keyword(conversation, adapter.keywords, kind, text, fixed, input.channel, input.messageId) : null;
275
+ const reply = answer ? answer.reply : fixed;
276
+ if (reply === null)
277
+ return { conversationId, messageId: conversation.receive(input.message, { channel: input.channel, messageId: input.messageId }) };
278
+ // Answered with no model call: kept with its reply and its id, and sent once that is stored, so a retry is not answered again.
279
+ const answered = conversation.answered(input.message, { channel: input.channel, messageId: input.messageId, claimed: answer?.stored, note: answer?.note }, reply);
280
+ if (answered && (await answered.taken))
281
+ await answered.send();
282
+ return null;
283
+ }
284
+ /**
285
+ * The agent's `intercept`, told the message's channel and whether it is a keyword. A keyword is
286
+ * handled the same whatever the hook does, so its error there is logged, not thrown.
287
+ */
288
+ intercepted(conversationId, text, message) {
289
+ if (!message.keyword)
290
+ return this.options.intercept?.(conversationId, text, message) ?? null;
291
+ try {
292
+ return this.options.intercept?.(conversationId, text, message) ?? null;
293
+ }
294
+ catch (error) {
295
+ console.warn(`flow: intercept threw on a ${message.keyword.toUpperCase()} keyword; Flow Framework handled it as usual:`, error);
296
+ return null;
297
+ }
298
+ }
299
+ /**
300
+ * A STOP, START or HELP keyword, answered with no model call: what to send back (the hook's own
301
+ * reply, else the channel's, else "" for nothing), and for STOP and START the line the agent
302
+ * learns it by and their write, which holds the message's id. Null for HELP with no reply: the
303
+ * hook's answer, or the agent, takes it like any message.
304
+ */
305
+ keyword(conversation, keywords, kind, text, own, channel, messageId) {
306
+ const reply = own || keywords.replies?.[kind] || "";
307
+ if (kind === "help")
308
+ return reply ? { reply } : null;
309
+ return { reply, ...conversation.keyword(kind, text, channel, messageId) };
310
+ }
311
+ /** The identity a message comes from, made (and linked to its person's conversation) the first time it writes. */
312
+ async conversationFor(adapter, channel, address, testPerson) {
313
+ const known = await this.store.identity(channel, address);
314
+ if (known)
315
+ return known;
316
+ let personKey;
317
+ let verifiedBy;
318
+ if (testPerson && !adapter.phone) {
319
+ // Not a phone channel: their playground address joins the person their number is.
320
+ [personKey, verifiedBy] = [await this.testPersonKey(testPerson, adapter), "test person"];
321
+ }
322
+ else {
323
+ const owner = await this.options.resolve?.({ channel, address });
324
+ const phone = adapter.phone ? normalizePhone(address, adapter.kind === "whatsapp" ? undefined : this.countryCode) : "";
325
+ [personKey, verifiedBy] = owner ? [`key:${owner}`, "resolver"] : phone ? [`phone:${phone}`, "phone"] : [`${channel}:${address}`, "channel"];
326
+ }
327
+ let row = await this.store.conversationByKey(personKey);
328
+ if (!row) {
329
+ // Two first messages from one person at once (on two channels) both get here; one insert
330
+ // wins, and both carry on with the conversation that is there now.
331
+ await this.store.createConversation(`p_${randomUUID().slice(0, 12)}`, personKey);
332
+ row = await this.store.conversationByKey(personKey);
333
+ if (!row)
334
+ throw new Error(`flow: could not make a conversation for ${personKey}`);
335
+ if (!this.conversations.has(row.id))
336
+ this.rows.set(row.id, row);
337
+ }
338
+ const id = row.id;
339
+ await this.store.addIdentity(id, channel, address, verifiedBy);
340
+ return { channel, address, conversationId: id, verifiedBy, lastMessageAt: null, via: "provider" };
341
+ }
342
+ /**
343
+ * The key the real person has: what the owner's resolve() gives their phone number (on the
344
+ * agent's first phone channel, else this one), else their phone number. So the playground and
345
+ * the provider reach one conversation, on every channel.
346
+ */
347
+ async testPersonKey(person, adapter) {
348
+ const phone = normalizePhone(person.phone);
349
+ const channel = [...this.channelsByName.values()].find((c) => c.phone) ?? adapter;
350
+ const owner = channel ? await this.options.resolve?.({ channel: channel.name, address: phone }) : null;
351
+ return owner ? `key:${owner}` : `phone:${phone}`;
352
+ }
353
+ testPerson(phone) {
354
+ const wanted = normalizePhone(phone);
355
+ return wanted ? this.options.testPeople?.find((p) => normalizePhone(p.phone) === wanted) : undefined;
356
+ }
357
+ /** A test person's address on a channel: their number on phone channels, else `test:<number>`. */
358
+ testAddress(adapter, person) {
359
+ const phone = normalizePhone(person.phone);
360
+ return adapter.phone ? phone : `test:${phone}`;
361
+ }
362
+ /** Sends to one identity: to the playground if it last wrote from there, else through the channel's provider. */
363
+ deliverer(adapter, address, conversationId) {
364
+ return (outbound) => {
365
+ if (this.via.get(viaKey(adapter.name, address)) !== "playground")
366
+ return adapter.send(address, outbound);
367
+ const phone = address.startsWith("test:") ? address.slice(5) : address;
368
+ const person = this.testPerson(phone);
369
+ // With no playground page open, nothing is shown, and the conversation keeps it as sent.
370
+ if (person)
371
+ return this.playgroundSink?.({ conversationId, person, channel: adapter.name, outbound });
372
+ };
373
+ }
374
+ /**
375
+ * A message from a person; replies go to `deliver`, the channel it came in on. Returns the id
376
+ * the agent knows the message by, or null when `intercept` or a keyword answered it. The conversation is
377
+ * this id alone: use message() and `channels` for one person across several channels. When
378
+ * `channelName` is one of the agent's channels, its definition (what it shows, its params) is
379
+ * used and replies go to `deliver`: that is how the simulators play an agent's own channels.
380
+ */
381
+ receive(conversationId, message, deliver, options = {}) {
382
+ const text = typeof message === "string" ? message : message.text;
383
+ const name = options.channelName ?? "chat";
384
+ const defined = this.channelsByName.get(name);
385
+ const kind = keywordIn(defined?.keywords, message);
386
+ const fixed = this.intercepted(conversationId, text, { keyword: kind, channel: name });
387
+ this.mustBeResumed();
388
+ const isNew = !this.conversations.has(conversationId) && !this.rows.has(conversationId);
389
+ const conversation = this.open(conversationId, `${name}:${conversationId}`);
390
+ if (isNew)
391
+ conversation.persist(() => this.store.addIdentity(conversationId, name, conversationId, "channel"));
392
+ conversation.link({ name, info: defined?.info ?? options.channel ?? NO_EXTRAS, params: defined?.params, instructions: defined?.instructions, address: conversationId, deliver, lastMessageAt: null });
393
+ const answer = kind ? this.keyword(conversation, defined.keywords, kind, text, fixed, name) : null;
394
+ const reply = answer ? answer.reply : fixed;
395
+ if (reply === null)
396
+ return conversation.receive(message, { channel: name, messageId: options.messageId });
397
+ const answered = conversation.answered(message, { channel: name, messageId: options.messageId, claimed: answer?.stored, note: answer?.note }, reply);
398
+ // A STOP's or START's reply goes once it is kept; any other starts now, while receive() runs, so
399
+ // a caller that waits on what it delivers sees it.
400
+ if (answered)
401
+ void (answer?.stored ? answer.stored.then((taken) => (taken ? answered.send() : undefined)) : answered.send());
402
+ return null;
403
+ }
404
+ /**
405
+ * The person reacted to one of the agent's messages; the agent learns it at its next turn. The
406
+ * conversation is loaded from the store when it is not in memory (after a restart).
407
+ */
408
+ reacted(conversationId, emoji, toText) {
409
+ this.withConversation(conversationId, (c) => c.reacted(emoji, toText));
410
+ }
411
+ /** The channel reports that a message did not reach the person; the agent hears about it, also after a restart. */
412
+ undelivered(conversationId, text, reason) {
413
+ this.withConversation(conversationId, (c) => c.undelivered(text, reason));
414
+ }
415
+ /** Acts on a conversation now when it is in memory, else once it is loaded from the store; never on one the store does not hold. */
416
+ withConversation(conversationId, act) {
417
+ const resident = this.conversations.get(conversationId);
418
+ if (resident)
419
+ return act(resident);
420
+ void (async () => {
421
+ await this.settings.ready;
422
+ const row = this.rows.get(conversationId) ?? (await this.store.conversation(conversationId));
423
+ if (!row)
424
+ return;
425
+ this.rows.set(conversationId, row);
426
+ act(this.open(conversationId));
427
+ })().catch((error) => console.warn("flow: conversation %s could not be loaded for a report:", conversationId, error));
428
+ }
429
+ conversation(conversationId) {
430
+ return this.conversations.get(conversationId);
431
+ }
432
+ /**
433
+ * A message the business starts. Resolves once it is settled: sent, silent (a prompt nudge the
434
+ * agent chose not to send), dropped (a no-contact rule covers it), refused (outside_window,
435
+ * unknown_person) or failed; or scheduled, for one with `at`.
436
+ */
437
+ /**
438
+ * The person's time zone as the business knows it (an IANA name, "Asia/Kolkata"), by a channel
439
+ * identity they have written from, like a nudge's `to`. Schedules and the agent's time use it from
440
+ * then on. Throws for a name that is not a zone; refused (unknown_person) for someone who never wrote.
441
+ */
442
+ async setTimeZone(input) {
443
+ if (!validZone(input.timeZone))
444
+ throw new Error(`flow: ${input.timeZone} is not a time zone; use an IANA name such as America/New_York`);
445
+ await this.settings.ready;
446
+ const identity = await this.store.identity(input.to.channel, input.to.address);
447
+ if (!identity)
448
+ return { status: "refused", reason: "unknown_person" };
449
+ const id = identity.conversationId;
450
+ const resident = this.conversations.get(id);
451
+ if (resident)
452
+ await resident.setTimeZone(input.timeZone, "business");
453
+ else {
454
+ await this.store.updateConversation(id, { timeZone: input.timeZone, timeZoneSource: "business" });
455
+ const row = this.rows.get(id);
456
+ if (row)
457
+ this.rows.set(id, { ...row, timeZone: input.timeZone, timeZoneSource: "business" });
458
+ }
459
+ return { status: "set" };
460
+ }
461
+ /**
462
+ * Their own quiet hours, as the business knows them: `{ from, to }`, "off" for none, or null to go
463
+ * back to the agent's. Refused (unknown_person) for someone who never wrote.
464
+ */
465
+ async setQuietHours(input) {
466
+ const q = input.quietHours;
467
+ if (q !== null && q !== "off" && !validQuietHours(q))
468
+ throw new Error('flow: quietHours is { from: "HH:MM", to: "HH:MM" }, "off" or null');
469
+ await this.settings.ready;
470
+ const identity = await this.store.identity(input.to.channel, input.to.address);
471
+ if (!identity)
472
+ return { status: "refused", reason: "unknown_person" };
473
+ const resident = this.conversations.get(identity.conversationId);
474
+ if (resident)
475
+ await resident.setQuietHours(q);
476
+ else
477
+ await this.store.updateConversation(identity.conversationId, { quietHours: q });
478
+ return { status: "set" };
479
+ }
480
+ /** What the business and the person set for someone, by a channel identity they wrote from; null for someone who never wrote. */
481
+ async contact(to) {
482
+ await this.settings.ready;
483
+ const identity = await this.store.identity(to.channel, to.address);
484
+ const row = identity && (await this.store.conversation(identity.conversationId));
485
+ if (!row)
486
+ return null;
487
+ const rules = await this.store.noContact(row.id, this.now());
488
+ const own = row.quietHours;
489
+ const hours = quietHoursOf(row, this.options.quietHours);
490
+ return {
491
+ timeZone: row.timeZone,
492
+ timeZoneSource: row.timeZoneSource,
493
+ quietHours: row.timeZone ? hours : null,
494
+ quietHoursSource: !hours ? "off" : !row.timeZone ? "no_time_zone" : own && own !== "off" ? "person" : "agent",
495
+ stop: rules.some((r) => r.kind === "stop"),
496
+ pauses: rules.filter((r) => r.kind === "pause").map((r) => ({ id: r.id, until: r.until })),
497
+ topics: rules.filter((r) => r.kind === "topic").map((r) => ({ id: r.id, rule: r.rule, until: r.until })),
498
+ };
499
+ }
500
+ /** A window-closed prompt nudge got no template from the agent: held until they reply, and the fallback goes now. */
501
+ async windowClosed(nudge, to) {
502
+ const row = await this.store.nudge(nudge);
503
+ if (!row)
504
+ return;
505
+ const { windowClosed: _closed, ...body } = row.body;
506
+ await this.store.holdNudge(nudge, body, "window_closed");
507
+ this.release(nudge, this.result(nudge, "held", "window_closed"));
508
+ await this.sendFallback(row.conversationId, to, nudge, body);
509
+ }
510
+ /** The fallback template for a held nudge, once, and the event that says it wanted a template of its own. */
511
+ async sendFallback(conversationId, to, nudge, body) {
512
+ this.settings.emit(conversationId, "template.missing", { nudge, channel: to.channel, ...(body.text !== undefined ? { text: body.text } : { prompt: body.prompt }) });
513
+ await this.recordNudge({ to, template: this.options.fallbackTemplate, idempotencyKey: `fallback:${nudge}` });
514
+ }
515
+ /**
516
+ * What is waiting to go to a person: their scheduled messages (theirs and the business's) and the
517
+ * nudges held for their quiet hours or a closed window. The playground shows it.
518
+ */
519
+ async outbox(conversationId) {
520
+ await this.settings.ready;
521
+ const schedules = (await this.store.schedules(conversationId)).map((s) => ({
522
+ id: s.id,
523
+ message: s.instruction,
524
+ origin: s.origin === "person" ? "person" : "business",
525
+ mode: s.mode,
526
+ nextRunAt: s.nextRunAt,
527
+ timeZone: s.timeZone,
528
+ respectsQuietHours: s.rule.quiet === true,
529
+ summary: summary(s).summary,
530
+ }));
531
+ const held = (await this.store.heldNudges(conversationId)).map((n) => {
532
+ const b = n.body;
533
+ return {
534
+ id: n.id,
535
+ channel: n.channel,
536
+ kind: b.template ? "template" : b.text !== undefined ? "text" : "prompt",
537
+ what: b.template?.name ?? b.text ?? b.prompt ?? "",
538
+ reason: n.reason ?? "",
539
+ heldAt: b.heldAt ?? null,
540
+ holdUntil: b.holdUntil ?? null,
541
+ expiresAt: b.expiresAt ?? null,
542
+ };
543
+ });
544
+ return { schedules, held };
545
+ }
546
+ /** Whether the agent has a profile (get_user_profile), so the test cloud knows to play it. */
547
+ hasProfile() {
548
+ return this.options.profile !== undefined;
549
+ }
550
+ async nudge(input) {
551
+ // outcome() waits while it is queued, so what it resolves is settled.
552
+ return (await this.outcome(await this.recordNudge(input)));
553
+ }
554
+ /**
555
+ * Records a nudge and resolves at once, without waiting for its send or turn: `queued`, or what
556
+ * was decided as it was recorded (dropped, refused, scheduled). The same idempotency key gets the
557
+ * first nudge's state now. `nudgeState(id)` reads it later.
558
+ */
559
+ async recordNudge(input) {
560
+ await this.settings.ready;
561
+ const kinds = [input.text, input.prompt, input.template].filter((v) => v !== undefined).length;
562
+ if (kinds !== 1)
563
+ throw new Error("flow: a nudge has exactly one of text, prompt or template");
564
+ // A declared template whose values don't fit it never goes: Meta would refuse it, the playground would show nonsense.
565
+ const declared = input.template && declaredTemplate(this.options.templates, input.template);
566
+ if (declared)
567
+ fillTemplate(declared, input.template);
568
+ if (input.idempotencyKey) {
569
+ const earlier = await this.store.nudgeByKey(input.idempotencyKey);
570
+ if (earlier)
571
+ return this.result(earlier.id, earlier.status, earlier.reason);
572
+ }
573
+ const identity = await this.store.identity(input.to.channel, input.to.address);
574
+ if (!identity)
575
+ return { id: "", status: "refused", reason: "unknown_person" };
576
+ const id = `n_${randomUUID().slice(0, 8)}`;
577
+ const conversationId = identity.conversationId;
578
+ const record = { id, conversationId, channel: input.to.channel, idempotencyKey: input.idempotencyKey ?? null, reason: null };
579
+ const stop = input.at === undefined ? await this.blockOf(conversationId) : null;
580
+ // Written once per key: a call that loses the insert to one with the same key gets that one's result.
581
+ const add = async (row, schedule) => {
582
+ const inbox = await this.store.addNudge(row, schedule);
583
+ if (inbox !== null)
584
+ return inbox;
585
+ const first = (await this.store.nudgeByKey(input.idempotencyKey));
586
+ return this.result(first.id, first.status, first.reason);
587
+ };
588
+ const settledAs = async (row) => {
589
+ const added = await add(row);
590
+ return typeof added === "number" ? this.result(row.id, row.status, row.reason) : added;
591
+ };
592
+ if (stop) {
593
+ const reason = `no-contact rule ${stop.id}: ${stop.rule}`;
594
+ return settledAs({ ...record, body: input.template ? { template: input.template } : { text: input.text, prompt: input.prompt }, status: "dropped", reason });
595
+ }
596
+ if (input.at !== undefined) {
597
+ if (input.template)
598
+ throw new Error("flow: a template nudge cannot be scheduled yet");
599
+ const zone = (await this.store.conversation(conversationId))?.timeZone ?? this.options.timeZone ?? "UTC";
600
+ const schedule = businessSchedule(conversationId, input.to.channel, input.at, zone, { text: input.text, prompt: input.prompt }, input.respectQuietHours !== false);
601
+ const added = await add({ ...record, body: { text: input.text, prompt: input.prompt, at: input.at }, status: "scheduled" }, schedule);
602
+ return typeof added === "number" ? { id, status: "scheduled" } : added;
603
+ }
604
+ const body = input.template ? { template: input.template } : input.text !== undefined ? { text: input.text } : { prompt: input.prompt };
605
+ const now = this.now();
606
+ const quietEnds = input.respectQuietHours === false ? null : await this.quietEnd(conversationId, now);
607
+ if (quietEnds !== null) {
608
+ const held = { ...body, heldAt: now, holdUntil: quietEnds, expiresAt: input.expiresAt ?? now + DAY_MS };
609
+ const added = await add({ ...record, body: held, status: "held", reason: "quiet_hours" });
610
+ return typeof added === "number" ? { id, status: "held", reason: "quiet_hours" } : added;
611
+ }
612
+ if (!input.template && windowClosed({ info: this.infoOf(input.to.channel, conversationId), lastMessageAt: identity.lastMessageAt }, this.now())) {
613
+ if (!this.options.fallbackTemplate)
614
+ return settledAs({ ...record, body, status: "refused", reason: "outside_window" });
615
+ if (input.text !== undefined) {
616
+ // Exact wording: it waits for their reply, and the fallback tells them something is waiting.
617
+ const added = await add({ ...record, body: { ...body, expiresAt: input.expiresAt ?? now + DAY_MS }, status: "held", reason: "window_closed" });
618
+ if (typeof added !== "number")
619
+ return added;
620
+ await this.sendFallback(conversationId, input.to, id, body);
621
+ return { id, status: "held", reason: "window_closed" };
622
+ }
623
+ // A prompt nudge: the agent may find a template that says it; else the fallback (windowClosed below).
624
+ Object.assign(body, { windowClosed: true, expiresAt: input.expiresAt ?? now + DAY_MS });
625
+ }
626
+ const added = await add({ ...record, body, status: "queued" });
627
+ if (typeof added !== "number")
628
+ return added;
629
+ this.wake(conversationId, added);
630
+ return { id, status: "queued" };
631
+ }
632
+ /** A nudge's state now, or null for an id the store does not hold. */
633
+ async nudgeState(id) {
634
+ await this.settings.ready;
635
+ const row = await this.store.nudge(id);
636
+ return row && this.result(row.id, row.status, row.reason);
637
+ }
638
+ /** A recorded nudge as `nudge()` returns it: settled, or (still queued) once it settles. */
639
+ async outcome(state) {
640
+ if (state.status !== "queued")
641
+ return state;
642
+ const settled = this.waitFor(state.id);
643
+ // It may have settled between the read and the wait.
644
+ const now = await this.store.nudge(state.id);
645
+ if (now && now.status !== "queued")
646
+ this.release(state.id, this.result(state.id, now.status, now.reason));
647
+ return settled;
648
+ }
649
+ waitFor(id) {
650
+ return new Promise((resolve) => this.nudgeWaiters.set(id, [...(this.nudgeWaiters.get(id) ?? []), resolve]));
651
+ }
652
+ release(id, result) {
653
+ for (const resolve of this.nudgeWaiters.get(id) ?? [])
654
+ resolve(result);
655
+ this.nudgeWaiters.delete(id);
656
+ }
657
+ /**
658
+ * Looks for due scheduled messages once and hands each to its conversation. Returns how many.
659
+ * Once an hour, one runner also deletes provider message ids older than `keepMessageIdsDays`.
660
+ */
661
+ async runDue() {
662
+ await this.settings.ready;
663
+ if (this.store.durable && !this.resumed)
664
+ return 0;
665
+ const now = this.now();
666
+ const queued = await claimDue(this.store, now, (s) => this.quietEnd(s.conversationId, now), async (s) => {
667
+ const stop = s.origin === "person" ? await this.stopOf(s.conversationId) : await this.blockOf(s.conversationId);
668
+ if (stop)
669
+ return `no-contact rule ${stop.id}: ${stop.rule}`;
670
+ const identity = (await this.store.identities(s.conversationId)).find((i) => i.channel === s.channel);
671
+ if (!identity)
672
+ return `no way to reach them on ${s.channel}`;
673
+ return windowClosed({ info: this.infoOf(s.channel, s.conversationId), lastMessageAt: identity.lastMessageAt }, now) ? "outside_window" : null;
674
+ });
675
+ for (const q of queued)
676
+ this.wake(q.conversationId, q.inbox);
677
+ await this.releaseHeld(now);
678
+ await this.pruneMessageIds(now).catch((error) => console.warn("flow: pruning message ids:", error));
679
+ return queued.length;
680
+ }
681
+ /** Deletes provider message ids past their keep, in batches, when this runner claims it (at most hourly). */
682
+ async pruneMessageIds(now) {
683
+ if (now < this.pruneAfter)
684
+ return;
685
+ this.pruneAfter = now + HOUR_MS;
686
+ if (!(await this.store.claimUpkeep("prune_message_ids", now, now + HOUR_MS)))
687
+ return;
688
+ const before = now - (this.options.keepMessageIdsDays ?? 7) * 24 * HOUR_MS;
689
+ while ((await this.store.pruneInbound(before, PRUNE_BATCH)) === PRUNE_BATCH)
690
+ ;
691
+ }
692
+ /** The agent's channels without their sends: what the simulators and the test cloud play. */
693
+ channels() {
694
+ return [...this.channelsByName.values()].map(summarize);
695
+ }
696
+ status() {
697
+ const all = [...this.conversations.values()];
698
+ const busy = all.some((c) => c.busy);
699
+ const idle = all.length ? Math.min(...all.map((c) => c.idleForMs)) : Date.now() - this.startedAt;
700
+ return {
701
+ busy,
702
+ idleForMs: busy ? 0 : idle,
703
+ conversations: all.length,
704
+ backgroundTasks: all.reduce((n, c) => n + c.backgroundTasks.length, 0),
705
+ };
706
+ }
707
+ /** The conversation in memory, made (and loaded from the store, if it is there) when needed. */
708
+ open(id, personKey = id) {
709
+ let conversation = this.conversations.get(id);
710
+ if (!conversation) {
711
+ const row = this.rows.get(id) ?? null;
712
+ conversation = new Conversation(id, row?.personKey ?? personKey, this.settings, row);
713
+ this.conversations.set(id, conversation);
714
+ }
715
+ return conversation;
716
+ }
717
+ /** An inbox row for a conversation: handed to it in memory, or found when it loads. */
718
+ wake(conversationId, inbox) {
719
+ const resident = this.conversations.get(conversationId);
720
+ if (!resident) {
721
+ void this.store.conversation(conversationId).then((row) => {
722
+ if (row)
723
+ this.rows.set(conversationId, row);
724
+ this.open(conversationId);
725
+ });
726
+ return;
727
+ }
728
+ void this.store.openInbox(conversationId).then((rows) => {
729
+ const row = rows.find((r) => r.id === inbox);
730
+ if (row)
731
+ resident.enqueue(row);
732
+ });
733
+ }
734
+ async links(conversationId) {
735
+ const identities = await this.store.identities(conversationId);
736
+ // Oldest first, so on a channel with two identities (the app, and the playground's) the latest one wins.
737
+ identities.sort((a, b) => (a.lastMessageAt ?? 0) - (b.lastMessageAt ?? 0));
738
+ return identities.flatMap((i) => {
739
+ const adapter = this.channelsByName.get(i.channel);
740
+ if (!adapter)
741
+ return [];
742
+ this.via.set(viaKey(i.channel, i.address), i.via);
743
+ return [{ name: i.channel, info: adapter.info, params: adapter.params, instructions: adapter.instructions, address: i.address, deliver: this.deliverer(adapter, i.address, conversationId), lastMessageAt: i.lastMessageAt }];
744
+ });
745
+ }
746
+ /** What a channel can show: its adapter's, else what the conversation was last told. */
747
+ infoOf(channel, conversationId) {
748
+ return this.channelsByName.get(channel)?.info ?? this.conversations.get(conversationId)?.channelInfo(channel) ?? NO_EXTRAS;
749
+ }
750
+ async settle(target, status, reason) {
751
+ if (target.run)
752
+ await this.store.settleRun(target.run, status, reason);
753
+ if (target.nudge) {
754
+ await this.store.settleNudge(target.nudge, status, reason);
755
+ this.release(target.nudge, this.result(target.nudge, status, reason));
756
+ }
757
+ }
758
+ /** Their STOP, if they texted one. */
759
+ async stopOf(conversationId) {
760
+ return (await this.store.noContact(conversationId, this.now())).find((r) => r.hard) ?? null;
761
+ }
762
+ /** Their STOP, or a pause in force: either way nothing the business starts goes now. */
763
+ async blockOf(conversationId) {
764
+ return (await this.store.noContact(conversationId, this.now())).find((r) => r.hard || r.kind === "pause") ?? null;
765
+ }
766
+ /** When their quiet hours end, if they are in them now; null when not, or when their time zone is not known. */
767
+ async quietEnd(conversationId, now) {
768
+ const row = await this.store.conversation(conversationId);
769
+ const hours = row && quietHoursOf(row, this.options.quietHours);
770
+ return row?.timeZone && hours ? quietUntil(now, row.timeZone, hours) : null;
771
+ }
772
+ result(id, status, reason) {
773
+ return reason ? { id, status: status, reason } : { id, status: status };
774
+ }
775
+ mustBeResumed() {
776
+ if (this.store.durable && !this.resumed)
777
+ throw new Error("flow: call await agent.resume() before the first message when the database is durable");
778
+ }
779
+ }
780
+ const viaKey = (channel, address) => `${channel}\u0000${address}`;
781
+ /** Which keyword a message is on a channel with them: text alone, never a file or a tap. */
782
+ function keywordIn(keywords, message) {
783
+ if (typeof message !== "string" && (message.attachments?.length || message.choice))
784
+ return null;
785
+ return keywordOf(keywords, typeof message === "string" ? message : message.text);
786
+ }
787
+ /**
788
+ * A phone number as one string however the channel gave it, in E.164: formatting dropped, a 00
789
+ * prefix read as +, and digits with no + taken as international (WhatsApp gives "15550100100" for
790
+ * "+1 555 010 0100"), or, given a `countryCode`, as that country's national number. "" when there
791
+ * are no digits (not a phone number).
792
+ */
793
+ export function normalizePhone(address, countryCode) {
794
+ const trimmed = address.trim();
795
+ const digits = trimmed.replace(/^00/, "").replace(/\D/g, "");
796
+ if (!digits)
797
+ return "";
798
+ if (!countryCode || trimmed.startsWith("+") || trimmed.startsWith("00"))
799
+ return `+${digits}`;
800
+ return `+${countryCode}${countryCode === "39" ? digits : digits.replace(/^0/, "")}`;
801
+ }
802
+ export function createAgent(options) {
803
+ return new Agent(options);
804
+ }
805
+ const DAY_MS = 24 * 60 * 60_000;
806
+ export const DEFAULT_QUIET_HOURS = { from: "22:00", to: "07:00" };
807
+ function validQuietHours(q) {
808
+ const v = q;
809
+ return typeof v === "object" && v !== null && typeof v.from === "string" && typeof v.to === "string" && !!clock(v.from) && !!clock(v.to);
810
+ }
811
+ /** The quiet hours that apply to a person: their own, else the agent's (the default unless it turned them off). */
812
+ export function quietHoursOf(row, agent) {
813
+ if (row.quietHours === "off")
814
+ return null;
815
+ if (row.quietHours)
816
+ return row.quietHours;
817
+ return agent === false ? null : (agent ?? DEFAULT_QUIET_HOURS);
818
+ }