@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,143 @@
1
+ export declare const LOGIN_URL = "https://flow.engineer";
2
+ /** The longest one request to flow.engineer may take; login's polls are cut shorter by what is left of --timeout. */
3
+ export declare const REQUEST_TIMEOUT_MS = 30000;
4
+ /** A login URL a token may go to: https, or http to this machine only (a local flow.engineer). */
5
+ export declare function safeLoginUrl(url: string): boolean;
6
+ /** flow.engineer, or FLOW_FRAMEWORK_LOGIN_URL, without trailing slashes (trimmed from the end, in linear time). */
7
+ export declare function loginUrl(env?: NodeJS.ProcessEnv): string;
8
+ /** ~/.config/flow-framework/credentials.json (XDG_CONFIG_HOME when set), written at mode 0600. */
9
+ export declare function credentialsPath(env?: NodeJS.ProcessEnv): string;
10
+ export interface Credentials {
11
+ token: string;
12
+ expires_at?: string;
13
+ /** The server that issued it, so logout revokes it there. */
14
+ url: string;
15
+ }
16
+ /**
17
+ * The token to use: FLOW_FRAMEWORK_TOKEN, else the credentials file's (with `url`, the server that
18
+ * issued it, the only one it is sent to); undefined when signed out.
19
+ */
20
+ export declare function currentToken(env?: NodeJS.ProcessEnv): {
21
+ token: string;
22
+ from: "env" | "file";
23
+ url?: string;
24
+ } | undefined;
25
+ export declare function readCredentials(env?: NodeJS.ProcessEnv): Credentials | undefined;
26
+ export declare function saveCredentials(credentials: Credentials, env?: NodeJS.ProcessEnv): string;
27
+ export declare function deleteCredentials(env?: NodeJS.ProcessEnv): boolean;
28
+ /** A device code started by `flow login` and not approved yet, kept for `flow login --wait`. */
29
+ export interface PendingLogin extends DeviceStart {
30
+ /** When the code expires, in ms since the epoch. */
31
+ expires_at: number;
32
+ /** The server that issued it. */
33
+ url: string;
34
+ }
35
+ /** ~/.config/flow-framework/pending.json, written at mode 0600 and deleted once the code is used or expired. */
36
+ export declare function pendingPath(env?: NodeJS.ProcessEnv): string;
37
+ export declare function readPending(env?: NodeJS.ProcessEnv): PendingLogin | undefined;
38
+ export declare function savePending(pending: PendingLogin, env?: NodeJS.ProcessEnv): void;
39
+ export declare function deletePending(env?: NodeJS.ProcessEnv): void;
40
+ export interface DeviceStart {
41
+ device_code: string;
42
+ user_code: string;
43
+ verification_uri: string;
44
+ verification_uri_complete?: string;
45
+ interval: number;
46
+ expires_in: number;
47
+ }
48
+ export type TokenAnswer = {
49
+ status: "signed_in";
50
+ token: string;
51
+ expires_at?: string;
52
+ account?: Account;
53
+ } | {
54
+ status: "authorization_pending" | "slow_down" | "expired_token" | "access_denied";
55
+ };
56
+ export interface Account {
57
+ email: string;
58
+ account: string;
59
+ }
60
+ export interface SetupAnswer {
61
+ agent_id: string;
62
+ /** Only the keys minted in this call come back (those the request did not say it has). */
63
+ cloud_key?: string;
64
+ traces_key?: string;
65
+ messaging_key?: string;
66
+ /** Each channel's shared sender and phone link, only while Flow has that channel open. */
67
+ telegram_sender?: string;
68
+ whatsapp_sender?: string;
69
+ imessage_sender?: string;
70
+ whatsapp_link?: string;
71
+ imessage_link?: string;
72
+ /** Missing only when the server could not make one; the keys it minted are written anyway. */
73
+ join_code?: string;
74
+ account?: Account;
75
+ app_id?: string;
76
+ dashboard_url?: string;
77
+ /** The flow.engineer page that opens Telegram with the join message filled in. */
78
+ try_url?: string;
79
+ telegram_link?: string;
80
+ /** The try page per channel with a sender: {telegram: ".../try/<code>?c=telegram", ...}. */
81
+ try_urls?: Record<string, string>;
82
+ /** The account's free-trial counts per channel; limit null is unlimited. iMessage also counts the people it may reach. */
83
+ trial?: Record<string, {
84
+ limit: number | null;
85
+ used: number;
86
+ contacts?: {
87
+ limit: number;
88
+ used: number;
89
+ };
90
+ }>;
91
+ /** The answer fields of the keys made in this call. */
92
+ minted?: string[];
93
+ next?: string[];
94
+ /** Up to 20 lines for the owner, shown as they are. */
95
+ warnings?: string[];
96
+ }
97
+ /** The answer's warnings: its strings only, at most 20. */
98
+ export declare function warningsOf(body: Record<string, unknown>): string[];
99
+ /** Retry-After as seconds from now (delta-seconds or an HTTP date); undefined when absent or unreadable. */
100
+ export declare function retryAfterSeconds(header: string | null, now?: number): number | undefined;
101
+ /**
102
+ * The server answered with an error status; `code` is its `error` field when it gave one, `next` its
103
+ * next step, and `fields` the whole answer (a failed setup carries the keys it minted anyway).
104
+ */
105
+ export declare class LoginRefused extends Error {
106
+ readonly status: number;
107
+ readonly code: string;
108
+ readonly next: string | undefined;
109
+ readonly fields: Record<string, unknown>;
110
+ /** Seconds the server asked to wait (Retry-After), for a 429. */
111
+ readonly retryAfter: number | undefined;
112
+ constructor(status: number, code: string, message: string, next?: string, fields?: Record<string, unknown>, retryAfter?: number);
113
+ }
114
+ /** The server answered nothing in time: `ms` is how long this request was given. */
115
+ export declare class TimedOut extends Error {
116
+ readonly ms: number;
117
+ constructor(url: string, ms: number);
118
+ }
119
+ /** `clientName` and `hostname` are shown on the approval page. */
120
+ export declare function startDevice(url: string, clientName: string, hostname: string): Promise<DeviceStart>;
121
+ /** `timeoutMs` cuts this one poll short (login gives it what is left of --timeout). */
122
+ export declare function pollToken(url: string, deviceCode: string, timeoutMs?: number): Promise<TokenAnswer>;
123
+ export declare function revoke(url: string, token: string): Promise<void>;
124
+ export declare function whoami(url: string, token: string): Promise<Account>;
125
+ export declare function setup(url: string, token: string, request: {
126
+ name: string;
127
+ hostname: string;
128
+ channels: string[];
129
+ have: string[];
130
+ }): Promise<SetupAnswer>;
131
+ /** The agent's .env names for what setup hands back; only the keys' and the join's names are sent as `have`. */
132
+ export declare const SETUP_ENV: {
133
+ readonly cloud_key: "FLOW_FRAMEWORK_CLOUD_KEY";
134
+ readonly traces_key: "FLOW_FRAMEWORK_TRACES_KEY";
135
+ readonly messaging_key: "FLOW_MESSAGING_KEY";
136
+ readonly telegram_sender: "FLOW_MESSAGING_TELEGRAM_SENDER";
137
+ readonly whatsapp_sender: "FLOW_MESSAGING_WHATSAPP_SENDER";
138
+ readonly imessage_sender: "FLOW_MESSAGING_IMESSAGE_SENDER";
139
+ readonly join_code: "FLOW_FRAMEWORK_JOIN_CODE";
140
+ readonly try_url: "FLOW_FRAMEWORK_TRY_URL";
141
+ };
142
+ /** The phone link for a join code, built as the server builds its try_url, for a .env from before setup kept it. */
143
+ export declare function tryLink(url: string, joinCode: string, channel: string): string;
@@ -0,0 +1,249 @@
1
+ // The flow.engineer side of `flow login`, `logout`, `whoami` and `setup`: every call to it, and
2
+ // where the Flow CLI token is kept. Only this file knows the server's routes and field names
3
+ // (flow.engineer's CLI sign-in contract).
4
+ // POST /cli/device {client_name, hostname} -> {device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}
5
+ // POST /cli/token {device_code} -> {access_token, expires_at, account}, or 400 {error} as RFC 8628 has it:
6
+ // authorization_pending, slow_down, expired_token, access_denied, invalid_grant
7
+ // POST /cli/revoke (bearer) {} ends this token
8
+ // GET /cli/whoami (bearer) -> {account, email, token}
9
+ // POST /cli/setup (bearer) {name, hostname, channels, have}
10
+ // -> {agent_id, cloud_key?, traces_key?, messaging_key?, join_code, account, app_id, dashboard_url, try_url,
11
+ // <channel>_sender?, <channel>_link? (telegram, whatsapp, imessage: each only while that shared sender
12
+ // is open), try_urls?, trial?, minted, next}; a 502 carries every field that worked too;
13
+ // 429 slow_down with Retry-After; 403 for a trial limit (sandbox_allowance_used, ...), its next prose
14
+ // Any answer may carry warnings: up to 20 strings to show the owner as they are.
15
+ // Every error is flat: {"error": "<code>", "error_description": "<sentence>", "next": "<command, URL or sentence>"}.
16
+ // The token is opaque (fcli_...), good only for /cli/*, and sent only to the server that issued it,
17
+ // over https (http only to this machine). Every request gives up after REQUEST_TIMEOUT_MS at most.
18
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { configDir, configFile, RUN, setting } from "../../src/names.js";
21
+ export const LOGIN_URL = "https://flow.engineer";
22
+ /** The longest one request to flow.engineer may take; login's polls are cut shorter by what is left of --timeout. */
23
+ export const REQUEST_TIMEOUT_MS = 30_000;
24
+ /** A login URL a token may go to: https, or http to this machine only (a local flow.engineer). */
25
+ export function safeLoginUrl(url) {
26
+ const parsed = URL.parse(url);
27
+ if (!parsed)
28
+ return false;
29
+ if (parsed.protocol === "https:")
30
+ return true;
31
+ return parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
32
+ }
33
+ /** flow.engineer, or FLOW_FRAMEWORK_LOGIN_URL, without trailing slashes (trimmed from the end, in linear time). */
34
+ export function loginUrl(env = process.env) {
35
+ const url = setting("LOGIN_URL", env) || LOGIN_URL;
36
+ let end = url.length;
37
+ while (end > 0 && url[end - 1] === "/")
38
+ end--;
39
+ return url.slice(0, end);
40
+ }
41
+ /** ~/.config/flow-framework/credentials.json (XDG_CONFIG_HOME when set), written at mode 0600. */
42
+ export function credentialsPath(env = process.env) {
43
+ return join(configDir(env), "credentials.json");
44
+ }
45
+ /** The credentials file to read or delete: credentialsPath, else ~/.config/chinwag/credentials.json while only it exists. */
46
+ const savedCredentialsPath = (env) => configFile("credentials.json", env);
47
+ /**
48
+ * The token to use: FLOW_FRAMEWORK_TOKEN, else the credentials file's (with `url`, the server that
49
+ * issued it, the only one it is sent to); undefined when signed out.
50
+ */
51
+ export function currentToken(env = process.env) {
52
+ const fromEnv = setting("TOKEN", env)?.trim();
53
+ if (fromEnv)
54
+ return { token: fromEnv, from: "env" };
55
+ const saved = readCredentials(env);
56
+ return saved ? { token: saved.token, from: "file", url: saved.url || LOGIN_URL } : undefined;
57
+ }
58
+ export function readCredentials(env = process.env) {
59
+ const path = savedCredentialsPath(env);
60
+ if (!existsSync(path))
61
+ return undefined;
62
+ try {
63
+ const saved = JSON.parse(readFileSync(path, "utf8"));
64
+ return typeof saved.token === "string" && saved.token ? saved : undefined;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ export function saveCredentials(credentials, env = process.env) {
71
+ const path = credentialsPath(env);
72
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
73
+ writeFileSync(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
74
+ chmodSync(path, 0o600); // a file that was already there keeps its mode on write
75
+ return path;
76
+ }
77
+ export function deleteCredentials(env = process.env) {
78
+ const path = savedCredentialsPath(env);
79
+ if (!existsSync(path))
80
+ return false;
81
+ rmSync(path);
82
+ return true;
83
+ }
84
+ /** ~/.config/flow-framework/pending.json, written at mode 0600 and deleted once the code is used or expired. */
85
+ export function pendingPath(env = process.env) {
86
+ return join(configDir(env), "pending.json");
87
+ }
88
+ export function readPending(env = process.env) {
89
+ try {
90
+ const saved = JSON.parse(readFileSync(pendingPath(env), "utf8"));
91
+ return typeof saved.device_code === "string" && typeof saved.expires_at === "number" ? saved : undefined;
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
97
+ export function savePending(pending, env = process.env) {
98
+ const path = pendingPath(env);
99
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
100
+ writeFileSync(path, `${JSON.stringify(pending, null, 2)}\n`, { mode: 0o600 });
101
+ chmodSync(path, 0o600);
102
+ }
103
+ export function deletePending(env = process.env) {
104
+ rmSync(pendingPath(env), { force: true });
105
+ }
106
+ /** The answer's warnings: its strings only, at most 20. */
107
+ export function warningsOf(body) {
108
+ return Array.isArray(body.warnings) ? body.warnings.filter((w) => typeof w === "string" && w !== "").slice(0, 20) : [];
109
+ }
110
+ /** Retry-After as seconds from now (delta-seconds or an HTTP date); undefined when absent or unreadable. */
111
+ export function retryAfterSeconds(header, now = Date.now()) {
112
+ if (!header)
113
+ return undefined;
114
+ const value = header.trim();
115
+ if (/^\d+$/.test(value))
116
+ return Number(value);
117
+ const at = Date.parse(value);
118
+ return Number.isNaN(at) ? undefined : Math.max(0, Math.ceil((at - now) / 1000));
119
+ }
120
+ /**
121
+ * The server answered with an error status; `code` is its `error` field when it gave one, `next` its
122
+ * next step, and `fields` the whole answer (a failed setup carries the keys it minted anyway).
123
+ */
124
+ export class LoginRefused extends Error {
125
+ status;
126
+ code;
127
+ next;
128
+ fields;
129
+ /** Seconds the server asked to wait (Retry-After), for a 429. */
130
+ retryAfter;
131
+ constructor(status, code, message, next, fields = {}, retryAfter) {
132
+ super(message);
133
+ this.status = status;
134
+ this.code = code;
135
+ this.next = next;
136
+ this.fields = fields;
137
+ this.retryAfter = retryAfter;
138
+ }
139
+ }
140
+ /** The server answered nothing in time: `ms` is how long this request was given. */
141
+ export class TimedOut extends Error {
142
+ ms;
143
+ constructor(url, ms) {
144
+ super(`${url} did not answer within ${Math.ceil(ms / 1000)} s.`);
145
+ this.ms = ms;
146
+ }
147
+ }
148
+ async function call(url, path, options = {}) {
149
+ if (!safeLoginUrl(url)) {
150
+ throw new LoginRefused(0, "insecure_login_url", `${url} is not https: the sign-in and its token go only over https (http only to localhost).`, `set FLOW_FRAMEWORK_LOGIN_URL to an https address, or unset it to use ${LOGIN_URL}`);
151
+ }
152
+ const timeoutMs = Math.max(0, Math.min(options.timeoutMs ?? REQUEST_TIMEOUT_MS, REQUEST_TIMEOUT_MS));
153
+ const headers = { accept: "application/json" };
154
+ if (options.token)
155
+ headers.authorization = `Bearer ${options.token}`;
156
+ if (options.body !== undefined)
157
+ headers["content-type"] = "application/json";
158
+ let response;
159
+ let text;
160
+ try {
161
+ response = await fetch(`${url}${path}`, {
162
+ method: options.method ?? "POST",
163
+ headers,
164
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
165
+ redirect: options.token ? "error" : "follow", // a redirect would carry the token to another address
166
+ signal: AbortSignal.timeout(timeoutMs),
167
+ });
168
+ text = await response.text();
169
+ }
170
+ catch (error) {
171
+ if (error.name === "TimeoutError")
172
+ throw new TimedOut(url, timeoutMs);
173
+ throw error;
174
+ }
175
+ let body = {};
176
+ try {
177
+ body = text ? JSON.parse(text) : {};
178
+ }
179
+ catch {
180
+ if (response.ok)
181
+ throw new LoginRefused(response.status, "bad_answer", `${url}${path} answered ${response.status} with no JSON.`);
182
+ }
183
+ return { status: response.status, body, retryAfter: retryAfterSeconds(response.headers.get("retry-after")) };
184
+ }
185
+ function refused(path, answer, url) {
186
+ // A bare 404: this server has no CLI sign-in (an older one, or not flow.engineer at all).
187
+ if (answer.status === 404 && typeof answer.body.error !== "string") {
188
+ return new LoginRefused(404, "server_unsupported", `${url} has no ${path}, so it cannot sign in the Flow CLI: it is an older server, or not flow.engineer.`, url === LOGIN_URL ? `${RUN}@latest login` : `unset FLOW_FRAMEWORK_LOGIN_URL to use ${LOGIN_URL}, or point it at a server that has /cli/`, answer.body);
189
+ }
190
+ const code = typeof answer.body.error === "string" ? answer.body.error : answer.status === 401 ? "not_signed_in" : `http_${answer.status}`;
191
+ const detail = typeof answer.body.error_description === "string" ? answer.body.error_description : typeof answer.body.message === "string" ? answer.body.message : "";
192
+ const next = typeof answer.body.next === "string" && answer.body.next ? answer.body.next : undefined;
193
+ const sentence = detail.endsWith(".") ? detail.slice(0, -1) : detail;
194
+ return new LoginRefused(answer.status, code, `${path} answered ${answer.status} ${code}${sentence ? `: ${sentence}` : ""}.`, next, answer.body, answer.retryAfter);
195
+ }
196
+ /** `clientName` and `hostname` are shown on the approval page. */
197
+ export async function startDevice(url, clientName, hostname) {
198
+ const answer = await call(url, "/cli/device", { body: { client_name: clientName, hostname } });
199
+ if (answer.status !== 200 || typeof answer.body.device_code !== "string")
200
+ throw refused("/cli/device", answer, url);
201
+ return answer.body;
202
+ }
203
+ const WAITING = new Set(["authorization_pending", "slow_down", "expired_token", "access_denied"]);
204
+ /** `timeoutMs` cuts this one poll short (login gives it what is left of --timeout). */
205
+ export async function pollToken(url, deviceCode, timeoutMs) {
206
+ const answer = await call(url, "/cli/token", { body: { device_code: deviceCode }, timeoutMs });
207
+ const token = answer.body.access_token;
208
+ if (answer.status === 200 && typeof token === "string") {
209
+ return { status: "signed_in", token, expires_at: answer.body.expires_at, account: answer.body.account };
210
+ }
211
+ const error = answer.body.error;
212
+ if (typeof error === "string" && WAITING.has(error))
213
+ return { status: error };
214
+ throw refused("/cli/token", answer, url);
215
+ }
216
+ export async function revoke(url, token) {
217
+ const answer = await call(url, "/cli/revoke", { token, body: {} });
218
+ // 401: the token had already ended, which is what logout wants.
219
+ if (answer.status >= 300 && answer.status !== 401)
220
+ throw refused("/cli/revoke", answer, url);
221
+ }
222
+ export async function whoami(url, token) {
223
+ const answer = await call(url, "/cli/whoami", { token, method: "GET" });
224
+ if (answer.status !== 200)
225
+ throw refused("/cli/whoami", answer, url);
226
+ return answer.body;
227
+ }
228
+ export async function setup(url, token, request) {
229
+ const answer = await call(url, "/cli/setup", { token, body: request });
230
+ // A 200 is returned whole even without a join code: the keys it minted are shown only now.
231
+ if (answer.status !== 200)
232
+ throw refused("/cli/setup", answer, url);
233
+ return answer.body;
234
+ }
235
+ /** The agent's .env names for what setup hands back; only the keys' and the join's names are sent as `have`. */
236
+ export const SETUP_ENV = {
237
+ cloud_key: "FLOW_FRAMEWORK_CLOUD_KEY",
238
+ traces_key: "FLOW_FRAMEWORK_TRACES_KEY",
239
+ messaging_key: "FLOW_MESSAGING_KEY",
240
+ telegram_sender: "FLOW_MESSAGING_TELEGRAM_SENDER",
241
+ whatsapp_sender: "FLOW_MESSAGING_WHATSAPP_SENDER",
242
+ imessage_sender: "FLOW_MESSAGING_IMESSAGE_SENDER",
243
+ join_code: "FLOW_FRAMEWORK_JOIN_CODE",
244
+ try_url: "FLOW_FRAMEWORK_TRY_URL",
245
+ };
246
+ /** The phone link for a join code, built as the server builds its try_url, for a .env from before setup kept it. */
247
+ export function tryLink(url, joinCode, channel) {
248
+ return `${url}/try/${encodeURIComponent(joinCode)}?c=${encodeURIComponent(channel)}`;
249
+ }
@@ -0,0 +1,47 @@
1
+ import { type ChannelInfo, type ChannelSummary, type Deliver, type EventSink, type Incoming, type Outbound, type ReceiveOptions } from "../../src/index.ts";
2
+ export interface HttpAgentOptions {
3
+ url: string;
4
+ /** Sent as `authorization: Bearer <token>` (FLOW_FRAMEWORK_AGENT_TOKEN). */
5
+ token?: string;
6
+ /** The channel every message names; default the platform the run plays (whatsapp, sms, imessage, telegram, web). */
7
+ channel?: string;
8
+ /** How long one message waits for its answer. Default 60 s. */
9
+ timeoutMs?: number;
10
+ events?: EventSink[];
11
+ log?: (line: string) => void;
12
+ }
13
+ /** The person's message as the agent's endpoint gets it. */
14
+ export interface HttpAgentRequest {
15
+ conversationId: string;
16
+ /** The id the cloud gave this message (m1, m2, ...): a reply's replyTo, a reaction's or a read's messageId. */
17
+ messageId: string;
18
+ channel: string;
19
+ message: {
20
+ text: string;
21
+ replyTo?: Incoming["replyTo"];
22
+ choice?: Incoming["choice"];
23
+ attachments?: Array<{
24
+ kind: "image" | "audio" | "document";
25
+ mime: string;
26
+ name?: string;
27
+ data: string;
28
+ }>;
29
+ };
30
+ }
31
+ export declare class TurnError extends Error {
32
+ }
33
+ /** POSTs one message and returns its replies, unchecked; throws a TurnError saying why when the answer fails the turn. */
34
+ export declare function postMessage(options: Pick<HttpAgentOptions, "url" | "token">, timeoutMs: number, body: HttpAgentRequest): Promise<unknown[]>;
35
+ /** The reply as the platform takes it, or why it cannot go: the checks a Flow Framework agent's send gets. */
36
+ export declare function checkReply(reply: unknown, info: ChannelInfo): {
37
+ outbound: Outbound;
38
+ } | {
39
+ refused: string;
40
+ };
41
+ export declare function httpAgent(options: HttpAgentOptions): {
42
+ /** None of its own: each run plays its platform as the platform is. */
43
+ channels(): ChannelSummary[];
44
+ receive(conversationId: string, message: string | Incoming, deliver: Deliver, receive?: ReceiveOptions): string | null;
45
+ /** The person reacted to one of its messages: not sent to the endpoint in this version. */
46
+ reacted(_conversationId: string, _emoji: string, _toText: string): void;
47
+ };
@@ -0,0 +1,155 @@
1
+ // An agent you already have, on any framework, tested through its HTTP endpoint: `flow test
2
+ // --agent-url <url>`. This plays the part of a Flow Framework Agent on the developer's machine: each of the
3
+ // person's messages is one POST to the URL from here (so the agent never has to be reachable from
4
+ // the internet), and the replies it answers go to the test cloud as a Flow Framework agent's would. Its own
5
+ // tools run on its side, for real.
6
+ //
7
+ // POST <url> { conversationId, messageId, channel, message: { text, attachments?: [{ kind, mime, name?, data }] } }
8
+ // 200 { replies: Outbound[] } (data is base64; [] is no reply)
9
+ //
10
+ // A conversation's messages go one at a time, in order: the next is sent once the last one's answer
11
+ // is in. Each reply is checked against what the run's platform can show, as a Flow Framework agent's send
12
+ // is. Agents that answer later (through a callback) are not supported yet.
13
+ import { Buffer } from "node:buffer";
14
+ import { emitter } from "../../src/events.js";
15
+ import { fitMessage, z } from "../../src/index.js";
16
+ import { sendInput, sendRefusal } from "../../src/talk.js";
17
+ const answerSchema = z.object({ replies: z.array(z.unknown()) });
18
+ const otherReply = z.discriminatedUnion("type", [
19
+ z.object({ type: z.literal("read"), messageIds: z.array(z.string()) }),
20
+ z.object({ type: z.literal("reaction"), emoji: z.string().min(1), messageId: z.string().min(1) }),
21
+ z.looseObject({ type: z.literal("template"), name: z.string().min(1) }),
22
+ z.object({ type: z.literal("status"), text: z.string().nullable() }),
23
+ z.object({ type: z.literal("typing"), on: z.boolean() }),
24
+ ]);
25
+ export class TurnError extends Error {
26
+ }
27
+ const problems = (error) => error.issues.map((i) => (i.code === "unrecognized_keys" ? `${i.keys.join(", ")}: not something this platform shows` : (i.path.length ? `${i.path.join(".")}: ` : "") + i.message)).join("; ");
28
+ /** POSTs one message and returns its replies, unchecked; throws a TurnError saying why when the answer fails the turn. */
29
+ export async function postMessage(options, timeoutMs, body) {
30
+ let response;
31
+ let text;
32
+ try {
33
+ response = await fetch(options.url, {
34
+ method: "POST",
35
+ headers: { "content-type": "application/json", ...(options.token ? { authorization: `Bearer ${options.token}` } : {}) },
36
+ body: JSON.stringify(body),
37
+ signal: AbortSignal.timeout(timeoutMs),
38
+ });
39
+ text = await response.text();
40
+ }
41
+ catch (error) {
42
+ const e = error;
43
+ if (e.name === "TimeoutError")
44
+ throw new TurnError(`no answer from ${options.url} within ${timeoutMs / 1000} s`);
45
+ throw new TurnError(`could not reach ${options.url}: ${e.cause?.code ?? e.cause?.message ?? e.message}`);
46
+ }
47
+ if (response.status !== 200)
48
+ throw new TurnError(`${options.url} answered ${response.status}${text.trim() ? `: ${text.trim().slice(0, 200)}` : ""}`);
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(text);
52
+ }
53
+ catch {
54
+ throw new TurnError(`${options.url} answered 200 with a body that is not JSON: ${text.slice(0, 200)}`);
55
+ }
56
+ const answer = answerSchema.safeParse(parsed);
57
+ if (!answer.success)
58
+ throw new TurnError(`${options.url} answered 200 without { "replies": [...] }: ${text.slice(0, 200)}`);
59
+ return answer.data.replies;
60
+ }
61
+ /** The reply as the platform takes it, or why it cannot go: the checks a Flow Framework agent's send gets. */
62
+ export function checkReply(reply, info) {
63
+ const type = reply?.type;
64
+ if (type !== "message") {
65
+ const other = otherReply.safeParse(reply);
66
+ return other.success ? { outbound: other.data } : { refused: problems(other.error) };
67
+ }
68
+ const { type: _, replyTo, fields, ...rest } = reply;
69
+ const input = { ...rest, ...(replyTo !== undefined ? { reply_to: replyTo } : {}) };
70
+ const sent = sendInput(info).strict().safeParse(input);
71
+ if (!sent.success)
72
+ return { refused: problems(sent.error).replaceAll("reply_to", "replyTo") };
73
+ if (fields !== undefined && (typeof fields !== "object" || fields === null || Array.isArray(fields)))
74
+ return { refused: "fields: must be an object" };
75
+ const refusal = sendRefusal(info, input);
76
+ if (refusal)
77
+ return { refused: refusal };
78
+ return { outbound: fitMessage(info, { type: "message", ...rest, ...(replyTo !== undefined ? { replyTo } : {}), ...(fields ? { fields } : {}) }) };
79
+ }
80
+ export function httpAgent(options) {
81
+ const emit = emitter(options.events ?? []);
82
+ const log = options.log ?? ((line) => console.log(line));
83
+ const timeoutMs = options.timeoutMs ?? 60_000;
84
+ /** Each conversation's last message, so the next one waits for its answer. */
85
+ const queues = new Map();
86
+ async function turn(conversationId, request, incoming, info, deliver) {
87
+ const started = Date.now();
88
+ emit(conversationId, "turn.start", {});
89
+ try {
90
+ if (incoming.attachments?.length) {
91
+ request.message.attachments = await Promise.all(incoming.attachments.map(async (a) => ({ kind: a.kind, mime: a.mime, ...(a.name ? { name: a.name } : {}), data: Buffer.from(await a.data()).toString("base64") })));
92
+ }
93
+ for (const reply of await postMessage(options, timeoutMs, request)) {
94
+ const checked = checkReply(reply, info);
95
+ const text = reply?.text;
96
+ const shown = typeof text === "string" ? text : `(${String(reply?.type ?? "reply")})`;
97
+ if ("refused" in checked) {
98
+ log(`${conversationId}: a reply was not sent: ${checked.refused}`);
99
+ emit(conversationId, "message.undelivered", { text: shown, reason: checked.refused });
100
+ continue;
101
+ }
102
+ const outbound = checked.outbound;
103
+ try {
104
+ await deliver(outbound);
105
+ }
106
+ catch (error) {
107
+ emit(conversationId, "message.undelivered", { text: shown, reason: error.message });
108
+ continue;
109
+ }
110
+ if (outbound.type === "message") {
111
+ const { type: _, ...sent } = outbound;
112
+ emit(conversationId, "message.out", { ...sent, channel: request.channel });
113
+ }
114
+ else if (outbound.type === "reaction") {
115
+ emit(conversationId, "reaction", { emoji: outbound.emoji, messageId: outbound.messageId });
116
+ }
117
+ }
118
+ }
119
+ catch (error) {
120
+ const reason = error instanceof TurnError ? error.message : `the message could not be sent: ${error.message}`;
121
+ log(`${conversationId}: ${reason}`);
122
+ emit(conversationId, "model.error", { error: reason });
123
+ emit(conversationId, "message.undelivered", { text: "(the agent's answer)", reason });
124
+ }
125
+ finally {
126
+ emit(conversationId, "turn.end", { ms: Date.now() - started });
127
+ }
128
+ }
129
+ return {
130
+ /** None of its own: each run plays its platform as the platform is. */
131
+ channels() {
132
+ return [];
133
+ },
134
+ receive(conversationId, message, deliver, receive = {}) {
135
+ const incoming = typeof message === "string" ? { text: message } : message;
136
+ const messageId = receive.messageId ?? "";
137
+ // The cloud's conversations are "<platform>:<person>".
138
+ const channel = options.channel ?? /^([a-z]+):/.exec(conversationId)?.[1] ?? "chat";
139
+ const request = {
140
+ conversationId,
141
+ messageId,
142
+ channel,
143
+ message: { text: incoming.text, ...(incoming.replyTo ? { replyTo: incoming.replyTo } : {}), ...(incoming.choice ? { choice: incoming.choice } : {}) },
144
+ };
145
+ const attachments = incoming.attachments?.map((a) => ({ kind: a.kind, mime: a.mime, ...(a.name ? { name: a.name } : {}) }));
146
+ emit(conversationId, "message.in", { text: incoming.text, messageId, channel, ...(attachments ? { attachments } : {}) });
147
+ const info = receive.channel ?? { reactions: false, status: false };
148
+ const next = (queues.get(conversationId) ?? Promise.resolve()).then(() => turn(conversationId, request, incoming, info, deliver));
149
+ queues.set(conversationId, next);
150
+ return messageId || null;
151
+ },
152
+ /** The person reacted to one of its messages: not sent to the endpoint in this version. */
153
+ reacted(_conversationId, _emoji, _toText) { },
154
+ };
155
+ }
@@ -0,0 +1,3 @@
1
+ export { runInCloud, type CloudSummary, type ConnectOptions } from "./client.ts";
2
+ export { hashOf, loadSuite, parseScenario, parseToolFile, suiteHash, type Scenario, type SuiteFile, type ToolFile } from "./suite.ts";
3
+ export type { Check, Command, OpenSession, Opened, RunKey, RunSummary, SessionSummary, ToolCallRequest, ToolCallResult, UploadFile, Usage, WireIncoming } from "./protocol.ts";
@@ -0,0 +1,2 @@
1
+ export { runInCloud } from "./client.js";
2
+ export { hashOf, loadSuite, parseScenario, parseToolFile, suiteHash } from "./suite.js";
@@ -0,0 +1,13 @@
1
+ /** Throws unless `name` can be an agent's folder and package name. */
2
+ export declare function checkName(name: string): void;
3
+ /** The @flow-engineer/flow package's folder: the root of a clone, or the installed package. */
4
+ export declare function packageRoot(from?: string): string;
5
+ /** Makes `<into>/<name>` (the current folder by default) and returns it. */
6
+ export declare function newStandalone(name: string, options?: {
7
+ into?: string;
8
+ root?: string;
9
+ }): string;
10
+ /** Makes the agent in `dir` (the current folder by default), named after the folder; returns its name. */
11
+ export declare function initHere(dir?: string, options?: {
12
+ root?: string;
13
+ }): string;