toolroll 0.9.96 → 0.9.97
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/CHANGELOG.md +8 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/chat-delivery.d.ts +5 -1
- package/dist/chat-delivery.js +74 -25
- package/dist/chat-follow.d.ts +6 -3
- package/dist/chat-follow.js +23 -7
- package/dist/chat-live-schema.d.ts +17 -0
- package/dist/chat-live-schema.js +32 -0
- package/dist/chat-live.d.ts +161 -0
- package/dist/chat-live.js +491 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-api.d.ts +3 -1
- package/dist/discord-api.js +9 -3
- package/dist/discord-chat.d.ts +13 -3
- package/dist/discord-chat.js +77 -21
- package/dist/discord.js +8 -1
- package/dist/lead-answer-eval.d.ts +86 -0
- package/dist/lead-answer-eval.js +255 -0
- package/dist/lead-cli-live.d.ts +56 -0
- package/dist/lead-cli-live.js +200 -0
- package/dist/lead-cli.d.ts +3 -0
- package/dist/lead-cli.js +34 -4
- package/dist/lead-latency-schema.d.ts +24 -0
- package/dist/lead-latency-schema.js +36 -0
- package/dist/lead-progress.d.ts +10 -1
- package/dist/lead-progress.js +1 -1
- package/dist/lead-queue-runtime.d.ts +1 -1
- package/dist/lead-queue-runtime.js +1 -1
- package/dist/lead-route.d.ts +56 -0
- package/dist/lead-route.js +104 -0
- package/dist/lead.d.ts +4 -2
- package/dist/lead.js +79 -26
- package/dist/live-reply.d.ts +83 -0
- package/dist/live-reply.js +302 -0
- package/dist/operate.js +39 -1
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/reply-shape.d.ts +9 -0
- package/dist/reply-shape.js +13 -0
- package/dist/serve.js +11 -4
- package/dist/server/chat.d.ts +2 -1
- package/dist/server/chat.js +18 -6
- package/dist/server/live.js +18 -21
- package/dist/server/render-chat.d.ts +5 -0
- package/dist/server/render-chat.js +4 -2
- package/dist/server/render-settings.d.ts +8 -0
- package/dist/server/render-settings.js +13 -2
- package/dist/server/route-table.js +2 -0
- package/dist/server/settings.js +14 -1
- package/dist/slack-api.js +11 -0
- package/dist/slack-chat.d.ts +11 -1
- package/dist/slack-chat.js +50 -5
- package/dist/slack-settings.js +8 -1
- package/dist/slack.js +10 -3
- package/dist/store.d.ts +31 -2
- package/dist/store.js +59 -6
- package/dist/subscription-chat.d.ts +2 -0
- package/dist/subscription-chat.js +3 -1
- package/dist/teams-chat.d.ts +9 -2
- package/dist/teams-chat.js +50 -2
- package/dist/teams.js +5 -2
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +63 -9
- package/dist/telegram-live.d.ts +116 -0
- package/dist/telegram-live.js +224 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +50 -2
- package/dist/toolroll-update.js +4 -1
- package/package.json +1 -1
|
@@ -158,6 +158,8 @@ export type BrowserConversation = {
|
|
|
158
158
|
previous?: {
|
|
159
159
|
messages: BrowserMessage[];
|
|
160
160
|
} | null;
|
|
161
|
+
/** How the last reply failed, from its saved turn (docs/plans/responsive-chat.md §3.4); absent or null otherwise. */
|
|
162
|
+
failure?: string | null;
|
|
161
163
|
};
|
|
162
164
|
/** A page rebuilt as React components (shadcn/ui). The server still renders
|
|
163
165
|
* its HTML as the no-JavaScript fallback; forms post to the same routes. */
|
package/dist/chat-delivery.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type WarmHooks } from "./chat-warmth.js";
|
|
2
|
+
import { type ChatLiveTransport } from "./chat-live.js";
|
|
2
3
|
import { ChatState, type ChatBinding, type ChatContent, type ChatEvent, type ChatIdentity } from "./chat-delivery-state.js";
|
|
3
4
|
import { type Store } from "./store.js";
|
|
4
5
|
import type { SubscriptionLeadRunner } from "./subscription-chat.js";
|
|
@@ -29,8 +30,11 @@ export type ChatDeliveryOptions = {
|
|
|
29
30
|
clock?: () => Date;
|
|
30
31
|
partSize?: number;
|
|
31
32
|
maxProposal?: number;
|
|
32
|
-
/** The app's warm
|
|
33
|
+
/** The app's warm touch while the lead works on one owner message (typing), where the app and the bot's permissions allow it. */
|
|
33
34
|
warm?: (event: ChatEvent, binding: ChatBinding) => WarmHooks;
|
|
35
|
+
/** Live replies (docs/plans/responsive-chat.md §3.2): the app's transport for the live message that answers the
|
|
36
|
+
* owner's own direct message; absent or null: the reply goes out whole at the end, as before. */
|
|
37
|
+
live?: (event: ChatEvent, binding: ChatBinding) => ChatLiveTransport | null;
|
|
34
38
|
/** Merges a pull request approved in chat (tests script it); otherwise the project's own merge. */
|
|
35
39
|
merge?: (input: {
|
|
36
40
|
runId: number;
|
package/dist/chat-delivery.js
CHANGED
|
@@ -5,6 +5,7 @@ import { channelRepos, resolveChannelLead, proposalPreview, proposalOutcomeText,
|
|
|
5
5
|
import { LEAD_MESSAGE_MAX_CHARS, leadFailureText, runLeadTurn } from "./lead.js";
|
|
6
6
|
import { shapeReplyParts } from "./reply-shape.js";
|
|
7
7
|
import { warmTurn } from "./chat-warmth.js";
|
|
8
|
+
import { openChatLive, planWithLive, settleLive } from "./chat-live.js";
|
|
8
9
|
import { confirmLeadProposal, dismissLeadProposal, } from "./lead-doors.js";
|
|
9
10
|
import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
|
|
10
11
|
import { ChatState, ChatDeliveryError, chatHash, savedChatPart, } from "./chat-delivery-state.js";
|
|
@@ -173,6 +174,10 @@ export async function processChatEvent(options) {
|
|
|
173
174
|
const event = state.next(identity.installation, now);
|
|
174
175
|
if (!event)
|
|
175
176
|
return false;
|
|
177
|
+
// The live message for this pass's turn, if any (chat-live.ts). Closed before any reply is planned.
|
|
178
|
+
let live = null;
|
|
179
|
+
// Every reply planned from here lands on the event's live message when it has one (its first part edits it).
|
|
180
|
+
const plan = (parts, at) => planWithLive(state, event.id, parts, at);
|
|
176
181
|
try {
|
|
177
182
|
if (event.kind === "pair") {
|
|
178
183
|
if (!(await options.member(event.member, event.channel))) {
|
|
@@ -217,12 +222,12 @@ export async function processChatEvent(options) {
|
|
|
217
222
|
}
|
|
218
223
|
const body = readChatMessageBody(event.payload);
|
|
219
224
|
if (!body.ok) {
|
|
220
|
-
|
|
225
|
+
plan([{ text: `That message couldn't be read (${body.issues.map(issue => issue.line).join("; ")}). Send it again.` }], nowOf(options));
|
|
221
226
|
return true;
|
|
222
227
|
}
|
|
223
228
|
const input = body.value;
|
|
224
229
|
if (typeof input.unsupported === "string") {
|
|
225
|
-
|
|
230
|
+
plan([{ text: input.unsupported }], nowOf(options));
|
|
226
231
|
return true;
|
|
227
232
|
}
|
|
228
233
|
const text = String(input.text ?? "");
|
|
@@ -238,7 +243,7 @@ export async function processChatEvent(options) {
|
|
|
238
243
|
const prompt = openPromptFor(store, { channel: state.provider, binding: binding.id, chat: binding.channel }, nowOf(options));
|
|
239
244
|
if (prompt !== null && answersPrompt(prompt, event.thread !== "" && event.thread !== event.ts ? event.thread : null)) {
|
|
240
245
|
const answer = applyDecideFeedback(store, decideSeat(state, binding), prompt, text, repos, options.evidenceRoot, nowOf(options));
|
|
241
|
-
|
|
246
|
+
plan([{ text: answer.said, ...(answer.link === null ? {} : { link: answer.link }) }], nowOf(options));
|
|
242
247
|
return true;
|
|
243
248
|
}
|
|
244
249
|
}
|
|
@@ -259,14 +264,14 @@ export async function processChatEvent(options) {
|
|
|
259
264
|
const focusedId = store.chatFocus(surface, binding.id);
|
|
260
265
|
const focusedTitle = focusedId === null ? null : phoneText(store.getTask(focusedId)?.title ?? focusedId, 64);
|
|
261
266
|
if (command.kind === "help")
|
|
262
|
-
|
|
267
|
+
plan([{ text: PHONE_HELP }], now_);
|
|
263
268
|
else if (command.kind === "status")
|
|
264
|
-
|
|
269
|
+
plan([{ text: phoneStatus(store, repos, now_, focusedTitle, options.evidenceRoot) }], now_);
|
|
265
270
|
else if (command.kind === "tasks")
|
|
266
|
-
|
|
271
|
+
plan([{ text: phoneTaskListText(phoneTaskChoices(store, repos, now_, null, 8, options.evidenceRoot), focusedTitle) }], now_);
|
|
267
272
|
else if (command.kind === "lead") {
|
|
268
273
|
store.setChatFocus(surface, binding.id, null, now_);
|
|
269
|
-
|
|
274
|
+
plan([{ text: PHONE_BACK_TO_LEAD }], now_);
|
|
270
275
|
}
|
|
271
276
|
else {
|
|
272
277
|
const pick = resolvePhoneTask(store, repos, now_, command.id);
|
|
@@ -274,13 +279,13 @@ export async function processChatEvent(options) {
|
|
|
274
279
|
store.setChatFocus(surface, binding.id, pick.id, now_);
|
|
275
280
|
const view = phoneTaskView(store, repos, pick.view, now_, options.evidenceRoot);
|
|
276
281
|
// The status names its task (and result): a reply to it is about that task.
|
|
277
|
-
|
|
282
|
+
plan([{ text: phoneFocusText(view.text), ...(view.link === null ? {} : { link: view.link }), task: pick.view, ...(view.run === null ? {} : { run: view.run }) }], now_);
|
|
278
283
|
}
|
|
279
284
|
else if (pick.kind === "many") {
|
|
280
|
-
|
|
285
|
+
plan([{ text: ["Several tasks match. Add a word from the title, or send one of these:", ...pick.choices.map(one => `• ${one.title} — /task ${one.id}`)].join("\n") }], now_);
|
|
281
286
|
}
|
|
282
287
|
else
|
|
283
|
-
|
|
288
|
+
plan([{ text: PHONE_NO_MATCH }], now_);
|
|
284
289
|
}
|
|
285
290
|
return true;
|
|
286
291
|
}
|
|
@@ -298,12 +303,12 @@ export async function processChatEvent(options) {
|
|
|
298
303
|
say: (reply, link) => replies.push({ text: reply, ...(link ? { link } : {}), ...(isRoom ? { channel: event.channel } : {}) }),
|
|
299
304
|
});
|
|
300
305
|
if (consumed) {
|
|
301
|
-
|
|
306
|
+
plan(replies, now);
|
|
302
307
|
return true;
|
|
303
308
|
}
|
|
304
309
|
}
|
|
305
310
|
if (text.length > LEAD_MESSAGE_MAX_CHARS) {
|
|
306
|
-
|
|
311
|
+
plan([
|
|
307
312
|
{
|
|
308
313
|
text: tooLongText(input.originalLength || text.length),
|
|
309
314
|
},
|
|
@@ -311,12 +316,40 @@ export async function processChatEvent(options) {
|
|
|
311
316
|
return true;
|
|
312
317
|
}
|
|
313
318
|
const request = chatHash(`${options.state.provider}:${binding.id}:${event.id}`).slice(0, 32);
|
|
319
|
+
// The live message answers only the owner's own message in their own chat: never the bot's, never a room.
|
|
320
|
+
const ownMessage = event.kind === "message" && event.member !== identity.bot && event.member === binding.member && event.channel === binding.channel;
|
|
321
|
+
const transport = ownMessage ? options.live?.(event, binding) ?? null : null;
|
|
322
|
+
// `ceiling`: the turn's; without one (still waiting) only the fixed "Waiting…" words may go, never the turn's.
|
|
323
|
+
const openLive = (ceiling) => {
|
|
324
|
+
if (transport === null)
|
|
325
|
+
return null;
|
|
326
|
+
live ??= openChatLive({
|
|
327
|
+
state, event, installation: identity.installation, transport, shape: { asked: text, appOrigin: options.origin() },
|
|
328
|
+
fenced: () => options.current() && state.owns(identity.installation, options.owner, nowOf(options)) && state.live(binding),
|
|
329
|
+
// False only on a definite refusal; a failed proof (a "slow down", a lost answer) throws: that view is dropped.
|
|
330
|
+
standing: async () => {
|
|
331
|
+
if (ceiling === null)
|
|
332
|
+
return false;
|
|
333
|
+
try {
|
|
334
|
+
await channelAccess(options, binding, ceiling);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (error instanceof ChatDeliveryError && ["Chat access changed", "Connected projects changed", `${options.label} account access changed`].includes(error.code))
|
|
339
|
+
return false;
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
now: () => nowOf(options),
|
|
344
|
+
});
|
|
345
|
+
return live;
|
|
346
|
+
};
|
|
314
347
|
let receipt = event.session === null
|
|
315
348
|
? null
|
|
316
349
|
: store.leadRequestReceipt(event.session, request);
|
|
317
350
|
if (!receipt) {
|
|
318
351
|
if (new Date(event.created).getTime() + 600_000 < now.getTime()) {
|
|
319
|
-
|
|
352
|
+
plan([
|
|
320
353
|
{
|
|
321
354
|
text: "This message waited too long to start. Send it again when you’re ready.",
|
|
322
355
|
},
|
|
@@ -326,10 +359,11 @@ export async function processChatEvent(options) {
|
|
|
326
359
|
const resolved = resolveChannelLead(store, { approver: binding.approver, approverGeneration: binding.generation }, repos, now);
|
|
327
360
|
if (!resolved.ok && resolved.reason === "busy") {
|
|
328
361
|
state.defer(event.id, "Assistant is answering another message", new Date(now.getTime() + 5000));
|
|
362
|
+
await openLive(null)?.queued();
|
|
329
363
|
return true;
|
|
330
364
|
}
|
|
331
365
|
if (!resolved.ok) {
|
|
332
|
-
|
|
366
|
+
plan([
|
|
333
367
|
{
|
|
334
368
|
text: (resolved.said ?? "This chat is no longer paired.")
|
|
335
369
|
.replaceAll("this phone", options.label)
|
|
@@ -339,7 +373,7 @@ export async function processChatEvent(options) {
|
|
|
339
373
|
return true;
|
|
340
374
|
}
|
|
341
375
|
if (event.session !== null && event.session !== resolved.session.id) {
|
|
342
|
-
|
|
376
|
+
plan([{ text: "This conversation was restarted after you sent that, so it wasn't answered. Send your message again." }], now);
|
|
343
377
|
return true;
|
|
344
378
|
}
|
|
345
379
|
state
|
|
@@ -363,7 +397,7 @@ export async function processChatEvent(options) {
|
|
|
363
397
|
.map((c) => [`${c.task}:${c.run ?? ""}`, c])).values(),
|
|
364
398
|
];
|
|
365
399
|
if (targets.length > 1) {
|
|
366
|
-
|
|
400
|
+
plan([
|
|
367
401
|
{
|
|
368
402
|
text: "This thread is about more than one task. Reply to the message about the one you mean, or send /tasks to pick one.",
|
|
369
403
|
},
|
|
@@ -383,8 +417,8 @@ export async function processChatEvent(options) {
|
|
|
383
417
|
// An unknown surface names no channel rather than a guess.
|
|
384
418
|
const channel = leadChannelOf(options.label);
|
|
385
419
|
// Only ever the owner's own message in their own chat: never the bot's, never a room.
|
|
386
|
-
const warm = warmTurn(options.warm !== undefined &&
|
|
387
|
-
|
|
420
|
+
const warm = warmTurn(options.warm !== undefined && ownMessage ? options.warm(event, binding) : {});
|
|
421
|
+
const turnLive = openLive(resolved.who.ceilingDigest);
|
|
388
422
|
const outcome = await runLeadTurn({
|
|
389
423
|
store,
|
|
390
424
|
who: resolved.who,
|
|
@@ -404,7 +438,7 @@ export async function processChatEvent(options) {
|
|
|
404
438
|
evidenceRoot: options.evidenceRoot,
|
|
405
439
|
mediaDelivery: "documents",
|
|
406
440
|
...(channel === undefined ? {} : { channel }),
|
|
407
|
-
onProgress: warm.onProgress,
|
|
441
|
+
onProgress: (progress) => { warm.onProgress(progress); turnLive?.push(progress); },
|
|
408
442
|
revalidate: async () => {
|
|
409
443
|
try {
|
|
410
444
|
await channelAccess(options, binding, resolved.who.ceilingDigest);
|
|
@@ -417,7 +451,11 @@ export async function processChatEvent(options) {
|
|
|
417
451
|
return { ok: false, reason };
|
|
418
452
|
}
|
|
419
453
|
},
|
|
420
|
-
}).finally(
|
|
454
|
+
}).finally(async () => {
|
|
455
|
+
warm.stop();
|
|
456
|
+
// Live edits end, and the one in flight settles, before anything of the reply is planned.
|
|
457
|
+
await turnLive?.close();
|
|
458
|
+
});
|
|
421
459
|
if (outcome.ok && !outcome.replayed && context?.task)
|
|
422
460
|
mirrorToTaskChat(store, resolved.who, context.task, options.label, text, outcome.reply, nowOf(options));
|
|
423
461
|
if (!state.owns(identity.installation, options.owner, nowOf(options)) ||
|
|
@@ -428,11 +466,12 @@ export async function processChatEvent(options) {
|
|
|
428
466
|
(outcome.refused === "concurrent" || outcome.refused === "backlog")) {
|
|
429
467
|
// The lead is answering, or messages sent earlier wait in the person's lead queue: this one waits its turn.
|
|
430
468
|
state.defer(event.id, outcome.refused === "backlog" ? "Waiting behind earlier messages" : "Assistant is answering another message", new Date(nowOf(options).getTime() + 5000));
|
|
469
|
+
await turnLive?.queued();
|
|
431
470
|
return true;
|
|
432
471
|
}
|
|
433
472
|
receipt = store.leadRequestReceipt(resolved.session.id, request);
|
|
434
473
|
if (!receipt) {
|
|
435
|
-
|
|
474
|
+
plan([
|
|
436
475
|
{
|
|
437
476
|
text: !outcome.ok
|
|
438
477
|
? phoneText(outcome.message, 1000)
|
|
@@ -442,6 +481,14 @@ export async function processChatEvent(options) {
|
|
|
442
481
|
return true;
|
|
443
482
|
}
|
|
444
483
|
}
|
|
484
|
+
// A live message whose create never confirmed (this pass, or before a restart) is found first (Discord, by its
|
|
485
|
+
// nonce) or marked uncertain, so the reply edits the right message or none.
|
|
486
|
+
if (!(await settleLive(state, event.id, transport, () => nowOf(options)))) {
|
|
487
|
+
// Whether the live message was posted is still unknown (its lookup failed): the reply waits, so it edits that
|
|
488
|
+
// message rather than leaving it behind.
|
|
489
|
+
state.defer(event.id, "Reply is waiting to send", new Date(Math.max(nowOf(options).getTime() + 5000, Date.parse(state.retryAt(identity.installation)) || 0)));
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
445
492
|
store.sweepStaleLeadTurns(nowOf(options));
|
|
446
493
|
const turn = store.getLeadTurn(receipt.turn);
|
|
447
494
|
if (turn?.state === "running" || turn?.state === "queued") {
|
|
@@ -449,9 +496,9 @@ export async function processChatEvent(options) {
|
|
|
449
496
|
return true;
|
|
450
497
|
}
|
|
451
498
|
if (turn?.state !== "answered") {
|
|
452
|
-
|
|
499
|
+
plan([
|
|
453
500
|
{
|
|
454
|
-
text: leadFailureText(turn?.failureReason ?? null, turn != null && store.leadTurnCostUnconfirmed(turn.id)),
|
|
501
|
+
text: leadFailureText(turn?.failureReason ?? null, turn != null && store.leadTurnCostUnconfirmed(turn.id), turn?.failureDetail ?? null),
|
|
455
502
|
},
|
|
456
503
|
], nowOf(options));
|
|
457
504
|
return true;
|
|
@@ -490,15 +537,17 @@ export async function processChatEvent(options) {
|
|
|
490
537
|
// Cards first, so what a card leads to sits right under it. A card is an ordinary part: it confirms nothing.
|
|
491
538
|
for (const proposal of cardsFirst(store.listLeadProposals(turn.thread, ["pending"]).filter((p) => p.turn === turn.id)))
|
|
492
539
|
parts.push(isCardProposal(proposal) ? cardPart(proposal) : { text: "", proposal: proposal.id });
|
|
493
|
-
|
|
540
|
+
plan(parts, nowOf(options));
|
|
494
541
|
return true;
|
|
495
542
|
}
|
|
496
543
|
catch (error) {
|
|
544
|
+
// Nothing is planned while a live edit could still land.
|
|
545
|
+
await live?.close();
|
|
497
546
|
const problem = error instanceof ChatDeliveryError
|
|
498
547
|
? error
|
|
499
548
|
: new ChatDeliveryError(`${options.label} is waiting to retry`);
|
|
500
549
|
if (problem.permanent) {
|
|
501
|
-
|
|
550
|
+
plan([{ text: problem.message }], nowOf(options));
|
|
502
551
|
state.prepare("UPDATE chat_event SET next_at=NULL,problem=? WHERE provider=:provider AND id=?").run(problem.message, event.id);
|
|
503
552
|
return true;
|
|
504
553
|
}
|
package/dist/chat-follow.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/** A pause that ends early, and quietly, when `signal` aborts. */
|
|
2
2
|
export declare const abortablePause: (signal: AbortSignal) => (ms: number) => Promise<void>;
|
|
3
|
+
/** The most parts one pass delivers (docs/plans/responsive-chat.md §3.5): a reply's parts go together, bounded. */
|
|
4
|
+
export declare const DELIVER_PER_PASS = 5;
|
|
3
5
|
/**
|
|
4
|
-
* Works the queue while this connection stays the current, leased one: each pass
|
|
5
|
-
*
|
|
6
|
+
* Works the queue while this connection stays the current, leased one: each pass first delivers parts already due,
|
|
7
|
+
* then, only when none remain, takes in saved events and plans room messages, delivering up to DELIVER_PER_PASS parts
|
|
8
|
+
* in all, and plans notifications (when they are on), checking it is still current between steps.
|
|
6
9
|
* A pass waits while the connection isn't ready (offline, or the provider asked to retry later).
|
|
7
10
|
*/
|
|
8
11
|
export declare function workChatQueue(steps: {
|
|
@@ -12,6 +15,6 @@ export declare function workChatQueue(steps: {
|
|
|
12
15
|
notifications: () => boolean;
|
|
13
16
|
process: () => Promise<unknown>;
|
|
14
17
|
rooms: () => Promise<unknown>;
|
|
15
|
-
deliver: () => Promise<
|
|
18
|
+
deliver: () => Promise<boolean>;
|
|
16
19
|
notify: () => Promise<unknown>;
|
|
17
20
|
}): Promise<void>;
|
package/dist/chat-follow.js
CHANGED
|
@@ -9,9 +9,12 @@ export const abortablePause = (signal) => async (ms) => {
|
|
|
9
9
|
/* shutdown */
|
|
10
10
|
}
|
|
11
11
|
};
|
|
12
|
+
/** The most parts one pass delivers (docs/plans/responsive-chat.md §3.5): a reply's parts go together, bounded. */
|
|
13
|
+
export const DELIVER_PER_PASS = 5;
|
|
12
14
|
/**
|
|
13
|
-
* Works the queue while this connection stays the current, leased one: each pass
|
|
14
|
-
*
|
|
15
|
+
* Works the queue while this connection stays the current, leased one: each pass first delivers parts already due,
|
|
16
|
+
* then, only when none remain, takes in saved events and plans room messages, delivering up to DELIVER_PER_PASS parts
|
|
17
|
+
* in all, and plans notifications (when they are on), checking it is still current between steps.
|
|
15
18
|
* A pass waits while the connection isn't ready (offline, or the provider asked to retry later).
|
|
16
19
|
*/
|
|
17
20
|
export async function workChatQueue(steps) {
|
|
@@ -20,11 +23,24 @@ export async function workChatQueue(steps) {
|
|
|
20
23
|
await steps.pause(1000);
|
|
21
24
|
continue;
|
|
22
25
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// Up to five parts a pass (one event's whole reply, usually), stopping at the first rate signal (`ready`). True
|
|
27
|
+
// when nothing more was due.
|
|
28
|
+
let parts = 0;
|
|
29
|
+
const drain = async () => {
|
|
30
|
+
while (parts < DELIVER_PER_PASS && steps.same() && steps.ready()) {
|
|
31
|
+
if (!(await steps.deliver()))
|
|
32
|
+
return true;
|
|
33
|
+
parts += 1;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
};
|
|
37
|
+
// Parts already due go first (§3.5): a new event's turn starts only once nothing earlier waits to be delivered.
|
|
38
|
+
if (await drain() && steps.same()) {
|
|
39
|
+
await steps.process();
|
|
40
|
+
if (steps.same())
|
|
41
|
+
await steps.rooms();
|
|
42
|
+
await drain();
|
|
43
|
+
}
|
|
28
44
|
if (steps.same() && steps.notifications())
|
|
29
45
|
await steps.notify();
|
|
30
46
|
await steps.pause(1000);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v157: live replies in Slack, Discord and Teams (docs/plans/responsive-chat.md §3.2). New tables only, so a saved row
|
|
3
|
+
* reads as it did. Kept beside nothing else so the update road can read the list without loading the store.
|
|
4
|
+
*
|
|
5
|
+
* chat_live: one "Working on it…" message per received event, so the final answer can replace it. Separate from
|
|
6
|
+
* chat_part: planning the reply still adds its own parts (the first one edits this message), and the event stays
|
|
7
|
+
* queued until then. state: creating (asked the app to post it, no answer yet) → placed (its id is known), or
|
|
8
|
+
* uncertain (a crash or a lost answer left it unknown whether it was posted: never posted again), then finalized
|
|
9
|
+
* (the reply was planned onto it) or abandoned (live edits stopped: access changed or the app refused).
|
|
10
|
+
*
|
|
11
|
+
* chat_ack: the 👀 on a person's message, put on when it arrives and taken off once the answer (or the failure
|
|
12
|
+
* notice) is delivered. state: sent (on, to take off later), done (taken off, or nothing to take off: Teams typing),
|
|
13
|
+
* skipped (not put on: the app refused, or it waited too long).
|
|
14
|
+
*/
|
|
15
|
+
export declare const V157_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_live (\n provider TEXT NOT NULL CHECK (provider IN ('slack','discord','teams')), event TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('creating','uncertain','placed','finalized','abandoned')),\n message TEXT, generation INTEGER NOT NULL DEFAULT 0, problem TEXT, created TEXT NOT NULL, updated TEXT NOT NULL,\n PRIMARY KEY (provider, event),\n FOREIGN KEY (provider, event) REFERENCES chat_event(provider, id)\n);\nCREATE TABLE IF NOT EXISTS chat_ack (\n provider TEXT NOT NULL CHECK (provider IN ('slack','discord','teams')), event TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('sent','done','skipped')), created TEXT NOT NULL, updated TEXT NOT NULL,\n PRIMARY KEY (provider, event),\n FOREIGN KEY (provider, event) REFERENCES chat_event(provider, id)\n);\nCREATE INDEX IF NOT EXISTS chat_ack_sent ON chat_ack(provider, state);\n";
|
|
16
|
+
export declare const V157_TABLES: readonly ["chat_live", "chat_ack"];
|
|
17
|
+
export declare const V157_STEPS: readonly ["chat-live-tables"];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v157: live replies in Slack, Discord and Teams (docs/plans/responsive-chat.md §3.2). New tables only, so a saved row
|
|
3
|
+
* reads as it did. Kept beside nothing else so the update road can read the list without loading the store.
|
|
4
|
+
*
|
|
5
|
+
* chat_live: one "Working on it…" message per received event, so the final answer can replace it. Separate from
|
|
6
|
+
* chat_part: planning the reply still adds its own parts (the first one edits this message), and the event stays
|
|
7
|
+
* queued until then. state: creating (asked the app to post it, no answer yet) → placed (its id is known), or
|
|
8
|
+
* uncertain (a crash or a lost answer left it unknown whether it was posted: never posted again), then finalized
|
|
9
|
+
* (the reply was planned onto it) or abandoned (live edits stopped: access changed or the app refused).
|
|
10
|
+
*
|
|
11
|
+
* chat_ack: the 👀 on a person's message, put on when it arrives and taken off once the answer (or the failure
|
|
12
|
+
* notice) is delivered. state: sent (on, to take off later), done (taken off, or nothing to take off: Teams typing),
|
|
13
|
+
* skipped (not put on: the app refused, or it waited too long).
|
|
14
|
+
*/
|
|
15
|
+
export const V157_SCHEMA = `
|
|
16
|
+
CREATE TABLE IF NOT EXISTS chat_live (
|
|
17
|
+
provider TEXT NOT NULL CHECK (provider IN ('slack','discord','teams')), event TEXT NOT NULL,
|
|
18
|
+
state TEXT NOT NULL CHECK (state IN ('creating','uncertain','placed','finalized','abandoned')),
|
|
19
|
+
message TEXT, generation INTEGER NOT NULL DEFAULT 0, problem TEXT, created TEXT NOT NULL, updated TEXT NOT NULL,
|
|
20
|
+
PRIMARY KEY (provider, event),
|
|
21
|
+
FOREIGN KEY (provider, event) REFERENCES chat_event(provider, id)
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS chat_ack (
|
|
24
|
+
provider TEXT NOT NULL CHECK (provider IN ('slack','discord','teams')), event TEXT NOT NULL,
|
|
25
|
+
state TEXT NOT NULL CHECK (state IN ('sent','done','skipped')), created TEXT NOT NULL, updated TEXT NOT NULL,
|
|
26
|
+
PRIMARY KEY (provider, event),
|
|
27
|
+
FOREIGN KEY (provider, event) REFERENCES chat_event(provider, id)
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS chat_ack_sent ON chat_ack(provider, state);
|
|
30
|
+
`;
|
|
31
|
+
export const V157_TABLES = Object.freeze(["chat_live", "chat_ack"]);
|
|
32
|
+
export const V157_STEPS = Object.freeze(["chat-live-tables"]);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live replies in Slack, Discord and Teams (docs/plans/responsive-chat.md §3.2, §3.5, slice 3): the 👀 on a person's
|
|
3
|
+
* message when it arrives, and one "Working on it…" message per turn that shows the lead's status and its reply as it
|
|
4
|
+
* is written, then becomes the answer.
|
|
5
|
+
*
|
|
6
|
+
* - The 👀 is put on beside the serial chat queue (a turn there can take a minute), from the saved event, for the
|
|
7
|
+
* owner's own direct message only, and taken off once the answer or the failure notice is delivered. It carries no
|
|
8
|
+
* project data: the lease and a live binding are its fence.
|
|
9
|
+
* - The live message is its own `chat_live` row (v157), never a `chat_part`: planning the reply still adds its own
|
|
10
|
+
* parts, and the first of them edits this message, so the event stays queued until then. Creation is
|
|
11
|
+
* creating → placed. On Discord a nonce makes a repeated create within its window return the same message, and
|
|
12
|
+
* a lookup that fails decides nothing; on Slack and Teams an answer that never came leaves the row uncertain, as
|
|
13
|
+
* does an older Discord create not found among the recent messages. An uncertain message is never posted again (the
|
|
14
|
+
* reply then posts fresh, and the event's problem says a "Working on it…" may be left above it). That is the
|
|
15
|
+
* promise: no duplicate, except after an uncertain create.
|
|
16
|
+
* - Edits go through one serialized sender per message (live-reply.ts): one request in flight, the newest view
|
|
17
|
+
* waiting, closed (and the request in flight settled) before the reply is planned. Every edit re-proves the chat's
|
|
18
|
+
* standing (`standing`, the same proof final delivery uses), then the lease after it. Once that fails, nothing more is
|
|
19
|
+
* edited, cleanup included: the last text already ends "(still writing — not final)".
|
|
20
|
+
* - Cosmetic sends share the app's cooldown with durable delivery: none goes while it holds, and a "slow down" on one
|
|
21
|
+
* sets it for everything. Final parts come first (they never wait on these), then the 👀, then live edits, which
|
|
22
|
+
* start each app's spacing conservatively and widen it on every "slow down".
|
|
23
|
+
*
|
|
24
|
+
* No live payload carries a button, a card or an ask: those exist only on the planned reply.
|
|
25
|
+
*/
|
|
26
|
+
import { type LiveView } from "./live-reply.js";
|
|
27
|
+
import type { LeadProgress } from "./lead-progress.js";
|
|
28
|
+
import { type ShapeOptions } from "./reply-shape.js";
|
|
29
|
+
import type { ChatContent, ChatEvent, ChatState } from "./chat-delivery-state.js";
|
|
30
|
+
export type LivePlatform = "slack" | "discord" | "teams";
|
|
31
|
+
/** The live message's fixed words. */
|
|
32
|
+
export declare const LIVE_WORDS: {
|
|
33
|
+
readonly working: "Working on it…";
|
|
34
|
+
readonly notFinal: "(still writing — not final)";
|
|
35
|
+
readonly continues: "(continues…)";
|
|
36
|
+
readonly maybeLeft: "A “Working on it…” message may be left above this reply.";
|
|
37
|
+
};
|
|
38
|
+
/** Each app's starting least time between two live edits (§3.5); a "slow down" doubles it, up to the most. */
|
|
39
|
+
export declare const LIVE_SPACING_MS: Readonly<Record<LivePlatform, number>>;
|
|
40
|
+
/** The chat_meta key that says reactions were refused, and when. */
|
|
41
|
+
export declare const ACK_REFUSED_META = "reactions-refused";
|
|
42
|
+
/** What one app does for a live message. Text arrives already rendered in the app's format (`liveText`). */
|
|
43
|
+
export type ChatLiveTransport = {
|
|
44
|
+
platform: LivePlatform;
|
|
45
|
+
/** The longest rendered text one live message carries (the reply's first window plus the marker). */
|
|
46
|
+
limit: number;
|
|
47
|
+
/** Starting spacing; tests pass 0. */
|
|
48
|
+
spacingMs?: number;
|
|
49
|
+
/** Post the live message; resolves its id. Discord sends its nonce, so a repeat returns the same message. */
|
|
50
|
+
create(text: string): Promise<string>;
|
|
51
|
+
/** Discord only: the message an unconfirmed create left, found by its nonce, or null (not among the recent ones).
|
|
52
|
+
* Throws when the lookup itself failed: that is not absence. */
|
|
53
|
+
recover?(): Promise<string | null>;
|
|
54
|
+
/** With `recover`: how long after a create a repeat under the same nonce is known to return the same message. */
|
|
55
|
+
nonceMs?: number;
|
|
56
|
+
/** Replace the live message's whole text (every field, as the final edit does). */
|
|
57
|
+
edit(message: string, text: string): Promise<void>;
|
|
58
|
+
/** The wait an error asks for when it means "slow down", else null. */
|
|
59
|
+
rateLimited(error: unknown): number | null;
|
|
60
|
+
/** True when the app may not have done it (the answer was lost). */
|
|
61
|
+
uncertain(error: unknown): boolean;
|
|
62
|
+
};
|
|
63
|
+
/** A worker's live-reply switch: on, or on with a least spacing between edits (tests pass 0). */
|
|
64
|
+
export type LiveReplies = boolean | {
|
|
65
|
+
spacingMs: number;
|
|
66
|
+
};
|
|
67
|
+
/** The transport's spacing for that switch (undefined: the app's own start). */
|
|
68
|
+
export declare const liveSpacingOf: (on: LiveReplies | undefined) => number | undefined;
|
|
69
|
+
/** Forget every connection's live spacing (tests). */
|
|
70
|
+
export declare function resetLiveGates(): void;
|
|
71
|
+
/**
|
|
72
|
+
* A view as one live message's text, rendered for the app and measured after rendering: the shaped reply's first
|
|
73
|
+
* window that fits (cut where the final split would cut, never inside a link, code or bold), "(continues…)" when
|
|
74
|
+
* more follows, then the status and the "not final" marker. Null: nothing to show (the turn ended).
|
|
75
|
+
*/
|
|
76
|
+
export declare function liveText(view: LiveView, platform: LivePlatform, limit: number, shape?: ShapeOptions): string | null;
|
|
77
|
+
export type LiveRow = {
|
|
78
|
+
state: "creating" | "uncertain" | "placed" | "finalized" | "abandoned";
|
|
79
|
+
message: string | null;
|
|
80
|
+
generation: number;
|
|
81
|
+
problem: string | null;
|
|
82
|
+
created: string;
|
|
83
|
+
};
|
|
84
|
+
export declare function liveRow(state: ChatState, event: string): LiveRow | null;
|
|
85
|
+
/** Is this message id the live message of that event (the one its reply's first part edits)? */
|
|
86
|
+
export declare function isLiveMessage(state: ChatState, event: string, message: string | undefined): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Plan an event's reply (ChatState.plan) onto its live message: when one was placed, the first part edits it, and the
|
|
89
|
+
* row is finalized in the same transaction. An uncertain create (Slack, Teams) leaves the reply to post fresh, and
|
|
90
|
+
* the event's problem says a "Working on it…" may be left above it.
|
|
91
|
+
*/
|
|
92
|
+
export declare function planWithLive(state: ChatState, event: string, parts: ChatContent[], now: Date): void;
|
|
93
|
+
export type ChatLiveTurn = {
|
|
94
|
+
/** Feed one engine event (synchronous, never throws). The first one posts "Working on it…". */
|
|
95
|
+
push(event: LeadProgress): void;
|
|
96
|
+
/** The message waits behind earlier ones: post "Waiting for your earlier messages…" unless a live message is up. */
|
|
97
|
+
queued(): Promise<void>;
|
|
98
|
+
/** Stop editing: drop the waiting view and settle the request in flight. Resolves before the reply may be planned. */
|
|
99
|
+
close(): Promise<void>;
|
|
100
|
+
};
|
|
101
|
+
export declare function openChatLive(input: {
|
|
102
|
+
state: ChatState;
|
|
103
|
+
event: ChatEvent;
|
|
104
|
+
installation: string;
|
|
105
|
+
transport: ChatLiveTransport;
|
|
106
|
+
/** The owner's message and this app's origin, as the final reply is shaped. */
|
|
107
|
+
shape: ShapeOptions;
|
|
108
|
+
/** The lease, the connection and the binding, read locally (no network): the fence for words that carry no data. */
|
|
109
|
+
fenced: () => boolean;
|
|
110
|
+
/** The chat's full standing (channelAccess under the turn's ceiling) before an edit that carries the turn's words.
|
|
111
|
+
* False: refused (nothing more is edited). Throws: no answer now (that view is dropped). */
|
|
112
|
+
standing: () => Promise<boolean>;
|
|
113
|
+
now: () => Date;
|
|
114
|
+
}): ChatLiveTurn;
|
|
115
|
+
/** What one app does for the 👀. Teams gives bots no reactions: its ack is "typing…", with nothing to take off. */
|
|
116
|
+
export type ChatAckHooks = {
|
|
117
|
+
react?: (event: ChatEvent) => Promise<unknown>;
|
|
118
|
+
unreact?: (event: ChatEvent) => Promise<unknown>;
|
|
119
|
+
typing?: (event: ChatEvent) => Promise<unknown>;
|
|
120
|
+
/** This install may not react (a missing permission or scope): stop asking for a while, quietly. */
|
|
121
|
+
refused: (error: unknown) => boolean;
|
|
122
|
+
/** Already so: the reaction is already on (when adding) or already gone, with its message (when taking it off). */
|
|
123
|
+
settled: (error: unknown) => boolean;
|
|
124
|
+
rateLimited: (error: unknown) => number | null;
|
|
125
|
+
};
|
|
126
|
+
/** Did this install refuse reactions within the last minute? Then no 👀 is asked for (no error after error). */
|
|
127
|
+
export declare function reactionsRefused(state: ChatState, installation: string, now: Date): boolean;
|
|
128
|
+
/**
|
|
129
|
+
* One step of the 👀 work for an installation, beside the queue: take one 👀 off a message whose reply (or failure
|
|
130
|
+
* notice) is delivered, else put one on the next owner's message that arrived. True: it did something (call again).
|
|
131
|
+
*/
|
|
132
|
+
export declare function ackPass(input: {
|
|
133
|
+
state: ChatState;
|
|
134
|
+
installation: string;
|
|
135
|
+
owner: string;
|
|
136
|
+
current: () => boolean;
|
|
137
|
+
hooks: ChatAckHooks;
|
|
138
|
+
now: () => Date;
|
|
139
|
+
}): Promise<boolean>;
|
|
140
|
+
/**
|
|
141
|
+
* Before an event's reply is planned: a create that never confirmed (this pass, or before a restart) is found by its
|
|
142
|
+
* nonce on Discord, else marked uncertain, so planning edits the right message or none. False: the lookup failed, so
|
|
143
|
+
* it is still undecided and planning waits (a "slow down" is shared).
|
|
144
|
+
*/
|
|
145
|
+
export declare function settleLive(state: ChatState, event: string, transport: ChatLiveTransport | null, now: () => Date): Promise<boolean>;
|
|
146
|
+
/** A cosmetic send (typing…) under the app's shared cooldown: skipped while it holds, and a "slow down" sets it. */
|
|
147
|
+
export declare function underCooldown(state: ChatState, installation: string, rateLimited: (error: unknown) => number | null, now: () => Date, call: () => Promise<unknown>): () => Promise<unknown>;
|
|
148
|
+
/** Forget who was acknowledged when (tests). */
|
|
149
|
+
export declare function resetAckSpacing(): void;
|
|
150
|
+
/**
|
|
151
|
+
* The 👀 loop a worker runs beside its queue while its connection is the current one: a pass every quarter second,
|
|
152
|
+
* and at once when `wake` is called (a message just arrived). Never throws.
|
|
153
|
+
*/
|
|
154
|
+
export declare function runChatAcks(input: Omit<Parameters<typeof ackPass>[0], "now"> & {
|
|
155
|
+
same: () => boolean;
|
|
156
|
+
signal: AbortSignal;
|
|
157
|
+
now?: () => Date;
|
|
158
|
+
}): {
|
|
159
|
+
wake: () => void;
|
|
160
|
+
done: Promise<void>;
|
|
161
|
+
};
|