Cierra el ciclo del analysis gliner_glirel_tuning: documenta en app.md el
pipeline NER+RE disponible en el registry y abre los dos issues que faltan
para cablearlo en extract_graph_hybrid + panel paste_extract. Archiva el
0042 original (mREBEL) tras la decision a favor de GLiNER2 (Apache 2.0,
joint NER+RE, 20-30x mas rapido en CPU).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 0038: lanzar Chrome/Edge/Brave externo con --remote-debugging-port +
--user-data-dir por profile, control via CDP desde cdp-cli Go.
Decision Go vs C++ in-process documentada; deja la puerta abierta a
un cliente C++ minimo solo para streaming en el futuro. Supersedes 0032.
- 0039: gestor de cookies/sesiones por profile via CDP — list, export
EditThisCookie, import, clear selectivo, health checks con selectores,
locks cuando un enricher esta usando el profile.
- 0040: profiles como concepto de primera clase — metadata (color, icon,
browser_preference, UA, project, template), templates anon/auth/work/
investigation, ProfilePicker reusable, project default, tag en
executions.metrics. Actualiza 0038 para apuntar a 0040 como duenio
del UX de profiles.
- Panel ImGui dockable: textarea, Extract button, preview tables (entities + relations)
- Subprocess directo a enrichers/paste_extract/run.py (no usa jobs system; preview puro)
- Pipeline Python emite preview JSON; commit a operations.db lo hace C++ con dedupe (type_ref, name)
- 12 tests pytest nuevos (paste_extract enricher + extract_panel logic)
- GLiNER/GLiREL path cableado pero no ejercitado en tests (modelos pesados); validacion interactiva pendiente
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- test_group_visual_inheritance.py (4 tests): homogeneo->Url heredado,
heterogeneo->generico Group, vacio->generico, subgrupos anidados
ignorados.
- test_manifest_threshold_override.py (4 tests): override 100 con 80
unicas no agrupa; override bajo (20) si agrupa cuando se supera;
threshold=0 cae al default 50; mirror Python del parser de manifest
C++ confirma el campo se extrae como int.
- test_schema_migration_group_id.py (3 tests): mirror Python de
project_migrate_schema, verifica idempotencia (1a y 2a apertura
no duplican columna), no-op sobre BD ya migrada, datos previos
sobreviven la migracion.
Subcomando que ejecuta SELECT DISTINCT type_ref FROM entities WHERE
group_id = ? AND type_ref != 'Group' (mismo SQL que el lado C++ de
apply_group_inherited_visuals). Devuelve homogeneous bool, child_types
ordenado y inherited (tipo unico o 'Group' generico). Permite a los
tests pytest validar el contrato sin ejecutar el binario.
12 tests cubriendo:
- modo preview (no escribe a operations.db)
- dedupe dentro de un run (mismo (type_ref, name) una sola vez)
- texto vacio retorna error con exit 2
- max_entities trunca al limite
- types filtra por tipo IoC
- use_hybrid=false ⇒ stats.layers solo regex
- runs idempotentes producen mismo proposal
Apply-side (replica en Python del extract_panel_apply C++):
- inserta solo selected
- dedupe por (type_ref, name)
- inserta relaciones cuando endpoints resuelven
- skip relacion si endpoint unselected
- dedupe relacion (from, to, name) en repeticion
GLiNER/GLiREL no se ejercitan en pytest — los modelos pesan
cientos de MB. La logica de hybrid se valida con regex+regex
(mismo path de merge/dedup) y con tests unitarios separados si
se quisiera. Se documenta la decision en el docstring del modulo.
Helper real_registry_root resuelve fn_registry desde un worktree
(el conftest del repo asume ancestor que en worktrees no existe).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- main.cpp: registra panel_extract en g_panels[], llama
extract_panel_init tras jobs_init y extract_panel_shutdown en el
cleanup, anade extract_panel_render(g_app) al render.
- CMakeLists.txt: anade extract_panel.cpp al target.
- app.md: declara extract_iocs_py_cybersecurity y
extract_graph_hybrid_py_pipelines en uses_functions (regla
uses_functions.md).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
extract_panel.{h,cpp}: panel ImGui dockeable con textarea grande,
boton Extract que lanza enrichers/paste_extract/run.py en un
std::thread aparte (no bloquea UI), tablas editables de entidades y
relaciones propuestas con checkboxes, y boton Apply Selected que
persiste a operations.db con dedupe por (type_ref, name) y por
(from, to, name) en relaciones.
Parser JSON ad-hoc (suficiente para el contrato del enricher) para
no añadir dependencias. Apply usa SQLite directamente (mismo
patron que entity_ops/jobs.cpp).
Anade panel_extract a AppState. La logica apply esta separada de
ImGui para poder testarla en aislamiento desde pytest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modo preview puro — no escribe a operations.db. Recibe texto via
params.text y devuelve JSON con entidades y relaciones propuestas.
Cascada: extract_iocs (regex) siempre + extract_graph_hybrid
(GLiNER+GLiREL) opcional con use_hybrid=true. La aplicacion procesa
el JSON y persiste con dedupe via codigo C++ (extract_panel_apply).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apply_group_inherited_visuals(GraphData*, db_path) recorre los nodos
Group del grafo y, para cada uno, consulta los type_ref distintos de
sus hijos (entities con group_id apuntando al Group). Si todos
comparten un solo tipo, reasigna el type_id del Group al type_id de
ese tipo y fija shape_override = SHAPE_SQUARE para preservar el
cuadrado distintivo. Heterogeneo o sin hijos: el Group conserva su
visual generico (slate + ti-stack-2). Se invoca desde main.cpp y
reload_graph antes de apply_group_filter para que la reasignacion
sobreviva al compactado del array.
Manifest YAML puede declarar 'auto_group_threshold: <int>' a nivel
top-level. enrichers.cpp lo parsea y lo guarda en EnricherSpec.
jobs.cpp lo inyecta como campo opcional 'auto_group_threshold' en el
JSON stdin del subprocess. Los enrichers Python que crean Groups
(web_search, split_words, split_sentences, extract_iocs_text) leen el
campo y, si viene > 0, lo usan en lugar de su DEFAULT_GROUP_THRESHOLD.
Helper _coerce_threshold tolera int / str / None / 0 cayendo al default.
El resolver buscaba un marker 'registry.db' que falla en /home/lucas
con un .db parasito (4KB, sin tabla functions). Endurecemos el marker
a cmd/fn/main.go (mas estricto), anadimos override via FN_REGISTRY_ROOT
y un fallback a ~/fn_registry. Sin esto los tests de vendor_script
fallan al ejecutarse desde un git worktree.
Dos bugs reportados tras 0036d/0037:
1. Promote button en NodeGroups window kind=Group no respondia al
click. Causa: el Selectable con SpanAllColumns + AllowDoubleClick
se tragaba el click destinado al SmallButton(TI_ARROW_UP) sobre
la misma fila. ImGui tiene un flag dedicado para esto:
AllowOverlap. Anyadido al Selectable y los botones recuperan los
clicks. Mismo fix beneficia al kind=Table porque los botones
"promote" y "expand" de DuckDB rows estaban en la misma situacion
silenciosa.
2. Placement direccional de 0037 enviaba hijos hasta r=400 cuando
habia colisiones, "muy lejos" segun el usuario. Ajustes:
- Anillos mas cercanos: {60,90,120,150,180,210,240} en vez de
{80,140,200,280,400}. Maximo 240px del source.
- Mas anillos disponibles (7 vs 5) — fan-out gradual sin saltar
bruscamente de 80 a 140.
- Espaciado entre hermanos en arco usa cluster_min_dist=35 en
vez de min_dist=60. Permite mas hijos por anillo dentro del
arco de 45 grados (cap @ r=240 = 5 vs 3 antes).
- Para 10-15 hijos tipicos los inner rings cubren todo dentro
de 200px del source.
Build limpio. Tests WSL 102 / Windows 91 + 11 skipped.
Bonus: borrado /home/lucas/fn_registry/cpp/registry.db (vacio, 0
bytes, creado por algun binario con flag O_CREAT) — violacion de
db_locations.md (registry.db solo en raiz del repo). Era el motivo
de un test flaky de python_runtime_resolver.
Antes los hijos del mismo anchor se distribuian en un anillo de 360
grados alrededor del padre. Cuando un enricher producia 10+ hijos,
se llenaban todas las direcciones y se pisaban nodos preexistentes.
Ahora los hijos se reparten en un abanico de 45 grados (pi/4) saliendo
del anchor en la direccion outward (vector anchor - centroide del resto
del grafo). Si solo hay 1 nodo placed o coincide con el anchor, default
a la derecha (0 rad). Capacidad por anillo restringida al arco
(arc_span * r / min_dist), con fallback de subida de radio en mismo
angulo si el slot ideal colisiona con un nodo no-orphan.
Solo afecta la pasada 2 (orphans con anchor). Pasadas 1 y 3 intactas.
build limpio, 102 pytest passed (WSL) + 91 passed/11 skipped (Windows).
Refs: issues/0037-directional-orphan-placement.md
Anyade un item al menu View del framework via el nuevo callback
AppConfig.view_extras. El item:
- Esta enabled solo si la seleccion del viewport (o, en su defecto,
el inspector) apunta a un nodo con type_ref Table o Group.
- Click → resuelve sql_id via entity_index_lookup, deriva
NodeGroupsKind del type_ref y llama
views_node_groups_open(g_app, sql_id, kind, ops_db). La API
marca focus_request=true (cubierto por 0036c), de modo que la
window emerge al frente si ya existia.
- Disabled → tooltip 'Select a Table or Group node first' (mostrado
con AllowWhenDisabled).
Sin atajo de teclado (descartado por el usuario).
Sin submenu de windows abiertas (fase 2).
Refs: issues/0036f-view-menu-open-nodegroups.md
- Reusa la infra de focus existente (AppState::want_focus_entity /
focus_entity_id) ya cableada en main.cpp desde 0011.
- kind=Group: single click sobre la fila pone want_focus_entity con
row.id; tooltip "Click to focus entity in viewport" en hover.
El doble click sigue funcionando (mismo efecto). El menu contextual
y el boton Promote-out-of-group quedan intactos.
- kind=Table promovida (row.promoted_entity_id no vacio): single click
pone want_focus_entity con promoted_entity_id; tooltip de focus.
- kind=Table no promovida: single click es no-op visual; tooltip
"promote first to focus\n(double click or right click to promote)"
como hint sutil. El doble click sigue lanzando el flujo de promote
(legado de 0036c) y el menu contextual ofrece Promote.
- Sin cambios en el handler de main.cpp — la logica de pan/zoom + select
+ load inspector ya existe y se reutiliza tal cual.
- Sin tests Python nuevos: el comportamiento es UI ImGui (no testeable
desde pytest). 102 passed WSL / 91+11 skipped Windows sin regresion.
Refs: issues/0036e-row-click-focus-viewport.md
NodeGroups window kind=Group ahora expone un boton SmallButton(TI_ARROW_UP)
por fila que saca la entidad del grupo (group_id = NULL) y dispara
reload del grafo. kind=Table mantiene el comportamiento de issue 0011.
- entity_ops: nueva op `entity_clear_group_id(db, id)` idempotente. Si
la columna group_id no existe (BD pre-0035a) retorna true como no-op.
Falla solo si la entidad no existe o SQLite revienta.
- views.cpp: extra columna "promote" en kind=Group, tooltip header
diferenciado por kind, boton conectado a app.want_clear_group_id_entity.
- main.cpp: handler que ejecuta entity_clear_group_id, marca windows
como dirty, llama reload_after_mutation y loguea
`[node_groups] promoted X out of group`.
- gx-cli: flag `node update --clear-group-id` (booleano) y exposicion
MCP en inputSchema + MCP_DISPATCH defaults para que el agente Echo
pueda promover via tool calls.
- tests: 3 nuevos CLI (clear, idempotente, combinable con --name) y
4 MCP (defaults, schema, dispatch end-to-end, idempotente).
WSL: 102 passed (95 base + 7).
Windows: 91 passed, 11 skipped (84 base + 7).
Refs: issues/0036d-promote-kind-aware.md
Cambia el dispatch del doble click sobre nodos del viewport: si el tipo
es Group o Table, ahora abre/enfoca la NodeGroups window correspondiente
via views_node_groups_open(...). El branch de Group ya no carga el
panel Table generico con un filtro group_id (logica heredada de 0035d
que provocaba el bug de "tabla vacia").
Limpieza correlativa en views_table:
- Eliminado el breadcrumb "Group: <name> (N)" + boton Clear filter.
- Eliminado el filtro r.group_id != table_filter_group_id en
build_visible y la restriccion de types_present.
- Eliminado el reset on-close de los campos de filtro.
Eliminados los campos AppState::table_filter_group_id y
table_filter_group_name (audit: git grep table_filter_group_id devuelve
vacio fuera de issues/).
Render de NodeGroups ahora consume focus_request: llama
SetNextWindowFocus() antes de Begin y SetWindowFocus() dentro, asi la
window queda al frente tanto al crearse como al re-enfocarse.
El right-click "Open NodeGroups" del context menu sigue intacto
(want_toggle_nodegroups + node_groups_set_expanded). El doble click es
flujo paralelo nuevo.
Refs: issues/0036c-double-click-group-opens-nodegroups.md
NodeGroupsWindowState gana un discriminador `kind` (Table | Group) y
un flag `focus_request` (lo consumira 0036c). Por defecto Table, asi
que el flujo historico (DuckDB rows tras expand de un nodo Table) no
cambia.
kind=Group lee directamente operations.db consultando
`entities WHERE group_id = container_id` con columnas fijas
(id, name, type_ref, status, updated_at) ordenadas por updated_at DESC.
Los nuevos loaders viven en node_groups.cpp:
- node_groups_count_for_group -> SELECT count(*) ...
- node_groups_page_for_group -> SELECT id,name,type_ref,status,
updated_at ... LIMIT ? OFFSET ?
Para columnas, opcion (A) del issue: pre-popular meta.columns con la
lista fija al abrir kind=Group, asi el render se mantiene generico.
NodeGroupsRow.values guarda los 5 campos en ese orden y row.id es la
key natural (= entity_id de la fila — al ser ya entidad, no hace falta
promocionarla).
Render en views.cpp ramifica por kind:
- Table: layout original [id_col + columns + promoted] con doble
click -> promote/focus.
- Group: layout [columns fijas] sin promoted. Doble click sobre la
fila ya pone want_focus_entity = id (los flujos posteriores 0036c-e
afinan UX). Right click ofrece "Focus in Inspector".
main.cpp dispatcha por kind al refrescar paginas y, al cerrar via X,
solo llama a node_groups_set_expanded para kind=Table (Group no usa
ese flag).
views_node_groups_windows_sync se hace kind-aware: solo reconcilia
entries kind=Table contra el set de Tables expandidas; no toca las
entries kind=Group (las gestiona views_node_groups_open).
Nueva API publica:
views_node_groups_open(app, container_id, kind, ops_db)
Crea o reusa la entry, setea focus_request=true y para kind=Group
pre-popula meta.columns + intenta leer `name` del Group para el
titulo. Sin caller todavia — la consume 0036c.
Tests:
- tests/test_node_groups_loader.py (6 tests) verifica el contrato
SQL via gx-cli. Nuevo subcomando `gx-cli group page <id>` espejea
el loader C++ exactamente (mismo SQL); tambien expuesto como tool
MCP `group_page` para que Echo pueda inspeccionar Groups.
Resultado:
- WSL: 89 -> 95 passed
- Windows: 78+11 -> 84+11 passed
- Build C++ Windows limpio, sin warnings nuevos.
- Regresion kind=Table: comportamiento identico (mismo render,
mismo loader DuckDB).
Refs: issues/0036b-kind-discriminator-and-group-loader.md
split_sentences a menudo no llega al umbral de 50 (un texto medio
tiene 5-15 frases). split_words tokeniza el mismo notes en palabras
y trivialmente lo supera con cualquier parrafo decente -> Group
visible y testeable end-to-end sin necesidad de pegar megabytes.
Diferencias respecto a split_sentences:
* Splits por regex de letras (incluye acentos espanyoles + apostrofo
interno como "don't"). Numeros y puntuacion ignorados.
* Lowercase + filtro por min_length (default 3, filtra a/el/de/y/o).
* Param `dedupe` (default true): vocabulario unico vs cada ocurrencia.
Con dedupe=false sirve como stress test de volumen.
* Tipo `Word` en types.yaml: amarillo, ti-letter-w, principal_field=word.
* Relacion `WORD_OF` desde cada Word al source.
* Mismo patron de grouping que split_sentences (threshold 50, K=10
preview, batch_id en metadata, Group con count + enricher).
Tests:
* below threshold no crea Group.
* >=50 tokens unicos -> Group + 10 sueltos + resto agrupados.
* dedupe=true (default) colapsa repeticiones; dedupe=false las
conserva como nodos separados.
* min_length filtra correctamente.
* notes prioriza sobre node_name.
* texto vacio -> exit 2.
* max_words trunca.
WSL 89 / Windows 78 + 11 skipped.
Bug derivado del fix anterior: gx-cli escribia ficheros JSON en
`$GX_APP_DIR/agent_jobs_queue/` (apuntando al repo fuente) mientras
main.cpp escaneaba `parent(g_layout_db_path)/agent_jobs_queue/`
(install Windows). Dos directorios distintos -> jobs huerfanos.
Echo reportaba "encolado" pero el worker nunca veia los ficheros.
La causa: chat.cpp setea GX_APP_DIR=<registry>/projects/osint_graph/
apps/graph_explorer y GX_APP_DB=<install>/local_files/projects/<slug>/
graph_explorer.db. Dos sitios. Solo APP_DB coincide con donde
graph_explorer.exe escanea (parent del .db).
Fix:
* gx-cli cmd_enricher_run: queue_dir = parent(GX_APP_DB) /
agent_jobs_queue. Alineado con main.cpp.
* gx-cli: nuevo helper `_log(tag, msg)` que escribe a stderr Y a
`<parent(app_db)>/gx-cli.log` para auditoria persistente. Cubre
node_create, node_update, node_delete, rel_create, enricher_run.
* gx-cli mcp _mcp_log tambien persiste a gx-cli.log.
* main.cpp: log el queue scan dir una vez por sesion para detectar
mismatches a futuro.
* .gitignore: agent_jobs_queue/ y gx-cli.log son runtime, no se
commitean.
Tests:
* test_enricher_run_queue_dir_derives_from_app_db (regresion)
configura GX_APP_DB en un dir distinto de GX_APP_DIR y verifica
que el JSON aterriza junto a APP_DB.
* test_enricher_run_writes_log_to_gx_cli_log valida la auditoria.
WSL 81 / Windows 70 + 11 skipped.
Bug: Echo (gx-cli en WSL) recibia "disk I/O error" al INSERT en la
tabla `agent_jobs` de graph_explorer.db. Causa: graph_explorer.exe
mantiene esa BD abierta con journal_mode=WAL desde Windows, y SQLite
WAL exige mmap del .shm compartido entre procesos. Cuando un escritor
accede via /mnt/c (9p) y el otro nativo NTFS, ese mmap falla.
El proyecto ya habia resuelto este patron antes: el contador de
mutaciones (.mutations.marker) usa fichero plano en vez de SQL por
exactamente la misma razon. agent_jobs era la unica cola que se
quedo en SQLite — momento de aplicar el mismo fix.
Cambios:
* gx-cli cmd_enricher_run: en lugar de INSERT, escribe
`<app_dir>/agent_jobs_queue/<req_id>.json` con el payload del job.
Atomic write (tmp + rename, atomico tanto en NTFS como en 9p).
* main.cpp polling: en lugar de SELECT/DELETE sobre agent_jobs,
escanea ese directorio cada frame, lee cada JSON via json_extract
(sqlite3 in-memory, sin tocar archivos en disco), llama jobs_submit,
y borra el fichero. Throttle a 8 jobs por frame igual que antes.
* main.cpp: anyade <filesystem> y <fstream>.
* tests/test_gx_cli.py: 5 tests nuevos en TestCliEnricherRun:
- escribe fichero JSON con req_id como nombre
- NO crea tabla agent_jobs en graph_explorer.db (regresion)
- errores claros si enricher o nodo no existen
- no quedan .tmp tras encolado exitoso
WSL 79 / Windows 68 + 11 skipped.
Bug encontrado por el agente Echo: el MCP server gx-cli (subcomando
`mcp-server`) llamaba a cmd_node_create / cmd_node_update con un
SimpleNamespace que NO incluia `notes`, asi que `args.notes` lanzaba
AttributeError. Causa raiz: MCP_DISPATCH no defaulteaba `notes` ni
`append_notes`, y el inputSchema de las tools tampoco los anunciaba.
Cambios:
* MCP_TOOLS["node_create"].inputSchema.properties anyade `notes`.
* MCP_TOOLS["node_update"].inputSchema.properties anyade `notes`
+ `append_notes` (boolean, default false).
* MCP_DISPATCH["node_create"] defaultea `notes: None`.
* MCP_DISPATCH["node_update"] defaultea `notes: None`,
`append_notes: False`.
Tests nuevos en tests/test_gx_cli.py (30 tests):
* CLI: node create/update/delete con notes (replace + append),
list/show/search, rel create/list/delete con cascada, query
read-only que rechaza writes, autodetect de tipos.
* MCP dispatcher: cada cmd_* tolera args opcionales omitidos,
notes y append_notes funcionan via dispatch, MCP_TOOLS y
MCP_DISPATCH coinciden 1:1 (sanity contractual).
* Regresion 0035d: tests dedicados que congelan el contrato
notes/append_notes en defaults e inputSchema — si alguien
vuelve a quitarlos el test se queja inmediatamente.
WSL 74 / Windows 63 + 11 skipped.