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
package/dist/telegram-lead.js
CHANGED
|
@@ -31,7 +31,7 @@ import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
|
|
|
31
31
|
import { LEAD_ASK_OTHER } from "./store.js";
|
|
32
32
|
import { phoneText } from "./telegram-status.js";
|
|
33
33
|
import { shapeReplyParts, telegramReply } from "./reply-shape.js";
|
|
34
|
-
import {
|
|
34
|
+
import { createTelegramLiveDraft, forgetWaitingDraft, telegramAck, telegramWaitingDraft } from "./telegram-live.js";
|
|
35
35
|
import { TeamLeads } from "./team-leads.js";
|
|
36
36
|
import { chatResultHref } from "./chat-controls.js";
|
|
37
37
|
import { resultImageFileName, safeResultImageCaption, verifyResultImage } from "./chat-evidence.js";
|
|
@@ -373,8 +373,26 @@ const ANSWERED_SENDING = "answered:sending";
|
|
|
373
373
|
*/
|
|
374
374
|
async function runTelegramConversation(row, args) {
|
|
375
375
|
const { store, botId, transport, owner, clock, readProjects, options, report, only } = args;
|
|
376
|
-
|
|
376
|
+
/** The row's live binding as it arrived (pairing alone: a reaction or a waiting draft carries no project data). */
|
|
377
|
+
const stillPaired = () => {
|
|
378
|
+
const live = store.liveTelegramBindingById(row.binding);
|
|
379
|
+
return live !== null && live.botId === botId && live.approverGeneration === row.approverGeneration && store.accountOf(live.approver)?.role === "approver";
|
|
380
|
+
};
|
|
381
|
+
const finish = (result) => {
|
|
382
|
+
const done = store.finishTelegramConversation(row.id, owner, result, clock());
|
|
383
|
+
// Answered (every part sent) or failed (its notice tried): the 👀 comes off, after what was delivered. Never awaited.
|
|
384
|
+
if (done && (result.state === "done" || result.state === "failed")) {
|
|
385
|
+
forgetWaitingDraft(botId, row.id);
|
|
386
|
+
void telegramAck({ store, botId, transport, clock, chatId: row.chatId, messageId: row.messageId, on: false, paired: stillPaired, ...(options.liveTiming?.ackGapMs === undefined ? {} : { gapMs: options.liveTiming.ackGapMs }) });
|
|
387
|
+
}
|
|
388
|
+
return done;
|
|
389
|
+
};
|
|
377
390
|
const held = () => store.renewTelegramConversation(row.id, owner, CONVERSATION_CLAIM_MS, clock());
|
|
391
|
+
/** The claim, read (a draft never renews it: only the turn's own heartbeat and durable steps do). */
|
|
392
|
+
const claimed = () => {
|
|
393
|
+
const now = store.getTelegramConversation(row.id);
|
|
394
|
+
return now !== null && now.claimOwner === owner && now.claimExpiresAt !== null && now.claimExpiresAt > clock().toISOString();
|
|
395
|
+
};
|
|
378
396
|
const ageMs = clock().getTime() - Date.parse(row.createdAt);
|
|
379
397
|
const requeue = async (outcome) => {
|
|
380
398
|
// A turn already admitted for it waits for that turn's own outcome (bounded by the turn's deadline); waiting for the
|
|
@@ -390,6 +408,10 @@ async function runTelegramConversation(row, args) {
|
|
|
390
408
|
// Behind the person's own earlier messages: said once, then it waits quietly.
|
|
391
409
|
if (outcome === "backlog" && row.outcome !== "backlog")
|
|
392
410
|
await notify("Queued behind your earlier messages. It goes to the lead once they're answered.");
|
|
411
|
+
// Waiting for the lead: a draft that says so, refreshed while it waits (never over this chat's running turn).
|
|
412
|
+
if (outcome === "busy" || outcome === "backlog") {
|
|
413
|
+
await telegramWaitingDraft({ store, botId, transport, clock, chatId: row.chatId, binding: row.binding, draftId: row.id, allowed: () => stillPaired() && claimed(), ...(options.liveTiming === undefined ? {} : { timing: options.liveTiming }) });
|
|
414
|
+
}
|
|
393
415
|
finish({ state: "queued", outcome, nextAttemptAt: new Date(clock().getTime() + CONVERSATION_RETRY_MS).toISOString() });
|
|
394
416
|
};
|
|
395
417
|
// 1. The exact binding the message arrived under must still be the live one.
|
|
@@ -677,7 +699,7 @@ async function runTelegramConversation(row, args) {
|
|
|
677
699
|
return;
|
|
678
700
|
}
|
|
679
701
|
if (turn.state !== "answered") {
|
|
680
|
-
await notify(leadFailureText(turn.failureReason, store.leadTurnCostUnconfirmed(turn.id)));
|
|
702
|
+
await notify(leadFailureText(turn.failureReason, store.leadTurnCostUnconfirmed(turn.id), turn.failureDetail ?? null));
|
|
681
703
|
finish({ state: "failed", outcome: `replayed:${turn.failureReason ?? "failed"}` });
|
|
682
704
|
report.refused++;
|
|
683
705
|
return;
|
|
@@ -777,10 +799,41 @@ async function runTelegramConversation(row, args) {
|
|
|
777
799
|
// crash gets the original turn back, never a second dispatch.
|
|
778
800
|
const heartbeat = setInterval(() => { held(); }, 30_000);
|
|
779
801
|
heartbeat.unref?.();
|
|
780
|
-
//
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
802
|
+
// The live reply: one draft in this private chat with the status line and the reply as it is written
|
|
803
|
+
// (telegram-live.ts). A draft with words goes out only after the full fence: this exact binding, approver and the
|
|
804
|
+
// ceiling the turn opened under (the registry read at most every 5 s), the turn still running under the generation it
|
|
805
|
+
// first ran with, and this row's claim, re-checked after the read.
|
|
806
|
+
let draftGeneration = null;
|
|
807
|
+
let draftRegistry = null;
|
|
808
|
+
const turnStillRunning = (turnId) => {
|
|
809
|
+
const turn = store.getLeadTurn(turnId);
|
|
810
|
+
if (turn === null || turn.state !== "running" || turn.session !== session.id || turn.thread !== thread.id)
|
|
811
|
+
return false;
|
|
812
|
+
draftGeneration ??= turn.generation;
|
|
813
|
+
return turn.generation === draftGeneration;
|
|
814
|
+
};
|
|
815
|
+
const draftStanding = async (turnId) => {
|
|
816
|
+
if (!turnStillRunning(turnId) || !claimed())
|
|
817
|
+
return false;
|
|
818
|
+
if (draftRegistry === null || clock().getTime() - draftRegistry.at > 5_000) {
|
|
819
|
+
try {
|
|
820
|
+
draftRegistry = { at: clock().getTime(), repos: await readProjects() };
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
draftRegistry = null;
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
const read = draftRegistry.repos;
|
|
828
|
+
const problem = await telegramChannelProblem(store, { botId, bindingId: binding.id, approverGeneration: binding.approverGeneration, repos: who.repos }, async () => read);
|
|
829
|
+
return problem === null && turnStillRunning(turnId) && claimed();
|
|
830
|
+
};
|
|
831
|
+
const live = createTelegramLiveDraft({
|
|
832
|
+
store, botId, transport, clock, chatId, binding: binding.id, draftId: row.id,
|
|
833
|
+
// Shaped as the final reply is: for this message, with the phone's own trusted origin.
|
|
834
|
+
shape: { asked: row.text, appOrigin: options.phoneOrigin?.() ?? null },
|
|
835
|
+
standing: draftStanding, paired: () => pairingProblem() === null,
|
|
836
|
+
...(options.liveTiming === undefined ? {} : { timing: options.liveTiming }),
|
|
784
837
|
});
|
|
785
838
|
const replyImage = row.replyTo === null ? undefined : store.telegramMessageBindings(binding, row.replyTo).find(one => one.taskId === row.taskId && one.run === row.sourceRun && one.image !== undefined)?.image;
|
|
786
839
|
let outcome;
|
|
@@ -790,12 +843,13 @@ async function runTelegramConversation(row, args) {
|
|
|
790
843
|
...(row.context === null ? {} : { context: row.context }),
|
|
791
844
|
...(replyImage === undefined ? {} : { replyImage }),
|
|
792
845
|
...(options.subscriptionRunner === undefined ? {} : { subscriptionRunner: options.subscriptionRunner }),
|
|
793
|
-
clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram", onProgress:
|
|
846
|
+
clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram", onProgress: live.onProgress,
|
|
794
847
|
});
|
|
795
848
|
}
|
|
796
849
|
finally {
|
|
797
850
|
clearInterval(heartbeat);
|
|
798
|
-
|
|
851
|
+
// Closed, and its draft in flight settled, before anything durable: no draft can land after the answer or a notice.
|
|
852
|
+
await live.close();
|
|
799
853
|
}
|
|
800
854
|
const receipt = store.leadRequestReceipt(session.id, row.request);
|
|
801
855
|
if (receipt !== null)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paired phone's live reply (docs/plans/responsive-chat.md §3.1): a 👀 on the owner's message as soon as it is
|
|
3
|
+
* saved, and while the lead works one Bot API message draft (`sendMessageDraft`, private chats only) that shows the
|
|
4
|
+
* status line and the reply as it is written.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here is durable or authoritative. A draft is Telegram's "temporary 30-second preview": it disappears when the
|
|
7
|
+
* bot sends a message or when it is not refreshed, so a draft that is never replaced is harmless. The answer itself is
|
|
8
|
+
* still the planned, receipted `sendMessage` parts (telegram-lead.ts), sent only after this turn's sender has closed.
|
|
9
|
+
* Drafts carry words only: no keyboard, no card, no token. Every draft that carries more than Telegram's own empty
|
|
10
|
+
* "Thinking…" placeholder is sent only after the caller's full standing check (live pairing, approver, the project
|
|
11
|
+
* ceiling the turn opened under, the turn still running under its generation, the row's claim).
|
|
12
|
+
*
|
|
13
|
+
* Rate: Telegram's own retry_after is shared with every other send (`telegramRetryAt` / `deferTelegram`). While it
|
|
14
|
+
* holds, an ack waits and a draft is dropped (the answer and failure notices go first, acks next, drafts last); a
|
|
15
|
+
* draft that is asked to pause widens this turn's spacing.
|
|
16
|
+
*/
|
|
17
|
+
import type { LeadProgress } from "./lead-progress.js";
|
|
18
|
+
import { type LiveView } from "./live-reply.js";
|
|
19
|
+
import { type ShapeOptions, type TelegramEntity } from "./reply-shape.js";
|
|
20
|
+
import type { Store } from "./store.js";
|
|
21
|
+
import type { TelegramTransport } from "./telegram.js";
|
|
22
|
+
/** The acknowledgement on the owner's message: "seen, working on it". Removed once the answer or the failure notice is
|
|
23
|
+
* delivered; never replaced by a "done" mark (an answer isn't finished work). */
|
|
24
|
+
export declare const ACK_EMOJI = "\uD83D\uDC40";
|
|
25
|
+
/** At most one reaction change per chat this often; a burst waits its turn, in order. */
|
|
26
|
+
export declare const ACK_GAP_MS = 1000;
|
|
27
|
+
/** Drafts start this far apart and widen (doubling, up to the cap) whenever Telegram asks for a pause. */
|
|
28
|
+
export declare const DRAFT_SPACING_MS = 1200;
|
|
29
|
+
/** Telegram keeps a draft about 30 seconds: one that has not changed is sent again after this long. */
|
|
30
|
+
export declare const DRAFT_KEEPALIVE_MS = 25000;
|
|
31
|
+
/** Telegram's message limit, counted after entities (UTF-16 units, as JS strings are). */
|
|
32
|
+
export declare const TELEGRAM_TEXT_MAX = 4096;
|
|
33
|
+
/** Test seams for the timings above (production uses the defaults). */
|
|
34
|
+
export type TelegramLiveTiming = {
|
|
35
|
+
ackGapMs?: number;
|
|
36
|
+
draftSpacingMs?: number;
|
|
37
|
+
keepAliveMs?: number;
|
|
38
|
+
};
|
|
39
|
+
export type DraftEntity = TelegramEntity | {
|
|
40
|
+
type: "italic";
|
|
41
|
+
offset: number;
|
|
42
|
+
length: number;
|
|
43
|
+
};
|
|
44
|
+
export type DraftPayload = {
|
|
45
|
+
text: string;
|
|
46
|
+
entities: DraftEntity[];
|
|
47
|
+
};
|
|
48
|
+
/** The body of a writing draft within `budget` characters once rendered: cut at a safe place in the shaped text first
|
|
49
|
+
* (never inside code, a link, a bold anchor or an emoji), rendered, measured, and cut smaller if it still doesn't fit.
|
|
50
|
+
* A body that can't fit (a single link longer than the budget) shows nothing. */
|
|
51
|
+
export declare function draftBody(shaped: string, budget: number): DraftPayload;
|
|
52
|
+
/**
|
|
53
|
+
* One live view as a draft: the status line in italics, then (once text exists) the partial reply as text plus
|
|
54
|
+
* entities, never Markdown. Plain "Thinking…" is Telegram's own empty-text placeholder. Closed views send nothing.
|
|
55
|
+
*/
|
|
56
|
+
export declare function telegramDraftOf(view: LiveView): DraftPayload | null;
|
|
57
|
+
/**
|
|
58
|
+
* Put 👀 on the owner's message (`on`) or take it off. Best effort and never awaited by the caller's durable work: a
|
|
59
|
+
* missing pairing, a failure or a long pause skips it silently. `paired` is re-checked at the moment of sending; a
|
|
60
|
+
* reaction carries no project data, so the pairing alone fences it. Resolves once this change was tried.
|
|
61
|
+
*/
|
|
62
|
+
export declare function telegramAck(args: {
|
|
63
|
+
store: Store;
|
|
64
|
+
botId: string;
|
|
65
|
+
transport: TelegramTransport;
|
|
66
|
+
clock: () => Date;
|
|
67
|
+
chatId: string;
|
|
68
|
+
messageId: string;
|
|
69
|
+
on: boolean;
|
|
70
|
+
paired: () => boolean;
|
|
71
|
+
gapMs?: number;
|
|
72
|
+
}): Promise<void>;
|
|
73
|
+
export type TelegramLiveDraft = {
|
|
74
|
+
/** The turn's progress listener (runLeadTurn's `onProgress`). */
|
|
75
|
+
onProgress: (event: LeadProgress) => void;
|
|
76
|
+
/** Stop: nothing more is sent, and the draft in flight has settled when this resolves. Durable delivery comes after. */
|
|
77
|
+
close: () => Promise<void>;
|
|
78
|
+
/** The turn the drafts follow, once it started. */
|
|
79
|
+
readonly turn: number | null;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* One running turn's draft. `standing` is the caller's full proof for a draft that carries words (it may await; the
|
|
83
|
+
* close is re-checked after it); `paired` is enough for Telegram's empty "Thinking…". A draft Telegram refuses outright
|
|
84
|
+
* (not a pause, not a lost answer) turns this turn's drafts off; previews are best effort.
|
|
85
|
+
*/
|
|
86
|
+
export declare function createTelegramLiveDraft(args: {
|
|
87
|
+
store: Store;
|
|
88
|
+
botId: string;
|
|
89
|
+
transport: TelegramTransport;
|
|
90
|
+
clock: () => Date;
|
|
91
|
+
chatId: string;
|
|
92
|
+
binding: number;
|
|
93
|
+
draftId: number;
|
|
94
|
+
shape: ShapeOptions;
|
|
95
|
+
standing: (turn: number) => Promise<boolean>;
|
|
96
|
+
paired: () => boolean;
|
|
97
|
+
timing?: TelegramLiveTiming;
|
|
98
|
+
}): TelegramLiveDraft;
|
|
99
|
+
/**
|
|
100
|
+
* A message waiting for the lead (busy, or behind the owner's earlier messages): "Waiting for your earlier messages…"
|
|
101
|
+
* as a draft, refreshed only after the keep-alive interval, and never while this chat's running turn has its own
|
|
102
|
+
* draft. Carries no project data: the pairing and the claim fence it.
|
|
103
|
+
*/
|
|
104
|
+
export declare function telegramWaitingDraft(args: {
|
|
105
|
+
store: Store;
|
|
106
|
+
botId: string;
|
|
107
|
+
transport: TelegramTransport;
|
|
108
|
+
clock: () => Date;
|
|
109
|
+
chatId: string;
|
|
110
|
+
binding: number;
|
|
111
|
+
draftId: number;
|
|
112
|
+
allowed: () => boolean;
|
|
113
|
+
timing?: TelegramLiveTiming;
|
|
114
|
+
}): Promise<void>;
|
|
115
|
+
/** The message is answered or failed: its waiting draft is forgotten. */
|
|
116
|
+
export declare function forgetWaitingDraft(botId: string, draftId: number): void;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createLiveReply, createLiveSender, LIVE_STATUS } from "./live-reply.js";
|
|
2
|
+
import { shapedWindow, telegramReply } from "./reply-shape.js";
|
|
3
|
+
/** The acknowledgement on the owner's message: "seen, working on it". Removed once the answer or the failure notice is
|
|
4
|
+
* delivered; never replaced by a "done" mark (an answer isn't finished work). */
|
|
5
|
+
export const ACK_EMOJI = "👀";
|
|
6
|
+
/** At most one reaction change per chat this often; a burst waits its turn, in order. */
|
|
7
|
+
export const ACK_GAP_MS = 1_000;
|
|
8
|
+
/** A reaction change that meets Telegram's pause waits it out when it is this short; a longer pause skips it. */
|
|
9
|
+
const ACK_MAX_WAIT_MS = 30_000;
|
|
10
|
+
/** Drafts start this far apart and widen (doubling, up to the cap) whenever Telegram asks for a pause. */
|
|
11
|
+
export const DRAFT_SPACING_MS = 1_200;
|
|
12
|
+
const DRAFT_MAX_SPACING_MS = 10_000;
|
|
13
|
+
/** Telegram keeps a draft about 30 seconds: one that has not changed is sent again after this long. */
|
|
14
|
+
export const DRAFT_KEEPALIVE_MS = 25_000;
|
|
15
|
+
/** Telegram's message limit, counted after entities (UTF-16 units, as JS strings are). */
|
|
16
|
+
export const TELEGRAM_TEXT_MAX = 4_096;
|
|
17
|
+
/** The body of a writing draft within `budget` characters once rendered: cut at a safe place in the shaped text first
|
|
18
|
+
* (never inside code, a link, a bold anchor or an emoji), rendered, measured, and cut smaller if it still doesn't fit.
|
|
19
|
+
* A body that can't fit (a single link longer than the budget) shows nothing. */
|
|
20
|
+
export function draftBody(shaped, budget) {
|
|
21
|
+
if (budget <= 0)
|
|
22
|
+
return { text: "", entities: [] };
|
|
23
|
+
let size = budget;
|
|
24
|
+
for (let attempt = 0; attempt < 8 && size >= 1; attempt += 1) {
|
|
25
|
+
const window = shapedWindow(shaped, size);
|
|
26
|
+
const cut = window.length < shaped.length;
|
|
27
|
+
const rendered = telegramReply(cut ? window.trimEnd() : window);
|
|
28
|
+
const text = cut ? `${rendered.text} …` : rendered.text;
|
|
29
|
+
if (text.length <= budget)
|
|
30
|
+
return { text, entities: rendered.entities };
|
|
31
|
+
size = Math.min(size - 1, Math.floor(size * budget / text.length) - 2);
|
|
32
|
+
}
|
|
33
|
+
return { text: "", entities: [] };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One live view as a draft: the status line in italics, then (once text exists) the partial reply as text plus
|
|
37
|
+
* entities, never Markdown. Plain "Thinking…" is Telegram's own empty-text placeholder. Closed views send nothing.
|
|
38
|
+
*/
|
|
39
|
+
export function telegramDraftOf(view) {
|
|
40
|
+
if (view.phase === "closed")
|
|
41
|
+
return null;
|
|
42
|
+
if (view.phase === "queued")
|
|
43
|
+
return { text: LIVE_STATUS.queued, entities: [{ type: "italic", offset: 0, length: LIVE_STATUS.queued.length }] };
|
|
44
|
+
if (view.phase === "working") {
|
|
45
|
+
if (view.status === LIVE_STATUS.thinking)
|
|
46
|
+
return { text: "", entities: [] };
|
|
47
|
+
return { text: view.status, entities: [{ type: "italic", offset: 0, length: view.status.length }] };
|
|
48
|
+
}
|
|
49
|
+
const head = view.status;
|
|
50
|
+
const gap = "\n\n";
|
|
51
|
+
const body = draftBody(view.text, TELEGRAM_TEXT_MAX - head.length - gap.length);
|
|
52
|
+
if (body.text === "")
|
|
53
|
+
return { text: head, entities: [{ type: "italic", offset: 0, length: head.length }] };
|
|
54
|
+
const shift = head.length + gap.length;
|
|
55
|
+
return {
|
|
56
|
+
text: `${head}${gap}${body.text}`,
|
|
57
|
+
entities: [{ type: "italic", offset: 0, length: head.length }, ...body.entities.map(one => ({ ...one, offset: one.offset + shift }))],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** One draft on the wire, under the shared pause. A retry_after is recorded for every sender of this bot. */
|
|
61
|
+
async function sendDraft(store, botId, transport, clock, chatId, draftId, payload) {
|
|
62
|
+
if (store.telegramRetryAt(botId) > clock().toISOString())
|
|
63
|
+
return "paused";
|
|
64
|
+
let answer;
|
|
65
|
+
try {
|
|
66
|
+
answer = await transport("sendMessageDraft", { chat_id: Number(chatId), draft_id: draftId, text: payload.text, ...(payload.entities.length === 0 ? {} : { entities: payload.entities }) });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return "lost";
|
|
70
|
+
}
|
|
71
|
+
if (answer.ok)
|
|
72
|
+
return "sent";
|
|
73
|
+
const retry = answer.parameters?.retry_after;
|
|
74
|
+
if (typeof retry === "number" && Number.isFinite(retry) && retry > 0) {
|
|
75
|
+
store.deferTelegram(botId, new Date(clock().getTime() + Math.ceil(retry) * 1_000).toISOString());
|
|
76
|
+
return "paused";
|
|
77
|
+
}
|
|
78
|
+
return answer.uncertain === true ? "lost" : "refused";
|
|
79
|
+
}
|
|
80
|
+
// ---- the 👀 acknowledgement ----------------------------------------------------------
|
|
81
|
+
/** One lane per chat: reaction changes go out in order (an ack never lands after its own removal), spaced. */
|
|
82
|
+
const ackLanes = new Map();
|
|
83
|
+
const pause = (ms) => new Promise(resolve => { const timer = setTimeout(resolve, ms); timer.unref?.(); });
|
|
84
|
+
/**
|
|
85
|
+
* Put 👀 on the owner's message (`on`) or take it off. Best effort and never awaited by the caller's durable work: a
|
|
86
|
+
* missing pairing, a failure or a long pause skips it silently. `paired` is re-checked at the moment of sending; a
|
|
87
|
+
* reaction carries no project data, so the pairing alone fences it. Resolves once this change was tried.
|
|
88
|
+
*/
|
|
89
|
+
export function telegramAck(args) {
|
|
90
|
+
const key = `${args.botId}:${args.chatId}`;
|
|
91
|
+
const lane = ackLanes.get(key) ?? { tail: Promise.resolve(), lastAt: Number.NEGATIVE_INFINITY };
|
|
92
|
+
ackLanes.set(key, lane);
|
|
93
|
+
const gap = args.gapMs ?? ACK_GAP_MS;
|
|
94
|
+
// Never throws: a store closed meanwhile (a stop) or anything else skips the change.
|
|
95
|
+
const run = () => change().catch(() => undefined);
|
|
96
|
+
const change = async () => {
|
|
97
|
+
const spaced = lane.lastAt + gap - Date.now();
|
|
98
|
+
if (spaced > 0)
|
|
99
|
+
await pause(spaced);
|
|
100
|
+
const held = Date.parse(args.store.telegramRetryAt(args.botId)) - args.clock().getTime();
|
|
101
|
+
if (Number.isFinite(held) && held > 0) {
|
|
102
|
+
if (held > ACK_MAX_WAIT_MS)
|
|
103
|
+
return;
|
|
104
|
+
await pause(held);
|
|
105
|
+
}
|
|
106
|
+
if (!args.paired())
|
|
107
|
+
return;
|
|
108
|
+
lane.lastAt = Date.now();
|
|
109
|
+
try {
|
|
110
|
+
const answer = await args.transport("setMessageReaction", {
|
|
111
|
+
chat_id: Number(args.chatId), message_id: Number(args.messageId), reaction: args.on ? [{ type: "emoji", emoji: ACK_EMOJI }] : [],
|
|
112
|
+
});
|
|
113
|
+
const retry = answer.ok ? undefined : answer.parameters?.retry_after;
|
|
114
|
+
if (typeof retry === "number" && Number.isFinite(retry) && retry > 0)
|
|
115
|
+
args.store.deferTelegram(args.botId, new Date(args.clock().getTime() + Math.ceil(retry) * 1_000).toISOString());
|
|
116
|
+
}
|
|
117
|
+
catch { /* skipped silently */ }
|
|
118
|
+
};
|
|
119
|
+
const done = lane.tail.then(run, run);
|
|
120
|
+
lane.tail = done;
|
|
121
|
+
// A finished lane is forgotten; the next change starts a fresh one (spacing restarts from now).
|
|
122
|
+
void done.then(() => { if (ackLanes.get(key) === lane && lane.tail === done && Date.now() - lane.lastAt >= gap)
|
|
123
|
+
ackLanes.delete(key); });
|
|
124
|
+
return done;
|
|
125
|
+
}
|
|
126
|
+
// ---- the draft --------------------------------------------------------------------------
|
|
127
|
+
/** Chats (bot:binding) whose turn has a live draft in this process: a waiting message's draft never covers it. */
|
|
128
|
+
const liveChats = new Set();
|
|
129
|
+
/** When each waiting message last had its "Waiting for your earlier messages…" draft (bot:row). */
|
|
130
|
+
const waitingDrafts = new Map();
|
|
131
|
+
/**
|
|
132
|
+
* One running turn's draft. `standing` is the caller's full proof for a draft that carries words (it may await; the
|
|
133
|
+
* close is re-checked after it); `paired` is enough for Telegram's empty "Thinking…". A draft Telegram refuses outright
|
|
134
|
+
* (not a pause, not a lost answer) turns this turn's drafts off; previews are best effort.
|
|
135
|
+
*/
|
|
136
|
+
export function createTelegramLiveDraft(args) {
|
|
137
|
+
const reply = createLiveReply(args.shape);
|
|
138
|
+
const keepAlive = args.timing?.keepAliveMs ?? DRAFT_KEEPALIVE_MS;
|
|
139
|
+
const base = args.timing?.draftSpacingMs ?? DRAFT_SPACING_MS;
|
|
140
|
+
const chat = `${args.botId}:${args.binding}`;
|
|
141
|
+
let spacing = base;
|
|
142
|
+
let lastSentAt = Date.now();
|
|
143
|
+
let off = false;
|
|
144
|
+
liveChats.add(chat);
|
|
145
|
+
/** What the draft shows now: a preview closed as a possible password or key keeps only the status line. */
|
|
146
|
+
const current = () => {
|
|
147
|
+
const view = reply.view();
|
|
148
|
+
if (view.phase !== "closed")
|
|
149
|
+
return view;
|
|
150
|
+
return view.why === "blocked" ? { phase: "working", status: LIVE_STATUS.held } : null;
|
|
151
|
+
};
|
|
152
|
+
const sender = createLiveSender({
|
|
153
|
+
spacingMs: () => spacing,
|
|
154
|
+
send: async (view) => {
|
|
155
|
+
if (off)
|
|
156
|
+
return;
|
|
157
|
+
const payload = telegramDraftOf(view);
|
|
158
|
+
const turn = reply.turn;
|
|
159
|
+
if (payload === null || turn === null)
|
|
160
|
+
return;
|
|
161
|
+
const allowed = payload.text === "" ? args.paired() : await args.standing(turn);
|
|
162
|
+
if (!allowed || sender.closed)
|
|
163
|
+
return;
|
|
164
|
+
const outcome = await sendDraft(args.store, args.botId, args.transport, args.clock, args.chatId, args.draftId, payload);
|
|
165
|
+
if (outcome === "sent")
|
|
166
|
+
lastSentAt = Date.now();
|
|
167
|
+
else if (outcome === "paused")
|
|
168
|
+
spacing = Math.min(DRAFT_MAX_SPACING_MS, spacing * 2);
|
|
169
|
+
else if (outcome === "refused")
|
|
170
|
+
off = true;
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
const timer = setInterval(() => {
|
|
174
|
+
if (sender.closed || Date.now() - lastSentAt < keepAlive)
|
|
175
|
+
return;
|
|
176
|
+
const view = current();
|
|
177
|
+
if (view !== null)
|
|
178
|
+
sender.offer(view);
|
|
179
|
+
}, Math.max(50, Math.min(keepAlive, 5_000)));
|
|
180
|
+
timer.unref?.();
|
|
181
|
+
return {
|
|
182
|
+
get turn() { return reply.turn; },
|
|
183
|
+
onProgress: event => {
|
|
184
|
+
// The first event names the turn: Telegram's "Thinking…" shows at once, even before the view changes.
|
|
185
|
+
const first = reply.turn === null;
|
|
186
|
+
if (!reply.push(event) && !first)
|
|
187
|
+
return;
|
|
188
|
+
const view = current();
|
|
189
|
+
if (view !== null)
|
|
190
|
+
sender.offer(view);
|
|
191
|
+
},
|
|
192
|
+
close: async () => {
|
|
193
|
+
clearInterval(timer);
|
|
194
|
+
liveChats.delete(chat);
|
|
195
|
+
await sender.close();
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A message waiting for the lead (busy, or behind the owner's earlier messages): "Waiting for your earlier messages…"
|
|
201
|
+
* as a draft, refreshed only after the keep-alive interval, and never while this chat's running turn has its own
|
|
202
|
+
* draft. Carries no project data: the pairing and the claim fence it.
|
|
203
|
+
*/
|
|
204
|
+
export async function telegramWaitingDraft(args) {
|
|
205
|
+
const key = `${args.botId}:${args.draftId}`;
|
|
206
|
+
if (liveChats.has(`${args.botId}:${args.binding}`))
|
|
207
|
+
return;
|
|
208
|
+
const last = waitingDrafts.get(key);
|
|
209
|
+
if (last !== undefined && Date.now() - last < (args.timing?.keepAliveMs ?? DRAFT_KEEPALIVE_MS))
|
|
210
|
+
return;
|
|
211
|
+
if (!args.allowed())
|
|
212
|
+
return;
|
|
213
|
+
const payload = telegramDraftOf({ phase: "queued" });
|
|
214
|
+
if (await sendDraft(args.store, args.botId, args.transport, args.clock, args.chatId, args.draftId, payload) !== "sent")
|
|
215
|
+
return;
|
|
216
|
+
waitingDrafts.set(key, Date.now());
|
|
217
|
+
// Bounded: the oldest entries go first.
|
|
218
|
+
while (waitingDrafts.size > 500)
|
|
219
|
+
waitingDrafts.delete(waitingDrafts.keys().next().value);
|
|
220
|
+
}
|
|
221
|
+
/** The message is answered or failed: its waiting draft is forgotten. */
|
|
222
|
+
export function forgetWaitingDraft(botId, draftId) {
|
|
223
|
+
waitingDrafts.delete(`${botId}:${draftId}`);
|
|
224
|
+
}
|
package/dist/telegram.d.ts
CHANGED
|
@@ -258,3 +258,5 @@ export declare function digestText(rows: readonly Notification[], since: string
|
|
|
258
258
|
*/
|
|
259
259
|
/** At most this many of a chat's messages wait for the lead at once (the personal lead queue's bound). */
|
|
260
260
|
export declare const TELEGRAM_MAX_WAITING = 10;
|
|
261
|
+
/** A pushed update was kept for this bot: wake its bridge if it runs in this process (docs/plans/responsive-chat.md §3.1). */
|
|
262
|
+
export declare function wakeTelegramBridge(botId: string): void;
|
package/dist/telegram.js
CHANGED
|
@@ -47,6 +47,7 @@ import { phoneCommand, phoneStatus, phoneTaskView, PHONE_CONSOLE_FOOTER, PHONE_H
|
|
|
47
47
|
import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
|
|
48
48
|
import { envValue } from "./names.js";
|
|
49
49
|
import { applyProposalTap, processTelegramConversations, phoneLinkButton, replyContextFor, telegramConversationRepos, telegramRequestId, tooLongText, whichTaskText, } from "./telegram-lead.js";
|
|
50
|
+
import { telegramAck } from "./telegram-live.js";
|
|
50
51
|
import { askOf, parseTelegramUpdate, pickOf, readTelegramButtonData, readTelegramUpdate, telegramButton } from "./contracts/telegram-callback.js";
|
|
51
52
|
/** Read the enrolled project list on demand. No callback means no task data,
|
|
52
53
|
* never an implicit all-database ceiling. Shared by pass and embedded follower. */
|
|
@@ -119,7 +120,11 @@ export function keepPushedUpdate(store, botId, body, now) {
|
|
|
119
120
|
console.warn(`Ignoring a pushed Telegram update: ${update.issues.map(issue => issue.line).join("; ")}`);
|
|
120
121
|
return { ok: true, kept: false };
|
|
121
122
|
}
|
|
122
|
-
|
|
123
|
+
const kept = store.queueTelegramUpdate(botId, update.value.update_id, JSON.stringify(raw), now);
|
|
124
|
+
// The bridge in this process (if any) stops idling and drains it now; elsewhere it waits at most one cycle.
|
|
125
|
+
if (kept)
|
|
126
|
+
wakeTelegramBridge(botId);
|
|
127
|
+
return { ok: true, kept };
|
|
123
128
|
}
|
|
124
129
|
/**
|
|
125
130
|
* Environment wins, the credential file beside the database otherwise.
|
|
@@ -387,6 +392,21 @@ export async function followBridge(store, options) {
|
|
|
387
392
|
}
|
|
388
393
|
signal.addEventListener("abort", finish, { once: true });
|
|
389
394
|
}));
|
|
395
|
+
/** The push idle: the ordinary wait, ended early when this process keeps a push for this bot. */
|
|
396
|
+
const idle = (ms) => {
|
|
397
|
+
if (bridgeWokenEarly.delete(botId))
|
|
398
|
+
return Promise.resolve();
|
|
399
|
+
let wake = () => { };
|
|
400
|
+
const woken = new Promise(resolve => { wake = resolve; });
|
|
401
|
+
const wakers = bridgeWakers.get(botId) ?? new Set();
|
|
402
|
+
bridgeWakers.set(botId, wakers);
|
|
403
|
+
wakers.add(wake);
|
|
404
|
+
return Promise.race([wait(ms), woken]).finally(() => {
|
|
405
|
+
wakers.delete(wake);
|
|
406
|
+
if (wakers.size === 0 && bridgeWakers.get(botId) === wakers)
|
|
407
|
+
bridgeWakers.delete(botId);
|
|
408
|
+
});
|
|
409
|
+
};
|
|
390
410
|
const total = { cycles: 0, sent: 0, answered: 0, paired: 0, ignored: 0, problems: [] };
|
|
391
411
|
let commandsListed = false;
|
|
392
412
|
let failures = 0;
|
|
@@ -568,7 +588,8 @@ export async function followBridge(store, options) {
|
|
|
568
588
|
// While Telegram pushes (v98) there is no long poll to wait on: a pushed update waits at most this long.
|
|
569
589
|
const floor = pushing() ? PUSH_IDLE_MS : FOLLOW_IDLE_FLOOR_MS;
|
|
570
590
|
if (!signal.aborted && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0 && took < floor) {
|
|
571
|
-
|
|
591
|
+
// While Telegram pushes, a push kept by this process's hook ends the idle at once.
|
|
592
|
+
await (pushing() ? idle(floor - took) : wait(floor - took));
|
|
572
593
|
}
|
|
573
594
|
}
|
|
574
595
|
}
|
|
@@ -1886,6 +1907,19 @@ function applyConversation(context, update, effects) {
|
|
|
1886
1907
|
request: telegramRequestId(botId, binding.id, update.update_id), text, context: context_, taskId, sourceRun,
|
|
1887
1908
|
}, clock());
|
|
1888
1909
|
report.chatQueued = (report.chatQueued ?? 0) + 1;
|
|
1910
|
+
// Saved: 👀 on the owner's message right away (docs/plans/responsive-chat.md §3.1), once this update's transaction
|
|
1911
|
+
// has committed. It comes off when the answer or the failure notice is delivered. Never awaited: a burst of messages
|
|
1912
|
+
// waits its turn on the chat's own lane, not the drain.
|
|
1913
|
+
const gapMs = context.conversation?.liveTiming?.ackGapMs;
|
|
1914
|
+
effects.push(async () => {
|
|
1915
|
+
void telegramAck({
|
|
1916
|
+
store, botId, transport, clock, chatId: binding.chatId, messageId: String(message.message_id), on: true, ...(gapMs === undefined ? {} : { gapMs }),
|
|
1917
|
+
paired: () => {
|
|
1918
|
+
const live = store.liveTelegramBindingById(binding.id);
|
|
1919
|
+
return live !== null && live.approverGeneration === binding.approverGeneration && store.accountOf(live.approver)?.role === "approver";
|
|
1920
|
+
},
|
|
1921
|
+
});
|
|
1922
|
+
});
|
|
1889
1923
|
}
|
|
1890
1924
|
/** The picker's buttons: one task per row (its title and where it stands),
|
|
1891
1925
|
* carried by the task's numeric reference so the data fits Telegram's 64
|
|
@@ -2605,3 +2639,17 @@ function expiredDraftGuard(store, binding, decisionId, now) {
|
|
|
2605
2639
|
store.setNoteDraftState(Number(expired["id"]), "discarded");
|
|
2606
2640
|
return true;
|
|
2607
2641
|
}
|
|
2642
|
+
/** Bridges in this process waiting out their push idle, by bot: a kept push ends the wait early. */
|
|
2643
|
+
const bridgeWakers = new Map();
|
|
2644
|
+
/** Bots with a push kept while no bridge idled (it was mid-cycle): the next idle doesn't wait. */
|
|
2645
|
+
const bridgeWokenEarly = new Set();
|
|
2646
|
+
/** A pushed update was kept for this bot: wake its bridge if it runs in this process (docs/plans/responsive-chat.md §3.1). */
|
|
2647
|
+
export function wakeTelegramBridge(botId) {
|
|
2648
|
+
const wakers = bridgeWakers.get(botId);
|
|
2649
|
+
if (wakers === undefined || wakers.size === 0) {
|
|
2650
|
+
bridgeWokenEarly.add(botId);
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
for (const wake of wakers)
|
|
2654
|
+
wake();
|
|
2655
|
+
}
|
package/dist/toolroll-update.js
CHANGED
|
@@ -43,6 +43,7 @@ import { V151_ADDED_COLUMNS } from "./storage.js";
|
|
|
43
43
|
import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS, V147_ADDED_COLUMNS, V148_ADDED_COLUMNS, V150_ADDED_COLUMNS } from "./store.js";
|
|
44
44
|
import { V152_ADDED_COLUMNS } from "./code-map-store.js";
|
|
45
45
|
import { V153_ADDED_COLUMNS } from "./plain-schema.js";
|
|
46
|
+
import { V158_ADDED_COLUMNS } from "./lead-latency-schema.js";
|
|
46
47
|
import { isNewer, REGISTRY } from "./releases.js";
|
|
47
48
|
import { markNeverIndex } from "./never-index.js";
|
|
48
49
|
import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
|
|
@@ -429,7 +430,9 @@ const HISTORY_RULES = {
|
|
|
429
430
|
// v154 adds a table only (check_stamp): no saved row changes, so no rule is needed for it.
|
|
430
431
|
// v155 adds tables only (fleet-schema.ts): no saved row changes, so no rule is needed for it.
|
|
431
432
|
// v156 adds tables only (fallback chains): likewise.
|
|
432
|
-
|
|
433
|
+
// v158 (after v157's chat-app tables): a saved lead step reads no tool names and no failure detail, and a saved reply no route and no failure detail
|
|
434
|
+
// (NULL); the quick-answer switch is a new table.
|
|
435
|
+
...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS, V147_ADDED_COLUMNS, V148_ADDED_COLUMNS, V150_ADDED_COLUMNS, V151_ADDED_COLUMNS, V152_ADDED_COLUMNS, V153_ADDED_COLUMNS, V158_ADDED_COLUMNS))
|
|
433
436
|
.map(([table, rule]) => [table, { ...EARLIER_RULES[table], ...rule }])),
|
|
434
437
|
};
|
|
435
438
|
/** The tables a migration may remove whole (v115's removed features). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.97",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|