toolroll 0.9.95 → 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 +13 -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/card-render.d.ts +18 -1
- package/dist/card-render.js +40 -3
- 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/teardown-git.js +33 -3
- 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/card-render.d.ts
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* made inert for each app, so a value can't become a link, a mention or formatting. Whatever an app can't show comes out
|
|
4
4
|
* as readable text, and every renderer has the same plain version to fall back on.
|
|
5
5
|
*/
|
|
6
|
-
import type { ChatCard } from "./contracts/chat-card.js";
|
|
6
|
+
import type { ChatCard, ChatCardBlock } from "./contracts/chat-card.js";
|
|
7
7
|
import { type Html } from "./html.js";
|
|
8
|
+
type Table = Extract<ChatCardBlock, {
|
|
9
|
+
kind: "table";
|
|
10
|
+
}>;
|
|
8
11
|
/** A Text part is the lead's (a model's) words: always labelled as such, on every app, never as a record. */
|
|
9
12
|
export declare const LEAD_NOTE_WORDS = "The lead's note, not Toolroll's records";
|
|
10
13
|
/** The whole card as plain text: the version every app can show. */
|
|
@@ -24,3 +27,17 @@ export declare function telegramCardMessage(card: ChatCard): {
|
|
|
24
27
|
};
|
|
25
28
|
/** The card as the web chat's own markup; every value is escaped by `html`. */
|
|
26
29
|
export declare function webCardHtml(card: ChatCard): Html;
|
|
30
|
+
/**
|
|
31
|
+
* A table's rows for a phone: each row's first value in full as its name, then its other values on one line ("queued ·
|
|
32
|
+
* standing-orders · 3h ago"; a number keeps its column's label: "Failed attempts 2"). A column with the same value in
|
|
33
|
+
* every shown row is said once above the rows instead ("All: queued · Failed attempts 0").
|
|
34
|
+
*/
|
|
35
|
+
export declare function telegramTableRows(block: Table, keep: number): {
|
|
36
|
+
same: string;
|
|
37
|
+
rows: {
|
|
38
|
+
name: string;
|
|
39
|
+
rest: string;
|
|
40
|
+
}[];
|
|
41
|
+
more: number;
|
|
42
|
+
};
|
|
43
|
+
export {};
|
package/dist/card-render.js
CHANGED
|
@@ -139,6 +139,16 @@ export function discordCardEmbed(card, plain) {
|
|
|
139
139
|
// ---- Telegram ------------------------------------------------------------------------------------------------------
|
|
140
140
|
/** The card as one Telegram message: plain words, tables and charts as `pre` entities, so nothing in a value is parsed. */
|
|
141
141
|
export function telegramCardMessage(card) {
|
|
142
|
+
// A phone can't fit a table's columns side by side, so each row is a short list item. Past Telegram's limit, rows are
|
|
143
|
+
// cut from the end (counted in "and N more") before falling back to the plain version.
|
|
144
|
+
for (let keep = TELEGRAM_ROWS; keep >= 1; keep = Math.floor(keep / 2)) {
|
|
145
|
+
const message = telegramCardWith(card, block => Math.min(block.rows.length, keep));
|
|
146
|
+
if (message.text.length <= 4000)
|
|
147
|
+
return message;
|
|
148
|
+
}
|
|
149
|
+
return { text: cardPlainText(card).slice(0, 4000), entities: [] };
|
|
150
|
+
}
|
|
151
|
+
function telegramCardWith(card, rowsFor) {
|
|
142
152
|
const entities = [];
|
|
143
153
|
let text = "";
|
|
144
154
|
const add = (words, type) => {
|
|
@@ -168,7 +178,16 @@ export function telegramCardMessage(card) {
|
|
|
168
178
|
add(one, "bold");
|
|
169
179
|
if (block.origin === "report")
|
|
170
180
|
add(reportWords);
|
|
171
|
-
|
|
181
|
+
const listed = telegramTableRows(block, rowsFor(block));
|
|
182
|
+
if (listed.same !== "")
|
|
183
|
+
add(listed.same);
|
|
184
|
+
for (const row of listed.rows) {
|
|
185
|
+
add(row.name, "bold");
|
|
186
|
+
if (row.rest !== "")
|
|
187
|
+
text += `\n${row.rest}`;
|
|
188
|
+
}
|
|
189
|
+
if (listed.more > 0)
|
|
190
|
+
add(moreWords(listed.more));
|
|
172
191
|
}
|
|
173
192
|
else if (block.kind === "chart") {
|
|
174
193
|
for (const one of headOf(card, block.heading))
|
|
@@ -180,8 +199,7 @@ export function telegramCardMessage(card) {
|
|
|
180
199
|
add(one);
|
|
181
200
|
if (card.actions.length > 0)
|
|
182
201
|
add(`Confirm below: ${card.actions.map(one => one.label).join(", ")}`);
|
|
183
|
-
|
|
184
|
-
return text.length <= 4000 ? { text, entities } : { text: cardPlainText(card).slice(0, 4000), entities: [] };
|
|
202
|
+
return { text, entities };
|
|
185
203
|
}
|
|
186
204
|
// ---- web chat ------------------------------------------------------------------------------------------------------
|
|
187
205
|
const CHART_COLORS = 6;
|
|
@@ -221,3 +239,22 @@ export function webCardHtml(card) {
|
|
|
221
239
|
});
|
|
222
240
|
return html `<div class="tc-card">${joinHtml(blocks)}${joinHtml(footWords(card).map(one => html `<p class="tc-note">${one}</p>`))}</div>`;
|
|
223
241
|
}
|
|
242
|
+
/** How many rows of a table a Telegram card shows before "and N more". */
|
|
243
|
+
const TELEGRAM_ROWS = 20;
|
|
244
|
+
/**
|
|
245
|
+
* A table's rows for a phone: each row's first value in full as its name, then its other values on one line ("queued ·
|
|
246
|
+
* standing-orders · 3h ago"; a number keeps its column's label: "Failed attempts 2"). A column with the same value in
|
|
247
|
+
* every shown row is said once above the rows instead ("All: queued · Failed attempts 0").
|
|
248
|
+
*/
|
|
249
|
+
export function telegramTableRows(block, keep) {
|
|
250
|
+
const shown = block.rows.slice(0, Math.max(0, keep));
|
|
251
|
+
const more = block.more + block.rows.length - shown.length;
|
|
252
|
+
const cell = (at, value) => block.columns[at].numeric ? `${block.columns[at].label} ${value}` : value;
|
|
253
|
+
const constant = block.columns.map((_, at) => at > 0 && shown.length > 1 && shown.every(row => row[at] === shown[0][at]));
|
|
254
|
+
const same = constant.flatMap((yes, at) => yes && shown[0][at] !== "" ? [cell(at, shown[0][at])] : []);
|
|
255
|
+
const rows = shown.map(row => ({
|
|
256
|
+
name: row[0] === "" ? "(untitled)" : row[0],
|
|
257
|
+
rest: row.flatMap((value, at) => at === 0 || constant[at] || value === "" ? [] : [cell(at, value)]).join(" · "),
|
|
258
|
+
}));
|
|
259
|
+
return { same: same.length === 0 ? "" : `All: ${same.join(" · ")}`, rows, more };
|
|
260
|
+
}
|
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"]);
|