| name | researchit |
| description | ResearchIt — DIY Deep Research Engine. SearXNG + httpx/BS4 + deepseek-v4-flash + Typst. Investigación profunda asíncrona con entrega de PDF por WhatsApp. |
| version | 1.2.0 |
| author | Hermes Agent / Toolset Personal |
| license | MIT |
| platforms | ["linux"] |
| metadata | {"hermes":{"tags":["research","deep-research","searxng","typst","pdf"],"related_skills":["kilo-code","markitdown-converter"]}} |
ResearchIt — Deep Research Engine
Descripción
ResearchIt es un motor de investigación profunda auto-hospedado que reemplaza a Gemini Deep Research. Corre completamente en el VPS (ARM64, OL9), 100% gratuito, sin APIs de pago.
Pipeline
- Plan — deepseek-v4-flash genera 8 sub-preguntas de investigación
- Search — SearXNG (Docker, localhost:4000, puerto 4000) busca cada sub-pregunta (5 resultados c/u)
- Dedup — Deduplica URLs por normalización
- Scrape — httpx+BS4 extrae contenido textual (lotes de 5, 6K chars/URL)
- Synthesize — deepseek-v4-flash sintetiza reporte estructurado (Markdown, temperature=0.3, max_tokens=8192)
- Refine — Si reporte <8000 chars, expande a 5000+ palabras con segunda llamada LLM
- PDF — Typst compila Markdown a PDF. Sin cmarker. Conversión directa MD→Typst vía
_md_to_typst()
- Delivery — Hermes lee el PDF de
vault/ y lo envía por WhatsApp como attachment nativo.
Prerrequisitos
- SearXNG en localhost:4000 (servicio
searxng en el docker-compose de toolset, kirlts/toolset/infrastructure/docker-compose.yml). Corre como --user root por compatibilidad ARM64/SELinux.
- Repo en
/opt/researchit/ (clonado desde kirlts/researchit)
- Python 3.11+ con
pip install -r requirements.txt
- Typst instalado (se auto-instala en primera ejecución)
Invocación desde Hermes
Hermes invoca ResearchIt como subproceso Python. La API key requiere set -a para exportarse correctamente:
set -a && source /home/opc/.hermes/.env && set +a && cd /opt/researchit && python3 -m src.research "tema" --max-sources 30
Parámetros clave:
--max-sources 30 (default, antes era 10): mínimo 30 fuentes para reportes robustos
--no-pdf: solo Markdown, sin PDF
--language en: búsqueda en inglés
Output:
- Markdown:
vault/researchit_{topic}_{timestamp}.md
- PDF:
vault/researchit_{topic}_{timestamp}.pdf
Generación de PDF (mobile-friendly)
El PDF se genera con Typst usando templates/report.typ. Configuración:
| Parámetro | Valor |
|---|
| Fuente | DejaVu Sans 11pt |
| Alineación | Justificado |
| Márgenes | 1.6cm laterales, 1.2cm verticales |
| Títulos H1 | 17pt bold, con pagebreak |
| Títulos H2 | 14pt bold |
| Títulos H3 | 12pt bold |
| Links | Azul #1a56db |
NO usar cmarker — no funciona en este entorno. La conversión MD→Typst es directa vía report._md_to_typst().
NO fallback raw — la compilación es una sola ruta limpia sin cmarker.
Si el PDF no se genera, revisar:
typst compile corre desde el directorio del output (cwd)
- La template report.typ existe en
templates/
- Las fuentes disponibles son: Cantarell, DejaVu Sans/Mono, Libertinus Serif, Source Code Pro
Uso directo CLI
python -m src.research "impacto de la IA en la medicina 2026"
python -m src.research "tema" --no-pdf
python -m src.research "tema" --max-sources 5 --language en
python -m src.research "tema" --output-dir /tmp/reports
Arquitectura
| Módulo | Función |
|---|
src/search.py | Cliente SearXNG (localhost:4000, formato JSON) |
src/scrape.py | Scraping async con httpx+BS4 |
src/synthesize.py | Síntesis con deepseek-v4-flash vía OpenCode Go |
src/report.py | Generación PDF con Typst |
src/research.py | Orquestador principal (pipeline 7 etapas) |
Token Optimization
- Truncado a 2K chars por entrada de búsqueda (bajado de 4K para evitar saturación con 30+ fuentes)
- Total máximo de contenido: 25K chars (bajado de 30K para evitar respuesta vacía del LLM)
- Priorización por score de SearXNG (las mejores fuentes primero)
- Progressive refinement: si reporte <8000 chars, expande a 5000+ palabras con segunda llamada LLM
- Refine inteligente: si el refine produce un resultado más corto que el original, se conserva el original (evita que refine empeore el reporte)
- System prompt exige explícitamente "Mínimo 3000 palabras. No respondas con vacío."
- Budget-aware: ~500 tokens plan, ~2000 search, ~15000 scrape, ~8192 synthesis (~25000 total input)
- 30 fuentes por defecto (configurable vía
--max-sources, default 30)
Mantenimiento
- SearXNG:
docker restart researchit-searxng
- Logs:
docker logs researchit-searxng
- Lock:
/tmp/researchit.lock (eliminar si una investigación se queda colgada)
- Reportes:
vault/
Troubleshooting
| Problema | Causa | Solución |
|---|
| 401 en OpenCode Go | API key no exportada | Usar set -a && source .env && set +a |
| 0 resultados SearXNG | SearXNG caído | docker restart researchit-searxng |
| PDF no generado | Typst compilation error | Revisar template en templates/report.typ y fuentes disponibles (typst fonts). Ver references/typst-escaping-pitfalls.md para errores comunes como unclosed delimiter, label does not exist, unknown font family. |
.env con *** | El archivo .env tiene valores masked (***) que Python lee literalmente | NO usar .env con valores masked. Usar set -a && source /home/opc/.hermes/.env && set +a para heredar env vars de Hermes. El .env de researchit solo debe contener valores reales o no existir. |
| 401 en OpenCode Go | API key no exportada | Usar set -a && source .env && set +a |
| Reporte corto | Pocas fuentes con contenido útil | Aumentar --max-sources (default 30) o mejorar queries de SearXNG |
| Síntesis devuelve 0 caracteres | El LLM puede devolver vacío en el primer pase si las fuentes scrapeadas son de baja calidad | Es normal. El refine (segunda llamada LLM) expande automáticamente si <8000 chars. Verificar que el refine sí produjo contenido antes de reintentar. |
| MEDIA tag no entrega PDF en WhatsApp | MEDIA: fue escrita dentro de backticks o markdown (MEDIA:/path) en lugar de línea aparte sin formato | La línea MEDIA:/ruta/al/archivo.pdf debe estar SOLA, sin backticks, sin emojis, sin markdown alrededor. Solo así el bridge de WhatsApp la parsea como attachment. |
Reddit Integration
ResearchIt puede incluir hasta 15 resultados de Reddit como fuentes adicionales. Los resultados se obtienen vía Composio MCP (herramienta REDDIT_SEARCH_ACROSS_SUBREDDITS) y se pasan al pipeline como archivo JSON.
Flujo:
- Hermes ejecuta búsquedas Reddit vía
mcp_composio_COMPOSIO_MULTI_EXECUTE_TOOL con queries en inglés y español
- Los resultados se guardan en
vault/reddit_{topic}.json
- Se pasan a research.py vía
--reddit-file vault/reddit_{topic}.json
- research.py inyecta hasta 15 resultados Reddit con score normalizado en la etapa de síntesis
Ejemplo:
python -m src.research "tema" --max-sources 30 --reddit-file vault/reddit_tema.json
Los secrets de Composio (API key, connection_id) se manejan vía Infisical/env vars, NO hardcodeados.
Cron Integration
ResearchIt puede ejecutarse como cron job semanal para entregar informes periódicos vía WhatsApp. El patrón típico:
- Cron job con skill
researchit cargada y deliver: origin
- El prompt del cron recolecta contexto dinámico (gh CLI, banks de Hindsight) antes de invocar ResearchIt
- ResearchIt genera PDF en
vault/
- El agente del cron encuentra el PDF más reciente (
ls -t /opt/researchit/vault/*.pdf | head -1), verifica que existe, y lo entrega con MEDIA: en línea aparte (sin backticks, sin markdown alrededor de la línea MEDIA)
Pitfall — múltiples PDFs generados en una ejecución: El agente del cron no debe asumir un solo PDF. Si el cron generó N reportes (e.g., dos temas diferentes en la misma ejecución programada), TODOS deben entregarse. NO hacer ls -t ... | head -1. En su lugar, encontrar todos los PDFs creados después del inicio de la ejecución actual, ordenar por tiempo de creación, y entregar cada uno con su propia línea MEDIA:.
import glob, os, time
batch_start = time.time()
pdfs = sorted(glob.glob("/opt/researchit/vault/researchit_*.pdf"), key=os.path.getctime)
for pdf in pdfs:
if os.path.getctime(pdf) >= batch_start - 60:
print(f"MEDIA:{pdf}")
Si ocurre que un PDF no se entrega (síntoma: el reflect lo menciona como "pendiente" días después), verificar:
- ¿El cron generó >1 PDF pero el agente solo tomó el primero?
- ¿El archivo aún existe en
vault/? (Si pasaron 4+ días, puede haber sido limpiado.)
- Si el archivo ya no existe, la única opción es re-ejecutar la investigación.
Ver references/weekly-cron-patterns.md para el patrón completo de inteligencia laboral semanal y ejemplos de configuración.
Mobile PDF — Formato para WhatsApp
El PDF está optimizado para lectura en teléfonos móviles:
| Parámetro | Valor |
|---|
| Fuente | DejaVu Sans 11pt (disponible en ARM64/OL9) |
| Alineación | Justificado con leading 0.7em |
| Márgenes | 1.6cm laterales, 1.2cm verticales |
| Títulos H1 | 17pt bold, con pagebreak, fondo azul marino (texto blanco), radius 4pt |
| Títulos H2 | 14pt bold, fondo gris claro (#e8f0fe), texto azul (#1e3a5f) |
| Títulos H3 | 12pt bold, texto azul (#2d5a87) |
| Links | Azul #1a56db |
| Raw blocks | Fondo gris (#f1f5f9), texto 7.5pt |
| Strong/Bold | Texto #1e293b |
| Encabezado/Footer | Texto gris suave (#94a3b8 / #cbd5e1) |
NO usar cmarker — no funciona en este entorno. La conversión MD→Typst es directa vía report._md_to_typst().
NO hay bold/italic conversion — el texto con * y _ se escapa completamente para evitar errores de "unclosed delimiter" en Typst. Los únicos formatos inline convertidos son: codigo → raw(), y [texto](url) → #link().
Secrets Management
Todos los secrets se manejan vía Infisical + GitHub Secrets. NO hardcodear en código.
Variables requeridas:
COMPOSIO_API_KEY — API key de Composio (para Reddit via MCP)
COMPOSIO_REDDIT_CONNECTION_ID — connection ID de Reddit en Composio
OPENCODE_GO_API_KEY — API key de OpenCode Go
OPENCODE_GO_BASE_URL — URL base de OpenCode Go (default: https://opencode.ai/zen/go/v1)
Resolución de secrets (por orden de prioridad):
- Infisical SDK (
INFISICAL_SERVICE_TOKEN en env → InfisicalClient.get_secret())
- Variable de entorno directa (
os.getenv())
- Warning en log si no se encuentra
Exportación correcta:
set -a && source /home/opc/.hermes/.env && set +a
Sin set -a, las variables no se exportan a procesos hijo (Kilo, Python) y fallan con 401 o "Missing API key".