@flow-engineer/flow 0.2.1 → 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.
Files changed (66) hide show
  1. package/README.md +167 -10
  2. package/coding-agents/auth-skill.md +137 -0
  3. package/coding-agents/context-skill.md +94 -0
  4. package/coding-agents/skill.md +219 -17
  5. package/coding-agents/test-skill.md +3 -1
  6. package/coding-agents/tools-skill.md +78 -1
  7. package/dist/cli/src/auth.d.ts +11 -0
  8. package/dist/cli/src/auth.js +147 -0
  9. package/dist/cli/src/commands.d.ts +119 -0
  10. package/dist/cli/src/commands.js +212 -0
  11. package/dist/cli/src/index.d.ts +2 -0
  12. package/dist/cli/src/index.js +5 -0
  13. package/dist/cli/src/run.d.ts +28 -0
  14. package/dist/cli/src/run.js +523 -0
  15. package/dist/cli/src/terminal.d.ts +3 -0
  16. package/dist/cli/src/terminal.js +135 -0
  17. package/dist/flow-messaging/src/index.js +7 -1
  18. package/dist/mcp/src/client.d.ts +54 -0
  19. package/dist/mcp/src/client.js +175 -0
  20. package/dist/mcp/src/describe.d.ts +41 -0
  21. package/dist/mcp/src/describe.js +110 -0
  22. package/dist/mcp/src/index.d.ts +4 -0
  23. package/dist/mcp/src/index.js +7 -0
  24. package/dist/mcp/src/server.d.ts +70 -0
  25. package/dist/mcp/src/server.js +252 -0
  26. package/dist/mcp/src/terminal.d.ts +24 -0
  27. package/dist/mcp/src/terminal.js +54 -0
  28. package/dist/simulators/src/index.d.ts +1 -1
  29. package/dist/simulators/src/page.html +267 -49
  30. package/dist/simulators/src/server.d.ts +27 -0
  31. package/dist/simulators/src/server.js +264 -40
  32. package/dist/src/agent.d.ts +69 -3
  33. package/dist/src/agent.js +162 -8
  34. package/dist/src/channels.d.ts +2 -0
  35. package/dist/src/conversation.d.ts +16 -1
  36. package/dist/src/conversation.js +140 -12
  37. package/dist/src/events.d.ts +1 -1
  38. package/dist/src/index.d.ts +2 -2
  39. package/dist/src/index.js +1 -1
  40. package/dist/src/model.d.ts +27 -0
  41. package/dist/src/model.js +85 -13
  42. package/dist/src/skills.d.ts +4 -0
  43. package/dist/src/skills.js +23 -0
  44. package/dist/src/store.d.ts +2 -0
  45. package/dist/src/store.js +18 -0
  46. package/dist/src/tools.d.ts +69 -0
  47. package/dist/src/tools.js +17 -1
  48. package/dist/src/traces.js +32 -4
  49. package/dist/src/types.d.ts +2 -0
  50. package/dist/testing/src/account.d.ts +1 -1
  51. package/dist/testing/src/account.js +368 -57
  52. package/dist/testing/src/chat.js +24 -16
  53. package/dist/testing/src/check.d.ts +4 -0
  54. package/dist/testing/src/check.js +90 -0
  55. package/dist/testing/src/cli.js +77 -19
  56. package/dist/testing/src/flow-login.d.ts +33 -0
  57. package/dist/testing/src/flow-login.js +30 -5
  58. package/dist/testing/src/new.d.ts +14 -5
  59. package/dist/testing/src/new.js +54 -20
  60. package/dist/testing/src/plugin.d.ts +4 -0
  61. package/dist/testing/src/plugin.js +116 -0
  62. package/docs/agents.md +87 -12
  63. package/examples/cafe/src/cli.ts +24 -0
  64. package/examples/cafe/src/mcp.ts +22 -0
  65. package/examples/cafe/src/tools.ts +13 -0
  66. 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) {
@@ -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[];
@@ -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.link(link);
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 specs = [...this.settings.specs, ...this.builtinSpecs, ...talkSpecs(views, this.settings.talk)];
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
- const failure = await this.send(d.channel, outbound);
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 context = { conversationId: this.id, person: this.personKey, key: this.personKey.startsWith("key:") ? this.personKey.slice(4) : null, channel: this.currentChannel(), callId: call.id, idempotencyKey: key, signal: this.lifetime.signal };
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(() => tool.run(parsed.data, context))
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 } : {}),
@@ -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;