/** * server-v2.ts - SmallClaw v2 Gateway * * Architecture: Native Ollama Tool Calling * Memory: Reads SOUL.md, IDENTITY.md, USER.md, MEMORY.md from workspace * Search: Tavily / Google Custom Search API / Brave / DuckDuckGo * Logging: Daily session logs in memory/ */ import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import http from 'http'; import path from 'path'; import fs from 'fs'; import fsp from 'fs/promises'; import crypto from 'crypto'; import { WebSocketServer, WebSocket } from 'ws'; import { addMemoryVector, queryMemoryVectors, embedQuery, queryVectorsWithEmbedding, warmupEmbedding, USER_FACTS_COLLECTION, DAILY_EXTRACTS_COLLECTION, } from './memory/memory-vector'; import { isGreetingLikeMessage, isExecutionLikeRequest, isLiveDataRequest, isFactualInfoRequest, looksLikeUnverifiedSpecClaim, isExemptFromVerification, isBrowserAutomationRequest, isDesktopAutomationRequest, isUsableGroundingResult, looksLikeSafetyRefusal, hasConcreteCompletion, isMessagingRequest, claimsMessageSent, isBrowserToolName, isDesktopToolName, PERSONAL_NEWS_IDIOM, isNewsRequest, isResumeIntent, isRerunIntent, isCancelIntent, isHighStakesFile, requestedFullTemplate, userRequestedImageEdit, } from './guards/prompt-gates'; import { registerArduinoRoutes } from './routes/routes-arduino'; import { registerAndroidRoutes, attachAndroidWsProxy, attachAndroidConsoleWsProxy } from './routes/routes-android'; import { registerComfyUIRoutes, attachComfyUIWsProxy } from './routes/routes-comfyui'; import { registerBgTasksRoutes, loadTaskHeartbeatConfig } from './routes/routes-bg-tasks'; import { registerKakaoRoutes } from './routes/routes-kakao'; import { registerScheduleRoutes } from './routes/routes-schedules'; import { registerSkillsRoutes } from './routes/routes-skills'; import { registerCodeRoutes } from './routes/routes-code'; import { registerTaskRoutes } from './routes/routes-tasks'; import { registerAgentRoutes } from './routes/routes-agents'; import { registerChannelsRoutes } from './routes/routes-channels'; import { registerLegacyTelegramRoutes } from './routes/routes-telegram'; import { registerOrchestrationRoutes } from './routes/routes-orchestration'; import { registerSchematicRoutes } from './routes/routes-schematics'; import { registerCanvasRoutes } from './routes/routes-canvas'; import { registerPptStaticRoutes } from './routes/routes-ppt'; import { registerMiscRoutes } from './routes/routes-misc'; import { registerChatSessionRoutes } from './routes/routes-chat-sessions'; import { createBuildTools } from './chat/build-tools'; import { createExecuteTool, type ToolResult } from './chat/execute-tool'; import { createHandleChat } from './chat/handle-chat'; import { createPersonalityContext } from './chat/personality-context'; import { createHandleTaskControl, inferTaskChannelFromSession, findBlockedTaskForSession, parseTaskIdFromText, } from './tasks/handle-task-control'; import { registerOpenAIAuthRoutes } from './routes/routes-auth-openai'; import { registerUserAppSessionRoutes } from './routes/routes-user-app-sessions'; import { registerChannelMappingRoutes } from './routes/routes-channel-mappings'; import { registerAdminSessionRoutes } from './routes/routes-admin-sessions'; import { registerUserRoutes } from './routes/routes-users'; import { registerDetectiveRoutes } from './routes/routes-detective'; import { registerPptxRoutes } from './routes/routes-pptx'; import { registerVoiceRoutes } from './routes/routes-voice'; import { registerVoiceRealtimeRoutes, attachVoiceRealtimeWsProxy } from './routes/routes-voice-realtime'; import { registerMusicRoutes } from './routes/routes-music'; import { registerMCPRoutes } from './routes/routes-mcp'; import { registerLanguageRoutes } from './routes/routes-language'; import { registerWriterRoutes } from './routes/routes-writer'; import { registerDentalRoutes } from './routes/routes-dental'; import { registerDetectiveDateRoutes } from './routes/routes-detective-dates'; import { registerLawyerCaseRoutes } from './routes/routes-lawyer-cases'; import { registerDoctorCaseRoutes } from './routes/routes-doctor-cases'; import { registerRobotRoutes } from './routes/routes-robot'; import { caseFilesDir, resolveUploadPath, registerWopiRoutes } from './routes/case-storage'; import { getConfig, getAgents, getAgentById, ensureAgentWorkspace, resolveAgentWorkspace, getUserWorkspace, ensureUserWorkspace, } from '../config/config'; import { getPaths } from '../config/paths'; import { getVault, SecretValue } from '../security/vault'; import { getOllamaClient } from '../agents/ollama-client'; import { startOllamaLocalKeepAlive } from './ollama-local-keepalive'; import { spawnAgent } from '../agents/spawner'; import { getSession, addMessage, getHistory, getHistoryForApiCall, getWorkspace, setWorkspace, clearHistory, setContextTokens, cleanupSessions, cleanupEmptySessions, migrateGlobalSessionsToUser, listAllSessions, resolveNumCtx, resolveNumCtxInfo, activeOllamaEndpoint } from './session'; import { hookBus } from './hooks/hooks'; import { loadWorkspaceHooks } from './hooks/hook-loader'; import { validateLinksInText, filterImageMarkdown } from './guards/link-validator'; import { runBootMd } from './infra/boot'; import { TaskRunner, runTask, TaskTool, TaskState } from './tasks/task-runner'; import { setupErrorResponseEndpoint } from './errors/error-response-endpoint-integrated'; import { initCredentialHandler, getCredentialHandler } from '../security/credential-handler'; import { getVerificationFlowManager } from './guards/verification-flow'; import { getErrorAnalyzer } from './errors/error-analyzer'; import { getErrorHistory } from './errors/error-history'; import { getRetryStrategy } from './guards/retry-strategy'; import { getVisualErrorDetector } from './guards/visual-error-detection'; import { getErrorAudit } from '../security/error-audit'; import { getContextInjectionManager } from './guards/context-injection'; import { SkillsManager } from './skills-manager'; import { writeSkillPackFromContent } from '../skills/processor.js'; import { summarizeSkillForApi } from '../tools/skills.js'; import { getToolRegistry } from '../tools/registry.js'; import { registerSettingsRoutes } from './routes/settings.js'; import { registerImagegenRoutes } from './routes/imagegen.js'; import { registerNvrRoutes, attachGo2rtcWsProxy } from './routes/nvr.js'; import { registerK2Routes } from './routes/routes-k2.js'; import { browserOpen, browserSnapshot, browserClick, browserFill, browserPressKey, browserWait, browserScroll, browserClose, browserGetImages, getBrowserToolDefinitions, getBrowserSessionInfo, getBrowserAdvisorPacket, } from './automation/browser-tools'; import { desktopScreenshot, desktopFindWindow, desktopFocusWindow, desktopClick, desktopDrag, desktopWait, desktopType, desktopPressKey, desktopGetClipboard, desktopSetClipboard, getDesktopToolDefinitions, getDesktopAdvisorPacket, } from './automation/desktop-tools'; import { CronScheduler } from './tasks/cron-scheduler'; import { HeartbeatRunner } from './tasks/heartbeat-runner'; import { initializeAgentSchedules, reloadAgentSchedules, stopAgentSchedules, getAgentRunHistory, getAgentLastRun, recordAgentRun, } from '../scheduler'; import { TelegramChannel } from './channels/telegram-channel'; import { KakaoNotify, resolveKakaoImageToken } from './channels/kakao-notify'; import { KakaoChannel } from './channels/kakao-channel'; import { OrchestrationTriggerState, callSecondaryPreflight, callSecondaryAdvisor, callSecondaryFileOpClassifier, callSecondaryFileAnalyzer, callSecondaryFileVerifier, callSecondaryFilePatchPlanner, callSecondaryBrowserAdvisor, callSecondaryDesktopAdvisor, callSecondaryHeartbeatAdvisor, formatPreflightExecutionObjective, formatPreflightHint, formatAdvisoryHint, formatBrowserAdvisorHint, formatDesktopAdvisorHint, getOrchestrationConfig, clampOrchestrationConfig, clampPreemptConfig, checkOrchestrationEligibility, shouldRunPreflight, type TaskSnapshot as HeartbeatTaskSnapshot, } from '../orchestration/multi-agent'; import { createTask, loadTask, saveTask, updateTaskStatus, appendJournal, updateResumeContext, listTasks, deleteTask, mutatePlan, buildTaskSnapshot, type TaskRecord, type TaskStatus, } from './tasks/task-store'; import { BackgroundTaskRunner } from './tasks/background-task-runner'; import { SubagentManager } from './tasks/subagent-manager'; import { FileOpProgressWatchdog, FileOpType, classifyFileOpType, resolveFileOpSettings, isFileMutationTool, isFileCreateTool, isFileEditTool, extractFileToolTarget, estimateFileToolChange, canPrimaryApplyFileTool, shouldVerifyFileTurn, isSmallSuggestedFix, buildFailureSignature, buildPatchSignature, loadFileOpCheckpoint, saveFileOpCheckpoint, clearFileOpCheckpoint, } from '../orchestration/file-op-v2'; import { OllamaProcessManager } from './infra/ollama-process-manager'; import { transcribeAudio } from '../tools/stt.js'; import { synthesizeSpeech, isTTSAvailable } from '../tools/tts.js'; import { raceWithWatchdog, PreemptState } from './tasks/preempt-watchdog'; import { detectGpu, logGpuStatus } from './infra/gpu-detector'; import { internalAgentTaskRouter } from './routes/internal-agent-task'; // ─── Server log ring buffer ──────────────────────────────────────────────────── const LOG_RING_SIZE = 300; interface LogEntry { ts: string; level: 'log' | 'warn' | 'error'; msg: string } const logRing: LogEntry[] = []; let logTotalCount = 0; // monotonic counter — never wraps with the ring function pushLog(level: LogEntry['level'], args: unknown[]) { const msg = args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '); const now = new Date(); const ts = now.toTimeString().slice(0, 8); // HH:MM:SS logRing.push({ ts, level, msg }); logTotalCount++; if (logRing.length > LOG_RING_SIZE) logRing.shift(); } const _cLog = console.log.bind(console); const _cWarn = console.warn.bind(console); const _cErr = console.error.bind(console); console.log = (...a) => { _cLog(...a); pushLog('log', a); }; console.warn = (...a) => { _cWarn(...a); pushLog('warn', a); }; console.error = (...a) => { _cErr(...a); pushLog('error', a); }; // ────────────────────────────────────────────────────────────────────────────── const config = getConfig().getConfig(); const CONFIG_DIR_PATH = getConfig().getConfigDir(); const PORT = config.gateway.port || (process.env.GATEWAY_PORT ? parseInt(process.env.GATEWAY_PORT, 10) : 18789); const HOST = (() => { const h = process.env.GATEWAY_HOST || config.gateway.host; if (Array.isArray(h)) return '0.0.0.0'; return h || (process.env.DOCKER_CONTAINER ? '0.0.0.0' : '127.0.0.1'); })(); function repairLegacyTaskChannelMetadata(): void { try { const tasks = listTasks(); let repaired = 0; for (const task of tasks) { if (!String(task.sessionId || '').startsWith('telegram_')) continue; if (task.channel === 'telegram' && task.telegramChatId) continue; task.channel = 'telegram'; if (!task.telegramChatId) { const parsed = Number(String(task.sessionId || '').replace(/^telegram_/, '')); if (Number.isFinite(parsed) && parsed > 0) task.telegramChatId = parsed; } saveTask(task); repaired++; } if (repaired > 0) { console.log(`[TaskStore] Repaired ${repaired} legacy task(s) with telegram metadata.`); } } catch (err: any) { console.warn('[TaskStore] Legacy task metadata repair skipped:', err?.message || err); } } { cleanupSessions().then((cleaned) => { if (cleaned.deleted > 0) { console.log(`[session] Cleaned up ${cleaned.deleted} stale automated session file(s).`); } }); cleanupEmptySessions(); repairLegacyTaskChannelMetadata(); } // Search config is now read dynamically from config on each request // so changing keys via settings takes effect immediately without restart // Active tasks (keyed by session) const activeTasks: Map = new Map(); type OrchestrationEvent = { ts: number; trigger: 'preflight' | 'explicit' | 'auto'; mode: 'planner' | 'rescue'; reason: string; route?: string; }; type OrchestrationSessionStats = { assistCount: number; events: OrchestrationEvent[]; }; const orchestrationSessionStats: Map = new Map(); const preemptSessionCounts: Map = new Map(); function getOrchestrationSessionStats(sessionId: string): OrchestrationSessionStats { const id = String(sessionId || 'default'); const existing = orchestrationSessionStats.get(id); if (existing) return existing; const created: OrchestrationSessionStats = { assistCount: 0, events: [] }; orchestrationSessionStats.set(id, created); return created; } function recordOrchestrationEvent( sessionId: string, event: Omit, cfg: ReturnType, ): OrchestrationSessionStats { const stats = getOrchestrationSessionStats(sessionId); stats.assistCount += 1; stats.events.push({ ts: Date.now(), ...event }); const limit = cfg?.limits?.telemetry_history_limit ?? 100; if (stats.events.length > limit) { stats.events = stats.events.slice(-limit); } return stats; } function getPreemptSessionCount(sessionId: string): number { return preemptSessionCounts.get(String(sessionId || 'default')) || 0; } function incrementPreemptSessionCount(sessionId: string): number { const id = String(sessionId || 'default'); const next = getPreemptSessionCount(id) + 1; preemptSessionCounts.set(id, next); return next; } // Converts ![alt](/api/files/...) markdown in user messages to ContentPart[] // with image_url, so Ollama vision models receive the actual image data. function resolveImageContent(content: any, workspacePath: string): any { if (!content || typeof content !== 'string') return content; // Find image markdown: ![alt](/api/files/path) const imageRe = /!\[([^\]]*)\]\(\/api\/files\/([^)]+)\)/g; const images: { alt: string; path: string }[] = []; let match: RegExpExecArray | null; while ((match = imageRe.exec(content)) !== null) { images.push({ alt: match[1], path: match[2] }); } if (images.length === 0) return content; // Build ContentPart[] with text + image_url parts const parts: any[] = []; // Add text content without the image markdown, but inject workspace-relative paths // so the AI knows the exact file path for image_edit / python_eval. const textOnly = content.replace(imageRe, '').trim(); const pathHints = images.map(img => `[IMAGE PATH for image_edit/python_eval — use EXACTLY this string: ${decodeURIComponent(img.path)}]`).join('\n'); const textWithPaths = [textOnly, pathHints].filter(Boolean).join('\n'); if (textWithPaths) parts.push({ type: 'text', text: textWithPaths }); for (const img of images) { const filePath = path.resolve(workspacePath, img.path); if (fs.existsSync(filePath)) { const stat = fs.statSync(filePath); let buf: Buffer | null = null; if (stat.size <= 200_000) { buf = fs.readFileSync(filePath); } else { // Resize large images to 512px thumbnail so the model can still see them // without blowing up the token budget. try { const { execSync } = require('child_process'); const script = `from PIL import Image; import sys; img=Image.open(sys.argv[1]); img.thumbnail((512,512)); img.save(sys.stdout.buffer, 'JPEG', quality=75)`; buf = execSync(`python3 -c "${script}" "${filePath}"`, { maxBuffer: 5 * 1024 * 1024 }); } catch { buf = null; } } if (buf && buf.length > 0) { const b64 = buf.toString('base64'); parts.push({ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${b64}` }, }); } else { parts.push({ type: 'text', text: `[Image: ${img.path}]` }); } } } return parts.length > 0 ? parts : content; } // Inject workspace-relative paths into tool result text so the AI knows the file location. // For image_edit / python_eval results: also embed a base64 thumbnail so the AI can SEE the result. function resolveToolImageContent(content: string, workspacePath: string, embedVision: boolean): any { if (!content || typeof content !== 'string') return content; const imageRe = /!\[([^\]]*)\]\(\/api\/files\/([^)]+)\)/g; if (!imageRe.test(content)) return content; imageRe.lastIndex = 0; if (!embedVision) { // Path-hint only: inject [편집 결과: path] before each image markdown. This model // never receives the actual pixels, but tool stdout (e.g. weather_map_screenshot) // often embeds instructions like "describe only what you actually see in the image // above" written for vision-capable models — left unanswered, that instruction // reads as a direct order to a model with nothing to look at, and it fabricates // plausible-sounding visual detail (colors, icons, coordinates) to comply. Append // an explicit override so the model knows it cannot see anything and must not guess. const withHints = content.replace(/!\[([^\]]*)\]\(\/api\/files\/([^)]+)\)/g, (m, alt, p) => { try { p = decodeURIComponent(p); } catch {} return `[편집 결과 경로: ${p}]\n${m}`; }); return `${withHints}\n\n[시스템 안내: 이 모델은 이미지를 볼 수 없습니다. 위 파일 경로는 참고용일 뿐이며, 이미지의 실제 시각적 내용(색상·좌표·아이콘·범례 등)은 알 수 없습니다. 절대 추측해서 설명하지 말고, 이미지 내용을 봐야 답할 수 있는 질문이면 볼 수 없다고 솔직히 말하세요.]`; } // Full vision: convert to ContentPart[] with base64 thumbnails const parts: any[] = []; const textWithHints = content.replace(/!\[([^\]]*)\]\(\/api\/files\/([^)]+)\)/g, (m, alt, p) => { try { p = decodeURIComponent(p); } catch {} return `[편집 결과 경로: ${p}]`; }); if (textWithHints.trim()) parts.push({ type: 'text', text: textWithHints }); let match: RegExpExecArray | null; imageRe.lastIndex = 0; while ((match = imageRe.exec(content)) !== null) { const imgPath = (() => { try { return decodeURIComponent(match[2]); } catch { return match[2]; } })(); const filePath = path.resolve(workspacePath, imgPath); if (!fs.existsSync(filePath)) continue; try { const stat = fs.statSync(filePath); let buf: Buffer | null = null; if (stat.size <= 300_000) { buf = fs.readFileSync(filePath); } else { const { execSync } = require('child_process'); const script = `from PIL import Image; import sys; img=Image.open(sys.argv[1]); img.thumbnail((600,600)); img.save(sys.stdout.buffer,'JPEG',quality=80)`; buf = execSync(`python3 -c "${script}" "${filePath}"`, { maxBuffer: 5 * 1024 * 1024 }); } if (buf && buf.length > 0) { const ext = path.extname(filePath).toLowerCase(); const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg'; parts.push({ type: 'image_url', image_url: { url: `data:${mime};base64,${buf.toString('base64')}` } }); } } catch {} } return parts.length > 0 ? parts : content; } // Path confinement check — immune to case/trailing-slash/"../" traversal function isPathInsideDir(base: string, target: string): boolean { const resolvedBase = path.resolve(base); const resolvedTarget = path.resolve(target); if (resolvedBase === resolvedTarget) return true; const rel = path.relative(resolvedBase, resolvedTarget); return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); } const IMAGE_TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json', '.csv': 'text/csv', '.html': 'text/html', '.md': 'text/plain', }; // Code-editor sessions whose files live in the browser's local folder (File System // Access API), NOT the server workspace. For these, the CLIENT is the source of // truth: it intercepts tool_call events and applies edits to the local folder. The // server must NOT execute file tools against its own workspace (it would either // fail with "not found" for files it never received, or clutter the workspace with // duplicate copies). Instead it returns a synthetic success so the model proceeds. // Keyed by sessionId; set per-request from req.body.codeAiLocal. const codeAiLocalSessions: Set = new Set(); // Skills system const configuredSkillsDir = (config as any).skills?.directory || path.join(CONFIG_DIR_PATH, 'skills'); const fallbackSkillsDir = path.join(CONFIG_DIR_PATH, 'skills'); function samePath(a: string, b: string): boolean { return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase(); } function syncMissingSkills(sourceDir: string, targetDir: string): void { if (!fs.existsSync(sourceDir)) return; fs.mkdirSync(targetDir, { recursive: true }); const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const sourceSkillDir = path.join(sourceDir, entry.name); const sourceSkillMd = path.join(sourceSkillDir, 'SKILL.md'); if (!fs.existsSync(sourceSkillMd)) continue; const targetSkillDir = path.join(targetDir, entry.name); if (fs.existsSync(path.join(targetSkillDir, 'SKILL.md'))) continue; fs.cpSync(sourceSkillDir, targetSkillDir, { recursive: true }); } } function ensureMultiAgentSkill(targetDir: string): void { const targetSkillDir = path.join(targetDir, 'multi-agent-orchestrator'); const targetSkillMd = path.join(targetSkillDir, 'SKILL.md'); if (fs.existsSync(targetSkillMd)) return; const templateCandidates = [ path.join(fallbackSkillsDir, 'multi-agent-orchestrator', 'SKILL.md'), path.join(process.cwd(), 'src', 'orchestration', 'SKILL.md'), ]; const templatePath = templateCandidates.find(p => fs.existsSync(p)); if (!templatePath) return; fs.mkdirSync(targetSkillDir, { recursive: true }); fs.writeFileSync(targetSkillMd, fs.readFileSync(templatePath, 'utf-8'), 'utf-8'); } function migrateSkillsStateIfMissing(targetDir: string): void { const targetStatePath = path.join(path.dirname(targetDir), 'skills_state.json'); if (fs.existsSync(targetStatePath)) return; const sourceStatePath = path.join(path.dirname(fallbackSkillsDir), 'skills_state.json'); if (!fs.existsSync(sourceStatePath)) return; fs.mkdirSync(path.dirname(targetStatePath), { recursive: true }); fs.copyFileSync(sourceStatePath, targetStatePath); } function resolveSkillsDir(configuredDir: string): string { const fallbackDir = fallbackSkillsDir; const targetDir = configuredDir || fallbackDir; try { fs.mkdirSync(targetDir, { recursive: true }); if (!samePath(targetDir, fallbackDir)) { syncMissingSkills(fallbackDir, targetDir); migrateSkillsStateIfMissing(targetDir); } ensureMultiAgentSkill(targetDir); return targetDir; } catch (err: any) { console.warn(`[Skills] Failed to prepare configured skills directory "${targetDir}": ${err.message}`); fs.mkdirSync(fallbackDir, { recursive: true }); ensureMultiAgentSkill(fallbackDir); return fallbackDir; } } const skillsDir = resolveSkillsDir(configuredSkillsDir); const skillsManager = new SkillsManager(skillsDir, ''); console.log(`[Skills] Directory: ${skillsDir}`); function isOrchestrationSkillEnabled(): boolean { return skillsManager.get('multi-agent-orchestrator')?.enabled === true; } function isSkillEnabledForUser(skillId: string, userDir: string | null): boolean { if (userDir) { const userState = skillsManager.getUserState(userDir); if (userState !== null && skillId in userState) return userState[skillId]; } return skillsManager.get(skillId)?.enabled === true; } function recoverSkillsIfEmpty(): void { // Refresh from disk first (handles files added while server is running). skillsManager.scanSkills(); if (skillsManager.getAll().length > 0) return; if (samePath(skillsDir, fallbackSkillsDir)) return; try { syncMissingSkills(fallbackSkillsDir, skillsDir); migrateSkillsStateIfMissing(skillsDir); ensureMultiAgentSkill(skillsDir); skillsManager.scanSkills(); } catch (err: any) { console.warn(`[Skills] Recovery failed: ${err.message}`); } } // Ensure skills are available for prompt injection from the first turn. recoverSkillsIfEmpty(); function setOrchestrationEnabled(enabled: boolean): void { const raw = getConfig().getConfig() as any; const current = raw.orchestration || {}; // Use the single-source-of-truth clamp utility from multi-agent.ts so bounds // can never silently diverge from getOrchestrationConfig() or getOrchestrationConfigForApi(). const clamped = clampOrchestrationConfig(current); const preempt = clampPreemptConfig(current.preempt || {}); const secBase: Record = { provider: String(current.secondary?.provider || '').trim(), model: String(current.secondary?.model || '').trim(), }; if (current.secondary?.vision !== undefined) secBase.vision = current.secondary.vision; const merged = { enabled, secondary: secBase, ...clamped, preempt, }; getConfig().updateConfig({ orchestration: merged } as any); } // Keep config flag aligned with persisted skill state on startup. (() => { const orchestratorSkill = skillsManager.get('multi-agent-orchestrator'); if (!orchestratorSkill) return; const configEnabled = (getConfig().getConfig() as any).orchestration?.enabled === true; if (configEnabled !== orchestratorSkill.enabled) { setOrchestrationEnabled(orchestratorSkill.enabled); } })(); // Prevents cron scheduler from firing while user chat is in-flight. // Critical for 4B models — can't handle parallel inference. let isModelBusy = false; let isModelBusySince = 0; const IS_MODEL_BUSY_STALE_MS = 10 * 60 * 1000; // 10 minutes let lastMainSessionId = 'default'; function checkIsModelBusy(): boolean { if (isModelBusy && isModelBusySince > 0 && Date.now() - isModelBusySince > IS_MODEL_BUSY_STALE_MS) { console.warn('[Server] isModelBusy has been true for over 10 minutes, force-clearing'); isModelBusy = false; isModelBusySince = 0; } return isModelBusy; } // wss is assigned after server creation below; broadcastWS is only ever called // after startup (by cron ticks), so the late assignment is safe. let wss: WebSocketServer | undefined; // Boot greeting stored here if no WS clients are connected at boot time. // Delivered to the first client that connects within 5 minutes of boot. let _pendingBootGreeting: { text: string; sessionId: string; expiresAt: number } | null = null; function broadcastWS(data: object): void { if (!wss) return; const msg = JSON.stringify(data); wss.clients.forEach((client: any) => { if (client.readyState === 1) { // OPEN try { client.send(msg); } catch {} } }); } type TelegramChannelConfig = { enabled: boolean; botToken: string; allowedUserIds: number[]; streamMode: 'full' | 'partial'; /** Map Telegram numeric chat/user id (as string) → SmallClaw username, * so messages from a known Telegram user run under that user's * workspace/persona instead of the global one. */ userMap: Record; voiceEnabled?: boolean; }; type DiscordChannelConfig = { enabled: boolean; botToken: string; applicationId: string; guildId: string; channelId: string; webhookUrl: string; }; type WhatsAppChannelConfig = { enabled: boolean; accessToken: string; phoneNumberId: string; businessAccountId: string; verifyToken: string; webhookSecret: string; testRecipient: string; }; type ChannelsConfig = { telegram: TelegramChannelConfig; discord: DiscordChannelConfig; whatsapp: WhatsAppChannelConfig; kakao: { appKey: string; refreshToken: string; userMap: Record; channelEnabled: boolean; publicBaseUrl: string }; }; // HIGH-01 fix: resolve vault references when normalizing channel configs. // Tokens stored as "vault:" are decrypted here at point-of-use, // so they never have to be plaintext in config.json. function resolveToken(raw: string | undefined): string { if (!raw) return ''; return getConfig().resolveSecret(raw) || ''; } function normalizeTelegramConfig(raw: any): TelegramChannelConfig { const userMap: Record = {}; if (raw?.userMap && typeof raw.userMap === 'object') { for (const [k, v] of Object.entries(raw.userMap)) { const id = String(k).trim(); const name = String(v || '').trim(); if (id && name && /^[a-zA-Z0-9_-]{2,32}$/.test(name)) { userMap[id] = name; } } } // Fall back to vault direct lookup when config field is empty let botToken = resolveToken(raw?.botToken); if (!botToken) { const vaultEntry = getVault(CONFIG_DIR_PATH).get('channels.telegram.botToken', 'telegram:init'); if (vaultEntry) botToken = vaultEntry.expose() || ''; } return { enabled: raw?.enabled === true, botToken, allowedUserIds: Array.isArray(raw?.allowedUserIds) ? raw.allowedUserIds.map(Number).filter((n: number) => Number.isFinite(n) && n > 0) : [], streamMode: raw?.streamMode === 'partial' ? 'partial' : 'full', userMap, voiceEnabled: raw?.voiceEnabled === true, }; } function normalizeDiscordConfig(raw: any): DiscordChannelConfig { return { enabled: raw?.enabled === true, botToken: resolveToken(raw?.botToken), applicationId: String(raw?.applicationId || ''), guildId: String(raw?.guildId || ''), channelId: String(raw?.channelId || ''), webhookUrl: resolveToken(raw?.webhookUrl) || String(raw?.webhookUrl || ''), }; } function normalizeWhatsAppConfig(raw: any): WhatsAppChannelConfig { return { enabled: raw?.enabled === true, accessToken: resolveToken(raw?.accessToken), phoneNumberId: String(raw?.phoneNumberId || ''), businessAccountId: String(raw?.businessAccountId || ''), verifyToken: resolveToken(raw?.verifyToken) || String(raw?.verifyToken || ''), webhookSecret: resolveToken(raw?.webhookSecret) || String(raw?.webhookSecret || ''), testRecipient: String(raw?.testRecipient || ''), }; } function resolveChannelsConfig(): ChannelsConfig { const cfg = getConfig().getConfig() as any; const channels = cfg.channels || {}; const vault = getVault(CONFIG_DIR_PATH); return { telegram: normalizeTelegramConfig(channels.telegram || {}), discord: normalizeDiscordConfig(channels.discord || {}), whatsapp: normalizeWhatsAppConfig(channels.whatsapp || {}), kakao: { appKey: resolveToken(channels.kakao?.appKey) || vault.get('channels.kakao.appKey', 'kakao:init')?.expose() || '', refreshToken: resolveToken(channels.kakao?.refreshToken) || vault.get('channels.kakao.refreshToken', 'kakao:init')?.expose() || '', userMap: channels.kakao?.userMap || {}, channelEnabled: channels.kakao?.channelEnabled === true, publicBaseUrl: vault.get('channels.kakao.publicBaseUrl', 'kakao:init')?.expose() || '', }, }; } const cronStorePath = path.join(CONFIG_DIR_PATH, 'cron', 'jobs.json'); const cronScheduler = new CronScheduler({ storePath: cronStorePath, handleChat: (message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username) => handleChat(message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username), broadcast: broadcastWS, getIsModelBusy: () => checkIsModelBusy(), deliverTelegram: (text: string) => telegramChannel.sendToAllowed(text), getMainSessionId: () => lastMainSessionId || 'default', injectSystemEvent: (sessionId, text, job) => { addMessage(sessionId, { role: 'assistant', content: `[System Event: ${job.name}]\n${text}`, timestamp: Date.now(), }); broadcastWS({ type: 'system_event', sessionId, source: 'cron', jobId: job.id, jobName: job.name, text, }); }, spawnBackgroundTask: async (job) => { try { // Awaited properly so the cron scheduler gets a real taskId back. let taskTitle = job.name; let plan: Array<{ index: number; description: string; status: 'pending' }> = []; try { const preflight = await callSecondaryPreflight({ userMessage: job.prompt }); if (preflight?.task_plan && preflight.task_plan.length > 0) { taskTitle = preflight.task_title || job.name; plan = preflight.task_plan.map((desc: string, i: number) => ({ index: i, description: desc, status: 'pending' as const, })); console.log(`[CronScheduler] Preflight generated ${plan.length} steps for "${job.name}"`); } } catch (preflightErr: any) { console.warn(`[CronScheduler] Preflight unavailable for "${job.name}", using default plan:`, preflightErr.message); } if (plan.length === 0) { const prompt = job.prompt.toLowerCase(); const isNews = /news|summar|stories|headlines|brief|report|digest/.test(prompt); const isResearch = /research|find|look up|search|gather|collect/.test(prompt); const isEmail = /email|inbox|gmail|message/.test(prompt); if (isNews) { plan = [ { index: 0, description: 'Search for today\'s top news stories from multiple sources', status: 'pending' }, { index: 1, description: 'Fetch and read full article content from results', status: 'pending' }, { index: 2, description: 'Synthesize stories into a concise 3-5 bullet summary with sources', status: 'pending' }, { index: 3, description: 'Deliver final summary to user', status: 'pending' }, ]; } else if (isResearch) { plan = [ { index: 0, description: 'Search for relevant information on the topic', status: 'pending' }, { index: 1, description: 'Read and extract key details from top results', status: 'pending' }, { index: 2, description: 'Compile findings into a clear summary', status: 'pending' }, ]; } else if (isEmail) { plan = [ { index: 0, description: 'Check inbox for new messages', status: 'pending' }, { index: 1, description: 'Summarize important emails', status: 'pending' }, ]; } else { plan = [ { index: 0, description: `Execute: ${job.prompt.slice(0, 120)}`, status: 'pending' }, { index: 1, description: 'Review results and deliver output to user', status: 'pending' }, ]; } } const cronSessionId = `cron_${job.id}`; // Pre-create the session under the job owner so all downstream tools // resolve to the correct user workspace, not the global one. if (job.ownerUsername) { try { ensureUserWorkspace(job.ownerUsername); } catch { /* non-fatal */ } getSession(cronSessionId, job.ownerUsername); } const task = createTask({ title: taskTitle, prompt: job.prompt, sessionId: cronSessionId, channel: 'web', plan, ownerUsername: job.ownerUsername, }); appendJournal(task.id, { type: 'status_push', content: `Scheduled job "${job.name}" launched as background task (${plan.length} steps)` }); const runner = new BackgroundTaskRunner(task.id, handleChat, makeBroadcastForTask(task.id), telegramChannel); runner.start().catch((err: any) => console.error(`[CronScheduler] Task ${task.id} error:`, err.message)); broadcastWS({ type: 'cron_task_spawned', jobId: job.id, jobName: job.name, taskId: task.id }); return { taskId: task.id, sessionId: cronSessionId }; } catch (err: any) { console.error('[CronScheduler] spawnBackgroundTask failed:', err.message); return null; } }, }); const telegramChannel = new TelegramChannel( resolveChannelsConfig().telegram, { handleChat: (message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username) => handleChat(message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username), addMessage, getIsModelBusy: () => checkIsModelBusy(), broadcast: broadcastWS, resolveUsername: (telegramUserId: string) => resolveUsernameByTelegramId(telegramUserId) || undefined, defaultUsername: () => { const users = listUsers(); for (const u of users) { const roleEntry = getUserField(u, 'role'); if (roleEntry && roleEntry.expose() === 'admin') return u; } return users[0]; }, } ); // ─── Kakao 채널 초기화 ──────────────────────────────────────────────────────── const _kakaoConf = resolveChannelsConfig().kakao; const kakaoNotify = new KakaoNotify({ appKey: _kakaoConf.appKey, refreshToken: _kakaoConf.refreshToken, onTokenRefreshed: (token) => { getVault(CONFIG_DIR_PATH).set('channels.kakao.refreshToken', token, 'kakao:token'); console.log('[KakaoNotify] Refresh token updated in vault.'); }, }); const kakaoChannel = new KakaoChannel( { enabled: _kakaoConf.channelEnabled, userMap: _kakaoConf.userMap }, { handleChat: (message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username) => handleChat(message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username), addMessage, getIsModelBusy: () => checkIsModelBusy(), defaultUsername: () => { const users = listUsers(); for (const u of users) { const roleEntry = getUserField(u, 'role'); if (roleEntry && roleEntry.expose() === 'admin') return u; } return users[0]; }, }, ); // ─── Per-user Telegram bot manager ──────────────────────────────────────────── // Each user can have their own dedicated Telegram bot (bot token stored in vault). // Vault key: gateway.auth.users.{username}.channels.telegram.botToken function makeUserTelegramDeps(username: string) { return { handleChat: (message: string, sessionId: string, sendSSE: (e: string, d: any) => void, pinnedMessages?: any, abortSignal?: any, callerContext?: string, modelOverride?: string, executionMode?: any, u?: string) => handleChat(message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, u), addMessage, getIsModelBusy: () => checkIsModelBusy(), broadcast: broadcastWS, fixedUsername: username, onFirstChatId: (u: string, chatId: number) => { // Persist chatId so proactive delivery works const existing = getUserChannelId(u, 'telegram'); if (!existing || existing !== String(chatId)) { saveUserChannelId(u, 'telegram', String(chatId)); console.log(`[Telegram:${u}] Stored chat ID ${chatId}`); } }, }; } class MultiUserTelegramManager { private channels: Map = new Map(); private getUserBotToken(username: string): string { const vault = getVault(CONFIG_DIR_PATH); const entry = vault.get(`gateway.auth.users.${username}.channels.telegram.botToken`, 'telegram:userbot'); return entry ? entry.expose() || '' : ''; } startAll(): void { for (const username of listUsers()) { const token = this.getUserBotToken(username); if (token) this.startUser(username, token); } } startUser(username: string, botToken: string): void { this.stopUser(username); const ch = new TelegramChannel( { enabled: true, botToken, allowedUserIds: [], streamMode: 'full' }, makeUserTelegramDeps(username), ); this.channels.set(username, ch); ch.start(); console.log(`[Telegram] Started per-user bot for ${username}`); } stopUser(username: string): void { const ch = this.channels.get(username); if (ch) { ch.stop(); this.channels.delete(username); } } updateUser(username: string, botToken: string): void { if (botToken) { this.startUser(username, botToken); } else { this.stopUser(username); } } getStatus(username: string): { running: boolean; botUsername: string | null } { const ch = this.channels.get(username); if (!ch) return { running: false, botUsername: null }; const s = ch.getStatus(); return { running: s.polling, botUsername: s.username }; } getAllStatuses(): Record { const out: Record = {}; for (const [u, ch] of this.channels) { const s = ch.getStatus(); out[u] = { running: s.polling, botUsername: s.username }; } return out; } /** Send proactive message to a specific user via their personal bot */ async sendToUser(username: string, text: string): Promise { const ch = this.channels.get(username); if (!ch) return false; const chatIdStr = getUserChannelId(username, 'telegram'); if (!chatIdStr) return false; const chatId = Number(chatIdStr); if (!Number.isFinite(chatId) || chatId <= 0) return false; await ch.sendToChatId(chatId, text); return true; } } const userTelegramManager = new MultiUserTelegramManager(); const heartbeatRunner = new HeartbeatRunner({ // Post multi-user migration, USER.md/SOUL.md live under the per-user workspace, not the // (now-empty) global one — point the heartbeat at papa's so memory_read/memory_write during // a heartbeat tick resolve to real files instead of failing "not found" every cycle. workspacePath: getUserWorkspace('papa'), username: 'papa', configPath: path.join(CONFIG_DIR_PATH, 'heartbeat', 'config.json'), handleChat: (message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username) => handleChat(message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username), // Dedicated, isolated session — must NEVER fall back to lastMainSessionId. // That global variable reflects whichever app/user last sent a chat message // server-wide, so heartbeat's autonomous tool calls (which can touch any // registered MCP tool, including sensitive per-app databases) would otherwise // get appended into a random unrelated app's conversation history. getMainSessionId: () => 'heartbeat_internal', getIsModelBusy: () => checkIsModelBusy(), broadcast: broadcastWS, deliverTelegram: (text: string) => telegramChannel.sendToAllowed(text), }); // --- Hook: gateway:startup -> run BOOT.md ------------------------------------ function buildBootStartupSnapshot(workspacePath: string): string { const lines: string[] = []; lines.push(`workspace_path: ${workspacePath}`); try { const blocked = listTasks({ status: ['paused', 'stalled', 'needs_assistance'] }).slice(0, 12); if (blocked.length === 0) { lines.push('blocked_tasks: none'); } else { lines.push('blocked_tasks:'); for (const t of blocked) { const total = Math.max(1, Number(t.plan?.length || 0)); const step = Math.min(total, Math.max(1, Number(t.currentStepIndex || 0) + 1)); lines.push(`- [${t.id}] [${t.status}] ${t.title} (step ${step}/${total})`); } } } catch (err: any) { lines.push(`blocked_tasks: unavailable (${String(err?.message || err || 'unknown')})`); } const now = new Date(); const today = now.toISOString().slice(0, 10); const yesterdayDate = new Date(now.getTime() - (24 * 60 * 60 * 1000)); const yesterday = yesterdayDate.toISOString().slice(0, 10); const memDir = path.join(workspacePath, 'memory'); const todayMem = path.join(memDir, `${today}.md`); const yesterdayMem = path.join(memDir, `${yesterday}.md`); // Current boot time — gives LLM a fresh reference point on every restart lines.push(`boot_time: ${now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', hour12: false })}`); const _bootCfg = getConfig().getConfig() as any; const _bootProvider = _bootCfg.llm?.provider || 'ollama'; const _bootModel = _bootCfg.llm?.providers?.[_bootProvider]?.model || _bootCfg.models?.primary || 'unknown'; lines.push(`active_model: ${_bootModel}`); // Inject actual memory content — HEARTBEAT_OK lines stripped so LLM sees real conversations const memFileToRead = fs.existsSync(todayMem) ? todayMem : fs.existsSync(yesterdayMem) ? yesterdayMem : null; if (memFileToRead) { try { const rawMem = fs.readFileSync(memFileToRead, 'utf-8').trim(); const memFilename = path.basename(memFileToRead); // Filter HEARTBEAT_OK and prior BOOT STARTUP SUMMARY blocks (avoid boot feedback loop) const linesArr = rawMem.split('\n'); const keptArr: string[] = []; let skipBoot = false; for (const line of linesArr) { if (/HEARTBEAT_OK|Check for anything important and reply HEARTBEAT/i.test(line)) continue; if (/\*\*User\*\*:\s*BOOT STARTUP SUMMARY/i.test(line)) { skipBoot = true; continue; } if (skipBoot) { if (/^\[\d{1,2}:\d{2}:\d{2}\]\s+\*\*User\*\*:/.test(line)) { skipBoot = false; keptArr.push(line); } continue; } keptArr.push(line); } const filtered = keptArr.join('\n').replace(/\n{3,}/g, '\n\n').trim(); const excerpt = filtered.slice(-2500) || '(no meaningful activity logged)'; lines.push(`recent_activity (${memFilename}, heartbeats excluded):`); lines.push(excerpt); } catch { lines.push('memory_content: unreadable'); } } else { lines.push('memory_content: no memory file found for today or yesterday'); } try { const dirents = fs.readdirSync(workspacePath, { withFileTypes: true }); const topFiles = dirents.filter(d => d.isFile()).map(d => d.name); const tmpFiles = topFiles.filter((f) => /_tmp(\.|$)/i.test(f)).slice(0, 20); lines.push(`tmp_files: ${tmpFiles.length ? tmpFiles.join(', ') : 'none'}`); const todoHead: string[] = []; for (const file of topFiles.slice(0, 200)) { try { const head = fs.readFileSync(path.join(workspacePath, file), 'utf-8') .split('\n') .slice(0, 5) .join('\n'); if (/\bTODO\b/i.test(head)) { todoHead.push(file); if (todoHead.length >= 20) break; } } catch { // skip unreadable files } } lines.push(`todo_in_first_5_lines: ${todoHead.length ? todoHead.join(', ') : 'none'}`); } catch (err: any) { lines.push(`workspace_scan: unavailable (${String(err?.message || err || 'unknown')})`); } return lines.join('\n'); } hookBus.register('gateway:startup', async ({ workspacePath }) => { const bootSessionId = 'boot-startup'; setWorkspace(bootSessionId, workspacePath); clearHistory(bootSessionId); const startupSnapshot = buildBootStartupSnapshot(workspacePath); await runBootMd(workspacePath, async (message, sessionId, sendSSE) => { const bootContext = [ 'CONTEXT: Internal startup BOOT.md turn. All data has been pre-fetched and is in the snapshot below.', 'Do NOT call any tools. Read the snapshot and write a 2-3 sentence startup summary IN KOREAN. MUST include: (1) the active_model name from the snapshot, (2) recent activity if any — not a generic greeting.', '[BOOT STARTUP SNAPSHOT - pre-fetched runtime data, no tools needed]', startupSnapshot, '[/BOOT STARTUP SNAPSHOT]', ].join('\n\n'); const effectiveSessionId = sessionId || bootSessionId; setWorkspace(effectiveSessionId, workspacePath); const result = await handleChat(message, effectiveSessionId, sendSSE, undefined, undefined, bootContext); if (result?.text) { const connected = wss && [...wss.clients].some((c: any) => c.readyState === 1); if (connected) { broadcastWS({ type: 'boot_greeting', text: result.text, sessionId: effectiveSessionId }); } else { // No clients yet — park the greeting for delivery on next WS connect _pendingBootGreeting = { text: result.text, sessionId: effectiveSessionId, expiresAt: Date.now() + 5 * 60_000 }; } } return { text: result.text }; }); }); // --- Hook: command:new -> snapshot session before reset ----------------------- hookBus.register('command:new', async ({ sessionId, workspacePath }) => { const history = getHistory(sessionId, 10); if (history.length === 0) return; const memDir = path.join(workspacePath, 'memory'); fs.mkdirSync(memDir, { recursive: true }); const stamp = new Date().toISOString().replace('T', '_').slice(0, 16).replace(':', '-'); const slug = String(sessionId || '').slice(0, 8) || 'default'; const outPath = path.join(memDir, `${stamp}-${slug}.md`); const lines = history.map((m) => `**${m.role}**: ${String(m.content || '').slice(0, 300)}`); fs.writeFileSync(outPath, `# Session snapshot - ${stamp}\n\n${lines.join('\n\n')}\n`, 'utf-8'); console.log(`[hooks:command:new] Saved session snapshot -> ${path.basename(outPath)}`); }); const PROMPT_FILES = new Set(['SOUL.md', 'IDENTITY.md', 'USER.md', 'SELF.md', 'TOOLS.md', 'BOOT.md', 'AGENTS.md', 'MEMORY.md']); function resolvePromptPath(workspacePath: string, filename: string): string { if (PROMPT_FILES.has(filename)) { const inPrompts = path.join(workspacePath, 'prompts', filename); if (fs.existsSync(inPrompts)) return inPrompts; } return path.join(workspacePath, filename); } const buildPersonalityContext = createPersonalityContext(resolvePromptPath); function logToDaily(workspacePath: string, role: string, content: string, sessionId?: string) { // Don't pollute daily memory with internal boot turns — they create a feedback loop // where each boot reads its own prior boot responses and paraphrases them. if (sessionId === 'boot-startup') return; if (typeof content === 'string' && content.startsWith('BOOT STARTUP SUMMARY')) return; try { const memDir = path.join(workspacePath, 'memory'); if (!fs.existsSync(memDir)) fs.mkdirSync(memDir, { recursive: true }); const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD const logPath = path.join(memDir, `${today}.md`); const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false }); const entry = `[${timestamp}] **${role}**: ${content.slice(0, 300)}\n`; fs.appendFileSync(logPath, entry); } catch {} } const buildTools = createBuildTools(isOrchestrationSkillEnabled); async function tavilySearch(query: string, apiKey: string): Promise { try { const response = await fetch('https://api.tavily.com/search', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, body: JSON.stringify({ query, max_results: 5, search_depth: 'basic' }), }); if (!response.ok) { const err = await response.text(); return `Tavily search failed (${response.status}): ${err.slice(0, 200)}`; } const data = await response.json() as any; const results = (data.results || []).slice(0, 5).map((r: any, i: number) => `[${i + 1}] ${r.title || 'No title'}\n${r.content?.slice(0, 200) || r.snippet || ''}\nURL: ${r.url || ''}` ); if (!results.length) return `No results found for "${query}".`; let output = results.join('\n\n'); const topUrl = (data.results || [])[0]?.url; if (topUrl) { console.log(`[v2] TAVILY AUTO-FETCH: ${topUrl.slice(0, 80)}`); const pageContent = await webFetch(topUrl); if (!pageContent.startsWith('Fetch failed') && !pageContent.startsWith('Fetch error') && !pageContent.startsWith('Fetch timed') && !pageContent.startsWith('Page fetched but very little')) { } } output += '\n\nOther URLs above can be read with web_fetch if needed.'; return output; } catch (err: any) { return `Tavily search error: ${err.message}`; } } async function googleSearch(query: string): Promise { const searchCfg = (getConfig().getConfig() as any).search || {}; const GOOGLE_API_KEY = (searchCfg.google_api_key || '').trim(); const GOOGLE_CX = (searchCfg.google_cx || '').trim(); if (!GOOGLE_API_KEY || !GOOGLE_CX) { return 'Google search not configured. Add google_api_key and google_cx in Settings → Search.'; } try { const encoded = encodeURIComponent(query); const url = `https://www.googleapis.com/customsearch/v1?key=${GOOGLE_API_KEY}&cx=${GOOGLE_CX}&q=${encoded}&num=5`; const response = await fetch(url); if (!response.ok) { const errText = await response.text(); console.error(`[v2] Google Search error: ${response.status} ${errText.slice(0, 200)}`); return `Search failed (${response.status}). Try again later.`; } const data = await response.json() as any; const items = data.items || []; if (items.length === 0) { return `No results found for "${query}".`; } const results = items.slice(0, 5).map((item: any, i: number) => { const title = item.title || 'No title'; const snippet = item.snippet || 'No description'; const link = item.link || ''; return `[${i + 1}] ${title}\n${snippet}\nURL: ${link}`; }); let output = results.join('\n\n'); const topUrl = items[0]?.link; if (topUrl) { console.log(`[v2] AUTO-FETCH: Fetching top result: ${topUrl.slice(0, 80)}`); const pageContent = await webFetch(topUrl); if (!pageContent.startsWith('Fetch failed') && !pageContent.startsWith('Fetch error') && !pageContent.startsWith('Fetch timed') && !pageContent.startsWith('Page fetched but very little')) { } } output += '\n\nOther URLs above can be read with web_fetch if needed.'; return output; } catch (err: any) { console.error(`[v2] Google Search error:`, err.message); return `Search error: ${err.message}`; } } async function duckDuckGoSearch(query: string): Promise { try { const encoded = encodeURIComponent(query); const url = `https://html.duckduckgo.com/html/?q=${encoded}`; const html = await webFetch(url); return html.startsWith('Content from') ? html : `No DDG results for "${query}".`; } catch (err: any) { return `DuckDuckGo search error: ${err.message}`; } } // Unified search router — picks provider based on config async function webSearch(query: string): Promise { const searchCfg = (getConfig().getConfig() as any).search || {}; const provider = searchCfg.preferred_provider || 'google'; const tavilyKey = searchCfg.tavily_api_key || ''; console.log(`[v2] webSearch via ${provider}: ${query.slice(0, 80)}`); if (provider === 'ollama' || provider === 'searxng') { try { const { executeWebSearch } = await import('../tools/web.js'); const res = await executeWebSearch({ query, max_results: 5 }); // res.success with empty stdout means the provider genuinely found 0 results — // that's a valid answer, not a failure. Only exceptions should fall through to DDG. if (res.success) return res.stdout || `"${query}"에 대한 검색 결과가 없습니다.`; } catch (err: any) { console.warn(`[v2] webSearch ${provider} failed, falling back to DDG:`, err.message); } return duckDuckGoSearch(query); } if (provider === 'tavily' && tavilyKey) { return tavilySearch(query, tavilyKey); } if (provider === 'google') { return googleSearch(query); } if (provider === 'ddg' || provider === 'duckduckgo') { return duckDuckGoSearch(query); } // Fallback: try tavily if key exists, then google, then ddg if (tavilyKey) return tavilySearch(query, tavilyKey); const googleResult = await googleSearch(query); if (!googleResult.includes('not configured')) return googleResult; return duckDuckGoSearch(query); } async function webFetch(url: string): Promise { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); const response = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', }, }); clearTimeout(timeout); if (!response.ok) { const needsBrowser = response.status === 401 || response.status === 403 || response.status === 429; const hint = needsBrowser ? ` This site requires authentication or blocks automated requests. Try browser_open("${url}") instead.` : ''; return `Fetch failed (${response.status} ${response.statusText}).${hint}`; } const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('text/html') && !contentType.includes('text/plain') && !contentType.includes('application/json')) { return `Non-text content type: ${contentType}. Cannot extract text.`; } const html = await response.text(); // Strip HTML to plain text — remove scripts, styles, tags, then clean whitespace let text = html .replace(//gi, '') .replace(//gi, '') .replace(//gi, '') .replace(//gi, '') .replace(//gi, '') .replace(//gi, '') .replace(//g, '') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/\s+/g, ' ') .trim(); // Truncate to fit in context — ~3000 chars is plenty for a 4B model const maxChars = 3000; if (text.length > maxChars) { text = text.slice(0, maxChars) + '\n\n...(truncated — page had ' + text.length + ' chars total)'; } if (text.length < 50) { return `Page fetched but very little text content extracted. The page may be JavaScript-heavy (SPA). Try using browser_open instead.`; } return `Content from ${url}:\n\n${text}`; } catch (err: any) { if (err.name === 'AbortError') return 'Fetch timed out after 15s.'; return `Fetch error: ${err.message}`; } } // UTM tag identifying homeclaw on Unsplash/Pexels referral links (per their guidelines). const IMG_ATTRIBUTION_UTM = 'utm_source=homeclaw&utm_medium=referral'; /** * Per Unsplash API guidelines: GET /photos/:id/download must be hit when the * photo is actually used (displayed in our chat output). Fire-and-forget. */ function triggerUnsplashDownload(photoId: string, accessKey: string): void { fetch(`https://api.unsplash.com/photos/${encodeURIComponent(photoId)}/download`, { headers: { Authorization: `Client-ID ${accessKey}` }, signal: AbortSignal.timeout(5000), }).catch(() => {}); } async function imageSearch(query: string, count: number = 3): Promise { const cfg = getConfig().getConfig() as any; const pexelsKey = process.env.PEXELS_API_KEY || getConfig().resolveSecret(cfg.ppt?.pexels_key) || ''; const unsplashKey = process.env.UNSPLASH_ACCESS_KEY || getConfig().resolveSecret(cfg.ppt?.unsplash_key) || ''; const pixabayKey = process.env.PIXABAY_API_KEY || getConfig().resolveSecret(cfg.ppt?.pixabay_key) || ''; if (!pexelsKey && !unsplashKey && !pixabayKey) { return 'No image API keys configured (PEXELS_API_KEY / UNSPLASH_ACCESS_KEY / PIXABAY_API_KEY).'; } const need = Math.min(Math.max(count, 1), 6); type ImgResult = { url: string; description: string; attribution: string }; // Fetch Pexels, Unsplash, and Pixabay in parallel const [pexelsRes, unsplashRes, pixabayRes] = await Promise.allSettled([ pexelsKey ? fetch( `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${need}&orientation=landscape`, { headers: { Authorization: pexelsKey }, signal: AbortSignal.timeout(8000) } ) : Promise.resolve(null), unsplashKey ? fetch( `https://api.unsplash.com/search/photos?query=${encodeURIComponent(query)}&per_page=${need}&orientation=landscape`, { headers: { Authorization: `Client-ID ${unsplashKey}` }, signal: AbortSignal.timeout(8000) } ) : Promise.resolve(null), pixabayKey ? fetch( `https://pixabay.com/api/?key=${encodeURIComponent(pixabayKey)}&q=${encodeURIComponent(query)}&per_page=${Math.max(need, 3)}&image_type=photo&orientation=horizontal&safesearch=true`, { signal: AbortSignal.timeout(8000) } ) : Promise.resolve(null), ]); const results: ImgResult[] = []; if (pexelsRes.status === 'fulfilled' && pexelsRes.value?.ok) { try { const d = await pexelsRes.value.json() as any; for (const p of (d.photos || []).slice(0, need)) { const url = p.src?.large2x || p.src?.large || p.src?.original; if (!url) continue; const name = p.photographer || 'Pexels contributor'; const profile = p.photographer_url; const author = profile ? `[${name}](${profile})` : name; const attribution = `📷 Photo by ${author} on [Pexels](https://www.pexels.com/?${IMG_ATTRIBUTION_UTM})`; results.push({ url, description: p.alt || query, attribution }); } } catch {} } let remaining = need - results.length; if (remaining > 0 && pixabayRes.status === 'fulfilled' && pixabayRes.value?.ok) { try { const d = await pixabayRes.value.json() as any; for (const p of (d.hits || []).slice(0, remaining)) { const url = p.largeImageURL || p.webformatURL; if (!url) continue; const name = p.user || 'Pixabay contributor'; const page = p.pageURL; const author = page ? `[${name}](${page})` : name; const attribution = `📷 Image by ${author} on [Pixabay](https://pixabay.com/)`; results.push({ url, description: p.tags || query, attribution }); } } catch {} } remaining = need - results.length; if (remaining > 0 && unsplashRes.status === 'fulfilled' && unsplashRes.value?.ok) { try { const d = await unsplashRes.value.json() as any; for (const p of (d.results || []).slice(0, remaining)) { const url = p.urls?.regular || p.urls?.full; if (!url) continue; const name = p.user?.name || p.user?.username || 'Unsplash contributor'; const username = p.user?.username; const author = username ? `[${name}](https://unsplash.com/@${username}?${IMG_ATTRIBUTION_UTM})` : name; const attribution = `📷 Photo by ${author} on [Unsplash](https://unsplash.com/?${IMG_ATTRIBUTION_UTM})`; results.push({ url, description: p.alt_description || p.description || query, attribution }); // Fire Unsplash download trigger — we are about to display this photo to the user. if (p.id && unsplashKey) triggerUnsplashDownload(p.id, unsplashKey); } } catch {} } if (!results.length) return `No images found for "${query}".`; return results .map(r => `![${r.description}](${r.url})\n*${r.attribution}*`) .join('\n\n'); } function normalizeToolArgs(rawArgs: any): any { if (rawArgs == null) return {}; if (typeof rawArgs === 'string') { const trimmed = rawArgs.trim(); if (!trimmed) return {}; try { const parsed = JSON.parse(trimmed); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { // Truncated JSON recovery: close open brackets/braces and retry try { const repaired = repairJson(trimmed); const parsed = JSON.parse(repaired); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } } } if (typeof rawArgs === 'object') return rawArgs; return {}; } /** Repair malformed JSON: close truncated brackets, strip trailing garbage after the last valid closing bracket. */ function repairJson(input: string): string { let s = input.trim(); // 1. Close open strings let inStr = false, escaped = false; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (escaped) { escaped = false; continue; } if (ch === '\\' && inStr) { escaped = true; continue; } if (ch === '"' && !escaped) { inStr = !inStr; } } if (inStr) s += '"'; // 2. Count unmatched brackets (outside strings) let curly = 0, square = 0; inStr = false; escaped = false; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (escaped) { escaped = false; continue; } if (ch === '\\' && inStr) { escaped = true; continue; } if (ch === '"' && !escaped) { inStr = !inStr; continue; } if (inStr) continue; if (ch === '{') curly++; else if (ch === '}') curly--; else if (ch === '[') square++; else if (ch === ']') square--; } // 3. Close open brackets while (square > 0) { s += ']'; square--; } while (curly > 0) { s += '}'; curly--; } // 4. Try parsing as-is try { JSON.parse(s); return s; } catch {} // 5. Trailing garbage: find the last '}' or ']' that yields valid JSON for (let end = s.length; end > 1; end--) { const candidate = s.slice(0, end).trimEnd(); if (candidate.endsWith('}') || candidate.endsWith(']')) { try { JSON.parse(candidate); return candidate; } catch {} } } return s; } // handleTaskControlAction needs handleChat (via launchBackgroundTaskRunner), and // handleChat needs executeTool — but executeTool must exist before handleChat can // be created. Lazy-bind: executeTool gets this hoisted wrapper function as its dep; // the real implementation is plugged into _handleTaskControlActionImpl once handleChat // exists below. Safe because the wrapper is only ever invoked at request time, well // after module init has finished assigning the real implementation. let _handleTaskControlActionImpl: (sessionId: string, args: any) => Promise; function handleTaskControlAction(sessionId: string, args: any): Promise { return _handleTaskControlActionImpl(sessionId, args); } const executeTool = createExecuteTool({ isPathInsideDir, cronScheduler, resolvePromptPath, webSearch, webFetch, imageSearch, IMAGE_TYPES, codeAiLocalSessions, handleTaskControlAction, broadcastWS, }); function logToolCall(workspacePath: string, toolName: string, args: any, result: string, error: boolean) { try { const logPath = path.join(workspacePath, 'tool_audit.log'); const ts = new Date().toISOString(); fs.appendFileSync(logPath, `[${ts}] ${error ? 'FAIL' : 'OK'} ${toolName}(${JSON.stringify(args).slice(0, 200)}) => ${result.slice(0, 200)}\n`); } catch {} } function separateThinkingFromContent(text: string): { reply: string; thinking: string } { if (!text) return { reply: '', thinking: '' }; let cleaned = text .replace(/[\s\S]*?<\/think>/gi, '') .replace(/[\s\S]*/gi, '') .replace(/<\/think>/gi, '') .trim(); if (!cleaned) return { reply: '', thinking: text }; // Fast-path: if the entire output looks like pure reasoning (starts with common // reasoning starters and is very long), treat the whole thing as thinking if (cleaned.length > 500 && /^(Okay|Ok,|Let me|First|Hmm|Wait|The user|I need|I should|So,)/i.test(cleaned)) { // Try to find the last sentence that looks like a real reply const sentences = cleaned.split(/(?<=[.!?])\s+/); let lastUseful: string | undefined; for (let i = sentences.length - 1; i >= 0; i--) { const s = sentences[i]; if (s.length > 10 && s.length < 200 && !/\b(the user|I need to|I should|let me|wait,|hmm|the rules|the tools|the instructions)\b/i.test(s)) { lastUseful = s; break; } } if (lastUseful) { return { reply: lastUseful.trim(), thinking: cleaned }; } return { reply: '', thinking: cleaned }; } const paragraphs = cleaned.split(/\n{2,}/).map(p => p.trim()).filter(Boolean); const reasoningRE = /\b(the user|the tools|the instructions|I need to|I should|let me|the problem|the question|the answer|looking at|first,|second,|wait,|hmm|the response|the correct|the assistant|check the rules|according to|the file|the current|the plan)\b/i; const starterRE = /^(Okay|Ok|Alright|Let me|First|Hmm|So,? |Wait|The user|Looking|I need|I should|Now,? |Since|Given|Based on|Check)/i; let lastIdx = -1; for (let i = 0; i < paragraphs.length; i++) { if (reasoningRE.test(paragraphs[i]) || starterRE.test(paragraphs[i])) lastIdx = i; } if (lastIdx === -1) return { reply: cleaned, thinking: '' }; if (lastIdx >= paragraphs.length - 1) { const last = paragraphs[paragraphs.length - 1]; const sentences = last.split(/(?<=[.!?])\s+/); for (let i = sentences.length - 1; i >= 0; i--) { if (!reasoningRE.test(sentences[i]) && sentences[i].length < 200) { return { reply: sentences.slice(i).join(' ').trim(), thinking: [...paragraphs.slice(0, -1), sentences.slice(0, i).join(' ')].join('\n\n').trim(), }; } } return { reply: cleaned, thinking: '' }; } const reply = paragraphs.slice(lastIdx + 1).join('\n\n'); const replyChars = reply.replace(/\s/g, '').length; if (replyChars < 10 && cleaned.length > reply.length) { return { reply: cleaned, thinking: '' }; } return { thinking: paragraphs.slice(0, lastIdx + 1).join('\n\n'), reply, }; } function normalizeForDedup(text: string): string { const raw = String(text || '').toLowerCase().trim(); if (!raw) return ''; // For CJK/Unicode text: keep alphanumeric + any non-ASCII letters (includes Korean, Chinese, Japanese, etc.) // For ASCII text: keep only a-z0-9 to avoid punctuation variations being treated as different const hasNonAscii = /[^\x00-\x7F]/.test(raw); if (hasNonAscii) { return raw.replace(/[^\p{L}\p{N}]+/gu, ''); } return raw.replace(/[^a-z0-9]+/g, ''); } function sanitizeFinalReply( text: string, opts: { preflightReason?: string } = {}, ): string { const raw = String(text || '').replace(/\r\n/g, '\n').trim(); if (!raw) return ''; const metaPatterns: RegExp[] = [ /^\s*No tools (are|were) needed for (this|the) greeting\.?\s*$/i, /^\s*Greeting only,\s*no tools needed\.?\s*$/i, /^\s*Advisor route selected .*$/i, /^\s*\[ADVISOR[^\]]*\]\s*$/i, /^\s*\[\/ADVISOR[^\]]*\]\s*$/i, /^\s*Understood\.?\s*I will execute this objective.*$/i, ]; const reasonNorm = normalizeForDedup(opts.preflightReason || ''); const parts = raw .split(/\n{2,}/) .map(p => p.trim()) .filter(Boolean) .filter((p) => { if (metaPatterns.some(re => re.test(p))) return false; if (reasonNorm && normalizeForDedup(p) === reasonNorm) return false; return true; }); const deduped: string[] = []; let prevNorm = ''; for (const p of parts) { const norm = normalizeForDedup(p); if (!norm) continue; if (norm === prevNorm) continue; deduped.push(p); prevNorm = norm; } let result = deduped.join('\n\n').trim(); // Dedup image markdown: if image_edit/image_read already injected the image via SSE, // the AI may still write a duplicate ![alt](uploads/result.png). The [IMAGE COMPLETE] // reminder tells the AI not to, but if it does anyway, strip only duplicates that match // a tool result path — never strip dental-dict, external URL, or other legitimate images. // (Previously: a blanket regex stripped ALL image markdown, breaking dental-dict images.) // Units are not math. Models wrap plain units in LaTeX ("$\mu\text{g/m}^3$" for μg/m³), which // only renders on index.html — the other eight chat pages have no KaTeX and show the raw // markup, and TTS reads it aloud as backslashes either way. Convert spans that contain nothing // but a unit; anything with real math (operators, fractions, variables) is left untouched. result = result.replace(/\$([^$\n]{1,40})\$/g, (whole, inner: string) => { const plain = inner .replace(/\\mu\b/g, 'μ') .replace(/\\text\{([^}]*)\}/g, '$1') .replace(/\\mathrm\{([^}]*)\}/g, '$1') .replace(/\^3\b/g, '³') .replace(/\^2\b/g, '²') .replace(/\\,|\\;|\\ /g, ' ') .replace(/[{}]/g, '') .trim(); // Only accept the rewrite if nothing LaTeX-ish survived and it reads as a bare unit. if (/[\\^_=+]/.test(plain)) return whole; return /^[μnmkKMGT]?[a-zA-Z°%]+([/·][a-zA-Z0-9²³]+)*[²³]?$/.test(plain) ? plain : whole; }); // Strip ALL AI-generated PPTX links — the tool result already has the correct link. // 1. Markdown links to .pptx via /api/files/ (encoded or plain Korean in URL) result = result.replace(/\[[^\]]*\]\([^)]*\/api\/files\/[^)]*\.pptx[^)]*\)/gi, ''); // 2. Bare /api/files/...pptx URLs result = result.replace(/\/api\/files\/[^\s)'"]*\.pptx[^\s)'""]*/gi, ''); // 3. /api/pptx/preview links result = result.replace(/\[[^\]]*\]\(\/api\/pptx\/preview[^\)]*\)/gi, ''); // 4. Absolute localhost URLs for .pptx — strip host prefix result = result.replace(/\(https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/api\/files\/[^\)]+\.pptx[^\)]*)\)/gi, '($1)'); return result.trim(); } function stripExplicitThinkTags(text: string): { cleaned: string; thinking: string } { const raw = String(text || ''); if (!raw) return { cleaned: '', thinking: '' }; const blocks: string[] = []; let cleaned = raw.replace(/([\s\S]*?)<\/think>/gi, (_m, inner) => { const t = String(inner || '').trim(); if (t) blocks.push(t); return ''; }); // Handle dangling open blocks from partial model outputs. const openIdx = cleaned.toLowerCase().lastIndexOf(''); if (openIdx !== -1) { const trailing = cleaned .slice(openIdx + ''.length) .replace(/<\/think>/gi, '') .trim(); if (trailing) blocks.push(trailing); cleaned = cleaned.slice(0, openIdx); } cleaned = cleaned.replace(/<\/think>/gi, '').trim(); return { cleaned, thinking: blocks.join('\n\n').trim() }; } function extractLikelyUrl(message: string): string | null { const raw = String(message || ''); const directUrlMatch = raw.match(/\bhttps?:\/\/[^\s)]+/i); const domainMatch = raw.match(/\b(?:www\.)?[a-z0-9][a-z0-9.-]+\.[a-z]{2,}(?:\/[^\s)]*)?/i); const url = (directUrlMatch?.[0] || domainMatch?.[0] || '').trim(); if (!url) return null; const normalized = /^https?:\/\//i.test(url) ? url : `https://${url}`; return normalized.replace(/["'<>]/g, ''); } // Per-model behavioral overrides. Some models (e.g. mistral-large-3) have observed quirks — // skipping tool calls and confidently fabricating instead — that the generic system prompt // doesn't fully correct. Rather than hardcoding model names in prompt text, config.json's // models.profiles[modelName].extraSystemPrompt lets us attach model-specific reminders that // only apply when that exact model is the one actually serving the turn, discovered/edited // without a code deploy. function getModelProfileExtraPrompt(modelName: string): string { const m = String(modelName || '').trim(); if (!m) return ''; try { const raw = getConfig().getConfig() as any; const extra = String(raw?.models?.profiles?.[m]?.extraSystemPrompt || '').trim(); return extra ? `\nMODEL-SPECIFIC NOTE: ${extra}` : ''; } catch { return ''; } } // Same profile mechanism as above, for models whose tool-skipping tendency the system prompt // alone doesn't fix (verified 2026-07-25: mistral-large-3 still hallucinated a full news answer // — see extraSystemPrompt above — despite the prompt-level warning). tool_choice: 'required' // makes round 0 mechanically unable to answer in plain text when a live-data/factual question // is detected, instead of just asking nicely. // // 2026-07-29: INVERTED from per-model opt-in to global default with per-model opt-out. The // original opt-in reasoning ("forcing an unwanted tool call on a gate false-positive is worse // than a no-op for models that don't have this problem") assumed a stable model choice, but // the user switches the active model frequently — so in practice only the two profiled models // (mistral-large-3, kimi-k2.6) ever had this defense, and every other model ran with the // round-0 gap wide open. Log audit of 2026-07-29 confirmed the cost: gemini-3.5-flash-lite, // which has no profile entry at all, fabricated GPU tok/s figures and an entirely nonexistent // product name across a long hardware chat. A spurious search on a gate false-positive is a // few wasted seconds; an unguarded fabrication is a wrong answer the user acts on. Set // models.profiles[].forceToolChoiceOnLiveData = false to opt a specific model out. function getModelProfileForceToolChoice(modelName: string): boolean { const m = String(modelName || '').trim(); if (!m) return false; try { const raw = getConfig().getConfig() as any; return raw?.models?.profiles?.[m]?.forceToolChoiceOnLiveData !== false; } catch { return true; } } // Same profile mechanism, for opting a specific model OUT of dynamic num_ctx sizing // (ollama-adapter.ts's _resolveCtx(), which rounds to the next power of 2 based on prompt size). // Dynamic sizing exists to avoid reserving VRAM a smaller GPU doesn't have — but 2026-08-10, // measured directly against 지서버's live Ollama instance: gemma4:26b at its native max context // (262144) loads in 18.3GB, leaving 13.7GB free on its 32GB (2×5060 Ti). It has no VRAM pressure // to economize for. The cost of dynamic sizing turned out to be real and measured too — every // time the resolved num_ctx crosses a power-of-2 boundary between turns, Ollama does a full // model reload before answering (verified: same num_ctx back-to-back = 1.3s load_duration, // num_ctx changed = 18.8s), which shows up mid-conversation as unexplained multi-second stalls. // Set models.profiles[].fixedNumCtx to a number to always request exactly that num_ctx // for that model — loads once, never reloads for a size change again. function getModelProfileFixedNumCtx(modelName: string): number | undefined { const m = String(modelName || '').trim(); if (!m) return undefined; try { const raw = getConfig().getConfig() as any; const v = Number(raw?.models?.profiles?.[m]?.fixedNumCtx); return Number.isFinite(v) && v > 0 ? v : undefined; } catch { return undefined; } } function resolveWorkspaceFilePath(workspacePath: string, filename: string): string { if (!filename) return ''; if (path.isAbsolute(filename)) return filename; return path.join(workspacePath, filename); } function collectFileSnapshots( workspacePath: string, files: string[], maxCharsPerFile: number = 3600, ): Array<{ filename: string; exists: boolean; content_preview: string; line_count: number; char_count: number; }> { const out: Array<{ filename: string; exists: boolean; content_preview: string; line_count: number; char_count: number; }> = []; const seen = new Set(); for (const raw of files || []) { const fn = String(raw || '').trim(); if (!fn) continue; if (seen.has(fn.toLowerCase())) continue; seen.add(fn.toLowerCase()); const fp = resolveWorkspaceFilePath(workspacePath, fn); if (!fp) continue; if (!fs.existsSync(fp)) { out.push({ filename: fn, exists: false, content_preview: '', line_count: 0, char_count: 0, }); continue; } try { const content = fs.readFileSync(fp, 'utf-8'); const lines = content.split('\n'); const numbered = lines.map((line, i) => `${i + 1}: ${line}`).join('\n'); out.push({ filename: fn, exists: true, content_preview: numbered.slice(0, maxCharsPerFile), line_count: lines.length, char_count: content.length, }); } catch { out.push({ filename: fn, exists: true, content_preview: '', line_count: 0, char_count: 0, }); } if (out.length >= 10) break; } return out; } // When multi-agent orchestrator is active, the LLM should NEVER see raw browser // snapshot data — only the secondary AI (via getBrowserAdvisorPacket) gets that. // The LLM receives a short acknowledgment so it knows the tool ran, then waits // for the advisor's directive telling it what to do next. function buildBrowserAck(toolName: string, result: ToolResult): string { if (result.error) { // On error the LLM does need to know what failed so it can decide next step return `${toolName} failed: ${result.result.slice(0, 200)}`; } switch (toolName) { case 'browser_open': return 'Browser opened. Secondary AI is analyzing the page — wait for directive.'; case 'browser_snapshot': return 'Snapshot captured. Secondary AI is analyzing — wait for directive.'; case 'browser_press_key': return 'Key pressed. Page updating — secondary AI will instruct next step.'; case 'browser_wait': return 'Wait complete.'; case 'browser_click': return 'Clicked. Secondary AI is analyzing the result — wait for directive.'; case 'browser_fill': return 'Input filled.'; default: return `${toolName} complete.`; } } function buildDesktopAck(toolName: string, result: ToolResult): string { if (result.error) { return `${toolName} failed: ${result.result.slice(0, 200)}`; } switch (toolName) { case 'desktop_screenshot': return 'Desktop screenshot captured. Secondary AI is analyzing window context and will direct next step.'; case 'desktop_find_window': return 'Window search complete.'; case 'desktop_focus_window': return 'Window focused.'; case 'desktop_click': return 'Desktop click executed.'; case 'desktop_drag': return 'Desktop drag executed.'; case 'desktop_wait': return 'Desktop wait complete.'; case 'desktop_type': return 'Text input sent to focused window.'; case 'desktop_press_key': return 'Key press sent.'; case 'desktop_get_clipboard': return 'Clipboard read complete.'; case 'desktop_set_clipboard': return 'Clipboard updated.'; default: return `${toolName} complete.`; } } function goalIsInteractiveAction(goal: string): boolean { // Returns true when the user's goal is to DO something on the page (post, click, fill, submit) // rather than READ or RESEARCH. Used to skip feed-collection mode on social feeds. return /\b(post|tweet|retweet|reply|send|publish|submit|compose|write.*tweet|make.*post|create.*post|type.*message|fill|click|navigate to|go to|open composer|draft)\b/i.test(String(goal || '')); } function isBrowserHeavyResearchPage(input: { url?: string; pageType?: string; snapshotElements?: number; feedCount?: number; goal?: string; }): boolean { const url = String(input.url || '').toLowerCase(); const pageType = String(input.pageType || '').toLowerCase(); const elements = Number(input.snapshotElements || 0); const feedCount = Number(input.feedCount || 0); if (pageType === 'x_feed' || pageType === 'search_results' || pageType === 'article') return true; if (feedCount >= 6) return true; if (elements >= 10) return true; return /(x\.com|twitter\.com|reddit\.com|google\.[a-z.]+\/search|bing\.com\/search|duckduckgo\.com|news|search\?q=)/.test(url); } type SnapshotDiagnostics = { scanned: number; included: number; hidden: number; unlabeledNonInput: number; unnamedInputIncluded: number; }; type BrowserSnapshotQuality = { low: boolean; reasons: string[]; elementCount: number; inputCandidates: number; dominantRoles: string[]; diagnostics: SnapshotDiagnostics | null; }; function goalLikelyNeedsTextInput(goal: string): boolean { const text = String(goal || ''); return /\b(type|fill|enter|input|message|say|send|search|write|reply|post|submit|login|log ?in|chat|comment)\b/i.test(text); } function parseSnapshotDiagnostics(snapshot: string): SnapshotDiagnostics | null { const m = String(snapshot || '').match( /Snapshot diagnostics:\s*scanned=([0-9]*)\s+included=([0-9]*)\s+hidden=([0-9]*)\s+unlabeled_non_input=([0-9]*)\s+unnamed_input_included=([0-9]*)/i, ); if (!m) return null; const toInt = (x: string) => { const n = Number(x); return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0; }; return { scanned: toInt(m[1]), included: toInt(m[2]), hidden: toInt(m[3]), unlabeledNonInput: toInt(m[4]), unnamedInputIncluded: toInt(m[5]), }; } function evaluateBrowserSnapshotQuality(snapshot: string, snapshotElements: number, goal: string): BrowserSnapshotQuality { const elementCount = Number.isFinite(Number(snapshotElements)) ? Math.max(0, Math.floor(Number(snapshotElements))) : 0; const roleCounts = new Map(); let inputCandidates = 0; for (const raw of String(snapshot || '').split(/\r?\n/)) { const line = raw.trim(); const m = line.match(/^\[@\d+\]\s+([a-z0-9_-]+)/i); if (!m) continue; const role = String(m[1] || '').toLowerCase(); roleCounts.set(role, (roleCounts.get(role) || 0) + 1); if ( /\[INPUT\]/i.test(line) || role === 'textbox' || role === 'searchbox' || role === 'combobox' || role === 'textarea' ) { inputCandidates++; } } const dominantRoles = Array.from(roleCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 4) .map(([role, count]) => `${role}:${count}`); const diagnostics = parseSnapshotDiagnostics(snapshot); const reasons: string[] = []; const needsInput = goalLikelyNeedsTextInput(goal); if (elementCount < 10) reasons.push(`low_elements=${elementCount}`); if (needsInput && inputCandidates === 0) reasons.push('expected_input_but_none_detected'); if (needsInput && inputCandidates === 0 && dominantRoles.length) { reasons.push(`top_roles=${dominantRoles.join(',')}`); } if (diagnostics && diagnostics.hidden > diagnostics.included) { reasons.push('many_hidden_candidates'); } if (diagnostics && diagnostics.unlabeledNonInput > diagnostics.included) { reasons.push('many_unlabeled_non_input_candidates'); } return { low: reasons.length > 0, reasons, elementCount, inputCandidates, dominantRoles, diagnostics, }; } const { handleChat, handleCodeChat } = createHandleChat({ logToolCall, logToDaily, collectFileSnapshots, recordOrchestrationEvent, telegramChannel, makeBroadcastForTask, resolveToolImageContent, isSkillEnabledForUser, executeTool, buildTools, buildDesktopAck, buildBrowserAck, skillsManager, sanitizeFinalReply, resolveImageContent, normalizeToolArgs, goalLikelyNeedsTextInput, getModelProfileForceToolChoice, getModelProfileFixedNumCtx, stripExplicitThinkTags, separateThinkingFromContent, isOrchestrationSkillEnabled, isBrowserHeavyResearchPage, incrementPreemptSessionCount, getPreemptSessionCount, getOrchestrationSessionStats, getModelProfileExtraPrompt, extractLikelyUrl, evaluateBrowserSnapshotQuality, buildPersonalityContext, broadcastWS, codeAiLocalSessions, CONFIG_DIR_PATH, }); _handleTaskControlActionImpl = createHandleTaskControl({ telegramChannel, makeBroadcastForTask, handleChat, }).handleTaskControlAction; function createSSESender(res: express.Response): (event: string, data: any) => void { return (type: string, data: any) => { try { res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`); } catch {} }; } async function tryHandleBlockedTaskFollowup(sessionId: string, rawMessage: string): Promise { if (String(sessionId || '').startsWith('task_')) return null; const message = String(rawMessage || '').trim(); if (!message) return null; const explicitTaskId = parseTaskIdFromText(message); if (explicitTaskId) { const rerunRequested = isRerunIntent(message); const resumeRequested = isResumeIntent(message); const cancelRequested = isCancelIntent(message); if (!rerunRequested && !resumeRequested && !cancelRequested) return null; const action = rerunRequested ? 'rerun' : cancelRequested ? 'pause' : 'resume'; const ctl = await handleTaskControlAction(sessionId, { action, task_id: explicitTaskId, note: message }); return ctl.success ? (ctl.message || null) : null; } // Handles user messages like "proceed", "I logged in", "go ahead", "fixed it", etc. // where the task ID is implicit from context. const blockedTask = findBlockedTaskForSession(sessionId); if (!blockedTask) return null; const cancelRequested = isCancelIntent(message); const rerunRequested = isRerunIntent(message); // Broad resume detection: standard resume verbs OR short affirmations that only // make sense as "yes, continue" replies when a blocked task already exists. const resumeRequestedBroad = isResumeIntent(message) || /^\s*(proceed|go ahead|ok|okay|continue|yes|yep|sure|do it|keep going|try again|move on|sounds good|ready|done|fixed|logged in|i logged in|it('s| is) fixed|all good)\.?\s*$/i.test(message) || /\b(logged in|fixed it|done now|all set|ready now|proceed|go ahead)\.?$/i.test(message); if (!resumeRequestedBroad && !cancelRequested && !rerunRequested) return null; // Safety: if the message is long and contains strong new-task language, let it // fall through to the AI rather than hijacking it as a resume. const hasNewTaskLanguage = message.length > 80 && /\b(open|go to|navigate|search for|create a|make a|write a|post a|send a|find me|check the)\b/i.test(message); if (hasNewTaskLanguage) return null; const action = rerunRequested ? 'rerun' : cancelRequested ? 'pause' : 'resume'; const ctl = await handleTaskControlAction(sessionId, { action, task_id: blockedTask.id, note: message, }); if (!ctl.success) return null; const verb = action === 'resume' ? 'Resuming' : action === 'rerun' ? 'Rerunning' : 'Cancelling'; const taskLabel = `"${blockedTask.title}"`; return `${verb} task ${taskLabel}. ${ctl.message || ''}`.trim(); } interface SessionInfo { username: string; role: 'admin' | 'user'; createdAt: number; } const activeSessions = new Map(); const SESSIONS_FILE = path.join(CONFIG_DIR_PATH, 'active-sessions.json'); const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days function pruneExpiredSessions(): void { const now = Date.now(); for (const [token, info] of activeSessions) { if (now - (info.createdAt || 0) >= SESSION_MAX_AGE_MS) activeSessions.delete(token); } } function saveActiveSessions(): void { pruneExpiredSessions(); try { const obj: Record = {}; for (const [k, v] of activeSessions) obj[k] = v; const tmp = SESSIONS_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(obj), 'utf-8'); fs.renameSync(tmp, SESSIONS_FILE); } catch { /* non-fatal */ } } function loadActiveSessions(): void { try { if (!fs.existsSync(SESSIONS_FILE)) return; const raw = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf-8')); const now = Date.now(); for (const [token, info] of Object.entries(raw) as [string, SessionInfo][]) { if (now - (info.createdAt || 0) < SESSION_MAX_AGE_MS) { activeSessions.set(token, info); } } if (activeSessions.size > 0) console.log(`[Auth] Restored ${activeSessions.size} active session(s)`); } catch { /* non-fatal */ } } loadActiveSessions(); function hashPassword(password: string, salt?: string): { hash: string; salt: string } { const s = salt || crypto.randomBytes(16).toString('hex'); const hash = crypto.scryptSync(password, s, 64).toString('hex'); return { hash, salt: s }; } function verifyPassword(password: string, storedHash: string, salt: string): boolean { const { hash } = hashPassword(password, salt); return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(storedHash, 'hex')); } function getAuthConfig() { const cfg = getConfig().getConfig(); return cfg.gateway?.auth ?? { enabled: true, token: undefined, multiUser: false }; } function getAuthState(): { hasUsers: boolean; hasLegacyPassword: boolean; multiUser: boolean } { const vault = getVault(CONFIG_DIR_PATH); const cfg = getAuthConfig(); return { hasUsers: vault.has('gateway.auth.user_list'), hasLegacyPassword: vault.has('gateway.auth.password_hash'), multiUser: cfg.multiUser === true, }; } function listUsers(): string[] { const vault = getVault(CONFIG_DIR_PATH); const entry = vault.get('gateway.auth.user_list', 'auth:listUsers'); if (!entry) return []; try { return JSON.parse(entry.expose()); } catch { return []; } } function getUserField(username: string, field: string): SecretValue | null { const vault = getVault(CONFIG_DIR_PATH); return vault.get(`gateway.auth.users.${username}.${field}`, 'auth:getUserField'); } function saveUserField(username: string, field: string, value: string, caller: string): void { const vault = getVault(CONFIG_DIR_PATH); vault.set(`gateway.auth.users.${username}.${field}`, value, caller); } function saveUserList(users: string[]): void { const vault = getVault(CONFIG_DIR_PATH); vault.set('gateway.auth.user_list', JSON.stringify(users), 'auth:saveUserList'); } // ─── Per-user channel registration (generalized, replaces hard-coded userMap) ─ function _channelKey(username: string, channel: string): string { return `gateway.auth.users.${username}.channels.${channel}`; } function getUserChannelId(username: string, channel: string): string | null { const vault = getVault(CONFIG_DIR_PATH); // 1. New canonical key const val = vault.get(_channelKey(username, channel), `channel:get:${channel}`); if (val) { const exposed = val.expose(); if (exposed) return exposed; } // 2. Legacy key for telegram (backward compat) if (channel === 'telegram') { const legacy = vault.get(`gateway.auth.users.${username}.telegram_user_id`, 'channel:get:legacy'); if (legacy) { const exposed = legacy.expose(); if (exposed) return exposed; } } return null; } function saveUserChannelId(username: string, channel: string, channelId: string): void { const vault = getVault(CONFIG_DIR_PATH); vault.set(_channelKey(username, channel), channelId, `channel:bind:${channel}:${username}`); } function deleteUserChannelId(username: string, channel: string): void { const vault = getVault(CONFIG_DIR_PATH); vault.delete(_channelKey(username, channel), `channel:unbind:${channel}:${username}`); // Also delete legacy key for telegram if (channel === 'telegram') { vault.delete(`gateway.auth.users.${username}.telegram_user_id`, `channel:unbind:legacy:${username}`); } } function resolveUsernameByChannelId(channel: string, channelId: string): string | null { const vault = getVault(CONFIG_DIR_PATH); const listRaw = vault.get('gateway.auth.user_list', `channel:resolve:${channel}`); if (!listRaw) return null; let users: string[] = []; try { users = JSON.parse(listRaw.expose() || '[]'); } catch { return null; } for (const username of users) { const val = vault.get(_channelKey(username, channel), `channel:resolve:${channel}`); if (val && val.expose() === channelId) return username; // Legacy check for telegram if (channel === 'telegram') { const legacy = vault.get(`gateway.auth.users.${username}.telegram_user_id`, `channel:resolve:legacy`); if (legacy && legacy.expose() === channelId) return username; } } return null; } function getAllChannelMappings(channel: string): Array<{ username: string; channelId: string }> { const vault = getVault(CONFIG_DIR_PATH); const listRaw = vault.get('gateway.auth.user_list', `channel:list:${channel}`); if (!listRaw) return []; let users: string[] = []; try { users = JSON.parse(listRaw.expose() || '[]'); } catch { return []; } const out: Array<{ username: string; channelId: string }> = []; for (const username of users) { const val = vault.get(_channelKey(username, channel), `channel:list:${channel}`); if (val) { const cid = val.expose(); if (cid) { out.push({ username, channelId: cid }); continue; } } // Legacy check for telegram if (channel === 'telegram') { const legacy = vault.get(`gateway.auth.users.${username}.telegram_user_id`, `channel:list:legacy`); if (legacy) { const cid = legacy.expose(); if (cid) out.push({ username, channelId: cid }); } } } return out; } // ─── Legacy Telegram helpers (deprecated, kept for compat) ────────────────── function getUserTelegramId(username: string): string | null { return getUserChannelId(username, 'telegram'); } function saveUserTelegramId(username: string, telegramId: string): void { saveUserChannelId(username, 'telegram', telegramId); } function deleteUserTelegramId(username: string): void { deleteUserChannelId(username, 'telegram'); } function resolveUsernameByTelegramId(telegramId: string): string | null { return resolveUsernameByChannelId('telegram', telegramId); } function getAllTelegramMappings(): Array<{ username: string; telegramId: string }> { return getAllChannelMappings('telegram').map(m => ({ username: m.username, telegramId: m.channelId })); } function extractAuthToken(req: express.Request | http.IncomingMessage): string | null { const h = (req.headers['authorization'] || req.headers['Authorization']) as string | undefined; if (h && /^Bearer\s+/i.test(h)) return h.replace(/^Bearer\s+/i, '').trim() || null; const cookies = parseCookies(req as any); return cookies[AUTH_COOKIE] || null; } function getSessionUserFromUpgradeReq(req: http.IncomingMessage): SessionInfo | null { // Browser WebSocket() can't set an Authorization header — accept a ?token= // query param too (same fallback the main /ws connection handler uses), // falling back to the auth cookie for same-origin connections. let token: string | null = null; try { const u = new URL(req.url || '/', `http://${req.headers.host || 'x'}`); token = u.searchParams.get('token'); } catch {} if (!token) { const cookies = parseCookies(req as any); token = cookies[AUTH_COOKIE] || null; } if (!token) return null; return activeSessions.get(token) ?? null; } function getSessionUser(req: express.Request): SessionInfo | null { const token = extractAuthToken(req); if (!token) return null; return activeSessions.get(token) ?? null; } function setAuthMultiUser(value: boolean): void { const cfg = getConfig(); const config = cfg.getConfig(); (config.gateway.auth as any).multiUser = value; cfg.saveConfig().catch((err) => console.error('[auth] Failed to save multiUser config:', err)); } function parseCookies(req: express.Request): Record { const header = req.headers.cookie || ''; const out: Record = {}; for (const part of header.split(';')) { const [k, ...v] = part.trim().split('='); if (k) out[k] = v.join('='); } return out; } const AUTH_COOKIE = 'smallclaw_session'; const app = express(); app.set('trust proxy', 1); app.use(cors()); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ limit: '50mb', extended: true })); const webUiPath = path.join(__dirname, '..', '..', 'web-ui'); app.get('/api/auth/status', (_req, res) => { const auth = getAuthConfig(); if (!auth.enabled) { return res.json({ enabled: false, hasPassword: false, authenticated: true }); } const state = getAuthState(); const session = getSessionUser(_req); res.json({ enabled: true, hasPassword: state.hasLegacyPassword || state.hasUsers, hasUsers: state.hasUsers, hasLegacyPassword: state.hasLegacyPassword, multiUser: state.multiUser, authenticated: !!session, username: session?.username ?? null, role: session?.role ?? null, brandName: getUserBrandName(session?.username), }); }); app.post('/api/auth/setup', (req, res) => { const auth = getAuthConfig(); if (!auth.enabled) return res.status(400).json({ error: 'Auth is disabled' }); const state = getAuthState(); if (state.hasUsers) return res.status(409).json({ error: 'Users already exist. Use /api/auth/login.' }); // Legacy migration: existing password-only install, creating first user account if (state.hasLegacyPassword && !req.body.username) { // Accept password-only for backward compat, auto-assign "admin" username const { password } = req.body; if (!password) return res.status(400).json({ error: 'Password required' }); const vault = getVault(CONFIG_DIR_PATH); const hashEntry = vault.get('gateway.auth.password_hash', 'auth:setup-legacy'); const saltEntry = vault.get('gateway.auth.password_salt', 'auth:setup-legacy'); if (!hashEntry || !saltEntry) return res.status(500).json({ error: 'Migration failed' }); if (!verifyPassword(password, hashEntry.expose(), saltEntry.expose())) { return res.status(401).json({ error: 'Invalid password' }); } const username = 'admin'; saveUserField(username, 'password_hash', hashEntry.expose(), 'auth:setup-migrate'); saveUserField(username, 'password_salt', saltEntry.expose(), 'auth:setup-migrate'); saveUserField(username, 'role', 'admin', 'auth:setup-migrate'); saveUserField(username, 'created_at', new Date().toISOString(), 'auth:setup-migrate'); saveUserList([username]); vault.delete('gateway.auth.password_hash', 'auth:setup-migrate'); vault.delete('gateway.auth.password_salt', 'auth:setup-migrate'); setAuthMultiUser(true); const sessionToken = crypto.randomBytes(32).toString('hex'); activeSessions.set(sessionToken, { username, role: 'admin', createdAt: Date.now() }); saveActiveSessions(); res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`); return res.json({ success: true, username, role: 'admin', token: sessionToken }); } // Legacy migration with username provided if (state.hasLegacyPassword && req.body.username) { const { username, password } = req.body; if (!username || typeof username !== 'string' || !/^[a-zA-Z0-9_-]{2,32}$/.test(username)) { return res.status(400).json({ error: 'Username must be 2-32 chars: letters, numbers, dash, underscore' }); } if (!password || typeof password !== 'string' || password.length < 4) { return res.status(400).json({ error: 'Password must be at least 4 characters' }); } const vault = getVault(CONFIG_DIR_PATH); const hashEntry = vault.get('gateway.auth.password_hash', 'auth:setup-legacy'); const saltEntry = vault.get('gateway.auth.password_salt', 'auth:setup-legacy'); if (!hashEntry || !saltEntry) return res.status(500).json({ error: 'Migration failed' }); if (!verifyPassword(password, hashEntry.expose(), saltEntry.expose())) { return res.status(401).json({ error: 'Invalid password' }); } saveUserField(username, 'password_hash', hashEntry.expose(), 'auth:setup-migrate'); saveUserField(username, 'password_salt', saltEntry.expose(), 'auth:setup-migrate'); saveUserField(username, 'role', 'admin', 'auth:setup-migrate'); saveUserField(username, 'created_at', new Date().toISOString(), 'auth:setup-migrate'); saveUserList([username]); vault.delete('gateway.auth.password_hash', 'auth:setup-migrate'); vault.delete('gateway.auth.password_salt', 'auth:setup-migrate'); setAuthMultiUser(true); const sessionToken = crypto.randomBytes(32).toString('hex'); activeSessions.set(sessionToken, { username, role: 'admin', createdAt: Date.now() }); saveActiveSessions(); res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`); return res.json({ success: true, username, role: 'admin', token: sessionToken }); } // Fresh install: first user setup const { username, password } = req.body; if (!username || typeof username !== 'string' || !/^[a-zA-Z0-9_-]{2,32}$/.test(username)) { return res.status(400).json({ error: 'Username must be 2-32 chars: letters, numbers, dash, underscore' }); } if (!password || typeof password !== 'string' || password.length < 4) { return res.status(400).json({ error: 'Password must be at least 4 characters' }); } const { hash, salt } = hashPassword(password); saveUserField(username, 'password_hash', hash, 'auth:setup'); saveUserField(username, 'password_salt', salt, 'auth:setup'); saveUserField(username, 'role', 'admin', 'auth:setup'); saveUserField(username, 'created_at', new Date().toISOString(), 'auth:setup'); saveUserList([username]); setAuthMultiUser(true); const sessionToken = crypto.randomBytes(32).toString('hex'); activeSessions.set(sessionToken, { username, role: 'admin', createdAt: Date.now() }); saveActiveSessions(); res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`); res.json({ success: true, username, role: 'admin', token: sessionToken }); }); app.post('/api/auth/login', (req, res) => { const auth = getAuthConfig(); if (!auth.enabled) return res.status(400).json({ error: 'Auth is disabled' }); const state = getAuthState(); // Legacy single-password mode (old install, not yet migrated) if (state.hasLegacyPassword && !state.hasUsers) { const { password } = req.body; if (!password) return res.status(400).json({ error: 'Password required' }); const vault = getVault(CONFIG_DIR_PATH); const hashEntry = vault.get('gateway.auth.password_hash', 'auth:login-legacy'); const saltEntry = vault.get('gateway.auth.password_salt', 'auth:login-legacy'); if (!hashEntry || !saltEntry) return res.status(401).json({ error: 'No password configured' }); if (!verifyPassword(password, hashEntry.expose(), saltEntry.expose())) { return res.status(401).json({ error: 'Invalid password' }); } const sessionToken = crypto.randomBytes(32).toString('hex'); activeSessions.set(sessionToken, { username: 'legacy', role: 'admin', createdAt: Date.now() }); saveActiveSessions(); res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`); return res.json({ success: true, username: 'legacy', role: 'admin', needsMigration: true, token: sessionToken }); } // Multi-user mode const { username, password } = req.body; if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); const hashEntry = getUserField(username, 'password_hash'); const saltEntry = getUserField(username, 'password_salt'); if (!hashEntry || !saltEntry) return res.status(401).json({ error: 'Invalid credentials' }); const roleEntry = getUserField(username, 'role'); const role: 'admin' | 'user' = (roleEntry?.expose() === 'admin' ? 'admin' : 'user'); if (!verifyPassword(password, hashEntry.expose(), saltEntry.expose())) { return res.status(401).json({ error: 'Invalid credentials' }); } const sessionToken = crypto.randomBytes(32).toString('hex'); activeSessions.set(sessionToken, { username, role, createdAt: Date.now() }); saveActiveSessions(); skillsManager.initUserSkillsState(getUserWorkspace(username)); res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`); res.json({ success: true, username, role, token: sessionToken }); }); app.post('/api/auth/logout', (req, res) => { const token = extractAuthToken(req); if (token) { activeSessions.delete(token); saveActiveSessions(); } res.setHeader('Set-Cookie', `${AUTH_COOKIE}=; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=0`); res.json({ success: true }); }); app.post('/api/auth/change-password', (req, res) => { const session = getSessionUser(req); if (!session) return res.status(401).json({ error: 'Not authenticated' }); const { currentPassword, newPassword } = req.body; if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Current and new password required' }); if (newPassword.length < 4) return res.status(400).json({ error: 'New password must be at least 4 characters' }); const hashEntry = getUserField(session.username, 'password_hash'); const saltEntry = getUserField(session.username, 'password_salt'); if (!hashEntry || !saltEntry) return res.status(500).json({ error: 'User record not found' }); if (!verifyPassword(currentPassword, hashEntry.expose(), saltEntry.expose())) { return res.status(401).json({ error: 'Current password is incorrect' }); } const { hash, salt } = hashPassword(newPassword); saveUserField(session.username, 'password_hash', hash, 'auth:changePassword'); saveUserField(session.username, 'password_salt', salt, 'auth:changePassword'); res.json({ success: true }); }); app.use((req, _res, next) => { const auth = getAuthConfig(); if (!auth.enabled) return next(); // Allow auth endpoints, login page, and static assets without authentication if (req.path.startsWith('/api/auth/') || req.path === '/login.html' || req.path === '/html/login.html') return next(); // companion.py / install_autostart.py must be fetchable by a standalone script running // on the user's machine (no session cookie available there), so they're public downloads. if (req.path === '/companion.py' || req.path === '/install_autostart.py') return next(); // PPT skin thumbnails are UI decoration — allow without auth if (req.path.startsWith('/api/ppt/skins/') || req.path === '/api/ppt/skins') return next(); // 카카오 오픈빌더 웹훅 — 카카오 서버에서 오는 요청이라 인증 없음 if (req.path === '/api/kakao/webhook') return next(); // 카카오 이미지 전송용 임시 공개 링크 — 카카오 서버가 직접 fetch (토큰 자체가 인증) if (req.path.startsWith('/api/kakao/img/')) return next(); // Collabora Online이 WOPI 콜백으로 직접 호출 — 세션 쿠키가 없으므로 access_token 자체가 인증 if (req.path.startsWith('/api/detective/wopi/')) return next(); const token = extractAuthToken(req); const session = token ? activeSessions.get(token) : undefined; if (token && activeSessions.has(token)) { if (session) { (req as any).user = { username: session.username, role: session.role, workspace: getUserWorkspace(session.username) }; } return next(); } // HLS 프록시: 브라우저 hls.js가 헤더 추가 불가 → 도메인 화이트리스트로 보안 유지 if (req.path === '/api/traffic/hls-proxy') return next(); // Static assets (CSS, JS, images, fonts) are public — but token check runs first above // so that fetch() calls with Bearer token still get user context set correctly. if (/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|wasm)$/i.test(req.path)) return next(); // Wavacity / AudioMass self-hosted audio editors if (req.path.startsWith('/wavacity/') || req.path === '/wavacity') return next(); if (req.path.startsWith('/audiomass/') || req.path === '/audiomass') return next(); // Code serving: allow without auth (falls back to default workspace) if (req.path.startsWith('/code-serve/')) return next(); // Internal-only endpoints: allow from localhost without auth if (req.path === '/api/music/midi-from-path') { const ip = req.socket.remoteAddress || ''; if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return next(); } // API requests get 401, page requests redirect to login if (req.path.startsWith('/api/')) { _res.status(401).json({ error: 'Authentication required' }); } else { _res.redirect('/html/login.html'); } }); app.use('/vendor', express.static(path.join(webUiPath, 'vendor'), { setHeaders: (res, filePath) => { res.setHeader('Cache-Control', 'no-cache'); if (filePath.endsWith('.js')) res.setHeader('Content-Type', 'application/javascript'); } })); // AudioMass — pure JS audio editor, no COOP/COEP needed app.use('/audiomass', express.static(path.join(webUiPath, 'audiomass'), { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache'); } })); // Wavacity — requires COOP/COEP for SharedArrayBuffer app.use('/wavacity', express.static(path.join(webUiPath, 'wavacity'), { setHeaders: (res, filePath) => { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); res.setHeader('Cache-Control', 'no-cache'); if (filePath.endsWith('.wasm')) res.setHeader('Content-Type', 'application/wasm'); if (filePath.endsWith('.js')) res.setHeader('Content-Type', 'application/javascript'); } })); // Entry point for opening the PDF editor scoped to a case: sets the // dt_pdf_case cookie server-side, then redirects to a clean /pdf-editor/ // URL with no query string. (Stirling-PDF's root path has a bug where a // query string on the bare context-path root causes a broken redirect — // see /pdf-editor below — so the case id is never sent in that request.) // Cookie value is ":" so the same Stirling-PDF instance and // auto-save logic serve any case-backed app (detective, lawyer, ...) — app // defaults to 'detective' when omitted, for links that predate the lawyer app. app.get('/pdf-editor-open', (req: express.Request, res: express.Response) => { const session = getSessionUser(req); if (!session) { res.redirect('/html/login.html'); return; } const caseId = String(req.query.caseId || ''); const appType = String(req.query.app || 'detective'); if (/^[a-zA-Z0-9_-]+$/.test(caseId) && /^[a-z]+$/.test(appType)) { res.setHeader('Set-Cookie', `dt_pdf_case=${appType}:${caseId}; Path=/pdf-editor; HttpOnly; SameSite=Lax; Max-Age=3600`); } res.redirect('/pdf-editor/'); }); // Stirling-PDF — self-hosted PDF editor (Docker, localhost:8090), reverse-proxied // behind the gateway's own auth. Container runs with SERVER_SERVLET_CONTEXT_PATH= // /pdf-editor so its self-referencing asset/API URLs match this mount point. // // Auto-save: the dt_pdf_case cookie (set by /pdf-editor-open above) ties this // browser tab to a case. Any response that looks like a finished result (PDF // content-type + a Content-Disposition filename, as opposed to preview/asset // traffic) is teed into that case's folder in addition to being streamed to // the browser as normal, so "download" in the editor also lands in the case. app.use('/pdf-editor', (req: express.Request, res: express.Response) => { const cookies = parseCookies(req); const rawCookie = cookies['dt_pdf_case'] || ''; const colonIdx = rawCookie.indexOf(':'); // No colon = old-format cookie set before app-scoping existed — treat as detective. const appType = colonIdx >= 0 ? rawCookie.slice(0, colonIdx) : 'detective'; const caseId = colonIdx >= 0 ? rawCookie.slice(colonIdx + 1) : rawCookie; const session = getSessionUser(req); const proxyReq = http.request({ hostname: '127.0.0.1', port: 8090, path: req.originalUrl, method: req.method, headers: { ...req.headers, host: '127.0.0.1:8090' }, }, (proxyRes) => { const ct = String(proxyRes.headers['content-type'] || ''); const cd = String(proxyRes.headers['content-disposition'] || ''); const isResult = session && caseId && /^[a-zA-Z0-9_-]+$/.test(caseId) && ct.includes('application/pdf') && /filename=/i.test(cd); if (!isResult) { res.writeHead(proxyRes.statusCode || 502, proxyRes.headers); proxyRes.pipe(res); return; } const chunks: Buffer[] = []; proxyRes.on('data', (c: Buffer) => chunks.push(c)); proxyRes.on('end', () => { const body = Buffer.concat(chunks); try { const dir = caseFilesDir(session!.username, appType, caseId); fs.mkdirSync(dir, { recursive: true }); let filename = 'edited.pdf'; const fnStar = cd.match(/filename\*=UTF-8''([^;]+)/i); const fnPlain = cd.match(/filename="?([^";]+)"?/i); if (fnStar) { try { filename = decodeURIComponent(fnStar[1]); } catch {} } else if (fnPlain) filename = fnPlain[1]; const { relPath, absPath } = resolveUploadPath(dir, filename); fs.writeFileSync(absPath, body); console.log(`[pdf-editor] saved result to ${appType} case ${caseId}: ${relPath}`); } catch (err) { console.error('[pdf-editor] auto-save failed:', err); } res.writeHead(proxyRes.statusCode || 200, proxyRes.headers); res.end(body); }); }); proxyReq.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'PDF editor unavailable' }); }); req.pipe(proxyReq); }); // Same-origin proxy for claude-app.html's local-model calls. Ollama/LM Studio only allow // CORS from local origins by default, so a page served from a public domain (e.g. behind a // reverse proxy) can't fetch them directly from the browser. Routing through the server here // sidesteps CORS entirely (server-to-server has no CORS concept) — used when the local model // runs on the same host as this homeclaw server rather than on the browsing client's machine. const LOCAL_LLM_PROXY_TARGETS: Record = { ollama: 'http://127.0.0.1:11434', lmstudio: 'http://127.0.0.1:1234', }; app.all('/api/localllm/:provider/{*subPath}', async (req: express.Request, res: express.Response) => { const target = LOCAL_LLM_PROXY_TARGETS[req.params.provider as string]; if (!target) { res.status(400).json({ error: 'unknown provider' }); return; } const rawPath = (req.params as Record).subPath; const subPath = Array.isArray(rawPath) ? rawPath.join('/') : String(rawPath || ''); const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''; try { const upstream = await fetch(`${target}/${subPath}${qs}`, { method: req.method, headers: { 'Content-Type': 'application/json' }, body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body), }); const text = await upstream.text(); res.status(upstream.status); res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/json'); res.send(text); } catch (e: any) { res.status(502).json({ error: `로컬 LLM(${req.params.provider}) 연결 실패: ${e.message || e}` }); } }); // Per-user brand name override, e.g. /users//brand_name.txt // containing "체리클로" — falls back to "HomeClaw" when absent. function getUserBrandName(username: string | null | undefined): string | null { if (!username) return null; try { const p = path.join(getConfig().getConfigDir(), 'users', username, 'brand_name.txt'); if (fs.existsSync(p)) { const name = fs.readFileSync(p, 'utf8').trim(); if (name) return name.slice(0, 40); } } catch {} return null; } // Per-user logo — falls back to the default HomeClaw logo when the user has // no custom logo file. Drop a PNG at /users//logo.png // to override (e.g. an old family nickname logo). app.get('/api/branding/logo', (req, res) => { const username = (req as any).user?.username as string | undefined; const defaultLogo = path.join(webUiPath, 'homeclaw_logo.png'); res.setHeader('Cache-Control', 'no-cache'); if (username) { const custom = path.join(getConfig().getConfigDir(), 'users', username, 'logo.png'); if (fs.existsSync(custom)) return res.sendFile(custom); } res.sendFile(defaultLogo); }); app.use(express.static(webUiPath, { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache'); } })); // Serve code directory files for HTML preview (window.open) const MIME_TYPES: Record = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript', py: 'text/x-python', json: 'application/json', png: 'image/png', jpg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', ico: 'image/x-icon', webp: 'image/webp', mp3: 'audio/mpeg', mp4: 'video/mp4', wav: 'audio/wav', ogg: 'audio/ogg', pdf: 'application/pdf', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', }; app.get('/code-serve/{*filePath}', (req: express.Request, res: express.Response) => { try { const rawPath = (req.params as Record).filePath; let reqPath = (Array.isArray(rawPath) ? rawPath.join('/') : String(rawPath || '')).replace(/^\/+/, ''); const user = (req as any).user; const workspacePath = user?.workspace || path.resolve(getConfig().getConfig().workspace?.path || process.cwd()); const fullPath = path.resolve(workspacePath, reqPath); if (!fullPath.startsWith(workspacePath)) { res.status(403).send('Forbidden'); return; } if (!fs.existsSync(fullPath) || fs.statSync(fullPath).isDirectory()) { res.status(404).send('Not found'); return; } const ext = fullPath.split('.').pop()?.toLowerCase() || ''; res.setHeader('Content-Type', MIME_TYPES[ext] || 'application/octet-stream'); res.setHeader('Cache-Control', 'no-cache'); const data = fs.readFileSync(fullPath); res.send(data); } catch (err: any) { res.status(500).send(err.message); } }); app.get('/api/files/{*filePath}', (req: express.Request, res: express.Response) => { try { const rawPath = (req.params as Record).filePath; let reqPath = (Array.isArray(rawPath) ? rawPath.join('/') : String(rawPath || '')).replace(/^\/+/, ''); // Decode URL-encoded characters (Korean, spaces, special chars) try { reqPath = decodeURIComponent(reqPath); } catch {} console.log('[files] reqPath:', reqPath); if (!reqPath) { res.status(400).json({ error: 'No file path provided' }); return; } // Segment-based check — a filename that merely *contains* ".." (e.g. a // sentence ending in a period right before the extension, "....txt") is not // a traversal attempt; only an actual ".." path segment is. The isAllowed // check below (on the resolved absolute path) is the real security boundary. if (reqPath.split('/').some(seg => seg === '..')) { res.status(403).json({ error: 'Access denied' }); return; } const user = (req as any).user; const globalWorkspace = path.resolve(getConfig().getConfig().workspace?.path || process.cwd()); const workspacePath = user?.workspace || globalWorkspace; const resolved = path.normalize(path.resolve(workspacePath, reqPath)); console.log('[files] resolved:', resolved, 'workspace:', workspacePath); // Security: ensure path stays within an allowed workspace // Always allow global workspace, user workspace, and shared databases dir const sharedDatabasesDir = path.resolve(CONFIG_DIR_PATH, 'databases'); const allowedRoots = [globalWorkspace, sharedDatabasesDir]; if (user?.workspace && user.workspace !== globalWorkspace) allowedRoots.push(workspacePath); const isAllowed = allowedRoots.some(root => { const normRoot = path.normalize(root).toLowerCase(); const normResolved = resolved.toLowerCase(); return normResolved.startsWith(normRoot + path.sep) || normResolved.startsWith(normRoot + '/') || normResolved === normRoot; }); if (!isAllowed) { console.log('[files] Access denied:', resolved, 'not in', allowedRoots.map(r => path.normalize(r).toLowerCase())); res.status(403).json({ error: 'Access denied' }); return; } // In multi-user mode, fall back to global workspace if file not found in user workspace // Helper: search attachments/ subdirs recursively for a filename match function findInAttachments(wsPath: string, basename: string): string | null { const attDir = path.join(wsPath, 'attachments'); if (!fs.existsSync(attDir)) return null; for (const sub of fs.readdirSync(attDir)) { const candidate = path.join(attDir, sub, basename); if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate; } return null; } // Also try resolving directly under sharedDatabasesDir (for dental_images etc.) const dbResolved = path.normalize(path.resolve(sharedDatabasesDir, reqPath)); let filePath = resolved; if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { const basename = path.basename(reqPath); const globalResolved = path.normalize(path.resolve(globalWorkspace, reqPath)); if (fs.existsSync(dbResolved) && fs.statSync(dbResolved).isFile()) { // Fallback 1: shared databases dir (dental_images, etc.) filePath = dbResolved; } else if (fs.existsSync(globalResolved) && fs.statSync(globalResolved).isFile()) { // Fallback 2: global workspace same relative path filePath = globalResolved; } else { // Fallback 3: search attachments/ in user workspace then global workspace const attMatch = (user?.workspace ? findInAttachments(user.workspace, basename) : null) ?? findInAttachments(globalWorkspace, basename); if (attMatch) { console.log('[files] found in attachments:', attMatch); filePath = attMatch; } else { // Fallback 4: prefix match in same directory ("figure_p05" → "figure_p05_1.png") const stem = basename.replace(/\.[^.]+$/, ''); const parentDir = path.dirname(resolved); let prefixMatch: string | null = null; if (stem.length > 3 && fs.existsSync(parentDir) && fs.statSync(parentDir).isDirectory()) { const hit = fs.readdirSync(parentDir).find(f => f.startsWith(stem + '_') || f.startsWith(stem + '.') ); if (hit) prefixMatch = path.join(parentDir, hit); } // Also scan uploads/ subdirs when path is fully truncated if (!prefixMatch && stem.length > 5) { for (const ws of [user?.workspace, globalWorkspace].filter(Boolean) as string[]) { const uploadsDir = path.join(ws, 'uploads'); if (!fs.existsSync(uploadsDir)) continue; for (const sub of fs.readdirSync(uploadsDir)) { const subDir = path.join(uploadsDir, sub); if (!fs.existsSync(subDir) || !fs.statSync(subDir).isDirectory()) continue; const hit = fs.readdirSync(subDir).find(f => f.startsWith(stem + '_') || f.startsWith(stem + '.') ); if (hit) { prefixMatch = path.join(subDir, hit); break; } } if (prefixMatch) break; } } if (prefixMatch) { console.log('[files] prefix match found:', prefixMatch); filePath = prefixMatch; } else { // Fallback 5: basename in workspace root const userRootMatch = user?.workspace ? path.join(user.workspace, basename) : null; const globalRootMatch = path.join(globalWorkspace, basename); if (userRootMatch && fs.existsSync(userRootMatch) && fs.statSync(userRootMatch).isFile()) { console.log('[files] found in user workspace root:', userRootMatch); filePath = userRootMatch; } else if (fs.existsSync(globalRootMatch) && fs.statSync(globalRootMatch).isFile()) { console.log('[files] found in global workspace root:', globalRootMatch); filePath = globalRootMatch; } else { console.log('[files] not found:', resolved, 'or:', globalResolved, 'or:', dbResolved); res.status(404).json({ error: 'File not found' }); return; } } } } } console.log('[files] serving:', filePath); const ext = path.extname(filePath).toLowerCase(); const contentType = IMAGE_TYPES[ext] || 'application/octet-stream'; const filename = path.basename(filePath); // Force download for non-image files (pptx, pdf, xlsx, docx, zip, etc.) // PDF/txt/csv: inline (browser viewer); other binary files: attachment (force download) const inlineExts = ['.pdf', '.txt', '.csv', '.md']; const downloadExts = ['.pptx', '.xlsx', '.xls', '.docx', '.doc', '.zip', '.mp4', '.mp3']; if (inlineExts.includes(ext)) { const encodedFilename = encodeURIComponent(filename); res.setHeader('Content-Disposition', `inline; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`); } else if (downloadExts.includes(ext)) { const encodedFilename = encodeURIComponent(filename); res.setHeader('Content-Disposition', `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`); } res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'public, max-age=60'); // Use createReadStream instead of sendFile for cross-platform reliability (Express 5) try { const stat = fs.statSync(filePath); res.setHeader('Content-Length', stat.size); const stream = fs.createReadStream(filePath); stream.on('error', (streamErr: any) => { console.error('[files] stream error:', streamErr.message); if (!res.headersSent) res.status(500).json({ error: 'Failed to stream file' }); }); stream.pipe(res); } catch (sendErr: any) { console.error('[files] read error:', sendErr.message); if (!res.headersSent) res.status(500).json({ error: 'Failed to read file' }); } } catch (err: any) { console.error('[files] handler error:', err.message); if (!res.headersSent) res.status(500).json({ error: 'Internal server error' }); } }); registerPptxRoutes(app, getSessionUser, requireGatewayAuth, imageSearch); // ── File overwrite (PUT /api/files/:relPath) ────────────────────────────── // Used by the xlsx editor to save changes back to the workspace. app.put('/api/files/{*filePath}', (req: express.Request, res: express.Response) => { try { const relPath = (req.params as any).filePath as string; if (!relPath) { res.status(400).json({ success: false, error: 'No path' }); return; } const user = (req as any).user; const workspacePath = user?.workspace || path.resolve(getConfig().getConfig().workspace?.path || process.cwd()); const target = path.resolve(workspacePath, relPath); // Confine to workspace if (!target.startsWith(path.resolve(workspacePath))) { res.status(403).json({ success: false, error: 'Access denied' }); return; } const chunks: Buffer[] = []; req.on('data', (c: Buffer) => chunks.push(c)); req.on('end', () => { const buf = Buffer.concat(chunks); if (buf.length > 50 * 1024 * 1024) { res.status(413).json({ success: false, error: 'File too large' }); return; } fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, buf); res.json({ success: true, path: relPath, size: buf.length }); }); req.on('error', (e: any) => res.status(500).json({ success: false, error: String(e?.message || e) })); } catch (e: any) { res.status(500).json({ success: false, error: String(e?.message || e) }); } }); registerVoiceRoutes(app); registerVoiceRealtimeRoutes(app); app.get('/api/status', async (_req, res) => { const ollama = getOllamaClient(); const connected = await ollama.testConnection(); const rawCfg = getConfig().getConfig() as any; const provider: string = rawCfg.llm?.provider || 'ollama'; const providerCfg = rawCfg.llm?.providers?.[provider] || {}; const activeModel: string = providerCfg.model || rawCfg.models?.primary || 'unknown'; const orchCfg = getOrchestrationConfig(); // A wakeable provider (지서버) that is merely asleep is not the same as a broken one: the // next message fires a magic packet and gets answered. Deliberately inferred from config // rather than probed — /api/status polls every 10s, and poking the wol-gate to confirm is // exactly what sends the magic packet, so probing here would keep the box permanently awake. const sleeping = !connected && !!providerCfg.wake_url; res.json({ status: 'ok', version: 'v2-tools', ollama: connected, sleeping, provider, currentModel: activeModel, workspace: (config as any).workspace?.path || '', search: rawCfg.search?.google_api_key ? 'google' : (rawCfg.search?.tavily_api_key ? 'tavily' : (rawCfg.search?.searxng_url ? 'searxng' : 'none')), googleUsage: provider === 'google' ? getGoogleUsageToday() : null, orchestration: orchCfg ? { enabled: orchCfg.enabled, secondary: orchCfg.secondary, } : null, }); }); app.post('/api/chat', async (req, res) => { const { message, sessionId = 'default', pinnedMessages, model: reqModel, direct, history: clientHistory, codeAiLocal, useTools, boardContext, skillContext } = req.body; if (!message || typeof message !== 'string') { res.status(400).json({ error: 'Message required' }); return; } const user = (req as any).user; // Record whether this code-editor turn uses the browser's local folder. The // server then skips disk file-tool execution (client is source of truth). if (/^code_ai_/.test(String(sessionId || ''))) { if (codeAiLocal) codeAiLocalSessions.add(String(sessionId)); else codeAiLocalSessions.delete(String(sessionId)); } // Pre-initialise the session under the authenticated user's directory so that // all subsequent addMessage / getHistory calls land in the right place. // ensureUserWorkspace() bootstraps the user's workspace on first login. const _isCodeEditorSid = /^(code_ai_|proj-plan-|proj-gen-)/.test(String(sessionId || '')); if (user?.username) { try { ensureUserWorkspace(user.username); } catch { /* non-fatal */ } if (!_isCodeEditorSid) getSession(String(sessionId || 'default'), user.username); } if (!_isCodeEditorSid && user?.workspace) setWorkspace(String(sessionId || 'default'), user.workspace, user?.username); // ---------------------------------------------------------------------------- lastMainSessionId = String(sessionId || 'default'); res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); const sendSSE = createSSESender(res); const heartbeat = setInterval(() => sendSSE('heartbeat', { state: 'processing' }), 5000); isModelBusy = true; isModelBusySince = Date.now(); const abortSignal = { aborted: false }; let requestCompleted = false; res.on('close', () => { if (!requestCompleted && !abortSignal.aborted) { abortSignal.aborted = true; console.log(`[v2] Client disconnected — aborting task for session ${sessionId}`); } }); try { const isCodeEditorSessionEarly = /^(code_ai_|proj-plan-|proj-gen-)/.test(String(sessionId)); const userMsg = { role: 'user' as const, content: message, timestamp: Date.now() }; const addResult = isCodeEditorSessionEarly ? { deferredForCompaction: false, deferredForMemoryFlush: false, compactionPrompt: undefined, memoryFlushPrompt: undefined, estimatedTokens: 0, contextLimitTokens: 0 } : addMessage(sessionId, userMsg, { deferOnMemoryFlush: true, deferOnCompaction: true }, user?.username); if (addResult.deferredForCompaction && addResult.compactionPrompt) { console.log(`[v2] Context compaction triggered for session ${sessionId} (${addResult.estimatedTokens}/${addResult.contextLimitTokens} est. tokens)`); try { const internalCompactionContext = 'CONTEXT: Internal context compaction turn. Summarize prior conversation into compact retained context only.'; const compactResult = await handleChat( addResult.compactionPrompt, sessionId, () => {}, undefined, abortSignal, internalCompactionContext, undefined, 'interactive', user?.username, ); if (!abortSignal.aborted && compactResult?.text) { addMessage( sessionId, { role: 'assistant', content: compactResult.text, timestamp: Date.now() }, { disableMemoryFlushCheck: true, disableCompactionCheck: true }, user?.username, ); } } catch (compactErr: any) { console.warn('[v2] Context compaction turn failed:', compactErr?.message || compactErr); } if (abortSignal.aborted) return; addMessage(sessionId, userMsg, { disableMemoryFlushCheck: true, disableCompactionCheck: true }, user?.username); } else if (addResult.deferredForMemoryFlush && addResult.memoryFlushPrompt) { console.log(`[v2] Pre-compaction memory flush triggered for session ${sessionId} (${addResult.estimatedTokens}/${addResult.contextLimitTokens} est. tokens)`); try { const internalFlushContext = 'CONTEXT: Internal pre-compaction memory flush turn. Before continuing, save important durable user/task facts to memory now.'; const flushResult = await handleChat( addResult.memoryFlushPrompt, sessionId, () => {}, undefined, abortSignal, internalFlushContext, undefined, 'interactive', user?.username, ); if (!abortSignal.aborted && flushResult?.text) { addMessage( sessionId, { role: 'assistant', content: flushResult.text, timestamp: Date.now() }, { disableMemoryFlushCheck: true, disableCompactionCheck: true }, user?.username, ); } } catch (flushErr: any) { console.warn('[v2] Pre-compaction memory flush failed:', flushErr?.message || flushErr); } if (abortSignal.aborted) return; addMessage(sessionId, userMsg, { disableMemoryFlushCheck: true, disableCompactionCheck: true }, user?.username); } const followupHandled = await tryHandleBlockedTaskFollowup(sessionId, message); if (followupHandled) { if (!abortSignal.aborted) { addMessage(sessionId, { role: 'assistant', content: followupHandled, timestamp: Date.now() }, {}, user?.username); sendSSE('final', { text: followupHandled }); sendSSE('done', { reply: followupHandled, mode: 'chat', sections: [{ type: 'text', content: followupHandled }], }); } return; } const pins = Array.isArray(pinnedMessages) ? pinnedMessages.slice(0, 3) : []; const modelOverride = reqModel ? String(reqModel).trim() || undefined : undefined; const activeSkillNames = skillsManager.getEnabledSkills().map(s => s.id).join(','); const sessionSuffix = activeSkillNames || sessionId.slice(0, 8); const _isCodeAiLogTag = /^(code_ai_|proj-gen-|proj-plan-)/.test(String(sessionId)); const _codeAiModelShort = _isCodeAiLogTag && modelOverride ? modelOverride.replace(/:cloud$/, '').split('/').pop()!.slice(0, 16) : ''; const userTag = _isCodeAiLogTag ? `${user?.username || 'anon'}@aicoder${_codeAiModelShort ? '-' + _codeAiModelShort : ''}` : (user?.username ? `${user.username}@${sessionSuffix}` : `(anon)@${sessionSuffix}`); console.log(`\n[v2] USER [${userTag}]: ${message}`); // callerContext carries three unrelated things — the 'no_preflight' control sentinel // (handle-chat reads it back as isDirectCall), a BOOT.md marker, and the caller-supplied // skill text. It used to be a ternary, so a request sending BOTH direct:true and // skillContext silently dropped the skill — which is exactly what pptx-wizard.js does on // its outline path. Carry both (2026-09-02). // // The cap was 8,000 while the presenter skill the wizard sends is 9,861 chars, so every // call lost the last 1,861 — the [필수] source_files rule and the entire 금지 사항 block // (never hand-write PPTX python, one call only, edit_presentation for edits, no spawn_agent). // Those are the highest-value constraints in the skill and they never reached the model. const SKILL_CTX_MAX = 12000; const _skillCtx = skillContext ? String(skillContext) : ''; if (_skillCtx.length > SKILL_CTX_MAX) { console.warn(`[v2] skillContext truncated: ${_skillCtx.length} → ${SKILL_CTX_MAX} chars (tail rules lost)`); } const callerCtx = [ direct ? 'no_preflight' : '', _skillCtx.slice(0, SKILL_CTX_MAX), ].filter(Boolean).join('\n\n') || undefined; const isCodeEditorSession = /^(code_ai_|proj-plan-|proj-gen-)/.test(String(sessionId)); const histOverride = (isCodeEditorSession && Array.isArray(clientHistory)) ? clientHistory : undefined; // Code editor chat runs on its own clean tool loop — no multi-agent orchestration. const isCodeAiChat = /^code_ai_/.test(String(sessionId)); // Client sends useTools:false for plan/ask phase — honour it so the model doesn't // loop on coder_list_files when it should just generate a text response. const codeAiUseTools = useTools !== false; const result = isCodeAiChat ? await handleCodeChat(message, sessionId, sendSSE, abortSignal, modelOverride, user?.username, histOverride, codeAiUseTools, boardContext ? String(boardContext) : undefined) : await handleChat(message, sessionId, sendSSE, pins.length > 0 ? pins : undefined, abortSignal, callerCtx, modelOverride, 'interactive', user?.username, histOverride); if (!abortSignal.aborted) { result.text = await validateLinksInText(result.text); console.log(`[v2] ASSISTANT [${userTag}]: ${result.text}`); if (!isCodeEditorSession) addMessage(sessionId, { role: 'assistant', content: result.text, timestamp: Date.now() }, {}, user?.username); // Replace the char/4 estimate with the model's REAL token count for this turn // so the session usage gauge reflects actual context fill. (Code editor sessions // keep history client-side, so there's nothing to store server-side.) if (!isCodeEditorSession && result.usage && result.usage.promptTokens > 0) { setContextTokens(sessionId, result.usage.promptTokens + result.usage.completionTokens, user?.username); } sendSSE('final', { text: result.text }); sendSSE('done', { reply: result.text, mode: result.type, sections: [{ type: result.type === 'execute' ? 'tool_results' : 'text', content: result.text }], thinking: result.thinking, results: result.toolResults, usage: result.usage, }); } } catch (err: any) { if (!abortSignal.aborted) { console.error('[v2] ERROR:', err); sendSSE('error', { message: err.message || 'Unknown error' }); } } finally { requestCompleted = true; clearInterval(heartbeat); isModelBusy = false; // release busy guard — cron scheduler may now run isModelBusySince = 0; res.end(); } }); // Lightweight one-round endpoint for client-driven code editor loop. // Calls Ollama once, streams tokens as SSE, returns tool_calls without executing them. app.post('/api/code/round', async (req, res) => { const { messages, tools, model: reqModel, sessionId = 'code_rnd' } = req.body; if (!Array.isArray(messages) || !messages.length) { res.status(400).json({ error: 'messages required' }); return; } const modelOverride = typeof reqModel === 'string' ? reqModel : undefined; const ollama = getOllamaClient(); res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); const sendSSE = createSSESender(res); const heartbeat = setInterval(() => sendSSE('heartbeat', { state: 'processing' }), 5000); isModelBusy = true; isModelBusySince = Date.now(); const abortSignal = { aborted: false }; res.on('close', () => { abortSignal.aborted = true; }); try { let streamedText = ''; const onToken = (t: string) => { streamedText += t; try { sendSSE('token', { text: t }); } catch {} }; const result = await ollama.chatWithThinkingStream(messages, 'executor', onToken, { tools: tools || [], temperature: 0.3, num_predict: 32768, think: true, model: modelOverride, }); const rawToolCalls: any[] = result.message?.tool_calls || []; const normalizedToolCalls = rawToolCalls.map((tc: any, i: number) => ({ id: tc.id || `call_${(tc.function?.name || 'tool').replace(/\W/g, '_')}_${i}_${Date.now()}`, name: tc.function?.name || '', args: normalizeToolArgs(tc.function?.arguments), })); if (result.usage) sendSSE('usage', result.usage); sendSSE('done', { reply: result.message?.content || streamedText, toolCalls: normalizedToolCalls, thinking: result.thinking, usage: result.usage, }); } catch (err: any) { if (!abortSignal.aborted) { console.error('[code/round]', err?.message || err); sendSSE('error', { message: err.message || 'Unknown error' }); } } finally { clearInterval(heartbeat); isModelBusy = false; isModelBusySince = 0; res.end(); } }); app.get('/api/open-path', async (req, res) => { const fp = req.query.path as string; if (!fp) { res.status(400).json({ error: 'Path required' }); return; } try { const { exec } = await import('child_process'); const cmd = process.platform === 'win32' ? `start "" "${fp}"` : process.platform === 'darwin' ? `open "${fp}"` : `xdg-open "${fp}"`; exec(cmd, (err) => { err ? res.status(500).json({ error: err.message }) : res.json({ success: true }); }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); app.post('/api/clear-history', async (req, res) => { const sid = req.body.sessionId || 'default'; const user = (req as any).user; // Ensure session is scoped to the authenticated user before we access it. if (user?.username) getSession(sid, user.username); const ws = getWorkspace(sid, user?.username) || (getConfig().getConfig() as any).workspace?.path || ''; if (ws) { await hookBus.fire({ type: 'command:reset', sessionId: sid, workspacePath: ws, timestamp: Date.now(), }); await hookBus.fire({ type: 'command:new', sessionId: sid, workspacePath: ws, timestamp: Date.now(), }); } clearHistory(sid, user?.username); res.json({ success: true }); }); app.get('/api/task-status', (req, res) => { const sessionId = (req.query.sessionId as string) || 'default'; const task = activeTasks.get(sessionId); if (!task) { res.json({ active: false }); return; } res.json({ active: task.status === 'running', ...task, journal: task.journal.slice(-10) }); }); let taskHeartbeatTimer: ReturnType | null = null; // Per-task followup timers — fired when a step completes to resume quickly // instead of waiting the full heartbeat interval. const taskFollowupTimers = new Map>(); const MAX_FOLLOWUP_RETRIES = 20; function scheduleTaskFollowup(taskId: string, delayMs: number, retryCount = 0): void { // Cancel any existing followup for this task const existing = taskFollowupTimers.get(taskId); if (existing) clearTimeout(existing); console.log(`[TaskFollowup] Scheduling quick resume for task ${taskId} in ${Math.round(delayMs / 1000)}s`); const t = setTimeout(async () => { taskFollowupTimers.delete(taskId); if (checkIsModelBusy()) { // Retry in 30s if model is busy, but cap retries if (retryCount >= MAX_FOLLOWUP_RETRIES) { console.warn(`[TaskFollowup] Max retries (${MAX_FOLLOWUP_RETRIES}) reached for task ${taskId}, marking stalled`); updateTaskStatus(taskId, 'stalled', { pauseReason: 'followup_timeout' }); broadcastWS({ type: 'task_stalled', taskId, reason: 'followup_timeout' }); return; } scheduleTaskFollowup(taskId, 30_000, retryCount + 1); return; } const task = loadTask(taskId); if (!task || task.status === 'complete' || task.status === 'failed' || task.status === 'running') return; console.log(`[TaskFollowup] Quick-resuming task ${taskId}: ${task.title}`); updateTaskStatus(taskId, 'queued'); appendJournal(taskId, { type: 'heartbeat', content: 'Quick follow-up resume triggered after step completion.' }); const runner = new BackgroundTaskRunner(taskId, handleChat, makeBroadcastForTask(taskId), telegramChannel); runner.start().catch(err => console.error(`[TaskFollowup] Runner error:`, err.message)); broadcastWS({ type: 'task_heartbeat_resumed', taskId, rationale: 'Quick step follow-up' }); }, delayMs); if (t && typeof (t as any).unref === 'function') (t as any).unref(); taskFollowupTimers.set(taskId, t); } // Broadcast interceptor for BackgroundTaskRunner — catches internal signals // that need server-side action (like scheduling a quick step follow-up) // while still forwarding all events to WS clients. function makeBroadcastForTask(taskId: string): (data: object) => void { return (data: object) => { const d = data as any; if (d.type === 'task_step_followup_needed' && d.taskId === taskId) { scheduleTaskFollowup(taskId, d.delayMs || 120_000); // Don't forward this internal signal to UI clients return; } broadcastWS(data); }; } function scheduleTaskHeartbeat(): void { if (taskHeartbeatTimer) clearTimeout(taskHeartbeatTimer); const cfg = loadTaskHeartbeatConfig(); if (!cfg.enabled) return; const intervalMs = cfg.interval_minutes * 60 * 1000; taskHeartbeatTimer = setTimeout(runTaskHeartbeat, intervalMs); if (taskHeartbeatTimer && typeof (taskHeartbeatTimer as any).unref === 'function') { (taskHeartbeatTimer as any).unref(); } } async function runTaskHeartbeat(): Promise { if (checkIsModelBusy()) { scheduleTaskHeartbeat(); return; } // ── Ghost runner detection ── // Tasks stuck in 'running' but with no active runner for > 5 minutes const GHOST_RUNNER_TIMEOUT_MS = 5 * 60 * 1000; const allTasks = listTasks(); for (const task of allTasks) { if (task.status === 'running' && !BackgroundTaskRunner.isRunning(task.id)) { const age = Date.now() - (task.lastProgressAt || task.startedAt); if (age > GHOST_RUNNER_TIMEOUT_MS) { console.warn(`[TaskHeartbeat] Ghost runner detected: task ${task.id} (${task.title}), stale ${Math.round(age / 1000)}s`); BackgroundTaskRunner.forceRelease(task.id); updateTaskStatus(task.id, 'stalled', { pauseReason: 'ghost_runner' }); broadcastWS({ type: 'task_stalled', taskId: task.id, reason: 'ghost_runner' }); } } } const orchCfg = getOrchestrationConfig(); if (!orchCfg?.enabled) { scheduleTaskHeartbeat(); return; } const pausedOrQueued = listTasks({ status: ['paused', 'queued', 'stalled'] }); if (pausedOrQueued.length === 0) { scheduleTaskHeartbeat(); return; } console.log(`[TaskHeartbeat] Firing advisor for ${pausedOrQueued.length} task(s)...`); broadcastWS({ type: 'task_heartbeat_tick', taskCount: pausedOrQueued.length }); // Single-pass map: pull both buildTaskSnapshot fields and raw task timestamps together // so there is no implicit index coupling between chained map calls. const snapshots: HeartbeatTaskSnapshot[] = pausedOrQueued.map(t => { const s = buildTaskSnapshot(t); return { id: s.id, title: s.title, status: s.status, pauseReason: s.pauseReason, currentStepIndex: s.currentStepIndex, totalSteps: s.totalSteps, currentStepDescription: s.currentStep, lastProgressAt: t.lastProgressAt, startedAt: t.startedAt, lastJournalEntries: s.recentJournal, channel: s.channel, sessionId: s.sessionId, }; }); try { const decision = await callSecondaryHeartbeatAdvisor({ tasks: snapshots, currentTimeMs: Date.now() }); if (!decision || decision.verdict !== 'continue' || !decision.resume_task_id) { console.log(`[TaskHeartbeat] Advisor verdict: ${decision?.verdict || 'null'} — nothing to resume.`); scheduleTaskHeartbeat(); return; } const taskToResume = loadTask(decision.resume_task_id); if (!taskToResume) { scheduleTaskHeartbeat(); return; } // Apply any plan mutations the advisor suggested if (decision.plan_mutations?.length) { mutatePlan(decision.resume_task_id, decision.plan_mutations); } appendJournal(decision.resume_task_id, { type: 'heartbeat', content: `Heartbeat resume: ${decision.rationale.slice(0, 120)}`, }); updateTaskStatus(decision.resume_task_id, 'queued'); const runner = new BackgroundTaskRunner( decision.resume_task_id, handleChat, makeBroadcastForTask(decision.resume_task_id), telegramChannel, decision.opening_action, ); runner.start().catch(err => console.error(`[TaskHeartbeat] Runner error:`, err.message)); broadcastWS({ type: 'task_heartbeat_resumed', taskId: decision.resume_task_id, rationale: decision.rationale }); console.log(`[TaskHeartbeat] Resuming task ${decision.resume_task_id}: ${taskToResume.title}`); } catch (err: any) { console.error('[TaskHeartbeat] Advisor error:', err.message); } scheduleTaskHeartbeat(); } // ── Settings routes (extracted to routes/settings.ts) ────────────────────────── registerSettingsRoutes(app); registerImagegenRoutes(app); registerNvrRoutes(app); registerK2Routes(app); // Fetch available Ollama models (proxies Ollama /api/tags), with vision capability flag app.get('/api/ollama/models', async (_req, res) => { try { const ollamaEndpoint = (getConfig().getConfig() as any).ollama?.endpoint || 'http://localhost:11434'; const response = await fetch(`${ollamaEndpoint}/api/tags`); if (!response.ok) { res.json({ success: false, models: [], error: `Ollama returned ${response.status}` }); return; } const data = await response.json() as any; const rawModels: any[] = data.models || []; // Fetch capabilities for each model in parallel (timeout 3s each) const withCaps = await Promise.all(rawModels.map(async (m: any) => { let vision = false; try { const r = await fetch(`${ollamaEndpoint}/api/show`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: m.name }), signal: AbortSignal.timeout(3000), }); if (r.ok) { const d = await r.json() as any; vision = Array.isArray(d.capabilities) && d.capabilities.includes('vision'); } } catch {} return { name: m.name, size: m.size, parameter_size: m.details?.parameter_size || '', family: m.details?.family || '', modified_at: m.modified_at, vision, }; })); res.json({ success: true, models: withCaps }); } catch (err: any) { res.json({ success: false, models: [], error: err.message }); } }); // GET /api/model-context?model= — return context window size for a model /** Read num_ctx from the Ollama manifest's params blob (for cloud/custom models * where /api/show doesn't surface PARAMETER num_ctx in the parameters field). */ function _readOllamaManifestNumCtx(modelName: string): number | null { try { const [nameTag, tag = 'latest'] = modelName.split(':'); const manifestDir = '/usr/share/ollama/.ollama/models/manifests/registry.ollama.ai/library'; const manifestPath = `${manifestDir}/${nameTag}/${tag}`; if (!fs.existsSync(manifestPath)) return null; const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const blobsDir = '/usr/share/ollama/.ollama/models/blobs'; for (const layer of (manifest.layers || [])) { if (layer.mediaType === 'application/vnd.ollama.image.params') { const blobPath = `${blobsDir}/${layer.digest.replace(':', '-')}`; if (fs.existsSync(blobPath)) { const params = JSON.parse(fs.readFileSync(blobPath, 'utf8')); if (typeof params.num_ctx === 'number') return params.num_ctx; } } } } catch {} return null; } app.get('/api/model-context', async (req, res) => { const model = String(req.query.model || '').trim().toLowerCase(); // No model specified: caller wants the *current active* model's window (main // chat sidebar gauge) — reuse resolveNumCtx() so this stays in sync with // whatever provider is actually primary (ollama/google/etc) instead of the // old hardcoded 200000 that silently applied no matter what was configured. if (!model) { // `provisional` = every real lookup failed and this is the 8192 last resort. The client must // not cache it, or one request made while 지서버 was asleep pins the panel at 8K for the life // of the page (2026-08-12 report: "세션창에서만 8킬로", fixed by reloading). const info = resolveNumCtxInfo(); res.json({ contextWindow: info.value, provisional: !info.known }); return; } // Explicit Claude model name (e.g. from code.js's per-model lookup): 200K, no local metadata. if (model.includes('claude')) { res.json({ contextWindow: 200000 }); return; } // Check manifest params blob first (catches PARAMETER num_ctx set via Modelfile) const manifestCtx = _readOllamaManifestNumCtx(model); if (manifestCtx) { res.json({ contextWindow: manifestCtx }); return; } // Fall back to /api/show model_info — on the active provider's endpoint, since // the model being asked about may live on a remote box (지서버) rather than local. try { const ollamaEndpoint = activeOllamaEndpoint(); const response = await fetch(`${ollamaEndpoint}/api/show`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: model }), }); if (!response.ok) { res.json({ contextWindow: null, provisional: true }); return; } const data = await response.json() as any; let ctxWindow: number | null = null; const modelInfo = data.model_info || {}; for (const key of Object.keys(modelInfo)) { if (key.endsWith('.context_length') && typeof modelInfo[key] === 'number') { ctxWindow = modelInfo[key]; break; } } if (!ctxWindow && typeof data.parameters === 'string') { const m = data.parameters.match(/\bnum_ctx\s+(\d+)/); if (m) ctxWindow = parseInt(m[1], 10); } res.json({ contextWindow: ctxWindow, provisional: !ctxWindow }); } catch (err: any) { res.json({ contextWindow: null, provisional: true, error: err.message }); } }); import * as osModule from 'os'; // Track previous CPU times for accurate utilization let prevCpuTimes: { idle: number; total: number } | null = null; function getCpuPercent(): number { const cpus = osModule.cpus(); let totalIdle = 0; let totalTick = 0; for (const cpu of cpus) { for (const type in cpu.times) totalTick += (cpu.times as any)[type]; totalIdle += cpu.times.idle; } const idle = totalIdle / cpus.length; const total = totalTick / cpus.length; if (!prevCpuTimes) { prevCpuTimes = { idle, total }; return 0; } const idleDiff = idle - prevCpuTimes.idle; const totalDiff = total - prevCpuTimes.total; prevCpuTimes = { idle, total }; if (totalDiff === 0) return 0; return Math.round(100 * (1 - idleDiff / totalDiff)); } app.get('/api/logs', (req, res) => { const authCfg = getAuthConfig(); if (authCfg.enabled) { const sess = getSessionUser(req); if (!sess) return res.status(401).json({ error: 'Unauthorized' }); } res.json({ entries: logRing.slice(), totalCount: logTotalCount }); }); // ── wol-gate wake log (TrueNAS 컨테이너 로그를 SSH로 가져옴) ──────────────────── app.get('/api/wol-gate-log', async (req, res) => { const authCfg = getAuthConfig(); if (authCfg.enabled) { const sess = getSessionUser(req); if (!sess) return res.status(401).json({ error: 'Unauthorized' }); } const { execFile } = await import('child_process'); execFile( 'ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', 'truenas', 'docker', 'logs', '-t', 'ix-wol-gate-wol-gate-1', '--tail', '40'], { timeout: 8000 }, (err, stdout, stderr) => { if (err) { res.status(502).json({ error: (stderr || err.message || 'ssh failed').trim() }); return; } const triggerRe = /triggered by (\S+) "(\S+) ([^"]*)" UA="([^"]*)"/; const lines = stdout.split('\n').filter(l => l.trim().length > 0).map(line => { // docker logs -t prefixes each line with an RFC3339 UTC timestamp, e.g. // "2026-08-04T04:12:34.123456789Z [wol-gate] waking - triggered by ..." const tsMatch = line.match(/^(\S+)\s(.*)$/); const rawTs = tsMatch ? tsMatch[1] : null; const text = tsMatch ? tsMatch[2] : line; const time = rawTs ? new Date(rawTs).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', hour12: false }) : null; let type: 'info' | 'success' | 'warn' = 'info'; let event: string | null = null; if (text.includes('waking - triggered by')) { type = 'success'; event = '깨움'; } else if (text.includes('wake suppressed')) { type = 'warn'; event = '억제(쿨다운)'; } const m = text.match(triggerRe); if (m) { const [, ip, method, hostPath, ua] = m; const slashIdx = hostPath.indexOf('/'); const host = slashIdx >= 0 ? hostPath.slice(0, slashIdx) : hostPath; const path = slashIdx >= 0 ? hostPath.slice(slashIdx) : ''; return { type, time, event, ip, method, host, path, ua, text }; } return { type, time, event, text }; }); res.json({ lines }); } ); }); // ── Network speed tracker ────────────────────────────────────────────────── let _netPrev: { rx: number; tx: number; ts: number } | null = null; let _netSpeed = { rx_bps: 0, tx_bps: 0, iface: '' }; function readNetDev(): { rx: number; tx: number; iface: string } | null { try { const lines = fs.readFileSync('/proc/net/dev', 'utf8').split('\n'); let rx = 0, tx = 0, iface = ''; for (const line of lines) { const m = line.trim().match(/^(\S+?):\s+(\d+)\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+(\d+)/); if (!m) continue; const name = m[1]; if (name === 'lo' || name.startsWith('docker') || name.startsWith('veth') || name.startsWith('br-')) continue; rx += Number(m[2]); tx += Number(m[3]); iface = name; } return { rx, tx, iface }; } catch { return null; } } function updateNetSpeed() { const cur = readNetDev(); if (!cur) return; const now = Date.now(); if (_netPrev) { const dt = (now - _netPrev.ts) / 1000; if (dt > 0) { _netSpeed = { rx_bps: Math.max(0, (cur.rx - _netPrev.rx) / dt), tx_bps: Math.max(0, (cur.tx - _netPrev.tx) / dt), iface: cur.iface, }; } } _netPrev = { rx: cur.rx, tx: cur.tx, ts: now }; } updateNetSpeed(); setInterval(updateNetSpeed, 2000); // ────────────────────────────────────────────────────────────────────────── app.get('/api/system-stats', async (req, res) => { const totalMem = osModule.totalmem(); const freeMem = osModule.freemem(); const usedMem = totalMem - freeMem; const memPercent = (usedMem / totalMem) * 100; const cpuPercent = getCpuPercent(); const rss = process.memoryUsage().rss; // Check if Ollama is reachable let ollamaRunning = false; let ollamaMemMb = 0; let ollamaCount = 0; try { const ollamaEndpoint = (getConfig().getConfig() as any).ollama?.endpoint || 'http://localhost:11434'; const r = await fetch(`${ollamaEndpoint}/api/tags`, { signal: AbortSignal.timeout(2000) }); if (r.ok) { ollamaRunning = true; const data = await r.json() as any; ollamaCount = (data.models || []).length; } } catch {} // GPU stats — use the cached detector (probed once at startup, never calls // nvidia-smi again). On non-NVIDIA systems this is instant and silent. const gpuInfo = detectGpu(); let gpuStats: any = { available: false, gpu_util_percent: 0, vram_used_percent: 0, vram_used_gb: 0, vram_total_gb: 0, name: '', gpus: [] as any[] }; if (gpuInfo.nvidiaAvailable) { // Re-query utilization metrics only when NVIDIA is confirmed present. // This is the *only* place nvidia-smi runs at runtime; startup detection // already verified the GPU exists so this call is guaranteed to succeed. try { const { execSync } = await import('child_process'); const smiOut = execSync( 'nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits', { timeout: 3000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, ); // One line per physical GPU — a machine with 2+ cards must split by line // before splitting by comma, or fields from adjacent lines bleed together // (e.g. memory.total gets "12288\nNVIDIA GeForce RTX 3060" glued on, NaN's // the VRAM percent). Aggregate across all detected GPUs: sum VRAM (so the // meter reflects total usage), max utilization (so activity on any card shows). // Also keep the per-GPU breakdown (gpus[]) so the UI can show each card // separately instead of only a blended combined figure. const gpuLines = smiOut.trim().split('\n') .map((line: string) => line.split(',').map((s: string) => s.trim())) .filter((parts: string[]) => parts.length >= 4); if (gpuLines.length > 0) { let vramUsedMbSum = 0; let vramTotalMbSum = 0; let utilMax = 0; const names: string[] = []; const perGpu: any[] = []; gpuLines.forEach((parts: string[], idx: number) => { const vramUsedMb = Number(parts[2]) || 0; const vramTotalMb = Number(parts[3]) || 0; const util = Number(parts[1]) || 0; vramUsedMbSum += vramUsedMb; vramTotalMbSum += vramTotalMb; utilMax = Math.max(utilMax, util); names.push(parts[0]); perGpu.push({ index: idx, name: parts[0], gpu_util_percent: util, vram_used_percent: vramTotalMb > 0 ? (vramUsedMb / vramTotalMb) * 100 : 0, vram_used_gb: vramUsedMb / 1024, vram_total_gb: vramTotalMb / 1024, }); }); gpuStats = { available: true, name: gpuLines.length > 1 ? `${names[0]} ×${gpuLines.length}` : names[0], gpu_util_percent: utilMax, vram_used_percent: vramTotalMbSum > 0 ? (vramUsedMbSum / vramTotalMbSum) * 100 : 0, vram_used_gb: vramUsedMbSum / 1024, vram_total_gb: vramTotalMbSum / 1024, gpus: perGpu, }; } } catch { /* nvidia-smi already confirmed working at startup; ignore transient errors */ } } else if (gpuInfo.amdAvailable) { gpuStats = { available: true, gpu_util_percent: 0, vram_used_percent: 0, vram_used_gb: 0, vram_total_gb: 0, name: gpuInfo.name ?? 'AMD GPU' }; } else if (gpuInfo.appleSilicon) { gpuStats = { available: true, gpu_util_percent: 0, vram_used_percent: 0, vram_used_gb: 0, vram_total_gb: 0, name: gpuInfo.name ?? 'Apple Silicon' }; } const onlineUsers = [...new Set([...activeSessions.values()].map(s => s.username))]; res.json({ system: { cpu_percent: cpuPercent, memory_percent: memPercent, memory_used_gb: usedMem / (1024 ** 3), memory_total_gb: totalMem / (1024 ** 3), }, gpu: gpuStats, network: _netSpeed, ollama_process: { running: ollamaRunning, process_count: ollamaCount, total_memory_mb: ollamaMemMb }, gateway_process: { rss_mb: rss / (1024 * 1024) }, active_provider: (getConfig().getConfig() as any).llm?.provider || 'ollama', active_model: (() => { const c = getConfig().getConfig() as any; const p = c.llm?.provider || 'ollama'; return c.llm?.providers?.[p]?.model || c.models?.primary || 'unknown'; })(), online_users: onlineUsers, timestamp: new Date().toISOString(), }); }); app.get('/api/agent/session/:id', (req, res) => { const sessionId = req.params.id; const user = (req as any).user; if (user?.username) getSession(sessionId, user.username); const history = getHistory(sessionId, 50, user?.username); const userMessages = history.filter(h => h.role === 'user'); const aiMessages = history.filter(h => h.role === 'assistant'); const recent = history.slice(-8).map(h => ({ kind: h.role, status: 'completed', text: String(h.content || '').slice(0, 120), })); const sess = getSession(sessionId, user?.username); const numCtx = resolveNumCtx(); const contextTokenEstimate = sess.contextTokenEstimate ?? 0; res.json({ mode_lock: null, mode: useAgentMode ? 'agent' : 'chat', tasks: [], task_counts: { total: 0, done: 0 }, turn_counts: { completed: history.length, open: 0 }, execution_counts: { total: 0, done: 0, running: 0, failed: 0 }, recent_turns: recent, recent_turn_executions: [], current_turn_execution: null, overview_objective: userMessages.length > 0 ? String(userMessages[0]?.content || '').slice(0, 80) : null, active_objective: userMessages.length > 0 ? String(userMessages[userMessages.length - 1]?.content || '').slice(0, 80) : null, contextTokenEstimate, numCtx, }); }); // Track agent mode per-session (simplified) let useAgentMode = false; // SECURITY: All approval endpoints require gateway auth. Approvals are the // confirmation gate before the agent executes irreversible actions — an // unauthenticated bypass here is a critical vulnerability. // CRIT-03 / CRIT-01 fix: protects approval, memory-confirm, and open-path // endpoints from unauthenticated access. // // Auth strategy (in priority order): // 1. Bearer token in Authorization header → Authorization: Bearer // 2. X-Gateway-Token header → X-Gateway-Token: // 3. Localhost bypass (127.0.0.1 / ::1) → always trusted when no token configured // // Token is read from config at request time so it takes effect immediately // after a config save without requiring a gateway restart. function requireGatewayAuth( req: express.Request, res: express.Response, next: express.NextFunction, ): void { // 1. Session cookie auth (web UI login) const session = getSessionUser(req); if (session) { next(); return; } const cfg = getConfig().getConfig() as any; const configuredToken = String(cfg?.gateway?.auth_token || '').trim(); // 2. If no token is configured, fall back to localhost-only access. if (!configuredToken) { const remoteIp = String( req.ip || req.socket?.remoteAddress || (req.connection as any)?.remoteAddress || '' ); const isLocalhost = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1'; if (isLocalhost) { next(); return; } res.status(401).json({ error: 'Unauthorized: configure gateway.auth_token to enable remote access to this endpoint.' }); return; } // 3. Extract token from Authorization header or X-Gateway-Token header. const authHeader = String(req.headers['authorization'] || ''); const xGatewayToken = String(req.headers['x-gateway-token'] || ''); let providedToken = ''; if (authHeader.toLowerCase().startsWith('bearer ')) { providedToken = authHeader.slice('bearer '.length).trim(); } else if (xGatewayToken) { providedToken = xGatewayToken.trim(); } if (!providedToken || providedToken !== configuredToken) { res.status(401).json({ error: 'Unauthorized' }); return; } next(); } // ───────────────────────────────────────────────────────────────────────────── app.post('/api/memory/confirm', requireGatewayAuth, (req, res) => { // Memory persistence stub — can be wired to ChromaDB/vector store // SECURITY: req.body is user/agent-supplied content — never log it raw. // scrubSecrets runs inside sanitizeToolLog before any write. import('../security/log-scrubber').then(({ log, sanitizeToolLog }) => { log.info('[Memory]', sanitizeToolLog('confirm', req.body)); }).catch(() => {}); res.json({ ok: true }); }); // Open a file path in the OS file explorer // SECURITY: This endpoint uses execFile() (not exec()) so the path is passed // as an argument, not interpolated into a shell string. The path is also // validated to be inside the workspace before execution. app.post('/api/open-path', requireGatewayAuth, async (req, res) => { const fp = (req.body?.path || '') as string; if (!fp) { res.status(400).json({ ok: false, error: 'Path required' }); return; } // Resolve and validate — must be inside workspace or config dir const resolvedFp = path.resolve(fp); const workspacePath = getConfig().getWorkspacePath(); const configDirPath = getConfig().getConfigDir(); const isInWorkspace = resolvedFp.startsWith(path.resolve(workspacePath)); const isInConfigDir = resolvedFp.startsWith(path.resolve(configDirPath)); if (!isInWorkspace && !isInConfigDir) { res.status(403).json({ ok: false, error: 'Path is outside allowed directories' }); return; } try { const { execFile } = await import('child_process'); // execFile passes args as a list — no shell interpolation possible if (process.platform === 'win32') { execFile('explorer.exe', [resolvedFp]); } else if (process.platform === 'darwin') { execFile('open', [resolvedFp]); } else { execFile('xdg-open', [resolvedFp]); } res.json({ ok: true }); } catch (err: any) { res.status(500).json({ ok: false, error: err.message }); } }); // Used by the Settings → Models tab to read/write provider config and // trigger the OpenAI OAuth flow. import { getProvider, resetProvider, buildProviderForLLM, getModelForRole } from '../providers/factory'; import { OpenAICompatAdapter } from '../providers/openai-compat-adapter'; import { getGoogleUsageToday } from '../providers/google-usage'; import { buildWebhookRouter, resolveHookConfig } from './channels/webhook-handler'; import { getMCPManager } from './infra/mcp-manager'; import { startOAuthFlow, isConnected, clearTokens, loadTokens, exchangeManualCodeFromPending } from '../auth/openai-oauth'; import { renderMusic, renderMidi, getRenderFile, getRenderPageFile, renderMidiAsync, saveMidiFile } from './routes/music-renderer'; function sanitizeLLMConfig(llm: any): any { if (!llm || typeof llm !== 'object') return llm; const copy = JSON.parse(JSON.stringify(llm)); const codexModel = copy?.providers?.openai_codex?.model; if (typeof codexModel === 'string' && codexModel.trim() === 'codex-davinci-002') { copy.providers.openai_codex.model = 'gpt-4o'; } return copy; } // HIGH-02 fix: redact all api_key / token fields before sending to the UI. // Vault references ("vault:...") and env references ("env:...") are also masked // so neither the vault key name nor the env var name leaks to the browser. const SENSITIVE_KEY_PATTERNS = /api[_-]?key|apikey|token|secret|password|passwd|credential/i; function redactConfigForUI(obj: any, depth = 0): any { if (depth > 8 || obj === null || typeof obj !== 'object') return obj; if (Array.isArray(obj)) return obj.map(v => redactConfigForUI(v, depth + 1)); const out: Record = {}; for (const [k, v] of Object.entries(obj)) { if (SENSITIVE_KEY_PATTERNS.test(k) && typeof v === 'string' && v.length > 0) { out[k] = '••••••••'; } else { out[k] = redactConfigForUI(v, depth + 1); } } return out; } // GET /api/settings/provider — return active provider config (keys redacted) // POST /api/settings/provider — update provider config /** * wake_url of the provider this request is about — the one being configured in the UI when an * override is supplied, otherwise whatever is active. */ function wakeUrlForLLM(llmOverride: any): string | undefined { const source = llmOverride || (getConfig().getConfig() as any)?.llm; const providerId = String(source?.provider || ''); const raw = source?.providers?.[providerId]?.wake_url // The UI posts only the edited provider's fields, so fall back to what is stored. ?? ((getConfig().getConfig() as any)?.llm?.providers?.[providerId]?.wake_url); return raw ? String(raw).replace(/\/+$/, '') : undefined; } /** * Poke the wol-gate so it emits a magic packet, then wait for the box to start answering. * * Picking a sleeping provider in Settings used to just report "모델 없음 — 서버가 실행 중인가요?", * leaving the user to wake 지서버 by hand or to send a throwaway chat message purely to trigger * the wake. Selecting it is a clear enough intent to use it, so treat it as the trigger. */ async function wakeAndAwaitProvider(wakeUrl: string, provider: any, budgetMs = 45_000): Promise { try { await fetch(`${wakeUrl}/api/tags`, { signal: AbortSignal.timeout(4_000) }); } catch { // The gate answers a sleeping target with HTML (or nothing); the request itself is what // sends the packet, so a failure here does not mean the wake failed. } const deadline = Date.now() + budgetMs; while (Date.now() < deadline) { await new Promise(r => setTimeout(r, 3_000)); try { if (await provider.testConnection()) return true; } catch { /* still booting */ } } return false; } // POST /api/models/test — test connectivity for the active (or a given) provider app.post('/api/models/test', async (req, res) => { try { let llmOverride = req.body?.llm ? sanitizeLLMConfig(req.body.llm) : null; // Replace redacted keys (••••••••) with the stored values so the UI can test // without the user re-entering credentials after they've been saved. if (llmOverride?.providers) { const stored = ((getConfig().getConfig() as any)?.llm?.providers) || {}; for (const provId of Object.keys(llmOverride.providers)) { const p = llmOverride.providers[provId]; if (p?.api_key === '••••••••' && stored[provId]?.api_key) { p.api_key = stored[provId].api_key; } } } const provider = llmOverride ? buildProviderForLLM(llmOverride) : getProvider(); let ok = await provider.testConnection(); // Unreachable but wakeable (지서버): selecting the provider is the wake trigger. let waking = false; if (!ok) { const wakeUrl = wakeUrlForLLM(llmOverride); if (wakeUrl) { waking = true; ok = await wakeAndAwaitProvider(wakeUrl, provider); } } const models = ok ? await provider.listModels() : []; res.json({ success: ok, models, waking, error: ok ? undefined : (waking ? '깨우는 중입니다 — 잠시 후 다시 시도해 주세요' : 'Could not connect'), }); } catch (err: any) { res.json({ success: false, models: [], error: err.message }); } }); // POST /api/openai/models — list models for a (possibly not-yet-saved) OpenAI API key, // used by the Settings → Models → OpenAI panel to populate the model dropdown before the // key is saved via /api/settings/provider. The frontend has called this since the OpenAI // provider was added, but the route was never implemented (always 404'd until now). app.post('/api/openai/models', async (req, res) => { try { let apiKey = String(req.body?.api_key || '').trim(); if (apiKey === '••••••••') { apiKey = ((getConfig().getConfig() as any)?.llm?.providers?.openai?.api_key) || ''; } if (!apiKey) { res.json({ success: false, models: [], error: 'API 키가 필요합니다' }); return; } const adapter = new OpenAICompatAdapter({ endpoint: 'https://api.openai.com', apiKey, providerId: 'openai' }); const models = await adapter.listModels(); res.json({ success: models.length > 0, models: models.map(m => m.name), error: models.length ? undefined : '모델 목록을 가져오지 못했습니다 (키를 확인하세요)', }); } catch (err: any) { res.json({ success: false, models: [], error: err.message }); } }); registerMusicRoutes(app); // ── Guitar Pro tab search/download (guitarprotabs.org proxy) ───────────────── function gpNormalizeArtist(name: string): { letter: string; slug: string } { let n = name.trim().toLowerCase(); // "The Eagles" → "eagles_(the)" if (n.startsWith('the ')) { n = n.slice(4) + '_(the)'; } const slug = n.replace(/[&]/g, 'and').replace(/[^a-z0-9_()]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, ''); const letter = slug[0] || 'a'; return { letter, slug }; } async function gpFetch(url: string, referer?: string): Promise<{ ok: boolean; status: number; text?: string; buffer?: Buffer; contentType?: string }> { return new Promise((resolve) => { const https = require('https'); const urlObj = new URL(url); const opts = { hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search, method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': '*/*', ...(referer ? { 'Referer': referer } : {}) } }; const req = https.request(opts, (res: any) => { const chunks: Buffer[] = []; res.on('data', (c: Buffer) => chunks.push(c)); res.on('end', () => { const buf = Buffer.concat(chunks); const ct = res.headers['content-type'] || ''; if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { const next = new URL(res.headers.location, url).href; gpFetch(next, referer).then(resolve); } else { resolve({ ok: res.statusCode < 400, status: res.statusCode, buffer: buf, contentType: ct, text: ct.includes('text') ? buf.toString('utf8') : undefined }); } }); }); req.on('error', () => resolve({ ok: false, status: 0 })); req.end(); }); } async function gpSearchGptabs(q: string): Promise<{ results: any[]; artistUrl: string; error?: string }> { const searchR = await gpFetch(`https://guitarprotabs.org/?s=${encodeURIComponent(q)}`); let baseUrl = ''; if (searchR.ok && searchR.text) { const qLower = q.toLowerCase().replace(/\s+/g, ''); const re = /href="(https:\/\/guitarprotabs\.org\/[a-z]\/([^/]+)\/\d+\/)"/g; let am; while ((am = re.exec(searchR.text)) !== null) { const slug = am[2].replace(/_/g, '').replace(/[^a-z0-9]/gi, '').toLowerCase(); if (slug.includes(qLower) || qLower.includes(slug)) { baseUrl = am[1]; break; } } } if (!baseUrl) { const { letter, slug } = gpNormalizeArtist(q); baseUrl = `https://guitarprotabs.org/${letter}/${slug}/1/`; } // Strip trailing page number to rebuild per-page URL const urlBase = baseUrl.replace(/\/\d+\/$/, '/'); const artistUrl = urlBase + '1/'; const results: any[] = []; const rowRe = /]*itemprop[^>]*>([\s\S]*?)<\/tr>/g; const MAX_PAGES = 10; for (let page = 1; page <= MAX_PAGES; page++) { const r = await gpFetch(urlBase + page + '/'); if (!r.ok || !r.text) break; let m; let pageCount = 0; rowRe.lastIndex = 0; while ((m = rowRe.exec(r.text)) !== null) { const row = m[1]; const href = row.match(/href="(https:\/\/guitarprotabs\.org\/[^"]+)"/); const nameM = row.match(/itemprop="name">([^<]+)/); const extM = row.match(/<\/td>\s*(\.[a-z0-9]+)<\/td>/i); const dlM = row.match(/badge">([^<]+) { const searchUrl = `https://www.gprotab.net/en/search/?q=${encodeURIComponent(q)}`; const r = await gpFetch(searchUrl); if (!r.ok || !r.text) return { results: [], searchUrl, error: '검색 실패' }; // Match each tab link directly — more robust than block regex const hrefRe = /href="(\/en\/tabs\/[^"]+)" class="tab-name"[^>]*>([^<]+)<\/a>/g; const bandRe = /class="tab-band"[^>]*>([^<]+)<\/a>/g; const results: any[] = []; // Collect bands first (parallel structure to hrefs) const bands: string[] = []; let bm; while ((bm = bandRe.exec(r.text)) !== null) bands.push(bm[1].trim()); let hm; let i = 0; while ((hm = hrefRe.exec(r.text)) !== null) { const band = bands[i] || ''; const name = hm[2].trim(); results.push({ title: band ? `${band} - ${name}` : name, url: 'https://www.gprotab.net' + hm[1], ext: '', downloads: '', site: 'gprotab' }); i++; } return { results, searchUrl }; } app.get('/api/gp-search', async (req: any, res: any) => { const q = (req.query.q || req.query.artist || '').toString().trim(); const site = (req.query.site || 'guitarprotabs').toString(); if (!q) return res.status(400).json({ error: '검색어가 필요합니다' }); try { if (site === 'gprotab') { const r = await gpSearchGprotab(q); res.json(r); } else { const r = await gpSearchGptabs(q); res.json(r); } } catch (e: any) { res.status(500).json({ error: e.message }); } }); app.get('/api/gp-download', async (req: any, res: any) => { const tabUrl = (req.query.url || '').toString(); const isGptabs = tabUrl.startsWith('https://guitarprotabs.org/'); const isGprotab = tabUrl.startsWith('https://www.gprotab.net/') || tabUrl.startsWith('https://gprotab.net/'); if (!isGptabs && !isGprotab) return res.status(400).json({ error: '잘못된 URL' }); let dlUrl = tabUrl; let referer = tabUrl; if (isGptabs) { dlUrl = tabUrl.endsWith('/') ? tabUrl + 'download/' : tabUrl + '/download/'; } else { dlUrl = tabUrl.includes('?') ? tabUrl + '&download' : tabUrl + '?download'; } try { const r = await gpFetch(dlUrl, referer); if (!r.ok || !r.buffer) return res.status(502).json({ error: '다운로드 실패' }); const isGp = r.buffer[1] === 0x46 && r.buffer[2] === 0x49; // .FICH magic if (!isGp) return res.status(502).json({ error: '유효하지 않은 GP 파일' }); const titleM = isGptabs ? tabUrl.match(/\/([^/]+)_\d+\/?$/) : tabUrl.match(/\/([^/]+)\/?$/); const filename = (titleM ? titleM[1].replace(/[-_]/g, ' ') : 'tab') + '.gp'; res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(r.buffer); } catch (e: any) { res.status(500).json({ error: e.message }); } }); // ── MIDI download site search/download (midifind.com, bitmidi.com proxy) ────── async function midiSearchMidifind(q: string): Promise<{ results: any[]; error?: string }> { const r = await gpFetch(`https://midifind.com/search/?q=${encodeURIComponent(q)}&t=0`); if (!r.ok || !r.text) return { results: [], error: '검색 실패' }; const results: any[] = []; // …title… const re = /]*class="item"[^>]*>([\s\S]*?)<\/a>/g; let m; while ((m = re.exec(r.text)) !== null) { const title = m[3].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); if (title) results.push({ title, url: 'https://midifind.com' + m[1], site: 'midifind' }); } return { results }; } async function midiSearchBitmidi(q: string): Promise<{ results: any[]; error?: string }> { const r = await gpFetch(`https://bitmidi.com/search?q=${encodeURIComponent(q)}`); if (!r.ok || !r.text) return { results: [], error: '검색 실패' }; const results: any[] = []; // Result anchors: (attribute order varies) const re = /]*)>/g; let m; while ((m = re.exec(r.text)) !== null) { const attrs = m[1]; const hrefM = attrs.match(/\bhref="(\/[^"]+-mid)"/); const titleM = attrs.match(/\btitle="([^"]+\.mid)"/i); if (hrefM && titleM) results.push({ title: titleM[1], url: 'https://bitmidi.com' + hrefM[1], site: 'bitmidi' }); } return { results }; } app.get('/api/midi-search', async (req: any, res: any) => { const q = (req.query.q || '').toString().trim(); const site = (req.query.site || 'midifind').toString(); if (!q) return res.status(400).json({ error: '검색어가 필요합니다' }); try { const r = site === 'bitmidi' ? await midiSearchBitmidi(q) : await midiSearchMidifind(q); res.json(r); } catch (e: any) { res.status(500).json({ error: e.message }); } }); app.get('/api/midi-download', async (req: any, res: any) => { const url = (req.query.url || '').toString(); const site = (req.query.site || 'midifind').toString(); let dlUrl = ''; let referer = url; if (site === 'midifind') { const idM = url.match(/(\d+)$/); if (!idM) return res.status(400).json({ error: '잘못된 URL' }); dlUrl = `https://midifind.com/files/0-0-1-${idM[1]}-20`; } else if (site === 'bitmidi') { // detail page → find /uploads/{id}.mid const page = await gpFetch(url); if (!page.ok || !page.text) return res.status(502).json({ error: '다운로드 실패' }); const upM = page.text.match(/href="(\/uploads\/\d+\.mid)"/); if (!upM) return res.status(502).json({ error: '다운로드 링크 없음' }); dlUrl = 'https://bitmidi.com' + upM[1]; } else { return res.status(400).json({ error: '잘못된 사이트' }); } try { const r = await gpFetch(dlUrl, referer); if (!r.ok || !r.buffer) return res.status(502).json({ error: '다운로드 실패' }); const isMidi = r.buffer.length > 4 && r.buffer[0] === 0x4D && r.buffer[1] === 0x54 && r.buffer[2] === 0x68 && r.buffer[3] === 0x64; // "MThd" if (!isMidi) return res.status(502).json({ error: '유효하지 않은 MIDI 파일' }); const fname = (req.query.name || 'download.mid').toString().replace(/[/\\:*?"<>|]/g, '_'); res.setHeader('Content-Type', 'audio/midi'); res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fname)}`); res.send(r.buffer); } catch (e: any) { res.status(500).json({ error: e.message }); } }); registerLanguageRoutes(app); registerWriterRoutes(app); registerDentalRoutes(app); registerDetectiveDateRoutes(app, getSessionUser); // GET /api/excel/list — list xlsx files from user workspace app.get('/api/excel/list', (req, res) => { const user = (req as any).user; const workspacePath = user?.workspace; if (!workspacePath) { res.json({ files: [] }); return; } const results: { name: string; relPath: string; apiPath: string; mtime: number }[] = []; const walk = (dir: string, depth: number) => { if (depth > 3) return; try { for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, ent.name); if (ent.isDirectory()) { walk(full, depth + 1); } else if (/\.(xlsx|xls)$/i.test(ent.name)) { const rel = path.relative(workspacePath, full); results.push({ name: ent.name, relPath: rel, apiPath: `/api/files/${rel}`, mtime: fs.statSync(full).mtimeMs }); } } } catch {} }; walk(workspacePath, 0); results.sort((a, b) => b.mtime - a.mtime); res.json({ files: results }); }); // GET /api/settings/openweather — return masked OpenWeather key status // POST /api/settings/openweather — save OpenWeather API key (vault-encrypted) // GET /api/settings/voice — return voice config // POST /api/settings/voice — save voice config // GET /api/settings/session — return session config // POST /api/settings/session — save session config registerMCPRoutes(app); // Mounted dynamically so the path is always read fresh from config. // Must be registered BEFORE the SPA catch-all below. (() => { const hookCfg = resolveHookConfig(); if (!hookCfg.enabled) { console.log('[Webhooks] Disabled — set hooks.enabled=true in config to activate.'); return; } if (!hookCfg.token) { console.warn('[Webhooks] hooks.enabled=true but no hooks.token set — webhooks will be disabled until a token is configured.'); return; } const webhookRouter = buildWebhookRouter({ handleChat: (message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username) => handleChat(message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username), addMessage, getIsModelBusy: () => checkIsModelBusy(), broadcast: broadcastWS, deliverTelegram: (text: string) => telegramChannel.sendToAllowed(text), }); app.use(hookCfg.path, webhookRouter); console.log(`[Webhooks] Listening at ${hookCfg.path} (wake, agent, status)`); })(); // Localhost-only unless SMALLCLAW_INTERNAL_TOKEN is set. app.use('/internal/agent-task', internalAgentTaskRouter); console.log('[InternalAgentTask] Endpoint mounted at POST /internal/agent-task'); // Global error handler — catches body-parser PayloadTooLarge and other middleware errors app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => { if (err?.type === 'entity.too.large' || err?.status === 413) { const size = req.headers['content-length'] ? `${Math.round(Number(req.headers['content-length']) / 1024)}KB` : 'unknown size'; console.error(`[413] PayloadTooLarge on ${req.method} ${req.path} (${size})`); res.status(413).json({ error: 'Request body too large', path: req.path }); return; } console.error(`[500] Unhandled error on ${req.method} ${req.path}:`, err?.message || err); res.status(500).json({ error: String(err?.message || err) }); }); registerArduinoRoutes(app, getSessionUser); registerAndroidRoutes(app, getSessionUser); registerComfyUIRoutes(app, getSessionUser); registerBgTasksRoutes(app, { getSessionUser, handleChat, makeBroadcastForTask, telegramChannel, broadcastWS, scheduleTaskHeartbeat, CONFIG_DIR_PATH, }); registerKakaoRoutes(app, { kakaoChannel, kakaoNotify, resolveChannelsConfig, IMAGE_TYPES, CONFIG_DIR_PATH, }); registerScheduleRoutes(app, cronScheduler); registerSkillsRoutes(app, { skillsManager, recoverSkillsIfEmpty, setOrchestrationEnabled, }); registerCodeRoutes(app); registerTaskRoutes(app, cronScheduler); registerAgentRoutes(app, resolvePromptPath); registerChannelsRoutes(app, { getConfig, CONFIG_DIR_PATH, telegramChannel, kakaoChannel, kakaoNotify, resolveChannelsConfig, normalizeTelegramConfig, normalizeDiscordConfig, normalizeWhatsAppConfig, }); registerLegacyTelegramRoutes(app, { getConfig, telegramChannel, resolveChannelsConfig, normalizeTelegramConfig, }); registerOrchestrationRoutes(app, { getConfig, getOrchestrationSessionStats, }); registerSchematicRoutes(app); registerCanvasRoutes(app); registerPptStaticRoutes(app); registerMiscRoutes(app, { getSessionUser, requireGatewayAuth, heartbeatRunner, CONFIG_DIR_PATH, }); registerChatSessionRoutes(app); registerOpenAIAuthRoutes(app, CONFIG_DIR_PATH); registerUserAppSessionRoutes(app, getSessionUser); registerChannelMappingRoutes(app, { getSessionUser, requireGatewayAuth, getUserChannelId, saveUserChannelId, deleteUserChannelId, getAllChannelMappings, getUserTelegramId, saveUserTelegramId, deleteUserTelegramId, getAllTelegramMappings, }); registerAdminSessionRoutes(app, getSessionUser, activeSessions); registerUserRoutes(app, { getSessionUser, listUsers, getUserField, saveUserField, saveUserList, hashPassword, activeSessions, saveActiveSessions, skillsManager, saveUserChannelId, userTelegramManager, CONFIG_DIR_PATH, }); registerDetectiveRoutes(app, getSessionUser, isPathInsideDir, CONFIG_DIR_PATH); registerLawyerCaseRoutes(app, getSessionUser, isPathInsideDir); registerWopiRoutes(app, '/api/lawyer'); registerDoctorCaseRoutes(app, getSessionUser, isPathInsideDir); registerWopiRoutes(app, '/api/doctor'); registerRobotRoutes(app, getSessionUser); // Redirect legacy top-level HTML routes into /html/ after web-ui reorganization. const LEGACY_HTML_REDIRECTS = [ '/login.html','/index.html','/claude-app.html','/code.html','/writer-app.html', '/language-app.html','/nvr-app.html','/weather-app.html','/music-app.html', '/mind-app.html','/lawyer-app.html','/investor-app.html','/accountant-app.html', '/detective-app.html','/studio-app.html','/pptx-wizard.html','/traffic-app.html', '/android-emulator.html','/arduino-emulator.html','/esp32-flasher.html', '/python-runner.html','/xlsx-viewer.html','/dental-agent.html','/doctor-app.html' ]; for (const p of LEGACY_HTML_REDIRECTS) { // req.url retains the original query string (e.g. xlsx-viewer.html?file=...); // redirecting with the bare path `p` silently drops it. app.get(p, (req, res) => { res.redirect(`/html${req.url}`); }); } app.get('/{*path}', (_req, res) => { res.sendFile(path.join(webUiPath, 'html', 'index.html')); }); const server = http.createServer(app); // ws's own WebSocketServer, when attached via {server, path}, registers an // unconditional 'upgrade' listener that actively aborts (writes HTTP 400 and // destroys the socket) any request whose path doesn't match — it doesn't just // ignore non-matching paths. That would race/clobber the Android emulator's // WS proxy below, which also listens on 'upgrade'. Using noServer + manual // path dispatch means each handler only touches sockets meant for it. wss = new WebSocketServer({ noServer: true }); server.on('upgrade', (req, socket, head) => { if ((req.url || '').startsWith('/ws') && !(req.url || '').startsWith('/ws/voice-realtime')) { wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); } }); attachAndroidWsProxy(server, getSessionUser); attachAndroidConsoleWsProxy(server, getSessionUser); attachComfyUIWsProxy(server, getSessionUserFromUpgradeReq); attachVoiceRealtimeWsProxy(server, getSessionUserFromUpgradeReq); attachGo2rtcWsProxy(server, getSessionUserFromUpgradeReq); wss.on('error', (err: any) => { if (err?.code === 'EADDRINUSE') { console.error(`[Gateway] Port ${HOST}:${PORT} is already in use.`); console.error('[Gateway] Another gateway instance is likely already running.'); console.error('[Gateway] Use one instance only, then open http://127.0.0.1:18789'); process.exit(1); return; } console.error('[Gateway] WebSocket error:', err?.message || err); process.exit(1); }); wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => { // Authenticate WS via query token first, then cookie fallback let token: string | null = null; try { const u = new URL(req.url || '/', `http://${req.headers.host || 'x'}`); token = u.searchParams.get('token'); } catch {} if (!token) { const cookies = parseCookies(req as any); token = cookies[AUTH_COOKIE] || null; } const authEnabled = getAuthConfig().enabled; const session = authEnabled && token ? activeSessions.get(token) : undefined; const user = authEnabled ? (session ? { username: session.username, role: session.role, workspace: getUserWorkspace(session.username) } : null) : null; (ws as any).user = user; (ws as any).isAlive = true; ws.on('pong', () => { (ws as any).isAlive = true; }); console.log(`[v2] WS connected${user ? ` (user: ${user.username})` : ''}`); // ── Per-user boot greeting ── if (user?.workspace) { (async () => { try { const userBootSessionId = `boot-${user.username}`; setWorkspace(userBootSessionId, user.workspace); clearHistory(userBootSessionId); const userSnapshot = buildBootStartupSnapshot(user.workspace); const bootResult = await runBootMd(user.workspace, async (message, sessionId, sendSSE) => { const bootContext = [ 'CONTEXT: Internal startup BOOT.md turn. All data has been pre-fetched and is in the snapshot below.', 'Do NOT call any tools. Read the snapshot and write a 2-3 sentence startup summary IN KOREAN. MUST include: (1) the active_model name from the snapshot, (2) recent activity if any — not a generic greeting.', '[BOOT STARTUP SNAPSHOT - pre-fetched runtime data, no tools needed]', userSnapshot, '[/BOOT STARTUP SNAPSHOT]', ].join('\n\n'); const effectiveSessionId = sessionId || userBootSessionId; setWorkspace(effectiveSessionId, user.workspace); const result = await handleChat(message, effectiveSessionId, sendSSE, undefined, undefined, bootContext); return { text: result.text }; }); if (bootResult.status === 'ran' && bootResult.reply) { try { ws.send(JSON.stringify({ type: 'boot_greeting', text: bootResult.reply, sessionId: userBootSessionId })); } catch {} } } catch (err: any) { console.warn(`[boot-md] Per-user boot failed for ${user.username}:`, err?.message || err); } })(); } else if (_pendingBootGreeting && Date.now() < _pendingBootGreeting.expiresAt) { // Anonymous / legacy user: deliver the global boot greeting try { ws.send(JSON.stringify({ type: 'boot_greeting', text: _pendingBootGreeting.text, sessionId: _pendingBootGreeting.sessionId })); } catch {} _pendingBootGreeting = null; } // ── PTY terminal support ── const ptySessions = new Map(); // shellId → node-pty process const ptyImport = import('node-pty').catch(() => null); ws.on('message', async (d) => { let msg: any; try { msg = JSON.parse(d.toString('utf8')); } catch { return; } if (msg.type === 'pty_spawn') { const shellId = msg.shellId; if (!shellId || typeof shellId !== 'string') return; if (ptySessions.has(shellId)) return; // already spawned try { if (user?.username) { try { ensureUserWorkspace(user.username); } catch {} } const ptyModule = await ptyImport; if (!ptyModule) { ws.send(JSON.stringify({ type: 'pty_error', shellId, error: 'node-pty not available' })); return; } const pty = (ptyModule as any).default || ptyModule; const proc = pty.spawn(process.env.SHELL || '/bin/bash', [], { name: 'xterm-256color', cols: msg.cols || 120, rows: msg.rows || 30, cwd: msg.cwd || user?.workspace || process.cwd(), env: { ...process.env, TERM: 'xterm-256color' } as any, }); ptySessions.set(shellId, proc); proc.onData((data: string) => { try { ws.send(JSON.stringify({ type: 'pty_output', shellId, data })); } catch {} }); proc.onExit(({ exitCode }: { exitCode: number }) => { ptySessions.delete(shellId); try { ws.send(JSON.stringify({ type: 'pty_exit', shellId, exitCode })); } catch {} }); } catch (err: any) { try { ws.send(JSON.stringify({ type: 'pty_error', shellId, error: err.message })); } catch {} } } else if (msg.type === 'pty_input') { const proc = ptySessions.get(msg.shellId); if (proc) { try { proc.write(msg.data || ''); } catch {} } } else if (msg.type === 'pty_resize') { const proc = ptySessions.get(msg.shellId); if (proc) { try { proc.resize(msg.cols || 120, msg.rows || 30); } catch {} } } else if (msg.type === 'pty_kill') { const proc = ptySessions.get(msg.shellId); if (proc) { try { proc.kill(); } catch {} ptySessions.delete(msg.shellId); } } }); ws.on('close', () => { // Kill all PTY sessions for this connection for (const [shellId, proc] of ptySessions) { try { proc.kill(); } catch {} } ptySessions.clear(); console.log(`[v2] WS disconnected${user ? ` (user: ${user.username})` : ''}`); }); }); // Ping all clients every 30s to prevent proxy/OS idle-timeout (92s pattern) const wsPingInterval = setInterval(() => { if (!wss) return; wss.clients.forEach((ws: any) => { if (ws.isAlive === false) { ws.terminate(); return; } ws.isAlive = false; ws.ping(); }); }, 30000); const ollamaLocalKeepAliveInterval = startOllamaLocalKeepAlive(() => !!wss && wss.clients.size > 0); server.on('error', (err: any) => { if (err?.code === 'EADDRINUSE') { console.error(`[Gateway] Port ${HOST}:${PORT} is already in use.`); console.error('[Gateway] Another gateway instance is likely already running.'); console.error('[Gateway] Use one instance only, then open http://127.0.0.1:18789'); process.exit(1); return; } console.error('[Gateway] HTTP server error:', err?.message || err); process.exit(1); }); // Setup error response endpoint setupErrorResponseEndpoint(app); const encryptionKey = process.env.CREDENTIAL_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); const credentialHandler = initCredentialHandler(encryptionKey); const verificationFlowManager = getVerificationFlowManager(); const errorAnalyzer = getErrorAnalyzer(); const errorHistory = getErrorHistory(); const retryStrategy = getRetryStrategy(); const visualErrorDetector = getVisualErrorDetector(); const errorAudit = getErrorAudit(process.env.ERROR_AUDIT_LOG_PATH || path.join(CONFIG_DIR_PATH, 'logs', 'audit.log')); const contextInjectionManager = getContextInjectionManager(); console.log('[Server] ✅ Advanced error response systems initialized'); console.log(`[Server] - Credential Handler: ${encryptionKey.substring(0, 8)}...`); console.log('[Server] - Verification Flow Manager: Ready'); console.log('[Server] - Error Analyzer: Ready'); console.log('[Server] - Error History: Ready'); console.log('[Server] - Retry Strategy: Ready'); console.log('[Server] - Visual Error Detector: Ready'); console.log('[Server] - Error Audit: Ready'); console.log('[Server] - Context Injection Manager: Ready'); server.listen(PORT, HOST, async () => { // Detect GPU hardware once — logs a single clean line, caches result for // the lifetime of the process (used by /api/system-stats, no repeated probes). logGpuStatus(); const liveConfig = getConfig().getConfig(); const _liveProvider = (liveConfig as any).llm?.provider || 'ollama'; const _liveActiveModel = (liveConfig as any).llm?.providers?.[_liveProvider]?.model || liveConfig.models.primary || 'unknown'; const searchCfg = (liveConfig as any).search || {}; // HIGH-03: resolve vault references before checking presence — never log the key value itself const cm = getConfig(); const tavilyKey = cm.resolveSecret(searchCfg.tavily_api_key); const googleKey = cm.resolveSecret(searchCfg.google_api_key); const searxngUrl = (searchCfg.searxng_url || '').trim(); const hasSearch = tavilyKey ? '✓ Tavily' : googleKey ? '✓ Google' : searxngUrl ? '✓ SearXNG' : '✗ None (configure in Settings → Search)'; console.log(` ╔════════════════════════════════════════════════════════════════╗ ║ SmallClaw v2 Gateway (Native Tools) ║ ╠════════════════════════════════════════════════════════════════╣ ║ Tasks: Cron scheduler active, jobs at .smallclaw/cron/ ║ ║ Skills: ${String(skillsManager.getAll().length + ' loaded, ' + skillsManager.getEnabledSkills().length + ' enabled').padEnd(49)}║ ║ Search: ${hasSearch.padEnd(49)}║ ║ Memory: SOUL.md + IDENTITY.md + USER.md + MEMORY.md ║ ║ ║ ║ Web UI: http://${HOST}:${PORT} ║ ║ Model: ${_liveActiveModel.padEnd(45)}║ ║ Workspace: ${liveConfig.workspace.path.slice(0, 43).padEnd(45)}║ ╚════════════════════════════════════════════════════════════════╝ `); // Auto-connect enabled MCP servers getMCPManager().startEnabledServers().catch(err => console.warn('[MCP] Startup error:', err?.message)); // Preload the embedding model (background, non-blocking) so the first chat that needs // vector-memory recall isn't stuck behind a cold model load. warmupEmbedding(); // GPU voice engine (faster-whisper + OmniVoice) — only if configured as the active provider, // starts lazily in the background so a slow/failed GPU load never blocks gateway boot. const voiceCfg = (liveConfig as any).voice; if (voiceCfg?.enabled && (voiceCfg?.stt?.provider === 'whisper_gpu' || voiceCfg?.tts?.provider === 'omnivoice_gpu')) { import('../tools/voice-engine-client.js') .then((m) => m.ensureEngineRunning()) .then(() => console.log('[VoiceEngine] GPU voice engine ready.')) .catch((err: any) => console.warn('[VoiceEngine] Startup error (will retry on first request):', err?.message)); } cronScheduler.start(); console.log('[CronScheduler] Tick loop started — heartbeat:', cronScheduler.getConfig().enabled ? 'ON' : 'OFF'); initializeAgentSchedules(); console.log('[Scheduler] Agent cron schedules initialized.'); heartbeatRunner.start(); console.log('[HeartbeatRunner] Started — interval:', heartbeatRunner.getConfig().intervalMinutes, 'min'); userTelegramManager.startAll(); telegramChannel.start().then(() => { // Check if we just restarted after a self-update const selfUpdateStatusFile = path.join(require('os').homedir(), '.smallclaw', 'last_self_update.txt'); if (fs.existsSync(selfUpdateStatusFile)) { try { const statusContent = fs.readFileSync(selfUpdateStatusFile, 'utf-8').trim(); fs.unlinkSync(selfUpdateStatusFile); // consume it — only notify once if (statusContent.startsWith('UPDATE_SUCCESS')) { const lines = statusContent.split('\n'); const timestamp = lines[1] || ''; const msg = `✅ SmallClaw self-update complete!\n\nI ran the update, rebuilt, and have restarted the gateway. I'm back online and up to date.\n\n🕐 Updated at: ${timestamp.trim()}`; setTimeout(() => telegramChannel.sendToAllowed(msg).catch(() => {}), 3000); console.log('[Gateway] Post-update Telegram notification queued.'); } else if (statusContent.startsWith('UPDATE_FAILED')) { const lines = statusContent.split('\n'); const timestamp = lines[1] || ''; const msg = `❌ SmallClaw self-update failed.\n\nThe update process encountered an error. Gateway has restarted with the previous version. Check the terminal for details.\n\n🕐 Attempted at: ${timestamp.trim()}`; setTimeout(() => telegramChannel.sendToAllowed(msg).catch(() => {}), 3000); console.log('[Gateway] Post-update failure Telegram notification queued.'); } } catch (e: any) { console.warn('[Gateway] Could not read self-update status file:', e.message); } } }).catch(err => console.error('[Telegram] Start failed:', err.message)); scheduleTaskHeartbeat(); console.log('[TaskHeartbeat] Scheduled — interval:', loadTaskHeartbeatConfig().interval_minutes, 'min'); const bootWorkspace = getConfig().getWorkspacePath() || (getConfig().getConfig() as any).workspace?.path || ''; if (bootWorkspace) { loadWorkspaceHooks(bootWorkspace); hookBus .fire({ type: 'gateway:startup', workspacePath: bootWorkspace }) .catch((err: any) => console.warn('[hooks] gateway:startup error:', err?.message || err)); } // For every existing user account (created before multi-user workspace // isolation was added), copy their legacy global sessions into their // per-user directory and bootstrap their workspace from the global template. // The migration is idempotent — a .migrated marker file prevents re-runs. try { const users: string[] = listUsers(); // already defined in server scope for (const username of users) { if (!username || username === 'legacy') continue; try { ensureUserWorkspace(username); } catch { /* non-fatal */ } await migrateGlobalSessionsToUser(username); } if (users.length > 0) { console.log(`[Migration] Multi-user workspace migration complete for: ${users.join(', ')}`); } } catch (err: any) { console.warn('[Migration] Could not run multi-user migration:', err?.message || err); } }); let shuttingDown = false; function gracefulShutdown(signal: 'SIGINT' | 'SIGTERM'): void { if (shuttingDown) return; shuttingDown = true; console.log('[Gateway] Shutting down error response systems...'); try { credentialHandler.stop(); console.log('[Gateway] ✅ Credential handler stopped'); } catch (e) { console.error('[Gateway] Error:', e); } try { verificationFlowManager.stop(); console.log('[Gateway] ✅ Verification flow stopped'); } catch (e) { console.error('[Gateway] Error:', e); } console.log(`[Gateway] Received ${signal}; shutting down...`); try { skillsManager.persistState(); } catch {} try { telegramChannel.stop(); } catch {} try { getMCPManager().disconnectAll(); } catch {} try { cronScheduler.stop(); } catch {} try { stopAgentSchedules(); } catch {} try { heartbeatRunner.stop(); } catch {} try { if (wss) wss.close(); } catch {} try { server.close(() => process.exit(0)); const forceExitTimer = setTimeout(() => process.exit(0), 1200) as any; if (typeof forceExitTimer?.unref === 'function') forceExitTimer.unref(); } catch { process.exit(0); } } process.on('SIGINT', () => gracefulShutdown('SIGINT')); process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); export { app, server };