0bab5c97c7
- CMakeLists.txt - agent.cpp - agent.h - gx-cli - main.cpp Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1242 lines
46 KiB
C++
1242 lines
46 KiB
C++
#include "agent.h"
|
|
|
|
#include "app_base.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 <atomic>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <cstdarg>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <ctime>
|
|
#include <deque>
|
|
#include <filesystem>
|
|
#include <mutex>
|
|
#include <random>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#ifdef _WIN32
|
|
#ifndef WIN32_LEAN_AND_MEAN
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#endif
|
|
#include <windows.h>
|
|
#else
|
|
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <signal.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/types.h>
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
namespace app_agent {
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 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<milliseconds>(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<std::mutex> 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<uint64_t> 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<std::string> 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<ChatMessage> history;
|
|
std::atomic<bool> busy{false};
|
|
std::thread worker;
|
|
|
|
char input_buf[8192] = {};
|
|
bool scroll_to_bottom = false;
|
|
bool show_raw = false;
|
|
std::vector<std::string> 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 <path>` 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<std::string>& 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<char*> args;
|
|
for (auto& a : argv) args.push_back(const_cast<char*>(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<std::string>& 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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 el Agente — 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 Agente cuando tenga sentido marcar identidad.";
|
|
}
|
|
|
|
// Convierte un path Windows (UNC \\wsl.localhost\<distro>\... 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: \\<server>\<share>\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
|
|
|
|
// gx-cli vive en <exe_dir>/assets/ tras el deploy con la
|
|
// convencion assets/. Fallback al app_dir del repo en modo dev.
|
|
std::string app_dir_linux = to_linux_path(g_st->app_dir);
|
|
std::string gxcli_path;
|
|
{
|
|
std::string assets_gx = fn::asset_path(
|
|
#ifdef _WIN32
|
|
"gx-cli.exe"
|
|
#else
|
|
"gx-cli"
|
|
#endif
|
|
);
|
|
std::error_code _ec;
|
|
if (std::filesystem::exists(assets_gx, _ec)) {
|
|
gxcli_path = to_linux_path(assets_gx);
|
|
} else {
|
|
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<std::string> 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 <subcomando> ...\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::milliseconds>(
|
|
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<std::mutex> 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) + "agent.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<std::mutex> 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<std::mutex> 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<std::mutex> 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 " Agent", 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<std::mutex> 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 " Agent");
|
|
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 app_agent
|