/** * Shared helpers for dsh-telegram-remote. */ import { join } from "node:path"; export function dshHome() { return process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || ".", ".dsh"); } export function nowIso() { return new Date().toISOString(); } export function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } export function truncate(text, max) { const str = String(text ?? ""); if (str.length <= max) return str; return str.slice(0, max) + "\n…[truncated]"; } export function pretty(value, max = 3000) { try { return truncate(JSON.stringify(value, null, 2), max); } catch { return truncate(String(value), max); } } export function fmtAge(ts) { if (typeof ts !== "number") return "?"; const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000)); if (sec < 60) return sec + "s ago"; if (sec < 3600) return Math.floor(sec / 60) + "m ago"; if (sec < 86400) return Math.floor(sec / 3600) + "h ago"; return Math.floor(sec / 86400) + "d ago"; } export function shortId(sessionId) { const id = String(sessionId ?? ""); return id.length > 12 ? id.slice(0, 8) + "…" + id.slice(-6) : id; } const HTML_CACHE_MAX = 64; const htmlCache = new Map(); /** Structured markdown → Telegram-HTML renderer (memoized). */ export function markdownToHtml(md) { const text = String(md ?? ""); const cached = htmlCache.get(text); if (cached !== undefined) return cached; const escape = (s) => String(s).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); const inline = (s) => s .replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1$2") .replace(/__([^_]+)__/g, "$1") .replace(/~~([^~]+)~~/g, "$1") .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); const lines = String(text).split("\n"); const out = []; let inFence = false; let fenceLang = ""; const fence = []; const flushFence = () => { if (!inFence) return; inFence = false; const body = fence.join("\n"); out.push((fenceLang ? "" + escape(fenceLang) + "\n" : "") + "
" + escape(body) + "
"); fence.length = 0; fenceLang = ""; }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const fenceMatch = line.match(/^\s*```(.*)$/); if (fenceMatch) { if (!inFence) { inFence = true; fenceLang = fenceMatch[1].trim(); } else { flushFence(); } continue; } if (inFence) { fence.push(line); continue; } const trimmed = line.trim(); if (!trimmed) { out.push(""); continue; } const h = trimmed.match(/^(#{1,6})\s+(.+)$/); if (h) { const prefix = h[1].length <= 2 ? "šŸ“Œ " : h[1].length === 3 ? "ā–ø " : "• "; out.push("" + prefix + inline(escape(h[2])) + ""); continue; } if (/^>\s?/.test(trimmed)) { out.push("" + inline(escape(trimmed.replace(/^>\s?/, ""))) + ""); continue; } if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { out.push("──────────────"); continue; } const ul = trimmed.match(/^([-*+])\s+(.+)$/); if (ul) { out.push("• " + inline(escape(ul[2]))); continue; } const ol = trimmed.match(/^(\d+)[.)]\s+(.+)$/); if (ol) { out.push("" + ol[1] + ". " + inline(escape(ol[2]))); continue; } if (trimmed.includes("-") && /^\|?[\s:-]+\|?\s*$/.test(trimmed)) { continue; } out.push(inline(escape(trimmed))); } flushFence(); const result = out.join("\n"); if (htmlCache.size >= HTML_CACHE_MAX) htmlCache.delete(htmlCache.keys().next().value); htmlCache.set(text, result); return result; } /** Extract readable text from a session content block array. */ export function contentText(blocks, { textOnly = false } = {}) { if (!Array.isArray(blocks)) return ""; return blocks .map((b) => { if (!b || typeof b !== "object") return ""; if (b.type === "text" && typeof b.text === "string") return b.text; if (b.type === "reasoning") return ""; if (b.type === "tool-call") { if (textOnly) return ""; return "[" + b.name + "] " + truncate(String(b.arguments ?? ""), 120); } if (b.type === "tool-result") return contentText(b.content, { textOnly }); return ""; }) .filter(Boolean) .join("\n"); } /** True when a content block array contains at least one real text block. */ export function hasText(blocks) { if (!Array.isArray(blocks)) return false; return blocks.some((b) => b && typeof b === "object" && b.type === "text" && typeof b.text === "string" && b.text.trim().length > 0); } /** Gateway invocation with RPC-envelope unwrapping. */ export async function invoke(gateway, namespace, method, args = {}) { const result = await gateway.invoke({ namespace, method, args }); if (result && typeof result === "object" && "ok" in result) { if (result.ok === true) return result.value; const error = result.error ?? {}; const err = new Error(error.message ?? namespace + "." + method + " failed"); err.code = error.code; throw err; } return result; } /** * Call one harness API method the way the web client does, in-process. * Prefers the host api-proxy service (domains: sessions, subagents, host, * goals, workspace, skills, agentPresets, settings, credentials, llm, ...), * falling back to the typert gateway (slash namespaces such as commands/*, * pluginInventory/*, messageFeedback/*) when the api-proxy does not mount * that method. Returns the unwrapped business value or throws with the * wire error code attached. */ export async function callApi(ctx, domain, method, args = {}) { const apiProxy = ctx.get("apiProxy"); if (apiProxy && typeof apiProxy?.[domain]?.[method] === "function") { const response = await apiProxy[domain][method]({ rpcId: "tg-" + Math.random().toString(36).slice(2, 10), payload: args, }); const result = response?.result; if (result && typeof result === "object") { if (result.ok === true) return result.value; if (result.ok === false) { const error = result.error ?? {}; const err = new Error(error.message ?? domain + "." + method + " failed"); err.code = error.code; throw err; } } return result; } const gateway = ctx.get("typertGateway"); if (gateway) return invoke(gateway, domain, method, args); throw new Error("no api surface for " + domain + "." + method); }