#!/usr/bin/env node // crowdcode.js — MiniLlama Terminal CLI (Linux + Windows) // Chat + Dateioperationen via llama.cpp API // Aufruf: node crowdcode.js [endpoint] [model] // ─── Konfiguration (via ENV überschreibbar) ────────────────────────────────── const API = process.argv[2] || process.env.LLAMA_API || 'http://127.0.0.1:8080/v1'; const MODEL = process.argv[3] || process.env.LLAMA_MODEL || ''; const CONFIG = { timeout: parseInt(process.env.LLAMA_TIMEOUT || '120000'), // Streaming-Timeout (ms) ctxSize: parseInt(process.env.LLAMA_CTX_SIZE || '4096'), // Kontext-Fenster gpuLayers: parseInt(process.env.LLAMA_GPU_LAYERS || '0'), // GPU-Layer für --n-gpu-layers }; // ─── Terminal-Farben (ANSI, funktioniert auf Win/Lin) ───────────────────────── const C = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', red: '\x1b[31m', grey: '\x1b[90m', }; // ─── async/await readline ───────────────────────────────────────────────────── const readline = require('readline'); const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const WORKSPACE = process.argv[4] ? path.resolve(process.argv[4]) : process.cwd(); const SYSTEM_PROMPT = `You are CrowdCode, a concise coding assistant with FULL shell access. FILE OPERATIONS (no confirmation needed): - DIR: path/to/dir — create a directory - FILE: path/to/file on its own line, then fenced code block with COMPLETE file content — create or overwrite a file - EDIT: path/to/file, then ---OLD, old text, ---NEW, new text, --- — edit a file SHELL COMMANDS (user confirms each): - Use \`\`\`bash for: cp, mv, rm, grep, find, git, npm, and other shell operations that aren't file/dir creation - When the user asks about files/dirs/system, use a \`\`\`bash block Examples: - user: "erstelle test.txt mit Hallo" → DIR: tests \n FILE: tests/test.txt \n\`\`\`\nHallo\n\`\`\` - user: "liste dateien" → \`\`\`bash\nls -la\n\`\`\` - user: "kopiere test.txt als test2.txt" → \`\`\`bash\ncp test.txt test2.txt\n\`\`\` Never explain — just output the commands. NEVER use echo, touch, mkdir, cat, printf to create files — use FILE: and DIR: instead.`; function askSingleLine(prompt) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(prompt, (answer) => { rl.close(); resolve(answer.trim()); }); }); } async function askInput() { const line = await askSingleLine(`${C.cyan}›${C.reset} `); if (line === ':m') { // Multiline-Modus return askMultiline(); } return line; } function askMultiline() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { const lines = []; console.log(`${C.dim} (Multiline — ${C.reset}:send${C.dim} zum Senden, ${C.reset}:cancel${C.dim} zum Abbrechen)${C.reset}`); rl.on('line', (line) => { const trimmed = line.trim(); if (trimmed === ':send' || trimmed === ':s') { rl.close(); return; } if (trimmed === ':cancel' || trimmed === ':c') { rl.close(); resolve(null); return; } lines.push(line); }); rl.on('close', () => resolve(lines.join('\n').replace(/\n+$/, ''))); }); } // ─── Modelle auflisten ───────────────────────────────────────────────────────── async function fetchModels() { try { const res = await fetch(`${API}/models`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); return data.data || data.models || []; } catch { return []; } } // ─── Chat Completion (Streaming) ─────────────────────────────────────────────── async function chatComplete(messages, onToken, maxTokens) { const model = MODEL || (await fetchModels())[0]?.id || 'unknown'; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), CONFIG.timeout); try { const body = JSON.stringify({ model, messages, stream: true, temperature: 0.2, top_p: 0.9, ...(maxTokens ? { max_tokens: maxTokens } : {}) }); const res = await fetch(`${API}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer local' }, body, signal: controller.signal, }); clearTimeout(timeoutId); if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder(); let fullText = ''; while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); for (const line of chunk.split('\n')) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const data = trimmed.slice(5).trim(); if (data === '[DONE]') continue; try { const parsed = JSON.parse(data); // reasoning_content für DeepSeek-artige Modelle const reasoning = parsed.choices?.[0]?.delta?.reasoning_content || ''; const token = parsed.choices?.[0]?.delta?.content || ''; if (reasoning) { fullText += reasoning; onToken(reasoning, true); } if (token) { fullText += token; onToken(token, false); } } catch { /* skip malformed JSON */ } } } return fullText; } finally { clearTimeout(timeoutId); } } // ─── @mention Auflösung ─────────────────────────────────────────────────────── async function resolveMentions(text) { const mentionRegex = /@(\S+)/g; let result = text; for (const match of text.matchAll(mentionRegex)) { const ref = match[1]; let replacement = ''; try { const targetPath = path.resolve(WORKSPACE, ref); // @dir/** — tree if (ref.endsWith('/**')) { const dirPath = ref.slice(0, -3); replacement = await buildTree(path.resolve(WORKSPACE, dirPath), ''); } // @dir/ — listing else if (ref.endsWith('/')) { const dirPath = ref.slice(0, -1); const entries = fs.readdirSync(path.resolve(WORKSPACE, dirPath), { withFileTypes: true }); entries.sort((a, b) => { if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1; return a.name.localeCompare(b.name); }); replacement = entries.map(e => e.isDirectory() ? e.name + '/' : e.name).join('\n'); } // @?term — grep else if (ref.startsWith('?')) { const term = ref.slice(1); const isGlob = term.includes('*'); replacement = isGlob ? await globSearch(term) : await grepSearch(term); } // @file.ext — file read else if (fs.existsSync(targetPath) && fs.statSync(targetPath).isFile()) { const content = fs.readFileSync(targetPath, 'utf8'); const ext = ref.split('.').pop() || ''; replacement = `\n[Dateiinhalt: ${ref}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`; } } catch { replacement = `(${ref}: nicht gefunden) ⚠️`; } result = result.replace(match[0], replacement); } return result; } async function buildTree(dirPath, prefix) { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); entries.sort((a, b) => { if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1; return a.name.localeCompare(b.name); }); const lines = []; for (let i = 0; i < entries.length; i++) { const e = entries[i]; const isLast = i === entries.length - 1; const conn = isLast ? '└── ' : '├── '; lines.push(`${prefix}${conn}${e.name}${e.isDirectory() ? '/' : ''}`); if (e.isDirectory()) { const childPrefix = prefix + (isLast ? ' ' : '│ '); const sub = await buildTree(path.join(dirPath, e.name), childPrefix); lines.push(...sub); } } return lines.join('\n'); } async function grepSearch(term) { const results = []; const maxFiles = 20; const maxPerFile = 5; let filesWithMatches = 0; const allFiles = walkSync(WORKSPACE).filter(f => !f.includes('node_modules') && !f.includes('.git')); for (const file of allFiles) { if (filesWithMatches >= maxFiles) break; try { const content = fs.readFileSync(file, 'utf8'); const fileResults = []; const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(term.toLowerCase())) { const relPath = path.relative(WORKSPACE, file); fileResults.push(` ${relPath}:${i + 1}: ${lines[i].trim()}`); if (fileResults.length >= maxPerFile) break; } } if (fileResults.length > 0) { results.push(...fileResults); filesWithMatches++; } } catch { /* skip */ } } return results.length > 0 ? results.join('\n') : `(keine Treffer für "${term}")`; } async function globSearch(pattern) { const allFiles = walkSync(WORKSPACE).filter(f => !f.includes('node_modules') && !f.includes('.git')); const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i'); const matches = allFiles.map(f => path.relative(WORKSPACE, f)).filter(f => regex.test(f)); return matches.length > 0 ? matches.join('\n') : `(keine Dateien für "${pattern}")`; } function walkSync(dir) { const files = []; try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name !== 'node_modules' && entry.name !== '.git') files.push(...walkSync(fullPath)); } else { files.push(fullPath); } } } catch { /* permission denied, skip */ } return files; } // ─── Aktionen aus LLM-Response ausführen ───────────────────────────────────── function executeActions(response) { const actions = []; // === FILE:/path: Pattern (8B + 24B kompatibel) === // 8B: FILE: pfad\n```\n...``` // 24B: path: name.md\n```markdown\n...``` // 24B: Path: name.md\n```\n...``` const fileWithPrefixRegex = /(?:^|\n)[ \t]*(?:FILE|Path|path|Datei|datei|file|File)[:\s]\s*`?\s*(.+?)\s*`?\n```(?:\w+)?\n([\s\S]+?)```/g; let m; const matchedRanges = []; while ((m = fileWithPrefixRegex.exec(response)) !== null) { let filePath = m[1].trim().replace(/^`+|`+$/g, ''); if (filePath && filePath.length < 300) { actions.push({ type: 'file', path: filePath, content: m[2].trim() }); matchedRanges.push({ start: m.index, end: m.index + m[0].length }); } } // === DIR: pattern === const dirRegex = /^DIR:\s*(.+)$/gm; while ((m = dirRegex.exec(response)) !== null) { actions.push({ type: 'dir', path: m[1].trim() }); } // === EDIT: pattern === const editRegex = /EDIT:\s*(.+?)\n---OLD\n([\s\S]*?)\n---NEW\n([\s\S]*?)\n---/gm; while ((m = editRegex.exec(response)) !== null) { actions.push({ type: 'edit', path: m[1].trim(), oldText: m[2], newText: m[3] }); } // === Bash pattern === const bashRegex = /```bash\n([\s\S]+?)```/g; while ((m = bashRegex.exec(response)) !== null) { actions.push({ type: 'bash', command: m[1].trim() }); } // === Namenlose Markdown-Codeblöcke (24B-Fallback) === // Erfasst Codeblöcke ohne FILE:-Prefix, die wie Wiki-Seiten aussehen const orphanCodeRegex = /```(?:\w+)?\n([\s\S]+?)```/g; while ((m = orphanCodeRegex.exec(response)) !== null) { // Bereits abgedeckt? const alreadyMatched = matchedRanges.some(r => m.index >= r.start && m.index < r.end); if (alreadyMatched) continue; const content = m[1].trim(); // Nur Markdown-ähnliche Inhalte mit Substanz erkennen if (content.length > 50 && (content.startsWith('#') || content.includes('\n##') || content.includes('|') || content.includes('Siehe auch') || content.includes('Gliederung') || content.includes('Inhalt') || content.includes('Übersicht') || content.includes('Grundlagen'))) { actions.push({ type: 'file-orphan', content }); } } return actions; } async function applyActions(actions) { for (const a of actions) { switch (a.type) { case 'file': { const fullPath = path.resolve(WORKSPACE, a.path); const parentDir = path.dirname(fullPath); fs.mkdirSync(parentDir, { recursive: true }); fs.writeFileSync(fullPath, a.content, 'utf8'); console.log(` ${C.green}✓${C.reset} Datei gespeichert: ${path.relative(WORKSPACE, fullPath)}`); break; } case 'file-orphan': { // Namenloser Codeblock — User nach Pfad fragen const firstLine = a.content.split('\n')[0].trim().replace(/^#+\s*/, ''); console.log(` ${C.cyan}📄${C.reset} Codeblock erkannt: ${C.grey}"${firstLine}"${C.reset}`); const pfad = await askSingleLine(` ${C.yellow}Speichern als?${C.reset} (wiki/Dateiname.md / Enter=überspringen) `); if (pfad && pfad.trim()) { const fullPath = path.resolve(WORKSPACE, pfad.trim()); const parentDir = path.dirname(fullPath); fs.mkdirSync(parentDir, { recursive: true }); fs.writeFileSync(fullPath, a.content, 'utf8'); console.log(` ${C.green}✓${C.reset} Datei gespeichert: ${path.relative(WORKSPACE, fullPath)}`); } else { console.log(` ${C.dim} (übersprungen)${C.reset}`); } break; } case 'dir': { const fullPath = path.resolve(WORKSPACE, a.path); fs.mkdirSync(fullPath, { recursive: true }); console.log(` ${C.yellow}✓${C.reset} Verzeichnis erstellt: ${path.relative(WORKSPACE, fullPath)}`); break; } case 'edit': { const fullPath = path.resolve(WORKSPACE, a.path); if (fs.existsSync(fullPath)) { let content = fs.readFileSync(fullPath, 'utf8'); if (content.includes(a.oldText)) { content = content.replace(a.oldText, a.newText); fs.writeFileSync(fullPath, content, 'utf8'); console.log(` ${C.magenta}✓${C.reset} Edit angewendet: ${path.relative(WORKSPACE, fullPath)}`); } else { console.log(` ${C.red}✗${C.reset} Edit fehlgeschlagen: "${a.oldText}" nicht gefunden in ${path.relative(WORKSPACE, fullPath)}`); } } else { console.log(` ${C.red}✗${C.reset} Datei nicht gefunden: ${path.relative(WORKSPACE, fullPath)}`); } break; } case 'bash': { console.log(` ${C.cyan}⚡${C.reset} Bash-Befehl erkannt:`); console.log(` ${C.grey}${a.command}${C.reset}`); const confirm = await askSingleLine(` ${C.yellow}Ausführen?${C.reset} (j/N) `); if (confirm.toLowerCase() === 'j' || confirm.toLowerCase() === 'ja') { try { const out = execSync(a.command, { cwd: WORKSPACE, timeout: 30000, encoding: 'utf8', maxBuffer: 1024 * 512 }); if (out) console.log(` ${C.grey}${out.split('\n').map(l => ' ' + l).join('\n')}${C.reset}`); console.log(` ${C.green}✓${C.reset} Bash beendet (exit 0)`); } catch (e) { console.log(` ${C.red}✗${C.reset} Bash-Fehler: ${e.stderr || e.message}`); } } else { console.log(` ${C.dim} (übersprungen)${C.reset}`); } break; } } } } // ─── Modelle aus dem MiniLlama/models-Ordner anzeigen ───────────────────────── function getLocalModels() { const modelsDir = path.join(__dirname, '..', 'models'); if (!fs.existsSync(modelsDir)) return []; return fs.readdirSync(modelsDir) .filter(f => f.endsWith('.gguf')) .sort() .map((name, i) => { const fullPath = path.join(modelsDir, name); try { const size = (fs.statSync(fullPath).size / 1024 / 1024 / 1024).toFixed(1); return { index: i + 1, name, path: fullPath, size }; } catch { return { index: i + 1, name, path: fullPath, size: '?' }; } }); } function showLocalModels(models) { if (models.length === 0) return; console.log(`\n${C.bold}📦 Verfügbare Modelle:${C.reset}`); models.forEach(m => { console.log(` ${C.cyan}${m.index})${C.reset} ${m.name} ${C.grey}(${m.size} GB)${C.reset}`); }); } // ─── Server starten ─────────────────────────────────────────────────────────── function findLlamaServer() { // Pfade für llama-server Binary suchen const candidates = [ path.join(__dirname, '..', 'bin', 'linux', 'llama-server'), path.join(__dirname, '..', 'bin', 'win', 'llama-server.exe'), ]; for (const p of candidates) { if (fs.existsSync(p)) return p; } return null; } async function startServerWithModel(localModels) { showLocalModels(localModels); if (localModels.length === 0) { console.log(`\n${C.red}❌ Keine .gguf-Modelle im MiniLlama/models/-Ordner gefunden.${C.reset}`); process.exit(1); } // User wählt Modell per Ziffer let modelPath = null; while (!modelPath) { const input = await askSingleLine(`\n${C.yellow}🔌 Server starten mit Modell Nr.${C.reset} [1-${localModels.length}] ${C.dim}(Enter=3=kleinstes)${C.reset}: `); const choice = input ? parseInt(input) : 3; if (!isNaN(choice) && choice >= 1 && choice <= localModels.length) { modelPath = localModels[choice - 1]; } else { console.log(` ${C.red}Bitte eine Zahl zwischen 1 und ${localModels.length}${C.reset}`); } } const serverBin = findLlamaServer(); if (!serverBin) { console.log(`\n${C.red}❌ Kein llama-server Binary gefunden.${C.reset}`); console.log(` ${C.dim}Erwartet in bin/linux/llama-server oder bin/win/llama-server.exe${C.reset}`); process.exit(1); } console.log(`\n${C.dim}🚀 Starte Server mit ${modelPath.name}...${C.reset}\n`); const { spawn } = require('child_process'); const MINILLAMA_DIR = path.join(__dirname, '..'); return new Promise((resolve, reject) => { const server = spawn(serverBin, [ '-m', modelPath.path, '--host', '127.0.0.1', '--port', '8080', '--ctx-size', String(CONFIG.ctxSize), '--n-gpu-layers', String(CONFIG.gpuLayers), ], { cwd: MINILLAMA_DIR, stdio: ['ignore', 'pipe', 'pipe'], }); let started = false; server.stdout.on('data', (data) => { const text = data.toString(); process.stdout.write(text); // Live-Logs zeigen if (!started && (text.includes('model loaded') || text.includes('listening on') || text.includes('start'))) { started = true; // Kurz warten bis Server wirklich bereit setTimeout(() => resolve(server), 500); } }); server.stderr.on('data', (data) => { const text = data.toString(); process.stdout.write(text); // Live-Logs zeigen if (!started && (text.includes('model loaded') || text.includes('listening on') || text.includes('start')) && !text.includes('error')) { started = true; setTimeout(() => resolve(server), 500); } }); server.on('error', (err) => { reject(new Error(`Server-Start fehlgeschlagen: ${err.message}`)); }); server.on('exit', (code) => { if (!started) reject(new Error(`Server beendet mit Code ${code}`)); }); // Timeout: Wenn Server nicht innerhalb von 120s startet (große Modelle vom Stick) setTimeout(() => { if (!started) reject(new Error('Timeout: Server nicht innerhalb von 120s gestartet. Möglicherweise zu großes Modell oder USB-Stick zu langsam.')); }, 120000); }); } // ─── Wiki-Ingest (Chunked: eine Seite pro Request) ────────────────────────── async function wikiIngest(chatCompleteFn) { const wikiDir = path.join(WORKSPACE, 'wiki'); const rawDir = path.join(WORKSPACE, 'raw'); const agentsFile = path.join(WORKSPACE, 'AGENTS.md'); // Prüfe ob Wiki-Umgebung if (!fs.existsSync(rawDir) || !fs.existsSync(agentsFile)) { console.log(` ${C.red}❌${C.reset} Keine Wiki-Umgebung gefunden.`); console.log(` ${C.dim} Erwartet: raw/ (Quellen) und AGENTS.md (Konfiguration) im Workspace.${C.reset}`); return; } const rawFiles = fs.readdirSync(rawDir).filter(f => f.endsWith('.md')); if (rawFiles.length === 0) { console.log(` ${C.red}❌${C.reset} Keine .md-Quellen in raw/ gefunden.`); return; } // AGENTS.md lesen const agents = fs.readFileSync(agentsFile, 'utf8'); const assistantsFile = path.join(WORKSPACE, 'wiki-assistant.md'); const assistants = fs.existsSync(assistantsFile) ? fs.readFileSync(assistantsFile, 'utf8') : ''; // Rohdaten lesen const rawContents = {}; const rawCompact = {}; for (const f of rawFiles) { const content = fs.readFileSync(path.join(rawDir, f), 'utf8'); rawContents[f] = content; // Kompakte Version: erste 800 Zeichen reichen als Kontext rawCompact[f] = (content.length > 800 ? content.substring(0, 800) + '\n...' : content); } // Kompakte Gesamt-Rohdaten (max ~1600 Zeichen für alle Quellen) const compactSources = Object.entries(rawCompact).map(([name, c]) => `--- ${name} ---\n${c}`).join('\n\n'); const fullSources = Object.entries(rawContents).map(([name, c]) => `--- ${name} ---\n${c}`).join('\n\n'); // Themen aus AGENTS.md extrahieren const topicMatch = agents.match(/## Aktuelle Wiki-Themen\n\n([\s\S]*?)(?:\n##|\n$)/); const topics = []; if (topicMatch) { const lines = topicMatch[1].split('\n').filter(l => l.trim().startsWith('- **')); for (const line of lines) { const m = line.match(/- \*\*(.+?)\*\*\s*[\u2014\u2013-]\s*(.+)/); if (m) topics.push({ title: m[1].trim(), description: m[2].trim() }); } } if (topics.length === 0) { console.log(` ${C.yellow}⚠️${C.reset} Keine Themen in AGENTS.md gefunden.`); console.log(` ${C.dim} Bitte pflege "## Aktuelle Wiki-Themen" in AGENTS.md.${C.reset}`); return; } // Inhalt.md als erstes — Gesamtüberblick const pages = [ { file: 'Inhalt.md', title: 'Inhalt (Startseite)', prompt: `Create wiki/Inhalt.md — the index page for this wiki about IFTMIN (EDIFACT Transportauftrag) for OBI. Include a brief overview paragraph and a table of contents linking to these pages:\n${topics.map(t => `- ${t.title} — ${t.description}`).join('\n')}\n\nWrite in German. Use markdown links like [Titel](Datei.md).` } ]; // Content-Pages: Nur ERSTE Seite bekommt volle Rohdaten, restliche nur Kurzfassung for (let i = 0; i < topics.length; i++) { const t = topics[i]; const sourceSection = i === 0 ? `Quellen:\n${fullSources}` : `Quellen (Auszug):\n${compactSources}`; pages.push({ file: `${t.title.replace(/[^a-zA-Z0-9äöüÄÖÜß-]/g, '').replace(/\s+/g, '-')}.md`, title: t.title, prompt: `Create wiki/${t.title.replace(/[^a-zA-Z0-9äöüÄÖÜß-]/g, '').replace(/\s+/g, '-')}.md in German for the IFTMIN OBI wiki.\n\nTopic: ${t.title} — ${t.description}\n\n${sourceSection}\n\nWrite a well-structured markdown page with sections, tables where appropriate, and 'Siehe auch' links at the end referencing the other wiki pages.` }); } fs.mkdirSync(wikiDir, { recursive: true }); console.log(`\n${C.bold}📚 Wiki-Ingest gestartet: ${pages.length} Seiten${C.reset}\n`); for (let i = 0; i < pages.length; i++) { const page = pages[i]; console.log(` ${C.cyan}[${i+1}/${pages.length}]${C.reset} ${page.title} ${C.dim}(${page.file})${C.reset}`); const history = [ { role: 'system', content: `You are a wiki builder. Create markdown wiki pages from source material. Output the page content directly as markdown (no code block wrapper needed).` }, { role: 'user', content: page.prompt } ]; let result = ''; let hasError = false; try { result = await chatCompleteFn(history, () => {}, 1500); } catch (err) { console.log(` ${C.red}✗${C.reset} Fehler: ${err.message}`); hasError = true; } if (!hasError) { result = result.trim(); // Codeblock extrahieren falls vorhanden (8B mit FILE:-Pattern, 24B-Roh-Output) const cb = result.match(/```(?:\w+)?\n([\s\S]+?)```/); const saveContent = cb ? cb[1].trim() : result; fs.writeFileSync(path.join(wikiDir, page.file), saveContent, 'utf8'); console.log(` ${C.green}✓${C.reset} Gespeichert (${saveContent.length} Zeichen${cb ? '' : ', Roh-Output'})`); } // Kleine Pause zwischen Requests (Server-Entlastung) if (i < pages.length - 1) await new Promise(r => setTimeout(r, 500)); } console.log(`\n${C.green}✅${C.bold} Wiki-Ingest abgeschlossen${C.reset}`); console.log(` ${C.dim}${pages.length} Seiten in ${path.relative(WORKSPACE, wikiDir)}${C.reset}`); } async function main() { console.clear ? console.clear() : null; console.log(`\n${C.bold}${C.cyan}╔══════════════════════════════════════════╗${C.reset}`); console.log(`${C.bold}${C.cyan}║ 🦙 CrowdCode CLI — ${C.reset}miniLLM Chat ${C.bold}${C.cyan}║${C.reset}`); console.log(`${C.bold}${C.cyan}╚══════════════════════════════════════════╝${C.reset}`); console.log(`${C.grey} API: ${API} | Workspace: ${WORKSPACE}${C.reset}\n`); // Verfügbare Modelle anzeigen const localModels = getLocalModels(); // Lokale Modelle IMMER anzeigen (auch wenn Server schon läuft) if (localModels.length > 0) { console.log(`${C.bold}📦 Verfügbare lokale Modelle:${C.reset}`); localModels.forEach(m => { console.log(` ${C.cyan}${m.index})${C.reset} ${m.name} ${C.grey}(${m.size} GB)${C.reset}`); }); } // API-Verbindung testen → falls Server schon läuft, direkt loslegen let serverProcess = null; let connected = false; console.log(`\n${C.dim}🔌 Teste Verbindung zu ${API}...${C.reset}`); try { const models = await fetchModels(); if (models.length > 0) { const modelName = MODEL || models[0]?.id || models[0]?.name || models[0]?.model || '(unbekannt)'; console.log(` ${C.green}✅${C.reset} Server läuft bereits. Modell: ${C.bold}${modelName}${C.reset}`); connected = true; } else { console.log(` ${C.yellow}⚠️${C.reset} Server antwortet, aber kein Modell geladen.`); console.log(` ${C.dim} → Starte Server neu mit einem lokalen Modell.${C.reset}`); } } catch { console.log(` ${C.red}❌${C.reset} Kein Server an ${API}`); console.log(` ${C.dim} → Starte Server automatisch mit lokalem Modell.${C.reset}`); } // Server starten, falls nicht verbunden if (!connected) { if (localModels.length === 0) { console.log(`\n${C.red}❌ Keine .gguf-Modelle gefunden.${C.reset}`); console.log(` ${C.dim}Lege Modelle in models/ ab oder starte llama-server manuell.${C.reset}`); process.exit(1); } try { serverProcess = await startServerWithModel(localModels); connected = true; console.log(`\n${C.green}✅ Server bereit.${C.reset}`); } catch (err) { console.log(`\n${C.red}❌ Server-Start fehlgeschlagen: ${err.message}${C.reset}`); process.exit(1); } } // ─── Chat-Loop ────────────────────────────────────────────────────────── const history = [{ role: 'system', content: SYSTEM_PROMPT }]; console.log(`\n${C.green}💬 Chat bereit.${C.reset} ${C.dim}Tippe deine Nachricht, :q zum Beenden, :h für Hilfe.${C.reset}`); while (true) { const input = await askInput(); if (input === null) continue; // :c (abbrechen) const trimmed = input.trim(); if (trimmed === ':q' || trimmed === ':quit' || trimmed === ':exit') break; if (trimmed === ':h' || trimmed === ':help') { console.log(`\n${C.bold}Hilfe:${C.reset} :h, :help Diese Hilfe :q, :quit Beenden :m Multiline-Eingabe (für Code, :send zum Senden) :clear Chat-Verlauf löschen :wiki Wiki-Ingest aus raw/ + AGENTS.md (Chunked-Modus) ${C.cyan}@datei.py${C.reset} Dateiinhalt lesen ${C.cyan}@verz/${C.reset} Verzeichnis auflisten ${C.cyan}@verz/**${C.reset} Rekursiver Dateibaum ${C.cyan}@?begriff${C.reset} Grep-Suche ${C.cyan}@?*.ts${C.reset} Glob-Suche Das LLM kann: FILE:, DIR:, EDIT:, \`\`\`bash ausgeben (24B: auch path:, Datei: oder Codeblock ohne Prefix) ${C.dim}Konfiguration (via ENV): LLAMA_TIMEOUT=300000 Streaming-Timeout in ms (Default: 120000) LLAMA_CTX_SIZE=8192 Kontext-Fenster (Default: 4096) LLAMA_GPU_LAYERS=8 GPU-Offload-Layer (Default: 0) LLAMA_API=url API-Endpoint (Default: http://127.0.0.1:8080/v1)${C.reset} `); continue; } if (trimmed === ':clear' || trimmed === ':reset') { history.length = 1; // nur System-Prompt behalten console.log(` ${C.dim}Chat-Verlauf gelöscht.${C.reset}`); continue; } if (trimmed === ':wiki') { await wikiIngest(chatComplete); continue; } if (!trimmed) continue; // ─── Shell-Kommando-Erkennung (direkt ausführen, nicht ans LLM) ───── const SHELL_COMMANDS = [ 'pwd', 'ls', 'll', 'cd', 'git', 'npm', 'npx', 'node', 'python', 'python3', 'pip', 'curl', 'wget', 'docker', 'docker-compose', 'ps', 'top', 'htop', 'df', 'du', 'whoami', 'env', 'which', 'whereis', 'ssh', 'scp', 'make', 'cargo', 'go', 'rustc', 'date', 'uptime', 'uname', 'hostname', 'id', 'free', 'netstat', 'ss', 'ip', 'ping', 'traceroute', 'nslookup', 'dig', 'tree', 'wc', 'sort', 'uniq', 'cut', 'awk', 'sed', 'tr', 'diff', 'cmp', 'stat', 'file', 'type', 'printenv', 'history', 'screen', 'tmux', 'bash', 'sh', 'zsh', 'source', '.', ]; // Shell-Prompt-Präfixe entfernen ($, >, #) und erstes Wort prüfen const cmdTrimmed = trimmed.replace(/^[$>#]\s*/, ''); const firstWord = cmdTrimmed.split(/\s+/)[0]; const isShellCmd = SHELL_COMMANDS.includes(firstWord) && !/[?!.]$/.test(cmdTrimmed); if (isShellCmd) { const lines = cmdTrimmed.split('\n').filter(l => l.trim()); process.stdout.write(`\n${C.dim}⚡ ${trimmed}${C.reset}\n`); for (const cmd of lines) { try { const out = execSync(cmd.trim(), { cwd: WORKSPACE, timeout: 10000, encoding: 'utf8', maxBuffer: 1024 * 512 }); console.log(`${C.grey}${out || '(keine Ausgabe)'}${C.reset}`); } catch (e) { console.log(`${C.red}${e.stderr || e.message}${C.reset}`); } } console.log(''); continue; } // @mentions auflösen const enhanced = await resolveMentions(trimmed); history.push({ role: 'user', content: enhanced }); // Streaming-Antwort let fullResponse = ''; let thinkingDone = false; process.stdout.write(`\n${C.cyan}──${C.reset}`); try { await chatComplete(history, (token, isReasoning) => { if (isReasoning) { if (!thinkingDone) { process.stdout.write(`\n${C.dim}🤔 `); thinkingDone = true; } process.stdout.write(`${C.grey}${token}${C.reset}`); } else { if (thinkingDone) { process.stdout.write(`${C.reset}\n${C.cyan}──${C.reset}`); thinkingDone = false; } fullResponse += token; process.stdout.write(token); } }); } catch (err) { process.stdout.write(`\n${C.red}❌ Fehler: ${err.message}${C.reset}`); console.log(`\n ${C.dim}Versuche erneute Verbindung...${C.reset}`); history.pop(); // fehlgeschlagene User-Nachricht entfernen continue; } process.stdout.write('\n'); history.push({ role: 'assistant', content: fullResponse }); // Aktionen ausführen const actions = executeActions(fullResponse); if (actions.length > 0) { console.log(`\n${C.bold}Aktionen:${C.reset}`); await applyActions(actions); } console.log(''); // Leerzeile vor nächstem Prompt } console.log(`\n${C.cyan}Tschüss!${C.reset}\n`); process.exit(0); } main().catch((err) => { console.error(`${C.red}❌ Fatal:${C.reset}`, err.message); process.exit(1); });