@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
|
@@ -87,6 +87,87 @@ const expectedCall = z.union([
|
|
|
87
87
|
args: withClock(z.record(z.string(), z.unknown())).optional(),
|
|
88
88
|
}),
|
|
89
89
|
], { error: "a tool name, or { tool: <name>, args: { <field>: <value> } }" });
|
|
90
|
+
/** "8h30m", "2d", "45m": how long a `wait` step lets pass. */
|
|
91
|
+
const WAIT = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/;
|
|
92
|
+
/** A wait's length in ms, or null when it is not one. */
|
|
93
|
+
export function waitMs(text) {
|
|
94
|
+
const m = WAIT.exec(text.trim());
|
|
95
|
+
if (!m || !(m[1] || m[2] || m[3]))
|
|
96
|
+
return null;
|
|
97
|
+
const ms = (Number(m[1] ?? 0) * 24 * 60 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) * 60_000;
|
|
98
|
+
return ms > 0 ? ms : null;
|
|
99
|
+
}
|
|
100
|
+
/** A moment on the test's clock: a date and time (local to the scenario's timezone unless it has an offset),
|
|
101
|
+
* HH:MM (the next time the clock shows it), or a `{{now ...}}` placeholder. */
|
|
102
|
+
const moment = z.string().refine((t) => isDateTime(t) || /^([01]\d|2[0-3]):[0-5]\d$/.test(t) || (/^\s*\{\{[\s\S]*\}\}\s*$/.test(t) && placeholderProblem(t) === null), "a time on the test's clock: 2026-10-20T23:30, 2026-10-20T23:30+05:30, 23:30, or {{now + 1d @ 08:00}}");
|
|
103
|
+
const template = z.strictObject({
|
|
104
|
+
name: z.string().min(1),
|
|
105
|
+
language: z.string().optional(),
|
|
106
|
+
body: z.array(z.string()).optional(),
|
|
107
|
+
buttons: z.array(z.strictObject({ index: z.number().int().min(0), value: z.string() })).optional(),
|
|
108
|
+
});
|
|
109
|
+
/** A Flow event as the business's backend sends it; `id` and `occurredAt` default to one per step and the clock. */
|
|
110
|
+
const flowEvent = withClock(z.strictObject({
|
|
111
|
+
id: z.string().min(1).optional(),
|
|
112
|
+
type: z.string().min(1),
|
|
113
|
+
occurredAt: z.string().optional(),
|
|
114
|
+
subject: z.string().min(1),
|
|
115
|
+
delivery: z.string().optional(),
|
|
116
|
+
data: z.record(z.string(), z.unknown()),
|
|
117
|
+
}));
|
|
118
|
+
const businessNudge = withClock(z
|
|
119
|
+
.strictObject({
|
|
120
|
+
text: z.string().min(1).optional(),
|
|
121
|
+
prompt: z.string().min(1).optional(),
|
|
122
|
+
template: template.optional(),
|
|
123
|
+
respectQuietHours: z.boolean().optional(),
|
|
124
|
+
expiresAt: moment.optional(),
|
|
125
|
+
})
|
|
126
|
+
.refine((n) => [n.text, n.prompt, n.template].filter((v) => v !== undefined).length === 1, "exactly one of text, prompt or template"));
|
|
127
|
+
/** What the agent's route must answer an event: a status, or a status and the field a 400 names. */
|
|
128
|
+
const answer = z.union([z.number().int().min(100).max(599), z.strictObject({ status: z.number().int().min(100).max(599), field: z.string().min(1).optional() })]);
|
|
129
|
+
/** One thing the agent sent (`expect.sent`), or must not have (`expect.not_sent`; `{}` is anything at all). */
|
|
130
|
+
const sentItem = z.strictObject({
|
|
131
|
+
/** When, on the test's clock: HH:MM (any day), or a date and time; to the minute. */
|
|
132
|
+
at: moment.optional(),
|
|
133
|
+
/** A template by name. */
|
|
134
|
+
template: z.string().min(1).optional(),
|
|
135
|
+
/** The agent's own words rather than a template. */
|
|
136
|
+
phrased: z.literal(true).optional(),
|
|
137
|
+
/** Text the message (or the template's values) holds. */
|
|
138
|
+
text: withClock(z.string().min(1)).optional(),
|
|
139
|
+
});
|
|
140
|
+
const openingStep = z
|
|
141
|
+
.object({
|
|
142
|
+
say: withClock(z.string()).optional(),
|
|
143
|
+
/** A file the person sends with it (say is its caption): a path in the tests folder. */
|
|
144
|
+
attach: z.object({ kind: z.enum(["image", "audio", "document"]), file: z.string().refine(inTests, "a path inside the tests folder, such as fixtures/receipt.jpg") }).optional(),
|
|
145
|
+
after_ms: z.number().int().min(0).optional(),
|
|
146
|
+
/** The business's backend sends this event, to the agent's own route. */
|
|
147
|
+
event: flowEvent.optional(),
|
|
148
|
+
/** The route the event goes to; /v1/events when left out. */
|
|
149
|
+
path: z.string().regex(/^\/[^\s?#]*$/, "a path such as /v1/events").optional(),
|
|
150
|
+
/** bad: signed with another secret; missing: no Flow-Signature header. */
|
|
151
|
+
signature: z.enum(["bad", "missing"]).optional(),
|
|
152
|
+
/** What the route must answer; anything 2xx when left out. */
|
|
153
|
+
answer: answer.optional(),
|
|
154
|
+
/** The business nudges the person. */
|
|
155
|
+
nudge: businessNudge.optional(),
|
|
156
|
+
/** Time passes on the test's clock. */
|
|
157
|
+
wait: z.string().refine((t) => waitMs(t) !== null, 'a length of time such as 8h30m, 45m or 2d').optional(),
|
|
158
|
+
/** Move the test's clock to this time before the step. */
|
|
159
|
+
at: moment.optional(),
|
|
160
|
+
})
|
|
161
|
+
.superRefine((step, ctx) => {
|
|
162
|
+
const kinds = [step.say !== undefined || step.attach ? "say" : null, step.event ? "event" : null, step.nudge ? "nudge" : null, step.wait !== undefined ? "wait" : null].filter(Boolean);
|
|
163
|
+
if (kinds.length !== 1)
|
|
164
|
+
ctx.addIssue({ code: "custom", message: kinds.length ? `one of say, event, nudge or wait per step, not ${kinds.join(" and ")}` : "say something, attach a file, send an event, nudge, or wait" });
|
|
165
|
+
if (!step.event && (step.path || step.signature || step.answer !== undefined))
|
|
166
|
+
ctx.addIssue({ code: "custom", message: "path, signature and answer belong to an event step" });
|
|
167
|
+
if (kinds[0] === "say" && !step.say && !step.attach)
|
|
168
|
+
ctx.addIssue({ code: "custom", message: "say something, attach a file, or both" });
|
|
169
|
+
})
|
|
170
|
+
.transform((step) => (step.event || step.nudge || step.wait !== undefined ? step : { ...step, say: step.say ?? "" }));
|
|
90
171
|
const scenarioSchema = z.object({
|
|
91
172
|
id: z.string().min(1),
|
|
92
173
|
title: z.string().optional(),
|
|
@@ -100,20 +181,15 @@ const scenarioSchema = z.object({
|
|
|
100
181
|
.preprocess((v) => (v instanceof Date ? v.toISOString().replace(/\.\d+Z$/, "Z") : v), z.string().refine(isDateTime, "not a date and time: write YYYY-MM-DDTHH:MM, with :SS, Z or an offset such as -04:00 if needed"))
|
|
101
182
|
.optional(),
|
|
102
183
|
person: z.object({
|
|
184
|
+
/** Their address on the run's channel (a phone number on phone channels): who a business run's events and nudges are for. */
|
|
185
|
+
address: z.string().min(1).optional(),
|
|
103
186
|
persona: withClock(z.string()),
|
|
104
187
|
goal: withClock(z.string()),
|
|
105
188
|
knows: z.array(withClock(z.string())).optional(),
|
|
106
189
|
}),
|
|
107
|
-
opening: z
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
say: withClock(z.string()).default(""),
|
|
111
|
-
/** A file the person sends with it (say is its caption): a path in the tests folder. */
|
|
112
|
-
attach: z.object({ kind: z.enum(["image", "audio", "document"]), file: z.string().refine(inTests, "a path inside the tests folder, such as fixtures/receipt.jpg") }).optional(),
|
|
113
|
-
after_ms: z.number().int().min(0).optional(),
|
|
114
|
-
})
|
|
115
|
-
.refine((step) => step.say || step.attach, "say something, attach a file, or both"))
|
|
116
|
-
.min(1),
|
|
190
|
+
opening: z.array(openingStep).min(1),
|
|
191
|
+
/** Tools whose own code runs in this scenario, in place of the cloud's simulation (state the agent keeps itself). */
|
|
192
|
+
tools: z.strictObject({ real: z.array(z.string().min(1)).min(1) }).optional(),
|
|
117
193
|
world: withClock(z.string()).optional(),
|
|
118
194
|
max_turns: z.number().int().min(1).max(50).default(8),
|
|
119
195
|
expect: z
|
|
@@ -124,6 +200,10 @@ const scenarioSchema = z.object({
|
|
|
124
200
|
calls: z.array(expectedCall).optional(),
|
|
125
201
|
/** None may be called. */
|
|
126
202
|
never: z.array(expectedCall).optional(),
|
|
203
|
+
/** What the agent sent, in order (other sends may come between): checked by code, with the test's clock. */
|
|
204
|
+
sent: z.array(sentItem).optional(),
|
|
205
|
+
/** None of these was sent; `{}` is anything at all. */
|
|
206
|
+
not_sent: z.array(sentItem).optional(),
|
|
127
207
|
})
|
|
128
208
|
.default({}),
|
|
129
209
|
});
|
|
@@ -149,6 +229,10 @@ export function mimeOf(path) {
|
|
|
149
229
|
const types = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", gif: "image/gif", pdf: "application/pdf", ogg: "audio/ogg", opus: "audio/ogg", mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav" };
|
|
150
230
|
return types[extname(path).slice(1).toLowerCase()] ?? "application/octet-stream";
|
|
151
231
|
}
|
|
232
|
+
/** The business starts something in it (an event, a nudge, time passing), or it checks what was sent: a business run. */
|
|
233
|
+
export function isBusiness(scenario) {
|
|
234
|
+
return scenario.opening.some((s) => s.event || s.nudge || s.wait !== undefined || s.at !== undefined) || !!scenario.expect.sent || !!scenario.expect.not_sent || !!scenario.tools;
|
|
235
|
+
}
|
|
152
236
|
/** Parse and check a scenario. The error has one line per problem: `file:line: field: what is wrong`. */
|
|
153
237
|
export function parseScenario(file) {
|
|
154
238
|
return parseFile(scenarioSchema, file);
|
package/docs/agents.md
CHANGED
|
@@ -15,16 +15,24 @@ package.
|
|
|
15
15
|
```sh
|
|
16
16
|
npx @flow-engineer/flow init --display-name "FlatX agent" # the starter in this (empty) folder; ask the owner what to call it first
|
|
17
17
|
npm install
|
|
18
|
-
npx @flow-engineer/flow link --json
|
|
19
|
-
npm start # keep it running
|
|
18
|
+
npx @flow-engineer/flow link --channel telegram --json # or whatsapp, imessage: signs in and sets up if needed (the keys, Flow's trial model key among them), then prints the phone link
|
|
19
|
+
npm start # after link: keep it running; the playground link it prints, and the phone link reaches it
|
|
20
20
|
npx @flow-engineer/flow status --json # any time: what the folder has and lacks
|
|
21
21
|
printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: talk to it locally, one message per line
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Before `init` (or `flow new <folder>`), ask the owner what people should call their agent, such as
|
|
25
25
|
"FlatX agent", and pass it as `--display-name`. It is kept as `displayName` in `package.json`, and
|
|
26
|
-
the message their phone sends to reach the agent is `Hi FlatX agent (ref <code>)
|
|
27
|
-
|
|
26
|
+
the message their phone sends to reach the agent is `Hi FlatX agent (ref <code>)`. The name takes the
|
|
27
|
+
starter's (Pip) place in the agent's prompt, `agent/AGENTS.md`, and the playground and MCP server
|
|
28
|
+
read it from `package.json`. To rename an agent made already, `link --display-name "<name>"` (or
|
|
29
|
+
`setup --display-name`) changes both; restart `npm start` after it. Without the flag, `init` asks in
|
|
30
|
+
a terminal and otherwise uses the folder's name in words.
|
|
31
|
+
|
|
32
|
+
`npm start` listens on `PORT`, else 8788: with 8788 taken and no `PORT` set it takes the next free
|
|
33
|
+
port and says so, and the playground link it prints has the port it uses. With `PORT` set to a port
|
|
34
|
+
that is taken it prints one line, `Port 8788 is in use. Run: PORT=8798 npm start`, and exits 1.
|
|
35
|
+
`npm run mcp` does the same with `MCP_PORT` (else 8790).
|
|
28
36
|
|
|
29
37
|
## How the owner tests it
|
|
30
38
|
|
|
@@ -64,7 +72,8 @@ before you hand it back; run them yourself, and do not offer them to the owner a
|
|
|
64
72
|
- `link` does the rest when the folder is not set up (no `FLOW_FRAMEWORK_JOIN_CODE` or
|
|
65
73
|
`FLOW_MESSAGING_KEY` in `.env`, or no sender for the WhatsApp or iMessage link asked for): it
|
|
66
74
|
runs `login` (opens the owner's browser itself and prints the link and code at once), then
|
|
67
|
-
`setup` (writes the keys and each open channel's sender into `.env
|
|
75
|
+
`setup` (writes the keys and each open channel's sender into `.env`, and the chosen channel's
|
|
76
|
+
page as `FLOW_FRAMEWORK_TRY_URL`), then prints the link and that channel's free-trial line only.
|
|
68
77
|
`--channel telegram|whatsapp|imessage` picks the channel (Telegram by default). Run it in the
|
|
69
78
|
agent's folder: `setup` and `link` refuse a folder with no `package.json` (`not_an_agent_folder`).
|
|
70
79
|
- A channel that is not open yet is said once ("iMessage isn't open yet; here are your Telegram
|
|
@@ -75,9 +84,13 @@ before you hand it back; run them yourself, and do not offer them to the owner a
|
|
|
75
84
|
24 hours of their last message); it cannot message them first. `link --json` says so
|
|
76
85
|
(`reply_only: true`, and in `say_to_person`): tell the owner.
|
|
77
86
|
- The starter's `npm start` is on Telegram, plus WhatsApp and iMessage when `.env` has their senders
|
|
78
|
-
(`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`), and reads `.env` once.
|
|
79
|
-
`
|
|
80
|
-
it
|
|
87
|
+
(`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`), and reads `.env` once.
|
|
88
|
+
`npm start` marks itself running in `.flow/running.json`, so `link` ends with the step that fits:
|
|
89
|
+
`Next: npm start, then send the link above` when it is not running; when it is, `Next: restart npm
|
|
90
|
+
start (it runs at <url> and lacks iMessage), then send the link above` if `link` just wrote `.env`
|
|
91
|
+
or renamed it, else `Next: send the link above (npm start is running at <url>)` (`link --json`:
|
|
92
|
+
`running`, `false` or `{"url"}`, and `next`). `setup` alone says "If npm start is running, restart it to add
|
|
93
|
+
iMessage."
|
|
81
94
|
- Give the owner `say_to_person` from `link --json`, and the sign-in link and code from the
|
|
82
95
|
`pending` line while you wait.
|
|
83
96
|
- `npm start` connects to Flow Messaging by an outbound event stream. Messages sent while it was
|
|
@@ -108,7 +121,8 @@ Every error is `{"error":{"code","message","next"}}` on stdout with `--json`, an
|
|
|
108
121
|
`next` is the exact command or URL to run; when it came from flow.engineer it is the server's.
|
|
109
122
|
A run gives one `next`: `setup --json`'s is a list in order (a model key first when there is
|
|
110
123
|
none, then `npm start` for the playground, then `link` for the phone); inside `link` setup's line
|
|
111
|
-
has none, and
|
|
124
|
+
has none, and `link`'s `next` is one string (a model key first when there is none, then starting or
|
|
125
|
+
restarting `npm start`); an error's `next` replaces it.
|
|
112
126
|
Run it, or give it to the owner when it needs them (a browser approval, a page to open). A
|
|
113
127
|
`slow_down` error also has `retry_after` (seconds, or `null` when the server gave none). A
|
|
114
128
|
trial limit's `next` is a sentence, not a command: it is printed on its own line as it came; pass
|
|
@@ -140,6 +154,14 @@ reach (`iMessage: 1 of 2 people`; `trial.imessage.contacts` `{limit, used}` in `
|
|
|
140
154
|
with `FLOW_FRAMEWORK_LOGIN_URL` naming another server, commands stop with `signed_in_elsewhere`.
|
|
141
155
|
- CI never signs in: `npx @flow-engineer/flow setup --ci` says how to give it the test cloud key.
|
|
142
156
|
|
|
157
|
+
## Messages the business starts
|
|
158
|
+
|
|
159
|
+
A nudge, a scheduled message or any turn the person did not start goes with link previews off
|
|
160
|
+
(`preview: false` on the message; Flow Messaging's WhatsApp `preview_url: false`): on WhatsApp a
|
|
161
|
+
preview card's title becomes the phone's notification instead of the message's text. For a call to
|
|
162
|
+
action use `link` (`agent.nudge({ to, text, link: { label, url } })`, or the agent's send with
|
|
163
|
+
`link`), not a URL in the text.
|
|
164
|
+
|
|
143
165
|
## Shared test numbers and privacy
|
|
144
166
|
|
|
145
167
|
The free trial runs on Flow's shared Telegram bot, WhatsApp number and iMessage line. Sending an
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
//
|
|
2
|
-
// FLOW_MESSAGING_KEY in .env (`npx @flow-engineer/flow link` puts it there),
|
|
1
|
+
// The agent in the playground: `npm start`, then open the printed link and pick a test person. With
|
|
2
|
+
// FLOW_MESSAGING_KEY in .env (`npx @flow-engineer/flow link` puts it there), it is on Telegram
|
|
3
3
|
// too (and WhatsApp and iMessage when .env names their senders), through Flow Messaging's event
|
|
4
|
-
// stream: an outbound connection, so no public URL or tunnel.
|
|
4
|
+
// stream: an outbound connection, so no public URL or tunnel. It listens on PORT, else 8788 or the
|
|
5
|
+
// next free port above it.
|
|
5
6
|
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { choosePort, markRunning } from "../../../src/index.ts";
|
|
6
9
|
import { flowMessaging } from "../../../flow-messaging/src/index.ts";
|
|
7
10
|
import { simulators } from "../../../simulators/src/index.ts";
|
|
8
11
|
import { buildAgent } from "./agent.ts";
|
|
9
12
|
import { channelNames, flowChannels } from "./channels.ts";
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
// What people call it: package.json's displayName (`npx @flow-engineer/flow link --display-name <name>` changes it).
|
|
15
|
+
const { displayName: name = "Pip" } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { displayName?: string };
|
|
16
|
+
const play = simulators({ title: `${name} · Bean & Leaf` });
|
|
12
17
|
const fm = process.env.FLOW_MESSAGING_KEY ? flowMessaging() : undefined;
|
|
13
18
|
const channels = fm ? flowChannels(fm) : [];
|
|
14
19
|
let agent: ReturnType<typeof buildAgent>;
|
|
@@ -18,12 +23,22 @@ try {
|
|
|
18
23
|
console.error((error as Error).message); // such as no model key in .env
|
|
19
24
|
process.exit(1);
|
|
20
25
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
let url: string;
|
|
27
|
+
try {
|
|
28
|
+
const { port, note } = await choosePort("PORT", 8788, { command: "npm start" });
|
|
29
|
+
if (note) console.log(note);
|
|
30
|
+
({ url } = await play.listen(agent, port));
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error((error as Error).message); // such as the port PORT names being in use
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const port = Number(new URL(url).port);
|
|
36
|
+
// `npx @flow-engineer/flow link` reads it (.flow/running.json) to say whether to start or restart this.
|
|
37
|
+
markRunning(url);
|
|
38
|
+
console.log(`${name}'s playground: ${url}/`);
|
|
24
39
|
console.log(`Every channel side by side: ${url}/person`);
|
|
25
40
|
if (fm) {
|
|
26
|
-
// Events sent while
|
|
41
|
+
// Events sent while it was not running come when it starts again (.flow/ keeps the last one handled).
|
|
27
42
|
const live = fm.start(agent, { cursorFile: ".flow/flow-messaging-cursor", port: port + 1 });
|
|
28
43
|
console.log(`On ${channelNames(channels)} through Flow Messaging (${live.mode}). The phone link: npx @flow-engineer/flow link`);
|
|
29
44
|
live.stopped.then((why) => console.error(`Flow Messaging stopped: ${why.message} Then: npx @flow-engineer/flow setup`));
|
package/examples/cafe/src/mcp.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The agent's tools and skills as an MCP server, for Claude, ChatGPT or any MCP host: `npm run mcp`.
|
|
2
2
|
// The same tools the chat agent uses; no model key needed, since the host brings its own model.
|
|
3
3
|
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
4
5
|
import { createServer } from "node:http";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { mcpServer } from "../../../mcp/src/index.ts";
|
|
8
|
+
import { choosePort } from "../../../src/index.ts";
|
|
7
9
|
import { tools } from "./tools.ts";
|
|
8
10
|
|
|
9
11
|
export const mcp = mcpServer({
|
|
@@ -15,8 +17,24 @@ export const mcp = mcpServer({
|
|
|
15
17
|
// auth: { issuer, resource, scopes, verify } (the chinwag-auth skill).
|
|
16
18
|
});
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
// What people call it: package.json's displayName.
|
|
21
|
+
const { displayName: name = "Pip" } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { displayName?: string };
|
|
22
|
+
// MCP_PORT, else 8790 or the next free port above it.
|
|
23
|
+
let port: number;
|
|
24
|
+
try {
|
|
25
|
+
const chosen = await choosePort("MCP_PORT", 8790, { command: "npm run mcp" });
|
|
26
|
+
port = chosen.port;
|
|
27
|
+
if (chosen.note) console.log(`${chosen.note} The CLI then needs CAFE_SERVER=http://127.0.0.1:${port}/mcp.`);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error((error as Error).message);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
createServer((req, res) => void mcp.node(req, res))
|
|
33
|
+
.once("error", (error) => {
|
|
34
|
+
console.error(`${error.message}. Run: MCP_PORT=<port> npm run mcp`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
})
|
|
37
|
+
.listen(port, "127.0.0.1", () => {
|
|
38
|
+
console.log(`${name}'s MCP server: http://127.0.0.1:${port}/mcp`);
|
|
39
|
+
console.log(`Try it in Claude Code: claude mcp add --transport http bean-and-leaf http://127.0.0.1:${port}/mcp`);
|
|
40
|
+
});
|
package/flow-messaging/README.md
CHANGED
|
@@ -148,7 +148,9 @@ Each of these can also be passed in code: `flowMessaging({ apiKey, webhookSecret
|
|
|
148
148
|
## What it does for you
|
|
149
149
|
|
|
150
150
|
- Every send carries its own Idempotency-Key. A 429 or 5xx is tried again with the same key after
|
|
151
|
-
a short Retry-After, so a message never goes out twice.
|
|
151
|
+
a short Retry-After, so a message never goes out twice. A nudge or a scheduled text keeps one key
|
|
152
|
+
from its id (`nudge:<id>`, `run:<id>`) in every process, so a restart or a second instance does not
|
|
153
|
+
send it again.
|
|
152
154
|
- Flow's `409 outside_window` becomes the framework's `outside_window`, so the agent knows to send a
|
|
153
155
|
template.
|
|
154
156
|
- A permanent refusal (opted out, no consent, ...) reaches the agent once, with "don't resend it".
|