@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,18 @@
1
+ export type Row = Record<string, unknown>;
2
+ export interface Database {
3
+ /** The database survives a restart (a file, or a server); in-memory SQLite does not. */
4
+ readonly durable: boolean;
5
+ /** The SQL it speaks, where engines differ (column types in Flow Framework's tables). Default "sqlite". */
6
+ readonly dialect?: "sqlite" | "postgres";
7
+ /** Rows of a SELECT, or [] for a statement that returns none. */
8
+ query(sql: string, params?: unknown[]): Promise<Row[]>;
9
+ /** A statement that changes rows; resolves to how many it changed. */
10
+ run(sql: string, params?: unknown[]): Promise<number>;
11
+ /** Everything inside commits together or not at all. */
12
+ transaction<T>(work: (db: Omit<Database, "transaction">) => Promise<T>): Promise<T>;
13
+ }
14
+ /**
15
+ * SQLite in a file (one process may use it at a time) or, by default, in memory. Statements run
16
+ * one at a time per connection: a transaction holds the connection until it ends.
17
+ */
18
+ export declare function sqlite(path?: string): Database;
package/dist/src/db.js ADDED
@@ -0,0 +1,57 @@
1
+ // The database under Flow Framework's state. Flow Framework talks to it through this small interface, so any
2
+ // engine can sit behind it: SQLite is built in (Node's own node:sqlite), and a Postgres adapter
3
+ // wraps a client the app already has. SQL is written once with `?` placeholders; an adapter turns
4
+ // them into its own.
5
+ /**
6
+ * SQLite in a file (one process may use it at a time) or, by default, in memory. Statements run
7
+ * one at a time per connection: a transaction holds the connection until it ends.
8
+ */
9
+ export function sqlite(path = ":memory:") {
10
+ const { DatabaseSync: Sqlite } = process.getBuiltinModule("node:sqlite");
11
+ const db = new Sqlite(path);
12
+ if (path !== ":memory:")
13
+ db.exec("PRAGMA journal_mode = WAL");
14
+ let lock = Promise.resolve();
15
+ const exclusive = (work) => {
16
+ const next = lock.then(work, work);
17
+ lock = next.catch(() => undefined);
18
+ return next;
19
+ };
20
+ const inner = {
21
+ durable: path !== ":memory:",
22
+ query: async (sql, params = []) => db.prepare(sql).all(...params),
23
+ run: async (sql, params = []) => Number(db.prepare(sql).run(...params).changes),
24
+ };
25
+ return {
26
+ durable: inner.durable,
27
+ query: (sql, params) => exclusive(() => inner.query(sql, params)),
28
+ run: (sql, params) => exclusive(() => inner.run(sql, params)),
29
+ transaction: (work) => exclusive(async () => {
30
+ await begin(db);
31
+ try {
32
+ const result = await work(inner);
33
+ db.exec("COMMIT");
34
+ return result;
35
+ }
36
+ catch (error) {
37
+ db.exec("ROLLBACK");
38
+ throw error;
39
+ }
40
+ }),
41
+ };
42
+ }
43
+ /** BEGIN IMMEDIATE, waiting while another connection to the same file holds it. */
44
+ async function begin(db) {
45
+ for (let attempt = 0;; attempt++) {
46
+ try {
47
+ db.exec("BEGIN IMMEDIATE");
48
+ return;
49
+ }
50
+ catch (error) {
51
+ const busy = error.errcode === 5;
52
+ if (!busy || attempt > 200)
53
+ throw error;
54
+ await new Promise((resolve) => setTimeout(resolve, 5));
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,17 @@
1
+ export interface FlowEvent {
2
+ type: "message.in" | "message.arrived" | "message.out" | "turn.start" | "turn.context" | "turn.end" | "model.call" | "model.restarted" | "model.error" | "tool.start" | "tool.end" | "tool.detached" | "tool.repeated" | "task.done" | "skill.loaded" | "text.hidden" | "ack.required" | "reaction" | "status" | "message.undelivered" | "reaction.in" | "attachment.failed" | "keyword" | "message.duplicate" | "history.trimmed" | "template.missing";
3
+ conversationId: string;
4
+ at: number;
5
+ [detail: string]: unknown;
6
+ }
7
+ /** @deprecated Renamed {@link FlowEvent} with the package (formerly chinwag). */
8
+ export type ChinwagEvent = FlowEvent;
9
+ /** A place events go. `context` asks for `turn.context`; without it the person's record is not sent. */
10
+ export interface EventSink {
11
+ (event: FlowEvent): void;
12
+ context?: boolean;
13
+ }
14
+ /** One JSON line per event, to a file. */
15
+ export declare function jsonlSink(path: string): EventSink;
16
+ export declare function emitter(sinks: EventSink[]): (conversationId: string, type: FlowEvent["type"], detail?: Record<string, unknown>) => void;
17
+ export type Emit = ReturnType<typeof emitter>;
@@ -0,0 +1,20 @@
1
+ // Observability: everything the agent does is an event. Sinks send them anywhere (a log, a
2
+ // dashboard, a test); they must never throw into the conversation.
3
+ import { appendFileSync } from "node:fs";
4
+ /** One JSON line per event, to a file. */
5
+ export function jsonlSink(path) {
6
+ return (event) => appendFileSync(path, JSON.stringify(event) + "\n");
7
+ }
8
+ export function emitter(sinks) {
9
+ return (conversationId, type, detail = {}) => {
10
+ const event = { type, conversationId, at: Date.now(), ...detail };
11
+ for (const sink of sinks) {
12
+ try {
13
+ sink(event);
14
+ }
15
+ catch (error) {
16
+ console.warn("flow: an event sink failed:", error);
17
+ }
18
+ }
19
+ };
20
+ }
@@ -0,0 +1,45 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Agent } from "./agent.ts";
3
+ /** Throws unless the token is a string of at least 32 characters; `what` names who needs it. */
4
+ export declare function requireToken(token: unknown, what: string): asserts token is string;
5
+ /** Whether two secrets are the same, in constant time whatever their lengths. */
6
+ export declare function sameSecret(given: string, wanted: string): boolean;
7
+ /** Wrong tokens per address: past MAX_FAILURES in a window, the address is refused until it ends. */
8
+ export declare function wrongTries(): {
9
+ blocked(ip: string): boolean;
10
+ failed(ip: string): void;
11
+ };
12
+ export interface NudgeHandlerOptions {
13
+ /** At least 32 characters, from a secret; callers send it as `Authorization: Bearer <token>`. */
14
+ token: string;
15
+ /** The path it answers under. Default "/nudges". */
16
+ base?: string;
17
+ }
18
+ /** What the handler needs from an agent; Flow Framework's Agent is one. */
19
+ export type NudgeAgent = Pick<Agent, "recordNudge" | "nudgeState" | "channels" | "templates">;
20
+ /**
21
+ * Nudges over HTTP, as a request handler for your own server: hand it the requests under `base`.
22
+ * Requests outside `base` get 404 without a token check. A nudge refused or dropped as it is
23
+ * recorded (unknown_person, outside_window, a STOP) is still a 202: that is the nudge's state, not
24
+ * an HTTP error.
25
+ */
26
+ export declare function nudgeHandler(agent: NudgeAgent, options: NudgeHandlerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
27
+ export interface PeopleHandlerOptions {
28
+ /** At least 32 characters, from a secret; callers send it as `Authorization: Bearer <token>`. */
29
+ token: string;
30
+ /** The path it answers under. Default "/people". */
31
+ base?: string;
32
+ }
33
+ /**
34
+ * What a business knows about people, over HTTP: their time zone and quiet hours, and their whole
35
+ * contact record (what they and the business set). Requests outside `base` get 404 without a token check.
36
+ *
37
+ * PUT {base}/time-zone { to, time_zone } → 200 { status: "set" }
38
+ * PUT {base}/quiet-hours { to, quiet_hours: { from, to } | "off" | null } (null: the agent's)
39
+ * GET {base}/contact?channel=&address= → 200 the contact record
40
+ *
41
+ * Someone who never wrote is 404 `{ status: "refused", reason: "unknown_person" }`.
42
+ */
43
+ export declare function peopleHandler(agent: Pick<Agent, "setTimeZone" | "setQuietHours" | "contact" | "channels">, options: PeopleHandlerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
44
+ /** A path with its trailing slashes taken off, in one pass (no regular expression to backtrack). */
45
+ export declare function withoutTrailingSlashes(path: string): string;
@@ -0,0 +1,269 @@
1
+ // Nudges and people's time zones over HTTP: handlers the owner mounts in their own server, so a
2
+ // backend that is a separate service can reach the agent without routes of its own. Behind one
3
+ // bearer token each.
4
+ //
5
+ // nudgeHandler:
6
+ // POST {base} { to: { channel, address }, text | prompt | template, at?, idempotency_key? }
7
+ // → 202 { id, status, reason? } as soon as it is recorded, before any send or turn
8
+ // GET {base}/:id → 200 { id, status, reason? }, its state now; 404 for an id the agent never recorded
9
+ //
10
+ // peopleHandler: a person's time zone and quiet hours as the business knows them, and their contact
11
+ // record (see its doc comment below).
12
+ //
13
+ // The token lock (a token of at least 32 characters, compared in constant time, wrong tries limited
14
+ // per address) is shared with the deployed playground.
15
+ import { createHash, timingSafeEqual } from "node:crypto";
16
+ import { z } from "zod";
17
+ import { declaredTemplate, fillTemplate } from "./channels.js";
18
+ /** Wrong tokens one address may try in a window before it is refused for the rest of it. */
19
+ const MAX_FAILURES = 10;
20
+ const FAILURE_WINDOW_MS = 15 * 60_000;
21
+ const MAX_BODY = 16_384;
22
+ /** Throws unless the token is a string of at least 32 characters; `what` names who needs it. */
23
+ export function requireToken(token, what) {
24
+ if (typeof token !== "string" || token.length < 32)
25
+ throw new Error(`${what} needs a token of at least 32 characters`);
26
+ }
27
+ /** Whether two secrets are the same, in constant time whatever their lengths. */
28
+ export function sameSecret(given, wanted) {
29
+ const hash = (text) => createHash("sha256").update(text).digest();
30
+ return timingSafeEqual(hash(given), hash(wanted));
31
+ }
32
+ /** Wrong tokens per address: past MAX_FAILURES in a window, the address is refused until it ends. */
33
+ export function wrongTries() {
34
+ const failures = new Map();
35
+ return {
36
+ blocked(ip) {
37
+ const failed = failures.get(ip);
38
+ if (failed && failed.until <= Date.now())
39
+ failures.delete(ip);
40
+ return (failures.get(ip)?.count ?? 0) >= MAX_FAILURES;
41
+ },
42
+ failed(ip) {
43
+ const now = Date.now();
44
+ for (const [k, v] of failures)
45
+ if (v.until <= now)
46
+ failures.delete(k);
47
+ const entry = failures.get(ip) ?? { count: 0, until: now + FAILURE_WINDOW_MS };
48
+ entry.count++;
49
+ failures.set(ip, entry);
50
+ },
51
+ };
52
+ }
53
+ const Header = z.union([
54
+ z.object({ kind: z.literal("text"), text: z.string() }).strict(),
55
+ z.object({ kind: z.enum(["image", "video", "document"]), url: z.string().optional(), fileId: z.string().optional() }).strict(),
56
+ ]);
57
+ const Template = z
58
+ .object({
59
+ name: z.string().min(1),
60
+ language: z.string().optional(),
61
+ header: Header.optional(),
62
+ body: z.array(z.string()).optional(),
63
+ buttons: z.array(z.object({ index: z.number().int().nonnegative(), value: z.string() }).strict()).optional(),
64
+ variables: z.record(z.string(), z.string()).optional(),
65
+ })
66
+ .strict();
67
+ const Body = z
68
+ .object({
69
+ to: z.object({ channel: z.string().min(1), address: z.string().min(1) }).strict(),
70
+ text: z.string().min(1).optional(),
71
+ prompt: z.string().min(1).optional(),
72
+ template: Template.optional(),
73
+ /** Milliseconds since the epoch, as `agent.nudge()` takes it. */
74
+ at: z.number().int().nonnegative().optional(),
75
+ idempotency_key: z.string().min(1).max(200).optional(),
76
+ /** Wait for the end of their quiet hours (default true). */
77
+ respect_quiet_hours: z.boolean().optional(),
78
+ /** Held for quiet hours past this (ms since the epoch), it is dropped as expired. */
79
+ expires_at: z.number().int().nonnegative().optional(),
80
+ })
81
+ .strict()
82
+ .refine((b) => [b.text, b.prompt, b.template].filter((v) => v !== undefined).length === 1, { message: "give exactly one of text, prompt or template" })
83
+ .refine((b) => !(b.template && b.at !== undefined), { message: "a template nudge cannot be scheduled yet: leave out at" });
84
+ /**
85
+ * Nudges over HTTP, as a request handler for your own server: hand it the requests under `base`.
86
+ * Requests outside `base` get 404 without a token check. A nudge refused or dropped as it is
87
+ * recorded (unknown_person, outside_window, a STOP) is still a 202: that is the nudge's state, not
88
+ * an HTTP error.
89
+ */
90
+ export function nudgeHandler(agent, options) {
91
+ requireToken(options?.token, "flow nudges");
92
+ const token = options.token;
93
+ const base = withoutTrailingSlashes(options.base ?? "/nudges");
94
+ const tries = wrongTries();
95
+ async function handle(req, res) {
96
+ const path = new URL(req.url ?? "/", "http://localhost").pathname;
97
+ const id = path.startsWith(`${base}/`) ? path.slice(base.length + 1) : null;
98
+ if (path !== base && (id === null || !id || id.includes("/")))
99
+ return json(res, 404, { error: "not found" });
100
+ const ip = req.socket.remoteAddress ?? "unknown";
101
+ if (!authorized(req, res, ip, token, tries))
102
+ return;
103
+ if (id !== null) {
104
+ if (req.method !== "GET")
105
+ return json(res, 404, { error: "not found" });
106
+ let asked;
107
+ try {
108
+ asked = decodeURIComponent(id);
109
+ }
110
+ catch {
111
+ return json(res, 404, { error: "no nudge with this id" });
112
+ }
113
+ const state = await agent.nudgeState(asked);
114
+ return state ? json(res, 200, state) : json(res, 404, { error: "no nudge with this id" });
115
+ }
116
+ if (req.method !== "POST")
117
+ return json(res, 404, { error: "not found" });
118
+ const raw = await readBody(req);
119
+ if (raw === undefined)
120
+ return json(res, 400, { error: `the body must be JSON of at most ${MAX_BODY} bytes` });
121
+ const parsed = Body.safeParse(raw);
122
+ if (!parsed.success)
123
+ return json(res, 400, { error: parsed.error.issues.map((i) => (i.path.length ? `${i.path.join(".")}: ${i.message}` : i.message)).join("; ") });
124
+ const body = parsed.data;
125
+ const channels = agent.channels().map((c) => c.name);
126
+ if (!channels.includes(body.to.channel))
127
+ return json(res, 400, { error: `to.channel must be one of the agent's channels: ${channels.join(", ") || "it has none"}` });
128
+ if (body.template) {
129
+ // A declared template sent with values that don't fit it is a bad request, not a failed nudge.
130
+ const declared = declaredTemplate(agent.templates(), body.template);
131
+ try {
132
+ if (declared)
133
+ fillTemplate(declared, body.template);
134
+ }
135
+ catch (error) {
136
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
137
+ }
138
+ }
139
+ const { idempotency_key, respect_quiet_hours, expires_at, ...rest } = body;
140
+ const input = {
141
+ ...rest,
142
+ ...(idempotency_key ? { idempotencyKey: idempotency_key } : {}),
143
+ ...(respect_quiet_hours !== undefined ? { respectQuietHours: respect_quiet_hours } : {}),
144
+ ...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
145
+ };
146
+ const state = await agent.recordNudge(input);
147
+ const kind = body.text !== undefined ? "text" : body.prompt !== undefined ? "prompt" : "template";
148
+ console.info(JSON.stringify({ chinwag: "nudge", id: state.id, status: state.status, ...(state.reason ? { reason: state.reason } : {}), channel: body.to.channel, kind, at: new Date().toISOString(), ip }));
149
+ return json(res, 202, state);
150
+ }
151
+ return (req, res) => handle(req, res).catch((error) => {
152
+ console.warn("flow nudges:", error);
153
+ if (!res.headersSent)
154
+ json(res, 500, { error: "the nudge could not be recorded" });
155
+ });
156
+ }
157
+ /** Checks the bearer token, answering 401 or 429 itself when it fails. */
158
+ function authorized(req, res, ip, token, tries) {
159
+ const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
160
+ let refusal = null;
161
+ if (bearer === undefined)
162
+ refusal = [401, "send Authorization: Bearer <token>"];
163
+ else if (tries.blocked(ip))
164
+ refusal = [429, "too many wrong tokens from this address; try again later"];
165
+ else if (!sameSecret(bearer, token)) {
166
+ tries.failed(ip);
167
+ refusal = [401, "wrong token"];
168
+ }
169
+ if (refusal)
170
+ json(res, refusal[0], { error: refusal[1] });
171
+ return refusal === null;
172
+ }
173
+ const To = z.object({ channel: z.string().min(1), address: z.string().min(1) }).strict();
174
+ const TimeZoneBody = z.object({ to: To, time_zone: z.string().min(1) }).strict();
175
+ const Hours = z.object({ from: z.string(), to: z.string() }).strict();
176
+ const QuietHoursBody = z.object({ to: To, quiet_hours: z.union([Hours, z.literal("off"), z.null()]) }).strict();
177
+ /**
178
+ * What a business knows about people, over HTTP: their time zone and quiet hours, and their whole
179
+ * contact record (what they and the business set). Requests outside `base` get 404 without a token check.
180
+ *
181
+ * PUT {base}/time-zone { to, time_zone } → 200 { status: "set" }
182
+ * PUT {base}/quiet-hours { to, quiet_hours: { from, to } | "off" | null } (null: the agent's)
183
+ * GET {base}/contact?channel=&address= → 200 the contact record
184
+ *
185
+ * Someone who never wrote is 404 `{ status: "refused", reason: "unknown_person" }`.
186
+ */
187
+ export function peopleHandler(agent, options) {
188
+ requireToken(options?.token, "flow people");
189
+ const token = options.token;
190
+ const base = withoutTrailingSlashes(options.base ?? "/people");
191
+ const tries = wrongTries();
192
+ const routes = new Set(["time-zone", "quiet-hours", "contact"]);
193
+ const unknown = { status: "refused", reason: "unknown_person" };
194
+ async function handle(req, res) {
195
+ const url = new URL(req.url ?? "/", "http://localhost");
196
+ const route = url.pathname.startsWith(`${base}/`) ? url.pathname.slice(base.length + 1) : "";
197
+ if (!routes.has(route))
198
+ return json(res, 404, { error: "not found" });
199
+ const ip = req.socket.remoteAddress ?? "unknown";
200
+ if (!authorized(req, res, ip, token, tries))
201
+ return;
202
+ const channels = agent.channels().map((c) => c.name);
203
+ const badChannel = (channel) => (channels.includes(channel) ? null : `to.channel must be one of the agent's channels: ${channels.join(", ") || "it has none"}`);
204
+ if (route === "contact") {
205
+ if (req.method !== "GET")
206
+ return json(res, 404, { error: "not found" });
207
+ const to = { channel: url.searchParams.get("channel") ?? "", address: url.searchParams.get("address") ?? "" };
208
+ if (!to.channel || !to.address)
209
+ return json(res, 400, { error: "give channel and address as query parameters" });
210
+ const problem = badChannel(to.channel);
211
+ if (problem)
212
+ return json(res, 400, { error: problem });
213
+ const contact = await agent.contact(to);
214
+ return contact ? json(res, 200, contact) : json(res, 404, unknown);
215
+ }
216
+ if (req.method !== "PUT")
217
+ return json(res, 404, { error: "not found" });
218
+ const raw = await readBody(req);
219
+ if (raw === undefined)
220
+ return json(res, 400, { error: `the body must be JSON of at most ${MAX_BODY} bytes` });
221
+ const parsed = (route === "time-zone" ? TimeZoneBody : QuietHoursBody).safeParse(raw);
222
+ if (!parsed.success)
223
+ return json(res, 400, { error: parsed.error.issues.map((i) => (i.path.length ? `${i.path.join(".")}: ${i.message}` : i.message)).join("; ") });
224
+ const body = parsed.data;
225
+ const problem = badChannel(body.to.channel);
226
+ if (problem)
227
+ return json(res, 400, { error: problem });
228
+ let result;
229
+ try {
230
+ result = route === "time-zone" ? await agent.setTimeZone({ to: body.to, timeZone: body.time_zone }) : await agent.setQuietHours({ to: body.to, quietHours: body.quiet_hours ?? null });
231
+ }
232
+ catch (error) {
233
+ return json(res, 400, { error: error instanceof Error ? error.message.replace(/^flow: /, "") : String(error) });
234
+ }
235
+ return json(res, result.status === "set" ? 200 : 404, result);
236
+ }
237
+ return (req, res) => handle(req, res).catch((error) => {
238
+ console.warn("flow people:", error);
239
+ if (!res.headersSent)
240
+ json(res, 500, { error: "that could not be done" });
241
+ });
242
+ }
243
+ /** A path with its trailing slashes taken off, in one pass (no regular expression to backtrack). */
244
+ export function withoutTrailingSlashes(path) {
245
+ let end = path.length;
246
+ while (end > 0 && path[end - 1] === "/")
247
+ end--;
248
+ return path.slice(0, end);
249
+ }
250
+ /** The parsed JSON body, or undefined when it is too large or not JSON. */
251
+ async function readBody(req) {
252
+ let size = 0;
253
+ const chunks = [];
254
+ for await (const chunk of req) {
255
+ size += chunk.length;
256
+ if (size > MAX_BODY)
257
+ return undefined;
258
+ chunks.push(chunk);
259
+ }
260
+ try {
261
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
262
+ }
263
+ catch {
264
+ return undefined;
265
+ }
266
+ }
267
+ function json(res, status, value) {
268
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }).end(JSON.stringify(value));
269
+ }
@@ -0,0 +1,14 @@
1
+ export { Agent, createAgent, type AgentOptions, type AgentStatus, type Contact, type HeldItem, type ScheduledItem, type NudgeInput, type NudgeResult, type NudgeState, type NudgeStatus, type PlaygroundSink, type ReceiveOptions, type TestPerson } from "./agent.ts";
2
+ export { customUI, declaredTemplate, fillTemplate, fitMessage, sms, SMS_KEYWORDS, US_CARRIER_KEYWORDS, WHATSAPP_KEYWORDS, whatsapp, SMS, WHATSAPP, type Channel, type ChannelKind, type ChannelSummary, type CustomUIProvider, type FilledTemplate, type Keywords, type Message, type SmsProvider, type WhatsAppFeature, type WhatsAppProvider, } from "./channels.ts";
3
+ export { Conversation, type Profile, type TranscriptEntry } from "./conversation.ts";
4
+ export { nudgeHandler, peopleHandler, type NudgeHandlerOptions, type PeopleHandlerOptions } from "./http.ts";
5
+ export { sqlite, type Database } from "./db.ts";
6
+ export { postgres, type PostgresPool } from "./postgres.ts";
7
+ export { jsonlSink, type EventSink, type FlowEvent, type ChinwagEvent } from "./events.ts";
8
+ export { openAICompatible, type OpenAICompatibleOptions } from "./model.ts";
9
+ export { Store, type StoreApi } from "./store.ts";
10
+ export type { AgentFiles } from "./skills.ts";
11
+ export type { TracesOptions } from "./traces.ts";
12
+ export { tool, toSpec, type Tool, type ToolContext } from "./tools.ts";
13
+ export type { Attachment, ChannelInfo, ChatMessage, Deliver, Incoming, ListRow, Media, Model, ModelRequest, ModelResponse, Outbound, PersonMessage, Template, TemplateButton, TemplateDefinition, ToolCall, ToolSpec, Transcribe } from "./types.ts";
14
+ export { z } from "zod";
@@ -0,0 +1,11 @@
1
+ export { Agent, createAgent } from "./agent.js";
2
+ export { customUI, declaredTemplate, fillTemplate, fitMessage, sms, SMS_KEYWORDS, US_CARRIER_KEYWORDS, WHATSAPP_KEYWORDS, whatsapp, SMS, WHATSAPP, } from "./channels.js";
3
+ export { Conversation } from "./conversation.js";
4
+ export { nudgeHandler, peopleHandler } from "./http.js";
5
+ export { sqlite } from "./db.js";
6
+ export { postgres } from "./postgres.js";
7
+ export { jsonlSink } from "./events.js";
8
+ export { openAICompatible } from "./model.js";
9
+ export { Store } from "./store.js";
10
+ export { tool, toSpec } from "./tools.js";
11
+ export { z } from "zod";
@@ -0,0 +1,21 @@
1
+ import type { Model } from "./types.ts";
2
+ export interface OpenAICompatibleOptions {
3
+ /** The provider's model id. When omitted, the key in the environment picks a default. */
4
+ model?: string;
5
+ apiKey?: string;
6
+ baseURL?: string;
7
+ temperature?: number;
8
+ /** The model sees photos (OpenAI-style image_url parts). Default true; false for a text-only model. */
9
+ images?: boolean;
10
+ /** The model reads PDFs (file parts). Default true on OpenRouter, which reads them for any model; false elsewhere. */
11
+ pdfs?: boolean;
12
+ /** Its context window in tokens, so the history sent is kept within it. */
13
+ contextTokens?: number;
14
+ }
15
+ /** Which key, address and model an omitted `apiKey` / `baseURL` resolve to. */
16
+ export declare function modelFromKeys(env: Record<string, string | undefined>): {
17
+ apiKey?: string;
18
+ baseURL: string;
19
+ model: string;
20
+ };
21
+ export declare function openAICompatible(options?: OpenAICompatibleOptions): Model;
@@ -0,0 +1,96 @@
1
+ // Models over the OpenAI-compatible chat API: OpenRouter by default; OpenAI, Gemini, Anthropic or a
2
+ // local server by changing the base URL.
3
+ import OpenAI from "openai";
4
+ /** The first of these with a value is the agent's model. `MODEL` overrides that provider's default id. */
5
+ const KEYS = [
6
+ { env: "OPENROUTER_API_KEY", baseURL: "https://openrouter.ai/api/v1", model: "anthropic/claude-haiku-5.5" },
7
+ { env: "OPENAI_API_KEY", baseURL: "https://api.openai.com/v1", model: "gpt-6-luna" },
8
+ { env: "ANTHROPIC_API_KEY", baseURL: "https://api.anthropic.com/v1/", model: "claude-haiku-5-5" },
9
+ { env: "GEMINI_API_KEY", baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", model: "gemini-3.8-flash" },
10
+ ];
11
+ /** Which key, address and model an omitted `apiKey` / `baseURL` resolve to. */
12
+ export function modelFromKeys(env) {
13
+ const named = env.MODEL?.trim();
14
+ for (const provider of KEYS) {
15
+ const apiKey = env[provider.env]?.trim();
16
+ if (apiKey)
17
+ return { apiKey, baseURL: provider.baseURL, model: named || provider.model };
18
+ }
19
+ return { baseURL: KEYS[0].baseURL, model: named || KEYS[0].model };
20
+ }
21
+ export function openAICompatible(options = {}) {
22
+ // An explicit key or address is that provider. Otherwise the first key in the environment wins.
23
+ const fromEnv = options.apiKey !== undefined || options.baseURL !== undefined ? undefined : modelFromKeys(process.env);
24
+ const baseURL = options.baseURL ?? fromEnv?.baseURL ?? KEYS[0].baseURL;
25
+ const apiKey = options.apiKey ?? fromEnv?.apiKey ?? (process.env.OPENROUTER_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim());
26
+ // A server at a base URL you name (a local one) may need no key; the client still sends one.
27
+ if (!apiKey && options.baseURL === undefined) {
28
+ throw new Error(`No model key: set one of ${KEYS.map((k) => k.env).join(", ")} (in the agent's .env), or pass apiKey, or baseURL for a server that needs none.`);
29
+ }
30
+ const client = new OpenAI({
31
+ apiKey: apiKey || "none",
32
+ baseURL,
33
+ maxRetries: 1,
34
+ });
35
+ const model = options.model?.trim() || fromEnv?.model || KEYS[0].model;
36
+ return {
37
+ reads: { images: options.images ?? true, pdfs: options.pdfs ?? baseURL.startsWith("https://openrouter.ai/") },
38
+ ...(options.contextTokens ? { contextTokens: options.contextTokens } : {}),
39
+ async complete(request) {
40
+ const completion = await client.chat.completions.create({
41
+ model,
42
+ temperature: options.temperature,
43
+ messages: [{ role: "system", content: request.system }, ...request.messages.map(toWire)],
44
+ ...(request.tools.length
45
+ ? { tools: request.tools.map((t) => ({ type: "function", function: t })) }
46
+ : {}),
47
+ }, { signal: request.signal });
48
+ const message = completion.choices[0]?.message;
49
+ return {
50
+ text: message?.content?.trim() || null,
51
+ toolCalls: (message?.tool_calls ?? [])
52
+ .filter((call) => call.type === "function")
53
+ .map((call) => ({ id: call.id, name: call.function.name, arguments: call.function.arguments || "{}" })),
54
+ usage: completion.usage ? { inputTokens: completion.usage.prompt_tokens, outputTokens: completion.usage.completion_tokens, ...costOf(completion.usage) } : undefined,
55
+ model: completion.model,
56
+ };
57
+ },
58
+ };
59
+ }
60
+ function toWire(message) {
61
+ if (message.role === "tool")
62
+ return { role: "tool", tool_call_id: message.toolCallId, content: message.content };
63
+ if (message.role === "user") {
64
+ const media = (message.media ?? []).filter((m) => m.data);
65
+ if (!media.length)
66
+ return { role: "user", content: message.content };
67
+ return {
68
+ role: "user",
69
+ content: [
70
+ { type: "text", text: message.content },
71
+ ...media.map((m) => {
72
+ const url = `data:${m.mime};base64,${m.data}`;
73
+ return m.kind === "image" ? { type: "image_url", image_url: { url } } : { type: "file", file: { filename: m.name ?? "document.pdf", file_data: url } };
74
+ }),
75
+ ],
76
+ };
77
+ }
78
+ return {
79
+ role: "assistant",
80
+ content: message.content,
81
+ ...(message.toolCalls?.length
82
+ ? {
83
+ tool_calls: message.toolCalls.map((call) => ({
84
+ id: call.id,
85
+ type: "function",
86
+ function: { name: call.name, arguments: call.arguments },
87
+ })),
88
+ }
89
+ : {}),
90
+ };
91
+ }
92
+ /** OpenRouter adds the call's cost in US dollars to usage; other providers leave it out. */
93
+ function costOf(usage) {
94
+ const cost = usage.cost;
95
+ return typeof cost === "number" ? { costUsd: cost } : {};
96
+ }
@@ -0,0 +1,22 @@
1
+ /** A setting from the environment: FLOW_FRAMEWORK_<name>, else CHINWAG_<name> from before the rename. */
2
+ export declare function setting(name: string, env?: NodeJS.ProcessEnv): string | undefined;
3
+ /**
4
+ * The command to print in a next step. `npx flow` would fetch another npm package wherever this one is
5
+ * not installed, so a hint always names the package.
6
+ */
7
+ export declare const RUN = "npx @flow-engineer/flow";
8
+ /** An agent's results folder, in the agent's folder: written here, and git-ignored by `flow new`. */
9
+ export declare const RESULTS_DIR = ".flow";
10
+ /** The results folder from before the rename, still read until a run writes {@link RESULTS_DIR}. */
11
+ export declare const OLD_RESULTS_DIR = ".chinwag";
12
+ /** ~/.config/flow-framework (XDG_CONFIG_HOME when set): where this machine's files are written. */
13
+ export declare function configDir(env?: NodeJS.ProcessEnv): string;
14
+ /** ~/.config/chinwag (XDG_CONFIG_HOME when set): the config folder from before the rename, only read. */
15
+ export declare function oldConfigDir(env?: NodeJS.ProcessEnv): string;
16
+ /**
17
+ * The file to read for a name in the config folder: ~/.config/flow-framework/<file>, else, while only it
18
+ * exists, ~/.config/chinwag/<file>. Write to {@link configDir} whichever this returns.
19
+ */
20
+ export declare function configFile(file: string, env?: NodeJS.ProcessEnv): string;
21
+ /** The file to read in an agent's results folder: .flow/<file>, else, while only it exists, .chinwag/<file>. */
22
+ export declare function resultsFile(dir: string, file: string): string;
@@ -0,0 +1,43 @@
1
+ // The Flow Framework's names on a machine: its settings in the environment, its config folder and an
2
+ // agent's results folder. Each is read under its name from before the rename (chinwag) too, so an
3
+ // install that moved from chinwag keeps working; everything new is written under Flow Framework's name.
4
+ import { existsSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ /** A setting from the environment: FLOW_FRAMEWORK_<name>, else CHINWAG_<name> from before the rename. */
8
+ export function setting(name, env = process.env) {
9
+ return env[`FLOW_FRAMEWORK_${name}`] ?? env[`CHINWAG_${name}`];
10
+ }
11
+ /**
12
+ * The command to print in a next step. `npx flow` would fetch another npm package wherever this one is
13
+ * not installed, so a hint always names the package.
14
+ */
15
+ export const RUN = "npx @flow-engineer/flow";
16
+ /** An agent's results folder, in the agent's folder: written here, and git-ignored by `flow new`. */
17
+ export const RESULTS_DIR = ".flow";
18
+ /** The results folder from before the rename, still read until a run writes {@link RESULTS_DIR}. */
19
+ export const OLD_RESULTS_DIR = ".chinwag";
20
+ const configBase = (env) => env.XDG_CONFIG_HOME || join(homedir(), ".config");
21
+ /** ~/.config/flow-framework (XDG_CONFIG_HOME when set): where this machine's files are written. */
22
+ export function configDir(env = process.env) {
23
+ return join(configBase(env), "flow-framework");
24
+ }
25
+ /** ~/.config/chinwag (XDG_CONFIG_HOME when set): the config folder from before the rename, only read. */
26
+ export function oldConfigDir(env = process.env) {
27
+ return join(configBase(env), "chinwag");
28
+ }
29
+ /**
30
+ * The file to read for a name in the config folder: ~/.config/flow-framework/<file>, else, while only it
31
+ * exists, ~/.config/chinwag/<file>. Write to {@link configDir} whichever this returns.
32
+ */
33
+ export function configFile(file, env = process.env) {
34
+ const path = join(configDir(env), file);
35
+ const old = join(oldConfigDir(env), file);
36
+ return !existsSync(path) && existsSync(old) ? old : path;
37
+ }
38
+ /** The file to read in an agent's results folder: .flow/<file>, else, while only it exists, .chinwag/<file>. */
39
+ export function resultsFile(dir, file) {
40
+ const path = join(dir, RESULTS_DIR, file);
41
+ const old = join(dir, OLD_RESULTS_DIR, file);
42
+ return !existsSync(path) && existsSync(old) ? old : path;
43
+ }