Files
Coder 25d4e0e113
All checks were successful
build-and-publish / build-test (push) Successful in 29s
build-and-publish / publish (push) Has been skipped
feat(telegram-remote): ship lib/ code, add voice transcription, chat pagination, stream photos
- Add lib/ (plain-ESM plugin code, no build step) so the published
  tarball actually contains the plugin; un-ignore lib/ for this package
- Sync README: voice/audio transcription via home-lab Whishper (default
  large-v2, device cuda, language auto), /chats pagination and subagent
  hiding, whisper* config keys, updated security note
2026-08-27 22:20:53 +07:00

884 lines
35 KiB
JavaScript

/**
* Live streaming engine: subscribes to the harness mux event stream (the same
* stream the web UI uses) and renders agent work into Telegram as ONE
* live-edited message per reply, in a clean structured layout:
*
* 🔵 THINKING — live reasoning preview (beginning + end), quote-boxed
* & italic so it can never be confused with the answer
* 🟢 TOOLS — every tool call with live status (⋯ running / ✅ done / ⚠️ error)
* 🟣 REPLY — live token stream once text starts
* ⏳ n s — elapsed timer footer
*
* Every header is a colored bullet + bold caps; thinking content sits in
* a <blockquote> card (accent-colored bar) with italic text.
*
* Final edit keeps the same structure but renders the full formatted markdown
* (code, bold, …), with long replies split into follow-up messages.
*
* Also handles interactive frames: agent questions (ask_user_question) and
* tool-approval requests, forwarded to Telegram with buttons.
*/
import { esc } from "./bot.js";
import { callApi, contentText, hasText, markdownToHtml, sleep, truncate } from "./util.js";
// Colored bullet + bold caps: every section gets its own accent color.
// (Telegram bots cannot set text colors, so the colored emoji bullets and
// the blockquote accent bar are the strongest color levers available.)
const THINKING_HEADER = "🔵 <b>THINKING</b>";
const TOOLS_HEADER = "🟢 <b>TOOLS</b>";
const REPLY_HEADER = "🟣 <b>REPLY</b>";
// Long underscore rule drawn under every section header — heavy
// box-drawing line with a sparkle star in the middle (user's pick: B).
const HEADER_RULE = "━━━━━━━━━━━ ✦ ━━━━━━━━━━━";
const DIVIDER = "─────── ⋆⋅☆⋅⋆ ───────";
const EDIT_INTERVAL_MS = 800;
const REASONING_HEAD = 350; // beginning of thinking shown in the bubble
const REASONING_TAIL = 350; // end of thinking shown in the bubble
const TEXT_PREVIEW = 2800;
const TOOLS_MAX = 6;
const EDIT_MAX = 3800; // Telegram edit limit is 4096; stay under for HTML overhead
const CHUNK_MAX = 4000;
const DONE_STREAM_TTL_MS = 5 * 60_000; // drop finished streams after 5min
const MAX_PHOTOS_PER_STREAM = 5; // photos delivered per stream (per turn)
const MAX_URL_IMAGE_BYTES = 10 * 1024 * 1024; // Telegram sendPhoto cap (10 MB)
const URL_FETCH_TIMEOUT_MS = 30_000;
/** Drop long-finished streams so the global map stays bounded. */
function pruneStreams(streams) {
const now = Date.now();
for (const [chatId, stream] of streams) {
if (stream.done && now - (stream.finishedAt ?? now) > DONE_STREAM_TTL_MS) {
streams.delete(chatId);
}
}
}
/**
* Start the live mux subscription loop, or refresh the shared engine's live
* handler references on reload. Idempotent: repeated calls never create a
* second subscription (the engine is shared via globalThis).
* @param {object} state - PluginState (ctx, bot, log).
* @returns {boolean} true when the subscription loop is running (or already
* running from an earlier apply); false when apiProxy or apiProxy.events.mux
* is not yet available.
*/
export function setupStreaming(state) {
const api = state.ctx.get("apiProxy");
if (!api?.events?.mux) {
state.log("streaming unavailable: no apiProxy.events.mux");
return false;
}
// Engine lives in globalThis so HMR reloads REPLACE the handler reference
// while keeping ONE mux subscription. (The old per-apply guard froze the
// first-loaded code in memory — later fixes never reached the running bot.)
const engine = (globalThis.__dshTgStreamEngine ??= {
streams: new Map(),
started: false,
abort: null,
scheduleEdit: null,
handleFrame: null,
});
const streams = engine.streams;
// One shared edit timer for all chats: chunks arriving within the same
// interval coalesce into a single Telegram edit instead of one timer each.
let editTimer = null;
const scheduleEdit = (chatId) => {
const stream = streams.get(chatId);
if (!stream || stream.msgId == null || stream.done || stream.editing) return;
stream.editing = true;
if (editTimer == null) {
editTimer = setTimeout(() => {
editTimer = null;
for (const current of streams.values()) {
if (!current.editing) continue;
current.editing = false;
if (current.msgId == null || current.done) continue;
current.lastEdit = Date.now();
state.bot.editMessageText(current.chatId, current.msgId, buildPreview(current)).catch(() => {});
}
}, EDIT_INTERVAL_MS);
}
};
// Publish the LIVE handler: every reload replaces these with the newest
// code, and the single subscription loop below dispatches through them.
engine.scheduleEdit = scheduleEdit;
engine.handleFrame = (frame) => handleFrame(state, streams, scheduleEdit, frame);
// Self-healing subscription: if the mux stream ever closes (even cleanly
// or with an error), reconnect after 5s instead of dying silently —
// a dead stream used to mean the bot stopped delivering replies forever.
const connect = (abort) => {
const run = async () => {
while (!abort.signal.aborted) {
const subscribedAt = Date.now();
try {
const frames = api.events.mux({ rpcId: "tg-mux-" + Math.random().toString(36).slice(2, 10), payload: {} }, abort.signal);
state.log("mux connected");
for await (const frame of frames) {
// Replay guard: after a reconnect the stream may re-deliver old
// events; only frames newer than the subscription (15s grace)
// are treated as live.
const ft = frame?.payload?.event?.time ?? 0;
if (ft > 0 && ft < subscribedAt - 15000) continue;
try {
engine.handleFrame?.(frame);
} catch (error) {
state.log("mux frame handler failed: " + (error.stack ?? error.message));
}
}
state.log("mux stream closed — reconnecting in 5s");
} catch (error) {
if (abort.signal.aborted) return;
state.log("mux stream ended: " + (error.message ?? error) + " — reconnecting in 5s");
}
await sleep(5000);
}
};
return run();
};
if (!engine.started || !engine.loopAlive) {
// Restart a dead loop (or replace a half-dead one) with a fresh
// controller; each loop captures its own abort signal so superseded
// loops exit cleanly and never double-subscribe.
engine.abort?.abort();
const ctrl = new AbortController();
const token = {};
engine.abort = ctrl;
engine.loopToken = token;
engine.started = true;
engine.loopAlive = true;
engine.loopPromise = connect(ctrl).catch(() => {}).finally(() => {
if (engine.loopToken === token) {
engine.loopAlive = false;
engine.loopToken = null;
engine.loopPromise = null;
}
});
}
return true;
}
function handleFrame(state, streams, scheduleEdit, frame) {
const payload = frame?.payload;
if (!payload || typeof payload !== "object") return;
switch (payload.type) {
case "session/event":
handleSessionEvent(state, streams, scheduleEdit, payload);
break;
case "question/requested":
handleQuestionRequested(state, payload, frame.rpcId);
break;
case "approval/requested":
handleApprovalRequested(state, payload, frame.rpcId);
break;
default:
break;
}
}
function interestedChats(state, sessionId) {
const chats = [];
for (const [chatId, chat] of state.chats) {
if (chat.notify === "off") continue;
if (chat.notify === "session" && sessionId !== chat.sessionId) continue;
chats.push({ chatId, chat, active: sessionId === chat.sessionId });
}
return chats;
}
function handleSessionEvent(state, streams, scheduleEdit, frame) {
const sessionId = frame.sessionId;
const event = frame.event;
if (!sessionId || !event) return;
const chats = interestedChats(state, sessionId);
if (chats.length === 0) return;
switch (event.type) {
case "turn/start": {
for (const { chatId, chat, active } of chats) {
if (!active) continue;
// Reuse a live stream from a previous module instance (HMR reload
// mid-turn) instead of posting a duplicate "Thinking" message. The
// posting flag guards against concurrent mux subscribers racing while
// the first send is still in flight (msgId not yet assigned).
const existing = streams.get(chatId);
if (existing && !existing.done && existing.sessionId === sessionId) {
existing.startedAt = Date.now();
existing.editing = false;
if (existing.msgId != null) scheduleEdit(chatId);
continue;
}
if (existing && !existing.done && existing.posting) continue;
const stream = {
chatId,
sessionId,
msgId: null,
posting: false,
text: "",
reasoning: "",
tools: new Map(),
sentImages: new Set(),
startedAt: Date.now(),
lastEdit: 0,
editing: false,
done: false,
};
pruneStreams(streams);
streams.set(chatId, stream);
stream.posting = true;
state.log("streaming turn → chat " + chatId + " session " + sessionId.slice(0, 12));
state.bot.send(chatId, THINKING_HEADER + "\n" + HEADER_RULE + "\n<i>⏳ 0s</i>", { replyToMessageId: chat.lastUserMessageId })
.then((ids) => {
stream.posting = false;
const current = streams.get(chatId);
if (current === stream && current.msgId == null) current.msgId = ids[0];
state.log("thinking stub posted: chat " + chatId + " msg " + ids[0]);
})
.catch((e2) => { stream.posting = false; state.log("stub send failed: " + (e2?.message ?? e2)); });
}
break;
}
case "assistant/chunk": {
const chunk = event.data?.chunk;
if (!chunk) break;
for (const { chatId, active } of chats) {
if (!active) continue;
const stream = streams.get(chatId);
if (!stream || stream.done) continue;
applyChunk(stream, chunk);
scheduleEdit(chatId);
}
break;
}
case "reasoning-chunks": {
// Batched reasoning deltas from the mux stream (the high-frequency
// carrier for thinking text). texts[] are individual delta strings.
const texts = event.data?.texts;
if (!Array.isArray(texts) || texts.length === 0) break;
const joined = texts.join("");
if (!joined) break;
for (const { chatId, active } of chats) {
if (!active) continue;
const stream = streams.get(chatId);
if (!stream || stream.done) continue;
stream.reasoning += joined;
if (stream.reasoning.length > 20000) stream.reasoning = stream.reasoning.slice(-20000);
scheduleEdit(chatId);
}
break;
}
case "assistant/message": {
// Only real text blocks count as the reply. Tool-call-only messages
// (the model asking to use tools) are already shown live in the Tools
// section — finalizing on them would print raw JSON as the reply.
const blocks = event.data?.message?.content;
const imageRefs = collectImageRefs(blocks);
if (!hasText(blocks)) {
// Image-only message (no text bubble): still deliver the photos so
// the turn's images are never silently dropped.
if (imageRefs.length > 0) {
for (const { chatId, active } of chats) {
if (!active) continue;
sendImageRefs(state, chatId, streams.get(chatId) ?? null, sessionId, imageRefs, "🖼");
}
}
break;
}
const content = contentText(blocks, { textOnly: true });
if (!content) break;
// Backup capture: the final message also carries full reasoning blocks,
// so the thinking snippet survives even if delta events were missed.
const reasoningText = (Array.isArray(blocks) ? blocks : [])
.filter((b) => b?.type === "reasoning")
.map((b) => (typeof b.text === "string" ? b.text : ""))
.join("\n")
.trim();
for (const { chatId, active } of chats) {
if (!active) continue;
const stream = streams.get(chatId);
state.log("assistant/message → chat " + chatId + " stream=" + (stream ? "yes" : "no"));
if (stream) {
if (reasoningText && reasoningText.length > stream.reasoning.length) stream.reasoning = reasoningText;
stream.done = true;
stream.finishedAt = Date.now();
finalizeReply(state, stream, content);
} else {
state.bot.send(chatId, REPLY_HEADER + "\n" + markdownToHtml(truncate(content, 3600)), { replyToMessageId: state.chatState(chatId).lastUserMessageId })
.catch((e) => state.log("fallback send failed: " + (e?.message ?? e)));
}
}
// Photos after the text is finalized: refs already sent via tool/result
// are skipped by the per-stream dedupe.
if (imageRefs.length > 0) {
for (const { chatId, active } of chats) {
if (!active) continue;
const stream = streams.get(chatId);
if (stream) sendImageRefs(state, chatId, stream, sessionId, imageRefs);
}
}
break;
}
case "tool/call": {
const callId = event.data?.callId;
const name = event.data?.name ?? "?";
for (const { chatId, active } of chats) {
if (!active) continue;
const stream = streams.get(chatId);
if (stream && !stream.done) {
stream.tools.set(callId, { name, status: "running" });
scheduleEdit(chatId);
}
}
break;
}
case "tool/result": {
const content = event.data?.message?.content;
const imageRefs = collectImageRefs(content);
const imageUrls = imageUrlsInContent(content);
const hasImages = imageRefs.length > 0 || imageUrls.length > 0;
const callId = event.data?.message?.source?.callId;
if (callId) {
const isError = !!event.data?.message?.isError;
for (const { chatId, active } of chats) {
if (!active) continue;
const stream = streams.get(chatId);
if (!stream || stream.done) continue;
const tool = stream.tools.get(callId);
if (tool) {
tool.status = isError ? "error" : "done";
scheduleEdit(chatId);
}
}
}
// Deliver images as soon as the tool result arrives; refs and URLs are
// deduped per stream (a URL echoed in the final text is NOT re-sent —
// assistant/message only collects refs).
if (hasImages) {
for (const { chatId, active } of chats) {
if (!active) continue;
const stream = streams.get(chatId);
if (!stream) continue;
sendImageRefs(state, chatId, stream, sessionId, imageRefs);
sendImageUrls(state, chatId, stream, imageUrls);
}
}
break;
}
case "turn/end": {
const reason = event.data?.reason;
if (!reason) break;
for (const { chatId, active } of chats) {
if (!active) continue;
const stream = streams.get(chatId);
if (!stream) continue;
if (reason.kind === "interrupted") {
if (!stream.done) {
stream.done = true;
state.bot.editMessageText(chatId, stream.msgId, "⏹ <b>Stopped</b>").catch(() => {});
}
} else if (reason.kind !== "completed" && !stream.done) {
stream.done = true;
const detail = reason.error?.message ? ": " + truncate(String(reason.error.message), 300) : "";
state.bot.editMessageText(chatId, stream.msgId, "⚠️ <b>" + esc(reason.kind) + "</b>" + esc(detail)).catch(() => {});
}
}
break;
}
case "user/message": {
const content = contentText(event.data?.content);
if (!content) break;
if (state.isRecentPrompt(sessionId, content)) break;
for (const { chatId, active } of chats) {
if (active) continue;
state.bot.send(chatId, "🧑 " + truncate(content, 600), { replyToMessageId: state.chatState(chatId).lastUserMessageId }).catch(() => {});
}
break;
}
default:
break;
}
}
/** Final, structured reply: edit the live message, then send overflow chunks. */
function finalizeReply(state, stream, content) {
const elapsed = Math.max(1, Math.round((Date.now() - stream.startedAt) / 1000));
const stats = [];
if (stream.tools.size) stats.push("🔧 " + stream.tools.size + (stream.tools.size === 1 ? " tool" : " tools"));
stats.push("⏱ " + elapsed + "s");
const footer = DIVIDER + "\n<i>" + stats.join(" · ") + "</i>";
// The live bubble is the SAME message as the final one — so the thinking
// section stays in it, above the answer, forever (users scroll back to it).
const thinking = stream.reasoning
? THINKING_HEADER + "\n" + HEADER_RULE + "\n" + reasoningSnippet(stream.reasoning) + "\n\n"
: "";
const full = thinking + REPLY_HEADER + "\n" + HEADER_RULE + "\n" + markdownToHtml(content) + "\n" + footer;
const chunks = splitByLength(full, CHUNK_MAX);
const fail = (e) => state.log("finalize send failed: " + (e?.message ?? e));
let head = chunks[0];
const tail = chunks.slice(1);
if (stream.msgId != null) {
// The live bubble is edited in place. If the head exceeds the edit
// limit, cut it at a line boundary and send the remainder separately —
// content is NEVER dropped (the old code silently lost the tail).
if (head.length > EDIT_MAX) {
let cut = head.lastIndexOf("\n", EDIT_MAX - 60);
if (cut < EDIT_MAX * 0.6) cut = EDIT_MAX - 60;
tail.unshift(head.slice(cut));
head = head.slice(0, cut) + "\n…";
}
state.bot.editMessageText(stream.chatId, stream.msgId, head).catch(fail);
} else {
state.bot.send(stream.chatId, head, { replyToMessageId: state.chatState(stream.chatId).lastUserMessageId }).catch(fail);
}
for (const chunk of tail) {
state.bot.send(stream.chatId, chunk).catch(fail);
}
}
function splitByLength(text, max) {
if (text.length <= max) return [text];
const chunks = [];
let rest = text;
while (rest.length > max) {
let cut = rest.lastIndexOf("\n", max);
if (cut <= 0) cut = max;
chunks.push(rest.slice(0, cut));
rest = rest.slice(cut);
}
if (rest.length > 0) chunks.push(rest);
return chunks;
}
/* ── image delivery ── */
/**
* Collect every image reference inside a content array, descending into
* nested tool-result content the way the harness live attachment route does.
* @param {unknown} content - an event content array (or nested tool-result content).
* @returns {Array<object>} unique ImageAttachmentRef values, first-seen order.
*/
function collectImageRefs(content) {
const refs = new Map();
if (!Array.isArray(content)) return [];
const pending = [...content];
while (pending.length > 0) {
const value = pending.pop();
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
if (value.type === "image" && typeof value.attachment === "object" && value.attachment !== null) {
const ref = value.attachment;
if (typeof ref.attachmentId === "string") refs.set(ref.attachmentId, ref);
}
if (Array.isArray(value.content)) {
for (const item of value.content) pending.push(item);
}
}
return [...refs.values()];
}
// Matches http(s) URLs whose text contains .png/.jpg/.jpeg/.webp/.gif
// (case-insensitive), including extension-in-query URLs such as
// /view?filename=abc.png&type=output; the trailing run also absorbs any
// query/params after the extension.
const IMAGE_URL_RE = /https?:\/\/[^\s"'<>`)]+\.(?:png|jpe?g|webp|gif)[^\s"'<>`)]*/gi;
/**
* Collect every http(s) image URL inside a content array, from text blocks,
* descending into nested tool-result content. Returns unique URLs.
* @param {unknown} content - an event content array.
* @returns {string[]} unique absolute image URLs, first-seen order.
*/
function imageUrlsInContent(content) {
const urls = new Set();
if (!Array.isArray(content)) return [];
const pending = [...content];
while (pending.length > 0) {
const value = pending.pop();
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
if (value.type === "text" && typeof value.text === "string") {
for (const match of value.text.matchAll(IMAGE_URL_RE)) {
urls.add(String(match[0]).replace(/[.,;]+$/, ""));
}
}
if (Array.isArray(value.content)) {
for (const item of value.content) pending.push(item);
}
}
return [...urls];
}
/**
* Send every collected image ref to one chat for the current stream, deduped.
* Keys are reserved synchronously BEFORE the async send so concurrent events
* can never double-deliver; a failed send is logged and skipped.
* @param {object} state - PluginState (ctx, bot, log).
* @param {number|string} chatId - Telegram chat id.
* @param {object|null} stream - the per-chat stream, or null for no dedupe.
* @param {string} sessionId - session owning the refs (RPC fallback reads).
* @param {Array<object>} refs - ImageAttachmentRef list.
* @param {string} [caption] - optional photo caption.
*/
function sendImageRefs(state, chatId, stream, sessionId, refs, caption) {
if (!Array.isArray(refs)) return;
for (const ref of refs) {
if (!ref || typeof ref !== "object" || typeof ref.attachmentId !== "string") continue;
if (stream) {
if (!stream.sentImages) stream.sentImages = new Set();
if (stream.sentImages.has(ref.attachmentId)) continue;
if (stream.sentImages.size >= MAX_PHOTOS_PER_STREAM) {
state.log("photo limit reached: chat " + chatId + " (max " + MAX_PHOTOS_PER_STREAM + ")");
continue;
}
stream.sentImages.add(ref.attachmentId);
}
void deliverRef(state, chatId, sessionId, ref, caption)
.catch((e) => state.log("photo send failed: " + (e?.message ?? e)));
}
}
/**
* Send every collected image URL to one chat for the current stream, deduped.
* @param {object} state - PluginState.
* @param {number|string} chatId - Telegram chat id.
* @param {object|null} stream - the per-chat stream, or null for no dedupe.
* @param {string[]} urls - unique image URLs.
*/
function sendImageUrls(state, chatId, stream, urls) {
if (!Array.isArray(urls)) return;
for (const url of urls) {
if (typeof url !== "string" || !url) continue;
if (stream) {
if (!stream.sentImages) stream.sentImages = new Set();
if (stream.sentImages.has(url)) continue;
if (stream.sentImages.size >= MAX_PHOTOS_PER_STREAM) {
state.log("photo limit reached: chat " + chatId + " (max " + MAX_PHOTOS_PER_STREAM + ")");
continue;
}
stream.sentImages.add(url);
}
void deliverUrl(state, chatId, url)
.catch((e) => state.log("photo url send failed: " + (e?.message ?? e)));
}
}
/** Read one attachment ref and deliver it as a Telegram photo. */
async function deliverRef(state, chatId, sessionId, ref, caption) {
if (ref.bytes > 50_000_000) {
state.log("skipped oversized photo (" + ref.bytes + " bytes): " + ref.attachmentId);
return;
}
let data;
const attachments = state.ctx.get("attachments");
if (attachments) {
const stored = await attachments.readImage(ref);
data = stored.data;
} else {
// RPC fallback: the same route the web client uses to resolve an image.
const value = await callApi(state.ctx, "sessions", "attachment", {
sessionId,
attachmentId: ref.attachmentId,
});
data = Buffer.from(String(value?.data ?? ""), "base64");
}
if (data.length === 0) throw new Error("empty image data for " + ref.attachmentId);
await state.bot.sendPhoto(chatId, Buffer.from(data), {
filename: ref.name || extensionForMediaType(ref.mediaType),
caption,
});
}
/** Download one image URL and deliver it as a Telegram photo. */
async function deliverUrl(state, chatId, url) {
const buffer = await fetchImageBytes(state, url);
if (!buffer) return;
await state.bot.sendPhoto(chatId, buffer, { filename: filenameFromUrl(url) });
}
/**
* Download one image URL into a Buffer with content-type and size guards.
* Never throws: every failure is logged and returns null.
* @param {object} state - PluginState (for logging).
* @param {string} url - absolute http(s) image URL.
* @returns {Promise<Buffer|null>} the bytes, or null when not an image, too
* large (over 10 MB), or the fetch failed.
*/
async function fetchImageBytes(state, url) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), URL_FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) {
state.log("photo url fetch failed: HTTP " + res.status + " " + url);
return null;
}
const ctype = String(res.headers.get("content-type") ?? "").toLowerCase();
if (!ctype.startsWith("image/")) {
state.log("photo url not an image (" + ctype + "): " + url);
return null;
}
const declared = Number(res.headers.get("content-length") ?? "0");
if (declared > MAX_URL_IMAGE_BYTES) {
state.log("photo url too large (" + declared + " bytes): " + url);
return null;
}
if (!res.body) {
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length > MAX_URL_IMAGE_BYTES) {
state.log("photo url too large (" + buffer.length + " bytes): " + url);
return null;
}
return buffer;
}
const reader = res.body.getReader();
const chunks = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_URL_IMAGE_BYTES) {
await reader.cancel();
state.log("photo url too large (" + total + " bytes): " + url);
return null;
}
chunks.push(value);
}
return Buffer.concat(chunks);
} catch (error) {
state.log("photo url fetch failed: " + (error?.message ?? error) + " " + url);
return null;
} finally {
clearTimeout(timer);
}
}
/** Map an image media type to a Telegram filename. */
function extensionForMediaType(mediaType) {
if (mediaType === "image/jpeg") return "image.jpg";
if (mediaType === "image/webp") return "image.webp";
if (mediaType === "image/gif") return "image.gif";
return "image.png";
}
/** Derive a display filename from an image URL. */
function filenameFromUrl(url) {
try {
const parsed = new URL(url);
const base = parsed.pathname.split("/").pop();
if (base && /\.(png|jpe?g|webp|gif)$/i.test(base)) return base;
const qname = parsed.searchParams.get("filename");
if (qname && /\.(png|jpe?g|webp|gif)$/i.test(qname)) return qname;
} catch {}
return "image.png";
}
function applyChunk(stream, chunk) {
const ctype = chunk.type;
if (ctype === "reasoning-delta") {
if (typeof chunk.text === "string") stream.reasoning += chunk.text;
} else if (ctype === "text-delta") {
if (typeof chunk.text === "string") stream.text += chunk.text;
} else if (ctype === "block-end") {
const block = chunk.block;
if (!block) return;
if (block.type === "text" && typeof block.text === "string") {
stream.text = block.text;
} else if (block.type === "reasoning" && typeof block.text === "string") {
stream.reasoning = block.text;
}
} else if (ctype === "delta" && chunk.block) {
// Older harness shape: delta with an inline block
const delta = typeof chunk.block.text === "string" ? chunk.block.text : "";
if (!delta) return;
if (chunk.block.type === "reasoning") stream.reasoning += delta;
else if (chunk.block.type === "text") stream.text += delta;
}
if (stream.text.length > 20000) stream.text = stream.text.slice(-20000);
if (stream.reasoning.length > 20000) stream.reasoning = stream.reasoning.slice(-20000);
}
/**
* Compact head+tail preview of long thinking text — the beginning and the
* end of the model's reasoning, with an ellipsis in between. Code-point safe
* (emoji/surrogates never split) and HTML-escaped.
*/
function reasoningSnippet(raw) {
const text = String(raw ?? "").trim();
if (!text) return "";
const chars = Array.from(text);
// Quote-boxed + italic: the thinking renders as a distinct indented card
// with a colored left border — impossible to confuse with the answer.
const body = (chars.length <= REASONING_HEAD + REASONING_TAIL)
? "<i>" + esc(text) + "</i>"
: "<i>" + esc(chars.slice(0, REASONING_HEAD).join("")) + "</i>\n<i>… thinking continues …</i>\n<i>" + esc(chars.slice(-REASONING_TAIL).join("")) + "</i>";
return "<blockquote>" + body + "</blockquote>";
}
/** Render the live message with clean, clearly separated sections. */
function buildPreview(stream) {
const elapsed = Math.max(0, Math.round((Date.now() - stream.startedAt) / 1000));
const sections = [];
if (stream.reasoning && !stream.done) {
sections.push(THINKING_HEADER + "\n" + HEADER_RULE + "\n" + reasoningSnippet(stream.reasoning));
}
if (stream.tools.size > 0 && !stream.done) {
const lines = [];
let idx = 0;
for (const tool of stream.tools.values()) {
if (idx >= TOOLS_MAX) {
lines.push("+ " + (stream.tools.size - idx) + " more");
break;
}
idx += 1;
const icon = tool.status === "done" ? "✅" : tool.status === "error" ? "⚠️" : "⋯";
lines.push(icon + " <code>" + esc(tool.name) + "</code>");
}
sections.push(TOOLS_HEADER + "\n" + HEADER_RULE + "\n" + lines.join(" "));
}
if (stream.text) {
sections.push(REPLY_HEADER + "\n" + HEADER_RULE + "\n" + truncate(stream.text.trim(), TEXT_PREVIEW));
} else if (!stream.reasoning && stream.tools.size === 0) {
sections.push(THINKING_HEADER + "\n" + HEADER_RULE);
}
// Blank line between every section keeps Thinking / Tools / Reply apart.
sections.push(DIVIDER + "\n<i>⏳ " + elapsed + "s</i>");
return sections.join("\n\n");
}
/* ── interactive: questions & approvals ── */
const QUESTION_CB = "qa:";
const APPROVE_CB = "ap:y:";
const REJECT_CB = "ap:n:";
function handleQuestionRequested(state, payload, rpcId) {
const sessionId = payload.sessionId;
const questions = payload.questions ?? [];
if (!questions.length) return;
const chats = interestedChats(state, sessionId);
if (!chats.length) return;
for (const { chatId, active } of chats) {
if (!active) continue;
const q = questions[0];
const rows = (q.options ?? []).map((opt, idx) => [{
text: truncate(opt.label, 40),
callback_data: QUESTION_CB + rpcId + ":" + idx,
}]);
const keyboard = rows.length ? { inline_keyboard: rows } : undefined;
let text = "❓ <b>" + esc(q.question) + "</b>";
if (q.header) text = "❓ <b>" + esc(q.header) + "</b>\n" + esc(q.question);
if (q.detail) text += "\n<i>" + esc(truncate(q.detail, 300)) + "</i>";
if (q.multiSelect) text += "\n<i>(multi-select: tap each, then send your final answer as a plain message)</i>";
else text += "\n<i>Tap an option, or reply with your own answer as a plain message.</i>";
const chat = state.chatState(chatId);
chat.pendingQuestion = { rpcId, sessionId, questionId: q.id, options: q.options ?? [], multiSelect: !!q.multiSelect, at: Date.now() };
state.saveState();
state.bot.send(chatId, text, { replyMarkup: keyboard }).catch(() => {});
}
}
function handleApprovalRequested(state, payload, rpcId) {
const sessionId = payload.sessionId;
// The harness identifies this approval by ITS OWN approvalId (separate from
// the envelope rpcId) — answering with the rpcId was silently rejected.
const approvalId = payload.approvalId;
const chats = interestedChats(state, sessionId);
if (!chats.length) return;
const text = "🛡️ <b>Permission needed</b>\n" +
"The AI wants to use <code>" + esc(payload.toolName ?? "a tool") + "</code>" +
(payload.reason ? "\n<i>" + esc(truncate(payload.reason, 300)) + "</i>" : "");
const keyboard = {
inline_keyboard: [
[
{ text: "✅ Allow once", callback_data: APPROVE_CB + rpcId },
{ text: "❌ Reject", callback_data: REJECT_CB + rpcId },
],
],
};
for (const { chatId, active } of chats) {
if (!active) continue;
const chat = state.chatState(chatId);
chat.pendingApproval = { sessionId, rpcId, approvalId, at: Date.now() };
state.saveState();
state.bot.send(chatId, text, { replyMarkup: keyboard }).catch(() => {});
}
}
export function handleInteractiveCallback(state, chatId, data) {
if (data.startsWith(QUESTION_CB)) {
const rest = data.slice(QUESTION_CB.length);
const sep = rest.lastIndexOf(":");
if (sep < 0) return false;
const rpcId = rest.slice(0, sep);
const idx = Number(rest.slice(sep + 1));
const chat = state.chatState(chatId);
const pending = chat.pendingQuestion;
if (!pending || pending.rpcId !== rpcId || !pending.options[idx]) return true;
const label = pending.options[idx].label;
const payload = {
sessionId: pending.sessionId,
answer: { answers: [{ id: pending.questionId, selected: [label] }] },
};
void answerQuestion(state, rpcId, payload);
chat.pendingQuestion = undefined;
state.saveState();
return true;
}
if (data.startsWith(APPROVE_CB) || data.startsWith(REJECT_CB)) {
const approve = data.startsWith(APPROVE_CB);
const rpcId = data.slice(approve ? APPROVE_CB.length : REJECT_CB.length);
const api = state.ctx.get("apiProxy");
if (!api?.respond) return true;
const chat = state.chatState(chatId);
const pending = chat.pendingApproval;
// Exactly the wire shape the harness validates:
// { type: "client-response", rpcId, result: { ok: true, value: {...} } }
const value = {
sessionId: pending?.sessionId ?? "",
approvalId: pending?.approvalId ?? rpcId,
outcome: approve ? "allowed-once" : "rejected",
};
if (pending) {
chat.pendingApproval = undefined;
state.saveState();
}
api.respond({ type: "client-response", rpcId, result: { ok: true, value } })
.then((receipt) => {
state.log("approval respond accepted=" + (receipt ? receipt.accepted : "?") + " " + JSON.stringify(receipt));
const note = approve
? "✅ <b>Allowed</b> — the AI can continue."
: "❌ <b>Rejected</b> — the AI will skip that action.";
state.bot.send(chatId, note).catch(() => {});
})
.catch((error) => {
state.log("approval respond failed: " + error.message);
state.bot.send(chatId, "⚠️ Could not send that permission. Try again, or allow it in the web GUI.").catch(() => {});
});
return approve ? "Allowed" : "Rejected";
}
return false;
}
async function answerQuestion(state, rpcId, payload) {
const api = state.ctx.get("apiProxy");
if (!api?.respond) {
state.log("question respond unavailable");
return;
}
try {
const receipt = await api.respond({ type: "client-response", rpcId, result: { ok: true, value: payload } });
state.log("question respond accepted=" + (receipt ? receipt.accepted : "?") + " " + JSON.stringify(receipt));
} catch (error) {
state.log("question respond failed: " + error.message);
}
}