pi-braid-telegram 0.0.0-stage → 0.1.4
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/LICENSE +21 -0
- package/README.md +204 -2
- package/docs/live-update.md +190 -0
- package/docs/pause-control.md +151 -0
- package/docs/turn-complete-notify.md +110 -0
- package/package.json +60 -4
- package/src/index.ts +1853 -0
- package/src/lib/audio-normalize.ts +91 -0
- package/src/lib/decision-buttons.ts +574 -0
- package/src/lib/document.ts +203 -0
- package/src/lib/format.ts +809 -0
- package/src/lib/html-check.ts +280 -0
- package/src/lib/inbound-poller.ts +1248 -0
- package/src/lib/inbound-queue.ts +157 -0
- package/src/lib/local-time.ts +181 -0
- package/src/lib/notify/agent-delivery.ts +29 -0
- package/src/lib/notify/craft.ts +157 -0
- package/src/lib/notify/live-update.ts +716 -0
- package/src/lib/notify/model.ts +197 -0
- package/src/lib/notify/rate-limit.ts +44 -0
- package/src/lib/notify/turn-notify.ts +138 -0
- package/src/lib/pause-control.ts +425 -0
- package/src/lib/photo.ts +182 -0
- package/src/lib/poller-lock.ts +127 -0
- package/src/lib/session-role.ts +32 -0
- package/src/lib/state-paths.ts +164 -0
- package/src/lib/stt-keys.ts +211 -0
- package/src/lib/telegram-client.ts +699 -0
- package/src/lib/telegram-inbox.ts +203 -0
- package/src/lib/telegram-settings.ts +92 -0
- package/src/lib/tool-logic.ts +604 -0
- package/src/lib/transcribe.ts +574 -0
- package/src/lib/tts.ts +451 -0
- package/src/lib/venice-catalog-base-url.ts +45 -0
- package/src/lib/voice.ts +192 -0
- package/src/lib/watchdog-tunnel.ts +102 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LIVE-UPDATE tracked message.
|
|
3
|
+
*
|
|
4
|
+
* The live-update trick: instead of re-sending a new Telegram message for every
|
|
5
|
+
* progress update (which spams the owner's chat), the tracker sends ONE pinned
|
|
6
|
+
* message immediately when the owner's message arrives ("⚙️ Working… received
|
|
7
|
+
* your message"), then EDITS that same message in place as work progresses, and
|
|
8
|
+
* finally edits it to a "✅ Done" state and sends a SEPARATE beautiful
|
|
9
|
+
* markdown summary via the (key-ring-backed) crafting model.
|
|
10
|
+
*
|
|
11
|
+
* Telegram's edit affordance keeps the edited message pinned at its original
|
|
12
|
+
* position — no new-message spam, no reordering. `message_id` + `chat_id` are
|
|
13
|
+
* persisted to a small JSON state file (`.braid/telegram-live-update.json`,
|
|
14
|
+
* mirroring telegram-state.json) so a process reload can resume editing the
|
|
15
|
+
* same message instead of starting over.
|
|
16
|
+
*
|
|
17
|
+
* Design decisions (see docs/live-update.md for the full list):
|
|
18
|
+
* - default ON via BRAID_TG_LIVE_UPDATE_ENABLED (explicit "0" disables);
|
|
19
|
+
* - sliding-window rate limit (default 4/min) applies to start/update edits —
|
|
20
|
+
* finish() is terminal and always attempts its done-edit + final summary;
|
|
21
|
+
* - start() is idempotent: an existing tracked message (incl. A persisted one
|
|
22
|
+
* from a prior run) is reused, never a duplicate;
|
|
23
|
+
* - the tracker is pure-of-network: client calls and clock are injected so
|
|
24
|
+
* fixture tests run with zero real API calls.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
28
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
29
|
+
import { dirname } from "node:path";
|
|
30
|
+
import { SlidingWindowLimiter } from "./rate-limit.js";
|
|
31
|
+
import { defaultLiveUpdateFilePath } from "../state-paths.js";
|
|
32
|
+
import {
|
|
33
|
+
clearAgentDeliveredMessage,
|
|
34
|
+
wasAgentDeliveredMessage,
|
|
35
|
+
} from "./agent-delivery.js";
|
|
36
|
+
import {
|
|
37
|
+
sendMessageChunks,
|
|
38
|
+
resolveDeliveryFormat,
|
|
39
|
+
TELEGRAM_MAX_TEXT_LENGTH,
|
|
40
|
+
type SendChunksResult,
|
|
41
|
+
} from "../format.js";
|
|
42
|
+
import type { TelegramClient } from "../telegram-client.js";
|
|
43
|
+
|
|
44
|
+
// Types
|
|
45
|
+
|
|
46
|
+
/** Persisted shape: the tracked message that survives restarts. */
|
|
47
|
+
export interface LiveUpdateState {
|
|
48
|
+
messageId: number;
|
|
49
|
+
chatId: number | string;
|
|
50
|
+
startedAt: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface LiveUpdateCraftRich {
|
|
54
|
+
(text: string): Promise<{ text: string; crafted: boolean } | null>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LiveUpdateDeps {
|
|
58
|
+
client: Pick<TelegramClient, "sendMessage" | "editMessageText">;
|
|
59
|
+
token: string;
|
|
60
|
+
/**
|
|
61
|
+
* Target chat id, or a resolver (index.ts passes `resolveChatId` so the
|
|
62
|
+
* chat is captured from the first inbound owner message, not at load time).
|
|
63
|
+
*/
|
|
64
|
+
chatId: number | string | (() => number | string);
|
|
65
|
+
/** Path to the live-update JSON state file (test-fixture-friendly). */
|
|
66
|
+
stateFile: string;
|
|
67
|
+
/** Clock (injectable for deterministic rate-limit tests). */
|
|
68
|
+
now?(): number;
|
|
69
|
+
/** Max progress edits per window (default 4/min, BRAID_TG_LIVE_UPDATE_MAX_PER_MIN). */
|
|
70
|
+
maxPerWindow?: number;
|
|
71
|
+
windowMs?: number;
|
|
72
|
+
/** Edit/send body cap (Telegram's 4096). */
|
|
73
|
+
maxTextLength?: number;
|
|
74
|
+
log?: (
|
|
75
|
+
level: "debug" | "info" | "warn" | "error",
|
|
76
|
+
message: string,
|
|
77
|
+
...rest: unknown[]
|
|
78
|
+
) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface LiveUpdateStartOutcome {
|
|
82
|
+
started: boolean;
|
|
83
|
+
/** true when an existing tracked message was reused (no new send). */
|
|
84
|
+
reused: boolean;
|
|
85
|
+
messageId?: number;
|
|
86
|
+
reason?: "rate-limited" | "send-failed" | "no-chat";
|
|
87
|
+
error?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface LiveUpdateUpdateOutcome {
|
|
91
|
+
updated: boolean;
|
|
92
|
+
reason?: "rate-limited" | "no-tracked-message";
|
|
93
|
+
error?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface LiveUpdateFinishOutcome {
|
|
97
|
+
completed: boolean;
|
|
98
|
+
doneEdited: boolean;
|
|
99
|
+
/** The separate final summary was delivered. */
|
|
100
|
+
summarySent: boolean;
|
|
101
|
+
/** Number of summary messages sent (split-to-fit; 0 when none). */
|
|
102
|
+
summaryChunks?: number;
|
|
103
|
+
error?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface LiveUpdateTerminateOutcome {
|
|
107
|
+
terminated: boolean;
|
|
108
|
+
/** The tracked message was edited to the terminal "no report" state. */
|
|
109
|
+
terminalEdited: boolean;
|
|
110
|
+
error?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Defaults
|
|
114
|
+
|
|
115
|
+
export const DEFAULT_LIVE_UPDATE_MAX_PER_MIN = 4;
|
|
116
|
+
export const DEFAULT_LIVE_UPDATE_WINDOW_MS = 60_000;
|
|
117
|
+
|
|
118
|
+
/** Status line shown in the tracked message immediately on inbound. */
|
|
119
|
+
export const TRACKED_START_TEXT = "⚙️ Working… received your message";
|
|
120
|
+
|
|
121
|
+
/** Status line the tracked message is edited to when the turn completes. */
|
|
122
|
+
export const TRACKED_DONE_TEXT = "✅ Done — full report below.";
|
|
123
|
+
|
|
124
|
+
/** Done-edit variant when the agent ALREADY delivered its own report via
|
|
125
|
+
* telegram_send during the turn — finish() suppresses its separate summary
|
|
126
|
+
* (no duplicate message), so the honest pointer is "above", not "below". */
|
|
127
|
+
export const TRACKED_DONE_TEXT_REPORT_SENT = "✅ Done — report above.";
|
|
128
|
+
|
|
129
|
+
/** Status line the tracked message is edited to when a turn ends WITHOUT a
|
|
130
|
+
* report (abort/error). Honest, distinct from TRACKED_DONE_TEXT. */
|
|
131
|
+
export const TRACKED_TERMINAL_TEXT = "⏹️ Turn ended without a report";
|
|
132
|
+
|
|
133
|
+
/** Live-update state file path (mirrors inbound-poller state-file conventions).
|
|
134
|
+
* Null when no absolute braid root resolves; the caller refuses the tracker. */
|
|
135
|
+
export function defaultLiveUpdateStateFile(): string | null {
|
|
136
|
+
return defaultLiveUpdateFilePath();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// State persistence (mirrors inbound-poller loadState/saveState)
|
|
140
|
+
|
|
141
|
+
export async function loadLiveState(
|
|
142
|
+
stateFile: string,
|
|
143
|
+
): Promise<LiveUpdateState | undefined> {
|
|
144
|
+
try {
|
|
145
|
+
const raw = await readFile(stateFile, "utf8");
|
|
146
|
+
const parsed = JSON.parse(raw) as Partial<LiveUpdateState>;
|
|
147
|
+
const messageId = Number(parsed?.messageId);
|
|
148
|
+
if (!Number.isInteger(messageId) || messageId <= 0) return undefined;
|
|
149
|
+
const chatId = parsed?.chatId;
|
|
150
|
+
if (chatId == null) return undefined;
|
|
151
|
+
const startedAt = Number(parsed?.startedAt) || 0;
|
|
152
|
+
return { messageId, chatId, startedAt };
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function saveLiveState(
|
|
159
|
+
stateFile: string,
|
|
160
|
+
state: LiveUpdateState,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const dir = dirname(stateFile);
|
|
163
|
+
try {
|
|
164
|
+
await mkdir(dir, { recursive: true });
|
|
165
|
+
} catch {
|
|
166
|
+
// parent may already exist; write will surface real errors
|
|
167
|
+
}
|
|
168
|
+
await writeLiveStateAtomic(stateFile, state);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Persist one state document ATOMICALLY (write-temp-rename, same rationale as
|
|
173
|
+
* saveInboundQueue and savePauseState). A plain `writeFile` opens with O_TRUNC
|
|
174
|
+
* and writes afterwards, so a reader parsing the file inside that window — or
|
|
175
|
+
* a second saver completing its own truncate-then-write in between — observes
|
|
176
|
+
* a state that was never written: an empty file, or one document followed by
|
|
177
|
+
* the tail of a longer one. The cost is a parse error in the RESUMER (a
|
|
178
|
+
* persisted tracked message is then abandoned as "no tracked message") and in
|
|
179
|
+
* every concurrent reader of the file. The temp name is unique per write: a
|
|
180
|
+
* shared temp made concurrent savers fail each other's rename (ENOENT), while
|
|
181
|
+
* unique temps all land and the last rename wins with a whole snapshot.
|
|
182
|
+
*/
|
|
183
|
+
async function writeLiveStateAtomic(
|
|
184
|
+
stateFile: string,
|
|
185
|
+
state: LiveUpdateState,
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
const tmp = `${stateFile}.${randomUUID()}.tmp`;
|
|
188
|
+
await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
189
|
+
await rename(tmp, stateFile);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Remove the state file (called after finish — the tracked message is done). */
|
|
193
|
+
export async function clearLiveState(stateFile: string): Promise<void> {
|
|
194
|
+
try {
|
|
195
|
+
await writeLiveStateAtomic(stateFile, {
|
|
196
|
+
messageId: 0,
|
|
197
|
+
chatId: 0,
|
|
198
|
+
startedAt: 0,
|
|
199
|
+
});
|
|
200
|
+
} catch {
|
|
201
|
+
// best-effort; stale state is harmless (loadLiveState ignores messageId 0)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function clipText(text: string, maxLen: number): string {
|
|
206
|
+
const trimmed = text.trim();
|
|
207
|
+
if (trimmed.length <= maxLen) return trimmed;
|
|
208
|
+
return trimmed.slice(0, maxLen - 1).trimEnd() + "…";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Tracker
|
|
212
|
+
|
|
213
|
+
export interface LiveUpdateTracker {
|
|
214
|
+
/** Send the immediate "Working…" tracked message (or reuse an existing one). */
|
|
215
|
+
start(): Promise<LiveUpdateStartOutcome>;
|
|
216
|
+
/** Edit the tracked message in place (idempotent + rate-limited). */
|
|
217
|
+
update(text: string): Promise<LiveUpdateUpdateOutcome>;
|
|
218
|
+
/**
|
|
219
|
+
* Edit the tracked message to "✅ Done", then deliver a SEPARATE final
|
|
220
|
+
* markdown summary (crafted via `craftRich` when supplied/fit, else the raw
|
|
221
|
+
* `resultText` split-to-fit). Terminal — always attempts its edits.
|
|
222
|
+
*/
|
|
223
|
+
finish(opts: {
|
|
224
|
+
resultText: string;
|
|
225
|
+
craftRich?: LiveUpdateCraftRich;
|
|
226
|
+
/** Skip the separate final summary (the agent already delivered its own
|
|
227
|
+
* report via telegram_send this turn — no duplicate message). The
|
|
228
|
+
* tracked message is still edited to its done state. */
|
|
229
|
+
suppressSummary?: boolean;
|
|
230
|
+
}): Promise<LiveUpdateFinishOutcome>;
|
|
231
|
+
/**
|
|
232
|
+
* Terminate the tracked message on a terminal non-completed turn (abort /
|
|
233
|
+
* error — there is no report to summarize): edit it to "⏹️ Turn ended
|
|
234
|
+
* without a report" (best-effort) and ALWAYS clear the tracked state + state
|
|
235
|
+
* file so a reload never resurrects a stale "Working…" message. Terminal —
|
|
236
|
+
* non-throwing, mirrors finish()'s always-attempts semantics.
|
|
237
|
+
*/
|
|
238
|
+
terminate(): Promise<LiveUpdateTerminateOutcome>;
|
|
239
|
+
/** True while a tracked message is active (in-memory or persisted). */
|
|
240
|
+
isActive(): Promise<boolean>;
|
|
241
|
+
/** Current tracked state (undefined when none). */
|
|
242
|
+
getState(): Promise<LiveUpdateState | undefined>;
|
|
243
|
+
/**
|
|
244
|
+
* ACTIVE-WORK REFCOUNT: the "⚙️ Working…" indicator is gated
|
|
245
|
+
* on real in-flight work, not on a fragile turn_end mapping. Begin()
|
|
246
|
+
* increments — 0→1 pins (or reuses) the tracked message; end() decrements —
|
|
247
|
+
* 1→0 resolves it to the honest terminal state. The indicator completion
|
|
248
|
+
* NEVER depends on the agent calling a finalize tool alone: settle() at an
|
|
249
|
+
* idle/settled boundary force-resolves any still-pinned indicator.
|
|
250
|
+
*/
|
|
251
|
+
/** +1 active work unit; on 0→1 pins (or reuses) the tracked Working… message. */
|
|
252
|
+
begin(): Promise<LiveUpdateStartOutcome>;
|
|
253
|
+
/**
|
|
254
|
+
* -1 active work unit; on 1→0 resolves the indicator to the honest terminal
|
|
255
|
+
* state — "done" → ✅ Done + final summary; "no-report" → ⏹.
|
|
256
|
+
*/
|
|
257
|
+
end(
|
|
258
|
+
kind: LiveUpdateEndKind,
|
|
259
|
+
opts?: { resultText?: string; craftRich?: LiveUpdateCraftRich },
|
|
260
|
+
): Promise<LiveUpdateEndOutcome>;
|
|
261
|
+
/** Current active-work counter (synchronous). */
|
|
262
|
+
activeCount(): number;
|
|
263
|
+
/**
|
|
264
|
+
* Never-freeze safety-net. Call at an agent_settled / idle boundary: if the
|
|
265
|
+
* indicator is still pinned while active work is stale/absent (and not
|
|
266
|
+
* marked mayContinue), force the honest terminal state so it ALWAYS resolves
|
|
267
|
+
* — even when the agent ended its turn without calling a finalize path.
|
|
268
|
+
*/
|
|
269
|
+
settle(opts?: { mayContinue?: boolean }): Promise<LiveUpdateSettleOutcome>;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The two honest terminal outcomes a work unit can resolve the indicator to. */
|
|
273
|
+
export type LiveUpdateEndKind = "done" | "no-report";
|
|
274
|
+
|
|
275
|
+
export interface LiveUpdateEndOutcome {
|
|
276
|
+
/** true when this end() resolved the indicator (active counter reached 0). */
|
|
277
|
+
ended: boolean;
|
|
278
|
+
/** Active-work counter after this call. */
|
|
279
|
+
active: number;
|
|
280
|
+
/** Set when ended && the unit was "done": tracked message edited to ✅ Done. */
|
|
281
|
+
doneEdited?: boolean;
|
|
282
|
+
/** Set when ended && the unit was "done": the separate final summary went out. */
|
|
283
|
+
summarySent?: boolean;
|
|
284
|
+
/** Set when ended && the unit was "no-report": the terminal ⏹ edit was made. */
|
|
285
|
+
terminalEdited?: boolean;
|
|
286
|
+
error?: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface LiveUpdateSettleOutcome {
|
|
290
|
+
/** true when the safety-net forced an honest completion (never a freeze). */
|
|
291
|
+
resolved: boolean;
|
|
292
|
+
/** true when the indicator was already resolved (nothing left to force). */
|
|
293
|
+
alreadyResolved: boolean;
|
|
294
|
+
/** Active-work counter (0 after a forced resolve). */
|
|
295
|
+
active: number;
|
|
296
|
+
reason?:
|
|
297
|
+
| "already-resolved"
|
|
298
|
+
| "no-active-work"
|
|
299
|
+
| "might-continue"
|
|
300
|
+
| "resolved";
|
|
301
|
+
doneEdited?: boolean;
|
|
302
|
+
terminalEdited?: boolean;
|
|
303
|
+
error?: string;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function createLiveUpdateTracker(
|
|
307
|
+
deps: LiveUpdateDeps,
|
|
308
|
+
): LiveUpdateTracker {
|
|
309
|
+
const limiter = new SlidingWindowLimiter({
|
|
310
|
+
maxPerWindow: deps.maxPerWindow ?? DEFAULT_LIVE_UPDATE_MAX_PER_MIN,
|
|
311
|
+
windowMs: deps.windowMs ?? DEFAULT_LIVE_UPDATE_WINDOW_MS,
|
|
312
|
+
});
|
|
313
|
+
const maxTextLen = deps.maxTextLength ?? TELEGRAM_MAX_TEXT_LENGTH;
|
|
314
|
+
const now = deps.now ?? (() => Date.now());
|
|
315
|
+
const log = deps.log ?? (() => {});
|
|
316
|
+
const chatIdOf = (): number | string =>
|
|
317
|
+
typeof deps.chatId === "function"
|
|
318
|
+
? (deps.chatId as () => number | string)()
|
|
319
|
+
: (deps.chatId as number | string);
|
|
320
|
+
|
|
321
|
+
// Loaded lazily once per tracker from the state file; mutated in memory and
|
|
322
|
+
// persisted on every change (start sets it, finish clears it).
|
|
323
|
+
let tracked: LiveUpdateState | undefined;
|
|
324
|
+
let loaded = false;
|
|
325
|
+
|
|
326
|
+
// Active-work refcount: begin()/end() gate the indicator on
|
|
327
|
+
// real in-flight work; settle() is the never-freeze safety-net. `cycle` is
|
|
328
|
+
// per-pinning-cycle: "none" (no owner work begun), "active" (pinned, units
|
|
329
|
+
// in flight), "resolved" (this cycle already completed honestly).
|
|
330
|
+
// `pendingKind` is the outcome of the most recent end()'d unit — it decides
|
|
331
|
+
// the honest terminal state when the counter reaches 0 (or settle forces);
|
|
332
|
+
// `lastResultText`/`lastCraftRich` carry the latest completed answer into
|
|
333
|
+
// settle()'s forced-completion fallback.
|
|
334
|
+
let active = 0;
|
|
335
|
+
let cycle: "none" | "active" | "resolved" = "none";
|
|
336
|
+
let pendingKind: LiveUpdateEndKind = "done";
|
|
337
|
+
let lastResultText = "";
|
|
338
|
+
let lastCraftRich: LiveUpdateCraftRich | undefined;
|
|
339
|
+
|
|
340
|
+
async function ensureLoaded(): Promise<LiveUpdateState | undefined> {
|
|
341
|
+
if (!loaded) {
|
|
342
|
+
loaded = true;
|
|
343
|
+
tracked = await loadLiveState(deps.stateFile);
|
|
344
|
+
if (tracked)
|
|
345
|
+
log(
|
|
346
|
+
"info",
|
|
347
|
+
`live-update: resumed tracked message ${tracked.messageId} in chat ${tracked.chatId}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return tracked;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function setTracked(messageId: number, startedAt: number): void {
|
|
354
|
+
tracked = { messageId, chatId: chatIdOf(), startedAt };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function clearTracked(): Promise<void> {
|
|
358
|
+
tracked = undefined;
|
|
359
|
+
await clearLiveState(deps.stateFile);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Deliver the final summary — crafted markdown when fit, else raw text. */
|
|
363
|
+
async function deliverSummary(
|
|
364
|
+
resultText: string,
|
|
365
|
+
craftRich?: LiveUpdateCraftRich,
|
|
366
|
+
): Promise<SendChunksResult> {
|
|
367
|
+
const trimmed = resultText.trim();
|
|
368
|
+
if (!trimmed) return { ok: true, sent: 0, total: 0, messageIds: [] };
|
|
369
|
+
const summaryChatId = stateChatId() ?? chatIdOf();
|
|
370
|
+
if (craftRich) {
|
|
371
|
+
try {
|
|
372
|
+
const crafted = await craftRich(trimmed);
|
|
373
|
+
if (crafted?.crafted && crafted.text?.trim()) {
|
|
374
|
+
const body = crafted.text.trim();
|
|
375
|
+
// Resolve the delivery format from the crafted BODY itself:
|
|
376
|
+
// raw HTML tags → HTML mode (Markdown markers neutralized so nothing
|
|
377
|
+
// leaks); balanced Markdown → Markdown; anything else (or unbalanced
|
|
378
|
+
// delimiters) → plain text rather than a Telegram API error. The
|
|
379
|
+
// craft prompt already asks the model to emit the right markup; this
|
|
380
|
+
// guard catches the model drifting to the other format anyway. The
|
|
381
|
+
// detectMarkdown flag opts the no-request body into active Markdown
|
|
382
|
+
// detection.
|
|
383
|
+
const fmt = resolveDeliveryFormat(undefined, body, true);
|
|
384
|
+
if (!fmt.parseMode) {
|
|
385
|
+
log(
|
|
386
|
+
"warn",
|
|
387
|
+
"live-update: crafted summary has no clean format (unbalanced markdown) — delivering plain text",
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return sendMessageChunks(deps.client, {
|
|
391
|
+
token: deps.token,
|
|
392
|
+
chatId: summaryChatId,
|
|
393
|
+
text: fmt.text,
|
|
394
|
+
parseMode: fmt.parseMode,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
log(
|
|
398
|
+
"warn",
|
|
399
|
+
"live-update: rich summary crafting produced no usable text — sending raw result",
|
|
400
|
+
);
|
|
401
|
+
} catch (err) {
|
|
402
|
+
log(
|
|
403
|
+
"warn",
|
|
404
|
+
`live-update: rich summary crafting threw (${err instanceof Error ? err.message : String(err)}) — sending raw result`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return sendMessageChunks(deps.client, {
|
|
409
|
+
token: deps.token,
|
|
410
|
+
chatId: summaryChatId,
|
|
411
|
+
text: trimmed,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** chat id of the tracked message (persisted state), else undefined. */
|
|
416
|
+
function stateChatId(): number | string | undefined {
|
|
417
|
+
return tracked?.chatId;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Resolve the indicator to the honest terminal state for `kind`, zeroing
|
|
421
|
+
* the active-work counter. Shared by end() (1→0) and settle() (forced). */
|
|
422
|
+
async function resolveIndicator(kind: LiveUpdateEndKind): Promise<{
|
|
423
|
+
doneEdited: boolean;
|
|
424
|
+
summarySent: boolean;
|
|
425
|
+
terminalEdited: boolean;
|
|
426
|
+
error?: string;
|
|
427
|
+
}> {
|
|
428
|
+
if (kind === "no-report") {
|
|
429
|
+
const out = await tracker.terminate();
|
|
430
|
+
return {
|
|
431
|
+
doneEdited: false,
|
|
432
|
+
summarySent: false,
|
|
433
|
+
terminalEdited: out.terminalEdited,
|
|
434
|
+
error: out.error,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
// Suppress the auto summary when the agent already delivered an explicit
|
|
438
|
+
// report this turn (telegram_send marked the flag). Read-then-clear here —
|
|
439
|
+
// this is the single boundary both end() and settle() resolve through.
|
|
440
|
+
const suppressSummary = wasAgentDeliveredMessage();
|
|
441
|
+
clearAgentDeliveredMessage();
|
|
442
|
+
const out = await tracker.finish({
|
|
443
|
+
resultText: lastResultText,
|
|
444
|
+
craftRich: lastCraftRich,
|
|
445
|
+
suppressSummary,
|
|
446
|
+
});
|
|
447
|
+
return {
|
|
448
|
+
doneEdited: out.doneEdited,
|
|
449
|
+
summarySent: out.summarySent,
|
|
450
|
+
terminalEdited: false,
|
|
451
|
+
error: out.error,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const tracker: LiveUpdateTracker = {
|
|
456
|
+
async start(): Promise<LiveUpdateStartOutcome> {
|
|
457
|
+
// Fresh work unit → the agent-delivered flag from a prior turn must not
|
|
458
|
+
// leak into it (defensive: the resolver already clears on read).
|
|
459
|
+
clearAgentDeliveredMessage();
|
|
460
|
+
const existing = await ensureLoaded();
|
|
461
|
+
if (existing?.messageId) {
|
|
462
|
+
// Reuse the persisted/active tracked message (no new-message spam).
|
|
463
|
+
await deps.client.editMessageText(
|
|
464
|
+
deps.token,
|
|
465
|
+
existing.chatId,
|
|
466
|
+
existing.messageId,
|
|
467
|
+
clipText(TRACKED_START_TEXT, maxTextLen),
|
|
468
|
+
);
|
|
469
|
+
return { started: true, reused: true, messageId: existing.messageId };
|
|
470
|
+
}
|
|
471
|
+
const chatId = chatIdOf();
|
|
472
|
+
if (!chatId) return { started: false, reused: false, reason: "no-chat" };
|
|
473
|
+
if (!limiter.try(now()))
|
|
474
|
+
return { started: false, reused: false, reason: "rate-limited" };
|
|
475
|
+
const res = await deps.client.sendMessage(
|
|
476
|
+
deps.token,
|
|
477
|
+
chatId,
|
|
478
|
+
TRACKED_START_TEXT,
|
|
479
|
+
);
|
|
480
|
+
if (!res.ok)
|
|
481
|
+
return {
|
|
482
|
+
started: false,
|
|
483
|
+
reused: false,
|
|
484
|
+
reason: "send-failed",
|
|
485
|
+
error: res.error,
|
|
486
|
+
};
|
|
487
|
+
const messageId = res.messageId ?? 0;
|
|
488
|
+
if (messageId > 0) {
|
|
489
|
+
// Only a real message_id is editable — track + persist it. Without one
|
|
490
|
+
// (shouldn't happen for sendMessage, but defensively) the Working…
|
|
491
|
+
// message still went out; finish() falls back to summary-only.
|
|
492
|
+
setTracked(messageId, now());
|
|
493
|
+
try {
|
|
494
|
+
await saveLiveState(deps.stateFile, tracked!);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
log(
|
|
497
|
+
"warn",
|
|
498
|
+
`live-update: failed to persist tracked message: ${String(error)}`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
log(
|
|
502
|
+
"info",
|
|
503
|
+
`live-update: tracked message ${messageId} started in chat ${chatId}`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
started: true,
|
|
508
|
+
reused: false,
|
|
509
|
+
...(messageId > 0 ? { messageId } : {}),
|
|
510
|
+
};
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
async update(text): Promise<LiveUpdateUpdateOutcome> {
|
|
514
|
+
const state = await ensureLoaded();
|
|
515
|
+
if (!state?.messageId)
|
|
516
|
+
return { updated: false, reason: "no-tracked-message" };
|
|
517
|
+
if (!limiter.try(now()))
|
|
518
|
+
return { updated: false, reason: "rate-limited" };
|
|
519
|
+
const res = await deps.client.editMessageText(
|
|
520
|
+
deps.token,
|
|
521
|
+
state.chatId,
|
|
522
|
+
state.messageId,
|
|
523
|
+
clipText(text, maxTextLen),
|
|
524
|
+
);
|
|
525
|
+
return res.ok ? { updated: true } : { updated: false, error: res.error };
|
|
526
|
+
},
|
|
527
|
+
|
|
528
|
+
async finish({
|
|
529
|
+
resultText,
|
|
530
|
+
craftRich,
|
|
531
|
+
suppressSummary = false,
|
|
532
|
+
}): Promise<LiveUpdateFinishOutcome> {
|
|
533
|
+
const state = await ensureLoaded();
|
|
534
|
+
let doneEdited = false;
|
|
535
|
+
if (state?.messageId) {
|
|
536
|
+
// Terminal edit first: pin the visited message to its done state.
|
|
537
|
+
const done = await deps.client.editMessageText(
|
|
538
|
+
deps.token,
|
|
539
|
+
state.chatId,
|
|
540
|
+
state.messageId,
|
|
541
|
+
suppressSummary ? TRACKED_DONE_TEXT_REPORT_SENT : TRACKED_DONE_TEXT,
|
|
542
|
+
);
|
|
543
|
+
doneEdited = done.ok;
|
|
544
|
+
if (!done.ok)
|
|
545
|
+
log(
|
|
546
|
+
"warn",
|
|
547
|
+
`live-update: done-edit failed (${done.error ?? "unknown"})`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Then the SEPARATE final summary — SKIPPED when the agent already
|
|
552
|
+
// delivered its own report this turn (the tracked done-edit still pins
|
|
553
|
+
// the conversation; the report above IS the deliverable).
|
|
554
|
+
const summary = suppressSummary
|
|
555
|
+
? null
|
|
556
|
+
: await deliverSummary(resultText, craftRich);
|
|
557
|
+
|
|
558
|
+
// The tracked message served its purpose — clear it (state file too).
|
|
559
|
+
await clearTracked();
|
|
560
|
+
|
|
561
|
+
// Honest outcome: a suppressed summary is neither sent nor failed — the
|
|
562
|
+
// suppression itself is the intended terminal state.
|
|
563
|
+
const completed = doneEdited || (summary ? summarySentOk(summary) : true);
|
|
564
|
+
return {
|
|
565
|
+
completed,
|
|
566
|
+
doneEdited,
|
|
567
|
+
summarySent: summary?.ok ?? false,
|
|
568
|
+
summaryChunks: summary?.total ?? 0,
|
|
569
|
+
...(completed
|
|
570
|
+
? {}
|
|
571
|
+
: { error: "done-edit and summary send both failed" }),
|
|
572
|
+
};
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
async terminate(): Promise<LiveUpdateTerminateOutcome> {
|
|
576
|
+
const state = await ensureLoaded();
|
|
577
|
+
let terminalEdited = false;
|
|
578
|
+
if (state?.messageId) {
|
|
579
|
+
// Terminal edit first: pin the tracked message to "⏹️ Turn ended
|
|
580
|
+
// without a report" (never "✅ Done" — the turn did not complete).
|
|
581
|
+
const done = await deps.client.editMessageText(
|
|
582
|
+
deps.token,
|
|
583
|
+
state.chatId,
|
|
584
|
+
state.messageId,
|
|
585
|
+
TRACKED_TERMINAL_TEXT,
|
|
586
|
+
);
|
|
587
|
+
terminalEdited = done.ok;
|
|
588
|
+
if (!done.ok)
|
|
589
|
+
log(
|
|
590
|
+
"warn",
|
|
591
|
+
`live-update: terminal-edit failed (${done.error ?? "unknown"})`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
// ALWAYS clear the persisted state — a reload must never resurrect a
|
|
595
|
+
// stale "Working…" message, even when the edit failed or nothing was
|
|
596
|
+
// tracked. Non-throwing (state mutations cannot reject the caller).
|
|
597
|
+
await clearTracked();
|
|
598
|
+
return { terminated: true, terminalEdited };
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
async isActive(): Promise<boolean> {
|
|
602
|
+
const state = await ensureLoaded();
|
|
603
|
+
return Boolean(state?.messageId);
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
async getState(): Promise<LiveUpdateState | undefined> {
|
|
607
|
+
const state = await ensureLoaded();
|
|
608
|
+
return state ? { ...state } : undefined;
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
async begin(): Promise<LiveUpdateStartOutcome> {
|
|
612
|
+
const wasIdle = active === 0;
|
|
613
|
+
active += 1;
|
|
614
|
+
if (wasIdle) {
|
|
615
|
+
// 0→1: (re)pin the Working… indicator and start a fresh cycle.
|
|
616
|
+
cycle = "active";
|
|
617
|
+
pendingKind = "done";
|
|
618
|
+
lastResultText = "";
|
|
619
|
+
lastCraftRich = undefined;
|
|
620
|
+
return tracker.start();
|
|
621
|
+
}
|
|
622
|
+
// Already active — the indicator is pinned for every in-flight unit;
|
|
623
|
+
// just track the extra unit (no duplicate send). Reuse the existing
|
|
624
|
+
// tracked message; if the first pin attempt produced no editable message
|
|
625
|
+
// (send failed), retry the send so a Working… message does go out.
|
|
626
|
+
const existing = await ensureLoaded();
|
|
627
|
+
if (existing?.messageId) {
|
|
628
|
+
return { started: true, reused: true, messageId: existing.messageId };
|
|
629
|
+
}
|
|
630
|
+
return tracker.start();
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
async end(
|
|
634
|
+
kind: LiveUpdateEndKind,
|
|
635
|
+
opts?: { resultText?: string; craftRich?: LiveUpdateCraftRich },
|
|
636
|
+
): Promise<LiveUpdateEndOutcome> {
|
|
637
|
+
if (kind === "done") {
|
|
638
|
+
if (opts?.resultText) lastResultText = opts.resultText;
|
|
639
|
+
if (opts?.craftRich) lastCraftRich = opts.craftRich;
|
|
640
|
+
}
|
|
641
|
+
pendingKind = kind;
|
|
642
|
+
active = Math.max(0, active - 1);
|
|
643
|
+
if (active > 0 || cycle !== "active") return { ended: false, active };
|
|
644
|
+
// 1→0 on an active cycle: resolve to the honest terminal state.
|
|
645
|
+
cycle = "resolved";
|
|
646
|
+
const r = await resolveIndicator(pendingKind);
|
|
647
|
+
return {
|
|
648
|
+
ended: true,
|
|
649
|
+
active,
|
|
650
|
+
doneEdited: r.doneEdited,
|
|
651
|
+
summarySent: r.summarySent,
|
|
652
|
+
terminalEdited: r.terminalEdited,
|
|
653
|
+
error: r.error,
|
|
654
|
+
};
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
activeCount(): number {
|
|
658
|
+
return active;
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
async settle(opts?: {
|
|
662
|
+
mayContinue?: boolean;
|
|
663
|
+
}): Promise<LiveUpdateSettleOutcome> {
|
|
664
|
+
if (cycle === "resolved") {
|
|
665
|
+
return {
|
|
666
|
+
resolved: false,
|
|
667
|
+
alreadyResolved: true,
|
|
668
|
+
active,
|
|
669
|
+
reason: "already-resolved",
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (opts?.mayContinue) {
|
|
673
|
+
return {
|
|
674
|
+
resolved: false,
|
|
675
|
+
alreadyResolved: false,
|
|
676
|
+
active,
|
|
677
|
+
reason: "might-continue",
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
const state = await ensureLoaded();
|
|
681
|
+
const hasTracked = Boolean(state?.messageId);
|
|
682
|
+
if (cycle !== "active" && !hasTracked && active === 0) {
|
|
683
|
+
return {
|
|
684
|
+
resolved: false,
|
|
685
|
+
alreadyResolved: false,
|
|
686
|
+
active,
|
|
687
|
+
reason: "no-active-work",
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
// Safety-net: the run is idle — force the honest completion so the
|
|
691
|
+
// indicator NEVER freezes at "Working…". An in-flight
|
|
692
|
+
// THIS-SESSION cycle settles with its last unit's outcome (✅ Done by
|
|
693
|
+
// default — the run finished); a resurrected stale pinned message (no
|
|
694
|
+
// active work this session) is a turn that ended without a report → ⏹.
|
|
695
|
+
const wasActive = cycle === "active";
|
|
696
|
+
cycle = "resolved";
|
|
697
|
+
active = 0;
|
|
698
|
+
const r = await resolveIndicator(wasActive ? pendingKind : "no-report");
|
|
699
|
+
return {
|
|
700
|
+
resolved: true,
|
|
701
|
+
alreadyResolved: false,
|
|
702
|
+
active,
|
|
703
|
+
reason: "resolved",
|
|
704
|
+
doneEdited: r.doneEdited,
|
|
705
|
+
terminalEdited: r.terminalEdited,
|
|
706
|
+
error: r.error,
|
|
707
|
+
};
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
return tracker;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function summarySentOk(summary: SendChunksResult): boolean {
|
|
715
|
+
return summary.ok && summary.sent > 0;
|
|
716
|
+
}
|