@flow-engineer/flow 0.2.1 → 0.2.3
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 +167 -10
- package/coding-agents/auth-skill.md +137 -0
- package/coding-agents/context-skill.md +94 -0
- package/coding-agents/skill.md +219 -17
- package/coding-agents/test-skill.md +3 -1
- package/coding-agents/tools-skill.md +78 -1
- package/dist/cli/src/auth.d.ts +11 -0
- package/dist/cli/src/auth.js +147 -0
- package/dist/cli/src/commands.d.ts +119 -0
- package/dist/cli/src/commands.js +212 -0
- package/dist/cli/src/index.d.ts +2 -0
- package/dist/cli/src/index.js +5 -0
- package/dist/cli/src/run.d.ts +28 -0
- package/dist/cli/src/run.js +523 -0
- package/dist/cli/src/terminal.d.ts +3 -0
- package/dist/cli/src/terminal.js +135 -0
- 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 +17 -9
- package/dist/mcp/src/client.d.ts +54 -0
- package/dist/mcp/src/client.js +175 -0
- package/dist/mcp/src/describe.d.ts +41 -0
- package/dist/mcp/src/describe.js +110 -0
- package/dist/mcp/src/index.d.ts +4 -0
- package/dist/mcp/src/index.js +7 -0
- package/dist/mcp/src/server.d.ts +70 -0
- package/dist/mcp/src/server.js +252 -0
- package/dist/mcp/src/terminal.d.ts +24 -0
- package/dist/mcp/src/terminal.js +54 -0
- package/dist/simulators/src/index.d.ts +1 -1
- package/dist/simulators/src/page.html +267 -49
- package/dist/simulators/src/server.d.ts +27 -0
- package/dist/simulators/src/server.js +264 -40
- package/dist/src/agent.d.ts +69 -3
- package/dist/src/agent.js +162 -8
- package/dist/src/channels.d.ts +6 -4
- package/dist/src/channels.js +4 -3
- package/dist/src/conversation.d.ts +17 -1
- package/dist/src/conversation.js +170 -16
- package/dist/src/events.d.ts +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/model.d.ts +27 -0
- package/dist/src/model.js +85 -13
- package/dist/src/skills.d.ts +4 -0
- package/dist/src/skills.js +23 -0
- package/dist/src/store.d.ts +2 -0
- package/dist/src/store.js +18 -0
- package/dist/src/tools.d.ts +69 -0
- package/dist/src/tools.js +17 -1
- package/dist/src/traces.js +32 -4
- package/dist/src/types.d.ts +5 -0
- package/dist/testing/src/account.d.ts +1 -1
- package/dist/testing/src/account.js +368 -57
- package/dist/testing/src/chat.js +24 -16
- package/dist/testing/src/check.d.ts +4 -0
- package/dist/testing/src/check.js +90 -0
- package/dist/testing/src/cli.js +77 -19
- package/dist/testing/src/flow-login.d.ts +33 -0
- package/dist/testing/src/flow-login.js +30 -5
- package/dist/testing/src/new.d.ts +14 -5
- package/dist/testing/src/new.js +54 -20
- package/dist/testing/src/plugin.d.ts +4 -0
- package/dist/testing/src/plugin.js +116 -0
- package/docs/agents.md +87 -12
- package/examples/cafe/src/cli.ts +24 -0
- package/examples/cafe/src/mcp.ts +22 -0
- package/examples/cafe/src/tools.ts +13 -0
- package/flow-messaging/README.md +3 -1
- package/package.json +28 -1
|
@@ -28,8 +28,10 @@
|
|
|
28
28
|
// free, and POST /api/reset to start a conversation over. Its transcript is kept in memory.
|
|
29
29
|
//
|
|
30
30
|
// The person's messages have the agent's ids (m1, m2, ...); the agent's messages get a1, a2, ...
|
|
31
|
-
import { createHash } from "node:crypto";
|
|
32
|
-
import { readFileSync } from "node:fs";
|
|
31
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
32
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
33
|
+
import { tmpdir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
33
35
|
import { createServer } from "node:http";
|
|
34
36
|
import { declaredTemplate, fillTemplate, fitMessage, } from "../../src/index.js";
|
|
35
37
|
import { requireToken, sameSecret, wrongTries } from "../../src/http.js";
|
|
@@ -41,6 +43,163 @@ export function playgroundClock() {
|
|
|
41
43
|
set: (at) => void (offset = Math.max(offset, at - Date.now())),
|
|
42
44
|
};
|
|
43
45
|
}
|
|
46
|
+
/** Literal argv, with quoting and escaping, but no shell expansion, pipelines or execution. */
|
|
47
|
+
function cliWords(text) {
|
|
48
|
+
const words = [];
|
|
49
|
+
let word = "", quote = "", started = false, escape = false;
|
|
50
|
+
for (const ch of text) {
|
|
51
|
+
if (escape) {
|
|
52
|
+
word += ch;
|
|
53
|
+
escape = false;
|
|
54
|
+
started = true;
|
|
55
|
+
}
|
|
56
|
+
else if (ch === "\\" && quote !== "'") {
|
|
57
|
+
escape = true;
|
|
58
|
+
started = true;
|
|
59
|
+
}
|
|
60
|
+
else if (quote) {
|
|
61
|
+
if (ch === quote)
|
|
62
|
+
quote = "";
|
|
63
|
+
else
|
|
64
|
+
word += ch;
|
|
65
|
+
}
|
|
66
|
+
else if (ch === '"' || ch === "'") {
|
|
67
|
+
quote = ch;
|
|
68
|
+
started = true;
|
|
69
|
+
}
|
|
70
|
+
else if (/\s/.test(ch)) {
|
|
71
|
+
if (started)
|
|
72
|
+
words.push(word);
|
|
73
|
+
word = "";
|
|
74
|
+
started = false;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
word += ch;
|
|
78
|
+
started = true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (quote || escape)
|
|
82
|
+
throw new Error("Finish the quoted or escaped argument, then try again.");
|
|
83
|
+
if (started)
|
|
84
|
+
words.push(word);
|
|
85
|
+
return words;
|
|
86
|
+
}
|
|
87
|
+
function localRequest(req) {
|
|
88
|
+
// TODO: hosted CLI execution needs a security review before implementation/deployment.
|
|
89
|
+
// Keep this direct-loopback boundary; hosted support is not enabled by localCli options.
|
|
90
|
+
const ip = req.socket.remoteAddress ?? "";
|
|
91
|
+
return loopback(req.headers.host) && (ip === "::1" || /^127\./.test(ip) || /^::ffff:127\./.test(ip)) && !req.headers["x-forwarded-host"] && !req.headers["x-forwarded-for"];
|
|
92
|
+
}
|
|
93
|
+
/** Only explicitly enabled local playgrounds instantiate a runner; the test cloud never does. */
|
|
94
|
+
function localCliRunner(options, base) {
|
|
95
|
+
const sessions = new Map();
|
|
96
|
+
const dispose = (s) => { s.abort.abort(); s.accept?.(null); rmSync(s.directory, { recursive: true, force: true }); };
|
|
97
|
+
const clean = () => { for (const [key, s] of sessions)
|
|
98
|
+
if (Date.now() - s.touched > 30 * 60_000) {
|
|
99
|
+
dispose(s);
|
|
100
|
+
sessions.delete(key);
|
|
101
|
+
} };
|
|
102
|
+
const timer = setInterval(clean, 60_000);
|
|
103
|
+
timer.unref();
|
|
104
|
+
const close = () => { clearInterval(timer); for (const s of sessions.values())
|
|
105
|
+
dispose(s); sessions.clear(); process.removeListener("exit", close); };
|
|
106
|
+
process.once("exit", close);
|
|
107
|
+
const emit = (s, stream, text) => {
|
|
108
|
+
if (s.abort.signal.aborted)
|
|
109
|
+
return;
|
|
110
|
+
// Keep a reconnectable tail, with a bounded amount of developer-visible output.
|
|
111
|
+
text = text.slice(-128 * 1024);
|
|
112
|
+
s.output.push({ seq: ++s.seq, stream, text });
|
|
113
|
+
s.size += text.length;
|
|
114
|
+
while (s.size > 128 * 1024 && s.output.length > 1)
|
|
115
|
+
s.size -= s.output.shift().text.length;
|
|
116
|
+
};
|
|
117
|
+
const input = (s) => new Promise((resolve) => {
|
|
118
|
+
if (s.abort.signal.aborted)
|
|
119
|
+
return resolve(null);
|
|
120
|
+
s.waiting = true;
|
|
121
|
+
s.accept = (line) => { s.waiting = false; s.accept = undefined; resolve(line); };
|
|
122
|
+
});
|
|
123
|
+
const handle = async (req, res) => {
|
|
124
|
+
if (!localRequest(req) || crossSite(req))
|
|
125
|
+
return forbid(res, "The CLI runner is available only on the local playground's own origin.");
|
|
126
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
127
|
+
const path = inside(url, base);
|
|
128
|
+
clean();
|
|
129
|
+
const cookie = `chinwag_cli_${createHash("sha256").update(`${req.headers.host} ${base}`).digest("hex").slice(0, 12)}`;
|
|
130
|
+
let key = (req.headers.cookie ?? "").split(/;\s*/).find((c) => c.startsWith(`${cookie}=`))?.slice(cookie.length + 1);
|
|
131
|
+
let s = key ? sessions.get(key) : undefined;
|
|
132
|
+
if (req.method === "POST" && path === "/api/cli/reset" && s) {
|
|
133
|
+
dispose(s);
|
|
134
|
+
sessions.delete(key);
|
|
135
|
+
s = undefined;
|
|
136
|
+
}
|
|
137
|
+
if (!s) {
|
|
138
|
+
key = randomBytes(32).toString("base64url");
|
|
139
|
+
const directory = mkdtempSync(join(tmpdir(), "chinwag-cli-"));
|
|
140
|
+
s = { directory, config: { ...options.config, directory }, output: [], seq: 0, size: 0, touched: Date.now(), running: false, waiting: false, abort: new AbortController() };
|
|
141
|
+
const owned = s;
|
|
142
|
+
s.config.openUrl = (link) => emit(owned, "login", link);
|
|
143
|
+
sessions.set(key, s);
|
|
144
|
+
res.appendHeader("set-cookie", `${cookie}=${key}; HttpOnly; SameSite=Lax; Path=${base || "/"}`);
|
|
145
|
+
}
|
|
146
|
+
s.touched = Date.now();
|
|
147
|
+
if (req.method === "GET" && path === "/api/cli") {
|
|
148
|
+
const after = Math.max(0, Number(url.searchParams.get("after")) || 0);
|
|
149
|
+
return json(res, 200, { events: s.output.filter((e) => e.seq > after), seq: s.seq, running: s.running, waiting: s.waiting });
|
|
150
|
+
}
|
|
151
|
+
if (req.method === "POST" && path === "/api/cli/reset")
|
|
152
|
+
return json(res, 200, { ok: true });
|
|
153
|
+
if (req.method === "POST" && path === "/api/cli/input") {
|
|
154
|
+
const body = await readBody(req, MAX_BODY);
|
|
155
|
+
const text = typeof body?.text === "string" ? body.text.slice(0, 4_000) : "";
|
|
156
|
+
if (!text.trim())
|
|
157
|
+
return json(res, 400, { error: "Enter a command or a reply." });
|
|
158
|
+
if (s.running) {
|
|
159
|
+
if (!s.accept)
|
|
160
|
+
return json(res, 409, { error: "The CLI is still running. Wait for its input prompt." });
|
|
161
|
+
emit(s, "input", `${text}\n`);
|
|
162
|
+
s.accept(text);
|
|
163
|
+
return json(res, 202, { ok: true });
|
|
164
|
+
}
|
|
165
|
+
let argv;
|
|
166
|
+
try {
|
|
167
|
+
argv = cliWords(text);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
return json(res, 400, { error: error.message });
|
|
171
|
+
}
|
|
172
|
+
if (argv[0] === s.config.name)
|
|
173
|
+
argv.shift();
|
|
174
|
+
if (argv.some((w) => /^--(server|token)(=|$)/.test(w)))
|
|
175
|
+
return json(res, 400, { error: "This runner uses its configured server and its own sign-in. Use auth login." });
|
|
176
|
+
if (argv[0] === "skill" && argv.some((w) => /^--write(=|$)/.test(w)))
|
|
177
|
+
return json(res, 400, { error: "Use the installed CLI to write a skill to your computer; this runner can display it with skill." });
|
|
178
|
+
emit(s, "input", `$ ${text}\n`);
|
|
179
|
+
s.running = true;
|
|
180
|
+
const owned = s;
|
|
181
|
+
const io = {
|
|
182
|
+
env: { NO_COLOR: "1", TERM: "dumb" }, signal: s.abort.signal, interactive: true,
|
|
183
|
+
write: (text) => emit(owned, "stdout", text), writeError: (text) => emit(owned, "stderr", text),
|
|
184
|
+
async *lines(prompt) {
|
|
185
|
+
while (!owned.abort.signal.aborted) {
|
|
186
|
+
if (prompt)
|
|
187
|
+
emit(owned, "stdout", prompt);
|
|
188
|
+
const line = await input(owned);
|
|
189
|
+
if (line === null)
|
|
190
|
+
return;
|
|
191
|
+
yield line;
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
confirm: async (question) => { emit(owned, "stderr", `${question} [y/N] `); return /^y(es)?$/i.test((await input(owned)) ?? ""); },
|
|
195
|
+
};
|
|
196
|
+
void import("../../cli/src/run.js").then(({ runCli }) => runCli(owned.config, argv, io)).then((code) => emit(owned, "exit", String(code)), () => { emit(owned, "stderr", "The CLI could not finish this command.\n"); emit(owned, "exit", "1"); }).finally(() => { owned.running = false; owned.waiting = false; owned.accept = undefined; });
|
|
197
|
+
return json(res, 202, { ok: true });
|
|
198
|
+
}
|
|
199
|
+
res.writeHead(404).end();
|
|
200
|
+
};
|
|
201
|
+
return Object.assign(handle, { close });
|
|
202
|
+
}
|
|
44
203
|
// What a US SMS provider sends for the standard keywords (Twilio's defaults).
|
|
45
204
|
const CARRIER = {
|
|
46
205
|
stop: ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"],
|
|
@@ -186,6 +345,7 @@ export function simulators(options = {}) {
|
|
|
186
345
|
...(outbound.buttons ? { buttons: outbound.buttons } : {}),
|
|
187
346
|
...(outbound.list ? { list: outbound.list } : {}),
|
|
188
347
|
...(outbound.link ? { link: outbound.link } : {}),
|
|
348
|
+
...(outbound.authentication ? { authentication: true } : {}),
|
|
189
349
|
...(outbound.image ? { image: outbound.image } : {}),
|
|
190
350
|
...(outbound.fields ? { fields: outbound.fields } : {}),
|
|
191
351
|
...(sms ? { sms: { segments: sms.segments, encoding: sms.encoding } } : {}),
|
|
@@ -382,38 +542,46 @@ export function simulators(options = {}) {
|
|
|
382
542
|
};
|
|
383
543
|
return testCloud;
|
|
384
544
|
}
|
|
385
|
-
// ---------------------------------------------------------------------------------------------
|
|
386
|
-
// The playground.
|
|
387
|
-
/** The test person (by phone) each conversation belongs to, so its trace shows in their simulators. */
|
|
388
545
|
const people = new Map();
|
|
389
546
|
let playing = null;
|
|
390
|
-
const
|
|
547
|
+
const personKey = (person) => person.key ? `account:${person.key}` : person.visitor ? `visitor:${person.visitor}` : person.phone;
|
|
548
|
+
const threadKey = (channel, person) => `${channel}:${personKey(person)}`;
|
|
549
|
+
const rememberPerson = (id, person) => {
|
|
550
|
+
const all = people.get(id) ?? new Map();
|
|
551
|
+
all.set(personKey(person), person);
|
|
552
|
+
people.set(id, all);
|
|
553
|
+
};
|
|
554
|
+
const conversation = (agent, person) => person.key
|
|
555
|
+
? agent.visitorConversation(person.key) : person.visitor ? agent.visitorConversation(null, { channel: person.visitorChannel, address: person.visitor }) : agent.testConversation(person.phone);
|
|
391
556
|
const playgroundEvents = (event) => {
|
|
392
|
-
const
|
|
557
|
+
const known = people.get(event.conversationId);
|
|
558
|
+
const person = known?.values().next().value;
|
|
393
559
|
// A failed turn (a wrong or expired model key, say) on the terminal too, also for no known person.
|
|
394
560
|
if (event.type === "model.error")
|
|
395
561
|
console.error(`flow: model error${person ? ` (${person.name})` : ""}: ${String(event.error)}`);
|
|
396
|
-
if (!
|
|
562
|
+
if (!known || !playing)
|
|
397
563
|
return;
|
|
398
|
-
for (const
|
|
399
|
-
|
|
564
|
+
for (const person of known.values())
|
|
565
|
+
for (const c of playing.channels())
|
|
566
|
+
show(threadKey(c.name, person), event);
|
|
400
567
|
};
|
|
401
568
|
function attach(agent) {
|
|
402
569
|
playing = agent;
|
|
403
570
|
agent.attachPlayground(({ conversationId, person, channel, outbound }) => {
|
|
404
|
-
|
|
571
|
+
rememberPerson(conversationId, { ...person, visitorChannel: channel });
|
|
405
572
|
const own = agent.channels().find((c) => c.name === channel);
|
|
406
573
|
if (own)
|
|
407
|
-
|
|
574
|
+
for (const other of people.get(conversationId).values())
|
|
575
|
+
deliverer(platformFor(own), threadKey(channel, other), agent.templates?.())(outbound);
|
|
408
576
|
});
|
|
409
577
|
}
|
|
410
578
|
/** The conversation on one channel, rebuilt from the agent's store, with what only the platform showed (the carrier's replies). */
|
|
411
579
|
async function load(agent, person, channel) {
|
|
412
580
|
const k = threadKey(channel.name, person);
|
|
413
581
|
const t = thread(k);
|
|
414
|
-
const id = await agent
|
|
582
|
+
const id = await conversation(agent, person);
|
|
415
583
|
if (id)
|
|
416
|
-
|
|
584
|
+
rememberPerson(id, person);
|
|
417
585
|
const p = platformFor(channel);
|
|
418
586
|
let count = 0;
|
|
419
587
|
const lines = [];
|
|
@@ -446,7 +614,7 @@ export function simulators(options = {}) {
|
|
|
446
614
|
t.agentCount = count;
|
|
447
615
|
return t;
|
|
448
616
|
}
|
|
449
|
-
async function handle(agent, req, res, base, gate) {
|
|
617
|
+
async function handle(agent, req, res, base, gate, cli) {
|
|
450
618
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
451
619
|
const path = inside(url, base);
|
|
452
620
|
// Without a token, a page on another site (or a DNS name rebound to 127.0.0.1) must not run
|
|
@@ -457,6 +625,13 @@ export function simulators(options = {}) {
|
|
|
457
625
|
return forbid(res, "This playground's API answers only its own page.");
|
|
458
626
|
if (gate && !gate.allows(req, res, url, base))
|
|
459
627
|
return;
|
|
628
|
+
if (path === "/api/cli" || path.startsWith("/api/cli/")) {
|
|
629
|
+
if (!cli) {
|
|
630
|
+
res.writeHead(404).end();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
return cli.handle(req, res);
|
|
634
|
+
}
|
|
460
635
|
if (req.method === "GET" && path === "/")
|
|
461
636
|
return html(res, page(playPage, base));
|
|
462
637
|
if (req.method === "GET" && path === "/person")
|
|
@@ -466,7 +641,8 @@ export function simulators(options = {}) {
|
|
|
466
641
|
if (req.method === "GET" && path === "/api/platforms") {
|
|
467
642
|
const channels = agent.channels().map((c) => ({ name: c.name, kind: c.kind, platform: platformFor(c) }));
|
|
468
643
|
return json(res, 200, {
|
|
469
|
-
platforms: PLATFORMS, channels, people: agent.testPeople(), cards: options.cards ?? [],
|
|
644
|
+
platforms: PLATFORMS, channels, people: agent.testPeople(), cards: options.cards ?? [], authentication: !!options.authentication,
|
|
645
|
+
...(cli && localRequest(req) ? { localCli: { channel: cli.options.channel, name: cli.options.config.name } } : {}),
|
|
470
646
|
templates: (agent.templates?.() ?? []).map((t) => ({ name: t.name, body: t.body })),
|
|
471
647
|
panel: { person: !!(agent.contact && agent.outbox), nudge: !!agent.recordNudge, clock: !!(options.clock && agent.runDue) },
|
|
472
648
|
});
|
|
@@ -491,11 +667,42 @@ export function simulators(options = {}) {
|
|
|
491
667
|
return json(res, 200, { now: options.clock.now(), ran });
|
|
492
668
|
}
|
|
493
669
|
const asked = (name) => (typeof body?.[name] === "string" ? body[name] : (url.searchParams.get(name) ?? ""));
|
|
494
|
-
const
|
|
670
|
+
const isVisitor = asked("person") === "visitor";
|
|
671
|
+
let person = agent.testPeople().find((p) => digits(p.phone) === digits(asked("person")) && digits(p.phone) !== "");
|
|
672
|
+
const channel = agent.channels().find((c) => c.name === asked("channel"));
|
|
673
|
+
let visitorAddress = "";
|
|
674
|
+
if (isVisitor && options.authentication) {
|
|
675
|
+
if (!channel)
|
|
676
|
+
return json(res, 400, { error: "channel must be one of the agent's own channels" });
|
|
677
|
+
if (!agent.simulateVisitor || !agent.visitorConversation)
|
|
678
|
+
return json(res, 400, { error: "this playground needs an agent with authenticated visitor support" });
|
|
679
|
+
const cookieName = `chinwag_visitor_${createHash("sha256").update(`${req.headers.host} ${base}`).digest("hex").slice(0, 12)}`;
|
|
680
|
+
let browser = (req.headers.cookie ?? "").split(/;\s*/).find((cookie) => cookie.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1);
|
|
681
|
+
if (!browser || !/^[A-Za-z0-9_-]{43}$/.test(browser))
|
|
682
|
+
browser = undefined;
|
|
683
|
+
const reset = req.method === "POST" && path === "/api/auth/reset";
|
|
684
|
+
if (!browser || reset) {
|
|
685
|
+
browser = randomBytes(32).toString("base64url");
|
|
686
|
+
const secure = req.socket.encrypted || req.headers["x-forwarded-proto"] === "https";
|
|
687
|
+
res.setHeader("set-cookie", `${cookieName}=${browser}; HttpOnly; SameSite=Lax; Path=${base || "/"}; Max-Age=2592000${secure ? "; Secure" : ""}`);
|
|
688
|
+
}
|
|
689
|
+
// Each interface needs its own proof. A web login cannot authenticate an unrelated CLI.
|
|
690
|
+
const visitor = createHash("sha256").update(JSON.stringify([browser, channel.name])).digest("base64url");
|
|
691
|
+
const state = await options.authentication({ channel, visitor });
|
|
692
|
+
const publicState = { signedIn: !!state.key, label: state.label, signInUrl: state.signInUrl };
|
|
693
|
+
if (path === "/api/auth" || reset)
|
|
694
|
+
return json(res, 200, publicState);
|
|
695
|
+
visitorAddress = state.address ?? visitor;
|
|
696
|
+
person = state.key ? { phone: "", name: "Signed-in user", key: state.key } : { phone: "", name: "New user", visitor: visitorAddress, visitorChannel: channel.name };
|
|
697
|
+
}
|
|
698
|
+
else if (!person)
|
|
699
|
+
return json(res, 403, { error: "choose a declared test person, or a new user when sign-in is configured" });
|
|
495
700
|
if (!person)
|
|
496
|
-
return json(res, 403, { error: "
|
|
701
|
+
return json(res, 403, { error: "Sign in to continue." });
|
|
702
|
+
if (path === "/api/auth")
|
|
703
|
+
return json(res, 200, { signedIn: true });
|
|
497
704
|
if (req.method === "POST" && path === "/api/clear") {
|
|
498
|
-
const id = await agent
|
|
705
|
+
const id = await conversation(agent, person);
|
|
499
706
|
if (id) {
|
|
500
707
|
await agent.forgetPerson(id);
|
|
501
708
|
people.delete(id);
|
|
@@ -507,21 +714,21 @@ export function simulators(options = {}) {
|
|
|
507
714
|
}
|
|
508
715
|
return json(res, 200, { ok: true });
|
|
509
716
|
}
|
|
510
|
-
const channel = agent.channels().find((c) => c.name === asked("channel"));
|
|
511
717
|
if (!channel)
|
|
512
718
|
return json(res, 400, { error: "channel must be one of the agent's own channels" });
|
|
513
719
|
const k = threadKey(channel.name, person);
|
|
514
720
|
const p = platformFor(channel);
|
|
515
721
|
// The Person panel: what is set and waiting for this person, and the business's side (nudges).
|
|
516
|
-
const to = { channel: channel.name, address: channel.kind === "custom" ? `test:${person.phone}` : person.phone };
|
|
722
|
+
const to = { channel: channel.name, address: person.key ? visitorAddress : person.visitor ? `visitor:${visitorAddress}` : channel.kind === "custom" ? `test:${person.phone}` : person.phone };
|
|
517
723
|
if (req.method === "GET" && path === "/api/person") {
|
|
518
724
|
if (!agent.contact || !agent.outbox)
|
|
519
725
|
return json(res, 404, { error: "this agent has no contact record" });
|
|
520
|
-
const id = await agent
|
|
726
|
+
const id = await conversation(agent, person);
|
|
521
727
|
// One person on every channel: their record from whichever channel they have written on.
|
|
522
728
|
let contact = await agent.contact(to);
|
|
523
|
-
|
|
524
|
-
|
|
729
|
+
if (!person.key && !person.visitor)
|
|
730
|
+
for (const c of agent.channels())
|
|
731
|
+
contact ??= await agent.contact({ channel: c.name, address: c.kind === "custom" ? `test:${person.phone}` : person.phone });
|
|
525
732
|
// When the agent wrote on each channel (its latest 20), so the page can flag a channel it is not showing.
|
|
526
733
|
const lastOut = {};
|
|
527
734
|
for (const e of id ? await agent.transcript(id) : [])
|
|
@@ -570,7 +777,7 @@ export function simulators(options = {}) {
|
|
|
570
777
|
const t = await load(agent, person, channel);
|
|
571
778
|
// Taken with the lines, in the same step: every event after this number came after these lines.
|
|
572
779
|
recentOf(k);
|
|
573
|
-
return json(res, 200, { channel: channel.name, person, optedOut: t.optedOut, lines: t.lines, seq });
|
|
780
|
+
return json(res, 200, { channel: channel.name, person: { name: person.name, phone: person.phone }, optedOut: t.optedOut, lines: t.lines, seq });
|
|
574
781
|
}
|
|
575
782
|
if (req.method === "GET" && path === "/api/events")
|
|
576
783
|
return events(req, res, k, url);
|
|
@@ -580,7 +787,7 @@ export function simulators(options = {}) {
|
|
|
580
787
|
const messageId = typeof body?.messageId === "string" ? body.messageId : "";
|
|
581
788
|
const emoji = typeof body?.emoji === "string" ? body.emoji.slice(0, 16) : "";
|
|
582
789
|
const target = findLine(k, messageId);
|
|
583
|
-
const id = await agent
|
|
790
|
+
const id = await conversation(agent, person);
|
|
584
791
|
if (!p.personReactions || !target || target.kind !== "out" || !emoji || !id) {
|
|
585
792
|
return json(res, 400, { error: "reactions need a platform that has them, an agent message id and an emoji" });
|
|
586
793
|
}
|
|
@@ -593,12 +800,13 @@ export function simulators(options = {}) {
|
|
|
593
800
|
if (!text && !attachmentsOf(body).length)
|
|
594
801
|
return json(res, 400, { error: "text or a file is required" });
|
|
595
802
|
const { incoming, line } = incomingOf(k, body, text, Boolean(p.replies));
|
|
596
|
-
console.info(JSON.stringify({ chinwag: "playground.message", person: person.name,
|
|
803
|
+
console.info(JSON.stringify({ chinwag: "playground.message", person: person.name, channel: channel.name, at: new Date(line.at).toISOString() }));
|
|
597
804
|
emitLine(k, line);
|
|
598
805
|
carrier(p, k, text);
|
|
599
|
-
const taken = await agent.
|
|
806
|
+
const taken = person.key || person.visitor ? await agent.simulateVisitor({ key: person.key, address: visitorAddress, channel: channel.name, message: incoming })
|
|
807
|
+
: await agent.simulate({ person: person.phone, channel: channel.name, message: incoming });
|
|
600
808
|
if (taken)
|
|
601
|
-
|
|
809
|
+
rememberPerson(taken.conversationId, person);
|
|
602
810
|
line.id = taken?.messageId ?? null;
|
|
603
811
|
push(k, { type: "id", at: line.at, messageId: line.id });
|
|
604
812
|
return json(res, 202, { messageId: line.id, at: line.at });
|
|
@@ -607,29 +815,41 @@ export function simulators(options = {}) {
|
|
|
607
815
|
}
|
|
608
816
|
const playground = {
|
|
609
817
|
events: playgroundEvents,
|
|
610
|
-
listen: (agent, port, listenOptions = {}) => {
|
|
818
|
+
listen: async (agent, port, listenOptions = {}) => {
|
|
611
819
|
const gate = listenOptions.token === undefined ? null : tokenGate(listenOptions.token);
|
|
612
820
|
attach(agent);
|
|
613
|
-
|
|
821
|
+
const runner = listenOptions.localCli ? localCliRunner(listenOptions.localCli, "") : undefined;
|
|
822
|
+
const cli = runner ? { options: listenOptions.localCli, handle: runner } : undefined;
|
|
823
|
+
try {
|
|
824
|
+
const listening = await serve(guarded((req, res) => handle(agent, req, res, "", gate, cli)), port);
|
|
825
|
+
return { ...listening, close: async () => { runner?.close(); await listening.close(); } };
|
|
826
|
+
}
|
|
827
|
+
catch (error) {
|
|
828
|
+
runner?.close();
|
|
829
|
+
throw error;
|
|
830
|
+
}
|
|
614
831
|
},
|
|
615
832
|
handler: (agent, handlerOptions) => {
|
|
616
833
|
const gate = tokenGate(handlerOptions?.token);
|
|
617
834
|
attach(agent);
|
|
618
835
|
const base = handlerOptions.base ?? "";
|
|
619
|
-
|
|
836
|
+
const cli = handlerOptions.localCli ? { options: handlerOptions.localCli, handle: localCliRunner(handlerOptions.localCli, base) } : undefined;
|
|
837
|
+
return guarded((req, res) => handle(agent, req, res, base, gate, cli));
|
|
620
838
|
},
|
|
621
839
|
};
|
|
622
840
|
return playground;
|
|
623
841
|
}
|
|
624
842
|
/**
|
|
625
843
|
* The playground's lock on a deployed agent. The token comes once as `?token=` (swapped for an
|
|
626
|
-
* HttpOnly, SameSite=
|
|
627
|
-
* `Authorization: Bearer`. Compared in constant time; wrong tokens are limited per address.
|
|
844
|
+
* HttpOnly, SameSite=Lax cookie, and dropped from the URL) or on every API call as
|
|
845
|
+
* `Authorization: Bearer`. Lax lets sign-in return from another site. Compared in constant time; wrong tokens are limited per address.
|
|
628
846
|
*/
|
|
629
847
|
function tokenGate(token) {
|
|
630
848
|
requireToken(token, "flow simulators: a deployed playground");
|
|
631
849
|
// The cookie holds a value made from the token, not the token itself.
|
|
632
850
|
const session = createHash("sha256").update(`chinwag-playground:${token}`).digest("hex");
|
|
851
|
+
// Cookies are shared across localhost ports: independent agents must not overwrite each other.
|
|
852
|
+
const cookieName = `${COOKIE}_${session.slice(0, 12)}`;
|
|
633
853
|
const tries = wrongTries();
|
|
634
854
|
return {
|
|
635
855
|
allows(req, res, url, base) {
|
|
@@ -640,6 +860,8 @@ function tokenGate(token) {
|
|
|
640
860
|
refuse(res, 401);
|
|
641
861
|
return false;
|
|
642
862
|
};
|
|
863
|
+
const secure = req.socket.encrypted || req.headers["x-forwarded-proto"] === "https";
|
|
864
|
+
const sessionCookie = `${cookieName}=${session}; Path=${base || "/"}; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
|
|
643
865
|
const query = url.searchParams.get("token");
|
|
644
866
|
const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
|
|
645
867
|
if ((query !== null || bearer !== undefined) && blocked) {
|
|
@@ -650,17 +872,19 @@ function tokenGate(token) {
|
|
|
650
872
|
if (req.method !== "GET" || !sameSecret(query, token))
|
|
651
873
|
return fail();
|
|
652
874
|
url.searchParams.delete("token");
|
|
653
|
-
const secure = req.socket.encrypted || req.headers["x-forwarded-proto"] === "https";
|
|
654
|
-
const cookie = `${COOKIE}=${session}; Path=${base || "/"}; HttpOnly; SameSite=Strict${secure ? "; Secure" : ""}`;
|
|
655
875
|
const rest = url.searchParams.toString();
|
|
656
|
-
res.writeHead(303, { "set-cookie":
|
|
876
|
+
res.writeHead(303, { "set-cookie": sessionCookie, location: url.pathname + (rest ? `?${rest}` : ""), "cache-control": "no-store" }).end();
|
|
657
877
|
return false;
|
|
658
878
|
}
|
|
659
879
|
if (bearer !== undefined)
|
|
660
880
|
return sameSecret(bearer, token) ? true : fail();
|
|
661
|
-
const cookie = (req.headers.cookie ?? "").split(/;\s*/).find((c) => c.startsWith(`${
|
|
662
|
-
if (cookie !== undefined && sameSecret(cookie, session))
|
|
881
|
+
const cookie = (req.headers.cookie ?? "").split(/;\s*/).find((c) => c.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1);
|
|
882
|
+
if (cookie !== undefined && sameSecret(cookie, session)) {
|
|
883
|
+
// Renew on page visits so existing Strict cookies also survive the next sign-in round trip.
|
|
884
|
+
if (req.method === "GET" && ["/", "/person"].includes(inside(url, base)))
|
|
885
|
+
res.setHeader("set-cookie", sessionCookie);
|
|
663
886
|
return true;
|
|
887
|
+
}
|
|
664
888
|
refuse(res, 401);
|
|
665
889
|
return false;
|
|
666
890
|
},
|
|
@@ -749,7 +973,7 @@ async function readBody(req, limit = MAX_BODY) {
|
|
|
749
973
|
}
|
|
750
974
|
}
|
|
751
975
|
function json(res, status, value) {
|
|
752
|
-
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value));
|
|
976
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }).end(JSON.stringify(value));
|
|
753
977
|
}
|
|
754
978
|
function html(res, body) {
|
|
755
979
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(body);
|
package/dist/src/agent.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { type Database } from "./db.ts";
|
|
|
4
4
|
import { type EventSink } from "./events.ts";
|
|
5
5
|
import { type AgentFiles } from "./skills.ts";
|
|
6
6
|
import { type ConversationRow, type QuietHours, type StoreApi, type TimeZoneSource } from "./store.ts";
|
|
7
|
-
import { type Tool } from "./tools.ts";
|
|
7
|
+
import { type ChatAuth, type CredentialProvider, type Tool } from "./tools.ts";
|
|
8
8
|
import { type TracesOptions } from "./traces.ts";
|
|
9
9
|
import type { ChannelInfo, Deliver, Incoming, Model, Outbound, Template, TemplateDefinition, Transcribe } from "./types.ts";
|
|
10
10
|
export interface AgentOptions {
|
|
@@ -19,6 +19,10 @@ export interface AgentOptions {
|
|
|
19
19
|
*/
|
|
20
20
|
transcribe?: Transcribe;
|
|
21
21
|
tools?: Tool[];
|
|
22
|
+
/** Backend credentials or clients for a verified person, available only to tool code as context.credentials. */
|
|
23
|
+
credentials?: CredentialProvider;
|
|
24
|
+
/** Enforce per-tool signIn in chat and send the brand's login challenge when needed. */
|
|
25
|
+
auth?: ChatAuth;
|
|
22
26
|
/**
|
|
23
27
|
* Added to the system prompt at the start of every turn, before the model is called: keep it to
|
|
24
28
|
* small facts at hand. It runs on every turn, so a lookup here (an API call, a slow query) delays
|
|
@@ -91,11 +95,13 @@ export interface AgentOptions {
|
|
|
91
95
|
channels?: Channel[];
|
|
92
96
|
/**
|
|
93
97
|
* The owner's own key for a person (a member id), from a channel identity, or null when there is
|
|
94
|
-
* none. Identities with the same key are one person.
|
|
98
|
+
* none. Identities with the same key are one person. `phone` is normalized and present only
|
|
99
|
+
* when this channel verifies phone identities; an arbitrary address is never phone evidence.
|
|
95
100
|
*/
|
|
96
101
|
resolve?: (identity: {
|
|
97
102
|
channel: string;
|
|
98
103
|
address: string;
|
|
104
|
+
phone?: string;
|
|
99
105
|
}) => Promise<string | null> | string | null;
|
|
100
106
|
/**
|
|
101
107
|
* The calling code ("+91", "+44", "+1") for phone numbers that come without one. Set it when
|
|
@@ -161,7 +167,10 @@ export interface TestPerson {
|
|
|
161
167
|
/** Where the playground shows what the agent sends to a test person who wrote from it. */
|
|
162
168
|
export type PlaygroundSink = (delivery: {
|
|
163
169
|
conversationId: string;
|
|
164
|
-
person: TestPerson
|
|
170
|
+
person: TestPerson & {
|
|
171
|
+
key?: string;
|
|
172
|
+
visitor?: string;
|
|
173
|
+
};
|
|
165
174
|
channel: string;
|
|
166
175
|
outbound: Outbound;
|
|
167
176
|
}) => Promise<void> | void;
|
|
@@ -272,6 +281,8 @@ export declare class Agent {
|
|
|
272
281
|
/** Conversations in the store, known once resume() ran. */
|
|
273
282
|
private readonly rows;
|
|
274
283
|
private resumed;
|
|
284
|
+
private readonly requestReplies;
|
|
285
|
+
private readonly requestCredentials;
|
|
275
286
|
private readonly arriving;
|
|
276
287
|
/** Callers waiting for a queued nudge to settle: its first call, and any with the same key. */
|
|
277
288
|
private readonly nudgeWaiters;
|
|
@@ -280,6 +291,7 @@ export declare class Agent {
|
|
|
280
291
|
/** Where each identity (channel, address) last wrote from; sends go back the same way. */
|
|
281
292
|
private readonly via;
|
|
282
293
|
private playgroundSink;
|
|
294
|
+
private readonly playgroundVisitors;
|
|
283
295
|
/** Before this, this runner does not ask for the pruning claim again (it, or another runner, had it). */
|
|
284
296
|
private pruneAfter;
|
|
285
297
|
/** `defaultCountryCode`'s digits. */
|
|
@@ -306,6 +318,20 @@ export declare class Agent {
|
|
|
306
318
|
conversationId: string;
|
|
307
319
|
messageId: string;
|
|
308
320
|
} | null>;
|
|
321
|
+
/**
|
|
322
|
+
* A signed-in HTTP channel's request. `key` must come from verified auth, never the body.
|
|
323
|
+
* Uses the same person as resolve() on other channels. The caller serializes requests per key.
|
|
324
|
+
*/
|
|
325
|
+
request(input: {
|
|
326
|
+
channel: string;
|
|
327
|
+
key: string;
|
|
328
|
+
text: string;
|
|
329
|
+
conversationId?: string;
|
|
330
|
+
credentials?: CredentialProvider;
|
|
331
|
+
}): Promise<{
|
|
332
|
+
conversationId: string;
|
|
333
|
+
replies: Outbound[];
|
|
334
|
+
}>;
|
|
309
335
|
/**
|
|
310
336
|
* A message from a test person (by their phone number) in the test playground: exactly what
|
|
311
337
|
* message() does for the same person writing on that channel, but its replies go to the
|
|
@@ -320,6 +346,46 @@ export declare class Agent {
|
|
|
320
346
|
conversationId: string;
|
|
321
347
|
messageId: string;
|
|
322
348
|
} | null>;
|
|
349
|
+
/**
|
|
350
|
+
* A playground visitor, signed out or authenticated. `key` is verified by server code, never
|
|
351
|
+
* accepted from the browser. Replies stay in the simulator; no provider send is made.
|
|
352
|
+
*/
|
|
353
|
+
simulateVisitor(input: {
|
|
354
|
+
key?: string | null;
|
|
355
|
+
address: string;
|
|
356
|
+
channel: string;
|
|
357
|
+
message: string | Incoming;
|
|
358
|
+
}): Promise<{
|
|
359
|
+
conversationId: string;
|
|
360
|
+
messageId: string;
|
|
361
|
+
} | null>;
|
|
362
|
+
/**
|
|
363
|
+
* A completed server-verified login. Bind its channel address to the account before sending a
|
|
364
|
+
* confirmation with recordNudge/nudge. Never take key from a browser body or model input.
|
|
365
|
+
* Anonymous history stays separate; an identity belonging to another account is refused.
|
|
366
|
+
*/
|
|
367
|
+
linkIdentity(input: {
|
|
368
|
+
channel: string;
|
|
369
|
+
address: string;
|
|
370
|
+
key: string;
|
|
371
|
+
via?: "provider" | "playground";
|
|
372
|
+
}): Promise<{
|
|
373
|
+
conversationId: string;
|
|
374
|
+
}>;
|
|
375
|
+
/** Wake the agent after verified sign-in; a repeated completion queues one internal event. */
|
|
376
|
+
signInCompleted(input: {
|
|
377
|
+
channel: string;
|
|
378
|
+
address: string;
|
|
379
|
+
key: string;
|
|
380
|
+
idempotencyKey: string;
|
|
381
|
+
conversationId?: string;
|
|
382
|
+
via?: "provider" | "playground";
|
|
383
|
+
}): Promise<NudgeState>;
|
|
384
|
+
/** A verified playground visitor's account history, including messages on other interfaces. */
|
|
385
|
+
visitorConversation(key: string | null, identity?: {
|
|
386
|
+
channel: string;
|
|
387
|
+
address: string;
|
|
388
|
+
}): Promise<string | null>;
|
|
323
389
|
/** They wrote, so they are there: what was held for their quiet hours goes now, in this turn. */
|
|
324
390
|
private released;
|
|
325
391
|
/**
|