@flow-engineer/flow 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +594 -2
  3. package/coding-agents/skill.md +321 -0
  4. package/coding-agents/test-skill.md +223 -0
  5. package/coding-agents/tools-skill.md +89 -0
  6. package/dist/flow-messaging/src/client.d.ts +34 -0
  7. package/dist/flow-messaging/src/client.js +130 -0
  8. package/dist/flow-messaging/src/index.d.ts +37 -0
  9. package/dist/flow-messaging/src/index.js +409 -0
  10. package/dist/simulators/src/index.d.ts +2 -0
  11. package/dist/simulators/src/index.js +2 -0
  12. package/dist/simulators/src/page.html +1245 -0
  13. package/dist/simulators/src/person.html +100 -0
  14. package/dist/simulators/src/platforms.d.ts +29 -0
  15. package/dist/simulators/src/platforms.js +72 -0
  16. package/dist/simulators/src/server.d.ts +201 -0
  17. package/dist/simulators/src/server.js +759 -0
  18. package/dist/src/agent.d.ts +504 -0
  19. package/dist/src/agent.js +818 -0
  20. package/dist/src/channels.d.ts +178 -0
  21. package/dist/src/channels.js +243 -0
  22. package/dist/src/conversation.d.ts +361 -0
  23. package/dist/src/conversation.js +1487 -0
  24. package/dist/src/db.d.ts +18 -0
  25. package/dist/src/db.js +57 -0
  26. package/dist/src/events.d.ts +17 -0
  27. package/dist/src/events.js +20 -0
  28. package/dist/src/http.d.ts +45 -0
  29. package/dist/src/http.js +269 -0
  30. package/dist/src/index.d.ts +14 -0
  31. package/dist/src/index.js +11 -0
  32. package/dist/src/model.d.ts +21 -0
  33. package/dist/src/model.js +96 -0
  34. package/dist/src/names.d.ts +17 -0
  35. package/dist/src/names.js +38 -0
  36. package/dist/src/nudges.d.ts +23 -0
  37. package/dist/src/nudges.js +117 -0
  38. package/dist/src/postgres.d.ts +20 -0
  39. package/dist/src/postgres.js +62 -0
  40. package/dist/src/schedules.d.ts +60 -0
  41. package/dist/src/schedules.js +249 -0
  42. package/dist/src/skills.d.ts +12 -0
  43. package/dist/src/skills.js +69 -0
  44. package/dist/src/store.d.ts +214 -0
  45. package/dist/src/store.js +448 -0
  46. package/dist/src/talk.d.ts +57 -0
  47. package/dist/src/talk.js +166 -0
  48. package/dist/src/time.d.ts +71 -0
  49. package/dist/src/time.js +170 -0
  50. package/dist/src/tools.d.ts +43 -0
  51. package/dist/src/tools.js +26 -0
  52. package/dist/src/traces.d.ts +31 -0
  53. package/dist/src/traces.js +303 -0
  54. package/dist/src/types.d.ts +260 -0
  55. package/dist/src/types.js +2 -0
  56. package/dist/testing/src/check-endpoint.d.ts +31 -0
  57. package/dist/testing/src/check-endpoint.js +158 -0
  58. package/dist/testing/src/chinwag.d.ts +2 -0
  59. package/dist/testing/src/chinwag.js +6 -0
  60. package/dist/testing/src/cli.d.ts +2 -0
  61. package/dist/testing/src/cli.js +563 -0
  62. package/dist/testing/src/client.d.ts +115 -0
  63. package/dist/testing/src/client.js +955 -0
  64. package/dist/testing/src/http-agent.d.ts +47 -0
  65. package/dist/testing/src/http-agent.js +155 -0
  66. package/dist/testing/src/index.d.ts +3 -0
  67. package/dist/testing/src/index.js +2 -0
  68. package/dist/testing/src/new.d.ts +9 -0
  69. package/dist/testing/src/new.js +122 -0
  70. package/dist/testing/src/print-run.d.ts +3 -0
  71. package/dist/testing/src/print-run.js +22 -0
  72. package/dist/testing/src/protocol.d.ts +159 -0
  73. package/dist/testing/src/protocol.js +28 -0
  74. package/dist/testing/src/report.d.ts +74 -0
  75. package/dist/testing/src/report.js +90 -0
  76. package/dist/testing/src/show.d.ts +36 -0
  77. package/dist/testing/src/show.js +280 -0
  78. package/dist/testing/src/suite.d.ts +87 -0
  79. package/dist/testing/src/suite.js +198 -0
  80. package/examples/cafe/agent/AGENTS.md +10 -0
  81. package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
  82. package/examples/cafe/src/agent.ts +39 -0
  83. package/examples/cafe/src/main.ts +16 -0
  84. package/examples/cafe/src/tools.ts +62 -0
  85. package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
  86. package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
  87. package/examples/cafe/tests/tools/get_menu.yaml +14 -0
  88. package/examples/cafe/tests/tools/place_order.yaml +29 -0
  89. package/examples/http-agent/server.mjs +32 -0
  90. package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
  91. package/flow-messaging/README.md +152 -0
  92. package/package.json +65 -4
  93. package/spec/agent-endpoint.openapi.yaml +148 -0
  94. package/spec/nudges.openapi.yaml +167 -0
  95. package/spec/request.schema.json +121 -0
  96. package/spec/response.schema.json +284 -0
@@ -0,0 +1,57 @@
1
+ import { z } from "zod";
2
+ import type { ChannelInfo, ToolSpec } from "./types.ts";
3
+ export declare const REACT = "react";
4
+ export declare const UPDATE = "share_updates";
5
+ export declare const reactInput: z.ZodObject<{
6
+ message_id: z.ZodString;
7
+ emoji: z.ZodString;
8
+ }, z.core.$strip>;
9
+ export declare const updateInput: z.ZodObject<{
10
+ text: z.ZodString;
11
+ }, z.core.$strip>;
12
+ /** A channel the person has written on, by the name the agent's channels use. */
13
+ export interface ChannelView {
14
+ name: string;
15
+ info: ChannelInfo;
16
+ /** The channel's own extra fields on its send tool. */
17
+ params?: Record<string, z.ZodType>;
18
+ /** The agent author's own guidance for this channel. */
19
+ instructions?: string;
20
+ }
21
+ /** The send tool for one channel. */
22
+ export declare function sendToolName(channel: string): string;
23
+ /** A send tool's input on this channel: only the features it has, with its limits, and its own params. */
24
+ export declare function sendInput(channel: ChannelInfo, several?: boolean, params?: Record<string, z.ZodType>): z.ZodObject<{
25
+ [x: string]: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
26
+ }, z.core.$strip>;
27
+ export type SendInput = {
28
+ text: string;
29
+ reply_to?: string;
30
+ buttons?: string[];
31
+ list?: {
32
+ button: string;
33
+ rows: Array<{
34
+ id: string;
35
+ title: string;
36
+ description?: string;
37
+ }>;
38
+ };
39
+ link?: {
40
+ label: string;
41
+ url: string;
42
+ };
43
+ image?: {
44
+ url: string;
45
+ };
46
+ confirm?: boolean;
47
+ };
48
+ /** Why a send that fits its schema still cannot go out as written (WhatsApp's rules), or null. */
49
+ export declare function sendRefusal(channel: ChannelInfo, input: SendInput): string | null;
50
+ export interface TalkSettings {
51
+ /** Live status on channels that can show it (web and app UIs). Default on. */
52
+ shareUpdates: boolean;
53
+ /** The owner's own guidance for when and what to share; replaces Flow Framework's default line. */
54
+ shareUpdatesInstructions?: string;
55
+ }
56
+ export declare function talkSpecs(channels: ChannelView[], settings: TalkSettings): ToolSpec[];
57
+ export declare function talkInstructions(channels: ChannelView[], settings: TalkSettings): string;
@@ -0,0 +1,166 @@
1
+ // How the agent talks to the person. Everything the person sees goes through these tools; text the
2
+ // model writes outside them is never shown. They are run by the conversation itself, not as
3
+ // ordinary tools: a sent message gets an empty result, and a failed one comes back to the agent as
4
+ // a message of its own. What a send tool can carry (replies, buttons, lists, length) follows the
5
+ // channel, so the agent is only offered what the platform really has.
6
+ //
7
+ // Each channel the person has written on gets its own send tool, named for it (send_sms,
8
+ // send_whatsapp), shaped for that channel, so the agent always knows where it is talking and can
9
+ // never write somewhere they have not. A second channel adds a tool; the first one's never changes.
10
+ import { z } from "zod";
11
+ import { toSpec, tool } from "./tools.js";
12
+ export const REACT = "react";
13
+ export const UPDATE = "share_updates";
14
+ export const reactInput = z.object({
15
+ message_id: z.string().describe("The id of the person's message, such as m3"),
16
+ emoji: z.string().min(1).max(16),
17
+ });
18
+ export const updateInput = z.object({ text: z.string().min(1).max(120) });
19
+ /** The send tool for one channel. */
20
+ export function sendToolName(channel) {
21
+ return `send_${channel.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
22
+ }
23
+ const https = z.url({ protocol: /^https$/ });
24
+ /** A send tool's input on this channel: only the features it has, with its limits, and its own params. */
25
+ export function sendInput(channel, several = false, params = {}) {
26
+ const shape = {
27
+ text: z.string().min(1).max(channel.maxLength ?? 4_000),
28
+ };
29
+ if (several) {
30
+ shape.confirm = z.boolean().optional().describe("Only when Chinwag asks: you are sure about this channel though they have not written on it lately");
31
+ }
32
+ if (channel.replies) {
33
+ shape.reply_to = z.string().optional().describe("Quote one of the person's messages, by id (such as m3)");
34
+ }
35
+ if (channel.buttons) {
36
+ shape.buttons = z
37
+ .array(z.string().min(1).max(channel.buttons.labelMax))
38
+ .max(channel.buttons.max)
39
+ .optional()
40
+ .describe(`Up to ${channel.buttons.max} reply buttons, ${channel.buttons.labelMax} characters each; a tap comes back as their message`);
41
+ }
42
+ if (channel.lists) {
43
+ shape.list = z
44
+ .object({
45
+ button: z.string().min(1).max(channel.lists.buttonMax).describe("The label that opens the list"),
46
+ rows: z
47
+ .array(z.object({
48
+ id: z.string().min(1).max(64),
49
+ title: z.string().min(1).max(channel.lists.titleMax),
50
+ description: z.string().max(72).optional(),
51
+ }))
52
+ .min(1)
53
+ .max(channel.lists.maxRows),
54
+ })
55
+ .optional()
56
+ .describe(`A list of up to ${channel.lists.maxRows} options the person picks one of; their pick comes back as their message`);
57
+ }
58
+ // Every channel takes a link: where there is no link button, Flow Framework puts its URL after the text.
59
+ shape.link = z
60
+ .object({ label: z.string().min(1).max(channel.linkButton?.labelMax ?? 20), url: https })
61
+ .optional()
62
+ .describe(channel.linkButton ? "A button that opens a page, by a URL you were given; not with buttons or a list" : "A page, by a URL you were given: it goes on a line after your text; not with buttons or a list");
63
+ if (channel.images) {
64
+ shape.image = z
65
+ .object({ url: https })
66
+ .optional()
67
+ .describe(`A photo, by a URL you were given; your text is its caption, up to ${channel.images.captionMax} characters${channel.lists ? "; not with a list" : ""}`);
68
+ }
69
+ for (const [name, schema] of Object.entries(params))
70
+ shape[name] = schema.optional();
71
+ return z.object(shape);
72
+ }
73
+ /** Why a send that fits its schema still cannot go out as written (WhatsApp's rules), or null. */
74
+ export function sendRefusal(channel, input) {
75
+ if (input.link && (input.buttons?.length || input.list))
76
+ return "Not sent: a message with a link cannot also have buttons or a list. Send them in separate messages.";
77
+ if (input.image && input.list)
78
+ return "Not sent: a message with an image cannot also have a list. Send them in separate messages.";
79
+ if (input.image && channel.images && input.text.length > channel.images.captionMax) {
80
+ return `Not sent: with an image, your text is its caption, at most ${channel.images.captionMax} characters. Shorten it, or send the rest in another message.`;
81
+ }
82
+ const max = channel.maxLength ?? 4_000;
83
+ if (input.link && !channel.linkButton && input.text.length + 1 + input.link.url.length > max) {
84
+ return `Not sent: the link's URL goes after your text here, and together they are over ${max} characters. Shorten the text.`;
85
+ }
86
+ return null;
87
+ }
88
+ const reactTool = tool({
89
+ name: REACT,
90
+ description: "React to one of the person's messages with an emoji.",
91
+ input: reactInput,
92
+ run: () => "",
93
+ });
94
+ const updateTool = tool({
95
+ name: UPDATE,
96
+ description: "Show the person a short line of what you are doing, in their loading indicator. Each update replaces the last.",
97
+ input: updateInput,
98
+ run: () => "",
99
+ });
100
+ export function talkSpecs(channels, settings) {
101
+ const several = channels.length > 1;
102
+ const specs = channels.map((c) => toSpec(tool({
103
+ name: sendToolName(c.name),
104
+ description: `Send a message to the person on ${c.name}. Call it several times to send several messages; they go out in the order you call them.`,
105
+ input: sendInput(c.info, several, c.params),
106
+ run: () => "",
107
+ })));
108
+ if (channels.some((c) => c.info.reactions))
109
+ specs.push(toSpec(reactTool));
110
+ if (channels.some((c) => c.info.status) && settings.shareUpdates)
111
+ specs.push(toSpec(updateTool));
112
+ return specs;
113
+ }
114
+ const FORMATTING = {
115
+ plain: "Write plain text, not Markdown or other formatting: this channel does not support it.",
116
+ whatsapp: "Format with WhatsApp's marks if useful: *bold*, _italic_, ~strikethrough~; no Markdown headings or [links](url).",
117
+ markdown: "Markdown works here.",
118
+ };
119
+ export function talkInstructions(channels, settings) {
120
+ if (channels.length > 1)
121
+ return severalChannels(channels, settings);
122
+ const channel = channels[0].info;
123
+ const lines = [
124
+ "## Talking to the person",
125
+ `- IMPORTANT: They see only what you send with ${sendToolName(channels[0].name)}` +
126
+ (channel.reactions ? ", react" : "") +
127
+ (channel.status && settings.shareUpdates ? " or share_updates" : "") +
128
+ `. Anything else you write (your own text, a tool's result) never reaches them: to tell them anything, call ${sendToolName(channels[0].name)}.`,
129
+ "- Lines starting [Chinwag] are INTERNAL MESSAGES from the system running you, not from them: they never see them. Never mention them to the person, nor any text of yours that did not reach them.",
130
+ "- After they write, the first thing you do must reach them: your answer, or, if you need tools first, an acknowledgement in the same response as those tool calls" +
131
+ (channel.reactions ? " (react to their message, or a short line)." : ": a short line."),
132
+ "- Call every tool you can in one response; wait for a result only when another call needs it.",
133
+ "- IMPORTANT: A message you send with tool calls reaches them before those tools run. So say what you are about to do (\"I'm booking that now\"), never that it is done (\"I've booked it\", \"done\"): the outcome comes once, after the result arrives. Never send a placeholder.",
134
+ "- Their messages start with an id in brackets, like [m3].",
135
+ `- ${FORMATTING[channel.formatting ?? "plain"]}`,
136
+ ];
137
+ if (channel.buttons || channel.lists) {
138
+ lines.push("- When they choose between a few options, offer them as " + (channel.buttons ? "buttons" : "a list") + (channel.buttons && channel.lists ? " (or a list when there are more than " + channel.buttons.max + ")" : "") + " so they can tap.");
139
+ }
140
+ if (channel.status && settings.shareUpdates) {
141
+ lines.push(`- ${settings.shareUpdatesInstructions ?? "While several tools run one after another, keep them posted with share_updates: a few words on what you are doing now."}`);
142
+ }
143
+ if (channels[0].instructions)
144
+ lines.push(`- ${channels[0].instructions}`);
145
+ return lines.join("\n");
146
+ }
147
+ function severalChannels(channels, settings) {
148
+ const names = channels.map((c) => c.name);
149
+ const tools = channels.map((c) => sendToolName(c.name));
150
+ const reactions = channels.some((c) => c.info.reactions);
151
+ const lines = [
152
+ "## Talking to the person",
153
+ `- They write to you on more than one channel: ${names.join(", ")}. One conversation covers all of them. Their messages start with the channel and an id, like [${names[0]} · m3].`,
154
+ `- IMPORTANT: They see only what you send with ${tools.join(", ")}${reactions ? ", react" : ""}. Anything else you write (your own text, a tool's result) never reaches them: to tell them anything, call a send tool.`,
155
+ "- Lines starting [Chinwag] are INTERNAL MESSAGES from the system running you, not from them: they never see them. Never mention them to the person, nor any text of yours that did not reach them.",
156
+ "- Answer each message on the channel it came on, with that channel's send tool. Messages on different channels can be about different things: answer each one.",
157
+ "- After they write, the first thing you do must reach them on every channel they are waiting on: your answer, or, if you need tools first, an acknowledgement there in the same response as those tool calls.",
158
+ "- Call every tool you can in one response; wait for a result only when another call needs it.",
159
+ "- IMPORTANT: A message you send with tool calls reaches them before those tools run. So say what you are about to do (\"I'm booking that now\"), never that it is done (\"I've booked it\", \"done\"): the outcome comes once, after the result arrives. Never send a placeholder.",
160
+ ...channels.map((c) => `- On ${c.name}: ${FORMATTING[c.info.formatting ?? "plain"]}${c.info.buttons ? ` Up to ${c.info.buttons.max} buttons.` : ""}${c.instructions ? ` ${c.instructions}` : ""}`),
161
+ ];
162
+ if (channels.some((c) => c.info.status) && settings.shareUpdates) {
163
+ lines.push(`- ${settings.shareUpdatesInstructions ?? "While several tools run one after another, keep them posted with share_updates: a few words on what you are doing now."}`);
164
+ }
165
+ return lines.join("\n");
166
+ }
@@ -0,0 +1,71 @@
1
+ export type Rule = {
2
+ repeat: "once";
3
+ at: number;
4
+ } | {
5
+ repeat: "daily";
6
+ time: string;
7
+ }
8
+ /** days: 0 is Sunday. */
9
+ | {
10
+ repeat: "weekly";
11
+ time: string;
12
+ days: number[];
13
+ } | {
14
+ repeat: "monthly";
15
+ time: string;
16
+ day: number | "last";
17
+ }
18
+ /** Every `every` hours from `from` to `to` (local, inclusive), on `days` if given. */
19
+ | {
20
+ repeat: "hourly";
21
+ every: number;
22
+ from?: string;
23
+ to?: string;
24
+ days?: number[];
25
+ };
26
+ export interface LocalParts {
27
+ year: number;
28
+ month: number;
29
+ day: number;
30
+ hour: number;
31
+ minute: number;
32
+ /** 0 is Sunday. */
33
+ weekday: number;
34
+ }
35
+ export declare function validZone(zone: string): boolean;
36
+ /** UTC given as a zone says nothing about where the person is, so it is never taken as theirs. */
37
+ export declare function isBareUtc(zone: string): boolean;
38
+ export declare function parts(zone: string, ms: number): LocalParts;
39
+ /** The UTC instant of a local wall-clock time in `zone`. */
40
+ export declare function localToUtc(zone: string, year: number, month: number, day: number, hour: number, minute: number): number;
41
+ /** "HH:MM" as hours and minutes, or null when it is not a time. */
42
+ export declare function clock(time: string): {
43
+ hour: number;
44
+ minute: number;
45
+ } | null;
46
+ /** "YYYY-MM-DD" as numbers, or null. */
47
+ export declare function date(text: string): {
48
+ year: number;
49
+ month: number;
50
+ day: number;
51
+ } | null;
52
+ export declare function localDate(zone: string, ms: number): string;
53
+ /** The first instant after the local day `text` ends. */
54
+ export declare function endOfLocalDay(zone: string, text: string): number | null;
55
+ /** The next time `rule` comes due strictly after `after`, or null when it never does again. */
56
+ export declare function nextRun(rule: Rule, zone: string, after: number): number | null;
57
+ /** "Kolkata time", "New York time"; "UTC" for UTC itself. */
58
+ export declare function zoneLabel(zone: string): string;
59
+ /** "Mon 12 Oct 2026 19:00 (New York time)". */
60
+ export declare function formatLocal(zone: string, ms: number): string;
61
+ /** What a rule does, in words, with its zone named. */
62
+ export declare function describe(rule: Rule, zone: string): string;
63
+ export declare const DAY_INDEX: Record<string, number>;
64
+ /**
65
+ * When quiet hours `from`–`to` ("22:00"–"07:00", in `zone`, may wrap midnight) end, if `now` is
66
+ * inside them; null when it is not, or when from equals to.
67
+ */
68
+ export declare function quietUntil(now: number, zone: string, hours: {
69
+ from: string;
70
+ to: string;
71
+ }): number | null;
@@ -0,0 +1,170 @@
1
+ // Local time for scheduled messages, with the platform's own time zone data (Intl), no library.
2
+ // A person is only ever told local time, with the zone named ("07:00 (Kolkata time)"), never UTC.
3
+ // Times are wall-clock times in the zone, so 07:00 stays 07:00 across daylight saving; a time the
4
+ // clocks skip runs the same distance after the jump (02:30 becomes 03:30), and a time that happens
5
+ // twice runs the first time.
6
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
7
+ const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
8
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
9
+ const DAY_MS = 86_400_000;
10
+ const formats = new Map();
11
+ export function validZone(zone) {
12
+ try {
13
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /** UTC given as a zone says nothing about where the person is, so it is never taken as theirs. */
21
+ export function isBareUtc(zone) {
22
+ return /^(etc\/)?(utc|gmt|uct|zulu|universal|greenwich)([+-]?0)?$/i.test(zone.trim());
23
+ }
24
+ export function parts(zone, ms) {
25
+ let format = formats.get(zone);
26
+ if (!format) {
27
+ format = new Intl.DateTimeFormat("en-US", { timeZone: zone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", weekday: "short" });
28
+ formats.set(zone, format);
29
+ }
30
+ const p = Object.fromEntries(format.formatToParts(ms).map((x) => [x.type, x.value]));
31
+ return { year: +p.year, month: +p.month, day: +p.day, hour: +p.hour % 24, minute: +p.minute, weekday: WEEKDAYS.indexOf(p.weekday) };
32
+ }
33
+ /** The UTC instant of a local wall-clock time in `zone`. */
34
+ export function localToUtc(zone, year, month, day, hour, minute) {
35
+ const guess = Date.UTC(year, month - 1, day, hour, minute);
36
+ const offset = (ms) => {
37
+ const p = parts(zone, ms);
38
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute) - Math.floor(ms / 60_000) * 60_000;
39
+ };
40
+ const candidates = [...new Set([guess - offset(guess - DAY_MS), guess - offset(guess + DAY_MS)])];
41
+ const matching = candidates.filter((c) => {
42
+ const p = parts(zone, c);
43
+ return p.year === year && p.month === month && p.day === day && p.hour === hour && p.minute === minute;
44
+ });
45
+ return matching.length ? Math.min(...matching) : Math.max(...candidates);
46
+ }
47
+ /** "HH:MM" as hours and minutes, or null when it is not a time. */
48
+ export function clock(time) {
49
+ const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(time.trim());
50
+ return m ? { hour: +m[1], minute: +m[2] } : null;
51
+ }
52
+ /** "YYYY-MM-DD" as numbers, or null. */
53
+ export function date(text) {
54
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text.trim());
55
+ if (!m)
56
+ return null;
57
+ const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
58
+ return d.getUTCMonth() === +m[2] - 1 ? { year: +m[1], month: +m[2], day: +m[3] } : null;
59
+ }
60
+ export function localDate(zone, ms) {
61
+ const p = parts(zone, ms);
62
+ return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`;
63
+ }
64
+ /** The first instant after the local day `text` ends. */
65
+ export function endOfLocalDay(zone, text) {
66
+ const d = date(text);
67
+ if (!d)
68
+ return null;
69
+ const next = new Date(Date.UTC(d.year, d.month - 1, d.day + 1));
70
+ return localToUtc(zone, next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate(), 0, 0);
71
+ }
72
+ /** The next time `rule` comes due strictly after `after`, or null when it never does again. */
73
+ export function nextRun(rule, zone, after) {
74
+ if (rule.repeat === "once")
75
+ return rule.at > after ? rule.at : null;
76
+ const start = parts(zone, after);
77
+ for (let k = 0; k <= 400; k++) {
78
+ const d = new Date(Date.UTC(start.year, start.month - 1, start.day + k));
79
+ const year = d.getUTCFullYear();
80
+ const month = d.getUTCMonth() + 1;
81
+ const day = d.getUTCDate();
82
+ const weekday = d.getUTCDay();
83
+ for (const t of timesOn(rule, year, month, day, weekday)) {
84
+ const at = localToUtc(zone, year, month, day, t.hour, t.minute);
85
+ if (at > after)
86
+ return at;
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ /** The local times `rule` runs at on one day, earliest first. */
92
+ function timesOn(rule, year, month, day, weekday) {
93
+ if (rule.repeat === "hourly") {
94
+ if (rule.days?.length && !rule.days.includes(weekday))
95
+ return [];
96
+ const from = clock(rule.from ?? "00:00");
97
+ const to = clock(rule.to ?? "23:59");
98
+ const out = [];
99
+ for (let minutes = from.hour * 60 + from.minute; minutes <= to.hour * 60 + to.minute; minutes += rule.every * 60) {
100
+ out.push({ hour: Math.floor(minutes / 60), minute: minutes % 60 });
101
+ }
102
+ return out;
103
+ }
104
+ const t = clock(rule.time);
105
+ if (rule.repeat === "weekly" && !rule.days.includes(weekday))
106
+ return [];
107
+ if (rule.repeat === "monthly") {
108
+ const last = new Date(Date.UTC(year, month, 0)).getUTCDate();
109
+ const target = rule.day === "last" ? last : Math.min(rule.day, last);
110
+ if (day !== target)
111
+ return [];
112
+ }
113
+ return [t];
114
+ }
115
+ /** "Kolkata time", "New York time"; "UTC" for UTC itself. */
116
+ export function zoneLabel(zone) {
117
+ if (isBareUtc(zone))
118
+ return "UTC";
119
+ const city = zone.split("/").at(-1).replaceAll("_", " ");
120
+ return `${city} time`;
121
+ }
122
+ /** "Mon 12 Oct 2026 19:00 (New York time)". */
123
+ export function formatLocal(zone, ms) {
124
+ const p = parts(zone, ms);
125
+ const hhmm = `${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`;
126
+ return `${WEEKDAYS[p.weekday]} ${p.day} ${MONTHS[p.month - 1]} ${p.year} ${hhmm} (${zoneLabel(zone)})`;
127
+ }
128
+ /** What a rule does, in words, with its zone named. */
129
+ export function describe(rule, zone) {
130
+ const label = zoneLabel(zone);
131
+ if (rule.repeat === "once")
132
+ return `once, ${formatLocal(zone, rule.at)}`;
133
+ if (rule.repeat === "daily")
134
+ return `every day at ${rule.time} (${label})`;
135
+ if (rule.repeat === "weekly") {
136
+ const days = rule.days.length === 5 && [1, 2, 3, 4, 5].every((d) => rule.days.includes(d)) ? "weekday" : list(rule.days.map((d) => DAY_NAMES[d]));
137
+ return `every ${days} at ${rule.time} (${label})`;
138
+ }
139
+ if (rule.repeat === "monthly")
140
+ return `${rule.day === "last" ? "on the last day" : `on day ${rule.day}`} of every month at ${rule.time} (${label})`;
141
+ const window = rule.from || rule.to ? ` from ${rule.from ?? "00:00"} to ${rule.to ?? "23:59"}` : "";
142
+ const days = rule.days?.length ? ` on ${list(rule.days.map((d) => DAY_NAMES[d]))}` : "";
143
+ return `every ${rule.every === 1 ? "hour" : `${rule.every} hours`}${window}${days} (${label})`;
144
+ }
145
+ function list(items) {
146
+ return items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
147
+ }
148
+ export const DAY_INDEX = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
149
+ /**
150
+ * When quiet hours `from`–`to` ("22:00"–"07:00", in `zone`, may wrap midnight) end, if `now` is
151
+ * inside them; null when it is not, or when from equals to.
152
+ */
153
+ export function quietUntil(now, zone, hours) {
154
+ const from = clock(hours.from);
155
+ const to = clock(hours.to);
156
+ if (!from || !to)
157
+ return null;
158
+ const p = parts(zone, now);
159
+ const minute = p.hour * 60 + p.minute;
160
+ const start = from.hour * 60 + from.minute;
161
+ const end = to.hour * 60 + to.minute;
162
+ if (start === end)
163
+ return null;
164
+ const inside = start < end ? minute >= start && minute < end : minute >= start || minute < end;
165
+ if (!inside)
166
+ return null;
167
+ // The end is today, or tomorrow when it wraps past midnight and we are before it.
168
+ const day = new Date(Date.UTC(p.year, p.month - 1, p.day + (start > end && minute >= start ? 1 : 0)));
169
+ return localToUtc(zone, day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), to.hour, to.minute);
170
+ }
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ import type { ToolSpec } from "./types.ts";
3
+ export interface ToolContext {
4
+ conversationId: string;
5
+ /**
6
+ * Who the person is, as Flow Framework verified it, in one of three forms: `key:<resolve's value>` when
7
+ * the owner's `resolve` knew them, else `phone:<E.164>` (`phone:+15551234567`) for a number a
8
+ * phone channel verified, else `<channel>:<address>`. It is the stable id to key a person's data
9
+ * by, never an id the model passed. To look them up in your own backend, use `key`.
10
+ */
11
+ person: string;
12
+ /** Exactly what the owner's `resolve` returned for this person (no `key:`), or null when they are known only by phone or channel and address. */
13
+ key: string | null;
14
+ /** The channel of their latest message. */
15
+ channel: string;
16
+ /** The model's id for this call, as the history keeps it. */
17
+ callId: string;
18
+ /**
19
+ * The same for the same tool with the same input in the same turn, also when that turn runs
20
+ * again after a restart (a cut-off call is not rerun, but the agent may call it again): pass it
21
+ * to your backend so a booking or payment happens once.
22
+ */
23
+ idempotencyKey: string;
24
+ /** Aborted when the conversation closes (the person was forgotten): stop and drop the work. */
25
+ signal: AbortSignal;
26
+ }
27
+ export interface Tool<Input = any> {
28
+ name: string;
29
+ description: string;
30
+ input: z.ZodType<Input>;
31
+ run(input: Input, context: ToolContext): Promise<unknown> | unknown;
32
+ /** The longest the reply waits for this tool before it moves to the background (default: the agent's, 60 s). */
33
+ deadlineMs?: number;
34
+ }
35
+ /**
36
+ * Marks a tool the test cloud plays in place of a built-in (get_user_profile): it is offered only
37
+ * to an agent that has that built-in, and then instead of it, so a test never runs the real one.
38
+ */
39
+ export declare const STAND_IN: unique symbol;
40
+ export declare function tool<Input>(definition: Tool<Input>): Tool<Input>;
41
+ export declare function toSpec(t: Tool): ToolSpec;
42
+ /** A tool's output as the model reads it. */
43
+ export declare function render(output: unknown): string;
@@ -0,0 +1,26 @@
1
+ // Tools are plain functions with a zod input. The runtime decides at call time whether one runs
2
+ // in the reply or carries on in the background; tool authors mark nothing.
3
+ import { z } from "zod";
4
+ /**
5
+ * Marks a tool the test cloud plays in place of a built-in (get_user_profile): it is offered only
6
+ * to an agent that has that built-in, and then instead of it, so a test never runs the real one.
7
+ */
8
+ export const STAND_IN = Symbol("chinwag.standIn");
9
+ export function tool(definition) {
10
+ return definition;
11
+ }
12
+ export function toSpec(t) {
13
+ const { $schema: _ignored, ...parameters } = z.toJSONSchema(t.input);
14
+ return { name: t.name, description: t.description, parameters };
15
+ }
16
+ /** A tool's output as the model reads it. */
17
+ export function render(output) {
18
+ if (typeof output === "string")
19
+ return output;
20
+ try {
21
+ return JSON.stringify(output ?? null);
22
+ }
23
+ catch {
24
+ return String(output);
25
+ }
26
+ }
@@ -0,0 +1,31 @@
1
+ import type { EventSink } from "./events.ts";
2
+ import { setting } from "./names.ts";
3
+ export interface TracesOptions {
4
+ /** The traces endpoint's base URL; traces go to `<url>/v1/traces`. */
5
+ url: string;
6
+ /** The agent's key for the traces endpoint. */
7
+ key: string;
8
+ /** Send what was written (messages, tool inputs and results). Default false: metadata only. */
9
+ content?: boolean;
10
+ /** This agent's version, sent as `service.version`. */
11
+ version?: string;
12
+ }
13
+ /** Where traces go when a key is set and no address is, and the test cloud `flow test` uses when none is named. */
14
+ export declare const OBSERVE_URL = "https://flow.engineer/observe";
15
+ /** A setting from the environment: FLOW_FRAMEWORK_<name>, else CHINWAG_<name> (src/names.ts). */
16
+ export { setting };
17
+ /**
18
+ * The traces options from the environment: on when FLOW_FRAMEWORK_TRACES_KEY is set, except under
19
+ * `node --test` (NODE_TEST_CONTEXT), so an agent's own tests never send their turns. The address
20
+ * is FLOW_FRAMEWORK_TRACES_URL, else {@link OBSERVE_URL}.
21
+ */
22
+ export declare function tracesFromEnv(env?: NodeJS.ProcessEnv): TracesOptions | undefined;
23
+ export interface Traces {
24
+ sink: EventSink;
25
+ /** Resolves once every finished turn queued so far has been sent (or dropped). */
26
+ flush(): Promise<void>;
27
+ }
28
+ export declare function traces(options: TracesOptions, agent: {
29
+ service: string;
30
+ kindOf(channel: string): string | undefined;
31
+ }): Traces;