@intentface/latch-channels 0.9.1

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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +27 -0
  3. package/dist/ask.d.ts +69 -0
  4. package/dist/ask.d.ts.map +1 -0
  5. package/dist/ask.js +94 -0
  6. package/dist/ask.js.map +1 -0
  7. package/dist/commands.d.ts +41 -0
  8. package/dist/commands.d.ts.map +1 -0
  9. package/dist/commands.js +54 -0
  10. package/dist/commands.js.map +1 -0
  11. package/dist/index.d.ts +19 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +19 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/kernel.d.ts +207 -0
  16. package/dist/kernel.d.ts.map +1 -0
  17. package/dist/kernel.js +883 -0
  18. package/dist/kernel.js.map +1 -0
  19. package/dist/render.d.ts +43 -0
  20. package/dist/render.d.ts.map +1 -0
  21. package/dist/render.js +192 -0
  22. package/dist/render.js.map +1 -0
  23. package/dist/slack/api.d.ts +207 -0
  24. package/dist/slack/api.d.ts.map +1 -0
  25. package/dist/slack/api.js +249 -0
  26. package/dist/slack/api.js.map +1 -0
  27. package/dist/slack/blocks.d.ts +36 -0
  28. package/dist/slack/blocks.d.ts.map +1 -0
  29. package/dist/slack/blocks.js +161 -0
  30. package/dist/slack/blocks.js.map +1 -0
  31. package/dist/slack/ids.d.ts +40 -0
  32. package/dist/slack/ids.d.ts.map +1 -0
  33. package/dist/slack/ids.js +47 -0
  34. package/dist/slack/ids.js.map +1 -0
  35. package/dist/slack/index.d.ts +24 -0
  36. package/dist/slack/index.d.ts.map +1 -0
  37. package/dist/slack/index.js +18 -0
  38. package/dist/slack/index.js.map +1 -0
  39. package/dist/slack/manifest.d.ts +23 -0
  40. package/dist/slack/manifest.d.ts.map +1 -0
  41. package/dist/slack/manifest.js +102 -0
  42. package/dist/slack/manifest.js.map +1 -0
  43. package/dist/slack/normalize.d.ts +35 -0
  44. package/dist/slack/normalize.d.ts.map +1 -0
  45. package/dist/slack/normalize.js +63 -0
  46. package/dist/slack/normalize.js.map +1 -0
  47. package/dist/slack/payloads.d.ts +36 -0
  48. package/dist/slack/payloads.d.ts.map +1 -0
  49. package/dist/slack/payloads.js +80 -0
  50. package/dist/slack/payloads.js.map +1 -0
  51. package/dist/slack/slackChannel.d.ts +152 -0
  52. package/dist/slack/slackChannel.d.ts.map +1 -0
  53. package/dist/slack/slackChannel.js +650 -0
  54. package/dist/slack/slackChannel.js.map +1 -0
  55. package/dist/slack/stream.d.ts +88 -0
  56. package/dist/slack/stream.d.ts.map +1 -0
  57. package/dist/slack/stream.js +418 -0
  58. package/dist/slack/stream.js.map +1 -0
  59. package/dist/slack/verify.d.ts +20 -0
  60. package/dist/slack/verify.d.ts.map +1 -0
  61. package/dist/slack/verify.js +32 -0
  62. package/dist/slack/verify.js.map +1 -0
  63. package/dist/telegram/api.d.ts +110 -0
  64. package/dist/telegram/api.d.ts.map +1 -0
  65. package/dist/telegram/api.js +170 -0
  66. package/dist/telegram/api.js.map +1 -0
  67. package/dist/telegram/format.d.ts +26 -0
  68. package/dist/telegram/format.d.ts.map +1 -0
  69. package/dist/telegram/format.js +56 -0
  70. package/dist/telegram/format.js.map +1 -0
  71. package/dist/telegram/html.d.ts +11 -0
  72. package/dist/telegram/html.d.ts.map +1 -0
  73. package/dist/telegram/html.js +35 -0
  74. package/dist/telegram/html.js.map +1 -0
  75. package/dist/telegram/index.d.ts +15 -0
  76. package/dist/telegram/index.d.ts.map +1 -0
  77. package/dist/telegram/index.js +15 -0
  78. package/dist/telegram/index.js.map +1 -0
  79. package/dist/telegram/stream.d.ts +59 -0
  80. package/dist/telegram/stream.d.ts.map +1 -0
  81. package/dist/telegram/stream.js +185 -0
  82. package/dist/telegram/stream.js.map +1 -0
  83. package/dist/telegram/telegramChannel.d.ts +136 -0
  84. package/dist/telegram/telegramChannel.d.ts.map +1 -0
  85. package/dist/telegram/telegramChannel.js +277 -0
  86. package/dist/telegram/telegramChannel.js.map +1 -0
  87. package/dist/telegram/verify.d.ts +7 -0
  88. package/dist/telegram/verify.d.ts.map +1 -0
  89. package/dist/telegram/verify.js +14 -0
  90. package/dist/telegram/verify.js.map +1 -0
  91. package/dist/types.d.ts +240 -0
  92. package/dist/types.d.ts.map +1 -0
  93. package/dist/types.js +12 -0
  94. package/dist/types.js.map +1 -0
  95. package/dist/whatsapp/api.d.ts +59 -0
  96. package/dist/whatsapp/api.d.ts.map +1 -0
  97. package/dist/whatsapp/api.js +154 -0
  98. package/dist/whatsapp/api.js.map +1 -0
  99. package/dist/whatsapp/index.d.ts +13 -0
  100. package/dist/whatsapp/index.d.ts.map +1 -0
  101. package/dist/whatsapp/index.js +13 -0
  102. package/dist/whatsapp/index.js.map +1 -0
  103. package/dist/whatsapp/verify.d.ts +17 -0
  104. package/dist/whatsapp/verify.d.ts.map +1 -0
  105. package/dist/whatsapp/verify.js +32 -0
  106. package/dist/whatsapp/verify.js.map +1 -0
  107. package/dist/whatsapp/whatsappChannel.d.ts +104 -0
  108. package/dist/whatsapp/whatsappChannel.d.ts.map +1 -0
  109. package/dist/whatsapp/whatsappChannel.js +217 -0
  110. package/dist/whatsapp/whatsappChannel.js.map +1 -0
  111. package/package.json +68 -0
package/dist/kernel.js ADDED
@@ -0,0 +1,883 @@
1
+ import { consumeRun, isChatBusyError, isPendingApprovalError, pendingApprovals, pendingQuestion } from "./render.js";
2
+ import { nextAskStep } from "./ask.js";
3
+ import { CHANNEL_COMMANDS, helpText, parseCommand, THREAD_CHANGING_COMMANDS, } from "./commands.js";
4
+ // ── Button payload codecs (shared wire format across surfaces) ──────────────
5
+ // Approvals: `lta:<0|1>|<approvalId>`. Questionnaires carry ordinals only —
6
+ // `ltq:<s|d>|<nonce>|<questionIndex>[|<optionIndex>]` — because Telegram caps
7
+ // callback_data at 64 bytes and a provider toolCallId alone would crowd it; the
8
+ // nonce pins a tap to the questionnaire that was posted.
9
+ export function encodeApprovalData(approved, approvalId) {
10
+ return `lta:${approved ? 1 : 0}|${approvalId}`;
11
+ }
12
+ export function decodeApprovalData(data) {
13
+ if (!data)
14
+ return null;
15
+ const m = /^lta:([01])\|(.+)$/.exec(data);
16
+ if (!m)
17
+ return null;
18
+ return { approved: m[1] === "1", approvalId: m[2] };
19
+ }
20
+ /** Length of the session nonce carried in button payloads. */
21
+ export const ASK_NONCE_LENGTH = 6;
22
+ export function encodeQuestionData(cb) {
23
+ const tail = cb.verb === "pick" ? `|${cb.optionIndex}` : "";
24
+ return `ltq:${cb.verb === "pick" ? "s" : "d"}|${cb.nonce}|${cb.questionIndex}${tail}`;
25
+ }
26
+ export function decodeQuestionData(data) {
27
+ if (!data)
28
+ return null;
29
+ const m = /^ltq:([sd])\|([^|]+)\|(\d+)(?:\|(\d+))?$/.exec(data);
30
+ if (!m)
31
+ return null;
32
+ const nonce = m[2];
33
+ const questionIndex = Number(m[3]);
34
+ if (m[1] === "d")
35
+ return { verb: "done", nonce, questionIndex };
36
+ if (m[4] === undefined)
37
+ return null;
38
+ return { verb: "pick", nonce, questionIndex, optionIndex: Number(m[4]) };
39
+ }
40
+ // ── Small shared helpers ────────────────────────────────────────────────────
41
+ export function newMessageId() {
42
+ return globalThis.crypto?.randomUUID?.() ?? `m_${Date.now()}_${Math.round(Math.random() * 1e9)}`;
43
+ }
44
+ /** Short random token pinning button payloads to one posted questionnaire. */
45
+ export function newNonce() {
46
+ const uuid = globalThis.crypto?.randomUUID?.() ?? String(Math.random());
47
+ return uuid.replace(/[^a-z0-9]/gi, "").slice(0, ASK_NONCE_LENGTH);
48
+ }
49
+ /** Split text into ≤`max`-char messages, preferring paragraph/line breaks. */
50
+ export function splitText(text, max) {
51
+ const out = [];
52
+ let rest = text;
53
+ while (rest.length > max) {
54
+ const window = rest.slice(0, max);
55
+ const cut = Math.max(window.lastIndexOf("\n\n"), window.lastIndexOf("\n"));
56
+ const at = cut > max * 0.5 ? cut : max;
57
+ out.push(rest.slice(0, at).trimEnd());
58
+ rest = rest.slice(at).trimStart();
59
+ }
60
+ if (rest.trim())
61
+ out.push(rest);
62
+ return out;
63
+ }
64
+ /**
65
+ * Run `fn` under the one-turn-per-chat lease. Every path that mutates a chat's
66
+ * run state — fresh turns, replays, approval resumes, questionnaire submits —
67
+ * goes through here, so two of them can never interleave on one chat. When the
68
+ * lease is held, `onBusy` runs instead (the "still working" notice).
69
+ */
70
+ export async function withTurnLease(lease, chatId, onBusy, fn) {
71
+ let token;
72
+ if (lease.claimTurn) {
73
+ const t = await lease.claimTurn(chatId);
74
+ if (!t) {
75
+ await onBusy().catch(() => { });
76
+ return;
77
+ }
78
+ token = t;
79
+ }
80
+ try {
81
+ await fn();
82
+ }
83
+ finally {
84
+ if (token)
85
+ await lease.releaseTurn?.(chatId, token).catch(() => { });
86
+ }
87
+ }
88
+ const MAX_INLINE_FILE_BYTES = 10 * 1024 * 1024;
89
+ const STILL_WORKING_MESSAGE = "I am still working on the previous message in this chat.";
90
+ const TRANSCRIBE_FAILED_MESSAGE = "⚠️ Couldn't transcribe your voice message — please try again or type it instead.";
91
+ const THREAD_UNAVAILABLE_MESSAGE = "⚠️ Couldn't work out which conversation this belongs to — please send that again.";
92
+ const LINK_FIRST_MESSAGE = "Link your Latch account first.";
93
+ const NOT_ACTIVE_MESSAGE = "This request is no longer active.";
94
+ /** How much of a compaction summary `/compact` echoes back. */
95
+ const MAX_SUMMARY_CHARS = 2000;
96
+ /** Typing hints expire after ~5s on every platform that has them. */
97
+ const TYPING_REFRESH_MS = 4000;
98
+ const truncate = (text, max) => text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
99
+ const errorText = (e) => (e instanceof Error ? e.message : String(e));
100
+ export function createTurnKernel(cfg, surface) {
101
+ const tag = `[${surface.kind}]`;
102
+ const maxBytes = cfg.maxInlineFileBytes ?? MAX_INLINE_FILE_BYTES;
103
+ /** Plain-text reply that never throws — notices, command answers, error lines. */
104
+ const say = (user, text) => surface.sendText(user, text).catch((e) => {
105
+ console.error(`${tag} reply failed:`, e);
106
+ return undefined;
107
+ });
108
+ const lease = (chatId, user, fn) => withTurnLease(cfg, chatId, () => say(user, STILL_WORKING_MESSAGE), fn);
109
+ // ── Threads ────────────────────────────────────────────────────────────────
110
+ /**
111
+ * The chat this person is currently writing to. `/new` bumps the epoch; epoch
112
+ * 0 keeps the original id so no existing conversation moves. A store failure
113
+ * THROWS rather than falling back to epoch 0 — that would write into the
114
+ * conversation the user just left behind.
115
+ */
116
+ async function currentThread(user) {
117
+ if (!cfg.thread)
118
+ return { chatId: surface.chatId(user, 0), epoch: 0 };
119
+ const epoch = await cfg.thread.current(surface.chatId(user, 0));
120
+ return { chatId: surface.chatId(user, epoch), epoch };
121
+ }
122
+ /** The current chat id, or null after telling the user we couldn't work it out. */
123
+ async function threadOrNotify(user) {
124
+ try {
125
+ return (await currentThread(user)).chatId;
126
+ }
127
+ catch (e) {
128
+ console.error(`${tag} thread lookup failed:`, e);
129
+ await say(user, THREAD_UNAVAILABLE_MESSAGE);
130
+ return null;
131
+ }
132
+ }
133
+ // ── Identity ───────────────────────────────────────────────────────────────
134
+ /** The principal behind a button press, or undefined after telling the user to link. */
135
+ async function tapPrincipal(user) {
136
+ let auth;
137
+ try {
138
+ auth = await cfg.authorizeUser(user);
139
+ }
140
+ catch (e) {
141
+ console.error(`${tag} tap auth failed:`, e);
142
+ return undefined;
143
+ }
144
+ if (auth.allowed && auth.principal !== undefined)
145
+ return auth.principal;
146
+ if (!auth.silent)
147
+ await say(user, auth.message ?? LINK_FIRST_MESSAGE);
148
+ return undefined;
149
+ }
150
+ // ── Rendering ──────────────────────────────────────────────────────────────
151
+ /** Keep the typing hint alive for as long as the returned stop() isn't called. */
152
+ function startTyping(user) {
153
+ if (!surface.typing)
154
+ return () => { };
155
+ const ping = () => surface.typing(user).catch(() => { });
156
+ void ping();
157
+ const timer = setInterval(ping, TYPING_REFRESH_MS);
158
+ timer.unref?.();
159
+ return () => clearInterval(timer);
160
+ }
161
+ /**
162
+ * Fold a run into its answer, post it once at finish (split past the cap),
163
+ * then render whatever the turn parked on. A failed turn has nothing new to
164
+ * decide, so its error line is the end of it.
165
+ */
166
+ async function renderRun(res, ctx) {
167
+ let text = "";
168
+ let errorMessage;
169
+ const stopTyping = startTyping(ctx.user);
170
+ try {
171
+ for await (const ev of consumeRun(res)) {
172
+ if (ev.type === "text-delta")
173
+ text += ev.text;
174
+ else if (ev.type === "error")
175
+ errorMessage = ev.message;
176
+ }
177
+ }
178
+ catch (e) {
179
+ console.error(`${tag} render stream failed:`, e);
180
+ errorMessage = errorText(e);
181
+ }
182
+ finally {
183
+ stopTyping();
184
+ }
185
+ let body = text.trim();
186
+ if (errorMessage)
187
+ body += `${body ? "\n\n" : ""}⚠️ ${errorMessage.slice(0, 500)}`;
188
+ for (const chunk of splitText(body, surface.maxTextChars)) {
189
+ await surface.sendText(ctx.user, chunk, { rich: true });
190
+ }
191
+ if (errorMessage)
192
+ return;
193
+ await renderPending(ctx);
194
+ }
195
+ /**
196
+ * Everything a paused turn is waiting on — message state, not stream chunks,
197
+ * so scan fresh history once. Approvals win when both are present: a gated
198
+ * tool blocks the run outright, so answering questions first would go nowhere.
199
+ */
200
+ async function renderPending(ctx) {
201
+ const history = await cfg.runtime.loadHistory({ chatId: ctx.chatId, principal: ctx.principal });
202
+ const approvals = pendingApprovals(history);
203
+ for (const pa of approvals) {
204
+ if (pa.isConnect && pa.connectionName && cfg.authorizeUrl) {
205
+ const url = cfg.authorizeUrl(pa.connectionName, {
206
+ chatId: ctx.chatId,
207
+ user: ctx.user,
208
+ approvalId: pa.approvalId,
209
+ });
210
+ await surface.sendLink(ctx.user, `Connect ${pa.connectionName} to continue.`, `Sign in to ${pa.connectionName}`, url);
211
+ }
212
+ else {
213
+ await surface.sendApproval(ctx.user, pa, {
214
+ approve: encodeApprovalData(true, pa.approvalId),
215
+ deny: encodeApprovalData(false, pa.approvalId),
216
+ });
217
+ }
218
+ }
219
+ if (approvals.length)
220
+ return;
221
+ await renderQuestions(pendingQuestion(history), ctx);
222
+ }
223
+ /** Re-post the pending card after a turn was refused for an outstanding decision. */
224
+ async function rerenderPending(ctx) {
225
+ await renderPending(ctx).catch((err) => console.error(`${tag} re-render pending failed:`, err));
226
+ }
227
+ // ── Questionnaires (askUser) ───────────────────────────────────────────────
228
+ const questionData = (session) => ({
229
+ pick: (optionIndex) => encodeQuestionData({ verb: "pick", nonce: session.nonce, questionIndex: session.cursor, optionIndex }),
230
+ done: encodeQuestionData({ verb: "done", nonce: session.nonce, questionIndex: session.cursor }),
231
+ });
232
+ /** Post the current question and remember its card as `promptRef`. */
233
+ async function promptQuestion(chatId, user, session) {
234
+ const card = await surface.sendQuestion(user, session, questionData(session));
235
+ if (card !== undefined) {
236
+ await cfg.askState
237
+ ?.update(chatId, session.toolCallId, session.rev, { promptRef: card })
238
+ .catch(() => null);
239
+ }
240
+ }
241
+ /**
242
+ * Prompt the questionnaire the turn is parked on (if any), and retire a stale
243
+ * session when it's gone — e.g. the user answered in the web app, so the
244
+ * buttons here must stop looking live.
245
+ */
246
+ async function renderQuestions(pending, ctx) {
247
+ const store = cfg.askState;
248
+ if (!pending) {
249
+ const stale = await store?.clear(ctx.chatId).catch(() => null);
250
+ if (stale?.promptRef)
251
+ await surface.retireCard?.(ctx.user, stale.promptRef).catch(() => { });
252
+ return;
253
+ }
254
+ const base = {
255
+ toolCallId: pending.toolCallId,
256
+ nonce: newNonce(),
257
+ questions: pending.questions,
258
+ cursor: 0,
259
+ answers: {},
260
+ selected: [],
261
+ promptChatId: surface.chatId(ctx.user, 0),
262
+ };
263
+ if (!store) {
264
+ // No host storage: post the questions as text so the user at least sees
265
+ // them, and leave the turn paused (answerable in the web app). Fabricating
266
+ // an answer would put words in their mouth.
267
+ console.warn(`${tag} askUser pending but no askState configured — posting as text`);
268
+ for (let i = 0; i < pending.questions.length; i++) {
269
+ await surface
270
+ .sendQuestion(ctx.user, { ...base, cursor: i, rev: 0 })
271
+ .catch(() => undefined);
272
+ }
273
+ return;
274
+ }
275
+ // `already` ⇒ this questionnaire is on screen from an earlier render (a
276
+ // redelivered webhook, or a resume that produced no new askUser). Prompting
277
+ // again would post a duplicate card whose buttons both mutate one session.
278
+ if ((await store.begin(ctx.chatId, base)) === "already")
279
+ return;
280
+ try {
281
+ await promptQuestion(ctx.chatId, ctx.user, { ...base, rev: 0 });
282
+ }
283
+ catch (e) {
284
+ // No prompt reached the user, so nothing is on screen to answer. Retire
285
+ // the session or their next message gets eaten as an answer to a question
286
+ // they never saw, with `/cancel` the only way out.
287
+ await store.clear(ctx.chatId).catch(() => null);
288
+ throw e;
289
+ }
290
+ }
291
+ /**
292
+ * Apply one questionnaire interaction. Only the final answer runs the agent,
293
+ * so only that path takes the turn lease — leasing intermediate taps would
294
+ * serialize a 5-question flow behind the lease TTL and reply "still working"
295
+ * to taps that touch no run state.
296
+ */
297
+ async function answerQuestion(ctx) {
298
+ const store = cfg.askState;
299
+ if (!store)
300
+ return {};
301
+ const { session, user } = ctx;
302
+ const step = nextAskStep(session, ctx.event);
303
+ if (step.kind === "rejected")
304
+ return { rejected: step.reason };
305
+ const card = ctx.card ?? session.promptRef;
306
+ if (step.kind === "toggled") {
307
+ const rev = await store.update(ctx.chatId, session.toolCallId, session.rev, {
308
+ selected: step.selected,
309
+ });
310
+ if (rev === null)
311
+ return { rejected: "Already answered." };
312
+ const next = { ...session, selected: step.selected, rev };
313
+ if (card && surface.updateQuestion) {
314
+ await surface.updateQuestion(user, card, next, questionData(next)).catch(() => { });
315
+ }
316
+ return {};
317
+ }
318
+ if (step.kind === "advanced") {
319
+ const rev = await store.update(ctx.chatId, session.toolCallId, session.rev, {
320
+ cursor: step.cursor,
321
+ answers: step.answers,
322
+ selected: [],
323
+ });
324
+ if (rev === null)
325
+ return { rejected: "Already answered." };
326
+ await surface.answered(user, card, step.answered).catch(() => { });
327
+ await promptQuestion(ctx.chatId, user, {
328
+ ...session,
329
+ cursor: step.cursor,
330
+ answers: step.answers,
331
+ selected: [],
332
+ rev,
333
+ });
334
+ return {};
335
+ }
336
+ // Last question answered — this resumes the agent. Ack the tap first: the
337
+ // resume runs the whole turn (tens of seconds), which outlives the
338
+ // platform's ack window, so acking afterwards fails and leaves a spinner.
339
+ const acked = ctx.ackId !== undefined;
340
+ if (ctx.ackId)
341
+ await surface.ack?.(ctx.ackId).catch(() => { });
342
+ // Nothing to submit through — bail BEFORE consuming the session, so the
343
+ // collected answers survive a misconfigured host.
344
+ const submit = cfg.runtime.applyToolResult;
345
+ if (!submit) {
346
+ console.error(`${tag} runtime.applyToolResult not wired — cannot resume askUser`);
347
+ return { acked };
348
+ }
349
+ // Take the lease BEFORE consuming the session: releasing the session first
350
+ // would destroy every collected answer with no way left to submit them.
351
+ await lease(ctx.chatId, user, async () => {
352
+ if (!(await store.finish(ctx.chatId, session.toolCallId, session.rev)))
353
+ return;
354
+ await surface.answered(user, card, step.answered).catch(() => { });
355
+ try {
356
+ const res = await submit({
357
+ chatId: ctx.chatId,
358
+ principal: ctx.principal,
359
+ toolCallId: session.toolCallId,
360
+ output: step.output,
361
+ });
362
+ await renderRun(res, ctx);
363
+ }
364
+ catch (e) {
365
+ if (isChatBusyError(e)) {
366
+ // Core refused the resume (a turn is live in the chat): the session
367
+ // was already consumed to fence the submit — put it back, answers
368
+ // included, so the same tap works once the chat is free.
369
+ await restoreSession(ctx.chatId, session).catch((err) => console.error(`${tag} could not restore questionnaire after busy refusal:`, err));
370
+ return void (await say(user, STILL_WORKING_MESSAGE));
371
+ }
372
+ console.error(`${tag} applyToolResult failed:`, e);
373
+ await say(user, `⚠️ ${errorText(e).slice(0, 3000)}`);
374
+ }
375
+ });
376
+ return { acked };
377
+ }
378
+ /** Re-create a finished session with its progress (`begin` starts at cursor 0; the fenced update fills it in). */
379
+ async function restoreSession(chatId, session) {
380
+ const store = cfg.askState;
381
+ if (!store)
382
+ return;
383
+ const { rev: _rev, promptRef, cursor, answers, selected, ...base } = session;
384
+ await store.begin(chatId, { ...base, cursor: 0, answers: {}, selected: [], ...(promptRef ? { promptRef } : {}) });
385
+ await store.update(chatId, session.toolCallId, 0, {
386
+ cursor,
387
+ answers,
388
+ selected,
389
+ ...(promptRef ? { promptRef } : {}),
390
+ });
391
+ }
392
+ // ── Inbound message → one agent turn ───────────────────────────────────────
393
+ async function handleMessage(ev) {
394
+ const { user } = ev;
395
+ const text = ev.text.trim();
396
+ const media = ev.media;
397
+ if (!text && media.length === 0)
398
+ return;
399
+ let auth;
400
+ try {
401
+ auth = await cfg.authorizeUser(user);
402
+ }
403
+ catch (e) {
404
+ await say(user, `⚠️ auth check failed: ${errorText(e).slice(0, 500)}`);
405
+ return;
406
+ }
407
+ // A command changes what the message MEANS everywhere below: it isn't an
408
+ // answer to a question, isn't worth stashing for replay, and never reaches
409
+ // the agent. Media rules it out — a captioned photo is a real message, and
410
+ // running a command would silently drop the file.
411
+ const command = media.length === 0 ? parseCommand(text) : null;
412
+ if (!auth.allowed || auth.principal === undefined) {
413
+ if (auth.silent)
414
+ return;
415
+ // Don't stash a command: the post-link replay would send the agent the
416
+ // literal "/new" as a message.
417
+ if (!command) {
418
+ await cfg
419
+ .stashPending?.({ user, text, ...(media.length ? { media } : {}) })
420
+ .catch((e) => console.error(`${tag} stashPending failed:`, e));
421
+ }
422
+ const body = auth.message ?? "You need a Latch account to use this agent.";
423
+ const notice = auth.linkUrl
424
+ ? surface.sendLink(user, body, "Link your Latch account", auth.linkUrl)
425
+ : surface.sendText(user, body);
426
+ await notice.catch((e) => console.error(`${tag} link notice failed:`, e));
427
+ return;
428
+ }
429
+ const principal = auth.principal;
430
+ // Which conversation this belongs to. If that can't be established, say so
431
+ // and drop the message: guessing would write it into the wrong thread.
432
+ let thread;
433
+ try {
434
+ thread = await currentThread(user);
435
+ }
436
+ catch (e) {
437
+ console.error(`${tag} thread lookup failed:`, e);
438
+ await say(user, THREAD_UNAVAILABLE_MESSAGE);
439
+ return;
440
+ }
441
+ const { chatId } = thread;
442
+ const ctx = { chatId, user, principal };
443
+ // A questionnaire on screen changes what a plain message MEANS: it answers
444
+ // the current question rather than starting a turn. Deliberately downstream
445
+ // of dedupe — a redelivered update would otherwise be applied as a second
446
+ // answer and skip a question — and of the auth gate, so an unlinked user
447
+ // still gets the link prompt and can't mutate ask state.
448
+ let droppedQuestions = false;
449
+ if (cfg.askState) {
450
+ const session = await cfg.askState.get(chatId).catch(() => null);
451
+ if (session) {
452
+ // Media is never an answer: treating a captioned photo as one would
453
+ // silently drop the file, since answers are plain text. Let it fall
454
+ // through to a normal turn so the attachment actually reaches the agent.
455
+ if (!command && text && media.length === 0) {
456
+ const { rejected } = await answerQuestion({ ...ctx, session, event: { kind: "typed", text } });
457
+ if (rejected)
458
+ await say(user, rejected);
459
+ return;
460
+ }
461
+ // Not an answer. Retire the questionnaire when whatever comes next
462
+ // abandons the parked call anyway — a fresh turn (media, or unknown `/`
463
+ // text) or a command that ends/replaces the thread. Read-only commands
464
+ // (`/status`, `/help`) leave it live: asking about a conversation
465
+ // shouldn't destroy the questions in it.
466
+ if (!command || THREAD_CHANGING_COMMANDS.has(command.name)) {
467
+ await cfg.askState.clear(chatId).catch(() => { });
468
+ if (session.promptRef)
469
+ await surface.retireCard?.(user, session.promptRef).catch(() => { });
470
+ droppedQuestions = true;
471
+ }
472
+ }
473
+ }
474
+ if (command) {
475
+ await runCommand({ command, ...ctx, epoch: thread.epoch, droppedQuestions });
476
+ return;
477
+ }
478
+ await lease(chatId, user, () => runMessage({ user, text, media, principal, chatId }));
479
+ }
480
+ async function runMessage(ctx) {
481
+ const { user } = ctx;
482
+ const chatId = ctx.chatId ?? (await threadOrNotify(user));
483
+ if (chatId === null)
484
+ return;
485
+ // Media → inline data-URL file parts (≤ cap each); oversized listed in a
486
+ // note. Audio is the exception: it's transcribed to text (never a file
487
+ // part) because the agent's chat model can't be assumed to accept audio.
488
+ const fileParts = [];
489
+ const transcripts = [];
490
+ const skipped = [];
491
+ const failedAudio = [];
492
+ for (const m of ctx.media ?? []) {
493
+ const isAudio = m.mediaType.startsWith("audio/");
494
+ const markFailed = () => (isAudio ? failedAudio : skipped).push(m.name);
495
+ if (isAudio && !cfg.transcribe) {
496
+ markFailed();
497
+ continue;
498
+ }
499
+ try {
500
+ const fetched = await surface.fetchMedia(m, maxBytes);
501
+ if (!fetched) {
502
+ markFailed();
503
+ continue;
504
+ }
505
+ if (isAudio) {
506
+ const transcript = (await cfg.transcribe({ bytes: fetched.bytes, mediaType: m.mediaType, filename: m.name })).trim();
507
+ if (!transcript) {
508
+ failedAudio.push(m.name);
509
+ continue;
510
+ }
511
+ transcripts.push(`[Voice message]: ${transcript}`);
512
+ }
513
+ else {
514
+ fileParts.push({
515
+ type: "file",
516
+ mediaType: fetched.mediaType,
517
+ filename: m.name,
518
+ url: `data:${fetched.mediaType};base64,${Buffer.from(fetched.bytes).toString("base64")}`,
519
+ });
520
+ }
521
+ }
522
+ catch (e) {
523
+ console.error(`${tag} media processing failed (${m.name}):`, e);
524
+ markFailed();
525
+ }
526
+ }
527
+ let text = [ctx.text, ...transcripts].filter(Boolean).join("\n\n");
528
+ // A voice message that produced nothing usable: tell the sender instead of
529
+ // running a turn the agent would have no content to answer.
530
+ if (failedAudio.length && !text && fileParts.length === 0) {
531
+ await say(user, TRANSCRIBE_FAILED_MESSAGE);
532
+ return;
533
+ }
534
+ if (failedAudio.length) {
535
+ const note = `[A voice/audio message could not be transcribed: ${failedAudio.join(", ")}]`;
536
+ text = text ? `${text}\n\n${note}` : note;
537
+ }
538
+ if (skipped.length) {
539
+ const note = `[Some attached files could not be included (too large or unavailable): ${skipped.join(", ")}]`;
540
+ text = text ? `${text}\n\n${note}` : note;
541
+ }
542
+ if (!text && fileParts.length === 0)
543
+ return;
544
+ const message = {
545
+ id: newMessageId(),
546
+ role: "user",
547
+ parts: [...(text ? [{ type: "text", text }] : []), ...fileParts],
548
+ metadata: { visibility: "user", createdAt: Date.now() },
549
+ };
550
+ const rctx = { chatId, user, principal: ctx.principal };
551
+ try {
552
+ const res = await cfg.runtime.handleChat({
553
+ agent: cfg.agent,
554
+ chatId,
555
+ principal: ctx.principal,
556
+ message,
557
+ });
558
+ await renderRun(res, rctx);
559
+ }
560
+ catch (e) {
561
+ // Core's own one-turn-per-chat rule refused: same answer as our lease.
562
+ if (isChatBusyError(e))
563
+ return void (await say(user, STILL_WORKING_MESSAGE));
564
+ console.error(`${tag} runMessage failed:`, e);
565
+ await say(user, `⚠️ ${errorText(e).slice(0, 3000)}`);
566
+ // The turn was refused because a decision is still outstanding — re-post
567
+ // the card so it can be resolved from here. Without this the chat is a
568
+ // dead end: the same refusal every turn and no buttons to clear it.
569
+ if (isPendingApprovalError(e))
570
+ await rerenderPending(rctx);
571
+ }
572
+ }
573
+ async function replayMessage(ctx) {
574
+ const chatId = await threadOrNotify(ctx.user);
575
+ if (chatId === null)
576
+ return;
577
+ await lease(chatId, ctx.user, () => runMessage({ ...ctx, chatId }));
578
+ }
579
+ // ── Button taps ────────────────────────────────────────────────────────────
580
+ async function handleAction(ev) {
581
+ const approval = decodeApprovalData(ev.data);
582
+ if (approval)
583
+ return handleApprovalTap(ev, approval);
584
+ const question = decodeQuestionData(ev.data);
585
+ if (question)
586
+ return handleQuestionTap(ev, question);
587
+ // Unknown payload (a button type nobody registered here): nothing to do.
588
+ if (ev.ackId)
589
+ await surface.ack?.(ev.ackId).catch(() => { });
590
+ }
591
+ /** Approve/Deny → resume the run. */
592
+ async function handleApprovalTap(ev, decoded) {
593
+ const { user } = ev;
594
+ // Ack up front: the resume outlives the platform's ack window.
595
+ if (ev.ackId)
596
+ await surface.ack?.(ev.ackId).catch(() => { });
597
+ const principal = await tapPrincipal(user);
598
+ if (principal === undefined)
599
+ return;
600
+ const chatId = await threadOrNotify(user);
601
+ if (chatId === null)
602
+ return;
603
+ const ctx = { chatId, user, principal };
604
+ // Resolve the tapped approval against fresh history BEFORE acting on it. Not
605
+ // just for naming the card: a card left behind by a previous thread (`/new`)
606
+ // or already decided in the web app points at an approval this chat no longer
607
+ // has, and pushing it through would surface a raw "Unknown chat" error.
608
+ let history;
609
+ try {
610
+ history = await cfg.runtime.loadHistory({ chatId, principal });
611
+ }
612
+ catch (e) {
613
+ // Couldn't tell live from stale. Say so and leave the card as it is —
614
+ // retiring it on a transient read failure would strand a real approval
615
+ // with no buttons left to resolve it.
616
+ console.error(`${tag} approval history read failed:`, e);
617
+ await say(user, "⚠️ Couldn't check that request — try again.");
618
+ return;
619
+ }
620
+ const live = pendingApprovals(history).find((p) => p.approvalId === decoded.approvalId);
621
+ if (!live) {
622
+ if (ev.card)
623
+ await surface.settleCard?.(user, ev.card, NOT_ACTIVE_MESSAGE).catch(() => { });
624
+ else
625
+ await say(user, NOT_ACTIVE_MESSAGE);
626
+ return;
627
+ }
628
+ // The resume mutates the same run state as a fresh turn — take the lease
629
+ // too, or an approval press racing a new message runs two turns at once.
630
+ // If the chat is busy the buttons stay in place; the user presses again.
631
+ await lease(chatId, user, async () => {
632
+ try {
633
+ const res = await cfg.runtime.applyApproval({
634
+ chatId,
635
+ principal,
636
+ decisions: [{ approvalId: decoded.approvalId, approved: decoded.approved }],
637
+ });
638
+ if (ev.card) {
639
+ const verdict = decoded.approved ? "✅ Approved" : "🚫 Denied";
640
+ await surface
641
+ .settleCard?.(user, ev.card, `${verdict} **${live.toolName}**`, { rich: true })
642
+ .catch(() => { });
643
+ }
644
+ // Continue rendering whatever the resume produced (chained approvals, or
645
+ // a questionnaire the resumed turn went on to ask).
646
+ await renderRun(res, ctx);
647
+ }
648
+ catch (e) {
649
+ if (isChatBusyError(e))
650
+ return void (await say(user, STILL_WORKING_MESSAGE));
651
+ console.error(`${tag} applyApproval failed:`, e);
652
+ await say(user, `⚠️ ${errorText(e).slice(0, 3000)}`);
653
+ }
654
+ });
655
+ }
656
+ /** An option / Done tap on a questionnaire card. */
657
+ async function handleQuestionTap(ev, decoded) {
658
+ const { user } = ev;
659
+ /** Answer the tap: a toast where the platform has one, a message otherwise. */
660
+ const respond = (text) => ev.ackId ? surface.ack?.(ev.ackId, text).catch(() => { }) : say(user, text);
661
+ // Unknown thread ⇒ we can't tell which session this tap belongs to. Say
662
+ // so and leave the card alone so a retry still works.
663
+ let chatId;
664
+ try {
665
+ chatId = (await currentThread(user)).chatId;
666
+ }
667
+ catch (e) {
668
+ console.error(`${tag} thread lookup failed:`, e);
669
+ await respond("Something went wrong — try again.");
670
+ return;
671
+ }
672
+ const session = await cfg.askState?.get(chatId).catch(() => null);
673
+ // Stale button: the session is gone (answered elsewhere, cancelled, expired)
674
+ // or belongs to a newer questionnaire, or this card isn't the current
675
+ // question. Retire the card instead of answering the wrong thing.
676
+ const stale = !session || session.nonce !== decoded.nonce || session.cursor !== decoded.questionIndex;
677
+ if (stale) {
678
+ await respond(session ? "Already answered." : "This questionnaire is no longer active.");
679
+ if (ev.card)
680
+ await surface.retireCard?.(user, ev.card).catch(() => { });
681
+ return;
682
+ }
683
+ const principal = await tapPrincipal(user);
684
+ if (principal === undefined) {
685
+ if (ev.ackId)
686
+ await surface.ack?.(ev.ackId).catch(() => { });
687
+ return;
688
+ }
689
+ const event = decoded.verb === "pick" ? { kind: "pick", option: decoded.optionIndex } : { kind: "done" };
690
+ const { rejected, acked } = await answerQuestion({
691
+ chatId,
692
+ user,
693
+ principal,
694
+ session,
695
+ event,
696
+ ...(ev.card !== undefined ? { card: ev.card } : {}),
697
+ ...(ev.ackId !== undefined ? { ackId: ev.ackId } : {}),
698
+ });
699
+ // The resume path already acked (it outlives the ack window); everything
700
+ // else settles synchronously and gets its ack — with the reason, if rejected.
701
+ if (!acked) {
702
+ if (ev.ackId)
703
+ await surface.ack?.(ev.ackId, rejected).catch(() => { });
704
+ else if (rejected)
705
+ await say(user, rejected);
706
+ }
707
+ }
708
+ const COMMAND_HELP = `Commands\n\n${helpText(CHANNEL_COMMANDS)}`;
709
+ async function runCommand(ctx) {
710
+ switch (ctx.command.name) {
711
+ case "help":
712
+ await say(ctx.user, COMMAND_HELP);
713
+ return;
714
+ case "start":
715
+ await say(ctx.user, `You're talking to ${cfg.agent}. Send a message — text, a photo, a file, or a voice note — and it will answer here.\n\n${COMMAND_HELP}`);
716
+ return;
717
+ case "new":
718
+ await runNewCommand(ctx);
719
+ return;
720
+ case "compact":
721
+ await runCompactCommand(ctx);
722
+ return;
723
+ case "cancel":
724
+ await runCancelCommand(ctx);
725
+ return;
726
+ case "status":
727
+ await runStatusCommand(ctx);
728
+ return;
729
+ default:
730
+ // Unreachable: `parseCommand` only resolves names in the registry.
731
+ await say(ctx.user, COMMAND_HELP);
732
+ }
733
+ }
734
+ /** `/new` — rotate to a fresh thread. Cheap: no lease, no agent turn. */
735
+ async function runNewCommand(ctx) {
736
+ if (!cfg.thread) {
737
+ console.warn(`${tag} /new used but no thread store configured`);
738
+ await say(ctx.user, "Starting a new conversation isn't available on this bot.");
739
+ return;
740
+ }
741
+ try {
742
+ await cfg.thread.rotate(surface.chatId(ctx.user, 0));
743
+ }
744
+ catch (e) {
745
+ console.error(`${tag} /new failed:`, e);
746
+ await say(ctx.user, "⚠️ Couldn't start a new conversation — please try again.");
747
+ return;
748
+ }
749
+ await say(ctx.user, "Started a new conversation. The previous one is still in the web app.");
750
+ }
751
+ /**
752
+ * `/compact` — summarize the thread and keep going in the same one. Takes the
753
+ * turn lease: it costs a model call and rewrites what the next turn sends.
754
+ */
755
+ async function runCompactCommand(ctx) {
756
+ const compact = cfg.runtime.compactChat;
757
+ if (!compact) {
758
+ console.warn(`${tag} /compact used but runtime.compactChat not wired`);
759
+ await say(ctx.user, "Compacting isn't available on this bot.");
760
+ return;
761
+ }
762
+ await lease(ctx.chatId, ctx.user, async () => {
763
+ await surface.typing?.(ctx.user).catch(() => { });
764
+ try {
765
+ const res = await compact({
766
+ chatId: ctx.chatId,
767
+ principal: ctx.principal,
768
+ ...(ctx.command.args ? { focus: ctx.command.args } : {}),
769
+ });
770
+ if (!res) {
771
+ await say(ctx.user, "This conversation is short enough already.");
772
+ return;
773
+ }
774
+ // Plain text on purpose: the summary is model output, and formatting it
775
+ // could push an escaped message past the platform cap or split an entity.
776
+ await say(ctx.user, `♻️ Compacted ${res.compacted} messages. Carrying forward:\n\n${truncate(res.summary, MAX_SUMMARY_CHARS)}`);
777
+ }
778
+ catch (e) {
779
+ console.error(`${tag} /compact failed:`, e);
780
+ await say(ctx.user, `⚠️ ${errorText(e).slice(0, 3000)}`);
781
+ // Paused turn: show what it's waiting on, so the refusal is actionable.
782
+ if (isPendingApprovalError(e))
783
+ await rerenderPending(ctx);
784
+ }
785
+ });
786
+ }
787
+ /**
788
+ * `/cancel` — get out of whatever the agent is waiting on. A questionnaire is
789
+ * already dropped by the time we get here; approvals are denied, which lets the
790
+ * agent wrap up instead of leaving the chat gated forever.
791
+ */
792
+ async function runCancelCommand(ctx) {
793
+ // The questionnaire is already gone, so report that BEFORE the lease: a busy
794
+ // chat would otherwise answer only "still working" and the user would never
795
+ // learn their questions had been retired.
796
+ if (ctx.droppedQuestions) {
797
+ await say(ctx.user, "Dropped those questions. What would you like to do instead?");
798
+ }
799
+ // Approvals, in contrast, are read and denied INSIDE the lease: a decision
800
+ // built from a pre-lease read could name approvals a concurrent turn has
801
+ // already moved past. If a turn is running the user gets "still working",
802
+ // which is the honest answer — there'd be nothing to cancel yet anyway.
803
+ await lease(ctx.chatId, ctx.user, async () => {
804
+ let approvals = [];
805
+ try {
806
+ approvals = pendingApprovals(await cfg.runtime.loadHistory({ chatId: ctx.chatId, principal: ctx.principal }));
807
+ }
808
+ catch (e) {
809
+ console.error(`${tag} /cancel history read failed:`, e);
810
+ await say(ctx.user, "⚠️ Couldn't read this conversation — try again.");
811
+ return;
812
+ }
813
+ // Nothing left to say when the questions were the thing being cancelled —
814
+ // "Nothing to cancel." right after "Dropped those questions." contradicts it.
815
+ if (!approvals.length) {
816
+ if (!ctx.droppedQuestions)
817
+ await say(ctx.user, "Nothing to cancel.");
818
+ return;
819
+ }
820
+ try {
821
+ const res = await cfg.runtime.applyApproval({
822
+ chatId: ctx.chatId,
823
+ principal: ctx.principal,
824
+ decisions: approvals.map((a) => ({ approvalId: a.approvalId, approved: false })),
825
+ });
826
+ await say(ctx.user, `🚫 Denied ${approvals.map((a) => a.toolName).join(", ")}.`);
827
+ await renderRun(res, ctx);
828
+ }
829
+ catch (e) {
830
+ console.error(`${tag} /cancel failed:`, e);
831
+ await say(ctx.user, `⚠️ ${errorText(e).slice(0, 3000)}`);
832
+ }
833
+ });
834
+ }
835
+ /** `/status` — what this thread is and what it's waiting on. Read-only. */
836
+ async function runStatusCommand(ctx) {
837
+ let history = [];
838
+ try {
839
+ history = await cfg.runtime.loadHistory({ chatId: ctx.chatId, principal: ctx.principal });
840
+ }
841
+ catch (e) {
842
+ console.error(`${tag} /status history read failed:`, e);
843
+ await say(ctx.user, "⚠️ Couldn't read this conversation.");
844
+ return;
845
+ }
846
+ const approvals = pendingApprovals(history);
847
+ const question = pendingQuestion(history);
848
+ const waiting = approvals.length
849
+ ? `approval for ${approvals.map((a) => a.toolName).join(", ")}`
850
+ : question
851
+ ? `${question.questions.length} question(s)`
852
+ : "nothing";
853
+ await say(ctx.user, [
854
+ `Agent: ${cfg.agent}`,
855
+ `Conversation: ${ctx.epoch > 0 ? `#${ctx.epoch + 1}` : "the first one"}`,
856
+ `Messages: ${history.length}`,
857
+ `Waiting on: ${waiting}`,
858
+ ].join("\n"));
859
+ }
860
+ // ── Entry ──────────────────────────────────────────────────────────────────
861
+ /**
862
+ * The error boundary: callers run this detached (under `waitUntil`, or as a
863
+ * dropped promise), so a rejection here would be an unhandled one. Every
864
+ * user-visible failure is already reported inside; whatever escapes is logged.
865
+ */
866
+ async function handleInbound(ev) {
867
+ try {
868
+ if (cfg.seen && (await cfg.seen(ev.deliveryKey))) {
869
+ console.log(`${tag} duplicate delivery ${ev.deliveryKey} — dropped`);
870
+ return;
871
+ }
872
+ if (ev.kind === "action")
873
+ await handleAction(ev);
874
+ else
875
+ await handleMessage(ev);
876
+ }
877
+ catch (e) {
878
+ console.error(`${tag} inbound handling failed:`, e);
879
+ }
880
+ }
881
+ return { handleInbound, runMessage, replayMessage };
882
+ }
883
+ //# sourceMappingURL=kernel.js.map