import { chmod, cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import type { FastifyBaseLogger } from 'fastify'; import type { IncomingMessage } from 'node:http'; import type { Socket } from 'node:net'; import { WebSocketServer, type RawData, type WebSocket } from 'ws'; import { env } from '../config/env.js'; import { db } from '../db/client.js'; import { getAppConfigSnapshot, getChatContextSettingsConfig, getChatTypesConfig } from './app-config-service.js'; import { BOARD_POSTS_TABLE } from './board-service.js'; import { appendChatConversationMessage, appendChatConversationActivityLine, getChatConversationRequest, getChatConversation, listChatConversationOfflineNotificationClientIds, listRecoverableChatConversationRequests, listChatConversationMessages, listChatConversationRequests, upsertChatConversationRequest, updateChatConversationJobState, updateChatConversationContext, } from './chat-room-service.js'; import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot } from './chat-runtime-service.js'; import { hasErrorLogViewAccessToken } from './error-log-service.js'; import { sendNotifications } from './notification-service.js'; import { createNotificationMessage } from './notification-message-service.js'; import { subscribeNotificationMessageChanges, type NotificationMessageChangeEvent, } from './notification-message-service.js'; import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js'; import { resolveMainProjectRoot } from './main-project-root-service.js'; import { findLatestPlanItem, findPlanItemByPreviewUrl, findPlanItemByWorkId, getPlanItemById, listPlanActionHistories, listPlanIssueHistories, listPlanSourceWorkHistories, mapPlanActionRow, mapPlanIssueRow, mapPlanSourceWorkRow, PLAN_TABLE, } from './plan-service.js'; type ChatAuthor = 'codex' | 'system' | 'user'; type ChatMessage = { id: number; author: ChatAuthor; text: string; timestamp: string; clientRequestId?: string | null; parts?: ChatMessagePart[]; }; type ChatContext = { pageId: string | null; pageTitle: string; topMenu: string; focusedComponentId: string | null; pageUrl: string; isStandaloneMode?: boolean; pageVisibilityState?: 'visible' | 'hidden'; pageFocusState?: 'focused' | 'blurred'; chatTypeId?: string | null; chatTypeLabel?: string; chatTypeDescription?: string; chatTypeBaseDescription?: string; defaultContextIds?: string[]; defaultContexts?: Array<{ id?: string; title?: string; content?: string; }>; customContextTitle?: string | null; customContextContent?: string | null; }; type ChatInboundMessage = | { type: 'context:update'; payload: ChatContext; } | { type: 'presence:ping'; payload?: { at?: number; }; } | { type: 'event:received'; payload: { eventId: number; }; } | { type: 'message:send'; payload: { text: string; requestId?: string; mode?: 'queue' | 'direct'; omitPromptHistory?: boolean; chatTypeId?: string | null; chatTypeLabel?: string; chatTypeDescription?: string; chatTypeBaseDescription?: string; defaultContextIds?: string[]; defaultContexts?: Array<{ id?: string; title?: string; content?: string; }>; customContextTitle?: string | null; customContextContent?: string | null; }; } | { type: 'runtime:watch'; payload: { requestId?: string | null; }; }; type ChatOutboundPayload = | { type: 'chat:init'; payload: { messages: ChatMessage[]; }; } | { type: 'chat:message'; payload: ChatMessage; } | { type: 'chat:message:update'; payload: ChatMessage; } | { type: 'chat:status'; payload: { connectedAt: string; }; } | { type: 'chat:job'; payload: { requestId: string; status: 'queued' | 'started' | 'completed' | 'failed'; mode: 'queue' | 'direct'; queueSize: number; message: string; }; } | { type: 'chat:error'; payload: { message: string; }; } | { type: 'chat:runtime'; payload: ChatRuntimeSnapshot; } | { type: 'chat:runtime:detail'; payload: ChatRuntimeJobDetail; } | { type: 'chat:activity'; payload: { requestId: string; line: string; lineCount: number; lineNo?: number; }; } | { type: 'notification:messages-updated'; payload: | { action: 'created' | 'updated'; itemId: number; category: string; read: boolean; } | { action: 'deleted'; itemId: number; }; }; type ChatOutboundMessage = ChatOutboundPayload & { eventId: number; sessionId: string; }; type ChatSessionState = { sessionId: string; clientId: string | null; socket: WebSocket | null; lastSeenAt: number; isDeleted: boolean; context: ChatContext | null; queue: Array<{ requestId: string; text: string; mode: 'queue' | 'direct'; requestedAtMs: number; omitPromptHistory?: boolean; context: ChatContext | null; }>; activeRequestCount: number; pendingQueueReleaseEventId: number | null; nextEventId: number; eventHistory: ChatOutboundMessage[]; messagePersistenceTail: Promise; watchedRuntimeRequestId: string | null; }; type ChatRuntimeController = { getJobDetail: (requestId: string) => ReturnType; cancelJob: (requestId: string) => Promise; removeQueuedJob: (requestId: string) => Promise; }; type ActiveChatExecution = { cancel: () => Promise | boolean; }; let activeRuntimeController: ChatRuntimeController | null = null; let activeChatService: ChatService | null = null; const activeChatProcessRegistry = new Map(); export function getChatRuntimeController() { return activeRuntimeController; } export function getActiveChatService() { return activeChatService; } const SOCKET_PATH = '/ws/chat'; const KST_TIME_ZONE = 'Asia/Seoul'; const STREAM_CAPTURE_LIMIT = 256 * 1024; const CHAT_PUBLIC_RESOURCE_DIR = '.codex_chat'; const CHAT_PUBLIC_RESOURCE_SUBDIR = 'resource'; const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources'; const CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH = 'source/chat-room-reference.md'; const CHAT_SESSION_REFERENCE_AUTO_START = ''; const CHAT_SESSION_REFERENCE_AUTO_END = ''; const CHAT_SESSION_RESOURCE_DIR_MODE = 0o777; const SOCKET_READY_STATE_OPEN = 1; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const MAX_CHAT_ACTIVITY_MESSAGE_LINES = 240; const MAX_CHAT_ACTIVITY_MESSAGE_CHARS = 80_000; const CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS = 15_000; const CHAT_PROMPT_HISTORY_MAX_MESSAGES = 12; const CHAT_PROMPT_HISTORY_MAX_CHARS = 3200; const CHAT_SESSION_EVENT_HISTORY_LIMIT = 400; let chatMessageSequence = 0; export function isChatClientActivelyViewing(clientId: string | null | undefined, sessions: Iterable) { return evaluateChatClientActiveViewing(clientId, sessions).isActive; } function evaluateChatClientActiveViewing(clientId: string | null | undefined, sessions: Iterable, nowMs = Date.now()) { const normalizedClientId = clientId?.trim(); if (!normalizedClientId) { return { isActive: false, matchedSessions: [], }; } const matchedSessions: Array<{ sessionId: string; lastSeenAt: number; presenceAgeMs: number; socketReadyState: number | null; pageVisibilityState: 'visible' | 'hidden'; pageFocusState: 'focused' | 'blurred'; pageOrigin: string | null; reason: | 'socket-not-open' | 'stale-presence' | 'backgrounded' | 'active-no-page-url' | 'active-supported-origin' | 'unsupported-origin' | 'invalid-page-url'; }> = []; for (const session of sessions) { if (session.clientId?.trim() !== normalizedClientId) { continue; } const pageVisibilityState = session.context?.pageVisibilityState ?? 'visible'; const pageFocusState = session.context?.pageFocusState ?? 'focused'; const pageUrl = session.context?.pageUrl?.trim(); const presenceAgeMs = Math.max(0, nowMs - session.lastSeenAt); const socketReadyState = session.socket?.readyState ?? null; if (!session.socket || session.socket.readyState !== SOCKET_READY_STATE_OPEN) { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: null, reason: 'socket-not-open', }); continue; } const hasFreshPresence = presenceAgeMs < CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS; if (!hasFreshPresence) { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: null, reason: 'stale-presence', }); continue; } if (pageVisibilityState === 'hidden' || pageFocusState === 'blurred') { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: null, reason: 'backgrounded', }); continue; } if (!pageUrl) { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: null, reason: 'active-no-page-url', }); return { isActive: true, matchedSessions, }; } try { const resolvedUrl = new URL(pageUrl); if (resolvedUrl.origin === 'https://preview.sm-home.cloud' || resolvedUrl.origin === 'https://test.sm-home.cloud') { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: resolvedUrl.origin, reason: 'active-supported-origin', }); return { isActive: true, matchedSessions, }; } matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: resolvedUrl.origin, reason: 'unsupported-origin', }); } catch { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: null, reason: 'invalid-page-url', }); continue; } } return { isActive: false, matchedSessions, }; } function logChatClientActiveViewingEvaluation( logger: FastifyBaseLogger, clientId: string, evaluation: ReturnType, ) { if (evaluation.matchedSessions.length === 0) { logger.info( { clientId, active: false, reason: 'no-matching-session', }, 'chat offline notification presence evaluation', ); return; } for (const sessionState of evaluation.matchedSessions) { logger.info( { clientId, active: evaluation.isActive, sessionId: sessionState.sessionId, lastSeenAt: sessionState.lastSeenAt, presenceAgeMs: sessionState.presenceAgeMs, socketReadyState: sessionState.socketReadyState, pageVisibilityState: sessionState.pageVisibilityState, pageFocusState: sessionState.pageFocusState, pageOrigin: sessionState.pageOrigin, reason: sessionState.reason, }, 'chat offline notification presence evaluation', ); } } export function resolveChatContextAppOrigin(context: ChatContext | null | undefined) { const pageUrl = context?.pageUrl?.trim(); if (!pageUrl) { return null; } try { return new URL(pageUrl).origin; } catch { return null; } } function buildChatNotificationTargetUrl(context: ChatContext | null, sessionId: string) { const fallbackUrl = new URL('https://preview.sm-home.cloud/chat/live'); fallbackUrl.searchParams.set('topMenu', 'chat'); fallbackUrl.searchParams.set('sessionId', sessionId); const pageUrl = context?.pageUrl?.trim(); if (!pageUrl) { return fallbackUrl.toString(); } try { const targetUrl = new URL(pageUrl); targetUrl.pathname = '/chat/live'; targetUrl.searchParams.set('topMenu', targetUrl.searchParams.get('topMenu') || 'chat'); targetUrl.searchParams.set('sessionId', sessionId); targetUrl.searchParams.delete('chatView'); targetUrl.searchParams.delete('runtimeRequestId'); return targetUrl.toString(); } catch { return fallbackUrl.toString(); } } function createChatNotificationPreview(text: string) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; } function createChatQuestionAnswerNotificationBody(args: { questionText?: string | null; answerText?: string | null; fallback: string; }) { const questionPreview = createChatNotificationPreview(args.questionText ?? ''); const answerPreview = createChatNotificationPreview(args.answerText ?? ''); if (questionPreview && answerPreview) { return `질문: ${questionPreview}\n답변: ${answerPreview}`; } if (answerPreview) { return `답변: ${answerPreview}`; } if (questionPreview) { return `질문: ${questionPreview}`; } return args.fallback; } function normalizeStructuredChatMessage(message: ChatMessage): ChatMessage { if (message.author === 'user') { return message; } const existingParts = Array.isArray(message.parts) ? message.parts.filter(Boolean) : []; const extracted = extractChatMessageParts(message.text); const nextParts = existingParts.length > 0 ? existingParts : extracted.parts; if (nextParts.length === 0) { return existingParts.length === 0 ? message : { ...message, parts: existingParts }; } return { ...message, text: extracted.strippedText, parts: nextParts, }; } function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) { const questionPreview = createChatNotificationPreview(questionText ?? ''); return questionPreview ? `질문: ${questionPreview}` : fallback ?? ''; } function normalizeNotificationDetailText(text?: string | null) { const normalized = String(text ?? '').trim(); return normalized || undefined; } export function collectOfflineNotificationClientIds(sessionClientId?: string | null, preferredClientIds?: string[]) { const nextClientIds = new Set(); for (const candidate of preferredClientIds ?? []) { const normalized = String(candidate ?? '').trim(); if (normalized) { nextClientIds.add(normalized); } } const normalizedSessionClientId = String(sessionClientId ?? '').trim(); if (normalizedSessionClientId) { nextClientIds.add(normalizedSessionClientId); } return [...nextClientIds]; } export function filterInactiveOfflineNotificationClientIds( clientIds: string[], sessions: Iterable, isActive: (clientId: string, currentSessions: Iterable) => boolean = isChatClientActivelyViewing, ) { return clientIds.filter((clientId) => !isActive(clientId, sessions)); } export function shouldSendOfflineChatNotification(options: { receiveRoomNotifications?: boolean | null; conversationNotifyOffline?: boolean | null; }) { if (options.receiveRoomNotifications === false) { return false; } return options.conversationNotifyOffline === true; } function isPreparingChatReply(text?: string | null) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.startsWith('응답을 준비하고 있습니다'); } function formatTime(date: Date) { return new Intl.DateTimeFormat('sv-SE', { timeZone: KST_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }) .format(date) .replace(',', ''); } export function resolveResponseTimestamp(requestedAtMs?: number | null, nowMs = Date.now()) { if (!Number.isFinite(requestedAtMs)) { return formatTime(new Date(nowMs)); } return formatTime(new Date(Math.max(nowMs, Number(requestedAtMs) + 1_000))); } function parseRequestedAtMs(value: string) { const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : Date.now(); } function formatKstDate(date = new Date()) { return new Intl.DateTimeFormat('en-CA', { timeZone: KST_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', }).format(date); } function createChatMessageId() { chatMessageSequence = (chatMessageSequence + 1) % 1_000; return Date.now() * 1_000 + chatMessageSequence; } function createMessage(author: ChatAuthor, text: string, clientRequestId?: string | null, parts?: ChatMessagePart[]): ChatMessage { return { id: createChatMessageId(), author, text, timestamp: formatTime(new Date()), clientRequestId: clientRequestId?.trim() || null, parts: Array.isArray(parts) ? parts : [], }; } function createRequestId() { return `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL) { const queryToken = url.searchParams.get('accessToken')?.trim(); const headerToken = Array.isArray(request.headers['x-access-token']) ? String(request.headers['x-access-token'][0] ?? '').trim() : String(request.headers['x-access-token'] ?? '').trim(); return hasErrorLogViewAccessToken(queryToken || headerToken); } function hashRequestId(value: string) { let hash = 0; for (const character of value) { hash = (hash * 31 + character.charCodeAt(0)) | 0; } return Math.abs(hash) + 1_000_000; } export function fitActivityLogLines(lines: string[]) { const normalizedLines = lines.map((line) => line.trim()).filter(Boolean); if (normalizedLines.length <= 1) { return normalizedLines; } let startIndex = Math.max(0, normalizedLines.length - MAX_CHAT_ACTIVITY_MESSAGE_LINES); let fittedLines = normalizedLines.slice(startIndex); while (fittedLines.length > 1 && fittedLines.join('\n\n').length > MAX_CHAT_ACTIVITY_MESSAGE_CHARS) { startIndex += 1; fittedLines = normalizedLines.slice(startIndex); } return fittedLines; } export function createActivityLogMessage(requestId: string, lines: string[]) { const normalizedRequestId = requestId.trim(); const fittedLines = fitActivityLogLines(lines); const messageBody = fittedLines.length > 0 ? fittedLines.join('\n\n') : '활동 로그를 준비하고 있습니다.'; const messageText = `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${messageBody}`; return createMessage('system', messageText, normalizedRequestId) ? { id: hashRequestId(normalizedRequestId), author: 'system' as const, text: messageText, timestamp: formatTime(new Date()), clientRequestId: normalizedRequestId, } : null; } function shouldPersistMessageUpdate(message: ChatMessage) { if (message.author === 'codex') { return false; } return !String(message.text ?? '').startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); } function isSocketOpen(socket: WebSocket | null | undefined) { return Boolean(socket && socket.readyState === SOCKET_READY_STATE_OPEN); } function closeSocketSafely( logger: FastifyBaseLogger, socket: WebSocket | null | undefined, message: string, code = 1000, reason = 'replaced', ) { if (!socket) { return; } try { socket.close(code, reason); } catch (error) { logger.warn(error, message); } } function sendSocketEnvelope( logger: FastifyBaseLogger, socket: WebSocket | null | undefined, envelope: ChatOutboundMessage, message: string, ) { const targetSocket = socket; if (!targetSocket || targetSocket.readyState !== SOCKET_READY_STATE_OPEN) { return false; } try { targetSocket.send(JSON.stringify(envelope)); return true; } catch (error) { logger.warn(error, message); return false; } } type ParsedPlanContext = { planId: number | null; workId: string | null; previewUrl: string | null; }; type PlanSnapshot = { planId: number; workId: string; status: string; note: string; workerStatus: string | null; issueTags: string[]; hasOpenIssues: boolean; assignedBranch: string | null; lastError: string | null; latestActionNote: string | null; latestIssue: { tag: string; message: string; resolved: boolean; } | null; latestSourceWork: { summary: string; previewUrl: string | null; changedFiles: string[]; } | null; recentActionNotes: string[]; recentWorkSummaries: string[]; }; function truncateText(value: string | null | undefined, limit = 120) { const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); if (!normalized) { return ''; } return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized; } function extractReferencedPlanId(input: string) { const planIdMatch = input.match(/(?:planid=|#)(\d{1,9})/i) ?? input.match(/\bplan\s*(\d{1,9})\b/i); const parsedPlanId = planIdMatch?.[1] ? Number(planIdMatch[1]) : NaN; return Number.isInteger(parsedPlanId) && parsedPlanId > 0 ? parsedPlanId : null; } function extractReferencedWorkId(input: string) { const workIdMatch = input.match(/\bworkid\s*[:=]?\s*([^\s,]+)/i); return workIdMatch?.[1]?.trim() || null; } function extractReferencedPreviewUrl(input: string) { const previewUrlMatch = input.match(/https?:\/\/[^\s)]+/i); return previewUrlMatch?.[0]?.trim() || null; } function buildChangedFilesSummary(changedFiles: string[], limit = 5) { if (changedFiles.length === 0) { return '변경/신규 파일: 기록 없음'; } const visibleFiles = changedFiles.slice(0, limit); const suffix = changedFiles.length > limit ? ` 외 ${changedFiles.length - limit}개` : ''; return `변경/신규 파일: ${visibleFiles.join(', ')}${suffix}`; } function parsePlanContext(context: ChatContext | null, input: string): ParsedPlanContext { const pageUrl = context?.pageUrl ?? ''; const referencedPlanId = extractReferencedPlanId(input); const referencedWorkId = extractReferencedWorkId(input); const referencedPreviewUrl = extractReferencedPreviewUrl(input); if (!pageUrl) { return { planId: referencedPlanId, workId: referencedWorkId, previewUrl: referencedPreviewUrl, }; } try { const url = new URL(pageUrl); const planIdText = url.searchParams.get('planId'); const workIdText = url.searchParams.get('workId'); const planIdNumber = planIdText ? Number(planIdText) : NaN; return { planId: Number.isInteger(planIdNumber) && planIdNumber > 0 ? planIdNumber : referencedPlanId, workId: workIdText?.trim() || referencedWorkId, previewUrl: referencedPreviewUrl, }; } catch { return { planId: referencedPlanId, workId: referencedWorkId, previewUrl: referencedPreviewUrl, }; } } async function loadPlanSnapshot(planId: number): Promise { const item = await getPlanItemById(planId); if (!item) { return null; } const [actionRows, issueRows, sourceWorkRows] = await Promise.all([ listPlanActionHistories(planId), listPlanIssueHistories(planId), listPlanSourceWorkHistories(planId), ]); const latestAction = actionRows[0] ? mapPlanActionRow(actionRows[0]) : null; const latestIssue = issueRows[0] ? mapPlanIssueRow(issueRows[0]) : null; const latestSourceWork = sourceWorkRows[0] ? mapPlanSourceWorkRow(sourceWorkRows[0]) : null; return { planId, workId: String(item.workId ?? '작업ID'), status: String(item.status ?? '-'), note: String(item.note ?? ''), workerStatus: item.workerStatus ? String(item.workerStatus) : null, issueTags: Array.isArray(item.issueTags) ? item.issueTags.map((value) => String(value)) : [], hasOpenIssues: Boolean(item.hasOpenIssues), assignedBranch: item.assignedBranch ? String(item.assignedBranch) : null, lastError: item.lastError ? String(item.lastError) : null, latestActionNote: latestAction ? String(latestAction.note) : null, latestIssue: latestIssue ? { tag: String(latestIssue.issueTag), message: String(latestIssue.message), resolved: Boolean(latestIssue.resolved), } : null, latestSourceWork: latestSourceWork ? { summary: String(latestSourceWork.summary), previewUrl: latestSourceWork.previewUrl ? String(latestSourceWork.previewUrl) : null, changedFiles: latestSourceWork.changedFiles, } : null, recentActionNotes: actionRows .slice(0, 5) .map((row) => truncateText(String(mapPlanActionRow(row).note ?? ''), 140)) .filter(Boolean), recentWorkSummaries: sourceWorkRows .slice(0, 5) .map((row) => truncateText(String(mapPlanSourceWorkRow(row).summary ?? ''), 140)) .filter(Boolean), }; } function isWorklogRequest(input: string) { const normalized = input.toLowerCase(); const mentionsWorklog = input.includes('워크일지') || input.includes('작업로그') || input.includes('작업 일지') || input.includes('작업일지') || normalized.includes('worklog'); const asksToWrite = input.includes('작성') || input.includes('정리') || input.includes('기록') || input.includes('밀린') || input.includes('써'); return mentionsWorklog || (normalized.includes('log') && asksToWrite); } function isPlanDetailRequest(input: string) { const normalized = input.toLowerCase(); return ( input.includes('작업') || input.includes('계획') || input.includes('이력') || input.includes('이슈') || input.includes('상태') || input.includes('브랜치') || normalized.includes('plan') || normalized.includes('issue') || normalized.includes('status') || normalized.includes('history') || normalized.includes('branch') ); } function buildWorklogReply(context: ChatContext | null, snapshot: PlanSnapshot | null) { const pageTitle = context?.pageTitle ?? '현재 화면'; if (!snapshot) { return `현재 화면: ${pageTitle}\n작업로그 초안을 만들 Plan을 찾지 못했습니다. Plan 상세를 연 뒤 다시 요청해 주세요.`; } const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()); const todayWork = [ truncateText(snapshot.note, 180), ...snapshot.recentWorkSummaries, snapshot.latestActionNote ? truncateText(snapshot.latestActionNote, 140) : '', ].filter(Boolean); const issues = snapshot.latestIssue ? [ `${snapshot.latestIssue.tag} ${snapshot.latestIssue.resolved ? '해결' : '미해결'}: ${truncateText( snapshot.latestIssue.message, 140, )}`, ] : []; const decisions = [ snapshot.assignedBranch ? `작업 브랜치: ${snapshot.assignedBranch}` : '', snapshot.latestSourceWork?.previewUrl ? `preview 링크: ${snapshot.latestSourceWork.previewUrl}` : '', snapshot.status ? `현재 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}` : '', ].filter(Boolean); const details = [...snapshot.recentActionNotes, ...snapshot.recentWorkSummaries].filter(Boolean); return [ `현재 화면: ${pageTitle}`, `기준 Plan: #${snapshot.planId} ${snapshot.workId}`, '아래 초안을 작업일지에 바로 붙여 넣으면 됩니다.', '', `# ${today} 작업일지`, '', '## 오늘 작업', ...(todayWork.length > 0 ? todayWork.slice(0, 6).map((item) => `- ${item}`) : ['- Plan 메모와 이력 확인 후 작업 내용을 보강해 주세요.']), '', '## 이슈 및 해결', ...(issues.length > 0 ? issues.map((item) => `- ${item}`) : ['- 현재 기록된 이슈 없음']), '', '## 결정 사항', ...(decisions.length > 0 ? decisions.map((item) => `- ${item}`) : ['- 별도 결정 사항 없음']), '', '## 상세 작업 내역', ...(details.length > 0 ? details.slice(0, 8).map((item) => `- ${item}`) : ['- 최근 조치 이력이 없어 상세 내역 보강이 필요합니다.']), ].join('\n'); } function buildRecentPlanHistoryLines(snapshot: PlanSnapshot | null, limit = 2) { if (!snapshot) { return []; } const historyItems = [ ...snapshot.recentActionNotes.map((note) => `[조치] ${note}`), ...snapshot.recentWorkSummaries.map((summary) => `[소스] ${summary}`), ] .filter(Boolean) .slice(0, limit); if (historyItems.length === 0) { return []; } return ['최근 조치 이력:', ...historyItems.map((item, index) => `${index + 1}. ${item}`)]; } function isDetailRequest(input: string) { const normalized = input.toLowerCase(); return ( input.includes('상세') || input.includes('자세히') || input.includes('전체') || normalized.includes('detail') || normalized.includes('details') || normalized.includes('more') ); } export function isAutomationRegistrationCountRequest(input: string) { const normalized = input.toLowerCase(); const mentionsAutomation = input.includes('자동화') || normalized.includes('automation') || normalized.includes('plan'); const mentionsRegistration = input.includes('등록') || input.includes('접수') || normalized.includes('register') || normalized.includes('count'); const mentionsToday = input.includes('오늘') || normalized.includes('today'); const asksCount = input.includes('총') || input.includes('건수') || input.includes('몇 건') || input.includes('몇건') || normalized.includes('count') || normalized.includes('total'); return mentionsAutomation && mentionsRegistration && (mentionsToday || asksCount); } function isAutomationRegistrationDefinitionRequest(input: string) { const normalized = input.toLowerCase(); const mentionsAutomation = input.includes('자동화') || normalized.includes('automation'); const mentionsRegistration = input.includes('등록') || input.includes('접수') || normalized.includes('register'); const asksMeaning = input.includes('무슨 뜻') || input.includes('뭐야') || input.includes('뜻') || input.includes('기준') || input.includes('뭘 의미') || normalized.includes('meaning') || normalized.includes('define'); return mentionsAutomation && mentionsRegistration && asksMeaning; } export function shouldUseAgenticCodexReply(input: string) { const normalized = input.toLowerCase(); const trimmed = input.trim(); if (isAutomationRegistrationCountRequest(input) || isAutomationRegistrationDefinitionRequest(input)) { return false; } if (!trimmed) { return false; } if (trimmed.length <= 3 && !/(db|api|fix|bug|log|sql)/i.test(trimmed)) { return false; } if (isDetailRequest(input) || isPlanDetailRequest(input) || isWorklogRequest(input)) { return false; } return ( input.includes('수정') || input.includes('변경') || input.includes('구현') || input.includes('추가') || input.includes('삭제') || input.includes('고쳐') || input.includes('만들어') || input.includes('등록해줘') || input.includes('조회') || input.includes('확인') || input.includes('읽어') || input.includes('분석') || input.includes('DB') || input.includes('API') || input.includes('소스') || input.includes('파일') || input.includes('로그') || /(?:\/|\\).+\.[a-z0-9]+/i.test(input) || normalized.includes('fix') || normalized.includes('implement') || normalized.includes('change') || normalized.includes('edit') || normalized.includes('read') || normalized.includes('source') || normalized.includes('file') || normalized.includes('db') || normalized.includes('api') || /\s/.test(trimmed) ); } export function shouldUseTemplateMacroReply(context: ChatContext | null, input: string) { void context; void input; return false; } function summarizeCodexOutput(output: string) { const normalized = String(output ?? '').trim(); if (!normalized) { return 'Codex 실행 결과가 비어 있습니다.'; } const lines = normalized .split('\n') .map((line) => line.trimEnd()) .filter(Boolean); return lines.slice(-12).join('\n'); } class ChatRuntimeExecutionError extends Error { responseText: string; constructor(message: string, responseText = '') { super(message); this.name = 'ChatRuntimeExecutionError'; this.responseText = responseText.trim(); } } function summarizeCommand(command: string, limit = 180) { const normalized = String(command ?? '').replace(/\s+/g, ' ').trim(); if (!normalized) { return ''; } return normalized.length > limit ? `${normalized.slice(0, limit - 1).trimEnd()}...` : normalized; } function summarizeCommandOutput(output: string, maxLines = 3, maxLength = 220) { const lines = String(output ?? '') .replace(/\r/g, '') .split('\n') .map((line) => line.trim()) .filter(Boolean); if (lines.length === 0) { return ''; } const joined = lines.slice(0, maxLines).join(' / '); return joined.length > maxLength ? `${joined.slice(0, maxLength - 1).trimEnd()}...` : joined; } function inferCommandReason(command: string) { const normalized = command.toLowerCase(); if (normalized.includes('rg ') || normalized.includes('ripgrep')) { return '관련 파일이나 텍스트를 빠르게 찾기 위해 검색했습니다.'; } if (normalized.includes('sed -n') || normalized.includes('cat ') || normalized.includes('less ')) { return '해당 파일 내용을 직접 읽어 문맥을 확인했습니다.'; } if (normalized.includes('ls ') || normalized === 'ls' || normalized.includes('rg --files')) { return '디렉터리 구조와 대상 파일 위치를 확인했습니다.'; } if (normalized.includes('git status')) { return '작업 트리 상태와 변경 범위를 점검했습니다.'; } if (normalized.includes('tsc ') || normalized.includes('npm test') || normalized.includes('node --test')) { return '수정 후 타입이나 동작 검증을 진행했습니다.'; } if (normalized.includes('curl ') || normalized.includes('fetch')) { return '실제 응답이나 연결 상태를 확인했습니다.'; } return '현재 요청을 해결하기 위한 다음 단계를 확인했습니다.'; } function isIgnorableCodexDiagnosticLine(line: string) { const normalized = line.trim(); if (!normalized) { return true; } return ( /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\b/.test(normalized) || normalized.includes('ignoring interface.defaultPrompt') || normalized.includes('failed to open state db') || normalized.includes('state db discrepancy') || normalized.includes('Failed to kill MCP process group') || normalized.includes('Failed to delete shell snapshot') || normalized.includes('failed to unwatch ') ); } function extractCodexActivityLog(parsed: Record) { const type = typeof parsed.type === 'string' ? parsed.type : ''; const item = parsed.item && typeof parsed.item === 'object' ? (parsed.item as Record) : null; const itemType = typeof item?.type === 'string' ? item.type : ''; if (!item || itemType !== 'command_execution') { return ''; } const command = summarizeCommand(typeof item.command === 'string' ? item.command : ''); if (!command) { return ''; } const reason = inferCommandReason(command); if (type === 'item.started') { return `# 이유: ${reason}\n$ ${command}`; } if (type === 'item.completed') { const exitCode = typeof item.exit_code === 'number' && Number.isFinite(item.exit_code) ? Math.round(item.exit_code) : null; const outputSummary = summarizeCommandOutput(typeof item.aggregated_output === 'string' ? item.aggregated_output : ''); const statusLabel = exitCode === null ? '# 결과: 완료' : exitCode === 0 ? '# 결과: 완료(0)' : `# 결과: 종료(${exitCode})`; return outputSummary ? `${statusLabel}\n# 출력: ${outputSummary}` : statusLabel; } return ''; } function normalizeCodexReplyOutput(output: string) { const normalized = String(output ?? '').trim(); return normalized || 'Codex 실행 결과가 비어 있습니다.'; } function collectCodexTextFragments(value: unknown): string[] { if (typeof value === 'string') { const normalized = value.trim(); return normalized ? [normalized] : []; } if (Array.isArray(value)) { return value.flatMap((item) => collectCodexTextFragments(item)); } if (!value || typeof value !== 'object') { return []; } const record = value as Record; const directTextKeys = ['text', 'delta', 'output_text', 'content', 'message']; for (const key of directTextKeys) { const fragments = collectCodexTextFragments(record[key]); if (fragments.length > 0) { return fragments; } } if (typeof record.type === 'string' && record.type.includes('output_text')) { const fragments = collectCodexTextFragments(record.text ?? record.delta); if (fragments.length > 0) { return fragments; } } return Object.values(record).flatMap((item) => collectCodexTextFragments(item)); } function extractCompletedAgentMessageText(item: unknown) { if (!item || typeof item !== 'object') { return ''; } const record = item as Record; if (record.type !== 'agent_message') { return ''; } return collectCodexTextFragments(record.text ?? record.content ?? record.message).join(''); } export function extractCodexStreamText(parsed: Record) { const type = typeof parsed.type === 'string' ? parsed.type : ''; if (type === 'item.completed') { const completedText = extractCompletedAgentMessageText(parsed.item); return { type, completedText, deltaText: '', }; } if (type === 'item.delta' || type === 'response.output_text.delta') { const deltaText = type === 'response.output_text.delta' ? collectCodexTextFragments(parsed.delta ?? parsed.text).join('') : collectCodexTextFragments(parsed.delta).join(''); return { type, completedText: '', deltaText, }; } if (type === 'response.completed') { const completedText = collectCodexTextFragments(parsed.response).join(''); return { type, completedText, deltaText: '', }; } return { type, completedText: '', deltaText: '', }; } export function parseStructuredCodexStdoutLine(line: string) { const normalizedLine = String(line ?? '').trim(); if (!normalizedLine) { return { activityLog: '', completedText: '', deltaText: '', shouldKeepRaw: false, }; } let parsed: Record; try { parsed = JSON.parse(normalizedLine) as Record; } catch { return { activityLog: '', completedText: '', deltaText: '', shouldKeepRaw: true, }; } const activityLog = extractCodexActivityLog(parsed); const streamText = extractCodexStreamText(parsed); const shouldKeepRaw = !activityLog && !streamText.completedText && !streamText.deltaText; return { activityLog, completedText: streamText.completedText, deltaText: streamText.deltaText, shouldKeepRaw, }; } async function streamReplyChunks(text: string, onProgress?: (text: string) => void, chunkSize = 28, delayMs = 24) { const normalized = normalizeCodexReplyOutput(text); if (!onProgress) { return normalized; } for (let index = chunkSize; index < normalized.length; index += chunkSize) { onProgress(normalized.slice(0, index)); await new Promise((resolve) => { setTimeout(resolve, delayMs); }); } onProgress(normalized); return normalized; } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function stripTrailingLineInfo(value: string) { return value.replace(/:\d+(?::\d+)?$/, ''); } function trimPathCandidate(value: string) { return value.replace(/^[("'`\[]+/, '').replace(/[)\]"'`,.;:]+$/, ''); } function encodeUrlPathSegments(value: string) { return value .split('/') .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join('/'); } const CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN = /^(?:src|public|docs|etc|scripts|\.github)\/[^\n\s)\]"'`,]+$/; const CHAT_RESOURCE_ROOT_FILE_PATTERN = /^(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)$/; function isLikelyRepoRelativeChatResourcePath(candidate: string) { const normalized = candidate.replace(/^\/+/, ''); return ( CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN.test(normalized) || CHAT_RESOURCE_ROOT_FILE_PATTERN.test(normalized) ); } function buildChatResourcePublicUrl(relativePath: string) { const cleaned = relativePath.replace(/^public\//, '').replace(/^\/+/, ''); return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${encodeUrlPathSegments(cleaned)}`; } function normalizeEmbeddedChatResourceUrls(text: string) { return String(text ?? '') .replace(/https?:\/(api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/gi, '/$1') .replace( /(?:\/[^\s)\]"'`,]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/g, (_match, resourcePath) => resourcePath, ); } function toPosixPath(value: string) { return value.split(path.sep).join('/'); } function normalizeExistingChatPublicUrl(candidate: string) { const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim())) .replace(/^\/+/, '') .replace(/^public\//, ''); if (!cleaned.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) { return null; } return buildChatResourcePublicUrl(cleaned); } export function extractDiffCodeBlocks(output: string) { const matches = Array.from(String(output ?? '').matchAll(/```diff[^\n]*\n([\s\S]*?)\n```/g)); return matches .map((match) => (typeof match[1] === 'string' ? match[1].trim() : '')) .filter(Boolean); } function protectDiffCodeBlocks(output: string) { const blocks: string[] = []; const text = String(output ?? '').replace(/```diff[^\n]*\n[\s\S]*?\n```/g, (match) => { const token = `__CODEX_DIFF_BLOCK_${blocks.length}__`; blocks.push(match); return token; }); return { text, blocks }; } function restoreDiffCodeBlocks(output: string, blocks: string[]) { return blocks.reduce( (current, block, index) => current.replace(`__CODEX_DIFF_BLOCK_${index}__`, block), String(output ?? ''), ); } async function resolveChatResourceSourcePath(repoPath: string, candidate: string) { const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim())); if (!cleaned) { return null; } let sourcePath: string | null = null; if (cleaned.startsWith(repoPath)) { sourcePath = cleaned; } else if (cleaned.startsWith('/')) { if (isLikelyRepoRelativeChatResourcePath(cleaned)) { sourcePath = path.resolve(repoPath, cleaned.replace(/^\/+/, '')); } else { sourcePath = cleaned; } } else { sourcePath = path.resolve(repoPath, cleaned); } const normalizedRepoPath = path.resolve(repoPath); const normalizedSourcePath = path.resolve(sourcePath); const relativePath = path.relative(normalizedRepoPath, normalizedSourcePath); if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return null; } try { const sourceStat = await stat(normalizedSourcePath); if (!sourceStat.isFile()) { return null; } } catch { return null; } return { absolutePath: normalizedSourcePath, relativePath: toPosixPath(relativePath), }; } export function isChatResourcePathCandidate(candidate: string) { const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim())); if (!cleaned) { return false; } if (cleaned.startsWith(`/${CHAT_PUBLIC_RESOURCE_DIR}/`)) { return true; } return isLikelyRepoRelativeChatResourcePath(cleaned) || /^\/(?:workspace|root|home|Users|tmp)\//.test(cleaned); } export async function stageChatResourceFile(repoPath: string, sessionId: string, candidate: string) { const existingPublicUrl = normalizeExistingChatPublicUrl(candidate); if (existingPublicUrl) { return existingPublicUrl; } const resolvedSource = await resolveChatResourceSourcePath(repoPath, candidate); if (!resolvedSource) { return null; } await ensureChatSessionResourceDirectories(repoPath, sessionId); const targetRelativePath = `${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/${resolvedSource.relativePath}`; const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath); await mkdir(path.dirname(targetAbsolutePath), { recursive: true }); await cp(resolvedSource.absolutePath, targetAbsolutePath, { force: true }); return buildChatResourcePublicUrl(targetRelativePath); } async function stageChatDiffResourceFiles(repoPath: string, sessionId: string, output: string) { const diffBlocks = extractDiffCodeBlocks(output); if (diffBlocks.length === 0) { return []; } const urls: string[] = []; await ensureChatSessionResourceDirectories(repoPath, sessionId); for (const [index, diffText] of diffBlocks.entries()) { const fileName = diffBlocks.length === 1 ? 'response.diff' : `response-${index + 1}.diff`; const targetRelativePath = `${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/_generated/${fileName}`; const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath); await mkdir(path.dirname(targetAbsolutePath), { recursive: true }); await writeFile(targetAbsolutePath, `${diffText}\n`, 'utf8'); urls.push(buildChatResourcePublicUrl(targetRelativePath)); } return urls; } async function ensureWorldWritableDirectory(absolutePath: string) { await mkdir(absolutePath, { recursive: true, mode: CHAT_SESSION_RESOURCE_DIR_MODE }); await chmod(absolutePath, CHAT_SESSION_RESOURCE_DIR_MODE).catch(() => {}); } export async function ensureChatSessionResourceDirectories(repoPath: string, sessionId: string) { const sessionRoot = path.join(repoPath, 'public', CHAT_PUBLIC_RESOURCE_DIR, sessionId); const resourceRoot = path.join(sessionRoot, CHAT_PUBLIC_RESOURCE_SUBDIR); const sourceRoot = path.join(resourceRoot, 'source'); const uploadRoot = path.join(resourceRoot, 'uploads'); await ensureWorldWritableDirectory(sessionRoot); await ensureWorldWritableDirectory(resourceRoot); await ensureWorldWritableDirectory(sourceRoot); await ensureWorldWritableDirectory(uploadRoot); return { sessionRoot, resourceRoot, sourceRoot, uploadRoot, }; } function appendDiffResourceLinks(output: string, diffUrls: string[]) { if (diffUrls.length === 0) { return output; } const uniqueUrls = diffUrls.filter((url, index) => diffUrls.indexOf(url) === index && !output.includes(url)); if (uniqueUrls.length === 0) { return output; } const hiddenPreviewTags = uniqueUrls.map((url) => `[[preview:${url}]]`).join('\n'); return `${output}\n\n${hiddenPreviewTags}`; } function isPreviewEligibleChatResourceUrl(url: string) { const pathname = url.split('?')[0]?.toLowerCase() ?? ''; return /\.(?:png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|pdf|html?)$/i.test(pathname); } function resolveChatResourcePublicUrlToAbsolutePath(repoPath: string, url: string) { const normalizedUrl = normalizeEmbeddedChatResourceUrls(url).trim(); const relativeUrl = normalizedUrl.replace(/^\/+/, ''); const routePrefix = `${CHAT_API_RESOURCE_ROUTE_PREFIX.replace(/^\/+/, '')}/`; if (!relativeUrl.startsWith(routePrefix)) { return null; } const encodedResourcePath = relativeUrl.slice(routePrefix.length); try { const decodedPath = encodedResourcePath .split('/') .filter(Boolean) .map((segment) => decodeURIComponent(segment)) .join('/'); if (!decodedPath.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) { return null; } return path.resolve(repoPath, 'public', decodedPath); } catch { return null; } } async function doesChatResourceUrlExist(repoPath: string, url: string) { const absolutePath = resolveChatResourcePublicUrlToAbsolutePath(repoPath, url); if (!absolutePath) { return false; } try { const fileStat = await stat(absolutePath); return fileStat.isFile(); } catch { return false; } } async function sanitizeChatResourcePresentation(output: string, repoPath: string) { let sanitized = normalizeEmbeddedChatResourceUrls(output); const previewMatches = Array.from(sanitized.matchAll(/\[\[preview:([^\]\n]+)\]\]/gi)); const previewReplacementMap = new Map(); for (const match of previewMatches) { const marker = match[0] ?? ''; const rawUrl = match[1]?.trim() ?? ''; if (!marker || previewReplacementMap.has(marker)) { continue; } const normalizedUrl = normalizeEmbeddedChatResourceUrls(rawUrl); const exists = await doesChatResourceUrlExist(repoPath, normalizedUrl); const keepMarker = exists && isPreviewEligibleChatResourceUrl(normalizedUrl); previewReplacementMap.set(marker, keepMarker ? `[[preview:${normalizedUrl}]]` : ''); } for (const [source, target] of previewReplacementMap.entries()) { sanitized = sanitized.replaceAll(source, target); } return sanitized.replace(/\n{3,}/g, '\n\n').trim(); } export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) { const { text: outputWithoutDiffBlocks, blocks: diffBlocks } = protectDiffCodeBlocks(output); const escapedRepoPath = escapeRegExp(path.resolve(repoPath)); const filePathPattern = "[^\\n\\s)\\]\"'`,]+"; const rootFilePattern = String.raw`\/?(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)`; const candidatePattern = new RegExp( `${escapedRepoPath}\\/${filePathPattern}|(?:\\/?(?:public\\/)?\\.codex_chat|src|public|docs|etc|scripts)\\/${filePathPattern}|${rootFilePattern}`, 'g', ); const matches = [...outputWithoutDiffBlocks.matchAll(candidatePattern)]; let rewrittenOutput = outputWithoutDiffBlocks; if (matches.length > 0) { const replacementMap = new Map(); for (const match of matches) { const rawCandidate = match[0]?.trim(); if (!rawCandidate || replacementMap.has(rawCandidate)) { continue; } const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate); if (stagedUrl) { replacementMap.set(rawCandidate, stagedUrl); } } const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length); for (const [sourcePath, publicUrl] of replacements) { rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl); } } rewrittenOutput = await sanitizeChatResourcePresentation(rewrittenOutput, repoPath); rewrittenOutput = restoreDiffCodeBlocks(rewrittenOutput, diffBlocks); const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, output); return sanitizeChatResourcePresentation(appendDiffResourceLinks(rewrittenOutput, diffUrls), repoPath); } function normalizeChatPromptHistoryText(text: string) { return String(text ?? '').replace(/\s+/g, ' ').trim(); } function normalizePromptHistoryMessageLimit(value: number | undefined) { if (value === undefined || !Number.isFinite(value)) { return CHAT_PROMPT_HISTORY_MAX_MESSAGES; } return Math.min(50, Math.max(1, Math.round(value))); } function normalizePromptHistoryCharLimit(value: number | undefined) { if (value === undefined || !Number.isFinite(value)) { return CHAT_PROMPT_HISTORY_MAX_CHARS; } return Math.min(20_000, Math.max(500, Math.round(value))); } async function buildRecentChatPromptHistory( sessionId: string, requestId: string, limits?: { maxMessages?: number; maxChars?: number; disabled?: boolean; }, ) { if (limits?.disabled) { return { items: [], omittedCount: 0, }; } const maxMessages = normalizePromptHistoryMessageLimit(limits?.maxMessages); const maxChars = normalizePromptHistoryCharLimit(limits?.maxChars); const messages = await listChatConversationMessages(sessionId, { limit: 80 }); const relevantMessages = messages.filter((message: (typeof messages)[number]) => { if (message.clientRequestId?.trim() === requestId.trim()) { return false; } if (message.author !== 'user' && message.author !== 'codex') { return false; } return Boolean(normalizeChatPromptHistoryText(message.text)); }); const selectedMessages: typeof relevantMessages = []; let totalChars = 0; for (let index = relevantMessages.length - 1; index >= 0; index -= 1) { const message = relevantMessages[index]; const text = normalizeChatPromptHistoryText(message.text); const nextChars = text.length; if ( selectedMessages.length >= maxMessages || (selectedMessages.length > 0 && totalChars + nextChars > maxChars) ) { break; } selectedMessages.unshift(message); totalChars += nextChars; } return { items: selectedMessages.map( (message: (typeof selectedMessages)[number]) => `[${message.author}] ${normalizeChatPromptHistoryText(message.text)}`, ), omittedCount: Math.max(0, relevantMessages.length - selectedMessages.length), }; } function cloneChatContext(context: ChatContext | null): ChatContext | null { return context ? { ...context } : null; } function resolvePromptChatTypeLabel(context: ChatContext | null) { return context?.chatTypeLabel?.trim() || ''; } function normalizeChatContextIdList(ids?: string[] | null) { if (!Array.isArray(ids)) { return []; } return Array.from( new Set( ids .map((id) => id?.trim() || '') .filter(Boolean), ), ); } function normalizeChatContextEntries( entries: ChatContext['defaultContexts'], ): Array<{ id: string; title: string; content: string }> { if (!Array.isArray(entries)) { return []; } return entries .map((entry) => ({ id: entry?.id?.trim() || '', title: entry?.title?.trim() || '', content: entry?.content?.trim() || '', })) .filter((entry) => entry.content); } function buildStructuredChatContextSections(context: ChatContext | null) { const sections: string[] = []; const baseDescription = context?.chatTypeBaseDescription?.trim() || ''; const defaultContexts = normalizeChatContextEntries(context?.defaultContexts); const customContextTitle = context?.customContextTitle?.trim() || ''; const customContextContent = context?.customContextContent?.trim() || ''; if (baseDescription) { sections.push(['## 채팅 유형 context 원문', baseDescription].join('\n')); } if (defaultContexts.length > 0) { sections.push( [ '## 채팅방에서 선택한 공통 문맥', ...defaultContexts.map((entry) => [`### ${entry.title || '공통 문맥'}`, entry.content].filter(Boolean).join('\n'), ), ].join('\n\n'), ); } if (customContextTitle || customContextContent) { sections.push( [`## 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`, customContextContent] .filter(Boolean) .join('\n'), ); } return sections; } function buildChatSessionReferenceContextSummary(context: ChatContext | null) { const lines: string[] = []; const defaultContexts = normalizeChatContextEntries(context?.defaultContexts); const customContextTitle = context?.customContextTitle?.trim() || ''; const customContextContent = context?.customContextContent?.trim() || ''; lines.push('## 현재 채팅 유형 context 요약'); lines.push('- 이 문서에는 채팅방 전용 메모와 현재 적용 중인 context 식별 정보만 짧게 유지합니다.'); lines.push('- 공통 문맥 상세 원문은 공통 문맥 관리 데이터와 실행 prompt 본문을 기준으로 확인합니다.'); if (defaultContexts.length > 0) { lines.push(''); lines.push('### 적용 중인 공통 문맥'); lines.push(...defaultContexts.map((entry) => `- ${entry.title || entry.id || '공통 문맥'}`)); } if (customContextTitle || customContextContent) { lines.push(''); lines.push(`### 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`); lines.push(customContextContent || '- 전용 메모 본문 없음'); } return lines.join('\n'); } function composeResolvedChatTypeDescription( baseDescription: string, defaultContexts: Array<{ id: string; title: string; content: string }>, customContextTitle?: string | null, customContextContent?: string | null, ) { const sections = [baseDescription.trim()].filter(Boolean); defaultContexts.forEach((context) => { const normalizedContent = context.content.trim(); if (!normalizedContent) { return; } sections.push(`## 기본 유형 · ${context.title || '공통 문맥'}\n${normalizedContent}`); }); const normalizedCustomContextTitle = customContextTitle?.trim() || ''; const normalizedCustomContextContent = customContextContent?.trim() || ''; if (normalizedCustomContextTitle || normalizedCustomContextContent) { sections.push( [`## 채팅방 전용 Context${normalizedCustomContextTitle ? ` · ${normalizedCustomContextTitle}` : ''}`, normalizedCustomContextContent] .filter(Boolean) .join('\n'), ); } return sections.join('\n\n').trim(); } export async function resolveCodexLiveChatContext(context: ChatContext | null, sessionId?: string | null) { if (!context) { return null; } const normalizedChatTypeId = context.chatTypeId?.trim() || null; if (!normalizedChatTypeId) { return cloneChatContext(context); } const [chatTypesConfig, chatContextSettings] = await Promise.all([ getChatTypesConfig(), getChatContextSettingsConfig(), ]); const resolvedChatType = chatTypesConfig.chatTypes.find((item) => item.id === normalizedChatTypeId && item.enabled) ?? null; const roomContext = chatContextSettings.roomContexts.find((item) => item.sessionId === (sessionId?.trim() || '')) ?? null; const explicitDefaultContextIds = normalizeChatContextIdList(context.defaultContextIds); const chatTypeDefaultContextIds = chatContextSettings.chatTypeDefaults.find((item) => item.chatTypeId === normalizedChatTypeId)?.defaultContextIds ?? []; const resolvedDefaultContextIds = explicitDefaultContextIds.length > 0 ? explicitDefaultContextIds : roomContext?.defaultContextIds.length ? roomContext.defaultContextIds : chatTypeDefaultContextIds; const resolvedDefaultContexts = resolvedDefaultContextIds .map((contextId) => chatContextSettings.defaultContexts.find((item) => item.id === contextId && item.enabled), ) .filter((item): item is NonNullable => Boolean(item)) .map((item) => ({ id: item.id, title: item.title, content: item.content, })); const resolvedCustomContextTitle = roomContext?.customContextTitle?.trim() || ''; const resolvedCustomContextContent = roomContext?.customContextContent?.trim() || ''; const resolvedBaseDescription = resolvedChatType?.description?.trim() || context.chatTypeBaseDescription?.trim() || ''; const resolvedDescription = composeResolvedChatTypeDescription( resolvedBaseDescription, resolvedDefaultContexts, resolvedCustomContextTitle, resolvedCustomContextContent, ) || context.chatTypeDescription?.trim() || resolvedBaseDescription; return { ...context, chatTypeId: normalizedChatTypeId, chatTypeLabel: resolvedChatType?.name ?? context.chatTypeLabel ?? '', chatTypeDescription: resolvedDescription, chatTypeBaseDescription: resolvedBaseDescription, defaultContextIds: resolvedDefaultContextIds, defaultContexts: resolvedDefaultContexts, customContextTitle: resolvedCustomContextTitle || null, customContextContent: resolvedCustomContextContent || null, } satisfies ChatContext; } function buildChatSessionReferenceAutoSection(args: { context: ChatContext | null; sessionId: string; requestId: string; requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled'; requestedAt?: Date | null; completedAt?: Date | null; input?: string; }) { const chatTypeLabel = resolvePromptChatTypeLabel(args.context) || '없음'; const pageTitle = args.context?.pageTitle?.trim() || '없음'; const topMenu = args.context?.topMenu?.trim() || '없음'; const pageUrl = args.context?.pageUrl?.trim() || '없음'; const focusedComponentId = args.context?.focusedComponentId?.trim() || '없음'; const requestStatusLabel = args.requestStatus === 'completed' ? '완료' : args.requestStatus === 'failed' ? '실패' : args.requestStatus === 'cancelled' ? '취소' : '실행 중'; return [ CHAT_SESSION_REFERENCE_AUTO_START, '## 자동 갱신 문맥', `- 마지막 갱신 시각: ${formatTime(new Date())}`, `- sessionId: ${args.sessionId}`, `- requestId: ${args.requestId}`, `- 요청 상태: ${requestStatusLabel}`, `- 요청 시작 시각: ${formatTime(args.requestedAt ?? new Date())}`, ...(args.completedAt ? [`- 요청 종료 시각: ${formatTime(args.completedAt)}`] : []), `- 채팅 유형: ${chatTypeLabel}`, `- 화면 제목: ${pageTitle}`, `- topMenu: ${topMenu}`, `- focusedComponentId: ${focusedComponentId}`, `- pageUrl: ${pageUrl}`, '', buildChatSessionReferenceContextSummary(args.context), CHAT_SESSION_REFERENCE_AUTO_END, ].join('\n'); } function mergeChatSessionReferenceContent(existingContent: string, autoSection: string) { const trimmedExisting = existingContent.trim(); const defaultHeader = [ '# 채팅방 참고 리소스', '', '이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.', '사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.', ].join('\n'); if (!trimmedExisting) { return `${defaultHeader}\n\n${autoSection}\n`; } const firstAutoStartIndex = existingContent.indexOf(CHAT_SESSION_REFERENCE_AUTO_START); if (firstAutoStartIndex >= 0) { const preservedHeader = existingContent.slice(0, firstAutoStartIndex).trim() || defaultHeader; return `${preservedHeader}\n\n${autoSection}\n`; } return `${trimmedExisting || defaultHeader}\n\n${autoSection}\n`; } async function writeChatSessionReferenceContentSafely(absolutePath: string, content: string) { try { await writeFile(absolutePath, content, 'utf8'); return; } catch (error) { if ( !error || typeof error !== 'object' || !('code' in error) || (error.code !== 'EACCES' && error.code !== 'EPERM') ) { throw error; } } await rm(absolutePath, { force: true }); await writeFile(absolutePath, content, 'utf8'); } export async function ensureChatSessionReferenceResource(args: { repoPath: string; sessionId: string; requestId: string; context: ChatContext | null; requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled'; requestedAt?: Date | null; completedAt?: Date | null; input: string; recentHistoryLines: string[]; omittedHistoryCount: number; }) { const ensuredDirectories = await ensureChatSessionResourceDirectories(args.repoPath, args.sessionId); const resourceRelativePath = `public/.codex_chat/${args.sessionId}/resource/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`; const absolutePath = path.join(ensuredDirectories.sourceRoot, 'chat-room-reference.md'); const autoSection = buildChatSessionReferenceAutoSection({ context: args.context, sessionId: args.sessionId, requestId: args.requestId, requestStatus: args.requestStatus, requestedAt: args.requestedAt, completedAt: args.completedAt, input: args.input, }); let existingContent = ''; try { existingContent = await readFile(absolutePath, 'utf8'); } catch { existingContent = ''; } const nextContent = mergeChatSessionReferenceContent(existingContent, autoSection); if (nextContent !== existingContent) { await writeChatSessionReferenceContentSafely(absolutePath, nextContent); } return resourceRelativePath; } async function refreshChatSessionReferenceForRequest(args: { sessionId: string; requestId: string; context: ChatContext | null; input: string; requestStatus: 'started' | 'completed' | 'failed' | 'cancelled'; requestedAt: Date; completedAt?: Date | null; }) { const repoPath = resolveMainProjectRoot(); await ensureChatSessionReferenceResource({ repoPath, sessionId: args.sessionId, requestId: args.requestId, context: args.context, requestStatus: args.requestStatus, requestedAt: args.requestedAt, completedAt: args.completedAt ?? null, input: args.input, recentHistoryLines: [], omittedHistoryCount: 0, }); } function buildChatTypeInstructionBlock(context: ChatContext | null) { const chatTypeLabel = resolvePromptChatTypeLabel(context); const chatTypeDescription = context?.chatTypeDescription?.trim() || ''; const structuredSections = buildStructuredChatContextSections(context); const hasSpecificChatType = Boolean(chatTypeLabel); const hasContextDescription = Boolean( structuredSections.length > 0 || (chatTypeDescription && chatTypeDescription !== '없음'), ); if (!hasSpecificChatType && !hasContextDescription) { return [ '## 채팅 유형 context 필수 규칙', '- 선택된 채팅 유형 context가 없습니다.', '- 그래도 AGENTS.md와 현재 사용자 요청을 기준으로 처리하되, Plan 자동화용 자동화 유형 context는 섞지 마세요.', ]; } return [ '## 채팅 유형 context 필수 규칙', '- 아래 채팅 유형 context는 선택 사항이나 참고 메모가 아니라 이 Codex Live 실행의 상위 필수 지시입니다.', '- 사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선하세요.', '- context 안의 작업 범위, 금지사항, 검증 방식, 답변 스타일, 산출물 규칙을 반드시 지키세요.', '- context가 모호하면 무시하지 말고, 가장 보수적으로 해석해 지킬 수 있는 범위에서 처리하세요.', '- 실행 전 내부적으로 context 준수 여부를 점검하고, 최종 답변도 context 기준에 맞추세요.', '', '### 선택된 채팅 유형', `- label: ${chatTypeLabel || '없음'}`, '', '### 반드시 지킬 context 원문', structuredSections.length > 0 ? structuredSections.join('\n\n') : (chatTypeDescription || '선택된 채팅 유형 context 원문 없음'), ]; } function buildChatSessionReferenceInstructionBlock(referenceContent?: string | null) { const normalizedContent = referenceContent?.trim() || ''; if (!normalizedContent) { return [ '## 채팅방 참고 문서', '- 현재 채팅방 참고 문서 본문을 불러오지 못했습니다.', '- 그래도 위에 제공된 참고 문서 경로를 우선 확인하고, 채팅 유형 context와 AGENTS.md 규칙을 먼저 따르세요.', ]; } return [ '## 채팅방 참고 문서', '- 아래는 이 채팅방에서 시작 전에 먼저 읽어야 하는 참고 문서 원문입니다.', '- 이 문서의 지시와 메모를 현재 요청 해석에 즉시 반영하세요.', '', normalizedContent, ]; } export function buildAgenticCodexPrompt( context: ChatContext | null, input: string, sessionId: string, promptContext?: { repoPath?: string; recentHistoryLines?: string[]; omittedHistoryCount?: number; sessionReferenceResourcePath?: string; sessionReferenceContent?: string; }, ) { const repoPath = promptContext?.repoPath?.trim() || resolveMainProjectRoot(); const chatSessionResourceDir = `public/.codex_chat/${sessionId}/resource`; const chatSessionUploadDir = `${chatSessionResourceDir}/uploads`; const sessionReferenceResourcePath = promptContext?.sessionReferenceResourcePath || `${chatSessionResourceDir}/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`; const recentHistoryLines = promptContext?.recentHistoryLines ?? []; const omittedHistoryCount = Math.max(0, promptContext?.omittedHistoryCount ?? 0); return [ '당신은 이 저장소에서 Codex Live 요청을 처리하는 실제 Codex 실행기입니다.', 'Codex Live는 Plan 자동화와 별개입니다. 자동화 유형 context를 Codex Live 기본 문맥으로 섞지 마세요.', `저장소 루트(main_project): ${repoPath}`, '반드시 저장소 루트의 AGENTS.md를 먼저 읽고 그 규칙을 따르세요.', '가능한 작업 범위:', '- 로컬 소스 파일 읽기', '- 필요 시 DB 직접 조회', '- 필요 시 로컬 API 응답 확인', '- 사용자가 요청했거나 해결에 필요하면 소스 코드 수정', '- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md를 먼저 확인하고 그 규칙 안에서만 작업하세요.', `- 현재 채팅 세션 리소스 기본 경로: ${chatSessionResourceDir}/`, `- 현재 채팅 첨부 업로드 경로: ${chatSessionUploadDir}/`, `- 이 채팅방의 지속 참고 문서: ${sessionReferenceResourcePath}`, '- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.', '- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.', '- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.', '- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.', '- 참고 문서는 필요한 기준만 짧게 유지하고, 최근 대화 요약이나 과한 메모 누적은 만들지 마세요.', '- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.', ...buildChatSessionReferenceInstructionBlock(promptContext?.sessionReferenceContent), '', ...buildChatTypeInstructionBlock(context), '', '응답 규칙:', '- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.', '- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.', '- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.', '- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.', '- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.', '- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 문법은 외부 공개 링크에만 사용하고, `/api/chat/resources/...` 같은 내부 리소스에는 사용하지 마세요. URL은 `https://...` 원문 그대로 쓰고 구분자 `|`를 인코딩하지 마세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.', '- 단계형 선택지를 채팅방 컴포넌트로 보여주려면 `[[prompt:{"title":"질문","description":"설명","submitLabel":"선택 전달","mode":"queue","options":[{"label":"옵션 A","value":"option-a","description":"설명","preview":{"type":"image","url":"https://..."}}],"responseTemplate":"{{selection_label}} 기준으로 다음 단계를 이어서 진행해 주세요."}]]` 한 줄 JSON 문법을 사용하세요. stepper가 필요하면 `steps` 배열을 추가해 `{"key":"scope","title":"범위 선택","options":[...]}` 형태로 단계별 옵션을 나누고, 단계별 `optional`, `multiple`, `freeTextLabel`, `freeTextPlaceholder`, `responseTemplate`도 사용할 수 있습니다. 옵션별 `preview`는 `image`, `markdown`, `html`, `resource`를 지원하고, 아이콘 버튼으로 확대 preview 할 수 있습니다. HTML 문서를 iframe으로 바로 보여줘야 할 때는 현재 앱 URL이나 `/chat/...` 경로를 넣지 말고, 먼저 실제 `.html` 리소스를 준비한 뒤 `preview":{"type":"resource","url":"/api/chat/resources/.../sample.html"}` 또는 `preview":{"type":"resource","url":"resource/Codex Live/.../sample.html"}` 형태를 사용하세요. 리소스 관리에 등록된 `resource/...` 경로는 자동으로 `/api/resource-manager/preview/...`로 해석됩니다. `type:"html"`은 HTML 본문 문자열을 `content`에 직접 넣을 때만 사용합니다. 이미 결정된 결과를 읽기 전용으로 보여줄 때는 `readOnly`, `selectedValues`, `resolvedBy`, `resultText`를 함께 넣을 수 있습니다. 이 줄은 본문에서 숨겨지고 prompt 컴포넌트로 렌더됩니다.', '- 변경된 소스 파일이 있는 경우에만 최종 답변에 변경 이력을 ```diff 코드블록으로 포함하세요.', '- 변경된 소스 파일이 있으면 마지막에 해당 파일 경로를 짧게 적으세요.', '- 한국어로 간결하게 답하세요.', '', '참고 화면 정보:', `- pageTitle: ${context?.pageTitle ?? '없음'}`, `- topMenu: ${context?.topMenu ?? '없음'}`, `- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`, `- pageUrl: ${context?.pageUrl ?? '없음'}`, '', '최근 대화 문맥(보조 참조):', ...(recentHistoryLines.length > 0 ? [ ...recentHistoryLines.map((line) => `- ${line}`), ...(omittedHistoryCount > 0 ? [`- 최근 문맥 일부만 포함했습니다. 이전 ${omittedHistoryCount}개 메시지는 제외되었습니다.`] : []), ] : ['- 참조할 최근 대화가 없습니다.']), '', '사용자 요청:', input, '', '최종 답변만 출력하세요.', ].join('\n'); } export async function validateAgenticCodexRuntime(repoPath: string, codexBin: string) { const issues: string[] = []; try { const repoStat = await stat(repoPath); if (!repoStat.isDirectory()) { issues.push(`PLAN_MAIN_PROJECT_REPO_PATH 경로가 디렉터리가 아닙니다: ${repoPath}`); } } catch { issues.push(`PLAN_MAIN_PROJECT_REPO_PATH 경로를 찾지 못했습니다: ${repoPath}`); } const runnerCandidates = buildCommandRunnerApiCandidates('/health'); const headers = new Headers(); if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim()) { headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim()); } let runnerReachable = false; for (const candidate of runnerCandidates) { try { const response = await fetch(candidate, { method: 'GET', headers, signal: AbortSignal.timeout(5000), }); if (response.ok) { runnerReachable = true; break; } } catch { // try next candidate } } if (!runnerReachable) { issues.push(`SERVER_COMMAND_RUNNER_URL 경로로 Codex runner에 연결하지 못했습니다: ${env.SERVER_COMMAND_RUNNER_URL}`); } if (issues.length > 0) { throw new Error(`채팅 실행 환경이 준비되지 않았습니다. ${issues.join(' / ')}`); } } function normalizeRunnerUrl(value: string) { return value.trim().replace(/\/+$/, ''); } function buildCommandRunnerApiCandidates(requestPath: string) { const configuredHealthUrl = env.SERVER_COMMAND_RUNNER_URL?.trim() || 'http://host.docker.internal:3211/health'; let parsedUrl: URL; try { parsedUrl = new URL(configuredHealthUrl); } catch { return []; } const hostVariants = parsedUrl.hostname === 'host.docker.internal' ? ['host.docker.internal', '127.0.0.1', 'localhost'] : parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost' ? [parsedUrl.hostname, parsedUrl.hostname === '127.0.0.1' ? 'localhost' : '127.0.0.1', 'host.docker.internal'] : [parsedUrl.hostname]; const deduped: string[] = []; for (const hostname of hostVariants) { const candidate = new URL(parsedUrl.toString()); candidate.hostname = hostname; candidate.pathname = requestPath.startsWith('/') ? requestPath : `/${requestPath}`; candidate.search = ''; candidate.hash = ''; const serialized = normalizeRunnerUrl(candidate.toString()); if (!deduped.includes(serialized)) { deduped.push(serialized); } } return deduped; } async function requestCommandRunner(requestPath: string, init?: RequestInit) { const headers = new Headers(init?.headers); if (init?.body != null && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() && !headers.has('X-Access-Token')) { headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim()); } let lastError: Error | null = null; for (const url of buildCommandRunnerApiCandidates(requestPath)) { try { return await fetch(url, { ...init, headers, }); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); } } throw lastError ?? new Error('command-runner에 연결하지 못했습니다.'); } async function cancelRunnerCodexExecution(requestId: string) { try { const response = await requestCommandRunner(`/api/codex-live/jobs/${encodeURIComponent(requestId)}/cancel`, { method: 'POST', }); if (!response.ok) { return false; } const payload = (await response.json().catch(() => null)) as { cancelled?: boolean } | null; return payload?.cancelled === true; } catch { return false; } } async function runAgenticCodexReply( context: ChatContext | null, input: string, sessionId: string, requestId: string, options?: { omitPromptHistory?: boolean; requestedAt?: Date | null; }, onProgress?: (text: string) => void, onActivity?: (line: string) => void, isCancellationRequested?: () => boolean, ) { const repoPath = resolveMainProjectRoot(); await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN); const resolvedContext = await resolveCodexLiveChatContext(context, sessionId); const appConfig = await getAppConfigSnapshot(); const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, { maxMessages: appConfig.chat?.maxContextMessages, maxChars: appConfig.chat?.maxContextChars, disabled: options?.omitPromptHistory === true, }); const codexLiveMaxExecutionSeconds = typeof appConfig.chat?.codexLiveMaxExecutionSeconds === 'number' && Number.isFinite(appConfig.chat.codexLiveMaxExecutionSeconds) ? Math.min(7200, Math.max(60, Math.round(appConfig.chat.codexLiveMaxExecutionSeconds))) : null; const codexLiveIdleTimeoutSeconds = typeof appConfig.chat?.codexLiveIdleTimeoutSeconds === 'number' && Number.isFinite(appConfig.chat.codexLiveIdleTimeoutSeconds) ? Math.min(3600, Math.max(30, Math.round(appConfig.chat.codexLiveIdleTimeoutSeconds))) : null; const requestedAt = options?.requestedAt ?? new Date(); const syncSessionReference = async ( requestStatus: 'started' | 'completed' | 'failed' | 'cancelled', completedAt?: Date | null, ) => ensureChatSessionReferenceResource({ repoPath, sessionId, requestId, context: resolvedContext, requestStatus, requestedAt, completedAt: completedAt ?? null, input, recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, }); const sessionReferenceResourcePath = await ensureChatSessionReferenceResource({ repoPath, sessionId, requestId, context: resolvedContext, requestStatus: 'started', requestedAt, input, recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, }); const sessionReferenceAbsolutePath = path.join(repoPath, sessionReferenceResourcePath); let sessionReferenceContent = ''; try { sessionReferenceContent = await readFile(sessionReferenceAbsolutePath, 'utf8'); } catch { sessionReferenceContent = ''; } const prompt = buildAgenticCodexPrompt(resolvedContext, input, sessionId, { repoPath, recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, sessionReferenceResourcePath, sessionReferenceContent, }); let streamedOutput = ''; let stdoutTail = ''; let stderr = ''; let jsonLineBuffer = ''; let lastProgressText = ''; let completedAgentMessage = ''; let hasIncrementalDelta = false; const finalizeReplyOutput = async () => { const normalizedOutput = normalizeCodexReplyOutput(completedAgentMessage || streamedOutput || stdoutTail); const rewrittenOutput = await rewriteCodexOutputWithChatResources(normalizedOutput, repoPath, sessionId); if (!rewrittenOutput) { return ''; } // If the CLI only produced a final completed event, avoid sending it as one big batch. if (!hasIncrementalDelta && rewrittenOutput) { await streamReplyChunks(rewrittenOutput, onProgress); } else if (rewrittenOutput !== lastProgressText) { onProgress?.(rewrittenOutput); } return rewrittenOutput; }; const throwIfCancelled = async () => { if (!isCancellationRequested?.()) { return; } await cancelRunnerCodexExecution(requestId).catch(() => false); throw new Error('CHAT_RUNTIME_CANCELLED'); }; await throwIfCancelled(); activeChatProcessRegistry.set(requestId, { cancel: async () => { const cancelled = await cancelRunnerCodexExecution(requestId); return cancelled || isCancellationRequested?.() === true; }, }); chatRuntimeService.appendLog( requestId, `실행 제한 설정을 적용했습니다. 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`, ); onActivity?.( `# 설정: 최대 실행 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`, ); try { await new Promise(async (resolve, reject) => { const emitProgress = (nextText: string) => { const normalizedProgress = nextText.trim(); if (!normalizedProgress || normalizedProgress === lastProgressText) { return; } lastProgressText = normalizedProgress; streamedOutput = normalizedProgress; onProgress?.(normalizedProgress); }; try { await throwIfCancelled(); const response = await requestCommandRunner('/api/codex-live/execute', { method: 'POST', body: JSON.stringify({ requestId, sessionId, repoPath, prompt, resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'), uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'), maxExecutionSeconds: codexLiveMaxExecutionSeconds, idleTimeoutSeconds: codexLiveIdleTimeoutSeconds, }), }); if (!response.ok) { reject(new Error((await response.text()) || 'command-runner Codex 실행 요청에 실패했습니다.')); return; } await throwIfCancelled(); if (!response.body) { reject(new Error('command-runner Codex 스트림이 비어 있습니다.')); return; } chatRuntimeService.appendLog(requestId, 'Codex 실행을 command-runner API로 요청했습니다.'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let remoteErrorMessage = ''; const handleRunnerLine = (line: string) => { let parsed: Record; try { parsed = JSON.parse(line) as Record; } catch { return; } const eventType = typeof parsed.type === 'string' ? parsed.type : ''; if (eventType === 'started') { const pid = typeof parsed.pid === 'number' && Number.isFinite(parsed.pid) ? parsed.pid : null; const appliedIdleTimeoutSeconds = typeof parsed.configuredIdleTimeoutSeconds === 'number' && Number.isFinite(parsed.configuredIdleTimeoutSeconds) ? Math.round(parsed.configuredIdleTimeoutSeconds) : null; const appliedMaxExecutionSeconds = typeof parsed.configuredMaxExecutionSeconds === 'number' && Number.isFinite(parsed.configuredMaxExecutionSeconds) ? Math.round(parsed.configuredMaxExecutionSeconds) : null; chatRuntimeService.attachProcess(requestId, pid); chatRuntimeService.appendLog( requestId, pid ? `호스트 command-runner에서 Codex 프로세스를 시작했습니다. pid=${pid}` : '호스트 command-runner에서 Codex 프로세스를 시작했습니다.', ); if (appliedMaxExecutionSeconds != null || appliedIdleTimeoutSeconds != null) { const appliedSummary = `command-runner 적용값: 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` + `무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}초`; chatRuntimeService.appendLog(requestId, appliedSummary); onActivity?.(`# ${appliedSummary}`); if ( (appliedMaxExecutionSeconds != null && codexLiveMaxExecutionSeconds != null && appliedMaxExecutionSeconds !== codexLiveMaxExecutionSeconds) || (appliedIdleTimeoutSeconds != null && codexLiveIdleTimeoutSeconds != null && appliedIdleTimeoutSeconds !== codexLiveIdleTimeoutSeconds) ) { const mismatchSummary = `설정 불일치 감지: 요청값 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / ` + `무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초, ` + `실제 적용값 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` + `무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}초`; chatRuntimeService.appendLog(requestId, mismatchSummary); onActivity?.(`# 경고: ${mismatchSummary}`); } } return; } if (eventType === 'attached') { const attachedRequestId = String(parsed.requestId ?? '').trim(); const completed = parsed.completed === true; const attachSummary = attachedRequestId && attachedRequestId === requestId ? completed ? '기존 command-runner 실행 이력을 다시 연결했습니다.' : '기존 command-runner 실행에 재부착했습니다.' : completed ? '기존 command-runner 응답 이력을 다시 연결했습니다.' : '기존 command-runner 실행 스트림에 다시 연결했습니다.'; chatRuntimeService.appendLog(requestId, attachSummary); onActivity?.(`# ${attachSummary}`); return; } if (eventType === 'activity') { const activityLog = String(parsed.line ?? '').trim(); if (activityLog) { chatRuntimeService.appendLog(requestId, activityLog); onActivity?.(activityLog); } return; } if (eventType === 'delta') { const deltaText = String(parsed.text ?? ''); if (deltaText) { hasIncrementalDelta = true; emitProgress(`${streamedOutput}${deltaText}`); } return; } if (eventType === 'completed') { completedAgentMessage = String(parsed.text ?? '').trim(); if (completedAgentMessage && hasIncrementalDelta) { emitProgress(completedAgentMessage); } return; } if (eventType === 'stdout') { const stdoutLine = String(parsed.line ?? '').trim(); if (stdoutLine) { const structuredStdout = parseStructuredCodexStdoutLine(stdoutLine); if (!structuredStdout.shouldKeepRaw) { if (structuredStdout.activityLog) { chatRuntimeService.appendLog(requestId, structuredStdout.activityLog); onActivity?.(structuredStdout.activityLog); } if (structuredStdout.deltaText) { hasIncrementalDelta = true; emitProgress(`${streamedOutput}${structuredStdout.deltaText}`); } if (structuredStdout.completedText) { completedAgentMessage = structuredStdout.completedText.trim(); if (completedAgentMessage && hasIncrementalDelta) { emitProgress(completedAgentMessage); } } return; } stdoutTail = `${stdoutTail}\n${stdoutLine}`.slice(-STREAM_CAPTURE_LIMIT); chatRuntimeService.appendLog(requestId, `[stdout] ${stdoutLine}`); onActivity?.(`[stdout] ${stdoutLine}`); } return; } if (eventType === 'stderr') { const stderrLine = String(parsed.line ?? '').trim(); if (stderrLine) { stderr = `${stderr}\n${stderrLine}`.slice(-STREAM_CAPTURE_LIMIT); chatRuntimeService.appendLog(requestId, `[stderr] ${stderrLine}`); onActivity?.(`[stderr] ${stderrLine}`); } return; } if (eventType === 'error') { remoteErrorMessage = String(parsed.message ?? '').trim(); } }; while (true) { await throwIfCancelled(); const { value, done } = await reader.read(); if (done) { break; } jsonLineBuffer += decoder.decode(value, { stream: true }); const lines = jsonLineBuffer.split('\n'); jsonLineBuffer = lines.pop() ?? ''; for (const rawLine of lines) { const line = rawLine.trim(); if (!line) { continue; } handleRunnerLine(line); } } const trailingLine = jsonLineBuffer.trim(); if (trailingLine) { handleRunnerLine(trailingLine); } if (remoteErrorMessage) { reject(new Error(remoteErrorMessage)); return; } resolve(); } catch (error) { reject(error); } }); } catch (error) { const completedAt = new Date(); await syncSessionReference(error instanceof Error && error.message === 'CHAT_RUNTIME_CANCELLED' ? 'cancelled' : 'failed', completedAt); const failureResponseText = await finalizeReplyOutput(); if (failureResponseText) { throw new ChatRuntimeExecutionError(error instanceof Error ? error.message : 'Codex 실행에 실패했습니다.', failureResponseText); } throw error; } await syncSessionReference('completed', new Date()); return await finalizeReplyOutput(); } async function getTodayAutomationRegistrationCounts() { const [planCountResult, boardReceivedCountResult] = await Promise.all([ db(PLAN_TABLE) .whereNot('automation_type', 'none') .whereRaw("(created_at at time zone ?)::date = (current_timestamp at time zone ?)::date", [KST_TIME_ZONE, KST_TIME_ZONE]) .count<{ count: string }>('* as count') .first(), db(BOARD_POSTS_TABLE) .whereNotNull('automation_received_at') .whereRaw( "(automation_received_at at time zone ?)::date = (current_timestamp at time zone ?)::date", [KST_TIME_ZONE, KST_TIME_ZONE], ) .count<{ count: string }>('* as count') .first(), ]); return { today: formatKstDate(), planCount: Number(planCountResult?.count ?? 0), boardReceivedCount: Number(boardReceivedCountResult?.count ?? 0), }; } async function buildAutomationRegistrationCountReply() { const counts = await getTodayAutomationRegistrationCounts(); const lines = [ '결과', `- ${counts.today} KST 기준 오늘 자동화 등록 총 건수는 ${counts.planCount}건입니다.`, '- 기본 기준: `plan_items`에서 `automation_type <> \'none\'` 이고 오늘 생성된 건수', ]; if (counts.planCount === counts.boardReceivedCount) { lines.push(`- 교차 확인: 오늘 ` + '`board_posts.automation_received_at`' + ` 기준 접수 건수도 ${counts.boardReceivedCount}건으로 같습니다.`); } else { lines.push(`- 참고: 오늘 ` + '`board_posts.automation_received_at`' + ` 기준 접수 건수는 ${counts.boardReceivedCount}건입니다.`); lines.push('- 이 프로젝트는 게시판 등록, 자동화 접수, Plan 생성이 분리될 수 있어 기준에 따라 숫자가 달라질 수 있습니다.'); } return lines.join('\n'); } function buildAutomationRegistrationDefinitionReply() { return [ '결과', '- 이 프로젝트에서 `자동화 등록`은 하나로 고정된 용어가 아닙니다.', '- `board_posts.created_at`: 게시판 등록', '- `board_posts.automation_received_at`: 자동화 접수', '- `plan_items.created_at` + `automation_type <> \'none\'`: 실제 자동화 대상 Plan 생성', '- 건수 질문이면 어떤 기준인지 먼저 구분하고, 모호하면 기본적으로 Plan 생성 기준을 우선 안내합니다.', ].join('\n'); } function buildProgressMessages(input: string) { const messages = ['요청을 분석하고 있습니다.']; if (isAutomationRegistrationCountRequest(input)) { messages.push('오늘 기준 집계라 DB 기준과 시간대 기준을 확인하고 있습니다.'); return messages; } if (/db|데이터베이스|sql|쿼리|집계|건수/i.test(input)) { messages.push('DB와 집계 기준을 확인하고 있습니다.'); } if (/api|응답|endpoint|엔드포인트|fetch|호출/i.test(input)) { messages.push('API 경로와 실제 응답을 확인하고 있습니다.'); } if (/파일|소스|코드|tsx|ts|js|css|수정|변경|구현|fix|edit|implement/i.test(input)) { messages.push('관련 소스와 연결 흐름을 확인하고 있습니다.'); } return [...new Set(messages)]; } function normalizeProgressSummary(text: string) { return text.replace(/\s+/g, ' ').trim(); } function shouldSkipActivityProgressSummary(text: string) { return ( !text || /^요청을 처리합니다\./.test(text) || /^완료(?:\(\d+\))?$/i.test(text) || /^종료\(\d+\)$/i.test(text) ); } export function summarizeActivityProgressLine(activityLine: string) { const lines = String(activityLine ?? '') .split('\n') .map((line) => normalizeProgressSummary(line)) .filter(Boolean); for (const line of lines) { if (line.startsWith('# 이유:')) { const summary = normalizeProgressSummary(line.slice('# 이유:'.length)); if (!shouldSkipActivityProgressSummary(summary)) { return summary; } } } for (const line of lines) { for (const prefix of ['# 진행:', '# 상태:', '# 경고:']) { if (!line.startsWith(prefix)) { continue; } const summary = normalizeProgressSummary(line.slice(prefix.length)); if (!shouldSkipActivityProgressSummary(summary)) { return summary; } } } return ''; } function stripActivityLinePrefix(line: string) { return line.replace(/^#\s*(상태|진행|이유|경고|오류):\s*/u, '').trim(); } function resolveCompactActivityStageLineNo(summary: string, normalizedLine: string) { if (!summary) { return null; } if (/^#\s*오류:/u.test(normalizedLine) || /오류|실패|중단/u.test(summary)) { return 5; } if (/요청을 처리합니다|대기열|즉시 요청 실행/u.test(summary)) { return 1; } if (/분석|의도|문맥|생각 중/u.test(summary)) { return 2; } if (/\bdb\b|데이터베이스|\bapi\b|엔드포인트|응답|소스|코드|파일|흐름|쿼리|집계|resource|리소스|화면/u.test(summary)) { return 3; } if (/구현|수정|변경|작성|빌드|patch|diff|전송 중/u.test(summary)) { return 4; } if (/검증|테스트|캡처|preview|완료|결과|정리/u.test(summary)) { return 5; } return null; } function createCompactActivityLogEntry(activityLine: string) { const normalizedLine = normalizeProgressSummary(activityLine); if (!normalizedLine) { return null; } const strippedSummary = stripActivityLinePrefix(normalizedLine); const summarizedProgress = summarizeActivityProgressLine(normalizedLine); const summary = summarizedProgress || strippedSummary; const lineNo = resolveCompactActivityStageLineNo(summary, normalizedLine); if (!lineNo || !summary) { return null; } if (/^#\s*오류:/u.test(normalizedLine)) { return { line: `# 오류: ${summary}`, lineNo, }; } const prefix = lineNo === 1 || lineNo === 5 ? '# 상태:' : '# 진행:'; return { line: `${prefix} ${summary}`, lineNo, }; } function buildGenericReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) { const normalized = input.toLowerCase(); const pageTitle = context?.pageTitle ?? '현재 화면'; const previewUrl = context?.pageUrl || '없음'; const detailRequested = isDetailRequest(input); if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) { const lines = [ '결과', `- preview: ${snapshot?.latestSourceWork?.previewUrl ?? previewUrl}`, ]; if (snapshot?.latestSourceWork?.summary) { lines.push(`- 요약: ${truncateText(snapshot.latestSourceWork.summary, 100)}`); } if (detailRequested && snapshot?.latestSourceWork) { lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`); } if (!detailRequested) { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } if (input.includes('계획') || normalized.includes('plan')) { if (!snapshot) { return ['결과', '- 연결된 Plan을 찾지 못했습니다.', '- planId 또는 작업 ID를 함께 보내 주세요.'].join('\n'); } const lines = [ '작업 요약', `- #${snapshot.planId} ${snapshot.workId}`, `- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`, `- 메모: ${truncateText(snapshot.note, 110) || '기록 없음'}`, ]; if (detailRequested) { if (snapshot.assignedBranch) { lines.push(`- 브랜치: ${snapshot.assignedBranch}`); } lines.push(...buildRecentPlanHistoryLines(snapshot)); } else { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } if ((input.includes('이슈') || normalized.includes('issue')) && snapshot) { const lines = ['이슈 요약']; if (snapshot.latestIssue) { lines.push( `- ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? '해결' : '미해결'}`, `- 내용: ${truncateText(snapshot.latestIssue.message, 110)}`, ); } else if (snapshot.issueTags.length > 0) { lines.push(`- 태그: ${snapshot.issueTags.join(', ')}`); } else { lines.push('- 최근 이슈 이력이 없습니다.'); } if (detailRequested) { lines.push(...buildRecentPlanHistoryLines(snapshot)); } return lines.join('\n'); } if ((input.includes('이력') || input.includes('최근') || normalized.includes('history')) && snapshot) { const lines = [ '최근 이력', `- ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 120) || '최근 작업 이력이 없습니다.'}`, ]; if (detailRequested) { lines.push(...buildRecentPlanHistoryLines(snapshot)); } else { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } if (input.includes('문서') || normalized.includes('docs')) { return ['결과', '- 문서 요청으로 인식했습니다.', '- 대상 문서명이나 링크를 함께 보내 주세요.'].join('\n'); } if (isWorklogRequest(input)) { if (snapshot) { return buildWorklogReply(context, snapshot); } return ['결과', '- 작업로그 초안을 만들 Plan이 없습니다.', '- planId 또는 작업 화면에서 다시 요청해 주세요.'].join('\n'); } if ( input.includes('컴포넌트') || normalized.includes('widget') || normalized.includes('api') ) { return ['결과', '- 대상 리소스를 특정해 주세요.', '- 예: 버튼 컴포넌트, previewer, history api'].join('\n'); } if (snapshot && isPlanDetailRequest(input)) { const lines = [ '작업 요약', `- #${snapshot.planId} ${snapshot.workId}`, `- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`, `- 최근 작업: ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 110) || '기록 없음'}`, ]; if (detailRequested) { lines.push(...buildRecentPlanHistoryLines(snapshot)); } else { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } return [ '결과', `- 현재 화면: ${pageTitle}`, '- 요청을 더 구체적으로 적어 주시면 필요한 정보만 짧게 정리합니다.', '- 예: preview 링크, 최근 이슈, 작업 요약, 상세', ].join('\n'); } function buildPlanReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) { const normalized = input.toLowerCase(); const detailRequested = isDetailRequest(input); const pageTitle = context?.pageTitle ?? '현재 화면'; if (!snapshot) { return `현재 화면: ${pageTitle}\n선택된 Plan을 찾지 못했습니다. Plan 목록에서 항목을 선택한 뒤 다시 요청해 주세요.`; } const lines = ['작업 요약', `- #${snapshot.planId} ${snapshot.workId}`, `- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`]; if (isWorklogRequest(input)) { return buildWorklogReply(context, snapshot); } if (snapshot.note) { lines.push(`- 메모: ${truncateText(snapshot.note, 110)}`); } if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) { lines.push(`- preview: ${snapshot.latestSourceWork?.previewUrl ?? context?.pageUrl ?? '없음'}`); if (snapshot.latestSourceWork) { lines.push(`- 최근 작업: ${truncateText(snapshot.latestSourceWork.summary, 100)}`); if (detailRequested) { lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`); } } if (!detailRequested) { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } if (snapshot.latestIssue) { lines.push(`- 최근 이슈: ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? '해결' : '미해결'}`); if (detailRequested) { lines.push(`- 이슈 내용: ${truncateText(snapshot.latestIssue.message, 100)}`); } } else if (snapshot.issueTags.length > 0) { lines.push(`- 이슈 태그: ${snapshot.issueTags.join(', ')}`); } if (snapshot.latestActionNote) { lines.push(`- 최근 조치: ${truncateText(snapshot.latestActionNote, 100)}`); } if (snapshot.latestSourceWork) { lines.push(`- 최근 작업: ${truncateText(snapshot.latestSourceWork.summary, 100)}`); if (detailRequested && snapshot.latestSourceWork.changedFiles.length > 0) { lines.push(`- 파일 ${snapshot.latestSourceWork.changedFiles.length}개 변경`); } } if (snapshot.lastError && snapshot.hasOpenIssues) { lines.push(`- 주의: ${truncateText(snapshot.lastError, 100)}`); } if (detailRequested) { if (snapshot.assignedBranch) { lines.push(`- 브랜치: ${snapshot.assignedBranch}`); } lines.push(...buildRecentPlanHistoryLines(snapshot)); } else { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } async function buildCodexReply( context: ChatContext | null, input: string, sessionId: string, requestId: string, options?: { omitPromptHistory?: boolean; requestedAt?: Date | null; }, onProgress?: (text: string) => void, onActivity?: (line: string) => void, isCancellationRequested?: () => boolean, ) { return runAgenticCodexReply( context, input, sessionId, requestId, options, onProgress, onActivity, isCancellationRequested, ); } export class ChatService { private readonly wss = new WebSocketServer({ noServer: true }); private readonly clientStates = new Map(); private readonly sessions = new Map(); private readonly cancelledRequestIds = new Set(); private readonly unsubscribeRuntimeBroadcast: () => void; private readonly unsubscribeNotificationBroadcast: () => void; constructor(private readonly logger: FastifyBaseLogger) { activeChatService = this; activeRuntimeController = { getJobDetail: (requestId) => chatRuntimeService.getJobDetail(requestId), cancelJob: (requestId) => this.cancelRuntimeJob(requestId), removeQueuedJob: (requestId) => this.removeQueuedRuntimeJob(requestId), }; this.unsubscribeRuntimeBroadcast = chatRuntimeService.subscribe((snapshot) => { this.broadcastRuntimeSnapshot(snapshot); }); this.unsubscribeNotificationBroadcast = subscribeNotificationMessageChanges((event) => { this.broadcastNotificationMessageChange(event); }); this.wss.on('connection', (socket: WebSocket, request: IncomingMessage) => { void this.handleConnection(socket, request).catch((error: unknown) => { const session = this.clientStates.get(socket); this.logger.error(error, 'chat websocket connection initialization failed'); this.clientStates.delete(socket); if (session?.socket === socket) { session.socket = null; } closeSocketSafely(this.logger, socket, 'failed to close websocket after initialization error'); }); }); } attachUpgradeHandler() { return (request: IncomingMessage, socket: Socket, head: Buffer) => { const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost'; const url = new URL(request.url ?? '/', origin); if (url.pathname !== SOCKET_PATH) { socket.destroy(); return; } this.wss.handleUpgrade(request, socket, head, (websocket: WebSocket) => { this.wss.emit('connection', websocket, request); }); }; } close() { activeRuntimeController = null; if (activeChatService === this) { activeChatService = null; } this.unsubscribeRuntimeBroadcast(); this.unsubscribeNotificationBroadcast(); for (const execution of activeChatProcessRegistry.values()) { void Promise.resolve(execution.cancel()).catch(() => { // noop }); } activeChatProcessRegistry.clear(); chatRuntimeService.clearAll(); for (const client of this.clientStates.keys()) { client.close(); } this.wss.close(); } async recoverInterruptedSessions() { const recoverableRequests = await listRecoverableChatConversationRequests(); if (recoverableRequests.length === 0) { return { sessionCount: 0, restartedCount: 0, requeuedCount: 0, }; } const requestsBySession = new Map(); for (const item of recoverableRequests) { const existing = requestsBySession.get(item.sessionId); if (existing) { existing.push(item); } else { requestsBySession.set(item.sessionId, [item]); } } let restartedCount = 0; let requeuedCount = 0; for (const [sessionId, items] of requestsBySession.entries()) { const session = this.getOrCreateSession(sessionId, items[0]?.clientId ?? null); const primaryItem = items.find((item) => item.requestId === item.currentRequestId && item.currentJobStatus === 'started') ?? items.find((item) => item.status === 'started') ?? items[0]; session.clientId = primaryItem?.clientId?.trim() || session.clientId; session.context = { pageId: null, pageTitle: '', topMenu: '', focusedComponentId: null, pageUrl: '', chatTypeId: primaryItem?.chatTypeId ?? primaryItem?.lastChatTypeId ?? null, chatTypeLabel: primaryItem?.contextLabel ?? '', chatTypeDescription: primaryItem?.contextDescription ?? '', }; for (const item of items) { if (item.requestId === primaryItem?.requestId) { continue; } const requestedAtMs = parseRequestedAtMs(item.createdAt); session.queue.push({ requestId: item.requestId, text: item.userText, mode: 'queue', requestedAtMs, context: session.context ? cloneChatContext(session.context) : null, }); chatRuntimeService.enqueueJob({ sessionId, requestId: item.requestId, mode: 'queue', text: item.userText, }); chatRuntimeService.appendLog(item.requestId, '워크서버 재기동 후 대기열 복구를 준비합니다.'); requeuedCount += 1; } if (!primaryItem) { continue; } restartedCount += 1; void this.executeRequest(session, { requestId: primaryItem.requestId, text: primaryItem.userText, mode: 'queue', requestedAtMs: parseRequestedAtMs(primaryItem.createdAt), context: session.context ? cloneChatContext(session.context) : null, }).catch((error: unknown) => { this.logger.error( { error, sessionId: session.sessionId, requestId: primaryItem.requestId }, 'failed to recover interrupted chat request', ); }); } this.logger.info( { sessionCount: requestsBySession.size, restartedCount, requeuedCount, }, 'recovered interrupted chat sessions after work-server restart', ); return { sessionCount: requestsBySession.size, restartedCount, requeuedCount, }; } private getOrCreateSession(sessionId: string, clientId?: string | null) { const existing = this.sessions.get(sessionId); if (existing) { if (clientId?.trim()) { existing.clientId = clientId.trim(); } return existing; } const nextSession: ChatSessionState = { sessionId, clientId: clientId?.trim() || null, socket: null, lastSeenAt: Date.now(), isDeleted: false, context: null, queue: [], activeRequestCount: 0, pendingQueueReleaseEventId: null, nextEventId: 1, eventHistory: [], messagePersistenceTail: Promise.resolve(), watchedRuntimeRequestId: null, }; this.sessions.set(sessionId, nextSession); return nextSession; } private createSessionEnvelope(session: ChatSessionState, message: ChatOutboundPayload): ChatOutboundMessage { return { ...message, eventId: session.nextEventId++, sessionId: session.sessionId, }; } private retainEnvelopeForReplay(session: ChatSessionState, envelope: ChatOutboundMessage) { if (envelope.type === 'chat:init' || envelope.type === 'chat:status') { return; } session.eventHistory.push(envelope); if (session.eventHistory.length > CHAT_SESSION_EVENT_HISTORY_LIMIT) { session.eventHistory.splice(0, session.eventHistory.length - CHAT_SESSION_EVENT_HISTORY_LIMIT); } } private persistConversationMessage(session: ChatSessionState, message: ChatMessage) { if (session.isDeleted) { return Promise.resolve(); } const nextPersistence = session.messagePersistenceTail .catch(() => undefined) .then(() => appendChatConversationMessage( { sessionId: session.sessionId, clientId: session.clientId, title: null, contextLabel: session.context?.chatTypeLabel ?? null, contextDescription: session.context?.chatTypeDescription ?? null, notifyOffline: undefined, }, { sessionId: session.sessionId, messageId: message.id, author: message.author, text: message.text, timestamp: message.timestamp, clientRequestId: message.clientRequestId ?? null, parts: message.parts ?? [], }, ), ); session.messagePersistenceTail = nextPersistence.catch((error: unknown) => { this.logger.error(error, 'failed to persist chat message'); }); return session.messagePersistenceTail; } private sendToSession( session: ChatSessionState, message: ChatOutboundPayload, options?: { skipOfflineNotification?: boolean; }, ) { if (session.isDeleted) { const normalizedDeletedMessage = message.type === 'chat:message' ? { ...message, payload: normalizeStructuredChatMessage(message.payload), } : message; return this.createSessionEnvelope(session, normalizedDeletedMessage); } const normalizedMessage = message.type === 'chat:message' ? { ...message, payload: normalizeStructuredChatMessage(message.payload), } : message; const envelope = this.createSessionEnvelope(session, normalizedMessage); this.retainEnvelopeForReplay(session, envelope); sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send websocket session envelope'); if (normalizedMessage.type === 'chat:message') { this.persistConversationMessage(session, normalizedMessage.payload); if (normalizedMessage.payload.author === 'codex' && options?.skipOfflineNotification !== true) { void this.sendOfflineNotificationBestEffort(session, normalizedMessage.payload); } } return envelope; } private async sendOfflineNotificationBestEffort(session: ChatSessionState, message: ChatMessage) { try { await this.sendOfflineNotificationIfNeeded(session, message); } catch (error: unknown) { this.logger.error(error, 'failed to send offline chat notification; request processing will continue'); } } private updateMessageInSession(session: ChatSessionState, message: ChatMessage) { const normalizedMessage = normalizeStructuredChatMessage(message); const envelope = this.createSessionEnvelope(session, { type: 'chat:message:update', payload: normalizedMessage, }); this.retainEnvelopeForReplay(session, envelope); sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send websocket message update envelope'); // Streaming codex deltas and synthesized activity summaries are transient UI state. // Persist only the final chat message / activity rows to avoid long DB tails that // can keep a finished request looking "running" until every intermediate update flushes. if (shouldPersistMessageUpdate(normalizedMessage)) { this.persistConversationMessage(session, normalizedMessage); } return envelope; } private broadcastRuntimeSnapshot(snapshot = chatRuntimeService.getSnapshot()) { for (const session of this.sessions.values()) { this.sendToSession(session, { type: 'chat:runtime', payload: snapshot, }); this.pushRuntimeDetail(session); } } private broadcastNotificationMessageChange(event: NotificationMessageChangeEvent) { for (const session of this.sessions.values()) { const payload = event.action === 'deleted' ? { action: 'deleted' as const, itemId: event.id, } : { action: event.action, itemId: event.item.id, category: event.item.category, read: event.item.read, }; const envelope = this.createSessionEnvelope(session, { type: 'notification:messages-updated', payload, }); sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send notification message update envelope'); } } getSessionClientIdMap() { return new Map( [...this.sessions.entries()].map(([sessionId, session]) => [sessionId, session.clientId?.trim() || null]), ); } private pushRuntimeDetail(session: ChatSessionState) { const requestId = session.watchedRuntimeRequestId?.trim(); if (!requestId) { return; } this.sendToSession(session, { type: 'chat:runtime:detail', payload: chatRuntimeService.getJobDetail(requestId), }); } private async cancelRuntimeJob(requestId: string) { const execution = activeChatProcessRegistry.get(requestId); const detail = chatRuntimeService.getJobDetail(requestId); if (!execution && detail.item && detail.terminalStatus == null) { chatRuntimeService.appendLog(requestId, '실행 준비 단계에서 취소 요청을 접수했습니다.'); this.cancelledRequestIds.add(requestId); const session = this.findSessionByRequestId(requestId); if (session) { void upsertChatConversationRequest(session.sessionId, { requestId, status: 'cancelled', statusMessage: '사용자 요청으로 실행 취소를 대기합니다.', }).catch((error: unknown) => { this.logger.warn(error, 'failed to persist pending chat request cancellation state'); }); } return true; } if (!execution) { return false; } chatRuntimeService.appendLog(requestId, '사용자 요청으로 실행 취소를 시도합니다.'); this.cancelledRequestIds.add(requestId); const session = this.findSessionByRequestId(requestId); if (session) { void upsertChatConversationRequest(session.sessionId, { requestId, status: 'cancelled', statusMessage: '사용자 요청으로 실행 취소를 시도합니다.', }).catch((error: unknown) => { this.logger.warn(error, 'failed to persist chat request cancellation state'); }); } try { const cancelled = await execution.cancel(); if (!cancelled && this.cancelledRequestIds.has(requestId)) { chatRuntimeService.appendLog(requestId, '취소 신호를 재시도 대기 중입니다.'); return true; } return cancelled; } catch (error) { this.logger.warn(error, 'failed to cancel chat runtime job'); chatRuntimeService.appendLog(requestId, '실행 취소 요청에 실패했습니다.'); this.cancelledRequestIds.delete(requestId); return false; } } private async removeQueuedRuntimeJob(requestId: string) { let removed = false; let removedSessionId: string | null = null; for (const session of this.sessions.values()) { const nextQueue = session.queue.filter((item) => item.requestId !== requestId); if (nextQueue.length === session.queue.length) { continue; } session.queue = nextQueue; removed = true; removedSessionId = session.sessionId; this.emitJobState(session, { requestId, status: 'completed', mode: 'queue', queueSize: session.queue.length, message: '대기열에서 제거됨', }); this.sendToSession(session, { type: 'chat:message', payload: createMessage('system', '대기열 요청이 관리 화면에서 제거되었습니다.'), }); break; } if (!removed) { return false; } chatRuntimeService.finishJob(requestId, 'removed'); if (removedSessionId) { await upsertChatConversationRequest(removedSessionId, { requestId, status: 'removed', statusMessage: '대기열에서 제거되었습니다.', }); } return true; } private tryStartNextQueuedRequest(session: ChatSessionState) { this.normalizeSessionExecutionState(session); if (session.activeRequestCount > 0 || session.queue.length === 0 || session.pendingQueueReleaseEventId !== null) { return; } const nextQueuedRequest = session.queue.shift(); if (!nextQueuedRequest) { return; } void this.executeRequest(session, nextQueuedRequest).catch((error: unknown) => { this.logger.error(error, 'queued chat reply build failed'); this.sendToSession(session, { type: 'chat:error', payload: { message: '대기 중이던 채팅 요청 처리 중 오류가 발생했습니다.', }, }); }); } private normalizeSessionExecutionState(session: ChatSessionState) { const snapshot = chatRuntimeService.getSnapshot(); const runtimeSession = snapshot.sessions.find((item) => item.sessionId === session.sessionId) ?? null; const runtimeRunningCount = runtimeSession?.runningCount ?? 0; const runtimeQueuedIds = new Set( snapshot.queued.filter((item) => item.sessionId === session.sessionId).map((item) => item.requestId), ); session.activeRequestCount = runtimeRunningCount; session.queue = session.queue.filter((item) => runtimeQueuedIds.has(item.requestId)); if (runtimeRunningCount === 0) { session.pendingQueueReleaseEventId = null; } } private emitJobState( session: ChatSessionState, payload: { requestId: string; status: 'queued' | 'started' | 'completed' | 'failed'; mode: 'queue' | 'direct'; queueSize: number; message: string; }, ) { this.sendToSession(session, { type: 'chat:job', payload, }); void updateChatConversationJobState(session.sessionId, { requestId: payload.requestId, status: payload.status, message: payload.message, queueSize: payload.queueSize, clear: false, }).catch((error: unknown) => { this.logger.error(error, 'failed to persist chat job state'); }); const requestStatus = payload.status === 'queued' ? 'queued' : payload.status === 'started' ? 'started' : payload.status === 'completed' ? 'completed' : this.cancelledRequestIds.has(payload.requestId) ? 'cancelled' : 'failed'; void upsertChatConversationRequest(session.sessionId, { requestId: payload.requestId, status: requestStatus, statusMessage: payload.message, }).catch((error: unknown) => { this.logger.error(error, 'failed to persist chat request state'); }); } private findSessionByRequestId(requestId: string) { for (const session of this.sessions.values()) { if (session.queue.some((item) => item.requestId === requestId)) { return session; } if ( session.eventHistory.some((event) => { if (event.type === 'chat:job') { return event.payload.requestId === requestId; } if (event.type === 'chat:message' || event.type === 'chat:message:update') { return event.payload.clientRequestId === requestId; } return false; }) ) { return session; } } return null; } private async sendOfflineNotificationIfNeeded(session: ChatSessionState, message: ChatMessage) { if (!session.clientId?.trim()) { return; } if (message.author !== 'codex' || isPreparingChatReply(message.text)) { return; } const appOrigin = resolveChatContextAppOrigin(session.context); const [conversation, appConfig] = await Promise.all([ getChatConversation(session.sessionId, session.clientId), getAppConfigSnapshot(appOrigin), ]); if ( !shouldSendOfflineChatNotification({ receiveRoomNotifications: appConfig.chat?.receiveRoomNotifications, conversationNotifyOffline: conversation?.notifyOffline, }) ) { return; } const requestOwnerClientId = message.clientRequestId?.trim() ? (await getChatConversationRequest(session.sessionId, message.clientRequestId).catch(() => null)) ?.requesterClientId?.trim() || null : null; if (!requestOwnerClientId) { return; } const preferredNotificationClientIds = await listChatConversationOfflineNotificationClientIds(session.sessionId, { keepClientIds: [session.clientId, requestOwnerClientId], }).catch(() => []); const notificationCandidateClientIds = collectOfflineNotificationClientIds(session.clientId, [ requestOwnerClientId, ...preferredNotificationClientIds, ]); const notificationTargetClientIds = notificationCandidateClientIds.filter((clientId) => { const evaluation = evaluateChatClientActiveViewing(clientId, this.sessions.values()); logChatClientActiveViewingEvaluation(this.logger, clientId, evaluation); return !evaluation.isActive; }); if (!notificationTargetClientIds.length) { this.logger.info( { sessionId: session.sessionId, messageId: message.id, requestOwnerClientId, notificationCandidateClientIds, }, 'chat offline notification skipped because every target client is actively viewing', ); return; } const notificationPayload = await this.buildOfflineChatNotificationPayload( session, message, conversation?.title || '현재 채팅방', ); if (!notificationPayload) { return; } await this.createOfflineAppNotificationIfNeeded(notificationPayload, notificationTargetClientIds); const result = await sendNotifications( { title: notificationPayload.title, body: notificationPayload.body, data: notificationPayload.data, threadId: notificationPayload.threadId, targetClientIds: notificationTargetClientIds, }, { disableIos: true, }, ); if (!result.web.ok && !result.web.skipped) { this.logger.warn( { sessionId: session.sessionId, messageId: message.id, targetClientIds: notificationTargetClientIds, webPush: result.web, }, 'chat webpush delivery reported failures', ); } } private async createOfflineAppNotificationIfNeeded( payload: { title: string; body: string; previewText: string; metadata: Record; linkUrl: string; }, targetClientIds: string[], ) { await createNotificationMessage({ title: payload.title, body: payload.body, category: 'chat', source: 'codex-live', priority: 'normal', metadata: { ...payload.metadata, targetClientIds, linkUrl: payload.linkUrl, linkLabel: '채팅 바로 열기', previewText: payload.previewText, }, }); } private async buildOfflineChatNotificationPayload( session: ChatSessionState, message: ChatMessage, conversationTitle: string, ) { const answerText = String(message.text ?? '').trim(); if (!answerText || isPreparingChatReply(answerText)) { return null; } const linkUrl = buildChatNotificationTargetUrl(session.context, session.sessionId); const requests = message.clientRequestId?.trim() ? await listChatConversationRequests(session.sessionId, 200).catch(() => []) : []; const matchingRequest = requests.find((request) => request.requestId === message.clientRequestId) ?? requests.find((request) => request.responseMessageId === message.id) ?? null; const questionText = matchingRequest?.userText?.trim() || ''; const fallback = createChatNotificationPreview(answerText) || `${conversationTitle} 채팅방에 새 응답이 도착했습니다.`; const body = createChatQuestionAnswerNotificationBody({ questionText, answerText, fallback, }); const previewText = createChatQuestionOnlyNotificationPreview(questionText, fallback); if (!body.trim()) { return null; } const metadata = { category: 'chat', sessionId: session.sessionId, conversationTitle, messageId: String(message.id), messageTimestamp: message.timestamp, questionText: normalizeNotificationDetailText(questionText) ?? '', answerText: normalizeNotificationDetailText(answerText) ?? '', targetUrl: linkUrl, notificationKey: `chat:${session.sessionId}:${message.id}`, type: 'chat-reply', suppressIfVisible: 'true', }; return { title: 'Codex Live 새 메시지', body, previewText, linkUrl, threadId: `chat:${session.sessionId}`, data: metadata, metadata, }; } private replaySessionHistory(session: ChatSessionState, lastEventId: number) { if (session.isDeleted || !Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) { return; } const pendingEnvelopes = session.eventHistory.filter((envelope) => envelope.eventId > lastEventId); for (const envelope of pendingEnvelopes) { sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to replay websocket session history envelope'); } } private async initializeSession(session: ChatSessionState) { if (session.isDeleted) { return; } await session.messagePersistenceTail.catch(() => undefined); const messages = await listChatConversationMessages(session.sessionId, { limit: 500 }); const initEnvelope = this.createSessionEnvelope(session, { type: 'chat:init', payload: { messages, }, }); sendSocketEnvelope(this.logger, session.socket, initEnvelope, 'failed to send websocket init envelope'); const statusEnvelope = this.createSessionEnvelope(session, { type: 'chat:status', payload: { connectedAt: new Date().toISOString(), }, }); sendSocketEnvelope(this.logger, session.socket, statusEnvelope, 'failed to send websocket status envelope'); this.sendToSession(session, { type: 'chat:runtime', payload: chatRuntimeService.getSnapshot(), }); } private async handleConnection(socket: WebSocket, request: IncomingMessage) { const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost'; const url = new URL(request.url ?? '/', origin); if (!hasAuthorizedChatSocketAccess(request, url)) { closeSocketSafely(this.logger, socket, 'failed to close unauthorized chat websocket session', 1008, 'unauthorized'); return; } const requestedSessionId = url.searchParams.get('sessionId')?.trim() || createRequestId(); const clientId = url.searchParams.get('clientId')?.trim() || null; const lastEventIdRaw = Number(url.searchParams.get('lastEventId') ?? 0); const lastEventId = Number.isFinite(lastEventIdRaw) && lastEventIdRaw > 0 ? lastEventIdRaw : 0; const session = this.getOrCreateSession(requestedSessionId, clientId); if (session.socket && session.socket !== socket) { closeSocketSafely(this.logger, session.socket, 'failed to close superseded websocket session'); } session.socket = socket; session.lastSeenAt = Date.now(); this.clientStates.set(socket, session); socket.on('message', (raw: RawData) => { this.handleMessage(socket, raw); }); socket.on('close', () => { this.clientStates.delete(socket); if (session.socket === socket) { session.socket = null; } }); socket.on('error', (error: Error) => { this.logger.error(error, 'chat websocket error'); this.clientStates.delete(socket); if (session.socket === socket) { session.socket = null; } }); await this.initializeSession(session); this.replaySessionHistory(session, lastEventId); } async forgetSession(sessionId: string) { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return; } const session = this.sessions.get(normalizedSessionId); const runtimeSnapshot = chatRuntimeService.getSnapshot(); const runtimeRequestIds = new Set( [...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent] .filter((item) => item.sessionId === normalizedSessionId) .map((item) => item.requestId), ); if (session) { session.isDeleted = true; session.queue = []; session.eventHistory = []; session.pendingQueueReleaseEventId = null; session.watchedRuntimeRequestId = null; session.activeRequestCount = 0; if (session.socket) { this.clientStates.delete(session.socket); closeSocketSafely(this.logger, session.socket, 'failed to close deleted chat websocket session'); session.socket = null; } this.sessions.delete(normalizedSessionId); } for (const requestId of runtimeRequestIds) { const detail = chatRuntimeService.getJobDetail(requestId); if (detail.availableActions.cancel) { try { await this.cancelRuntimeJob(requestId); } catch { // ignore and hard-clear runtime state below } } else if (detail.availableActions.remove) { try { await this.removeQueuedRuntimeJob(requestId); } catch { // ignore and hard-clear runtime state below } } activeChatProcessRegistry.delete(requestId); this.cancelledRequestIds.delete(requestId); } chatRuntimeService.clearSession(normalizedSessionId); } resetSessionData(sessionId: string) { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return; } const session = this.sessions.get(normalizedSessionId); if (!session) { return; } session.queue = []; session.eventHistory = []; session.pendingQueueReleaseEventId = null; session.watchedRuntimeRequestId = null; session.activeRequestCount = 0; } private handleMessage(socket: WebSocket, raw: RawData) { try { const message = JSON.parse(raw.toString()) as ChatInboundMessage; const session = this.clientStates.get(socket); if (session) { session.lastSeenAt = Date.now(); } if (message.type === 'context:update') { this.handleContextUpdate(socket, message.payload); return; } if (message.type === 'presence:ping') { return; } if (message.type === 'event:received') { const receivedEventId = Math.round(Number(message.payload?.eventId ?? 0)); if ( session && Number.isFinite(receivedEventId) && receivedEventId > 0 && session.pendingQueueReleaseEventId !== null && receivedEventId >= session.pendingQueueReleaseEventId ) { session.pendingQueueReleaseEventId = null; this.tryStartNextQueuedRequest(session); } return; } if (message.type === 'message:send') { void this.handleUserMessage( socket, message.payload.text, message.payload.requestId, message.payload.mode === 'direct' ? 'direct' : 'queue', { chatTypeId: message.payload.chatTypeId ?? null, chatTypeLabel: message.payload.chatTypeLabel, chatTypeDescription: message.payload.chatTypeDescription, chatTypeBaseDescription: message.payload.chatTypeBaseDescription, defaultContextIds: message.payload.defaultContextIds, defaultContexts: message.payload.defaultContexts, customContextTitle: message.payload.customContextTitle, customContextContent: message.payload.customContextContent, }, { omitPromptHistory: message.payload.omitPromptHistory === true, }, ).catch((error: unknown) => { this.logger.error(error, 'chat reply build failed'); const session = this.clientStates.get(socket); if (!session) { return; } this.sendToSession(session, { type: 'chat:error', payload: { message: error instanceof Error && error.message.trim() ? error.message.trim() : '채팅 응답을 만드는 중 오류가 발생했습니다.', }, }); }); return; } if (message.type === 'runtime:watch') { if (!session) { return; } session.watchedRuntimeRequestId = message.payload?.requestId?.trim() || null; this.pushRuntimeDetail(session); } } catch (error) { this.logger.warn(error, 'invalid chat websocket payload'); const session = this.clientStates.get(socket); if (!session) { return; } this.sendToSession(session, { type: 'chat:error', payload: { message: '채팅 메시지를 처리하지 못했습니다.', }, }); } } private handleContextUpdate(socket: WebSocket, context: ChatContext) { const state = this.clientStates.get(socket); if (!state) { return; } const previousFocus = state.context?.focusedComponentId; const previousContextLabel = state.context?.chatTypeLabel ?? null; const previousContextDescription = state.context?.chatTypeDescription ?? null; state.context = context; const nextContextLabel = context.chatTypeLabel ?? null; const nextContextDescription = context.chatTypeDescription ?? null; if (previousContextLabel !== nextContextLabel || previousContextDescription !== nextContextDescription) { void updateChatConversationContext(state.sessionId, { clientId: state.clientId, contextLabel: nextContextLabel, contextDescription: nextContextDescription, }).catch((error: unknown) => { this.logger.error(error, 'failed to persist chat context'); }); } if (context.focusedComponentId && context.focusedComponentId !== previousFocus) { this.sendToSession(state, { type: 'chat:message', payload: createMessage('codex', `선택 포커스가 ${context.focusedComponentId}로 이동했습니다. 관련 문맥을 반영합니다.`), }); } } private async handleUserMessage( socket: WebSocket, text: string, requestId?: string, mode: 'queue' | 'direct' = 'queue', contextOverride?: Partial | null, requestOptions?: { omitPromptHistory?: boolean; }, ) { const trimmed = text.trim(); if (!trimmed) { return; } const state = this.clientStates.get(socket); if (!state) { return; } if (contextOverride) { const mergedContext = { ...(state.context ?? { pageId: null, pageTitle: '', topMenu: '', focusedComponentId: null, pageUrl: '', }), ...contextOverride, }; state.context = (await resolveCodexLiveChatContext(mergedContext, state.sessionId)) ?? mergedContext; void updateChatConversationContext(state.sessionId, { clientId: state.clientId, chatTypeId: state.context.chatTypeId ?? null, lastChatTypeId: state.context.chatTypeId ?? null, contextLabel: state.context.chatTypeLabel ?? null, contextDescription: state.context.chatTypeDescription ?? null, }).catch((error: unknown) => { this.logger.error(error, 'failed to persist chat context from message send'); }); } this.normalizeSessionExecutionState(state); const nextRequestId = requestId?.trim() || createRequestId(); const requestedAtMs = Date.now(); const request = { requestId: nextRequestId, text: trimmed, mode, requestedAtMs, omitPromptHistory: requestOptions?.omitPromptHistory === true, context: cloneChatContext(state.context), }; if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) { const queuedUserMessage = { ...createMessage('user', trimmed, nextRequestId), timestamp: formatTime(new Date(requestedAtMs)), }; this.sendToSession(state, { type: 'chat:message', payload: queuedUserMessage, }); state.queue.push(request); chatRuntimeService.enqueueJob({ sessionId: state.sessionId, requestId: nextRequestId, mode, text: trimmed, }); chatRuntimeService.registerQueuedControl(nextRequestId, { remove: () => this.removeQueuedRuntimeJob(nextRequestId), }); this.emitJobState(state, { requestId: nextRequestId, status: 'queued', mode, queueSize: state.queue.length, message: `대기열 ${state.queue.length}건`, }); void upsertChatConversationRequest(state.sessionId, { requestId: nextRequestId, status: 'queued', statusMessage: `대기열 ${state.queue.length}건`, userMessageId: queuedUserMessage.id, userText: trimmed, }).catch((error: unknown) => { this.logger.error(error, 'failed to persist queued chat request'); }); return; } await this.executeRequest(state, request); } private async executeRequest( session: ChatSessionState, request: { requestId: string; text: string; mode: 'queue' | 'direct'; requestedAtMs: number; omitPromptHistory?: boolean; context: ChatContext | null; }, ) { let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed'; let hasAnnouncedStreaming = false; const compactActivityLineMap = new Map(); session.activeRequestCount += 1; const existingRequest = await getChatConversationRequest(session.sessionId, request.requestId); const hasStoredUserMessage = existingRequest?.userMessageId != null; if (!hasStoredUserMessage) { this.sendToSession(session, { type: 'chat:message', payload: { ...createMessage('user', request.text, request.requestId), timestamp: formatTime(new Date(request.requestedAtMs)), }, }); } const appendActivityLine = (line: string) => { const compactEntry = createCompactActivityLogEntry(line); if (!compactEntry) { return; } const previousLine = compactActivityLineMap.get(compactEntry.lineNo); if (previousLine === compactEntry.line) { return; } compactActivityLineMap.set(compactEntry.lineNo, compactEntry.line); const activityLines = Array.from(compactActivityLineMap.entries()) .sort((left, right) => left[0] - right[0]) .map(([, activityLine]) => activityLine); void appendChatConversationActivityLine( session.sessionId, request.requestId, compactEntry.line, compactEntry.lineNo, ).catch((error: unknown) => { this.logger.warn(error, 'failed to persist compact chat activity line'); }); this.sendToSession(session, { type: 'chat:activity', payload: { requestId: request.requestId, line: compactEntry.line, lineCount: activityLines.length, lineNo: compactEntry.lineNo, }, }); const activityMessage = createActivityLogMessage(request.requestId, activityLines); if (activityMessage) { this.updateMessageInSession(session, activityMessage); } }; chatRuntimeService.startJob({ sessionId: session.sessionId, requestId: request.requestId, mode: request.mode, text: request.text, }); chatRuntimeService.registerRunningControl(request.requestId, { cancel: () => this.cancelRuntimeJob(request.requestId), }); chatRuntimeService.appendLog(request.requestId, `요청을 처리합니다. mode=${request.mode}`); appendActivityLine(`# 상태: 요청을 처리합니다. mode=${request.mode}`); this.emitJobState(session, { requestId: request.requestId, status: 'started', mode: request.mode, queueSize: session.queue.length, message: request.mode === 'direct' ? '즉시 요청 실행 중' : `요청 처리 중${session.queue.length > 0 ? ` · 대기열 ${session.queue.length}건` : ''}`, }); this.sendToSession(session, { type: 'chat:message', payload: createMessage( 'system', request.mode === 'direct' ? '즉시 요청 실행 중입니다.' : `요청 실행 중입니다.${session.queue.length > 0 ? ` 남은 대기열 ${session.queue.length}건` : ''}`, request.requestId, ), }); const stopProgressTimer = () => { if (progressTimer !== null) { clearTimeout(progressTimer); progressTimer = null; } }; const progressMessages = buildProgressMessages(request.text); let progressIndex = progressMessages.length > 1 ? 1 : 0; let lastProgressMessage = progressMessages[0] ?? ''; let lastMeaningfulProgressSummary = ''; let progressTimer: ReturnType | null = null; const emitSystemProgressMessage = (nextMessage: string, options?: { appendActivity?: boolean }) => { const normalizedMessage = normalizeProgressSummary(nextMessage); if (!normalizedMessage || normalizedMessage === lastProgressMessage) { return false; } lastProgressMessage = normalizedMessage; chatRuntimeService.appendLog(request.requestId, normalizedMessage); if (options?.appendActivity !== false) { appendActivityLine(`# 진행: ${normalizedMessage}`); } this.sendToSession(session, { type: 'chat:message', payload: createMessage('system', normalizedMessage, request.requestId), }); return true; }; const scheduleFallbackProgressMessage = () => { if (progressIndex >= progressMessages.length) { return; } stopProgressTimer(); progressTimer = setTimeout(() => { const nextMessage = progressMessages[progressIndex] ?? ''; if (!nextMessage || lastMeaningfulProgressSummary) { stopProgressTimer(); return; } if (emitSystemProgressMessage(nextMessage)) { progressIndex += 1; } stopProgressTimer(); }, 2200); }; const codexReplyMessage = createMessage('codex', '', request.requestId); try { chatRuntimeService.appendLog(request.requestId, '요청 분석을 시작합니다.'); appendActivityLine('# 진행: 요청 분석을 시작합니다.'); this.sendToSession(session, { type: 'chat:message', payload: createMessage('system', progressMessages[0] ?? '요청을 분석하고 있습니다.', request.requestId), }); scheduleFallbackProgressMessage(); this.updateMessageInSession(session, { ...codexReplyMessage, text: '응답을 준비하고 있습니다...', timestamp: resolveResponseTimestamp(request.requestedAtMs), }); const reply = await buildCodexReply( request.context ?? session.context ?? null, request.text, session.sessionId, request.requestId, { omitPromptHistory: request.omitPromptHistory === true, requestedAt: new Date(request.requestedAtMs), }, (partialReply) => { stopProgressTimer(); if (!hasAnnouncedStreaming) { hasAnnouncedStreaming = true; chatRuntimeService.appendLog(request.requestId, '응답을 실시간으로 전송 중입니다.'); appendActivityLine('# 진행: 응답을 실시간으로 전송 중입니다.'); } this.updateMessageInSession(session, { ...codexReplyMessage, text: partialReply, timestamp: resolveResponseTimestamp(request.requestedAtMs), }); }, (activityLine) => { appendActivityLine(activityLine); const activitySummary = summarizeActivityProgressLine(activityLine); if (activitySummary && activitySummary !== lastMeaningfulProgressSummary) { lastMeaningfulProgressSummary = activitySummary; stopProgressTimer(); emitSystemProgressMessage(activitySummary, { appendActivity: false }); } }, () => this.cancelledRequestIds.has(request.requestId), ); chatRuntimeService.appendLog(request.requestId, '응답 생성이 완료되었습니다.'); appendActivityLine('# 상태: 응답 생성이 완료되었습니다.'); const finalCodexReplyMessage = { ...codexReplyMessage, ...(() => { const extracted = extractChatMessageParts(reply); return { text: extracted.strippedText, parts: extracted.parts, }; })(), timestamp: resolveResponseTimestamp(request.requestedAtMs), }; this.sendToSession( session, { type: 'chat:message', payload: finalCodexReplyMessage, }, { skipOfflineNotification: true, }, ); // Final replies must be durably stored before the request is marked complete, // otherwise replay/reload can be stuck on the placeholder text. await this.persistConversationMessage(session, finalCodexReplyMessage); await upsertChatConversationRequest(session.sessionId, { requestId: request.requestId, status: 'completed', statusMessage: '요청 처리 완료', responseMessageId: finalCodexReplyMessage.id, responseText: finalCodexReplyMessage.text, }); await refreshChatSessionReferenceForRequest({ sessionId: session.sessionId, requestId: request.requestId, context: request.context ?? session.context ?? null, input: request.text, requestStatus: 'completed', requestedAt: new Date(request.requestedAtMs), completedAt: new Date(), }); const terminalMessageEnvelope = this.sendToSession(session, { type: 'chat:message', payload: createMessage( 'system', request.mode === 'direct' ? '즉시 요청 처리가 끝났습니다.' : `요청 처리가 끝났습니다.${session.queue.length > 0 ? ` 대기열 ${session.queue.length}건이 이어집니다.` : ''}`, request.requestId, ), }); session.pendingQueueReleaseEventId = terminalMessageEnvelope.eventId; this.emitJobState(session, { requestId: request.requestId, status: 'completed', mode: request.mode, queueSize: session.queue.length, message: '요청 처리 완료', }); chatRuntimeService.finishJob(request.requestId, 'completed'); await this.sendOfflineNotificationBestEffort(session, finalCodexReplyMessage); } catch (error) { const wasCancelled = this.cancelledRequestIds.has(request.requestId); terminalStatus = wasCancelled ? 'cancelled' : 'failed'; const failureResponseText = error instanceof ChatRuntimeExecutionError ? error.responseText : ''; if (failureResponseText) { const extractedFailureReply = extractChatMessageParts(failureResponseText); const failedCodexReplyMessage = { ...codexReplyMessage, text: extractedFailureReply.strippedText, parts: extractedFailureReply.parts, timestamp: resolveResponseTimestamp(request.requestedAtMs), }; this.sendToSession( session, { type: 'chat:message', payload: failedCodexReplyMessage, }, { skipOfflineNotification: true, }, ); await this.persistConversationMessage(session, failedCodexReplyMessage); await upsertChatConversationRequest(session.sessionId, { requestId: request.requestId, status: wasCancelled ? 'cancelled' : 'failed', statusMessage: wasCancelled ? '요청 실행 중단' : '요청 처리 실패', responseMessageId: failedCodexReplyMessage.id, responseText: failedCodexReplyMessage.text, }); } chatRuntimeService.appendLog( request.requestId, wasCancelled ? '사용자 요청으로 실행이 중단되었습니다.' : error instanceof Error ? `실행 오류: ${error.message}` : '실행 오류가 발생했습니다.', ); appendActivityLine( wasCancelled ? '# 상태: 사용자 요청으로 실행이 중단되었습니다.' : error instanceof Error ? `# 오류: ${error.message}` : '# 오류: 실행 오류가 발생했습니다.', ); const terminalErrorEnvelope = this.sendToSession(session, { type: 'chat:message', payload: createMessage( 'system', wasCancelled ? '요청 실행이 중단되었습니다.' : '요청 처리 중 오류가 발생했습니다.', request.requestId, ), }); session.pendingQueueReleaseEventId = terminalErrorEnvelope.eventId; this.emitJobState(session, { requestId: request.requestId, status: 'failed', mode: request.mode, queueSize: session.queue.length, message: wasCancelled ? '요청 실행 중단' : '요청 처리 실패', }); await refreshChatSessionReferenceForRequest({ sessionId: session.sessionId, requestId: request.requestId, context: request.context ?? session.context ?? null, input: request.text, requestStatus: wasCancelled ? 'cancelled' : 'failed', requestedAt: new Date(request.requestedAtMs), completedAt: new Date(), }); throw error; } finally { stopProgressTimer(); activeChatProcessRegistry.delete(request.requestId); if (chatRuntimeService.getJobDetail(request.requestId).terminalStatus == null) { chatRuntimeService.finishJob(request.requestId, terminalStatus); } this.cancelledRequestIds.delete(request.requestId); session.activeRequestCount = Math.max(0, session.activeRequestCount - 1); this.tryStartNextQueuedRequest(session); } } }