Files
homeclaw/src/gateway/server.ts
T
kimandClaude Sonnet 5 9641f689b8 feat: 로봇 프로젝트 관리 대시보드(robot-app) 추가
메카넘휠 이동로봇 프로젝트(파이5+RPLidar, 추후 팔 추가)의 부품리스트/
단계별 작업/예산을 관리하는 웹앱. doctor-app의 GET/PUT 단일문서 패턴을
재사용하되 케이스 목록 없이 단일 프로젝트 문서로 단순화했고, 첫 로드시
project_robot_dog.md 메모 기준 실제 진행상황을 기본 시드로 반환한다.

- src/gateway/routes/routes-robot.ts: GET/PUT /api/robot/project,
  사용자별 workspace/.smallclaw/robot-project.json에 원자적 저장
- web-ui/html/robot-app.html: 부품/작업/메모 3탭, 실시간 예산 요약,
  디바운스 자동저장
- index.html 🏠 홈 드롭다운에 🤖 로봇 링크 추가

장비연동/전용챗봇/외부데이터연동/CAD플러그인은 다음 단계로 보류.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:46:37 +09:00

4965 lines
211 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, TaskState> = 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<string, OrchestrationSessionStats> = new Map();
const preemptSessionCounts: Map<string, number> = 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<OrchestrationEvent, 'ts'>,
cfg: ReturnType<typeof getOrchestrationConfig>,
): 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<string, string> = {
'.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<string> = 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<string, any> = {
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<string, string>;
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<string, string>; channelEnabled: boolean; publicBaseUrl: string };
};
// HIGH-01 fix: resolve vault references when normalizing channel configs.
// Tokens stored as "vault:<key>" 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<string, string> = {};
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<string, TelegramChannel> = 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<string, { running: boolean; botUsername: string | null }> {
const out: Record<string, { running: boolean; botUsername: string | null }> = {};
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<boolean> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<nav[\s\S]*?<\/nav>/gi, '')
.replace(/<header[\s\S]*?<\/header>/gi, '')
.replace(/<footer[\s\S]*?<\/footer>/gi, '')
.replace(/<aside[\s\S]*?<\/aside>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/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<string> {
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<any>;
function handleTaskControlAction(sessionId: string, args: any): Promise<any> {
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(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/<think>[\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(/<think>([\s\S]*?)<\/think>/gi, (_m, inner) => {
const t = String(inner || '').trim();
if (t) blocks.push(t);
return '';
});
// Handle dangling open <think> blocks from partial model outputs.
const openIdx = cleaned.toLowerCase().lastIndexOf('<think>');
if (openIdx !== -1) {
const trailing = cleaned
.slice(openIdx + '<think>'.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[<model>].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[<model>].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<string>();
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<string, number>();
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<string | null> {
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<string, SessionInfo>();
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<string, SessionInfo> = {};
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<string, string> {
const header = req.headers.cookie || '';
const out: Record<string, string> = {};
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 "<appType>:<caseId>" 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<string, string> = {
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<string, string | string[]>).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. <configDir>/users/<username>/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 <configDir>/users/<username>/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<string, string> = {
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<string, string | string[]>).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<string, string | string[]>).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<typeof setTimeout> | 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<string, ReturnType<typeof setTimeout>>();
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<void> {
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=<name> — 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 <token>
// 2. X-Gateway-Token header → X-Gateway-Token: <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<string, any> = {};
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<boolean> {
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 = /<tr[^>]*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*<td>(\.[a-z0-9]+)<\/td>/i);
const dlM = row.match(/badge">([^<]+)</);
if (href && nameM) {
results.push({ title: nameM[1].trim(), url: href[1], ext: extM?.[1].trim() || '', downloads: dlM?.[1].trim() || '', site: 'guitarprotabs' });
pageCount++;
}
}
if (pageCount === 0) break; // 더 이상 결과 없음
}
if (!results.length) return { results: [], artistUrl, error: '아티스트를 찾을 수 없습니다' };
return { results, artistUrl };
}
async function gpSearchGprotab(q: string): Promise<{ results: any[]; searchUrl: string; error?: string }> {
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[] = [];
// <a href="/files/<letter>/<artist>/<slug>/<nums>-<nums>-<nums>-<id>" class="item">…title…</a>
const re = /<a href="(\/files\/[a-z]\/[^"]+\/\d+-\d+-\d+-(\d+))"[^>]*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: <a ... href="/slug-mid" ... title="filename.mid" ...> (attribute order varies)
const re = /<a\b([^>]*)>/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<string, any>(); // 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 };