import { logger } from "../logger.js"; import { type SessionTurnRecord } from "../sessions/transcriptStore.js"; import { MemoryStore } from "../memory/store.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { collectTextContent } from "./chatStream.js"; const TITLE_PROMPT_TIMEOUT_MS = 5000; const TITLE_CONTEXT_MESSAGE_LIMIT = 40; const TITLE_CONTEXT_CHAR_LIMIT = 2400; const TITLE_CONTEXT_MESSAGE_CHAR_LIMIT = 240; const RESTORE_TURN_LIMIT = 8; const RESTORE_MESSAGE_CHAR_LIMIT = 480; const RESTORE_CONTEXT_CHAR_LIMIT = 3200; const DEFAULT_SESSION_TITLE = "新对话"; const buildSessionTitle = (message: string) => { const normalized = message.replace(/\s+/g, " ").trim(); if (!normalized) { return DEFAULT_SESSION_TITLE; } return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized; }; const appendTitleContextMessage = ( lines: string[], role: "用户" | "助手", content: string | undefined, maxLength = TITLE_CONTEXT_MESSAGE_CHAR_LIMIT, ) => { const normalized = content?.replace(/\s+/g, " ").trim(); if (!normalized) { return; } lines.push(`${role}:${normalized.slice(0, maxLength)}`); }; const buildTitleConversationContext = async ( runtime: OpencodeRuntimeAdapter, sessionId: string, ) => { const messages = await runtime.messages(sessionId, TITLE_CONTEXT_MESSAGE_LIMIT); const recentMessages = messages .filter( (message) => message.info.role === "user" || message.info.role === "assistant", ) .map((message) => ({ role: message.info.role, content: collectTextContent(message.parts) .replace(/\s+/g, " ") .trim() .slice(0, TITLE_CONTEXT_MESSAGE_CHAR_LIMIT), })) .filter((message) => message.content.length > 0); if (recentMessages.length === 0) { return ""; } const formattedMessages = recentMessages.map( (message) => `${message.role === "user" ? "用户" : "助手"}:${message.content}`, ); const fullConversation = formattedMessages.join("\n"); if (fullConversation.length <= TITLE_CONTEXT_CHAR_LIMIT) { return fullConversation; } const headCount = Math.min(4, formattedMessages.length); const tailCount = Math.min(8, Math.max(0, formattedMessages.length - headCount)); const middleOmitted = formattedMessages.length > headCount + tailCount; const summary = [ ...formattedMessages.slice(0, headCount), ...(middleOmitted ? ["……(中间省略若干轮对话)"] : []), ...formattedMessages.slice(-tailCount), ].join("\n"); return summary.slice(0, TITLE_CONTEXT_CHAR_LIMIT); }; const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => { const normalized = rawTitle .replace(/\s+/g, " ") .replace(/^标题[::]\s*/i, "") .replace(/["'“”‘’`]/g, "") .replace(/[。!?!?,,、;;::]+$/g, "") .trim(); if (!normalized) { return fallback; } return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized; }; export const shouldGenerateSessionTitle = (options: { recentTurnCount: number; isTitleManuallyEdited: boolean; }) => options.recentTurnCount <= 1 && !options.isTitleManuallyEdited; export const generateSessionTitle = async ( runtime: OpencodeRuntimeAdapter, options: { sessionId: string; latestUserMessage: string; latestAssistantMessage?: string; fallbackTitle?: string; }, ) => { const fallbackTitle = options.fallbackTitle?.trim(); const fallback = fallbackTitle && fallbackTitle !== DEFAULT_SESSION_TITLE ? fallbackTitle : buildSessionTitle(options.latestUserMessage); let titleSessionId: string | undefined; try { const scopedContext: string[] = []; appendTitleContextMessage(scopedContext, "用户", options.latestUserMessage, 480); appendTitleContextMessage(scopedContext, "助手", options.latestAssistantMessage, 960); const conversation = scopedContext.length > 0 ? scopedContext.join("\n") : await buildTitleConversationContext(runtime, options.sessionId); if (!conversation) { return fallback; } const titleSession = await runtime.createSession(`title-${Date.now().toString(36)}`); titleSessionId = titleSession.id; const request = runtime .prompt( titleSession.id, [ "你是会话标题生成器。", "请根据下面整段多轮对话生成一个 8-16 字中文标题。", "要求:简洁、具体、可读、避免标点、不要引号、不要解释。", "优先概括用户当前真实需求和助手最终结论。", "忽略系统提示、历史记忆、学习上下文、工具日志等元信息。", "不要直接照抄用户任一条消息原文。", "只输出标题本身。", "", conversation, ].join("\n"), ) .then(async () => { await runtime.waitForSessionIdle(titleSession.id, TITLE_PROMPT_TIMEOUT_MS); const messages = await runtime.messages(titleSession.id, 20); const assistantMessage = [...messages] .reverse() .find((message) => message.info.role === "assistant"); const title = collectTextContent(assistantMessage?.parts ?? []); return normalizeGeneratedTitle(title, fallback); }); const timeout = new Promise((resolve) => { setTimeout(() => resolve(fallback), TITLE_PROMPT_TIMEOUT_MS); }); return await Promise.race([request, timeout]); } catch (error) { logger.warn({ err: error }, "failed to generate session title, using fallback"); return fallback; } finally { if (titleSessionId) { await runtime.abortSession(titleSessionId).catch((error) => { logger.debug({ sessionId: titleSessionId, err: error }, "failed to cleanup title session"); }); } } }; export const getConversationTurnStats = async ( runtime: OpencodeRuntimeAdapter, sessionId: string, ) => { const messages = await runtime.messages(sessionId, 12); return messages.reduce( (stats, message) => { if (message.info.role === "user") { stats.userMessageCount += 1; } else if (message.info.role === "assistant") { stats.assistantMessageCount += 1; } return stats; }, { userMessageCount: 0, assistantMessageCount: 0, }, ); }; export const buildPromptWithLearningContext = async ( memoryStore: MemoryStore, actorKey: string, projectKey: string, options: { recentTurns: SessionTurnRecord[]; persistedMessages?: unknown[]; message: string; restoreConversation?: boolean; }, ) => { const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey }); const restoredConversation = options.restoreConversation === false ? "" : buildRestoredConversationFromMessages(options.persistedMessages) || buildRestoredConversationContext(options.recentTurns); if (!snapshot && !restoredConversation) { return options.message; } return [snapshot, restoredConversation, `[Current user request]\n${options.message}`] .filter(Boolean) .join("\n\n"); }; export const shouldRestoreConversationForRuntime = (options: { hadExistingSessionRecord: boolean; runtimeHasConversation: boolean; }) => !options.hadExistingSessionRecord || !options.runtimeHasConversation; const buildRestoredConversationContext = (recentTurns: SessionTurnRecord[]) => { const formattedTurns = recentTurns .slice(-RESTORE_TURN_LIMIT) .flatMap((turn) => [ `用户:${compactMessage(turn.userMessage)}`, `助手:${compactMessage(turn.assistantMessage)}`, ]) .filter((entry) => entry.length > 0); if (formattedTurns.length === 0) { return ""; } const conversation = formattedTurns.join("\n"); const trimmedConversation = conversation.length > RESTORE_CONTEXT_CHAR_LIMIT ? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...` : conversation; return [ "[Previous conversation context]", "以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。", trimmedConversation, ].join("\n"); }; const compactMessage = (value: string) => { const normalized = value.replace(/\s+/g, " ").trim(); if (!normalized) { return ""; } return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT ? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...` : normalized; }; const isObjectRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const isSyntheticAssistantError = (content: string) => /^⚠️\s*\*\*(请求已中断|错误[::]?)/.test(content); export const extractLatestFrontendTurn = (messages: unknown[] | undefined) => { if (!Array.isArray(messages) || messages.length === 0) { return null; } for (let index = messages.length - 1; index >= 0; index -= 1) { const assistant = messages[index]; if (!isObjectRecord(assistant) || assistant.role !== "assistant") { continue; } const assistantMessage = typeof assistant.content === "string" ? assistant.content.replace(/\s+/g, " ").trim() : ""; if (!assistantMessage || isSyntheticAssistantError(assistantMessage)) { continue; } const user = messages .slice(0, index) .reverse() .find((message) => isObjectRecord(message) && message.role === "user"); if (!isObjectRecord(user) || typeof user.content !== "string") { continue; } const userMessage = user.content.replace(/\s+/g, " ").trim(); if (!userMessage) { continue; } return { assistantMessage, toolCallCount: estimateFrontendToolCallCount(assistant), userMessage, }; } return null; }; const buildRestoredConversationFromMessages = (messages: unknown[] | undefined) => { if (!Array.isArray(messages) || messages.length === 0) { return ""; } const formattedMessages = messages .slice(-(RESTORE_TURN_LIMIT * 2 + 2)) .flatMap((message) => { if (!isObjectRecord(message)) { return []; } const role = message.role; const content = message.content; if ((role !== "user" && role !== "assistant") || typeof content !== "string") { return []; } const normalizedContent = compactMessage(content); if (!normalizedContent) { return []; } if (role === "assistant" && isSyntheticAssistantError(normalizedContent)) { return []; } return [`${role === "user" ? "用户" : "助手"}:${normalizedContent}`]; }); if (formattedMessages.length === 0) { return ""; } const conversation = formattedMessages.join("\n"); const trimmedConversation = conversation.length > RESTORE_CONTEXT_CHAR_LIMIT ? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...` : conversation; return [ "[Previous conversation context]", "以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。", trimmedConversation, ].join("\n"); }; const estimateFrontendToolCallCount = (assistant: Record) => { const progress = Array.isArray(assistant.progress) ? assistant.progress : []; const artifacts = Array.isArray(assistant.artifacts) ? assistant.artifacts : []; const toolProgressCount = progress.filter( (item) => isObjectRecord(item) && (item.phase === "tool" || (typeof item.id === "string" && item.id.startsWith("tool-"))), ).length; return Math.max(toolProgressCount, artifacts.length); };