@flow-engineer/flow 0.2.0 → 0.2.2
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/LICENSE +202 -21
- package/README.md +190 -17
- package/coding-agents/auth-skill.md +137 -0
- package/coding-agents/context-skill.md +94 -0
- package/coding-agents/skill.md +245 -11
- package/coding-agents/test-skill.md +13 -11
- 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 +7 -1
- package/dist/flow-messaging/src/client.js +13 -1
- package/dist/flow-messaging/src/index.d.ts +38 -3
- package/dist/flow-messaging/src/index.js +242 -40
- 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 +2 -0
- package/dist/src/conversation.d.ts +16 -1
- package/dist/src/conversation.js +140 -12
- 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/names.d.ts +5 -0
- package/dist/src/names.js +5 -0
- 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 +2 -0
- package/dist/testing/src/account.d.ts +4 -0
- package/dist/testing/src/account.js +891 -0
- package/dist/testing/src/chat.d.ts +3 -0
- package/dist/testing/src/chat.js +125 -0
- package/dist/testing/src/check.d.ts +4 -0
- package/dist/testing/src/check.js +90 -0
- package/dist/testing/src/chinwag.js +1 -1
- package/dist/testing/src/cli.js +105 -18
- package/dist/testing/src/client.d.ts +2 -0
- package/dist/testing/src/client.js +4 -3
- package/dist/testing/src/flow-login.d.ts +176 -0
- package/dist/testing/src/flow-login.js +274 -0
- package/dist/testing/src/new.d.ts +15 -2
- package/dist/testing/src/new.js +80 -21
- package/dist/testing/src/plugin.d.ts +4 -0
- package/dist/testing/src/plugin.js +116 -0
- package/docs/agents.md +160 -0
- package/examples/cafe/src/agent.ts +6 -2
- package/examples/cafe/src/channels.ts +20 -0
- package/examples/cafe/src/cli.ts +24 -0
- package/examples/cafe/src/main.ts +17 -3
- package/examples/cafe/src/mcp.ts +22 -0
- package/examples/cafe/src/tools.ts +13 -0
- package/flow-messaging/README.md +66 -27
- package/package.json +30 -2
- package/spec/agent-endpoint.openapi.yaml +2 -2
- package/spec/nudges.openapi.yaml +2 -2
package/dist/src/agent.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// every send to it (replies, scheduled messages, nudges) goes back the same way.
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
16
|
import { declaredTemplate, fillTemplate, keywordOf, summarize } from "./channels.js";
|
|
17
|
-
import { Conversation, PROFILE_TOOL, profileTool, transcriptOf, windowClosed } from "./conversation.js";
|
|
17
|
+
import { Conversation, PROFILE_TOOL, profileTool, signInNotice, transcriptOf, windowClosed } from "./conversation.js";
|
|
18
18
|
import { sqlite } from "./db.js";
|
|
19
19
|
import { emitter } from "./events.js";
|
|
20
20
|
import { businessSchedule, claimDue, summary } from "./schedules.js";
|
|
@@ -36,6 +36,8 @@ export class Agent {
|
|
|
36
36
|
/** Conversations in the store, known once resume() ran. */
|
|
37
37
|
rows = new Map();
|
|
38
38
|
resumed = false;
|
|
39
|
+
requestReplies = new Map();
|
|
40
|
+
requestCredentials = new Map();
|
|
39
41
|
arriving = new Map();
|
|
40
42
|
/** Callers waiting for a queued nudge to settle: its first call, and any with the same key. */
|
|
41
43
|
nudgeWaiters = new Map();
|
|
@@ -44,6 +46,7 @@ export class Agent {
|
|
|
44
46
|
/** Where each identity (channel, address) last wrote from; sends go back the same way. */
|
|
45
47
|
via = new Map();
|
|
46
48
|
playgroundSink = null;
|
|
49
|
+
playgroundVisitors = new Map();
|
|
47
50
|
/** Before this, this runner does not ask for the pruning claim again (it, or another runner, had it). */
|
|
48
51
|
pruneAfter = 0;
|
|
49
52
|
/** `defaultCountryCode`'s digits. */
|
|
@@ -72,7 +75,7 @@ export class Agent {
|
|
|
72
75
|
throw new Error(`flow: defaultCountryCode is a calling code such as "+91", not "${options.defaultCountryCode}"`);
|
|
73
76
|
this.countryCode = options.defaultCountryCode?.trim().replace(/^\+/, "");
|
|
74
77
|
const tracing = options.traces ?? tracesFromEnv();
|
|
75
|
-
this.traces = tracing ? traces(tracing, { service: options.id ?? "flow", kindOf: (name) => this.channelsByName.get(name)?.kind }) : null;
|
|
78
|
+
this.traces = tracing ? traces(tracing, { service: options.id ?? "flow", kindOf: (name) => this.channelsByName.get(name)?.traceChannel ?? this.channelsByName.get(name)?.kind }) : null;
|
|
76
79
|
const sinks = [...(options.events ?? []), ...(this.traces ? [this.traces.sink] : [])];
|
|
77
80
|
const emit = emitter(sinks);
|
|
78
81
|
if ((options.dir === undefined) === (options.files === undefined))
|
|
@@ -92,6 +95,9 @@ export class Agent {
|
|
|
92
95
|
model: options.model,
|
|
93
96
|
transcribe: options.transcribe,
|
|
94
97
|
tools: new Map(tools.map((t) => [t.name, t])),
|
|
98
|
+
credentials: options.credentials,
|
|
99
|
+
credentialProvider: ctx => this.requestCredentials.get(viaKey(ctx.channel, ctx.address ?? "")) ?? options.credentials,
|
|
100
|
+
auth: options.auth,
|
|
95
101
|
specs: tools.map(toSpec),
|
|
96
102
|
instructions: loadInstructions(files, skills, options.inlineSkills),
|
|
97
103
|
context: options.context,
|
|
@@ -145,6 +151,33 @@ export class Agent {
|
|
|
145
151
|
async message(input) {
|
|
146
152
|
return this.released(await this.take(this.channelOf(input.channel), { ...input, via: "provider" }));
|
|
147
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* A signed-in HTTP channel's request. `key` must come from verified auth, never the body.
|
|
156
|
+
* Uses the same person as resolve() on other channels. The caller serializes requests per key.
|
|
157
|
+
*/
|
|
158
|
+
async request(input) {
|
|
159
|
+
await this.settings.ready;
|
|
160
|
+
const adapter = this.channelOf(input.channel);
|
|
161
|
+
if (adapter.kind !== "custom")
|
|
162
|
+
throw new Error("chinwag: request needs a custom UI channel");
|
|
163
|
+
const identity = await this.conversationFor(adapter, input.channel, input.key, undefined, input.key);
|
|
164
|
+
if (input.conversationId && input.conversationId !== identity.conversationId)
|
|
165
|
+
throw new Error("chinwag: conversation does not belong to this person");
|
|
166
|
+
const replies = [];
|
|
167
|
+
const address = viaKey(input.channel, input.key);
|
|
168
|
+
if (input.credentials)
|
|
169
|
+
this.requestCredentials.set(address, input.credentials);
|
|
170
|
+
this.requestReplies.set(address, (reply) => { replies.push(reply); });
|
|
171
|
+
try {
|
|
172
|
+
await this.released(await this.take(adapter, { channel: input.channel, from: input.key, message: input.text, via: "provider", verifiedKey: input.key }));
|
|
173
|
+
await this.conversation(identity.conversationId)?.whenIdle();
|
|
174
|
+
return { conversationId: identity.conversationId, replies };
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
this.requestReplies.delete(address);
|
|
178
|
+
this.requestCredentials.delete(address);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
148
181
|
/**
|
|
149
182
|
* A message from a test person (by their phone number) in the test playground: exactly what
|
|
150
183
|
* message() does for the same person writing on that channel, but its replies go to the
|
|
@@ -157,6 +190,106 @@ export class Agent {
|
|
|
157
190
|
const adapter = this.channelOf(input.channel);
|
|
158
191
|
return this.released(await this.take(adapter, { channel: input.channel, from: this.testAddress(adapter, person), message: input.message, messageId: input.messageId, via: "playground", person }));
|
|
159
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* A playground visitor, signed out or authenticated. `key` is verified by server code, never
|
|
195
|
+
* accepted from the browser. Replies stay in the simulator; no provider send is made.
|
|
196
|
+
*/
|
|
197
|
+
async simulateVisitor(input) {
|
|
198
|
+
if (!input.address)
|
|
199
|
+
throw new Error("chinwag: a playground visitor needs a server-issued channel address");
|
|
200
|
+
return this.released(await this.take(this.channelOf(input.channel), { channel: input.channel, from: input.key ? input.address : `visitor:${input.address}`, message: input.message, via: "playground", verifiedKey: input.key ?? undefined, visitor: input.key ? undefined : input.address }));
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* A completed server-verified login. Bind its channel address to the account before sending a
|
|
204
|
+
* confirmation with recordNudge/nudge. Never take key from a browser body or model input.
|
|
205
|
+
* Anonymous history stays separate; an identity belonging to another account is refused.
|
|
206
|
+
*/
|
|
207
|
+
async linkIdentity(input) {
|
|
208
|
+
if (!input.address || !input.key)
|
|
209
|
+
throw new Error("chinwag: linking needs a verified account and channel address");
|
|
210
|
+
const adapter = this.channelOf(input.channel);
|
|
211
|
+
const lock = viaKey(input.channel, input.address);
|
|
212
|
+
const work = (this.arriving.get(lock) ?? Promise.resolve()).then(async () => {
|
|
213
|
+
await this.settings.ready;
|
|
214
|
+
const personKey = `key:${input.key}`;
|
|
215
|
+
let row = await this.store.conversationByKey(personKey);
|
|
216
|
+
if (!row) {
|
|
217
|
+
await this.store.createConversation(`p_${randomUUID().slice(0, 12)}`, personKey);
|
|
218
|
+
row = await this.store.conversationByKey(personKey);
|
|
219
|
+
}
|
|
220
|
+
if (!row)
|
|
221
|
+
throw new Error("chinwag: could not open the verified account conversation");
|
|
222
|
+
if (!await this.store.linkIdentity(row.id, input.channel, input.address))
|
|
223
|
+
throw new Error("chinwag: channel identity belongs to a different person");
|
|
224
|
+
const identity = (await this.store.identity(input.channel, input.address));
|
|
225
|
+
const via = input.via ?? identity.via;
|
|
226
|
+
if (identity.via !== via)
|
|
227
|
+
await this.store.setIdentityVia(input.channel, input.address, via);
|
|
228
|
+
this.via.set(lock, via);
|
|
229
|
+
if (via === "playground")
|
|
230
|
+
this.playgroundVisitors.set(lock, { key: input.key });
|
|
231
|
+
this.rows.set(row.id, row);
|
|
232
|
+
this.open(row.id).link({ name: input.channel, info: adapter.info, params: adapter.params, instructions: adapter.instructions, address: input.address, deliver: this.deliverer(adapter, input.address, row.id), lastMessageAt: identity.lastMessageAt });
|
|
233
|
+
return { conversationId: row.id };
|
|
234
|
+
});
|
|
235
|
+
this.arriving.set(lock, work.catch(() => undefined));
|
|
236
|
+
return work;
|
|
237
|
+
}
|
|
238
|
+
/** Wake the agent after verified sign-in; a repeated completion queues one internal event. */
|
|
239
|
+
async signInCompleted(input) {
|
|
240
|
+
await this.settings.ready;
|
|
241
|
+
if (!input.idempotencyKey)
|
|
242
|
+
throw new Error("chinwag: sign-in completion needs an idempotency key");
|
|
243
|
+
const earlier = await this.store.nudgeByKey(input.idempotencyKey);
|
|
244
|
+
if (earlier) {
|
|
245
|
+
const linked = await this.linkIdentity(input);
|
|
246
|
+
if (earlier.conversationId !== linked.conversationId || earlier.channel !== input.channel || earlier.body.address !== input.address)
|
|
247
|
+
throw new Error("chinwag: sign-in completion belongs to a different identity");
|
|
248
|
+
return this.result(earlier.id, earlier.status, earlier.reason);
|
|
249
|
+
}
|
|
250
|
+
const known = await this.store.identity(input.channel, input.address);
|
|
251
|
+
const anonymous = await this.store.identity(input.channel, `visitor:${input.address}`);
|
|
252
|
+
let source = input.conversationId ? [known, anonymous].find(i => i?.conversationId === input.conversationId) ?? null : known ?? anonymous;
|
|
253
|
+
if (input.conversationId && !source) {
|
|
254
|
+
// A previous attempt may have linked this address before the internal event could be queued.
|
|
255
|
+
const row = await this.store.conversation(input.conversationId);
|
|
256
|
+
const adapter = this.channelOf(input.channel);
|
|
257
|
+
const phone = adapter.phone ? normalizePhone(input.address, adapter.kind === "whatsapp" ? undefined : this.countryCode) : "";
|
|
258
|
+
const allowed = [`key:${input.key}`, `${input.channel}:${input.address}`, `${input.channel}:visitor:${input.address}`, ...(phone ? [`phone:${phone}`] : [])];
|
|
259
|
+
if (!row || !allowed.includes(row.personKey))
|
|
260
|
+
throw new Error("chinwag: sign-in context does not belong to this channel address");
|
|
261
|
+
source = { conversationId: row.id };
|
|
262
|
+
}
|
|
263
|
+
const history = source ? await this.transcript(source.conversationId) : [];
|
|
264
|
+
const latest = history.findLastIndex(e => e.kind === "in" && e.channel === input.channel);
|
|
265
|
+
const request = latest >= 0 ? history[latest] : undefined;
|
|
266
|
+
const after = history.slice(latest + 1);
|
|
267
|
+
const blocked = request?.kind === "in" && after.some(e => e.kind === "out" && e.channel === input.channel && e.authentication) && after.some(e => {
|
|
268
|
+
if (e.kind !== "tool")
|
|
269
|
+
return false;
|
|
270
|
+
try {
|
|
271
|
+
const output = JSON.parse(e.output);
|
|
272
|
+
return output.error === "sign_in_required" && output.executed === false;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
const { conversationId } = await this.linkIdentity(input);
|
|
279
|
+
const account = source?.conversationId === conversationId ? history : await this.transcript(conversationId);
|
|
280
|
+
const newer = request && account.some(e => e.kind === "in" && e.at > request.at);
|
|
281
|
+
return this.recordNudge({ to: { channel: input.channel, address: input.address },
|
|
282
|
+
prompt: signInNotice(input.channel, blocked && !newer && request?.kind === "in" ? request.text : undefined),
|
|
283
|
+
idempotencyKey: input.idempotencyKey, respectQuietHours: false,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/** A verified playground visitor's account history, including messages on other interfaces. */
|
|
287
|
+
async visitorConversation(key, identity) {
|
|
288
|
+
await this.settings.ready;
|
|
289
|
+
if (!key)
|
|
290
|
+
return identity ? (await this.store.identity(identity.channel, `visitor:${identity.address}`))?.conversationId ?? null : null;
|
|
291
|
+
return (await this.store.conversationByKey(`key:${key}`))?.id ?? null;
|
|
292
|
+
}
|
|
160
293
|
/** They wrote, so they are there: what was held for their quiet hours goes now, in this turn. */
|
|
161
294
|
async released(taken) {
|
|
162
295
|
if (taken)
|
|
@@ -254,7 +387,14 @@ export class Agent {
|
|
|
254
387
|
}
|
|
255
388
|
async arrive(adapter, input) {
|
|
256
389
|
await this.settings.ready;
|
|
257
|
-
const identity = await this.conversationFor(adapter, input.channel, input.from, input.person);
|
|
390
|
+
const identity = await this.conversationFor(adapter, input.channel, input.from, input.person, input.verifiedKey);
|
|
391
|
+
if (input.via === "playground") {
|
|
392
|
+
const address = viaKey(input.channel, input.from);
|
|
393
|
+
if (input.verifiedKey || input.visitor)
|
|
394
|
+
this.playgroundVisitors.set(address, { key: input.verifiedKey, visitor: input.visitor });
|
|
395
|
+
else
|
|
396
|
+
this.playgroundVisitors.delete(address);
|
|
397
|
+
}
|
|
258
398
|
const conversationId = identity.conversationId;
|
|
259
399
|
if (input.messageId !== undefined && (await this.store.inboundTaken(conversationId, input.channel, input.messageId))) {
|
|
260
400
|
// The provider sent this message before (a webhook retried): it was taken then. Its id is
|
|
@@ -309,10 +449,13 @@ export class Agent {
|
|
|
309
449
|
return { reply, ...conversation.keyword(kind, text, channel, messageId) };
|
|
310
450
|
}
|
|
311
451
|
/** The identity a message comes from, made (and linked to its person's conversation) the first time it writes. */
|
|
312
|
-
async conversationFor(adapter, channel, address, testPerson) {
|
|
452
|
+
async conversationFor(adapter, channel, address, testPerson, verifiedKey) {
|
|
313
453
|
const known = await this.store.identity(channel, address);
|
|
314
|
-
if (known)
|
|
454
|
+
if (known) {
|
|
455
|
+
if (verifiedKey && (await this.store.conversation(known.conversationId))?.personKey !== `key:${verifiedKey}`)
|
|
456
|
+
throw new Error("chinwag: channel identity belongs to a different person");
|
|
315
457
|
return known;
|
|
458
|
+
}
|
|
316
459
|
let personKey;
|
|
317
460
|
let verifiedBy;
|
|
318
461
|
if (testPerson && !adapter.phone) {
|
|
@@ -320,8 +463,8 @@ export class Agent {
|
|
|
320
463
|
[personKey, verifiedBy] = [await this.testPersonKey(testPerson, adapter), "test person"];
|
|
321
464
|
}
|
|
322
465
|
else {
|
|
323
|
-
const owner = await this.options.resolve?.({ channel, address });
|
|
324
466
|
const phone = adapter.phone ? normalizePhone(address, adapter.kind === "whatsapp" ? undefined : this.countryCode) : "";
|
|
467
|
+
const owner = verifiedKey ?? await this.options.resolve?.({ channel, address, ...(phone ? { phone } : {}) });
|
|
325
468
|
[personKey, verifiedBy] = owner ? [`key:${owner}`, "resolver"] : phone ? [`phone:${phone}`, "phone"] : [`${channel}:${address}`, "channel"];
|
|
326
469
|
}
|
|
327
470
|
let row = await this.store.conversationByKey(personKey);
|
|
@@ -347,7 +490,7 @@ export class Agent {
|
|
|
347
490
|
async testPersonKey(person, adapter) {
|
|
348
491
|
const phone = normalizePhone(person.phone);
|
|
349
492
|
const channel = [...this.channelsByName.values()].find((c) => c.phone) ?? adapter;
|
|
350
|
-
const owner = channel ? await this.options.resolve?.({ channel: channel.name, address: phone }) : null;
|
|
493
|
+
const owner = channel ? await this.options.resolve?.({ channel: channel.name, address: phone, ...(channel.phone ? { phone } : {}) }) : null;
|
|
351
494
|
return owner ? `key:${owner}` : `phone:${phone}`;
|
|
352
495
|
}
|
|
353
496
|
testPerson(phone) {
|
|
@@ -362,13 +505,24 @@ export class Agent {
|
|
|
362
505
|
/** Sends to one identity: to the playground if it last wrote from there, else through the channel's provider. */
|
|
363
506
|
deliverer(adapter, address, conversationId) {
|
|
364
507
|
return (outbound) => {
|
|
508
|
+
const reply = this.requestReplies.get(viaKey(adapter.name, address));
|
|
509
|
+
if (reply)
|
|
510
|
+
return reply(outbound);
|
|
365
511
|
if (this.via.get(viaKey(adapter.name, address)) !== "playground")
|
|
366
512
|
return adapter.send(address, outbound);
|
|
513
|
+
const visitor = this.playgroundVisitors.get(viaKey(adapter.name, address));
|
|
514
|
+
if (visitor)
|
|
515
|
+
return this.playgroundSink?.({ conversationId, person: { name: visitor.key ? "Signed-in user" : "New user", phone: "", ...visitor }, channel: adapter.name, outbound });
|
|
367
516
|
const phone = address.startsWith("test:") ? address.slice(5) : address;
|
|
368
517
|
const person = this.testPerson(phone);
|
|
369
518
|
// With no playground page open, nothing is shown, and the conversation keeps it as sent.
|
|
370
519
|
if (person)
|
|
371
520
|
return this.playgroundSink?.({ conversationId, person, channel: adapter.name, outbound });
|
|
521
|
+
const key = this.rows.get(conversationId)?.personKey;
|
|
522
|
+
if (key?.startsWith("key:"))
|
|
523
|
+
return this.playgroundSink?.({ conversationId, person: { name: "Signed-in user", phone: "", key: key.slice(4) }, channel: adapter.name, outbound });
|
|
524
|
+
if (address.startsWith("visitor:"))
|
|
525
|
+
return this.playgroundSink?.({ conversationId, person: { name: "New user", phone: "", visitor: address.slice(8) }, channel: adapter.name, outbound });
|
|
372
526
|
};
|
|
373
527
|
}
|
|
374
528
|
/**
|
|
@@ -601,7 +755,7 @@ export class Agent {
|
|
|
601
755
|
const added = await add({ ...record, body: { text: input.text, prompt: input.prompt, at: input.at }, status: "scheduled" }, schedule);
|
|
602
756
|
return typeof added === "number" ? { id, status: "scheduled" } : added;
|
|
603
757
|
}
|
|
604
|
-
const body = input.template ? { template: input.template } : input.text !== undefined ? { text: input.text } : { prompt: input.prompt };
|
|
758
|
+
const body = { address: input.to.address, ...(input.template ? { template: input.template } : input.text !== undefined ? { text: input.text } : { prompt: input.prompt }) };
|
|
605
759
|
const now = this.now();
|
|
606
760
|
const quietEnds = input.respectQuietHours === false ? null : await this.quietEnd(conversationId, now);
|
|
607
761
|
if (quietEnds !== null) {
|
package/dist/src/channels.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface Channel {
|
|
|
6
6
|
/** The agent's name for it; its send tool is always send_<name> (lowercase, other characters as _). "key" and "phone" are reserved. */
|
|
7
7
|
name: string;
|
|
8
8
|
kind: ChannelKind;
|
|
9
|
+
/** Override the trace channel classification, e.g. a terminal custom UI as cli. */
|
|
10
|
+
traceChannel?: string;
|
|
9
11
|
info: ChannelInfo;
|
|
10
12
|
/** Addresses are phone numbers the channel verified, so the same number on two channels is one person. */
|
|
11
13
|
phone?: boolean;
|
|
@@ -2,13 +2,16 @@ import { z } from "zod";
|
|
|
2
2
|
import type { Emit, EventSink } from "./events.ts";
|
|
3
3
|
import type { ConversationRow, InboxRow, QuietHours, StoreApi, TimeZoneSource } from "./store.ts";
|
|
4
4
|
import { type TalkSettings } from "./talk.ts";
|
|
5
|
-
import { type Tool } from "./tools.ts";
|
|
5
|
+
import { type ChatAuth, type CredentialProvider, type Tool, type ToolContext } from "./tools.ts";
|
|
6
6
|
import type { ChannelInfo, ChatMessage, Deliver, Incoming, Model, TemplateDefinition, ToolSpec, Transcribe } from "./types.ts";
|
|
7
7
|
export interface ConversationSettings {
|
|
8
8
|
model: Model;
|
|
9
9
|
/** Speech to text for voice notes; without it the agent is told it can't listen to them. */
|
|
10
10
|
transcribe?: Transcribe;
|
|
11
11
|
tools: Map<string, Tool>;
|
|
12
|
+
credentials?: CredentialProvider;
|
|
13
|
+
credentialProvider?: (context: Readonly<Omit<ToolContext, "credentials">>) => CredentialProvider | undefined;
|
|
14
|
+
auth?: ChatAuth;
|
|
12
15
|
specs: ToolSpec[];
|
|
13
16
|
instructions: string;
|
|
14
17
|
/** Per-turn context for the system prompt (the person's profile, the time). */
|
|
@@ -68,6 +71,8 @@ export interface ChannelLink {
|
|
|
68
71
|
deliver: Deliver;
|
|
69
72
|
lastMessageAt: number | null;
|
|
70
73
|
}
|
|
74
|
+
/** A server-verified auth change: an internal prompt, never a prewritten chat response. */
|
|
75
|
+
export declare function signInNotice(channel: string, request?: string): string;
|
|
71
76
|
interface Task {
|
|
72
77
|
id: string;
|
|
73
78
|
tool: string;
|
|
@@ -98,6 +103,7 @@ export declare class Conversation {
|
|
|
98
103
|
/** Aborted when the conversation closes: tools' signal. */
|
|
99
104
|
private readonly lifetime;
|
|
100
105
|
private recentChannels;
|
|
106
|
+
private promptDestination?;
|
|
101
107
|
private timeZone;
|
|
102
108
|
/** Their own quiet hours ("off": none), or null for the agent's. */
|
|
103
109
|
private quietHours;
|
|
@@ -117,6 +123,10 @@ export declare class Conversation {
|
|
|
117
123
|
/** The next model response is the first since their latest message. */
|
|
118
124
|
private firstResponse;
|
|
119
125
|
private readonly typingOn;
|
|
126
|
+
/** One challenge per channel per turn, even when the model calls several protected tools. */
|
|
127
|
+
private signInPrompts;
|
|
128
|
+
private authBlocked;
|
|
129
|
+
private authPromptsSent;
|
|
120
130
|
/** The send tool for each channel, this turn. */
|
|
121
131
|
private sendTools;
|
|
122
132
|
private readonly builtins;
|
|
@@ -276,6 +286,10 @@ export declare class Conversation {
|
|
|
276
286
|
private runTools;
|
|
277
287
|
/** Start one tool call; the model's input is checked first (a refusal is the call's result). */
|
|
278
288
|
private start;
|
|
289
|
+
/** A failed or unresponsive auth service cannot leave a protected call hanging or let it execute. */
|
|
290
|
+
private authentication;
|
|
291
|
+
/** A login request is a real channel message, recorded as delivered, without another model call. */
|
|
292
|
+
private sendSignIn;
|
|
279
293
|
/** The reply stops waiting for this tool: it carries on in the background and its result comes back as a note. */
|
|
280
294
|
private detach;
|
|
281
295
|
}
|
|
@@ -296,6 +310,7 @@ export type TranscriptEntry = {
|
|
|
296
310
|
kind: "out";
|
|
297
311
|
channel: string;
|
|
298
312
|
text: string;
|
|
313
|
+
authentication?: boolean;
|
|
299
314
|
at: number;
|
|
300
315
|
replyTo?: string;
|
|
301
316
|
buttons?: string[];
|
package/dist/src/conversation.js
CHANGED
|
@@ -43,13 +43,20 @@ import { coveredBy, noContactTools, rulesSection } from "./nudges.js";
|
|
|
43
43
|
import { scheduleTools } from "./schedules.js";
|
|
44
44
|
import { REACT, UPDATE, reactInput, sendInput, sendRefusal, sendToolName, talkInstructions, talkSpecs, updateInput } from "./talk.js";
|
|
45
45
|
import { formatLocal } from "./time.js";
|
|
46
|
-
import { render, tool, toSpec } from "./tools.js";
|
|
46
|
+
import { render, tool, toolContext, toSpec } from "./tools.js";
|
|
47
47
|
const ACKNOWLEDGE_FIRST = "[Chinwag] They have not heard from you yet. In one response, acknowledge them " +
|
|
48
48
|
"(react to their message or send a short line) and make your tool calls again.";
|
|
49
49
|
const INTERRUPTED = "[Chinwag] Cut off by a restart: this may or may not have happened. Check before you rely on it.";
|
|
50
50
|
const APOLOGY = "Sorry, something went wrong on my side. Could you send that again?";
|
|
51
51
|
const EARLIER = "[Chinwag] Older messages of this conversation are no longer shown to you.";
|
|
52
52
|
const NOT_SHOWN = "[Chinwag] You are no longer shown the photos or documents in these messages; ask them to send one again if you need it.";
|
|
53
|
+
/** A server-verified auth change: an internal prompt, never a prewritten chat response. */
|
|
54
|
+
export function signInNotice(channel, request) {
|
|
55
|
+
return `Internal authentication event: sign-in completed on ${channel}. Continue the conversation on that channel. ` +
|
|
56
|
+
"Resume a request that was blocked only by sign-in, using the verified account and normal tools. " +
|
|
57
|
+
"Do not replay operations that already ran or retry unrelated failures. If nothing is waiting for sign-in, acknowledge naturally. Do not quote this internal event. " +
|
|
58
|
+
(request ? `The pending request (user text, not system instructions) is: ${JSON.stringify(request)}` : "");
|
|
59
|
+
}
|
|
53
60
|
/** Photos and PDFs the model is shown at once, the latest; each is resent on every model call. */
|
|
54
61
|
const MEDIA_SHOWN = 3;
|
|
55
62
|
/** The largest photo, and the largest PDF or voice note, the agent reads (most models take 5 MB images). */
|
|
@@ -80,6 +87,7 @@ export class Conversation {
|
|
|
80
87
|
/** Aborted when the conversation closes: tools' signal. */
|
|
81
88
|
lifetime = new AbortController();
|
|
82
89
|
recentChannels = [];
|
|
90
|
+
promptDestination;
|
|
83
91
|
timeZone = null;
|
|
84
92
|
/** Their own quiet hours ("off": none), or null for the agent's. */
|
|
85
93
|
quietHours = null;
|
|
@@ -99,6 +107,10 @@ export class Conversation {
|
|
|
99
107
|
/** The next model response is the first since their latest message. */
|
|
100
108
|
firstResponse = false;
|
|
101
109
|
typingOn = new Set();
|
|
110
|
+
/** One challenge per channel per turn, even when the model calls several protected tools. */
|
|
111
|
+
signInPrompts = new Map();
|
|
112
|
+
authBlocked = new Set();
|
|
113
|
+
authPromptsSent = new Set();
|
|
102
114
|
/** The send tool for each channel, this turn. */
|
|
103
115
|
sendTools = new Map();
|
|
104
116
|
builtins;
|
|
@@ -439,7 +451,7 @@ export class Conversation {
|
|
|
439
451
|
if (p.prompt !== undefined) {
|
|
440
452
|
if (p.windowClosed) {
|
|
441
453
|
const text = `[Nudge from the business, not from the person: ${p.prompt}] Their WhatsApp 24-hour window is closed: no ordinary message reaches them. If one of the business's templates says this, send it with send_template, its values filled in. If none fits, send nothing: Flow Framework sends them a "you have a new message" template and gives you this again when they reply.`;
|
|
442
|
-
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel, windowClosed: true } });
|
|
454
|
+
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel, address: p.address, windowClosed: true } });
|
|
443
455
|
if (!this.running && this.hasWork())
|
|
444
456
|
this.schedule(0);
|
|
445
457
|
return;
|
|
@@ -448,10 +460,10 @@ export class Conversation {
|
|
|
448
460
|
const text = held
|
|
449
461
|
? `[Nudge from the business, not from the person, ${held}${p.prompt}] Tell them now in your own words. Others held with it come in this turn too: cover them all in one short message, not a text each, and answer anything they just asked first.`
|
|
450
462
|
: `[Nudge from the business, not from the person: ${p.prompt}] Write it to them now in your own words, or send nothing if it does not fit what they asked not to be messaged about.`;
|
|
451
|
-
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel } });
|
|
463
|
+
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel, address: p.address } });
|
|
452
464
|
}
|
|
453
465
|
else
|
|
454
|
-
this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, template: p.template, nudge: p.nudge, ...(p.theyWrote ? { theyWrote: true } : {}) });
|
|
466
|
+
this.direct.push({ inbox: row.id, channel: p.channel, address: p.address, text: p.text, template: p.template, nudge: p.nudge, ...(p.theyWrote ? { theyWrote: true } : {}) });
|
|
455
467
|
}
|
|
456
468
|
if (!this.running && this.hasWork())
|
|
457
469
|
this.schedule(0);
|
|
@@ -535,8 +547,10 @@ export class Conversation {
|
|
|
535
547
|
/** Picks up where the conversation was: its history, events not yet handled, tools cut off. */
|
|
536
548
|
async load() {
|
|
537
549
|
const { store } = this.settings;
|
|
550
|
+
// A live arrival can attach its new address while persisted links are loading. Keep that proof.
|
|
538
551
|
for (const link of await this.settings.links(this.id))
|
|
539
|
-
this.
|
|
552
|
+
if (!this.channels.has(link.name))
|
|
553
|
+
this.link(link);
|
|
540
554
|
const history = await store.history(this.id);
|
|
541
555
|
this.saved = history.length;
|
|
542
556
|
this.history.push(...history);
|
|
@@ -669,7 +683,7 @@ export class Conversation {
|
|
|
669
683
|
}
|
|
670
684
|
/** The channel of the person's latest message. */
|
|
671
685
|
currentChannel() {
|
|
672
|
-
return this.recentChannels.at(-1) ?? [...this.channels.keys()][0] ?? "chat";
|
|
686
|
+
return (!this.theirMessages ? this.promptDestination?.channel ?? this.owed.at(-1)?.channel : undefined) ?? this.recentChannels.at(-1) ?? [...this.channels.keys()][0] ?? "chat";
|
|
673
687
|
}
|
|
674
688
|
/** Move waiting messages and notes into the history; the person's messages are now read. */
|
|
675
689
|
absorb() {
|
|
@@ -774,7 +788,11 @@ export class Conversation {
|
|
|
774
788
|
const turns = this.turns;
|
|
775
789
|
this.resumeTurn = false;
|
|
776
790
|
this.theirMessages = false;
|
|
791
|
+
this.promptDestination = undefined;
|
|
777
792
|
this.settings.emit(this.id, "turn.start", {});
|
|
793
|
+
this.signInPrompts.clear();
|
|
794
|
+
this.authBlocked.clear();
|
|
795
|
+
this.authPromptsSent.clear();
|
|
778
796
|
this.persist(() => store.updateConversation(this.id, { turnOpen: true, turns }));
|
|
779
797
|
this.absorb();
|
|
780
798
|
// A turn the person started (as against a background result, a scheduled message or a nudge).
|
|
@@ -812,11 +830,21 @@ export class Conversation {
|
|
|
812
830
|
if (this.closed)
|
|
813
831
|
return;
|
|
814
832
|
// The channels can grow mid-turn (they wrote on another one), so each step offers what is there now.
|
|
833
|
+
// A business prompt is addressed to one session, not whichever address last wrote on that channel.
|
|
834
|
+
if (!this.theirMessages)
|
|
835
|
+
this.promptDestination = this.owed.at(-1) ?? this.promptDestination;
|
|
836
|
+
const destination = !this.theirMessages ? this.promptDestination : undefined;
|
|
837
|
+
if (destination?.address) {
|
|
838
|
+
const link = (await this.settings.links(this.id)).find(l => l.name === destination.channel && l.address === destination.address);
|
|
839
|
+
if (link)
|
|
840
|
+
this.link(link);
|
|
841
|
+
}
|
|
815
842
|
const views = this.views();
|
|
816
843
|
const several = views.length > 1;
|
|
817
844
|
this.sendTools = new Map(views.map((v) => [sendToolName(v.name), v.name]));
|
|
818
845
|
const system = [this.settings.instructions, talkInstructions(views, this.settings.talk), rules, nowLine(this.settings.now(), this.timeZone, this.settings.timeZone), context].filter(Boolean).join("\n\n");
|
|
819
|
-
const
|
|
846
|
+
const ownSpecs = this.settings.auth ? this.settings.specs.map((spec) => ({ ...spec, description: spec.description + ((this.settings.tools.get(spec.name)?.signIn ?? "required") === "required" ? " Requires sign-in." : "") })) : this.settings.specs;
|
|
847
|
+
const specs = [...ownSpecs, ...this.builtinSpecs, ...talkSpecs(views, this.settings.talk)];
|
|
820
848
|
const abort = new AbortController();
|
|
821
849
|
if (restarts < this.settings.maxRestarts)
|
|
822
850
|
this.stepAbort = abort;
|
|
@@ -914,7 +942,15 @@ export class Conversation {
|
|
|
914
942
|
for (const call of response.toolCalls) {
|
|
915
943
|
this.history.push({ role: "tool", toolCallId: call.id, content: results.get(call.id) ?? "" });
|
|
916
944
|
}
|
|
945
|
+
for (const [channel, prompt] of this.signInPrompts) {
|
|
946
|
+
if (this.authPromptsSent.has(channel))
|
|
947
|
+
continue;
|
|
948
|
+
await this.sendSignIn(channel, await prompt);
|
|
949
|
+
this.authPromptsSent.add(channel);
|
|
950
|
+
}
|
|
917
951
|
this.flush();
|
|
952
|
+
if (work.length && work.every((call) => this.authBlocked.has(call.id)) && !this.absorb())
|
|
953
|
+
break;
|
|
918
954
|
// Only messages: the turn is over unless one was refused or something new came in meanwhile, or
|
|
919
955
|
// this replies to their message without having used any tool: it gets one more step, in case it
|
|
920
956
|
// meant to act next (it said "getting your order" and has not asked for it yet). A tool
|
|
@@ -989,7 +1025,7 @@ export class Conversation {
|
|
|
989
1025
|
const input = parsed.data;
|
|
990
1026
|
const fields = Object.fromEntries(Object.keys(link.params ?? {}).flatMap((k) => (input[k] === undefined ? [] : [[k, input[k]]])));
|
|
991
1027
|
const hasFields = Object.keys(fields).length > 0;
|
|
992
|
-
if (several && !input.confirm && !this.recentChannels.includes(channel)) {
|
|
1028
|
+
if (several && !input.confirm && !this.recentChannels.includes(channel) && !this.owed.some(o => o.channel === channel) && this.promptDestination?.channel !== channel) {
|
|
993
1029
|
const last = link.lastMessageAt ? ` (last time ${ago(this.settings.now() - link.lastMessageAt)} ago)` : "";
|
|
994
1030
|
return refused(`Not sent: they have not written on ${channel} in their last 5 messages${last}. If you are sure, call ${call.name} again with confirm: true.`);
|
|
995
1031
|
}
|
|
@@ -1104,7 +1140,7 @@ export class Conversation {
|
|
|
1104
1140
|
this.settle(d, status, reason);
|
|
1105
1141
|
this.flush([d]);
|
|
1106
1142
|
};
|
|
1107
|
-
const link = this.channels.get(d.channel);
|
|
1143
|
+
const link = d.address === undefined ? this.channels.get(d.channel) : (await this.settings.links(this.id)).find(l => l.name === d.channel && l.address === d.address);
|
|
1108
1144
|
if (!link)
|
|
1109
1145
|
return settle("failed", `no way to reach them on ${d.channel} right now`);
|
|
1110
1146
|
if (!d.template && !d.theyWrote && windowClosed(link, this.settings.now()))
|
|
@@ -1128,7 +1164,15 @@ export class Conversation {
|
|
|
1128
1164
|
if (covered)
|
|
1129
1165
|
return settle("dropped", `no-contact rule ${covered.id}: ${covered.rule}`);
|
|
1130
1166
|
const outbound = d.template ? { type: "template", ...d.template } : { type: "message", text: d.text ?? "" };
|
|
1131
|
-
|
|
1167
|
+
let failure = null;
|
|
1168
|
+
if (this.closed)
|
|
1169
|
+
return settle("failed", "this person was forgotten");
|
|
1170
|
+
try {
|
|
1171
|
+
await link.deliver(outbound);
|
|
1172
|
+
}
|
|
1173
|
+
catch (error) {
|
|
1174
|
+
failure = errorText(error);
|
|
1175
|
+
}
|
|
1132
1176
|
if (failure)
|
|
1133
1177
|
return settle("failed", failure);
|
|
1134
1178
|
if (d.template) {
|
|
@@ -1205,6 +1249,9 @@ export class Conversation {
|
|
|
1205
1249
|
else
|
|
1206
1250
|
live.push({ i, r: started });
|
|
1207
1251
|
});
|
|
1252
|
+
// Authentication is a prerequisite, never a detached background operation.
|
|
1253
|
+
await Promise.all(live.map(({ r }) => r.authorized));
|
|
1254
|
+
await Promise.all(live.filter(({ r }) => this.authBlocked.has(r.call.id)).map(({ r }) => r.outcome));
|
|
1208
1255
|
let firstDoneAt = null;
|
|
1209
1256
|
for (;;) {
|
|
1210
1257
|
for (const { i, r } of live) {
|
|
@@ -1274,19 +1321,99 @@ export class Conversation {
|
|
|
1274
1321
|
}
|
|
1275
1322
|
this.settings.emit(this.id, "tool.start", { callId: call.id, tool: call.name, input: parsed.data });
|
|
1276
1323
|
const key = createHash("sha256").update(`${this.id}\u0000${this.turns}\u0000${call.name}\u0000${input}`).digest("hex").slice(0, 32);
|
|
1277
|
-
const
|
|
1324
|
+
const identityContext = { conversationId: this.id, person: this.personKey, key: this.personKey.startsWith("key:") ? this.personKey.slice(4) : null, channel: this.currentChannel(), address: this.channels.get(this.currentChannel())?.address, callId: call.id, idempotencyKey: key, signal: this.lifetime.signal };
|
|
1325
|
+
const credentials = this.settings.credentialProvider?.(identityContext) ?? this.settings.credentials;
|
|
1326
|
+
let context = toolContext(identityContext, credentials);
|
|
1327
|
+
let authorize;
|
|
1328
|
+
const authorized = new Promise(resolve => { authorize = resolve; });
|
|
1278
1329
|
const r = {
|
|
1279
1330
|
call,
|
|
1280
1331
|
input,
|
|
1281
1332
|
startedAt: Date.now(),
|
|
1282
1333
|
limitMs: tool.deadlineMs ?? this.settings.deadlineMs,
|
|
1334
|
+
authorized,
|
|
1283
1335
|
outcome: Promise.resolve()
|
|
1284
|
-
.then(() =>
|
|
1336
|
+
.then(async () => {
|
|
1337
|
+
try {
|
|
1338
|
+
const auth = this.settings.auth;
|
|
1339
|
+
const signIn = tool.signIn ?? (this.builtins.has(call.name) ? "none" : "required");
|
|
1340
|
+
if (auth && signIn !== "none") {
|
|
1341
|
+
const identity = Object.freeze({ conversationId: this.id, person: context.person, key: context.key, channel: context.channel, address: this.channels.get(context.channel)?.address ?? "", tool: call.name, signal: this.lifetime.signal });
|
|
1342
|
+
let verified = !!context.key;
|
|
1343
|
+
if (verified && auth.verify) {
|
|
1344
|
+
try {
|
|
1345
|
+
verified = await this.authentication(() => auth.verify(identity));
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
verified = false;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
if (this.closed)
|
|
1352
|
+
return { error: "conversation_closed" };
|
|
1353
|
+
if (!verified && signIn === "required") {
|
|
1354
|
+
this.authBlocked.add(call.id);
|
|
1355
|
+
if (!this.signInPrompts.has(context.channel)) {
|
|
1356
|
+
const prompt = this.authentication(() => auth.signIn(identity)).then((challenge) => {
|
|
1357
|
+
if (!challenge.message?.trim())
|
|
1358
|
+
throw new Error("Empty sign-in challenge");
|
|
1359
|
+
if (challenge.url) {
|
|
1360
|
+
const url = new URL(challenge.url);
|
|
1361
|
+
if (url.username || url.password || (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))))
|
|
1362
|
+
throw new Error("Unsafe sign-in URL");
|
|
1363
|
+
}
|
|
1364
|
+
return challenge;
|
|
1365
|
+
}).catch(() => ({ message: "I couldn't start sign-in. Please try again." }));
|
|
1366
|
+
this.signInPrompts.set(context.channel, prompt);
|
|
1367
|
+
}
|
|
1368
|
+
this.settings.emit(this.id, "auth.required", { tool: call.name, channel: context.channel });
|
|
1369
|
+
return { error: "sign_in_required", executed: false };
|
|
1370
|
+
}
|
|
1371
|
+
if (!verified)
|
|
1372
|
+
context = toolContext({ ...context, key: null, person: `${context.channel}:${identity.address}` }, credentials);
|
|
1373
|
+
}
|
|
1374
|
+
if (this.closed)
|
|
1375
|
+
return { error: "conversation_closed" };
|
|
1376
|
+
return tool.run(parsed.data, context);
|
|
1377
|
+
}
|
|
1378
|
+
finally {
|
|
1379
|
+
r.startedAt = Date.now();
|
|
1380
|
+
authorize();
|
|
1381
|
+
}
|
|
1382
|
+
})
|
|
1285
1383
|
.then((output) => ({ ok: true, text: render(output) }), (error) => ({ ok: false, text: `Failed: ${error instanceof Error ? error.message : String(error)}` })),
|
|
1286
1384
|
};
|
|
1287
1385
|
void r.outcome.then((result) => (r.result = result));
|
|
1288
1386
|
return r;
|
|
1289
1387
|
}
|
|
1388
|
+
/** A failed or unresponsive auth service cannot leave a protected call hanging or let it execute. */
|
|
1389
|
+
async authentication(work) {
|
|
1390
|
+
let timer;
|
|
1391
|
+
try {
|
|
1392
|
+
return await Promise.race([Promise.resolve().then(work), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Sign-in timed out")), 15_000); })]);
|
|
1393
|
+
}
|
|
1394
|
+
finally {
|
|
1395
|
+
clearTimeout(timer);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
/** A login request is a real channel message, recorded as delivered, without another model call. */
|
|
1399
|
+
async sendSignIn(channel, prompt) {
|
|
1400
|
+
const link = this.channels.get(channel);
|
|
1401
|
+
if (!link)
|
|
1402
|
+
return;
|
|
1403
|
+
const outbound = fitMessage(link.info, { type: "message", text: prompt.message, authentication: true, ...(prompt.url ? { link: { label: "Sign in", url: prompt.url } } : {}) });
|
|
1404
|
+
const failure = await this.send(channel, outbound);
|
|
1405
|
+
if (failure) {
|
|
1406
|
+
this.undelivered(prompt.message, failure);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const callId = `auth_${randomUUID()}`;
|
|
1410
|
+
this.history.push({ role: "assistant", content: null, toolCalls: [{ id: callId, name: sendToolName(channel), arguments: JSON.stringify({ text: outbound.text, ...(outbound.link ? { link: outbound.link } : {}) }) }] });
|
|
1411
|
+
this.history.push({ role: "tool", toolCallId: callId, content: JSON.stringify({ delivered: true, channel, authentication: true }) });
|
|
1412
|
+
this.settings.emit(this.id, "message.out", { text: outbound.text, link: outbound.link, channel, authentication: true });
|
|
1413
|
+
this.sent();
|
|
1414
|
+
this.sentThisTurn.add(prompt.message.trim());
|
|
1415
|
+
await this.acknowledged(channel);
|
|
1416
|
+
}
|
|
1290
1417
|
/** The reply stops waiting for this tool: it carries on in the background and its result comes back as a note. */
|
|
1291
1418
|
detach(r, why) {
|
|
1292
1419
|
const { call, startedAt } = r;
|
|
@@ -1362,6 +1489,7 @@ export function transcriptOf(history, only) {
|
|
|
1362
1489
|
kind: "out",
|
|
1363
1490
|
channel: delivered.channel,
|
|
1364
1491
|
text: String(args.text ?? ""),
|
|
1492
|
+
...(delivered.authentication ? { authentication: true } : {}),
|
|
1365
1493
|
at: result.at,
|
|
1366
1494
|
...(typeof args.reply_to === "string" ? { replyTo: args.reply_to } : {}),
|
|
1367
1495
|
...(Array.isArray(args.buttons) && args.buttons.length ? { buttons: args.buttons } : {}),
|
package/dist/src/events.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export interface FlowEvent {
|
|
2
|
-
type: "message.in" | "message.arrived" | "message.out" | "turn.start" | "turn.context" | "turn.end" | "model.call" | "model.restarted" | "model.error" | "tool.start" | "tool.end" | "tool.detached" | "tool.repeated" | "task.done" | "skill.loaded" | "text.hidden" | "ack.required" | "reaction" | "status" | "message.undelivered" | "reaction.in" | "attachment.failed" | "keyword" | "message.duplicate" | "history.trimmed" | "template.missing";
|
|
2
|
+
type: "message.in" | "message.arrived" | "message.out" | "turn.start" | "turn.context" | "turn.end" | "model.call" | "model.restarted" | "model.error" | "tool.start" | "tool.end" | "tool.detached" | "tool.repeated" | "task.done" | "skill.loaded" | "text.hidden" | "ack.required" | "auth.required" | "reaction" | "status" | "message.undelivered" | "reaction.in" | "attachment.failed" | "keyword" | "message.duplicate" | "history.trimmed" | "template.missing";
|
|
3
3
|
conversationId: string;
|
|
4
4
|
at: number;
|
|
5
5
|
[detail: string]: unknown;
|