lack / lack.py
webxos's picture
Upload lack.py
2c58176 verified
Raw
History Blame Contribute Delete
161 kB
#!/usr/bin/env python3
"""
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"
# ----------------------------------------------------------------------
# Embedded Node.js server – REORDERED to avoid ReferenceError on logError
# ----------------------------------------------------------------------
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;'})[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);
}
}
''' # end of SERVER_JS
# ----------------------------------------------------------------------
# Full HTML Frontend (unchanged)
# ----------------------------------------------------------------------
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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[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();
'''
# ----------------------------------------------------------------------
# Python Launcher
# ----------------------------------------------------------------------
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()