manychat-ai-agent 0.26.0 → 0.26.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 (80) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/agent/tools/build.d.ts +23 -0
  3. package/dist/agent/tools/build.js +42 -0
  4. package/dist/agent/tools/contact-actions.d.ts +63 -0
  5. package/dist/agent/tools/contact-actions.js +64 -0
  6. package/dist/agent/tools/copy.d.ts +18 -0
  7. package/dist/agent/tools/copy.js +21 -0
  8. package/dist/agent/tools/get-contact.d.ts +5 -0
  9. package/dist/agent/tools/get-contact.js +26 -0
  10. package/dist/agent/tools/lookups.d.ts +90 -0
  11. package/dist/agent/tools/lookups.js +70 -0
  12. package/dist/agent/tools/notes.d.ts +4 -0
  13. package/dist/agent/tools/notes.js +36 -0
  14. package/dist/agent/tools/nudge.d.ts +4 -0
  15. package/dist/agent/tools/nudge.js +29 -0
  16. package/dist/agent/tools/send-flow.d.ts +4 -0
  17. package/dist/agent/tools/send-flow.js +115 -0
  18. package/dist/agent/tools/set-field.d.ts +7 -0
  19. package/dist/agent/tools/set-field.js +142 -0
  20. package/dist/agent/tools/stage.d.ts +92 -0
  21. package/dist/agent/tools/stage.js +176 -0
  22. package/dist/agent/tools/tags.d.ts +4 -0
  23. package/dist/agent/tools/tags.js +26 -0
  24. package/dist/agent/tools/turn.d.ts +93 -0
  25. package/dist/agent/tools/turn.js +93 -0
  26. package/dist/agent/tools.d.ts +11 -266
  27. package/dist/agent/tools.js +7 -738
  28. package/dist/channels/manychat/client.d.ts +11 -0
  29. package/dist/channels/manychat/client.js +16 -1
  30. package/dist/contracts/config/catalog.d.ts +55 -0
  31. package/dist/contracts/config/catalog.js +36 -0
  32. package/dist/contracts/config/channels.d.ts +14 -0
  33. package/dist/contracts/config/channels.js +50 -0
  34. package/dist/contracts/config/env.d.ts +51 -0
  35. package/dist/contracts/config/env.js +126 -0
  36. package/dist/contracts/config/funnel.d.ts +20 -0
  37. package/dist/contracts/config/funnel.js +27 -0
  38. package/dist/contracts/config/prompt-overrides.d.ts +26 -0
  39. package/dist/contracts/config/prompt-overrides.js +26 -0
  40. package/dist/contracts/config/rules.d.ts +54 -0
  41. package/dist/contracts/config/rules.js +98 -0
  42. package/dist/contracts/config/tools.d.ts +121 -0
  43. package/dist/contracts/config/tools.js +286 -0
  44. package/dist/contracts/config.d.ts +11 -329
  45. package/dist/contracts/config.js +11 -650
  46. package/dist/server.js +1 -1
  47. package/dist/turn/admission.d.ts +52 -0
  48. package/dist/turn/admission.js +94 -0
  49. package/dist/turn/completion.d.ts +23 -0
  50. package/dist/turn/completion.js +1 -0
  51. package/dist/turn/context.d.ts +23 -0
  52. package/dist/turn/context.js +33 -0
  53. package/dist/turn/deferred.d.ts +38 -0
  54. package/dist/turn/deferred.js +76 -0
  55. package/dist/turn/delivery.d.ts +100 -0
  56. package/dist/turn/delivery.js +162 -0
  57. package/dist/turn/fold.d.ts +44 -0
  58. package/dist/turn/fold.js +59 -0
  59. package/dist/{routes/turn.d.ts → turn/handler.d.ts} +14 -68
  60. package/dist/turn/handler.js +335 -0
  61. package/dist/turn/logger.d.ts +10 -0
  62. package/dist/turn/logger.js +11 -0
  63. package/dist/turn/media.d.ts +46 -0
  64. package/dist/turn/media.js +109 -0
  65. package/dist/turn/order.d.ts +49 -0
  66. package/dist/turn/order.js +115 -0
  67. package/dist/turn/prechecks.d.ts +30 -0
  68. package/dist/turn/prechecks.js +48 -0
  69. package/dist/turn/race.d.ts +7 -0
  70. package/dist/turn/race.js +18 -0
  71. package/dist/turn/recorder.d.ts +86 -0
  72. package/dist/turn/recorder.js +125 -0
  73. package/dist/turn/replies.d.ts +15 -0
  74. package/dist/turn/replies.js +25 -0
  75. package/dist/turn/result.d.ts +20 -0
  76. package/dist/turn/result.js +1 -0
  77. package/dist/turn/scope.d.ts +83 -0
  78. package/dist/turn/scope.js +71 -0
  79. package/package.json +1 -1
  80. package/dist/routes/turn.js +0 -1007
@@ -0,0 +1,115 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { LINK_SENT, PREPARED } from "../../contracts/config.js";
4
+ import { eventFor } from "./contact-actions.js";
5
+ import { catalogOf, CONTACT_ASKED, idsOf, SENT_NOW, SILENT, silentResult, STAGED } from "./copy.js";
6
+ import { stageIndex } from "./lookups.js";
7
+ import { fitsOffering, NOT_PROSPECT } from "./turn.js";
8
+ /** `send_flow`: the content flows not yet sent to the contact (specs/023, 028, 029, 032). */
9
+ export function addSendFlow(turn, tools) {
10
+ const { config, stage, contact, options, funnel } = turn;
11
+ if (turn.unsent.length > 0) {
12
+ const flows = new Map(turn.unsent.map(flow => [flow.id, flow]));
13
+ // The enum holds every unsent flow, so one the agent makes available by
14
+ // setting the offering this turn can still be named; the description lists
15
+ // only those it can accept now.
16
+ const available = turn.unsent.filter(flow => fitsOffering(flow, turn.turnOffering()));
17
+ // With an offering known, an empty list means its content was all sent:
18
+ // told to set the offering, the model could switch it just to have something to
19
+ // send, or try a write the lock refuses.
20
+ const listing = available.length > 0
21
+ ? catalogOf(available)
22
+ : turn.turnOffering() === undefined
23
+ ? 'None yet: record the contact’s offering with set_field first.'
24
+ : 'None: everything for this contact’s offering has been sent.';
25
+ const sends = options.flows;
26
+ // The payment link waits for readiness unless the contact asked to pay
27
+ // (specs/032 § The payment link waits for readiness).
28
+ const gated = funnel !== undefined && turn.unsent.some(flow => flow.role === 'payment_link');
29
+ tools.send_flow = tool({
30
+ description: `Send the contact one of these flows.\n${listing}\n${sends ? SENT_NOW : STAGED}` +
31
+ (turn.unsent.some(flow => flow.silent) ? `\n${SILENT}` : '') +
32
+ (gated ? `\n${CONTACT_ASKED}` : ''),
33
+ inputSchema: gated
34
+ ? z.object({ flow: z.enum(idsOf(turn.unsent)), contactAsked: z.boolean().optional() })
35
+ : z.object({ flow: z.enum(idsOf(turn.unsent)) }),
36
+ execute: async (input) => {
37
+ if (turn.closed())
38
+ return sends ? { sent: false, reason: 'not_prospect' } : NOT_PROSPECT;
39
+ const { flow } = input;
40
+ const entry = flows.get(flow);
41
+ // Another offering's content is refused, whatever the model names.
42
+ if (!fitsOffering(entry, turn.turnOffering()))
43
+ return sends ? { sent: false } : { staged: false };
44
+ // Recorded only when it opened the gate, so the bypass rate counts
45
+ // bypasses, not every claim the model makes (specs/032).
46
+ const asked = entry.role === 'payment_link' &&
47
+ input.contactAsked === true &&
48
+ funnel !== undefined &&
49
+ turn.stageFloor() < stageIndex(PREPARED);
50
+ if (entry.role === 'payment_link' &&
51
+ funnel &&
52
+ !asked &&
53
+ turn.stageFloor() < stageIndex(PREPARED)) {
54
+ return sends
55
+ ? { sent: false, reason: 'not_prepared' }
56
+ : { staged: false, reason: 'not_prepared' };
57
+ }
58
+ // The server, not the model, records that the link went out, and only
59
+ // once the flow itself has (specs/023 § The sale ends at the
60
+ // payment-link flow).
61
+ // Its event, if any, follows the write in turn (specs/027).
62
+ const event = funnel ? eventFor(config, contact, LINK_SENT) : undefined;
63
+ const followOn = entry.role === 'payment_link' && funnel
64
+ ? {
65
+ tool: 'set_field',
66
+ id: funnel.id,
67
+ field: funnel.field,
68
+ value: LINK_SENT,
69
+ ...(event ? { followOn: event } : {}),
70
+ }
71
+ : undefined;
72
+ const action = {
73
+ tool: 'send_flow',
74
+ id: flow,
75
+ flowNs: entry.flowNs,
76
+ ...(followOn ? { followOn } : {}),
77
+ ...(asked ? { contactAsked: true } : {}),
78
+ };
79
+ // What a silent flow leaves to the reply, said where the model reads
80
+ // the outcome (specs/023 § A silent payment flow leaves the link to
81
+ // the reply).
82
+ const silent = entry.silent ? { silent: silentResult(entry.role) } : {};
83
+ if (!sends) {
84
+ const staged = stage.stage(action);
85
+ return staged ? { staged, ...silent } : { staged };
86
+ }
87
+ // The opening goes first on the prospect turn (specs/034). A payment
88
+ // link is counted at link_sent before the await, so a funnel or offering
89
+ // write made beside it is refused rather than landing after it (specs/029).
90
+ const link = entry.role === 'payment_link' && funnel !== undefined;
91
+ if (link)
92
+ turn.linkStarting += 1;
93
+ try {
94
+ if (options.beforeFlow)
95
+ await options.beforeFlow();
96
+ }
97
+ finally {
98
+ // From here the reserved entry counts while the link is in flight.
99
+ if (link)
100
+ turn.linkStarting -= 1;
101
+ }
102
+ // Sent now, so the reply written after it follows it (specs/029). A
103
+ // payment link takes the turn's staged stage writes ahead of it, so
104
+ // its `link_sent` is the last stage ManyChat is given.
105
+ const first = entry.role === 'payment_link' && funnel
106
+ ? stage.staged.filter(staged => staged.tool === 'set_field' && staged.id === funnel.id)
107
+ : [];
108
+ const sent = await stage.send(action, sends, first);
109
+ if (sent === undefined)
110
+ return { sent: false, reason: 'over the per-turn limit' };
111
+ return sent ? { sent, ...silent } : { sent };
112
+ },
113
+ });
114
+ }
115
+ }
@@ -0,0 +1,7 @@
1
+ import { type ToolSet } from 'ai';
2
+ import type { ToolTurn } from './turn.ts';
3
+ /**
4
+ * `set_field`: the tenant's enum fields, the funnel, the offering and intent
5
+ * among them (specs/012, 023, 028, 032, 034, 045).
6
+ */
7
+ export declare function addSetField(turn: ToolTurn, tools: ToolSet): void;
@@ -0,0 +1,142 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { LINK_SENT, PROSPECT } from "../../contracts/config.js";
4
+ import { eventFor } from "./contact-actions.js";
5
+ import { idsOf, STAGED } from "./copy.js";
6
+ import { OFFERING_LOCKED_FROM, openingFlow, stageIndex } from "./lookups.js";
7
+ import { fitsOffering, NOT_PROSPECT } from "./turn.js";
8
+ /**
9
+ * `set_field`: the tenant's enum fields, the funnel, the offering and intent
10
+ * among them (specs/012, 023, 028, 032, 034, 045).
11
+ */
12
+ export function addSetField(turn, tools) {
13
+ const { config, stage, contact, options, funnel, intent, offering } = turn;
14
+ if (config.fields.length > 0) {
15
+ // `link_sent` is the server's to write, so the model cannot name it.
16
+ const offered = config.fields.map(field => field.funnel
17
+ ? { ...field, values: field.values.filter(value => value !== LINK_SENT) }
18
+ : field);
19
+ const fields = new Map(offered.map(field => [field.id, field]));
20
+ const values = [...new Set(offered.flatMap(field => field.values))];
21
+ const listing = offered
22
+ .map(field => `- ${field.id} (one of: ${field.values.join(', ')}): ${field.description}`)
23
+ .join('\n');
24
+ /**
25
+ * The flow tied to a stage the model just moved the contact to, sent as a
26
+ * flow the model calls is (specs/032 § A stage move can carry a flow):
27
+ * once per contact, only for the turn's offering, and only for the stage
28
+ * written, never one skipped.
29
+ */
30
+ const overCap = (id) => ({ flowDropped: id, reason: 'over the per-turn limit' });
31
+ const sendTied = async (value) => {
32
+ const tied = config.flows.find(flow => flow.onStage === value);
33
+ if (!tied || !fitsOffering(tied, turn.turnOffering()))
34
+ return {};
35
+ if (!tied.repeatable && contact.sentFlows.has(tied.id))
36
+ return {};
37
+ if (stage.sent.some(sent => sent.action.tool === 'send_flow' && sent.action.id === tied.id)) {
38
+ return {};
39
+ }
40
+ const action = {
41
+ tool: 'send_flow',
42
+ id: tied.id,
43
+ flowNs: tied.flowNs,
44
+ origin: 'stage',
45
+ };
46
+ if (!options.flows) {
47
+ // `stage` refuses only over the cap; a repeat is reported as staged.
48
+ return stage.stage(action) ? { flowStaged: tied.id } : overCap(tied.id);
49
+ }
50
+ if (options.beforeFlow)
51
+ await options.beforeFlow();
52
+ const sent = await stage.send(action, options.flows);
53
+ // Dropped over the cap is not refused by ManyChat: the model may still
54
+ // send the flow itself on a later turn (specs/032).
55
+ if (sent === undefined)
56
+ return overCap(tied.id);
57
+ return sent ? { flowSent: tied.id } : { flowRefused: tied.id };
58
+ };
59
+ tools.set_field = tool({
60
+ description: `Record one of these choices on the contact.\n${listing}\n${STAGED}`,
61
+ // Never free text (specs/012 § Free-text field values are refused). The
62
+ // enum covers every field's values; the refinement holds each value to
63
+ // its own field.
64
+ inputSchema: z
65
+ .object({ field: z.enum(idsOf(config.fields)), value: z.enum(values) })
66
+ .refine(input => fields.get(input.field).values.includes(input.value), {
67
+ message: 'value is not one of this field’s configured values',
68
+ path: ['value'],
69
+ }),
70
+ execute: async ({ field, value }) => {
71
+ if (field === intent?.id) {
72
+ // Intent moves one way, and `prospect` is final (specs/034 § Intent
73
+ // is a field the model records).
74
+ const before = turn.turnIntent();
75
+ if (before === PROSPECT && value !== PROSPECT)
76
+ return { staged: false };
77
+ const staged = stage.stage({
78
+ tool: 'set_field',
79
+ id: field,
80
+ field: fields.get(field).field,
81
+ value,
82
+ });
83
+ // The opening belongs to the turn that first stages `prospect`, and
84
+ // the result says so, since the server could not know before the
85
+ // loop ran (specs/034 § The opening waits for a prospect).
86
+ const opening = openingFlow(config);
87
+ return staged &&
88
+ value === PROSPECT &&
89
+ before !== PROSPECT &&
90
+ contact.openingDue &&
91
+ opening
92
+ ? { staged, openingQueued: { flow: opening.id, description: opening.description } }
93
+ : { staged };
94
+ }
95
+ if (turn.closed())
96
+ return NOT_PROSPECT;
97
+ // The stage only moves forward (specs/023 § The funnel is a field the
98
+ // agent moves).
99
+ const floor = turn.stageFloor();
100
+ if (field === funnel?.id && stageIndex(value) < floor)
101
+ return { staged: false };
102
+ if (field !== offering?.id) {
103
+ // The server's measurement of the write, never the model's choice
104
+ // (specs/027 § An event is a measurement).
105
+ const event = field === funnel?.id ? eventFor(config, contact, value) : undefined;
106
+ const staged = stage.stage({
107
+ tool: 'set_field',
108
+ id: field,
109
+ field: fields.get(field).field,
110
+ value,
111
+ ...(event ? { followOn: event } : {}),
112
+ });
113
+ const moved = field === funnel?.id && staged && stageIndex(value) > floor;
114
+ return moved ? { staged, ...(await sendTied(value)) } : { staged };
115
+ }
116
+ // Once an offering and its price have been put to the contact, a person
117
+ // decides a switch (specs/028 § The offering may change until the offer),
118
+ // unless the tenant keeps it open (specs/045).
119
+ if (!offering?.switchable && turn.stageFloor() >= stageIndex(OFFERING_LOCKED_FROM)) {
120
+ return { staged: false };
121
+ }
122
+ const before = turn.turnOffering();
123
+ const staged = stage.stage({
124
+ tool: 'set_field',
125
+ id: field,
126
+ field: fields.get(field).field,
127
+ value,
128
+ offering: true,
129
+ });
130
+ if (!staged)
131
+ return { staged };
132
+ // Available in the same turn, and said so, since the send_flow
133
+ // description was written before the offering was known.
134
+ const opened = turn.unsent.filter(flow => flow.offering === value && !fitsOffering(flow, before));
135
+ return {
136
+ staged,
137
+ flowsAvailable: opened.map(flow => ({ flow: flow.id, description: flow.description })),
138
+ };
139
+ },
140
+ });
141
+ }
142
+ }
@@ -0,0 +1,92 @@
1
+ import type { ActionRecord, ActionStatus, PerformableAction, StagedAction } from '../../contracts/agent.ts';
2
+ import type { FlowSends, SendFlowAction } from '../flows.ts';
3
+ /**
4
+ * Bounds the burst one turn can put through the ManyChat rate limiter, one
5
+ * request per action. Eight fit a flow, a funnel stage, two qualification
6
+ * fields, a tag and three notes (specs/024). Chosen, not measured.
7
+ */
8
+ export declare const MAX_ACTIONS_PER_TURN = 8;
9
+ /** `send_flow intro_course_brochure`, `set_field preferred_shift=evening`. */
10
+ export declare function describeAction(action: {
11
+ tool: string;
12
+ id: string;
13
+ value?: string | undefined;
14
+ }): string;
15
+ /**
16
+ * The entry recorded on the turn: ids and values, never ManyChat names. A
17
+ * note is recorded by its length, never its text (specs/024 § Note text never
18
+ * reaches the record or the logs).
19
+ */
20
+ export declare function recordOf(action: PerformableAction, status: ActionStatus, error?: string): ActionRecord;
21
+ /**
22
+ * Everything the model staged on one turn. A tool's `execute` writes here and
23
+ * nothing else: no ManyChat write is made inside the model call, because at
24
+ * that point the model has not decided whether to escalate and the guardrails
25
+ * have not run (ADR-0010). The exception is a flow on an inbound turn, sent
26
+ * when called so the reply can follow it (specs/029, ADR-0019); it is kept
27
+ * here with what became of it, in call order.
28
+ *
29
+ * Created by the caller rather than the runner, so what was staged is still
30
+ * known when the model call is aborted and never returns.
31
+ */
32
+ export declare class ActionStage {
33
+ private readonly accepted;
34
+ private readonly overCap;
35
+ private readonly sentNow;
36
+ /** Staged and sent entries in the order the model made them, for the record. */
37
+ private readonly order;
38
+ /** Sends the cap does not count: the opening flow is the server's (specs/032). */
39
+ private uncapped;
40
+ /**
41
+ * Whether the action is staged. A repeat of one already staged is. Compared
42
+ * whole rather than by record, since two notes of one length differ.
43
+ */
44
+ stage(action: StagedAction): boolean;
45
+ /** A flow sent this turn counts against the cap as a staged action does. */
46
+ private get full();
47
+ /**
48
+ * Sends a flow now (specs/029). Returns whether ManyChat accepted it, or
49
+ * `undefined` when it is over the cap and was not sent. A flow already sent
50
+ * this turn is not sent again; its first outcome stands.
51
+ *
52
+ * `first` are actions already staged that must reach ManyChat before the
53
+ * flow: a payment link's `link_sent` would otherwise be overwritten by an
54
+ * earlier funnel write performed after the reply (specs/029 § A payment link
55
+ * takes the turn's stage writes with it). They are sent ahead of it, keep
56
+ * their place in the record, and are no longer staged.
57
+ */
58
+ send(action: SendFlowAction, sends: FlowSends, first?: readonly StagedAction[],
59
+ /** Not counted against the cap: the server's opening flow (specs/032). */
60
+ options?: {
61
+ uncapped?: boolean;
62
+ }): Promise<boolean | undefined>;
63
+ /**
64
+ * Resolves once every send started this turn has its answer. A call aborted
65
+ * while a request is in flight does not stop the request, so the record waits
66
+ * for it rather than keeping the placeholder (specs/029). Bounded by the
67
+ * client's request timeout.
68
+ */
69
+ settled(): Promise<void>;
70
+ /** Actions sent this turn, whatever became of them. */
71
+ get sent(): readonly {
72
+ action: StagedAction;
73
+ records: readonly ActionRecord[];
74
+ pending: boolean;
75
+ }[];
76
+ /** In the order the model staged them, which is the order they are performed in. */
77
+ get staged(): readonly StagedAction[];
78
+ get dropped(): readonly StagedAction[];
79
+ /**
80
+ * What an escalated turn still performs: the notes marked `onEscalation`,
81
+ * and only when the model or the confidence threshold escalated (specs/024
82
+ * § A handoff summary survives the escalation it describes).
83
+ */
84
+ survivors(keepNotes: boolean): StagedAction[];
85
+ /**
86
+ * The turn's record before anything is performed: every accepted action
87
+ * with `status`, then those dropped over the cap. On an escalated turn
88
+ * (`discarded`), an action in `kept` is recorded `staged` instead, since it
89
+ * will still be performed.
90
+ */
91
+ records(status: 'staged' | 'discarded', kept?: readonly StagedAction[]): ActionRecord[];
92
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Bounds the burst one turn can put through the ManyChat rate limiter, one
3
+ * request per action. Eight fit a flow, a funnel stage, two qualification
4
+ * fields, a tag and three notes (specs/024). Chosen, not measured.
5
+ */
6
+ export const MAX_ACTIONS_PER_TURN = 8;
7
+ /** `send_flow intro_course_brochure`, `set_field preferred_shift=evening`. */
8
+ export function describeAction(action) {
9
+ return action.value === undefined
10
+ ? `${action.tool} ${action.id}`
11
+ : `${action.tool} ${action.id}=${action.value}`;
12
+ }
13
+ /**
14
+ * The entry recorded on the turn: ids and values, never ManyChat names. A
15
+ * note is recorded by its length, never its text (specs/024 § Note text never
16
+ * reaches the record or the logs).
17
+ */
18
+ export function recordOf(action, status, error) {
19
+ return {
20
+ tool: action.tool,
21
+ id: action.id,
22
+ ...(action.tool === 'set_field' ? { value: action.value } : {}),
23
+ ...(action.tool === 'write_note' ? { length: action.text.length } : {}),
24
+ status,
25
+ ...(error === undefined ? {} : { error }),
26
+ ...(action.tool === 'send_flow' && action.origin ? { origin: action.origin } : {}),
27
+ ...(action.tool === 'send_flow' && action.contactAsked ? { contactAsked: true } : {}),
28
+ };
29
+ }
30
+ /**
31
+ * Everything the model staged on one turn. A tool's `execute` writes here and
32
+ * nothing else: no ManyChat write is made inside the model call, because at
33
+ * that point the model has not decided whether to escalate and the guardrails
34
+ * have not run (ADR-0010). The exception is a flow on an inbound turn, sent
35
+ * when called so the reply can follow it (specs/029, ADR-0019); it is kept
36
+ * here with what became of it, in call order.
37
+ *
38
+ * Created by the caller rather than the runner, so what was staged is still
39
+ * known when the model call is aborted and never returns.
40
+ */
41
+ export class ActionStage {
42
+ accepted = [];
43
+ overCap = [];
44
+ sentNow = [];
45
+ /** Staged and sent entries in the order the model made them, for the record. */
46
+ order = [];
47
+ /** Sends the cap does not count: the opening flow is the server's (specs/032). */
48
+ uncapped = 0;
49
+ /**
50
+ * Whether the action is staged. A repeat of one already staged is. Compared
51
+ * whole rather than by record, since two notes of one length differ.
52
+ */
53
+ stage(action) {
54
+ const key = JSON.stringify(action);
55
+ const same = (existing) => JSON.stringify(existing) === key;
56
+ if (this.accepted.some(same))
57
+ return true;
58
+ if (this.full) {
59
+ if (!this.overCap.some(same))
60
+ this.overCap.push(action);
61
+ return false;
62
+ }
63
+ this.accepted.push(action);
64
+ this.order.push(action);
65
+ return true;
66
+ }
67
+ /** A flow sent this turn counts against the cap as a staged action does. */
68
+ get full() {
69
+ return this.accepted.length + this.sentNow.length - this.uncapped >= MAX_ACTIONS_PER_TURN;
70
+ }
71
+ /**
72
+ * Sends a flow now (specs/029). Returns whether ManyChat accepted it, or
73
+ * `undefined` when it is over the cap and was not sent. A flow already sent
74
+ * this turn is not sent again; its first outcome stands.
75
+ *
76
+ * `first` are actions already staged that must reach ManyChat before the
77
+ * flow: a payment link's `link_sent` would otherwise be overwritten by an
78
+ * earlier funnel write performed after the reply (specs/029 § A payment link
79
+ * takes the turn's stage writes with it). They are sent ahead of it, keep
80
+ * their place in the record, and are no longer staged.
81
+ */
82
+ async send(action, sends, first = [],
83
+ /** Not counted against the cap: the server's opening flow (specs/032). */
84
+ options = {}) {
85
+ const earlier = this.sentNow.find(entry => entry.action.tool === 'send_flow' && entry.action.id === action.id);
86
+ if (earlier) {
87
+ // A repeat made while the first request is in flight (parallel calls in
88
+ // one step) gets its real outcome, not the placeholder.
89
+ await earlier.done;
90
+ return earlier.records[0]?.status === 'performed';
91
+ }
92
+ if (!options.uncapped && this.full) {
93
+ if (!this.overCap.some(entry => entry.tool === 'send_flow' && entry.id === action.id)) {
94
+ this.overCap.push(action);
95
+ }
96
+ return undefined;
97
+ }
98
+ // Reserved before the request, so a second call made while it is in
99
+ // flight neither sends the flow twice nor slips past the cap. Recorded as
100
+ // failed until ManyChat answers: a turn that ends while the request is in
101
+ // flight cannot say it went out.
102
+ const reserve = (entryAction) => ({
103
+ action: entryAction,
104
+ records: [recordOf(entryAction, 'failed', 'no answer before the turn ended')],
105
+ done: Promise.resolve(),
106
+ pending: true,
107
+ });
108
+ const ahead = first.filter(staged => this.accepted.includes(staged)).map(reserve);
109
+ for (const entry of ahead) {
110
+ const at = this.accepted.indexOf(entry.action);
111
+ this.accepted.splice(at, 1);
112
+ this.sentNow.push(entry);
113
+ this.order[this.order.indexOf(entry.action)] = { sent: this.sentNow.length - 1 };
114
+ }
115
+ const entry = reserve(action);
116
+ if (options.uncapped)
117
+ this.uncapped += 1;
118
+ this.sentNow.push(entry);
119
+ this.order.push({ sent: this.sentNow.length - 1 });
120
+ const done = sends.send([...ahead.map(staged => staged.action), action]).then(groups => {
121
+ [...ahead, entry].forEach((sent, index) => {
122
+ sent.records = groups[index] ?? sent.records;
123
+ sent.pending = false;
124
+ });
125
+ });
126
+ for (const sent of [...ahead, entry])
127
+ sent.done = done;
128
+ await done;
129
+ return entry.records[0]?.status === 'performed';
130
+ }
131
+ /**
132
+ * Resolves once every send started this turn has its answer. A call aborted
133
+ * while a request is in flight does not stop the request, so the record waits
134
+ * for it rather than keeping the placeholder (specs/029). Bounded by the
135
+ * client's request timeout.
136
+ */
137
+ async settled() {
138
+ await Promise.all(this.sentNow.map(entry => entry.done));
139
+ }
140
+ /** Actions sent this turn, whatever became of them. */
141
+ get sent() {
142
+ return this.sentNow;
143
+ }
144
+ /** In the order the model staged them, which is the order they are performed in. */
145
+ get staged() {
146
+ return this.accepted;
147
+ }
148
+ get dropped() {
149
+ return this.overCap;
150
+ }
151
+ /**
152
+ * What an escalated turn still performs: the notes marked `onEscalation`,
153
+ * and only when the model or the confidence threshold escalated (specs/024
154
+ * § A handoff summary survives the escalation it describes).
155
+ */
156
+ survivors(keepNotes) {
157
+ return keepNotes
158
+ ? this.accepted.filter(action => action.tool === 'write_note' && action.onEscalation)
159
+ : [];
160
+ }
161
+ /**
162
+ * The turn's record before anything is performed: every accepted action
163
+ * with `status`, then those dropped over the cap. On an escalated turn
164
+ * (`discarded`), an action in `kept` is recorded `staged` instead, since it
165
+ * will still be performed.
166
+ */
167
+ records(status, kept = []) {
168
+ // A flow already sent keeps its outcome: an escalation cannot recall it.
169
+ return [
170
+ ...this.order.flatMap(entry => 'sent' in entry
171
+ ? this.sentNow[entry.sent].records
172
+ : [recordOf(entry, kept.includes(entry) ? 'staged' : status)]),
173
+ ...this.overCap.map(action => recordOf(action, 'dropped_over_cap')),
174
+ ];
175
+ }
176
+ }
@@ -0,0 +1,4 @@
1
+ import { type ToolSet } from 'ai';
2
+ import type { ToolTurn } from './turn.ts';
3
+ /** `add_tag` and `remove_tag`: the tenant's tags (specs/012). */
4
+ export declare function addTagTools(turn: ToolTurn, tools: ToolSet): void;
@@ -0,0 +1,26 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { catalogOf, idsOf, STAGED } from "./copy.js";
4
+ import { NOT_PROSPECT } from "./turn.js";
5
+ /** `add_tag` and `remove_tag`: the tenant's tags (specs/012). */
6
+ export function addTagTools(turn, tools) {
7
+ const { config, stage } = turn;
8
+ if (config.tags.length > 0) {
9
+ const tags = new Map(config.tags.map(entry => [entry.id, entry]));
10
+ const input = z.object({ tag: z.enum(idsOf(config.tags)) });
11
+ tools.add_tag = tool({
12
+ description: `Add one of these tags to the contact.\n${catalogOf(config.tags)}\n${STAGED}`,
13
+ inputSchema: input,
14
+ execute: ({ tag }) => turn.closed()
15
+ ? NOT_PROSPECT
16
+ : { staged: stage.stage({ tool: 'add_tag', id: tag, tag: tags.get(tag).tag }) },
17
+ });
18
+ tools.remove_tag = tool({
19
+ description: `Remove one of these tags from the contact.\n${catalogOf(config.tags)}\n${STAGED}`,
20
+ inputSchema: input,
21
+ execute: ({ tag }) => turn.closed()
22
+ ? NOT_PROSPECT
23
+ : { staged: stage.stage({ tool: 'remove_tag', id: tag, tag: tags.get(tag).tag }) },
24
+ });
25
+ }
26
+ }
@@ -0,0 +1,93 @@
1
+ import type { Tools } from '../../contracts/config.ts';
2
+ import type { Plugins } from '../../plugins/plugins.ts';
3
+ import type { PluginReads } from '../../plugins/reads.ts';
4
+ import type { FlowSends } from '../flows.ts';
5
+ import type { ContactActions } from './contact-actions.ts';
6
+ import { funnelField, intentField, offeringField } from './lookups.ts';
7
+ import type { Intent } from './lookups.ts';
8
+ import type { ActionStage } from './stage.ts';
9
+ export interface BuildToolsOptions {
10
+ /**
11
+ * A nudge turn is offered no `schedule_nudge`, so a nudge never schedules
12
+ * another (specs/025).
13
+ */
14
+ nudgeTurn?: boolean;
15
+ /** Sends a flow when it is called instead of staging it (specs/029). */
16
+ flows?: FlowSends | undefined;
17
+ /**
18
+ * Runs before the turn's first flow goes out: on the prospect turn it
19
+ * sends the opening, so no content reaches the contact ahead of it
20
+ * (specs/032 § The opening flow is the server's, not the model's).
21
+ */
22
+ beforeFlow?: (() => Promise<void>) | undefined;
23
+ /** The tenant's plugin tools, staged as the built-in ones are (specs/036). */
24
+ plugins?: Plugins | undefined;
25
+ /**
26
+ * The turn's plugin reads. Absent, no read tool is offered: as for
27
+ * `get_contact`, only a turn that reads history may read (specs/039).
28
+ */
29
+ pluginReads?: PluginReads | undefined;
30
+ }
31
+ /** What a sales tool returns while the contact is not a prospect (specs/034). */
32
+ export declare const NOT_PROSPECT: {
33
+ readonly staged: false;
34
+ readonly reason: "not_prospect";
35
+ };
36
+ /** Whether a flow serves the turn's offering: its own, or every one (specs/028). */
37
+ export declare const fitsOffering: (flow: {
38
+ offering?: string | undefined;
39
+ }, current: string | undefined) => boolean;
40
+ /**
41
+ * What every tool of one turn reads as it runs: the gate on intent, the
42
+ * turn's offering and the floor under the funnel stage. Read from the stage
43
+ * each time, so a write staged by one tool is seen by the next, in the same
44
+ * step included.
45
+ */
46
+ export declare class ToolTurn {
47
+ readonly config: Tools;
48
+ readonly stage: ActionStage;
49
+ readonly contact: ContactActions;
50
+ readonly options: BuildToolsOptions;
51
+ readonly funnel: ReturnType<typeof funnelField>;
52
+ readonly intent: ReturnType<typeof intentField>;
53
+ readonly offering: ReturnType<typeof offeringField>;
54
+ /**
55
+ * A flow already sent to this contact is not offered again, so a repeat is
56
+ * unrepresentable rather than discouraged (specs/023 § Every content flow
57
+ * is a leaf, sent once). An offering change does not bring one back
58
+ * (specs/028). The opening flow is the server's to send, never the model's
59
+ * (specs/032).
60
+ */
61
+ readonly unsent: Tools['flows'];
62
+ /**
63
+ * A payment link marked here before any await on its way out, so a call
64
+ * beside it in the same step already sees it (specs/029, specs/032).
65
+ */
66
+ linkStarting: number;
67
+ constructor(config: Tools, stage: ActionStage, contact: ContactActions, options: BuildToolsOptions);
68
+ /**
69
+ * The contact's intent as the server knows it, a write staged earlier this
70
+ * turn included, so a first message can be recorded as a prospect and sold
71
+ * to in one turn (specs/034 § Until intent is prospect, the sale's tools
72
+ * refuse).
73
+ */
74
+ turnIntent(): Intent | undefined;
75
+ /**
76
+ * Refused rather than removed from the enums: removing them would rule out
77
+ * the same-turn unlock. A refusal makes no request and takes no slot of the
78
+ * cap (specs/034).
79
+ */
80
+ closed(): boolean;
81
+ /**
82
+ * The turn's offering: one staged on the offering field earlier in this
83
+ * turn, or else the contact's (specs/028 § A flow belongs to one offering or
84
+ * to all).
85
+ */
86
+ turnOffering(): string | undefined;
87
+ /**
88
+ * The earliest stage a write may name: the last one performed for this
89
+ * contact, or a later one already staged this turn. Parallel calls in one
90
+ * step must not walk a lead back any more than a later turn may.
91
+ */
92
+ stageFloor(): number;
93
+ }