@flow-engineer/flow 0.2.3 → 0.3.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.
- package/README.md +3 -3
- package/coding-agents/skill.md +24 -9
- package/coding-agents/test-skill.md +50 -1
- package/dist/flow-messaging/src/index.js +8 -5
- package/dist/simulators/src/server.d.ts +2 -0
- package/dist/simulators/src/server.js +7 -1
- package/dist/src/agent.d.ts +32 -0
- package/dist/src/agent.js +38 -8
- package/dist/src/channels.d.ts +2 -0
- package/dist/src/channels.js +1 -0
- package/dist/src/conversation.d.ts +2 -0
- package/dist/src/conversation.js +8 -2
- package/dist/src/http.js +5 -2
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/local.d.ts +22 -0
- package/dist/src/local.js +85 -0
- package/dist/src/types.d.ts +6 -0
- package/dist/testing/src/account.d.ts +1 -1
- package/dist/testing/src/account.js +107 -29
- package/dist/testing/src/business.d.ts +42 -0
- package/dist/testing/src/business.js +145 -0
- package/dist/testing/src/cli.js +10 -8
- package/dist/testing/src/client.d.ts +4 -2
- package/dist/testing/src/client.js +20 -0
- package/dist/testing/src/index.d.ts +2 -2
- package/dist/testing/src/index.js +1 -1
- package/dist/testing/src/new.d.ts +9 -0
- package/dist/testing/src/new.js +44 -5
- package/dist/testing/src/protocol.d.ts +99 -1
- package/dist/testing/src/protocol.js +9 -0
- package/dist/testing/src/suite.d.ts +142 -3
- package/dist/testing/src/suite.js +94 -10
- package/docs/agents.md +40 -10
- package/examples/cafe/src/agent.ts +3 -3
- package/examples/cafe/src/channels.ts +1 -1
- package/examples/cafe/src/main.ts +23 -8
- package/examples/cafe/src/mcp.ts +24 -6
- package/examples/cafe/src/tools.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// An agent run on this machine (`npm start`, `npm run mcp`): the port it listens on, and the marker
|
|
2
|
+
// in .flow/ that says it is running, which `flow link` reads to say whether to start it or restart it.
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createServer } from "node:net";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { RESULTS_DIR } from "./names.js";
|
|
7
|
+
/** How many ports above the default are tried before giving up. */
|
|
8
|
+
const SCAN = 50;
|
|
9
|
+
/** True when nothing listens on 127.0.0.1:`port` (the address the playground and the MCP server bind). */
|
|
10
|
+
export function portFree(port) {
|
|
11
|
+
return new Promise((done) => {
|
|
12
|
+
const probe = createServer();
|
|
13
|
+
probe.once("error", () => done(false));
|
|
14
|
+
probe.listen(port, "127.0.0.1", () => probe.close(() => done(true)));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
async function firstFree(from, to) {
|
|
18
|
+
for (let port = from; port <= Math.min(to, 65535); port++)
|
|
19
|
+
if (await portFree(port))
|
|
20
|
+
return port;
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The port to listen on. `env[name]` set: that port, or an error naming a free one when it is taken
|
|
25
|
+
* (the caller prints its message and exits, so no stack). Not set: `fallback`, else the next free
|
|
26
|
+
* port above it, with `note` saying so. `command` is how the agent is started, for the error's
|
|
27
|
+
* "Run: PORT=8798 npm start".
|
|
28
|
+
*/
|
|
29
|
+
export async function choosePort(name, fallback, options) {
|
|
30
|
+
const given = (options.env ?? process.env)[name]?.trim();
|
|
31
|
+
if (given) {
|
|
32
|
+
const port = Number(given);
|
|
33
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
34
|
+
throw new Error(`${name} must be a port number, such as ${name}=${fallback}; it is "${given}".`);
|
|
35
|
+
if (port === 0 || (await portFree(port)))
|
|
36
|
+
return { port };
|
|
37
|
+
const free = await firstFree(port + 1, port + SCAN);
|
|
38
|
+
throw new Error(`Port ${port} is in use. ${free ? `Run: ${name}=${free} ${options.command}` : `Set ${name} to a free port: ${name}=<port> ${options.command}`}`);
|
|
39
|
+
}
|
|
40
|
+
if (await portFree(fallback))
|
|
41
|
+
return { port: fallback };
|
|
42
|
+
const free = await firstFree(fallback + 1, fallback + SCAN);
|
|
43
|
+
if (!free)
|
|
44
|
+
throw new Error(`Ports ${fallback} to ${fallback + SCAN} are in use. Set ${name} to a free port: ${name}=<port> ${options.command}`);
|
|
45
|
+
return { port: free, note: `Port ${fallback} is in use, so this runs on ${free} (${name}=<port> picks one).` };
|
|
46
|
+
}
|
|
47
|
+
const RUNNING = "running.json";
|
|
48
|
+
/** Writes .flow/running.json in `dir` (this process and its URL) and removes it when the process exits. */
|
|
49
|
+
export function markRunning(url, dir = process.cwd()) {
|
|
50
|
+
const file = join(dir, RESULTS_DIR, RUNNING);
|
|
51
|
+
mkdirSync(join(dir, RESULTS_DIR), { recursive: true });
|
|
52
|
+
writeFileSync(file, `${JSON.stringify({ pid: process.pid, url })}\n`);
|
|
53
|
+
process.once("exit", () => {
|
|
54
|
+
try {
|
|
55
|
+
if (JSON.parse(readFileSync(file, "utf8")).pid === process.pid)
|
|
56
|
+
rmSync(file);
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** The agent running from `dir` (its marker names a live process), or undefined. */
|
|
62
|
+
export function runningAgent(dir = process.cwd()) {
|
|
63
|
+
const file = join(dir, RESULTS_DIR, RUNNING);
|
|
64
|
+
if (!existsSync(file))
|
|
65
|
+
return undefined;
|
|
66
|
+
let marker;
|
|
67
|
+
try {
|
|
68
|
+
marker = JSON.parse(readFileSync(file, "utf8"));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const { pid, url } = marker;
|
|
74
|
+
if (typeof pid !== "number" || typeof url !== "string")
|
|
75
|
+
return undefined;
|
|
76
|
+
try {
|
|
77
|
+
process.kill(pid, 0); // signal 0 only asks whether the process is there
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
// EPERM: it is there, run by another user; ESRCH: it has gone (killed without removing its marker).
|
|
81
|
+
if (error.code !== "EPERM")
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return { pid, url };
|
|
85
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -107,6 +107,12 @@ export type Outbound = {
|
|
|
107
107
|
};
|
|
108
108
|
/** The channel's own params (see Channel.params), as the agent filled them in. */
|
|
109
109
|
fields?: Record<string, unknown>;
|
|
110
|
+
/**
|
|
111
|
+
* false: no link preview. Set on every message the business starts (a nudge, a scheduled
|
|
112
|
+
* message, a turn the person did not start): on WhatsApp a preview card's title would become
|
|
113
|
+
* the phone's notification instead of the message's text. Its call to action goes as `link`.
|
|
114
|
+
*/
|
|
115
|
+
preview?: false;
|
|
110
116
|
/** The same in every process for one send the business starts (a nudge, a scheduled text), for providers that take one. */
|
|
111
117
|
idempotencyKey?: string;
|
|
112
118
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export declare const ACCOUNT_COMMANDS: Set<string>;
|
|
2
|
-
export declare const ACCOUNT_LINES = " flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]\n Sign in to flow.engineer: opens the browser, prints the link and code,\n and waits up to --timeout (default 120) for approval; --wait goes on\n waiting for the same code, --no-wait returns at once\n flow logout [--json] End this machine's sign-in and delete its token\n flow whoami [--json] Show the account you are signed in as\n flow setup [--replace] [--rotate <keys> [--now]] [--ci] [--json]\n Write this agent's keys (its Flow key FLOW_KEY, which also pays for the\n model on the account's trial credit when there is no provider key, test\n cloud, traces, Flow Messaging) and each open channel's sender into\n ./.env (in an agent's folder: package.json); --rotate key,cloud,traces,\n messaging or all makes those keys again (the old FLOW_KEY works 24 h\n more; --now ends it at once)\n flow link [--channel telegram|whatsapp|imessage] [--timeout <s>] [--no-browser] [--json]\n Print the link that opens a chat with this agent from a phone; signs in\n and runs setup first when this folder is not set up yet\n flow status [--json] What this folder has and lacks: FLOW_KEY and the model key (never their\n values) with the trial credit left, the\n sign-in, each channel's sender in .env, and, signed in, which channels\n are open now and their free-trial counts";
|
|
2
|
+
export declare const ACCOUNT_LINES = " flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]\n Sign in to flow.engineer: opens the browser, prints the link and code,\n and waits up to --timeout (default 120) for approval; --wait goes on\n waiting for the same code, --no-wait returns at once\n flow logout [--json] End this machine's sign-in and delete its token\n flow whoami [--json] Show the account you are signed in as\n flow setup [--replace] [--rotate <keys> [--now]] [--display-name <name>] [--ci] [--json]\n Write this agent's keys (its Flow key FLOW_KEY, which also pays for the\n model on the account's trial credit when there is no provider key, test\n cloud, traces, Flow Messaging) and each open channel's sender into\n ./.env (in an agent's folder: package.json); --rotate key,cloud,traces,\n messaging or all makes those keys again (the old FLOW_KEY works 24 h\n more; --now ends it at once)\n flow link [--channel telegram|whatsapp|imessage] [--display-name <name>] [--timeout <s>] [--no-browser] [--json]\n Print the link that opens a chat with this agent from a phone; signs in\n and runs setup first when this folder is not set up yet. --display-name\n (here and on setup) renames the agent: package.json's displayName and\n its prompt, agent/AGENTS.md; the phone link then says \"Hi <name>\"\n flow status [--json] What this folder has and lacks: FLOW_KEY and the model key (never their\n values) with the trial credit left, the\n sign-in, each channel's sender in .env, and, signed in, which channels\n are open now and their free-trial counts";
|
|
3
3
|
/** Runs one account command; resolves to the exit status. */
|
|
4
4
|
export declare function accountCli(command: string, args: string[]): Promise<number>;
|
|
@@ -19,9 +19,10 @@ import { basename, join } from "node:path";
|
|
|
19
19
|
import { parseArgs } from "node:util";
|
|
20
20
|
import { setTimeout as delay } from "node:timers/promises";
|
|
21
21
|
import { currentToken, deleteCredentials, deletePending, LoginRefused, loginUrl, pollToken, readPending, REQUEST_TIMEOUT_MS, revoke, saveCredentials, savePending, modelCredit, SETUP_ENV, setup, startDevice, TimedOut, tryLink, warningsOf, whoami, } from "./flow-login.js";
|
|
22
|
-
import { packageRoot } from "./new.js";
|
|
22
|
+
import { packageRoot, setDisplayName } from "./new.js";
|
|
23
23
|
import { flowModelURL, hasModelKey, MODEL_KEY_NAMES, NO_MODEL_KEY } from "../../src/model.js";
|
|
24
24
|
import { RUN } from "../../src/names.js";
|
|
25
|
+
import { runningAgent } from "../../src/local.js";
|
|
25
26
|
export const ACCOUNT_COMMANDS = new Set(["login", "logout", "whoami", "setup", "link", "status"]);
|
|
26
27
|
export const ACCOUNT_LINES = ` flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]
|
|
27
28
|
Sign in to flow.engineer: opens the browser, prints the link and code,
|
|
@@ -29,16 +30,18 @@ export const ACCOUNT_LINES = ` flow login [--wait | --no-wait] [--timeout <s>]
|
|
|
29
30
|
waiting for the same code, --no-wait returns at once
|
|
30
31
|
flow logout [--json] End this machine's sign-in and delete its token
|
|
31
32
|
flow whoami [--json] Show the account you are signed in as
|
|
32
|
-
flow setup [--replace] [--rotate <keys> [--now]] [--ci] [--json]
|
|
33
|
+
flow setup [--replace] [--rotate <keys> [--now]] [--display-name <name>] [--ci] [--json]
|
|
33
34
|
Write this agent's keys (its Flow key FLOW_KEY, which also pays for the
|
|
34
35
|
model on the account's trial credit when there is no provider key, test
|
|
35
36
|
cloud, traces, Flow Messaging) and each open channel's sender into
|
|
36
37
|
./.env (in an agent's folder: package.json); --rotate key,cloud,traces,
|
|
37
38
|
messaging or all makes those keys again (the old FLOW_KEY works 24 h
|
|
38
39
|
more; --now ends it at once)
|
|
39
|
-
flow link [--channel telegram|whatsapp|imessage] [--timeout <s>] [--no-browser] [--json]
|
|
40
|
+
flow link [--channel telegram|whatsapp|imessage] [--display-name <name>] [--timeout <s>] [--no-browser] [--json]
|
|
40
41
|
Print the link that opens a chat with this agent from a phone; signs in
|
|
41
|
-
and runs setup first when this folder is not set up yet
|
|
42
|
+
and runs setup first when this folder is not set up yet. --display-name
|
|
43
|
+
(here and on setup) renames the agent: package.json's displayName and
|
|
44
|
+
its prompt, agent/AGENTS.md; the phone link then says "Hi <name>"
|
|
42
45
|
flow status [--json] What this folder has and lacks: FLOW_KEY and the model key (never their
|
|
43
46
|
values) with the trial credit left, the
|
|
44
47
|
sign-in, each channel's sender in .env, and, signed in, which channels
|
|
@@ -97,6 +100,7 @@ export async function accountCli(command, args) {
|
|
|
97
100
|
: {}),
|
|
98
101
|
...(command === "setup" ? { replace: { type: "boolean", default: false }, rotate: { type: "string" }, now: { type: "boolean", default: false }, ci: { type: "boolean", default: false } } : {}),
|
|
99
102
|
...(command === "link" ? { channel: { type: "string", default: "telegram" } } : {}),
|
|
103
|
+
...(command === "link" || command === "setup" ? { "display-name": { type: "string" } } : {}),
|
|
100
104
|
},
|
|
101
105
|
}));
|
|
102
106
|
}
|
|
@@ -131,7 +135,23 @@ export async function accountCli(command, args) {
|
|
|
131
135
|
// Straight to the file descriptors, never buffered: a coding agent's cut-off command keeps every line.
|
|
132
136
|
const out = (report) => writeAll(1, `${JSON.stringify(report)}\n`);
|
|
133
137
|
const say = (line) => writeAll(json ? 2 : 1, `${line}\n`);
|
|
138
|
+
let renamed = false;
|
|
134
139
|
try {
|
|
140
|
+
if (typeof values["display-name"] === "string" && values.ci !== true) {
|
|
141
|
+
agentFolder(process.cwd());
|
|
142
|
+
const before = agentDisplayName();
|
|
143
|
+
let named;
|
|
144
|
+
try {
|
|
145
|
+
named = setDisplayName(process.cwd(), values["display-name"]);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
throw new Stop("bad_display_name", error.message, undefined, EXIT.usage);
|
|
149
|
+
}
|
|
150
|
+
renamed = named.displayName !== before || named.prompt;
|
|
151
|
+
if (json)
|
|
152
|
+
out({ display_name: named.displayName, prompt_renamed: named.prompt });
|
|
153
|
+
say(`People call it "${named.displayName}" now (package.json displayName${named.prompt ? " and agent/AGENTS.md" : "; agent/AGENTS.md named neither the old name nor Pip, so it is as it was"}).`);
|
|
154
|
+
}
|
|
135
155
|
if (command === "login") {
|
|
136
156
|
await login({ noBrowser: values["no-browser"] === true, wait: values.wait === true, noWait: values["no-wait"] === true, force: values.force === true, timeoutSeconds }, json, out, say);
|
|
137
157
|
}
|
|
@@ -144,7 +164,7 @@ export async function accountCli(command, args) {
|
|
|
144
164
|
else if (command === "setup")
|
|
145
165
|
await setupEnv({ replace: values.replace === true, rotate, now: values.now === true, ci: values.ci === true }, json, out, say);
|
|
146
166
|
else
|
|
147
|
-
await link({ channel, noBrowser: values["no-browser"] === true, timeoutSeconds }, json, out, say);
|
|
167
|
+
await link({ channel, noBrowser: values["no-browser"] === true, timeoutSeconds, renamed }, json, out, say);
|
|
148
168
|
return EXIT.ok;
|
|
149
169
|
}
|
|
150
170
|
catch (error) {
|
|
@@ -491,7 +511,7 @@ async function setupEnv(options, json, out, say, forLink) {
|
|
|
491
511
|
out({ ci: { secret: SETUP_ENV.cloud_key, steps } });
|
|
492
512
|
for (const line of steps)
|
|
493
513
|
say(line);
|
|
494
|
-
return { answer: {} };
|
|
514
|
+
return { answer: {}, warnings: [] };
|
|
495
515
|
}
|
|
496
516
|
const dir = process.cwd();
|
|
497
517
|
agentFolder(dir);
|
|
@@ -519,31 +539,57 @@ async function setupEnv(options, json, out, say, forLink) {
|
|
|
519
539
|
// (and, for FLOW_KEY, ends) the old one rather than adding a second.
|
|
520
540
|
const rotate = all ? true : rotated.size ? [...rotated] : undefined;
|
|
521
541
|
const rotateNow = options.now === true && rotated.has("flow_key");
|
|
542
|
+
// FLOW_FRAMEWORK_TRY_URL is one channel's page: link's channel when it is open, else the server's (Telegram's).
|
|
543
|
+
const withTryUrl = (fields) => {
|
|
544
|
+
const code = fields.join_code;
|
|
545
|
+
if (!forLink || typeof fields[`${forLink.channel}_sender`] !== "string" || typeof code !== "string" || !code)
|
|
546
|
+
return fields;
|
|
547
|
+
const pages = fields.try_urls;
|
|
548
|
+
const page = typeof pages?.[forLink.channel] === "string" ? pages[forLink.channel] : tryLink(url, code, forLink.channel);
|
|
549
|
+
return { ...fields, try_url: page };
|
|
550
|
+
};
|
|
551
|
+
const chosenPage = (n) => forLink !== undefined && n === SETUP_ENV.try_url;
|
|
522
552
|
let answer;
|
|
553
|
+
// For link: the server's refusal when Messaging's part came (a join code) and only observe's failed.
|
|
554
|
+
let observeFailed;
|
|
523
555
|
try {
|
|
524
556
|
// Every channel: each comes back with its shared sender only while it is open.
|
|
525
557
|
answer = await setup(url, token, { name, hostname: hostname(), channels: [...CHANNELS], have, ...(rotate ? { rotate } : {}), ...(rotateNow ? { rotate_now: true } : {}) });
|
|
526
558
|
}
|
|
527
559
|
catch (error) {
|
|
528
|
-
//
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
560
|
+
// Texting needs only Messaging's part, so link goes on without observe's (traces and the test
|
|
561
|
+
// cloud) and says what is missing; setup alone, whose job is both, still stops.
|
|
562
|
+
if (forLink && error instanceof LoginRefused && typeof error.fields.join_code === "string" && error.fields.join_code) {
|
|
563
|
+
const { error: _code, error_description: why, next: fix, ...rest } = error.fields;
|
|
564
|
+
answer = rest;
|
|
565
|
+
observeFailed = { why: typeof why === "string" && why ? why : error.message, fix: typeof fix === "string" && fix ? fix : error.next };
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
// The keys minted anyway are not shown again, so whatever the error carries goes into .env first.
|
|
569
|
+
if (error instanceof LoginRefused) {
|
|
570
|
+
const written = writeEnv(file, lines, before, envEntries(withTryUrl(error.fields)), (n) => options.replace || rotatedEnv.has(n) || chosenPage(n));
|
|
571
|
+
if (written.some((w) => w.action === "added" || w.action === "replaced"))
|
|
572
|
+
say(`Kept in .env before the error: ${written.map((w) => `${w.name} ${w.action}`).join(", ")}.`);
|
|
573
|
+
}
|
|
574
|
+
throw error;
|
|
533
575
|
}
|
|
534
|
-
throw error;
|
|
535
576
|
}
|
|
536
577
|
const fields = answer;
|
|
537
578
|
const warnings = warningsOf(fields);
|
|
538
579
|
const had = new Map(before);
|
|
539
580
|
// --replace takes every new value but a model key: only --rotate key replaces FLOW_KEY.
|
|
540
|
-
const written = writeEnv(file, lines, before, envEntries(fields), (n) => rotatedEnv.has(n) || (options.replace && !MODEL_KEY_NAMES.includes(n)));
|
|
581
|
+
const written = writeEnv(file, lines, before, envEntries(withTryUrl(fields)), (n) => rotatedEnv.has(n) || (options.replace && !MODEL_KEY_NAMES.includes(n)) || chosenPage(n));
|
|
541
582
|
if (typeof answer.join_code !== "string" || !answer.join_code) {
|
|
542
583
|
const kept = written.filter((w) => w.action === "added" || w.action === "replaced").map((w) => w.name);
|
|
543
584
|
const stop = new Stop("no_join_code", `flow.engineer gave no join code${kept.length ? ` (the keys it made are in .env: ${kept.join(", ")})` : ""}, so there is no phone link yet.`, answer.next?.[0] ?? NEXT.setup, EXIT.refused);
|
|
544
585
|
stop.warnings = warnings;
|
|
545
586
|
throw stop;
|
|
546
587
|
}
|
|
588
|
+
if (observeFailed) {
|
|
589
|
+
const missing = [SETUP_ENV.cloud_key, SETUP_ENV.traces_key].filter((n) => !before.get(n));
|
|
590
|
+
const why = observeFailed.why.replace(/\.$/, "");
|
|
591
|
+
warnings.push(`Traces and tests won't work${missing.length ? ` until .env has ${missing.join(" and ")}` : ""}: ${why}.${observeFailed.fix ? ` Then: ${observeFailed.fix}` : ""}`);
|
|
592
|
+
}
|
|
547
593
|
printWarnings(warnings);
|
|
548
594
|
const open = CHANNELS.filter((c) => typeof fields[`${c}_sender`] === "string");
|
|
549
595
|
const displayName = agentDisplayName(dir);
|
|
@@ -582,7 +628,7 @@ async function setupEnv(options, json, out, say, forLink) {
|
|
|
582
628
|
...(forLink ? {} : { next }), warnings,
|
|
583
629
|
});
|
|
584
630
|
}
|
|
585
|
-
say(`Agent ${answer.agent_id}. In .env:`);
|
|
631
|
+
say(answer.agent_id ? `Agent ${answer.agent_id}. In .env:` : "In .env:");
|
|
586
632
|
for (const w of written)
|
|
587
633
|
say(` ${w.name}=${w.value} ${w.action}${w.action === "kept" ? (w.name === FLOW_KEY ? " (--rotate key makes a new one)" : " (yours differs; --replace takes the new one)") : ""}`);
|
|
588
634
|
if (previous)
|
|
@@ -592,18 +638,24 @@ async function setupEnv(options, json, out, say, forLink) {
|
|
|
592
638
|
// Said by link instead, once, with its link.
|
|
593
639
|
if (!forLink)
|
|
594
640
|
say(model.line);
|
|
595
|
-
|
|
596
|
-
if (people && typeof people.used === "number" && typeof people.limit === "number")
|
|
597
|
-
say(`iMessage: ${people.used} of ${people.limit} people`);
|
|
641
|
+
// link says its own channel's trial line instead.
|
|
598
642
|
if (!forLink) {
|
|
643
|
+
const people = peopleLine(trial?.imessage);
|
|
644
|
+
if (people)
|
|
645
|
+
say(people);
|
|
599
646
|
for (const c of open)
|
|
600
647
|
say(`On ${CHANNEL_NAME[c]}: ${direct[c] ? `${direct[c].link} (on a computer: ${links[c]})` : links[c]}${trialNote(trial?.[c])}`);
|
|
601
648
|
const added = addedToRunning((n) => had.get(n) || process.env[n]?.trim(), (n) => before.get(n) || process.env[n]?.trim());
|
|
602
649
|
if (added)
|
|
603
|
-
say(added);
|
|
650
|
+
say(`If npm start is running, restart it to add ${added}.`);
|
|
604
651
|
say(`Next: ${next.join(", then ")}`);
|
|
605
652
|
}
|
|
606
|
-
return { answer };
|
|
653
|
+
return { answer, warnings };
|
|
654
|
+
}
|
|
655
|
+
/** iMessage's count of the people it may reach on the trial, as a line; undefined when the answer had none. */
|
|
656
|
+
function peopleLine(t) {
|
|
657
|
+
const people = t?.contacts;
|
|
658
|
+
return people && typeof people.used === "number" && typeof people.limit === "number" ? `iMessage: ${people.used} of ${people.limit} people` : undefined;
|
|
607
659
|
}
|
|
608
660
|
const trialNote = (t) => (t && typeof t.limit === "number" ? ` (free trial: ${t.used} of ${t.limit} messages used)` : "");
|
|
609
661
|
/** An answer's field when it is an object (flow_key, model_credit), else undefined (an older server sends none). */
|
|
@@ -658,8 +710,8 @@ function writeEnv(file, lines, before, entries, replace) {
|
|
|
658
710
|
}
|
|
659
711
|
/**
|
|
660
712
|
* What a running agent (npm start reads .env once, when it starts) lacks after .env changed from
|
|
661
|
-
* `had` to `now`: the channels it is not on yet and a model key, as
|
|
662
|
-
* nothing it uses is new.
|
|
713
|
+
* `had` to `now`: the channels it is not on yet and a model key, as words ("Telegram and the model
|
|
714
|
+
* key"); undefined when nothing it uses is new.
|
|
663
715
|
*/
|
|
664
716
|
function addedToRunning(had, now) {
|
|
665
717
|
// The starter is on Telegram whenever it has the Flow Messaging key, and on WhatsApp and iMessage
|
|
@@ -670,7 +722,7 @@ function addedToRunning(had, now) {
|
|
|
670
722
|
const what = [...channels, ...model];
|
|
671
723
|
if (!what.length)
|
|
672
724
|
return undefined;
|
|
673
|
-
return
|
|
725
|
+
return `${what.length === 1 ? what[0] : `${what.slice(0, -1).join(", ")} and ${what.at(-1)}`}`;
|
|
674
726
|
}
|
|
675
727
|
/**
|
|
676
728
|
* `flow status`: what this folder has and lacks, read from .env and the environment, and, when
|
|
@@ -764,7 +816,8 @@ function dotenvValue() {
|
|
|
764
816
|
* in .env, or no sender for a WhatsApp or iMessage link): it signs in first when it has to (the same
|
|
765
817
|
* login, resuming a code already waiting) and runs setup, then asks flow.engineer for the channel's
|
|
766
818
|
* page. A channel that is not open yet is said once, with the links of the channels that are (exit
|
|
767
|
-
* 0); with none open, it stops with the server's next step.
|
|
819
|
+
* 0); with none open, it stops with the server's next step. A setup whose observe part failed but
|
|
820
|
+
* whose Messaging part came goes on the same way, with one warning (setupEnv).
|
|
768
821
|
*/
|
|
769
822
|
async function link(options, json, out, say) {
|
|
770
823
|
const again = `${NEXT.link}${options.channel === "telegram" ? "" : ` --channel ${options.channel}`}`;
|
|
@@ -772,7 +825,9 @@ async function link(options, json, out, say) {
|
|
|
772
825
|
const needsSender = options.channel !== "telegram";
|
|
773
826
|
let value = dotenvValue();
|
|
774
827
|
let credit;
|
|
828
|
+
let trial;
|
|
775
829
|
let had;
|
|
830
|
+
let warnings = [];
|
|
776
831
|
if (!value(SETUP_ENV.join_code) || !value(SETUP_ENV.messaging_key) || (needsSender && !value(SENDER_ENV[options.channel]))) {
|
|
777
832
|
agentFolder(process.cwd());
|
|
778
833
|
if (!(await signedInAccount(loginUrl()))) {
|
|
@@ -781,7 +836,7 @@ async function link(options, json, out, say) {
|
|
|
781
836
|
}
|
|
782
837
|
say("Setting up this agent's keys in .env.");
|
|
783
838
|
had = value;
|
|
784
|
-
({ answer: { model_credit: credit } } = await setupEnv({ replace: false, rotate: [], ci: false }, json, out, say, { channel: options.channel }));
|
|
839
|
+
({ answer: { model_credit: credit, trial }, warnings } = await setupEnv({ replace: false, rotate: [], ci: false }, json, out, say, { channel: options.channel }));
|
|
785
840
|
value = dotenvValue();
|
|
786
841
|
}
|
|
787
842
|
const joinCode = value(SETUP_ENV.join_code);
|
|
@@ -827,11 +882,24 @@ async function link(options, json, out, say) {
|
|
|
827
882
|
const replyOnly = REPLY_ONLY[channel];
|
|
828
883
|
const sayToPerson = `${notOpen ? `${what} isn't open yet. ` : ""}Your ${CHANNEL_NAME[channel]} link: ${first.link} ${OPEN_HINT[channel]} `
|
|
829
884
|
+ `On a computer without ${CHANNEL_NAME[channel]}, open ${first.page} instead.${replyOnly ? ` ${replyOnly}` : ""}`;
|
|
885
|
+
const added = had ? addedToRunning(had, value) : undefined;
|
|
886
|
+
// npm start reads .env and its name once, when it starts. Not running: start it. Running (its
|
|
887
|
+
// .flow/running.json names a live process): restart it when setup wrote .env, the name changed
|
|
888
|
+
// just now or a model key is still to come, else it is ready as it is.
|
|
889
|
+
const running = runningAgent();
|
|
890
|
+
const runStep = !running
|
|
891
|
+
? "npm start, then send the link above"
|
|
892
|
+
: had || options.renamed || model.next
|
|
893
|
+
? `restart npm start (it runs at ${running.url}${added ? ` and lacks ${added}` : " and reads .env and its name only when it starts"}), then send the link above`
|
|
894
|
+
: `send the link above (npm start is running at ${running.url})`;
|
|
895
|
+
const next = [...(model.next ? [model.next] : []), runStep];
|
|
830
896
|
if (json) {
|
|
831
897
|
out({
|
|
832
898
|
url: first.link, page: first.page, join_message: first.message, channel, join_code: joinCode, reply_only: Boolean(replyOnly), say_to_person: sayToPerson,
|
|
833
899
|
...(notOpen ? { not_open: options.channel, message: notOpen, links: Object.fromEntries(links.map(([c, d]) => [c, d.link])) } : {}),
|
|
834
|
-
model_key: model.report,
|
|
900
|
+
model_key: model.report, running: running ? { url: running.url } : false, next: next.join(", then "),
|
|
901
|
+
// Setup's warnings when it ran just now (also on its own line above), such as observe's part failing.
|
|
902
|
+
...(warnings.length ? { warnings } : {}),
|
|
835
903
|
});
|
|
836
904
|
}
|
|
837
905
|
if (notOpen) {
|
|
@@ -843,6 +911,18 @@ async function link(options, json, out, say) {
|
|
|
843
911
|
say(first.link);
|
|
844
912
|
say(`On a computer without ${CHANNEL_NAME[channel]}: ${first.page}`);
|
|
845
913
|
}
|
|
914
|
+
// The trial's counts for the channel the link is on, when setup just gave them; no other channel's.
|
|
915
|
+
const counts = trial?.[channel];
|
|
916
|
+
if (counts) {
|
|
917
|
+
const note = trialNote(counts);
|
|
918
|
+
if (note)
|
|
919
|
+
say(`${CHANNEL_NAME[channel]} ${note.trim().slice(1, -1)}`); // "WhatsApp free trial: 3 of 300 messages used"
|
|
920
|
+
if (channel === "imessage") {
|
|
921
|
+
const people = peopleLine(counts);
|
|
922
|
+
if (people)
|
|
923
|
+
say(people);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
846
926
|
if (model.report.set)
|
|
847
927
|
say(model.line);
|
|
848
928
|
const one = notOpen && links.length > 1 ? "one" : "it";
|
|
@@ -850,13 +930,11 @@ async function link(options, json, out, say) {
|
|
|
850
930
|
// No model key: the agent cannot answer yet, so the phone does not chat with it until there is one.
|
|
851
931
|
say(model.report.set ? "that phone chats with this agent while it runs (npm start), with no public URL or tunnel." : "that phone reaches this agent while it runs (npm start), but it answers only once .env has a model key:");
|
|
852
932
|
if (model.next)
|
|
853
|
-
say(
|
|
933
|
+
say(model.line);
|
|
854
934
|
for (const [c] of links)
|
|
855
935
|
if (REPLY_ONLY[c])
|
|
856
936
|
say(REPLY_ONLY[c]);
|
|
857
|
-
|
|
858
|
-
if (added)
|
|
859
|
-
say(added);
|
|
937
|
+
say(`Next: ${next.join(", then ")}.`);
|
|
860
938
|
}
|
|
861
939
|
/**
|
|
862
940
|
* Asks flow.engineer's /try page for its direct link as JSON ({"channel", "message", "link", "page"}):
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Agent, Channel, EventSink, Incoming, Tool } from "../../src/index.ts";
|
|
2
|
+
import type { Command } from "./protocol.ts";
|
|
3
|
+
/** What a business run's agent must do: a Flow Framework Agent. */
|
|
4
|
+
export type BusinessAgent = Pick<Agent, "channels" | "channel" | "message" | "reacted" | "nudge" | "reach" | "runDue" | "conversation" | "fetch">;
|
|
5
|
+
/** What buildAgent is given for a business run, beside its tools and events. */
|
|
6
|
+
export interface BusinessBuild {
|
|
7
|
+
channels: Channel[];
|
|
8
|
+
testing: {
|
|
9
|
+
/** The run's clock. */
|
|
10
|
+
now: () => number;
|
|
11
|
+
/** No schedule timer: the clock moves only when the cloud says, and what came due runs then. */
|
|
12
|
+
scheduleCheckMs: 0;
|
|
13
|
+
/** The secret the run's events are signed with (Flow-Signature): made for the run, never a real one. */
|
|
14
|
+
eventsSecret: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** One minute: the clock moves this far at a time, and what came due runs at each step. */
|
|
18
|
+
export declare const STEP_MS = 60000;
|
|
19
|
+
export interface BusinessContext {
|
|
20
|
+
/** The agent the session was opened with: its channels are copied for each run. */
|
|
21
|
+
sessionAgent: BusinessAgent;
|
|
22
|
+
build: (options: {
|
|
23
|
+
tools: Tool[];
|
|
24
|
+
events: EventSink[];
|
|
25
|
+
} & BusinessBuild) => BusinessAgent;
|
|
26
|
+
/** The tunnel's tools (each call asks the cloud), and the agent's own, for tools.real. */
|
|
27
|
+
tunnelTools: Tool[];
|
|
28
|
+
realTools: Tool[];
|
|
29
|
+
/** The session's event uploader. */
|
|
30
|
+
events: EventSink;
|
|
31
|
+
/** A POST to the session, sent again under `key` while it may be. */
|
|
32
|
+
post: (path: string, body: unknown, key: string, what: string) => Promise<unknown>;
|
|
33
|
+
/** Keeps a failure of fire-and-forget work as the session's (refusals allowed). */
|
|
34
|
+
watch: (work: Promise<unknown>, context: string, allowRefusal?: boolean) => void;
|
|
35
|
+
log: (line: string) => void;
|
|
36
|
+
}
|
|
37
|
+
export declare function businessRuns(ctx: BusinessContext): {
|
|
38
|
+
/** A business run's command, or a message or reaction for one: true when it was one. */
|
|
39
|
+
handle(command: Command, withFiles: (incoming: Extract<Command, {
|
|
40
|
+
type: "message";
|
|
41
|
+
}>["incoming"]) => Incoming): boolean;
|
|
42
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Business runs on the developer's side of the tunnel: scenarios the business starts (an event its
|
|
2
|
+
// backend sends, a nudge, time passing). Each run gets an agent of its own, built with a clock the
|
|
3
|
+
// cloud's commands move and with copies of the agent's own channels, so its sends go through what
|
|
4
|
+
// the agent wraps around them and come to the cloud with the run's time. The person writes through
|
|
5
|
+
// agent.message(), as a provider's webhook would, so they are a person the agent can nudge.
|
|
6
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
7
|
+
/** One minute: the clock moves this far at a time, and what came due runs at each step. */
|
|
8
|
+
export const STEP_MS = 60_000;
|
|
9
|
+
export function businessRuns(ctx) {
|
|
10
|
+
const runs = new Map();
|
|
11
|
+
const open = async (command) => {
|
|
12
|
+
const cloudId = command.conversationId;
|
|
13
|
+
const real = new Set(command.realTools ?? []);
|
|
14
|
+
const unknown = [...real].filter((name) => !ctx.realTools.some((t) => t.name === name));
|
|
15
|
+
if (unknown.length)
|
|
16
|
+
return { conversationId: cloudId, step: 0, at: command.now, error: `the agent has no tool ${unknown.join(", ")} to run for real` };
|
|
17
|
+
const run = { clock: command.now, secret: `whsec_test_${randomBytes(18).toString("hex")}`, address: command.address, channelName: command.channelName, conversationId: "", deliveries: new Set(), sent: 0 };
|
|
18
|
+
// The run's events and tool calls are the cloud's conversation, whatever the agent calls it.
|
|
19
|
+
const events = Object.assign((event) => ctx.events({ ...event, conversationId: cloudId }), { context: true });
|
|
20
|
+
const tools = ctx.tunnelTools.map((t) => {
|
|
21
|
+
if (real.has(t.name))
|
|
22
|
+
return ctx.realTools.find((r) => r.name === t.name);
|
|
23
|
+
return { ...t, run: (input, context) => t.run(input, { ...context, conversationId: cloudId }) };
|
|
24
|
+
});
|
|
25
|
+
const channels = ctx.sessionAgent.channels().map(({ name }) => {
|
|
26
|
+
const own = ctx.sessionAgent.channel(name);
|
|
27
|
+
return { ...own, send: (address, outbound) => deliver(run, cloudId, address, outbound) };
|
|
28
|
+
});
|
|
29
|
+
run.agent = ctx.build({ tools, events: [events], channels, testing: { now: () => run.clock, scheduleCheckMs: 0, eventsSecret: run.secret } });
|
|
30
|
+
run.conversationId = (await run.agent.reach({ channel: command.channelName, address: command.address })).conversationId;
|
|
31
|
+
runs.set(cloudId, run);
|
|
32
|
+
return { conversationId: cloudId, step: 0, at: run.clock };
|
|
33
|
+
};
|
|
34
|
+
const deliver = (run, cloudId, address, outbound) => {
|
|
35
|
+
if (address !== run.address) {
|
|
36
|
+
ctx.log(`${cloudId}: the agent sent to ${address}, not the run's person; the cloud does not see it`);
|
|
37
|
+
return Promise.resolve();
|
|
38
|
+
}
|
|
39
|
+
const body = { conversationId: cloudId, outbound, at: run.clock };
|
|
40
|
+
const delivery = ctx.post("outbound", body, `outbound:${cloudId}:b${++run.sent}`, "outbound delivery").then(() => { });
|
|
41
|
+
run.deliveries.add(delivery);
|
|
42
|
+
const done = () => void run.deliveries.delete(delivery);
|
|
43
|
+
delivery.then(done, done);
|
|
44
|
+
ctx.watch(delivery, "outbound delivery", true);
|
|
45
|
+
return delivery;
|
|
46
|
+
};
|
|
47
|
+
/** Until nothing more comes due now and everything set off has been sent. */
|
|
48
|
+
const settle = async (run) => {
|
|
49
|
+
for (let i = 0; i < 10; i++) {
|
|
50
|
+
const due = await run.agent.runDue();
|
|
51
|
+
await run.agent.conversation(run.conversationId)?.whenIdle();
|
|
52
|
+
await Promise.allSettled([...run.deliveries]);
|
|
53
|
+
if (!due && !run.agent.conversation(run.conversationId)?.busy)
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const event = async (run, command) => {
|
|
58
|
+
const body = JSON.stringify(command.envelope);
|
|
59
|
+
const t = Math.floor(run.clock / 1000);
|
|
60
|
+
const secret = command.signature === "bad" ? `whsec_test_${randomBytes(18).toString("hex")}` : run.secret;
|
|
61
|
+
const headers = { "content-type": "application/json", "idempotency-key": command.envelope.id };
|
|
62
|
+
if (command.signature !== "missing")
|
|
63
|
+
headers["flow-signature"] = `t=${t},v1=${createHmac("sha256", secret).update(`t.${t}.${body}`).digest("hex")}`;
|
|
64
|
+
const response = await run.agent.fetch(new Request(`http://agent.test${command.path ?? "/v1/events"}`, { method: "POST", headers, body }));
|
|
65
|
+
const text = await response.text();
|
|
66
|
+
let parsed = text;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(text);
|
|
69
|
+
}
|
|
70
|
+
catch { /* not JSON: kept as text */ }
|
|
71
|
+
return { answer: { status: response.status, body: parsed } };
|
|
72
|
+
};
|
|
73
|
+
const step = async (run, command) => {
|
|
74
|
+
const base = { conversationId: command.conversationId, step: command.step };
|
|
75
|
+
let result = {};
|
|
76
|
+
try {
|
|
77
|
+
if (command.type === "event")
|
|
78
|
+
result = await event(run, command);
|
|
79
|
+
else if (command.type === "nudge") {
|
|
80
|
+
const { expiresAt, ...rest } = command.nudge;
|
|
81
|
+
const nudged = await run.agent.nudge({ to: { channel: run.channelName, address: run.address }, ...rest, ...(expiresAt !== undefined ? { expiresAt } : {}) });
|
|
82
|
+
result = { nudge: { status: nudged.status, ...(nudged.reason ? { reason: nudged.reason } : {}) } };
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
if (command.to < run.clock)
|
|
86
|
+
throw new Error("the clock never moves back");
|
|
87
|
+
while (run.clock < command.to) {
|
|
88
|
+
run.clock = Math.min(command.to, run.clock + STEP_MS);
|
|
89
|
+
await settle(run);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
await settle(run);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
result = { error: error instanceof Error ? error.message : String(error) };
|
|
96
|
+
}
|
|
97
|
+
return { ...base, ...result, at: run.clock };
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
/** A business run's command, or a message or reaction for one: true when it was one. */
|
|
101
|
+
handle(command, withFiles) {
|
|
102
|
+
// Steps run beside the stream, so one run's long wait never holds up another's commands; the cloud
|
|
103
|
+
// sends a run's next command only after this one's answer.
|
|
104
|
+
if (command.type === "open") {
|
|
105
|
+
ctx.watch((async () => {
|
|
106
|
+
const answer = await open(command).catch((error) => ({ conversationId: command.conversationId, step: 0, at: command.now, error: error instanceof Error ? error.message : String(error) }));
|
|
107
|
+
await ctx.post("steps", answer, `step:${command.conversationId}:0`, "step answer");
|
|
108
|
+
})(), "business step");
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (command.type === "close") {
|
|
112
|
+
runs.delete(command.conversationId);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (command.type === "event" || command.type === "nudge" || command.type === "advance") {
|
|
116
|
+
ctx.watch((async () => {
|
|
117
|
+
const run = runs.get(command.conversationId);
|
|
118
|
+
const answer = run ? await step(run, command) : { conversationId: command.conversationId, step: command.step, at: 0, error: "no such run open" };
|
|
119
|
+
await ctx.post("steps", answer, `step:${command.conversationId}:${command.step}`, "step answer");
|
|
120
|
+
})(), "business step");
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (command.type !== "message" && command.type !== "reaction")
|
|
124
|
+
return false;
|
|
125
|
+
const run = runs.get(command.conversationId);
|
|
126
|
+
if (!run)
|
|
127
|
+
return false;
|
|
128
|
+
if (command.type === "reaction") {
|
|
129
|
+
run.agent.reacted(run.conversationId, command.emoji, command.toText);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
// As a provider's webhook would: the person by their address on the run's channel.
|
|
133
|
+
const work = (async () => {
|
|
134
|
+
const taken = await run.agent.message({ channel: run.channelName, from: run.address, message: withFiles(command.incoming), messageId: command.messageId });
|
|
135
|
+
// An intercepted message's reply is out before its receipt, as on the other runs.
|
|
136
|
+
if (!taken)
|
|
137
|
+
await Promise.allSettled([...run.deliveries]);
|
|
138
|
+
const receipt = { conversationId: command.conversationId, messageId: command.messageId, status: taken ? "taken" : "intercepted" };
|
|
139
|
+
await ctx.post("receipts", receipt, `receipt:${receipt.conversationId}:${receipt.messageId}`, "delivery receipt");
|
|
140
|
+
})();
|
|
141
|
+
ctx.watch(work, "delivery receipt");
|
|
142
|
+
return true;
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|