@flow-engineer/flow 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/coding-agents/skill.md +20 -7
- package/coding-agents/test-skill.md +50 -1
- package/dist/flow-messaging/src/client.d.ts +2 -2
- package/dist/flow-messaging/src/client.js +3 -3
- package/dist/flow-messaging/src/index.js +13 -8
- 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 +6 -4
- package/dist/src/channels.js +5 -3
- package/dist/src/conversation.d.ts +3 -0
- package/dist/src/conversation.js +37 -5
- 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 +9 -0
- package/dist/testing/src/account.d.ts +1 -1
- package/dist/testing/src/account.js +78 -20
- 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 +31 -9
- package/examples/cafe/src/main.ts +23 -8
- package/examples/cafe/src/mcp.ts +24 -6
- package/flow-messaging/README.md +3 -1
- package/package.json +1 -1
package/dist/src/http.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// bearer token each.
|
|
4
4
|
//
|
|
5
5
|
// nudgeHandler:
|
|
6
|
-
// POST {base} { to: { channel, address }, text | prompt | template, at?, idempotency_key? }
|
|
6
|
+
// POST {base} { to: { channel, address }, text (+ link?) | prompt | template, at?, idempotency_key? }
|
|
7
7
|
// → 202 { id, status, reason? } as soon as it is recorded, before any send or turn
|
|
8
8
|
// GET {base}/:id → 200 { id, status, reason? }, its state now; 404 for an id the agent never recorded
|
|
9
9
|
//
|
|
@@ -68,6 +68,8 @@ const Body = z
|
|
|
68
68
|
.object({
|
|
69
69
|
to: z.object({ channel: z.string().min(1), address: z.string().min(1) }).strict(),
|
|
70
70
|
text: z.string().min(1).optional(),
|
|
71
|
+
/** With text: its call to action, as a link button. */
|
|
72
|
+
link: z.object({ label: z.string().min(1), url: z.url({ protocol: /^https$/ }) }).strict().optional(),
|
|
71
73
|
prompt: z.string().min(1).optional(),
|
|
72
74
|
template: Template.optional(),
|
|
73
75
|
/** Milliseconds since the epoch, as `agent.nudge()` takes it. */
|
|
@@ -80,7 +82,8 @@ const Body = z
|
|
|
80
82
|
})
|
|
81
83
|
.strict()
|
|
82
84
|
.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" })
|
|
85
|
+
.refine((b) => !(b.template && b.at !== undefined), { message: "a template nudge cannot be scheduled yet: leave out at" })
|
|
86
|
+
.refine((b) => !(b.link && (b.text === undefined || b.at !== undefined)), { message: "link goes with text, sent now (no at)" });
|
|
84
87
|
/**
|
|
85
88
|
* Nudges over HTTP, as a request handler for your own server: hand it the requests under `base`.
|
|
86
89
|
* Requests outside `base` get 404 without a token check. A nudge refused or dropped as it is
|
package/dist/src/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { nudgeHandler, peopleHandler, type NudgeHandlerOptions, type PeopleHandl
|
|
|
5
5
|
export { sqlite, type Database } from "./db.ts";
|
|
6
6
|
export { postgres, type PostgresPool } from "./postgres.ts";
|
|
7
7
|
export { jsonlSink, type EventSink, type FlowEvent, type ChinwagEvent } from "./events.ts";
|
|
8
|
+
export { choosePort, markRunning } from "./local.ts";
|
|
8
9
|
export { flowModel, flowModelURL, openAICompatible, type OpenAICompatibleOptions } from "./model.ts";
|
|
9
10
|
export { Store, type StoreApi } from "./store.ts";
|
|
10
11
|
export type { AgentFiles } from "./skills.ts";
|
package/dist/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { nudgeHandler, peopleHandler } from "./http.js";
|
|
|
5
5
|
export { sqlite } from "./db.js";
|
|
6
6
|
export { postgres } from "./postgres.js";
|
|
7
7
|
export { jsonlSink } from "./events.js";
|
|
8
|
+
export { choosePort, markRunning } from "./local.js";
|
|
8
9
|
export { flowModel, flowModelURL, openAICompatible } from "./model.js";
|
|
9
10
|
export { Store } from "./store.js";
|
|
10
11
|
export { tool, toSpec } from "./tools.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** True when nothing listens on 127.0.0.1:`port` (the address the playground and the MCP server bind). */
|
|
2
|
+
export declare function portFree(port: number): Promise<boolean>;
|
|
3
|
+
/**
|
|
4
|
+
* The port to listen on. `env[name]` set: that port, or an error naming a free one when it is taken
|
|
5
|
+
* (the caller prints its message and exits, so no stack). Not set: `fallback`, else the next free
|
|
6
|
+
* port above it, with `note` saying so. `command` is how the agent is started, for the error's
|
|
7
|
+
* "Run: PORT=8798 npm start".
|
|
8
|
+
*/
|
|
9
|
+
export declare function choosePort(name: string, fallback: number, options: {
|
|
10
|
+
command: string;
|
|
11
|
+
env?: Record<string, string | undefined>;
|
|
12
|
+
}): Promise<{
|
|
13
|
+
port: number;
|
|
14
|
+
note?: string;
|
|
15
|
+
}>;
|
|
16
|
+
/** Writes .flow/running.json in `dir` (this process and its URL) and removes it when the process exits. */
|
|
17
|
+
export declare function markRunning(url: string, dir?: string): void;
|
|
18
|
+
/** The agent running from `dir` (its marker names a live process), or undefined. */
|
|
19
|
+
export declare function runningAgent(dir?: string): {
|
|
20
|
+
pid: number;
|
|
21
|
+
url: string;
|
|
22
|
+
} | undefined;
|
|
@@ -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,14 @@ 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;
|
|
116
|
+
/** The same in every process for one send the business starts (a nudge, a scheduled text), for providers that take one. */
|
|
117
|
+
idempotencyKey?: string;
|
|
110
118
|
}
|
|
111
119
|
/** The agent has picked these messages up (WhatsApp's blue ticks). */
|
|
112
120
|
| {
|
|
@@ -122,6 +130,7 @@ export type Outbound = {
|
|
|
122
130
|
/** A template the owner set up with the channel (WhatsApp), sent as is, even outside its window. */
|
|
123
131
|
| ({
|
|
124
132
|
type: "template";
|
|
133
|
+
idempotencyKey?: string;
|
|
125
134
|
} & Template)
|
|
126
135
|
/** A short line of what the agent is doing, shown in the loading indicator; null clears it. */
|
|
127
136
|
| {
|
|
@@ -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) {
|
|
@@ -519,6 +539,16 @@ 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;
|
|
523
553
|
try {
|
|
524
554
|
// Every channel: each comes back with its shared sender only while it is open.
|
|
@@ -527,7 +557,7 @@ async function setupEnv(options, json, out, say, forLink) {
|
|
|
527
557
|
catch (error) {
|
|
528
558
|
// The keys minted anyway are not shown again, so whatever the error carries goes into .env first.
|
|
529
559
|
if (error instanceof LoginRefused) {
|
|
530
|
-
const written = writeEnv(file, lines, before, envEntries(error.fields), (n) => options.replace || rotatedEnv.has(n));
|
|
560
|
+
const written = writeEnv(file, lines, before, envEntries(withTryUrl(error.fields)), (n) => options.replace || rotatedEnv.has(n) || chosenPage(n));
|
|
531
561
|
if (written.some((w) => w.action === "added" || w.action === "replaced"))
|
|
532
562
|
say(`Kept in .env before the error: ${written.map((w) => `${w.name} ${w.action}`).join(", ")}.`);
|
|
533
563
|
}
|
|
@@ -537,7 +567,7 @@ async function setupEnv(options, json, out, say, forLink) {
|
|
|
537
567
|
const warnings = warningsOf(fields);
|
|
538
568
|
const had = new Map(before);
|
|
539
569
|
// --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)));
|
|
570
|
+
const written = writeEnv(file, lines, before, envEntries(withTryUrl(fields)), (n) => rotatedEnv.has(n) || (options.replace && !MODEL_KEY_NAMES.includes(n)) || chosenPage(n));
|
|
541
571
|
if (typeof answer.join_code !== "string" || !answer.join_code) {
|
|
542
572
|
const kept = written.filter((w) => w.action === "added" || w.action === "replaced").map((w) => w.name);
|
|
543
573
|
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);
|
|
@@ -592,19 +622,25 @@ async function setupEnv(options, json, out, say, forLink) {
|
|
|
592
622
|
// Said by link instead, once, with its link.
|
|
593
623
|
if (!forLink)
|
|
594
624
|
say(model.line);
|
|
595
|
-
|
|
596
|
-
if (people && typeof people.used === "number" && typeof people.limit === "number")
|
|
597
|
-
say(`iMessage: ${people.used} of ${people.limit} people`);
|
|
625
|
+
// link says its own channel's trial line instead.
|
|
598
626
|
if (!forLink) {
|
|
627
|
+
const people = peopleLine(trial?.imessage);
|
|
628
|
+
if (people)
|
|
629
|
+
say(people);
|
|
599
630
|
for (const c of open)
|
|
600
631
|
say(`On ${CHANNEL_NAME[c]}: ${direct[c] ? `${direct[c].link} (on a computer: ${links[c]})` : links[c]}${trialNote(trial?.[c])}`);
|
|
601
632
|
const added = addedToRunning((n) => had.get(n) || process.env[n]?.trim(), (n) => before.get(n) || process.env[n]?.trim());
|
|
602
633
|
if (added)
|
|
603
|
-
say(added);
|
|
634
|
+
say(`If npm start is running, restart it to add ${added}.`);
|
|
604
635
|
say(`Next: ${next.join(", then ")}`);
|
|
605
636
|
}
|
|
606
637
|
return { answer };
|
|
607
638
|
}
|
|
639
|
+
/** iMessage's count of the people it may reach on the trial, as a line; undefined when the answer had none. */
|
|
640
|
+
function peopleLine(t) {
|
|
641
|
+
const people = t?.contacts;
|
|
642
|
+
return people && typeof people.used === "number" && typeof people.limit === "number" ? `iMessage: ${people.used} of ${people.limit} people` : undefined;
|
|
643
|
+
}
|
|
608
644
|
const trialNote = (t) => (t && typeof t.limit === "number" ? ` (free trial: ${t.used} of ${t.limit} messages used)` : "");
|
|
609
645
|
/** An answer's field when it is an object (flow_key, model_credit), else undefined (an older server sends none). */
|
|
610
646
|
function objectField(fields, name) {
|
|
@@ -658,8 +694,8 @@ function writeEnv(file, lines, before, entries, replace) {
|
|
|
658
694
|
}
|
|
659
695
|
/**
|
|
660
696
|
* 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.
|
|
697
|
+
* `had` to `now`: the channels it is not on yet and a model key, as words ("Telegram and the model
|
|
698
|
+
* key"); undefined when nothing it uses is new.
|
|
663
699
|
*/
|
|
664
700
|
function addedToRunning(had, now) {
|
|
665
701
|
// The starter is on Telegram whenever it has the Flow Messaging key, and on WhatsApp and iMessage
|
|
@@ -670,7 +706,7 @@ function addedToRunning(had, now) {
|
|
|
670
706
|
const what = [...channels, ...model];
|
|
671
707
|
if (!what.length)
|
|
672
708
|
return undefined;
|
|
673
|
-
return
|
|
709
|
+
return `${what.length === 1 ? what[0] : `${what.slice(0, -1).join(", ")} and ${what.at(-1)}`}`;
|
|
674
710
|
}
|
|
675
711
|
/**
|
|
676
712
|
* `flow status`: what this folder has and lacks, read from .env and the environment, and, when
|
|
@@ -772,6 +808,7 @@ async function link(options, json, out, say) {
|
|
|
772
808
|
const needsSender = options.channel !== "telegram";
|
|
773
809
|
let value = dotenvValue();
|
|
774
810
|
let credit;
|
|
811
|
+
let trial;
|
|
775
812
|
let had;
|
|
776
813
|
if (!value(SETUP_ENV.join_code) || !value(SETUP_ENV.messaging_key) || (needsSender && !value(SENDER_ENV[options.channel]))) {
|
|
777
814
|
agentFolder(process.cwd());
|
|
@@ -781,7 +818,7 @@ async function link(options, json, out, say) {
|
|
|
781
818
|
}
|
|
782
819
|
say("Setting up this agent's keys in .env.");
|
|
783
820
|
had = value;
|
|
784
|
-
({ answer: { model_credit: credit } } = await setupEnv({ replace: false, rotate: [], ci: false }, json, out, say, { channel: options.channel }));
|
|
821
|
+
({ answer: { model_credit: credit, trial } } = await setupEnv({ replace: false, rotate: [], ci: false }, json, out, say, { channel: options.channel }));
|
|
785
822
|
value = dotenvValue();
|
|
786
823
|
}
|
|
787
824
|
const joinCode = value(SETUP_ENV.join_code);
|
|
@@ -827,11 +864,22 @@ async function link(options, json, out, say) {
|
|
|
827
864
|
const replyOnly = REPLY_ONLY[channel];
|
|
828
865
|
const sayToPerson = `${notOpen ? `${what} isn't open yet. ` : ""}Your ${CHANNEL_NAME[channel]} link: ${first.link} ${OPEN_HINT[channel]} `
|
|
829
866
|
+ `On a computer without ${CHANNEL_NAME[channel]}, open ${first.page} instead.${replyOnly ? ` ${replyOnly}` : ""}`;
|
|
867
|
+
const added = had ? addedToRunning(had, value) : undefined;
|
|
868
|
+
// npm start reads .env and its name once, when it starts. Not running: start it. Running (its
|
|
869
|
+
// .flow/running.json names a live process): restart it when setup wrote .env, the name changed
|
|
870
|
+
// just now or a model key is still to come, else it is ready as it is.
|
|
871
|
+
const running = runningAgent();
|
|
872
|
+
const runStep = !running
|
|
873
|
+
? "npm start, then send the link above"
|
|
874
|
+
: had || options.renamed || model.next
|
|
875
|
+
? `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`
|
|
876
|
+
: `send the link above (npm start is running at ${running.url})`;
|
|
877
|
+
const next = [...(model.next ? [model.next] : []), runStep];
|
|
830
878
|
if (json) {
|
|
831
879
|
out({
|
|
832
880
|
url: first.link, page: first.page, join_message: first.message, channel, join_code: joinCode, reply_only: Boolean(replyOnly), say_to_person: sayToPerson,
|
|
833
881
|
...(notOpen ? { not_open: options.channel, message: notOpen, links: Object.fromEntries(links.map(([c, d]) => [c, d.link])) } : {}),
|
|
834
|
-
model_key: model.report,
|
|
882
|
+
model_key: model.report, running: running ? { url: running.url } : false, next: next.join(", then "),
|
|
835
883
|
});
|
|
836
884
|
}
|
|
837
885
|
if (notOpen) {
|
|
@@ -843,6 +891,18 @@ async function link(options, json, out, say) {
|
|
|
843
891
|
say(first.link);
|
|
844
892
|
say(`On a computer without ${CHANNEL_NAME[channel]}: ${first.page}`);
|
|
845
893
|
}
|
|
894
|
+
// The trial's counts for the channel the link is on, when setup just gave them; no other channel's.
|
|
895
|
+
const counts = trial?.[channel];
|
|
896
|
+
if (counts) {
|
|
897
|
+
const note = trialNote(counts);
|
|
898
|
+
if (note)
|
|
899
|
+
say(`${CHANNEL_NAME[channel]} ${note.trim().slice(1, -1)}`); // "WhatsApp free trial: 3 of 300 messages used"
|
|
900
|
+
if (channel === "imessage") {
|
|
901
|
+
const people = peopleLine(counts);
|
|
902
|
+
if (people)
|
|
903
|
+
say(people);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
846
906
|
if (model.report.set)
|
|
847
907
|
say(model.line);
|
|
848
908
|
const one = notOpen && links.length > 1 ? "one" : "it";
|
|
@@ -850,13 +910,11 @@ async function link(options, json, out, say) {
|
|
|
850
910
|
// No model key: the agent cannot answer yet, so the phone does not chat with it until there is one.
|
|
851
911
|
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
912
|
if (model.next)
|
|
853
|
-
say(
|
|
913
|
+
say(model.line);
|
|
854
914
|
for (const [c] of links)
|
|
855
915
|
if (REPLY_ONLY[c])
|
|
856
916
|
say(REPLY_ONLY[c]);
|
|
857
|
-
|
|
858
|
-
if (added)
|
|
859
|
-
say(added);
|
|
917
|
+
say(`Next: ${next.join(", then ")}.`);
|
|
860
918
|
}
|
|
861
919
|
/**
|
|
862
920
|
* 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
|
+
}
|