diff --git a/CMakeLists.txt b/CMakeLists.txt index a3f1dfd..d509b3b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,7 @@ add_imgui_app(graph_explorer tableview.cpp jobs.cpp enrichers.cpp + chat.cpp # --- viz --- ${FN_CPP_ROOT_DIR}/functions/viz/graph_renderer.cpp ${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout.cpp diff --git a/chat.cpp b/chat.cpp new file mode 100644 index 0000000..935950e --- /dev/null +++ b/chat.cpp @@ -0,0 +1,1224 @@ +#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 diff --git a/chat.h b/chat.h new file mode 100644 index 0000000..a759c0d --- /dev/null +++ b/chat.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +// Panel Chat — agente Claude (claude -p) con tool-use sobre operations.db +// via gx-cli. Subprocess persistente bidireccional (stdin/stdout JSON-lines). +// El usuario escribe, el hilo lector parsea stream-json y va emitiendo +// fragmentos al historial. gx-cli muta operations.db; el contador +// agent_mutations en graph_explorer.db dispara reload del viewport. + +namespace ge { + +// Inicia el subprocess claude -p (lazy: hasta el primer mensaje no se +// arranca). Setea env vars GX_OPS_DB / GX_APP_DB / GX_APP_DIR. Devuelve +// false si claude no esta disponible (o, en Windows, wsl no esta). +bool chat_init(const char* ops_db_path, + const char* app_db_path, + const char* app_dir); + +// Si la ops_db cambia (proyecto switch), refresca env del subprocess +// matandolo y dejando que el siguiente send lo reabra. +void chat_set_ops_db(const char* ops_db_path); + +// Envia un mensaje del usuario al agente. Si el subprocess no esta vivo, +// lo arranca primero. No bloquea — el resultado llega via chat_render +// al ir vaciando la cola del hilo lector. +void chat_send(const char* user_text); + +// Renderiza el panel ImGui (titulo "Chat"). Drena cola de mensajes del +// hilo lector. `panel_open` es bound al close button. +void chat_render(bool* panel_open); + +// Cierra el subprocess y libera recursos. Llamar en shutdown. +void chat_shutdown(); + +// Counter de mutaciones (lee tabla agent_mutations en app_db). Se llama +// desde main.cpp cada frame para detectar si gx-cli muto algo y disparar +// reload del grafo. Devuelve 0 si la tabla no existe todavia. +int chat_mutations_counter(); + +// ---------------------------------------------------------------------------- +// Logging con tags +// +// Todas las trazas del subsistema chat van a `/chat.log` ademas de +// stderr. Cada linea tiene formato: +// +// 2026-05-01T18:35:50.853Z [chat:detect] mensaje +// +// Tags usados (grep amigable): +// detect deteccion de claude/wsl al arrancar +// env env vars seteadas para el subprocess +// spawn argv completo + cwd al lanzar el subprocess +// io operaciones sobre los pipes (lectura/escritura/EOF) +// parse eventos JSON parseados desde stream-json +// tools tool_use detectados, comandos Bash invocados +// mut cambios detectados via agent_mutations.counter +// error fallos y exit codes +// ---------------------------------------------------------------------------- +void chat_log(const char* tag, const char* fmt, ...); + +// Devuelve el path absoluto del fichero de log (vacio si no inicializado). +const char* chat_log_path(); + +} // namespace ge diff --git a/gx-cli b/gx-cli new file mode 100755 index 0000000..fa3e686 --- /dev/null +++ b/gx-cli @@ -0,0 +1,1035 @@ +#!/usr/bin/env python3 +""" +gx-cli — CLI usado por el agente Claude del panel Chat de graph_explorer. + +Expone CRUD/promote/enricher/query sobre la operations.db activa, e incrementa +un contador en graph_explorer.db tras cada mutacion para que el viewport se +refresque automaticamente. + +Env vars (las setea graph_explorer al spawnear claude -p): + GX_OPS_DB path absoluto a operations.db (mutable) + GX_APP_DB path absoluto a graph_explorer.db (jobs + agent counters) + GX_APP_DIR path al directorio de la app (para enrichers/cache) + +Salida: + - Comandos de lectura: JSON en stdout. + - Mutaciones: JSON con `{"ok": true, "id": "...", ...}`. + - Errores: JSON con `{"ok": false, "error": "..."}` y exit code 1. +""" +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import sys +import time +from contextlib import redirect_stdout +from datetime import datetime, timezone +from io import StringIO +from pathlib import Path +from types import SimpleNamespace + + +# ---------------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------------- + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _ops_db() -> str: + p = os.environ.get("GX_OPS_DB", "") + if not p: + _die("GX_OPS_DB env var is empty") + if not Path(p).exists(): + _die(f"GX_OPS_DB does not exist: {p}") + return p + + +def _app_db() -> str: + p = os.environ.get("GX_APP_DB", "") + if not p: + _die("GX_APP_DB env var is empty") + return p + + +def _emit(payload: dict) -> None: + print(json.dumps(payload, ensure_ascii=False, default=str)) + + +def _ok(**kwargs) -> None: + out = {"ok": True} + out.update(kwargs) + _emit(out) + + +def _die(msg: str, code: int = 1) -> None: + _emit({"ok": False, "error": msg}) + sys.exit(code) + + +def _connect(path: str, *, readonly: bool = False) -> sqlite3.Connection: + if readonly: + uri = f"file:{path}?mode=ro" + cn = sqlite3.connect(uri, uri=True) + else: + cn = sqlite3.connect(path) + cn.row_factory = sqlite3.Row + return cn + + +def _bump_counter(op: str, detail: str = "") -> None: + """Escribe un marker file que graph_explorer poll para refrescar viewport. + + Usamos un fichero en lugar de tabla SQLite porque graph_explorer.db esta + abierto en WAL desde el lado Windows mientras gx-cli escribe desde WSL. + El locking de WAL falla silenciosamente cross-filesystem-boundary + (NTFS <-> 9p). Un marker file con stat() funciona en todos lados. + """ + # El marker vive junto a graph_explorer.db (mismo dir = misma escritura + # rapida en /mnt/c). + p = _app_db() + marker = Path(p).parent / ".mutations.marker" + try: + ts = _now_ms() + marker.write_text(f"{ts}\n{op}\n{detail}\n", encoding="utf-8") + except OSError as e: + sys.stderr.write(f"[gx-cli] warn: marker write failed: {e}\n") + + +# ---------------------------------------------------------------------------- +# Detect type heuristic (mirror de entity_ops::detect_type) +# ---------------------------------------------------------------------------- + +def _detect_type(text: str) -> str: + import re + t = (text or "").strip() + if not t: + return "text" + if re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", t): + return "email" + if re.fullmatch(r"(\d{1,3}\.){3}\d{1,3}", t): + return "ip_address" + if t.lower().startswith(("http://", "https://")): + return "url" + if re.fullmatch(r"[a-z0-9-]+(\.[a-z0-9-]+)+", t.lower()): + return "domain" + if re.fullmatch(r"\+?\d[\d\s-]{6,}\d", t): + return "phone" + return "text" + + +# ---------------------------------------------------------------------------- +# node ops +# ---------------------------------------------------------------------------- + +def cmd_node_create(args) -> None: + name = args.name + type_ref = args.type or _detect_type(name) + new_id = f"{type_ref}_{_now_ms()}" + ts = _now_iso() + src = "agent:gx-cli" + description = args.description or "" + + cn = _connect(_ops_db()) + try: + cn.execute( + "INSERT INTO entities (id, name, type_ref, description, source, " + "created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (new_id, name, type_ref, description, src, ts, ts), + ) + cn.commit() + except sqlite3.IntegrityError as e: + _die(f"insert failed: {e}") + finally: + cn.close() + + _bump_counter("node.create", new_id) + _ok(id=new_id, name=name, type_ref=type_ref) + + +def cmd_node_delete(args) -> None: + cn = _connect(_ops_db()) + try: + cur = cn.execute("DELETE FROM entities WHERE id = ?", (args.id,)) + cn.execute( + "DELETE FROM relations WHERE from_entity = ? OR to_entity = ?", + (args.id, args.id), + ) + cn.commit() + if cur.rowcount == 0: + _die(f"entity not found: {args.id}", code=2) + finally: + cn.close() + _bump_counter("node.delete", args.id) + _ok(id=args.id) + + +def cmd_node_update(args) -> None: + sets, params = [], [] + if args.name is not None: + sets.append("name = ?") + params.append(args.name) + if args.type is not None: + sets.append("type_ref = ?") + params.append(args.type) + if args.status is not None: + if args.status not in ("active", "stale", "corrupted", "archived"): + _die(f"invalid status: {args.status}") + sets.append("status = ?") + params.append(args.status) + if args.description is not None: + sets.append("description = ?") + params.append(args.description) + if args.tags is not None: + try: + tags = json.loads(args.tags) if args.tags.startswith("[") \ + else [t.strip() for t in args.tags.split(",") if t.strip()] + if not isinstance(tags, list): + raise ValueError("tags must be array") + except (json.JSONDecodeError, ValueError) as e: + _die(f"bad tags: {e}") + sets.append("tags = ?") + params.append(json.dumps(tags)) + if not sets: + _die("no fields to update") + + sets.append("updated_at = ?") + params.append(_now_iso()) + params.append(args.id) + + cn = _connect(_ops_db()) + try: + cur = cn.execute( + f"UPDATE entities SET {', '.join(sets)} WHERE id = ?", params + ) + cn.commit() + if cur.rowcount == 0: + _die(f"entity not found: {args.id}", code=2) + finally: + cn.close() + _bump_counter("node.update", args.id) + _ok(id=args.id, fields_set=len(sets) - 1) + + +def cmd_node_list(args) -> None: + sql = ("SELECT id, name, type_ref, status, updated_at FROM entities") + where, params = [], [] + if args.type: + where.append("type_ref = ?") + params.append(args.type) + if args.status: + where.append("status = ?") + params.append(args.status) + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY type_ref, name LIMIT ?" + params.append(max(1, min(args.limit, 1000))) + + cn = _connect(_ops_db(), readonly=True) + rows = [dict(r) for r in cn.execute(sql, params).fetchall()] + cn.close() + _emit({"ok": True, "count": len(rows), "rows": rows}) + + +def cmd_node_show(args) -> None: + cn = _connect(_ops_db(), readonly=True) + row = cn.execute( + "SELECT * FROM entities WHERE id = ?", (args.id,) + ).fetchone() + if row is None: + cn.close() + _die(f"entity not found: {args.id}", code=2) + + rec = dict(row) + # metadata + tags llegan como JSON string — parsea para legibilidad + for k in ("metadata", "tags"): + if k in rec and isinstance(rec[k], str): + try: + rec[k] = json.loads(rec[k]) + except json.JSONDecodeError: + pass + + # neighbors via relations + rels = cn.execute( + "SELECT id, name, from_entity, to_entity FROM relations " + "WHERE from_entity = ? OR to_entity = ?", + (args.id, args.id), + ).fetchall() + neighbors = [] + for r in rels: + other = r["to_entity"] if r["from_entity"] == args.id else r["from_entity"] + direction = "out" if r["from_entity"] == args.id else "in" + neighbors.append({ + "rel_id": r["id"], "rel_name": r["name"], + "other_id": other, "direction": direction, + }) + cn.close() + _emit({"ok": True, "entity": rec, "neighbors": neighbors}) + + +def cmd_node_search(args) -> None: + q = (args.query or "").strip() + if not q: + _die("empty query") + # FTS5 prefix search por token + tokens = [t for t in q.split() if t.replace("-", "").replace("_", "").isalnum()] + if not tokens: + _die("no usable tokens after sanitization") + fts = " OR ".join(f"{t}*" for t in tokens) + + cn = _connect(_ops_db(), readonly=True) + try: + rows = cn.execute( + "SELECT e.id, e.name, e.type_ref, e.status, " + " bm25(entities_fts) AS rank " + "FROM entities_fts f JOIN entities e ON e.id = f.id " + "WHERE entities_fts MATCH ? ORDER BY rank LIMIT ?", + (fts, max(1, min(args.limit, 200))), + ).fetchall() + except sqlite3.OperationalError as e: + _die(f"FTS not available: {e}") + finally: + cn.close() + _emit({"ok": True, "count": len(rows), "rows": [dict(r) for r in rows]}) + + +# ---------------------------------------------------------------------------- +# relation ops +# ---------------------------------------------------------------------------- + +def cmd_rel_create(args) -> None: + new_id = f"rel_{_now_ms()}" + ts = _now_iso() + name = args.name or "RELATED_TO" + cn = _connect(_ops_db()) + try: + # verifica que existen los endpoints + for entity_id in (args.from_id, args.to_id): + r = cn.execute( + "SELECT 1 FROM entities WHERE id = ?", (entity_id,) + ).fetchone() + if r is None: + _die(f"entity not found: {entity_id}", code=2) + cn.execute( + "INSERT INTO relations (id, name, from_entity, to_entity, " + "created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + (new_id, name, args.from_id, args.to_id, ts, ts), + ) + cn.commit() + finally: + cn.close() + _bump_counter("rel.create", new_id) + _ok(id=new_id, name=name, from_id=args.from_id, to_id=args.to_id) + + +def cmd_rel_delete(args) -> None: + cn = _connect(_ops_db()) + try: + cur = cn.execute("DELETE FROM relations WHERE id = ?", (args.id,)) + cn.commit() + if cur.rowcount == 0: + _die(f"relation not found: {args.id}", code=2) + finally: + cn.close() + _bump_counter("rel.delete", args.id) + _ok(id=args.id) + + +def cmd_rel_list(args) -> None: + sql = ("SELECT id, name, from_entity, to_entity, status FROM relations") + where, params = [], [] + if args.from_id: + where.append("from_entity = ?") + params.append(args.from_id) + if args.to_id: + where.append("to_entity = ?") + params.append(args.to_id) + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY name LIMIT ?" + params.append(max(1, min(args.limit, 2000))) + cn = _connect(_ops_db(), readonly=True) + rows = [dict(r) for r in cn.execute(sql, params).fetchall()] + cn.close() + _emit({"ok": True, "count": len(rows), "rows": rows}) + + +# ---------------------------------------------------------------------------- +# table ops (Table-typed entities backed by DuckDB) +# ---------------------------------------------------------------------------- + +def cmd_table_list(args) -> None: + cn = _connect(_ops_db(), readonly=True) + rows = cn.execute( + "SELECT id, name, metadata FROM entities WHERE type_ref = 'Table' " + "ORDER BY name" + ).fetchall() + cn.close() + out = [] + for r in rows: + m = {} + try: + m = json.loads(r["metadata"] or "{}") + except json.JSONDecodeError: + pass + out.append({ + "id": r["id"], "name": r["name"], + "duckdb_path": m.get("duckdb_path", ""), + "table_name": m.get("table_name", ""), + "row_type": m.get("row_type", ""), + "columns": m.get("columns", []), + }) + _emit({"ok": True, "count": len(out), "tables": out}) + + +def cmd_table_promote(args) -> None: + """Promociona una fila DuckDB a entidad. Replica tableview_promote_row.""" + cn = _connect(_ops_db()) + try: + row = cn.execute( + "SELECT metadata FROM entities WHERE id = ? AND type_ref = 'Table'", + (args.table_id,), + ).fetchone() + if row is None: + _die(f"Table not found: {args.table_id}", code=2) + meta = json.loads(row["metadata"] or "{}") + duckdb_path = meta.get("duckdb_path", "") + table_name = meta.get("table_name", "") + row_type = meta.get("row_type") or "row" + label_column = meta.get("label_column") or "name" + id_column = meta.get("id_column") or "id" + if not duckdb_path or not table_name: + _die(f"Table {args.table_id} has no duckdb_path/table_name") + + # resolver path relativo respecto al dir de operations.db + if not Path(duckdb_path).is_absolute(): + duckdb_path = str(Path(_ops_db()).parent / duckdb_path) + + # idempotencia: existe ya entidad con metadata.source.row_id? + prom_id = f"prom_{row_type}_{args.row_id}".replace(" ", "_") + existing = cn.execute( + "SELECT id FROM entities WHERE id = ?", (prom_id,) + ).fetchone() + if existing: + _ok(id=existing["id"], promoted=False, message="already promoted") + return + + # leer la fila desde DuckDB + try: + import duckdb # type: ignore + except ImportError: + _die("duckdb python module not installed") + ddb = duckdb.connect(duckdb_path, read_only=True) + cur = ddb.execute( + f'SELECT * FROM "{table_name}" WHERE CAST("{id_column}" AS VARCHAR) = ?', + [args.row_id], + ) + cols = [d[0] for d in cur.description] + vals = cur.fetchone() + ddb.close() + if vals is None: + _die(f"row not found: {args.row_id}") + row_dict = dict(zip(cols, vals)) + + label = str(row_dict.get(label_column, args.row_id)) + ts = _now_iso() + meta_out = { + "source": {"duckdb": meta.get("duckdb_path"), + "table": table_name, + "row_id": args.row_id}, + **{k: (v if isinstance(v, (str, int, float, bool)) or v is None + else str(v)) for k, v in row_dict.items()}, + } + cn.execute( + "INSERT INTO entities (id, name, type_ref, source, metadata, " + "created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (prom_id, label, row_type, "agent:gx-cli", + json.dumps(meta_out, default=str), ts, ts), + ) + # CONTAINS_ROW relacion (idempotente) + rel_id = f"rel_contains_{prom_id}" + cn.execute( + "INSERT OR IGNORE INTO relations (id, name, from_entity, to_entity, " + "created_at, updated_at) VALUES (?, 'CONTAINS_ROW', ?, ?, ?, ?)", + (rel_id, args.table_id, prom_id, ts, ts), + ) + cn.commit() + finally: + cn.close() + _bump_counter("table.promote", prom_id) + _ok(id=prom_id, promoted=True) + + +def cmd_table_demote(args) -> None: + cn = _connect(_ops_db()) + try: + cur = cn.execute("DELETE FROM entities WHERE id = ?", (args.id,)) + cn.execute( + "DELETE FROM relations WHERE from_entity = ? OR to_entity = ?", + (args.id, args.id), + ) + cn.commit() + if cur.rowcount == 0: + _die(f"entity not found: {args.id}", code=2) + finally: + cn.close() + _bump_counter("table.demote", args.id) + _ok(id=args.id) + + +def cmd_table_page(args) -> None: + cn = _connect(_ops_db(), readonly=True) + row = cn.execute( + "SELECT metadata FROM entities WHERE id = ? AND type_ref = 'Table'", + (args.table_id,), + ).fetchone() + cn.close() + if row is None: + _die(f"Table not found: {args.table_id}", code=2) + meta = json.loads(row["metadata"] or "{}") + duckdb_path = meta.get("duckdb_path", "") + table_name = meta.get("table_name", "") + if not duckdb_path or not table_name: + _die("metadata sin duckdb_path/table_name") + if not Path(duckdb_path).is_absolute(): + duckdb_path = str(Path(_ops_db()).parent / duckdb_path) + cols = meta.get("columns") or [] + id_col = meta.get("id_column") or "id" + try: + import duckdb # type: ignore + except ImportError: + _die("duckdb python module not installed") + ddb = duckdb.connect(duckdb_path, read_only=True) + select = f'"{id_col}"' + ( + ", " + ", ".join(f'"{c}"' for c in cols) if cols else "" + ) + sql = f'SELECT {select} FROM "{table_name}" ORDER BY "{id_col}" LIMIT ? OFFSET ?' + cur = ddb.execute(sql, [max(1, min(args.limit, 200)), max(0, args.offset)]) + names = [d[0] for d in cur.description] + rows = [dict(zip(names, [str(v) if v is not None else None for v in r])) + for r in cur.fetchall()] + total = ddb.execute(f'SELECT COUNT(*) FROM "{table_name}"').fetchone()[0] + ddb.close() + _emit({"ok": True, "table": args.table_id, "total": total, + "offset": args.offset, "limit": args.limit, "rows": rows}) + + +# ---------------------------------------------------------------------------- +# enricher ops +# ---------------------------------------------------------------------------- + +def _app_dir() -> str: + p = os.environ.get("GX_APP_DIR", "") + if p and Path(p).exists(): + return p + # fallback: dir donde vive este script + return str(Path(__file__).resolve().parent) + + +def _enrichers_dir() -> Path: + return Path(_app_dir()) / "enrichers" + + +def cmd_enricher_list(args) -> None: + out = [] + edir = _enrichers_dir() + if not edir.is_dir(): + _emit({"ok": True, "count": 0, "enrichers": []}) + return + for sub in sorted(edir.iterdir()): + manifest = sub / "manifest.yaml" + if not manifest.is_file(): + continue + m = _parse_yaml_minimal(manifest.read_text(encoding="utf-8")) + applies_to = m.get("applies_to", []) or [] + if args.type and applies_to and args.type not in applies_to: + continue + out.append({ + "id": m.get("id", sub.name), + "name": m.get("name", sub.name), + "description": m.get("description", ""), + "applies_to": applies_to, + }) + _emit({"ok": True, "count": len(out), "enrichers": out}) + + +def _parse_yaml_minimal(text: str) -> dict: + """Mini parser: top-level scalars + listas inline. Suficiente para los + manifests de enrichers que ya tenemos. No es YAML completo.""" + out: dict = {} + for raw in text.splitlines(): + line = raw.split("#", 1)[0].rstrip() + if not line or not line[0].isalpha(): + continue + if ":" not in line: + continue + k, _, v = line.partition(":") + k = k.strip() + v = v.strip() + if v.startswith("[") and v.endswith("]"): + inner = v[1:-1].strip() + out[k] = [x.strip().strip('"').strip("'") + for x in inner.split(",") if x.strip()] + elif v.startswith('"') and v.endswith('"'): + out[k] = v[1:-1] + elif v.startswith("'") and v.endswith("'"): + out[k] = v[1:-1] + else: + out[k] = v + return out + + +def cmd_enricher_run(args) -> None: + """Inserta un job en la cola agent_jobs. main.cpp lo recoge cada frame y + lo somete via jobs_submit (que arranca el subprocess). Asi reusamos el + pool de workers existente sin duplicar logica.""" + edir = _enrichers_dir() + if not (edir / args.enricher / "manifest.yaml").is_file(): + _die(f"enricher not found: {args.enricher}") + if args.node: + cn = _connect(_ops_db(), readonly=True) + r = cn.execute( + "SELECT id, name FROM entities WHERE id = ?", (args.node,) + ).fetchone() + cn.close() + if r is None: + _die(f"node not found: {args.node}", code=2) + node_name = r["name"] + else: + node_name = "" + + req_id = f"areq_{_now_ms()}" + try: + cn = sqlite3.connect(_app_db()) + cn.execute( + "CREATE TABLE IF NOT EXISTS agent_jobs (" + " id TEXT PRIMARY KEY," + " enricher_id TEXT NOT NULL," + " node_id TEXT NOT NULL DEFAULT ''," + " node_name TEXT NOT NULL DEFAULT ''," + " params_json TEXT NOT NULL DEFAULT '{}'," + " created_at INTEGER NOT NULL)" + ) + cn.execute( + "INSERT INTO agent_jobs (id, enricher_id, node_id, node_name, " + "params_json, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (req_id, args.enricher, args.node or "", node_name, + args.params or "{}", _now_ms()), + ) + cn.commit() + cn.close() + except sqlite3.Error as e: + _die(f"could not enqueue: {e}") + _ok(request_id=req_id, enricher=args.enricher, node=args.node or "", + message="job encolado, lo recoge el panel Jobs") + + +# ---------------------------------------------------------------------------- +# query (read-only SELECT) +# ---------------------------------------------------------------------------- + +def cmd_query(args) -> None: + sql = (args.sql or "").strip() + low = sql.lower().lstrip("(").lstrip() + if not (low.startswith("select") or low.startswith("with")): + _die("only SELECT/WITH queries allowed") + forbidden = ("attach", "detach", "pragma writable", "vacuum") + if any(f in low for f in forbidden): + _die(f"forbidden token in query") + cn = _connect(_ops_db(), readonly=True) + try: + cur = cn.execute(sql) + names = [d[0] for d in cur.description] if cur.description else [] + rows = [dict(zip(names, [str(v) if v is not None else None for v in r])) + for r in cur.fetchmany(max(1, min(args.limit, 500)))] + except sqlite3.Error as e: + cn.close() + _die(f"sql error: {e}") + cn.close() + _emit({"ok": True, "count": len(rows), "columns": names, "rows": rows}) + + +# ---------------------------------------------------------------------------- +# info +# ---------------------------------------------------------------------------- + +def cmd_info(args) -> None: + info = { + "ok": True, + "ops_db": os.environ.get("GX_OPS_DB", ""), + "app_db": os.environ.get("GX_APP_DB", ""), + "app_dir": _app_dir(), + } + try: + cn = _connect(_ops_db(), readonly=True) + info["entity_count"] = cn.execute( + "SELECT COUNT(*) FROM entities").fetchone()[0] + info["relation_count"] = cn.execute( + "SELECT COUNT(*) FROM relations").fetchone()[0] + info["types"] = [r[0] for r in cn.execute( + "SELECT DISTINCT type_ref FROM entities ORDER BY type_ref" + ).fetchall()] + cn.close() + except sqlite3.Error as e: + info["error"] = str(e) + _emit(info) + + +# ---------------------------------------------------------------------------- +# MCP server (JSON-RPC 2.0 stdio) +# +# Cuando se invoca como `gx-cli mcp-server`, el script entra en bucle +# leyendo lineas JSON-RPC en stdin y emitiendo respuestas en stdout. Los +# logs van a stderr (claude los recoge en su propio log). +# +# Reutilizamos los `cmd_*` capturando stdout: cada cmd_* emite UN unico JSON +# con _emit, asi que captura + json.loads = resultado de la tool. Mas simple +# que reescribir 17 funciones. +# ---------------------------------------------------------------------------- + +MCP_TOOLS = [ + {"name": "info", + "description": "Resumen del operations.db: contadores de entidades y relaciones, tipos distintos. Llamar primero al empezar para tener contexto.", + "inputSchema": {"type": "object", "properties": {}}}, + {"name": "node_list", + "description": "Lista entidades del grafo. Filtros opcionales por type_ref y status.", + "inputSchema": {"type": "object", "properties": { + "type": {"type": "string", "description": "Filtra por type_ref exacto"}, + "status": {"type": "string", "enum": ["active", "stale", "corrupted", "archived"]}, + "limit": {"type": "integer", "default": 100, "minimum": 1, "maximum": 1000}}}}, + {"name": "node_show", + "description": "Devuelve la entidad completa (metadata, tags, descripcion, status) y todos sus vecinos 1-hop con direccion (in/out).", + "inputSchema": {"type": "object", "properties": { + "id": {"type": "string", "description": "ID de la entidad"}}, + "required": ["id"]}}, + {"name": "node_search", + "description": "Busqueda FTS5 sobre name, description, tags. Tokeniza por whitespace y aplica busqueda por prefijo. Devuelve hits ordenados por relevancia.", + "inputSchema": {"type": "object", "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "default": 50, "minimum": 1, "maximum": 200}}, + "required": ["query"]}}, + {"name": "node_create", + "description": "Crea una entidad nueva. Si type se omite, se infiere heuristicamente del name (email/url/domain/ip/phone/text).", + "inputSchema": {"type": "object", "properties": { + "name": {"type": "string"}, + "type": {"type": "string", "description": "Opcional. Auto-detectado si se omite."}, + "description": {"type": "string"}}, + "required": ["name"]}}, + {"name": "node_update", + "description": "Modifica campos de una entidad existente. Al menos un campo aparte de id debe pasarse.", + "inputSchema": {"type": "object", "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "status": {"type": "string", "enum": ["active", "stale", "corrupted", "archived"]}, + "description": {"type": "string"}, + "tags": {"type": "string", "description": "JSON array literal o CSV 'a,b,c'"}}, + "required": ["id"]}}, + {"name": "node_delete", + "description": "Borra la entidad y todas sus relaciones. Irreversible. Confirmar con el usuario antes.", + "inputSchema": {"type": "object", "properties": { + "id": {"type": "string"}}, "required": ["id"]}}, + {"name": "rel_create", + "description": "Crea una relacion dirigida from_id -> to_id. Ambas entidades deben existir.", + "inputSchema": {"type": "object", "properties": { + "from_id": {"type": "string"}, + "to_id": {"type": "string"}, + "name": {"type": "string", "default": "RELATED_TO", + "description": "Nombre semantico ej: KNOWS, OWNS, BELONGS_TO"}}, + "required": ["from_id", "to_id"]}}, + {"name": "rel_delete", + "description": "Borra una relacion por id.", + "inputSchema": {"type": "object", "properties": { + "id": {"type": "string"}}, "required": ["id"]}}, + {"name": "rel_list", + "description": "Lista relaciones, opcionalmente filtradas por endpoint.", + "inputSchema": {"type": "object", "properties": { + "from_id": {"type": "string"}, + "to_id": {"type": "string"}, + "limit": {"type": "integer", "default": 200, "minimum": 1, "maximum": 2000}}}}, + {"name": "table_list", + "description": "Lista entidades de tipo Table (datasets DuckDB respaldando filas tabulares).", + "inputSchema": {"type": "object", "properties": {}}}, + {"name": "table_page", + "description": "Lee una pagina de filas de la tabla DuckDB asociada a un nodo Table.", + "inputSchema": {"type": "object", "properties": { + "table_id": {"type": "string"}, + "offset": {"type": "integer", "default": 0, "minimum": 0}, + "limit": {"type": "integer", "default": 50, "minimum": 1, "maximum": 200}}, + "required": ["table_id"]}}, + {"name": "table_promote", + "description": "Promociona una fila DuckDB a entidad del grafo (idempotente: si ya existe, devuelve la misma).", + "inputSchema": {"type": "object", "properties": { + "table_id": {"type": "string"}, + "row_id": {"type": "string"}}, + "required": ["table_id", "row_id"]}}, + {"name": "table_demote", + "description": "Borra la entidad promovida. La fila DuckDB queda intacta.", + "inputSchema": {"type": "object", "properties": { + "id": {"type": "string"}}, "required": ["id"]}}, + {"name": "enricher_list", + "description": "Lista enrichers cargados. Si se pasa type, filtra por applies_to.", + "inputSchema": {"type": "object", "properties": { + "type": {"type": "string"}}}}, + {"name": "enricher_run", + "description": "Encola un enricher para correr async sobre un nodo. graph_explorer tomara el job en su pool de workers; el viewport se refresca al terminar.", + "inputSchema": {"type": "object", "properties": { + "enricher": {"type": "string", "description": "ID del enricher (ej: fetch_webpage)"}, + "node": {"type": "string", "description": "ID del nodo objetivo. Opcional para enrichers globales."}, + "params": {"type": "string", "description": "JSON object stringified con params. Default '{}'."}}, + "required": ["enricher"]}}, + {"name": "query", + "description": "Ejecuta SQL read-only (SELECT/WITH) sobre operations.db. Util para queries no cubiertas por las otras tools (agregaciones, joins).", + "inputSchema": {"type": "object", "properties": { + "sql": {"type": "string"}, + "limit": {"type": "integer", "default": 100, "minimum": 1, "maximum": 500}}, + "required": ["sql"]}}, +] + + +def _mcp_call_cmd(fn, args_dict: dict) -> dict: + """Llama un cmd_* capturando su stdout y devolviendo el JSON parseado. + Si el cmd hace sys.exit(1) (via _die), recoge el JSON de error igualmente.""" + args = SimpleNamespace(**args_dict) + buf = StringIO() + try: + with redirect_stdout(buf): + fn(args) + except SystemExit: + pass + out = buf.getvalue().strip() + if not out: + return {"ok": False, "error": "command produced no output"} + try: + return json.loads(out) + except json.JSONDecodeError as e: + return {"ok": False, "error": f"invalid JSON output: {e}", + "raw": out[:500]} + + +# Mapa: nombre de tool MCP -> (cmd_func, default_kwargs) +MCP_DISPATCH = { + "info": (cmd_info, {}), + "node_list": (cmd_node_list, {"type": None, "status": None, "limit": 100}), + "node_show": (cmd_node_show, {}), + "node_search": (cmd_node_search, {"limit": 50}), + "node_create": (cmd_node_create, {"type": None, "description": None}), + "node_update": (cmd_node_update, {"name": None, "type": None, + "status": None, "description": None, + "tags": None}), + "node_delete": (cmd_node_delete, {}), + "rel_create": (cmd_rel_create, {"name": None}), + "rel_delete": (cmd_rel_delete, {}), + "rel_list": (cmd_rel_list, {"from_id": None, "to_id": None, + "limit": 200}), + "table_list": (cmd_table_list, {}), + "table_page": (cmd_table_page, {"offset": 0, "limit": 50}), + "table_promote": (cmd_table_promote, {}), + "table_demote": (cmd_table_demote, {}), + "enricher_list": (cmd_enricher_list, {"type": None}), + "enricher_run": (cmd_enricher_run, {"node": None, "params": None}), + "query": (cmd_query, {"limit": 100}), +} + + +def _mcp_dispatch(tool_name: str, args: dict) -> dict: + if tool_name not in MCP_DISPATCH: + return {"ok": False, "error": f"unknown tool: {tool_name}"} + fn, defaults = MCP_DISPATCH[tool_name] + merged = dict(defaults) + merged.update(args or {}) + return _mcp_call_cmd(fn, merged) + + +def _mcp_log(msg: str) -> None: + sys.stderr.write(f"[gx-cli mcp] {msg}\n") + sys.stderr.flush() + + +def cmd_mcp_server(_args) -> None: + """Bucle JSON-RPC 2.0 stdio. Reads line-delimited JSON, writes responses.""" + _mcp_log("server starting (pid=" + str(os.getpid()) + ")") + _mcp_log(f"GX_OPS_DB={os.environ.get('GX_OPS_DB', '')}") + _mcp_log(f"GX_APP_DB={os.environ.get('GX_APP_DB', '')}") + _mcp_log(f"GX_APP_DIR={os.environ.get('GX_APP_DIR', '')}") + + def emit(obj: dict) -> None: + sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n") + sys.stdout.flush() + + for raw in sys.stdin: + line = raw.strip() + if not line: + continue + try: + req = json.loads(line) + except json.JSONDecodeError as e: + _mcp_log(f"bad json: {e}") + continue + + method = req.get("method", "") + rpc_id = req.get("id") + params = req.get("params") or {} + _mcp_log(f"<- method={method} id={rpc_id}") + + if method == "initialize": + emit({"jsonrpc": "2.0", "id": rpc_id, "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "graph_explorer", "version": "0.1.0"}, + }}) + + elif method == "notifications/initialized": + # Notification — no response. + pass + + elif method == "tools/list": + emit({"jsonrpc": "2.0", "id": rpc_id, + "result": {"tools": MCP_TOOLS}}) + + elif method == "tools/call": + tool_name = params.get("name", "") + tool_args = params.get("arguments", {}) or {} + _mcp_log(f" call {tool_name}({tool_args})") + try: + result = _mcp_dispatch(tool_name, tool_args) + payload = json.dumps(result, ensure_ascii=False) + is_err = not result.get("ok", True) + emit({"jsonrpc": "2.0", "id": rpc_id, "result": { + "content": [{"type": "text", "text": payload}], + "isError": is_err, + }}) + except Exception as e: + _mcp_log(f" exception: {e}") + emit({"jsonrpc": "2.0", "id": rpc_id, "error": { + "code": -32603, "message": str(e), + }}) + + elif method == "ping": + emit({"jsonrpc": "2.0", "id": rpc_id, "result": {}}) + + elif method.startswith("notifications/"): + # Otras notificaciones — ignorar. + pass + + else: + if rpc_id is not None: + emit({"jsonrpc": "2.0", "id": rpc_id, "error": { + "code": -32601, "message": f"method not found: {method}", + }}) + + _mcp_log("stdin closed, exiting") + + +# ---------------------------------------------------------------------------- +# argparse wiring +# ---------------------------------------------------------------------------- + +def main() -> None: + p = argparse.ArgumentParser(prog="gx-cli") + sub = p.add_subparsers(dest="cmd", required=True) + + # info + sp = sub.add_parser("info", help="Show ops_db summary") + sp.set_defaults(fn=cmd_info) + + # node + n = sub.add_parser("node").add_subparsers(dest="op", required=True) + sp = n.add_parser("create") + sp.add_argument("--name", required=True) + sp.add_argument("--type") + sp.add_argument("--description") + sp.set_defaults(fn=cmd_node_create) + sp = n.add_parser("delete") + sp.add_argument("id") + sp.set_defaults(fn=cmd_node_delete) + sp = n.add_parser("update") + sp.add_argument("id") + sp.add_argument("--name") + sp.add_argument("--type") + sp.add_argument("--status") + sp.add_argument("--description") + sp.add_argument("--tags", + help='JSON array o "tag1,tag2" CSV') + sp.set_defaults(fn=cmd_node_update) + sp = n.add_parser("list") + sp.add_argument("--type") + sp.add_argument("--status") + sp.add_argument("--limit", type=int, default=100) + sp.set_defaults(fn=cmd_node_list) + sp = n.add_parser("show") + sp.add_argument("id") + sp.set_defaults(fn=cmd_node_show) + sp = n.add_parser("search") + sp.add_argument("query") + sp.add_argument("--limit", type=int, default=50) + sp.set_defaults(fn=cmd_node_search) + + # relation + r = sub.add_parser("rel").add_subparsers(dest="op", required=True) + sp = r.add_parser("create") + sp.add_argument("--from", dest="from_id", required=True) + sp.add_argument("--to", dest="to_id", required=True) + sp.add_argument("--name") + sp.set_defaults(fn=cmd_rel_create) + sp = r.add_parser("delete") + sp.add_argument("id") + sp.set_defaults(fn=cmd_rel_delete) + sp = r.add_parser("list") + sp.add_argument("--from", dest="from_id") + sp.add_argument("--to", dest="to_id") + sp.add_argument("--limit", type=int, default=200) + sp.set_defaults(fn=cmd_rel_list) + + # table + t = sub.add_parser("table").add_subparsers(dest="op", required=True) + sp = t.add_parser("list") + sp.set_defaults(fn=cmd_table_list) + sp = t.add_parser("promote") + sp.add_argument("table_id") + sp.add_argument("row_id") + sp.set_defaults(fn=cmd_table_promote) + sp = t.add_parser("demote") + sp.add_argument("id") + sp.set_defaults(fn=cmd_table_demote) + sp = t.add_parser("page") + sp.add_argument("table_id") + sp.add_argument("--offset", type=int, default=0) + sp.add_argument("--limit", type=int, default=50) + sp.set_defaults(fn=cmd_table_page) + + # enricher + e = sub.add_parser("enricher").add_subparsers(dest="op", required=True) + sp = e.add_parser("list") + sp.add_argument("--type") + sp.set_defaults(fn=cmd_enricher_list) + sp = e.add_parser("run") + sp.add_argument("enricher") + sp.add_argument("--node") + sp.add_argument("--params", help='JSON, default "{}"') + sp.set_defaults(fn=cmd_enricher_run) + + # query + sp = sub.add_parser("query") + sp.add_argument("sql") + sp.add_argument("--limit", type=int, default=100) + sp.set_defaults(fn=cmd_query) + + # mcp-server (JSON-RPC 2.0 stdio) + sp = sub.add_parser("mcp-server", + help="Run as MCP server reading JSON-RPC from stdin") + sp.set_defaults(fn=cmd_mcp_server) + + args = p.parse_args() + args.fn(args) + + +if __name__ == "__main__": + main() diff --git a/main.cpp b/main.cpp index 4cc99f4..e81cc82 100644 --- a/main.cpp +++ b/main.cpp @@ -8,6 +8,8 @@ #include "core/button.h" #include "core/tokens.h" #include "core/icons_tabler.h" +#include "core/layout_storage.h" +#include "core/layouts_menu.h" #include "viz/graph_types.h" #include "viz/graph_viewport.h" @@ -27,6 +29,7 @@ #include "project_manager.h" #include "jobs.h" #include "enrichers.h" +#include "chat.h" #include "../../../../cpp/vendor/sqlite3/sqlite3.h" @@ -73,6 +76,98 @@ static std::string g_layout_db_path; // ruta de graph_explorer.db static ForceLayoutGPU* g_gpu_ctx = nullptr; static bool g_gpu_dirty = true; +// Layout storage (menu Layouts) — guardado/cargado de layouts ImGui en +// graph_explorer.db tabla imgui_layouts. +static fn_ui::LayoutStorage* g_layout_storage = nullptr; +static fn_ui::LayoutCallbacks g_layout_cb{}; + +// ---------------------------------------------------------------------------- +// Persistencia de paneles abiertos/cerrados +// +// Los toggles `panel_chat`, `panel_jobs`, etc. viven en AppState (RAM). Sin +// persistencia, al reabrir la app vuelven a sus defaults — el usuario tiene +// que reabrir manualmente cada panel cada vez. +// +// Tabla `panel_state(name TEXT PK, open INT, updated_at INT)` en la misma +// graph_explorer.db. load al arrancar, save al cerrar. +// ---------------------------------------------------------------------------- + +static void panel_state_ensure_table(sqlite3* db) { + sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS panel_state (" + " name TEXT PRIMARY KEY," + " open INTEGER NOT NULL," + " updated_at INTEGER NOT NULL)", + nullptr, nullptr, nullptr); +} + +static void panel_state_load_db(const std::string& db_path, + fn_ui::PanelToggle* panels, size_t n) { + if (db_path.empty()) return; + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path.c_str(), &db, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, + nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return; + } + panel_state_ensure_table(db); + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, + "SELECT open FROM panel_state WHERE name = ?", + -1, &st, nullptr) == SQLITE_OK) { + int restored = 0; + for (size_t i = 0; i < n; ++i) { + if (!panels[i].open || !panels[i].label) continue; + sqlite3_bind_text(st, 1, panels[i].label, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) { + *panels[i].open = (sqlite3_column_int(st, 0) != 0); + ++restored; + } + sqlite3_reset(st); + } + sqlite3_finalize(st); + std::fprintf(stdout, + "[graph_explorer] panel_state: restored %d/%zu panels\n", + restored, n); + } + sqlite3_close(db); +} + +static void panel_state_save_db(const std::string& db_path, + const fn_ui::PanelToggle* panels, size_t n) { + if (db_path.empty()) return; + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path.c_str(), &db, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, + nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return; + } + panel_state_ensure_table(db); + sqlite3_stmt* st = nullptr; + const char* sql = + "INSERT INTO panel_state(name, open, updated_at) " + "VALUES (?, ?, strftime('%s','now')) " + "ON CONFLICT(name) DO UPDATE SET " + " open = excluded.open, " + " updated_at = excluded.updated_at"; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) == SQLITE_OK) { + int saved = 0; + for (size_t i = 0; i < n; ++i) { + if (!panels[i].open || !panels[i].label) continue; + sqlite3_bind_text(st, 1, panels[i].label, -1, SQLITE_TRANSIENT); + sqlite3_bind_int (st, 2, *panels[i].open ? 1 : 0); + if (sqlite3_step(st) == SQLITE_DONE) ++saved; + sqlite3_reset(st); + } + sqlite3_finalize(st); + std::fprintf(stdout, + "[graph_explorer] panel_state: saved %d/%zu panels\n", saved, n); + } + sqlite3_close(db); +} + // Icon atlas (de types.yaml) static IconAtlas* g_atlas = nullptr; static bool g_atlas_bound = false; @@ -142,86 +237,139 @@ static int layout_first_placed_neighbor(const GraphData& g, int node_idx) { return -1; } -// Coloca todos los nodos del grafo que esten en (0,0) cerca de su primer -// vecino con posicion conocida, eligiendo el primer slot angular libre -// dentro de un radio creciente. Sin vecinos colocados → aparca en columna -// a la derecha del bbox actual. -static void place_orphans_near_neighbors(GraphData& g, float min_dist) { - if (g.node_count == 0) return; - const float radii[] = {80.0f, 140.0f, 200.0f, 280.0f, 400.0f}; - const int n_radii = (int)(sizeof(radii) / sizeof(radii[0])); +// Encuentra una posicion sin colision para `self_idx` haciendo un barrido +// de slots angulares en radios crecientes alrededor de (cx, cy). Devuelve +// true y escribe (out_x, out_y); false si no hay hueco en los radios +// disponibles. `seed` se usa para jitter deterministico (ej: user_data). +static bool find_collision_free_slot(const GraphData& g, int self_idx, + float cx, float cy, float min_dist, + uint64_t seed, + const float* radii, int n_radii, + float* out_x, float* out_y) +{ const int slots = 12; const float two_pi = 6.28318530718f; const float slot_arc = two_pi / slots; + float jitter = ((float)((seed >> 16) & 0xFF) / 255.0f) * slot_arc; - // Recompute bbox solo de los nodos colocados (para park_in_column). - float bbox_max_x = 0.0f, bbox_min_y = 0.0f, bbox_max_y = 0.0f; + // Slot 0 = el centro (sin desplazamiento). Si no colisiona, perfecto. + if (layout_no_collision(g, self_idx, cx, cy, min_dist)) { + *out_x = cx; *out_y = cy; + return true; + } + for (int ri = 0; ri < n_radii; ++ri) { + float r = radii[ri]; + for (int s = 0; s < slots; ++s) { + float a = jitter + s * slot_arc; + float px = cx + r * std::cos(a); + float py = cy + r * std::sin(a); + if (layout_no_collision(g, self_idx, px, py, min_dist)) { + *out_x = px; *out_y = py; + return true; + } + } + } + return false; +} + +// Coloca todos los nodos del grafo que esten en (0,0): +// 1. Si tiene un vecino con posicion → ring placement junto al vecino. +// 2. Sin vecino: si `use_camera` → ring placement alrededor de la camara +// (cam_cx, cam_cy) con un radio inicial proporcional al zoom — asi +// los nodos creados por el agente aparecen DENTRO de la vista actual, +// sin solapar con lo que ya hay en pantalla. +// 3. Sin vecino y sin camera → fallback legacy: columna a la derecha del +// bbox (usado en first-load donde el viewport todavia no se ha hecho +// fit). +static void place_orphans_near_neighbors(GraphData& g, float min_dist, + bool use_camera = false, + float cam_cx = 0.0f, + float cam_cy = 0.0f, + float cam_radius = 120.0f) { + if (g.node_count == 0) return; + const float neighbor_radii[] = {80.0f, 140.0f, 200.0f, 280.0f, 400.0f}; + const int n_neighbor_radii = (int)(sizeof(neighbor_radii) / + sizeof(neighbor_radii[0])); + + // Anillos crecientes alrededor de la camara — empieza pequeno (cam_radius + // base ~viewport/zoom) para mantener los nuevos cerca del foco visual. + float cam_radii[6]; + for (int i = 0; i < 6; ++i) cam_radii[i] = cam_radius * (1.0f + i * 0.6f); + + // Bbox para fallback legacy (columna lateral) cuando use_camera=false. + float bbox_max_x = 0.0f, bbox_min_y = 0.0f; bool bbox_init = false; - for (int i = 0; i < g.node_count; ++i) { - const GraphNode& n = g.nodes[i]; - if (n.x == 0.0f && n.y == 0.0f) continue; - if (!bbox_init) { - bbox_max_x = n.x; bbox_min_y = n.y; bbox_max_y = n.y; - bbox_init = true; - } else { - if (n.x > bbox_max_x) bbox_max_x = n.x; - if (n.y < bbox_min_y) bbox_min_y = n.y; - if (n.y > bbox_max_y) bbox_max_y = n.y; + if (!use_camera) { + for (int i = 0; i < g.node_count; ++i) { + const GraphNode& n = g.nodes[i]; + if (n.x == 0.0f && n.y == 0.0f) continue; + if (!bbox_init) { + bbox_max_x = n.x; bbox_min_y = n.y; + bbox_init = true; + } else { + if (n.x > bbox_max_x) bbox_max_x = n.x; + if (n.y < bbox_min_y) bbox_min_y = n.y; + } } } float park_x = bbox_init ? bbox_max_x + 120.0f : 0.0f; float park_y = bbox_init ? bbox_min_y : 0.0f; int park_n = 0; - int placed = 0, parked = 0; + int placed_neighbor = 0, placed_camera = 0, parked = 0; for (int i = 0; i < g.node_count; ++i) { GraphNode& n = g.nodes[i]; if (n.x != 0.0f || n.y != 0.0f) continue; int parent = layout_first_placed_neighbor(g, i); - if (parent < 0) { - // Sin vecino colocado: aparca en columna lateral, separados - // verticalmente por min_dist. Si tampoco hay bbox (grafo - // recien creado), columna en (0, 0) hacia abajo. - n.x = park_x; - n.y = park_y + park_n * min_dist; - n.vx = 0.0f; n.vy = 0.0f; - ++park_n; ++parked; + if (parent >= 0) { + float ox, oy; + if (find_collision_free_slot( + g, i, g.nodes[parent].x, g.nodes[parent].y, + min_dist, n.user_data, + neighbor_radii, n_neighbor_radii, &ox, &oy)) { + n.x = ox; n.y = oy; + } else { + // Acepta solape como ultimo recurso. + n.x = g.nodes[parent].x + neighbor_radii[n_neighbor_radii - 1]; + n.y = g.nodes[parent].y; + } + n.vx = n.vy = 0.0f; + ++placed_neighbor; continue; } - // Jitter deterministico por user_data → dos huerfanos del mismo - // padre eligen ciclos diferentes y no se solapan. - float jitter = ((float)((n.user_data >> 16) & 0xFF) / 255.0f) * slot_arc; - - bool placed_ok = false; - for (int ri = 0; ri < n_radii && !placed_ok; ++ri) { - float r = radii[ri]; - for (int s = 0; s < slots && !placed_ok; ++s) { - float a = jitter + s * slot_arc; - float cx = g.nodes[parent].x + r * std::cos(a); - float cy = g.nodes[parent].y + r * std::sin(a); - if (layout_no_collision(g, i, cx, cy, min_dist)) { - n.x = cx; n.y = cy; - n.vx = 0.0f; n.vy = 0.0f; - placed_ok = true; - } + if (use_camera) { + // Sin vecino → colocar dentro de la camara con ring placement. + float ox, oy; + if (find_collision_free_slot( + g, i, cam_cx, cam_cy, min_dist, n.user_data, + cam_radii, 6, &ox, &oy)) { + n.x = ox; n.y = oy; + } else { + // Anillo amplio aceptando solape. + float two_pi = 6.28318530718f; + float a = ((float)((n.user_data >> 8) & 0xFFFF) / 65535.0f) * two_pi; + float r = cam_radii[5]; + n.x = cam_cx + std::cos(a) * r; + n.y = cam_cy + std::sin(a) * r; } + n.vx = n.vy = 0.0f; + ++placed_camera; + continue; } - if (!placed_ok) { - // Fallback: pone en el ultimo radio + slot 0 (acepta solape). - float r = radii[n_radii - 1]; - n.x = g.nodes[parent].x + r * std::cos(jitter); - n.y = g.nodes[parent].y + r * std::sin(jitter); - n.vx = 0.0f; n.vy = 0.0f; - } - ++placed; + + // Legacy: columna lateral (fuera de cam — usado en first_load). + n.x = park_x; + n.y = park_y + park_n * min_dist; + n.vx = n.vy = 0.0f; + ++park_n; ++parked; } - if (placed > 0 || parked > 0) { + if (placed_neighbor || placed_camera || parked) { std::fprintf(stdout, - "[graph_explorer] placed %d orphans near neighbors, %d parked in column\n", - placed, parked); + "[graph_explorer] placed %d near-neighbor, %d in-camera, %d parked\n", + placed_neighbor, placed_camera, parked); } } @@ -424,35 +572,37 @@ static bool load_input(bool first_load) { g_viewport.layout_running = false; g_viewport.layout_energy = 0.0f; - // Posicionar nodos en primera carga: si todos tienen (x,y)=0, aplicar - // layout circular como arranque. En reloads NO — los huerfanos los - // resuelve `place_orphans_near_neighbors` despues de layout_store_load - // (issue 0031). - if (first_load) { - int zero_pos = 0; - for (int i = 0; i < g_graph.node_count; ++i) { - if (g_graph.nodes[i].x == 0.0f && g_graph.nodes[i].y == 0.0f) ++zero_pos; - } - if (zero_pos == g_graph.node_count) { - graph::layout_circular(g_graph, 200.0f); - } - } - g_graph.update_bounds(); - // Indice user_data -> sql id (para CRUD desde menu contextual). ge::entity_index_build(g_input.uri, &g_idx); g_app.input_db_path = g_input.uri ? g_input.uri : ""; // issue 0026 — apunta el JobRunner a la nueva operations.db. if (g_input.uri) ge::jobs_set_ops_db(g_input.uri); + // Chat agent — refrescar contexto de la nueva operations.db. + if (g_input.uri) ge::chat_set_ops_db(g_input.uri); - // Cargar posiciones guardadas para este graph_hash + // Cargar posiciones guardadas para este graph_hash. Ahora ANTES del + // bootstrap circular: si tenemos posiciones guardadas las respetamos; + // solo aplicamos circular si NO hay nada guardado en primera carga. g_graph_hash = ge::compute_graph_hash(g_input.uri); int restored = ge::layout_store_load(g_graph_hash, g_graph); if (restored > 0) { - std::fprintf(stdout, "[graph_explorer] restored %d node positions from layout store\n", restored); + std::fprintf(stdout, + "[graph_explorer] restored %d node positions from layout store\n", + restored); } + // Bootstrap circular SOLO si no se restauro nada en primera carga (ej: + // primer arranque tras crear el proyecto, o tras `Reset layout`). Si + // restored>0 los nodos cargados ya tienen posicion; los nuevos sin + // posicion guardada los colocara place_orphans_near_neighbors. + if (first_load && restored == 0 && g_graph.node_count > 0) { + graph::layout_circular(g_graph, 200.0f); + std::fprintf(stdout, + "[graph_explorer] bootstrap layout_circular (no saved positions)\n"); + } + g_graph.update_bounds(); + // Huerfanos (nodos sin posicion guardada): halo placement junto a su // primer vecino con coordenadas conocidas (issue 0031). En primera carga // tambien aplica — si layout_circular ya los puso en circulo, no entran @@ -754,11 +904,23 @@ static fn_ui::PanelToggle g_panels[] = { {"Types", nullptr, &g_app.panel_type_editor}, {"Table", nullptr, &g_app.panel_table}, {"Jobs", nullptr, &g_app.panel_jobs}, + {"Echo", nullptr, &g_app.panel_chat}, }; static void render() { update_fps(); + // Aplicar layout pendiente (si el usuario seleccionó uno del menu Layouts). + // Debe ir antes de crear ventanas — LoadIniSettingsFromMemory afecta a las + // posiciones que se calculan a continuación. + if (g_layout_storage) { + std::string applied = fn_ui::layout_storage_apply_pending(g_layout_storage); + if (!applied.empty()) { + std::fprintf(stdout, "[graph_explorer] layout aplicado: %s\n", + applied.c_str()); + } + } + // No tenemos menu propio — fn::run_app llamara al app_menubar via panels[]. if (!g_loaded) { @@ -848,6 +1010,81 @@ static void render() { } } + // Chat agent — gx-cli toca .mutations.marker tras cada mutacion. + // Polleamos su mtime cada N frames; si cambia, recargamos el grafo. + // (Antes usaba un contador en agent_mutations.SQLite, pero WAL falla + // cross-NTFS<->9p cuando el .exe Windows tiene la BD abierta.) + { + static int s_last_mut = -1; // -1 = primera lectura no hecha + static int s_throttle = 0; + if (++s_throttle >= 8) { + s_throttle = 0; + int m = ge::chat_mutations_counter(); + if (s_last_mut == -1) { + // Primera lectura: solo memorizar, sin disparar reload. + s_last_mut = m; + } else if (m != s_last_mut) { + ge::chat_log("mut", + "marker mtime cambio %d -> %d, disparando reload", + s_last_mut, m); + s_last_mut = m; + g_app.want_reload = true; + } + } + } + + // Chat agent — drena cola agent_jobs (gx-cli enricher run) e invoca + // jobs_submit() para que el worker pool corriendo en C++ haga el trabajo. + if (!g_layout_db_path.empty()) { + sqlite3* adb = nullptr; + if (sqlite3_open_v2(g_layout_db_path.c_str(), &adb, + SQLITE_OPEN_READWRITE, nullptr) == SQLITE_OK) { + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(adb, + "SELECT id, enricher_id, node_id, node_name, params_json " + "FROM agent_jobs ORDER BY created_at LIMIT 8", + -1, &st, nullptr) == SQLITE_OK) { + std::vector ids_to_drop; + while (sqlite3_step(st) == SQLITE_ROW) { + const char* req_id = (const char*)sqlite3_column_text(st, 0); + const char* enr_id = (const char*)sqlite3_column_text(st, 1); + const char* node = (const char*)sqlite3_column_text(st, 2); + const char* nname = (const char*)sqlite3_column_text(st, 3); + const char* params = (const char*)sqlite3_column_text(st, 4); + char job_id[64]; + if (ge::jobs_submit(enr_id ? enr_id : "", + node ? node : "", + nname ? nname : "", + params ? params : "{}", + job_id, sizeof(job_id))) { + std::fprintf(stdout, + "[chat] queued enricher=%s node=%s as %s (req=%s)\n", + enr_id ? enr_id : "", node ? node : "", job_id, + req_id ? req_id : ""); + if (req_id) ids_to_drop.push_back(req_id); + g_app.panel_jobs = true; + } else { + std::fprintf(stderr, + "[chat] jobs_submit failed (req=%s enricher=%s)\n", + req_id ? req_id : "", enr_id ? enr_id : ""); + } + } + sqlite3_finalize(st); + for (auto& id : ids_to_drop) { + sqlite3_stmt* d = nullptr; + if (sqlite3_prepare_v2(adb, + "DELETE FROM agent_jobs WHERE id = ?", + -1, &d, nullptr) == SQLITE_OK) { + sqlite3_bind_text(d, 1, id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_step(d); + sqlite3_finalize(d); + } + } + } + sqlite3_close(adb); + } + } + // Triggers desde la toolbar if (g_app.want_fit) { graph_viewport_fit(g_graph, g_viewport); @@ -867,20 +1104,37 @@ static void render() { ge::views_reset_visibility(g_app); ge::views_apply_visibility(g_app); + // Reaplica types.yaml + atlas. Sin esto, los tipos pierden + // color/shape/icon tras reload (todo nodo vuelve a circulo + // gris). Mismo flujo que reload_after_mutation. + if (!g_app.parsed_types.entities.empty() || + !g_app.parsed_types.relations.empty()) { + std::vector cps = + ge::apply_types_yaml(g_graph, g_app.parsed_types); + if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; } + g_atlas = ge::build_icon_atlas(cps); + } + // Restaura posiciones guardadas para nodos preexistentes. int restored = ge::layout_store_load(g_graph_hash, g_graph); (void)restored; - // (C) Halo placement: huerfanos creados por enrichers se - // colocan junto a su primer vecino con posicion conocida, - // evitando solapamiento con nodos existentes (issue 0031). - place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f); + // Halo placement: junto a vecino con posicion conocida; si no + // tiene vecino (caso tipico cuando el agente crea un nodo + // aislado via MCP node_create), DENTRO de la camara visible + // con anti-colision. Convencion: world_pos == cam_pos cuando + // el nodo cae en el centro de la pantalla (graph_viewport.cpp + // L23: gx = (vx - center) / zoom + cam_x). + float cam_cx = g_viewport.cam_x; + float cam_cy = g_viewport.cam_y; + float cam_r = 80.0f / (g_viewport.zoom > 0.01f + ? g_viewport.zoom : 0.01f); + place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f, + /*use_camera=*/true, + cam_cx, cam_cy, cam_r); g_graph.update_bounds(); - // (B) NO graph_viewport_fit en reloads: preserva camara del - // usuario (issue 0031). - - // (E) Physics siempre pausadas tras reload (issue 0031). + // Physics pausadas tras reload (issue 0031). g_viewport.layout_running = false; // Refresca el indice user_data -> sql id (puede haber nuevos @@ -1051,25 +1305,17 @@ static void render() { int restored = ge::layout_store_load(g_graph_hash, g_graph); (void)restored; - // Centro del area visible en world coords (para que los nuevos nodos - // aparezcan donde el usuario esta mirando, no en el origen). - float cx = -g_viewport.cam_x; - float cy = -g_viewport.cam_y; - float spread_r = 80.0f / (g_viewport.zoom > 0.01f ? g_viewport.zoom : 0.01f); - - // Reparte los nodos sin posicion en un anillo poisson alrededor del - // centro visible. Determinista por user_data para que el mismo nodo - // caiga siempre en el mismo sitio entre reloads. - for (int i = 0; i < g_graph.node_count; ++i) { - GraphNode& n = g_graph.nodes[i]; - if (n.x != 0.0f || n.y != 0.0f) continue; - uint64_t h = n.user_data ? n.user_data : (uint64_t)i * 2654435761ull; - float a = (float)((h >> 0) & 0xFFFF) / 65535.0f * 6.2831853f; - float r = spread_r * (0.4f + (float)((h >> 16) & 0xFFFF) / 65535.0f * 0.6f); - n.x = cx + std::cos(a) * r; - n.y = cy + std::sin(a) * r; - n.vx = n.vy = 0.0f; - } + // Halo placement: prefiere vecino, fallback a la camara con anti- + // colision. Los nodos nuevos aparecen DENTRO de la camara y NO + // encima de otros — el usuario los ve sin pan/zoom. + // (cam_x, cam_y) es el world point en el centro de la pantalla. + float cam_cx = g_viewport.cam_x; + float cam_cy = g_viewport.cam_y; + float cam_r = 80.0f / (g_viewport.zoom > 0.01f + ? g_viewport.zoom : 0.01f); + place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f, + /*use_camera=*/true, + cam_cx, cam_cy, cam_r); g_graph.update_bounds(); g_atlas_bound = false; g_gpu_dirty = true; @@ -1480,6 +1726,12 @@ static void render() { ImGui::SetNextWindowSize(ImVec2(900.0f, 360.0f), ImGuiCond_FirstUseEver); ge::views_jobs(g_app); + // Chat panel (claude -p) — flotante, dockeable. + ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.55f, top + 40.0f), + ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(520.0f, 720.0f), ImGuiCond_FirstUseEver); + ge::chat_render(&g_app.panel_chat); + g_first_render = false; } @@ -1666,6 +1918,8 @@ int main(int argc, char** argv) { if (!g_input_path.empty()) { load_input(); } + panel_state_load_db(g_layout_db_path, g_panels, + sizeof(g_panels) / sizeof(g_panels[0])); } else { // Modo proyecto: migra layout legacy si aplica, decide proyecto activo, // crea default si no existe ninguno. @@ -1695,6 +1949,8 @@ int main(int argc, char** argv) { ge::layout_store_open(g_layout_db_path.c_str()); ge::project_settings_touch(target.c_str()); load_input(); + panel_state_load_db(g_layout_db_path, g_panels, + sizeof(g_panels) / sizeof(g_panels[0])); } fn_ui::about_window_set_info( @@ -1716,6 +1972,27 @@ int main(int argc, char** argv) { const char* app_db = g_layout_db_path.empty() ? "graph_explorer.db" : g_layout_db_path.c_str(); + // Layout storage — guardado/cargado de layouts ImGui en + // graph_explorer.db. El menu Layouts del menubar consume estos cb. + if (g_layout_db_path.empty()) { + std::fprintf(stderr, + "[graph_explorer] layout storage skipped (no db_path)\n"); + } else { + g_layout_storage = fn_ui::layout_storage_open( + g_layout_db_path.c_str()); + if (g_layout_storage) { + fn_ui::layout_storage_make_callbacks( + g_layout_storage, g_layout_cb); + std::fprintf(stdout, + "[graph_explorer] layout storage abierto en %s\n", + g_layout_db_path.c_str()); + } else { + std::fprintf(stderr, + "[graph_explorer] layout_storage_open fallo: %s\n", + g_layout_db_path.c_str()); + } + } + ge::enrichers_load(enrichers_dir.c_str()); if (!ge::jobs_init(app_db, g_input.uri ? g_input.uri : "", @@ -1730,6 +2007,16 @@ int main(int argc, char** argv) { enrichers_dir.c_str(), registry_root.c_str(), (int)ge::enrichers_all().size()); } + + // Chat panel (claude -p) — el agente invoca gx-cli para mutar + // operations.db. agent_mutations counter en graph_explorer.db dispara + // reload del viewport en cada cambio. + if (!ge::chat_init(g_input.uri ? g_input.uri : "", + app_db, app_dir.c_str())) { + std::fprintf(stderr, + "[graph_explorer] chat_init: claude no detectado " + "(panel Chat deshabilitado)\n"); + } } int rc = fn::run_app( @@ -1739,11 +2026,29 @@ int main(int argc, char** argv) { .viewports = true, .panels = g_panels, .panel_count = sizeof(g_panels) / sizeof(g_panels[0]), + .layouts_cb = g_layout_storage ? &g_layout_cb : nullptr, .init_gl_loader = true}, render); + // Auto-save de posiciones de nodos al salir — sin esto las posiciones se + // pierden si el usuario nunca presiona "Save layout" (issue 0031 + nudge). + if (g_loaded && g_graph_hash != 0) { + int n = ge::layout_store_save(g_graph_hash, g_graph); + std::fprintf(stdout, + "[graph_explorer] auto-saved %d node positions on exit\n", n); + } + + // Auto-save de paneles abiertos/cerrados al salir. + panel_state_save_db(g_layout_db_path, g_panels, + sizeof(g_panels) / sizeof(g_panels[0])); + // Cleanup + ge::chat_shutdown(); ge::jobs_shutdown(); + if (g_layout_storage) { + fn_ui::layout_storage_close(g_layout_storage); + g_layout_storage = nullptr; + } if (g_gpu_ctx) graph_force_layout_gpu_destroy(g_gpu_ctx); if (g_atlas) graph_icons_destroy(g_atlas); graph_viewport_destroy(g_viewport); diff --git a/views.h b/views.h index a12ac92..e950a54 100644 --- a/views.h +++ b/views.h @@ -56,6 +56,7 @@ struct AppState { bool panel_viewport = true; bool panel_note = false; bool panel_jobs = false; // issue 0026 + bool panel_chat = false; // claude -p chat (issue 0001) bool show_filters_modal = false; bool show_open_modal = false;