From d0218d3e592fe1ce52e35bccd1b3c7701b51e3ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 15 May 2026 20:54:47 +0100 Subject: [PATCH] fix(telegram): retain transcript-backed truncated finals --- CHANGELOG.md | 1 + .../src/bot-message-dispatch.runtime.ts | 1 + .../telegram/src/bot-message-dispatch.test.ts | 45 ++++ .../telegram/src/bot-message-dispatch.ts | 40 +++ .../src/lane-delivery-text-deliverer.ts | 98 ++++++++ extensions/telegram/src/lane-delivery.test.ts | 228 ++++++++++++++++++ src/plugin-sdk/session-store-runtime.ts | 1 + 7 files changed, 414 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32575481647..5d3ff6882f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Discord: validate message-read results before normalizing channel history and report unexpected payloads with a Discord boundary error instead of `map is not a function`. Fixes #82252. Thanks @jessewunderlich. - Agents/runtime: apply `agents.defaults.models["provider/*"].agentRuntime` as provider-wide model runtime policy while preserving exact model runtime precedence. Fixes #82243. Thanks @rendrag-git. - Agents/auto-reply: restrict `NO_REPLY` prompt guidance to automatic group/channel replies, remove legacy silent-reply rewrites, and suppress accidental direct-chat silent tokens instead of delivering fallback text. Fixes #82254. Thanks @absol89. +- Telegram: retain a longer partial-stream preview when a final callback only carries an ellipsis-truncated snapshot, preventing the visible answer and transcript mirror from being replaced by the short preview. Fixes #82239. - Telegram/active-memory: run blocking memory recall through the Telegram provider for direct-message turns even when the hook context carries the raw chat id, preventing embedded recall from launching against an invalid numeric channel. Fixes #82177. Thanks @cslash-zz. - Control UI/WebChat: keep optimistic image messages from embedding large inline `data:` previews and preserve image-only user turns in chat history, avoiding browser stack overflows when sending image attachments. Fixes #82182. Thanks @ExploreSheep. - Agents/media: preserve message-tool-only delivery for generated music and video completion handoffs, so group/channel completions do not finish without posting the generated attachment. diff --git a/extensions/telegram/src/bot-message-dispatch.runtime.ts b/extensions/telegram/src/bot-message-dispatch.runtime.ts index 2ff8025fc2f..e50ad721b05 100644 --- a/extensions/telegram/src/bot-message-dispatch.runtime.ts +++ b/extensions/telegram/src/bot-message-dispatch.runtime.ts @@ -1,5 +1,6 @@ export { loadSessionStore, + readLatestAssistantTextFromSessionTranscript, resolveAndPersistSessionFile, resolveSessionStoreEntry, } from "openclaw/plugin-sdk/session-store-runtime"; diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 6458fc327fd..2437e1398ce 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -58,6 +58,7 @@ const appendSessionTranscriptMessage = vi.hoisted(() => ); const emitSessionTranscriptUpdate = vi.hoisted(() => vi.fn()); const loadSessionStore = vi.hoisted(() => vi.fn()); +const readLatestAssistantTextFromSessionTranscript = vi.hoisted(() => vi.fn()); const resolveStorePath = vi.hoisted(() => vi.fn(() => "/tmp/sessions.json")); const resolveAndPersistSessionFile = vi.hoisted(() => vi.fn(async () => ({ @@ -131,6 +132,7 @@ vi.mock("./bot-message-dispatch.runtime.js", () => ({ generateTopicLabel, getAgentScopedMediaLocalRoots, loadSessionStore, + readLatestAssistantTextFromSessionTranscript, resolveAndPersistSessionFile, resolveAutoTopicLabelConfig: resolveAutoTopicLabelConfigRuntime, resolveChunkMode, @@ -219,6 +221,7 @@ describe("dispatchTelegramMessage draft streaming", () => { wasSentByBot.mockReset(); appendSessionTranscriptMessage.mockReset(); emitSessionTranscriptUpdate.mockReset(); + readLatestAssistantTextFromSessionTranscript.mockReset(); loadSessionStore.mockReset(); resolveStorePath.mockReset(); resolveAndPersistSessionFile.mockReset(); @@ -962,6 +965,48 @@ describe("dispatchTelegramMessage draft streaming", () => { }); }); + it("mirrors the longer streamed preview when final text is truncated", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + const fullAnswer = + "Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen."; + const truncatedFinal = + "Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man..."; + const context = createContext(); + context.ctxPayload.SessionKey = "agent:default:telegram:direct:123"; + loadSessionStore.mockReturnValue({ + "agent:default:telegram:direct:123": { sessionId: "s1" }, + }); + readLatestAssistantTextFromSessionTranscript.mockResolvedValue({ + text: fullAnswer, + timestamp: Date.now() + 1_000, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: fullAnswer }); + await dispatcherOptions.deliver({ text: truncatedFinal }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + + await dispatchWithContext({ context }); + + expect(answerDraftStream.update).toHaveBeenCalledWith(fullAnswer); + expect(answerDraftStream.update).not.toHaveBeenCalledWith(truncatedFinal); + expectRecordFields(mockCallArg(emitInternalMessageSentHook), { + content: fullAnswer, + messageId: 2001, + }); + const transcriptCall = expectRecordFields(mockCallArg(appendSessionTranscriptMessage), { + transcriptPath: "/tmp/session.jsonl", + }); + expectRecordFields(transcriptCall.message, { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: fullAnswer }], + }); + }); + it("emits the redacted appended message in transcript updates", async () => { setupDraftStreams({ answerMessageId: 2001 }); const context = createContext(); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 73e0492de7c..8b01b6bedf5 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -66,6 +66,7 @@ import { generateTopicLabel, getAgentScopedMediaLocalRoots, loadSessionStore, + readLatestAssistantTextFromSessionTranscript, resolveAutoTopicLabelConfig, resolveChunkMode, resolveMarkdownTableMode, @@ -442,6 +443,7 @@ export const dispatchTelegramMessage = async ({ telegramDeps: injectedTelegramDeps, opts, }: DispatchTelegramMessageParams) => { + const dispatchStartedAt = Date.now(); const telegramDeps = injectedTelegramDeps ?? (await import("./bot-deps.js")).defaultTelegramBotDeps; const { @@ -962,6 +964,43 @@ export const dispatchTelegramMessage = async ({ ); const endTelegramInboundTurnDeliveryCorrelation = beginDeliveryCorrelation(); const sessionKey = ctxPayload.SessionKey; + const resolveCurrentTurnTranscriptFinalText = async (): Promise => { + if (!sessionKey) { + return undefined; + } + try { + const storePath = telegramDeps.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, { + skipCache: true, + }); + const sessionEntry = resolveSessionStoreEntry({ + store, + sessionKey, + }).existing; + if (!sessionEntry?.sessionId) { + return undefined; + } + const { sessionFile } = await resolveAndPersistSessionFile({ + sessionId: sessionEntry.sessionId, + sessionKey, + sessionStore: store, + storePath, + sessionEntry, + agentId: route.agentId, + sessionsDir: path.dirname(storePath), + }); + const latest = await readLatestAssistantTextFromSessionTranscript(sessionFile); + if (!latest?.timestamp || latest.timestamp < dispatchStartedAt) { + return undefined; + } + return latest.text; + } catch (err) { + logVerbose(`telegram transcript final candidate lookup failed: ${formatErrorMessage(err)}`); + return undefined; + } + }; const deliveryBaseOptions = { chatId: String(chatId), accountId: route.accountId, @@ -1201,6 +1240,7 @@ export const dispatchTelegramMessage = async ({ buttons, }); }, + resolveFinalTextCandidate: () => resolveCurrentTurnTranscriptFinalText(), log: logVerbose, markDelivered: () => { deliveryState.markDelivered(); diff --git a/extensions/telegram/src/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts index c1187f312ad..4843f41f713 100644 --- a/extensions/telegram/src/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -52,6 +52,10 @@ type CreateLaneTextDelivererParams = { text: string; buttons?: TelegramInlineButtons; }) => Promise; + resolveFinalTextCandidate?: (params: { + finalText: string; + laneName: LaneName; + }) => Promise | string | undefined; log: (message: string) => void; markDelivered: () => void; }; @@ -101,6 +105,53 @@ function compactChunks(chunks: readonly string[]): string[] { return out; } +function stripTrailingEllipsis(text: string): string { + return text.replace(/(?:\s*(?:\.{3}|\u2026))+$/u, "").trimEnd(); +} + +const MIN_TRUNCATED_FINAL_PREFIX_CHARS = 48; +const MIN_TRUNCATED_FINAL_CONTINUATION_CHARS = 24; + +function isPotentialTruncatedFinal(finalText: string): boolean { + const trimmedFinal = finalText.trimEnd(); + const untruncatedFinal = stripTrailingEllipsis(trimmedFinal); + return ( + untruncatedFinal.length >= MIN_TRUNCATED_FINAL_PREFIX_CHARS && untruncatedFinal !== trimmedFinal + ); +} + +function selectLongerPreviewForFinal(params: { + finalText: string; + candidateTexts: readonly (string | undefined)[]; +}): string | undefined { + const finalText = params.finalText.trimEnd(); + const untruncatedFinal = stripTrailingEllipsis(finalText); + if ( + untruncatedFinal.length < MIN_TRUNCATED_FINAL_PREFIX_CHARS || + untruncatedFinal === finalText + ) { + return undefined; + } + for (const candidate of params.candidateTexts) { + const candidateText = candidate?.trimEnd(); + if ( + !candidateText || + candidateText.length <= finalText.length || + !candidateText.startsWith(untruncatedFinal) + ) { + continue; + } + const continuation = candidateText.slice(untruncatedFinal.length).trimStart(); + if ( + continuation.length >= MIN_TRUNCATED_FINAL_CONTINUATION_CHARS && + /^[\p{L}\p{N}]/u.test(continuation) + ) { + return candidateText; + } + } + return undefined; +} + export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { const followUpPayload = (payload: ReplyPayload, text: string) => params.applyTextToFollowUpPayload @@ -138,6 +189,53 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { return undefined; } + const retainedPreview = + isFinal && remainingChunks.length === 0 && isPotentialTruncatedFinal(text) + ? selectLongerPreviewForFinal({ + finalText: text, + candidateTexts: [ + await params.resolveFinalTextCandidate?.({ finalText: text, laneName }), + stream.lastDeliveredText?.(), + lane.lastPartialText, + ], + }) + : undefined; + if (retainedPreview && (!buttons || retainedPreview.length <= params.draftMaxChars)) { + const previewText = retainedPreview; + lane.lastPartialText = previewText; + lane.hasStreamedMessage = true; + await params.stopDraftLane(lane); + const messageId = stream.messageId(); + if (typeof messageId !== "number") { + if (stream.sendMayHaveLanded?.()) { + lane.finalized = true; + params.markDelivered(); + return result("preview-retained"); + } + return undefined; + } + const deliveredStreamText = stream.lastDeliveredText?.(); + if (deliveredStreamText !== undefined && deliveredStreamText !== previewText) { + return undefined; + } + if (buttons) { + try { + await params.editStreamMessage({ laneName, messageId, text: previewText, buttons }); + } catch (err) { + params.log(`telegram: ${laneName} stream button edit failed: ${String(err)}`); + } + } + for (const chunk of remainingChunks) { + if (chunk.trim().length === 0) { + continue; + } + await params.sendPayload(followUpPayload(payload, chunk)); + } + lane.finalized = true; + params.markDelivered(); + return result("preview-finalized", { content: previewText, messageId }); + } + lane.lastPartialText = firstChunk; lane.hasStreamedMessage = true; lane.finalized = false; diff --git a/extensions/telegram/src/lane-delivery.test.ts b/extensions/telegram/src/lane-delivery.test.ts index fcf8a668595..30e385dbe7f 100644 --- a/extensions/telegram/src/lane-delivery.test.ts +++ b/extensions/telegram/src/lane-delivery.test.ts @@ -15,6 +15,10 @@ function createHarness(params?: { answerStream?: DraftLaneState["stream"] | null; draftMaxChars?: number; splitFinalTextForStream?: (text: string) => readonly string[]; + resolveFinalTextCandidate?: (params: { + finalText: string; + laneName: LaneName; + }) => string | undefined; }) { const answer = params?.answerStream === null @@ -59,6 +63,7 @@ function createHarness(params?: { stopDraftLane, clearDraftLane, editStreamMessage, + resolveFinalTextCandidate: params?.resolveFinalTextCandidate, log, markDelivered, }); @@ -151,6 +156,229 @@ describe("createLaneTextDeliverer", () => { expect(harness.lanes.answer.finalized).toBe(true); }); + it("keeps a longer partial preview when the final payload is an ellipsis-truncated snapshot", async () => { + const fullAnswer = + "Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen."; + const truncatedFinal = + "Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man..."; + const answer = createTestDraftStream({ messageId: 999 }); + answer.lastDeliveredText.mockReturnValue(fullAnswer); + const harness = createHarness({ + answerStream: answer, + resolveFinalTextCandidate: () => fullAnswer, + }); + + const result = await deliverFinalAnswer(harness, truncatedFinal); + + const delivery = expectPreviewFinalized(result); + expect(delivery.content).toBe(fullAnswer); + expect(delivery.messageId).toBe(999); + expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal); + expect(harness.stopDraftLane).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.markDelivered).toHaveBeenCalledTimes(1); + expect(harness.lanes.answer.finalized).toBe(true); + }); + + it("keeps a longer delivered stream preview when transcript lookup misses", async () => { + const fullAnswer = + "Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen."; + const truncatedFinal = + "Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man..."; + const answer = createTestDraftStream({ messageId: 999 }); + answer.lastDeliveredText.mockReturnValue(fullAnswer); + const harness = createHarness({ answerStream: answer }); + harness.lanes.answer.lastPartialText = fullAnswer; + harness.lanes.answer.hasStreamedMessage = true; + + const result = await deliverFinalAnswer(harness, truncatedFinal); + + const delivery = expectPreviewFinalized(result); + expect(delivery.content).toBe(fullAnswer); + expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.markDelivered).toHaveBeenCalledTimes(1); + }); + + it("keeps a longer pending partial preview before it is delivered", async () => { + const fullAnswer = + "Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen."; + const truncatedFinal = + "Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man..."; + let deliveredText = ""; + const answer = createTestDraftStream({ + messageId: 999, + onStop: () => { + deliveredText = fullAnswer; + }, + }); + answer.lastDeliveredText.mockImplementation(() => deliveredText); + const harness = createHarness({ + answerStream: answer, + resolveFinalTextCandidate: () => fullAnswer, + }); + + answer.update(fullAnswer); + harness.lanes.answer.lastPartialText = fullAnswer; + harness.lanes.answer.hasStreamedMessage = true; + const result = await deliverFinalAnswer(harness, truncatedFinal); + + const delivery = expectPreviewFinalized(result); + expect(delivery.content).toBe(fullAnswer); + expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal); + expect(harness.stopDraftLane).toHaveBeenCalledTimes(1); + expect(harness.markDelivered).toHaveBeenCalledTimes(1); + }); + + it("materializes a pending retained preview before reading the message id", async () => { + const fullAnswer = + "Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen."; + const truncatedFinal = + "Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man..."; + let answer: ReturnType; + let deliveredText = ""; + answer = createTestDraftStream({ + onStop: () => { + answer.setMessageId(999); + deliveredText = fullAnswer; + }, + }); + answer.lastDeliveredText.mockImplementation(() => deliveredText); + const harness = createHarness({ + answerStream: answer, + resolveFinalTextCandidate: () => fullAnswer, + }); + + answer.update(fullAnswer); + harness.lanes.answer.lastPartialText = fullAnswer; + harness.lanes.answer.hasStreamedMessage = true; + const result = await deliverFinalAnswer(harness, truncatedFinal); + + const delivery = expectPreviewFinalized(result); + expect(delivery.content).toBe(fullAnswer); + expect(delivery.messageId).toBe(999); + expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.markDelivered).toHaveBeenCalledTimes(1); + }); + + it("falls back when the retained pending preview does not land", async () => { + const fullAnswer = + "Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console."; + const truncatedFinal = "Ja. Hier nochmal sauber Schritt fuer Schritt..."; + const answer = createTestDraftStream({ messageId: 999 }); + answer.lastDeliveredText.mockReturnValue("older preview"); + const harness = createHarness({ + answerStream: answer, + resolveFinalTextCandidate: () => fullAnswer, + }); + harness.lanes.answer.lastPartialText = fullAnswer; + harness.lanes.answer.hasStreamedMessage = true; + + const result = await deliverFinalAnswer(harness, truncatedFinal); + + expect(result.kind).toBe("sent"); + expect(harness.clearDraftLane).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).toHaveBeenCalledWith({ text: truncatedFinal }, { durable: true }); + }); + + it("uses the canonical final when the shorter final has no truncation marker", async () => { + const answer = createTestDraftStream({ messageId: 999 }); + answer.lastDeliveredText.mockReturnValue("Hello world"); + const harness = createHarness({ answerStream: answer }); + + const result = await deliverFinalAnswer(harness, "Hello"); + + expect(result.kind).toBe("sent"); + expect(answer.update).toHaveBeenCalledWith("Hello"); + expect(harness.sendPayload).toHaveBeenCalledWith({ text: "Hello" }, { durable: true }); + }); + + it("uses the canonical final when the shorter final intentionally ends with ellipsis", async () => { + const answer = createTestDraftStream({ messageId: 999 }); + answer.lastDeliveredText.mockReturnValue("Let's leave it... and continue"); + const harness = createHarness({ answerStream: answer }); + + const result = await deliverFinalAnswer(harness, "Let's leave it..."); + + expect(result.kind).toBe("sent"); + expect(answer.update).toHaveBeenCalledWith("Let's leave it..."); + expect(harness.sendPayload).toHaveBeenCalledWith( + { text: "Let's leave it..." }, + { durable: true }, + ); + }); + + it("uses the canonical final when an intentional ellipsis replaces a longer draft", async () => { + const answer = createTestDraftStream({ messageId: 999 }); + answer.lastDeliveredText.mockReturnValue("I don't know the answer"); + const harness = createHarness({ answerStream: answer }); + + const result = await deliverFinalAnswer(harness, "I don't know..."); + + expect(result.kind).toBe("sent"); + expect(answer.update).toHaveBeenCalledWith("I don't know..."); + expect(harness.sendPayload).toHaveBeenCalledWith( + { text: "I don't know..." }, + { durable: true }, + ); + }); + + it("uses the canonical split final when only the first chunk ends with ellipsis", async () => { + const buttons = [[{ text: "OK", callback_data: "ok" }]]; + const answer = createTestDraftStream({ messageId: 999 }); + const harness = createHarness({ + answerStream: answer, + draftMaxChars: 10, + splitFinalTextForStream: () => ["Hello...", " world"], + }); + harness.lanes.answer.lastPartialText = "Hello retained preview"; + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello... world", + payload: { text: "Hello... world" }, + infoKind: "final", + buttons, + }); + + const delivery = expectPreviewFinalized(result); + expect(delivery.content).toBe("Hello... world"); + expect(answer.update).toHaveBeenCalledWith("Hello..."); + expect(harness.editStreamMessage).toHaveBeenCalledWith({ + laneName: "answer", + messageId: 999, + text: "Hello...", + buttons, + }); + expect(harness.sendPayload).toHaveBeenCalledWith({ text: " world" }); + expect(harness.markDelivered).toHaveBeenCalledTimes(1); + }); + + it("uses normal final delivery when retained preview is too long for button edit", async () => { + const buttons = [[{ text: "OK", callback_data: "ok" }]]; + const answer = createTestDraftStream({ messageId: 999 }); + answer.lastDeliveredText.mockReturnValue("Hello retained preview"); + const harness = createHarness({ + answerStream: answer, + draftMaxChars: 10, + resolveFinalTextCandidate: () => "Hello retained preview", + }); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello...", + payload: { text: "Hello..." }, + infoKind: "final", + buttons, + }); + + expect(result.kind).toBe("sent"); + expect(answer.update).toHaveBeenCalledWith("Hello..."); + expect(harness.editStreamMessage).not.toHaveBeenCalled(); + expect(harness.sendPayload).toHaveBeenCalledWith({ text: "Hello..." }, { durable: true }); + }); + it("falls back to normal delivery when no stream exists", async () => { const harness = createHarness({ answerStream: null }); diff --git a/src/plugin-sdk/session-store-runtime.ts b/src/plugin-sdk/session-store-runtime.ts index 3370552e5d9..93de3ecdde1 100644 --- a/src/plugin-sdk/session-store-runtime.ts +++ b/src/plugin-sdk/session-store-runtime.ts @@ -4,6 +4,7 @@ export { loadSessionStore } from "../config/sessions/store-load.js"; export { resolveSessionStoreEntry } from "../config/sessions/store-entry.js"; export { resolveSessionTranscriptPathInDir, resolveStorePath } from "../config/sessions/paths.js"; export { resolveAndPersistSessionFile } from "../config/sessions/session-file.js"; +export { readLatestAssistantTextFromSessionTranscript } from "../config/sessions/transcript.js"; export { resolveSessionKey } from "../config/sessions/session-key.js"; export { resolveGroupSessionKey } from "../config/sessions/group.js"; export { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";