import type { MoltbotConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { sanitizeUserFacingText } from "../pi-embedded-helpers.js"; import { stripDowngradedToolCallText, stripMinimaxToolCallXml, stripThinkingTagsFromText, } from "../pi-embedded-utils.js"; import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js"; export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other"; export type SessionListDeliveryContext = { channel?: string; to?: string; accountId?: string; }; export type SessionListRow = { key: string; kind: SessionKind; channel: string; label?: string; displayName?: string; deliveryContext?: SessionListDeliveryContext; updatedAt?: number | null; sessionId?: string; model?: string; contextTokens?: number | null; totalTokens?: number | null; thinkingLevel?: string; verboseLevel?: string; systemSent?: boolean; abortedLastRun?: boolean; sendPolicy?: string; lastChannel?: string; lastTo?: string; lastAccountId?: string; transcriptPath?: string; messages?: unknown[]; }; function normalizeKey(value?: string) { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } export function resolveMainSessionAlias(cfg: MoltbotConfig) { const mainKey = normalizeMainKey(cfg.session?.mainKey); const scope = cfg.session?.scope ?? "per-sender"; const alias = scope === "global" ? "global" : mainKey; return { mainKey, alias, scope }; } export function resolveDisplaySessionKey(params: { key: string; alias: string; mainKey: string }) { if (params.key === params.alias) return "main"; if (params.key === params.mainKey) return "main"; return params.key; } export function resolveInternalSessionKey(params: { key: string; alias: string; mainKey: string }) { if (params.key === "main") return params.alias; return params.key; } export type AgentToAgentPolicy = { enabled: boolean; matchesAllow: (agentId: string) => boolean; isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean; }; export function createAgentToAgentPolicy(cfg: MoltbotConfig): AgentToAgentPolicy { const routingA2A = cfg.tools?.agentToAgent; const enabled = routingA2A?.enabled === true; const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : []; const matchesAllow = (agentId: string) => { if (allowPatterns.length === 0) return true; return allowPatterns.some((pattern) => { const raw = String(pattern ?? "").trim(); if (!raw) return false; if (raw === "*") return true; if (!raw.includes("*")) return raw === agentId; const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); return re.test(agentId); }); }; const isAllowed = (requesterAgentId: string, targetAgentId: string) => { if (requesterAgentId === targetAgentId) return true; if (!enabled) return false; return matchesAllow(requesterAgentId) && matchesAllow(targetAgentId); }; return { enabled, matchesAllow, isAllowed }; } const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export function looksLikeSessionId(value: string): boolean { return SESSION_ID_RE.test(value.trim()); } export function looksLikeSessionKey(value: string): boolean { const raw = value.trim(); if (!raw) return false; // These are canonical key shapes that should never be treated as sessionIds. if (raw === "main" || raw === "global" || raw === "unknown") return true; if (isAcpSessionKey(raw)) return true; if (raw.startsWith("agent:")) return true; if (raw.startsWith("cron:") || raw.startsWith("hook:")) return true; if (raw.startsWith("node-") || raw.startsWith("node:")) return true; if (raw.includes(":group:") || raw.includes(":channel:")) return true; return false; } export function shouldResolveSessionIdInput(value: string): boolean { // Treat anything that doesn't look like a well-formed key as a sessionId candidate. return looksLikeSessionId(value) || !looksLikeSessionKey(value); } export type SessionReferenceResolution = | { ok: true; key: string; displayKey: string; resolvedViaSessionId: boolean; } | { ok: false; status: "error" | "forbidden"; error: string }; async function resolveSessionKeyFromSessionId(params: { sessionId: string; alias: string; mainKey: string; requesterInternalKey?: string; restrictToSpawned: boolean; }): Promise { try { // Resolve via gateway so we respect store routing and visibility rules. const result = (await callGateway({ method: "sessions.resolve", params: { sessionId: params.sessionId, spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, includeGlobal: !params.restrictToSpawned, includeUnknown: !params.restrictToSpawned, }, })) as { key?: unknown }; const key = typeof result?.key === "string" ? result.key.trim() : ""; if (!key) { throw new Error( `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, ); } return { ok: true, key, displayKey: resolveDisplaySessionKey({ key, alias: params.alias, mainKey: params.mainKey, }), resolvedViaSessionId: true, }; } catch (err) { if (params.restrictToSpawned) { return { ok: false, status: "forbidden", error: `Session not visible from this sandboxed agent session: ${params.sessionId}`, }; } const message = err instanceof Error ? err.message : String(err); return { ok: false, status: "error", error: message || `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, }; } } async function resolveSessionKeyFromKey(params: { key: string; alias: string; mainKey: string; requesterInternalKey?: string; restrictToSpawned: boolean; }): Promise { try { // Try key-based resolution first so non-standard keys keep working. const result = (await callGateway({ method: "sessions.resolve", params: { key: params.key, spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, }, })) as { key?: unknown }; const key = typeof result?.key === "string" ? result.key.trim() : ""; if (!key) return null; return { ok: true, key, displayKey: resolveDisplaySessionKey({ key, alias: params.alias, mainKey: params.mainKey, }), resolvedViaSessionId: false, }; } catch { return null; } } export async function resolveSessionReference(params: { sessionKey: string; alias: string; mainKey: string; requesterInternalKey?: string; restrictToSpawned: boolean; }): Promise { const raw = params.sessionKey.trim(); if (shouldResolveSessionIdInput(raw)) { // Prefer key resolution to avoid misclassifying custom keys as sessionIds. const resolvedByKey = await resolveSessionKeyFromKey({ key: raw, alias: params.alias, mainKey: params.mainKey, requesterInternalKey: params.requesterInternalKey, restrictToSpawned: params.restrictToSpawned, }); if (resolvedByKey) return resolvedByKey; return await resolveSessionKeyFromSessionId({ sessionId: raw, alias: params.alias, mainKey: params.mainKey, requesterInternalKey: params.requesterInternalKey, restrictToSpawned: params.restrictToSpawned, }); } const resolvedKey = resolveInternalSessionKey({ key: raw, alias: params.alias, mainKey: params.mainKey, }); const displayKey = resolveDisplaySessionKey({ key: resolvedKey, alias: params.alias, mainKey: params.mainKey, }); return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false }; } export function classifySessionKind(params: { key: string; gatewayKind?: string | null; alias: string; mainKey: string; }): SessionKind { const key = params.key; if (key === params.alias || key === params.mainKey) return "main"; if (key.startsWith("cron:")) return "cron"; if (key.startsWith("hook:")) return "hook"; if (key.startsWith("node-") || key.startsWith("node:")) return "node"; if (params.gatewayKind === "group") return "group"; if (key.includes(":group:") || key.includes(":channel:")) { return "group"; } return "other"; } export function deriveChannel(params: { key: string; kind: SessionKind; channel?: string | null; lastChannel?: string | null; }): string { if (params.kind === "cron" || params.kind === "hook" || params.kind === "node") return "internal"; const channel = normalizeKey(params.channel ?? undefined); if (channel) return channel; const lastChannel = normalizeKey(params.lastChannel ?? undefined); if (lastChannel) return lastChannel; const parts = params.key.split(":").filter(Boolean); if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) { return parts[0]; } return "unknown"; } export function stripToolMessages(messages: unknown[]): unknown[] { return messages.filter((msg) => { if (!msg || typeof msg !== "object") return true; const role = (msg as { role?: unknown }).role; return role !== "toolResult"; }); } /** * Sanitize text content to strip tool call markers and thinking tags. * This ensures user-facing text doesn't leak internal tool representations. */ export function sanitizeTextContent(text: string): string { if (!text) return text; return stripThinkingTagsFromText(stripDowngradedToolCallText(stripMinimaxToolCallXml(text))); } export function extractAssistantText(message: unknown): string | undefined { if (!message || typeof message !== "object") return undefined; if ((message as { role?: unknown }).role !== "assistant") return undefined; const content = (message as { content?: unknown }).content; if (!Array.isArray(content)) return undefined; const chunks: string[] = []; for (const block of content) { if (!block || typeof block !== "object") continue; if ((block as { type?: unknown }).type !== "text") continue; const text = (block as { text?: unknown }).text; if (typeof text === "string") { const sanitized = sanitizeTextContent(text); if (sanitized.trim()) { chunks.push(sanitized); } } } const joined = chunks.join("").trim(); return joined ? sanitizeUserFacingText(joined) : undefined; }