MiniLlama/cli/crowdcode.js
xray 05e349131c fix: crowdcode — fullResponse im Chat-Loop akkumulieren
Im interaktiven Chat wurde fullResponse nie zugewiesen (Return-Value
von chatComplete ignoriert, Callback akkumuliert nicht). Folge:
executeActions('') fand keine FILE:/DIR:/EDIT:-Aktionen und history.push
bekam leere Assistant-Turns. Beides silent kaputt; nur :wiki funktionierte.

Fix: fullResponse += token im Content-Zweig (Reasoning bewusst draußen).
Live getestet gegen llama-server (LFM2-8B-A1B Testsieger): FILE/DIR-Aktionen
und Multi-Turn-History (assistant len 0→55) verifiziert.
2026-07-13 14:40:22 +02:00

791 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
});