pi-ui-extend 0.1.64 → 0.1.66

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.
@@ -1,669 +0,0 @@
1
- /**
2
- * telegram-mirror: bidirectional Telegram mirror of the running pix session.
3
- *
4
- * Opt-in module configured via ~/.config/pi/pi-tools-suite.jsonc under the
5
- * `telegramMirror` key:
6
- *
7
- * "telegramMirror": {
8
- * "enabled": true,
9
- * "botToken": "123456789:ABCdef...", // from @BotFather
10
- * "chatId": 123456789 // numeric id allowed to control the bot
11
- * }
12
- *
13
- * Self-disables when the section is missing, `enabled: false`, `botToken` is
14
- * empty, or `chatId` is not an integer.
15
- *
16
- * === Multi-instance architecture ===
17
- *
18
- * Telegram allows exactly one concurrent getUpdates call per bot token, so N
19
- * pi processes can't each poll the same bot. This module elects a leader:
20
- *
21
- * - First pi to start binds `~/.pi/agent/extensions/pi-tools-suite/.run/
22
- * telegram-mirror.sock`. It owns the TG polling loop and a Multiplexer
23
- * that routes events/commands between Telegram and pi instances.
24
- * - Subsequent pi processes connect to the socket as followers. They
25
- * forward their pix events to the leader over IPC and execute commands
26
- * received from the leader.
27
- * - If the leader dies (socket close / heartbeat timeout), followers race
28
- * to take over; the first to bind wins.
29
- *
30
- * See IPC protocol in `./ipc.ts` and routing logic in `./multiplexer.ts`.
31
- *
32
- * The user picks the active instance in Telegram with /list and /use N.
33
- * Events from non-active instances are dropped (silent).
34
- */
35
-
36
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
37
- import { loadTelegramMirrorConfig } from "../config.js";
38
- import { ignoreStaleExtensionContextError, isAgentBusyRaceError } from "../context-usage.js";
39
- import { TelegramBot } from "./bot.js";
40
- import { captureAbortableContext, registerPixEventHandlers, type PixMirrorHooks, type RendererSink } from "./events.js";
41
- import { TurnRenderer, type RendererEvent } from "./renderer.js";
42
- import {
43
- DEFAULT_SOCKET_PATH,
44
- IpcServer,
45
- IpcSocket,
46
- buildInstanceId,
47
- tryAcquireLeadership,
48
- updateInstanceInfo,
49
- type IpcMessage,
50
- type InstanceInfo,
51
- } from "./ipc.js";
52
- import { Multiplexer, type LocalDispatch } from "./multiplexer.js";
53
-
54
- type Role = "starting" | "leader" | "follower";
55
-
56
- interface MirrorContext {
57
- abort(): void;
58
- isIdle(): boolean;
59
- hasPendingMessages(): boolean;
60
- compact(): void;
61
- currentDialog(): string | undefined;
62
- }
63
-
64
- interface SessionSnapshot {
65
- cwd: string;
66
- sessionId?: string;
67
- sessionFile?: string;
68
- sessionName?: string;
69
- }
70
-
71
- const RECONNECT_DELAY_MS = 2_000;
72
- const COMMAND_NAME = "telegram-mirror";
73
- const COMMAND_ALIAS = "tg";
74
- const COMMAND_OFF = "tg-off";
75
-
76
- export default function telegramMirror(pi: ExtensionAPI): void {
77
- const cfg = loadTelegramMirrorConfig();
78
- if (!cfg) {
79
- // Soft-disable: no logging at startup, the user hasn't configured
80
- // botToken/chatId yet. When the block exists, even with
81
- // enabled:false, still register /telegram-mirror and /tg so the
82
- // user can activate the bot explicitly by slash command.
83
- return;
84
- }
85
-
86
- const { botToken: token, chatId } = cfg;
87
- const { id: selfId, info: baseSelfInfo } = buildInstanceId();
88
- let selfInfo: InstanceInfo = baseSelfInfo;
89
-
90
- let role: Role = "starting";
91
- let bot: TelegramBot | undefined;
92
- let renderer: TurnRenderer | undefined;
93
- let multiplexer: Multiplexer | undefined;
94
- let server: IpcServer | undefined;
95
- let clientSocket: IpcSocket | undefined;
96
- let mirrorCtx: MirrorContext | undefined;
97
- let captureHooksInstalled = false;
98
- let reconnectTimer: NodeJS.Timeout | undefined;
99
- let shutdown = false;
100
- let disabled = false;
101
- let activationRequested = false;
102
-
103
- const log = (message: string): void => {
104
- // eslint-disable-next-line no-console
105
- console.error(`[telegram-mirror] ${message}`);
106
- };
107
-
108
- function staleSafe<T>(callback: () => T, fallback?: T): T | undefined {
109
- try {
110
- return callback();
111
- } catch (error) {
112
- ignoreStaleExtensionContextError(error);
113
- return fallback;
114
- }
115
- }
116
-
117
- function sendUserMessageSafely(text: string): void {
118
- try {
119
- pi.sendUserMessage(text);
120
- } catch (error) {
121
- if (isAgentBusyRaceError(error)) {
122
- pi.sendUserMessage(text, { deliverAs: "followUp" });
123
- return;
124
- }
125
- throw error;
126
- }
127
- }
128
-
129
- // Dispatch the leader uses to execute commands on its own pi session.
130
- const localDispatch: LocalDispatch = {
131
- sendUserMessage(text) {
132
- staleSafe(() => sendUserMessageSafely(text));
133
- },
134
- currentDialog() {
135
- return mirrorCtx?.currentDialog();
136
- },
137
- abort() {
138
- mirrorCtx?.abort();
139
- },
140
- compact() {
141
- mirrorCtx?.compact();
142
- },
143
- status() {
144
- const ctx = mirrorCtx;
145
- if (!ctx) return undefined;
146
- return {
147
- idle: staleSafe(() => ctx.isIdle(), true) ?? true,
148
- hasPending: staleSafe(() => ctx.hasPendingMessages(), false) ?? false,
149
- };
150
- },
151
- };
152
-
153
- // Renderer sink that routes events based on current role.
154
- const eventSink: RendererSink = {
155
- push(event: RendererEvent) {
156
- if (role === "leader" && multiplexer) {
157
- multiplexer.pushLocalEvent(event);
158
- } else if (role === "follower" && clientSocket && !clientSocket.isClosed) {
159
- clientSocket.send({ type: "event", from: selfId, event });
160
- }
161
- // starting / no IPC yet → drop; events between session_start and
162
- // IPC ready are lost (acceptable; rendering is lossy anyway).
163
- },
164
- };
165
-
166
- const hooks: PixMirrorHooks = {
167
- getRenderer: () => eventSink,
168
- describeInstance: (ctx) => describeInstance(ctx),
169
- notifyAgentSettled: () => undefined,
170
- };
171
-
172
- registerPixEventHandlers(pi, hooks);
173
- registerActivationCommand(COMMAND_NAME);
174
- registerActivationCommand(COMMAND_ALIAS);
175
- registerOffCommand();
176
-
177
- pi.on("session_start", async (_event, ctx) => {
178
- refreshCtx(ctx as ExtensionContext | undefined);
179
- refreshSelfInfo(ctx as ExtensionContext | undefined);
180
- if (!captureHooksInstalled) {
181
- captureHooksInstalled = true;
182
- captureAbortableContext(ctx as ExtensionContext | undefined, {
183
- captureAbort(fn) {
184
- mirrorCtx = mirrorCtx ?? makeCtx({ abort: fn });
185
- (mirrorCtx as Mutable<MirrorContext>).abort = fn;
186
- },
187
- captureIdle(fn) {
188
- mirrorCtx = mirrorCtx ?? makeCtx({ isIdle: fn });
189
- (mirrorCtx as Mutable<MirrorContext>).isIdle = fn;
190
- },
191
- capturePending(fn) {
192
- mirrorCtx = mirrorCtx ?? makeCtx({ hasPendingMessages: fn });
193
- (mirrorCtx as Mutable<MirrorContext>).hasPendingMessages = fn;
194
- },
195
- captureCompact(fn) {
196
- mirrorCtx = mirrorCtx ?? makeCtx({ compact: fn });
197
- (mirrorCtx as Mutable<MirrorContext>).compact = fn;
198
- },
199
- });
200
- }
201
- if (activationRequested) await start();
202
- });
203
-
204
- pi.on("agent_start", (_e, ctx) => {
205
- refreshCtx(ctx as ExtensionContext | undefined);
206
- refreshSelfInfo(ctx as ExtensionContext | undefined);
207
- });
208
- pi.on("before_agent_start", (_e, ctx) => {
209
- refreshCtx(ctx as ExtensionContext | undefined);
210
- refreshSelfInfo(ctx as ExtensionContext | undefined);
211
- });
212
-
213
- pi.on("session_shutdown", async (event) => {
214
- // On reload/fork the module will be reloaded in the same process —
215
- // keep IPC and bot alive so cluster leadership stays stable.
216
- if (event?.reason === "reload" || event?.reason === "fork") return;
217
- shutdown = true;
218
- await teardown();
219
- });
220
-
221
- async function start(): Promise<void> {
222
- activationRequested = true;
223
- if (shutdown) return;
224
- if (disabled) return;
225
- if (role !== "starting") return;
226
- if (reconnectTimer) return;
227
-
228
- try {
229
- const outcome = await tryAcquireLeadership(DEFAULT_SOCKET_PATH);
230
- if (outcome.role === "leader") {
231
- await becomeLeader(outcome.server);
232
- } else {
233
- becomeFollower(outcome.socket);
234
- }
235
- } catch (error) {
236
- log(`failed to acquire leadership: ${errorMessage(error)}`);
237
- scheduleReconnect();
238
- }
239
- }
240
-
241
- function scheduleReconnect(): void {
242
- if (shutdown) return;
243
- if (disabled) return;
244
- if (reconnectTimer) return;
245
- reconnectTimer = setTimeout(() => {
246
- reconnectTimer = undefined;
247
- void start();
248
- }, RECONNECT_DELAY_MS);
249
- }
250
-
251
- async function becomeLeader(server_: IpcServer): Promise<void> {
252
- role = "leader";
253
- server = server_;
254
-
255
- // Validate bot token before declaring leadership; if auth fails,
256
- // step down so another pi with a working config can try.
257
- let created: TelegramBot;
258
- try {
259
- created = new TelegramBot({ token, allowedChatId: chatId });
260
- } catch (error) {
261
- log(`failed to create bot: ${errorMessage(error)}`);
262
- await stepDown();
263
- return;
264
- }
265
-
266
- try {
267
- const me = await created.getMe();
268
- if (!me?.ok || !me.result) {
269
- log(`getMe rejected the token; stepping down`);
270
- created.abort();
271
- await stepDown();
272
- return;
273
- }
274
- await created.setMyCommands([
275
- { command: "menu", description: "Choose project/session" },
276
- { command: "list", description: "List pi sessions" },
277
- { command: "use", description: "Follow a session by number" },
278
- { command: "status", description: "Show followed session status" },
279
- { command: "clear", description: "Clear known bot messages" },
280
- { command: "abort", description: "Abort followed session" },
281
- { command: "compact", description: "Compact followed session" },
282
- { command: "disconnect", description: "Stop Telegram mirror" },
283
- { command: "help", description: "Show help" },
284
- ]).catch((error) => log(`setMyCommands failed: ${errorMessage(error)}`));
285
- bot = created;
286
- log(`connected as @${me.result.username} (leader) [${selfInfo.label}]`);
287
- } catch (error) {
288
- log(`getMe failed: ${errorMessage(error)}`);
289
- created.abort();
290
- await stepDown();
291
- return;
292
- }
293
-
294
- renderer = new TurnRenderer(bot, log);
295
- multiplexer = new Multiplexer({
296
- selfId,
297
- selfInfo,
298
- bot,
299
- renderer,
300
- server: server_,
301
- dispatch: localDispatch,
302
- standDown: clusterStandDown,
303
- log,
304
- });
305
- multiplexer.init();
306
-
307
- bot.startPolling(async (update) => {
308
- await multiplexer?.handleTelegramUpdate(update);
309
- });
310
- await bot.sendMessage(`✅ Telegram mirror active: ${selfInfo.label}`, {
311
- replyMarkup: { inline_keyboard: [[{ text: "🧭 Choose project/session", callback_data: "tg:list" }]] },
312
- }).catch((error) => log(`startup message failed: ${errorMessage(error)}`));
313
- await multiplexer.showActiveDialog();
314
- }
315
-
316
- function becomeFollower(socket: IpcSocket): void {
317
- role = "follower";
318
- clientSocket = socket;
319
- socket.send({ type: "register", info: selfInfo });
320
-
321
- socket.onMessage = (msg: IpcMessage) => {
322
- if (msg.type === "registered") {
323
- log(`registered with leader ${msg.leader.label} [${selfInfo.label}]`);
324
- return;
325
- }
326
- if (msg.type === "stand_down") {
327
- log(`received stand_down from leader; stopping`);
328
- disabled = true;
329
- if (reconnectTimer) {
330
- clearTimeout(reconnectTimer);
331
- reconnectTimer = undefined;
332
- }
333
- socket.close();
334
- return;
335
- }
336
- if (msg.type === "command") {
337
- void handleFollowerCommand(socket, msg.reqId, msg.command, msg.args);
338
- return;
339
- }
340
- if (msg.type === "query") {
341
- void handleFollowerQuery(socket, msg.reqId, msg.query);
342
- return;
343
- }
344
- // pings/pongs/acks handled in IpcSocket
345
- };
346
-
347
- socket.onClose = () => {
348
- log(`lost connection to leader; retrying in ${RECONNECT_DELAY_MS}ms`);
349
- clientSocket = undefined;
350
- role = "starting";
351
- scheduleReconnect();
352
- };
353
-
354
- socket.onError = (error) => {
355
- log(`ipc client error: ${error.message}`);
356
- };
357
- }
358
-
359
- async function handleFollowerCommand(socket: IpcSocket, reqId: string, command: string, args: unknown): Promise<void> {
360
- let ok = true;
361
- let error: string | undefined;
362
- try {
363
- switch (command) {
364
- case "sendUserMessage":
365
- staleSafe(() => sendUserMessageSafely(((args as { text?: string } | undefined)?.text ?? "")));
366
- break;
367
- case "abort":
368
- mirrorCtx?.abort();
369
- break;
370
- case "compact":
371
- mirrorCtx?.compact();
372
- break;
373
- default:
374
- ok = false;
375
- error = `unknown command: ${command}`;
376
- }
377
- } catch (err) {
378
- ok = false;
379
- error = errorMessage(err);
380
- }
381
- socket.send({ type: "command_ack", reqId, ok, error });
382
- }
383
-
384
- async function handleFollowerQuery(socket: IpcSocket, reqId: string, query: string): Promise<void> {
385
- if (query === "status") {
386
- const result = mirrorCtx
387
- ? { idle: mirrorCtx.isIdle(), hasPending: mirrorCtx.hasPendingMessages() }
388
- : { idle: true, hasPending: false };
389
- socket.send({ type: "query_reply", reqId, ok: true, result });
390
- return;
391
- }
392
- if (query === "dialog") {
393
- socket.send({ type: "query_reply", reqId, ok: true, result: { text: mirrorCtx?.currentDialog() ?? "" } });
394
- return;
395
- }
396
- socket.send({ type: "query_reply", reqId, ok: false, error: `unknown query: ${query}` });
397
- }
398
-
399
- function registerActivationCommand(name: string): void {
400
- pi.registerCommand(name, {
401
- description: name === COMMAND_NAME ? "Start Telegram mirror and show connection status" : "Alias for /telegram-mirror",
402
- handler: async (args, ctx) => {
403
- refreshCtx(ctx as ExtensionContext | undefined);
404
- refreshSelfInfo(ctx as ExtensionContext | undefined);
405
- const trimmed = args.trim();
406
- if (trimmed === "status") {
407
- notify(ctx, localStatusText(), "info");
408
- return;
409
- }
410
- if (trimmed === "stop" || trimmed === "disconnect") {
411
- await clusterStandDown();
412
- notify(ctx, "Telegram mirror stopped. Run /telegram-mirror to start again.", "info");
413
- return;
414
- }
415
- disabled = false;
416
- activationRequested = true;
417
- await start();
418
- notify(ctx, localStatusText(), "info");
419
- },
420
- });
421
- }
422
-
423
- function registerOffCommand(): void {
424
- pi.registerCommand(COMMAND_OFF, {
425
- description: "Stop Telegram mirror cluster",
426
- handler: async (_args, ctx) => {
427
- await clusterStandDown();
428
- notify(ctx, "Telegram mirror stopped. Run /tg or /telegram-mirror to start again.", "info");
429
- },
430
- });
431
- }
432
-
433
- async function stepDown(): Promise<void> {
434
- role = "starting";
435
- if (server) {
436
- try {
437
- await server.close();
438
- } catch {
439
- // ignore
440
- }
441
- server = undefined;
442
- }
443
- scheduleReconnect();
444
- }
445
-
446
- /**
447
- * Cluster-wide teardown triggered by `/disconnect` from Telegram.
448
- *
449
- * Leader: broadcast `stand_down` to every follower, wait briefly for
450
- * delivery, then tear down bot + server. The socket file is unlinked
451
- * in IpcServer.close so no follower can race into leadership after.
452
- *
453
- * Follower: just tear down local IPC. (Should not normally be reached
454
- * from /disconnect because TG commands arrive at the leader only.)
455
- *
456
- * `disabled` is set BEFORE broadcast/teardown so that any subsequent
457
- * socket close / reconnect attempt short-circuits and the cluster
458
- * stays down until pi is /reload-ed (which re-runs this module
459
- * factory and resets `disabled`).
460
- */
461
- async function clusterStandDown(): Promise<void> {
462
- if (disabled) return;
463
- disabled = true;
464
- activationRequested = false;
465
- if (server && role === "leader") {
466
- try {
467
- server.broadcast({ type: "stand_down" });
468
- } catch (error) {
469
- log(`broadcast stand_down failed: ${errorMessage(error)}`);
470
- }
471
- // Give followers a moment to receive stand_down and set their
472
- // own `disabled` flag before we close the sockets.
473
- await new Promise<void>((resolve) => setTimeout(resolve, 200));
474
- }
475
- await teardown();
476
- log(`disconnected (/disconnect). /reload in pi to resume.`);
477
- }
478
-
479
- async function teardown(): Promise<void> {
480
- if (reconnectTimer) {
481
- clearTimeout(reconnectTimer);
482
- reconnectTimer = undefined;
483
- }
484
- renderer?.reset();
485
- renderer = undefined;
486
- multiplexer?.close();
487
- multiplexer = undefined;
488
- if (bot) bot.abort();
489
- bot = undefined;
490
- if (clientSocket) clientSocket.close();
491
- clientSocket = undefined;
492
- if (server) {
493
- try {
494
- await server.close();
495
- } catch {
496
- // ignore
497
- }
498
- server = undefined;
499
- }
500
- role = "starting";
501
- }
502
-
503
- function refreshCtx(ctx: ExtensionContext | undefined): void {
504
- if (!ctx) return;
505
- if (!mirrorCtx) {
506
- mirrorCtx = makeCtx({
507
- abort: () => {
508
- staleSafe(() => ctx.abort());
509
- },
510
- isIdle: () => staleSafe(() => ctx.isIdle(), true) ?? true,
511
- hasPendingMessages: () => staleSafe(() => ctx.hasPendingMessages(), false) ?? false,
512
- compact: () => {
513
- staleSafe(() => ctx.compact());
514
- },
515
- currentDialog: () => currentDialogFromContext(ctx),
516
- });
517
- return;
518
- }
519
- const m = mirrorCtx as Mutable<MirrorContext>;
520
- m.abort = () => {
521
- staleSafe(() => ctx.abort());
522
- };
523
- m.isIdle = () => staleSafe(() => ctx.isIdle(), true) ?? true;
524
- m.hasPendingMessages = () => staleSafe(() => ctx.hasPendingMessages(), false) ?? false;
525
- m.compact = () => {
526
- staleSafe(() => ctx.compact());
527
- };
528
- m.currentDialog = () => currentDialogFromContext(ctx);
529
- }
530
-
531
- function refreshSelfInfo(ctx: ExtensionContext | undefined): void {
532
- const snapshot = sessionSnapshot(ctx);
533
- if (!snapshot) return;
534
- selfInfo = updateInstanceInfo(selfInfo, snapshot);
535
- multiplexer?.updateSelfInfo(selfInfo);
536
- if (role === "follower" && clientSocket && !clientSocket.isClosed) {
537
- clientSocket.send({ type: "instance_update", info: selfInfo });
538
- }
539
- }
540
-
541
- function describeInstance(ctx: ExtensionContext | undefined) {
542
- refreshSelfInfo(ctx);
543
- return {
544
- label: selfInfo.label,
545
- cwd: selfInfo.cwd,
546
- ...(selfInfo.sessionId ? { sessionId: selfInfo.sessionId } : {}),
547
- ...(selfInfo.sessionName ? { sessionName: selfInfo.sessionName } : {}),
548
- };
549
- }
550
-
551
- function localStatusText(): string {
552
- const status = role === "leader" ? "leader/polling" : role === "follower" ? "follower/connected" : "not connected";
553
- return `Telegram mirror: ${status}\n${selfInfo.label}${selfInfo.sessionName ? ` · ${selfInfo.sessionName}` : ""}`;
554
- }
555
- }
556
-
557
- type Mutable<T> = { -readonly [K in keyof T]: T[K] };
558
-
559
- function makeCtx(seed: Partial<MirrorContext>): MirrorContext {
560
- return {
561
- abort: seed.abort ?? (() => undefined),
562
- isIdle: seed.isIdle ?? (() => true),
563
- hasPendingMessages: seed.hasPendingMessages ?? (() => false),
564
- compact: seed.compact ?? (() => undefined),
565
- currentDialog: seed.currentDialog ?? (() => undefined),
566
- };
567
- }
568
-
569
- const TRANSCRIPT_MAX_MESSAGES = 40;
570
- const TRANSCRIPT_MAX_CHARS = 28_000;
571
-
572
- function currentDialogFromContext(ctx: ExtensionContext | undefined): string | undefined {
573
- if (!ctx) return undefined;
574
- let branch: unknown[];
575
- try {
576
- const maybeBranch = ctx.sessionManager.getBranch?.();
577
- branch = Array.isArray(maybeBranch) ? maybeBranch : [...(maybeBranch ?? [])];
578
- } catch {
579
- return undefined;
580
- }
581
-
582
- const messages: { role: "user" | "assistant"; text: string }[] = [];
583
- for (const entry of branch) {
584
- if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) continue;
585
- const role = entry.message.role === "user" || entry.message.role === "assistant" ? entry.message.role : undefined;
586
- if (!role) continue;
587
- const text = stripDcpMarkers(visibleMessageText(entry.message.content, role)).trim();
588
- if (!text) continue;
589
- messages.push({ role, text });
590
- }
591
- if (messages.length === 0) return undefined;
592
-
593
- const selected: string[] = [];
594
- let used = 0;
595
- let omitted = 0;
596
- for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
597
- const formatted = formatDialogMessage(messages[idx]);
598
- if (selected.length >= TRANSCRIPT_MAX_MESSAGES || (used + formatted.length > TRANSCRIPT_MAX_CHARS && selected.length > 0)) {
599
- omitted = idx + 1;
600
- break;
601
- }
602
- selected.unshift(formatted);
603
- used += formatted.length;
604
- }
605
- return `${omitted > 0 ? `… ${omitted} earlier message(s) omitted …\n\n` : ""}${selected.join("\n\n")}`;
606
- }
607
-
608
- function formatDialogMessage(message: { role: "user" | "assistant"; text: string }): string {
609
- return `${message.role === "user" ? "👤 You" : "🤖 Pi"}\n${message.text}`;
610
- }
611
-
612
- function stripDcpMarkers(text: string): string {
613
- return text
614
- .split(/\r?\n/u)
615
- .map((line) => line.replace(DCP_MARKER_RE, "").trimEnd())
616
- .join("\n")
617
- .replace(/[ \t]+\n/gu, "\n")
618
- .replace(/\n{3,}/gu, "\n\n")
619
- .trim();
620
- }
621
-
622
- const DCP_MARKER_RE = /\[dcp(?:-[\w-]+)?\]:\s*#\s*\([^)]*\)/giu;
623
-
624
- function visibleMessageText(value: unknown, role: "user" | "assistant"): string {
625
- if (typeof value === "string") return value;
626
- if (Array.isArray(value)) return value.map((item) => visibleContentBlockText(item, role)).filter(Boolean).join("\n");
627
- return visibleContentBlockText(value, role);
628
- }
629
-
630
- function visibleContentBlockText(value: unknown, role: "user" | "assistant"): string {
631
- if (typeof value === "string") return value;
632
- if (!isRecord(value)) return "";
633
- const type = typeof value.type === "string" ? value.type.toLowerCase() : "";
634
- if (type.includes("tool") || type.includes("thinking")) return "";
635
- if (typeof value.text === "string") return value.text;
636
- if (typeof value.content === "string") return value.content;
637
- if (Array.isArray(value.content)) return value.content.map((item) => visibleContentBlockText(item, role)).filter(Boolean).join("\n");
638
- if (role === "user" && (type.includes("image") || type.includes("file"))) return `[${type || "attachment"}]`;
639
- return "";
640
- }
641
-
642
- function isRecord(value: unknown): value is Record<string, unknown> {
643
- return value !== null && typeof value === "object" && !Array.isArray(value);
644
- }
645
-
646
- function sessionSnapshot(ctx: ExtensionContext | undefined): SessionSnapshot | undefined {
647
- if (!ctx) return undefined;
648
- try {
649
- const manager = ctx.sessionManager;
650
- return {
651
- cwd: manager.getCwd?.() ?? ctx.cwd,
652
- ...(manager.getSessionId?.() ? { sessionId: manager.getSessionId() } : {}),
653
- ...(manager.getSessionFile?.() ? { sessionFile: manager.getSessionFile() } : {}),
654
- ...(manager.getSessionName?.() ? { sessionName: manager.getSessionName() } : {}),
655
- };
656
- } catch (error) {
657
- ignoreStaleExtensionContextError(error);
658
- return undefined;
659
- }
660
- }
661
-
662
- function notify(ctx: { hasUI?: boolean; ui?: { notify?: (message: string, type?: "info" | "warning" | "error") => void } }, message: string, type: "info" | "warning" | "error" = "info"): void {
663
- if (ctx.hasUI) ctx.ui?.notify?.(message, type);
664
- else console.error(`[telegram-mirror] ${message}`);
665
- }
666
-
667
- function errorMessage(error: unknown): string {
668
- return error instanceof Error ? error.message : String(error);
669
- }