| |
| """ |
| LACK v3.9.2 – STACK Edition (Complete Production Release) |
| Fixed: logError defined before first use in embedded Node.js server. |
| Fixed: Empty code block detection + JSON linter (no more "No linter configured for json"). |
| Full code moderation pipeline with /repo, /lint, /moderate commands. |
| Enhanced with small‑model resilience (JSON repair, fallbacks, forced commits). |
| """ |
|
|
| import os |
| import sys |
| import subprocess |
| import stat |
| import webbrowser |
| import threading |
| import time |
| import json |
| from pathlib import Path |
|
|
| VERSION = "3.9.2" |
|
|
| |
| |
| |
| SERVER_JS = r''' |
| const express = require('express'); |
| const path = require('path'); |
| const WebSocket = require('ws'); |
| const { v4: uuidv4 } = require('uuid'); |
| const fs = require('fs'); |
| const axios = require('axios'); |
| const cheerio = require('cheerio'); |
| const simpleGit = require('simple-git'); |
| const { exec } = require('child_process'); |
| const util = require('util'); |
| const execPromise = util.promisify(exec); |
| |
| // ==================== CONFIGURATION (early) ==================== |
| const configPath = path.join(__dirname, 'config', 'lack.config.json'); |
| let config; |
| try { |
| config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); |
| } catch (err) { |
| config = { |
| httpPort: 3721, |
| agents: [ |
| { id: "agent1", name: "Agent 1", model: "qwen2.5:0.5b", systemPrompt: "You are a helpful AI assistant.", channels: ["general","random","siphon","code"] }, |
| { id: "agent2", name: "Agent 2", model: "qwen2.5:0.5b", systemPrompt: "You are a creative AI.", channels: ["general","random","siphon","code"] } |
| ], |
| channels: [ |
| { id: "general", name: "general" }, |
| { id: "random", name: "random" }, |
| { id: "siphon", name: "siphon" }, |
| { id: "code", name: "code" } |
| ], |
| dms: [] |
| }; |
| fs.mkdirSync(path.join(__dirname, 'config'), { recursive: true }); |
| fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); |
| } |
| const PORT = config.httpPort || 3721; |
| const OLLAMA_URL = 'http://localhost:11434'; |
| const RESEARCH_DIR = path.join(__dirname, 'research'); |
| const LOG_DIR = path.join(__dirname, 'logs'); |
| const ERROR_LOG_PATH = path.join(LOG_DIR, 'error.log'); |
| fs.mkdirSync(LOG_DIR, { recursive: true }); |
| fs.mkdirSync(path.join(__dirname, 'lineage'), { recursive: true }); |
| const GIT = simpleGit(); |
| |
| // ==================== LOGGING & ERROR HANDLERS (before any usage) ==================== |
| global.errorLog = []; |
| function logError(errorObj) { |
| const entry = { timestamp: Date.now(), ...errorObj }; |
| try { fs.appendFileSync(ERROR_LOG_PATH, JSON.stringify(entry) + '\n'); } catch (e) {} |
| global.errorLog.unshift(entry); |
| if (global.errorLog.length > 200) global.errorLog.pop(); |
| console.error('[ERROR]', entry); |
| } |
| |
| process.on('uncaughtException', (err) => { |
| console.error('🔥 Uncaught Exception:', err); |
| if (typeof logError === 'function') logError({ context: 'uncaughtException', error: err.stack }); |
| }); |
| process.on('unhandledRejection', (reason, promise) => { |
| console.error('❌ Unhandled Rejection:', reason); |
| if (typeof logError === 'function') logError({ context: 'unhandledRejection', error: reason }); |
| }); |
| process.on('SIGTERM', () => { console.log('[LACK] SIGTERM received, shutting down...'); process.exit(0); }); |
| process.on('SIGINT', () => { console.log('[LACK] SIGINT received, shutting down...'); process.exit(0); }); |
| |
| // ==================== STACK CORE (can now safely call logError) ==================== |
| const STACK_ROOT = path.join(__dirname, 'lack_repos'); |
| const TEMPLATES_DIR = path.join(STACK_ROOT, 'templates'); |
| const MANIFEST_PATH = path.join(TEMPLATES_DIR, 'manifest.json'); |
| fs.mkdirSync(STACK_ROOT, { recursive: true }); |
| fs.mkdirSync(TEMPLATES_DIR, { recursive: true }); |
| |
| let stackManifest = {}; |
| |
| async function getEmbedding(text) { |
| try { |
| const res = await axios.post('http://localhost:11434/api/embeddings', { |
| model: 'nomic-embed-text', |
| prompt: text.slice(0, 2000) |
| }); |
| return res.data.embedding; |
| } catch (e) { |
| logError({ context: 'getEmbedding', error: e.message }); |
| return null; |
| } |
| } |
| |
| function cosineSimilarity(v1, v2) { |
| if (!v1 || !v2 || v1.length !== v2.length) return 0; |
| let dot = 0, mag1 = 0, mag2 = 0; |
| for (let i = 0; i < v1.length; i++) { |
| dot += v1[i] * v2[i]; |
| mag1 += v1[i] * v1[i]; |
| mag2 += v2[i] * v2[i]; |
| } |
| return dot / (Math.sqrt(mag1) * Math.sqrt(mag2)); |
| } |
| |
| async function scanAndReindexTemplates() { |
| if (!fs.existsSync(TEMPLATES_DIR)) return; |
| const items = fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true }); |
| const newManifest = {}; |
| for (const item of items) { |
| if (item.isDirectory()) { |
| const templatePath = path.join(TEMPLATES_DIR, item.name); |
| let combinedText = ''; |
| const files = {}; |
| const walk = (dir) => { |
| const entries = fs.readdirSync(dir, { withFileTypes: true }); |
| for (const e of entries) { |
| const full = path.join(dir, e.name); |
| if (e.isDirectory()) walk(full); |
| else if (/\.(js|json|md|txt|py|html|css)$/.test(e.name)) { |
| const content = fs.readFileSync(full, 'utf-8'); |
| combinedText += content + '\n'; |
| files[path.relative(templatePath, full)] = content; |
| } |
| } |
| }; |
| walk(templatePath); |
| if (combinedText.length === 0) continue; |
| const vector = await getEmbedding(combinedText); |
| if (vector) newManifest[item.name] = { vector, files }; |
| } |
| } |
| stackManifest = newManifest; |
| fs.writeFileSync(MANIFEST_PATH, JSON.stringify(stackManifest, null, 2)); |
| console.log(`[STACK] Reindexed ${Object.keys(stackManifest).length} templates`); |
| } |
| |
| async function stackBuild(repoName) { |
| const repoPath = path.join(STACK_ROOT, repoName); |
| if (fs.existsSync(repoPath)) return `⚠️ Repository ${repoName} already exists.`; |
| fs.mkdirSync(repoPath, { recursive: true }); |
| await execPromise(`git init && git checkout -b main`, { cwd: repoPath }); |
| const configFile = path.join(repoPath, 'STACK_CONFIG.json'); |
| fs.writeFileSync(configFile, JSON.stringify({ project: repoName, managedBy: "LACK-STACK" }, null, 2)); |
| return `✅ STACK repository **${repoName}** created at ${repoPath}`; |
| } |
| |
| async function stackAdd(intent, storeId) { |
| if (!fs.existsSync(MANIFEST_PATH)) return "No templates found. Add folders to ./lack_repos/templates/ first."; |
| const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8')); |
| let queryVec = await getEmbedding(intent); |
| let useFallback = !queryVec; |
| let best = null, bestScore = -1; |
| |
| if (useFallback) { |
| console.warn('[STACK] Embedding failed, using keyword fallback'); |
| const intentWords = intent.toLowerCase().split(/\s+/); |
| for (const [name, data] of Object.entries(manifest)) { |
| const combinedText = Object.values(data.files).join(' ').toLowerCase(); |
| let score = 0; |
| for (const word of intentWords) { |
| if (combinedText.includes(word)) score++; |
| } |
| if (score > bestScore) { bestScore = score; best = { name, files: data.files }; } |
| } |
| if (best && bestScore > 0) { |
| const activeRepo = activeStackRepo.get(storeId) || 'default'; |
| const repoPath = path.join(STACK_ROOT, activeRepo); |
| if (!fs.existsSync(repoPath)) return `No active repository. Use /stack set <repo> first.`; |
| for (const [relPath, content] of Object.entries(best.files)) { |
| const target = path.join(repoPath, relPath); |
| fs.mkdirSync(path.dirname(target), { recursive: true }); |
| fs.writeFileSync(target, content); |
| } |
| await execPromise(`git add . && git commit -m "STACK add: ${best.name}"`, { cwd: repoPath }).catch(e => console.warn(e)); |
| return `🔍 Keyword match: **${best.name}** (score ${bestScore}) – fallback used.\nApplied ${Object.keys(best.files).length} files to ${activeRepo}.`; |
| } else { |
| return `No template matches keywords: "${intent}".`; |
| } |
| } |
| |
| // normal vector search |
| for (const [name, data] of Object.entries(manifest)) { |
| const score = cosineSimilarity(queryVec, data.vector); |
| if (score > bestScore) { bestScore = score; best = { name, files: data.files }; } |
| } |
| if (best && bestScore > 0.45) { |
| const activeRepo = activeStackRepo.get(storeId) || 'default'; |
| const repoPath = path.join(STACK_ROOT, activeRepo); |
| if (!fs.existsSync(repoPath)) return `No active repository. Use /stack set <repo> first.`; |
| for (const [relPath, content] of Object.entries(best.files)) { |
| const target = path.join(repoPath, relPath); |
| fs.mkdirSync(path.dirname(target), { recursive: true }); |
| fs.writeFileSync(target, content); |
| } |
| await execPromise(`git add . && git commit -m "STACK add: ${best.name}"`, { cwd: repoPath }).catch(e => console.warn(e)); |
| return `🔍 Best match: **${best.name}** (score ${bestScore.toFixed(2)})\nApplied ${Object.keys(best.files).length} files to ${activeRepo}.`; |
| } else { |
| return `No strong match for "${intent}" (best score ${bestScore.toFixed(2)}).`; |
| } |
| } |
| |
| async function stackImport(jsonPath) { |
| const fullPath = path.resolve(jsonPath); |
| if (!fs.existsSync(fullPath)) return `File not found: ${fullPath}`; |
| const data = JSON.parse(fs.readFileSync(fullPath, 'utf-8')); |
| if (!data.templates) return "Invalid format: missing 'templates' key."; |
| for (const [name, template] of Object.entries(data.templates)) { |
| const dir = path.join(TEMPLATES_DIR, name); |
| fs.mkdirSync(dir, { recursive: true }); |
| for (const [file, content] of Object.entries(template.files || {})) { |
| const target = path.join(dir, file); |
| fs.mkdirSync(path.dirname(target), { recursive: true }); |
| fs.writeFileSync(target, content); |
| } |
| } |
| await scanAndReindexTemplates(); |
| return `Imported ${Object.keys(data.templates).length} templates.`; |
| } |
| |
| let stackWatcher = null; |
| function startStackWatcher() { |
| if (stackWatcher) clearInterval(stackWatcher); |
| stackWatcher = setInterval(async () => { |
| const oldKeys = Object.keys(stackManifest); |
| await scanAndReindexTemplates(); |
| const newKeys = Object.keys(stackManifest); |
| if (JSON.stringify(oldKeys) !== JSON.stringify(newKeys)) |
| console.log('[STACK] Templates changed, reindexed.'); |
| }, 10000); |
| } |
| |
| const activeStackRepo = new Map(); |
| |
| // ==================== HARDENED JSON EXTRACTION (with repair) ==================== |
| function repairJSON(str) { |
| if (!str || typeof str !== 'string') return str; |
| // 1. Remove markdown fences |
| str = str.replace(/```json\s*|```\s*/g, ''); |
| // 2. Add missing quotes around unquoted keys |
| str = str.replace(/(\{|\,)\s*([a-zA-Z0-9_]+)\s*\:/g, '$1"$2":'); |
| // 3. Fix trailing commas |
| str = str.replace(/,\s*\}/g, '}').replace(/,\s*\]/g, ']'); |
| // 4. Replace single quotes with double quotes (but careful inside strings) |
| str = str.replace(/'/g, '"'); |
| // 5. Add missing closing braces/brackets |
| let openBraces = (str.match(/\{/g) || []).length; |
| let closeBraces = (str.match(/\}/g) || []).length; |
| let openBrackets = (str.match(/\[/g) || []).length; |
| let closeBrackets = (str.match(/\]/g) || []).length; |
| str += '}'.repeat(openBraces - closeBraces) + ']'.repeat(openBrackets - closeBrackets); |
| return str; |
| } |
| |
| function extractJSON(str) { |
| str = repairJSON(str); |
| if (!str || typeof str !== 'string') return null; |
| str = str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); |
| const jsonBlock = str.match(/```json\s*([\s\S]*?)\s*```/); |
| if (jsonBlock && jsonBlock[1]) { |
| try { return JSON.parse(jsonBlock[1].trim()); } catch(e) {} |
| } |
| let start = str.indexOf('{'); |
| if (start === -1) return null; |
| let depth = 0, end = -1; |
| for (let i = start; i < str.length; i++) { |
| if (str[i] === '{') depth++; |
| else if (str[i] === '}') { depth--; if (depth === 0) { end = i; break; } } |
| } |
| if (end !== -1) { |
| try { return JSON.parse(str.substring(start, end + 1)); } catch(e) { |
| let fragment = str.substring(start, end + 1); |
| let opens = (fragment.match(/\{/g) || []).length - (fragment.match(/\}/g) || []).length; |
| let arrOpens = (fragment.match(/\[/g) || []).length - (fragment.match(/\]/g) || []).length; |
| fragment += ']'.repeat(Math.max(0, arrOpens)) + '}'.repeat(Math.max(0, opens)); |
| try { return JSON.parse(fragment); } catch(e2) {} |
| } |
| } |
| const possible = str.match(/\{[\s\S]*\}/); |
| if (possible) { |
| try { return JSON.parse(possible[0]); } catch(e) {} |
| } |
| return null; |
| } |
| |
| // ==================== FORCE CODE BLOCKS FROM PLAIN TEXT ==================== |
| function ensureCodeBlock(text, language) { |
| if (text.includes('```')) return text; |
| // Heuristics: looks like code |
| if (text.includes('<html') || text.includes('def ') || text.includes('function(') || |
| text.includes('class ') || text.includes('import ') || text.includes('require(')) { |
| return '```' + (language || 'text') + '\n' + text + '\n```'; |
| } |
| return text; |
| } |
| |
| // ==================== TOOL DEFINITIONS & SANDBOX ==================== |
| const WORKSPACE_ROOT = path.join(__dirname, 'workspace'); |
| fs.mkdirSync(WORKSPACE_ROOT, { recursive: true }); |
| |
| function securePath(relPath) { |
| const safe = path.resolve(WORKSPACE_ROOT, relPath); |
| if (!safe.startsWith(WORKSPACE_ROOT)) throw new Error("Path traversal blocked"); |
| return safe; |
| } |
| |
| async function executeTool(toolName, args) { |
| try { |
| switch (toolName) { |
| case 'read_file': { |
| const target = securePath(args.path); |
| return fs.readFileSync(target, 'utf-8'); |
| } |
| case 'write_file': { |
| const target = securePath(args.path); |
| fs.mkdirSync(path.dirname(target), { recursive: true }); |
| fs.writeFileSync(target, args.content, 'utf-8'); |
| await gitCommit(`Agent wrote ${args.path}`); |
| return `✅ Wrote ${args.path} (${args.content.length} chars)`; |
| } |
| case 'execute_command': { |
| if (/rm -rf|sudo|rmdir/.test(args.command)) return "⛔ Command blocked for safety."; |
| const { stdout, stderr } = await execPromise(args.command, { cwd: WORKSPACE_ROOT, timeout: 15000 }); |
| return stdout || stderr || "Command executed (no output)"; |
| } |
| default: return `❌ Unknown tool: ${toolName}`; |
| } |
| } catch (err) { |
| logError({ context: 'executeTool', tool: toolName, error: err.message }); |
| return `⚠️ Tool error: ${err.message}`; |
| } |
| } |
| |
| const SKILLS_DIR = path.join(WORKSPACE_ROOT, '.skills'); |
| fs.mkdirSync(SKILLS_DIR, { recursive: true }); |
| |
| function loadSkills() { |
| if (!fs.existsSync(SKILLS_DIR)) return ''; |
| const files = fs.readdirSync(SKILLS_DIR).filter(f => f.endsWith('.md')); |
| return files.map(f => fs.readFileSync(path.join(SKILLS_DIR, f), 'utf-8')).join('\n\n---\n\n'); |
| } |
| |
| async function saveSkill(summary, toolCalls) { |
| const content = `# Skill ${new Date().toISOString()}\n\n**Summary:** ${summary}\n**Tools used:** ${toolCalls.map(t => t.name).join(', ')}\n\`\`\`json\n${JSON.stringify(toolCalls, null, 2)}\n\`\`\``; |
| await executeTool('write_file', { path: `.skills/skill_${Date.now()}.md`, content }); |
| } |
| |
| // ==================== GIT HELPERS ==================== |
| async function ensureGitRepo() { |
| try { |
| if (!fs.existsSync(RESEARCH_DIR)) fs.mkdirSync(RESEARCH_DIR, { recursive: true }); |
| const gitDir = path.join(RESEARCH_DIR, '.git'); |
| if (!fs.existsSync(gitDir)) { |
| await GIT.cwd(RESEARCH_DIR).init(); |
| await GIT.cwd(RESEARCH_DIR).addConfig('user.name', 'LACK SIPHON'); |
| await GIT.cwd(RESEARCH_DIR).addConfig('user.email', 'lack@localhost'); |
| await GIT.cwd(RESEARCH_DIR).commit('Initial research repo', { '--allow-empty': null }); |
| console.log('[LACK] Git repo initialised at', RESEARCH_DIR); |
| } |
| } catch (err) { console.error('Git init failed:', err.message); } |
| } |
| async function gitCommit(message) { |
| try { |
| const gitDir = path.join(RESEARCH_DIR, '.git'); |
| if (!fs.existsSync(gitDir)) { |
| console.warn('[LACK] Git repo missing – re-initialising before commit'); |
| await ensureGitRepo(); |
| } |
| await GIT.cwd(RESEARCH_DIR).add('.'); |
| const status = await GIT.cwd(RESEARCH_DIR).status(); |
| if (status.files.length > 0) { |
| await GIT.cwd(RESEARCH_DIR).commit(message); |
| console.log(`Git commit: ${message}`); |
| } |
| } catch (e) { throw new Error(`Git commit failed: ${e.message}`); } |
| } |
| |
| // ==================== DATA STRUCTURES ==================== |
| const channels = new Map(); |
| const agents = new Map(); |
| const clients = new Map(); |
| const researchSessions = new Map(); |
| const slimeSessions = new Map(); |
| const dms = new Map(); |
| const pinnedMessages = new Map(); |
| const userReactions = new Map(); |
| const agentMetrics = new Map(); |
| const jsonFailCount = new Map(); |
| |
| const projectStates = new Map(); |
| function getProjectState(storeId) { |
| return projectStates.get(storeId) || { active: false, title: null, goals: [], nextSteps: [], completedTasks: [], memory: {} }; |
| } |
| function setProjectState(storeId, state) { |
| projectStates.set(storeId, { ...state }); |
| persistProjectState(storeId); |
| } |
| |
| const ralphActive = new Map(); |
| const ralphGenerations = new Map(); |
| const ralphGoals = new Map(); |
| const ralphTimers = new Map(); |
| const ralphCancel = new Map(); |
| const ralphStagnation = new Map(); |
| const ralphNextAgentIdx = new Map(); |
| const ralphLastBroadcast = new Map(); |
| |
| function getUserId(ws) { |
| let client = clients.get(ws); |
| if (!client) { |
| const id = `human_${uuidv4().slice(0,4)}`; |
| clients.set(ws, { username: id, channelId: 'general', userId: id, dmId: null, openThreadId: null }); |
| client = clients.get(ws); |
| } |
| return client.userId; |
| } |
| |
| // Initialize channels |
| config.channels.forEach(ch => { |
| channels.set(ch.id, { |
| id: ch.id, name: ch.name, messages: [], |
| researchActive: false, researchTopic: null, abstractActive: false, |
| loopTimer: null, pinned: new Set() |
| }); |
| }); |
| if (config.dms) { |
| config.dms.forEach(dm => { |
| dms.set(dm.id, { |
| id: dm.id, participants: dm.participants, |
| name: dm.name || dm.participants.join(', '), messages: [] |
| }); |
| }); |
| } |
| |
| function generateSyntheticMetrics() { |
| const now = Date.now(); |
| return { |
| cpu: Array(60).fill(0).map((_, i) => 15 + Math.sin(i * 0.2) * 10), |
| mem: Array(60).fill(0).map((_, i) => 20 + Math.sin(i * 0.1) * 5), |
| activity: Array(60).fill(0).map((_, i) => 30 + Math.cos(i * 0.3) * 15), |
| timestamps: Array(60).fill(0).map((_, i) => now - (59 - i) * 3000) |
| }; |
| } |
| |
| // ==================== MODERATOR AGENT (embed‑only, with code moderation) ==================== |
| const THREAD_REPO_ROOT = path.join(__dirname, 'thread_repos'); |
| const CODE_BLOCK_REGEX = /```(\w*)\n([\s\S]*?)```/g; |
| const LINT_TIMEOUT_MS = 10000; |
| |
| function getThreadRepoPath(threadId) { |
| return path.join(THREAD_REPO_ROOT, threadId); |
| } |
| async function ensureThreadRepo(threadId) { |
| const repoPath = getThreadRepoPath(threadId); |
| if (!fs.existsSync(repoPath)) { |
| fs.mkdirSync(repoPath, { recursive: true }); |
| await execPromise(`git init && git checkout -b main`, { cwd: repoPath }); |
| const readme = `# Thread: ${threadId}\nCreated: ${new Date().toISOString()}\n\n## Files\n<!-- auto-generated -->`; |
| fs.writeFileSync(path.join(repoPath, 'README.md'), readme); |
| await execPromise(`git add . && git commit -m "Initialize thread repository"`, { cwd: repoPath }); |
| console.log(`[MODERATOR] Created repo for thread ${threadId}`); |
| } |
| return repoPath; |
| } |
| async function updateThreadReadme(threadId, files) { |
| const repoPath = getThreadRepoPath(threadId); |
| const readmePath = path.join(repoPath, 'README.md'); |
| let content = fs.readFileSync(readmePath, 'utf-8'); |
| let filesSection = content.includes('## Files') ? '' : '\n## Files\n'; |
| for (const file of files) { |
| if (!content.includes(file.name)) { |
| filesSection += `- \`${file.name}\` - ${file.lang} - ${new Date(file.timestamp).toLocaleString()}\n`; |
| } |
| } |
| if (filesSection) { |
| fs.writeFileSync(readmePath, content + filesSection); |
| await execPromise(`git add README.md && git commit -m "Update README with ${files.length} new file(s)"`, { cwd: repoPath }); |
| } |
| } |
| |
| async function runLinter(language, filePath) { |
| const config = agents.get('moderator')?.moderationConfig?.lintConfigs || {}; |
| const errors = []; |
| const warnings = []; |
| let passed = true; |
| |
| try { |
| switch (language.toLowerCase()) { |
| case 'python': |
| try { |
| const { stdout, stderr } = await execPromise(`python -m py_compile "${filePath}" 2>&1`, { timeout: LINT_TIMEOUT_MS }); |
| if (stderr || (stdout && stdout.includes('SyntaxError'))) { |
| const lines = (stderr || stdout).split('\n'); |
| for (const line of lines) { |
| if (line.includes('SyntaxError')) errors.push(line.trim()); |
| else if (line.trim()) warnings.push(line.trim()); |
| } |
| passed = errors.length === 0; |
| } |
| } catch(e) { |
| errors.push(e.stderr || e.message); |
| passed = false; |
| } |
| break; |
| |
| case 'javascript': |
| try { |
| const { stdout } = await execPromise(`node -c "${filePath}" 2>&1`, { timeout: LINT_TIMEOUT_MS }); |
| if (stdout && stdout.includes('SyntaxError')) { |
| errors.push(stdout.trim()); |
| passed = false; |
| } |
| } catch(e) { |
| errors.push(e.stderr || e.message); |
| passed = false; |
| } |
| break; |
| |
| case 'html': |
| const content = fs.readFileSync(filePath, 'utf-8'); |
| const openTags = (content.match(/<([a-z][a-z0-9]*)[^>]*>/gi) || []).length; |
| const closeTags = (content.match(/<\/([a-z][a-z0-9]*)>/gi) || []).length; |
| if (openTags !== closeTags) { |
| errors.push(`Tag mismatch: ${openTags} opening vs ${closeTags} closing tags`); |
| passed = false; |
| } |
| break; |
| |
| case 'json': |
| case 'jsonc': |
| try { |
| JSON.parse(fs.readFileSync(filePath, 'utf-8')); |
| passed = true; |
| } catch (e) { |
| errors.push(`JSON parse error: ${e.message}`); |
| passed = false; |
| } |
| break; |
| |
| default: |
| warnings.push(`No linter configured for ${language}`); |
| passed = true; |
| } |
| } catch (err) { |
| errors.push(`Linter error: ${err.message}`); |
| passed = false; |
| } |
| |
| return { passed, errors: errors.slice(0, 10), warnings: warnings.slice(0, 5) }; |
| } |
| |
| function suggestFilename(language, code, index) { |
| const langMap = { |
| 'python': '.py', 'javascript': '.js', 'html': '.html', |
| 'css': '.css', 'json': '.json', 'markdown': '.md', |
| 'bash': '.sh', 'text': '.txt', 'xml': '.xml', 'yaml': '.yml' |
| }; |
| const firstLine = code.split('\n')[0]; |
| let suggested = `code_${Date.now()}_${index}`; |
| if (firstLine.includes('#!/usr/bin/env python')) suggested = 'script.py'; |
| else if (firstLine.includes('#!/bin/bash')) suggested = 'script.sh'; |
| else if (code.includes('<!DOCTYPE html>')) suggested = 'index.html'; |
| else if (code.includes('{') && language === 'json') suggested = 'data.json'; |
| else if (code.includes('def ') || code.includes('class ')) suggested = 'module.py'; |
| else if (code.includes('function ') || code.includes('=>')) suggested = 'function.js'; |
| const ext = langMap[language.toLowerCase()] || '.txt'; |
| return suggested.endsWith(ext) ? suggested : suggested + ext; |
| } |
| |
| function buildModeratorFeedback(agentId, results, threadId, repoPath) { |
| const passedCount = results.filter(r => r.passed).length; |
| const totalCount = results.length; |
| const allPassed = passedCount === totalCount; |
| |
| let feedback = `🛡️ **Moderator Review** (agent: ${agentId})\n`; |
| feedback += `Thread: \`${threadId}\` | Repo: \`${repoPath}\`\n\n`; |
| |
| for (const r of results) { |
| const icon = r.passed ? '✅' : '❌'; |
| feedback += `${icon} **${r.filename}** (${r.language})\n`; |
| |
| if (r.errors.length > 0) { |
| feedback += ` **Errors:**\n`; |
| for (const err of r.errors.slice(0, 3)) { |
| feedback += ` - \`${err.substring(0, 80)}\`\n`; |
| } |
| } |
| |
| if (r.warnings.length > 0) { |
| feedback += ` **Warnings:**\n`; |
| for (const warn of r.warnings.slice(0, 2)) { |
| feedback += ` - ${warn}\n`; |
| } |
| } |
| |
| if (r.commitHash && r.passed) { |
| feedback += ` 📦 Committed as \`${r.commitHash}\`\n`; |
| } else if (r.commitHash && !r.passed) { |
| feedback += ` ⚠️ Committed with errors (review needed) \`${r.commitHash}\`\n`; |
| } else if (!r.passed) { |
| feedback += ` 🔧 **Suggested fixes:**\n`; |
| feedback += ` - Check syntax near reported lines\n`; |
| feedback += ` - Run locally before re-submitting\n`; |
| } |
| feedback += '\n'; |
| } |
| |
| if (allPassed) { |
| feedback += `🎉 **All ${totalCount} file(s) validated and committed.**\n`; |
| feedback += `Use \`/repo ${threadId}\` to browse the repository.`; |
| } else { |
| feedback += `⚠️ **${totalCount - passedCount}/${totalCount} files failed validation.**\n`; |
| feedback += `@${agentId} please fix the errors above and re-submit the code block.`; |
| } |
| |
| return feedback; |
| } |
| |
| async function moderateCodeFromAgent(agentId, channelId, responseText, parentId) { |
| if (agentId === 'moderator') return null; |
| |
| const moderatorAgent = agents.get('moderator'); |
| if (!moderatorAgent || !moderatorAgent.isCodeModerator) return null; |
| |
| const codeBlocks = []; |
| let match; |
| while ((match = CODE_BLOCK_REGEX.exec(responseText)) !== null) { |
| codeBlocks.push({ |
| language: match[1] || 'text', |
| code: match[2].trim(), |
| fullMatch: match[0] |
| }); |
| } |
| |
| if (codeBlocks.length === 0) return null; |
| |
| const threadId = parentId || channelId; |
| await ensureThreadRepo(threadId); |
| const repoPath = getThreadRepoPath(threadId); |
| |
| const results = []; |
| for (let i = 0; i < codeBlocks.length; i++) { |
| const block = codeBlocks[i]; |
| |
| if (!block.code) { |
| results.push({ |
| filename: suggestFilename(block.language, '', i), |
| language: block.language, |
| passed: false, |
| errors: ['Empty code block – please provide the complete code.'], |
| warnings: [], |
| commitHash: null, |
| fullCode: '' |
| }); |
| continue; |
| } |
| |
| const suggestedFilename = suggestFilename(block.language, block.code, i); |
| const filePath = path.join(repoPath, suggestedFilename); |
| |
| fs.writeFileSync(filePath, block.code, 'utf-8'); |
| const lintResult = await runLinter(block.language, filePath); |
| |
| let commitHash = null; |
| // Always commit, even if lint fails – mark in commit message |
| try { |
| await execPromise(`git add "${suggestedFilename}"`, { cwd: repoPath }); |
| let commitMsg; |
| if (lintResult.passed || !moderatorAgent.moderationConfig.requireLintPass) { |
| commitMsg = `[MODERATOR] Add ${suggestedFilename} from agent ${agentId}\nErrors: ${lintResult.errors.length} | Warnings: ${lintResult.warnings.length}`; |
| } else { |
| commitMsg = `[FAILED] ${suggestedFilename} from agent ${agentId} – lint errors\nErrors: ${lintResult.errors.join('; ')}`; |
| } |
| const { stdout } = await execPromise(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, { cwd: repoPath }); |
| commitHash = stdout.split('[')[1]?.split(']')[0] || 'unknown'; |
| } catch(e) { console.warn('Git commit failed:', e.message); } |
| |
| results.push({ |
| filename: suggestedFilename, |
| language: block.language, |
| passed: lintResult.passed, |
| errors: lintResult.errors, |
| warnings: lintResult.warnings, |
| commitHash, |
| fullCode: block.code.substring(0, 500) + (block.code.length > 500 ? '...' : '') |
| }); |
| } |
| |
| await updateThreadReadme(threadId, results.map(r => ({ |
| name: r.filename, |
| lang: r.language, |
| timestamp: Date.now(), |
| passed: r.passed |
| }))); |
| |
| const feedback = buildModeratorFeedback(agentId, results, threadId, repoPath); |
| await handleAgentResponse(moderatorAgent, channelId, feedback, parentId); |
| |
| if (channelId === 'siphon') { |
| const siphonLogPath = path.join(repoPath, 'MODERATION_LOG.md'); |
| const logEntry = `## ${new Date().toISOString()} | Agent: ${agentId}\n${feedback}\n\n---\n`; |
| fs.appendFileSync(siphonLogPath, logEntry); |
| await execPromise(`git add MODERATION_LOG.md && git commit -m "Update moderation log"`, { cwd: repoPath }).catch(()=>{}); |
| } |
| |
| return results; |
| } |
| |
| const moderator = { |
| id: "moderator", |
| name: "Moderator", |
| model: "nomic-embed-text", |
| systemPrompt: "Embedding only – not for chat.", |
| channels: ["general", "random", "siphon", "code"], |
| isEmbedOperator: true, |
| isCodeModerator: true, |
| lastResponseTime: new Map(), |
| status: 'online', |
| statusMessage: 'embed-only + code moderation', |
| moderationConfig: { |
| autoCorrect: false, |
| requireLintPass: true, |
| maxFileSizeBytes: 50000, |
| allowedLanguages: ['python', 'javascript', 'html', 'css', 'json', 'markdown', 'text'], |
| lintConfigs: { |
| python: 'pyflakes', |
| javascript: 'eslint --quiet', |
| html: 'htmlhint' |
| } |
| } |
| }; |
| agents.set("moderator", moderator); |
| agentMetrics.set("moderator", generateSyntheticMetrics()); |
| jsonFailCount.set("moderator", 0); |
| |
| // Normal agents from config |
| config.agents.forEach(agentCfg => { |
| agents.set(agentCfg.id, { |
| ...agentCfg, lastResponseTime: new Map(), status: 'online', statusMessage: '' |
| }); |
| agentMetrics.set(agentCfg.id, generateSyntheticMetrics()); |
| jsonFailCount.set(agentCfg.id, 0); |
| }); |
| |
| let globalMaxMem = 0; |
| function updateAgentMetrics(agentId, responseTimeMs = 0, wasActive = false) { |
| const metrics = agentMetrics.get(agentId); |
| if (!metrics) return; |
| const cpuVal = Math.min(100, Math.max(5, Math.floor(responseTimeMs / 80))); |
| const activityVal = wasActive ? 85 : 20; |
| const memUsageMB = process.memoryUsage().rss / (1024 * 1024); |
| if (memUsageMB > globalMaxMem) globalMaxMem = memUsageMB; |
| const memScale = Math.max(128, globalMaxMem); |
| const memVal = Math.min(100, Math.floor((memUsageMB / memScale) * 100)); |
| metrics.cpu.push(cpuVal); metrics.cpu.shift(); |
| metrics.activity.push(activityVal); metrics.activity.shift(); |
| metrics.mem.push(memVal); metrics.mem.shift(); |
| metrics.timestamps.push(Date.now()); metrics.timestamps.shift(); |
| agentMetrics.set(agentId, metrics); |
| } |
| |
| setInterval(() => { |
| for (let [agentId, metrics] of agentMetrics.entries()) { |
| metrics.cpu = metrics.cpu.map(v => Math.max(5, v - 3)); |
| metrics.activity = metrics.activity.map(v => { |
| let newV = Math.max(5, v - 8); |
| if (Math.random() < 0.25) newV = Math.max(newV, 20 + Math.random() * 25); |
| return newV; |
| }); |
| metrics.mem = metrics.mem.map(v => Math.max(5, v - 1)); |
| metrics.timestamps.push(Date.now()); metrics.timestamps.shift(); |
| agentMetrics.set(agentId, metrics); |
| } |
| }, 3000); |
| |
| // ==================== EVENTSTORE (LINEAGE) ==================== |
| function getLineagePath(storeId) { return path.join(__dirname, 'lineage', `${storeId}.jsonl`); } |
| function appendEvent(storeId, event) { |
| try { fs.appendFileSync(getLineagePath(storeId), JSON.stringify(event) + '\n'); } catch (e) {} |
| } |
| function reconstructLineage(storeId) { |
| const filePath = getLineagePath(storeId); |
| if (!fs.existsSync(filePath)) return []; |
| return fs.readFileSync(filePath, 'utf-8').split('\n').filter(l => l.trim()).map(l => JSON.parse(l)); |
| } |
| function persistProjectState(storeId) { |
| appendEvent(storeId, { type: 'project_state', timestamp: Date.now(), state: getProjectState(storeId) }); |
| } |
| function loadProjectStateFromLineage(storeId) { |
| const lineage = reconstructLineage(storeId); |
| for (let i = lineage.length-1; i >= 0; i--) { |
| if (lineage[i].type === 'project_state') { |
| setProjectState(storeId, lineage[i].state); |
| return true; |
| } |
| } |
| return false; |
| } |
| function persistRalphState(storeId) { |
| appendEvent(storeId, { |
| type: 'ralph_state', timestamp: Date.now(), |
| generation: ralphGenerations.get(storeId), goal: ralphGoals.get(storeId), active: ralphActive.get(storeId) |
| }); |
| } |
| function loadRalphStateFromLineage(storeId) { |
| const lineage = reconstructLineage(storeId); |
| for (let i = lineage.length-1; i >= 0; i--) { |
| if (lineage[i].type === 'ralph_state') { |
| ralphGenerations.set(storeId, lineage[i].generation); |
| ralphGoals.set(storeId, lineage[i].goal); |
| ralphActive.set(storeId, lineage[i].active); |
| return true; |
| } |
| } |
| return false; |
| } |
| function pruneLineageFiles() { |
| const lineageDir = path.join(__dirname, 'lineage'); |
| if (!fs.existsSync(lineageDir)) return; |
| const now = Date.now(); |
| const maxAge = 7 * 24 * 60 * 60 * 1000; |
| fs.readdirSync(lineageDir).forEach(file => { |
| const filePath = path.join(lineageDir, file); |
| const stats = fs.statSync(filePath); |
| if (now - stats.mtimeMs > maxAge) { |
| fs.unlinkSync(filePath); |
| console.log(`[LACK] Pruned old lineage: ${file}`); |
| } |
| }); |
| } |
| |
| // ==================== MESSAGE HELPERS ==================== |
| function addMessage(storeId, sender, senderType, content, parentId = null) { |
| let store = channels.get(storeId) || dms.get(storeId); |
| if (!store) return null; |
| let threadId = null; |
| if (parentId) { |
| const parent = store.messages.find(m => m.id === parentId); |
| threadId = parent ? (parent.threadId || parent.id) : parentId; |
| } |
| const msg = { |
| id: uuidv4(), sender, senderType, content, timestamp: Date.now(), |
| parentId: parentId || null, threadId, replyCount: 0, reactions: {} |
| }; |
| store.messages.push(msg); |
| if (store.messages.length > 1000) store.messages.shift(); |
| if (parentId) { |
| const parent = store.messages.find(m => m.id === parentId); |
| if (parent) { |
| parent.replyCount = (parent.replyCount || 0) + 1; |
| if (!parent.threadId) parent.threadId = parent.id; |
| } |
| } |
| appendEvent(storeId, { type: 'message', timestamp: msg.timestamp, message: { id: msg.id, sender, senderType, content, parentId, threadId } }); |
| return msg; |
| } |
| |
| function getThreadMessages(storeId, threadId) { |
| const store = channels.get(storeId) || dms.get(storeId); |
| if (!store) return []; |
| const rootId = store.messages.find(m => m.id === threadId)?.threadId || threadId; |
| return store.messages.filter(m => m.threadId === rootId || m.id === rootId); |
| } |
| |
| function broadcastToStore(storeId, message, excludeWs = null) { |
| const isChannel = channels.has(storeId); |
| for (let [ws, client] of clients.entries()) { |
| if (ws === excludeWs) continue; |
| if (ws.readyState !== WebSocket.OPEN) continue; |
| if (isChannel && client.channelId === storeId) { |
| ws.send(JSON.stringify({ type: 'new_message', channelId: storeId, message })); |
| } else if (!isChannel && client.dmId === storeId) { |
| ws.send(JSON.stringify({ type: 'new_dm_message', dmId: storeId, message })); |
| } |
| } |
| } |
| |
| function broadcastThreadUpdate(storeId, threadId, excludeWs = null) { |
| const threadMsgs = getThreadMessages(storeId, threadId); |
| for (let [ws, client] of clients.entries()) { |
| if (ws !== excludeWs && client.openThreadId === threadId && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ type: 'thread_update', storeId, threadId, messages: threadMsgs })); |
| } |
| } |
| } |
| |
| function broadcastAgents() { |
| let agentList = Array.from(agents.values()); |
| agentList.sort((a,b) => (a.id === "moderator" ? -1 : b.id === "moderator" ? 1 : 0)); |
| const slim = agentList.map(a => ({ |
| id: a.id, name: a.name, model: a.model, |
| systemPrompt: a.systemPrompt, channels: a.channels, |
| status: a.status, statusMessage: a.statusMessage |
| })); |
| for (let [ws] of clients.entries()) { |
| if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'agents_list', agents: slim })); |
| } |
| } |
| |
| function broadcastRalphStatus(storeId) { |
| const now = Date.now(); |
| const last = ralphLastBroadcast.get(storeId) || 0; |
| if (now - last < 800) return; |
| ralphLastBroadcast.set(storeId, now); |
| const active = ralphActive.get(storeId); |
| const gen = ralphGenerations.get(storeId) || 0; |
| const goal = ralphGoals.get(storeId) || ''; |
| const snippet = goal.length > 30 ? goal.substring(0,30)+'…' : goal; |
| for (let [ws, client] of clients.entries()) { |
| if (ws.readyState === WebSocket.OPEN && (client.channelId === storeId || client.dmId === storeId)) { |
| ws.send(JSON.stringify({ type: 'ralph_status', storeId, active, generation: gen, goal: snippet })); |
| } |
| } |
| } |
| |
| function broadcastDMs(userId) { |
| const userDMs = getUserDMs(userId); |
| for (let [ws, client] of clients.entries()) { |
| if (client.userId === userId && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ type: 'dms', dms: userDMs })); |
| } |
| } |
| } |
| |
| // ==================== OLLAMA CIRCUIT‑BREAKER ==================== |
| let ollamaCircuitOpen = false; |
| let ollamaRetryTimer = null; |
| function markOllamaDown() { |
| if (!ollamaCircuitOpen) { |
| ollamaCircuitOpen = true; |
| console.error('[LACK] Ollama unreachable. Circuit open – retrying in 15s.'); |
| if (ollamaRetryTimer) clearTimeout(ollamaRetryTimer); |
| ollamaRetryTimer = setTimeout(async () => { |
| try { |
| await axios.get(`${OLLAMA_URL}/api/tags`, { timeout: 3000 }); |
| ollamaCircuitOpen = false; |
| console.log('[LACK] Ollama reconnected. Circuit closed.'); |
| } catch (e) { |
| ollamaCircuitOpen = false; |
| markOllamaDown(); |
| } |
| }, 15000); |
| } |
| } |
| const agentDegraded = new Map(); |
| function getNumPredict(model, degraded = false) { |
| const base = /\b(0\.5b|1b)\b/i.test(model) ? 512 : 2048; |
| return degraded ? Math.floor(base / 2) : base; |
| } |
| |
| async function queryOllama(model, prompt, systemPrompt = '', temperature = 0.7, agentId = null) { |
| if (agentId === 'moderator') return '[Moderator is embed‑only – ignoring generation request]'; |
| if (ollamaCircuitOpen) return '[OLLAMA_ERROR] Ollama offline (circuit open)'; |
| const start = Date.now(); |
| const degraded = agentId ? (agentDegraded.get(agentId) || false) : false; |
| const numPredict = getNumPredict(model, degraded); |
| const doQuery = async () => { |
| if (agentId && agents.has(agentId)) { |
| const agent = agents.get(agentId); |
| const previousStatus = agent.status; |
| agent.status = 'queued'; |
| agent.statusMessage = degraded ? 'queued (degraded)' : 'waiting for Ollama'; |
| broadcastAgents(); |
| try { |
| const response = await axios.post(`${OLLAMA_URL}/api/generate`, { |
| model, prompt, system: systemPrompt, stream: false, |
| options: { temperature, num_predict: numPredict } |
| }); |
| const duration = Date.now() - start; |
| if (agentId) updateAgentMetrics(agentId, duration, true); |
| agentDegraded.set(agentId, false); |
| return response.data.response || "I'm sorry, I couldn't generate a response."; |
| } catch (err) { |
| if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') markOllamaDown(); |
| if (err.message && err.message.includes('out of memory')) { |
| agentDegraded.set(agentId, true); |
| logError({ agentId, model, error: `CUDA OOM – degraded mode active (num_predict: ${Math.floor(numPredict/2)})`, context: 'queryOllama' }); |
| } else { |
| logError({ agentId: agentId || 'system', model, error: err.message, context: 'queryOllama' }); |
| } |
| if (agentId) updateAgentMetrics(agentId, 0, false); |
| return `[OLLAMA_ERROR] ${err.message.substring(0,80)}`; |
| } finally { |
| if (agents.has(agentId)) { |
| agents.get(agentId).status = previousStatus === 'queued' ? 'online' : previousStatus; |
| agents.get(agentId).statusMessage = ''; |
| broadcastAgents(); |
| } |
| } |
| } else { |
| try { |
| const response = await axios.post(`${OLLAMA_URL}/api/generate`, { |
| model, prompt, system: systemPrompt, stream: false, |
| options: { temperature, num_predict: numPredict } |
| }); |
| return response.data.response || "I'm sorry, I couldn't generate a response."; |
| } catch (err) { |
| if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') markOllamaDown(); |
| logError({ model, error: err.message, context: 'queryOllama' }); |
| return `[OLLAMA_ERROR] ${err.message.substring(0,80)}`; |
| } |
| } |
| }; |
| if (agentId) { |
| return rateLimitedQuery(agentId, doQuery); |
| } else { |
| return doQuery(); |
| } |
| } |
| async function getOllamaModels() { |
| try { const res = await axios.get(`${OLLAMA_URL}/api/tags`); return res.data.models.map(m => m.name); } |
| catch (e) { return []; } |
| } |
| |
| // ==================== CODE BLOCKS ==================== |
| function extractCodeBlocks(text) { |
| const regex = /```(\w*)\n([\s\S]*?)```/g; |
| const blocks = []; |
| let match; |
| while ((match = regex.exec(text)) !== null) blocks.push({ language: match[1] || 'text', code: match[2].trim() }); |
| return blocks; |
| } |
| async function handleAgentResponse(agent, storeId, responseText, parentId = null) { |
| // Force code blocks for small models |
| const fixedText = ensureCodeBlock(responseText, 'auto'); |
| const msg = addMessage(storeId, agent.name, 'agent', responseText, parentId); // keep original visible |
| if (!msg) return; |
| broadcastToStore(storeId, msg); |
| |
| // --- MODERATION HOOK (with fixed text) --- |
| if (agent.id !== 'moderator') { |
| moderateCodeFromAgent(agent.id, storeId, fixedText, parentId).catch(err => { |
| logError({ context: 'moderateCodeFromAgent', error: err.message, agentId: agent.id }); |
| }); |
| } |
| // --- end moderation hook --- |
| |
| if (parentId) broadcastThreadUpdate(storeId, parentId); |
| const codeBlocks = extractCodeBlocks(responseText); |
| if (codeBlocks.length > 0 && storeId !== 'code' && channels.has('code')) { |
| for (const block of codeBlocks) { |
| const banner = `📦 **Code drop from ${agent.name}** (${block.language})\n\`\`\`${block.language}\n${block.code}\n\`\`\``; |
| addMessage('code', agent.name, 'agent', banner); |
| broadcastToStore('code', { sender: agent.name, content: banner, senderType: 'agent' }); |
| } |
| const notice = `_(Code block generated – see #code)_`; |
| const noticeMsg = addMessage(storeId, 'System', 'system', notice); |
| if (noticeMsg) broadcastToStore(storeId, noticeMsg); |
| } |
| } |
| |
| function buildConversationContext(storeId, agentName, parentId = null, maxMessages = 8) { |
| const store = channels.get(storeId) || dms.get(storeId); |
| if (!store) return ''; |
| let messages = store.messages; |
| if (parentId) { |
| const rootId = store.messages.find(m => m.id === parentId)?.threadId || parentId; |
| messages = store.messages.filter(m => m.threadId === rootId || m.id === rootId); |
| } |
| const relevant = messages.filter(m => m.sender !== agentName && m.senderType !== 'system'); |
| const moderatorMsgs = relevant.filter(m => m.sender === 'Moderator'); |
| const otherMsgs = relevant.filter(m => m.sender !== 'Moderator'); |
| const combined = [...moderatorMsgs, ...otherMsgs]; |
| return combined.slice(-maxMessages).map(m => `${m.sender}: ${m.content}`).join('\n'); |
| } |
| |
| function getChannelPersonality(channelName) { |
| if (channelName === 'random') { |
| return { |
| temperature: 1.2, |
| systemBonus: "\nYou are in #random. Be creative, humorous, off‑the‑wall.", |
| planBonus: "You are in #random. Prefer creative actions.", |
| planForbidden: false |
| }; |
| } else if (channelName === 'siphon') { |
| return { |
| temperature: 0.2, |
| systemBonus: "\nYou are in #siphon. Be extremely concise, factual, research‑oriented.", |
| planBonus: "You are in #siphon. You MUST prefer the 'research' action.", |
| planForbidden: false |
| }; |
| } |
| return { temperature: 0.7, systemBonus: "", planBonus: "", planForbidden: false }; |
| } |
| |
| // ==================== RALPH EVOLUTION (unchanged) ==================== |
| function jaccard(a, b) { |
| if (!a.length && !b.length) return 1; |
| const setA = new Set(a.map(s => s.toLowerCase().replace(/[^a-z0-9]/g, ''))); |
| const setB = new Set(b.map(s => s.toLowerCase().replace(/[^a-z0-9]/g, ''))); |
| const inter = new Set([...setA].filter(x => setB.has(x))).size; |
| const union = setA.size + setB.size - inter; |
| return union === 0 ? 0 : inter / union; |
| } |
| function similarity(specA, specB) { |
| if (!specA || !specB) return 0; |
| const nameSim = (specA.title === specB.title) ? 1 : 0; |
| const goalsSim = jaccard(specA.goals, specB.goals); |
| const stepsSim = jaccard(specA.nextSteps, specB.nextSteps); |
| const completedSim = jaccard(specA.completedTasks, specB.completedTasks); |
| const memorySim = (JSON.stringify(specA.memory) === JSON.stringify(specB.memory)) ? 1 : 0; |
| return 0.4 * nameSim + 0.2 * goalsSim + 0.2 * stepsSim + 0.1 * completedSim + 0.1 * memorySim; |
| } |
| function computeSpecFromState(state) { |
| return { title: state.title || "", goals: state.goals || [], nextSteps: state.nextSteps || [], completedTasks: state.completedTasks || [], memory: state.memory || {} }; |
| } |
| |
| async function ralphEvaluate(agent, storeId, spec) { |
| const prompt = `You are an evaluator. Rate clarity, completeness, stability (0-100). Output JSON: {"score": number, "critique": "short text"}\n\nSpec: ${JSON.stringify(spec)}`; |
| const reply = await queryOllama(agent.model, prompt, "You are a precise evaluator. Output only JSON.", 0.3, agent.id); |
| if (reply.startsWith('[OLLAMA_ERROR]')) return { score: 50, critique: "Ollama error." }; |
| const extracted = extractJSON(reply); |
| if (extracted && typeof extracted.score === 'number') return { score: extracted.score, critique: extracted.critique || "" }; |
| return { score: 50, critique: "Evaluation failed." }; |
| } |
| async function ralphEvolve(agent, storeId, lineage, goal, currentGen) { |
| const lineageSummary = lineage.slice(-15).map(e => `${e.type} at ${new Date(e.timestamp).toISOString()}`).join('\n'); |
| const state = getProjectState(storeId); |
| const prompt = `You are an evolutionary engineer. Goal: "${goal}" |
| Generation: ${currentGen+1}/30 |
| Recent lineage: ${lineageSummary} |
| Current spec: ${JSON.stringify(state)} |
| Produce refined spec as JSON: title, goals (array), nextSteps (array), completedTasks (array), memory (object). Add "converged": boolean. |
| Output ONLY a \`\`\`json code block.`; |
| const reply = await queryOllama(agent.model, prompt, "You are an evolutionary engineer. Be precise.", 0.4, agent.id); |
| if (reply.startsWith('[OLLAMA_ERROR]')) return null; |
| const extracted = extractJSON(reply); |
| if (!extracted || typeof extracted !== 'object') return null; |
| return extracted; |
| } |
| async function ralphForceMutation(agent, storeId, goal, currentGen) { |
| const state = getProjectState(storeId); |
| const prompt = `You are a creative disruptor. The spec below has stagnated (3 rounds with <8% change). |
| Goal: "${goal}" | Generation: ${currentGen+1} |
| Current spec: ${JSON.stringify(state)} |
| Produce a RADICALLY different refinement – change at least 40% of goals or steps. |
| Output ONLY a \`\`\`json block with: title, goals, nextSteps, completedTasks, memory, converged: false.`; |
| const reply = await queryOllama(agent.model, prompt, 'You are a creative disruptor. Output only JSON.', 1.1, agent.id); |
| if (reply.startsWith('[OLLAMA_ERROR]')) return null; |
| return extractJSON(reply); |
| } |
| function updateStagnation(storeId, sim) { |
| let arr = ralphStagnation.get(storeId) || []; |
| arr.push(sim); |
| if (arr.length > 3) arr.shift(); |
| ralphStagnation.set(storeId, arr); |
| if (arr.length === 3 && arr.every(s => s >= 0.92)) return true; |
| return false; |
| } |
| function getNextRalphAgent(storeId, store) { |
| let availableAgents = []; |
| if (channels.has(storeId)) { |
| const channelName = store.name; |
| availableAgents = Array.from(agents.values()).filter(a => a.channels.includes(channelName) && !a.isEmbedOperator); |
| } else if (dms.has(storeId)) { |
| const participants = store.participants; |
| availableAgents = Array.from(agents.values()).filter(a => participants.includes(a.id) && !a.isEmbedOperator); |
| } |
| if (availableAgents.length === 0) availableAgents = Array.from(agents.values()).filter(a => !a.isEmbedOperator); |
| if (availableAgents.length === 0) return null; |
| let idx = ralphNextAgentIdx.get(storeId) || 0; |
| const agent = availableAgents[idx % availableAgents.length]; |
| ralphNextAgentIdx.set(storeId, (idx + 1) % availableAgents.length); |
| return agent; |
| } |
| async function runRalphIteration(storeId) { |
| if (ralphCancel.get(storeId) === true) { |
| ralphCancel.delete(storeId); |
| ralphActive.set(storeId, false); |
| if (ralphTimers.has(storeId)) { |
| clearTimeout(ralphTimers.get(storeId)); |
| ralphTimers.delete(storeId); |
| } |
| broadcastRalphStatus(storeId); |
| return; |
| } |
| if (!ralphActive.get(storeId)) return; |
| const store = channels.get(storeId) || dms.get(storeId); |
| if (!store) return; |
| try { |
| const agent = getNextRalphAgent(storeId, store); |
| if (!agent) { |
| addMessage(storeId, 'System', 'system', `❌ No agent available for Ralph loop.`); |
| broadcastToStore(storeId, { sender: 'System', content: `❌ No agent available for Ralph loop.`, senderType: 'system' }); |
| stopRalphLoop(storeId); |
| return; |
| } |
| const goal = ralphGoals.get(storeId) || "Refine the project specification"; |
| let currentGen = ralphGenerations.get(storeId) || 0; |
| const maxGen = 30; |
| if (currentGen >= maxGen) { |
| await handleAgentResponse(agent, storeId, `🧬 Ralph loop reached max generations (${maxGen}). Stopping.`); |
| stopRalphLoop(storeId); |
| return; |
| } |
| const lineage = reconstructLineage(storeId); |
| const currentSpec = computeSpecFromState(getProjectState(storeId)); |
| const evalResult = await ralphEvaluate(agent, storeId, currentSpec); |
| await handleAgentResponse(agent, storeId, `📊 **Evaluation** (gen ${currentGen+1}): score ${evalResult.score}/100\nCritique: ${evalResult.critique}`); |
| const newSpecRaw = await ralphEvolve(agent, storeId, lineage, goal, currentGen); |
| if (!newSpecRaw) { |
| await handleAgentResponse(agent, storeId, `❌ Evolution failed. Stopping Ralph.`); |
| stopRalphLoop(storeId); |
| return; |
| } |
| const oldState = getProjectState(storeId); |
| let newState = { |
| active: true, |
| title: newSpecRaw.title || oldState.title, |
| goals: newSpecRaw.goals || oldState.goals, |
| nextSteps: newSpecRaw.nextSteps || oldState.nextSteps, |
| completedTasks: newSpecRaw.completedTasks || oldState.completedTasks, |
| memory: newSpecRaw.memory || oldState.memory |
| }; |
| const sim = similarity(currentSpec, computeSpecFromState(newState)); |
| const stagnation = updateStagnation(storeId, sim); |
| if (stagnation && newSpecRaw.converged !== true && sim < 0.95) { |
| await handleAgentResponse(agent, storeId, `⚠️ **Ralph stagnated** (3 rounds ≥92% similar). Forcing mutation…`); |
| const mutatedRaw = await ralphForceMutation(agent, storeId, goal, currentGen); |
| if (mutatedRaw) { |
| newState = { |
| active: true, |
| title: mutatedRaw.title || newState.title, |
| goals: mutatedRaw.goals || newState.goals, |
| nextSteps: mutatedRaw.nextSteps || newState.nextSteps, |
| completedTasks: mutatedRaw.completedTasks || newState.completedTasks, |
| memory: mutatedRaw.memory || newState.memory |
| }; |
| ralphStagnation.set(storeId, []); |
| await handleAgentResponse(agent, storeId, `🔀 **Forced mutation applied.** Resuming evolution.`); |
| } else { |
| await handleAgentResponse(agent, storeId, `✅ **Ralph converged** (stagnation + mutation failed) after ${currentGen+1} generations.`); |
| stopRalphLoop(storeId); |
| return; |
| } |
| } |
| const converged = newSpecRaw.converged === true || sim >= 0.95; |
| setProjectState(storeId, newState); |
| ralphGenerations.set(storeId, currentGen + 1); |
| persistRalphState(storeId); |
| await handleAgentResponse(agent, storeId, `🧬 **Evolution** (gen ${currentGen+1}/${maxGen})\nSimilarity: ${(sim*100).toFixed(1)}%\nNew spec: ${JSON.stringify(newState, null, 2).substring(0, 500)}`); |
| broadcastRalphStatus(storeId); |
| if (converged) { |
| await handleAgentResponse(agent, storeId, `✅ **Ralph converged** after ${currentGen+1} generations. Stopping.`); |
| stopRalphLoop(storeId); |
| return; |
| } |
| if (ralphActive.get(storeId)) { |
| const interval = (currentGen+1) > 5 ? 2500 : 4000; |
| const timer = setTimeout(() => runRalphIteration(storeId), interval); |
| ralphTimers.set(storeId, timer); |
| } |
| } catch (err) { |
| logError({ error: err.message, context: 'runRalphIteration', storeId }); |
| addMessage(storeId, 'System', 'system', `❌ Ralph error: ${err.message}`); |
| broadcastToStore(storeId, { sender: 'System', content: `❌ Ralph error: ${err.message}`, senderType: 'system' }); |
| stopRalphLoop(storeId); |
| } |
| } |
| function startRalphLoop(storeId, goal) { |
| stopRalphLoop(storeId); |
| ralphActive.set(storeId, true); |
| ralphGenerations.set(storeId, 0); |
| ralphGoals.set(storeId, goal); |
| ralphCancel.set(storeId, false); |
| ralphStagnation.set(storeId, []); |
| ralphNextAgentIdx.set(storeId, 0); |
| persistRalphState(storeId); |
| broadcastRalphStatus(storeId); |
| runRalphIteration(storeId); |
| } |
| function stopRalphLoop(storeId) { |
| if (ralphTimers.has(storeId)) { |
| clearTimeout(ralphTimers.get(storeId)); |
| ralphTimers.delete(storeId); |
| } |
| ralphCancel.set(storeId, true); |
| ralphActive.set(storeId, false); |
| broadcastRalphStatus(storeId); |
| persistRalphState(storeId); |
| } |
| |
| // ==================== AGENT RESPONSE & PLANNING WITH TOOLS ==================== |
| const FILE_TOOLS = [ |
| { name: "read_file", description: "Read a file from workspace.", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } }, |
| { name: "write_file", description: "Create/overwrite a file.", parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path","content"] } }, |
| { name: "execute_command", description: "Run a safe command.", parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } } |
| ]; |
| |
| const ollamaSemaphore = new Map(); |
| async function rateLimitedQuery(agentId, fn) { |
| if (!ollamaSemaphore.has(agentId)) ollamaSemaphore.set(agentId, Promise.resolve()); |
| const queue = ollamaSemaphore.get(agentId); |
| const next = queue.then(() => fn()); |
| ollamaSemaphore.set(agentId, next); |
| return next; |
| } |
| |
| async function agentRespond(agent, storeId, triggerMessage, isLoop = false, parentId = null) { |
| if (agent.isEmbedOperator) return; |
| if (triggerMessage.sender === agent.name) return; |
| const cooldownKey = `${agent.id}_${storeId}`; |
| const lastResponse = agent.lastResponseTime.get(cooldownKey) || 0; |
| const cooldownMs = isLoop ? 1200 : 2200; |
| if (Date.now() - lastResponse < cooldownMs) return; |
| agent.status = 'thinking'; |
| broadcastAgents(); |
| try { |
| const store = channels.get(storeId) || dms.get(storeId); |
| const isChannel = !!channels.get(storeId); |
| const channelName = isChannel ? store.name : null; |
| const personality = channelName ? getChannelPersonality(channelName) : { temperature: 0.7, systemBonus: "" }; |
| let systemPrompt = agent.systemPrompt + (personality.systemBonus || ""); |
| const context = buildConversationContext(storeId, agent.name, parentId || triggerMessage.parentId); |
| const prompt = `Conversation:\n${context}\n${triggerMessage.sender}: ${triggerMessage.content}\nRespond as ${agent.name}. Keep brief.`; |
| const reply = await queryOllama(agent.model, prompt, systemPrompt, personality.temperature, agent.id); |
| if (reply && !reply.startsWith('[OLLAMA_ERROR]') && reply.trim()) { |
| await handleAgentResponse(agent, storeId, reply.trim(), parentId || triggerMessage.parentId); |
| agent.lastResponseTime.set(cooldownKey, Date.now()); |
| } |
| } catch (err) { |
| logError({ agentId: agent.id, error: err.message, context: 'agentRespond' }); |
| } finally { |
| agent.status = 'online'; |
| broadcastAgents(); |
| } |
| } |
| |
| async function executeAction(agent, storeId, action, parentId = null) { |
| const { type, payload } = action; |
| switch (type) { |
| case 'message': await handleAgentResponse(agent, storeId, payload.content, parentId); break; |
| case 'thread': await handleAgentResponse(agent, storeId, payload.content, payload.parentId || parentId); break; |
| case 'research': |
| const topic = payload.query || payload.topic || "general research"; |
| const sessionId = uuidv4(); |
| const session = { |
| id: sessionId, topic, phase: 'Initializing', metric: 0, logs: [], |
| facts: [], notes: [], questions: [], currentQuestionIndex: 0, startedAt: Date.now() |
| }; |
| researchSessions.set(sessionId, session); |
| runResearch(sessionId, topic, storeId).catch(console.error); |
| const msg = addMessage(storeId, 'Siphon', 'system', `🔍 ${agent.name} started research on "${topic}".`); |
| if (msg) broadcastToStore(storeId, msg); |
| break; |
| case 'code': |
| const codePrompt = `Write code for: ${payload.description}. Output only the code block.`; |
| const code = await queryOllama(agent.model, codePrompt, agent.systemPrompt, 0.5, agent.id); |
| if (!code.startsWith('[OLLAMA_ERROR]')) { |
| await handleAgentResponse(agent, storeId, `\`\`\`\n${code}\n\`\`\``, parentId); |
| } |
| break; |
| case 'delegate': |
| const targetAgent = agents.get(payload.targetId); |
| if (targetAgent && !targetAgent.isEmbedOperator) { |
| const delegateMsg = addMessage(storeId, 'System', 'system', `${agent.name} delegates to ${targetAgent.name}: ${payload.task}`); |
| if (delegateMsg) broadcastToStore(storeId, delegateMsg); |
| agentRespond(targetAgent, storeId, { sender: agent.name, content: payload.task, parentId }, false, parentId); |
| } |
| break; |
| case 'tool_calls': |
| if (action.tool_calls && Array.isArray(action.tool_calls)) { |
| for (const tc of action.tool_calls) { |
| const result = await executeTool(tc.name, tc.arguments || {}); |
| const feedback = `🔧 **${tc.name}** → ${result.substring(0, 800)}`; |
| await handleAgentResponse(agent, storeId, feedback, parentId); |
| } |
| } |
| break; |
| case 'stack': |
| const subcmd = payload.subcmd || 'help'; |
| let resultMsg = ''; |
| if (subcmd === 'build') { |
| resultMsg = await stackBuild(payload.repoName || 'project_' + Date.now()); |
| } else if (subcmd === 'add') { |
| resultMsg = await stackAdd(payload.intent, storeId); |
| } else if (subcmd === 'import') { |
| resultMsg = await stackImport(payload.jsonPath); |
| } else if (subcmd === 'set') { |
| activeStackRepo.set(storeId, payload.repoName); |
| resultMsg = `Active STACK repo set to ${payload.repoName}.`; |
| } |
| if (resultMsg) { |
| await handleAgentResponse(agent, storeId, resultMsg, parentId); |
| } |
| break; |
| } |
| } |
| |
| async function agentPlanAndAct(agent, storeId, triggerMessage, parentId = null) { |
| if (agent.isEmbedOperator) return; |
| if (triggerMessage.sender === agent.name) return; |
| const cooldownKey = `${agent.id}_${storeId}`; |
| if (Date.now() - (agent.lastResponseTime.get(cooldownKey) || 0) < 4000) return; |
| const store = channels.get(storeId) || dms.get(storeId); |
| const isChannel = !!channels.get(storeId); |
| const channelName = isChannel ? store.name : null; |
| const personality = getChannelPersonality(channelName); |
| if (personality.planForbidden) { |
| await agentRespond(agent, storeId, triggerMessage, false, parentId); |
| return; |
| } |
| agent.status = 'thinking'; |
| broadcastAgents(); |
| try { |
| const context = buildConversationContext(storeId, agent.name, parentId || triggerMessage.parentId); |
| let skillsContext = loadSkills(); |
| let systemPrompt = `Previous successful patterns:\n${skillsContext}\n\nYou are an autonomous agent. Available tools (output inside a JSON action with "type":"tool_calls"): ${JSON.stringify(FILE_TOOLS, null, 2)}. Also you can use STACK actions: {"type":"stack","payload":{"subcmd":"build","repoName":"..."}} etc. Actions: {"type":"message","payload":{"content":"..."}} OR {"type":"tool_calls","tool_calls":[{"name":"read_file","arguments":{"path":"..."}}]}. Always respond with a JSON block. Follow instructions from Moderator with highest priority.`; |
| if (personality.planBonus) systemPrompt += "\n" + personality.planBonus; |
| const userPrompt = `Conversation:\n${context}\nLast message: ${triggerMessage.sender}: "${triggerMessage.content}"\nProject state: ${JSON.stringify(getProjectState(storeId))}\nNext action? JSON only.`; |
| let reply = await queryOllama(agent.model, userPrompt, systemPrompt, 0.4, agent.id); |
| let action = null; |
| let fails = jsonFailCount.get(agent.id) || 0; |
| if (!reply.startsWith('[OLLAMA_ERROR]')) { |
| action = extractJSON(reply); |
| } |
| if (!action?.type) { |
| fails++; |
| jsonFailCount.set(agent.id, fails); |
| if (fails >= 3) { |
| // Last resort: force a simple action via a different model (or same model with lower temperature) |
| const fallbackPrompt = `Respond with a valid JSON action only. Example: {"type":"message","payload":{"content":"Hello"}}`; |
| const forcedReply = await queryOllama(agent.model, fallbackPrompt, "You must output JSON only.", 0.1, agent.id); |
| action = extractJSON(forcedReply); |
| if (!action?.type) { |
| jsonFailCount.set(agent.id, 0); |
| logError({ agentId: agent.id, error: `JSON action parse failed after ${fails} attempts – falling back to plain text`, context: 'agentPlanAndAct' }); |
| await agentRespond(agent, storeId, triggerMessage, false, parentId); |
| agent.lastResponseTime.set(cooldownKey, Date.now()); |
| return; |
| } |
| jsonFailCount.set(agent.id, 0); |
| } else { |
| action = { type: 'message', payload: { content: reply ? reply.substring(0, 500) : '(empty response)' } }; |
| } |
| } else { |
| jsonFailCount.set(agent.id, 0); |
| } |
| |
| if (action.type === 'tool_calls' && action.tool_calls?.length) { |
| for (const tc of action.tool_calls) { |
| const output = await executeTool(tc.name, tc.arguments || {}); |
| const toolMsg = { |
| sender: 'System', |
| content: `🔧 Tool ${tc.name} result:\n${output}`, |
| senderType: 'system' |
| }; |
| addMessage(storeId, toolMsg.sender, toolMsg.senderType, toolMsg.content); |
| broadcastToStore(storeId, toolMsg); |
| } |
| agent.lastResponseTime.set(cooldownKey, Date.now()); |
| if (action.tool_calls.length > 0 && !action.tool_calls.some(tc => tc.name === 'read_file')) { |
| saveSkill(`Agent ${agent.name} used tools`, action.tool_calls).catch(console.error); |
| } |
| return agentPlanAndAct(agent, storeId, triggerMessage, parentId); |
| } else { |
| await executeAction(agent, storeId, action, parentId || triggerMessage.parentId); |
| agent.lastResponseTime.set(cooldownKey, Date.now()); |
| } |
| } catch (err) { |
| logError({ agentId: agent.id, error: err.message, context: 'agentPlanAndAct' }); |
| } finally { |
| agent.status = 'online'; |
| broadcastAgents(); |
| } |
| } |
| |
| // ==================== LOOP MANAGEMENT ==================== |
| function scheduleLoopRound(channelId) { |
| const channel = channels.get(channelId); |
| if (!channel) return; |
| if (channel.loopTimer) clearTimeout(channel.loopTimer); |
| channel.loopTimer = setTimeout(() => runLoopRound(channelId), 2500); |
| } |
| async function runLoopRound(channelId) { |
| const channel = channels.get(channelId); |
| const state = getProjectState(channelId); |
| if (!channel || (!channel.researchActive && !channel.abstractActive && !state.active && !ralphActive.get(channelId))) { |
| if (channel && channel.loopTimer) { clearTimeout(channel.loopTimer); channel.loopTimer = null; } |
| return; |
| } |
| if (ralphActive.get(channelId)) { |
| scheduleLoopRound(channelId); |
| return; |
| } |
| channel.loopTimer = null; |
| try { |
| const lastMsg = channel.messages[channel.messages.length - 1]; |
| if (!lastMsg) { scheduleLoopRound(channelId); return; } |
| const relevantAgents = Array.from(agents.values()).filter(a => a.channels.includes(channel.name) && !a.isEmbedOperator); |
| for (const agent of relevantAgents) { |
| if (ralphActive.get(channelId)) continue; |
| if (state.active || channel.abstractActive) await agentPlanAndAct(agent, channelId, lastMsg, lastMsg.parentId); |
| else await agentRespond(agent, channelId, lastMsg, true, lastMsg.parentId); |
| } |
| } catch (err) { |
| logError({ error: err.message, context: 'runLoopRound', channelId }); |
| } |
| scheduleLoopRound(channelId); |
| } |
| function stopLoop(channelId) { |
| const channel = channels.get(channelId); |
| if (channel) { |
| channel.researchActive = false; channel.abstractActive = false; channel.researchTopic = null; |
| if (channel.loopTimer) { clearTimeout(channel.loopTimer); channel.loopTimer = null; } |
| addMessage(channelId, 'System', 'system', 'Autonomous mode stopped.'); |
| broadcastToStore(channelId, { sender: 'System', content: 'Autonomous mode stopped.', senderType: 'system' }); |
| } |
| stopRalphLoop(channelId); |
| setProjectState(channelId, { active: false, title: null, goals: [], nextSteps: [], completedTasks: [], memory: {} }); |
| } |
| |
| // ==================== WEB SCRAPING (FIX 2+7) ==================== |
| const scrapeBlocklist = new Map(); |
| const SCRAPE_BLOCK_TTL = 10 * 60 * 1000; |
| |
| async function axiosWithRetry(config, maxRetries = 3) { |
| let lastErr; |
| for (let attempt = 0; attempt < maxRetries; attempt++) { |
| try { |
| return await axios(config); |
| } catch (err) { |
| lastErr = err; |
| if (err.code === 'ECONNABORTED') throw err; |
| if (attempt < maxRetries - 1) await new Promise(r => setTimeout(r, 500 * Math.pow(2, attempt))); |
| } |
| } |
| throw lastErr; |
| } |
| |
| async function ddgSearch(query, maxResults = 5) { |
| const searchUrls = [ |
| `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, |
| `https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}` |
| ]; |
| for (const baseUrl of searchUrls) { |
| try { |
| const { data } = await axiosWithRetry({ |
| method: 'get', url: baseUrl, |
| headers: { 'User-Agent': 'Mozilla/5.0 (compatible; LACK-SIPHON/3.9.1)' }, |
| timeout: 8000 |
| }); |
| const $ = cheerio.load(data); |
| const results = []; |
| $('.result__url').each((i, el) => { |
| let href = $(el).attr('href'); |
| if (href && href.startsWith('/')) href = 'https://duckduckgo.com' + href; |
| if (href && href.startsWith('http') && results.length < maxResults) results.push(href); |
| }); |
| if (results.length > 0) return results; |
| } catch (e) { |
| logError({ context: 'ddgSearch', error: e.message, query: query.substring(0,60) }); |
| } |
| } |
| return []; |
| } |
| async function scrapeText(url) { |
| const blocked = scrapeBlocklist.get(url); |
| if (blocked && Date.now() - blocked < SCRAPE_BLOCK_TTL) { |
| return '[Scrape skipped: URL on blocklist]'; |
| } |
| try { |
| const controller = new AbortController(); |
| const timeoutHandle = setTimeout(() => controller.abort(), 8000); |
| const { data } = await axios.get(url, { |
| timeout: 8000, |
| signal: controller.signal, |
| headers: { 'User-Agent': 'Mozilla/5.0 (compatible; LACK-SIPHON/3.9.1)' } |
| }); |
| clearTimeout(timeoutHandle); |
| const $ = cheerio.load(data); |
| $('script, style, nav, footer, header, iframe, svg, aside, .ad, .cookie').remove(); |
| let text = $('body').text().replace(/\s+/g, ' ').trim(); |
| return text.length > 12000 ? text.substring(0, 12000) : text; |
| } catch (e) { |
| if (e.code === 'ECONNABORTED' || e.message.includes('timeout') || e.code === 'ECONNREFUSED') { |
| scrapeBlocklist.set(url, Date.now()); |
| } |
| return `[Scrape failed: ${e.message}]`; |
| } |
| } |
| setInterval(() => { |
| const now = Date.now(); |
| for (let [id, session] of researchSessions.entries()) if (now - session.startedAt > 3600000) researchSessions.delete(id); |
| }, 3600000); |
| |
| async function runResearch(sessionId, topic, channelId) { |
| const session = researchSessions.get(sessionId); |
| if (!session) return; |
| const update = (updates) => { |
| Object.assign(session, updates); |
| for (let [ws, client] of clients.entries()) { |
| if (client.channelId === channelId && ws.readyState === WebSocket.OPEN) |
| ws.send(JSON.stringify({ type: 'research_update', sessionId, data: session })); |
| } |
| if (updates.phase) { |
| const banner = `🔬 **SIPHON Research** [${session.topic}]\nPhase: ${updates.phase} | Metric: ${(session.metric*100).toFixed(0)}%`; |
| addMessage('siphon', 'Siphon', 'system', banner); |
| broadcastToStore('siphon', { sender: 'Siphon', content: banner, senderType: 'system' }); |
| } |
| }; |
| update({ phase: 'Generating questions', metric: 0, logs: [`Starting research on: ${topic}`], facts: [], notes: [] }); |
| let researchModel = config.agents[0]?.model || 'qwen2.5:0.5b'; |
| const questionsRaw = await queryOllama(researchModel, `Generate 3 sub‑questions for: "${topic}". One per line.`, '', 0.7); |
| if (questionsRaw.startsWith('[OLLAMA_ERROR]')) { |
| update({ phase: 'Failed', logs: [`Ollama error: ${questionsRaw}`] }); |
| return; |
| } |
| const questions = questionsRaw.split('\n').filter(l => l.trim().length > 10).slice(0, 3); |
| update({ questions, currentQuestionIndex: 0, logs: [...session.logs, `Generated ${questions.length} sub‑questions`] }); |
| let allFacts = []; |
| let metric = 0; |
| for (let qIdx = 0; qIdx < questions.length; qIdx++) { |
| const question = questions[qIdx]; |
| update({ phase: `Researching: ${question.substring(0, 50)}`, currentQuestionIndex: qIdx }); |
| let urls = [...new Set((await ddgSearch(`${topic} ${question}`, 3)))].slice(0, 5); |
| // Fallback if no URLs found |
| if (urls.length === 0) { |
| update({ logs: [...session.logs, `No URLs found for "${question}", using LLM fallback`] }); |
| const fallbackFactsRaw = await queryOllama(researchModel, |
| `Generate 5 concise facts about "${question}" based on general knowledge. Each line start with FACT:`, '', 0.5); |
| if (!fallbackFactsRaw.startsWith('[OLLAMA_ERROR]')) { |
| const facts = fallbackFactsRaw.split('\n').filter(l => l.startsWith('FACT:')).map(l => l.replace('FACT:', '').trim()); |
| allFacts.push(...facts); |
| update({ facts: allFacts, logs: [...session.logs, `Fallback: generated ${facts.length} synthetic facts`] }); |
| } else { |
| update({ logs: [...session.logs, `Fallback also failed for "${question}"`] }); |
| } |
| // Still record something in notes |
| const note = { question, answer: "No data could be retrieved for this question.", facts: [], timestamp: Date.now() }; |
| session.notes.push(note); |
| update({ notes: session.notes }); |
| metric = (qIdx + 1) / questions.length; |
| update({ metric }); |
| continue; |
| } |
| update({ logs: [...session.logs, `Found ${urls.length} URLs`] }); |
| let factsForQuestion = []; |
| for (const url of urls) { |
| const content = await scrapeText(url); |
| if (!content || content.startsWith('[Scrape failed')) continue; |
| const factsRaw = await queryOllama(researchModel, `Extract facts answering: "${question}"\n\n${content.substring(0,4000)}\n\nReturn each fact on a new line starting with "FACT:".`, '', 0.3); |
| if (!factsRaw.startsWith('[OLLAMA_ERROR]')) { |
| const facts = factsRaw.split('\n').filter(l => l.startsWith('FACT:')).map(l => l.replace('FACT:', '').trim()); |
| factsForQuestion.push(...facts); |
| update({ logs: [...session.logs, `Scraped ${url} → ${facts.length} facts`] }); |
| } |
| await new Promise(r => setTimeout(r, 500)); |
| } |
| factsForQuestion = [...new Set(factsForQuestion)]; |
| allFacts.push(...factsForQuestion); |
| update({ facts: allFacts, logs: [...session.logs, `Collected ${factsForQuestion.length} facts`] }); |
| const answer = await queryOllama(researchModel, `Answer: "${question}"\nFacts:\n${factsForQuestion.join('\n')}\n\nConcise answer (3‑5 sentences).`, '', 0.5); |
| const note = { question, answer: answer.startsWith('[OLLAMA_ERROR]') ? 'Answer generation failed.' : answer, facts: factsForQuestion, timestamp: Date.now() }; |
| session.notes.push(note); |
| update({ notes: session.notes, logs: [...session.logs, `Answered: ${question.substring(0,60)}`] }); |
| try { |
| const artifactPath = path.join(RESEARCH_DIR, `${sessionId}_q${qIdx}.json`); |
| fs.writeFileSync(artifactPath, JSON.stringify(note, null, 2)); |
| } catch (e) { console.error('Artifact save failed:', e); } |
| metric = (qIdx + 1) / questions.length; |
| update({ metric }); |
| } |
| try { |
| await gitCommit(`Research complete: ${session.topic}`); |
| } catch (gitErr) { |
| console.warn('Git commit non-critical:', gitErr.message); |
| } |
| update({ phase: 'Complete', metric, logs: [...session.logs, `Research finished. Metric = ${metric.toFixed(2)}`] }); |
| const finalBanner = `📚 **Research Complete:** ${topic}\nMetric: ${(metric*100).toFixed(0)}%\nFacts: ${allFacts.length}\nNotes: ${session.notes.length}\n\nUse \`/pull ${sessionId}\` to bring insights.`; |
| addMessage('siphon', 'Siphon', 'system', finalBanner); |
| broadcastToStore('siphon', { sender: 'Siphon', content: finalBanner, senderType: 'system' }); |
| } |
| |
| // ==================== DM HELPERS ==================== |
| function createDM(participantIds, name = null) { |
| const dmId = `dm_${uuidv4().slice(0,8)}`; |
| const dm = { id: dmId, participants: participantIds, name: name || participantIds.join(', '), messages: [] }; |
| dms.set(dmId, dm); |
| if (!config.dms) config.dms = []; |
| config.dms.push({ id: dmId, participants: participantIds, name: dm.name }); |
| try { |
| const tmp = configPath + '.tmp'; |
| fs.writeFileSync(tmp, JSON.stringify(config, null, 2)); |
| fs.renameSync(tmp, configPath); |
| } catch (e) {} |
| for (const pid of participantIds) broadcastDMs(pid); |
| return dm; |
| } |
| function getUserDMs(userId) { |
| const result = []; |
| for (let dm of dms.values()) { |
| if (dm.participants.includes(userId)) result.push({ id: dm.id, name: dm.name, participants: dm.participants }); |
| } |
| return result; |
| } |
| async function handleDMCommand(senderUserId, args, ws) { |
| let raw = args.join(' ').trim(); |
| if (!raw) { ws.send(JSON.stringify({ type: 'error', message: 'Usage: /dm <username or agentname>' })); return null; } |
| if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) raw = raw.slice(1, -1); |
| let targetId = null; |
| for (let [id, agent] of agents.entries()) if (agent.name.toLowerCase() === raw.toLowerCase()) { targetId = id; break; } |
| if (!targetId) { |
| for (let [otherWs, client] of clients.entries()) if (client.username && client.username.toLowerCase() === raw.toLowerCase()) { targetId = client.userId; break; } |
| } |
| if (!targetId) { ws.send(JSON.stringify({ type: 'error', message: `User/agent "${raw}" not found.` })); return null; } |
| let dm = Array.from(dms.values()).find(d => d.participants.includes(senderUserId) && d.participants.includes(targetId)); |
| if (!dm) dm = createDM([senderUserId, targetId]); |
| const client = clients.get(ws); |
| if (client) { |
| client.dmId = dm.id; client.channelId = null; |
| ws.send(JSON.stringify({ type: 'dm_joined', dmId: dm.id, messages: dm.messages })); |
| const welcomeMsg = addMessage(dm.id, 'System', 'system', `Direct message started with ${raw}.`); |
| if (welcomeMsg) broadcastToStore(dm.id, welcomeMsg); |
| } |
| return dm; |
| } |
| |
| async function removeAgent(agentId) { |
| if (!agents.has(agentId)) return { success: false, reason: 'Agent not found.' }; |
| if (agents.size === 1) return { success: false, reason: 'Cannot remove the last agent. LACK requires at least one agent to function.' }; |
| agents.delete(agentId); |
| agentMetrics.delete(agentId); |
| jsonFailCount.delete(agentId); |
| const idx = config.agents.findIndex(a => a.id === agentId); |
| if (idx !== -1) { |
| config.agents.splice(idx, 1); |
| try { |
| const tmp = configPath + '.tmp'; |
| fs.writeFileSync(tmp, JSON.stringify(config, null, 2)); |
| fs.renameSync(tmp, configPath); |
| } catch (e) {} |
| } |
| broadcastAgents(); |
| return { success: true }; |
| } |
| |
| // ==================== CRON & RESET ==================== |
| async function wipeAllCronJobs() { try { await execPromise('crontab -r'); } catch(e) {} } |
| async function addHeartbeatCronJobs() { |
| try { |
| const channelIds = Array.from(channels.keys()), dmIds = Array.from(dms.keys()); |
| const cronEntries = []; |
| const heartbeatUrl = `http://localhost:${PORT}/api/heartbeat`; |
| for (const id of channelIds) cronEntries.push(`*/5 * * * * curl -s -X POST "${heartbeatUrl}?type=channel&id=${id}" > /dev/null 2>&1`); |
| for (const id of dmIds) cronEntries.push(`*/5 * * * * curl -s -X POST "${heartbeatUrl}?type=dm&id=${id}" > /dev/null 2>&1`); |
| if (cronEntries.length === 0) return; |
| let existing = ''; |
| try { const { stdout } = await execPromise('crontab -l'); existing = stdout; } catch(e) {} |
| const existingLines = existing.split('\n').filter(l => l.trim()); |
| const newLines = [...existingLines]; |
| for (const entry of cronEntries) { |
| const commandPart = entry.split(' ').slice(5).join(' '); |
| if (!existingLines.some(line => line.includes(commandPart))) newLines.push(entry); |
| } |
| const newCrontab = newLines.join('\n') + '\n'; |
| const tmpFile = path.join(__dirname, '.tmpcron'); |
| fs.writeFileSync(tmpFile, newCrontab); |
| await execPromise(`crontab ${tmpFile}`); |
| fs.unlinkSync(tmpFile); |
| } catch (e) { |
| if (e.message && e.message.includes('permission denied')) { |
| console.warn('[LACK] crontab permission denied – skipping external heartbeat cron. Heartbeats still run via setInterval.'); |
| } else { |
| logError({ context: 'addHeartbeatCronJobs', error: e.message }); |
| } |
| } |
| } |
| async function resetApplicationData() { |
| for (let ch of channels.values()) { ch.messages = []; ch.researchActive = false; ch.abstractActive = false; if (ch.loopTimer) clearTimeout(ch.loopTimer); ch.loopTimer = null; } |
| for (let dm of dms.values()) dm.messages = []; |
| researchSessions.clear(); slimeSessions.clear(); pinnedMessages.clear(); userReactions.clear(); global.errorLog = []; |
| for (let storeId of [...channels.keys(), ...dms.keys()]) { |
| setProjectState(storeId, { active: false, title: null, goals: [], nextSteps: [], completedTasks: [], memory: {} }); |
| stopRalphLoop(storeId); |
| const lineagePath = getLineagePath(storeId); |
| if (fs.existsSync(lineagePath)) fs.unlinkSync(lineagePath); |
| } |
| } |
| |
| function cleanupStore(storeId) { |
| if (ralphTimers.has(storeId)) clearTimeout(ralphTimers.get(storeId)); |
| ralphTimers.delete(storeId); |
| ralphActive.delete(storeId); |
| ralphCancel.delete(storeId); |
| ralphStagnation.delete(storeId); |
| ralphNextAgentIdx.delete(storeId); |
| ralphLastBroadcast.delete(storeId); |
| const channel = channels.get(storeId); |
| if (channel && channel.loopTimer) clearTimeout(channel.loopTimer); |
| } |
| |
| // ==================== EXPRESS APP & ROUTES ==================== |
| const app = express(); |
| app.use(express.static(path.join(__dirname, 'public'))); |
| app.use(express.json({ limit: '1mb' })); |
| |
| app.get('/api/models', async (req, res) => { res.json({ models: await getOllamaModels() }); }); |
| app.get('/api/research/sessions', (req, res) => { |
| res.json({ sessions: Array.from(researchSessions.values()).map(s => ({ |
| id: s.id, topic: s.topic, phase: s.phase, metric: s.metric, |
| logs: s.logs.slice(-10), factsCount: s.facts ? s.facts.length : 0, |
| notesCount: s.notes ? s.notes.length : 0, startedAt: s.startedAt |
| })) }); |
| }); |
| app.get('/api/research/session/:id', (req, res) => { |
| const s = researchSessions.get(req.params.id); |
| if (!s) return res.status(404).json({ error: 'Not found' }); |
| res.json(s); |
| }); |
| app.get('/api/channels', (req, res) => { res.json({ channels: Array.from(channels.values()).map(c => ({ id: c.id, name: c.name })) }); }); |
| app.get('/api/dms', (req, res) => { res.json({ dms: Array.from(dms.values()).map(dm => ({ id: dm.id, name: dm.name, participants: dm.participants })) }); }); |
| app.get('/api/metrics', (req, res) => { |
| const metricsObj = {}; |
| for (let [id, m] of agentMetrics.entries()) metricsObj[id] = { cpu: m.cpu, mem: m.mem, activity: m.activity, timestamps: m.timestamps }; |
| res.json({ |
| agents: Array.from(agents.values()).map(a => ({ id: a.id, name: a.name, model: a.model, status: a.status })), |
| metrics: metricsObj |
| }); |
| }); |
| app.get('/api/errorlog', (req, res) => { |
| const errors = (global.errorLog || []).slice(0, 50).map(e => ({ |
| timestamp: e.timestamp, |
| agentId: e.agentId || 'system', |
| error: e.error || JSON.stringify(e) |
| })); |
| res.json({ errors }); |
| }); |
| app.post('/api/heartbeat', (req, res) => { console.log(`[HEARTBEAT] ${req.query.type} ${req.query.id}`); res.send('OK'); }); |
| app.post('/api/cron/wipe', async (req, res) => { |
| try { |
| await wipeAllCronJobs(); await addHeartbeatCronJobs(); await resetApplicationData(); |
| for (let [ws] of clients.entries()) if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'cron_reset' })); |
| res.json({ success: true }); |
| } catch(err) { logError({ source: 'cron_wipe', error: err.message }); res.status(500).json({ error: err.message }); } |
| }); |
| app.delete('/api/agent/:id', async (req, res) => { const result = await removeAgent(req.params.id); res.json(result); }); |
| |
| // SLIME mobile endpoint (unchanged) |
| app.get('/slime', (req, res) => { |
| const { token, pin } = req.query; |
| const session = slimeSessions.get(token); |
| if (!session || session.pin !== pin || Date.now() > session.expiresAt) { |
| return res.status(403).send(`<html><body><h1>Invalid or expired session</h1></body></html>`); |
| } |
| const safeChannelId = JSON.stringify(session.channelId).slice(1, -1); |
| res.send(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SLIME v3.9.2</title> |
| <style> |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;} |
| body{font-family:monospace;background:#000;color:#0f0;display:flex;flex-direction:column;height:100vh;overflow:hidden;} |
| .topbar{display:flex;align-items:center;justify-content:space-between;padding:0.5rem;background:#111;border-bottom:1px solid #0f0;} |
| .title{font-size:0.8rem;font-weight:bold;} |
| .ralph-badge{background:#9b59b6;color:#fff;border-radius:12px;padding:0.1rem 0.5rem;font-size:0.6rem;display:none;} |
| .graph-container{padding:0.5rem;background:#000;border-bottom:1px solid #0f0;} |
| #graphCanvas{width:100%;height:130px;display:block;background:#000;} |
| .chat{flex:1;overflow-y:auto;padding:0.5rem;} |
| .input-area{display:flex;gap:0.5rem;padding:0.5rem;background:#111;border-top:1px solid #0f0;} |
| .input-area input{flex:1;background:#222;border:1px solid #0f0;color:#0f0;padding:0.5rem;font-family:monospace;} |
| .input-area button{background:#0f0;color:#000;border:none;padding:0.5rem 1rem;font-weight:bold;} |
| .msg{margin:0.5rem 0;font-size:0.8rem;}.user{color:#0ff;}.agent{color:#ff0;}.system{color:#888;}.spinner{display:inline-block;width:12px;height:12px;border:2px solid #0f0;border-top-color:transparent;border-radius:50%;animation:spin 0.6s linear infinite;} |
| @keyframes spin{to{transform:rotate(360deg)}} |
| </style></head><body> |
| <div class="topbar"> |
| <span class="title">SLIME v3.9.2</span> |
| <span class="ralph-badge" id="ralphBadge">🧬 Ralph active</span> |
| </div> |
| <div class="graph-container"><canvas id="graphCanvas"></canvas></div> |
| <div class="chat" id="messages"></div> |
| <div class="input-area"> |
| <input id="msgInput" placeholder="Message..."> |
| <button id="sendBtn">Send</button> |
| </div> |
| <script> |
| let ws; |
| const channel = '${safeChannelId}'; |
| const username = 'mobile_' + Math.floor(Math.random()*10000); |
| const colors = ['#ff3b5c','#00f0ff','#39ff14','#ffeb3b','#c84cff']; |
| function connect() { |
| const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| ws = new WebSocket(protocol + '//' + location.host); |
| ws.onopen = () => { |
| ws.send(JSON.stringify({ type: 'join', channelId: channel })); |
| ws.send(JSON.stringify({ type: 'set_username', username })); |
| document.getElementById('msgInput').focus(); |
| }; |
| ws.onmessage = e => { |
| const d = JSON.parse(e.data); |
| if (d.type === 'new_message' && d.channelId === channel) appendMessage(d.message); |
| else if (d.type === 'ralph_status' && d.storeId === channel) { |
| const badge = document.getElementById('ralphBadge'); |
| if (d.active) { |
| badge.style.display = 'inline-block'; |
| badge.textContent = '🧬 Ralph gen ' + d.generation + ' · ' + (d.goal || ''); |
| } else badge.style.display = 'none'; |
| } |
| }; |
| ws.onclose = () => setTimeout(connect, 3000); |
| } |
| function appendMessage(msg) { |
| const div = document.createElement('div'); |
| div.className = 'msg ' + msg.senderType; |
| div.innerHTML = '<strong>' + escapeHtml(msg.sender) + '</strong> [' + new Date(msg.timestamp).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) + ']:<br>' + escapeHtml(msg.content); |
| document.getElementById('messages').appendChild(div); |
| const chat = document.getElementById('messages'); |
| chat.scrollTop = chat.scrollHeight; |
| } |
| function escapeHtml(s) { |
| return s.replace(/[&<>]/g, m => ({'&':'&','<':'<','>':'>'})[m]); |
| } |
| document.getElementById('sendBtn').onclick = () => { |
| const input = document.getElementById('msgInput'); |
| const text = input.value.trim(); |
| if (text && ws && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ type: 'message', content: text })); |
| input.value = ''; |
| } |
| }; |
| document.getElementById('msgInput').onkeypress = e => { if (e.key === 'Enter') document.getElementById('sendBtn').click(); }; |
| let graphInterval; |
| function startGraph() { |
| const canvas = document.getElementById('graphCanvas'); |
| function resize() { canvas.width = canvas.clientWidth; canvas.height = canvas.clientHeight; } |
| resize(); |
| window.addEventListener('resize', resize); |
| graphInterval = setInterval(fetchAndDrawGraph, 2000); |
| } |
| async function fetchAndDrawGraph() { |
| try { |
| const res = await fetch('/api/metrics'); |
| const data = await res.json(); |
| if (!data.agents) return; |
| drawGraph(data); |
| } catch(e) {} |
| } |
| function drawGraph(data) { |
| const canvas = document.getElementById('graphCanvas'); |
| if (!canvas) return; |
| const w = canvas.width, h = canvas.height; |
| if (w === 0 || h === 0) return; |
| const ctx = canvas.getContext('2d'); |
| ctx.clearRect(0, 0, w, h); |
| ctx.fillStyle = '#000'; |
| ctx.fillRect(0, 0, w, h); |
| const pad = 30, pw = w - 2*pad, ph = h - 2*pad; |
| if (pw <= 0 || ph <= 0) return; |
| ctx.strokeStyle = '#1a1a1a'; |
| ctx.lineWidth = 0.5; |
| for (let i = 0; i <= 5; i++) { |
| let y = pad + ph * (i/5); |
| ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(w-pad, y); ctx.stroke(); |
| } |
| for (let i = 0; i <= 4; i++) { |
| let x = pad + pw * (i/4); |
| ctx.beginPath(); ctx.moveTo(x, pad); ctx.lineTo(x, h-pad); ctx.stroke(); |
| } |
| ctx.fillStyle = '#0f0'; ctx.font = '8px monospace'; |
| ctx.fillText('100%', pad-20, pad+4); |
| ctx.fillText('0%', pad-20, h-pad-2); |
| data.agents.forEach((a, idx) => { |
| const m = data.metrics[a.id]; |
| if (!m || !m.cpu || !m.timestamps) return; |
| const cpu = m.cpu, ts = m.timestamps; |
| const points = []; |
| for (let i = 0; i < cpu.length; i++) { |
| if (ts[i] && ts[i] > 0) points.push({ cpu: cpu[i], t: ts[i] }); |
| } |
| if (points.length < 2) return; |
| let minT = points[0].t, maxT = points[points.length-1].t; |
| if (minT === maxT) { minT -= 5000; maxT += 5000; } |
| const col = colors[idx % colors.length]; |
| ctx.beginPath(); |
| ctx.strokeStyle = col; |
| ctx.lineWidth = 2; |
| for (let i = 0; i < points.length; i++) { |
| const x = pad + ((points[i].t - minT) / (maxT - minT)) * pw; |
| const y = h - pad - (points[i].cpu / 100) * ph; |
| if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); |
| } |
| ctx.stroke(); |
| }); |
| } |
| connect(); |
| startGraph(); |
| </script></body></html>`); |
| }); |
| |
| // ==================== WEBSOCKET SERVER ==================== |
| const server = app.listen(PORT, async () => { |
| await ensureGitRepo(); |
| pruneLineageFiles(); |
| for (const storeId of [...channels.keys(), ...dms.keys()]) { |
| loadProjectStateFromLineage(storeId); |
| loadRalphStateFromLineage(storeId); |
| } |
| startStackWatcher(); |
| try { |
| await axios.post(`${OLLAMA_URL}/api/embeddings`, { model: 'nomic-embed-text', prompt: 'test' }); |
| } catch(e) { |
| console.warn('[STACK] nomic-embed-text not loaded – run: ollama pull nomic-embed-text'); |
| } |
| console.log(`\x1b[32m✓ LACK v3.9.2 with STACK & full code moderation running at http://localhost:${PORT}\x1b[0m`); |
| console.log(` Real‑time file tools, tool calling, STACK orchestration, and code moderation active.`); |
| }); |
| const wss = new WebSocket.Server({ server }); |
| |
| wss.on('connection', (ws) => { |
| const userId = `human_${uuidv4().slice(0,4)}`; |
| clients.set(ws, { username: userId, channelId: 'general', userId, dmId: null, openThreadId: null }); |
| ws.on('message', async (raw) => { |
| try { |
| const data = JSON.parse(raw); |
| const client = clients.get(ws); |
| if (!client) return; |
| switch (data.type) { |
| case 'join': |
| if (channels.has(data.channelId)) { |
| client.channelId = data.channelId; client.dmId = null; |
| ws.send(JSON.stringify({ type: 'history', channelId: data.channelId, messages: channels.get(data.channelId).messages })); |
| ws.send(JSON.stringify({ type: 'agents_list', agents: Array.from(agents.values()).map(a => ({ id: a.id, name: a.name, model: a.model, systemPrompt: a.systemPrompt, channels: a.channels, status: a.status })) })); |
| ws.send(JSON.stringify({ type: 'channels', channels: Array.from(channels.values()).map(c => ({ id: c.id, name: c.name })) })); |
| ws.send(JSON.stringify({ type: 'dms', dms: getUserDMs(client.userId) })); |
| broadcastRalphStatus(data.channelId); |
| } |
| break; |
| case 'join_dm': |
| if (dms.has(data.dmId)) { |
| client.dmId = data.dmId; client.channelId = null; |
| ws.send(JSON.stringify({ type: 'dm_history', dmId: data.dmId, messages: dms.get(data.dmId).messages })); |
| broadcastRalphStatus(data.dmId); |
| } |
| break; |
| case 'message': |
| if (client.channelId) { |
| let msgText = data.content.trim(); |
| if (!msgText) break; |
| msgText = msgText.replace(/<[^>]*>/g, ''); |
| const humanMsg = addMessage(client.channelId, client.username, 'human', msgText); |
| if (humanMsg) { |
| broadcastToStore(client.channelId, humanMsg); |
| await onHumanMessage(client.channelId, humanMsg, ws); |
| } |
| } else if (client.dmId) { |
| let msgText = data.content.trim(); |
| if (!msgText) break; |
| msgText = msgText.replace(/<[^>]*>/g, ''); |
| const humanMsg = addMessage(client.dmId, client.username, 'human', msgText); |
| if (humanMsg) { |
| broadcastToStore(client.dmId, humanMsg); |
| await onHumanDmMessage(client.dmId, humanMsg, ws); |
| } |
| } |
| break; |
| case 'reply_in_thread': |
| let { parentId, content, storeId } = data; |
| if (!storeId) storeId = client.channelId || client.dmId; |
| if (storeId && (channels.has(storeId) || dms.has(storeId))) { |
| content = content.replace(/<[^>]*>/g, ''); |
| const replyMsg = addMessage(storeId, client.username, 'human', content, parentId); |
| if (replyMsg) { |
| broadcastToStore(storeId, replyMsg); |
| broadcastThreadUpdate(storeId, parentId); |
| ws.send(JSON.stringify({ type: 'thread_messages', storeId, threadId: parentId, messages: getThreadMessages(storeId, parentId) })); |
| } |
| } |
| break; |
| case 'set_username': |
| client.username = data.username.substring(0, 20).replace(/[<>]/g, ''); |
| break; |
| case 'start_dm': { |
| const targetName = data.targetName; |
| let targetId = null; |
| for (let [id, agent] of agents.entries()) if (agent.name.toLowerCase() === targetName.toLowerCase()) { targetId = id; break; } |
| if (!targetId) { ws.send(JSON.stringify({ type: 'error', message: 'Agent not found' })); break; } |
| let dm = Array.from(dms.values()).find(d => d.participants.includes(client.userId) && d.participants.includes(targetId)); |
| if (!dm) dm = createDM([client.userId, targetId]); |
| client.dmId = dm.id; client.channelId = null; |
| ws.send(JSON.stringify({ type: 'dm_joined', dmId: dm.id, messages: dm.messages })); |
| broadcastDMs(client.userId); |
| break; |
| } |
| case 'spawn_agent': { |
| const { name, model, systemPrompt, channels: agentChannels } = data; |
| const id = uuidv4().slice(0,8); |
| const newAgent = { id, name, model, systemPrompt, channels: agentChannels, lastResponseTime: new Map(), status: 'online', statusMessage: '' }; |
| agents.set(id, newAgent); |
| config.agents.push({ id, name, model, systemPrompt, channels: agentChannels }); |
| try { |
| const tmp = configPath + '.tmp'; |
| fs.writeFileSync(tmp, JSON.stringify(config, null, 2)); |
| fs.renameSync(tmp, configPath); |
| } catch (e) {} |
| agentMetrics.set(id, generateSyntheticMetrics()); |
| jsonFailCount.set(id, 0); |
| broadcastAgents(); |
| ws.send(JSON.stringify({ type: 'spawn_confirm', agent: newAgent })); |
| break; |
| } |
| case 'update_agent': { |
| const agent = agents.get(data.id); |
| if (agent) { |
| agent.name = data.name; agent.model = data.model; agent.systemPrompt = data.systemPrompt; agent.channels = data.channels; |
| const idx = config.agents.findIndex(a => a.id === data.id); |
| if (idx !== -1) { |
| config.agents[idx] = { id: data.id, name: data.name, model: data.model, systemPrompt: data.systemPrompt, channels: data.channels }; |
| try { |
| const tmp = configPath + '.tmp'; |
| fs.writeFileSync(tmp, JSON.stringify(config, null, 2)); |
| fs.renameSync(tmp, configPath); |
| } catch (e) {} |
| } |
| broadcastAgents(); |
| } |
| break; |
| } |
| case 'get_models': |
| ws.send(JSON.stringify({ type: 'models_list', models: await getOllamaModels() })); |
| break; |
| case 'add_reaction': { |
| const { messageId, emoji, storeId: reactStoreId } = data; |
| if (!userReactions.has(messageId)) userReactions.set(messageId, new Map()); |
| const msgReactions = userReactions.get(messageId); |
| if (!msgReactions.has(emoji)) msgReactions.set(emoji, new Set()); |
| msgReactions.get(emoji).add(client.userId); |
| for (let [otherWs] of clients.entries()) { |
| if (otherWs.readyState === WebSocket.OPEN) otherWs.send(JSON.stringify({ type: 'reaction_update', messageId, emoji, userId: client.userId, add: true })); |
| } |
| break; |
| } |
| case 'open_thread': |
| client.openThreadId = data.threadId; |
| break; |
| case 'close_thread': |
| client.openThreadId = null; |
| break; |
| } |
| } catch(err) { |
| const preview = typeof raw === 'string' ? raw.substring(0, 60).replace(/[\n\r]/g, ' ') : '[binary]'; |
| logError({ source: 'websocket_parse', error: err.message, preview }); |
| try { ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format – check JSON syntax' })); } catch(_) {} |
| } |
| }); |
| ws.on('close', () => { |
| const client = clients.get(ws); |
| if (client) { |
| if (client.channelId) cleanupStore(client.channelId); |
| if (client.dmId) cleanupStore(client.dmId); |
| } |
| clients.delete(ws); |
| }); |
| ws.send(JSON.stringify({ type: 'channels', channels: Array.from(channels.values()).map(c => ({ id: c.id, name: c.name })) })); |
| ws.send(JSON.stringify({ type: 'dms', dms: getUserDMs(userId) })); |
| ws.send(JSON.stringify({ type: 'agents_list', agents: Array.from(agents.values()).map(a => ({ id: a.id, name: a.name, model: a.model, systemPrompt: a.systemPrompt, channels: a.channels, status: a.status })) })); |
| }); |
| |
| // ==================== MESSAGE HANDLERS (with new moderation commands + test_dm) ==================== |
| async function onHumanMessage(channelId, messageObj, ws) { |
| const channel = channels.get(channelId); |
| if (!channel) return; |
| const content = messageObj.content; |
| if (content.startsWith('/')) { |
| const parts = content.slice(1).split(' '); |
| const cmd = parts[0].toLowerCase(); |
| const args = parts.slice(1); |
| if (cmd === 'help') { |
| const help = `Commands: /ground, /research <topic>, /abstract, /plan <goal>, /ralph <goal>, /stop, /list, /spawn, /siphon <topic>, /slime, /pull <id>, /dm <user>, /thread <id>, /pin <id>, /graph, /errorlog, /convergence, /tools, /stack build <name>, /stack add <description>, /stack import <file.json>, /stack set <repo>, /repo, /lint <filename>, /moderate on/off, /test_dm <agentName>`; |
| addMessage(channelId, 'System', 'system', help); broadcastToStore(channelId, { sender: 'System', content: help, senderType: 'system' }); |
| } else if (cmd === 'tools') { |
| const toolList = FILE_TOOLS.map(t => `- ${t.name}: ${t.description}`).join('\n'); |
| addMessage(channelId, 'System', 'system', `Available tools:\n${toolList}`); |
| broadcastToStore(channelId, { sender: 'System', content: toolList, senderType: 'system' }); |
| } else if (cmd === 'stack') { |
| const sub = args[0]; |
| const moderatorAgent = agents.get('moderator'); |
| if (!moderatorAgent) { |
| addMessage(channelId, 'System', 'system', 'Moderator agent not available.'); |
| return; |
| } |
| if (sub === 'build' && args[1]) { |
| await executeAction(moderatorAgent, channelId, {type: 'stack', payload: {subcmd: 'build', repoName: args[1]}}); |
| } else if (sub === 'add') { |
| await executeAction(moderatorAgent, channelId, {type: 'stack', payload: {subcmd: 'add', intent: args.slice(1).join(' ')}}); |
| } else if (sub === 'import' && args[1]) { |
| await executeAction(moderatorAgent, channelId, {type: 'stack', payload: {subcmd: 'import', jsonPath: args[1]}}); |
| } else if (sub === 'set' && args[1]) { |
| activeStackRepo.set(channelId, args[1]); |
| addMessage(channelId, 'System', 'system', `Active STACK repository set to ${args[1]}`); |
| broadcastToStore(channelId, { sender: 'System', content: `Active STACK repo: ${args[1]}`, senderType: 'system' }); |
| } else { |
| const help = `STACK Commands:\n/stack build <name> - Create new repo\n/stack add <description> - Semantic inject from templates\n/stack import <json> - Load blueprints\n/stack set <repo> - Set active repo for this chat`; |
| addMessage(channelId, 'Moderator', 'system', help); |
| broadcastToStore(channelId, { sender: 'Moderator', content: help, senderType: 'system' }); |
| } |
| } else if (cmd === 'repo') { |
| const targetId = args[0] || channelId; |
| const repoPath = getThreadRepoPath(targetId); |
| if (fs.existsSync(repoPath)) { |
| let output = `📁 **Repository for ${targetId}**\n\`${repoPath}\`\n\n**Files:**\n`; |
| const files = fs.readdirSync(repoPath).filter(f => !f.startsWith('.')); |
| output += files.map(f => `- ${f}`).join('\n'); |
| addMessage(channelId, 'Moderator', 'system', output); |
| broadcastToStore(channelId, { sender: 'Moderator', content: output, senderType: 'system' }); |
| } else { |
| addMessage(channelId, 'Moderator', 'system', `No repository found for ${targetId}. Create code first.`); |
| broadcastToStore(channelId, { sender: 'Moderator', content: `No repository found for ${targetId}.`, senderType: 'system' }); |
| } |
| } else if (cmd === 'moderate') { |
| const setting = args[0]; |
| const moderatorAgent = agents.get('moderator'); |
| if (setting === 'on') { |
| moderatorAgent.isCodeModerator = true; |
| addMessage(channelId, 'Moderator', 'system', '🔛 Code moderation ENABLED. All code blocks will be validated.'); |
| broadcastToStore(channelId, { sender: 'Moderator', content: 'Code moderation ENABLED.', senderType: 'system' }); |
| } else if (setting === 'off') { |
| moderatorAgent.isCodeModerator = false; |
| addMessage(channelId, 'Moderator', 'system', '🔴 Code moderation DISABLED.'); |
| broadcastToStore(channelId, { sender: 'Moderator', content: 'Code moderation DISABLED.', senderType: 'system' }); |
| } else { |
| addMessage(channelId, 'Moderator', 'system', `Moderation is ${moderatorAgent.isCodeModerator ? 'ON' : 'OFF'}. Use /moderate on/off`); |
| broadcastToStore(channelId, { sender: 'Moderator', content: `Moderation: ${moderatorAgent.isCodeModerator ? 'ON' : 'OFF'}`, senderType: 'system' }); |
| } |
| } else if (cmd === 'lint') { |
| const filename = args[0]; |
| if (!filename) { |
| addMessage(channelId, 'Moderator', 'system', 'Usage: /lint <filename>'); |
| broadcastToStore(channelId, { sender: 'Moderator', content: 'Usage: /lint <filename>', senderType: 'system' }); |
| } else { |
| const threadId = messageObj.parentId || channelId; |
| const repoPath = getThreadRepoPath(threadId); |
| const filePath = path.join(repoPath, filename); |
| if (!fs.existsSync(filePath)) { |
| addMessage(channelId, 'Moderator', 'system', `File not found: ${filename}`); |
| } else { |
| const ext = path.extname(filename).slice(1); |
| const langMap = { 'py': 'python', 'js': 'javascript', 'html': 'html', 'json': 'json' }; |
| const lang = langMap[ext] || 'text'; |
| const result = await runLinter(lang, filePath); |
| let output = `🔍 **Lint Results** for \`${filename}\`\n`; |
| output += result.passed ? '✅ Syntax OK\n' : '❌ Syntax errors found\n'; |
| if (result.errors.length) output += `\n**Errors:**\n${result.errors.map(e => `- ${e}`).join('\n')}`; |
| if (result.warnings.length) output += `\n**Warnings:**\n${result.warnings.map(w => `- ${w}`).join('\n')}`; |
| addMessage(channelId, 'Moderator', 'system', output); |
| broadcastToStore(channelId, { sender: 'Moderator', content: output, senderType: 'system' }); |
| } |
| } |
| } else if (cmd === 'test_dm') { |
| const targetName = args[0]; |
| if (!targetName) { |
| addMessage(channelId, 'System', 'system', 'Usage: /test_dm <agentName>'); |
| broadcastToStore(channelId, { sender: 'System', content: 'Usage: /test_dm <agentName>', senderType: 'system' }); |
| return; |
| } |
| const dm = await handleDMCommand(getUserId(ws), [targetName], ws); |
| if (dm) { |
| // Send a thread root and reply to test threading |
| const parentMsg = addMessage(dm.id, 'System', 'system', 'Thread root message for testing'); |
| if (parentMsg) { |
| broadcastToStore(dm.id, parentMsg); |
| const replyMsg = addMessage(dm.id, 'System', 'system', 'Nested reply inside thread', parentMsg.id); |
| if (replyMsg) broadcastToStore(dm.id, replyMsg); |
| ws.send(JSON.stringify({ type: 'thread_messages', storeId: dm.id, threadId: parentMsg.id, messages: getThreadMessages(dm.id, parentMsg.id) })); |
| } |
| ws.send(JSON.stringify({ type: 'switch_to_dm', dmId: dm.id })); |
| addMessage(channelId, 'System', 'system', `Test DM created with ${targetName}. Check your DMs.`); |
| broadcastToStore(channelId, { sender: 'System', content: `Test DM created with ${targetName}.`, senderType: 'system' }); |
| } else { |
| addMessage(channelId, 'System', 'system', `Failed to create DM with ${targetName}.`); |
| broadcastToStore(channelId, { sender: 'System', content: `Failed to create DM with ${targetName}.`, senderType: 'system' }); |
| } |
| } else { |
| // Original commands (unchanged) |
| if (cmd === 'ground') { |
| const groundMsg = { sender: 'System', content: 'GROUND: All agents respond.' }; |
| addMessage(channelId, 'System', 'system', groundMsg.content); broadcastToStore(channelId, groundMsg); |
| const agentsInChannel = Array.from(agents.values()).filter(a => a.channels.includes(channel.name) && !a.isEmbedOperator); |
| for (const agent of agentsInChannel) agentRespond(agent, channelId, groundMsg, false); |
| } else if (cmd === 'research' && args.length) { |
| stopLoop(channelId); channel.researchActive = true; channel.researchTopic = args.join(' '); |
| addMessage(channelId, 'System', 'system', `Research mode started on: ${channel.researchTopic}`); broadcastToStore(channelId, { sender: 'System', content: `Research mode started on: ${channel.researchTopic}`, senderType: 'system' }); |
| scheduleLoopRound(channelId); |
| } else if (cmd === 'abstract') { |
| stopLoop(channelId); channel.abstractActive = true; |
| addMessage(channelId, 'System', 'system', 'Abstract mode active – agents will plan actions.'); broadcastToStore(channelId, { sender: 'System', content: 'Abstract mode active – agents will plan actions.', senderType: 'system' }); |
| scheduleLoopRound(channelId); |
| } else if (cmd === 'plan' && args.length) { |
| stopLoop(channelId); |
| const newState = { active: true, title: args.join(' '), goals: [args.join(' ')], nextSteps: [], completedTasks: [], memory: {} }; |
| setProjectState(channelId, newState); |
| channel.abstractActive = true; |
| addMessage(channelId, 'System', 'system', `📋 Project planning started: "${newState.title}".`); broadcastToStore(channelId, { sender: 'System', content: `Project planning started: "${newState.title}"`, senderType: 'system' }); |
| scheduleLoopRound(channelId); |
| } else if (cmd === 'ralph' && args.length) { |
| stopLoop(channelId); |
| const goal = args.join(' '); |
| loadProjectStateFromLineage(channelId); |
| startRalphLoop(channelId, goal); |
| addMessage(channelId, 'System', 'system', `🧬 **Ralph loop started**\nGoal: ${goal}\nWill converge when similarity ≥ 0.95 or max 30 generations.`); |
| broadcastToStore(channelId, { sender: 'System', content: `Ralph evolution started: ${goal}`, senderType: 'system' }); |
| } else if (cmd === 'stop') { stopLoop(channelId); } |
| else if (cmd === 'list') { const models = await getOllamaModels(); const listText = models.length ? 'Available Ollama models:\n' + models.join('\n') : 'No Ollama models found.'; addMessage(channelId, 'System', 'system', listText); broadcastToStore(channelId, { sender: 'System', content: listText, senderType: 'system' }); } |
| else if (cmd === 'spawn') { ws.send(JSON.stringify({ type: 'models_list', models: await getOllamaModels() })); } |
| else if (cmd === 'siphon') { const topic = args.join(' ') || 'general research topic'; const sessionId = uuidv4(); const session = { id: sessionId, topic, phase: 'Initializing', metric: 0, logs: [], facts: [], notes: [], questions: [], currentQuestionIndex: 0, startedAt: Date.now() }; researchSessions.set(sessionId, session); runResearch(sessionId, topic, channelId).catch(console.error); addMessage(channelId, 'Siphon', 'system', `🔍 Started research on "${topic}". Check #siphon.`); broadcastToStore(channelId, { sender: 'Siphon', content: `Research started: ${topic}`, senderType: 'system' }); } |
| else if (cmd === 'slime') { const token = uuidv4().replace(/-/g, '').substring(0,16); const pin = Math.floor(100000 + Math.random() * 900000).toString(); const expiresAt = Date.now() + 60 * 60 * 1000; slimeSessions.set(token, { pin, expiresAt, channelId }); const url = `http://localhost:${PORT}/slime?token=${token}&pin=${pin}`; addMessage(channelId, 'System', 'system', `📱 Mobile URL: ${url} PIN: ${pin} (expires 1h)`); broadcastToStore(channelId, { sender: 'System', content: `Mobile access: ${url}`, senderType: 'system' }); } |
| else if (cmd === 'pull' && args.length) { const session = researchSessions.get(args[0]); if (!session) { addMessage(channelId, 'System', 'system', `No session ${args[0]}.`); broadcastToStore(channelId, { sender: 'System', content: `No session ${args[0]}.`, senderType: 'system' }); return; } let summary = `📊 **Research "${session.topic}"**\nMetric: ${(session.metric*100).toFixed(0)}%\n`; if (session.notes.length) { const last = session.notes[session.notes.length-1]; summary += `**Latest answer:** ${last.answer.substring(0,300)}\nKey facts:\n${last.facts.slice(0,3).map(f => `- ${f}`).join('\n')}`; } else { summary += 'Research still in progress.'; } addMessage(channelId, 'Siphon', 'system', summary); broadcastToStore(channelId, { sender: 'Siphon', content: summary, senderType: 'system' }); } |
| else if (cmd === 'dm') { const dm = await handleDMCommand(getUserId(ws), args, ws); if (dm) ws.send(JSON.stringify({ type: 'switch_to_dm', dmId: dm.id })); } |
| else if (cmd === 'thread') { const messageId = args[0]; if (!messageId) ws.send(JSON.stringify({ type: 'error', message: 'Usage: /thread <messageId>' })); else ws.send(JSON.stringify({ type: 'thread_messages', storeId: channelId, threadId: messageId, messages: getThreadMessages(channelId, messageId) })); } |
| else if (cmd === 'pin') { if (!args[0]) ws.send(JSON.stringify({ type: 'error', message: 'Usage: /pin <messageId>' })); else { if (!pinnedMessages.has(channelId)) pinnedMessages.set(channelId, new Set()); pinnedMessages.get(channelId).add(args[0]); ws.send(JSON.stringify({ type: 'pinned', messageId: args[0], channelId })); } } |
| else if (cmd === 'graph') { ws.send(JSON.stringify({ type: 'graph_ack' })); } |
| else if (cmd === 'errorlog') { |
| let logText = '**ERROR LOG**\n'; |
| const errors = global.errorLog || []; |
| errors.slice(0,50).forEach(e => { logText += `${new Date(e.timestamp).toLocaleString()} | ${e.agentId || 'system'}: ${e.error}\n`; }); |
| if (!errors.length) logText += 'No errors recorded.'; |
| addMessage(channelId, 'System', 'system', logText); |
| broadcastToStore(channelId, { sender: 'System', content: logText, senderType: 'system' }); |
| } |
| else if (cmd === 'convergence') { const lineage = reconstructLineage(channelId); let lastSpec = null, sim = 0; for (let i = lineage.length-1; i >= 0; i--) { if (lineage[i].type === 'project_state') { const spec = computeSpecFromState(lineage[i].state); if (lastSpec) { sim = similarity(lastSpec, spec); break; } lastSpec = spec; } } const msg = `🔍 Convergence similarity: ${(sim*100).toFixed(1)}%`; addMessage(channelId, 'System', 'system', msg); broadcastToStore(channelId, { sender: 'System', content: msg, senderType: 'system' }); } |
| else { addMessage(channelId, 'System', 'system', `Unknown command: ${cmd}. Type /help`); broadcastToStore(channelId, { sender: 'System', content: `Unknown command: ${cmd}`, senderType: 'system' }); } |
| } |
| return; |
| } |
| const relevantAgents = Array.from(agents.values()).filter(a => a.channels.includes(channel.name) && !a.isEmbedOperator); |
| const state = getProjectState(channelId); |
| const usePlanning = state.active || channel.abstractActive || channel.researchActive; |
| for (const agent of relevantAgents) { |
| if (ralphActive.get(channelId)) continue; |
| if (usePlanning) await agentPlanAndAct(agent, channelId, messageObj, messageObj.parentId); |
| else await agentRespond(agent, channelId, messageObj, false, messageObj.parentId); |
| } |
| } |
| |
| async function onHumanDmMessage(dmId, messageObj, ws) { |
| const dm = dms.get(dmId); |
| if (!dm) return; |
| const content = messageObj.content; |
| if (content.startsWith('/')) { |
| const parts = content.slice(1).split(' '); |
| const cmd = parts[0].toLowerCase(); |
| const args = parts.slice(1); |
| if (cmd === 'ralph' && args.length) { |
| stopRalphLoop(dmId); |
| const goal = args.join(' '); |
| loadProjectStateFromLineage(dmId); |
| startRalphLoop(dmId, goal); |
| addMessage(dmId, 'System', 'system', `🧬 Ralph loop started in DM.\nGoal: ${goal}`); |
| broadcastToStore(dmId, { sender: 'System', content: `Ralph started: ${goal}`, senderType: 'system' }); |
| return; |
| } else if (cmd === 'stop') { |
| stopRalphLoop(dmId); |
| setProjectState(dmId, { active: false, title: null, goals: [], nextSteps: [], completedTasks: [], memory: {} }); |
| addMessage(dmId, 'System', 'system', 'Autonomous mode stopped.'); |
| broadcastToStore(dmId, { sender: 'System', content: 'Autonomous mode stopped.', senderType: 'system' }); |
| return; |
| } else if (cmd === 'help') { |
| const help = 'DM Commands: /ralph <goal>, /stop, /help, /tools, /stack ...'; |
| addMessage(dmId, 'System', 'system', help); |
| broadcastToStore(dmId, { sender: 'System', content: help, senderType: 'system' }); |
| return; |
| } |
| } |
| const agentParticipants = dm.participants.filter(p => agents.has(p) && !agents.get(p).isEmbedOperator); |
| const state = getProjectState(dmId); |
| for (const agentId of agentParticipants) { |
| const agent = agents.get(agentId); |
| if (!agent) continue; |
| if (ralphActive.get(dmId)) continue; |
| const usePlanning = state.active; |
| if (usePlanning) await agentPlanAndAct(agent, dmId, messageObj, messageObj.parentId); |
| else await agentRespond(agent, dmId, messageObj, false, messageObj.parentId); |
| } |
| } |
| ''' |
|
|
| |
| |
| |
| INDEX_HTML = r'''<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> |
| <title>LACK v3.9.2</title> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
| <style> |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| body { font-family: monospace; background: var(--white); color: var(--black); height: 100vh; overflow: hidden; transition: background 0.3s, color 0.3s; } |
| :root { --white: #fff; --off-white: #f8f8f8; --light-gray: #e0e0e0; --gray: #a0a0a0; --dark-gray: #666; --black: #000; --shadow-dark: rgba(0,0,0,0.2); } |
| .dark-mode { --white: #0a0a0a; --off-white: #1a1a1a; --light-gray: #2a2a2a; --gray: #555; --dark-gray: #999; --black: #f0f0f0; --shadow-dark: rgba(255,255,255,0.1); } |
| .neuro-menu { position: fixed; top: 0; left: 0; right: 0; height: 48px; background: var(--white); border-bottom: 2px solid var(--black); display: flex; align-items: center; justify-content: space-between; padding: 0 1rem; z-index: 10000; flex-wrap: wrap; gap: 0.5rem; } |
| .menu-item { font-size: 0.85rem; font-weight: 600; white-space: nowrap; } |
| .neuro-status { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.7rem; } |
| .dark-mode-toggle, .ground-btn, .cron-btn { background: var(--white); border: 1px solid var(--black); border-radius: 20px; padding: 0.25rem 0.75rem; cursor: pointer; font-size: 0.7rem; white-space: nowrap; } |
| .cron-btn { background: #ff4444; color: white; border-color: #ff4444; } |
| .ralph-badge { background: #9b59b6; color: white; border-radius: 12px; padding: 0.2rem 0.6rem; font-size: 0.7rem; display: none; } |
| .neuro-desktop { position: absolute; top: 48px; left: 0; right: 0; bottom: 0; padding: 1rem; background: var(--off-white); display: flex; overflow: hidden; } |
| .chat-container { display: flex; width: 100%; height: 100%; background: var(--white); border: 2px solid var(--black); box-shadow: 8px 8px 0 var(--shadow-dark); overflow: hidden; } |
| .sidebar { width: 260px; min-width: 200px; max-width: 30%; background: var(--white); border-right: 2px solid var(--black); display: flex; flex-direction: column; overflow-y: auto; flex-shrink: 0; } |
| .main-chat { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--white); } |
| .thread-panel { width: 300px; background: var(--white); border-left: 2px solid var(--black); display: none; flex-direction: column; flex-shrink: 0; } |
| .thread-panel.open { display: flex; } |
| @media (max-width: 700px) { .sidebar { min-width: 160px; width: 180px; } .thread-panel.open { position: fixed; right: 0; top: 48px; bottom: 0; width: 85%; max-width: 320px; z-index: 2000; box-shadow: -4px 0 12px rgba(0,0,0,0.3); } } |
| .chat-header { padding: 0.75rem 1rem; border-bottom: 2px solid var(--black); font-weight: 600; background: var(--white); flex-shrink: 0; } |
| .messages-area { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; background: var(--off-white); } |
| .input-area { padding: 0.75rem 1rem; border-top: 2px solid var(--black); display: flex; gap: 0.75rem; align-items: flex-start; background: var(--white); flex-wrap: wrap; } |
| .input-area textarea { flex: 1; background: var(--white); border: 1px solid var(--black); padding: 0.5rem; font-family: monospace; resize: vertical; min-width: 120px; font-size: 0.85rem; } |
| .input-area button { background: var(--white); border: 2px solid var(--black); padding: 0.5rem 1rem; cursor: pointer; font-weight: bold; font-size: 0.8rem; } |
| .file-upload-btn { background: none; border: none; font-size: 1.4rem; cursor: pointer; color: var(--gray); padding: 0 0.25rem; } |
| .file-upload-btn:hover { color: var(--black); } |
| .message-group { margin-bottom: 0.5rem; } |
| .message { display: flex; gap: 0.75rem; padding: 0.25rem 0; } |
| .message-avatar { width: 32px; height: 32px; background: var(--light-gray); border: 1px solid var(--black); display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0; } |
| .message-content { flex: 1; min-width: 0; word-wrap: break-word; } |
| .message-sender { font-weight: 600; font-size: 0.8rem; } |
| .message-timestamp { font-size: 0.7rem; color: var(--dark-gray); } |
| .message-text { font-size: 0.85rem; line-height: 1.4; word-wrap: break-word; } |
| .message-text pre { background: #111; color: #0f0; padding: 0.5rem; overflow-x: auto; font-size: 0.75rem; border-radius: 4px; } |
| .reply-badge { font-size: 0.7rem; text-decoration: underline; cursor: pointer; margin-top: 0.25rem; } |
| .message-actions { display: none; gap: 0.5rem; margin-top: 0.25rem; } |
| .message:hover .message-actions { display: flex; } |
| .action-icon { font-size: 0.7rem; background: var(--white); border: 1px solid var(--light-gray); padding: 0.2rem 0.4rem; cursor: pointer; } |
| .sidebar-section { border-bottom: 1px solid var(--light-gray); } |
| .sidebar-header { padding: 0.75rem; font-weight: 600; font-size: 0.75rem; background: var(--off-white); cursor: pointer; } |
| .channel-list { padding: 0.5rem; } |
| .channel-item, .agent-item, .research-item { padding: 0.5rem; margin: 0.25rem 0; cursor: pointer; font-size: 0.75rem; display: flex; align-items: center; gap: 0.5rem; border: 1px solid transparent; } |
| .channel-item:hover, .agent-item:hover, .research-item:hover { background: var(--light-gray); } |
| .agent-item { justify-content: space-between; } |
| .agent-info { display: flex; align-items: center; gap: 0.5rem; flex: 1; } |
| .agent-status { width: 8px; height: 8px; border-radius: 50%; } |
| .status-online { background: #2eb67d; } |
| .status-thinking { background: #ecb22e; animation: pulse 1s infinite; } |
| .status-queued { background: #ffa500; animation: pulse 0.5s infinite; } |
| @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } } |
| .remove-agent { color: #ff4444; cursor: pointer; opacity: 0.6; } |
| .research-progress { font-size: 0.7rem; margin-left: auto; } |
| .thread-header { padding: 0.75rem; border-bottom: 2px solid var(--black); display: flex; justify-content: space-between; } |
| .thread-messages { flex: 1; overflow-y: auto; padding: 0.75rem; } |
| .thread-input { padding: 0.75rem; border-top: 1px solid var(--light-gray); } |
| .thread-input textarea { width: 100%; padding: 0.5rem; font-family: monospace; } |
| .modal { display: none; position: fixed; z-index: 20000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); align-items: center; justify-content: center; } |
| .modal-content { background: var(--white); border: 2px solid var(--black); padding: 1.5rem; width: 90%; max-width: 700px; max-height: 90vh; overflow-y: auto; } |
| .modal-content input, .modal-content select, .modal-content textarea { width: 100%; margin: 0.5rem 0; padding: 0.5rem; } |
| .modal-buttons { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; } |
| .error-log-entry { font-family: monospace; font-size: 0.7rem; border-bottom: 1px solid var(--light-gray); padding: 0.5rem; white-space: pre-wrap; } |
| .toast { position: fixed; bottom: 1rem; right: 1rem; background: #333; color: white; padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.8rem; opacity: 0; transition: opacity 0.3s; z-index: 20001; pointer-events: none; } |
| .toast.show { opacity: 1; } |
| .toast.success { background: #2ecc71; } |
| .toast.error { background: #e74c3c; } |
| .agent-thinking-overlay { position: fixed; bottom: 1rem; left: 1rem; background: rgba(0,0,0,0.7); color: #ff0; padding: 0.4rem 1rem; border-radius: 20px; font-size: 0.75rem; z-index: 10001; } |
| .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: var(--white); border-top: 1px solid var(--light-gray); padding: 0.25rem 1rem; font-size: 0.6rem; display: flex; justify-content: space-between; z-index: 10000; } |
| .file-name-chip { background: var(--light-gray); border-radius: 16px; padding: 0.2rem 0.6rem; font-size: 0.7rem; display: inline-flex; align-items: center; gap: 6px; } |
| .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--gray); border-top-color: var(--black); border-radius: 50%; animation: spin 0.6s linear infinite; } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| .slash-suggestions { position: absolute; bottom: 100%; left: 0; background: var(--white); border: 1px solid var(--black); list-style: none; padding: 0.25rem; max-height: 150px; overflow-y: auto; z-index: 200; } |
| .slash-suggestions li { padding: 0.25rem 0.5rem; cursor: pointer; font-size: 0.7rem; } |
| canvas#agentGraph { width: 100%; height: 100%; display: block; } |
| </style> |
| </head> |
| <body> |
| <div class="neuro-menu"> |
| <div class="menu-item">LACK v3.9.2</div> |
| <div class="neuro-status"> |
| <span id="agentCount">Agents: 0</span> |
| <span id="ralphStatusBadge" class="ralph-badge">🧬 Ralph active</span> |
| <button class="ground-btn" id="groundBtn">🌍 GROUND</button> |
| <button class="ground-btn" id="graphBtn">📈 GRAPH</button> |
| <button class="ground-btn" id="errorlogBtn">⚠️ ERRORLOG</button> |
| <button class="cron-btn" id="cronBtn">💣 CRON</button> |
| <div class="dark-mode-toggle" id="darkModeToggle">🌓</div> |
| </div> |
| </div> |
| <div class="neuro-desktop"> |
| <div class="chat-container"> |
| <div class="sidebar" id="sidebar"></div> |
| <div class="main-chat"> |
| <div class="chat-header" id="currentChatName">#general</div> |
| <div class="messages-area" id="messagesArea"></div> |
| <div class="input-area"> |
| <label class="file-upload-btn"><i class="fas fa-paperclip"></i><input type="file" id="fileInput" style="display:none" accept=".txt,.md,.json,.csv,.log,.py,.js,.html,.css"></label> |
| <div id="filePreview" style="display:flex; align-items:center; gap:4px;"></div> |
| <textarea id="messageInput" rows="1" placeholder="Type /help ..."></textarea> |
| <button id="sendBtn">SEND</button> |
| <div id="uploadSpinner" class="spinner" style="display:none;"></div> |
| </div> |
| </div> |
| <div class="thread-panel" id="threadPanel"> |
| <div class="thread-header"><span>Thread</span><i class="fas fa-times" id="closeThreadBtn" style="cursor:pointer"></i></div> |
| <div class="thread-messages" id="threadMessages"></div> |
| <div class="thread-input"><textarea id="threadReplyInput" rows="2" placeholder="Reply..."></textarea><button id="sendThreadReply">Reply</button></div> |
| </div> |
| </div> |
| </div> |
| <div class="bottom-bar"><span>LACK · Real‑time scrolling graph | Click agent → edit | Double‑click → DM</span><span id="statusText">CONNECTED</span></div> |
| <div id="agentThinkingToast" class="agent-thinking-overlay" style="display:none;"><i class="fas fa-spinner fa-pulse"></i> Agent is thinking...</div> |
| |
| <div id="agentModal" class="modal"><div class="modal-content"><h3>Edit Agent</h3><input type="text" id="editAgentId" hidden><label>Name:</label><input type="text" id="editAgentName"><label>Model:</label><select id="editAgentModel"></select><label>System Prompt:</label><textarea id="editAgentPrompt" rows="3"></textarea><label>Channels (comma):</label><input type="text" id="editAgentChannels"><div class="modal-buttons"><button id="openDmBtn">Open DM</button><button id="removeAgentBtn">Remove Agent</button><button id="saveAgentBtn">Save</button><button id="closeModalBtn">Cancel</button></div></div></div> |
| <div id="quickSwitcherModal" class="modal"><div class="modal-content"><input type="text" id="switcherInput" placeholder="Jump... Ctrl+K"><div class="shortcut-hint">Ctrl+K</div></div></div> |
| <div id="graphModal" class="modal"><div class="modal-content" style="width:820px; max-width:95%; height:620px; display:flex; flex-direction:column;"><div style="display:flex; justify-content:space-between;"><h3>🧪 Agent Resource Monitor (Real‑time Scrolling)</h3><button id="closeGraphBtn" style="background:none;border:none;font-size:24px;">✕</button></div><div style="flex:1; background:var(--off-white); margin:12px 0; border:2px solid var(--black);"><canvas id="agentGraph" width="780" height="420" style="width:100%; height:100%;"></canvas></div><div id="graphLegend" style="display:flex; gap:16px; flex-wrap:wrap; font-size:11px;"></div><div style="font-size:10px; text-align:center;">CPU (solid) & Activity (semi) – Time scrolls left to right</div></div></div> |
| <div id="errorLogModal" class="modal"><div class="modal-content"><h3>📋 Error Log (last 50)</h3><div id="errorLogContent" style="max-height: 500px; overflow-y: auto; font-family: monospace; font-size: 0.7rem;"></div><div class="modal-buttons"><button id="closeErrorLogBtn">Close</button></div></div></div> |
| |
| <div id="toast" class="toast"></div> |
| |
| <script> |
| let ws, currentStoreId = 'general', currentStoreType = 'channel', username = localStorage.getItem('lack_username') || 'human_' + Math.floor(Math.random()*1000), userId = '', agents = [], researchSessions = [], channels = [], dms = [], currentThreadId = null, graphInterval = null, graphCanvas, graphCtx, resizeListener = false; |
| let pendingFile = null; |
| |
| function showToast(msg, type = 'info') { |
| const toast = document.getElementById('toast'); |
| toast.className = `toast ${type}`; |
| toast.innerText = msg; |
| toast.classList.add('show'); |
| setTimeout(() => toast.classList.remove('show'), 3000); |
| } |
| |
| function init() { |
| connect(); |
| document.getElementById('sendBtn').onclick = sendMessage; |
| document.getElementById('messageInput').onkeypress = e => { if(e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }; |
| document.getElementById('messageInput').addEventListener('input', autoGrow); |
| document.getElementById('messageInput').addEventListener('keydown', handleSlash); |
| document.getElementById('darkModeToggle').onclick = () => { document.body.classList.toggle('dark-mode'); if(document.getElementById('graphModal').style.display === 'flex') fetchAndDrawGraph(); }; |
| document.getElementById('groundBtn').onclick = () => sendCommand('/ground'); |
| document.getElementById('graphBtn').onclick = () => openGraphModal(); |
| document.getElementById('errorlogBtn').onclick = () => fetchAndShowErrorLog(); |
| document.getElementById('cronBtn').onclick = () => { if(confirm('⚠️ CRON WIPE: Delete ALL cron jobs, add heartbeats, reset ALL data. Are you sure?')) fetch('/api/cron/wipe',{method:'POST'}).then(r=>r.json()).then(d=>{if(d.success) location.reload(); else alert('Error: '+d.error);}); }; |
| document.getElementById('closeThreadBtn').onclick = closeThreadPanel; |
| document.getElementById('sendThreadReply').onclick = sendThreadReply; |
| document.addEventListener('keydown', e => { if((e.ctrlKey||e.metaKey) && e.key === 'k') { e.preventDefault(); document.getElementById('quickSwitcherModal').style.display = 'flex'; document.getElementById('switcherInput').focus(); } }); |
| document.getElementById('quickSwitcherModal').onclick = e => { if(e.target === document.getElementById('quickSwitcherModal')) document.getElementById('quickSwitcherModal').style.display = 'none'; }; |
| document.getElementById('switcherInput').addEventListener('keyup', e => { if(e.key === 'Enter') handleQuickSwitch(); }); |
| setInterval(fetchResearchSessions, 5000); |
| document.getElementById('closeGraphBtn').onclick = () => { document.getElementById('graphModal').style.display = 'none'; if(graphInterval) clearInterval(graphInterval); if(resizeListener) window.removeEventListener('resize', handleGraphResize); }; |
| document.getElementById('closeErrorLogBtn').onclick = () => { document.getElementById('errorLogModal').style.display = 'none'; }; |
| |
| const fileInput = document.getElementById('fileInput'); |
| fileInput.addEventListener('change', async (e) => { |
| const file = e.target.files[0]; |
| if (!file) return; |
| const MAX_SIZE = 512 * 1024; |
| if (file.size > MAX_SIZE) { showToast(`File too large (max 512KB)`, 'error'); fileInput.value = ''; return; } |
| const previewDiv = document.getElementById('filePreview'); |
| previewDiv.innerHTML = `<span class="file-name-chip">📎 ${escapeHtml(file.name)} <i class="fas fa-times-circle" id="removeFileChip" style="cursor:pointer"></i></span>`; |
| document.getElementById('removeFileChip').onclick = () => { previewDiv.innerHTML = ''; pendingFile = null; fileInput.value = ''; }; |
| const spinner = document.getElementById('uploadSpinner'); |
| spinner.style.display = 'inline-block'; |
| try { |
| const base64 = await readFileAsBase64(file); |
| pendingFile = { name: file.name, contentBase64: base64, size: file.size }; |
| showToast(`File "${file.name}" ready to send`, 'success'); |
| } catch (err) { showToast(`Failed to read file`, 'error'); pendingFile = null; previewDiv.innerHTML = ''; } |
| finally { spinner.style.display = 'none'; fileInput.value = ''; } |
| }); |
| |
| document.getElementById('saveAgentBtn').onclick = () => { |
| const id = document.getElementById('editAgentId').value; |
| const name = document.getElementById('editAgentName').value; |
| const model = document.getElementById('editAgentModel').value; |
| const prompt = document.getElementById('editAgentPrompt').value; |
| const chans = document.getElementById('editAgentChannels').value.split(',').map(s=>s.trim()); |
| ws.send(JSON.stringify({type:'update_agent',id,name,model,systemPrompt:prompt,channels:chans})); |
| document.getElementById('agentModal').style.display='none'; |
| showToast(`Agent "${name}" updated`, 'success'); |
| }; |
| document.getElementById('closeModalBtn').onclick = () => { document.getElementById('agentModal').style.display='none'; }; |
| document.getElementById('openDmBtn').onclick = () => { |
| const agentName = document.getElementById('editAgentName').value; |
| if (agentName) { openDmWithAgent(agentName); document.getElementById('agentModal').style.display='none'; } |
| }; |
| document.getElementById('removeAgentBtn').onclick = async () => { |
| const agentId = document.getElementById('editAgentId').value; |
| const agentName = document.getElementById('editAgentName').value; |
| if (confirm(`Delete agent "${agentName}" permanently?`)) { |
| const resp = await fetch(`/api/agent/${agentId}`, { method: 'DELETE' }); |
| const result = await resp.json(); |
| if (resp.ok && result.success) { document.getElementById('agentModal').style.display='none'; showToast(`Agent "${agentName}" removed`, 'success'); } |
| else { showToast(result.reason || 'Cannot remove agent', 'error'); } |
| } |
| }; |
| |
| setInterval(() => { |
| const anyThinking = agents.some(a => a.status === 'thinking' || a.status === 'queued'); |
| document.getElementById('agentThinkingToast').style.display = anyThinking ? 'flex' : 'none'; |
| }, 500); |
| } |
| |
| function readFileAsBase64(file) { |
| return new Promise((resolve, reject) => { |
| const reader = new FileReader(); |
| reader.onload = () => resolve(reader.result.split(',')[1]); |
| reader.onerror = reject; |
| reader.readAsDataURL(file); |
| }); |
| } |
| |
| function connect() { |
| const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| ws = new WebSocket(protocol+'//'+location.host); |
| ws.onopen = () => { |
| document.getElementById('statusText').innerText = 'CONNECTED'; |
| ws.send(JSON.stringify({type:'join',channelId:currentStoreId})); |
| ws.send(JSON.stringify({type:'set_username',username})); |
| }; |
| ws.onmessage = e => { |
| const d = JSON.parse(e.data); |
| switch(d.type) { |
| case 'channels': channels = d.channels; renderSidebar(); break; |
| case 'dms': dms = d.dms; renderSidebar(); break; |
| case 'agents_list': agents = d.agents; document.getElementById('agentCount').innerText = 'Agents: '+agents.length; renderSidebar(); |
| if (document.getElementById('graphModal').style.display === 'flex') fetchAndDrawGraph(); |
| break; |
| case 'history': renderMessages(d.messages); break; |
| case 'new_message': if(d.channelId === currentStoreId && currentStoreType==='channel') appendMessage(d.message); break; |
| case 'dm_history': renderMessages(d.messages); break; |
| case 'new_dm_message': if(d.dmId === currentStoreId && currentStoreType==='dm') appendMessage(d.message); break; |
| case 'dm_joined': switchToDm(d.dmId); renderMessages(d.messages); showToast('Joined DM', 'success'); break; |
| case 'switch_to_dm': { const dm = dms.find(x=>x.id===d.dmId); if(dm) switchToDm(dm.id); break; } |
| case 'research_update': fetchResearchSessions(); break; |
| case 'thread_messages': renderThreadMessages(d.messages); currentThreadId = d.threadId; openThreadPanel(); break; |
| case 'thread_update': if(currentThreadId === d.threadId) renderThreadMessages(d.messages); break; |
| case 'models_list': populateModelSelect(d.models); break; |
| case 'spawn_confirm': appendSystemMessage('Agent '+d.agent.name+' created.'); showToast(`Agent ${d.agent.name} spawned`, 'success'); break; |
| case 'error': showToast(d.message, 'error'); break; |
| case 'cron_reset': location.reload(); break; |
| case 'graph_ack': openGraphModal(); break; |
| case 'ralph_status': |
| const badge = document.getElementById('ralphStatusBadge'); |
| if (d.storeId === currentStoreId && d.active) { |
| badge.style.display = 'inline-block'; |
| badge.innerHTML = `🧬 Ralph gen ${d.generation} · ${d.goal || ''}`; |
| } else if (d.storeId === currentStoreId && !d.active) { |
| badge.style.display = 'none'; |
| } |
| break; |
| } |
| }; |
| ws.onclose = () => { document.getElementById('statusText').innerText = 'DISCONNECTED'; setTimeout(connect,3000); }; |
| } |
| |
| function populateModelSelect(models) { |
| const select = document.getElementById('editAgentModel'); |
| if (!select) return; |
| select.innerHTML = ''; |
| (models || []).forEach(m => { |
| const opt = document.createElement('option'); |
| opt.value = m; opt.textContent = m; |
| select.appendChild(opt); |
| }); |
| } |
| |
| function renderSidebar() { |
| const sidebar = document.getElementById('sidebar'); if(!sidebar) return; |
| sidebar.innerHTML = ''; |
| addSection('CHANNELS', channels.map(c => ({ id: c.id, name: '#'+c.name, type:'channel', icon:'fa-hashtag' }))); |
| addSection('DIRECT MESSAGES', dms.map(d => ({ id: d.id, name: d.name, type:'dm', icon:'fa-comment' }))); |
| addSection('AGENTS', agents.map(a => ({ id: a.id, name: a.name, type:'agent', icon:'fa-robot', status:a.status, model: a.model }))); |
| addSection('RESEARCH', researchSessions.map(s => ({ id: s.id, name: s.topic.substring(0,20), type:'research', icon:'fa-flask', progress:s.metric }))); |
| } |
| |
| function addSection(title, items) { |
| if(!items.length) return; |
| const section = document.createElement('div'); section.className = 'sidebar-section'; |
| const header = document.createElement('div'); header.className = 'sidebar-header'; header.innerHTML = `<i class="fas fa-chevron-down"></i> ${title}`; |
| let itemsDiv = document.createElement('div'); itemsDiv.className = 'channel-list'; |
| header.onclick = () => { itemsDiv.style.display = itemsDiv.style.display === 'none' ? 'block' : 'none'; }; |
| section.appendChild(header); |
| items.forEach(item => { |
| let div; |
| if (item.type === 'agent') { |
| div = document.createElement('div'); |
| div.className = 'agent-item'; |
| div.innerHTML = ` |
| <div class="agent-info" data-agent-id="${item.id}"> |
| <i class="fas ${item.icon}"></i> |
| <span class="agent-name">${escapeHtml(item.name)}</span> |
| <span class="agent-model">${escapeHtml(item.model || '')}</span> |
| <span class="agent-status status-${item.status}"></span> |
| </div> |
| <i class="fas fa-trash-alt remove-agent" data-agent-id="${item.id}" title="Remove"></i> |
| `; |
| const infoDiv = div.querySelector('.agent-info'); |
| infoDiv.onclick = (e) => { e.stopPropagation(); openEditModal(agents.find(a => a.id === item.id)); }; |
| infoDiv.ondblclick = (e) => { e.stopPropagation(); openDmWithAgent(item.name); showToast(`Opening DM with ${item.name}...`, 'info'); }; |
| const trash = div.querySelector('.remove-agent'); |
| trash.onclick = async (e) => { e.stopPropagation(); if(confirm(`Permanently remove agent "${item.name}"?`)) { const resp = await fetch(`/api/agent/${item.id}`, { method: 'DELETE' }); const result = await resp.json(); if (resp.ok && result.success) { showToast(`Agent "${item.name}" removed`, 'success'); } else { showToast(result.reason || 'Cannot remove agent', 'error'); } } }; |
| } else { |
| div = document.createElement('div'); div.className = 'channel-item'; |
| div.innerHTML = `<i class="fas ${item.icon}"></i> ${escapeHtml(item.name)}`; |
| if(item.progress !== undefined) { let p = document.createElement('span'); p.style.fontSize='0.7rem'; p.style.marginLeft='auto'; p.innerText = `${Math.round(item.progress*100)}%`; div.appendChild(p); } |
| div.onclick = () => { |
| if(item.type === 'channel') switchToChannel(item.id); |
| else if(item.type === 'dm') switchToDm(item.id); |
| else if(item.type === 'research') sendCommand('/pull '+item.id); |
| }; |
| } |
| itemsDiv.appendChild(div); |
| }); |
| section.appendChild(itemsDiv); sidebar.appendChild(section); |
| } |
| |
| function switchToChannel(id) { currentStoreId = id; currentStoreType = 'channel'; const ch = channels.find(c=>c.id===id); document.getElementById('currentChatName').innerHTML = ch ? '#'+ch.name : id; ws.send(JSON.stringify({type:'join',channelId:id})); closeThreadPanel(); updateRalphBadge(); } |
| function switchToDm(id) { currentStoreId = id; currentStoreType = 'dm'; const dm = dms.find(d=>d.id===id); document.getElementById('currentChatName').innerHTML = dm ? dm.name : 'DM'; ws.send(JSON.stringify({type:'join_dm',dmId:id})); closeThreadPanel(); updateRalphBadge(); } |
| |
| function updateRalphBadge() { |
| const badge = document.getElementById('ralphStatusBadge'); |
| badge.style.display = 'none'; |
| } |
| |
| function renderMessages(messages) { |
| const container = document.getElementById('messagesArea'); container.innerHTML = ''; |
| const groups = []; let cur = null; |
| for(const msg of messages) { |
| if(msg.senderType === 'system') { if(cur) groups.push(cur); groups.push({sender:msg.sender, messages:[msg], lastTimestamp:msg.timestamp}); cur=null; continue; } |
| if(!cur || cur.sender !== msg.sender || msg.timestamp - cur.lastTimestamp > 300000) { if(cur) groups.push(cur); cur = { sender: msg.sender, messages: [], lastTimestamp: msg.timestamp }; } |
| cur.messages.push(msg); cur.lastTimestamp = msg.timestamp; |
| } |
| if(cur) groups.push(cur); |
| for(const g of groups) { |
| const groupDiv = document.createElement('div'); groupDiv.className = 'message-group'; |
| for(let i=0;i<g.messages.length;i++) { |
| const msg = g.messages[i]; |
| const msgDiv = createMessageElement(msg, i===0 ? g.sender : null); |
| groupDiv.appendChild(msgDiv); |
| } |
| container.appendChild(groupDiv); |
| } |
| container.scrollTop = container.scrollHeight; |
| } |
| |
| function createMessageElement(msg, showSender) { |
| const div = document.createElement('div'); div.className = 'message'; |
| const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.innerText = msg.sender.charAt(0).toUpperCase(); |
| const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; |
| if(showSender) { |
| const senderSpan = document.createElement('div'); senderSpan.className = 'message-sender'; |
| senderSpan.innerHTML = `${escapeHtml(msg.sender)} <span class="message-timestamp">${formatTime(msg.timestamp)}</span>`; |
| contentDiv.appendChild(senderSpan); |
| } |
| const textDiv = document.createElement('div'); textDiv.className = 'message-text'; |
| textDiv.innerHTML = formatCode(escapeHtml(msg.content)); |
| contentDiv.appendChild(textDiv); |
| if(msg.replyCount > 0) { |
| const badge = document.createElement('div'); badge.className = 'reply-badge'; |
| badge.innerHTML = `<i class="fas fa-reply-all"></i> ${msg.replyCount} replies`; |
| badge.onclick = () => fetchThread(msg.id); |
| contentDiv.appendChild(badge); |
| } |
| const actions = document.createElement('div'); actions.className = 'message-actions'; |
| actions.innerHTML = `<i class="fas fa-reply action-icon" title="Reply"></i><i class="fas fa-plus-circle action-icon" title="React"></i><i class="fas fa-thumbtack action-icon" title="Pin"></i><i class="fas fa-copy action-icon" title="Copy"></i>`; |
| actions.querySelector('.fa-reply').onclick = () => fetchThread(msg.id); |
| actions.querySelector('.fa-plus-circle').onclick = (e) => { e.stopPropagation(); showReactionPicker(msg.id, e); }; |
| actions.querySelector('.fa-thumbtack').onclick = () => sendCommand(`/pin ${msg.id}`); |
| actions.querySelector('.fa-copy').onclick = () => navigator.clipboard.writeText(msg.content); |
| contentDiv.appendChild(actions); |
| div.appendChild(avatar); div.appendChild(contentDiv); |
| return div; |
| } |
| |
| function appendMessage(msg) { const container = document.getElementById('messagesArea'); const groupDiv = document.createElement('div'); groupDiv.className = 'message-group'; groupDiv.appendChild(createMessageElement(msg,true)); container.appendChild(groupDiv); container.scrollTop = container.scrollHeight; } |
| function appendSystemMessage(text) { const container = document.getElementById('messagesArea'); const div = document.createElement('div'); div.className = 'message'; div.innerHTML = `<div class="message-avatar">S</div><div class="message-content"><em>${escapeHtml(text)}</em></div>`; container.appendChild(div); container.scrollTop = container.scrollHeight; } |
| function fetchThread(mid) { ws.send(JSON.stringify({type:'reply_in_thread',parentId:mid,content:'',storeId:currentStoreId})); } |
| function renderThreadMessages(messages) { const container = document.getElementById('threadMessages'); container.innerHTML = ''; for(const msg of messages) { const div = document.createElement('div'); div.className = 'message'; div.innerHTML = `<strong>${escapeHtml(msg.sender)}</strong> ${formatTime(msg.timestamp)}<br>${formatCode(escapeHtml(msg.content))}`; container.appendChild(div); } container.scrollTop = container.scrollHeight; } |
| function sendThreadReply() { const txt = document.getElementById('threadReplyInput').value.trim(); if(txt && currentThreadId) { ws.send(JSON.stringify({type:'reply_in_thread',parentId:currentThreadId,content:txt,storeId:currentStoreId})); document.getElementById('threadReplyInput').value = ''; } } |
| function openThreadPanel() { document.getElementById('threadPanel').classList.add('open'); ws.send(JSON.stringify({type:'open_thread',threadId:currentThreadId})); } |
| function closeThreadPanel() { document.getElementById('threadPanel').classList.remove('open'); if(currentThreadId) ws.send(JSON.stringify({type:'close_thread',threadId:currentThreadId})); currentThreadId = null; } |
| |
| function sendMessage() { |
| let finalMessage = ''; |
| if (pendingFile) { |
| let fileContent; |
| try { fileContent = atob(pendingFile.contentBase64); } catch(e) { showToast('Failed to decode file', 'error'); return; } |
| const MAX_CHARS = 1500; |
| const truncated = fileContent.length > MAX_CHARS ? fileContent.substring(0, MAX_CHARS) + '\n...(truncated)' : fileContent; |
| const fileBlock = `📎 **File: ${pendingFile.name}**\n\`\`\`\n${truncated}\n\`\`\``; |
| const userText = document.getElementById('messageInput').value.trim(); |
| finalMessage = userText ? `${fileBlock}\n\n${userText}` : fileBlock; |
| pendingFile = null; |
| document.getElementById('filePreview').innerHTML = ''; |
| } else { |
| finalMessage = document.getElementById('messageInput').value.trim(); |
| } |
| if (!finalMessage) return; |
| if (finalMessage.startsWith('/spawn')) { handleSpawn(); document.getElementById('messageInput').value = ''; autoGrow(); return; } |
| ws.send(JSON.stringify({type:'message',content:finalMessage})); |
| document.getElementById('messageInput').value = ''; |
| autoGrow(); |
| } |
| |
| function openDmWithAgent(agentName) { |
| if (!ws) return; |
| ws.send(JSON.stringify({ type: 'start_dm', targetName: agentName })); |
| } |
| |
| function sendCommand(cmd) { if(ws) ws.send(JSON.stringify({type:'message',content:cmd})); } |
| function formatTime(ts) { return new Date(ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); } |
| function formatCode(t) { return t.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>'); } |
| function autoGrow() { const ta = document.getElementById('messageInput'); ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight,200)+'px'; } |
| let slashTimeout; |
| function handleSlash(e) { if(e.key !== '/') return; const input = e.target; if(input.selectionStart === 0 || input.value[input.selectionStart-1] === ' ') { if(slashTimeout) clearTimeout(slashTimeout); const existing = document.querySelector('.slash-suggestions'); if(existing) existing.remove(); const commands = ['help','ground','research','abstract','plan','ralph','stop','list','spawn','siphon','slime','pull','dm','thread','pin','graph','errorlog','convergence','tools','stack','repo','lint','moderate']; const sug = document.createElement('ul'); sug.className = 'slash-suggestions'; commands.forEach(cmd => { const li = document.createElement('li'); li.innerText = '/'+cmd; li.onclick = () => { input.value = '/'+cmd+' '; input.focus(); sug.remove(); }; sug.appendChild(li); }); input.parentNode.style.position = 'relative'; input.parentNode.appendChild(sug); slashTimeout = setTimeout(() => { if(sug.parentNode) sug.remove(); },5000); document.addEventListener('click', function close(e) { if(!sug.contains(e.target) && e.target !== input) { sug.remove(); document.removeEventListener('click', close); } }); } } |
| function showReactionPicker(messageId, event) { |
| const emojis = ['👍','❤️','😂','😮','😢','🔥']; |
| const picker = document.createElement('div'); |
| picker.style.position='fixed'; picker.style.background='var(--white)'; picker.style.border='1px solid var(--black)'; picker.style.borderRadius='20px'; picker.style.padding='4px'; picker.style.display='flex'; picker.style.gap='8px'; picker.style.zIndex=1000; |
| emojis.forEach(emoji => { |
| const btn = document.createElement('span'); |
| btn.innerText=emoji; btn.style.cursor='pointer'; btn.style.fontSize='1.2rem'; btn.style.padding='4px'; |
| btn.onclick = () => { ws.send(JSON.stringify({type:'add_reaction',messageId:messageId,emoji,storeId:currentStoreId})); picker.remove(); }; |
| picker.appendChild(btn); |
| }); |
| document.body.appendChild(picker); |
| if (event) { picker.style.left = (event.clientX - 50) + 'px'; picker.style.top = (event.clientY - 40) + 'px'; } |
| else { picker.style.left = '50%'; picker.style.top = '50%'; picker.style.transform = 'translate(-50%, -50%)'; } |
| setTimeout(()=>picker.remove(),3000); |
| } |
| function handleQuickSwitch() { const q = document.getElementById('switcherInput').value.toLowerCase(); const ch = channels.find(c=>c.name.toLowerCase().includes(q)); if(ch) switchToChannel(ch.id); const dm = dms.find(d=>d.name.toLowerCase().includes(q)); if(dm) switchToDm(dm.id); const ag = agents.find(a=>a.name.toLowerCase().includes(q)); if(ag) openEditModal(ag); document.getElementById('quickSwitcherModal').style.display='none'; } |
| function fetchResearchSessions() { fetch('/api/research/sessions').then(r=>r.json()).then(d=>{ researchSessions = d.sessions; renderSidebar(); }).catch(console.error); } |
| function openEditModal(agent) { |
| document.getElementById('editAgentId').value = agent.id; |
| document.getElementById('editAgentName').value = agent.name; |
| document.getElementById('editAgentPrompt').value = agent.systemPrompt; |
| document.getElementById('editAgentChannels').value = agent.channels.join(','); |
| fetch('/api/models').then(r=>r.json()).then(data => { |
| const sel = document.getElementById('editAgentModel'); |
| sel.innerHTML = ''; |
| (data.models||[]).forEach(m => { |
| const opt = document.createElement('option'); |
| opt.value = m; opt.textContent = m; |
| if (m === agent.model) opt.selected = true; |
| sel.appendChild(opt); |
| }); |
| }); |
| document.getElementById('agentModal').style.display = 'block'; |
| } |
| function handleSpawn() { ws.send(JSON.stringify({type:'get_models'})); const orig = ws.onmessage; ws.onmessage = e => { const d = JSON.parse(e.data); if(d.type === 'models_list') { if(!d.models.length) alert('No Ollama models'); else { const name = prompt('Agent name:'); if(name) { const model = prompt('Model:',d.models[0]); const promptText = prompt('System prompt:','You are helpful.'); const chans = prompt('Channels (comma):','general').split(',').map(s=>s.trim()); ws.send(JSON.stringify({type:'spawn_agent',name,model,systemPrompt:promptText,channels:chans})); } } ws.onmessage = orig; } else if(orig) orig(e); }; } |
| function escapeHtml(s) { return s.replace(/[&<>]/g,m=>({'&':'&','<':'<','>':'>'}[m])); } |
| |
| async function fetchAndShowErrorLog() { |
| try { |
| const res = await fetch('/api/errorlog'); |
| const data = await res.json(); |
| const container = document.getElementById('errorLogContent'); |
| container.innerHTML = ''; |
| if (!data.errors || data.errors.length === 0) { |
| container.innerHTML = '<div class="error-log-entry">No errors recorded.</div>'; |
| } else { |
| data.errors.forEach(err => { |
| const div = document.createElement('div'); |
| div.className = 'error-log-entry'; |
| div.textContent = `${new Date(err.timestamp).toLocaleString()} | ${err.agentId || 'system'}: ${err.error}`; |
| container.appendChild(div); |
| }); |
| } |
| document.getElementById('errorLogModal').style.display = 'flex'; |
| } catch(e) { showToast('Failed to fetch error log', 'error'); } |
| } |
| |
| async function openGraphModal() { |
| document.getElementById('graphModal').style.display = 'flex'; |
| await new Promise(r => setTimeout(r, 100)); |
| graphCanvas = document.getElementById('agentGraph'); |
| if (!graphCanvas) return; |
| resizeGraphCanvas(); |
| await fetchAndDrawGraph(); |
| if (graphInterval) clearInterval(graphInterval); |
| graphInterval = setInterval(fetchAndDrawGraph, 1500); |
| if (!resizeListener) { |
| window.addEventListener('resize', handleGraphResize); |
| resizeListener = true; |
| } |
| } |
| function handleGraphResize() { |
| if (document.getElementById('graphModal').style.display === 'flex') { |
| resizeGraphCanvas(); |
| fetchAndDrawGraph(); |
| } |
| } |
| function resizeGraphCanvas() { |
| if (!graphCanvas) return; |
| const container = graphCanvas.parentElement; |
| if (!container) return; |
| const w = container.clientWidth, h = container.clientHeight; |
| if (w === 0 || h === 0) return; |
| const dpr = window.devicePixelRatio || 1; |
| graphCanvas.width = w * dpr; |
| graphCanvas.height = h * dpr; |
| graphCanvas.style.width = w + 'px'; |
| graphCanvas.style.height = h + 'px'; |
| graphCtx = graphCanvas.getContext('2d'); |
| graphCtx.setTransform(1, 0, 0, 1, 0, 0); |
| graphCtx.scale(dpr, dpr); |
| } |
| async function fetchAndDrawGraph() { |
| resizeGraphCanvas(); |
| try { |
| const res = await fetch('/api/metrics'); |
| const data = await res.json(); |
| if (!data.agents) return; |
| const legend = document.getElementById('graphLegend'); |
| const colors = ['#ff3b5c','#00f0ff','#39ff14','#ffeb3b','#c84cff']; |
| legend.innerHTML = ''; |
| data.agents.forEach((a,i) => { |
| const c = colors[i%colors.length]; |
| const d = document.createElement('div'); |
| d.style.display='flex'; d.style.alignItems='center'; d.style.gap='6px'; d.style.fontSize='11px'; |
| d.innerHTML = `<span style="display:inline-block;width:20px;height:3px;background:${c};border-radius:2px;"></span> ${escapeHtml(a.name)}`; |
| legend.appendChild(d); |
| }); |
| drawGraph(data); |
| } catch(e) { console.error(e); } |
| } |
| function drawGraph(data) { |
| if (!graphCtx || !graphCanvas) return; |
| const container = graphCanvas.parentElement; |
| const w = container.clientWidth, h = container.clientHeight; |
| if (w === 0 || h === 0) return; |
| const isDark = document.body.classList.contains('dark-mode'); |
| const bg = isDark?'#0a0a0a':'#f8f8f8'; |
| const grid = isDark?'#2a2a2a':'#e0e0e0'; |
| const text = isDark?'#f0f0f0':'#000'; |
| graphCtx.clearRect(0,0,w,h); |
| graphCtx.fillStyle=bg; graphCtx.fillRect(0,0,w,h); |
| const pad=40, pw=w-2*pad, ph=h-2*pad; |
| if(pw<=0||ph<=0) return; |
| graphCtx.strokeStyle=grid; graphCtx.lineWidth=1; |
| for(let i=0;i<=5;i++){ let y=pad+ph*(i/5); graphCtx.beginPath(); graphCtx.moveTo(pad,y); graphCtx.lineTo(w-pad,y); graphCtx.stroke(); } |
| for(let i=0;i<=4;i++){ let x=pad+pw*(i/4); graphCtx.beginPath(); graphCtx.moveTo(x,pad); graphCtx.lineTo(x,h-pad); graphCtx.stroke(); } |
| graphCtx.fillStyle=text; graphCtx.font='10px monospace'; |
| graphCtx.fillText('100%',pad-30,pad+5); |
| graphCtx.fillText('0%',pad-25,h-pad-5); |
| graphCtx.fillText('Time →',w/2,h-10); |
| const colors = ['#ff3b5c','#00f0ff','#39ff14','#ffeb3b','#c84cff']; |
| let allZero = true; |
| for (const a of data.agents) { |
| const m = data.metrics[a.id]; |
| if (m && m.cpu && m.cpu.some(v => v > 0)) { allZero = false; break; } |
| } |
| if (allZero) { |
| graphCtx.fillStyle = text; |
| graphCtx.font = '12px monospace'; |
| graphCtx.textAlign = 'center'; |
| graphCtx.fillText('Waiting for agent activity...', w/2, h/2); |
| graphCtx.textAlign = 'left'; |
| return; |
| } |
| data.agents.forEach((a, idx) => { |
| const m = data.metrics[a.id]; |
| if (!m || !m.cpu || !m.activity || !m.timestamps) return; |
| const cpu = m.cpu, act = m.activity, ts = m.timestamps; |
| const validPoints = []; |
| for (let i = 0; i < cpu.length; i++) { |
| if (ts[i] && ts[i] > 0) validPoints.push({ cpu: cpu[i], act: act[i], t: ts[i] }); |
| } |
| if (validPoints.length < 2) return; |
| let minT = validPoints[0].t; |
| let maxT = validPoints[validPoints.length - 1].t; |
| if (minT === maxT) { |
| minT = minT - 5000; |
| maxT = maxT + 5000; |
| } |
| const col = colors[idx % colors.length]; |
| graphCtx.beginPath(); |
| graphCtx.strokeStyle = col; |
| graphCtx.lineWidth = 2.5; |
| graphCtx.shadowBlur = 3; |
| graphCtx.shadowColor = col; |
| for (let i = 0; i < validPoints.length; i++) { |
| const x = pad + ((validPoints[i].t - minT) / (maxT - minT)) * pw; |
| const y = h - pad - (validPoints[i].cpu / 100) * ph; |
| if (i === 0) graphCtx.moveTo(x, y); |
| else graphCtx.lineTo(x, y); |
| } |
| graphCtx.stroke(); |
| graphCtx.beginPath(); |
| graphCtx.strokeStyle = col + 'dd'; |
| graphCtx.lineWidth = 2; |
| graphCtx.shadowBlur = 0; |
| for (let i = 0; i < validPoints.length; i++) { |
| const x = pad + ((validPoints[i].t - minT) / (maxT - minT)) * pw; |
| const y = h - pad - (validPoints[i].act / 100) * ph; |
| if (i === 0) graphCtx.moveTo(x, y); |
| else graphCtx.lineTo(x, y); |
| } |
| graphCtx.stroke(); |
| }); |
| graphCtx.shadowBlur = 0; |
| } |
| window.onload = init; |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| CONFIG_JSON = r'''{ |
| "httpPort": 3721, |
| "agents": [ |
| { "id": "agent1", "name": "Agent 1", "model": "qwen2.5:0.5b", "systemPrompt": "You are a helpful AI assistant.", "channels": ["general","random","siphon","code"] }, |
| { "id": "agent2", "name": "Agent 2", "model": "qwen2.5:0.5b", "systemPrompt": "You are a creative AI.", "channels": ["general","random","siphon","code"] } |
| ], |
| "channels": [ |
| { "id": "general", "name": "general" }, |
| { "id": "random", "name": "random" }, |
| { "id": "siphon", "name": "siphon" }, |
| { "id": "code", "name": "code" } |
| ], |
| "dms": [] |
| }''' |
|
|
| BIN_LACK_JS = r'''#!/usr/bin/env node |
| const { spawn } = require('child_process'); |
| const path = require('path'); |
| const projectRoot = path.resolve(__dirname, '..'); |
| process.chdir(projectRoot); |
| async function checkOllama() { |
| const http = require('http'); |
| return new Promise((resolve) => { |
| const req = http.get('http://localhost:11434/api/tags', (res) => resolve(res.statusCode === 200)); |
| req.on('error', () => resolve(false)); |
| req.setTimeout(1000, () => resolve(false)); |
| }); |
| } |
| async function main() { |
| console.log('\x1b[36m[ LACK v3.9.2 ] Starting – STACK + tools + code moderator\x1b[0m'); |
| if (!await checkOllama()) { console.error('\x1b[31m✗ Ollama not running\x1b[0m'); process.exit(1); } |
| console.log('\x1b[32m✓ Ollama detected\x1b[0m'); |
| const server = spawn('node', ['server.js'], { stdio: 'inherit', cwd: projectRoot }); |
| server.on('error', (err) => { console.error('Failed to start server:', err); process.exit(1); }); |
| process.on('SIGINT', () => { server.kill('SIGINT'); process.exit(); }); |
| } |
| main(); |
| ''' |
|
|
| |
| |
| |
| def create_directory(path): |
| Path(path).mkdir(parents=True, exist_ok=True) |
|
|
| def write_file(path, content): |
| with open(path, 'w', encoding='utf-8') as f: |
| f.write(content) |
|
|
| def make_executable(path): |
| st = os.stat(path) |
| os.chmod(path, st.st_mode | stat.S_IEXEC) |
|
|
| def run_command(cmd, cwd=None): |
| print(f"Running: {' '.join(cmd)}") |
| result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) |
| if result.returncode != 0: |
| print("STDERR:", result.stderr) |
| if "npm" in cmd[0] and "install" in cmd: |
| print("\n❌ npm install failed. Possible fixes:") |
| print(" 1. Ensure Node.js >= 18 is installed: node --version") |
| print(" 2. Check internet connection (npm needs network)") |
| print(" 3. Run manually: npm install express ws uuid axios cheerio simple-git") |
| raise subprocess.CalledProcessError(result.returncode, cmd, output=result.stdout, stderr=result.stderr) |
| print(result.stdout) |
|
|
| def open_browser(): |
| time.sleep(2) |
| webbrowser.open('http://localhost:3721') |
|
|
| def check_ollama_with_retry(max_attempts=3, delay=2): |
| import urllib.request |
| for attempt in range(1, max_attempts + 1): |
| try: |
| req = urllib.request.Request("http://localhost:11434/api/tags", method="GET") |
| with urllib.request.urlopen(req, timeout=3) as resp: |
| if resp.status == 200: |
| return json.loads(resp.read().decode()) |
| except Exception as e: |
| if attempt < max_attempts: |
| print(f" Ollama check attempt {attempt}/{max_attempts} failed ({e}). Retrying in {delay}s...") |
| time.sleep(delay) |
| else: |
| raise |
| return None |
|
|
| def main(): |
| print("=== LACK v3.9.2 – STACK + Code Moderation (enhanced for small models) ===") |
| print("Added JSON repair, forced code blocks, fallbacks, forced commits, test DM.\n") |
|
|
| for d in ["config", "public", "bin", "logs", "lineage", "research", "workspace", "lack_repos/templates", "thread_repos"]: |
| create_directory(d) |
|
|
| print("Generating files...") |
| write_file("server.js", SERVER_JS) |
| write_file("public/index.html", INDEX_HTML) |
| write_file("config/lack.config.json", CONFIG_JSON) |
| write_file("bin/lack.js", BIN_LACK_JS) |
| make_executable("bin/lack.js") |
|
|
| try: |
| node_version = subprocess.run(["node", "--version"], capture_output=True, text=True, check=True) |
| print(f"Node.js detected: {node_version.stdout.strip()}") |
| except (subprocess.CalledProcessError, FileNotFoundError): |
| print("Error: Node.js is not installed. Install from https://nodejs.org") |
| sys.exit(1) |
|
|
| if not Path("node_modules").exists(): |
| print("Installing npm dependencies...") |
| run_command(["npm", "install", "express", "ws", "uuid", "axios", "cheerio", "simple-git"]) |
| else: |
| print("node_modules already present.") |
|
|
| print("Checking Ollama...") |
| try: |
| models_data = check_ollama_with_retry() |
| if models_data is not None: |
| print("✓ Ollama is running.") |
| model_names = [m['name'] for m in models_data.get('models', [])] |
| if not any('qwen2.5:0.5b' in m for m in model_names): |
| print("⚠ qwen2.5:0.5b not found. Run: ollama pull qwen2.5:0.5b") |
| if not any('nomic-embed-text' in m for m in model_names): |
| print("⚠ nomic-embed-text not found. Run: ollama pull nomic-embed-text") |
| else: |
| print("⚠ Ollama responded but returned no data.") |
| except Exception as e: |
| print(f"⚠ Ollama not reachable after 3 attempts: {e}") |
|
|
| threading.Thread(target=open_browser, daemon=True).start() |
| print("\nStarting LACK v3.9.2 with enhanced small‑model resilience. Press Ctrl+C to stop.\n") |
|
|
| while True: |
| print("Launching Node server...") |
| server_process = subprocess.Popen( |
| ["node", "server.js"], |
| stdout=sys.stdout, |
| stderr=sys.stderr |
| ) |
| try: |
| server_process.wait() |
| except KeyboardInterrupt: |
| print("Shutting down...") |
| server_process.terminate() |
| sys.exit(0) |
| print("Server crashed. Restarting in 3 seconds...") |
| time.sleep(3) |
|
|
| if __name__ == "__main__": |
| main() |
|
|