@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
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;
|
|
@@ -74,6 +76,8 @@ export interface Message {
|
|
|
74
76
|
image?: {
|
|
75
77
|
url: string;
|
|
76
78
|
};
|
|
79
|
+
/** Given for a send the business starts (a nudge, a scheduled text): the same in every process, so a provider that takes one sends it once. */
|
|
80
|
+
idempotencyKey?: string;
|
|
77
81
|
}
|
|
78
82
|
export interface WhatsAppProvider {
|
|
79
83
|
sendMessage(to: string, message: Message): Promise<void>;
|
|
@@ -88,12 +92,10 @@ export interface WhatsAppProvider {
|
|
|
88
92
|
react?(to: string, messageId: string, emoji: string): Promise<void>;
|
|
89
93
|
typing?(to: string, on: boolean): Promise<void>;
|
|
90
94
|
markRead?(to: string, messageIds: string[]): Promise<void>;
|
|
91
|
-
sendTemplate?(to: string, template: Template): Promise<void>;
|
|
95
|
+
sendTemplate?(to: string, template: Template, idempotencyKey?: string): Promise<void>;
|
|
92
96
|
}
|
|
93
97
|
export interface SmsProvider {
|
|
94
|
-
sendMessage(to: string, message:
|
|
95
|
-
text: string;
|
|
96
|
-
}): Promise<void>;
|
|
98
|
+
sendMessage(to: string, message: Pick<Message, "text" | "idempotencyKey">): Promise<void>;
|
|
97
99
|
}
|
|
98
100
|
/** Your own UI. `message` carries the text, any buttons, and your params by name. */
|
|
99
101
|
export interface CustomUIProvider {
|
package/dist/src/channels.js
CHANGED
|
@@ -93,8 +93,8 @@ export function whatsapp(provider, options = {}) {
|
|
|
93
93
|
case "template": {
|
|
94
94
|
if (!provider.sendTemplate || off.has("templates"))
|
|
95
95
|
throw new Error("this WhatsApp provider sends no templates");
|
|
96
|
-
const { type: _type, ...template } = outbound;
|
|
97
|
-
return provider.sendTemplate(to, template);
|
|
96
|
+
const { type: _type, idempotencyKey, ...template } = outbound;
|
|
97
|
+
return provider.sendTemplate(to, template, idempotencyKey);
|
|
98
98
|
}
|
|
99
99
|
case "status":
|
|
100
100
|
return;
|
|
@@ -112,7 +112,7 @@ export function sms(provider, options = {}) {
|
|
|
112
112
|
phone: true,
|
|
113
113
|
send: (to, outbound) => {
|
|
114
114
|
if (outbound.type === "message")
|
|
115
|
-
return provider.sendMessage(to, { text: outbound.text });
|
|
115
|
+
return provider.sendMessage(to, { text: outbound.text, ...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}) });
|
|
116
116
|
if (outbound.type === "template")
|
|
117
117
|
throw new Error("SMS has no templates");
|
|
118
118
|
// Reactions, typing, read receipts and status do not exist on SMS.
|
|
@@ -159,6 +159,7 @@ function message(outbound) {
|
|
|
159
159
|
...(outbound.list ? { list: outbound.list } : {}),
|
|
160
160
|
...(outbound.link ? { link: outbound.link } : {}),
|
|
161
161
|
...(outbound.image ? { image: outbound.image } : {}),
|
|
162
|
+
...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
|
|
162
163
|
};
|
|
163
164
|
}
|
|
164
165
|
/**
|
|
@@ -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;
|
|
@@ -258,6 +268,7 @@ export declare class Conversation {
|
|
|
258
268
|
private send;
|
|
259
269
|
/** A fixed-text scheduled message or a text or template nudge, sent with no model turn. */
|
|
260
270
|
private sendDirect;
|
|
271
|
+
private directSettled;
|
|
261
272
|
/**
|
|
262
273
|
* The history the model is sent: all of it while it fits the budget, else the latest entries
|
|
263
274
|
* that do, from the start of one (never between a call and its result), after a line that says
|
|
@@ -276,6 +287,10 @@ export declare class Conversation {
|
|
|
276
287
|
private runTools;
|
|
277
288
|
/** Start one tool call; the model's input is checked first (a refusal is the call's result). */
|
|
278
289
|
private start;
|
|
290
|
+
/** A failed or unresponsive auth service cannot leave a protected call hanging or let it execute. */
|
|
291
|
+
private authentication;
|
|
292
|
+
/** A login request is a real channel message, recorded as delivered, without another model call. */
|
|
293
|
+
private sendSignIn;
|
|
279
294
|
/** The reply stops waiting for this tool: it carries on in the background and its result comes back as a note. */
|
|
280
295
|
private detach;
|
|
281
296
|
}
|
|
@@ -296,6 +311,7 @@ export type TranscriptEntry = {
|
|
|
296
311
|
kind: "out";
|
|
297
312
|
channel: string;
|
|
298
313
|
text: string;
|
|
314
|
+
authentication?: boolean;
|
|
299
315
|
at: number;
|
|
300
316
|
replyTo?: string;
|
|
301
317
|
buttons?: string[];
|