toolroll 0.9.9 → 0.9.11
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 +15 -0
- package/dist/assignment.js +3 -1
- package/dist/chat-channel.d.ts +9 -7
- package/dist/chat-channel.js +23 -17
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +49 -8
- package/dist/chat-quiet.d.ts +10 -4
- package/dist/chat-quiet.js +87 -22
- package/dist/chat-voice.d.ts +68 -0
- package/dist/chat-voice.js +175 -0
- package/dist/discord-chat.js +1 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +118 -37
- package/dist/project-delete.js +1 -0
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +30 -0
- package/dist/serve.js +9 -2
- package/dist/slack-chat.js +2 -0
- package/dist/store.d.ts +39 -1
- package/dist/store.js +112 -2
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-mate.d.ts +7 -0
- package/dist/telegram-mate.js +50 -21
- package/dist/telegram-progress.d.ts +6 -1
- package/dist/telegram-progress.js +11 -4
- package/dist/telegram-settings.d.ts +3 -0
- package/dist/telegram-settings.js +11 -0
- package/dist/telegram-status.d.ts +2 -1
- package/dist/telegram-status.js +3 -2
- package/dist/telegram.d.ts +15 -1
- package/dist/telegram.js +164 -52
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.11 — 2026-10-02
|
|
4
|
+
|
|
5
|
+
- **The Telegram bot always answers.** Replies and notifications go out from
|
|
6
|
+
the service itself, never from inside a build. If it can't answer, it says
|
|
7
|
+
why in plain words, and a session mix-up starts a fresh conversation instead
|
|
8
|
+
of refusing.
|
|
9
|
+
|
|
10
|
+
## 0.9.10 — 2026-10-02
|
|
11
|
+
|
|
12
|
+
- **Chat reads like a teammate.** Short messages, outcome first, with real
|
|
13
|
+
choices: Merge or Mark complete, and Look first. Updates within two minutes
|
|
14
|
+
arrive as one message. You're never told about your own actions, release
|
|
15
|
+
checks and replaced tasks stay quiet, and a release sends one "is live" line.
|
|
16
|
+
The Telegram bot is named Toolroll.
|
|
17
|
+
|
|
3
18
|
## 0.9.9 — 2026-10-02
|
|
4
19
|
|
|
5
20
|
- **Faster, lighter tests.** The unit suite runs about three times faster
|
package/dist/assignment.js
CHANGED
|
@@ -409,8 +409,10 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
|
|
|
409
409
|
store.recordAction({ at: now.toISOString(), actor, repo: current.repo,
|
|
410
410
|
taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work" });
|
|
411
411
|
const ref = store.lookupRef(current.rootId);
|
|
412
|
+
// Whose act this is, even outside a command that named its person: the operator who marked it.
|
|
413
|
+
const by = currentActor() ?? (actor.startsWith("operator:") ? { account: actor.slice("operator:".length), lead: false } : null);
|
|
412
414
|
if (ref !== null)
|
|
413
|
-
store.noteTaskAct(ref.id, "completed", now);
|
|
415
|
+
store.noteTaskAct(ref.id, "completed", now, undefined, by);
|
|
414
416
|
store.bumpWake();
|
|
415
417
|
}
|
|
416
418
|
const assignment = assignmentOf(store, current.rootId, now, access, root);
|
package/dist/chat-channel.d.ts
CHANGED
|
@@ -12,18 +12,18 @@ export type ResolvedMate = {
|
|
|
12
12
|
};
|
|
13
13
|
} | {
|
|
14
14
|
ok: false;
|
|
15
|
-
reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "
|
|
15
|
+
reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "busy" | "no-session";
|
|
16
16
|
said: string | null;
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* The session and thread a paired phone speaks in: the approver's live
|
|
20
20
|
* session when it is compatible (same generation, same membership
|
|
21
|
-
* credential, same ceiling), else one minted under the very terms
|
|
22
|
-
* console and CLI state for membership chat (no dollar ceiling, this
|
|
23
|
-
* ceiling digest)
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
21
|
+
* credential, same ceiling), else a fresh one minted under the very terms
|
|
22
|
+
* the console and CLI state for membership chat (no dollar ceiling, this
|
|
23
|
+
* ceiling digest). An incompatible session or thread left open on the
|
|
24
|
+
* computer is replaced rather than refused, so a message from the phone
|
|
25
|
+
* always gets an answer — except while a turn is still running there,
|
|
26
|
+
* which is "busy": the message waits its turn instead of ending it.
|
|
27
27
|
*/
|
|
28
28
|
export declare function resolveChannelMate(store: Store, binding: {
|
|
29
29
|
approver: string;
|
|
@@ -34,6 +34,8 @@ export declare function resolveChannelMate(store: Store, binding: {
|
|
|
34
34
|
* chat as well, so the task shows everything asked of it wherever it was
|
|
35
35
|
* asked. Best effort: the phone conversation never depends on it. */
|
|
36
36
|
export declare function mirrorToTaskChat(store: Store, who: VerifiedApprover, taskId: string, surface: string, message: string, reply: string, now: Date): void;
|
|
37
|
+
/** The one short message a person gets when their question could not be answered. */
|
|
38
|
+
export declare function couldNotAnswerText(reason: string): string;
|
|
37
39
|
export declare function tooLongText(length: number): string;
|
|
38
40
|
/** A reply to a message about several tasks is never guessed: it names them and says how to choose. */
|
|
39
41
|
export declare function whichTaskText(titles: readonly string[]): string;
|
package/dist/chat-channel.js
CHANGED
|
@@ -25,12 +25,12 @@ export function channelRepos(store, approver, registry) {
|
|
|
25
25
|
/**
|
|
26
26
|
* The session and thread a paired phone speaks in: the approver's live
|
|
27
27
|
* session when it is compatible (same generation, same membership
|
|
28
|
-
* credential, same ceiling), else one minted under the very terms
|
|
29
|
-
* console and CLI state for membership chat (no dollar ceiling, this
|
|
30
|
-
* ceiling digest)
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* credential, same ceiling), else a fresh one minted under the very terms
|
|
29
|
+
* the console and CLI state for membership chat (no dollar ceiling, this
|
|
30
|
+
* ceiling digest). An incompatible session or thread left open on the
|
|
31
|
+
* computer is replaced rather than refused, so a message from the phone
|
|
32
|
+
* always gets an answer — except while a turn is still running there,
|
|
33
|
+
* which is "busy": the message waits its turn instead of ending it.
|
|
34
34
|
*/
|
|
35
35
|
export function resolveChannelMate(store, binding, repos, now) {
|
|
36
36
|
const config = store.getChatConfig();
|
|
@@ -60,18 +60,17 @@ export function resolveChannelMate(store, binding, repos, now) {
|
|
|
60
60
|
return { ok: false, reason: "unpaired", said: null };
|
|
61
61
|
const who = verified.who;
|
|
62
62
|
const credentialKey = subscriptionCredentialKey(config.provider);
|
|
63
|
-
const mismatch = "Your open chat session on the computer covers different projects or another provider. Finish or end it there, then message here again. Nothing was changed.";
|
|
64
63
|
let session = store.activeMateSession(who.name);
|
|
65
|
-
|
|
64
|
+
const liveThread = store.liveMateThreadFor(who.name);
|
|
65
|
+
const sessionMismatch = session !== null &&
|
|
66
66
|
(session.approverGeneration !== who.generation ||
|
|
67
67
|
session.credentialKey !== credentialKey ||
|
|
68
|
-
session.ceilingDigest !== who.ceilingDigest)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (session === null) {
|
|
68
|
+
session.ceilingDigest !== who.ceilingDigest);
|
|
69
|
+
const threadMismatch = liveThread !== null && liveThread.ceilingDigest !== who.ceilingDigest;
|
|
70
|
+
// Replacing either would end a turn still running on the computer.
|
|
71
|
+
if ((sessionMismatch || threadMismatch) && store.liveMateTurnFor(who.name) !== null)
|
|
72
|
+
return { ok: false, reason: "busy", said: null };
|
|
73
|
+
if (session === null || sessionMismatch) {
|
|
75
74
|
const termsDigest = createHash("sha256")
|
|
76
75
|
.update(`0\n${who.ceilingDigest}`)
|
|
77
76
|
.digest("hex");
|
|
@@ -85,9 +84,12 @@ export function resolveChannelMate(store, binding, repos, now) {
|
|
|
85
84
|
}, now);
|
|
86
85
|
session = store.getMateSession(id);
|
|
87
86
|
if (session === null)
|
|
88
|
-
return { ok: false, reason: "session
|
|
87
|
+
return { ok: false, reason: "no-session", said: couldNotAnswerText("a new chat session couldn't be started") };
|
|
89
88
|
}
|
|
90
|
-
|
|
89
|
+
// openMateThread closes a thread kept under another ceiling.
|
|
90
|
+
const thread = threadMismatch || liveThread === null
|
|
91
|
+
? store.openMateThread(who.name, who.ceilingDigest, now).thread
|
|
92
|
+
: liveThread;
|
|
91
93
|
return {
|
|
92
94
|
ok: true,
|
|
93
95
|
who,
|
|
@@ -113,6 +115,10 @@ export function mirrorToTaskChat(store, who, taskId, surface, message, reply, no
|
|
|
113
115
|
// The task chat is a copy; the phone's own conversation already has it.
|
|
114
116
|
}
|
|
115
117
|
}
|
|
118
|
+
/** The one short message a person gets when their question could not be answered. */
|
|
119
|
+
export function couldNotAnswerText(reason) {
|
|
120
|
+
return `I couldn't answer that just now: ${reason}. Ask again, or open the console.`;
|
|
121
|
+
}
|
|
116
122
|
export function tooLongText(length) {
|
|
117
123
|
return `That message is ${length.toLocaleString("en-US")} characters; chat takes up to ${MATE_MESSAGE_MAX_CHARS.toLocaleString("en-US")}. Nothing was sent to the assistant. Send it in shorter parts, or say which part matters most.`;
|
|
118
124
|
}
|
|
@@ -76,6 +76,11 @@ export type ChatContent = {
|
|
|
76
76
|
label: string;
|
|
77
77
|
path: string;
|
|
78
78
|
};
|
|
79
|
+
/** More link buttons after `link`: [Look first] beside [Merge] or [Mark complete]. */
|
|
80
|
+
also?: Array<{
|
|
81
|
+
label: string;
|
|
82
|
+
path: string;
|
|
83
|
+
}>;
|
|
79
84
|
/** A flow decision's buttons ride this part (v88): minted when it is planned. */
|
|
80
85
|
flow?: {
|
|
81
86
|
card: number;
|
package/dist/chat-delivery.js
CHANGED
|
@@ -9,7 +9,8 @@ import { answerChatFlowPrompt, applyChatFlowTap, flowDecisionParts } from "./cha
|
|
|
9
9
|
import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
|
|
10
10
|
import { triggerConfigOf } from "./flow-triggers.js";
|
|
11
11
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
12
|
-
import { enqueueEveningDigests, isTaskFact, needsPerson, quietCardView
|
|
12
|
+
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
13
|
+
import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
|
|
13
14
|
import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
|
|
14
15
|
import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
|
|
15
16
|
import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
|
|
@@ -201,6 +202,10 @@ export async function processChatEvent(options) {
|
|
|
201
202
|
return true;
|
|
202
203
|
}
|
|
203
204
|
const resolved = resolveChannelMate(store, { approver: binding.approver, approverGeneration: binding.generation }, repos, now);
|
|
205
|
+
if (!resolved.ok && resolved.reason === "busy") {
|
|
206
|
+
state.defer(event.id, "Assistant is answering another message", new Date(now.getTime() + 5000));
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
204
209
|
if (!resolved.ok) {
|
|
205
210
|
state.plan(event.id, [
|
|
206
211
|
{
|
|
@@ -582,7 +587,7 @@ function planQuietCard(options, binding, notification, now) {
|
|
|
582
587
|
if (notification.taskRef === null || notification.taskId === null)
|
|
583
588
|
return;
|
|
584
589
|
const card = store.chatCardFor(`${state.channel}:${binding.id}`, notification.taskRef, store.getTask(notification.taskId)?.createdAt ?? notification.createdAt, now);
|
|
585
|
-
const view = quietCardView(store, card.tasks, now, options.evidenceRoot);
|
|
590
|
+
const view = quietCardView(store, card.tasks, now, options.evidenceRoot, binding.approver);
|
|
586
591
|
if (view === null)
|
|
587
592
|
return;
|
|
588
593
|
const content = { text: view.text, link: view.link, ...(card.tasks.length === 1 ? { task: notification.taskId } : {}) };
|
|
@@ -602,6 +607,37 @@ function planQuietCard(options, binding, notification, now) {
|
|
|
602
607
|
if (part !== undefined)
|
|
603
608
|
store.setChatCardMessage(card.id, String(part.id), shown);
|
|
604
609
|
}
|
|
610
|
+
/** Any update for this person joins their open batch (two minutes from its first), and the batch's one message
|
|
611
|
+
* is planned once, then repainted in place as more updates land. `notification`: an update that is not a finished
|
|
612
|
+
* result (null for one that is). False when nothing in the batch reads as a line, so the update goes out on its own. */
|
|
613
|
+
function planFinished(options, binding, taskRef, run, now, notification = null) {
|
|
614
|
+
const { state, store, identity } = options;
|
|
615
|
+
const batch = store.chatBatchFor(`${state.channel}:${binding.id}`, taskRef, run, now, BATCH_MS, notification);
|
|
616
|
+
const view = finishedView(store, batch, now, options.evidenceRoot, binding.approver);
|
|
617
|
+
if (view === null)
|
|
618
|
+
return false;
|
|
619
|
+
// A single update keeps its task and run, so a reply to it names that work.
|
|
620
|
+
const single = batch.items.length === 1 ? batch.items[0] : null;
|
|
621
|
+
const task = single === null ? null : store.refById(single.taskRef)?.externalId ?? null;
|
|
622
|
+
const content = { text: view.text, link: view.link, ...(view.also === undefined ? {} : { also: view.also }),
|
|
623
|
+
...(task === null ? {} : { task }), ...(single?.run == null ? {} : { run: single.run }) };
|
|
624
|
+
const shown = chatHash(JSON.stringify(content));
|
|
625
|
+
if (batch.message !== null) {
|
|
626
|
+
if (batch.digest === shown)
|
|
627
|
+
return true;
|
|
628
|
+
state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(JSON.stringify(content), now.toISOString(), Number(batch.message));
|
|
629
|
+
store.setChatBatchMessage(batch.id, batch.message, shown);
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
const id = chatHash(`${state.channel}:batch:${binding.id}:${batch.id}`);
|
|
633
|
+
state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
|
|
634
|
+
ts: "", thread: "", payload: "{}", created: now.toISOString() });
|
|
635
|
+
state.plan(id, [content], now);
|
|
636
|
+
const part = state.prepare("SELECT id FROM chat_part WHERE event=?").get(id);
|
|
637
|
+
if (part !== undefined)
|
|
638
|
+
store.setChatBatchMessage(batch.id, String(part.id), shown);
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
605
641
|
/** One progress card per exact result; separate urgent facts retain their own review link. */
|
|
606
642
|
export async function planChatNotifications(options) {
|
|
607
643
|
const state = options.state, { store, identity } = options, bindings = state.bindings(identity.installation).filter(one => state.live(one));
|
|
@@ -645,15 +681,19 @@ export async function planChatNotifications(options) {
|
|
|
645
681
|
if (!needsPerson(notification))
|
|
646
682
|
return;
|
|
647
683
|
if (notification.kind === "run-finished" && run) {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
state.plan(id, [{ text: ping.text, task: notification.taskId, run: run.id, link: ping.link }], now);
|
|
684
|
+
// Finished work goes out only as the batch line; one left out on purpose (a release check, a replaced
|
|
685
|
+
// task, the reader's own completion) stays quiet rather than falling through to another message.
|
|
686
|
+
planFinished(options, binding, notification.taskRef, run.id, now);
|
|
652
687
|
return;
|
|
653
688
|
}
|
|
689
|
+
// Every other update for this person within two minutes joins the same one message.
|
|
690
|
+
else if (notification.kind !== "run-finished" && joinsBatch(notification)) {
|
|
691
|
+
if (planFinished(options, binding, notification.taskRef, run?.id ?? null, now, notification.id))
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
654
694
|
}
|
|
655
695
|
if (!quiet && run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
|
|
656
|
-
const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot);
|
|
696
|
+
const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot, binding.approver);
|
|
657
697
|
const content = {
|
|
658
698
|
text: card.text,
|
|
659
699
|
task: notification.taskId,
|
|
@@ -726,7 +766,8 @@ export async function planChatNotifications(options) {
|
|
|
726
766
|
});
|
|
727
767
|
state.plan(id, [
|
|
728
768
|
{
|
|
729
|
-
|
|
769
|
+
// Never the task's id or a "— revision" suffix (chat-voice.ts).
|
|
770
|
+
text: chatText(phoneText(notification.body === "" ? notification.subject : `${notification.subject}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
|
|
730
771
|
...(notification.taskId ? { task: notification.taskId } : {}),
|
|
731
772
|
...(run ? { run: run.id } : {}),
|
|
732
773
|
...(notification.link
|
package/dist/chat-quiet.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type ProgressEntity } from "./telegram-progress.js";
|
|
2
2
|
import { type PhoneTaskLink } from "./telegram-status.js";
|
|
3
|
-
import { type
|
|
3
|
+
import { type ChatBatch, type Notification, type Store } from "./store.js";
|
|
4
|
+
/** `also`: more buttons after `link`, such as [Look first] beside [Merge]. */
|
|
4
5
|
export type QuietView = {
|
|
5
6
|
text: string;
|
|
6
7
|
entities: ProgressEntity[];
|
|
7
8
|
link: PhoneTaskLink;
|
|
9
|
+
also?: PhoneTaskLink[];
|
|
8
10
|
};
|
|
9
11
|
export declare function needsPerson(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient">): boolean;
|
|
10
12
|
/** A fact about one placed task: in quiet mode it lands on that task's card. */
|
|
@@ -14,9 +16,13 @@ export declare function isTaskFact(row: Pick<Notification, "taskRef" | "taskId"
|
|
|
14
16
|
project: string;
|
|
15
17
|
};
|
|
16
18
|
/** The card's words now: one task's progress card, or one line per task when several were filed together. */
|
|
17
|
-
export declare function quietCardView(store: Store, taskRefs: readonly number[], now: Date, root?: string): QuietView | null;
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
export declare function quietCardView(store: Store, taskRefs: readonly number[], now: Date, root?: string, viewer?: string): QuietView | null;
|
|
20
|
+
/** Whether this update for a person joins their two-minute batch: anything about one task that needs them,
|
|
21
|
+
* except a fact that must stand alone. */
|
|
22
|
+
export declare function joinsBatch(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient" | "taskRef">): boolean;
|
|
23
|
+
/** One person's update message: one task says what happened with its next step; several updates within two
|
|
24
|
+
* minutes become "4 tasks finished: …" (or "3 updates: …") with one Open button. Null when none can be read. */
|
|
25
|
+
export declare function finishedView(store: Store, batch: Pick<ChatBatch, "items">, now: Date, root?: string, viewer?: string): QuietView | null;
|
|
20
26
|
/** This computer's local day and time, which is what a person means by "the evening". */
|
|
21
27
|
export declare function localDay(now: Date): string;
|
|
22
28
|
export declare function localTime(now: Date): string;
|
package/dist/chat-quiet.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* Delivery receipts, claims and retries stay with each transport; the
|
|
6
6
|
* console's activity keeps every fact whatever a chat showed. */
|
|
7
7
|
import { assignmentOf } from "./assignment.js";
|
|
8
|
-
import { chatControlHref } from "./chat-controls.js";
|
|
8
|
+
import { chatControlHref, chatResultHref } from "./chat-controls.js";
|
|
9
9
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
10
|
-
import { assignmentStatusFacts, headlineEmoji,
|
|
10
|
+
import { assignmentStatusFacts, headlineEmoji, taskStatusOf } from "./task-status.js";
|
|
11
|
+
import { asksToFinish, batchLine, chatText, chatTitle, factLinkLabel, mentions, READY_ACTIONS } from "./chat-voice.js";
|
|
11
12
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
12
13
|
import { isLifecycleNotification } from "./store.js";
|
|
13
14
|
/** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
|
|
@@ -49,25 +50,27 @@ const BEFORE_RUN = {
|
|
|
49
50
|
"task-requeued": before("Queued"),
|
|
50
51
|
"task-cancelled": before("Stopped"),
|
|
51
52
|
};
|
|
52
|
-
function taskLine(store, taskRef, now, root) {
|
|
53
|
+
function taskLine(store, taskRef, now, root, viewer) {
|
|
53
54
|
const ref = store.refById(taskRef);
|
|
54
55
|
if (ref === null || ref.repo === null)
|
|
55
56
|
return null;
|
|
56
57
|
const task = store.getTask(ref.externalId);
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// A short human title: never "— revision" or an id (chat-voice.ts).
|
|
59
|
+
const title = chatTitle(store, ref.externalId);
|
|
60
|
+
// Replaced, never "Cancelled": the card says a newer task took over and links to it, without either id.
|
|
59
61
|
const successor = task?.state === "cancelled" ? store.replacementOf(ref.externalId) : null;
|
|
60
62
|
if (successor !== null) {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
63
|
+
const words = "Replaced by a newer task";
|
|
64
|
+
const heading = `${headlineEmoji("Stopped")} ${words}`;
|
|
65
|
+
const text = [title, heading, "", projectLabel(ref.repo)].join("\n");
|
|
66
|
+
return { title, icon: headlineEmoji("Stopped"), status: words, project: ref.repo, view: {
|
|
64
67
|
text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
65
|
-
link: { label:
|
|
68
|
+
link: { label: "Open the new task", path: chatControlHref("task", successor) },
|
|
66
69
|
} };
|
|
67
70
|
}
|
|
68
71
|
const run = cardRun(store, taskRef);
|
|
69
72
|
if (run !== null) {
|
|
70
|
-
const view = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root);
|
|
73
|
+
const view = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root, viewer);
|
|
71
74
|
const heading = view.entities[1] === undefined ? "" : view.text.slice(view.entities[1].offset, view.entities[1].offset + view.entities[1].length);
|
|
72
75
|
const split = heading.indexOf(" ");
|
|
73
76
|
return { title, icon: heading.slice(0, split), status: heading.slice(split + 1), project: ref.repo, view };
|
|
@@ -75,15 +78,15 @@ function taskLine(store, taskRef, now, root) {
|
|
|
75
78
|
const fact = store.latestTaskFact(taskRef);
|
|
76
79
|
const words = task?.state === "cancelled" ? BEFORE_RUN["task-cancelled"] : BEFORE_RUN[fact?.kind ?? ""] ?? BEFORE_RUN["task-filed"];
|
|
77
80
|
const heading = `${words.icon} ${words.status}`;
|
|
78
|
-
const text = [title, heading, "",
|
|
81
|
+
const text = [title, heading, "", projectLabel(ref.repo)].join("\n");
|
|
79
82
|
return { title, icon: words.icon, status: words.status, project: ref.repo, view: {
|
|
80
83
|
text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
81
84
|
link: { label: "Open task", path: chatControlHref("task", ref.externalId) },
|
|
82
85
|
} };
|
|
83
86
|
}
|
|
84
87
|
/** The card's words now: one task's progress card, or one line per task when several were filed together. */
|
|
85
|
-
export function quietCardView(store, taskRefs, now, root) {
|
|
86
|
-
const lines = taskRefs.map(ref => taskLine(store, ref, now, root)).filter((one) => one !== null);
|
|
88
|
+
export function quietCardView(store, taskRefs, now, root, viewer) {
|
|
89
|
+
const lines = taskRefs.map(ref => taskLine(store, ref, now, root, viewer)).filter((one) => one !== null);
|
|
87
90
|
if (lines.length === 0)
|
|
88
91
|
return null;
|
|
89
92
|
if (lines.length === 1)
|
|
@@ -93,14 +96,76 @@ export function quietCardView(store, taskRefs, now, root) {
|
|
|
93
96
|
const text = [heading, ...lines.map(one => `${one.icon} ${phoneText(one.title, 60)} · ${one.status}`), "", projects.join(", ")].join("\n");
|
|
94
97
|
return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/tasks" } };
|
|
95
98
|
}
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
/** Facts that carry their own controls or must stand alone (a security alert, a decision, a flow card, a
|
|
100
|
+
* screenshot, an acceptance packet) are never folded into a batch. */
|
|
101
|
+
const OWN_MESSAGE_KINDS = new Set(["security-release", "secret-detected", "flow-decision", "flow-card", "acceptance-ready", "acceptance-evidence"]);
|
|
102
|
+
/** Whether this update for a person joins their two-minute batch: anything about one task that needs them,
|
|
103
|
+
* except a fact that must stand alone. */
|
|
104
|
+
export function joinsBatch(row) {
|
|
105
|
+
return row.taskRef !== null && row.recipient === null && needsPerson(row) && !OWN_MESSAGE_KINDS.has(row.kind) && !/^decision:\d+$/.test(row.dedupeKey);
|
|
106
|
+
}
|
|
107
|
+
/** A few words for an update in a list, and how it counts: "has a plan to review", "failed". */
|
|
108
|
+
function updatePhrase(row) {
|
|
109
|
+
if (row.kind === "plan-ready")
|
|
110
|
+
return { phrase: "has a plan to review", headline: "Needs you" };
|
|
111
|
+
if (row.kind === "report-ready")
|
|
112
|
+
return { phrase: "has a report to read", headline: "Update" };
|
|
113
|
+
if (row.kind === "stale-approval" || row.kind === "approval-withdrawn")
|
|
114
|
+
return { phrase: "needs a fresh approval", headline: "Needs you" };
|
|
115
|
+
if (row.pushClass === "merge" || row.kind === "pull-request-ready")
|
|
116
|
+
return { phrase: "is ready to merge", headline: "Update" };
|
|
117
|
+
if (/fail|exhausted|stalled|fenced/.test(row.kind))
|
|
118
|
+
return { phrase: "failed", headline: "Failed" };
|
|
119
|
+
return { phrase: "needs you", headline: "Needs you" };
|
|
120
|
+
}
|
|
121
|
+
/** One task's line in a batch: a finished result in a person's words with its real next step, or another
|
|
122
|
+
* update in its own words with its own link. */
|
|
123
|
+
function lineOf(store, item, now, root, viewer) {
|
|
124
|
+
const ref = store.refById(item.taskRef);
|
|
125
|
+
if (ref === null || ref.repo === null)
|
|
126
|
+
return null;
|
|
127
|
+
const summary = chatTitle(store, ref.externalId);
|
|
128
|
+
const row = item.notification === null ? null : store.notificationById(item.notification);
|
|
129
|
+
if (row !== null && row.kind !== "run-finished") {
|
|
130
|
+
// Never the task's id or a "— revision" suffix; the title once, in front unless the words already name it.
|
|
131
|
+
const words = chatText(phoneText(row.body === "" ? row.subject : `${row.subject}\n\n${row.body}`, 2500), [{ id: ref.externalId, title: summary }]);
|
|
132
|
+
const { phrase, headline } = updatePhrase(row);
|
|
133
|
+
return { also: [], link: row.link === null ? { label: "Open task", path: chatControlHref("task", ref.externalId) } : { label: factLinkLabel(row.link), path: row.link },
|
|
134
|
+
fact: { summary, headline, checks: null, report: false, completedBy: null, update: { words: mentions(words, summary) ? words : `${summary} · ${words}`, phrase } } };
|
|
135
|
+
}
|
|
136
|
+
const run = item.run === null ? null : store.getRun(item.run);
|
|
137
|
+
if (run === null)
|
|
138
|
+
return null;
|
|
139
|
+
const card = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root, viewer);
|
|
140
|
+
const checks = card.facts.checks ?? null;
|
|
141
|
+
const pullRequest = card.facts.pullRequest?.state === "open";
|
|
142
|
+
const fact = {
|
|
143
|
+
summary,
|
|
144
|
+
headline: card.status.headline,
|
|
145
|
+
checks: checks === null ? null : checks.level === "off" && checks.status !== "passed" ? "off"
|
|
146
|
+
: checks.status === "passed" || checks.status === "failed" || checks.status === "not-run" ? checks.status : null,
|
|
147
|
+
report: card.facts.report === true,
|
|
148
|
+
completedBy: card.facts.completedBy ?? null,
|
|
149
|
+
pullRequest,
|
|
150
|
+
// The lead speaks as "I" for what it did: the work it filed or approved, or the completion it recorded.
|
|
151
|
+
lead: card.status.headline === "Complete" ? card.completedByLead : store.leadWorkOf(item.taskRef) !== null,
|
|
152
|
+
};
|
|
153
|
+
// A result ready for a person offers its real next step, then a look first.
|
|
154
|
+
if (asksToFinish(fact))
|
|
155
|
+
return { fact, also: [{ label: READY_ACTIONS.look, path: chatResultHref(ref.externalId, run.id, "changes") }],
|
|
156
|
+
link: pullRequest ? { label: READY_ACTIONS.merge, path: `/t/${encodeURIComponent(ref.externalId)}#merge` } : { label: READY_ACTIONS.complete, path: chatResultHref(ref.externalId, run.id) } };
|
|
157
|
+
return { fact, link: card.link, also: [] };
|
|
158
|
+
}
|
|
159
|
+
/** One person's update message: one task says what happened with its next step; several updates within two
|
|
160
|
+
* minutes become "4 tasks finished: …" (or "3 updates: …") with one Open button. Null when none can be read. */
|
|
161
|
+
export function finishedView(store, batch, now, root, viewer) {
|
|
162
|
+
const lines = batch.items.map(one => lineOf(store, one, now, root, viewer)).filter((one) => one !== null);
|
|
163
|
+
if (lines.length === 0)
|
|
164
|
+
return null;
|
|
165
|
+
const text = batchLine(lines.map(one => one.fact));
|
|
166
|
+
if (lines.length > 1)
|
|
167
|
+
return { text, entities: [], link: { label: "Open", path: "/tasks" } };
|
|
168
|
+
return { text, entities: [], link: lines[0].link, ...(lines[0].also.length === 0 ? {} : { also: lines[0].also }) };
|
|
104
169
|
}
|
|
105
170
|
// ---- the evening digest ----------------------------------------------------------
|
|
106
171
|
const FAILURE = /fail|exhausted|fenced/;
|
|
@@ -118,7 +183,7 @@ export function eveningDigestText(store, account, now, root) {
|
|
|
118
183
|
const since = new Date(now.getTime() - 86_400_000).toISOString();
|
|
119
184
|
const recent = new Date(now.getTime() - 14 * 86_400_000).toISOString();
|
|
120
185
|
const visible = (row) => row.project !== null && store.accountCanAccess(account, row.project);
|
|
121
|
-
const title = (taskId) =>
|
|
186
|
+
const title = (taskId) => chatTitle(store, taskId);
|
|
122
187
|
const finished = new Map(), failed = new Map(), waits = new Map();
|
|
123
188
|
const facts = store.taskFactsSince(recent).filter(visible);
|
|
124
189
|
for (const row of facts) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** How chat sounds: a teammate talking. One or two short sentences, outcome
|
|
2
|
+
* first, plain words, one action at most. Every chat (Telegram, Slack,
|
|
3
|
+
* Discord, Teams) takes its titles and its finished-work lines from here, so a
|
|
4
|
+
* task id, a "— revision" suffix or a person's own act never reaches a chat.
|
|
5
|
+
* The console keeps every exact name and fact. */
|
|
6
|
+
import type { TelegramTransport } from "./telegram.js";
|
|
7
|
+
import type { Store } from "./store.js";
|
|
8
|
+
/** The bot's display name in every chat app. */
|
|
9
|
+
export declare const BOT_NAME = "Toolroll";
|
|
10
|
+
/** Updates for one person landing within this window become one message, edited in place as it grows. */
|
|
11
|
+
export declare const BATCH_MS: number;
|
|
12
|
+
export declare const SHORT_TITLE_MAX = 60;
|
|
13
|
+
/** A short human summary of a task's title, at most 60 characters: no "— revision", and no task ids. Only the
|
|
14
|
+
* task's own id and tokens `isTaskId` names as real tasks are dropped; `node-20` and `utf-8` stay. */
|
|
15
|
+
export declare function shortTitle(title: string | null | undefined, taskId?: string | null, isTaskId?: (token: string) => boolean): string;
|
|
16
|
+
/** A task's title as every chat says it: short, with its own id and any other real task's id dropped. */
|
|
17
|
+
export declare function chatTitle(store: Pick<Store, "getTask">, taskId: string): string;
|
|
18
|
+
/** A title inside a sentence or list: "faster tests", but "API keys" and "iOS" keep their capitals. */
|
|
19
|
+
export declare function inSentence(summary: string): string;
|
|
20
|
+
/** The facts one line of a chat update is made of, read from the shared task status. */
|
|
21
|
+
export type FinishedFact = {
|
|
22
|
+
summary: string;
|
|
23
|
+
headline: string;
|
|
24
|
+
checks: "passed" | "failed" | "not-run" | "off" | null;
|
|
25
|
+
report: boolean;
|
|
26
|
+
completedBy: string | null;
|
|
27
|
+
/** An open pull request carries this result: the ask is "Merge it?" rather than "Mark it complete?". */
|
|
28
|
+
pullRequest?: boolean;
|
|
29
|
+
/** The lead did this (built it, or marked it complete), so the lead says "I". */
|
|
30
|
+
lead?: boolean;
|
|
31
|
+
/** Not a finished result but another update about the task: its own words when it stands alone, and a few
|
|
32
|
+
* words for a list ("has a plan to review"). */
|
|
33
|
+
update?: {
|
|
34
|
+
words: string;
|
|
35
|
+
phrase: string;
|
|
36
|
+
} | null;
|
|
37
|
+
};
|
|
38
|
+
/** The two buttons under a result ready for a person: the real next step, then a look first. */
|
|
39
|
+
export declare const READY_ACTIONS: {
|
|
40
|
+
readonly merge: "Merge";
|
|
41
|
+
readonly complete: "Mark complete";
|
|
42
|
+
readonly look: "Look first";
|
|
43
|
+
};
|
|
44
|
+
/** Whether this line asks for the result's next step, so it carries [Merge] or [Mark complete] and [Look first]. */
|
|
45
|
+
export declare function asksToFinish(fact: FinishedFact): boolean;
|
|
46
|
+
/** One update, said plainly: what happened, then what happens next. The lead says "I" for what it did. */
|
|
47
|
+
export declare function finishedLine(fact: FinishedFact): string;
|
|
48
|
+
/** Several updates in one message: "4 tasks finished: faster tests, cleanup, …" (or "3 updates: …" when not all of
|
|
49
|
+
* them are finished work), then what broke and what happens next. */
|
|
50
|
+
export declare function batchLine(facts: readonly FinishedFact[]): string;
|
|
51
|
+
/**
|
|
52
|
+
* Name the Telegram bot Toolroll. On pairing it always becomes Toolroll; on upgrade only a bot still called
|
|
53
|
+
* StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
|
|
54
|
+
* leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
|
|
55
|
+
*/
|
|
56
|
+
export declare function nameTelegramBot(transport: TelegramTransport, when: "pairing" | "upgrade"): Promise<boolean>;
|
|
57
|
+
/** The last word on any pushed chat text: a task's own id (bare or in brackets), a "— revision" suffix and a
|
|
58
|
+
* "Replaced by <id>" never reach a chat, whatever words the fact was recorded with. A bare id reads as the
|
|
59
|
+
* task's short title when one is given, else "this task". */
|
|
60
|
+
export declare function chatText(text: string, tasks?: readonly (string | null | undefined | {
|
|
61
|
+
id: string;
|
|
62
|
+
title?: string;
|
|
63
|
+
})[]): string;
|
|
64
|
+
/** The one button a plain fact may carry: its label names where its machine-minted console path goes; nothing in
|
|
65
|
+
* it comes from the fact's text. */
|
|
66
|
+
export declare function factLinkLabel(path: string): string;
|
|
67
|
+
/** Whether the words already name this title as a whole phrase (so it need not be said twice). */
|
|
68
|
+
export declare function mentions(text: string, title: string): boolean;
|