manychat-ai-agent 0.24.0 → 0.25.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.24.1...v0.25.0) (2026-10-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * a silent payment-link flow leaves the link to the reply ([b4d019e](https://github.com/pedronastasi/manychat-ai-agent/commit/b4d019e275fc0073acc82f1361eec0e59a68c163))
9
+ * a silent payment-link flow leaves the link to the reply ([11d3929](https://github.com/pedronastasi/manychat-ai-agent/commit/11d3929ae3c4e734d42fab62022dab33ac214e3b))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * an offering's link does not count as one it is a prefix of ([426ade9](https://github.com/pedronastasi/manychat-ai-agent/commit/426ade9850e3e72354697c9961218558ad8b6d05))
15
+
16
+
17
+ ### Specs and Docs
18
+
19
+ * **specs:** a silent payment flow leaves the link to the reply ([4611b15](https://github.com/pedronastasi/manychat-ai-agent/commit/4611b15744f4a81bd3cf6943a2a1f39644d2bb7f))
20
+
21
+ ## [0.24.1](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.24.0...v0.24.1) (2026-10-16)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * **db:** wait for PGlite to be ready before handing it out ([3668e26](https://github.com/pedronastasi/manychat-ai-agent/commit/3668e262c04acb59aa363dbbba6a141563407113))
27
+ * **db:** wait for PGlite to be ready before handing it out ([0497e88](https://github.com/pedronastasi/manychat-ai-agent/commit/0497e880dee8a6a649abdb0fc80c72651f667d6a))
28
+ * **deps:** update patch dependencies ([#233](https://github.com/pedronastasi/manychat-ai-agent/issues/233)) ([026886c](https://github.com/pedronastasi/manychat-ai-agent/commit/026886c0f138cc0f145a191ed285127b021c90b1))
29
+
30
+
31
+ ### Dependencies
32
+
33
+ * **deps:** lock file maintenance ([d4d3bb7](https://github.com/pedronastasi/manychat-ai-agent/commit/d4d3bb720d89538eaa47c7ac7c5aa329ec9fca57))
34
+ * **deps:** lock file maintenance ([07c9d9e](https://github.com/pedronastasi/manychat-ai-agent/commit/07c9d9e331b04d21570da7952222ac8901b048ad))
35
+
3
36
  ## [0.24.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.23.4...v0.24.0) (2026-10-15)
4
37
 
5
38
 
@@ -54,8 +54,14 @@ export declare function escalationReply(reason: EscalationReason, message: strin
54
54
  *
55
55
  * `earlierReplies` is the text of the agent's earlier turns in the
56
56
  * conversation, which the repetition check compares against (specs/013).
57
+ *
58
+ * `paymentLink` is the link the reply must carry because the turn sent a
59
+ * silent payment-link flow (specs/023 § A silent payment flow leaves the link
60
+ * to the reply).
57
61
  */
58
- export declare function applyGuardrails(raw: unknown, rules: Rules, earlierReplies?: readonly string[]): GuardedReply;
62
+ export declare function applyGuardrails(raw: unknown, rules: Rules, earlierReplies?: readonly string[], options?: {
63
+ paymentLink?: string | undefined;
64
+ }): GuardedReply;
59
65
  /**
60
66
  * Flags prices that do not appear in the catalog.
61
67
  *
@@ -110,8 +110,12 @@ export function escalationReply(reason, message) {
110
110
  *
111
111
  * `earlierReplies` is the text of the agent's earlier turns in the
112
112
  * conversation, which the repetition check compares against (specs/013).
113
+ *
114
+ * `paymentLink` is the link the reply must carry because the turn sent a
115
+ * silent payment-link flow (specs/023 § A silent payment flow leaves the link
116
+ * to the reply).
113
117
  */
114
- export function applyGuardrails(raw, rules, earlierReplies = []) {
118
+ export function applyGuardrails(raw, rules, earlierReplies = [], options = {}) {
115
119
  const interventions = [];
116
120
  const parsed = AgentReply.safeParse(raw);
117
121
  if (!parsed.success) {
@@ -200,12 +204,14 @@ export function applyGuardrails(raw, rules, earlierReplies = []) {
200
204
  // matters is that exactly one question ends the turn, not which.
201
205
  const bodyMessages = reply.messages;
202
206
  const body = bodyMessages.at(-1);
207
+ let questionAppended = false;
203
208
  if (reply.closing_question !== null && !reply.escalate) {
204
209
  if (body !== undefined && endsWithQuestion(body)) {
205
210
  interventions.push('closing_question_already_in_body');
206
211
  }
207
212
  else {
208
213
  reply = { ...reply, messages: [...reply.messages, reply.closing_question] };
214
+ questionAppended = true;
209
215
  }
210
216
  }
211
217
  // Verbatim repetition, most often an earlier answer replayed in reply to a
@@ -231,10 +237,59 @@ export function applyGuardrails(raw, rules, earlierReplies = []) {
231
237
  // is nothing to ask and nothing to hand off.
232
238
  interventions.push('duplicate_replaced');
233
239
  reply = { ...reply, messages: [closer], closing_question: null };
240
+ questionAppended = false;
234
241
  }
235
242
  }
243
+ // A silent payment-link flow sent nothing, so a reply without the link
244
+ // leaves the contact recorded at link_sent with no way to pay (specs/023
245
+ // § A silent payment flow leaves the link to the reply). Checked last, on
246
+ // what would be delivered. The link goes on its own line at the end of the
247
+ // body, so the turn still ends on its one question. Skipped on an
248
+ // escalation: the person taking over sends it.
249
+ const link = options.paymentLink;
250
+ if (link !== undefined && !reply.escalate && !carriesLink(reply.messages, link)) {
251
+ interventions.push('payment_link_added');
252
+ reply = { ...reply, messages: withLink(reply.messages, link, questionAppended) };
253
+ }
236
254
  return { reply, interventions, ...(escalatedBy ? { escalatedBy } : {}) };
237
255
  }
256
+ /**
257
+ * The messages with the link on its own line after the body's last one, or as
258
+ * a message of its own ahead of a question the body ends on or that would not
259
+ * fit beside it.
260
+ */
261
+ function withLink(messages, link, questionAppended) {
262
+ const last = messages.length - 1;
263
+ const asked = questionAppended || endsWithQuestion(messages[last] ?? '');
264
+ const at = asked ? last - 1 : last;
265
+ const target = messages[at];
266
+ if (target === undefined || target.length + link.length + 1 > MAX_MESSAGE_CHARS) {
267
+ return messages.toSpliced(at + 1, 0, link);
268
+ }
269
+ return messages.with(at, `${target}\n${link}`);
270
+ }
271
+ /**
272
+ * Whether any message carries the link, as the catalog writes it or with its
273
+ * percent-encoding undone, which is how a model often copies a URL with an
274
+ * accented path. A trailing slash is optional.
275
+ *
276
+ * The link must end where the URL in the message ends: another offering's
277
+ * link that starts with this one (`…/foundation-plus` for `…/foundation`) is
278
+ * not this link, and counting it would leave the contact with the wrong one.
279
+ */
280
+ function carriesLink(messages, link) {
281
+ const bare = (url) => url.replace(/\/+$/, '');
282
+ let decoded = link;
283
+ try {
284
+ decoded = decodeURI(link);
285
+ }
286
+ catch {
287
+ // A malformed escape: only the link as written can match.
288
+ }
289
+ const patterns = [...new Set([bare(link), bare(decoded)])].map(form => new RegExp(`${escapeRegExp(form)}/?(?![\\p{L}\\p{N}_%/-])`, 'u'));
290
+ return messages.some(message => patterns.some(pattern => pattern.test(message)));
291
+ }
292
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
238
293
  /** Numbers with a currency cue nearby, e.g. "$45.000", "45000 pesos". */
239
294
  const PRICE_PATTERN = /(?:\$\s?)([\d][\d.,]{2,})|([\d][\d.,]{2,})\s?(?:pesos|ars|usd)/gi;
240
295
  /** Digits only, so "45.000", "45,000" and "45000" compare equal. */
@@ -99,6 +99,12 @@ opening?: {
99
99
  reads?: readonly {
100
100
  tool: string;
101
101
  result: ReadResult;
102
+ }[],
103
+ /** The tenant's flows, for which of them send the contact nothing (specs/023). */
104
+ flows?: readonly {
105
+ id: string;
106
+ role?: string | undefined;
107
+ silent?: boolean | undefined;
102
108
  }[]): string;
103
109
  /**
104
110
  * The server's note on an earlier turn of what it performed, or null when it
@@ -409,7 +409,16 @@ function salesSection(tools) {
409
409
  'moves forward: a write to an earlier stage is refused.',
410
410
  ...(link
411
411
  ? [
412
- `The payment link is the flow ${link.id}. It is refused before prepared, unless the contact`,
412
+ ...(link.silent
413
+ ? [
414
+ `The payment link is recorded by the flow ${link.id}, which is silent: it sends the`,
415
+ 'contact nothing. Every time you send it, write the link of the contact’s offering from',
416
+ 'the CATALOG in the same reply: the flow does not deliver it. It is refused before',
417
+ 'prepared, unless the contact',
418
+ ]
419
+ : [
420
+ `The payment link is the flow ${link.id}. It is refused before prepared, unless the contact`,
421
+ ]),
413
422
  'asks for it: a contact whose message asks for the link, the payment methods or how to pay',
414
423
  'gets it, qualified or not, and you pass contactAsked true. Never pass it otherwise.',
415
424
  ]
@@ -528,15 +537,35 @@ contact,
528
537
  /** The opening the turn's prospect write queued, if it did (specs/034). */
529
538
  opening,
530
539
  /** Each plugin read tool's last successful read, its text still fenced (specs/039). */
531
- reads = []) {
540
+ reads = [],
541
+ /** The tenant's flows, for which of them send the contact nothing (specs/023). */
542
+ flows = []) {
532
543
  // Flows only: a stage write sent ahead of a payment link is not news to the contact.
533
544
  const sent = (stage.sent ?? []).filter(entry => entry.action.tool === 'send_flow');
534
- const went = sent.filter(entry => entry.records[0]?.status === 'performed');
545
+ const flowOf = (entry) => flows.find(flow => flow.id === entry.action.id);
546
+ const performed = sent.filter(entry => entry.records[0]?.status === 'performed');
547
+ const went = performed.filter(entry => !flowOf(entry)?.silent);
548
+ // A silent flow is not something the contact received, and a reply that
549
+ // treats it as one leaves out what it stands for (specs/023 § A silent
550
+ // payment flow leaves the link to the reply).
551
+ const recorded = performed.filter(entry => flowOf(entry)?.silent);
552
+ const silentLink = (action) => {
553
+ const flow = flows.find(entry => entry.id === action.id);
554
+ return action.tool === 'send_flow' && flow?.silent === true && flow.role === 'payment_link';
555
+ };
556
+ const link = recorded.some(entry => silentLink(entry.action));
557
+ const stagedLink = stage.staged.some(silentLink);
535
558
  const failed = sent.filter(entry => entry.records[0]?.status !== 'performed');
536
559
  const lines = [
537
560
  went.length > 0
538
561
  ? `SENT: Already sent to the contact, who receives it before your reply: ${went.map(entry => describeAction(entry.action)).join(', ')}. Your reply follows it; do not repeat what it contains.`
539
562
  : null,
563
+ recorded.length > 0
564
+ ? `RECORDED: ${recorded.map(entry => describeAction(entry.action)).join(', ')}. Silent: the contact received nothing from it. Write your reply in full, as if it had not been sent.` +
565
+ (link
566
+ ? ' The payment link is yours to write: put the link of the contact’s offering from the CATALOG in this reply.'
567
+ : '')
568
+ : null,
540
569
  failed.length > 0
541
570
  ? `NOT SENT: ManyChat refused: ${failed.map(entry => describeAction(entry.action)).join(', ')}. Do not say it was sent.`
542
571
  : null,
@@ -545,6 +574,9 @@ reads = []) {
545
574
  : sent.length > 0
546
575
  ? null
547
576
  : 'ACTIONS: None of your tool calls were staged.',
577
+ stagedLink
578
+ ? 'The payment link flow is silent and sends the contact nothing: put the link of the contact’s offering from the CATALOG in this reply.'
579
+ : null,
548
580
  stage.dropped.length > 0
549
581
  ? `Not staged, over the limit of ${MAX_ACTIONS_PER_TURN} per turn: ${stage.dropped.map(describeAction).join(', ')}.`
550
582
  : null,
@@ -4,7 +4,7 @@ import { NO_TOOLS, offersTools } from "../contracts/config.js";
4
4
  import { actionsNote, buildSystemPrompt, offeringNotice, fenceUserText, funnelNotice, mediaNotice, intentNotice, nudgeNotice, stagedNotice, } from "./prompt.js";
5
5
  import { applyGuardrails, escalationReply } from "./guardrails.js";
6
6
  import { estimateCostUsd, supportsTemperature } from "./registry.js";
7
- import { ActionStage, buildTools, offeringField, funnelField, intentField, MAX_STEPS, openingFlow, stagesProspect, } from "./tools.js";
7
+ import { ActionStage, buildTools, offeringField, funnelField, intentField, MAX_STEPS, openingFlow, silentPaymentLink, stagesProspect, } from "./tools.js";
8
8
  import { renderPlaybook } from "../learning/playbook.js";
9
9
  /**
10
10
  * An earlier turn as the model reads it. An agent turn is followed by the
@@ -171,7 +171,7 @@ export class GenerateTextRunner {
171
171
  ...messages,
172
172
  {
173
173
  role: 'user',
174
- content: stagedNotice(stage, reads?.latest, opening && stagesProspect(stage, tenantTools) ? opening : undefined, pluginReads?.latest),
174
+ content: stagedNotice(stage, reads?.latest, opening && stagesProspect(stage, tenantTools) ? opening : undefined, pluginReads?.latest, tenantTools.flows),
175
175
  },
176
176
  ],
177
177
  }
@@ -242,7 +242,21 @@ export class GenerateTextRunner {
242
242
  };
243
243
  }
244
244
  const earlierReplies = history.filter(turn => turn.role === 'agent').map(turn => turn.text);
245
- const guarded = applyGuardrails(output, config.rules, earlierReplies);
245
+ const paymentLink = silentPaymentLink(tenantTools, config.catalog, stage, contact);
246
+ let guarded = applyGuardrails(output, config.rules, earlierReplies, {
247
+ paymentLink: paymentLink ?? undefined,
248
+ });
249
+ // Sent, with no link to put in the reply: the loader's checks leave only a
250
+ // contact with no offering recorded. A reply without the link would leave
251
+ // them at link_sent with no way to pay, so a person finishes the turn
252
+ // (specs/023 § A silent payment flow leaves the link to the reply, C6).
253
+ if (paymentLink === null && !guarded.reply.escalate) {
254
+ guarded = {
255
+ reply: escalationReply('low_confidence', config.rules.messages.escalation),
256
+ interventions: [...guarded.interventions, 'payment_link_unknown'],
257
+ escalatedBy: 'error',
258
+ };
259
+ }
246
260
  return {
247
261
  reply: guarded.reply,
248
262
  interventions: guarded.interventions,
@@ -1,5 +1,5 @@
1
1
  import { type ToolSet } from 'ai';
2
- import type { Tools, INTENT_VALUES } from '../contracts/config.ts';
2
+ import type { Catalog, Tools, INTENT_VALUES } from '../contracts/config.ts';
3
3
  import type { ActionRecord, ActionStatus, PerformableAction, StagedAction } from '../contracts/agent.ts';
4
4
  import type { ContactReads } from './contact.ts';
5
5
  import type { FlowSends, SendFlowAction } from './flows.ts';
@@ -122,7 +122,22 @@ export declare function paymentLinkFlow(config: Tools): {
122
122
  onStage?: "qualifying" | "nurturing" | "offered" | "prepared" | undefined;
123
123
  offering?: string | undefined;
124
124
  settleSeconds?: number | undefined;
125
+ silent?: boolean | undefined;
125
126
  } | undefined;
127
+ /**
128
+ * The link a reply must carry because the turn sent a silent payment-link
129
+ * flow, which leaves the link to the reply (specs/023 § A silent payment flow
130
+ * leaves the link to the reply). `undefined` when the turn neither sent such
131
+ * a flow nor staged it to be sent after the reply.
132
+ *
133
+ * The link is the catalog `url` of the turn's offering: one written this turn,
134
+ * else the contact's, else the catalog's only offering. `null` when the flow
135
+ * went out and no offering or link is known, which the loader's checks leave
136
+ * to a contact with no offering recorded yet.
137
+ */
138
+ export declare function silentPaymentLink(config: Tools, catalog: Catalog, stage: ActionStage, contact: {
139
+ offering?: string | undefined;
140
+ } | undefined): string | null | undefined;
126
141
  /** The flow the server sends on a contact's prospect turn, if the tenant marked one (specs/034). */
127
142
  export declare function openingFlow(config: Tools): {
128
143
  id: string;
@@ -133,6 +148,7 @@ export declare function openingFlow(config: Tools): {
133
148
  onStage?: "qualifying" | "nurturing" | "offered" | "prepared" | undefined;
134
149
  offering?: string | undefined;
135
150
  settleSeconds?: number | undefined;
151
+ silent?: boolean | undefined;
136
152
  } | undefined;
137
153
  /** The field the contact's offering is kept in, if the tenant marked one (specs/028). */
138
154
  export declare function offeringField(config: Tools): {
@@ -192,6 +192,33 @@ export function funnelField(config) {
192
192
  export function paymentLinkFlow(config) {
193
193
  return config.flows.find(flow => flow.role === 'payment_link');
194
194
  }
195
+ /**
196
+ * The link a reply must carry because the turn sent a silent payment-link
197
+ * flow, which leaves the link to the reply (specs/023 § A silent payment flow
198
+ * leaves the link to the reply). `undefined` when the turn neither sent such
199
+ * a flow nor staged it to be sent after the reply.
200
+ *
201
+ * The link is the catalog `url` of the turn's offering: one written this turn,
202
+ * else the contact's, else the catalog's only offering. `null` when the flow
203
+ * went out and no offering or link is known, which the loader's checks leave
204
+ * to a contact with no offering recorded yet.
205
+ */
206
+ export function silentPaymentLink(config, catalog, stage, contact) {
207
+ const link = paymentLinkFlow(config);
208
+ if (!link?.silent)
209
+ return undefined;
210
+ const sent = stage.sent.some(entry => entry.action.tool === 'send_flow' &&
211
+ entry.action.id === link.id &&
212
+ entry.records[0]?.status === 'performed');
213
+ const staged = stage.staged.some(action => action.tool === 'send_flow' && action.id === link.id);
214
+ if (!sent && !staged)
215
+ return undefined;
216
+ const written = stage.staged.findLast((action) => action.tool === 'set_field' && action.offering === true);
217
+ const id = written?.value ??
218
+ contact?.offering ??
219
+ (catalog.offerings.length === 1 ? catalog.offerings[0].id : undefined);
220
+ return catalog.offerings.find(offering => offering.id === id)?.url ?? null;
221
+ }
195
222
  /** The flow the server sends on a contact's prospect turn, if the tenant marked one (specs/034). */
196
223
  export function openingFlow(config) {
197
224
  return config.flows.find(flow => flow.role === 'opening');
@@ -299,12 +326,18 @@ const catalogOf = (entries) => entries
299
326
  const notes = [
300
327
  entry.offering === undefined ? null : `offering ${entry.offering}`,
301
328
  entry.onStage === undefined ? null : `sent by the system when you record ${entry.onStage}`,
329
+ entry.silent ? 'silent: sends the contact nothing' : null,
302
330
  ].filter(Boolean);
303
331
  return `- ${entry.id}${notes.length > 0 ? ` (${notes.join('; ')})` : ''}: ${entry.description}`;
304
332
  })
305
333
  .join('\n');
306
334
  const STAGED = 'The action is staged, not performed: it happens after your reply is sent, and not at all if the turn escalates.';
307
335
  const SENT_NOW = 'The flow is sent when you call this, before your reply: the contact receives it first and your reply follows it. Do not repeat what it contains. The result says whether it went out.';
336
+ const SILENT = 'A silent flow sends the contact nothing: write your reply in full, as if it had not been sent.';
337
+ /** The note a silent flow's result carries; the payment link's names the link. */
338
+ const silentResult = (role) => role === 'payment_link'
339
+ ? 'The contact received nothing from this flow. Your reply must carry the link of the contact’s offering from the CATALOG.'
340
+ : 'The contact received nothing from this flow. Write your reply in full, as if it had not been sent.';
308
341
  const CONTACT_ASKED = 'The payment link is refused before the stage is prepared. Pass contactAsked: true only when the contact’s message this turn asks for the link, the payment methods or how to pay; it is recorded.';
309
342
  const STAGED_NOTE = 'The note is staged, not written: it is written after your reply is sent. It replaces what the note held, so read it first with get_contact to add to it.';
310
343
  /**
@@ -419,6 +452,7 @@ options = {}) {
419
452
  const gated = funnel !== undefined && unsent.some(flow => flow.role === 'payment_link');
420
453
  tools.send_flow = tool({
421
454
  description: `Send the contact one of these flows.\n${listing}\n${sends ? SENT_NOW : STAGED}` +
455
+ (unsent.some(flow => flow.silent) ? `\n${SILENT}` : '') +
422
456
  (gated ? `\n${CONTACT_ASKED}` : ''),
423
457
  inputSchema: gated
424
458
  ? z.object({ flow: z.enum(idsOf(unsent)), contactAsked: z.boolean().optional() })
@@ -466,8 +500,14 @@ options = {}) {
466
500
  ...(followOn ? { followOn } : {}),
467
501
  ...(asked ? { contactAsked: true } : {}),
468
502
  };
469
- if (!sends)
470
- return { staged: stage.stage(action) };
503
+ // What a silent flow leaves to the reply, said where the model reads
504
+ // the outcome (specs/023 § A silent payment flow leaves the link to
505
+ // the reply).
506
+ const silent = entry.silent ? { silent: silentResult(entry.role) } : {};
507
+ if (!sends) {
508
+ const staged = stage.stage(action);
509
+ return staged ? { staged, ...silent } : { staged };
510
+ }
471
511
  // The opening goes first on the prospect turn (specs/034). A payment
472
512
  // link is counted at link_sent before the await, so a funnel or offering
473
513
  // write made beside it is refused rather than landing after it (specs/029).
@@ -490,7 +530,9 @@ options = {}) {
490
530
  ? stage.staged.filter(staged => staged.tool === 'set_field' && staged.id === funnel.id)
491
531
  : [];
492
532
  const sent = await stage.send(action, sends, first);
493
- return sent === undefined ? { sent: false, reason: 'over the per-turn limit' } : { sent };
533
+ if (sent === undefined)
534
+ return { sent: false, reason: 'over the per-turn limit' };
535
+ return sent ? { sent, ...silent } : { sent };
494
536
  },
495
537
  });
496
538
  }
@@ -84,6 +84,22 @@ function offeringMismatches(tools, catalog) {
84
84
  : tools.flows
85
85
  .filter(flow => flow.offering !== undefined)
86
86
  .map(flow => `flow '${flow.id}' has an offering, but no field is marked "offering"`)),
87
+ // A silent payment flow leaves the link to the reply, and the server puts
88
+ // the contact's offering's link there when the reply leaves it out, so
89
+ // that link must exist and the offering must be knowable (specs/023
90
+ // § A silent payment flow leaves the link to the reply).
91
+ ...tools.flows
92
+ .filter(flow => flow.role === 'payment_link' && flow.silent)
93
+ .flatMap(flow => [
94
+ ...catalog.offerings
95
+ .filter(offering => offering.url === null)
96
+ .map(offering => `flow '${flow.id}' is a silent payment link, so offering '${offering.id}' needs a url`),
97
+ ...(field || ids.length === 1
98
+ ? []
99
+ : [
100
+ `flow '${flow.id}' is a silent payment link, so with several offerings a field must be marked "offering"`,
101
+ ]),
102
+ ]),
87
103
  ];
88
104
  }
89
105
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -186,6 +186,7 @@ export declare const ToolsSchema: z.ZodObject<{
186
186
  }> & z.ZodType<"qualifying" | "nurturing" | "offered" | "prepared", "new" | "qualifying" | "nurturing" | "offered" | "prepared" | "link_sent", z.core.$ZodTypeInternals<"qualifying" | "nurturing" | "offered" | "prepared", "new" | "qualifying" | "nurturing" | "offered" | "prepared" | "link_sent">>>;
187
187
  offering: z.ZodOptional<z.ZodString>;
188
188
  settleSeconds: z.ZodOptional<z.ZodNumber>;
189
+ silent: z.ZodOptional<z.ZodBoolean>;
189
190
  }, z.core.$strip>>>;
190
191
  tags: z.ZodDefault<z.ZodArray<z.ZodObject<{
191
192
  id: z.ZodString;
@@ -315,8 +315,9 @@ export const ToolsSchema = z
315
315
  repeatable: z.boolean().optional(),
316
316
  /**
317
317
  * `payment_link`: performing it writes the funnel field to `link_sent`
318
- * (specs/023). `opening`: the server sends it on the contact's first
319
- * model turn, and the model never does (specs/032).
318
+ * (specs/023); with `silent`, the reply carries the link. `opening`:
319
+ * the server sends it on the contact's first model turn, and the
320
+ * model never does (specs/032).
320
321
  */
321
322
  role: z.enum(['payment_link', 'opening']).optional(),
322
323
  /**
@@ -338,9 +339,20 @@ export const ToolsSchema = z
338
339
  * (specs/030). Absent, it does not wait.
339
340
  */
340
341
  settleSeconds: z.number().int().min(0).max(MAX_SETTLE_SECONDS).optional(),
342
+ /**
343
+ * The flow sends the contact no message: it only records something
344
+ * in ManyChat, such as a tracking event. On the payment-link flow it
345
+ * means the reply carries the link (specs/023 § A silent payment
346
+ * flow leaves the link to the reply).
347
+ */
348
+ silent: z.boolean().optional(),
341
349
  }))
342
350
  .default([])
343
351
  .refine(uniqueIds, 'flow ids must be unique')
352
+ // Nothing plays, so there is nothing for the reply to wait for (specs/030).
353
+ .refine(flows => !flows.some(flow => flow.silent && flow.settleSeconds !== undefined), 'a "silent" flow may not have "settleSeconds"')
354
+ // The opening is the contact's first message from the tenant (specs/032).
355
+ .refine(flows => !flows.some(flow => flow.silent && flow.role === 'opening'), 'the "opening" flow may not be "silent"')
344
356
  .refine(flows => flows.filter(flow => flow.role === 'payment_link').length <= 1, 'only one flow may have role "payment_link"')
345
357
  // One payment flow serves every offering and branches on the offering
346
358
  // field inside ManyChat (specs/028 § One payment flow serves every offering).
package/dist/db/client.js CHANGED
@@ -15,6 +15,10 @@ export async function createEmbeddedDatabase(url) {
15
15
  const { drizzle: drizzlePglite } = await import('drizzle-orm/pglite');
16
16
  const dataDir = url.startsWith('pglite:') ? url.slice('pglite:'.length) : undefined;
17
17
  const client = dataDir ? new PGlite(dataDir) : new PGlite();
18
+ // Since PGlite 0.5.10, close() on an instance still starting up never
19
+ // settles, so a caller that closes without querying (scripts/export-openapi.ts)
20
+ // would hang. Hand back a client that is ready.
21
+ await client.waitReady;
18
22
  return { db: drizzlePglite(client, { schema }), client };
19
23
  }
20
24
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "manychat-ai-agent",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "A provider-agnostic conversational AI agent gateway that bridges no-code chat platforms to any LLM.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -71,7 +71,7 @@
71
71
  "@ai-sdk/anthropic": "^4.0.53",
72
72
  "@ai-sdk/google": "^4.0.69",
73
73
  "@ai-sdk/openai": "^4.0.66",
74
- "@ai-sdk/provider": "4.0.27",
74
+ "@ai-sdk/provider": "4.0.28",
75
75
  "@fastify/helmet": "^13.1.1",
76
76
  "@fastify/rate-limit": "^11.2.0",
77
77
  "@fastify/swagger": "^9.8.1",
@@ -97,7 +97,7 @@
97
97
  "@electric-sql/pglite": "^0.5.8",
98
98
  "@eslint/js": "^10.0.1",
99
99
  "@types/node": "^24.0.0",
100
- "@vitest/coverage-v8": "5.0.4",
100
+ "@vitest/coverage-v8": "5.0.5",
101
101
  "drizzle-kit": "^0.31.10",
102
102
  "eslint": "^10.10.0",
103
103
  "mermaid": "^12.0.0",