#include "chat.h" #include "imgui.h" #include "core/icons_tabler.h" #include "core/selectable_text.h" #include "core/logger.h" #include "../../../../cpp/vendor/sqlite3/sqlite3.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif #include #else #include #include #include #include #include #include #include #endif namespace ge { // ---------------------------------------------------------------------------- // Logger con tags // ---------------------------------------------------------------------------- namespace { std::mutex g_log_mu; std::string g_log_path; std::string iso_now_ms() { using namespace std::chrono; auto now = system_clock::now(); auto t = system_clock::to_time_t(now); auto ms = duration_cast(now.time_since_epoch()).count() % 1000; char buf[40]; std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", std::gmtime(&t)); char out[64]; std::snprintf(out, sizeof(out), "%s.%03lldZ", buf, (long long)ms); return out; } } // namespace void chat_log(const char* tag, const char* fmt, ...) { if (!tag) tag = "chat"; char body[2048]; va_list ap; va_start(ap, fmt); std::vsnprintf(body, sizeof(body), fmt, ap); va_end(ap); std::string ts = iso_now_ms(); std::string ln = ts + " [chat:" + tag + "] " + body + "\n"; { std::lock_guard lk(g_log_mu); std::fputs(ln.c_str(), stderr); std::fflush(stderr); if (!g_log_path.empty()) { FILE* f = std::fopen(g_log_path.c_str(), "ab"); if (f) { std::fputs(ln.c_str(), f); std::fclose(f); } } } // Mirror al logger global (ventana Settings → Logs...) — asi el usuario // puede ver los eventos de chat sin abrir chat.log en disco. if (std::strcmp(tag, "error") == 0) { fn_log::log_error("[chat:%s] %s", tag, body); } else { fn_log::log_info ("[chat:%s] %s", tag, body); } } const char* chat_log_path() { return g_log_path.c_str(); } namespace { // ---------------------------------------------------------------------------- // Pequeño extractor JSON (sin dependencias). Solo lo justo para sacar el // valor string asociado a una clave dentro de un objeto JSON. Maneja escapes // basicos y devuelve "" si no encuentra la clave. // ---------------------------------------------------------------------------- std::string json_unescape(const char* s, size_t n) { std::string out; out.reserve(n); for (size_t i = 0; i < n; ++i) { char c = s[i]; if (c == '\\' && i + 1 < n) { char nx = s[++i]; switch (nx) { case 'n': out += '\n'; break; case 't': out += '\t'; break; case 'r': out += '\r'; break; case '"': out += '"'; break; case '\\': out += '\\'; break; case '/': out += '/'; break; case 'b': out += '\b'; break; case 'f': out += '\f'; break; case 'u': { if (i + 4 < n) { unsigned cp = 0; for (int k = 0; k < 4; ++k) { char h = s[i + 1 + k]; cp <<= 4; if (h >= '0' && h <= '9') cp |= (h - '0'); else if (h >= 'a' && h <= 'f') cp |= (h - 'a' + 10); else if (h >= 'A' && h <= 'F') cp |= (h - 'A' + 10); } i += 4; // utf-8 encode if (cp < 0x80) { out += (char)cp; } else if (cp < 0x800) { out += (char)(0xC0 | (cp >> 6)); out += (char)(0x80 | (cp & 0x3F)); } else { out += (char)(0xE0 | (cp >> 12)); out += (char)(0x80 | ((cp >> 6) & 0x3F)); out += (char)(0x80 | (cp & 0x3F)); } } break; } default: out += nx; break; } } else { out += c; } } return out; } // Devuelve el offset del valor (excluyendo comillas si string) o -1 si no // existe. Solo busca claves a nivel "shallow" — no recursivo. Suficiente // para el formato de stream-json de claude -p. bool json_find_string(const std::string& obj, const char* key, std::string* out) { std::string pat = "\""; pat += key; pat += "\""; size_t pos = obj.find(pat); while (pos != std::string::npos) { size_t i = pos + pat.size(); while (i < obj.size() && (obj[i] == ' ' || obj[i] == '\t')) ++i; if (i >= obj.size() || obj[i] != ':') { pos = obj.find(pat, pos + 1); continue; } ++i; while (i < obj.size() && (obj[i] == ' ' || obj[i] == '\t')) ++i; if (i >= obj.size() || obj[i] != '"') return false; // no es string ++i; size_t start = i; while (i < obj.size()) { if (obj[i] == '\\' && i + 1 < obj.size()) { i += 2; continue; } if (obj[i] == '"') break; ++i; } if (i >= obj.size()) return false; *out = json_unescape(obj.data() + start, i - start); return true; } return false; } std::string make_uuid_v4() { std::random_device rd; std::mt19937_64 gen(rd()); std::uniform_int_distribution dis; uint64_t a = dis(gen), b = dis(gen); char buf[40]; std::snprintf(buf, sizeof(buf), "%08x-%04x-4%03x-%04x-%012llx", (unsigned)(a & 0xFFFFFFFF), (unsigned)((a >> 32) & 0xFFFF), (unsigned)((a >> 48) & 0x0FFF), (unsigned)(0x8000 | (b & 0x3FFF)), (unsigned long long)((b >> 16) & 0xFFFFFFFFFFFFULL)); return buf; } // ---------------------------------------------------------------------------- // Estado del chat // ---------------------------------------------------------------------------- struct ChatMessage { enum Kind { USER, ASSISTANT, TOOL_USE, TOOL_RESULT, SYSTEM, ERROR_MSG }; Kind kind; std::string text; // user/assistant: texto. tool_*: descripcion. std::string tool_name; // para TOOL_USE std::string tool_input; // resumen del input }; struct State { std::string ops_db; std::string app_db; std::string app_dir; std::string claude_cmd; // "claude" o "wsl" std::vector claude_pre_args; // ["claude"] si wsl, vacio si nativo std::string mcp_config_path; // path Linux del mcp.json para --mcp-config bool ready = false; bool first_turn_done = false; std::string session_id; // uuid std::mutex mu; std::vector history; std::atomic busy{false}; std::thread worker; char input_buf[8192] = {}; bool scroll_to_bottom = false; bool show_raw = false; std::vector raw_lines; }; State* g_st = nullptr; // ---------------------------------------------------------------------------- // Detectar disponibilidad de claude // ---------------------------------------------------------------------------- // Captura stdout+stderr de un comando corto y devuelve {exit_code, output}. // Si falla creando el proceso, devuelve {-1, ""}. struct ProbeResult { int rc; std::string out; }; #ifdef _WIN32 ProbeResult probe_capture(const std::string& cmdline) { SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; HANDLE r=nullptr, w=nullptr; if (!CreatePipe(&r, &w, &sa, 0)) return {-1, ""}; SetHandleInformation(r, HANDLE_FLAG_INHERIT, 0); STARTUPINFOA si{}; si.cb = sizeof(si); si.dwFlags = STARTF_USESTDHANDLES; si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); si.hStdOutput = w; si.hStdError = w; PROCESS_INFORMATION pi{}; std::string mutable_cmd = cmdline; BOOL ok = CreateProcessA( nullptr, mutable_cmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi); CloseHandle(w); if (!ok) { CloseHandle(r); DWORD err = GetLastError(); chat_log("detect", "CreateProcess fallo (err=%lu) para: %s", (unsigned long)err, cmdline.c_str()); return {-1, ""}; } std::string out; char buf[1024]; DWORD n = 0; while (ReadFile(r, buf, sizeof(buf), &n, nullptr) && n > 0) { out.append(buf, n); } CloseHandle(r); DWORD code = 0; WaitForSingleObject(pi.hProcess, 5000); // 5s timeout GetExitCodeProcess(pi.hProcess, &code); CloseHandle(pi.hProcess); CloseHandle(pi.hThread); return {(int)code, out}; } #else ProbeResult probe_capture(const std::string& cmdline) { std::string c = cmdline + " 2>&1"; FILE* p = popen(c.c_str(), "r"); if (!p) return {-1, ""}; std::string out; char buf[1024]; size_t n; while ((n = std::fread(buf, 1, sizeof(buf), p)) > 0) { out.append(buf, n); } int rc = pclose(p); if (rc == -1) return {-1, out}; if (WIFEXITED(rc)) rc = WEXITSTATUS(rc); return {rc, out}; } #endif void detect_claude() { if (!g_st) return; #ifdef _WIN32 // Probe 1: wsl --status (verifica que WSL existe). auto p_status = probe_capture("wsl.exe --status"); chat_log("detect", "wsl.exe --status -> rc=%d, out=%.200s", p_status.rc, p_status.out.c_str()); // Probe 2: bash login shell carga ~/.profile asi que claude (en // ~/.local/bin/) entra en PATH. Capturamos la ruta absoluta para usarla // luego con `wsl.exe --exec ` y bypassear el problema de quoting // de wsl.exe en modo `--`. auto p_path = probe_capture( "wsl.exe -- bash -lc \"command -v claude\""); chat_log("detect", "wsl bash -lc 'command -v claude' -> rc=%d, out=%.200s", p_path.rc, p_path.out.c_str()); // Trim del path resultante (puede tener \r\n) std::string claude_path = p_path.out; while (!claude_path.empty() && (claude_path.back() == '\n' || claude_path.back() == '\r' || claude_path.back() == ' ' || claude_path.back() == '\t')) { claude_path.pop_back(); } bool wsl_works = (p_status.rc == 0); bool claude_works = (p_path.rc == 0) && !claude_path.empty(); if (claude_works) { g_st->claude_cmd = "wsl.exe"; // --exec NO usa shell, evita el problema de wsl.exe que reconcatena // y rompe el quoting de los args (perdiamos las comillas de // --append-system-prompt y de "$@" → exit 127). Path absoluto al // binario claude detectado en el probe anterior. g_st->claude_pre_args = {"--exec", claude_path}; g_st->ready = true; chat_log("detect", "OK — claude en %s, usaremos wsl.exe --exec", claude_path.c_str()); } else if (wsl_works) { // wsl funciona pero no localizamos claude. Como fallback intentamos // un path estandar conocido — el primer send dira si funciono. g_st->claude_cmd = "wsl.exe"; g_st->claude_pre_args = {"--exec", "/home/lucas/.local/bin/claude"}; g_st->ready = true; chat_log("detect", "wsl OK pero no localizamos claude — " "fallback a /home/lucas/.local/bin/claude"); } else { g_st->ready = false; chat_log("detect", "wsl --status fallo — panel Chat deshabilitado"); } #else auto p = probe_capture("command -v claude"); chat_log("detect", "command -v claude -> rc=%d, out=%.200s", p.rc, p.out.c_str()); if (p.rc == 0) { g_st->claude_cmd = "claude"; g_st->claude_pre_args = {}; g_st->ready = true; auto v = probe_capture("claude --version"); chat_log("detect", "claude --version -> rc=%d, out=%.200s", v.rc, v.out.c_str()); } else { g_st->ready = false; chat_log("detect", "claude no esta en PATH — panel deshabilitado"); } #endif } // ---------------------------------------------------------------------------- // Subprocess: enviar mensaje y leer stream-json hasta EOF // ---------------------------------------------------------------------------- #ifndef _WIN32 // Lanza claude -p, escribe `prompt` en stdin, lee stdout linea a linea. // Cada linea bien-formada (JSON object) se pasa a `on_line`. Devuelve el // exit code, o -1 si fork/exec falla. int run_claude_streaming( const std::vector& argv, const std::string& prompt, void (*on_line)(const std::string&)) { int in_pipe[2], out_pipe[2]; if (pipe(in_pipe) != 0 || pipe(out_pipe) != 0) return -1; pid_t pid = fork(); if (pid < 0) { chat_log("error", "fork() fallo: errno=%d (%s)", errno, std::strerror(errno)); close(in_pipe[0]); close(in_pipe[1]); close(out_pipe[0]); close(out_pipe[1]); return -1; } if (pid == 0) { // child dup2(in_pipe[0], STDIN_FILENO); dup2(out_pipe[1], STDOUT_FILENO); // stderr al stderr del padre (debugging visible en consola) close(in_pipe[0]); close(in_pipe[1]); close(out_pipe[0]); close(out_pipe[1]); std::vector args; for (auto& a : argv) args.push_back(const_cast(a.c_str())); args.push_back(nullptr); execvp(args[0], args.data()); _exit(127); } // parent close(in_pipe[0]); close(out_pipe[1]); // escribe prompt y cierra if (!prompt.empty()) { ssize_t n = write(in_pipe[1], prompt.data(), prompt.size()); chat_log("io", "stdin write %zd/%zu bytes", (ssize_t)n, prompt.size()); } close(in_pipe[1]); size_t total_in = 0; int lines = 0; std::string buf; char chunk[4096]; for (;;) { ssize_t r = read(out_pipe[0], chunk, sizeof(chunk)); if (r <= 0) break; total_in += (size_t)r; buf.append(chunk, (size_t)r); size_t nl; while ((nl = buf.find('\n')) != std::string::npos) { std::string line = buf.substr(0, nl); buf.erase(0, nl + 1); if (!line.empty()) { on_line(line); ++lines; } } } if (!buf.empty()) { on_line(buf); ++lines; } chat_log("io", "stdout EOF — total=%zu bytes, %d lineas", total_in, lines); close(out_pipe[0]); int status = 0; waitpid(pid, &status, 0); if (WIFEXITED(status)) return WEXITSTATUS(status); if (WIFSIGNALED(status)) { chat_log("error", "subprocess signaled %d", WTERMSIG(status)); } return -1; } #else // _WIN32 int run_claude_streaming( const std::vector& argv, const std::string& prompt, void (*on_line)(const std::string&)) { // Construye command line con escape minimo std::string cmd; for (size_t i = 0; i < argv.size(); ++i) { if (i) cmd += ' '; const std::string& a = argv[i]; bool need_q = a.empty() || a.find_first_of(" \t\"") != std::string::npos; if (need_q) { cmd += '"'; for (char c : a) { if (c == '"') cmd += "\\\""; else cmd += c; } cmd += '"'; } else { cmd += a; } } SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; HANDLE in_r=nullptr, in_w=nullptr, out_r=nullptr, out_w=nullptr; if (!CreatePipe(&in_r, &in_w, &sa, 0)) return -1; SetHandleInformation(in_w, HANDLE_FLAG_INHERIT, 0); if (!CreatePipe(&out_r, &out_w, &sa, 0)) { CloseHandle(in_r); CloseHandle(in_w); return -1; } SetHandleInformation(out_r, HANDLE_FLAG_INHERIT, 0); STARTUPINFOA si{}; si.cb = sizeof(si); si.dwFlags = STARTF_USESTDHANDLES; si.hStdInput = in_r; si.hStdOutput = out_w; si.hStdError = GetStdHandle(STD_ERROR_HANDLE); PROCESS_INFORMATION pi{}; chat_log("spawn", "CreateProcess cmdline=%s", cmd.c_str()); BOOL ok = CreateProcessA( nullptr, cmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi); CloseHandle(in_r); CloseHandle(out_w); if (!ok) { DWORD err = GetLastError(); chat_log("error", "CreateProcess fallo, GetLastError=%lu", (unsigned long)err); CloseHandle(in_w); CloseHandle(out_r); return -1; } if (!prompt.empty()) { DWORD wn = 0; WriteFile(in_w, prompt.data(), (DWORD)prompt.size(), &wn, nullptr); chat_log("io", "stdin write %lu/%zu bytes", (unsigned long)wn, prompt.size()); } CloseHandle(in_w); size_t total_in = 0; int lines = 0; std::string buf; char chunk[4096]; for (;;) { DWORD rn = 0; BOOL r = ReadFile(out_r, chunk, sizeof(chunk), &rn, nullptr); if (!r || rn == 0) break; total_in += (size_t)rn; buf.append(chunk, (size_t)rn); size_t nl; while ((nl = buf.find('\n')) != std::string::npos) { std::string line = buf.substr(0, nl); // strip trailing CR if (!line.empty() && line.back() == '\r') line.pop_back(); buf.erase(0, nl + 1); if (!line.empty()) { on_line(line); ++lines; } } } if (!buf.empty()) { on_line(buf); ++lines; } chat_log("io", "stdout EOF — total=%zu bytes, %d lineas", total_in, lines); CloseHandle(out_r); WaitForSingleObject(pi.hProcess, INFINITE); DWORD code = 0; GetExitCodeProcess(pi.hProcess, &code); CloseHandle(pi.hProcess); CloseHandle(pi.hThread); return (int)code; } #endif // ---------------------------------------------------------------------------- // Procesado de eventos stream-json // ---------------------------------------------------------------------------- void on_stream_line(const std::string& line) { if (!g_st) return; // Filtrar lineas no-JSON (logs spurios) — empiezan con '{' if (line.empty() || line[0] != '{') return; { std::lock_guard lk(g_st->mu); if (g_st->raw_lines.size() > 500) { g_st->raw_lines.erase(g_st->raw_lines.begin(), g_st->raw_lines.begin() + 200); } g_st->raw_lines.push_back(line); } std::string type; if (!json_find_string(line, "type", &type)) return; // System init: capturar session_id si todavia no lo tenemos if (type == "system") { std::string sid, model; json_find_string(line, "session_id", &sid); json_find_string(line, "model", &model); chat_log("parse", "system init: session=%s model=%s", sid.c_str(), model.c_str()); if (!sid.empty()) { std::lock_guard lk(g_st->mu); if (g_st->session_id.empty()) g_st->session_id = sid; } return; } // Assistant message: extraer text content if (type == "assistant") { // El campo "text" puede aparecer dentro del array content[]. // Buscamos todos los "text":"..." que no sean del campo de tool_input. std::string s = line; size_t pos = 0; while (true) { size_t k = s.find("\"text\"", pos); if (k == std::string::npos) break; size_t i = k + 6; while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i; if (i >= s.size() || s[i] != ':') { pos = k + 1; continue; } ++i; while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i; if (i >= s.size() || s[i] != '"') { pos = k + 1; continue; } ++i; size_t start = i; while (i < s.size()) { if (s[i] == '\\' && i + 1 < s.size()) { i += 2; continue; } if (s[i] == '"') break; ++i; } std::string text = json_unescape(s.data() + start, i - start); if (!text.empty()) { std::lock_guard lk(g_st->mu); ChatMessage m; m.kind = ChatMessage::ASSISTANT; m.text = text; g_st->history.push_back(std::move(m)); g_st->scroll_to_bottom = true; } pos = i + 1; } // Detectar tool_use if (line.find("\"type\":\"tool_use\"") != std::string::npos) { std::string tname; json_find_string(line, "name", &tname); // Resumen del input — toma el primer campo string std::string summary; std::string command; json_find_string(line, "command", &command); // claude usa input:{...}; sacar la parte legible size_t ip = line.find("\"input\":"); if (ip != std::string::npos) { size_t end = line.find("}", ip); if (end != std::string::npos) { summary = line.substr(ip, end - ip + 1); if (summary.size() > 240) summary = summary.substr(0, 240) + "..."; } } chat_log("tools", "tool_use name=%s cmd=%s", tname.c_str(), command.c_str()); std::lock_guard lk(g_st->mu); ChatMessage m; m.kind = ChatMessage::TOOL_USE; m.tool_name = tname; m.tool_input = summary; g_st->history.push_back(std::move(m)); g_st->scroll_to_bottom = true; } return; } if (type == "user") { // Mensajes user wrappean tool_result. Si contiene "tool_use_id" y // "content", lo mostramos como TOOL_RESULT. if (line.find("\"tool_use_id\"") != std::string::npos) { std::string content; // El "content" puede ser string o array — intento string primero if (!json_find_string(line, "content", &content)) { // saca lo que haya entre el primer "text":" y " size_t k = line.find("\"text\":"); if (k != std::string::npos) { size_t s = line.find('"', k + 7); size_t e = (s == std::string::npos) ? std::string::npos : line.find('"', s + 1); if (e != std::string::npos) content = json_unescape(line.data() + s + 1, e - s - 1); } } if (content.size() > 1500) content = content.substr(0, 1500) + "\n... (truncado)"; std::lock_guard lk(g_st->mu); ChatMessage m; m.kind = ChatMessage::TOOL_RESULT; m.text = content; g_st->history.push_back(std::move(m)); g_st->scroll_to_bottom = true; } return; } if (type == "result") { std::string sub; json_find_string(line, "subtype", &sub); chat_log("parse", "result subtype=%s", sub.c_str()); if (sub == "error_max_turns" || sub == "error_during_execution") { std::string err = "(claude error: " + sub + ")"; std::lock_guard lk(g_st->mu); ChatMessage m; m.kind = ChatMessage::ERROR_MSG; m.text = err; g_st->history.push_back(std::move(m)); g_st->scroll_to_bottom = true; } return; } } // ---------------------------------------------------------------------------- // System prompt para el agente // ---------------------------------------------------------------------------- std::string build_system_prompt() { return "Eres Echo — copiloto OSINT del usuario sobre la operations.db\n" "abierta en graph_explorer. Tu trabajo es explorar, investigar y\n" "conectar piezas. El usuario VE el grafo en pantalla; tu lo\n" "modificas y razonas sobre el.\n\n" "TUS HERRAMIENTAS — todas las operaciones del grafo estan expuestas\n" "como tools tipadas del MCP server `graph_explorer`. Tendran prefijo\n" "`mcp__graph_explorer__` en el listado de tools. NO uses Bash para\n" "tareas del grafo — usa siempre las tools tipadas. (Bash queda para\n" "cosas raras como debugging.)\n\n" "Tools disponibles:\n" " info, node_list, node_show, node_search,\n" " node_create, node_update, node_delete,\n" " rel_create, rel_delete, rel_list,\n" " table_list, table_page, table_promote, table_demote,\n" " enricher_list, enricher_run, query\n\n" "WORKFLOW:\n" "1. Empieza por `info` para ver counts y tipos del grafo.\n" "2. Para investigar antes de mutar usa `node_search` (FTS) o `query`\n" " (SQL libre, solo SELECT). Lee `node_show` para detalle de uno.\n" "3. Cada mutacion (create/update/delete/promote) refresca el viewport\n" " automaticamente via marker file — el usuario lo VE al instante.\n" "4. Para enriquecer un nodo (fetch web, extraer entidades, etc.) usa\n" " `enricher_list` para ver opciones aplicables al tipo, luego\n" " `enricher_run`. Los jobs corren async; el resultado aparece en\n" " el viewport sin que tengas que esperar.\n" "5. Si el usuario pide algo ambiguo, ejecuta tu mejor interpretacion\n" " y describe el resultado. Solo pregunta para evitar destruccion\n" " (delete masivo, sobreescritura de datos importantes).\n" "6. Resume brevemente al final — el usuario ve los cambios en el\n" " viewport, asi que no narres lo obvio. Lo importante: que falta,\n" " que sugerencias de proximo paso, hallazgos no obvios.\n\n" "Tu objetivo: ser un copiloto util para investigacion OSINT, no un\n" "ejecutor mecanico. Propon, conecta, descubre patrones. Firmas\n" "como Echo cuando tenga sentido marcar identidad."; } // Convierte un path Windows (UNC \\wsl.localhost\\... o C:\...) al // equivalente WSL (/home/... o /mnt/c/...). En POSIX es no-op. Necesario // para escribir el mcp.json: claude corre en WSL y lee paths Linux. std::string to_linux_path(const std::string& p) { #ifndef _WIN32 return p; #else if (p.empty()) return p; if (p[0] == '/') return p; auto is_sep = [](char c) { return c == '\\' || c == '/'; }; // UNC: \\\\rest if (p.size() >= 2 && is_sep(p[0]) && is_sep(p[1])) { size_t i = 2; while (i < p.size() && !is_sep(p[i])) ++i; std::string server = p.substr(2, i - 2); for (auto& c : server) c = (char)std::tolower((unsigned char)c); if (server == "wsl.localhost" || server == "wsl$") { if (i < p.size()) ++i; while (i < p.size() && !is_sep(p[i])) ++i; std::string rest = p.substr(i); for (char& c : rest) if (c == '\\') c = '/'; return rest.empty() ? std::string("/") : rest; } std::string out = p; for (char& c : out) if (c == '\\') c = '/'; return out; } // Drive letter: X:\... -> /mnt/x/... if (p.size() >= 3 && std::isalpha((unsigned char)p[0]) && p[1] == ':' && is_sep(p[2])) { std::string out = "/mnt/"; out.push_back((char)std::tolower((unsigned char)p[0])); for (size_t i = 2; i < p.size(); ++i) { out.push_back(p[i] == '\\' ? '/' : p[i]); } return out; } return p; #endif } // Escribe el fichero mcp.json que claude leera al arrancar para spawnar // el MCP server gx-cli. Devuelve el path para --mcp-config (Linux dentro // de WSL en Windows; native en Linux). El contenido del fichero usa paths // Linux porque claude corre dentro de WSL. std::string write_mcp_config() { if (!g_st) return ""; std::string config_path_native; // path para fopen std::string config_path_for_claude; // path que se pasa a --mcp-config // Ponemos mcp.json junto a app_db (mismo dir que chat.log). { std::string p = g_st->app_db; size_t slash = p.find_last_of("/\\"); size_t dir_end = (slash == std::string::npos) ? 0 : slash + 1; config_path_native = p.substr(0, dir_end) + "mcp.json"; } #ifdef _WIN32 // claude corre en WSL — necesita el path Linux del config file. // Pero to_linux_path solo convierte paths ABSOLUTOS. Si app_db era // relativo (caso normal cuando cwd = directorio del exe), config_path // tambien lo es. Resolvemos primero a absoluto Windows. { char buf[MAX_PATH * 2]; DWORD n = GetFullPathNameA(config_path_native.c_str(), (DWORD)sizeof(buf), buf, nullptr); if (n > 0 && n < sizeof(buf)) { config_path_native = buf; // ahora absoluto: C:\Users\...\mcp.json } } config_path_for_claude = to_linux_path(config_path_native); #else config_path_for_claude = config_path_native; #endif // El "command" que claude ejecutara es gx-cli con shebang python3. En // WSL esto es la ruta Linux dentro de fn_registry. std::string app_dir_linux = to_linux_path(g_st->app_dir); std::string gxcli_path = app_dir_linux + "/gx-cli"; // No metemos env: en el config para que herede del proceso padre // (claude, que ya tiene GX_OPS_DB/GX_APP_DB/GX_APP_DIR via WSLENV). std::ostringstream js; js << "{\n" << " \"mcpServers\": {\n" << " \"graph_explorer\": {\n" << " \"command\": \"" << gxcli_path << "\",\n" << " \"args\": [\"mcp-server\"]\n" << " }\n" << " }\n" << "}\n"; FILE* f = std::fopen(config_path_native.c_str(), "wb"); if (!f) { chat_log("error", "no pude escribir mcp.json en %s", config_path_native.c_str()); return ""; } std::string content = js.str(); std::fwrite(content.data(), 1, content.size(), f); std::fclose(f); chat_log("init", "mcp.json escrito en %s (claude lo lee como %s)", config_path_native.c_str(), config_path_for_claude.c_str()); chat_log("init", "MCP server: %s mcp-server", gxcli_path.c_str()); return config_path_for_claude; } // ---------------------------------------------------------------------------- // Worker: ejecuta una vuelta completa y termina // ---------------------------------------------------------------------------- void worker_send(std::string user_text) { if (!g_st) return; if (g_st->session_id.empty()) g_st->session_id = make_uuid_v4(); chat_log("spawn", "turno %d, session_id=%s, prompt_len=%zu bytes", g_st->first_turn_done ? 2 : 1, g_st->session_id.c_str(), user_text.size()); std::vector argv; argv.push_back(g_st->claude_cmd); for (auto& a : g_st->claude_pre_args) argv.push_back(a); argv.push_back("-p"); argv.push_back("--input-format"); argv.push_back("text"); argv.push_back("--output-format"); argv.push_back("stream-json"); argv.push_back("--verbose"); argv.push_back("--allowed-tools"); // mcp__graph_explorer__* expone las 17 tools tipadas. Bash queda como // fallback para casos raros; el system prompt empuja a usar las MCP. argv.push_back("mcp__graph_explorer__* Bash Read Glob Grep"); argv.push_back("--permission-mode"); argv.push_back("bypassPermissions"); if (!g_st->mcp_config_path.empty()) { argv.push_back("--mcp-config"); argv.push_back(g_st->mcp_config_path); } if (g_st->first_turn_done) { argv.push_back("--resume"); argv.push_back(g_st->session_id); } else { argv.push_back("--session-id"); argv.push_back(g_st->session_id); argv.push_back("--append-system-prompt"); argv.push_back(build_system_prompt()); // En la primera vuelta inyectamos las env-vars como contexto explicito // porque no podemos confiar en que claude las herede en todos los // entornos (Windows wsl.exe las propaga, Linux nativo tambien, pero // documentarlo en el mensaje hace al agente independiente). } // Setenv para el subprocess (claude hereda env, lo pasa a Bash tool calls). #ifndef _WIN32 setenv("GX_OPS_DB", g_st->ops_db.c_str(), 1); setenv("GX_APP_DB", g_st->app_db.c_str(), 1); setenv("GX_APP_DIR", g_st->app_dir.c_str(), 1); // Asegura que gx-cli esta en PATH anteponiendo app_dir. std::string path = g_st->app_dir; if (const char* p = std::getenv("PATH")) { path += ":"; path += p; } setenv("PATH", path.c_str(), 1); chat_log("env", "GX_OPS_DB=%s", g_st->ops_db.c_str()); chat_log("env", "GX_APP_DB=%s", g_st->app_db.c_str()); chat_log("env", "GX_APP_DIR=%s", g_st->app_dir.c_str()); #else // WSLENV /p traduce paths Windows -> WSL automaticamente, pero SOLO si // el path es absoluto. Los relativos pasan literales (con backslash) y // dentro de WSL no resuelven a nada — por eso el agente abria la BD // equivocada. Resolvemos con GetFullPathNameA antes de setear. auto abs_win = [](const std::string& p) -> std::string { if (p.empty()) return p; char buf[MAX_PATH * 2]; DWORD n = GetFullPathNameA(p.c_str(), (DWORD)sizeof(buf), buf, nullptr); if (n == 0 || n >= sizeof(buf)) return p; return std::string(buf); }; std::string ops_abs = abs_win(g_st->ops_db); std::string appdb_abs = abs_win(g_st->app_db); std::string appdir_abs; // app_dir suele ser ya UNC \\wsl.localhost\... o ruta WSL — no queremos // que GetFullPathNameA lo "windowsifique". Si empieza por \\ lo dejamos. if (g_st->app_dir.size() >= 2 && g_st->app_dir[0] == '\\' && g_st->app_dir[1] == '\\') { appdir_abs = g_st->app_dir; } else { appdir_abs = abs_win(g_st->app_dir); } SetEnvironmentVariableA("GX_OPS_DB", ops_abs.c_str()); SetEnvironmentVariableA("GX_APP_DB", appdb_abs.c_str()); SetEnvironmentVariableA("GX_APP_DIR", appdir_abs.c_str()); SetEnvironmentVariableA( "WSLENV", "GX_OPS_DB/p:GX_APP_DB/p:GX_APP_DIR/p"); chat_log("env", "GX_OPS_DB=%s", ops_abs.c_str()); chat_log("env", "GX_APP_DB=%s", appdb_abs.c_str()); chat_log("env", "GX_APP_DIR=%s", appdir_abs.c_str()); chat_log("env", "WSLENV=GX_OPS_DB/p:GX_APP_DB/p:GX_APP_DIR/p"); #endif // Log del argv completo { std::string joined; for (size_t i = 0; i < argv.size(); ++i) { if (i) joined += ' '; joined += argv[i]; } chat_log("spawn", "argv: %s", joined.c_str()); } // En la primera vuelta inyectamos el contexto del entorno como prefijo // del primer mensaje. NO imprimimos los paths Windows tal cual — WSLENV // los traducira a /mnt/c/... dentro de WSL antes de que el agente vea // las env vars. Lo importante es decirle al agente: usa las env vars // GX_OPS_DB / GX_APP_DB / GX_APP_DIR que ya estan seteadas, NO inventes // paths. std::string prompt = user_text; if (!g_st->first_turn_done) { std::ostringstream ctx; ctx << "[contexto del entorno — no respondas a esto, solo recuerdalo]\n" << "Las variables de entorno GX_OPS_DB, GX_APP_DB y GX_APP_DIR\n" << "estan seteadas en TU shell (bash). gx-cli las lee\n" << "automaticamente, asi que NUNCA pases --db ni configures paths\n" << "manualmente. Verifica con: `env | grep GX_`. La BD apuntada\n" << "por GX_OPS_DB es la que el usuario VE en el viewport.\n" << "Comando estandar: $GX_APP_DIR/gx-cli ...\n\n" << "[mensaje del usuario]\n" << user_text; prompt = ctx.str(); } auto t0 = std::chrono::steady_clock::now(); int rc = run_claude_streaming(argv, prompt, on_stream_line); auto dt_ms = std::chrono::duration_cast( std::chrono::steady_clock::now() - t0).count(); chat_log("spawn", "subprocess exit rc=%d, duration=%lldms", rc, (long long)dt_ms); if (rc != 0) { std::lock_guard lk(g_st->mu); ChatMessage m; m.kind = ChatMessage::ERROR_MSG; m.text = "claude -p exit code " + std::to_string(rc) + " — revisa " + g_log_path; g_st->history.push_back(std::move(m)); g_st->scroll_to_bottom = true; } g_st->first_turn_done = true; g_st->busy.store(false); } } // namespace // ---------------------------------------------------------------------------- // API publica // ---------------------------------------------------------------------------- bool chat_init(const char* ops_db_path, const char* app_db_path, const char* app_dir) { if (!g_st) g_st = new State(); g_st->ops_db = ops_db_path ? ops_db_path : ""; g_st->app_db = app_db_path ? app_db_path : ""; g_st->app_dir = app_dir ? app_dir : ""; // Path del log: junto a app_db (= graph_explorer.db, sibling del exe en // Windows). Si app_db esta vacio, fallback a stderr-only. if (!g_st->app_db.empty()) { std::string p = g_st->app_db; // strip ".db" / sustituir extension size_t slash = p.find_last_of("/\\"); size_t dir_end = (slash == std::string::npos) ? 0 : slash + 1; g_log_path = p.substr(0, dir_end) + "chat.log"; // arranca con un separador para distinguir sesiones FILE* f = std::fopen(g_log_path.c_str(), "ab"); if (f) { std::fputs("\n========================================\n", f); std::fclose(f); } } chat_log("init", "ops_db=%s", g_st->ops_db.c_str()); chat_log("init", "app_db=%s", g_st->app_db.c_str()); chat_log("init", "app_dir=%s", g_st->app_dir.c_str()); chat_log("init", "log_path=%s", g_log_path.c_str()); detect_claude(); // Genera mcp.json apuntando a gx-cli mcp-server. Solo si claude esta // disponible — si no, no merece la pena. if (g_st->ready) { g_st->mcp_config_path = write_mcp_config(); } return g_st->ready; } void chat_set_ops_db(const char* ops_db_path) { if (!g_st) return; if (!ops_db_path) return; if (g_st->ops_db == ops_db_path) return; g_st->ops_db = ops_db_path; // resetear sesion para que el nuevo grafo se contextualice en turno 1 { std::lock_guard lk(g_st->mu); g_st->session_id.clear(); g_st->first_turn_done = false; g_st->history.push_back({ChatMessage::SYSTEM, "[ops_db cambiada → nueva sesion]", "", ""}); } // Regenerar mcp.json — el server hereda env del proceso claude que se // arranca por turno, asi que con env-vars actualizadas en worker_send // el MCP server vera los nuevos paths. El fichero mcp.json en si no // contiene los paths, asi que tecnicamente no hace falta reescribir; // lo regeneramos por si el path del propio mcp.json cambio. if (g_st->ready) { g_st->mcp_config_path = write_mcp_config(); } } void chat_send(const char* user_text) { if (!g_st || !user_text || !*user_text) return; chat_log("send", "user msg, %zu bytes, ready=%d busy=%d", std::strlen(user_text), (int)g_st->ready, (int)g_st->busy.load()); if (!g_st->ready) { std::lock_guard lk(g_st->mu); g_st->history.push_back({ChatMessage::ERROR_MSG, std::string("claude no detectado — revisa ") + g_log_path + ". En Windows necesitas Claude Code instalado en WSL.", "", ""}); return; } if (g_st->busy.exchange(true)) { chat_log("send", "ignorado: ya hay una vuelta en curso"); return; } { std::lock_guard lk(g_st->mu); g_st->history.push_back({ChatMessage::USER, user_text, "", ""}); g_st->scroll_to_bottom = true; } if (g_st->worker.joinable()) g_st->worker.join(); g_st->worker = std::thread(worker_send, std::string(user_text)); } int chat_mutations_counter() { // Lee el mtime del marker file `.mutations.marker` junto a graph_explorer.db. // Devuelve mtime en segundos como int — si cambia entre polls, el caller // sabe que hay nueva mutacion. Si el fichero no existe, devuelve 0. // // Ventaja sobre tabla SQLite: stat() funciona cross-filesystem-boundary // (NTFS <-> 9p) sin contencion. SQLite WAL no. if (!g_st || g_st->app_db.empty()) return 0; std::string marker = g_st->app_db; size_t slash = marker.find_last_of("/\\"); if (slash != std::string::npos) { marker = marker.substr(0, slash + 1) + ".mutations.marker"; } else { marker = ".mutations.marker"; } #ifdef _WIN32 WIN32_FILE_ATTRIBUTE_DATA fad; if (!GetFileAttributesExA(marker.c_str(), GetFileExInfoStandard, &fad)) { return 0; } // FILETIME es 100-nanoseg desde 1601 — suficiente como int monotono. return (int)((((uint64_t)fad.ftLastWriteTime.dwHighDateTime << 32) | fad.ftLastWriteTime.dwLowDateTime) / 10000000ULL); #else struct stat st; if (::stat(marker.c_str(), &st) != 0) return 0; return (int)st.st_mtime; #endif } void chat_render(bool* panel_open) { if (!g_st) return; if (panel_open && !*panel_open) return; if (!ImGui::Begin(TI_MESSAGE_CIRCLE " Echo", panel_open)) { ImGui::End(); return; } // Status line if (!g_st->ready) { ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.3f, 1.0f), TI_ALERT_TRIANGLE " claude no detectado"); ImGui::Separator(); } else { ImGui::TextDisabled("ops_db: %s", g_st->ops_db.c_str()); ImGui::SameLine(); ImGui::Checkbox("raw", &g_st->show_raw); ImGui::Separator(); } // History float input_h = 80.0f; ImGui::BeginChild("##history", ImVec2(0, -input_h - 4.0f), true); { std::lock_guard lk(g_st->mu); for (const auto& m : g_st->history) { switch (m.kind) { case ChatMessage::USER: ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), TI_USER " You"); fn_ui::selectable_text_wrapped_force(m.text.c_str()); break; case ChatMessage::ASSISTANT: ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.7f, 1.0f), TI_ROBOT " Echo"); fn_ui::selectable_text_wrapped_force(m.text.c_str()); break; case ChatMessage::TOOL_USE: ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.4f, 1.0f), TI_TOOL " %s", m.tool_name.empty() ? "(tool)" : m.tool_name.c_str()); if (!m.tool_input.empty()) { fn_ui::selectable_text_wrapped_force(m.tool_input.c_str()); } break; case ChatMessage::TOOL_RESULT: ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), TI_CORNER_DOWN_LEFT " result"); fn_ui::selectable_text_wrapped_force(m.text.c_str()); break; case ChatMessage::SYSTEM: ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); ImGui::TextWrapped("%s", m.text.c_str()); ImGui::PopStyleColor(); break; case ChatMessage::ERROR_MSG: ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.4f, 0.3f, 1.0f)); fn_ui::selectable_text_wrapped_force(m.text.c_str()); ImGui::PopStyleColor(); break; } ImGui::Spacing(); } if (g_st->show_raw) { ImGui::Separator(); ImGui::TextDisabled("== raw stream-json =="); for (const auto& l : g_st->raw_lines) { ImGui::TextWrapped("%s", l.c_str()); } } } if (g_st->scroll_to_bottom) { ImGui::SetScrollHereY(1.0f); g_st->scroll_to_bottom = false; } ImGui::EndChild(); // Spinner if (g_st->busy.load()) { ImGui::Text(TI_LOADER " thinking..."); } else { ImGui::Dummy(ImVec2(0, ImGui::GetTextLineHeight())); } // Input ImGui::PushItemWidth(-80.0f); bool send = ImGui::InputTextMultiline( "##chat_input", g_st->input_buf, sizeof(g_st->input_buf), ImVec2(0, input_h - 4.0f), ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine); ImGui::PopItemWidth(); ImGui::SameLine(); if (ImGui::Button("Send", ImVec2(72.0f, input_h - 4.0f))) send = true; if (send && !g_st->busy.load() && g_st->input_buf[0]) { chat_send(g_st->input_buf); g_st->input_buf[0] = 0; } ImGui::End(); } void chat_shutdown() { if (!g_st) return; if (g_st->worker.joinable()) { // No bloqueamos forzosamente — claude -p termina solo al cerrar stdin g_st->worker.join(); } delete g_st; g_st = nullptr; } } // namespace ge