@flow-engineer/flow 0.0.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +611 -0
  3. package/coding-agents/skill.md +353 -0
  4. package/coding-agents/test-skill.md +223 -0
  5. package/coding-agents/tools-skill.md +89 -0
  6. package/dist/flow-messaging/src/client.d.ts +40 -0
  7. package/dist/flow-messaging/src/client.js +142 -0
  8. package/dist/flow-messaging/src/index.d.ts +72 -0
  9. package/dist/flow-messaging/src/index.js +605 -0
  10. package/dist/simulators/src/index.d.ts +2 -0
  11. package/dist/simulators/src/index.js +2 -0
  12. package/dist/simulators/src/page.html +1245 -0
  13. package/dist/simulators/src/person.html +100 -0
  14. package/dist/simulators/src/platforms.d.ts +29 -0
  15. package/dist/simulators/src/platforms.js +72 -0
  16. package/dist/simulators/src/server.d.ts +201 -0
  17. package/dist/simulators/src/server.js +759 -0
  18. package/dist/src/agent.d.ts +504 -0
  19. package/dist/src/agent.js +818 -0
  20. package/dist/src/channels.d.ts +178 -0
  21. package/dist/src/channels.js +243 -0
  22. package/dist/src/conversation.d.ts +361 -0
  23. package/dist/src/conversation.js +1487 -0
  24. package/dist/src/db.d.ts +18 -0
  25. package/dist/src/db.js +57 -0
  26. package/dist/src/events.d.ts +17 -0
  27. package/dist/src/events.js +20 -0
  28. package/dist/src/http.d.ts +45 -0
  29. package/dist/src/http.js +269 -0
  30. package/dist/src/index.d.ts +14 -0
  31. package/dist/src/index.js +11 -0
  32. package/dist/src/model.d.ts +21 -0
  33. package/dist/src/model.js +96 -0
  34. package/dist/src/names.d.ts +22 -0
  35. package/dist/src/names.js +43 -0
  36. package/dist/src/nudges.d.ts +23 -0
  37. package/dist/src/nudges.js +117 -0
  38. package/dist/src/postgres.d.ts +20 -0
  39. package/dist/src/postgres.js +62 -0
  40. package/dist/src/schedules.d.ts +60 -0
  41. package/dist/src/schedules.js +249 -0
  42. package/dist/src/skills.d.ts +12 -0
  43. package/dist/src/skills.js +69 -0
  44. package/dist/src/store.d.ts +214 -0
  45. package/dist/src/store.js +448 -0
  46. package/dist/src/talk.d.ts +57 -0
  47. package/dist/src/talk.js +166 -0
  48. package/dist/src/time.d.ts +71 -0
  49. package/dist/src/time.js +170 -0
  50. package/dist/src/tools.d.ts +43 -0
  51. package/dist/src/tools.js +26 -0
  52. package/dist/src/traces.d.ts +31 -0
  53. package/dist/src/traces.js +303 -0
  54. package/dist/src/types.d.ts +260 -0
  55. package/dist/src/types.js +2 -0
  56. package/dist/testing/src/account.d.ts +4 -0
  57. package/dist/testing/src/account.js +580 -0
  58. package/dist/testing/src/chat.d.ts +3 -0
  59. package/dist/testing/src/chat.js +117 -0
  60. package/dist/testing/src/check-endpoint.d.ts +31 -0
  61. package/dist/testing/src/check-endpoint.js +158 -0
  62. package/dist/testing/src/chinwag.d.ts +2 -0
  63. package/dist/testing/src/chinwag.js +6 -0
  64. package/dist/testing/src/cli.d.ts +2 -0
  65. package/dist/testing/src/cli.js +592 -0
  66. package/dist/testing/src/client.d.ts +117 -0
  67. package/dist/testing/src/client.js +956 -0
  68. package/dist/testing/src/flow-login.d.ts +143 -0
  69. package/dist/testing/src/flow-login.js +249 -0
  70. package/dist/testing/src/http-agent.d.ts +47 -0
  71. package/dist/testing/src/http-agent.js +155 -0
  72. package/dist/testing/src/index.d.ts +3 -0
  73. package/dist/testing/src/index.js +2 -0
  74. package/dist/testing/src/new.d.ts +13 -0
  75. package/dist/testing/src/new.js +147 -0
  76. package/dist/testing/src/print-run.d.ts +3 -0
  77. package/dist/testing/src/print-run.js +22 -0
  78. package/dist/testing/src/protocol.d.ts +159 -0
  79. package/dist/testing/src/protocol.js +28 -0
  80. package/dist/testing/src/report.d.ts +74 -0
  81. package/dist/testing/src/report.js +90 -0
  82. package/dist/testing/src/show.d.ts +36 -0
  83. package/dist/testing/src/show.js +280 -0
  84. package/dist/testing/src/suite.d.ts +87 -0
  85. package/dist/testing/src/suite.js +198 -0
  86. package/docs/agents.md +85 -0
  87. package/examples/cafe/agent/AGENTS.md +10 -0
  88. package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
  89. package/examples/cafe/src/agent.ts +43 -0
  90. package/examples/cafe/src/channels.ts +20 -0
  91. package/examples/cafe/src/main.ts +30 -0
  92. package/examples/cafe/src/tools.ts +62 -0
  93. package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
  94. package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
  95. package/examples/cafe/tests/tools/get_menu.yaml +14 -0
  96. package/examples/cafe/tests/tools/place_order.yaml +29 -0
  97. package/examples/http-agent/server.mjs +32 -0
  98. package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
  99. package/flow-messaging/README.md +191 -0
  100. package/package.json +63 -8
  101. package/spec/agent-endpoint.openapi.yaml +148 -0
  102. package/spec/nudges.openapi.yaml +167 -0
  103. package/spec/request.schema.json +121 -0
  104. package/spec/response.schema.json +284 -0
@@ -0,0 +1,23 @@
1
+ import type { NoContactRow, QuietHours, StoreApi } from "./store.ts";
2
+ import { type Tool } from "./tools.ts";
3
+ import type { Model, ModelResponse } from "./types.ts";
4
+ export interface NoContactScope {
5
+ store: StoreApi;
6
+ conversationId: string;
7
+ now(): number;
8
+ /** Their zone, once known: an `until` date is read in it. */
9
+ zone(): string | null;
10
+ setQuietHours(hours: QuietHours | "off" | null): Promise<void>;
11
+ }
12
+ export declare function noContactTools(scope: NoContactScope): Tool[];
13
+ /** The rules as the agent reads them each turn; empty when there are none. */
14
+ export declare function rulesSection(rules: NoContactRow[], zone: string, quiet?: QuietHours | null): string;
15
+ /**
16
+ * A fixed-text message the business starts, to a person with rules: one small model call says
17
+ * whether a rule covers it. Rejects when the model does; the caller sends nothing then.
18
+ */
19
+ export declare function coveredBy(model: Model, rules: NoContactRow[], text: string): Promise<{
20
+ rule: NoContactRow | null;
21
+ usage?: ModelResponse["usage"];
22
+ model?: string;
23
+ }>;
@@ -0,0 +1,117 @@
1
+ // Nudges are messages the business starts: sent as written (text), written by the agent (prompt),
2
+ // or a template the owner already set up with the channel. What a person asked not to be messaged
3
+ // about is the agent's judgement, kept as rules it adds and removes: a message the business starts
4
+ // that the rules cover is not sent. The rules cover only what the business starts, never what the
5
+ // person asks for themselves. A channel's STOP keyword is different: a hard rule, written without
6
+ // the model, that stops everything the business starts until they text START (channels.ts).
7
+ import { randomUUID } from "node:crypto";
8
+ import { z } from "zod";
9
+ import { clock, endOfLocalDay, formatLocal } from "./time.js";
10
+ import { tool, toSpec } from "./tools.js";
11
+ export function noContactTools(scope) {
12
+ return [
13
+ tool({
14
+ name: "add_no_contact",
15
+ description: "When they say they do not want to be messaged about something (or at all, or for a while), keep it as a rule, in their words: \"no promotions\", \"nothing before 9am\". Messages the business starts that the rule covers will not be sent. Also cancel their scheduled messages it covers. Not for things they ask for themselves.",
16
+ input: z.object({
17
+ rule: z.string().min(1).max(200),
18
+ until: z.string().optional().describe("YYYY-MM-DD, the last day it applies, when they gave one (\"not this week\")"),
19
+ }),
20
+ run: async ({ rule, until }) => {
21
+ // Their zone unknown, the day ends where it ends last (UTC-12): the rule never lifts early.
22
+ const ends = until ? endOfLocalDay(scope.zone() ?? "Etc/GMT+12", until) : null;
23
+ if (until && !ends)
24
+ return { error: "until must be YYYY-MM-DD." };
25
+ const id = `nc_${randomUUID().slice(0, 6)}`;
26
+ await scope.store.addNoContact(scope.conversationId, id, rule, ends);
27
+ return { added: { id, rule, until: until ?? null } };
28
+ },
29
+ }),
30
+ tool({
31
+ name: "pause_messages",
32
+ description: "When they want nothing at all from the business for a while (\"no messages this week\", \"pause until Monday\"): nothing it starts goes until that day ends. Remove it with remove_no_contact.",
33
+ input: z.object({ until: z.string().describe("YYYY-MM-DD, the last day of the pause") }),
34
+ run: async ({ until }) => {
35
+ const ends = endOfLocalDay(scope.zone() ?? "Etc/GMT+12", until);
36
+ if (!ends)
37
+ return { error: "until must be YYYY-MM-DD." };
38
+ if (ends <= scope.now())
39
+ return { error: "That day is already over." };
40
+ const id = `nc_${randomUUID().slice(0, 6)}`;
41
+ await scope.store.addNoContact(scope.conversationId, id, `pause: nothing the business starts until ${until} is over`, ends, "pause");
42
+ return { paused: { id, until } };
43
+ },
44
+ }),
45
+ tool({
46
+ name: "set_quiet_hours",
47
+ description: "When they say when not to be messaged by the clock (\"not after 9pm\", \"nothing before 8\"): their quiet hours, in their own time, when what the business starts waits. off: none at all; default: back to the usual ones.",
48
+ input: z.object({
49
+ from: z.string().optional().describe("HH:MM their time, when quiet starts"),
50
+ to: z.string().optional().describe("HH:MM their time, when it ends"),
51
+ set: z.enum(["hours", "off", "default"]).describe("hours: from and to; off: no quiet hours; default: the usual ones"),
52
+ }),
53
+ run: async ({ from, to, set }) => {
54
+ if (set !== "hours") {
55
+ await scope.setQuietHours(set === "off" ? "off" : null);
56
+ return { quiet_hours: set };
57
+ }
58
+ if (!from || !to || !clock(from) || !clock(to))
59
+ return { error: "from and to are HH:MM, such as 21:00 and 08:00." };
60
+ await scope.setQuietHours({ from, to });
61
+ return { quiet_hours: { from, to }, ...(scope.zone() ? {} : { note: "Their time zone is not known, so these apply once it is: ask where they are." }) };
62
+ },
63
+ }),
64
+ tool({
65
+ name: "remove_no_contact",
66
+ description: "Remove one of their no-contact rules, by id, when they want those messages again.",
67
+ input: z.object({ id: z.string() }),
68
+ run: async ({ id }) => {
69
+ const rule = (await scope.store.noContact(scope.conversationId, scope.now())).find((r) => r.id === id);
70
+ if (rule?.hard)
71
+ return { error: "That is their STOP: it ends only when they text START." };
72
+ return { removed: await scope.store.removeNoContact(scope.conversationId, id) };
73
+ },
74
+ }),
75
+ ];
76
+ }
77
+ /** The rules as the agent reads them each turn; empty when there are none. */
78
+ export function rulesSection(rules, zone, quiet = null) {
79
+ if (!rules.length && !quiet)
80
+ return "";
81
+ return [
82
+ "## When and what not to message them",
83
+ "Messages the business starts (nudges, its scheduled messages) that these cover are not sent. Things they ask for themselves are fine.",
84
+ ...(quiet ? [`- Quiet hours: ${quiet.from} to ${quiet.to}, their time: what the business starts waits until they end (set_quiet_hours changes them).`] : []),
85
+ ...rules.map((r) => `- [${r.id}] ${r.rule}${r.until ? ` (until ${formatLocal(zone, r.until - 1)})` : ""}`),
86
+ ].join("\n");
87
+ }
88
+ const decision = tool({
89
+ name: "decide",
90
+ description: "Say whether the message may be sent.",
91
+ input: z.object({ send: z.boolean(), rule: z.string().optional().describe("The id of the rule that covers it, when send is false") }),
92
+ run: () => "",
93
+ });
94
+ /**
95
+ * A fixed-text message the business starts, to a person with rules: one small model call says
96
+ * whether a rule covers it. Rejects when the model does; the caller sends nothing then.
97
+ */
98
+ export async function coveredBy(model, rules, text) {
99
+ const response = await model.complete({
100
+ system: "A business wants to send this message to a person. The person asked not to be messaged about some things. Decide whether any of their rules covers this message; call decide once.",
101
+ messages: [{ role: "user", content: `Rules:\n${rules.map((r) => `- [${r.id}] ${r.rule}`).join("\n")}\n\nMessage:\n${text}` }],
102
+ tools: [toSpec(decision)],
103
+ signal: new AbortController().signal,
104
+ });
105
+ const call = response.toolCalls.find((c) => c.name === "decide");
106
+ let parsed = {};
107
+ try {
108
+ parsed = JSON.parse(call?.arguments ?? "{}");
109
+ }
110
+ catch {
111
+ // An unreadable answer sends nothing: a rule the person set is safer kept than broken.
112
+ }
113
+ const said = { usage: response.usage, model: response.model };
114
+ if (parsed.send === true)
115
+ return { rule: null, ...said };
116
+ return { rule: rules.find((r) => r.id === parsed.rule) ?? rules[0], ...said };
117
+ }
@@ -0,0 +1,20 @@
1
+ import type { Database, Row } from "./db.ts";
2
+ interface Queryable {
3
+ query(text: string, params?: unknown[]): Promise<{
4
+ rows: Row[];
5
+ rowCount: number | null;
6
+ }>;
7
+ }
8
+ /** A connection pool: queries on any connection, and one connection checked out for a transaction. */
9
+ export interface PostgresPool extends Queryable {
10
+ connect(): Promise<Queryable & {
11
+ release(error?: Error | boolean): void;
12
+ }>;
13
+ }
14
+ /**
15
+ * Postgres behind Flow Framework's Database. SQL written with `?` reaches Postgres as `$1`, `$2`, ...; a
16
+ * transaction runs BEGIN ... COMMIT on one connection taken from the pool for it, and a connection
17
+ * whose ROLLBACK failed is given back broken, so the pool closes it instead of reusing it.
18
+ */
19
+ export declare function postgres(pool: PostgresPool): Database;
20
+ export {};
@@ -0,0 +1,62 @@
1
+ // Flow Framework's state in Postgres, over a connection pool the app already has (node-postgres's Pool, or
2
+ // anything shaped like it). Flow Framework takes no Postgres dependency: the app brings the client.
3
+ //
4
+ // import pg from "pg";
5
+ // const database = postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL }));
6
+ /**
7
+ * Postgres behind Flow Framework's Database. SQL written with `?` reaches Postgres as `$1`, `$2`, ...; a
8
+ * transaction runs BEGIN ... COMMIT on one connection taken from the pool for it, and a connection
9
+ * whose ROLLBACK failed is given back broken, so the pool closes it instead of reusing it.
10
+ */
11
+ export function postgres(pool) {
12
+ const on = (client) => ({
13
+ durable: true,
14
+ dialect: "postgres",
15
+ query: async (sql, params = []) => (await client.query(numbered(sql), params)).rows,
16
+ run: async (sql, params = []) => (await client.query(numbered(sql), params)).rowCount ?? 0,
17
+ });
18
+ return {
19
+ ...on(pool),
20
+ async transaction(work) {
21
+ const client = await pool.connect();
22
+ try {
23
+ await client.query("BEGIN");
24
+ const result = await work(on(client));
25
+ await client.query("COMMIT");
26
+ client.release();
27
+ return result;
28
+ }
29
+ catch (error) {
30
+ try {
31
+ await client.query("ROLLBACK");
32
+ client.release();
33
+ }
34
+ catch (rollback) {
35
+ client.release(rollback);
36
+ }
37
+ throw error;
38
+ }
39
+ },
40
+ };
41
+ }
42
+ /** `?` placeholders as Postgres's `$n`, leaving any inside quoted strings or names alone. */
43
+ function numbered(sql) {
44
+ let n = 0;
45
+ let quote = null;
46
+ let out = "";
47
+ for (const ch of sql) {
48
+ if (quote) {
49
+ if (ch === quote)
50
+ quote = null;
51
+ }
52
+ else if (ch === "'" || ch === '"') {
53
+ quote = ch;
54
+ }
55
+ else if (ch === "?") {
56
+ out += `$${++n}`;
57
+ continue;
58
+ }
59
+ out += ch;
60
+ }
61
+ return out;
62
+ }
@@ -0,0 +1,60 @@
1
+ import type { NewSchedule, ScheduleRow, StoreApi } from "./store.ts";
2
+ import { type Tool } from "./tools.ts";
3
+ export declare const LIMITS: {
4
+ /** Active schedules one person may have. */
5
+ perPerson: number;
6
+ /** The same message to the same person at least this far apart. */
7
+ sameMessageGapMs: number;
8
+ /** A run found later than this after its time is missed, not sent late. */
9
+ lateMs: number;
10
+ maxAheadMs: number;
11
+ };
12
+ /** What the schedule tools need from the conversation they run in. */
13
+ export interface ScheduleScope {
14
+ store: StoreApi;
15
+ conversationId: string;
16
+ /** The channel of the person's latest message, where a schedule's messages will go. */
17
+ channel(): string;
18
+ now(): number;
19
+ /** The person's zone, once known. */
20
+ personZone(): string | null;
21
+ setPersonZone(zone: string): Promise<void>;
22
+ /** The agent's own zone (the business's), if set: only for "in N minutes", which needs no one's clock. */
23
+ agentZone?: string;
24
+ }
25
+ export declare function scheduleTools(scope: ScheduleScope): Tool[];
26
+ export declare function summary(s: ScheduleRow): {
27
+ id: string;
28
+ message: string;
29
+ mode: "check" | "message";
30
+ summary: string;
31
+ next_run: string | null;
32
+ };
33
+ /** A scheduled message from the business (a nudge with a time): fixed text, or an instruction. */
34
+ export declare function businessSchedule(conversationId: string, channel: string, at: number, zone: string, body: {
35
+ text?: string;
36
+ prompt?: string;
37
+ }, quiet?: boolean): NewSchedule;
38
+ /** What one due run becomes in the conversation's inbox. */
39
+ export interface DueRun {
40
+ run: string;
41
+ schedule: string;
42
+ channel: string;
43
+ mode: ScheduleRow["mode"];
44
+ fixed: boolean;
45
+ text: string;
46
+ }
47
+ /**
48
+ * Claims every due schedule once: a run found too late is missed (and the schedule moves past
49
+ * now, so nothing floods after downtime), one `blocked` names a reason is skipped, the rest are
50
+ * queued for their conversation. Returns the queued runs with their inbox ids.
51
+ */
52
+ /**
53
+ * Claims the schedules due at `now`. One marked `quiet` that comes due in the person's quiet hours
54
+ * (`quietEnd` says when they end) waits: a once moves to their end, a repeat skips that run.
55
+ */
56
+ export declare function claimDue(store: StoreApi, now: number, quietEnd: (s: ScheduleRow) => Promise<number | null>, blocked: (s: ScheduleRow) => Promise<string | null>): Promise<Array<{
57
+ conversationId: string;
58
+ inbox: number;
59
+ run: DueRun;
60
+ }>>;
@@ -0,0 +1,249 @@
1
+ // Scheduled messages: an agent never runs timers. A
2
+ // schedule is a row; when it comes due, a runner moves it on and writes an inbox event in one
3
+ // transaction (so it runs once, even with several runners), and the conversation wakes like it
4
+ // does for any message. The agent then writes the message with fresh data. Times are the
5
+ // person's local time, never UTC (see time.ts).
6
+ import { randomUUID } from "node:crypto";
7
+ import { z } from "zod";
8
+ import { DAY_INDEX, clock, date, describe, endOfLocalDay, formatLocal, isBareUtc, localDate, localToUtc, nextRun, parts, validZone } from "./time.js";
9
+ import { tool } from "./tools.js";
10
+ export const LIMITS = {
11
+ /** Active schedules one person may have. */
12
+ perPerson: 10,
13
+ /** The same message to the same person at least this far apart. */
14
+ sameMessageGapMs: 60 * 60_000,
15
+ /** A run found later than this after its time is missed, not sent late. */
16
+ lateMs: 30 * 60_000,
17
+ maxAheadMs: 366 * 86_400_000,
18
+ };
19
+ const scheduleInput = z.object({
20
+ message: z.string().min(1).max(500).describe("What to do at that time, written for yourself (\"send today's specials\"), not the finished text"),
21
+ repeat: z.enum(["once", "daily", "weekly", "monthly", "hourly"]),
22
+ time: z.string().optional().describe("HH:MM in their local time"),
23
+ date: z.string().optional().describe("once: YYYY-MM-DD; leave it out for the next time the clock shows `time`"),
24
+ in_minutes: z.number().int().min(1).max(525_600).optional().describe("once: this many minutes from now, instead of time and date"),
25
+ days: z.array(z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])).optional().describe("weekly, or hourly on some days only"),
26
+ day_of_month: z.union([z.number().int().min(1).max(31), z.literal("last")]).optional().describe("monthly: 1 to 31 (a shorter month uses its last day), or \"last\""),
27
+ every_hours: z.number().int().min(1).max(23).optional().describe("hourly: every this many hours"),
28
+ from: z.string().optional().describe("hourly: HH:MM, the first run of the day"),
29
+ to: z.string().optional().describe("hourly: HH:MM, the last run of the day"),
30
+ until: z.string().optional().describe("Repeats: YYYY-MM-DD, the last day it may run"),
31
+ times: z.number().int().min(1).max(1000).optional().describe("Repeats: how many runs in all"),
32
+ timezone: z.string().optional().describe("An IANA zone, only when they named a place; leave it out otherwise"),
33
+ respect_quiet_hours: z.boolean().optional().describe("true for checks and updates that can wait for the morning (\"check for new listings and let me know\"); leave it out when they asked for that time, or for every hour"),
34
+ mode: z.enum(["message", "check"]).optional().describe("check: at that time, look, and message them only if there is something to tell"),
35
+ });
36
+ export function scheduleTools(scope) {
37
+ return [
38
+ tool({
39
+ name: "schedule_message",
40
+ description: "Message this person later, once or repeatedly, when they ask for a reminder or a regular message. Confirm vague times first (\"end of the month\" is the last day). Tell them plainly what you scheduled, with its time as the result gives it.",
41
+ input: scheduleInput,
42
+ run: (input) => create(scope, input),
43
+ }),
44
+ tool({
45
+ name: "list_scheduled",
46
+ description: "This person's scheduled messages that are still active.",
47
+ input: z.object({}),
48
+ run: async () => ({ scheduled: (await scope.store.schedules(scope.conversationId)).map(summary) }),
49
+ }),
50
+ tool({
51
+ name: "cancel_scheduled",
52
+ description: "Cancel one of this person's scheduled messages, by its id from list_scheduled.",
53
+ input: z.object({ id: z.string() }),
54
+ run: async ({ id }) => ({ cancelled: await scope.store.cancelSchedule(scope.conversationId, id) }),
55
+ }),
56
+ ];
57
+ }
58
+ export function summary(s) {
59
+ const ends = [s.endsOn ? `until ${s.endsOn}` : "", s.maxRuns ? `${s.maxRuns} times in all` : ""].filter(Boolean).join(", ");
60
+ return {
61
+ id: s.id,
62
+ message: s.instruction,
63
+ mode: s.mode,
64
+ summary: describe(s.rule, s.timeZone) + (ends ? `, ${ends}` : ""),
65
+ next_run: s.nextRunAt ? formatLocal(s.timeZone, s.nextRunAt) : null,
66
+ };
67
+ }
68
+ /**
69
+ * The zone a schedule uses: the one named (never a bare UTC), else the person's. Their zone is never
70
+ * guessed from the business's: only "in N minutes", which is the same in every zone, goes ahead
71
+ * without it (shown in the business's time).
72
+ */
73
+ async function zoneFor(scope, named, relative = false) {
74
+ if (named && !isBareUtc(named)) {
75
+ if (!validZone(named))
76
+ return { error: `${named} is not a time zone. Use an IANA name such as America/New_York.` };
77
+ await scope.setPersonZone(named);
78
+ return named;
79
+ }
80
+ const known = scope.personZone() ?? (relative ? (scope.agentZone ?? "UTC") : null);
81
+ if (known)
82
+ return known;
83
+ return { error: "timezone_needed: you do not know their time zone. Ask which city they are in, then schedule again with timezone." };
84
+ }
85
+ async function create(scope, input) {
86
+ const zone = await zoneFor(scope, input.timezone, input.repeat === "once" && input.in_minutes !== undefined);
87
+ if (typeof zone !== "string")
88
+ return zone;
89
+ const now = scope.now();
90
+ const rule = ruleOf(input, zone, now);
91
+ if ("error" in rule)
92
+ return rule;
93
+ const repeats = rule.repeat !== "once";
94
+ if (!repeats && (input.until || input.times))
95
+ return { error: "until and times are only for repeats." };
96
+ const next = rule.repeat === "once" ? rule.at : nextRun(rule, zone, now);
97
+ if (!next)
98
+ return { error: "That never comes due. Check the time and days." };
99
+ if (next < now + 60_000)
100
+ return { error: "That time is less than a minute away or already past. Pick a later one." };
101
+ if (next > now + LIMITS.maxAheadMs)
102
+ return { error: "That is more than a year ahead." };
103
+ if (input.until) {
104
+ if (!date(input.until))
105
+ return { error: "until must be YYYY-MM-DD." };
106
+ if (input.until < localDate(zone, next))
107
+ return { error: "until is before the first run." };
108
+ }
109
+ const active = await scope.store.schedules(scope.conversationId);
110
+ if (active.length >= LIMITS.perPerson)
111
+ return { error: `They already have ${LIMITS.perPerson} scheduled messages. Cancel one first.` };
112
+ const same = active.find((s) => normalize(s.instruction) === normalize(input.message) && s.nextRunAt !== null && Math.abs(s.nextRunAt - next) < LIMITS.sameMessageGapMs);
113
+ if (same)
114
+ return { error: "already_scheduled: the same message is already scheduled at about that time.", scheduled: summary(same) };
115
+ const row = {
116
+ id: `s_${randomUUID().slice(0, 8)}`,
117
+ conversationId: scope.conversationId,
118
+ channel: scope.channel(),
119
+ instruction: input.message,
120
+ fixedText: false,
121
+ mode: input.mode ?? "message",
122
+ rule: input.respect_quiet_hours ? { ...rule, quiet: true } : rule,
123
+ timeZone: zone,
124
+ nextRunAt: next,
125
+ maxRuns: input.times ?? null,
126
+ endsOn: input.until ?? null,
127
+ origin: "person",
128
+ };
129
+ await scope.store.addSchedule(row);
130
+ const [saved] = (await scope.store.schedules(scope.conversationId)).filter((s) => s.id === row.id);
131
+ return { scheduled: summary(saved), now: formatLocal(zone, now) };
132
+ }
133
+ function ruleOf(input, zone, now) {
134
+ const time = input.time ? clock(input.time) : null;
135
+ if (input.time && !time)
136
+ return { error: "time must be HH:MM." };
137
+ if (input.day_of_month !== undefined && input.repeat !== "monthly")
138
+ return { error: "day_of_month is only for monthly." };
139
+ switch (input.repeat) {
140
+ case "once": {
141
+ if (input.in_minutes)
142
+ return { repeat: "once", at: Math.ceil((now + input.in_minutes * 60_000) / 60_000) * 60_000 };
143
+ if (!time)
144
+ return { error: "once needs time (and date), or in_minutes." };
145
+ if (input.date) {
146
+ const d = date(input.date);
147
+ if (!d)
148
+ return { error: "date must be YYYY-MM-DD." };
149
+ return { repeat: "once", at: localToUtc(zone, d.year, d.month, d.day, time.hour, time.minute) };
150
+ }
151
+ const today = parts(zone, now);
152
+ let at = localToUtc(zone, today.year, today.month, today.day, time.hour, time.minute);
153
+ if (at <= now) {
154
+ const t = new Date(Date.UTC(today.year, today.month - 1, today.day + 1));
155
+ at = localToUtc(zone, t.getUTCFullYear(), t.getUTCMonth() + 1, t.getUTCDate(), time.hour, time.minute);
156
+ }
157
+ return { repeat: "once", at };
158
+ }
159
+ case "daily":
160
+ return time ? { repeat: "daily", time: input.time } : { error: "daily needs time." };
161
+ case "weekly":
162
+ if (!time || !input.days?.length)
163
+ return { error: "weekly needs time and days." };
164
+ return { repeat: "weekly", time: input.time, days: input.days.map((d) => DAY_INDEX[d]) };
165
+ case "monthly":
166
+ if (!time || input.day_of_month === undefined)
167
+ return { error: "monthly needs time and day_of_month." };
168
+ return { repeat: "monthly", time: input.time, day: input.day_of_month };
169
+ case "hourly": {
170
+ if ((input.from && !clock(input.from)) || (input.to && !clock(input.to)))
171
+ return { error: "from and to must be HH:MM." };
172
+ return { repeat: "hourly", every: input.every_hours ?? 1, from: input.from, to: input.to, days: input.days?.map((d) => DAY_INDEX[d]) };
173
+ }
174
+ }
175
+ }
176
+ /** A scheduled message from the business (a nudge with a time): fixed text, or an instruction. */
177
+ export function businessSchedule(conversationId, channel, at, zone, body, quiet = true) {
178
+ return {
179
+ id: `s_${randomUUID().slice(0, 8)}`,
180
+ conversationId,
181
+ channel,
182
+ instruction: body.text ?? body.prompt ?? "",
183
+ fixedText: body.text !== undefined,
184
+ mode: "message",
185
+ rule: quiet ? { repeat: "once", at, quiet: true } : { repeat: "once", at },
186
+ timeZone: zone,
187
+ nextRunAt: at,
188
+ maxRuns: null,
189
+ endsOn: null,
190
+ origin: "api",
191
+ };
192
+ }
193
+ /**
194
+ * Claims every due schedule once: a run found too late is missed (and the schedule moves past
195
+ * now, so nothing floods after downtime), one `blocked` names a reason is skipped, the rest are
196
+ * queued for their conversation. Returns the queued runs with their inbox ids.
197
+ */
198
+ /**
199
+ * Claims the schedules due at `now`. One marked `quiet` that comes due in the person's quiet hours
200
+ * (`quietEnd` says when they end) waits: a once moves to their end, a repeat skips that run.
201
+ */
202
+ export async function claimDue(store, now, quietEnd, blocked) {
203
+ const queued = [];
204
+ for (const s of await store.dueSchedules(now)) {
205
+ const due = s.nextRunAt;
206
+ const late = now - due > LIMITS.lateMs;
207
+ const rule = s.rule;
208
+ let nextAt = nextRun(rule, s.timeZone, late ? now : due);
209
+ if (s.maxRuns && s.runCount + 1 >= s.maxRuns)
210
+ nextAt = null;
211
+ if (nextAt && s.endsOn && nextAt >= (endOfLocalDay(s.timeZone, s.endsOn) ?? Infinity))
212
+ nextAt = null;
213
+ const next = { nextRunAt: nextAt, status: (nextAt ? "active" : "done") };
214
+ const runId = `r_${randomUUID().slice(0, 8)}`;
215
+ if (late) {
216
+ const minutes = Math.round((now - due) / 60_000);
217
+ await store.claimRun(s, next, { id: runId, outcome: "missed", reason: `found ${minutes} minutes after its time; not sent late`, ranAt: now });
218
+ continue;
219
+ }
220
+ const ends = s.rule.quiet ? await quietEnd(s) : null;
221
+ if (ends !== null) {
222
+ const held = rule.repeat === "once";
223
+ await store.claimRun(s, held ? { nextRunAt: ends, status: "active" } : next, { id: runId, outcome: held ? "held" : "skipped", reason: held ? `their quiet hours: moved to ${formatLocal(s.timeZone, ends)}` : "their quiet hours", ranAt: now });
224
+ continue;
225
+ }
226
+ const reason = await blocked(s);
227
+ if (reason) {
228
+ await store.claimRun(s, next, { id: runId, outcome: "skipped", reason, ranAt: now });
229
+ continue;
230
+ }
231
+ const run = { run: runId, schedule: s.id, channel: s.channel, mode: s.mode, fixed: s.fixedText, text: s.fixedText ? s.instruction : noteFor(s) };
232
+ const inbox = await store.claimRun(s, next, { id: runId, outcome: "queued", ranAt: now }, { ...run });
233
+ if (inbox)
234
+ queued.push({ conversationId: s.conversationId, inbox, run });
235
+ }
236
+ return queued;
237
+ }
238
+ /** The note the agent's turn starts with when a schedule comes due. */
239
+ function noteFor(s) {
240
+ const when = describe(s.rule, s.timeZone);
241
+ if (s.origin === "api")
242
+ return `[Nudge from the business, not from the person, scheduled earlier: ${s.instruction}] Write it to them now in your own words, or send nothing if it does not fit what they asked not to be messaged about.`;
243
+ if (s.mode === "check")
244
+ return `[Scheduled check, not from the person: they asked you to check ${when}: ${s.instruction}] Look now. Message them only if there is something to tell; otherwise send nothing.`;
245
+ return `[Scheduled message, not from the person: they asked you to message them ${when}. Do it now: ${s.instruction}] Write that message to them now.`;
246
+ }
247
+ function normalize(text) {
248
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
249
+ }
@@ -0,0 +1,12 @@
1
+ import { type Tool } from "./tools.ts";
2
+ export interface Skill {
3
+ name: string;
4
+ description: string;
5
+ body: string;
6
+ }
7
+ /** An agent's folder as text: AGENTS.md and skills/<name>/SKILL.md, by their paths in it. */
8
+ export type AgentFiles = Record<string, string>;
9
+ export declare function readAgentFolder(dir: string): AgentFiles;
10
+ export declare function loadSkills(files: AgentFiles): Skill[];
11
+ export declare function loadInstructions(files: AgentFiles, skills: Skill[], inline?: boolean): string;
12
+ export declare function skillTool(skills: Skill[], onLoad: (conversationId: string, name: string) => void): Tool;
@@ -0,0 +1,69 @@
1
+ // An agent starts from its AGENTS.md and the one-line description of each skill; a skill's full
2
+ // file is read only when the agent asks for it, so every prompt stays small.
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import { tool } from "./tools.js";
7
+ function frontmatter(text) {
8
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
9
+ if (!match)
10
+ return { fields: {}, body: text };
11
+ const fields = {};
12
+ for (const line of match[1].split("\n")) {
13
+ const at = line.indexOf(":");
14
+ if (at > 0)
15
+ fields[line.slice(0, at).trim()] = line.slice(at + 1).trim();
16
+ }
17
+ return { fields, body: match[2] };
18
+ }
19
+ export function readAgentFolder(dir) {
20
+ const files = {};
21
+ const agents = join(dir, "AGENTS.md");
22
+ if (existsSync(agents))
23
+ files["AGENTS.md"] = readFileSync(agents, "utf8");
24
+ const root = join(dir, "skills");
25
+ if (!existsSync(root))
26
+ return files;
27
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
28
+ const file = join(root, entry.name, "SKILL.md");
29
+ if (entry.isDirectory() && existsSync(file))
30
+ files[`skills/${entry.name}/SKILL.md`] = readFileSync(file, "utf8");
31
+ }
32
+ return files;
33
+ }
34
+ export function loadSkills(files) {
35
+ const skills = [];
36
+ for (const [path, text] of Object.entries(files)) {
37
+ const folder = /^skills\/([^/]+)\/SKILL\.md$/.exec(path)?.[1];
38
+ if (!folder)
39
+ continue;
40
+ const { fields, body } = frontmatter(text);
41
+ skills.push({ name: fields.name || folder, description: fields.description || "", body: body.trim() });
42
+ }
43
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
44
+ }
45
+ export function loadInstructions(files, skills, inline = false) {
46
+ const base = (files["AGENTS.md"] ?? "").trim();
47
+ if (!skills.length)
48
+ return base;
49
+ if (inline)
50
+ return `${base}\n\n${skills.map((s) => `## Skill: ${s.name}\n${s.body}`).join("\n\n")}`;
51
+ const list = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
52
+ return `${base}\n\n## Skills\nLoad a skill with load_skill before doing what it covers; call it together with the first other tool you need, not on its own.\n${list}`;
53
+ }
54
+ export function skillTool(skills, onLoad) {
55
+ const names = skills.map((s) => s.name);
56
+ return tool({
57
+ name: "load_skill",
58
+ description: "Read a skill's full instructions. Use it before doing what the skill covers.",
59
+ input: z.object({ name: z.enum(names) }),
60
+ deadlineMs: 60_000,
61
+ run({ name }, context) {
62
+ const skill = skills.find((s) => s.name === name);
63
+ if (!skill)
64
+ return `No skill named ${name}.`;
65
+ onLoad(context.conversationId, name);
66
+ return skill.body;
67
+ },
68
+ });
69
+ }