메카넘휠 이동로봇 프로젝트(파이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>
4965 lines
211 KiB
TypeScript
4965 lines
211 KiB
TypeScript
/**
|
||
* 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  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: 
|
||
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(/ /g, ' ')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
|
||
// Truncate to fit in context — ~3000 chars is plenty for a 4B model
|
||
const maxChars = 3000;
|
||
if (text.length > maxChars) {
|
||
text = text.slice(0, maxChars) + '\n\n...(truncated — page had ' + text.length + ' chars total)';
|
||
}
|
||
|
||
if (text.length < 50) {
|
||
return `Page fetched but very little text content extracted. The page may be JavaScript-heavy (SPA). Try using browser_open instead.`;
|
||
}
|
||
|
||
return `Content from ${url}:\n\n${text}`;
|
||
} catch (err: any) {
|
||
if (err.name === 'AbortError') return 'Fetch timed out after 15s.';
|
||
return `Fetch error: ${err.message}`;
|
||
}
|
||
}
|
||
|
||
|
||
// UTM tag identifying homeclaw on Unsplash/Pexels referral links (per their guidelines).
|
||
const IMG_ATTRIBUTION_UTM = 'utm_source=homeclaw&utm_medium=referral';
|
||
|
||
/**
|
||
* Per Unsplash API guidelines: GET /photos/:id/download must be hit when the
|
||
* photo is actually used (displayed in our chat output). Fire-and-forget.
|
||
*/
|
||
function triggerUnsplashDownload(photoId: string, accessKey: string): void {
|
||
fetch(`https://api.unsplash.com/photos/${encodeURIComponent(photoId)}/download`, {
|
||
headers: { Authorization: `Client-ID ${accessKey}` },
|
||
signal: AbortSignal.timeout(5000),
|
||
}).catch(() => {});
|
||
}
|
||
|
||
async function imageSearch(query: string, count: number = 3): Promise<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 => `\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 . 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 };
|