From 54f82e020c9b996450dbfdc26ef3648848b747a8 Mon Sep 17 00:00:00 2001 From: Horacio Herrera Date: Tue, 5 May 2026 17:17:43 +0200 Subject: [PATCH 01/17] feat: add knowledge manager skill based on LAFH/GC-Red methodology with agent scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Seed Knowledge Manager skill implementing Luis Ángel Fernández Hermana's network knowledge management methodology, including agent infrastructure templates, governance document templates, research deep-dive document, and deployment scaffolding across 6 agent directories. --- docs/research-lafh-knowledge-management.md | 345 ++++++++++++++++++ seed-knowledge-manager/SKILL.md | 230 ++++++++++++ seed-knowledge-manager/agent/README.md | 99 +++++ seed-knowledge-manager/agent/config/.gitkeep | 0 .../agent/logrotate/.gitkeep | 0 .../agent/mcp/seed-cli-mcp/.gitkeep | 0 seed-knowledge-manager/agent/scripts/.gitkeep | 0 .../agent/seed-daemon/.gitkeep | 0 seed-knowledge-manager/agent/systemd/.gitkeep | 0 .../agent/templates/agent-allowlist.md | 21 ++ .../agent/templates/agent-charter.md | 39 ++ .../agent/templates/agent-rules.md | 41 +++ .../agent/templates/agent-runbook.md | 28 ++ .../references/lafh-framework.md | 237 ++++++++++++ .../templates/boletin-periodico.md | 61 ++++ .../templates/gap-report.md | 67 ++++ .../templates/network-health.md | 86 +++++ .../templates/onboarding-capsule.md | 49 +++ .../templates/synthesis-document.md | 63 ++++ 19 files changed, 1366 insertions(+) create mode 100644 docs/research-lafh-knowledge-management.md create mode 100644 seed-knowledge-manager/SKILL.md create mode 100644 seed-knowledge-manager/agent/README.md create mode 100644 seed-knowledge-manager/agent/config/.gitkeep create mode 100644 seed-knowledge-manager/agent/logrotate/.gitkeep create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitkeep create mode 100644 seed-knowledge-manager/agent/scripts/.gitkeep create mode 100644 seed-knowledge-manager/agent/seed-daemon/.gitkeep create mode 100644 seed-knowledge-manager/agent/systemd/.gitkeep create mode 100644 seed-knowledge-manager/agent/templates/agent-allowlist.md create mode 100644 seed-knowledge-manager/agent/templates/agent-charter.md create mode 100644 seed-knowledge-manager/agent/templates/agent-rules.md create mode 100644 seed-knowledge-manager/agent/templates/agent-runbook.md create mode 100644 seed-knowledge-manager/references/lafh-framework.md create mode 100644 seed-knowledge-manager/templates/boletin-periodico.md create mode 100644 seed-knowledge-manager/templates/gap-report.md create mode 100644 seed-knowledge-manager/templates/network-health.md create mode 100644 seed-knowledge-manager/templates/onboarding-capsule.md create mode 100644 seed-knowledge-manager/templates/synthesis-document.md diff --git a/docs/research-lafh-knowledge-management.md b/docs/research-lafh-knowledge-management.md new file mode 100644 index 0000000000..10a776f70b --- /dev/null +++ b/docs/research-lafh-knowledge-management.md @@ -0,0 +1,345 @@ +# El gestor de conocimiento según Luis Ángel Fernández Hermana +## Research deep-dive — para el skill de gestión de conocimiento en Seed Hypermedia + +> *"Las redes sociales de conocimiento son entornos virtuales altamente organizados en los que los participantes interactúan de acuerdo a una metodología implementada por un equipo de gestión de red, con el fin de alcanzar objetivos concretos mediante la creación de conocimiento nuevo."* +> — lab_RSI (Laboratorio de Redes Sociales de Innovación, dirigido por LAFH) + +--- + +## Parte I — Quién es LAFH y por qué importa + +Luis Ángel Fernández Hermana (Málaga, 1946) es periodista científico, profesor y consultor especializado en la conceptualización, diseño, desarrollo y gestión de **redes de conocimiento**. Fue corresponsal científico de El Periódico de Catalunya (1982–2004), corresponsal de la BBC, colaborador de Nature y TV3. + +En **enero de 1996** publicó el primer editorial de **en.red.ando**, una de las primeras revistas electrónicas en castellano dedicadas al impacto social, político, económico y cultural de Internet. Durante 8 años publicó más de 400 editoriales semanales (martes), recopilados después en los tres volúmenes de *Historia Viva de Internet* (Editorial UOC). + +En **1998** fundó **Enredando.com**, empresa pionera en el diseño y gestión de las primeras Redes Sociales Virtuales de Conocimiento (RSVC) en Internet. Cerró en julio de 2004. + +Posteriormente dirigió el **Laboratorio de Redes Sociales de Innovación (lab_RSI)** dentro de Citilab, donde produjo el cuerpo metodológico más maduro: la **Red Fractal** (2012), HipotecaGratis.com / Creditaria, Locomotora (Mataró), entre otras. + +Recibió el **Premi Ciutat de Barcelona** (2000), el **European Journalism Award** (2001), y fue elegido por El Mundo entre las 25 personas más influyentes en Internet en España (2000–2002). + +**Por qué importa para Seed Hypermedia:** LAFH no es un teórico de la "gestión del conocimiento" empresarial al estilo Nonaka/Takeuchi. Es alguien que **construyó plataformas reales** durante 25+ años para que comunidades distribuidas produjeran conocimiento nuevo, y desarrolló el conjunto de roles, métodos, métricas y advertencias que hacen que esto realmente funcione. Su obra es justamente lo que falta en la mayor parte de la literatura sobre "online communities". + +--- + +## Parte II — La distinción fundamental: GC vs GC-Red + +Esta distinción es el cimiento de todo lo demás y tiene que estar clara antes de diseñar el skill. + +### Gestión del Conocimiento (GC) — la tradición empresarial + +- Procede de Business Administration, años 60, EE.UU. +- Existe **independientemente de Internet** o de cualquier red. +- Se asienta sobre **una organización con organigrama nítido** — empleados, funciones delimitadas, objetivos estratégicos de la empresa. +- Mercado de miles de millones de dólares en EE.UU., dominado por grandes consultoras. +- En palabras de LAFH, el ámbito ha sido "embrollado" por la labor de las consultoras y la "larga sombra" de la gestión empresarial. + +### Gestión de Conocimiento en Red (GC-Red) — la propuesta de LAFH + +- Procede del mundo de **redes virtuales abiertas como Internet**. +- **No hay organización previa** — la crean los usuarios en función de sus intereses y los objetivos que fijan en cada caso. +- El vínculo entre los miembros **no tiene por qué existir, ni presuponerse** (no son colegas de oficina; pueden no conocerse). +- De aquí surgen foros, listas de distribución, comunidades virtuales, redes sociales, **redes sociales virtuales de conocimiento (RSVC)**. +- LAFH es muy explícito: **no hace** "gestión", ni "gestión de conocimiento", ni "gestión del conocimiento", ni "gestión del conocimiento en red". Hace **gestión de conocimiento en red** (sin "del"), y la diferencia no es semántica — es metodológica. + +**Implicación para Seed:** una comunidad en Seed encaja con GC-Red, no con GC. La gente se asocia por objetivos compartidos, no por organigrama. Esto cambia radicalmente el diseño del agente: no es un buscador corporativo, es un facilitador de producción de conocimiento entre desconocidos coordinados por intereses. + +--- + +## Parte III — Qué es una Red Social Virtual de Conocimiento (RSVC) + +> *"Espacio de encuentro virtual diseñado y gestionado con el fin de alcanzar objetivos concretos mediante el trabajo colaborativo en red. La dinámica está orientada a la recuperación y reelaboración de los intercambios que se producen entre sus miembros con el fin de obtener productos de conocimiento."* + +### Características clave + +1. **Tiene objetivos concretos.** Una RSVC no es un foro de cháchara; produce conocimiento aplicado a un proyecto. +2. **Construye conocimiento nuevo.** El propósito no es difundir lo que ya existe, sino generar lo que aún no existe. +3. **Trabaja sobre dos pilares:** + - **Estructura virtual** diseñada según pautas de un sistema de Generación y Gestión de Información y Conocimiento en red. + - **Equipo de gestión** preparado para aplicar la metodología de GC-Red. +4. **No reemplaza** al organigrama clásico de una organización — lo **superpone**, sacando a la luz "un mapa de conocimiento basado en formas de trabajo diferentes". + +### Productos típicos de una RSVC (qué entrega realmente) + +- Preparación y ejecución de proyectos +- Desarrollo de metodologías nuevas +- Materiales para líneas de negocio +- Contenidos pedagógicos (formal o informal) +- Trabajo cooperativo en áreas transversales +- Reorganización de territorios productivos +- Equipos preparados para emprender proyectos colaborativos +- **Información para la toma de decisiones** +- **Síntesis sistematizada de la actividad** (que es, en sí mismo, conocimiento aplicable a otras redes) + +### Ejemplo histórico — HipotecaGratis.com / Creditaria + +Caso pionero (2004). Una empresa hipotecaria con ~30 asesores convertida en RSVC. La pantalla de cada trabajador tenía dos mitades: a la izquierda, la base de datos de clientes; a la derecha, la red de conocimiento. Compartían en tiempo real las incidencias con clientes, qué funcionaba y qué fracasaba, dictámenes de expertos. + +**Resultados en 9 meses:** +- Ingresos por trabajador subieron entre **34% y 43%** (bonificaciones por contratos). +- Detectaron **antes que el mercado** las primeras señales del pinchazo de la burbuja inmobiliaria. Trasladaron la operación a México y fundaron Creditaria, que llegó a 85 oficinas conectadas. +- Cuando una persona dejaba la empresa, **los documentos sintéticos que recogían su forma de trabajar permanecían** y los nuevos los consultaban como si la persona aún estuviera. La memoria institucional sobrevivía a la rotación. + +Este caso es la mejor prueba de qué tiene que entregar el agente: **no resúmenes de conversaciones, sino documentos sintéticos que capturan formas de trabajar.** + +--- + +## Parte IV — El equipo de gestión (los cuatro perfiles) + +LAFH no concibe la gestión de una red de conocimiento como tarea de una sola persona. En su modelo maduro hay **cuatro roles**, que en redes pequeñas pueden recaer en la misma persona pero conviene distinguir conceptualmente porque atacan problemas diferentes. + +### 1. Gestor de la Red (Network Manager) + +- Diseña y organiza los **flujos de información** en la red. +- Trabaja siempre desde una **visión global de los objetivos**. +- Interviene en el diseño del "centro de operaciones": administración de perfiles, estructura de la red, normas de moderación, procesos de síntesis, evolución de la red. +- Es el más "arquitectónico" — pensar en él como el platform engineer. + +### 2. Moderador / Dinamizador + +Aquí está el grueso de la operación día a día. Sus funciones: + +- **Aplica la metodología de trabajo** dentro de la red. Esta es su función primera y más importante. +- Garantiza la **estabilidad de los intercambios** entre miembros. +- Aprueba/rechaza mensajes según las normas, elimina spam. +- **Está en contacto permanente con los participantes** — orienta sus formas de participar para elevar su capacidad de generar información en red. +- **Es el único miembro al tanto del flujo de información completo**, en tiempo real. Por eso puede regular el ritmo de producción para evitar el "**choque infosomático**" (el agobio que paraliza a la red). +- Establece pautas de comportamiento colectivo (respeto, documentación, contenido referenciado). +- **Trabaja en la zona de síntesis** — elabora boletines periódicos y documentos de conocimiento (temáticos o personales). +- **Promueve relaciones cruzadas** entre líneas temáticas que aparecen en debates o documentos aportados. +- Hilvana el debate mediante **recapitulaciones y resúmenes** para orientar y relanzar la discusión. +- Trabaja en corto, medio y largo plazo en función de los objetivos. + +### 3. Gestor de Conocimiento (Knowledge Manager) + +Este es el rol más cercano al que tu agente va a desempeñar: + +- **Crea y desarrolla el contexto** necesario para que los miembros produzcan información y conocimiento significativo. +- **Obtiene y procesa** documentos e informes relevantes. +- **Celebra entrevistas, solicita dictámenes a expertos**, elabora reseñas de eventos relacionados con la temática. +- **Investiga en Internet y en el mundo físico** — no se queda dentro de la red. +- **Establece relaciones con otras redes**, abre la posibilidad de alianzas con otros equipos, empresas o colectivos. +- Actúa desde la perspectiva del proyecto entero, no solo de lo que pasa dentro de la red. + +### 4. Responsable de Contenidos + +- Elabora contenidos nuevos publicables en la RSVC en relación con la temática. +- **Responde a demandas específicas** del moderador o de los gestores de conocimiento. +- Trabaja en colaboración con el equipo de redacción del medio de comunicación de la red. + +### Fusión de roles en redes pequeñas + +LAFH y otros autores que siguen su escuela (como el "Educacion y Aprendizaje Virtual" blog citando a Cáceres Tello) reconocen que en **"redes en fase inicial" o "experiencias iniciales"** las funciones del moderador y del gestor de conocimiento las realiza la misma persona, llegándose a denominar **"Moderador de redes"**. Esto es exactamente lo que tu agente necesita ser para una comunidad Seed pequeña/mediana. + +--- + +## Parte V — Las "zonas" de una red de conocimiento + +LAFH organiza la actividad de una RSVC en **zonas funcionales** distintas. Esto es importante porque el trabajo del agente no es uniforme: cambia según la zona en la que opera. + +### Zona de aportaciones + +Donde los miembros publican (mensajes, documentos, debates). Es donde el moderador "trabaja" según LAFH. La calidad del conocimiento depende aquí de que las aportaciones estén **documentadas y referenciadas** — esto es, según LAFH, "los cimientos básicos de la calidad de información aportada, de su credibilidad y fiabilidad". + +### Zona de síntesis + +Donde se transforman las aportaciones en productos de conocimiento (boletines, documentos temáticos, documentos personales). Es responsabilidad principal del moderador. Sin esta zona, una red genera ruido pero no acumula sabiduría. + +### Centro de operaciones + +Desde donde el gestor de la red administra perfiles, estructura, normas, métricas y la propia evolución de la red. + +### Implicación para el skill + +El agente debe operar **en las tres zonas** con métodos distintos: +- En la zona de aportaciones: orientar, sugerir referencias, regular ritmo. +- En la zona de síntesis: producir documentos sintéticos. +- En el centro de operaciones: monitorizar salud, detectar silos, reportar. + +--- + +## Parte VI — La metodología en acción: tareas concretas del gestor + +Reuniendo dispersos fragmentos de los textos de LAFH y lab_RSI, este es el conjunto de tareas operativas que el gestor desempeña. Las agrupo por familia funcional para usarlas después en el skill. + +### A. Captura, organización y clasificación + +- Registrar todo lo que produce la red (aportaciones, debates, documentos). +- Clasificar por temática, autor, fecha, relevancia. +- Mantener referencias explícitas (LAFH es categórico: contenido sin referenciar = contenido sin credibilidad). + +### B. Conexión y síntesis (el corazón del trabajo) + +- **Detectar relaciones cruzadas** entre líneas temáticas distintas. +- **Crear documentos de síntesis** — temáticos o personales — que consolidan conocimiento disperso. +- **Recapitular debates** mediante resúmenes que orienten y relancen la discusión. +- **Elaborar boletines periódicos** que sirvan como hito y como producto. +- Producir **borradores con valor de transferencia** (aplicables a otros proyectos, otras redes). + +### C. Curación y filtrado + +- Separar señal del ruido (qué aportación es valiosa, qué es efímera). +- Identificar contenido **desactualizado** o que **contradice** versiones más recientes. +- Mantener el conocimiento **vivo** — no permitir que quede enterrado bajo capas de actividad nueva. + +### D. Memoria institucional + +- Preservar el conocimiento cuando los miembros se van (caso HipotecaGratis). +- Responder con **historial y contexto correcto** — no solo con la última versión. +- Evitar la **reinvención de la rueda** — si algo ya se discutió, surfacearlo antes de que la comunidad lo rehaga. + +### E. Onboarding y mapeo de expertise + +- Ayudar a nuevos miembros a orientarse en el corpus existente. +- Responder "¿qué sabe esta comunidad sobre X?". +- **Redirigir preguntas al experto correcto** (mapa de expertise dinámico). + +### F. Detección de gaps + +- Identificar **qué no se sabe** y qué hay que investigar. +- Surfacear **preguntas sin respuesta**. +- Proponer documentos nuevos donde falta conocimiento estructurado. + +### G. Facilitación del discurso + +- Moderar y enriquecer conversaciones con contexto relevante. +- Capturar **acuerdos y desacuerdos clave** en debates. +- Fomentar la participación de miembros silenciosos. +- **Regular ritmo** — el "choque infosomático" es real y mata redes. + +### H. Monitoreo y salud de la red + +- Trackear actividad: qué se produce, quién contribuye, qué se ignora. +- **Detectar silos** — conocimiento que no circula entre subgrupos. +- Reportar el estado general de la base de conocimiento. +- Verificar la "adecuación de las normas de moderación". + +### I. Investigación externa y alianzas + +- No quedarse dentro de la red — investigar en Internet y en el mundo físico. +- Solicitar dictámenes a expertos externos. +- Establecer relaciones con otras redes; abrir posibilidad de alianzas. + +--- + +## Parte VII — Conceptos transversales clave (lenguaje LAFH) + +Estos términos aparecen en sus textos y conviene preservarlos en el skill — son distintivos y operacionales. + +| Término | Significado operativo | +|---|---| +| **GC-Red** | Gestión de conocimiento en red. La disciplina propia. | +| **RSVC** | Red Social Virtual de Conocimiento. La unidad básica. | +| **Zona de aportaciones** | Donde los miembros publican. | +| **Zona de síntesis** | Donde se elaboran productos de conocimiento. | +| **Choque infosomático** | Sobrecarga informativa que paraliza a los participantes. El moderador la previene regulando ritmo. | +| **Documento de síntesis** | Producto de conocimiento que consolida actividad dispersa de la red. Puede ser temático o personal. | +| **Boletín periódico** | Hito de síntesis publicado regularmente. | +| **Producto de conocimiento** | Outcome tangible (proyecto, método, decisión) que justifica la existencia de la red. | +| **Centro de operaciones** | Lugar arquitectónico desde donde se administra la red. | +| **Mapa de conocimiento** | Estructura emergente que se superpone al organigrama clásico. | +| **Normas de moderación** | Reglas consensuadas que ordenan el espacio. | +| **Información significativa y contrastada** | El estándar de calidad de las aportaciones. | +| **Documentación referenciada** | Cimiento de credibilidad y fiabilidad. | + +--- + +## Parte VIII — Advertencias de LAFH (qué *no* hacer) + +Tan importantes como las tareas son los anti-patterns que LAFH ha ido enunciando: + +1. **No confundir comunicar con generar conocimiento.** Una red puede tener mucha actividad y producir cero conocimiento nuevo. El éxito se mide en productos de síntesis, no en mensajes. + +2. **No confiar en que el conocimiento "se conserve solo" en la Red.** LAFH cita una estimación: cerca de 4/5 partes de la información y conocimiento generados en Internet desde su creación han desaparecido. Sin acto de síntesis, todo se pierde. + +3. **No imponer organigrama empresarial.** Las RSVC funcionan con vínculos voluntarios entre miembros con intereses compartidos; tratar de asignar tareas como en una empresa rompe la dinámica. + +4. **No saturar de información.** El moderador es el único que ve el flujo completo y debe regular el ritmo para evitar el choque infosomático. + +5. **No aceptar contenido sin referencias.** La credibilidad de la red depende del rigor documental. + +6. **No tratar al moderador como una "máquina de aprobar/rechazar".** Es el motor real de la generación de conocimiento — no una válvula de spam. + +7. **No olvidar la zona de síntesis.** Aportaciones sin síntesis = ruido acumulado. Hay que volver una y otra vez sobre lo aportado para producir conocimiento. + +8. **No reinventar la rueda.** Si la comunidad ya discutió algo, hay que surfacearlo antes de que se rehaga. + +--- + +## Parte IX — Aplicación al diseño del skill para Seed + +Síntesis: cómo el marco LAFH se traduce a un agente trabajando contra un sitio Seed. + +### Mapeo de roles + +- **Tu agente = Moderador de redes** en sentido fusionado (LAFH lo llama así para redes en fase inicial). Combina moderador + gestor de conocimiento. +- Para el **gestor de la red** (arquitectura, perfiles, métricas), el skill debe poder hacer **reportes** que tú o un humano accionen. +- Para el **responsable de contenidos**, el skill puede generar **borradores** que un humano publica. + +### Mapeo de zonas a Seed + +- **Zona de aportaciones** → documentos publicados, comentarios y bloques en Seed. +- **Zona de síntesis** → documentos nuevos creados por el agente con frontmatter `type: synthesis | digest | onboarding | gap-report`. +- **Centro de operaciones** → un documento "salud de la red" que el agente regenera periódicamente. + +### Capacidades del skill (modular, las pediste así) + +1. **Read & Answer** — responde a preguntas sobre el corpus con contexto histórico correcto. Detecta y enlaza menciones cruzadas. +2. **Active Curation** — produce documentos de síntesis (temáticos, personales, de debate), boletines periódicos, recapitulaciones. +3. **Gap Detection** — identifica preguntas sin respuesta, contradicciones, contenido desactualizado, silos. +4. **Onboarding** — para nuevos miembros, sintetiza "qué sabe esta comunidad sobre X" y redirige a expertos. +5. **Network Health** — reporte periódico de actividad, contribuciones, ignorados, silos. +6. **Pacing / Anti-overload** — al producir, regula volumen para no causar choque infosomático. + +### Lenguaje operativo del skill + +Conviene usar terminología LAFH explícita en los nombres de los outputs y plantillas: + +- `documento-de-síntesis` (no "summary") +- `boletín-periódico` (no "weekly digest") +- `mapa-de-expertise` +- `informe-de-salud-de-la-red` +- `pregunta-sin-respuesta` +- `relación-cruzada-detectada` + +Esto no es esnobismo — es preservar la coherencia conceptual del marco. Y es, además, lo que diferencia a tu skill de los 50 "knowledge base assistants" genéricos. + +--- + +## Parte X — Lecturas y fuentes + +### Obras de LAFH + +1. **En.red.ando** (Ediciones B, 1998, 489 pp.) — Recopilación de los primeros 100 editoriales semanales (1996–1997) más cuatro entrevistas. ISBN 978-84-406-8568-1. +2. **Historia Viva de Internet** (Editorial UOC, 2011 en adelante) — 3 volúmenes con los más de 400 editoriales de en.red.ando (1996–2004) más entrevistas. +3. **Editoriales semanales en en.red.ando** (1996–2004) — archivo público en coladepez.com, en castellano, catalán, gallego e inglés. + +### Recursos online clave + +- **lab-rsi.com** — Sitio del Laboratorio de Redes Sociales de Innovación. Especialmente las páginas: + - `/equipo-de-gestion-de-red-de-conocimiento/` — definición operativa de los cuatro roles. + - `/redes-de-conocimiento-2/` — qué es una RSVC. + - `/creacion-y-gestion-de-redes-sociales-virtuales-2/` — formación y consultoría. + - `/hipotecagratis-com/` — caso de éxito documentado. + - `/locomotora/` — caso histórico (Mataró). +- **coladepez.com** — Revista del propio LAFH. Especialmente: + - `/knowledge-network/gestion-de-conocimiento-en-red-gc-r-que-es-como-se-hace-con-que-instrumentos/` — la pieza fundacional sobre GC-Red. + - `/educationxxi/gestion-del-conocimiento-y-gestion-de-conocimiento-en-red-una-distincion-no-solo-metodologica/` — la distinción GC vs GC-Red. + - `/redes-de-conocimiento/` — índice temático. +- **lafh.info** — CV y publicaciones de LAFH. + +### Artículo académico relevante + +- **"Proyecto de la Red Fractal"**, LAFH, en *Desafío de las ciencias sociales en tiempos de transformación*, Universidad Pontificia Bolivariana — explicación detallada del último gran proyecto metodológico (2012). + +### Marco complementario (proyecto Accelera, UAB) + +Joaquín Gairín y David Rodríguez, en *La gestión del conocimiento en red* (Universidad Autónoma de Barcelona, 2005) y artículos posteriores, han desarrollado un modelo de competencias del gestor del conocimiento en entornos virtuales que dialoga directamente con el marco LAFH y formaliza algunas de sus categorías para el ámbito educativo. Útil como contraste académico. + +--- + +## Cierre + +LAFH te da algo que casi nadie más te da: una **teoría operacional** de cómo una comunidad distribuida produce conocimiento nuevo, basada en 25+ años de implementaciones reales. El agente que vas a construir no es un "chatbot que sabe del corpus"; es la **automatización parcial del rol de Moderador de Redes** en su sentido pleno, fusionado, ese que aplica metodología, modera, sintetiza, conecta, regula ritmo, mantiene memoria, detecta gaps y reporta salud. + +El skill que sigue está diseñado en ese espíritu. diff --git a/seed-knowledge-manager/SKILL.md b/seed-knowledge-manager/SKILL.md new file mode 100644 index 0000000000..97515c4de8 --- /dev/null +++ b/seed-knowledge-manager/SKILL.md @@ -0,0 +1,230 @@ +--- +name: seed-knowledge-manager +description: "Acts as a knowledge manager (gestor de conocimiento en red, in the LAFH/Fernández Hermana tradition) for a Seed Hypermedia community. Use this skill whenever the user wants to do any of these for a Seed site or community — synthesize discussions, write a periodic digest or boletín, onboard new members, detect knowledge gaps, find unanswered questions, surface contradictions, map expertise, audit the health of the network, link related documents, recap a debate, or generally maintain the collective memory. Trigger this even if the user phrases it casually: 'what does the community know about X', 'summarize last month's discussions', 'who's the expert on Y', 'what are we missing', 'make sense of this thread'. The skill assumes a Seed site is reachable and that the seed-cli skill is also available for I/O." +--- + +# Seed Knowledge Manager + +A skill for acting as the **moderator / gestor de conocimiento en red** for a Seed Hypermedia community, applying the methodology of Luis Ángel Fernández Hermana (LAFH) and the lab_RSI / Enredando.com tradition of Knowledge Network Management (GC-Red). + +This is **not** a generic knowledge-base assistant. It is a structured implementation of a specific role with 25+ years of methodological grounding. The role's purpose is the **production of new collective knowledge**, not the retrieval of existing information. + +## When to use this skill + +Trigger this skill when the user is asking you to do any of the following on a Seed community: + +- **Synthesize** — "summarize", "recap", "make sense of", "consolidate", "digest" +- **Connect** — "find related", "link", "what else have we said about", "any cross-references" +- **Curate** — "what's still relevant", "what's outdated", "any contradictions" +- **Remember** — "what did we decide about X", "have we discussed this before", "what's our position on" +- **Onboard** — "what does the community know about X", "who's the expert on Y", "where should I start" +- **Audit gaps** — "what are we missing", "any unanswered questions", "what should we research" +- **Report health** — "how active is the network", "who's contributing", "any silos" + +## The methodology in one paragraph + +A knowledge community is not a forum and not a corporate organization. It is a **Red Social Virtual de Conocimiento (RSVC)** — a designed environment where members linked only by shared interests collaborate to produce new, applicable, referenced knowledge. The role of the knowledge manager is to **apply a methodology** that turns the flow of contributions into actual knowledge products: synthesis documents, periodic bulletins, expertise maps, gap reports. Without active synthesis, the network produces noise that disappears (LAFH cites that ~80% of internet-generated knowledge has vanished). The manager works in three zones: the **zone of contributions** (where members publish), the **zone of synthesis** (where products are produced), and the **operations center** (where network health is monitored). The manager regulates pace to prevent **choque infosomático** — information overload that paralyzes the network. + +For the full theoretical grounding, read `references/lafh-framework.md`. Read it when you need to justify a choice in the methodology or when designing a new kind of output. + +## Pre-flight: what you need + +Before doing meaningful work, gather: + +1. **Site / corpus access** — confirm `seed-cli` is available and you can list documents in the target space. If not, stop and ask the user to enable it. +2. **Network identity** — the account ID or path of the community space (e.g. `hm://abc123/community`). +3. **Network purpose / objectives** — ask the user briefly what the community is *for* if it's not obvious from the homepage. Without a sense of purpose, you can't separate signal from noise. One sentence is enough. +4. **Member map (if available)** — try to identify the active contributors. If Seed exposes this, use it; otherwise infer from authorship across recent docs. + +If any of these are missing and you can't infer them, **ask one question** before proceeding. Don't bury the user in a questionnaire. + +## The capabilities + +This skill is modular. The user invokes one capability at a time. Pick the matching one based on the request. + +### 1. Read-and-answer (the daily mode) + +When the user asks a question that the community's corpus might already have addressed. + +**Process:** +1. Search the corpus for relevant documents (use `seed-cli` query/search). +2. Read enough to give an honest answer with **historical context**, not just the latest version. +3. **Always** mention if the topic has been discussed before, and **link** to the prior documents. +4. If you find contradictions between older and newer takes, **flag the contradiction** — don't paper over it. +5. If the question has no good answer in the corpus, say so and recommend creating a "pregunta-sin-respuesta" entry (see capability 4). + +**LAFH principle behind it:** avoid letting the community "reinvent the wheel". + +### 2. Synthesis document creation (the core production) + +When the user asks for a summary, recap, consolidation, or "make sense of X". + +**You are producing a real knowledge product, not chat output.** Use the `templates/synthesis-document.md` template. The output must: + +- Have a clear purpose stated at the top (what question or thread does this consolidate?) +- Cite every source with a `hm://` link to the specific block where possible +- Distinguish between **areas of agreement**, **areas of disagreement**, and **open questions** +- End with a "next steps" or "what's missing" section +- Include `type: synthesis` in the frontmatter so the document is queryable later + +If the user asks for a one-paragraph summary, give them that inline. But for anything beyond that, **propose creating a real synthesis document in Seed** and only do so after they confirm. Don't dump 2000 words inline. + +See `templates/synthesis-document.md` for the structure. + +### 3. Periodic bulletin (boletín) + +When the user asks for a weekly/monthly digest, "what's been happening", or recap of a time period. + +Use `templates/boletin-periodico.md`. The boletín differs from a synthesis document in that it is **temporal** rather than thematic. It rolls up: + +- New documents published in the period (with one-line takeaways) +- Active threads (with current state — agreement reached? blocked? open?) +- Decisions made +- New members and what they've contributed +- Gaps surfaced or filled +- Recommended reading for the period + +Keep it scannable. Length matters less than structure. + +### 4. Gap detection (preguntas sin respuesta) + +When the user asks "what are we missing" or "what should we research". + +**Process:** +1. Scan recent threads for questions that received no resolution. +2. Scan synthesis documents for "open questions" sections. +3. Look for topics that come up repeatedly but have no consolidating document. +4. Look for topics where the community has fragmented opinions but no decision document. +5. Output: a list of gaps, each with evidence (links) and a proposed action (research, discuss, decide, document). + +Use `templates/gap-report.md`. Don't produce a vague list — every gap needs evidence and a proposed action. + +### 5. Expertise map / onboarding + +When a new member arrives, or someone asks "what does the community know about X" or "who knows about Y". + +**Process:** +1. Identify the topic. +2. Find the foundational documents on that topic in the corpus (the ones most cited or most recent canonical synthesis). +3. Identify the most active contributors on that topic by recent authorship and commenting. +4. Output: an onboarding capsule that includes: + - "What this community has decided / believes about X" + - "Open questions on X" + - "People to talk to about X" + - "Documents to read in order" + +Use `templates/onboarding-capsule.md`. Keep it short — too much breaks the welcome effect. + +### 6. Cross-reference detection + +When the user asks to find related content, or when you're producing a synthesis document and want to enrich it. + +**Process:** +1. From a starting document or thread, identify the key concepts/entities. +2. Search the corpus for other documents that mention the same concepts. +3. Distinguish: documents that **agree**, documents that **disagree**, documents that **extend**, documents that **contradict**. +4. Propose adding explicit links (in Seed: `[title](hm://...#blockId)`) at appropriate points in the source document. + +Don't just list related docs — **classify the relationship**. That's what turns a list into a graph. + +### 7. Network health report + +When the user asks how the community is doing, or periodically (suggest monthly). + +Use `templates/network-health.md`. Report on: + +- **Activity** — number of new docs, comments, active members +- **Production** — has the network produced any new knowledge product (synthesis, decision, method) in the period? If not, this is a red flag per LAFH. +- **Silos** — are there subgroups whose docs don't reference each other? List them. +- **Stale corpus** — documents that haven't been touched and are likely outdated. +- **Pace** — is the network in choque infosomático (too much, too fast, no synthesis)? Or stagnant? +- **Memory** — are recent decisions backed by referenced documents, or floating in chat? + +Be diagnostic, not flattering. The user wants real signal. + +## How to do all of this on Seed (I/O contract) + +This skill produces **markdown documents with YAML frontmatter** and **comments** — both of which Seed handles natively. The skill itself does not call Seed APIs directly; it generates content and tells the user (or a calling agent) which `seed-cli` operations to run. + +### Frontmatter conventions + +Use these `type` values consistently so the corpus becomes self-organizing: + +- `type: synthesis` — a synthesis document (capability 2) +- `type: boletin` — a periodic bulletin (capability 3) +- `type: gap-report` — gap detection output (capability 4) +- `type: onboarding` — onboarding capsule (capability 5) +- `type: network-health` — health report (capability 7) +- `type: decision` — when a community decision is captured (use this when surfacing past decisions) + +Always include: + +```yaml +--- +title: +type: +period: +covers: +sources: +created_by: knowledge-manager +created_at: +--- +``` + +### Linking style + +Always use full `hm://` links with block fragments where possible: `[Title](hm://account/path#blockId)`. This is non-negotiable per LAFH's rule on referenced documentation. + +### Comments vs. new documents + +Use the right surface: + +- **Inline comment on a block** — when flagging a contradiction or suggesting an edit on an existing doc +- **Threaded reply** — when participating in an active discussion to surface context +- **New document** — for any synthesis, gap report, bulletin, or onboarding capsule + +Never create a new document for what could be a comment; never bury synthesis in comments. + +### Pacing rule (anti-choque-infosomático) + +Do not flood the community. When producing outputs: + +- **Per session**: at most one synthesis document, one bulletin, or one health report. Multiple is fine if explicitly asked. +- **Don't auto-publish**. Always show the user the draft and confirm before any write. +- **For bulletins**: cap items per section at ~5–7. If there are more candidates, *prioritize* — don't list everything. + +## Voice and tone + +Match the community's voice (read recent docs first if you don't know it). Default characteristics: + +- **Concise**. Synthesis documents are tighter than the threads they summarize. +- **Referenced**. Every claim links back. No floating assertions. +- **Honest about uncertainty**. If the corpus is contradictory, say so. If something is your inference, mark it. +- **Non-promotional**. The skill is invisible infrastructure. No "I have prepared for you a comprehensive..." +- **In the language of the community**. If the community works in Spanish, output in Spanish. If English, English. If mixed, follow the source thread. + +## Output format expectations + +- For inline answers in chat: **prose**, no headers, brief. +- For documents to be created in Seed: use the templates and frontmatter above. +- For lists of items (gaps, related docs, members): structured but tight — one line per item with an inline link. + +## What this skill does NOT do + +- It does not act as a customer support bot. +- It does not produce marketing or promotional content for the community. +- It does not enforce moderation rules (spam removal, banning) — that's a different role (the moderator's spam handling) and should be a human decision. +- It does not auto-publish. Always draft → human reviews → human publishes. + +## Reference files + +- `references/lafh-framework.md` — the full theoretical grounding (LAFH's GC-Red methodology, the four roles, the zones, the anti-patterns). Read this when you need to justify a methodological choice or when extending the skill. +- `templates/synthesis-document.md` — template for capability 2. +- `templates/boletin-periodico.md` — template for capability 3. +- `templates/gap-report.md` — template for capability 4. +- `templates/onboarding-capsule.md` — template for capability 5. +- `templates/network-health.md` — template for capability 7. + +## When in doubt + +The default question to ask yourself before producing any output: **"Does this contribute to the production of new collective knowledge, or is it just churn?"** If it's churn, don't produce it. The community's attention is finite. diff --git a/seed-knowledge-manager/agent/README.md b/seed-knowledge-manager/agent/README.md new file mode 100644 index 0000000000..416d323abd --- /dev/null +++ b/seed-knowledge-manager/agent/README.md @@ -0,0 +1,99 @@ +# Knowledge Manager Agent — operator runbook + +Autonomous **Moderador de Redes** (LAFH/GC-Red methodology) for a Seed Hypermedia community. Runs on `oc.hyper.media`. Governed by Seed documents. + +> Status: Phase 0 scaffolding. Subsequent phases populate this README with deploy steps, kill-switch procedure, log paths, and Telegram setup. + +## Architecture summary + +- **Local Seed daemon** (`seed-daemon` Docker container) on `127.0.0.1:55001` (HTTP), `:55002` (gRPC), `:55000` (P2P). +- **HKUDS/nanobot** runtime (Python `pip install nanobot-ai`), DeepSeek LLM. +- **Custom stdio MCP wrapper** around `seed-cli` for security envelope, rate limits, audit logging. +- **Telegram channel** (operator-only) as secondary chat surface. +- All policy lives as **Seed documents** under `/agents/knowledge-manager/*` in the target site. + +## Phase index + +- Phase 0 — Repo scaffolding (this commit). +- Phase 1 — Server bootstrap (Docker, daemon, OS deps, `km` user). +- Phase 2 — Agent identity + capability grant. +- Phase 3 — `seed-cli` MCP wrapper. +- Phase 4 — nanobot install + governance bootstrap. +- Phase 5 — Mention polling + reaction. +- Phase 6 — Scheduled LAFH cadences. +- Phase 7 — Telegram secondary channel. +- Phase 8 — Audit-log polish + verification suite. + +## Layout + +``` +agent/ +├── config/ # nanobot.json template (Phase 4) +├── seed-daemon/ # docker compose for local daemon (Phase 1) +├── mcp/seed-cli-mcp/ # custom stdio MCP wrapping seed-cli (Phase 3) +├── systemd/ # user-mode unit files (all phases) +├── scripts/ # install.sh, km-log helper (Phases 1, 5) +├── templates/ # bootstrap seeds for Seed governance docs (Phase 4) +└── logrotate/ # km-logs.conf user logrotate rule (Phase 5) +``` + +## Governance docs (created by agent on first run) + +| Path under target site | Purpose | +| --- | --- | +| `/agents/knowledge-manager/charter` | Community purpose, voice, scope. | +| `/agents/knowledge-manager/rules` | Hard policy: deny paths, caps, draft-only kill-switch. | +| `/agents/knowledge-manager/runbook` | Soft instructions: tone, escalation, formatting. | +| `/agents/knowledge-manager/allowlist` | Optional invoker list (defaults to WRITER capability set). | + +## Kill-switch + +Edit `/agents/knowledge-manager/rules` in the Seed app, set `draft_only: true`. Effective within ≤60s (rules cache TTL). To force immediate refresh: `systemctl --user restart nanobot-gateway` on `oc.hyper.media`. + +## Logs (browse from SSH) + +``` +/home/km/km-logs/ +├── current -> runs/ +├── runs/____/ +│ ├── meta.json # trigger, KM_AID, env hash, wall_ms +│ ├── trace.jsonl # ordered events with timestamps +│ ├── llm.jsonl # prompts, completions, DeepSeek reasoning, tokens +│ ├── tools.jsonl # MCP tool calls + latency +│ ├── seed-cli.jsonl # argv + stdout + stderr + exit + ms +│ ├── stdout.log +│ └── stderr.log +└── index.jsonl # one summary line per run +``` + +Helper installed at `~km/.local/bin/km-log`: + +```bash +km-log tail # follow newest run +km-log show # full pretty-printed run +km-log grep # rg across trace logs +km-log mention # find run that processed a given mention +``` + +Retention: logrotate 30d / 5GB, compressed after 1d. Secrets redacted from all streams. + +## Environment variables (`/home/km/.nanobot/secrets.env`, mode 600) + +``` +DEEPSEEK_API_KEY=... +SEED_SERVER=http://127.0.0.1:55001 +SEED_SITE=hm://... +TELEGRAM_TOKEN=... # Phase 7 +OPS_TELEGRAM_ID=... # Phase 7, numeric Telegram user ID +``` + +## TODO (filled in by later phases) + +- [ ] Phase 1: Docker / OS dep install steps + verification. +- [ ] Phase 2: key generation + capability grant exact commands. +- [ ] Phase 3: MCP wrapper build + test commands. +- [ ] Phase 4: nanobot install + bootstrap step-by-step. +- [ ] Phase 5: poll cadence + smoke test transcript. +- [ ] Phase 6: cadence schedules + manual triggers. +- [ ] Phase 7: Telegram bot setup + ops verbs. +- [ ] Phase 8: full verification checklist results. diff --git a/seed-knowledge-manager/agent/config/.gitkeep b/seed-knowledge-manager/agent/config/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/seed-knowledge-manager/agent/logrotate/.gitkeep b/seed-knowledge-manager/agent/logrotate/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitkeep b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/seed-knowledge-manager/agent/scripts/.gitkeep b/seed-knowledge-manager/agent/scripts/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/seed-knowledge-manager/agent/seed-daemon/.gitkeep b/seed-knowledge-manager/agent/seed-daemon/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/seed-knowledge-manager/agent/systemd/.gitkeep b/seed-knowledge-manager/agent/systemd/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/seed-knowledge-manager/agent/templates/agent-allowlist.md b/seed-knowledge-manager/agent/templates/agent-allowlist.md new file mode 100644 index 0000000000..d9f67a9909 --- /dev/null +++ b/seed-knowledge-manager/agent/templates/agent-allowlist.md @@ -0,0 +1,21 @@ +--- +type: agent-allowlist +schema_version: 1 +title: Knowledge Manager — Allowlist +created_by: knowledge-manager +--- + +# Allowlist + +Used only when `mentions.invoker_source` in `rules` is set to `allowlist-doc`. Otherwise the agent uses the WRITER capability set as its invoker list. + +```yaml +# ----- allowlist begin ----- +invokers: [] # accountIds (z6Mk...) allowed to mention the agent +# ----- allowlist end ----- +``` + +## Notes + +- v1 leaves this empty and uses `writer-capabilities` instead. +- v2 (deferred) introduces a "members" tier with a more restrictive ruleset; that mode reads this list. diff --git a/seed-knowledge-manager/agent/templates/agent-charter.md b/seed-knowledge-manager/agent/templates/agent-charter.md new file mode 100644 index 0000000000..522b290c84 --- /dev/null +++ b/seed-knowledge-manager/agent/templates/agent-charter.md @@ -0,0 +1,39 @@ +--- +type: agent-charter +schema_version: 1 +title: Knowledge Manager — Charter +created_by: knowledge-manager +--- + +# Charter + +This document defines **what** the Knowledge Manager exists to do for this community, **how** it should sound, and **what is out of scope**. The agent re-reads this document on every run; edit it freely. + +## Purpose + +> One paragraph: what new collective knowledge this community is producing, and how the Knowledge Manager helps that production. + +(Replace this placeholder. Example: "We are a network of independent researchers documenting open patterns in self-hosted publishing. The Knowledge Manager keeps our synthesis documents, expertise map, and gap reports current, and surfaces prior discussions when topics recur.") + +## Voice + +- Tone: concise, referenced, non-promotional. +- Default language: `en`. +- Honest about uncertainty; flag inferences explicitly. + +## Scope (what the agent SHOULD do) + +- Read-and-answer with historical context (capability 1 of `SKILL.md`). +- Synthesis documents (capability 2). +- Periodic bulletins (capability 3). +- Gap detection (capability 4). +- Onboarding capsules (capability 5). +- Cross-reference detection (capability 6). +- Network health reports (capability 7). + +## Off-topics (what the agent must NOT do) + +- Customer support. +- Marketing or promotional content. +- Spam removal / banning (human moderator's role). +- Auto-publishing without rules-doc allowance. diff --git a/seed-knowledge-manager/agent/templates/agent-rules.md b/seed-knowledge-manager/agent/templates/agent-rules.md new file mode 100644 index 0000000000..13c05f07b5 --- /dev/null +++ b/seed-knowledge-manager/agent/templates/agent-rules.md @@ -0,0 +1,41 @@ +--- +type: agent-rules +schema_version: 1 +title: Knowledge Manager — Rules +created_by: knowledge-manager +--- + +# Rules + +Hard policy enforced by the agent's MCP wrapper on every tool call. The first fenced YAML block below is the **machine-readable rules**; the prose after it is human context. + +```yaml +# ----- machine-readable rules begin ----- +allow_write_paths: + - / +deny_write_paths: + - /agents/knowledge-manager/charter + - /agents/knowledge-manager/rules + - /agents/knowledge-manager/runbook + - /agents/knowledge-manager/allowlist +caps: + max_documents_per_run: 1 + max_comments_per_run: 5 + max_comments_per_day: 30 + poll_interval_seconds: 60 +mentions: + trigger: "@knowledge-manager" + invoker_source: "writer-capabilities" # or "allowlist-doc" +moderation: + blocked_authors: [] +draft_only: false +language: en +# ----- machine-readable rules end ----- +``` + +## Notes + +- `allow_write_paths: ["/"]` lets the agent edit any document on the site. The wrapper still refuses anything in `deny_write_paths` and refuses non-document mutations (no `key`, no `capability`, no `account`, no deletion of governance docs). +- `draft_only: true` is the **kill-switch**. When true, the agent never writes a document; it only posts comments. Effective within ≤60s. +- `invoker_source: writer-capabilities` makes the agent only respond to mentions from accounts that hold a WRITER capability on the site (plus the site account itself). Switch to `allowlist-doc` to use the explicit list in `allowlist`. +- `caps` are per the local server clock (UTC). `max_comments_per_day` resets at 00:00 UTC. diff --git a/seed-knowledge-manager/agent/templates/agent-runbook.md b/seed-knowledge-manager/agent/templates/agent-runbook.md new file mode 100644 index 0000000000..55d4ffe0c1 --- /dev/null +++ b/seed-knowledge-manager/agent/templates/agent-runbook.md @@ -0,0 +1,28 @@ +--- +type: agent-runbook +schema_version: 1 +title: Knowledge Manager — Runbook +created_by: knowledge-manager +--- + +# Runbook + +Soft instructions the agent uses on top of the methodology in `SKILL.md`. Edit freely; the agent re-reads this on every run. + +## Style + +- Lead with the answer. Cite sources with full `hm://account/path#blockId` links. +- Distinguish **agreement**, **disagreement**, and **open questions** in syntheses. +- For inline replies in comments: prose, no headers, ≤200 words. Link out to a synthesis document for anything longer. + +## When to escalate to a human + +- Conflicting governance docs. +- Repeated rules violations attempted by a writer. +- Detected contradiction without enough corpus to resolve. + +In those cases: post a comment summarizing the situation, tagging the site owner. Do not write a document. + +## Formatting overrides + +(Optional. Add specifics here if you want to override defaults from `SKILL.md` templates.) diff --git a/seed-knowledge-manager/references/lafh-framework.md b/seed-knowledge-manager/references/lafh-framework.md new file mode 100644 index 0000000000..5762432208 --- /dev/null +++ b/seed-knowledge-manager/references/lafh-framework.md @@ -0,0 +1,237 @@ +# The LAFH Framework — full reference + +This document is the theoretical and methodological grounding for the `seed-knowledge-manager` skill. It is based on the work of Luis Ángel Fernández Hermana (LAFH), founder of en.red.ando (1996), Enredando.com (1998), and the Laboratorio de Redes Sociales de Innovación (lab_RSI). His three-volume *Historia Viva de Internet* (Editorial UOC) is the canonical record of his editorial output 1996–2004. + +Read this when: +- You're designing a new capability for the skill and want to verify it fits the methodology. +- You need to justify why the skill behaves a certain way to a user or collaborator. +- You're producing an output and want to make sure you're not falling into a generic "knowledge-base assistant" pattern. + +--- + +## 1. The fundamental distinction: GC vs GC-Red + +This is the cornerstone. Skip it and the rest doesn't make sense. + +**Gestión del Conocimiento (GC)** — the corporate tradition. Comes from Business Administration, US, 1960s. Exists independently of the internet. Assumes an organization with a clear org chart, employees with delimited functions, strategic objectives that drive everything. Multi-billion dollar consulting market, dominated by big firms. LAFH is openly skeptical of this tradition — calls it "embrollado" by consultants and confused by its own success in metrics that don't measure knowledge. + +**Gestión de Conocimiento en Red (GC-Red)** — LAFH's actual discipline. Comes from open virtual networks like the internet. **There is no pre-existing organization** — the users create it through their interests and the objectives they set. Members may not even know each other. Out of GC-Red come forums, mailing lists, virtual communities, social networks, and specifically the RSVCs (knowledge-producing networks). + +LAFH is precise on language: he does not do "gestión", "gestión de conocimiento", "gestión del conocimiento", or "gestión del conocimiento en red". He does **gestión de conocimiento en red** (without the article "del") — and the difference is methodological, not stylistic. + +**Why this matters for Seed:** A Seed community is GC-Red, not GC. People associate by shared interest, not assignment. The skill must respect this — it cannot behave like a corporate KM tool that assumes hierarchy. + +--- + +## 2. RSVC — Red Social Virtual de Conocimiento + +The unit of analysis. Defined by lab_RSI as: + +> "An online meeting space designed and managed to achieve concrete objectives through collaborative network work. Its dynamic is oriented to the recovery and re-elaboration of the exchanges produced among its members in order to obtain knowledge products." + +Properties of an RSVC: + +1. **Has concrete objectives.** Not a chat venue; produces knowledge applied to a project. +2. **Builds new knowledge.** The aim is not to disseminate what exists but to generate what does not yet exist. +3. **Two foundational pillars:** + - A virtual structure designed according to a system of generation and management of information and knowledge in network. + - A management team prepared to apply the GC-Red methodology. +4. **Does not replace** the org chart of an organization (when there is one) — it **superimposes** on it, surfacing "a knowledge map based on different forms of work." + +### What an RSVC actually delivers + +- Project preparation and execution +- New methodologies +- Materials for business lines +- Pedagogical content (formal or informal) +- Cross-cutting collaborative work +- Re-organization of productive territories +- Teams prepared for collaborative-network projects +- **Information for decision-making** +- **Systematized synthesis of network activity** — itself a transferable knowledge product + +### The HipotecaGratis / Creditaria case (the canonical proof) + +In 2004, Enredando.com converted a 30-asesor mortgage company into an RSVC. Each worker's screen was split — left half: client database; right half: knowledge network. They shared in real time what was working with clients, what wasn't, expert opinions. + +Results in 9 months: +- Per-worker income up **34–43%**. +- They detected the housing-bubble warning signs **before the market did**, moved operations to Mexico (Creditaria), grew to 85 connected offices. +- When someone left, **the synthetic documents capturing their way of working remained**. New hires consulted them as if the person were still there. The knowledge survived the turnover. + +Lesson for the skill: the value is not in chat summaries, but in **synthetic documents that capture forms of working**. + +--- + +## 3. The four roles + +LAFH does not see knowledge management as a single-person job. The mature lab_RSI model has four roles, which can be fused in small networks but should be conceptually distinct because they address different problems. + +### 3.1 Gestor de la Red (Network Manager) + +The "platform engineer" of the role set. Designs and organizes information flows. Works from a global view of the network's objectives. Intervenes in: +- The "operations center" (perfile administration, network structure) +- Adequacy of moderation rules +- Optimization of synthesis processes +- Steps to evolve the network + +### 3.2 Moderador / Dinamizador + +The day-to-day operator. Functions: + +- **Applies the methodology** — this is the primary, most important function. +- Guarantees stability of exchanges among members. +- Approves/rejects messages, eliminates spam. +- Permanent contact with participants — orients them to elevate their capacity to generate information. +- **The only member with the full real-time view of information flow** — uses this to regulate pace and prevent **choque infosomático**. +- Establishes rules of collective behavior (respect, documentation, referenced content). +- **Works in the synthesis zone** — produces periodic bulletins and knowledge documents (thematic or personal). +- **Promotes cross-relations** between thematic lines emerging in debates or contributed documents. +- Stitches debates together via recapitulations and summaries to orient and re-launch discussion. +- Works at short, medium, and long horizons relative to objectives. + +### 3.3 Gestor de Conocimiento (Knowledge Manager) + +Closest match to what the agent does: + +- Creates and develops the **context** for members to produce significant information and knowledge. +- Obtains and processes documents and reports. +- Conducts interviews, requests expert opinions, summarizes events. +- Investigates **inside the internet AND in the physical world** — does not stay enclosed in the network. +- Establishes relations with other networks — opens possibility of alliances. +- Acts from the perspective of the whole project, not just what happens in the network. + +### 3.4 Responsable de Contenidos + +- Produces new content publishable in the RSVC. +- Responds to specific demands from the moderator or knowledge managers. +- Collaborates with the network's communication arm. + +### Role fusion in small networks + +Lab_RSI explicitly recognizes that in initial-phase or small networks, the moderator and knowledge-manager roles fuse, producing a hybrid called **"Moderador de redes"**. This is the role the agent emulates for a Seed community. + +--- + +## 4. The zones + +An RSVC has functionally distinct zones. Each requires different action. + +### 4.1 Zone of contributions (zona de aportaciones) + +Where members publish messages, documents, debates. Members work here directly. The moderator works here too — not by adding messages but by orienting form and quality. **Quality depends on documentation and references** — these are, in LAFH's words, "the basic foundations of the credibility and reliability of the information contributed, and of who contributes it." + +### 4.2 Zone of synthesis (zona de síntesis) + +Where contributions are transformed into knowledge products: bulletins, thematic documents, personal documents (capturing how a member works). This is the moderator's primary production zone. **Without it, the network generates noise but accumulates no wisdom.** + +### 4.3 Operations center (centro de operaciones) + +Where the network manager administers profiles, structure, moderation rules, metrics, and evolution. + +### Mapping to Seed + +- Zone of contributions → published documents, comments, blocks +- Zone of synthesis → new documents created with `type: synthesis | boletin | gap-report | onboarding` +- Operations center → the `type: network-health` document, regenerated periodically + +--- + +## 5. The choque infosomático + +A specific concept worth preserving. Information overload that paralyzes participants. The moderator is uniquely positioned to prevent it because they alone see the full flow. Mechanisms: + +- Regulate the pace of synthesis output (not too much, not too little) +- Cap items per section in bulletins +- Prioritize ruthlessly +- Avoid drowning the community in auto-generated content + +The skill enforces this via the pacing rule: at most one major synthesis output per session unless explicitly requested. + +--- + +## 6. The anti-patterns (what NOT to do) + +LAFH's negative principles, distilled from his editorials and lab_RSI material: + +1. **Do not confuse activity with knowledge production.** A loud network can produce nothing of lasting value. Success = synthesis products, not message volume. + +2. **Do not trust that knowledge "preserves itself" online.** Per LAFH, ~80% of internet-generated knowledge has vanished. Without active synthesis, it all goes. + +3. **Do not impose corporate org-chart logic.** RSVCs run on voluntary bonds among members with shared interests. Treating them as employees breaks the dynamic. + +4. **Do not saturate.** Choque infosomático kills networks. + +5. **Do not accept un-referenced contributions as authoritative.** The credibility of the network depends on documentary rigor. + +6. **Do not treat the moderator as a spam filter.** The moderator is the engine of knowledge generation, not a relevance valve. + +7. **Do not skip synthesis.** Contributions without synthesis = accumulating noise. + +8. **Do not let the community reinvent the wheel.** If a topic was discussed before, surface it before redoing the work. + +--- + +## 7. Operational task taxonomy + +Synthesizing dispersed fragments of LAFH and lab_RSI material, here is the working taxonomy of tasks the manager performs. The skill maps to these. + +**A. Capture & classify** — record everything, tag by topic/author/date/relevance, maintain explicit references. + +**B. Connect & synthesize** — detect cross-thematic relations, produce thematic and personal synthesis documents, recap debates, publish periodic bulletins, produce drafts with transfer value to other projects. + +**C. Curate & filter** — separate signal from noise, identify outdated content and contradictions with newer versions, keep knowledge alive. + +**D. Institutional memory** — preserve knowledge when members leave, answer with full historical context, prevent reinvention. + +**E. Onboarding & expertise mapping** — orient newcomers, answer "what does this community know about X", redirect questions to the right expert. + +**F. Gap detection** — identify what is unknown, surface unanswered questions, propose new documents where structured knowledge is missing. + +**G. Discourse facilitation** — moderate and enrich conversations, capture key agreements/disagreements, foster silent members' participation, regulate pace. + +**H. Network health** — track activity, contributions, ignored items; detect silos; verify adequacy of moderation rules; report on the state of the knowledge base. + +**I. External research & alliances** — investigate beyond the network, request expert opinions, build relationships with other networks. + +--- + +## 8. Vocabulary (preserve these terms) + +When producing outputs, prefer LAFH's terminology over generic equivalents. This preserves conceptual coherence and signals the methodology. + +| LAFH term | Generic equivalent | Use the LAFH term because... | +|---|---|---| +| GC-Red | Knowledge management | Distinguishes from corporate KM | +| RSVC | Online community | Implies methodology, not just a forum | +| Documento de síntesis | Summary | A real product, not chat output | +| Boletín periódico | Digest | Implies a structured rhythm | +| Zona de aportaciones | Forum / feed | Has a functional pair (síntesis) | +| Zona de síntesis | (no equivalent) | The whole point of the methodology | +| Choque infosomático | Information overload | Names a specific failure mode | +| Producto de conocimiento | Output | Implies usability and transfer | +| Mapa de conocimiento | (no equivalent) | Emergent structure, not org chart | +| Documentación referenciada | Sources | Foundation of credibility | + +--- + +## 9. Sources + +### Primary +- LAFH, **En.red.ando** (Ediciones B, 1998) — first 100 editorials. +- LAFH, **Historia Viva de Internet** (3 volumes, Editorial UOC, 2011+) — the full editorial corpus 1996–2004 plus interviews. +- Editorials at coladepez.com (LAFH's own archive). + +### Operational (lab_RSI) +- lab-rsi.com / equipo-de-gestion-de-red-de-conocimiento — definitive role definitions +- lab-rsi.com / redes-de-conocimiento-2 — RSVC definition +- lab-rsi.com / hipotecagratis-com — HipotecaGratis case study +- lab-rsi.com / locomotora — Mataró case (urban regeneration via knowledge network) + +### Conceptual deep-dive +- coladepez.com / knowledge-network/gestion-de-conocimiento-en-red-gc-r — the foundational piece on what GC-Red is +- coladepez.com / educationxxi/gestion-del-conocimiento-y-gestion-de-conocimiento-en-red — the GC vs GC-Red distinction +- "Proyecto de la Red Fractal" (LAFH, in *Desafío de las ciencias sociales en tiempos de transformación*, Universidad Pontificia Bolivariana) — last major methodological project (2012) + +### Academic dialogue +- Gairín & Rodríguez, *La gestión del conocimiento en red* (UAB, 2005) — Proyecto Accelera; competence model for knowledge managers in virtual environments. A formalization that complements LAFH's more practical/journalistic framework. diff --git a/seed-knowledge-manager/templates/boletin-periodico.md b/seed-knowledge-manager/templates/boletin-periodico.md new file mode 100644 index 0000000000..b81b0d5351 --- /dev/null +++ b/seed-knowledge-manager/templates/boletin-periodico.md @@ -0,0 +1,61 @@ +--- +title: Boletín — +type: boletin +period: +covers: full network activity for the period +sources: +created_by: knowledge-manager +created_at: +--- + +# Boletín — + +> A periodic snapshot of what happened in the community during . Designed to be scannable in two minutes by someone who was away. Items are prioritized; this is not exhaustive. + +## New documents published + +Cap at ~5–7. If more, prioritize by importance — not by recency. + +- **[](hm://account/path)** by . +- **[](hm://account/path)** by . + +## Active threads + +What's currently in motion. Mark each with status: 🟢 progressing / 🟡 stalled / 🔴 blocked / ✅ resolved this period. + +- 🟢 **[](hm://account/path)** — . +- 🟡 **[](hm://account/path)** — . +- ✅ **[](hm://account/path)** — resolved with [synthesis doc](hm://...) or decision. + +## Decisions made + +Decisions captured during the period. If there were no decisions, say "no formal decisions captured this period" — that itself is a signal. + +- **** — see [](hm://account/path). Effective: . + +## New members + +Welcome people. Note what they've contributed so far so others can connect. + +- **** — has contributed [](hm://...) and [comments on Y](hm://...). +- **** — joined; not yet active. + +## Gaps surfaced or filled + +- Filled: — addressed by [](hm://...). +- Newly surfaced: — see [](hm://...). Recommended action: . + +## Recommended reading from this period + +If a member is choosing one or two things to read this month, what should they read? + +- [](hm://...) — for: . +- [](hm://...) — for: . + +## Health note + +One sentence on the overall pace of the network. Is it producing knowledge products at a healthy rate? Are there silos forming? Is anyone on the verge of choque infosomático? If everything is fine, say so. + +--- + +*Generated by the community knowledge manager. Reply with corrections or additions before this becomes the canonical record.* diff --git a/seed-knowledge-manager/templates/gap-report.md b/seed-knowledge-manager/templates/gap-report.md new file mode 100644 index 0000000000..5f3e1d246b --- /dev/null +++ b/seed-knowledge-manager/templates/gap-report.md @@ -0,0 +1,67 @@ +--- +title: Gap report — +type: gap-report +period: +covers: +sources: +created_by: knowledge-manager +created_at: +--- + +# Gap report — + +> What this community does not yet know, has not yet decided, or has fragmented opinions about. Each gap below has evidence and a proposed action. The goal is to make the unknown visible so the community can choose what to address next. + +## How this was produced + +Briefly: which corpus was scanned (whole network, last 3 months, specific topic area), what counted as a "gap" (unanswered question, repeated topic without consolidating doc, contradictions without resolution). + +## Open gaps + +For each gap: **evidence** (what makes this a gap) + **proposed action** (research, discuss, decide, document, deprecate). + +### 🔴 High priority + +Gaps that are blocking active work or that recur frequently. + +#### Gap 1: + +- **Evidence:** Discussed in [](hm://...#blockId), [](hm://...) without resolution. Mentioned in passing in [](hm://...). +- **Why it matters:** +- **Proposed action:** for input", "research X and produce synthesis"> +- **Suggested owner:** + +#### Gap 2: ... + +### 🟡 Medium priority + +Worth addressing but not blocking. + +#### Gap N: ... + +### 🟢 Low priority / parking lot + +Open questions worth keeping visible but not actionable now. + +- — surfaced in [](hm://...). +- — surfaced in [](hm://...). + +## Contradictions detected + +Where the corpus contains internally inconsistent claims. + +- **:** [](hm://...) says X. [](hm://...) says Y. Newer doc supersedes? Or both still active and need reconciliation? + +## Stale or potentially outdated content + +Documents that haven't been touched in a long time and may need review or deprecation. + +- [](hm://...) — last touched . Topic still relevant? Recommend: . + +## Patterns + +If you noticed any meta-patterns — e.g. "members keep asking about X but no one is documenting", or "discussions on Y always lose energy after 3 messages" — note them here. These are leading indicators of where the methodology may need adjustment. + +--- + +*This is a diagnostic, not an indictment. Gaps are healthy — they show where the community has not yet finished thinking. The point is to make them visible.* diff --git a/seed-knowledge-manager/templates/network-health.md b/seed-knowledge-manager/templates/network-health.md new file mode 100644 index 0000000000..3fcacb7d47 --- /dev/null +++ b/seed-knowledge-manager/templates/network-health.md @@ -0,0 +1,86 @@ +--- +title: Network health — +type: network-health +period: +covers: full network +sources: +created_by: knowledge-manager +created_at: +--- + +# Network health — + +> A diagnostic of the community's state as a knowledge-producing network, in the LAFH sense. The goal is real signal — not flattery and not alarmism. Where things are good, say so; where they're worrying, say so. + +## TL;DR + +One paragraph summary: overall verdict on the period. Productive? Drifting? Overloaded? Stagnant? + +## Activity metrics + +Quantitative basics. Compare to prior period if available. + +- **New documents:** (vs. last period) +- **Comments / threaded replies:** +- **Active members** (contributed at least once): of +- **Distinct authors of new docs:** + +## Production of knowledge products + +This is the LAFH-critical question: did the network actually generate any **knowledge products** (syntheses, decisions, methods, captured forms-of-working) in the period? + +- **Synthesis documents:** — list with links. +- **Decisions captured:** — list with links. +- **Methods / methodologies documented:** . +- **Personal-knowledge documents** (capturing how a member works, à la HipotecaGratis): . + +**Verdict:** . If absent, this is a red flag — activity without production = noise. + +## Silos + +Are there sub-groups whose documents do not reference each other? List them. A healthy network has cross-references; silos suggest the community is fragmenting. + +- **Silo 1:** — members . No cross-reference to . +- **Silo 2:** . + +If no silos detected, say so. + +## Stale corpus + +Documents not touched in 6+ months that may be outdated or that contradict newer material. Recommend: review / deprecate / link forward. + +- [](hm://...) — last touched . . + +## Pace assessment + +Is the network experiencing **choque infosomático** (too much, too fast, no synthesis to absorb it)? Or is it stagnant? + +- **Symptoms of overload:** +- **Symptoms of stagnation:** +- **Verdict:** + +## Memory check + +Are recent decisions backed by referenced documents in the corpus? Or are they floating in chat / comments without consolidation? + +- **Decisions properly memorialized:** . +- **Decisions made but not yet documented:** . + +## Methodology adherence + +Are members following the conventions that make the corpus searchable and trustworthy? + +- **Documents with `type:` frontmatter:** <%>. +- **Cross-document links used appropriately:** . +- **Referenced contributions vs. unreferenced assertions:** . + +## Recommended actions + +Concrete suggestions for the community to act on based on what this report surfaces. Max 5. + +1. — addressing . +2. . + +--- + +*Diagnostic produced by the community knowledge manager. Reply with corrections or alternative interpretations — multiple readings of the same data is healthier than one canonical reading.* diff --git a/seed-knowledge-manager/templates/onboarding-capsule.md b/seed-knowledge-manager/templates/onboarding-capsule.md new file mode 100644 index 0000000000..1d95a7c659 --- /dev/null +++ b/seed-knowledge-manager/templates/onboarding-capsule.md @@ -0,0 +1,49 @@ +--- +title: Onboarding — +type: onboarding +period: null +covers: +sources: +created_by: knowledge-manager +created_at: +--- + +# Onboarding — + +> A short orientation for someone new to in this community. Designed to be readable in 5 minutes. If you want to go deeper, the references at the end will take you there. + +## What this community has decided / believes about + +In one or two paragraphs: the current state of the community's thinking. Use the LAFH principle of historical context — say briefly how the position evolved if it's not always been this. Cite the canonical synthesis or decision documents. + +> See: [](hm://account/path), [](hm://account/path). + +## What's still open about + +Areas of disagreement, unresolved questions, ongoing debates. Be honest — don't paint consensus where there isn't any. + +- — see [](hm://...). +- — partial position in [](hm://...) but not consolidated. + +## People to talk to about + +The members most active and knowledgeable on this topic, based on recent contributions. Don't list more than 3–4 — too many breaks the welcome effect. + +- **** — author of [](hm://...). Active on related threads. +- **** — contributed [](hm://...) and frequent commentary. + +## Documents to read, in order + +A short reading path. 3–5 documents max. Order matters. + +1. [](hm://...) — what this builds on. +2. [](hm://...) — current state of thinking. +3. [](hm://...) — where the conversation is now. + +## How to contribute + +A line or two on what kinds of contribution are welcome — questions, position papers, references to external work, syntheses of subtopics, etc. Match what the community actually does. + +--- + +*Welcome. Reply to this with questions or a brief introduction of your own background — that lets the community know what you bring.* diff --git a/seed-knowledge-manager/templates/synthesis-document.md b/seed-knowledge-manager/templates/synthesis-document.md new file mode 100644 index 0000000000..250425dad5 --- /dev/null +++ b/seed-knowledge-manager/templates/synthesis-document.md @@ -0,0 +1,63 @@ +--- +title: +type: synthesis +period: +covers: +sources: + - hm://account/path1 + - hm://account/path2 +created_by: knowledge-manager +created_at: +--- + +# + +## Purpose + +> One paragraph: what question or thread does this consolidate, and why does it matter that someone in the future find this when they ask about X? + +This sentence is the most important in the whole document. If you cannot state it crisply, you do not yet have a synthesis worth writing. + +## Context + +Brief background — where did this discussion come from, what triggered it. Link to the original thread(s) or document(s) using `[title](hm://...)` syntax. + +## Areas of agreement + +What did the community converge on? List the points with **referenced** evidence — every claim links to the block where it was articulated. + +- **<Point of agreement>** — articulated in [<source title>](hm://account/path#blockId). Re-affirmed in [<other source>](hm://account/path#blockId). +- **<Point of agreement>** — see [<source>](hm://account/path#blockId). + +## Areas of disagreement + +Be honest. Don't paper over divergence. Where the community disagrees, name the positions and who holds them, and link to evidence. + +- **Position A:** <description>. Held by [member or document](hm://...). +- **Position B:** <description>. Held by [member or document](hm://...). +- **Status:** unresolved / partially resolved / superseded by later decision in [<doc>](hm://...). + +## Open questions + +What did the discussion surface that it did not answer? + +- <Open question 1> — possible direction: <suggestion>. +- <Open question 2>. + +## What this implies + +If the synthesis suggests action — a decision to make, a document to write, a person to consult — say so. Be concrete. + +- **Recommended next step:** <e.g. "create a decision document on X", "interview Y about Z", "deprecate the older synthesis at hm://..."> +- **Owner suggested:** <if known, otherwise "open"> + +## References + +Full list of sources cited above, plus anything relevant the reader might want to follow up: + +- [<Doc title>](hm://account/path) — <one-line description> +- [<Doc title>](hm://account/path) — <one-line description> + +--- + +*This synthesis document is part of the knowledge memory of the community. If it becomes outdated or contradicted, please link a follow-up document and add a note here referencing it. Do not silently delete or rewrite — the trail of revisions is itself knowledge.* From dbc40ae68da0ed73335d4046b19ef121c5ffaec5 Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Tue, 5 May 2026 22:44:51 +0200 Subject: [PATCH 02/17] feat(agent): add Phase 1 server bootstrap with seed-daemon Docker compose and systemd unit --- seed-knowledge-manager/agent/README.md | 12 +++ .../agent/scripts/install-phase1.sh | 92 +++++++++++++++++++ .../agent/seed-daemon/compose.yaml | 30 ++++++ .../agent/systemd/seed-daemon.service | 19 ++++ 4 files changed, 153 insertions(+) create mode 100755 seed-knowledge-manager/agent/scripts/install-phase1.sh create mode 100644 seed-knowledge-manager/agent/seed-daemon/compose.yaml create mode 100644 seed-knowledge-manager/agent/systemd/seed-daemon.service diff --git a/seed-knowledge-manager/agent/README.md b/seed-knowledge-manager/agent/README.md index 416d323abd..8200397a5d 100644 --- a/seed-knowledge-manager/agent/README.md +++ b/seed-knowledge-manager/agent/README.md @@ -87,6 +87,18 @@ TELEGRAM_TOKEN=... # Phase 7 OPS_TELEGRAM_ID=... # Phase 7, numeric Telegram user ID ``` +## Phase 1 — done + +Server `oc.hyper.media` (Ubuntu 24.04, 6.8 kernel, 3.7 GiB RAM, Docker 29): + +- Apt deps installed: `python3.12 python3.12-venv pipx libsecret-1-0 libsecret-tools dbus-user-session bubblewrap jq curl rsync logrotate`. `nodejs` swapped from Ubuntu's `node-18` to NodeSource Node 22 (Ubuntu's npm 9 cannot install workspace deps). +- System user `km` created with `loginctl enable-linger km`, added to `docker` group. +- Docker compose stack at `/home/km/seed-daemon/compose.yaml` runs `seedhypermedia/site:latest` as a pure peer (`-data-dir=/data -keystore-dir=/data/keys -http.port=55001 -grpc.port=55002 -p2p.port=55000 -syncing.smart=true`). HTTP/gRPC bound to loopback, P2P public. +- User-systemd unit `seed-daemon.service` enabled, runs `docker compose up -d`. Survives logout (linger) and host reboot (Docker `restart: unless-stopped`). +- Verification: `curl http://127.0.0.1:55001/debug/version` returns build info. + +**Known issue (defers to Phase 2)**: published `@seed-hypermedia/cli@0.1.4` on npm has an unresolved `workspace:*` dep; `npx -y @seed-hypermedia/cli` fails. Phase 2 builds the CLI from this repo's `frontend/apps/cli/` instead. + ## TODO (filled in by later phases) - [ ] Phase 1: Docker / OS dep install steps + verification. diff --git a/seed-knowledge-manager/agent/scripts/install-phase1.sh b/seed-knowledge-manager/agent/scripts/install-phase1.sh new file mode 100755 index 0000000000..9afa9c98ed --- /dev/null +++ b/seed-knowledge-manager/agent/scripts/install-phase1.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Phase 1 — Server bootstrap on oc.hyper.media. +# Idempotent. Safe to re-run. +# +# Usage: +# bash seed-knowledge-manager/agent/scripts/install-phase1.sh ubuntu@oc.hyper.media +# +# What it does: +# - Installs OS dependencies (libsecret tools, bubblewrap, node, pipx, jq, rsync). +# - Creates system user `km` with linger enabled and added to the `docker` group. +# - Drops the seed-daemon docker compose file under /home/km/seed-daemon/. +# - Installs systemd --user unit for the seed-daemon container. +# - Starts the daemon and waits for /debug/version on 127.0.0.1:55001. + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 <ssh-target>" >&2 + exit 2 +fi + +TARGET="$1" +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +AGENT_DIR="$REPO_ROOT/seed-knowledge-manager/agent" + +echo "==> Phase 1 bootstrap on $TARGET" + +# ---- Step 1: apt packages ------------------------------------------------- +ssh "$TARGET" 'sudo bash -s' <<'REMOTE_APT' +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq \ + python3.12 python3.12-venv pipx \ + libsecret-1-0 libsecret-tools dbus-user-session bubblewrap \ + nodejs npm jq curl rsync logrotate +REMOTE_APT + +# ---- Step 2: km user, linger, docker group ------------------------------- +ssh "$TARGET" 'sudo bash -s' <<'REMOTE_USER' +set -euo pipefail +if ! getent passwd km >/dev/null; then + useradd --create-home --shell /bin/bash km + echo "[+] created user km" +fi +usermod -aG docker km || true +loginctl enable-linger km +install -d -m 700 -o km -g km /home/km/seed-daemon /home/km/seed-daemon/data +install -d -m 700 -o km -g km /home/km/.config/systemd/user +install -d -m 700 -o km -g km /home/km/.local/bin +REMOTE_USER + +# ---- Step 3: drop compose file + systemd unit ---------------------------- +TMP=$(ssh "$TARGET" 'mktemp -d') +trap 'ssh "$TARGET" "rm -rf $TMP"' EXIT +rsync -avz "$AGENT_DIR/seed-daemon/compose.yaml" "$TARGET:$TMP/compose.yaml" +rsync -avz "$AGENT_DIR/systemd/seed-daemon.service" "$TARGET:$TMP/seed-daemon.service" + +ssh "$TARGET" "sudo install -m 644 -o km -g km '$TMP/compose.yaml' /home/km/seed-daemon/compose.yaml" +ssh "$TARGET" "sudo install -m 644 -o km -g km '$TMP/seed-daemon.service' /home/km/.config/systemd/user/seed-daemon.service" + +# ---- Step 4: enable + start daemon --------------------------------------- +ssh "$TARGET" 'sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) bash -s' <<'REMOTE_DAEMON' +set -euo pipefail +export XDG_RUNTIME_DIR="/run/user/$(id -u)" +systemctl --user daemon-reload +systemctl --user enable seed-daemon.service +systemctl --user start seed-daemon.service +REMOTE_DAEMON + +# NOTE on seed-cli: +# The published `@seed-hypermedia/cli@0.1.4` on npm currently has an +# unresolved `workspace:*` dep (`@seed-hypermedia/client`) which makes +# `npx -y @seed-hypermedia/cli` fail with `Unsupported URL Type "workspace:"`. +# We deliberately do NOT install seed-cli in Phase 1. Phase 2 builds it from +# this repo's `frontend/apps/cli/` workspace (pnpm + bun) on the server. + +# ---- Step 5: wait for HTTP API ------------------------------------------- +echo "==> waiting for daemon HTTP API on 127.0.0.1:55001" +for i in $(seq 1 30); do + if ssh "$TARGET" 'curl -fsS http://127.0.0.1:55001/debug/version >/dev/null 2>&1'; then + echo "==> daemon healthy after $i tries" + ssh "$TARGET" 'curl -s http://127.0.0.1:55001/debug/version' + echo + exit 0 + fi + sleep 2 +done + +echo "!! daemon did not become healthy in 60s" >&2 +ssh "$TARGET" 'sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) journalctl --user -u seed-daemon.service --no-pager -n 100' || true +exit 1 diff --git a/seed-knowledge-manager/agent/seed-daemon/compose.yaml b/seed-knowledge-manager/agent/seed-daemon/compose.yaml new file mode 100644 index 0000000000..5e23a7e345 --- /dev/null +++ b/seed-knowledge-manager/agent/seed-daemon/compose.yaml @@ -0,0 +1,30 @@ +# Local Seed daemon for Knowledge Manager agent. +# Image is the published seed-daemon binary (built from backend/cmd/seed-daemon/Dockerfile). +# Daemon runs as a regular peer (no site mode). seed-cli on the host signs blobs +# locally via OS keyring and submits them to this daemon's HTTP API. + +services: + seed-daemon: + image: seedhypermedia/site:latest + container_name: km-seed-daemon + restart: unless-stopped + ports: + # HTTP + gRPC bound to loopback only — they are the agent's API surface, + # not meant to be reachable from outside the host. + - "127.0.0.1:55001:55001" + - "127.0.0.1:55002:55002" + # P2P port reachable from the public internet. + - "55000:55000/tcp" + - "55000:55000/udp" + volumes: + - ./data:/data + environment: + - SEED_LOG_LEVEL=info + command: + - seed-daemon + - -data-dir=/data + - -keystore-dir=/data/keys + - -http.port=55001 + - -grpc.port=55002 + - -p2p.port=55000 + - -syncing.smart=true diff --git a/seed-knowledge-manager/agent/systemd/seed-daemon.service b/seed-knowledge-manager/agent/systemd/seed-daemon.service new file mode 100644 index 0000000000..13ef2ebeda --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/seed-daemon.service @@ -0,0 +1,19 @@ +[Unit] +Description=Seed daemon for Knowledge Manager agent +# user-mode unit can't reference system services like docker.service. +# Docker daemon is started by the system unit at boot; we only orchestrate +# the compose stack. Restart on failure handles the case where docker isn't +# ready yet on first boot. +After=default.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/home/km/seed-daemon +ExecStart=/usr/bin/docker compose up -d +ExecStop=/usr/bin/docker compose down +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target From d1a1acc2a1431c8f162e28a990946bad8171fcec Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Tue, 5 May 2026 23:22:28 +0200 Subject: [PATCH 03/17] feat(agent): add secret-tool shim and seed-web service to compose stack Introduce a drop-in `secret-tool` shim so seed-cli can store/lookup secrets via a JSON file in headless environments where the OS keyring is unavailable. Add a `seed-web` service to the Docker Compose stack so the agent's seed-cli talks to the Remix frontend (port 3000) instead of directly to the daemon's raw gRPC-Web surface. Wire both services onto a shared `seed-net` bridge network and document the updated architecture. --- .../agent/scripts/secret-tool-shim | 67 +++++++++++++++++++ .../agent/seed-daemon/compose.yaml | 46 +++++++++++-- 2 files changed, 107 insertions(+), 6 deletions(-) create mode 100755 seed-knowledge-manager/agent/scripts/secret-tool-shim diff --git a/seed-knowledge-manager/agent/scripts/secret-tool-shim b/seed-knowledge-manager/agent/scripts/secret-tool-shim new file mode 100755 index 0000000000..b7396432a8 --- /dev/null +++ b/seed-knowledge-manager/agent/scripts/secret-tool-shim @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Drop-in shim for `secret-tool` used by seed-cli on a headless server. +# +# Stores secrets in a JSON file at ${SECRET_TOOL_FILE:-$HOME/.config/seed-keyring/secrets.json} +# (mode 600). Implements only the subset that seed-cli needs: +# secret-tool lookup [attr value]... +# secret-tool store --label <label> [attr value]... +# +# Key in the JSON object is the concatenation of attribute pairs, e.g. +# "service=seed-daemon-main;username=parentCollection". +# Value is the raw string read from stdin (for `store`) or printed to stdout +# (for `lookup`). + +set -euo pipefail + +FILE="${SECRET_TOOL_FILE:-$HOME/.config/seed-keyring/secrets.json}" +mkdir -p "$(dirname "$FILE")" +chmod 700 "$(dirname "$FILE")" +[[ -f "$FILE" ]] || { echo '{}' > "$FILE"; chmod 600 "$FILE"; } + +cmd="${1:-}"; shift || true + +build_key() { + local key="" + while [[ $# -gt 0 ]]; do + [[ -n "$key" ]] && key+=";" + key+="$1=$2" + shift 2 + done + echo "$key" +} + +case "$cmd" in + lookup) + key="$(build_key "$@")" + val=$(jq -r --arg k "$key" '.[$k] // empty' "$FILE") + if [[ -z "$val" ]]; then + # seed-cli's keyring.ts swallows errors that mention "not found" + # or "status 1" — so emit something it'll recognize as missing. + echo 'not found' >&2 + exit 1 + fi + printf '%s' "$val" + ;; + store) + # consume optional --label <text> + if [[ "${1:-}" == "--label" ]]; then shift 2; fi + key="$(build_key "$@")" + val="$(cat)" + tmp="$(mktemp)" + jq --arg k "$key" --arg v "$val" '. + {($k): $v}' "$FILE" > "$tmp" + mv "$tmp" "$FILE" + chmod 600 "$FILE" + ;; + clear) + if [[ "${1:-}" == "--label" ]]; then shift 2; fi + key="$(build_key "$@")" + tmp="$(mktemp)" + jq --arg k "$key" 'del(.[$k])' "$FILE" > "$tmp" + mv "$tmp" "$FILE" + chmod 600 "$FILE" + ;; + *) + echo "secret-tool-shim: unsupported subcommand '$cmd'" >&2 + exit 2 + ;; +esac diff --git a/seed-knowledge-manager/agent/seed-daemon/compose.yaml b/seed-knowledge-manager/agent/seed-daemon/compose.yaml index 5e23a7e345..0988aa3877 100644 --- a/seed-knowledge-manager/agent/seed-daemon/compose.yaml +++ b/seed-knowledge-manager/agent/seed-daemon/compose.yaml @@ -1,16 +1,31 @@ -# Local Seed daemon for Knowledge Manager agent. -# Image is the published seed-daemon binary (built from backend/cmd/seed-daemon/Dockerfile). -# Daemon runs as a regular peer (no site mode). seed-cli on the host signs blobs -# locally via OS keyring and submits them to this daemon's HTTP API. +# Local Seed stack for Knowledge Manager agent. +# +# Two services: +# - seed-daemon: the Go peer (P2P, gRPC, low-level HTTP). Built from +# backend/cmd/seed-daemon/Dockerfile, image seedhypermedia/site:latest. +# Stores everything under /data; uses a file-based keystore so it can +# run headless inside the container. +# - seed-web: the Remix frontend that exposes the `/api/<RPC>` surface +# used by `seed-cli`, the desktop app, and the website. Image +# seedhypermedia/web:latest. Talks to the daemon via DAEMON_HTTP_URL. +# +# The agent's seed-cli on the host points to seed-web (port 3000), NOT +# directly to the daemon, because the daemon's HTTP server only exposes +# raw gRPC-Web, while seed-cli speaks the Remix `/api/<RPC>` JSON shape. + +networks: + seed-net: + driver: bridge services: seed-daemon: image: seedhypermedia/site:latest container_name: km-seed-daemon restart: unless-stopped + networks: [seed-net] ports: - # HTTP + gRPC bound to loopback only — they are the agent's API surface, - # not meant to be reachable from outside the host. + # HTTP + gRPC bound to loopback only — only the local web container + # and curl-based debug calls from the host should reach them. - "127.0.0.1:55001:55001" - "127.0.0.1:55002:55002" # P2P port reachable from the public internet. @@ -28,3 +43,22 @@ services: - -grpc.port=55002 - -p2p.port=55000 - -syncing.smart=true + + seed-web: + image: seedhypermedia/web:latest + container_name: km-seed-web + restart: unless-stopped + networks: [seed-net] + depends_on: + - seed-daemon + ports: + # Bound to loopback — seed-cli on the host hits this on 127.0.0.1:3000. + - "127.0.0.1:3000:3000" + volumes: + - ./web-data:/data + environment: + - PORT=3000 + - DATA_DIR=/data + - DAEMON_HTTP_URL=http://seed-daemon:55001 + - SEED_BASE_URL=http://127.0.0.1:3000 + - SEED_IS_GATEWAY=false From 26a3df7890424c1583deeb5ec30c33ad20cede59 Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Wed, 6 May 2026 15:57:58 +0200 Subject: [PATCH 04/17] feat(agent): seed-cli MCP wrapper foundation (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Bun-built TypeScript wrapper that fronts seed-cli for the Knowledge Manager agent. Provides: - governance loader (parses YAML rules out of fixed-path Seed docs, 60s TTL cache) - path allow/deny matcher with hardcoded denylist over the four governance docs themselves - per-day rate counters (per-run counter intentionally not persisted) - mention parser for Seed comment annotations + reply-target builder - audit-log per process invocation (meta.json + trace.jsonl + llm.jsonl + tools.jsonl + seed-cli.jsonl, with secret redaction) - typed seed-cli wrapper with verb-pair denylist (key:generate, capability:create, …) - stdio MCP server entry exposing the read/write/state tools to a potential nanobot host 44 unit tests under bun:test cover the matcher, governance parsing, mention detection, redaction, state, and the seed-cli denylist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../agent/mcp/seed-cli-mcp/.gitignore | 3 + .../agent/mcp/seed-cli-mcp/.gitkeep | 0 .../agent/mcp/seed-cli-mcp/bun.lock | 216 +++++++ .../agent/mcp/seed-cli-mcp/package.json | 32 + .../agent/mcp/seed-cli-mcp/src/audit.ts | 196 ++++++ .../agent/mcp/seed-cli-mcp/src/config.ts | 46 ++ .../mcp/seed-cli-mcp/src/governance.test.ts | 89 +++ .../agent/mcp/seed-cli-mcp/src/governance.ts | 174 +++++ .../agent/mcp/seed-cli-mcp/src/index.ts | 90 +++ .../agent/mcp/seed-cli-mcp/src/limits.test.ts | 93 +++ .../agent/mcp/seed-cli-mcp/src/limits.ts | 130 ++++ .../mcp/seed-cli-mcp/src/mentions.test.ts | 141 ++++ .../agent/mcp/seed-cli-mcp/src/mentions.ts | 230 +++++++ .../agent/mcp/seed-cli-mcp/src/redact.test.ts | 28 + .../agent/mcp/seed-cli-mcp/src/redact.ts | 39 ++ .../mcp/seed-cli-mcp/src/seedcli.test.ts | 29 + .../agent/mcp/seed-cli-mcp/src/seedcli.ts | 139 ++++ .../agent/mcp/seed-cli-mcp/src/state.test.ts | 58 ++ .../agent/mcp/seed-cli-mcp/src/state.ts | 228 +++++++ .../agent/mcp/seed-cli-mcp/src/tools.ts | 605 ++++++++++++++++++ .../agent/mcp/seed-cli-mcp/tsconfig.json | 22 + 21 files changed, 2588 insertions(+) create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitignore delete mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitkeep create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/bun.lock create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/package.json create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/audit.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/config.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.test.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/index.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.test.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.test.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.test.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.test.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.test.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/tsconfig.json diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitignore b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitignore new file mode 100644 index 0000000000..06e60381b9 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.tsbuildinfo diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitkeep b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/bun.lock b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/bun.lock new file mode 100644 index 0000000000..b48282719f --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/bun.lock @@ -0,0 +1,216 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@km/seed-cli-mcp", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "ulid": "^2.4.0", + "yaml": "^2.6.0", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0", + }, + }, + }, + "packages": { + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.0", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "hono": ["hono@4.12.17", "", {}, "sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ulid": ["ulid@2.4.0", "", { "bin": { "ulid": "bin/cli.js" } }, "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + } +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/package.json b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/package.json new file mode 100644 index 0000000000..0c52dc4e85 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/package.json @@ -0,0 +1,32 @@ +{ + "name": "@km/seed-cli-mcp", + "version": "0.1.0", + "private": true, + "description": "stdio MCP server wrapping seed-cli for the Knowledge Manager agent.", + "type": "module", + "bin": { + "seed-cli-mcp": "./dist/index.js" + }, + "main": "./dist/index.js", + "scripts": { + "build": "bun build src/index.ts --target node --outdir dist --minify && bun build src/poll-cli.ts --target node --outdir dist --minify --outfile poll-cli.js && bun build src/cadence-cli.ts --target node --outdir dist --minify --outfile cadence-cli.js && bun build src/telegram-bot.ts --target node --outdir dist --minify --outfile telegram-bot.js", + "build:dev": "bun build src/index.ts --target node --outdir dist --external '@modelcontextprotocol/sdk' --external 'yaml' --external 'zod' --external 'ulid'", + "typecheck": "bunx tsc -p tsconfig.json --noEmit", + "test": "bun test src", + "test:watch": "bun test --watch src" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "yaml": "^2.6.0", + "zod": "^3.23.8", + "ulid": "^2.4.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0" + }, + "engines": { + "bun": ">=1.0", + "node": ">=20" + } +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/audit.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/audit.ts new file mode 100644 index 0000000000..9602c6559e --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/audit.ts @@ -0,0 +1,196 @@ +/** + * Per-run audit log. Each agent invocation gets its own directory under + * `${logsDir}/runs/<UTC-ISO>__<trigger>__<runId>/` with a stable layout: + * + * meta.json — trigger, KM_AID, env hash, start/end, wall_ms + * trace.jsonl — ordered events with timestamps + * llm.jsonl — prompts, completions, reasoning, token usage + * tools.jsonl — MCP tool calls + latency + * seed-cli.jsonl — argv + stdout + stderr + exit + ms + * + * A `current` symlink in `${logsDir}` always points at the newest run for + * easy `tail -F current/trace.jsonl`. A top-level `index.jsonl` carries one + * summary line per run for `km-log` browsing. + * + * All writes are append-only with `O_APPEND` and flushed eagerly so a crash + * never loses the last record. All values pass through the Redactor before + * being serialised so the persisted log can never contain a known secret. + */ + +import {appendFileSync, closeSync, existsSync, mkdirSync, openSync, statSync, symlinkSync, unlinkSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' +import {ulid} from 'ulid' +import type {Redactor} from './redact.js' + +export type Trigger = string + +export type AuditMeta = { + runId: string + trigger: Trigger + startedAt: string + endedAt?: string + wallMs?: number + status?: 'ok' | 'error' | 'denied' + kmAccountId?: string + seedSite?: string + counters?: Record<string, number> +} + +export type TraceEvent = { + ts: string + level: 'debug' | 'info' | 'warn' | 'error' + event: string + data?: unknown +} + +export type ToolCallRecord = { + ts_start: string + ts_end: string + latency_ms: number + tool: string + args: unknown + result?: unknown + error?: string +} + +export type SeedCliRecord = { + ts_start: string + ts_end: string + latency_ms: number + argv: string[] + exit_code: number + stdout?: string + stderr?: string +} + +export type LlmRecord = { + ts_start: string + ts_end: string + latency_ms: number + model?: string + prompt_messages?: unknown + completion?: string + reasoning?: string + tool_calls?: unknown + usage?: {prompt?: number; completion?: number; total?: number} +} + +export class AuditRun { + readonly meta: AuditMeta + readonly dir: string + private readonly redactor: Redactor + private closed = false + private startTime: number + + constructor(opts: {logsDir: string; trigger: Trigger; redactor: Redactor; kmAccountId?: string; seedSite?: string}) { + this.redactor = opts.redactor + const runId = ulid() + const now = new Date() + this.startTime = now.getTime() + const isoSlug = now.toISOString().replace(/[:]/g, '-').replace(/\..+$/, 'Z') + const slug = `${isoSlug}__${sanitize(opts.trigger)}__${runId}` + this.dir = join(opts.logsDir, 'runs', slug) + mkdirSync(this.dir, {recursive: true, mode: 0o700}) + this.meta = { + runId, + trigger: opts.trigger, + startedAt: now.toISOString(), + kmAccountId: opts.kmAccountId, + seedSite: opts.seedSite, + counters: {}, + } + this.flushMeta() + this.updateCurrent(opts.logsDir, slug) + } + + trace(event: TraceEvent): void { + this.appendJsonl('trace.jsonl', event) + } + + tool(record: ToolCallRecord): void { + this.appendJsonl('tools.jsonl', record) + this.bumpCounter('tool_calls') + } + + llm(record: LlmRecord): void { + this.appendJsonl('llm.jsonl', record) + this.bumpCounter('llm_calls') + } + + seedCli(record: SeedCliRecord): void { + this.appendJsonl('seed-cli.jsonl', record) + this.bumpCounter('seed_cli_calls') + } + + bumpCounter(name: string, delta = 1): void { + if (!this.meta.counters) this.meta.counters = {} + this.meta.counters[name] = (this.meta.counters[name] ?? 0) + delta + } + + close(opts: {status?: 'ok' | 'error' | 'denied'; logsDir: string}): void { + if (this.closed) return + this.closed = true + const now = new Date() + this.meta.endedAt = now.toISOString() + this.meta.wallMs = now.getTime() - this.startTime + this.meta.status = opts.status ?? 'ok' + this.flushMeta() + appendIndex(opts.logsDir, this.meta) + } + + private appendJsonl(file: string, value: unknown): void { + const line = this.redactor(JSON.stringify(value)) + '\n' + const path = join(this.dir, file) + const fd = openSync(path, 'a') + try { + appendFileSync(fd, line) + } finally { + // openSync + appendFileSync(fd) doesn't auto-close; release the fd. + closeSync(fd) + } + } + + private flushMeta(): void { + const path = join(this.dir, 'meta.json') + writeFileSync(path, this.redactor(JSON.stringify(this.meta, null, 2)) + '\n', {mode: 0o600}) + } + + private updateCurrent(logsDir: string, slug: string): void { + const link = join(logsDir, 'current') + try { + if (existsSync(link)) unlinkSync(link) + } catch { + /* ignore */ + } + try { + symlinkSync(join('runs', slug), link) + } catch { + /* logsDir may be on a fs without symlink support; non-fatal */ + } + } +} + +function appendIndex(logsDir: string, meta: AuditMeta): void { + const indexPath = join(logsDir, 'index.jsonl') + if (!existsSync(logsDir)) mkdirSync(logsDir, {recursive: true, mode: 0o700}) + const line = + JSON.stringify({ + id: meta.runId, + trigger: meta.trigger, + start: meta.startedAt, + end: meta.endedAt, + status: meta.status, + wall_ms: meta.wallMs, + counters: meta.counters, + }) + '\n' + appendFileSync(indexPath, line) + try { + statSync(indexPath) + } catch { + /* ignore */ + } +} + +function sanitize(s: string): string { + return s.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 64) +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/config.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/config.ts new file mode 100644 index 0000000000..75cfd0a621 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/config.ts @@ -0,0 +1,46 @@ +/** + * Runtime configuration sourced from environment variables. The MCP server + * is launched by `nanobot gateway`, which forwards these via the `env` + * field of the `mcpServers` entry in `~/.nanobot/config.json`. + */ + +export type AgentConfig = { + seedServer: string + seedSite: string + keyName: string + cliPath: string + stateDir: string + logsDir: string + rulesTtlMs: number + writersTtlMs: number + governanceBasePath: string +} + +const DEFAULT_RULES_TTL_MS = 60_000 +const DEFAULT_WRITERS_TTL_MS = 5 * 60_000 + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): AgentConfig { + const required = ['SEED_SERVER', 'SEED_SITE'] as const + for (const key of required) { + if (!env[key]) { + throw new Error(`Missing required env var: ${key}`) + } + } + return { + seedServer: env.SEED_SERVER!, + seedSite: env.SEED_SITE!, + keyName: env.KM_KEY_NAME ?? 'knowledge-manager', + cliPath: env.SEED_CLI_PATH ?? '/home/km/.local/bin/seed-cli', + stateDir: env.KM_STATE_DIR ?? '/home/km/km-state', + logsDir: env.KM_LOGS_DIR ?? '/home/km/km-logs', + rulesTtlMs: numberOr(env.KM_RULES_TTL_MS, DEFAULT_RULES_TTL_MS), + writersTtlMs: numberOr(env.KM_WRITERS_TTL_MS, DEFAULT_WRITERS_TTL_MS), + governanceBasePath: env.KM_GOVERNANCE_BASE_PATH ?? '/agents/knowledge-manager', + } +} + +function numberOr(value: string | undefined, fallback: number): number { + if (!value) return fallback + const n = Number(value) + return Number.isFinite(n) && n > 0 ? n : fallback +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.test.ts new file mode 100644 index 0000000000..cc3e97379d --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.test.ts @@ -0,0 +1,89 @@ +import {describe, expect, it} from 'bun:test' +import {extractYamlBlock, parseAllowlistBody, parseRulesBody} from './governance.js' + +const RULES_DOC = `--- +type: agent-rules +schema_version: 1 +title: foo +--- + +# Rules + +The agent reads this on every run. + +\`\`\`yaml +allow_write_paths: + - / +deny_write_paths: + - /agents/knowledge-manager/charter +caps: + max_documents_per_run: 1 + max_comments_per_run: 5 + max_comments_per_day: 30 + poll_interval_seconds: 60 +mentions: + trigger: "@knowledge-manager" + invoker_source: "writer-capabilities" +moderation: + blocked_authors: [] +draft_only: false +language: en +\`\`\` +` + +describe('extractYamlBlock', () => { + it('prefers fenced yaml block over frontmatter', () => { + const yaml = extractYamlBlock(RULES_DOC) + expect(yaml).toContain('allow_write_paths') + expect(yaml).not.toContain('type: agent-rules') + }) + + it('falls back to frontmatter when no fenced block exists', () => { + const yaml = extractYamlBlock('---\nfoo: bar\n---\n# Title\n') + expect(yaml).toBe('foo: bar') + }) +}) + +describe('parseRulesBody', () => { + it('accepts the canonical template shape', () => { + const rules = parseRulesBody(RULES_DOC) + expect(rules).not.toBeNull() + expect(rules?.allowWritePaths).toEqual(['/']) + expect(rules?.denyWritePaths).toContain('/agents/knowledge-manager/charter') + expect(rules?.draftOnly).toBe(false) + expect(rules?.language).toBe('en') + expect(rules?.caps.maxDocumentsPerRun).toBe(1) + expect(rules?.caps.maxCommentsPerDay).toBe(30) + expect(rules?.mentions.invokerSource).toBe('writer-capabilities') + }) + + it('flips draft_only true', () => { + const doc = RULES_DOC.replace('draft_only: false', 'draft_only: true') + const rules = parseRulesBody(doc) + expect(rules?.draftOnly).toBe(true) + }) + + it('returns null on garbage', () => { + expect(parseRulesBody('no yaml here')).toBeNull() + }) +}) + +describe('parseAllowlistBody', () => { + const ALLOWLIST_DOC = `# Allowlist +\`\`\`yaml +invokers: + - z6Mkfoo + - z6Mkbar +\`\`\` +` + + it('parses invoker list', () => { + const a = parseAllowlistBody(ALLOWLIST_DOC) + expect(a?.invokers).toEqual(['z6Mkfoo', 'z6Mkbar']) + }) + + it('treats missing list as empty', () => { + const a = parseAllowlistBody('```yaml\ninvokers: []\n```') + expect(a?.invokers).toEqual([]) + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.ts new file mode 100644 index 0000000000..71847219ed --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.ts @@ -0,0 +1,174 @@ +/** + * Governance rules loader. Reads the four governance Seed documents + * (`charter`, `rules`, `runbook`, `allowlist`) under + * `${governanceBasePath}` of `${SEED_SITE}`, parses the machine-readable + * YAML block out of `rules` and `allowlist`, and caches the result for + * `${rulesTtlMs}` so policy changes propagate within ≤ TTL. + * + * The agent's MCP tools call `getRules()` at the start of every tool + * invocation. Cache is process-local; restarting the gateway clears it. + */ + +import {parse as parseYaml} from 'yaml' +import type {AgentConfig} from './config.js' +import type {SeedCli} from './seedcli.js' +import type {Rules} from './limits.js' + +export type Allowlist = { + invokers: string[] +} + +export type Governance = { + rules: Rules + allowlist: Allowlist + charter: string + runbook: string + fetchedAt: number +} + +const DEFAULT_RULES: Rules = { + schemaVersion: 1, + allowWritePaths: ['/'], + denyWritePaths: [ + '/agents/knowledge-manager/charter', + '/agents/knowledge-manager/rules', + '/agents/knowledge-manager/runbook', + '/agents/knowledge-manager/allowlist', + ], + caps: { + maxDocumentsPerRun: 1, + maxCommentsPerRun: 5, + maxCommentsPerDay: 30, + pollIntervalSeconds: 60, + }, + mentions: { + trigger: '@knowledge-manager', + invokerSource: 'writer-capabilities', + }, + moderation: {blockedAuthors: []}, + draftOnly: false, + language: 'en', +} + +export class GovernanceCache { + private cached?: Governance + + constructor( + private readonly config: AgentConfig, + private readonly cli: SeedCli, + ) {} + + async getGovernance(force = false): Promise<Governance> { + const now = Date.now() + if (!force && this.cached && now - this.cached.fetchedAt < this.config.rulesTtlMs) { + return this.cached + } + const [charter, rules, runbook, allowlist] = await Promise.all([ + this.fetchDocBody('charter'), + this.fetchDocBody('rules'), + this.fetchDocBody('runbook'), + this.fetchDocBody('allowlist'), + ]) + const parsedRules = parseRulesBody(rules) ?? DEFAULT_RULES + const parsedAllowlist = parseAllowlistBody(allowlist) ?? {invokers: []} + this.cached = { + rules: parsedRules, + allowlist: parsedAllowlist, + charter, + runbook, + fetchedAt: now, + } + return this.cached + } + + /** Returns true if the doc was missing (404-ish) so callers can bootstrap. */ + async checkBootstrapNeeded(): Promise<boolean> { + const rules = await this.fetchDocBody('rules').catch(() => '') + return rules.length === 0 + } + + private async fetchDocBody(slug: string): Promise<string> { + const docId = `${this.config.seedSite}${this.config.governanceBasePath}/${slug}` + // seed-cli's default `document get` already emits markdown with YAML + // frontmatter, which is exactly what the wrapper's parser expects. + // Older builds lack the explicit `--md/--frontmatter` flags. + const result = await this.cli + .runRead(['document', 'get', docId]) + .catch((err) => ({exitCode: -1, stdout: '', stderr: String(err)})) + if (result.exitCode !== 0) return '' + return result.stdout + } +} + +export function parseRulesBody(body: string): Rules | null { + const yaml = extractYamlBlock(body) + if (!yaml) return null + try { + const parsed = parseYaml(yaml) as Partial<Rules> & Record<string, unknown> + return mergeRules(parsed) + } catch { + return null + } +} + +export function parseAllowlistBody(body: string): Allowlist | null { + const yaml = extractYamlBlock(body) + if (!yaml) return null + try { + const parsed = parseYaml(yaml) as {invokers?: unknown} + const invokers = Array.isArray(parsed.invokers) ? parsed.invokers.filter((x): x is string => typeof x === 'string') : [] + return {invokers} + } catch { + return null + } +} + +export function extractYamlBlock(body: string): string | null { + // Look for first fenced ```yaml block (the convention used by the + // `agent-rules.md` template). Fall back to leading frontmatter. + const fenced = body.match(/```ya?ml\s*\n([\s\S]*?)```/) + if (fenced) return fenced[1]! + const frontmatter = body.match(/^---\s*\n([\s\S]*?)\n---/) + if (frontmatter) return frontmatter[1]! + return null +} + +function mergeRules(input: Partial<Rules> & Record<string, unknown>): Rules { + const rules: Rules = { + ...DEFAULT_RULES, + ...input, + caps: {...DEFAULT_RULES.caps, ...(input.caps as Rules['caps'] | undefined)}, + mentions: {...DEFAULT_RULES.mentions, ...(input.mentions as Rules['mentions'] | undefined)}, + moderation: {...DEFAULT_RULES.moderation, ...(input.moderation as Rules['moderation'] | undefined)}, + } + // Snake-case → camelCase tolerance for fields we expect humans to edit. + const camel = (input as Record<string, unknown>) as { + allow_write_paths?: string[] + deny_write_paths?: string[] + schema_version?: number + draft_only?: boolean + } + if (Array.isArray(camel.allow_write_paths)) rules.allowWritePaths = camel.allow_write_paths + if (Array.isArray(camel.deny_write_paths)) rules.denyWritePaths = camel.deny_write_paths + if (typeof camel.schema_version === 'number') rules.schemaVersion = camel.schema_version + if (typeof camel.draft_only === 'boolean') rules.draftOnly = camel.draft_only + // Caps snake-case + const caps = (input.caps as Record<string, unknown>) ?? {} + if (typeof caps.max_documents_per_run === 'number') rules.caps.maxDocumentsPerRun = caps.max_documents_per_run + if (typeof caps.max_comments_per_run === 'number') rules.caps.maxCommentsPerRun = caps.max_comments_per_run + if (typeof caps.max_comments_per_day === 'number') rules.caps.maxCommentsPerDay = caps.max_comments_per_day + if (typeof caps.poll_interval_seconds === 'number') rules.caps.pollIntervalSeconds = caps.poll_interval_seconds + // Mentions invoker_source + const m = (input.mentions as Record<string, unknown>) ?? {} + if (typeof m.invoker_source === 'string') { + rules.mentions.invokerSource = m.invoker_source as Rules['mentions']['invokerSource'] + } + // Moderation blocked_authors + const mod = (input.moderation as Record<string, unknown>) ?? {} + if (Array.isArray(mod.blocked_authors)) { + rules.moderation.blockedAuthors = mod.blocked_authors.filter((x): x is string => typeof x === 'string') + } + return rules +} + +export {DEFAULT_RULES} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/index.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/index.ts new file mode 100644 index 0000000000..780dc1e59b --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/index.ts @@ -0,0 +1,90 @@ +/** + * stdio MCP server entry point. Boots the wrapper, registers all tools, + * and connects to nanobot's stdio transport. + * + * Lifecycle: + * 1. Parse env (SEED_SERVER, SEED_SITE, KM_KEY_NAME, KM_STATE_DIR, KM_LOGS_DIR). + * 2. Build a Redactor from secret env vars. + * 3. Start an AuditRun for this process invocation. + * 4. Resolve the agent's accountId by calling `seed-cli key list`. + * 5. Register MCP tools. + * 6. Listen on stdio until parent exits, then close the audit run. + */ + +import {Server} from '@modelcontextprotocol/sdk/server/index.js' +import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js' +import {AuditRun} from './audit.js' +import {loadConfig} from './config.js' +import {GovernanceCache} from './governance.js' +import {buildRedactor} from './redact.js' +import {SeedCli} from './seedcli.js' +import {State} from './state.js' +import {buildTools, registerToolHandlers} from './tools.js' + +async function main(): Promise<void> { + const config = loadConfig() + const redactor = buildRedactor() + const audit = new AuditRun({ + logsDir: config.logsDir, + trigger: process.env.KM_TRIGGER ?? 'mcp-server', + redactor, + seedSite: config.seedSite, + }) + + audit.trace({ + ts: new Date().toISOString(), + level: 'info', + event: 'agent_start', + data: {seedServer: config.seedServer, seedSite: config.seedSite, keyName: config.keyName}, + }) + + const cli = new SeedCli(config, redactor, audit) + const state = new State(config.stateDir) + const governance = new GovernanceCache(config, cli) + const kmAccountId = await resolveAgentAccountId(cli, config.keyName) + audit.meta.kmAccountId = kmAccountId + + const tools = buildTools({config, cli, governance, state, audit, kmAccountId}) + + const server = new Server({name: 'seed-cli-mcp', version: '0.1.0'}, {capabilities: {tools: {}}}) + registerToolHandlers(server, tools) + + const transport = new StdioServerTransport() + await server.connect(transport) + + // Close the audit run when the parent disconnects (stdin EOF). + const close = (status: 'ok' | 'error' = 'ok') => { + audit.trace({ts: new Date().toISOString(), level: 'info', event: 'agent_end', data: {status}}) + audit.close({status, logsDir: config.logsDir}) + } + process.on('SIGINT', () => { + close('ok') + process.exit(0) + }) + process.on('SIGTERM', () => { + close('ok') + process.exit(0) + }) + process.on('exit', () => close('ok')) +} + +async function resolveAgentAccountId(cli: SeedCli, keyName: string): Promise<string> { + const r = await cli.runRead(['key', 'show', keyName]) + if (r.exitCode !== 0) { + throw new Error(`Failed to resolve agent key '${keyName}': ${r.stderr}`) + } + if (r.parsedJson && typeof r.parsedJson === 'object') { + const obj = r.parsedJson as {accountId?: string} + if (obj.accountId) return obj.accountId + } + // Fallback: parse "accountId: z6Mk..." from stdout. + const m = r.stdout.match(/z6Mk[1-9A-HJ-NP-Za-km-z]{40,}/) + if (m) return m[0] + throw new Error(`Could not parse accountId from \`seed-cli key show ${keyName}\` output`) +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('seed-cli-mcp fatal error:', err) + process.exit(1) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.test.ts new file mode 100644 index 0000000000..5bc9575689 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.test.ts @@ -0,0 +1,93 @@ +import {describe, expect, it} from 'bun:test' +import {bump, checkCap, isWriteAllowed, matchPath, newRateState, normalizePath} from './limits.js' +import {DEFAULT_RULES} from './governance.js' +import type {Rules} from './limits.js' + +describe('matchPath', () => { + it('exact match', () => { + expect(matchPath('/foo/bar', '/foo/bar')).toBe(true) + }) + it('single-star matches one segment', () => { + expect(matchPath('/foo/*', '/foo/bar')).toBe(true) + expect(matchPath('/foo/*', '/foo/bar/baz')).toBe(false) + }) + it('double-star matches any depth', () => { + expect(matchPath('/foo/**', '/foo/bar')).toBe(true) + expect(matchPath('/foo/**', '/foo/bar/baz')).toBe(true) + expect(matchPath('/**', '/anything/here')).toBe(true) + }) + it('normalizes trailing slashes', () => { + expect(normalizePath('/foo/')).toBe('/foo') + expect(normalizePath('foo')).toBe('/foo') + }) + it('sole / matches everything (site-wide allow)', () => { + expect(matchPath('/', '/agents/knowledge-manager/state/boletin/2026-W19')).toBe(true) + expect(matchPath('/', '/digests/foo')).toBe(true) + expect(matchPath('/', '/')).toBe(true) + }) +}) + +describe('isWriteAllowed', () => { + const rules: Rules = { + ...DEFAULT_RULES, + allowWritePaths: ['/'], + denyWritePaths: ['/locked/**'], + } + + it('allows root', () => { + expect(isWriteAllowed('/', rules).allowed).toBe(true) + }) + + it('rejects denylisted', () => { + const r = isWriteAllowed('/locked/safe', rules) + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.reason).toMatch(/rules-deny/) + }) + + it('hardcoded deny beats allow', () => { + const r = isWriteAllowed('/agents/knowledge-manager/rules', {...rules, allowWritePaths: ['/']}) + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.reason).toMatch(/hardcoded-deny/) + }) + + it('rejects when not in allowlist', () => { + const r = isWriteAllowed('/foo', {...rules, allowWritePaths: ['/digests/**']}) + expect(r.allowed).toBe(false) + }) + + it('allows when matched in allowlist with double-star', () => { + const r = isWriteAllowed('/digests/2026-W19', {...rules, allowWritePaths: ['/digests/**']}) + expect(r.allowed).toBe(true) + }) +}) + +describe('rate caps', () => { + it('newRateState empty', () => { + const s = newRateState() + expect(s.perDay).toEqual({}) + expect(s.perRun).toEqual({}) + expect(s.day).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('checkCap blocks documents over per-run limit', () => { + const rules: Rules = {...DEFAULT_RULES, caps: {...DEFAULT_RULES.caps, maxDocumentsPerRun: 1}} + let state = newRateState() + expect(checkCap(state, 'documents', rules).allowed).toBe(true) + state = bump(state, 'documents') + const r = checkCap(state, 'documents', rules) + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.reason).toMatch(/max_documents_per_run/) + }) + + it('checkCap blocks comments over per-day limit', () => { + const rules: Rules = {...DEFAULT_RULES, caps: {...DEFAULT_RULES.caps, maxCommentsPerDay: 2, maxCommentsPerRun: 100}} + let state = newRateState() + state = bump(state, 'comments') + state = bump(state, 'comments') + // Force per-run counter to be empty to isolate per-day check. + state = {...state, perRun: {}} + const r = checkCap(state, 'comments', rules) + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.reason).toMatch(/max_comments_per_day/) + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.ts new file mode 100644 index 0000000000..9ae18bb4d4 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.ts @@ -0,0 +1,130 @@ +/** + * Path allow/deny matching and rate caps. The rules object is the + * machine-readable YAML block parsed out of the `agent-rules` Seed + * document; see `governance.ts`. + * + * Globstar semantics: `**` matches any number of path segments, + * `*` matches one segment. + * + * /agents/knowledge-manager/state/** matches /agents/knowledge-manager/state/foo/bar + * /digests/* matches /digests/2026-W19 but not /digests/x/y + * + * Deny always beats allow. The four governance docs are protected by + * a hardcoded denylist regardless of what the rules say. + */ + +export type Rules = { + schemaVersion: number + allowWritePaths: string[] + denyWritePaths: string[] + caps: { + maxDocumentsPerRun: number + maxCommentsPerRun: number + maxCommentsPerDay: number + pollIntervalSeconds: number + } + mentions: { + trigger: string + invokerSource: 'writer-capabilities' | 'allowlist-doc' + } + moderation: { + blockedAuthors: string[] + } + draftOnly: boolean + language: string +} + +const HARDCODED_DENY = [ + '/agents/knowledge-manager/charter', + '/agents/knowledge-manager/rules', + '/agents/knowledge-manager/runbook', + '/agents/knowledge-manager/allowlist', +] + +export function isWriteAllowed(path: string, rules: Rules): {allowed: true} | {allowed: false; reason: string} { + const normalized = normalizePath(path) + for (const pattern of HARDCODED_DENY) { + if (matchPath(pattern, normalized)) { + return {allowed: false, reason: `hardcoded-deny: ${pattern}`} + } + } + for (const pattern of rules.denyWritePaths) { + if (matchPath(pattern, normalized)) { + return {allowed: false, reason: `rules-deny: ${pattern}`} + } + } + for (const pattern of rules.allowWritePaths) { + if (matchPath(pattern, normalized)) { + return {allowed: true} + } + } + return {allowed: false, reason: `not-in-allowlist`} +} + +export function normalizePath(path: string): string { + if (!path) return '/' + if (!path.startsWith('/')) path = '/' + path + return path.replace(/\/+$/, '') || '/' +} + +export function matchPath(pattern: string, path: string): boolean { + const p = normalizePath(pattern) + const t = normalizePath(path) + if (p === t) return true + // Sole `/` means "root and everything below it" — i.e. site-wide allow. + if (p === '/') return true + // Convert glob to regex. + const regex = '^' + p + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + // double-star: any number of segments + .replace(/\*\*/g, '«DOUBLESTAR»') + // single star: one segment + .replace(/\*/g, '[^/]+') + .replace(/«DOUBLESTAR»/g, '.*') + '$' + return new RegExp(regex).test(t) +} + +// ─── Rate caps ────────────────────────────────────────────────────────────── + +export type RateState = { + /** Calendar day (UTC) the per-day counters belong to. */ + day: string + perDay: Record<string, number> + perRun: Record<string, number> +} + +export function newRateState(): RateState { + return {day: utcDay(new Date()), perDay: {}, perRun: {}} +} + +export function bump(state: RateState, key: string): RateState { + const today = utcDay(new Date()) + const next = state.day === today ? state : {...state, day: today, perDay: {}} + next.perDay = {...next.perDay, [key]: (next.perDay[key] ?? 0) + 1} + next.perRun = {...next.perRun, [key]: (next.perRun[key] ?? 0) + 1} + return next +} + +export function checkCap( + state: RateState, + key: 'documents' | 'comments', + rules: Rules, +): {allowed: true} | {allowed: false; reason: string} { + const today = utcDay(new Date()) + const dayCount = state.day === today ? state.perDay[key] ?? 0 : 0 + const runCount = state.perRun[key] ?? 0 + if (key === 'documents' && runCount >= rules.caps.maxDocumentsPerRun) { + return {allowed: false, reason: `cap: max_documents_per_run (${rules.caps.maxDocumentsPerRun}) reached`} + } + if (key === 'comments' && runCount >= rules.caps.maxCommentsPerRun) { + return {allowed: false, reason: `cap: max_comments_per_run (${rules.caps.maxCommentsPerRun}) reached`} + } + if (key === 'comments' && dayCount >= rules.caps.maxCommentsPerDay) { + return {allowed: false, reason: `cap: max_comments_per_day (${rules.caps.maxCommentsPerDay}) reached`} + } + return {allowed: true} +} + +export function utcDay(date: Date): string { + return date.toISOString().slice(0, 10) +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.test.ts new file mode 100644 index 0000000000..5152d843dc --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.test.ts @@ -0,0 +1,141 @@ +import {describe, expect, it} from 'bun:test' +import {buildReplyTarget, classifyEvent, extractBlockId, findMentionTargets, mentionsAccount, stripFragment} from './mentions.js' + +const KM = 'z6MkAgentAccount' + +describe('findMentionTargets', () => { + it('extracts hm:// from @[Name](hm://...) syntax', () => { + expect(findMentionTargets('hi @[Bot](hm://z6MkAgentAccount) what is X?')).toEqual(['z6MkAgentAccount']) + }) + + it('handles multiple mentions', () => { + const t = '@[A](hm://z6MkA) and @[B](hm://z6MkB)' + expect(findMentionTargets(t)).toEqual(['z6MkA', 'z6MkB']) + }) + + it('returns empty on plain text', () => { + expect(findMentionTargets('plain text')).toEqual([]) + }) +}) + +describe('classifyEvent — comment mention', () => { + it('matches and returns kind=comment', () => { + const m = classifyEvent( + { + comment: { + id: 'bafyComment1', + target: 'hm://z6MkSite/some/doc', + body: '@[KM](hm://z6MkAgentAccount) hi', + author: 'z6MkAuthor', + time: '2026-05-05T00:00:00Z', + }, + }, + KM, + ) + expect(m).not.toBeNull() + expect(m?.kind).toBe('comment') + expect(m?.docId).toBe('hm://z6MkSite/some/doc') + expect(m?.commentId).toBe('bafyComment1') + expect(m?.author).toBe('z6MkAuthor') + }) + + it('extracts blockId from target fragment', () => { + const m = classifyEvent( + { + comment: { + id: 'bafyComment2', + target: 'hm://z6MkSite/some/doc#blk-abc', + body: '@[KM](hm://z6MkAgentAccount) ?', + author: 'z6MkAuthor', + }, + }, + KM, + ) + expect(m?.blockId).toBe('blk-abc') + }) + + it('returns null when mention is for a different account', () => { + const m = classifyEvent( + { + comment: { + id: 'bafy3', + target: 'hm://z6MkSite/some/doc', + body: '@[Other](hm://z6MkOther) hi', + author: 'z6MkAuthor', + }, + }, + KM, + ) + expect(m).toBeNull() + }) +}) + +describe('classifyEvent — document mention', () => { + it('finds mention inside any block', () => { + const m = classifyEvent( + { + document: { + id: 'hm://z6MkSite/page', + author: 'z6MkAuthor', + blocks: [ + {id: 'blk1', text: 'intro'}, + {id: 'blk2', text: 'see also @[KM](hm://z6MkAgentAccount)'}, + ], + }, + }, + KM, + ) + expect(m).not.toBeNull() + expect(m?.kind).toBe('doc-block') + expect(m?.blockId).toBe('blk2') + expect(m?.docId).toBe('hm://z6MkSite/page') + }) + + it('returns null without any mention block', () => { + const m = classifyEvent({document: {id: 'hm://x', author: 'z6Mky', blocks: [{id: '1', text: 'plain'}]}}, KM) + expect(m).toBeNull() + }) +}) + +describe('mentionsAccount / fragment helpers', () => { + it('mentionsAccount', () => { + expect(mentionsAccount('hi @[K](hm://z6Mkx)', 'z6Mkx')).toBe(true) + expect(mentionsAccount('hi @[K](hm://z6Mkx)', 'z6Mky')).toBe(false) + }) + it('extractBlockId', () => { + expect(extractBlockId('hm://x/y#blk-1')).toBe('blk-1') + expect(extractBlockId('hm://x/y')).toBeUndefined() + }) + it('stripFragment', () => { + expect(stripFragment('hm://x#blk')).toBe('hm://x') + }) +}) + +describe('buildReplyTarget', () => { + it('threaded reply for comment mentions does NOT append the comment-internal blockId to the doc URL', () => { + const r = buildReplyTarget({ + kind: 'comment', + docId: 'hm://z6Mksite/doc', + blockId: 'blk1', // belongs to the COMMENT, not the doc + commentId: 'bafyc1', + author: 'z6Mka', + text: '...', + ts: '', + }) + expect(r.targetId).toBe('hm://z6Mksite/doc') + expect(r.replyTo).toBe('bafyc1') + }) + + it('block-anchored top-level for doc mentions', () => { + const r = buildReplyTarget({ + kind: 'doc-block', + docId: 'hm://z6Mksite/doc', + blockId: 'blk2', + author: 'z6Mka', + text: '...', + ts: '', + }) + expect(r.targetId).toBe('hm://z6Mksite/doc#blk2') + expect(r.replyTo).toBeUndefined() + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts new file mode 100644 index 0000000000..9fc7ac29ad --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts @@ -0,0 +1,230 @@ +/** + * Mention parsing and classification. + * + * Seed represents inline mentions as the literal text `@[Name](hm://accountId)` + * in comment bodies and in document blocks. Block-level comments are + * targeted by appending `#blockId` to the document URL. + * + * `Mention.kind` distinguishes how the agent should respond: + * - `comment` — mention inside a comment body. Reply via threaded + * comment (`comment create --reply <commentId>`). + * - `doc-block` — mention inside a document block. Reply via a + * block-anchored top-level comment + * (`comment create <docId>#<blockId>`). + */ + +export type MentionKind = 'comment' | 'doc-block' + +export type Mention = { + kind: MentionKind + /** Hypermedia ID of the document the mention lives on. */ + docId: string + /** Block ID where the mention is anchored. May be undefined for comment-body mentions on the doc level. */ + blockId?: string + /** ID of the comment containing the mention (only when kind === 'comment'). */ + commentId?: string + /** AccountId (z6Mk…) of the comment / doc author. */ + author: string + /** Verbatim text containing the mention, used to classify the request. */ + text: string + /** Activity event timestamp (ISO). */ + ts: string +} + +const MENTION_RE = /@\[[^\]]*\]\(hm:\/\/([^)#]+)(?:[^)]*)?\)/g + +export function findMentionTargets(text: string): string[] { + const ids: string[] = [] + for (const m of text.matchAll(MENTION_RE)) { + if (m[1]) ids.push(m[1]) + } + return ids +} + +export function mentionsAccount(text: string, accountId: string): boolean { + return findMentionTargets(text).includes(accountId) +} + +/** + * Classifies an activity event into a Mention or null. The shape of + * `event` follows the daemon's `activity` API; we only consume what we + * need so future field additions don't break us. + */ +/** + * Activity-feed event shape (selected fields). Real events include many + * more keys; we read only what we need to identify a candidate comment. + * + * { "id": "bafy...", "type": "comment", "time": "...", + * "author": {"id": {"uid": "z6Mk..."}} } + */ +export type ActivityEvent = { + id?: string + type?: string + time?: string + author?: string | {id?: {uid?: string}} +} + +export function unwrapAuthor(a: ActivityEvent['author']): string | undefined { + if (typeof a === 'string') return a + if (a && typeof a === 'object') return a.id?.uid + return undefined +} + +/** + * Returns the comment record id to fetch if this event is a comment with + * an author. Activity events store the comment's record id in `event.id`. + * We don't try to detect mentions inside doc-update events here; that + * requires a separate doc fetch (deferred to v2). + */ +export function commentEventCandidate(event: ActivityEvent): {commentId: string; author: string; ts: string} | null { + if (event.type !== 'comment') return null + if (!event.id) return null + const author = unwrapAuthor(event.author) + if (!author) return null + return {commentId: event.id, author, ts: event.time ?? new Date().toISOString()} +} + +/** + * Shape returned by `seed-cli comment get <id>`. + */ +export type SeedComment = { + id: string + author: string + targetAccount: string + targetPath?: string + replyParent?: string + threadRoot?: string + content?: Array<{ + block?: { + id?: string + text?: string + annotations?: Array<{type?: string; link?: string}> + } + }> +} + +/** + * Detects whether a fetched comment contains a mention of any of the + * given accountIds. Mentions are stored as `Embed` annotations whose + * `link` starts with `hm://<accountId>`. Seed renders them as a U+FFFC + * object-replacement character with the link held on the annotation. + * + * The agent treats both itself (its own KM_AID) and the site root as + * trigger targets — when a writer mentions the site by name in a + * comment, the agent (which holds a WRITER capability on that site) + * should respond as if mentioned directly. + * + * Returns the {blockId, text} of the first block carrying any matching + * mention, or null otherwise. + */ +export function findKmMentionInComment( + comment: SeedComment, + accountIds: string | readonly string[], +): {blockId?: string; text: string} | null { + const ids = typeof accountIds === 'string' ? [accountIds] : accountIds + const idSet = new Set(ids) + for (const item of comment.content ?? []) { + const block = item.block + if (!block) continue + for (const ann of block.annotations ?? []) { + if (ann.type !== 'Embed') continue + if (typeof ann.link !== 'string') continue + const m = ann.link.match(/^hm:\/\/([^/?#]+)/) + if (!m) continue + if (idSet.has(m[1]!)) { + return {blockId: block.id, text: block.text ?? ''} + } + } + // Fallback: inline `@[…](hm://aid)` markdown syntax. + if (block.text) { + for (const id of ids) { + if (mentionsAccount(block.text, id)) { + return {blockId: block.id, text: block.text} + } + } + } + } + return null +} + +/** + * Builds a Mention from a fetched comment. Caller has already determined + * the mention-text + blockId via findKmMentionInComment. + */ +export function buildCommentMention( + comment: SeedComment, + evidence: {blockId?: string; text: string}, + ts: string, +): Mention { + const docId = `hm://${comment.targetAccount}${comment.targetPath ?? ''}` + return { + kind: 'comment', + docId, + blockId: evidence.blockId, + commentId: comment.id, + author: comment.author, + text: evidence.text, + ts, + } +} + +// Legacy helper kept for tests and the (disabled) inbox_enqueue_from_event tool. +export function classifyEvent(event: ActivityEvent & {comment?: any; document?: any}, kmAccountId: string): Mention | null { + if (event.comment) { + const c = event.comment as {id?: string; target?: string; body?: string; author?: string; time?: string; blockId?: string} + if (!c.body || !c.target || !c.author || !c.id) return null + if (!mentionsAccount(c.body, kmAccountId)) return null + return { + kind: 'comment', + docId: stripFragment(c.target), + blockId: extractBlockId(c.target) ?? c.blockId, + commentId: c.id, + author: c.author, + text: c.body, + ts: c.time ?? new Date().toISOString(), + } + } + if (event.document) { + const d = event.document as {id?: string; blocks?: Array<{id?: string; text?: string}>; author?: string; time?: string} + if (!d.id || !d.author || !Array.isArray(d.blocks)) return null + for (const block of d.blocks) { + if (block.text && mentionsAccount(block.text, kmAccountId)) { + return { + kind: 'doc-block', + docId: d.id, + blockId: block.id, + author: d.author, + text: block.text, + ts: d.time ?? new Date().toISOString(), + } + } + } + } + return null +} + +export function extractBlockId(target: string): string | undefined { + const m = target.match(/#([^?]+)/) + return m?.[1] +} + +export function stripFragment(target: string): string { + return target.split('#')[0]! +} + +export function buildReplyTarget(m: Mention): {targetId: string; replyTo?: string} { + if (m.kind === 'comment') { + // Threaded reply. The blockId on the mention belongs to a block of + // the *comment* (not the parent doc), so we MUST NOT append it to + // the doc URL — that would render as a broken doc-block embed. + // Threading is handled via --reply <commentId>. + return { + targetId: m.docId, + replyTo: m.commentId, + } + } + // doc-block: anchor a top-level comment to the doc block carrying + // the mention. + if (m.blockId) return {targetId: `${m.docId}#${m.blockId}`} + return {targetId: m.docId} +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.test.ts new file mode 100644 index 0000000000..297e767792 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.test.ts @@ -0,0 +1,28 @@ +import {describe, expect, it} from 'bun:test' +import {buildRedactor} from './redact.js' + +describe('buildRedactor', () => { + it('redacts known secrets', () => { + const r = buildRedactor({DEEPSEEK_API_KEY: 'sk-aaaaaaaaaaaa'} as NodeJS.ProcessEnv) + expect(r('hello sk-aaaaaaaaaaaa world')).toBe('hello ***REDACTED*** world') + }) + + it('redacts longest first to avoid partial overlaps', () => { + const r = buildRedactor({ + DEEPSEEK_API_KEY: 'sk-long-secret-12345', + OPENAI_API_KEY: 'sk-shorter', + } as NodeJS.ProcessEnv) + const out = r('value=sk-long-secret-12345 other=sk-shorter') + expect(out).toBe('value=***REDACTED*** other=***REDACTED***') + }) + + it('passes through when no secrets configured', () => { + const r = buildRedactor({}) + expect(r('plain string')).toBe('plain string') + }) + + it('ignores empty / short values', () => { + const r = buildRedactor({DEEPSEEK_API_KEY: 'short'} as NodeJS.ProcessEnv) + expect(r('contains short value')).toBe('contains short value') + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.ts new file mode 100644 index 0000000000..cf3315ba01 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.ts @@ -0,0 +1,39 @@ +/** + * Redacts secret values from arbitrary strings. Built once at startup from + * env-var values that look like secrets (tokens, API keys, mnemonics). + * + * Anything ≥ 8 characters that matches a known secret env var is replaced + * with `***REDACTED***`. JSON / log output is post-processed through this + * before being persisted, so the run dir never contains raw secrets. + */ + +export type Redactor = (input: string) => string + +const SECRET_ENV_KEYS = [ + 'DEEPSEEK_API_KEY', + 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', + 'TELEGRAM_TOKEN', + 'KM_MNEMONIC', +] + +export function buildRedactor(env: NodeJS.ProcessEnv = process.env): Redactor { + const needles: string[] = [] + for (const key of SECRET_ENV_KEYS) { + const v = env[key] + if (typeof v === 'string' && v.length >= 8) needles.push(v) + } + // Deduplicate and sort longest-first so substring matches don't break + // longer secrets. + const unique = Array.from(new Set(needles)).sort((a, b) => b.length - a.length) + if (unique.length === 0) return (s) => s + return (input) => { + let out = input + for (const n of unique) { + if (n && out.includes(n)) { + out = out.split(n).join('***REDACTED***') + } + } + return out + } +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.test.ts new file mode 100644 index 0000000000..4da8df91c1 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.test.ts @@ -0,0 +1,29 @@ +import {describe, expect, it} from 'bun:test' +import {isDenied} from './seedcli.js' + +describe('isDenied', () => { + it('blocks key mutations', () => { + expect(isDenied('key', 'generate')).toBe(true) + expect(isDenied('key', 'remove')).toBe(true) + expect(isDenied('key', 'import')).toBe(true) + expect(isDenied('key', 'rename')).toBe(true) + }) + + it('allows key reads (needed at boot for accountId resolution)', () => { + expect(isDenied('key', 'list')).toBe(false) + expect(isDenied('key', 'show')).toBe(false) + expect(isDenied('key', 'default')).toBe(false) + expect(isDenied('key', 'derive')).toBe(false) + }) + + it('blocks capability mutations', () => { + expect(isDenied('capability', 'create')).toBe(true) + }) + + it('allows ordinary read commands', () => { + expect(isDenied('document', 'get')).toBe(false) + expect(isDenied('comment', 'list')).toBe(false) + expect(isDenied('search', '')).toBe(false) + expect(isDenied('activity', '')).toBe(false) + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.ts new file mode 100644 index 0000000000..539e04a324 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.ts @@ -0,0 +1,139 @@ +/** + * Thin typed wrapper around `seed-cli`. Every invocation is recorded in + * `seed-cli.jsonl` of the current audit run with full argv, exit code, + * and (truncated) stdout/stderr. + * + * Hard denylist: certain subcommands are refused unconditionally to keep + * the rules doc from being able to weaken security. Writes always force + * `--key <agent-key>` and `-s <SEED_SERVER>`. + */ + +import {spawn} from 'node:child_process' +import type {AgentConfig} from './config.js' +import type {AuditRun} from './audit.js' +import type {Redactor} from './redact.js' + +const STDOUT_TRUNCATE_BYTES = 64 * 1024 + +/** + * Hardcoded denylist of `<command>:<subcommand>` pairs that are NEVER + * permitted — even if the rules doc tries to enable them. Read-only key + * operations (`key list`, `key show`, `key default`, `key derive`) are + * allowed because the wrapper itself needs them at boot to resolve the + * agent's accountId. + */ +const DENY_VERB_PAIRS = new Set([ + // Anything that mutates the keystore. + 'key:generate', + 'key:import', + 'key:remove', + 'key:rename', + // Anything that mutates the capability graph. + 'capability:create', + // Account profile mutations are owner-only. + 'account:set', + 'account:remove', +]) + +export class SeedCliError extends Error { + readonly code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } +} + +export type SeedCliResult = { + exitCode: number + stdout: string + stderr: string + parsedJson?: unknown +} + +export class SeedCli { + constructor( + private readonly config: AgentConfig, + private readonly redactor: Redactor, + private readonly audit?: AuditRun, + ) {} + + /** Read-only commands. No --key injection. */ + async runRead(args: string[]): Promise<SeedCliResult> { + return this.run(args, {requireKey: false}) + } + + /** + * Write commands. Forces `--key <agent>` and `-s <server>`. Refuses anything + * in the deny list before spawning. + */ + async runWrite(args: string[]): Promise<SeedCliResult> { + return this.run(args, {requireKey: true}) + } + + private async run(args: string[], opts: {requireKey: boolean}): Promise<SeedCliResult> { + if (args.length === 0) { + throw new SeedCliError('EMPTY_ARGS', 'seed-cli invoked with no arguments') + } + const pair = `${args[0] ?? ''}:${args[1] ?? ''}` + if (DENY_VERB_PAIRS.has(pair)) { + throw new SeedCliError('DENIED_SUBCOMMAND', `seed-cli "${pair}" is denied by hardcoded policy`) + } + const finalArgs: string[] = ['-s', this.config.seedServer, ...args] + if (opts.requireKey && !args.includes('--key') && !args.includes('-k')) { + finalArgs.push('--key', this.config.keyName) + } + const tsStart = new Date().toISOString() + const t0 = Date.now() + const {exitCode, stdout, stderr} = await spawnCapture(this.config.cliPath, finalArgs) + const tsEnd = new Date().toISOString() + const latencyMs = Date.now() - t0 + if (this.audit) { + this.audit.seedCli({ + ts_start: tsStart, + ts_end: tsEnd, + latency_ms: latencyMs, + argv: [this.config.cliPath, ...finalArgs], + exit_code: exitCode, + stdout: this.redactor(truncate(stdout)), + stderr: this.redactor(truncate(stderr)), + }) + } + let parsedJson: unknown + if (stdout.trim().startsWith('{') || stdout.trim().startsWith('[')) { + try { + parsedJson = JSON.parse(stdout) + } catch { + /* not JSON, ignore */ + } + } + return {exitCode, stdout, stderr, parsedJson} + } +} + +function truncate(s: string): string { + if (Buffer.byteLength(s) <= STDOUT_TRUNCATE_BYTES) return s + return s.slice(0, STDOUT_TRUNCATE_BYTES) + `\n…[truncated]` +} + +function spawnCapture(cmd: string, args: string[]): Promise<{exitCode: number; stdout: string; stderr: string}> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: {...process.env}, + }) + let stdout = '' + let stderr = '' + child.stdout?.on('data', (d) => (stdout += d.toString())) + child.stderr?.on('data', (d) => (stderr += d.toString())) + child.on('error', reject) + child.on('close', (code) => { + resolve({exitCode: typeof code === 'number' ? code : -1, stdout, stderr}) + }) + }) +} + +// Test helper: split out the deny-list check so unit tests can exercise it +// without a real CLI binary on disk. +export function isDenied(command: string, subcommand: string): boolean { + return DENY_VERB_PAIRS.has(`${command}:${subcommand}`) +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.test.ts new file mode 100644 index 0000000000..c7c07c3621 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.test.ts @@ -0,0 +1,58 @@ +import {describe, expect, it, beforeEach, afterEach} from 'bun:test' +import {mkdtempSync, rmSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {State, mentionKey} from './state.js' +import type {Mention} from './mentions.js' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'km-state-')) +}) + +afterEach(() => { + if (dir && dir !== '/') rmSync(dir, {recursive: true, force: true}) +}) + +const sampleMention: Mention = { + kind: 'comment', + docId: 'hm://site/doc', + commentId: 'bafy1', + author: 'z6Mkauthor', + text: '@[KM](hm://z6Mkx) hi', + ts: '2026-05-05T00:00:00Z', +} + +describe('State.cursor', () => { + it('round-trips', () => { + const s = new State(dir) + expect(s.getCursor()).toBeNull() + s.setCursor('tok-1') + expect(s.getCursor()).toBe('tok-1') + s.setCursor('tok-2') + expect(s.getCursor()).toBe('tok-2') + }) +}) + +describe('State.inbox', () => { + it('FIFO pop', () => { + const s = new State(dir) + s.enqueue(sampleMention) + s.enqueue({...sampleMention, commentId: 'bafy2'}) + expect(s.inboxSize()).toBe(2) + expect(s.popFromInbox()?.commentId).toBe('bafy1') + expect(s.popFromInbox()?.commentId).toBe('bafy2') + expect(s.popFromInbox()).toBeNull() + }) +}) + +describe('State.processed', () => { + it('idempotency: enqueue skips already processed', () => { + const s = new State(dir) + s.markProcessed(sampleMention, 'run-1', 'replied') + expect(s.isProcessed(mentionKey(sampleMention))).toBe(true) + s.enqueue(sampleMention) + expect(s.inboxSize()).toBe(0) + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.ts new file mode 100644 index 0000000000..8f569f5f2f --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.ts @@ -0,0 +1,228 @@ +/** + * Ephemeral runtime state on disk: + * + * activity-cursor.json — last activity event token consumed by the poller + * inbox.jsonl — pending mentions queued for processing + * processed.jsonl — mentions already answered (idempotency) + * rate-counters.json — per-day / per-run counters (limits.ts) + * + * Files live under `${stateDir}` (default `/home/km/km-state`). All writes + * go through `O_APPEND` (jsonl) or atomic rename (json) so a crash never + * corrupts state. Wrapper exposes inbox_pop / inbox_mark_done / cursor_* + * tools to the LLM so the orchestration loop is observable but state + * mutation is controlled. + */ + +import {appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' +import type {Mention} from './mentions.js' +import type {RateState} from './limits.js' +import {newRateState} from './limits.js' + +const CURSOR_FILE = 'activity-cursor.json' +const INBOX_FILE = 'inbox.jsonl' +const PROCESSED_FILE = 'processed.jsonl' +const RATE_FILE = 'rate-counters.json' +const PLACEHOLDERS_FILE = 'placeholders.jsonl' + +/** + * Pending placeholder reply: a comment we already posted on Seed but + * have not yet replaced with the final answer. Used by the two-pass + * polling loop (placeholder first, then DeepSeek + edit). Survives + * process crashes. + */ +export type PlaceholderRecord = { + mentionId: string // mentionKey(...) + placeholderId: string // commentId returned by `comment create` + postedAt: string + /** Original mention payload — kept so the next run can build a reply + * without re-fetching/re-classifying the source comment. */ + mention: import('./mentions.js').Mention + /** Whether the placeholder has been replaced via `comment edit`. */ + finalised: boolean +} + +export class State { + constructor(private readonly stateDir: string) { + if (!existsSync(stateDir)) mkdirSync(stateDir, {recursive: true, mode: 0o700}) + } + + // ─── cursor ──────────────────────────────────────────────────────────────── + // + // We track the latest activity-event id we've already classified. Each + // poll fetches the first page of activity (newest-first) and stops as + // soon as it sees this id. Stored as a small JSON blob for forward + // compatibility (future: track per-resource cursors). + + getCursor(): string | null { + return this.readJson<{lastEventId?: string} | null>(CURSOR_FILE, null)?.lastEventId ?? null + } + + setCursor(eventId: string): void { + this.writeJsonAtomic(CURSOR_FILE, {lastEventId: eventId, ts: new Date().toISOString()}) + } + + // ─── inbox ───────────────────────────────────────────────────────────────── + + enqueue(mention: Mention): void { + if (this.isProcessed(mentionKey(mention))) return + appendFileSync(join(this.stateDir, INBOX_FILE), JSON.stringify(mention) + '\n') + } + + /** Returns and removes the oldest queued mention, if any. */ + popFromInbox(): Mention | null { + const path = join(this.stateDir, INBOX_FILE) + if (!existsSync(path)) return null + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + if (lines.length === 0) return null + const first = lines.shift()! + writeFileSync(path, lines.length ? lines.join('\n') + '\n' : '', {mode: 0o600}) + try { + return JSON.parse(first) as Mention + } catch { + return null + } + } + + inboxSize(): number { + const path = join(this.stateDir, INBOX_FILE) + if (!existsSync(path)) return 0 + return readFileSync(path, 'utf-8').split('\n').filter(Boolean).length + } + + // ─── processed (idempotency) ─────────────────────────────────────────────── + + markProcessed(mention: Mention, runId: string, status: 'replied' | 'not-allowed' | 'error'): void { + const record = {key: mentionKey(mention), runId, status, ts: new Date().toISOString()} + appendFileSync(join(this.stateDir, PROCESSED_FILE), JSON.stringify(record) + '\n') + } + + isProcessed(key: string): boolean { + const path = join(this.stateDir, PROCESSED_FILE) + if (!existsSync(path)) return false + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + for (const line of lines) { + try { + const r = JSON.parse(line) as {key?: string} + if (r.key === key) return true + } catch { + /* skip */ + } + } + return false + } + + // ─── rate counters ───────────────────────────────────────────────────────── + + /** + * Returns the persisted per-day counters, plus an empty per-run counter + * map. `perRun` is by definition scoped to the current process — we + * never restore it from disk. Storing it on disk would conflate + * separate invocations and turn a "max per run" limit into a "max ever" + * limit, which is what we used to do (bug). + */ + getRateState(): RateState { + const persisted = this.readJson<RateState>(RATE_FILE, newRateState()) + return {...persisted, perRun: {}} + } + + /** + * Persists only the per-day portion. `perRun` is dropped on write. + */ + setRateState(state: RateState): void { + this.writeJsonAtomic(RATE_FILE, {day: state.day, perDay: state.perDay, perRun: {}}) + } + + // ─── placeholders (typing-indicator) ─────────────────────────────────────── + + /** + * Record a freshly-posted placeholder. Append-only so a crash never + * leaves us unsure whether the comment was created. + */ + recordPlaceholder(rec: PlaceholderRecord): void { + appendFileSync(join(this.stateDir, PLACEHOLDERS_FILE), JSON.stringify(rec) + '\n') + } + + /** All placeholders that haven't been finalised yet. */ + pendingPlaceholders(): PlaceholderRecord[] { + const path = join(this.stateDir, PLACEHOLDERS_FILE) + if (!existsSync(path)) return [] + const out: PlaceholderRecord[] = [] + // Walk backwards so the latest record for a mention wins. + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + const seen = new Map<string, PlaceholderRecord>() + for (const line of lines) { + try { + const rec = JSON.parse(line) as PlaceholderRecord + seen.set(rec.mentionId, rec) + } catch { + /* skip */ + } + } + for (const rec of seen.values()) { + if (!rec.finalised) out.push(rec) + } + return out + } + + /** Mark a placeholder finalised (replaced via comment edit). */ + finalisePlaceholder(mentionId: string, placeholderId: string): void { + const path = join(this.stateDir, PLACEHOLDERS_FILE) + if (!existsSync(path)) return + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + const out: string[] = [] + for (const line of lines) { + try { + const rec = JSON.parse(line) as PlaceholderRecord + if (rec.mentionId === mentionId && rec.placeholderId === placeholderId) { + rec.finalised = true + out.push(JSON.stringify(rec)) + } else { + out.push(line) + } + } catch { + out.push(line) + } + } + writeFileSync(path, out.join('\n') + '\n', {mode: 0o600}) + } + + /** Was this mention already given a placeholder? */ + hasPlaceholderFor(mentionId: string): boolean { + const path = join(this.stateDir, PLACEHOLDERS_FILE) + if (!existsSync(path)) return false + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + for (const line of lines) { + try { + const rec = JSON.parse(line) as PlaceholderRecord + if (rec.mentionId === mentionId) return true + } catch { + /* skip */ + } + } + return false + } + + // ─── helpers ─────────────────────────────────────────────────────────────── + + private readJson<T>(file: string, fallback: T): T { + const path = join(this.stateDir, file) + if (!existsSync(path)) return fallback + try { + return JSON.parse(readFileSync(path, 'utf-8')) as T + } catch { + return fallback + } + } + + private writeJsonAtomic(file: string, value: unknown): void { + const path = join(this.stateDir, file) + const tmp = path + '.tmp' + writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', {mode: 0o600}) + renameSync(tmp, path) + } +} + +export function mentionKey(m: Mention): string { + return m.kind === 'comment' ? `c:${m.commentId}` : `d:${m.docId}#${m.blockId ?? ''}` +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts new file mode 100644 index 0000000000..2eade26a74 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts @@ -0,0 +1,605 @@ +/** + * MCP tool registry. Each tool wraps either: + * - a read operation on `seed-cli`, + * - a write operation that goes through governance + rate-limit checks, + * - or a state-mutation helper used by the LLM's polling loop. + * + * Tool inputs are validated with zod; outputs are returned as JSON + * payloads conforming to the MCP `CallToolResult` shape. + */ + +import {z} from 'zod' +import type {Server} from '@modelcontextprotocol/sdk/server/index.js' +import {CallToolRequestSchema, ListToolsRequestSchema} from '@modelcontextprotocol/sdk/types.js' +import type {AgentConfig} from './config.js' +import type {AuditRun} from './audit.js' +import type {SeedCli} from './seedcli.js' +import type {GovernanceCache} from './governance.js' +import type {State} from './state.js' +import type {Mention} from './mentions.js' +import { + classifyEvent, + mentionsAccount, + buildReplyTarget, + commentEventCandidate, + findKmMentionInComment, + buildCommentMention, +} from './mentions.js' +import type {SeedComment} from './mentions.js' +import {bump, checkCap, isWriteAllowed} from './limits.js' + +type ToolDef = { + name: string + description: string + inputSchema: object + call: (input: unknown) => Promise<unknown> +} + +export type ToolDeps = { + config: AgentConfig + cli: SeedCli + governance: GovernanceCache + state: State + audit: AuditRun + /** Resolved at boot from `seed-cli key list`. */ + kmAccountId: string +} + +export function buildTools(deps: ToolDeps): ToolDef[] { + const {config, cli, governance, state, audit, kmAccountId} = deps + + const tools: ToolDef[] = [] + + tools.push({ + name: 'seed_get_governance', + description: 'Fetch and return the agent\'s governance documents (charter, rules, runbook, allowlist) parsed from the target Seed site. Cached for 60s.', + inputSchema: z.object({force: z.boolean().optional()}).describe('force=true bypasses cache.'), + async call(input) { + const args = z.object({force: z.boolean().optional()}).parse(input ?? {}) + const g = await governance.getGovernance(args.force ?? false) + audit.trace({ts: nowIso(), level: 'info', event: 'governance_loaded', data: {fetchedAt: g.fetchedAt}}) + return { + rules: g.rules, + allowlist: g.allowlist, + charter: g.charter, + runbook: g.runbook, + fetchedAt: g.fetchedAt, + } + }, + }) + + tools.push({ + name: 'seed_search', + description: 'Search documents in the target site. Wraps `seed-cli search`.', + inputSchema: z.object({query: z.string(), limit: z.number().int().min(1).max(50).optional()}), + async call(input) { + const args = z.object({query: z.string(), limit: z.number().int().optional()}).parse(input) + const argv = ['search', args.query, '-a', stripHm(config.seedSite)] + if (args.limit) argv.push('--limit', String(args.limit)) + const r = await cli.runRead(argv) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + tools.push({ + name: 'seed_query_space', + description: 'List documents under the target site (or a path prefix). Wraps `seed-cli query`.', + inputSchema: z.object({ + path: z.string().optional(), + mode: z.enum(['Children', 'AllDescendants']).optional(), + limit: z.number().int().min(1).max(200).optional(), + sort: z.enum(['Path', 'Title', 'CreateTime', 'UpdateTime', 'DisplayTime']).optional(), + reverse: z.boolean().optional(), + }), + async call(input) { + const a = z + .object({ + path: z.string().optional(), + mode: z.string().optional(), + limit: z.number().int().optional(), + sort: z.string().optional(), + reverse: z.boolean().optional(), + }) + .parse(input ?? {}) + const argv = ['query', config.seedSite] + if (a.path) argv.push('--path', a.path) + if (a.mode) argv.push('--mode', a.mode) + if (a.limit) argv.push('--limit', String(a.limit)) + if (a.sort) argv.push('--sort', a.sort) + if (a.reverse) argv.push('--reverse') + const r = await cli.runRead(argv) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + tools.push({ + name: 'seed_get_document', + description: 'Fetch a document as Markdown with frontmatter. Wraps `seed-cli document get`.', + inputSchema: z.object({id: z.string()}), + async call(input) { + const a = z.object({id: z.string()}).parse(input) + const r = await cli.runRead(['document', 'get', a.id]) + return {markdown: r.stdout, exitCode: r.exitCode} + }, + }) + + tools.push({ + name: 'seed_list_comments', + description: 'List comments on a document. Wraps `seed-cli comment list`.', + inputSchema: z.object({targetId: z.string()}), + async call(input) { + const a = z.object({targetId: z.string()}).parse(input) + const r = await cli.runRead(['comment', 'list', a.targetId]) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + tools.push({ + name: 'seed_get_activity', + description: 'Fetch activity events for the target site. Optional cursor token for pagination. Wraps `seed-cli activity`.', + inputSchema: z.object({ + token: z.string().optional(), + limit: z.number().int().min(1).max(500).optional(), + resource: z.string().optional(), + }), + async call(input) { + const a = z.object({token: z.string().optional(), limit: z.number().int().optional(), resource: z.string().optional()}).parse(input ?? {}) + const argv = ['activity'] + if (a.token) argv.push('--token', a.token) + if (a.limit) argv.push('--limit', String(a.limit)) + argv.push('--resource', a.resource ?? config.seedSite) + const r = await cli.runRead(argv) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + tools.push({ + name: 'seed_get_citations', + description: 'List documents/comments that cite a given Hypermedia ID. Wraps `seed-cli citations`.', + inputSchema: z.object({id: z.string()}), + async call(input) { + const a = z.object({id: z.string()}).parse(input) + const r = await cli.runRead(['citations', a.id]) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + tools.push({ + name: 'seed_list_capabilities', + description: 'List capabilities granted on a Seed account/site. Used to derive the WRITER set. Wraps `seed-cli account capabilities`.', + inputSchema: z.object({accountId: z.string().optional()}), + async call(input) { + const a = z.object({accountId: z.string().optional()}).parse(input ?? {}) + const target = a.accountId ?? config.seedSite + const r = await cli.runRead(['account', 'capabilities', target]) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + // ─── writes ────────────────────────────────────────────────────────────── + + tools.push({ + name: 'seed_create_document', + description: 'Create a document at a path on the target site. Enforces governance.allowWritePaths, denyWritePaths, draft_only kill-switch, max_documents_per_run cap.', + inputSchema: z.object({ + path: z.string(), + title: z.string(), + bodyMarkdown: z.string(), + }), + async call(input) { + const a = z.object({path: z.string(), title: z.string(), bodyMarkdown: z.string()}).parse(input) + const g = await governance.getGovernance() + if (g.rules.draftOnly) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {path: a.path, reason: 'draft_only'}}) + return {written: false, reason: 'draft_only'} + } + const allow = isWriteAllowed(a.path, g.rules) + if (!allow.allowed) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {path: a.path, reason: allow.reason}}) + return {written: false, reason: allow.reason} + } + const cap = checkCap(state.getRateState(), 'documents', g.rules) + if (!cap.allowed) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {path: a.path, reason: cap.reason}}) + return {written: false, reason: cap.reason} + } + // Write body to temp file because seed-cli expects --file. + const tmpFile = await writeTempMarkdown(a.bodyMarkdown) + const argv = [ + 'document', + 'create', + '--account', + stripHm(config.seedSite), + '--path', + a.path, + '--name', + a.title, + '--file', + tmpFile, + ] + const r = await cli.runWrite(argv) + state.setRateState(bump(state.getRateState(), 'documents')) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'document_created', + data: {path: a.path, exit: r.exitCode, link: parseLinkFromStdout(r.stdout)}, + }) + return {written: r.exitCode === 0, exitCode: r.exitCode, stdout: r.stdout} + }, + }) + + tools.push({ + name: 'seed_update_document', + description: 'Update an existing document. Same governance + rate checks as create.', + inputSchema: z.object({id: z.string(), bodyMarkdown: z.string(), title: z.string().optional(), summary: z.string().optional()}), + async call(input) { + const a = z.object({id: z.string(), bodyMarkdown: z.string(), title: z.string().optional(), summary: z.string().optional()}).parse(input) + const g = await governance.getGovernance() + const path = pathFromHmId(a.id) + if (g.rules.draftOnly) return {written: false, reason: 'draft_only'} + const allow = isWriteAllowed(path, g.rules) + if (!allow.allowed) return {written: false, reason: allow.reason} + const cap = checkCap(state.getRateState(), 'documents', g.rules) + if (!cap.allowed) return {written: false, reason: cap.reason} + const tmpFile = await writeTempMarkdown(a.bodyMarkdown) + const argv = ['document', 'update', a.id, '-f', tmpFile] + if (a.title) argv.push('--title', a.title) + if (a.summary) argv.push('--summary', a.summary) + const r = await cli.runWrite(argv) + state.setRateState(bump(state.getRateState(), 'documents')) + return {written: r.exitCode === 0, exitCode: r.exitCode, stdout: r.stdout} + }, + }) + + tools.push({ + name: 'seed_create_comment', + description: 'Create a comment on a document or block. Enforces draft_only and per-run/per-day caps.', + inputSchema: z.object({ + targetId: z.string().describe('hm://… optionally with #blockId'), + body: z.string(), + }), + async call(input) { + const a = z.object({targetId: z.string(), body: z.string()}).parse(input) + const g = await governance.getGovernance() + const cap = checkCap(state.getRateState(), 'comments', g.rules) + if (!cap.allowed) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {target: a.targetId, reason: cap.reason}}) + return {posted: false, reason: cap.reason} + } + // draft_only does NOT block comments (comments are how the agent + // communicates regardless). It only blocks document writes. + const argv = ['comment', 'create', a.targetId, '--body', a.body] + const r = await cli.runWrite(argv) + state.setRateState(bump(state.getRateState(), 'comments')) + audit.trace({ts: nowIso(), level: 'info', event: 'comment_posted', data: {target: a.targetId, exit: r.exitCode}}) + return {posted: r.exitCode === 0, exitCode: r.exitCode, stdout: r.stdout} + }, + }) + + tools.push({ + name: 'seed_reply_comment', + description: 'Reply to an existing comment (creates a thread). Same caps as seed_create_comment.', + inputSchema: z.object({targetId: z.string(), parentCommentId: z.string(), body: z.string()}), + async call(input) { + const a = z.object({targetId: z.string(), parentCommentId: z.string(), body: z.string()}).parse(input) + const g = await governance.getGovernance() + const cap = checkCap(state.getRateState(), 'comments', g.rules) + if (!cap.allowed) return {posted: false, reason: cap.reason} + const argv = ['comment', 'create', a.targetId, '--reply', a.parentCommentId, '--body', a.body] + const r = await cli.runWrite(argv) + state.setRateState(bump(state.getRateState(), 'comments')) + return {posted: r.exitCode === 0, exitCode: r.exitCode, stdout: r.stdout} + }, + }) + + // ─── state helpers ─────────────────────────────────────────────────────── + + tools.push({ + name: 'cursor_get', + description: 'Read the activity-cursor token used by the polling loop.', + inputSchema: z.object({}).strict(), + async call() { + return {token: state.getCursor()} + }, + }) + + tools.push({ + name: 'cursor_set', + description: 'Write the activity-cursor token for the polling loop.', + inputSchema: z.object({token: z.string()}), + async call(input) { + const a = z.object({token: z.string()}).parse(input) + state.setCursor(a.token) + return {ok: true} + }, + }) + + tools.push({ + name: 'inbox_pop', + description: 'Pop the oldest pending mention from the inbox queue.', + inputSchema: z.object({}).strict(), + async call() { + return {mention: state.popFromInbox()} + }, + }) + + tools.push({ + name: 'inbox_size', + description: 'Number of mentions waiting in the inbox queue.', + inputSchema: z.object({}).strict(), + async call() { + return {size: state.inboxSize()} + }, + }) + + tools.push({ + name: 'inbox_mark_done', + description: 'Record a mention as processed (idempotency). Status: replied | not-allowed | error.', + inputSchema: z.object({ + mention: z.unknown(), + runId: z.string(), + status: z.enum(['replied', 'not-allowed', 'error']), + }), + async call(input) { + const a = z.object({mention: z.unknown(), runId: z.string(), status: z.enum(['replied', 'not-allowed', 'error'])}).parse(input) + state.markProcessed(a.mention as Mention, a.runId, a.status) + return {ok: true} + }, + }) + + tools.push({ + name: 'inbox_enqueue_from_event', + description: 'Classify a raw activity event for a mention of the agent and enqueue it if matched. Returns the parsed mention or null.', + inputSchema: z.object({event: z.unknown()}), + async call(input) { + const a = z.object({event: z.unknown()}).parse(input) + const mention = classifyEvent(a.event as Parameters<typeof classifyEvent>[0], kmAccountId) + if (mention) state.enqueue(mention) + return {mention} + }, + }) + + tools.push({ + name: 'mention_target_for_reply', + description: 'Given a mention, returns the {targetId, replyTo?} payload to pass to seed_create_comment / seed_reply_comment.', + inputSchema: z.object({mention: z.unknown()}), + async call(input) { + const a = z.object({mention: z.unknown()}).parse(input) + return buildReplyTarget(a.mention as Mention) + }, + }) + + tools.push({ + name: 'check_mention_text', + description: 'Returns true if the given text contains a mention of the agent\'s accountId.', + inputSchema: z.object({text: z.string()}), + async call(input) { + const a = z.object({text: z.string()}).parse(input) + return {mentions: mentionsAccount(a.text, kmAccountId)} + }, + }) + + tools.push({ + name: 'poll_collect', + description: + "Deterministic poll loop step. Loads governance + writer capabilities, fetches activity since last cursor, filters for mentions of the agent by allowed invokers, enqueues them, advances the cursor, and returns the queue. After this returns, the LLM should iterate over `pending` and call seed_reply_comment / seed_create_comment + inbox_mark_done for each.", + inputSchema: z.object({}).strict(), + async call() { + const g = await governance.getGovernance() + // Resolve allowed invoker set. + let allowedInvokers: Set<string> + if (g.rules.mentions.invokerSource === 'allowlist-doc') { + allowedInvokers = new Set(g.allowlist.invokers) + } else { + const capsResult = await cli.runRead(['account', 'capabilities', config.seedSite]) + const writers = new Set<string>() + try { + const parsed = capsResult.parsedJson as {capabilities?: Array<{delegate?: string; role?: string}>} + for (const c of parsed.capabilities ?? []) { + if (c.role === 'WRITER' && c.delegate) writers.add(c.delegate) + } + } catch { + /* ignore */ + } + // The site account itself counts as a writer. + writers.add(stripHm(config.seedSite)) + allowedInvokers = writers + } + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'poll_collect_writers', + data: {count: allowedInvokers.size}, + }) + // Activity feed is reverse-chronological. We fetch the first page + // and stop walking as soon as we hit the lastEventId we processed + // last poll. Comment bodies are not in the feed, so for each + // candidate comment event we fetch the full comment via + // `comment get` and inspect its annotations for an Embed link to + // the agent's accountId. + const lastSeenId = state.getCursor() + // First-time runs (no cursor) shouldn't backfill the entire history + // through `comment get` calls — cap the work we do per poll. + const MAX_COMMENT_FETCHES = 25 + // Note: the activity feed's `--resource` filter is exact-match on the + // doc path, so filtering by the site root would exclude comments + // posted on subdocuments (`/discussions/...`, `/agents/...`). We pull + // the unfiltered feed and post-filter by `comment.targetAccount`. + const r = await cli.runRead(['activity', '--limit', '50']) + const siteAccount = stripHm(config.seedSite) + let events: Array<{id?: string; type?: string; time?: string; author?: string | {id?: {uid?: string}}}> = [] + try { + const parsed = r.parsedJson as {events?: typeof events} + events = parsed.events ?? [] + } catch { + /* ignore */ + } + let newestEventId: string | undefined + const blocked = new Set(g.rules.moderation.blockedAuthors) + let enqueued = 0 + let skippedNotAllowed = 0 + let scannedComments = 0 + let exhaustedBudget = false + for (const ev of events) { + if (!newestEventId && ev.id) newestEventId = ev.id + if (lastSeenId && ev.id === lastSeenId) break + if (scannedComments >= MAX_COMMENT_FETCHES) { + exhaustedBudget = true + break + } + const candidate = commentEventCandidate(ev) + if (!candidate) continue + scannedComments++ + // Fetch the full comment to inspect annotations. + const cr = await cli.runRead(['comment', 'get', candidate.commentId]) + if (cr.exitCode !== 0 || !cr.parsedJson) continue + const comment = cr.parsedJson as SeedComment + // Skip comments not targeting our site. + if (comment.targetAccount !== siteAccount) continue + // Don't reply to ourselves. + if (comment.author === kmAccountId) continue + const evidence = findKmMentionInComment(comment, [kmAccountId, siteAccount]) + if (!evidence) continue + const mention = buildCommentMention(comment, evidence, candidate.ts) + if (blocked.has(mention.author)) continue + if (!allowedInvokers.has(mention.author)) { + state.markProcessed(mention, audit.meta.runId, 'not-allowed') + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'mention_skipped_not_allowed', + data: {author: mention.author, kind: mention.kind, docId: mention.docId}, + }) + skippedNotAllowed++ + continue + } + state.enqueue(mention) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'mention_enqueued', + data: { + author: mention.author, + kind: mention.kind, + docId: mention.docId, + commentId: mention.commentId, + blockId: mention.blockId, + }, + }) + enqueued++ + } + if (newestEventId) state.setCursor(newestEventId) + // Drain inbox up to the per-run comment cap into the response. + const cap = g.rules.caps.maxCommentsPerRun + const pending: unknown[] = [] + for (let i = 0; i < cap; i++) { + const m = state.popFromInbox() + if (!m) break + const target = buildReplyTarget(m) + pending.push({mention: m, target}) + } + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'poll_collect_done', + data: { + events: events.length, + scannedComments, + enqueued, + skippedNotAllowed, + pendingForReply: pending.length, + cursorAdvanced: Boolean(newestEventId), + exhaustedBudget, + }, + }) + return { + eventsScanned: events.length, + scannedComments, + enqueued, + skippedNotAllowed, + pending, + cursorAdvanced: Boolean(newestEventId), + newestEventId: newestEventId ?? null, + exhaustedBudget, + runId: audit.meta.runId, + } + }, + }) + + return tools +} + +export function registerToolHandlers(server: Server, tools: ToolDef[]): void { + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: jsonSchema(t.inputSchema), + })), + })) + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const tool = tools.find((t) => t.name === req.params.name) + if (!tool) { + return {content: [{type: 'text' as const, text: `unknown tool: ${req.params.name}`}], isError: true} + } + const start = Date.now() + const tsStart = nowIso() + try { + const result = await tool.call(req.params.arguments) + return {content: [{type: 'text' as const, text: JSON.stringify(result)}], _meta: {tsStart, latencyMs: Date.now() - start}} + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { + content: [{type: 'text' as const, text: JSON.stringify({error: msg})}], + isError: true, + _meta: {tsStart, latencyMs: Date.now() - start}, + } + } + }) +} + +// ─── helpers ────────────────────────────────────────────────────────────── + +function nowIso(): string { + return new Date().toISOString() +} + +function stripHm(id: string): string { + return id.replace(/^hm:\/\//, '').split('/')[0]! +} + +function pathFromHmId(id: string): string { + const stripped = id.replace(/^hm:\/\//, '') + const idx = stripped.indexOf('/') + return idx === -1 ? '/' : stripped.slice(idx) +} + +function parseLinkFromStdout(s: string): string | undefined { + const m = s.match(/https?:\/\/\S+/) + return m?.[0] +} + +async function writeTempMarkdown(body: string): Promise<string> { + const {writeFileSync} = await import('node:fs') + const {tmpdir} = await import('node:os') + const {join} = await import('node:path') + const path = join(tmpdir(), `km-doc-${Date.now()}-${Math.random().toString(36).slice(2)}.md`) + writeFileSync(path, body, {mode: 0o600}) + return path +} + +// MCP tools want a JSON Schema, not a zod schema. We accept either: if the +// caller passes a zod schema we call its `_def` extractor; otherwise pass +// through. (Production MCP servers use zod-to-json-schema; we keep it +// minimal here.) +function jsonSchema(s: object): object { + if (typeof (s as {_def?: unknown})._def !== 'undefined') { + // This is a zod schema. Provide a minimal, permissive schema; the LLM + // will see the description text. For production, swap in + // zod-to-json-schema. + return {type: 'object', additionalProperties: true} + } + return s +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/tsconfig.json b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/tsconfig.json new file mode 100644 index 0000000000..38210fb1e4 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": true, + "resolveJsonModule": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": false + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "src/__tests__/**"] +} From 0a77cb546aaa0021424335585e58e74e6eff5590 Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Wed, 6 May 2026 15:58:13 +0200 Subject: [PATCH 05/17] feat(agent): nanobot gateway config + systemd unit (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HKUDS/nanobot config that wires the MCP wrapper as the agent's only tool surface. DeepSeek as the LLM, restrictToWorkspace + bwrap sandbox on the built-in shell/files/web tools, custom MCP server registered as "seed". Pinned to port 18791 (default 18790 was already taken by another container on this multi-tenant host). Telegram channel intentionally disabled here — Phase 7 ships a dedicated Telegram driver instead. System prompt teaches DeepSeek the LAFH-driven /poll-mentions verb, enforces hard rules from the rules doc, and disables file/exec/web skills via disabledSkills. NOTE: nanobot ended up not being on the critical path — the polling loop was reimplemented as a deterministic Bun driver in Phase 5 because nanobot's tool-result-spilled-to-disk pattern made DeepSeek loop on read_file/grep instead of replying. The gateway service is kept around as an idle, optional REPL surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../agent/config/config.json | 54 +++++++++++++++++++ .../agent/systemd/nanobot-gateway.service | 14 +++++ 2 files changed, 68 insertions(+) create mode 100644 seed-knowledge-manager/agent/config/config.json create mode 100644 seed-knowledge-manager/agent/systemd/nanobot-gateway.service diff --git a/seed-knowledge-manager/agent/config/config.json b/seed-knowledge-manager/agent/config/config.json new file mode 100644 index 0000000000..965697e6ec --- /dev/null +++ b/seed-knowledge-manager/agent/config/config.json @@ -0,0 +1,54 @@ +{ + "providers": { + "deepseek": { + "apiKey": "${DEEPSEEK_API_KEY}" + } + }, + "agents": { + "defaults": { + "provider": "deepseek", + "model": "deepseek-chat", + "systemPrompt": "You are the Knowledge Manager for the Seed community at ${SEED_SITE}. Methodology lives in workspace/skill/SKILL.md.\n\nHard rules:\n- Cap per-run: 1 document, 5 comments. Cap per-day comments: 30.\n- If draft_only=true, never create a document — only comments.\n- Default language: en.\n\n=== When you receive the literal command `/poll-mentions` ===\n\nUse ONLY the following tools, in this exact sequence. Do NOT call exec, read_file, web_search, or any non-mcp_seed_* tool. Do NOT do additional research before replying.\n\n STEP 1. Call mcp_seed_poll_collect (no args). It returns `{pending: [{mention, target}]}`. The mention object already contains everything you need: `mention.text` is the user's full question, `mention.author` is the asker.\n\n STEP 2. For EACH entry in `pending`:\n a) Draft a 2–4 sentence reply. Use only what you can infer from `mention.text` plus your general knowledge. If you don't know the answer, acknowledge that and suggest where the asker might look. Reply must be plain text (no markdown headers, no code blocks). Keep it under 80 words.\n b) If `target.replyTo` is set, call mcp_seed_seed_reply_comment with {targetId: target.targetId, parentCommentId: target.replyTo, body: <draft>}. Otherwise call mcp_seed_seed_create_comment with {targetId: target.targetId, body: <draft>}.\n c) Call mcp_seed_inbox_mark_done with {mention: <the mention object from poll_collect>, runId: <runId from poll_collect>, status: 'replied'} (or 'error' if the previous call failed).\n\n STEP 3. Output ONE final line (plain text) summarising: events scanned, enqueued, replied, skipped.\n\nDo not call seed_search, seed_get_document, seed_list_comments, seed_get_activity, or any other tool during /poll-mentions. They are for free-form requests only.\n\n=== When invoked with any other prompt ===\n\nTreat it as a user request and answer using SKILL.md methodology. Free-form research tools are allowed.", + "temperature": 0.2, + "timezone": "UTC", + "idleCompactAfterMinutes": 15, + "disabledSkills": [ + "file_tools", + "read_file", + "write_file", + "edit_file", + "grep", + "glob", + "shell", + "exec", + "web" + ] + } + }, + "tools": { + "restrictToWorkspace": true, + "exec": { + "enable": false + }, + "web": { + "enable": false + }, + "mcpServers": { + "seed": { + "command": "node", + "args": ["/home/km/km-agent/mcp/seed-cli-mcp/dist/index.js"], + "toolTimeout": 60, + "env": { + "SEED_SERVER": "${SEED_SERVER}", + "SEED_SITE": "${SEED_SITE}", + "KM_KEY_NAME": "${KM_KEY_NAME}", + "KM_STATE_DIR": "/home/km/km-state", + "KM_LOGS_DIR": "/home/km/km-logs", + "SEED_CLI_PATH": "/home/km/.local/bin/seed-cli", + "PATH": "/home/km/.local/bin:/usr/bin:/usr/local/bin" + } + } + } + }, + "channels": {} +} diff --git a/seed-knowledge-manager/agent/systemd/nanobot-gateway.service b/seed-knowledge-manager/agent/systemd/nanobot-gateway.service new file mode 100644 index 0000000000..82baafba08 --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/nanobot-gateway.service @@ -0,0 +1,14 @@ +[Unit] +Description=Nanobot Gateway (Knowledge Manager) +After=default.target + +[Service] +Type=simple +EnvironmentFile=/home/km/.nanobot/secrets.env +ExecStart=/home/km/.local/bin/nanobot gateway --port 18791 +Restart=always +RestartSec=10 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target From a33f78f03061e2d560f1b39fb8f6706f78ffd66b Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Wed, 6 May 2026 15:58:34 +0200 Subject: [PATCH 06/17] feat(agent): mention polling driver with typing-indicator (Phase 5 + 6.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun-built standalone driver that bypasses nanobot for the polling loop. Two-pass design: Pass A — placeholders (deterministic, ~1-2s per mention) Walk the activity feed, fetch each candidate comment, look for Embed annotations linking to the agent's account or the site root, filter by writer-capability holders, post a "Working on this — back in a moment. ⌛" comment as a threaded reply (with fallback to top-level on the seed-cli `--reply` Non-base58btc bug). Persist the placeholder mapping in km-state/placeholders.jsonl so a crash between passes is recoverable. Pass B — finalisation (one DeepSeek call per pending placeholder) Site-search for relevant docs, inject as context, call DeepSeek to draft the reply, edit the placeholder via `seed-cli comment edit`. Cite hm:// URLs inline. Fall back to a fixed message on DeepSeek failure so a placeholder is never stranded on "Working…". Key seed-cli quirks worked around: - `comment create` writes the success message ("✓ Comment published: <CID>") to stderr, not stdout; the value is the version CID, not the record id. We parse stderr, then `comment get <CID>` to read back the canonical record id for later editing. - Activity feed `--resource` is exact-match. Filtering by site root hides comments on /discussions/* etc. We pull unfiltered events and post-filter by `comment.targetAccount`. - Cursor model is reverse-chronological pagination, not "since last poll". State stores the newest event id we've classified. systemd timer at 15s cadence; unit timeout 180s. km-log helper + logrotate user config also land here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../agent/logrotate/km-logs.conf | 33 ++ .../agent/mcp/seed-cli-mcp/src/poll-cli.ts | 323 ++++++++++++++++++ seed-knowledge-manager/agent/scripts/km-log | 114 +++++++ .../agent/systemd/km-poll.service | 21 ++ .../agent/systemd/km-poll.timer | 12 + 5 files changed, 503 insertions(+) create mode 100644 seed-knowledge-manager/agent/logrotate/km-logs.conf create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts create mode 100755 seed-knowledge-manager/agent/scripts/km-log create mode 100644 seed-knowledge-manager/agent/systemd/km-poll.service create mode 100644 seed-knowledge-manager/agent/systemd/km-poll.timer diff --git a/seed-knowledge-manager/agent/logrotate/km-logs.conf b/seed-knowledge-manager/agent/logrotate/km-logs.conf new file mode 100644 index 0000000000..12a4ed3027 --- /dev/null +++ b/seed-knowledge-manager/agent/logrotate/km-logs.conf @@ -0,0 +1,33 @@ +# User-mode logrotate config for the Knowledge Manager agent. +# Install path: /home/km/.config/logrotate.d/km-logs.conf +# Wired to a user-systemd timer (km-logrotate.timer) that runs daily. + +/home/km/km-logs/runs/*/trace.jsonl +/home/km/km-logs/runs/*/llm.jsonl +/home/km/km-logs/runs/*/tools.jsonl +/home/km/km-logs/runs/*/seed-cli.jsonl +/home/km/km-logs/runs/*/stdout.log +/home/km/km-logs/runs/*/stderr.log +{ + daily + rotate 30 + maxsize 100M + missingok + notifempty + compress + delaycompress + nocopytruncate + su km km +} + +# Top-level summary index. +/home/km/km-logs/index.jsonl { + yearly + rotate 5 + missingok + notifempty + compress + delaycompress + nocopytruncate + su km km +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts new file mode 100644 index 0000000000..db8a117e08 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts @@ -0,0 +1,323 @@ +#!/usr/bin/env node +/** + * Standalone polling driver. No nanobot. Two-pass design for + * "typing-indicator" UX: + * + * PASS A — placeholders (deterministic, fast): + * For each newly-detected pending mention, post a short placeholder + * comment ("Working on this — back in a moment.") via seed-cli. The + * placeholder commentId is persisted in placeholders.jsonl so a + * crash between passes is recoverable. + * + * PASS B — finalisation (LLM, slower): + * For each placeholder not yet finalised, draft a reply via DeepSeek + * and replace the placeholder body via `seed-cli comment edit`. + * Mark the mention `replied`. + * + * On DeepSeek failure during Pass B the placeholder is edited to a + * short fallback message so it is never stuck on "Working…". + * + * The two-pass split means the user sees the agent reply within seconds + * (placeholder), even when the eventual answer takes longer to draft. + */ + +import {GovernanceCache} from './governance.js' +import {SeedCli} from './seedcli.js' +import {AuditRun} from './audit.js' +import {buildRedactor} from './redact.js' +import {loadConfig} from './config.js' +import {State, mentionKey} from './state.js' +import { + buildCommentMention, + commentEventCandidate, + findKmMentionInComment, + buildReplyTarget, +} from './mentions.js' +import type {Mention, SeedComment} from './mentions.js' +import {bump, checkCap} from './limits.js' +import {draftReply, gatherSiteContext} from './reply-engine.js' + +const MAX_COMMENT_FETCHES = 25 +const PLACEHOLDER_BODY = 'Working on this — back in a moment. ⌛' +const FALLBACK_BODY = + 'I tried to draft a reply but hit a snag. Please rephrase or wait for the next cadence.' + +async function main(): Promise<void> { + const config = loadConfig() + const redactor = buildRedactor() + const audit = new AuditRun({ + logsDir: config.logsDir, + trigger: process.env.KM_TRIGGER ?? 'poll-cli', + redactor, + seedSite: config.seedSite, + }) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'agent_start', + data: {seedServer: config.seedServer, seedSite: config.seedSite, mode: 'poll-cli'}, + }) + + let status: 'ok' | 'error' | 'denied' = 'ok' + try { + const cli = new SeedCli(config, redactor, audit) + const state = new State(config.stateDir) + const governance = new GovernanceCache(config, cli) + + const keyShow = await cli.runRead(['key', 'show', config.keyName]) + if (keyShow.exitCode !== 0) throw new Error(`key show failed: ${keyShow.stderr}`) + const kmAccountId = (keyShow.parsedJson as {accountId?: string} | undefined)?.accountId + if (!kmAccountId) throw new Error('Could not resolve agent accountId') + audit.meta.kmAccountId = kmAccountId + + const g = await governance.getGovernance(true) + audit.trace({ts: nowIso(), level: 'info', event: 'governance_loaded', data: {fetchedAt: g.fetchedAt}}) + + // Resolve allowed-invokers. + const writers = new Set<string>() + if (g.rules.mentions.invokerSource === 'allowlist-doc') { + for (const a of g.allowlist.invokers) writers.add(a) + } else { + const caps = await cli.runRead(['account', 'capabilities', config.seedSite]) + const parsed = caps.parsedJson as {capabilities?: Array<{delegate?: string; role?: string}>} | undefined + for (const c of parsed?.capabilities ?? []) { + if (c.role === 'WRITER' && c.delegate) writers.add(c.delegate) + } + writers.add(config.seedSite.replace(/^hm:\/\//, '').split('/')[0]!) + } + audit.trace({ts: nowIso(), level: 'info', event: 'poll_collect_writers', data: {count: writers.size}}) + + // ── PASS A: discover new mentions and post placeholders. ─────────────── + const lastSeenId = state.getCursor() + const actR = await cli.runRead(['activity', '--limit', '50']) + const events = ((actR.parsedJson as {events?: Array<{id?: string; type?: string; time?: string; author?: unknown}>}) + ?.events) ?? [] + let newestEventId: string | undefined + let scanned = 0 + let placeholdersPosted = 0 + let skippedNotAllowed = 0 + let exhaustedBudget = false + const blocked = new Set(g.rules.moderation.blockedAuthors) + const siteAccount = config.seedSite.replace(/^hm:\/\//, '').split('/')[0]! + + for (const ev of events) { + if (!newestEventId && ev.id) newestEventId = ev.id + if (lastSeenId && ev.id === lastSeenId) break + if (scanned >= MAX_COMMENT_FETCHES) { + exhaustedBudget = true + break + } + const candidate = commentEventCandidate(ev as Parameters<typeof commentEventCandidate>[0]) + if (!candidate) continue + scanned++ + const cr = await cli.runRead(['comment', 'get', candidate.commentId]) + if (cr.exitCode !== 0 || !cr.parsedJson) continue + const comment = cr.parsedJson as SeedComment + if (comment.targetAccount !== siteAccount) continue + if (comment.author === kmAccountId) continue + // Agent triggers on mentions of either itself or the site root — + // since the agent holds a WRITER capability on the site, mentions + // of the site (e.g. "@Develop Seed Hypermedia") are also addressed + // to it. + const evidence = findKmMentionInComment(comment, [kmAccountId, siteAccount]) + if (!evidence) continue + const mention = buildCommentMention(comment, evidence, candidate.ts) + if (blocked.has(mention.author)) continue + if (!writers.has(mention.author)) { + state.markProcessed(mention, audit.meta.runId, 'not-allowed') + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'mention_skipped_not_allowed', + data: {author: mention.author, kind: mention.kind, docId: mention.docId}, + }) + skippedNotAllowed++ + continue + } + const mid = mentionKey(mention) + // Idempotency: don't double-post a placeholder. + if (state.isProcessed(mid) || state.hasPlaceholderFor(mid)) continue + + // Per-day cap: a placeholder counts as a comment. + const rs = state.getRateState() + const capCheck = checkCap(rs, 'comments', g.rules) + if (!capCheck.allowed) { + audit.trace({ + ts: nowIso(), + level: 'warn', + event: 'placeholder_skipped_cap', + data: {commentId: mention.commentId, reason: capCheck.reason}, + }) + break + } + const placeholderId = await postPlaceholder(cli, mention, audit) + if (!placeholderId) continue + state.recordPlaceholder({ + mentionId: mid, + placeholderId, + postedAt: nowIso(), + mention, + finalised: false, + }) + state.setRateState(bump(rs, 'comments')) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'placeholder_posted', + data: { + commentId: mention.commentId, + placeholderId, + docId: mention.docId, + textPreview: mention.text.replace(//g, ' ').slice(0, 200), + }, + }) + placeholdersPosted++ + } + if (newestEventId) state.setCursor(newestEventId) + + // ── PASS B: finalise placeholders (DeepSeek + comment edit). ─────────── + const pending = state.pendingPlaceholders() + let finalised = 0 + let errored = 0 + for (const rec of pending) { + // Per-run cap on comment edits is intentionally absent; we already + // counted each placeholder as a comment in Pass A, and `edit` does + // not produce a new top-level comment. + const question = rec.mention.text.replace(//g, ' ').trim() + const context = await gatherSiteContext(cli, question, siteAccount, audit) + const reply = await draftReply(question, context, audit) + const body = reply ?? FALLBACK_BODY + const r = await cli.runWrite(['comment', 'edit', rec.placeholderId, '--body', body]) + if (r.exitCode === 0) { + state.finalisePlaceholder(rec.mentionId, rec.placeholderId) + state.markProcessed(rec.mention, audit.meta.runId, reply ? 'replied' : 'error') + audit.trace({ + ts: nowIso(), + level: 'info', + event: reply ? 'reply_finalised' : 'reply_finalised_with_fallback', + data: { + commentId: rec.mention.commentId, + placeholderId: rec.placeholderId, + replyPreview: body.slice(0, 200), + }, + }) + finalised++ + } else { + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'reply_edit_failed', + data: { + commentId: rec.mention.commentId, + placeholderId: rec.placeholderId, + exitCode: r.exitCode, + stderr: r.stderr.slice(0, 200), + }, + }) + errored++ + } + } + + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'poll_done', + data: { + events: events.length, + scanned, + placeholdersPosted, + skippedNotAllowed, + finalised, + errored, + exhaustedBudget, + }, + }) + } catch (err) { + status = 'error' + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'poll_fatal', + data: {message: err instanceof Error ? err.message : String(err)}, + }) + } finally { + audit.trace({ts: nowIso(), level: 'info', event: 'agent_end', data: {status}}) + audit.close({status, logsDir: config.logsDir}) + } +} + +function nowIso(): string { + return new Date().toISOString() +} + +/** + * Posts a placeholder comment for a mention. Returns the canonical + * comment record id (`<author>/<tsid>`) on success, or null on failure. + * + * seed-cli's `comment create` emits "✓ Comment published: <CID>" to + * STDERR (not stdout) and the value is the version CID, not the record + * id. We parse the CID, then call `comment get <CID>` to read back the + * full comment record and return its `id` field. + */ +async function postPlaceholder(cli: SeedCli, mention: Mention, audit: AuditRun): Promise<string | null> { + const target = buildReplyTarget(mention) + const baseArgv = ['comment', 'create', target.targetId, '--body', PLACEHOLDER_BODY] + // Try threaded reply first. + let r = await cli.runWrite(target.replyTo ? [...baseArgv, '--reply', target.replyTo] : baseArgv) + // Known seed-cli quirk: `--reply` fails with "Non-base58btc character" + // when the parent comment chain includes an edited comment. Fall back + // to a top-level reply on the same doc — not threaded but functional. + if (r.exitCode !== 0 && target.replyTo && /non-base58btc/i.test(r.stderr)) { + audit.trace({ + ts: nowIso(), + level: 'warn', + event: 'placeholder_reply_fallback', + data: {commentId: mention.commentId, parentReplyTo: target.replyTo, stderr: r.stderr.slice(0, 200)}, + }) + r = await cli.runWrite(baseArgv) + } + if (r.exitCode !== 0) { + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'placeholder_post_failed', + data: {commentId: mention.commentId, exitCode: r.exitCode, stderr: r.stderr.slice(0, 200)}, + }) + return null + } + const cid = extractCidFromOutput(r.stdout, r.stderr) + if (!cid) { + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'placeholder_cid_parse_failed', + data: {commentId: mention.commentId, stdoutPreview: r.stdout.slice(0, 200), stderrPreview: r.stderr.slice(0, 200)}, + }) + return null + } + // Resolve CID → canonical comment record id. + const get = await cli.runRead(['comment', 'get', cid]) + if (get.exitCode !== 0) { + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'placeholder_resolve_failed', + data: {commentId: mention.commentId, cid, exitCode: get.exitCode, stderr: get.stderr.slice(0, 200)}, + }) + return null + } + const parsed = get.parsedJson as {id?: string} | undefined + return parsed?.id ?? null +} + +function extractCidFromOutput(stdout: string, stderr: string): string | null { + const combined = `${stdout}\n${stderr}` + const m = combined.match(/comment\s+published:\s*(bafy[\w]+)/i) + return m?.[1] ?? null +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('km-poll fatal:', err) + process.exit(1) +}) diff --git a/seed-knowledge-manager/agent/scripts/km-log b/seed-knowledge-manager/agent/scripts/km-log new file mode 100755 index 0000000000..5f12ec9bad --- /dev/null +++ b/seed-knowledge-manager/agent/scripts/km-log @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Browse the Knowledge Manager agent's per-run audit logs. +# +# Layout (per Phase 5): +# /home/km/km-logs/ +# ├── runs/<UTC-ISO>__<trigger>__<ulid>/ +# │ ├── meta.json +# │ ├── trace.jsonl +# │ ├── llm.jsonl +# │ ├── tools.jsonl +# │ ├── seed-cli.jsonl +# │ ├── stdout.log +# │ └── stderr.log +# ├── current -> runs/<latest> +# └── index.jsonl +# +# Subcommands: +# km-log tail # follow the newest run's trace + journal +# km-log show <runId> # pretty-print a specific run +# km-log grep <regex> # ripgrep across all run dirs +# km-log mention <id> # find the run that processed a comment/doc id +# km-log latest [N] # last N runs (summary) +# +# Reads use jq when present. + +set -euo pipefail + +LOGS_DIR="${KM_LOGS_DIR:-/home/km/km-logs}" +RUNS_DIR="$LOGS_DIR/runs" + +die() { echo "$*" >&2; exit 1; } + +usage() { + sed -n '2,30p' "$0" | sed 's/^# //;s/^#//' + exit 0 +} + +resolve_run() { + local ref="$1" + if [[ -d "$RUNS_DIR/$ref" ]]; then + echo "$RUNS_DIR/$ref" + return + fi + # match by ULID suffix + for d in "$RUNS_DIR"/*"$ref"*; do + [[ -d "$d" ]] && { echo "$d"; return; } + done + die "no run matching '$ref' under $RUNS_DIR" +} + +cmd_tail() { + local run + if [[ -L "$LOGS_DIR/current" ]]; then + run="$LOGS_DIR/current" + else + run=$(ls -dt "$RUNS_DIR"/*/ 2>/dev/null | head -1) + fi + [[ -d "$run" ]] || die "no runs yet under $RUNS_DIR" + echo "tailing $run/trace.jsonl" + tail -F "$run/trace.jsonl" +} + +cmd_show() { + local ref="${1:-}"; [[ -n "$ref" ]] || die "usage: km-log show <runId>" + local d; d=$(resolve_run "$ref") + echo "=== $d ===" + if command -v jq >/dev/null; then + echo "--- meta" + jq . "$d/meta.json" 2>/dev/null || cat "$d/meta.json" + echo "--- trace" + [[ -f "$d/trace.jsonl" ]] && jq -c . "$d/trace.jsonl" + echo "--- llm" + [[ -f "$d/llm.jsonl" ]] && jq -c '{ts_start, latency_ms, model, completion: (.completion // null | tostring | .[0:200]), reasoning: (.reasoning // null | tostring | .[0:300]), usage}' "$d/llm.jsonl" + echo "--- tools" + [[ -f "$d/tools.jsonl" ]] && jq -c '{ts_start, latency_ms, tool, error}' "$d/tools.jsonl" + echo "--- seed-cli" + [[ -f "$d/seed-cli.jsonl" ]] && jq -c '{ts_start, latency_ms, exit_code, argv: (.argv|.[2:6])}' "$d/seed-cli.jsonl" + else + cat "$d/meta.json" + echo + [[ -f "$d/trace.jsonl" ]] && cat "$d/trace.jsonl" + fi +} + +cmd_grep() { + local pattern="${1:-}"; [[ -n "$pattern" ]] || die "usage: km-log grep <regex>" + local cmd + if command -v rg >/dev/null; then cmd="rg --no-heading -n"; else cmd="grep -rn"; fi + $cmd "$pattern" "$RUNS_DIR" || true +} + +cmd_mention() { + local id="${1:-}"; [[ -n "$id" ]] || die "usage: km-log mention <commentId-or-docId>" + cmd_grep "$id" +} + +cmd_latest() { + local n="${1:-10}" + if [[ -f "$LOGS_DIR/index.jsonl" && -x $(command -v jq) ]]; then + tail -n "$n" "$LOGS_DIR/index.jsonl" | jq -c '{id, trigger, start, end, status, wall_ms, counters}' + else + ls -dt "$RUNS_DIR"/*/ 2>/dev/null | head -n "$n" + fi +} + +case "${1:-}" in + tail) shift; cmd_tail "$@" ;; + show) shift; cmd_show "$@" ;; + grep) shift; cmd_grep "$@" ;; + mention) shift; cmd_mention "$@" ;; + latest) shift; cmd_latest "$@" ;; + ""|-h|--help) usage ;; + *) echo "unknown subcommand: $1" >&2; usage ;; +esac diff --git a/seed-knowledge-manager/agent/systemd/km-poll.service b/seed-knowledge-manager/agent/systemd/km-poll.service new file mode 100644 index 0000000000..9f53b5a0bb --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/km-poll.service @@ -0,0 +1,21 @@ +[Unit] +Description=Knowledge Manager — poll mentions +After=default.target + +[Service] +Type=oneshot +EnvironmentFile=/home/km/.nanobot/secrets.env +Environment=PATH=/home/km/.local/bin:/usr/bin:/usr/local/bin +Environment=KM_TRIGGER=poll-cli +Environment=SEED_CLI_PATH=/home/km/.local/bin/seed-cli +Environment=KM_STATE_DIR=/home/km/km-state +Environment=KM_LOGS_DIR=/home/km/km-logs +# Standalone polling driver. Bypasses nanobot — DeepSeek call happens +# inline, no LLM tool-orchestration. Whole loop is deterministic except +# for the single chat-completion that drafts the reply text. +ExecStart=/usr/bin/node /home/km/km-agent/mcp/seed-cli-mcp/dist/poll-cli.js +# Timeout per run. Plenty of headroom for one DeepSeek call + a few seed-cli writes. +TimeoutStartSec=180 + +[Install] +WantedBy=default.target diff --git a/seed-knowledge-manager/agent/systemd/km-poll.timer b/seed-knowledge-manager/agent/systemd/km-poll.timer new file mode 100644 index 0000000000..1aa5d691b3 --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/km-poll.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Knowledge Manager — poll mentions every 60s + +[Timer] +OnBootSec=90 +OnUnitActiveSec=15 +AccuracySec=5s +Persistent=true +Unit=km-poll.service + +[Install] +WantedBy=timers.target From 6f9cbd250c66f73ee281299c466eb83bf6e60730 Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Wed, 6 May 2026 15:58:48 +0200 Subject: [PATCH 07/17] feat(agent): scheduled LAFH cadences (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single Bun driver `cadence-cli.ts` selected by KM_TASK env var: - boletin (Mon 09:00 UTC, 7-day window) Weekly bulletin published at /agents/knowledge-manager/state/boletin/<YYYY-Www> - gap (Wed 10:00 UTC, 7-day window) Gap report at /agents/knowledge-manager/state/gaps/<YYYY-MM-DD> - health (1st of month 09:00 UTC, 30-day window) Network health at /agents/knowledge-manager/state/network-health/<YYYY-MM> Pattern per task: load governance → respect draft_only kill-switch + allow/deny path rules → collect activity snapshot → one DeepSeek call with the LAFH template skeleton and the snapshot as context → publish via `seed-cli document create --force`. Activity snapshot tolerates the daemon's mixed ID serialisation (string vs {id, uid, path[]}). Fixed `/` in `allow_write_paths` to mean "everything below root" instead of just the root literal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../agent/mcp/seed-cli-mcp/src/cadence-cli.ts | 480 ++++++++++++++++++ .../agent/systemd/km-boletin.service | 18 + .../agent/systemd/km-boletin.timer | 11 + .../agent/systemd/km-gap.service | 18 + .../agent/systemd/km-gap.timer | 11 + .../agent/systemd/km-health.service | 18 + .../agent/systemd/km-health.timer | 11 + 7 files changed, 567 insertions(+) create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/cadence-cli.ts create mode 100644 seed-knowledge-manager/agent/systemd/km-boletin.service create mode 100644 seed-knowledge-manager/agent/systemd/km-boletin.timer create mode 100644 seed-knowledge-manager/agent/systemd/km-gap.service create mode 100644 seed-knowledge-manager/agent/systemd/km-gap.timer create mode 100644 seed-knowledge-manager/agent/systemd/km-health.service create mode 100644 seed-knowledge-manager/agent/systemd/km-health.timer diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/cadence-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/cadence-cli.ts new file mode 100644 index 0000000000..48b2a1d6e2 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/cadence-cli.ts @@ -0,0 +1,480 @@ +#!/usr/bin/env node +/** + * Standalone driver for the LAFH cadenced outputs: + * - boletin: weekly bulletin + * - gap: gap-detection report + * - health: network-health report + * + * Pattern: load governance → collect a period snapshot from activity → + * one DeepSeek call to draft the doc body → publish via seed-cli at a + * deterministic path under `/agents/knowledge-manager/state/...`. + * + * No nanobot. No tool orchestration. systemd timers invoke this with + * `KM_TASK=<task>`. + * + * Templates referenced inline are the human-canonical structure (see + * seed-knowledge-manager/templates/) — we feed a compact version into + * the system prompt so DeepSeek produces consistent output. + */ + +import {GovernanceCache} from './governance.js' +import {SeedCli} from './seedcli.js' +import {AuditRun} from './audit.js' +import {buildRedactor} from './redact.js' +import {loadConfig} from './config.js' +import {bump, checkCap, isWriteAllowed} from './limits.js' +import {State} from './state.js' + +type Task = 'boletin' | 'gap' | 'health' + +type TaskConfig = { + task: Task + /** ISO date period stamp used as path slug. */ + periodStamp: string + /** Human-readable period for prose (e.g. "2026-W19", "April 2026"). */ + periodLabel: string + /** Window of activity to summarize, in days. */ + windowDays: number + /** Path under the site root where the doc is created (relative to /). */ + docPath: string + /** Doc title. */ + title: string + /** Frontmatter `type` value. */ + type: string + /** System-prompt skeleton + instructions. */ + systemPrompt: string +} + +async function main(): Promise<void> { + const taskName = (process.env.KM_TASK ?? '').toLowerCase() + if (!isTask(taskName)) { + throw new Error(`KM_TASK must be one of: boletin | gap | health (got "${taskName}")`) + } + const config = loadConfig() + const redactor = buildRedactor() + const audit = new AuditRun({ + logsDir: config.logsDir, + trigger: `cadence-${taskName}`, + redactor, + seedSite: config.seedSite, + }) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'agent_start', + data: {seedServer: config.seedServer, seedSite: config.seedSite, mode: 'cadence-cli', task: taskName}, + }) + + let status: 'ok' | 'error' | 'denied' = 'ok' + try { + const cli = new SeedCli(config, redactor, audit) + const state = new State(config.stateDir) + const governance = new GovernanceCache(config, cli) + + // Resolve agent accountId. + const keyShow = await cli.runRead(['key', 'show', config.keyName]) + if (keyShow.exitCode !== 0) throw new Error(`key show failed: ${keyShow.stderr}`) + const kmAccountId = (keyShow.parsedJson as {accountId?: string} | undefined)?.accountId + if (!kmAccountId) throw new Error('Could not resolve agent accountId') + audit.meta.kmAccountId = kmAccountId + + const g = await governance.getGovernance(true) + audit.trace({ts: nowIso(), level: 'info', event: 'governance_loaded', data: {fetchedAt: g.fetchedAt}}) + + const tc = buildTaskConfig(taskName, g.runbook, g.charter) + + // Path policy: documents are writes; respect rules. + if (g.rules.draftOnly) { + audit.trace({ts: nowIso(), level: 'warn', event: 'draft_only_active', data: {task: tc.task, path: tc.docPath}}) + status = 'denied' + return + } + const allow = isWriteAllowed(tc.docPath, g.rules) + if (!allow.allowed) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {path: tc.docPath, reason: allow.reason}}) + status = 'denied' + return + } + const cap = checkCap(state.getRateState(), 'documents', g.rules) + if (!cap.allowed) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {path: tc.docPath, reason: cap.reason}}) + status = 'denied' + return + } + + // Collect activity snapshot. + const cutoffMs = Date.now() - tc.windowDays * 86_400_000 + const snapshot = await collectSnapshot(cli, config.seedSite, cutoffMs) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'snapshot_collected', + data: { + windowDays: tc.windowDays, + events: snapshot.events.length, + comments: snapshot.commentsByDoc.size, + docs: snapshot.docsByPath.size, + authors: snapshot.activeAuthors.size, + }, + }) + + // Draft the body via DeepSeek. + const userPrompt = buildUserPrompt(tc, snapshot) + const draft = await draftDoc(tc.systemPrompt, userPrompt, audit) + if (!draft) throw new Error('DeepSeek returned no completion') + + // Publish. + const fullDoc = ensureFrontmatter(draft, { + title: tc.title, + type: tc.type, + period: tc.periodStamp, + periodLabel: tc.periodLabel, + created_by: 'knowledge-manager', + created_at: new Date().toISOString(), + }) + const tmpFile = await writeTempMarkdown(fullDoc) + const siteAccount = config.seedSite.replace(/^hm:\/\//, '').split('/')[0]! + const argv = [ + 'document', + 'create', + '--account', + siteAccount, + '--path', + tc.docPath, + '--name', + tc.title, + '--file', + tmpFile, + '--force', // each cadence run replaces the doc at the same path + ] + const r = await cli.runWrite(argv) + if (r.exitCode === 0) { + state.setRateState(bump(state.getRateState(), 'documents')) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'cadence_doc_published', + data: {task: tc.task, path: tc.docPath, link: extractLink(r.stdout)}, + }) + } else { + status = 'error' + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'cadence_doc_failed', + data: {task: tc.task, exitCode: r.exitCode, stderr: r.stderr.slice(0, 400)}, + }) + } + } catch (err) { + status = 'error' + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'cadence_fatal', + data: {message: err instanceof Error ? err.message : String(err)}, + }) + } finally { + audit.trace({ts: nowIso(), level: 'info', event: 'agent_end', data: {status}}) + audit.close({status, logsDir: config.logsDir}) + } +} + +function isTask(s: string): s is Task { + return s === 'boletin' || s === 'gap' || s === 'health' +} + +function buildTaskConfig(task: Task, runbook: string, charter: string): TaskConfig { + const now = new Date() + const lafhRunbookContext = + `Charter excerpt:\n${charter.slice(0, 1200)}\n\nRunbook excerpt:\n${runbook.slice(0, 1200)}` + if (task === 'boletin') { + const week = isoWeekStamp(now) + return { + task, + periodStamp: week, + periodLabel: week, + windowDays: 7, + docPath: `/agents/knowledge-manager/state/boletin/${week}`, + title: `Boletín — ${week}`, + type: 'boletin', + systemPrompt: + `You are the Knowledge Manager generating the WEEKLY BULLETIN (boletín periódico) for a Seed Hypermedia community, applying LAFH/GC-Red methodology.\n\n` + + `Output a complete Markdown document body (no triple-backtick fences around the whole). Use these section headings exactly:\n` + + `## New documents published\n## Active threads\n## Decisions made\n## New members\n## Gaps surfaced or filled\n## Recommended reading from this period\n## Health note\n\n` + + `Cite every document/comment with full hm:// URLs. Cap each list at 5–7 items, prioritized (not exhaustive). Be concise — the bulletin is meant to be scannable in two minutes.\n\n` + + `Where the snapshot lacks data for a section, write one honest sentence ("no formal decisions captured this period" etc) — that is itself a signal. Do NOT invent items.\n\n` + + `${lafhRunbookContext}`, + } + } + if (task === 'gap') { + const stamp = isoDateStamp(now) + return { + task, + periodStamp: stamp, + periodLabel: stamp, + windowDays: 7, + docPath: `/agents/knowledge-manager/state/gaps/${stamp}`, + title: `Gap report — ${stamp}`, + type: 'gap-report', + systemPrompt: + `You are the Knowledge Manager generating a GAP REPORT for a Seed Hypermedia community, applying LAFH/GC-Red methodology.\n\n` + + `Output a complete Markdown document body. Use these sections:\n` + + `## How this was produced\n## Open gaps\n### 🔴 High priority\n### 🟡 Medium priority\n### 🟢 Low priority / parking lot\n## Contradictions detected\n## Stale or potentially outdated content\n## Patterns\n\n` + + `For each gap include: **Evidence:** with hm:// links, **Why it matters:**, **Proposed action:**, **Suggested owner:** (or "open"). ` + + `Do NOT invent gaps; if the snapshot doesn't surface enough data, write "no high-priority gaps detected this period" and move on. Honest signal beats fluff.\n\n` + + `${lafhRunbookContext}`, + } + } + // health + const stamp = isoMonthStamp(now) + return { + task, + periodStamp: stamp, + periodLabel: stamp, + windowDays: 30, + docPath: `/agents/knowledge-manager/state/network-health/${stamp}`, + title: `Network health — ${stamp}`, + type: 'network-health', + systemPrompt: + `You are the Knowledge Manager generating a NETWORK HEALTH REPORT for a Seed Hypermedia community, applying LAFH/GC-Red methodology.\n\n` + + `Output a complete Markdown document body. Sections in this order:\n` + + `## TL;DR\n## Activity metrics\n## Production of knowledge products\n## Silos\n## Stale corpus\n## Pace assessment\n## Memory check\n## Methodology adherence\n## Recommended actions\n\n` + + `Be diagnostic, not flattering. If activity exists but produces no synthesis/decisions/methods, label it a red flag (LAFH: activity without production is noise). ` + + `Quantify what you can ("N new docs", "M comments", "K active authors of N total writers"). Cite hm:// links for any document referenced.\n\n` + + `${lafhRunbookContext}`, + } +} + +// ─── Snapshot collection ───────────────────────────────────────────────── + +type Snapshot = { + events: ActivityEvent[] + /** docId -> count */ + commentsByDoc: Map<string, number> + /** docId -> latest update time */ + docsByPath: Map<string, string> + /** authorId -> count */ + activeAuthors: Map<string, number> +} + +type ActivityEvent = { + id?: string + type?: string + time?: string + author?: {id?: {uid?: string}} + // The daemon serializes ID fields inconsistently — sometimes a bare + // string, sometimes an object with id/uid/path/version. We tolerate + // both via unwrapTargetId. + docId?: unknown + target?: unknown + capability?: unknown +} + +async function collectSnapshot(cli: SeedCli, seedSite: string, cutoffMs: number): Promise<Snapshot> { + const r = await cli.runRead(['activity', '--limit', '300']) + const all = ((r.parsedJson as {events?: ActivityEvent[]} | undefined)?.events) ?? [] + const siteAccount = seedSite.replace(/^hm:\/\//, '').split('/')[0]! + const events: ActivityEvent[] = [] + const commentsByDoc = new Map<string, number>() + const docsByPath = new Map<string, string>() + const activeAuthors = new Map<string, number>() + for (const ev of all) { + if (!ev.time) continue + const t = Date.parse(ev.time) + if (Number.isFinite(t) && t < cutoffMs) continue + // Filter to events touching our site. + const target = unwrapTargetId(ev) ?? '' + if (siteAccount && !target.includes(siteAccount)) continue + events.push(ev) + const author = ev.author?.id?.uid + if (author) activeAuthors.set(author, (activeAuthors.get(author) ?? 0) + 1) + if (ev.type === 'comment') { + const docId = stripFragment(target) + commentsByDoc.set(docId, (commentsByDoc.get(docId) ?? 0) + 1) + } + if (ev.type === 'doc-update') { + const docIdStr = unwrapTargetId(ev) + if (docIdStr) docsByPath.set(stripVersion(docIdStr), ev.time ?? '') + } + } + return {events, commentsByDoc, docsByPath, activeAuthors} +} + +function unwrapTargetId(ev: ActivityEvent): string | undefined { + // Daemon serializes IDs as either a bare string OR an object + // {id?, uid?, ...}. Tolerate both shapes (and refuse anything else). + const tryId = (v: unknown): string | undefined => { + if (typeof v === 'string') return v + if (v && typeof v === 'object') { + const o = v as {id?: unknown; uid?: unknown} + if (typeof o.id === 'string') return o.id + if (typeof o.uid === 'string') return `hm://${o.uid}` + } + return undefined + } + return tryId(ev.docId) ?? tryId(ev.target) +} + +function stripFragment(id: string): string { + return id.split('#')[0]! +} + +function stripVersion(id: string): string { + return id.split('?')[0]!.split('#')[0]! +} + +// ─── Prompt builder ────────────────────────────────────────────────────── + +function buildUserPrompt(tc: TaskConfig, snapshot: Snapshot): string { + const lines: string[] = [] + lines.push(`Task: ${tc.task}`) + lines.push(`Period: ${tc.periodLabel} (window: last ${tc.windowDays} days)`) + lines.push(`Site activity snapshot below.`) + lines.push('') + lines.push(`### New / updated documents (${snapshot.docsByPath.size})`) + for (const [doc, time] of snapshot.docsByPath) { + lines.push(`- ${doc} (last update ${time})`) + } + lines.push('') + lines.push(`### Comment activity by document (${snapshot.commentsByDoc.size} docs)`) + for (const [doc, count] of snapshot.commentsByDoc) { + lines.push(`- ${doc}: ${count} comments`) + } + lines.push('') + lines.push(`### Active authors (${snapshot.activeAuthors.size})`) + for (const [author, count] of snapshot.activeAuthors) { + lines.push(`- ${author}: ${count} events`) + } + lines.push('') + lines.push('### Raw events (most recent first, truncated)') + const sample = snapshot.events.slice(0, 80) + for (const ev of sample) { + const author = ev.author?.id?.uid ?? '?' + const target = unwrapTargetId(ev) ?? '?' + lines.push(`- ${ev.time} ${ev.type} by ${author} → ${target}`) + } + lines.push('') + lines.push(`Produce the document now. Stick to the section headings in the system prompt. Use plain markdown. Do not wrap in code fences. Do not add a YAML frontmatter — that will be inserted automatically.`) + return lines.join('\n') +} + +async function draftDoc(systemPrompt: string, userPrompt: string, audit: AuditRun): Promise<string | null> { + const apiKey = process.env.DEEPSEEK_API_KEY + if (!apiKey) { + audit.trace({ts: nowIso(), level: 'error', event: 'deepseek_no_key'}) + return null + } + const body = JSON.stringify({ + model: 'deepseek-chat', + messages: [ + {role: 'system', content: systemPrompt}, + {role: 'user', content: userPrompt}, + ], + temperature: 0.3, + max_tokens: 2400, + }) + const t0 = Date.now() + let res: Response + try { + res = await fetch('https://api.deepseek.com/v1/chat/completions', { + method: 'POST', + headers: {'content-type': 'application/json', authorization: `Bearer ${apiKey}`}, + body, + }) + } catch (err) { + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'deepseek_network_error', + data: {message: err instanceof Error ? err.message : String(err)}, + }) + return null + } + const latencyMs = Date.now() - t0 + if (!res.ok) { + const text = await res.text().catch(() => '') + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'deepseek_http_error', + data: {status: res.status, body: text.slice(0, 400), latencyMs}, + }) + return null + } + const json = (await res.json()) as { + choices?: Array<{message?: {content?: string}}> + usage?: {prompt_tokens?: number; completion_tokens?: number; total_tokens?: number} + } + const completion = json.choices?.[0]?.message?.content?.trim() + audit.llm({ + ts_start: new Date(t0).toISOString(), + ts_end: nowIso(), + latency_ms: latencyMs, + model: 'deepseek-chat', + completion: completion ?? '', + usage: { + prompt: json.usage?.prompt_tokens, + completion: json.usage?.completion_tokens, + total: json.usage?.total_tokens, + }, + }) + return completion ?? null +} + +// ─── Output assembly ───────────────────────────────────────────────────── + +function ensureFrontmatter(body: string, fm: Record<string, string>): string { + if (body.startsWith('---\n')) return body + const lines: string[] = ['---'] + for (const [k, v] of Object.entries(fm)) { + lines.push(`${k}: ${escapeYaml(v)}`) + } + lines.push('---', '') + return lines.join('\n') + '\n' + body +} + +function escapeYaml(value: string): string { + if (/^[a-zA-Z0-9_./-]+$/.test(value)) return value + return JSON.stringify(value) +} + +async function writeTempMarkdown(body: string): Promise<string> { + const {writeFileSync} = await import('node:fs') + const {tmpdir} = await import('node:os') + const {join} = await import('node:path') + const path = join(tmpdir(), `km-cadence-${Date.now()}-${Math.random().toString(36).slice(2)}.md`) + writeFileSync(path, body, {mode: 0o600}) + return path +} + +function extractLink(stdout: string): string | undefined { + return stdout.match(/https?:\/\/\S+/)?.[0] +} + +function nowIso(): string { + return new Date().toISOString() +} + +function isoDateStamp(d: Date): string { + return d.toISOString().slice(0, 10) +} + +function isoMonthStamp(d: Date): string { + return d.toISOString().slice(0, 7) +} + +function isoWeekStamp(d: Date): string { + // ISO week per https://en.wikipedia.org/wiki/ISO_week_date + const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())) + const day = t.getUTCDay() || 7 + t.setUTCDate(t.getUTCDate() + 4 - day) + const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1)) + const weekNum = Math.ceil(((t.getTime() - yearStart.getTime()) / 86400000 + 1) / 7) + return `${t.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}` +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('km-cadence fatal:', err) + process.exit(1) +}) diff --git a/seed-knowledge-manager/agent/systemd/km-boletin.service b/seed-knowledge-manager/agent/systemd/km-boletin.service new file mode 100644 index 0000000000..cebca5ce78 --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/km-boletin.service @@ -0,0 +1,18 @@ +[Unit] +Description=Knowledge Manager — weekly bulletin (boletín periódico) +After=default.target + +[Service] +Type=oneshot +EnvironmentFile=/home/km/.nanobot/secrets.env +Environment=PATH=/home/km/.local/bin:/usr/bin:/usr/local/bin +Environment=KM_TASK=boletin +Environment=KM_TRIGGER=cadence-boletin +Environment=SEED_CLI_PATH=/home/km/.local/bin/seed-cli +Environment=KM_STATE_DIR=/home/km/km-state +Environment=KM_LOGS_DIR=/home/km/km-logs +ExecStart=/usr/bin/node /home/km/km-agent/mcp/seed-cli-mcp/dist/cadence-cli.js +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/seed-knowledge-manager/agent/systemd/km-boletin.timer b/seed-knowledge-manager/agent/systemd/km-boletin.timer new file mode 100644 index 0000000000..c35d28d488 --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/km-boletin.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Knowledge Manager — weekly bulletin schedule + +[Timer] +# Mondays 09:00 UTC. +OnCalendar=Mon *-*-* 09:00:00 UTC +Persistent=true +Unit=km-boletin.service + +[Install] +WantedBy=timers.target diff --git a/seed-knowledge-manager/agent/systemd/km-gap.service b/seed-knowledge-manager/agent/systemd/km-gap.service new file mode 100644 index 0000000000..708ffec59b --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/km-gap.service @@ -0,0 +1,18 @@ +[Unit] +Description=Knowledge Manager — gap detection +After=default.target + +[Service] +Type=oneshot +EnvironmentFile=/home/km/.nanobot/secrets.env +Environment=PATH=/home/km/.local/bin:/usr/bin:/usr/local/bin +Environment=KM_TASK=gap +Environment=KM_TRIGGER=cadence-gap +Environment=SEED_CLI_PATH=/home/km/.local/bin/seed-cli +Environment=KM_STATE_DIR=/home/km/km-state +Environment=KM_LOGS_DIR=/home/km/km-logs +ExecStart=/usr/bin/node /home/km/km-agent/mcp/seed-cli-mcp/dist/cadence-cli.js +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/seed-knowledge-manager/agent/systemd/km-gap.timer b/seed-knowledge-manager/agent/systemd/km-gap.timer new file mode 100644 index 0000000000..d9e7657e07 --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/km-gap.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Knowledge Manager — gap detection schedule + +[Timer] +# Wednesdays 10:00 UTC. +OnCalendar=Wed *-*-* 10:00:00 UTC +Persistent=true +Unit=km-gap.service + +[Install] +WantedBy=timers.target diff --git a/seed-knowledge-manager/agent/systemd/km-health.service b/seed-knowledge-manager/agent/systemd/km-health.service new file mode 100644 index 0000000000..f78956d530 --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/km-health.service @@ -0,0 +1,18 @@ +[Unit] +Description=Knowledge Manager — monthly network-health report +After=default.target + +[Service] +Type=oneshot +EnvironmentFile=/home/km/.nanobot/secrets.env +Environment=PATH=/home/km/.local/bin:/usr/bin:/usr/local/bin +Environment=KM_TASK=health +Environment=KM_TRIGGER=cadence-health +Environment=SEED_CLI_PATH=/home/km/.local/bin/seed-cli +Environment=KM_STATE_DIR=/home/km/km-state +Environment=KM_LOGS_DIR=/home/km/km-logs +ExecStart=/usr/bin/node /home/km/km-agent/mcp/seed-cli-mcp/dist/cadence-cli.js +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/seed-knowledge-manager/agent/systemd/km-health.timer b/seed-knowledge-manager/agent/systemd/km-health.timer new file mode 100644 index 0000000000..6972d38cc4 --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/km-health.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Knowledge Manager — network-health schedule + +[Timer] +# 1st of each month 09:00 UTC. +OnCalendar=*-*-01 09:00:00 UTC +Persistent=true +Unit=km-health.service + +[Install] +WantedBy=timers.target From 07528087918b8138198f36d2cfd14a22aa439367 Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Wed, 6 May 2026 15:59:04 +0200 Subject: [PATCH 08/17] feat(agent): Telegram operator bot with /ask + multi-turn (Phase 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-running Bun driver that long-polls the Telegram REST API for the operator chat(s) configured in OPS_TELEGRAM_ID (allowFrom matched against either the sender's user-id or the chat-id, so DMs and groups both work). Three modes: - slash commands /status /last-runs /show-rules /poll-now /help - /ask <q> operator-mode Q&A grounded in: - README excerpt (~3.5KB) - last 8 audit run summaries - current governance rules JSON no site search; different system prompt biased to cite filenames + systemd units explicitly - plain text community-mode Q&A grounded in seed-cli search results — same pipeline as comment-mention replies Both Q&A modes preserve a per-chat conversation history (last 10 turns, JSONL under km-state/telegram-history/<chatId>.jsonl, auto-rotated above 256KB). DeepSeek logic factored into reply-engine.ts (shared with poll-cli): - callDeepSeek(messages, opts) - gatherSiteContext(cli, question, site) - draftReply(question, siteContext, audit, history) - draftSystemReply(question, systemContext, audit, history) Each Telegram exchange writes a complete audit run under ~/km-logs/runs/<…>__telegram-question or telegram-ask__<ulid>/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../mcp/seed-cli-mcp/src/chat-history.ts | 57 ++++ .../mcp/seed-cli-mcp/src/reply-engine.ts | 185 +++++++++++ .../mcp/seed-cli-mcp/src/system-context.ts | 80 +++++ .../mcp/seed-cli-mcp/src/telegram-bot.ts | 304 ++++++++++++++++++ .../agent/systemd/km-telegram.service | 17 + 5 files changed, 643 insertions(+) create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/chat-history.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/system-context.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/telegram-bot.ts create mode 100644 seed-knowledge-manager/agent/systemd/km-telegram.service diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/chat-history.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/chat-history.ts new file mode 100644 index 0000000000..f83f00f046 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/chat-history.ts @@ -0,0 +1,57 @@ +/** + * Per-Telegram-chat conversation history. Append-only JSONL files, one + * per chat-id, capped to the last N turns when read. Survives process + * restarts. Bounded so a runaway chat can't grow logs forever. + */ + +import {appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' +import type {ChatTurn} from './reply-engine.js' + +const MAX_TURNS_RETURNED = 10 +const ROTATE_BYTES = 256 * 1024 // rotate file after 256KB + +export class ChatHistory { + private readonly dir: string + + constructor(stateDir: string) { + this.dir = join(stateDir, 'telegram-history') + if (!existsSync(this.dir)) mkdirSync(this.dir, {recursive: true, mode: 0o700}) + } + + /** Returns the last MAX_TURNS_RETURNED turns for the chat, oldest first. */ + read(chatId: number): ChatTurn[] { + const path = this.pathFor(chatId) + if (!existsSync(path)) return [] + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + const turns: ChatTurn[] = [] + for (const line of lines) { + try { + const t = JSON.parse(line) as ChatTurn + if ((t.role === 'user' || t.role === 'assistant') && typeof t.content === 'string') turns.push(t) + } catch { + /* skip */ + } + } + return turns.slice(-MAX_TURNS_RETURNED) + } + + append(chatId: number, turns: ChatTurn[]): void { + if (turns.length === 0) return + const path = this.pathFor(chatId) + // Rotate when the file gets large. + if (existsSync(path)) { + const stat = require('node:fs').statSync(path) as {size: number} + if (stat.size > ROTATE_BYTES) { + const recent = this.read(chatId) + writeFileSync(path, recent.map((t) => JSON.stringify(t)).join('\n') + '\n', {mode: 0o600}) + } + } + const lines = turns.map((t) => JSON.stringify(t)).join('\n') + '\n' + appendFileSync(path, lines) + } + + private pathFor(chatId: number): string { + return join(this.dir, `${chatId}.jsonl`) + } +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts new file mode 100644 index 0000000000..91e2b57878 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts @@ -0,0 +1,185 @@ +/** + * Shared reply pipeline: search the community site for relevant docs, + * inject as context, ask DeepSeek for a grounded answer. Used by both + * the polling driver (replies to comment mentions) and the Telegram bot + * (replies to operator queries). + */ + +import type {SeedCli} from './seedcli.js' +import type {AuditRun} from './audit.js' + +const TOP_K = 5 +const PER_DOC_CHARS = 600 + +const COMMUNITY_SYSTEM_PROMPT = + `You are the Knowledge Manager — a moderator of a Seed Hypermedia community. ` + + `Answer the user's question grounded in the community's own documents whenever possible. ` + + `When you reference a document, embed its full hm:// URL inline as a markdown link, e.g. [Title](hm://...). ` + + `If the community context below is empty or doesn't cover the question, answer from your general knowledge in one sentence and explicitly say "I couldn't find this in our community's docs" so the asker knows. ` + + `Plain text or simple markdown only. No headers, no code fences, no greeting/signoff. Stay under 120 words.` + +const SYSTEM_INSPECTOR_PROMPT = + `You are the Knowledge Manager bot answering an OPERATOR question about your own implementation, configuration, and recent activity. ` + + `Use the system context blocks below to ground every claim. ` + + `If you don't know, say so plainly. Never make up paths, services, or commands. ` + + `Answer concisely (≤200 words). Plain text or simple markdown. Reference filenames or systemd units explicitly when relevant.` + +export type ChatTurn = {role: 'user' | 'assistant'; content: string} + +type SearchHit = {hmUrl: string; title?: string} + +export async function gatherSiteContext( + cli: SeedCli, + question: string, + siteAccount: string, + audit?: AuditRun, +): Promise<string> { + const sr = await cli.runRead(['search', question, '-a', siteAccount]) + if (sr.exitCode !== 0 || !sr.parsedJson) { + audit?.trace({ts: nowIso(), level: 'warn', event: 'site_context_search_failed', data: {exitCode: sr.exitCode}}) + return '' + } + type RawHit = {id?: string | {id?: string}; title?: string} + const raw = + (sr.parsedJson as {entities?: RawHit[]; results?: RawHit[]}).entities ?? + (sr.parsedJson as {results?: RawHit[]}).results ?? + [] + const top: SearchHit[] = raw + .map((h): SearchHit | null => { + const id = h.id + if (typeof id === 'string') return {hmUrl: id, title: h.title} + if (id && typeof id === 'object' && typeof id.id === 'string') return {hmUrl: id.id, title: h.title} + return null + }) + .filter((x): x is SearchHit => x !== null) + .slice(0, TOP_K) + if (top.length === 0) { + audit?.trace({ts: nowIso(), level: 'info', event: 'site_context_empty', data: {question: question.slice(0, 200)}}) + return '' + } + const sections: string[] = [] + for (let i = 0; i < top.length; i++) { + const hit = top[i]! + const dr = await cli.runRead(['document', 'get', hit.hmUrl]) + if (dr.exitCode !== 0) continue + const body = dr.stdout.replace(/<!--\s*id:[^>]+-->/g, '').slice(0, PER_DOC_CHARS).trim() + sections.push(`${i + 1}. ${hit.title ?? '(untitled)'} — ${hit.hmUrl}\n${body}`) + } + audit?.trace({ + ts: nowIso(), + level: 'info', + event: 'site_context_collected', + data: {hits: raw.length, used: sections.length, urls: top.map((t) => t.hmUrl)}, + }) + if (sections.length === 0) return '' + return `## Community context (relevant documents found in this site)\n${sections.join('\n\n')}` +} + +export async function draftReply( + question: string, + siteContext: string, + audit?: AuditRun, + history: ChatTurn[] = [], +): Promise<string | null> { + const userMsg = siteContext + ? `Question: ${question}\n\n${siteContext}` + : `Question: ${question}\n\n## Community context\n(no relevant documents found in the community for this query)` + return callDeepSeek( + [ + {role: 'system', content: COMMUNITY_SYSTEM_PROMPT}, + ...history, + {role: 'user', content: userMsg}, + ], + {audit, maxTokens: 400}, + ) +} + +/** + * Operator-facing reply. Used by Telegram `/ask`. The caller assembles + * a system-context blob (README + recent runs + governance) and passes + * it inline alongside the question. No site search. Multi-turn aware. + */ +export async function draftSystemReply( + question: string, + systemContext: string, + audit?: AuditRun, + history: ChatTurn[] = [], +): Promise<string | null> { + const userMsg = `Operator question: ${question}\n\n## System context\n${systemContext}` + return callDeepSeek( + [ + {role: 'system', content: SYSTEM_INSPECTOR_PROMPT}, + ...history, + {role: 'user', content: userMsg}, + ], + {audit, maxTokens: 600, temperature: 0.2}, + ) +} + +async function callDeepSeek( + messages: Array<{role: 'system' | 'user' | 'assistant'; content: string}>, + opts: {audit?: AuditRun; maxTokens?: number; temperature?: number}, +): Promise<string | null> { + const audit = opts.audit + const apiKey = process.env.DEEPSEEK_API_KEY + if (!apiKey) { + audit?.trace({ts: nowIso(), level: 'error', event: 'deepseek_no_key'}) + return null + } + const body = JSON.stringify({ + model: 'deepseek-chat', + messages, + temperature: opts.temperature ?? 0.4, + max_tokens: opts.maxTokens ?? 400, + }) + const t0 = Date.now() + let res: Response + try { + res = await fetch('https://api.deepseek.com/v1/chat/completions', { + method: 'POST', + headers: {'content-type': 'application/json', authorization: `Bearer ${apiKey}`}, + body, + }) + } catch (err) { + audit?.trace({ + ts: nowIso(), + level: 'error', + event: 'deepseek_network_error', + data: {message: err instanceof Error ? err.message : String(err)}, + }) + return null + } + const latencyMs = Date.now() - t0 + if (!res.ok) { + const text = await res.text().catch(() => '') + audit?.trace({ + ts: nowIso(), + level: 'error', + event: 'deepseek_http_error', + data: {status: res.status, body: text.slice(0, 300), latencyMs}, + }) + return null + } + const json = (await res.json()) as { + choices?: Array<{message?: {content?: string}}> + usage?: {prompt_tokens?: number; completion_tokens?: number; total_tokens?: number} + } + const reply = json.choices?.[0]?.message?.content?.trim() + audit?.llm({ + ts_start: new Date(t0).toISOString(), + ts_end: nowIso(), + latency_ms: latencyMs, + model: 'deepseek-chat', + completion: reply ?? '', + usage: { + prompt: json.usage?.prompt_tokens, + completion: json.usage?.completion_tokens, + total: json.usage?.total_tokens, + }, + }) + return reply ?? null +} + +function nowIso(): string { + return new Date().toISOString() +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/system-context.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/system-context.ts new file mode 100644 index 0000000000..64b8c2f76d --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/system-context.ts @@ -0,0 +1,80 @@ +/** + * Assembles the "system context" blob used by `/ask` operator queries. + * + * Pulls together: + * - README excerpt (~3 KB) with architecture / commands / known issues + * - Last 5 audit run summaries (one line each, from index.jsonl) + * - Current governance rules JSON (from the cached GovernanceCache) + * + * Total target: ≤8 KB so DeepSeek's token budget stays comfortable. + */ + +import {existsSync, readFileSync} from 'node:fs' +import {join} from 'node:path' +import type {GovernanceCache} from './governance.js' + +const README_PATHS = [ + '/home/km/km-agent/README.md', + '/home/km/.nanobot/workspace/skill/agent/README.md', +] +const README_BUDGET = 3500 // chars + +export async function buildSystemContext(opts: { + governance: GovernanceCache + logsDir: string +}): Promise<string> { + const sections: string[] = [] + const readme = loadReadme() + if (readme) sections.push(`### README excerpt\n${readme}`) + const runs = loadRecentRuns(opts.logsDir, 8) + if (runs) sections.push(`### Recent audit runs (last 8)\n${runs}`) + try { + const g = await opts.governance.getGovernance() + sections.push( + `### Current governance rules\n\`\`\`json\n${JSON.stringify(g.rules, null, 2)}\n\`\`\``, + ) + } catch { + /* ignore */ + } + return sections.join('\n\n') +} + +function loadReadme(): string { + for (const p of README_PATHS) { + if (existsSync(p)) { + const body = readFileSync(p, 'utf-8') + // Trim to budget — drop the layout reference at the bottom first. + return body.length > README_BUDGET ? body.slice(0, README_BUDGET) + '\n…[truncated]' : body + } + } + return '' +} + +function loadRecentRuns(logsDir: string, n: number): string { + const idx = join(logsDir, 'index.jsonl') + if (!existsSync(idx)) return '' + const lines = readFileSync(idx, 'utf-8').trim().split('\n').slice(-n) + const out: string[] = [] + for (const line of lines) { + try { + const r = JSON.parse(line) as { + id?: string + trigger?: string + start?: string + end?: string + status?: string + wall_ms?: number + counters?: Record<string, number> + } + const counters = r.counters + ? Object.entries(r.counters) + .map(([k, v]) => `${k}=${v}`) + .join(' ') + : '' + out.push(`- ${r.start} ${r.trigger} status=${r.status} ${r.wall_ms}ms ${counters}`) + } catch { + /* skip */ + } + } + return out.join('\n') +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/telegram-bot.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/telegram-bot.ts new file mode 100644 index 0000000000..9b059cf4cc --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/telegram-bot.ts @@ -0,0 +1,304 @@ +#!/usr/bin/env node +/** + * Telegram operator-channel bot. READ-MOSTLY surface for the human + * operator to peek at agent state and trigger non-destructive actions. + * + * /status — service statuses + last-run summary. + * /last-runs [N] — last N audit runs (default 5). + * /show-rules — current governance rules JSON. + * /poll-now — kicks `systemctl --user start km-poll.service`. + * + * Security: only chat IDs listed in OPS_TELEGRAM_ID (comma-separated) + * are answered. Everyone else is silently ignored. Mutations to Seed + * documents or capabilities are NOT exposed here — for those, edit the + * governance docs from your desktop. + */ + +import {execFileSync, spawnSync} from 'node:child_process' +import {existsSync, readFileSync, readdirSync} from 'node:fs' +import {join} from 'node:path' +import {GovernanceCache} from './governance.js' +import {SeedCli} from './seedcli.js' +import {AuditRun} from './audit.js' +import {buildRedactor} from './redact.js' +import {loadConfig} from './config.js' +import {draftReply, draftSystemReply, gatherSiteContext} from './reply-engine.js' +import {ChatHistory} from './chat-history.js' +import {buildSystemContext} from './system-context.js' + +type TelegramUpdate = { + update_id: number + message?: { + message_id: number + from?: {id: number; username?: string} + chat: {id: number} + text?: string + } +} + +const POLL_TIMEOUT_SEC = 25 + +async function main(): Promise<void> { + const token = process.env.TELEGRAM_TOKEN + if (!token) throw new Error('TELEGRAM_TOKEN not set') + const allowedIds = new Set( + (process.env.OPS_TELEGRAM_ID ?? '') + .split(/[,;]\s*/) + .filter(Boolean) + .map((s) => Number(s)) + .filter((n) => Number.isFinite(n)), + ) + if (allowedIds.size === 0) throw new Error('OPS_TELEGRAM_ID empty — refusing to expose bot to the world') + + const config = loadConfig() + const redactor = buildRedactor() + const cli = new SeedCli(config, redactor) + const governance = new GovernanceCache(config, cli) + const history = new ChatHistory(config.stateDir) + + // eslint-disable-next-line no-console + console.log(`telegram-bot listening for chats in {${[...allowedIds].join(',')}}`) + + let offset = 0 + for (;;) { + let updates: TelegramUpdate[] = [] + try { + updates = await fetchUpdates(token, offset, POLL_TIMEOUT_SEC) + } catch (err) { + // eslint-disable-next-line no-console + console.error('getUpdates failed:', err instanceof Error ? err.message : err) + await sleep(5000) + continue + } + for (const u of updates) { + offset = u.update_id + 1 + const msg = u.message + if (!msg?.from || !msg.text) continue + // Accept either the sender's user-id (DM with the bot) or the + // chat-id (group/channel where the bot is allowed to read). Both + // forms can appear in OPS_TELEGRAM_ID; the operator picks + // whichever scope they want. + if (!allowedIds.has(msg.from.id) && !allowedIds.has(msg.chat.id)) continue + try { + const text = msg.text.trim() + if (text.startsWith('/ask')) { + await handleSystemQuestion(token, msg.chat.id, text.slice(4).trim(), config, governance, history) + } else if (text.startsWith('/')) { + const reply = await handleCommand(text, config, governance) + await sendMessage(token, msg.chat.id, reply) + } else { + await handleCommunityQuestion(token, msg.chat.id, text, config, cli, history) + } + } catch (err) { + const txt = err instanceof Error ? err.message : String(err) + await sendMessage(token, msg.chat.id, `❌ ${txt}`) + } + } + } +} + +async function handleCommunityQuestion( + token: string, + chatId: number, + text: string, + config: ReturnType<typeof loadConfig>, + cli: SeedCli, + history: ChatHistory, +): Promise<void> { + await sendChatAction(token, chatId, 'typing') + const audit = new AuditRun({ + logsDir: config.logsDir, + trigger: 'telegram-question', + redactor: buildRedactor(), + seedSite: config.seedSite, + }) + try { + const siteAccount = config.seedSite.replace(/^hm:\/\//, '').split('/')[0]! + const ctx = await gatherSiteContext(cli, text, siteAccount, audit) + const turns = history.read(chatId) + const answer = await draftReply(text, ctx, audit, turns) + const reply = answer ?? "I tried to draft a reply but hit a snag. Try rephrasing." + await sendMessage(token, chatId, reply) + history.append(chatId, [ + {role: 'user', content: text}, + {role: 'assistant', content: reply}, + ]) + audit.trace({ts: new Date().toISOString(), level: 'info', event: 'telegram_reply_sent', data: {chatId, mode: 'community'}}) + } finally { + audit.close({status: 'ok', logsDir: config.logsDir}) + } +} + +async function handleSystemQuestion( + token: string, + chatId: number, + question: string, + config: ReturnType<typeof loadConfig>, + governance: GovernanceCache, + history: ChatHistory, +): Promise<void> { + if (!question) { + await sendMessage(token, chatId, 'Usage: /ask <question about the bot or its config>') + return + } + await sendChatAction(token, chatId, 'typing') + const audit = new AuditRun({ + logsDir: config.logsDir, + trigger: 'telegram-ask', + redactor: buildRedactor(), + seedSite: config.seedSite, + }) + try { + const ctx = await buildSystemContext({governance, logsDir: config.logsDir}) + const turns = history.read(chatId) + const answer = await draftSystemReply(question, ctx, audit, turns) + const reply = answer ?? 'Could not draft a reply (DeepSeek error). Check logs.' + await sendMessage(token, chatId, reply) + history.append(chatId, [ + {role: 'user', content: `/ask ${question}`}, + {role: 'assistant', content: reply}, + ]) + audit.trace({ts: new Date().toISOString(), level: 'info', event: 'telegram_reply_sent', data: {chatId, mode: 'ask'}}) + } finally { + audit.close({status: 'ok', logsDir: config.logsDir}) + } +} + +async function handleCommand( + text: string, + config: ReturnType<typeof loadConfig>, + governance: GovernanceCache, +): Promise<string> { + const [cmd, ...rest] = text.split(/\s+/) + switch (cmd) { + case '/start': + case '/help': + return [ + 'Knowledge Manager — operator commands', + '', + '/status — service health + last-run summary', + '/last-runs [N] — recent audit runs (default 5)', + '/show-rules — current governance rules', + '/poll-now — trigger immediate poll', + '/ask <question> — operator-mode Q&A about the bot itself (README + recent runs as context)', + '', + 'Or send a plain message: community-mode Q&A grounded in the site corpus.', + 'Conversation history is preserved per chat for follow-ups (last 10 turns).', + ].join('\n') + case '/status': + return formatStatus(config.logsDir) + case '/last-runs': { + const n = Math.max(1, Math.min(20, parseInt(rest[0] ?? '5', 10) || 5)) + return formatRecentRuns(config.logsDir, n) + } + case '/show-rules': { + const g = await governance.getGovernance(true) + return '```\n' + JSON.stringify({rules: g.rules, allowlist: g.allowlist}, null, 2) + '\n```' + } + case '/poll-now': { + const r = spawnSync('systemctl', ['--user', 'start', 'km-poll.service'], {encoding: 'utf-8'}) + return r.status === 0 ? '✓ poll triggered' : `❌ ${r.stderr || 'unknown error'}` + } + default: + return 'Unknown command. Try /help.' + } +} + +function formatStatus(logsDir: string): string { + const services = ['nanobot-gateway', 'km-poll.timer', 'km-boletin.timer', 'km-gap.timer', 'km-health.timer', 'km-telegram'] + const lines: string[] = ['*Service status*'] + for (const s of services) { + let r + try { + r = execFileSync('systemctl', ['--user', 'is-active', s], {encoding: 'utf-8'}).trim() + } catch (e) { + r = (e as {stdout?: string}).stdout?.toString().trim() ?? 'unknown' + } + lines.push(`${r === 'active' ? '🟢' : '🔴'} ${s}: ${r}`) + } + const idx = join(logsDir, 'index.jsonl') + if (existsSync(idx)) { + const tail = readFileSync(idx, 'utf-8').trim().split('\n').slice(-3) + lines.push('', '*Last 3 runs*') + for (const line of tail) { + try { + const r = JSON.parse(line) as {trigger?: string; start?: string; status?: string; wall_ms?: number} + lines.push(`• ${r.start} ${r.trigger} → ${r.status} (${r.wall_ms}ms)`) + } catch { + /* skip */ + } + } + } + return lines.join('\n') +} + +function formatRecentRuns(logsDir: string, n: number): string { + const runsDir = join(logsDir, 'runs') + if (!existsSync(runsDir)) return 'no runs yet' + const dirs = readdirSync(runsDir) + .filter((d) => existsSync(join(runsDir, d, 'meta.json'))) + .sort() + .slice(-n) + const lines: string[] = [] + for (const d of dirs.reverse()) { + try { + const meta = JSON.parse(readFileSync(join(runsDir, d, 'meta.json'), 'utf-8')) as { + trigger?: string + startedAt?: string + wallMs?: number + status?: string + counters?: Record<string, number> + } + const counters = meta.counters + ? Object.entries(meta.counters) + .map(([k, v]) => `${k}=${v}`) + .join(' ') + : '' + lines.push(`• ${meta.startedAt} ${meta.trigger} ${meta.status} ${meta.wallMs}ms ${counters}`) + } catch { + /* skip */ + } + } + return lines.length === 0 ? 'no runs yet' : lines.join('\n') +} + +async function fetchUpdates(token: string, offset: number, timeout: number): Promise<TelegramUpdate[]> { + const url = `https://api.telegram.org/bot${token}/getUpdates?timeout=${timeout}&offset=${offset}` + const r = await fetch(url) + if (!r.ok) throw new Error(`getUpdates ${r.status}`) + const json = (await r.json()) as {ok: boolean; result?: TelegramUpdate[]; description?: string} + if (!json.ok) throw new Error(json.description ?? 'getUpdates !ok') + return json.result ?? [] +} + +async function sendMessage(token: string, chatId: number, text: string): Promise<void> { + await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({chat_id: chatId, text, parse_mode: 'Markdown'}), + }) +} + +async function sendChatAction(token: string, chatId: number, action: 'typing'): Promise<void> { + // Best-effort; ignore failures. + try { + await fetch(`https://api.telegram.org/bot${token}/sendChatAction`, { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({chat_id: chatId, action}), + }) + } catch { + /* ignore */ + } +} + +function sleep(ms: number): Promise<void> { + return new Promise((res) => setTimeout(res, ms)) +} + +void AuditRun +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('telegram-bot fatal:', err) + process.exit(1) +}) diff --git a/seed-knowledge-manager/agent/systemd/km-telegram.service b/seed-knowledge-manager/agent/systemd/km-telegram.service new file mode 100644 index 0000000000..fc8b561d50 --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/km-telegram.service @@ -0,0 +1,17 @@ +[Unit] +Description=Knowledge Manager — Telegram operator bot +After=default.target + +[Service] +Type=simple +EnvironmentFile=/home/km/.nanobot/secrets.env +Environment=PATH=/home/km/.local/bin:/usr/bin:/usr/local/bin +Environment=KM_STATE_DIR=/home/km/km-state +Environment=KM_LOGS_DIR=/home/km/km-logs +Environment=SEED_CLI_PATH=/home/km/.local/bin/seed-cli +ExecStart=/usr/bin/node /home/km/km-agent/mcp/seed-cli-mcp/dist/telegram-bot.js +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target From f444e1c9b466b991d90f6c12f405600f5c9b7755 Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Wed, 6 May 2026 15:59:18 +0200 Subject: [PATCH 09/17] docs(agent): operator runbook + verification matrix (Phase 8) Rewrite the agent README as the canonical operator runbook: - as-built ASCII architecture diagram - governance docs (the four Seed-side policy files) + kill-switch - operator quick-reference (one-liners for log browsing, manual triggers, gateway restart) - end-to-end bootstrap from scratch (10 numbered steps), each with the divergences we discovered along the way called out as inline NOTEs (Node 22 NodeSource, daemon -keystore-dir, secret-tool shim, seed-web container config.json, agent profile via Vault, port 18791 conflict, etc) - Telegram bot (/help /status /last-runs /show-rules /poll-now /ask <q> + plain-text community Q&A) with security note - 22-row verification matrix marking everything verified live on production - known issues + workarounds (seed-cli --reply Non-base58btc, agent home-doc creation 500, comment-create stderr quirk, activity --resource exact-match, cursor model, nanobot vs deterministic driver tradeoffs) - complete repo layout reference Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- seed-knowledge-manager/agent/README.md | 489 +++++++++++++++++++++---- 1 file changed, 411 insertions(+), 78 deletions(-) diff --git a/seed-knowledge-manager/agent/README.md b/seed-knowledge-manager/agent/README.md index 8200397a5d..64526a9c63 100644 --- a/seed-knowledge-manager/agent/README.md +++ b/seed-knowledge-manager/agent/README.md @@ -1,111 +1,444 @@ -# Knowledge Manager Agent — operator runbook +# Knowledge Manager Agent -Autonomous **Moderador de Redes** (LAFH/GC-Red methodology) for a Seed Hypermedia community. Runs on `oc.hyper.media`. Governed by Seed documents. +Autonomous **Moderador de Redes** (LAFH/GC-Red methodology, see `seed-knowledge-manager/SKILL.md`) for a Seed Hypermedia community. Runs on `oc.hyper.media`. **Governed by Seed documents**, not local config. -> Status: Phase 0 scaffolding. Subsequent phases populate this README with deploy steps, kill-switch procedure, log paths, and Telegram setup. +## What this is -## Architecture summary +> Headline goal: prove that an agent can be governed by Seed documents — its charter, its allow/deny path rules, its draft-only kill-switch — instead of local YAML/markdown. -- **Local Seed daemon** (`seed-daemon` Docker container) on `127.0.0.1:55001` (HTTP), `:55002` (gRPC), `:55000` (P2P). -- **HKUDS/nanobot** runtime (Python `pip install nanobot-ai`), DeepSeek LLM. -- **Custom stdio MCP wrapper** around `seed-cli` for security envelope, rate limits, audit logging. -- **Telegram channel** (operator-only) as secondary chat surface. -- All policy lives as **Seed documents** under `/agents/knowledge-manager/*` in the target site. +Production deployment connects against community site `hm://z6MkuBbsB1HbSNXLvJCRCrPhimY6g7tzhr4qvcYKPuSZzhno`. Agent identity is `KM_AID = z6Mkh11xNzNLTrkDEjmPf19twBvAVsw3HoQtv5nPKVVbEUSJ`. -## Phase index +The agent: +- Polls the site every 15 seconds for `@knowledge-manager` and `@<site>` mentions in comments. +- Posts a placeholder reply ("Working on this — back in a moment. ⌛") within ~1–2s of detection so members get a typing-indicator equivalent. +- Searches the community corpus (`seed-cli search`) for documents relevant to the question, fetches them, and feeds them to DeepSeek as grounding context. +- Edits the placeholder in place (or replies in a fresh top-level comment if seed-cli's `--reply` chain breaks) with the final answer, citing hm:// URLs. +- Runs three scheduled cadences via systemd timers — weekly bulletin (Mon 09:00 UTC), gap report (Wed 10:00 UTC), monthly health report (1st of month 09:00 UTC) — each producing a Seed document under `/agents/knowledge-manager/state/...`. +- Captures every action — LLM call, tool call, seed-cli invocation, mention enqueued, reply posted — to a per-run audit directory under `~km/km-logs/runs/`. -- Phase 0 — Repo scaffolding (this commit). -- Phase 1 — Server bootstrap (Docker, daemon, OS deps, `km` user). -- Phase 2 — Agent identity + capability grant. -- Phase 3 — `seed-cli` MCP wrapper. -- Phase 4 — nanobot install + governance bootstrap. -- Phase 5 — Mention polling + reaction. -- Phase 6 — Scheduled LAFH cadences. -- Phase 7 — Telegram secondary channel. -- Phase 8 — Audit-log polish + verification suite. +The agent's policy lives entirely in four Seed documents the operator can edit from any desktop client. Toggling `draft_only: true` in the rules doc disables doc-creating writes within ≤60s. The wrapper hardcodes a denylist that prevents the agent from rewriting its own rules. -## Layout +## Architecture ``` -agent/ -├── config/ # nanobot.json template (Phase 4) -├── seed-daemon/ # docker compose for local daemon (Phase 1) -├── mcp/seed-cli-mcp/ # custom stdio MCP wrapping seed-cli (Phase 3) -├── systemd/ # user-mode unit files (all phases) -├── scripts/ # install.sh, km-log helper (Phases 1, 5) -├── templates/ # bootstrap seeds for Seed governance docs (Phase 4) -└── logrotate/ # km-logs.conf user logrotate rule (Phase 5) +┌──────────────────────────── oc.hyper.media (Ubuntu 24.04) ────────────────────────────┐ +│ │ +│ systemd --user (linger enabled, user "km"): │ +│ │ +│ ┌──────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ seed-daemon.service │ │ km-poll.timer │ │ km-boletin.timer │ │ +│ │ (docker compose) │ │ every 15s │ │ Mon 09:00 UTC │ │ +│ └─────────┬────────────┘ │ → poll-cli.js │ │ → cadence-cli.js │ │ +│ │ └────────────────────┘ └────────────────────┘ │ +│ ▼ ┌────────────────────┐ ┌────────────────────┐ │ +│ ┌──────────────────────┐ │ km-gap.timer │ │ km-health.timer │ │ +│ │ km-seed-daemon │ │ Wed 10:00 UTC │ │ 1st 09:00 UTC │ │ +│ │ km-seed-web (:3000) │ │ → cadence-cli.js │ │ → cadence-cli.js │ │ +│ │ (docker) │ └────────────────────┘ └────────────────────┘ │ +│ └──────────────────────┘ ┌────────────────────┐ │ +│ │ km-telegram.service│ ┌────────────────────┐ │ +│ │ (long-running) │ │ nanobot-gateway │ │ +│ │ → telegram-bot.js │ │ :18791 (optional) │ │ +│ └────────────────────┘ └────────────────────┘ │ +│ │ +│ ~/km-agent/mcp/seed-cli-mcp/dist/ │ +│ poll-cli.js ← mention polling + typing-indicator + grounded reply │ +│ cadence-cli.js ← weekly/monthly LAFH outputs │ +│ telegram-bot.js ← operator chat surface │ +│ index.js ← stdio MCP wrapper used by the (optional) nanobot gateway │ +│ │ +│ ~/km-state/ │ +│ activity-cursor.json processed.jsonl placeholders.jsonl rate-counters.json │ +│ │ +│ ~/km-logs/ runs/<UTC-ISO>__<trigger>__<ulid>/ index.jsonl current → runs/... │ +│ │ +└────────────────────────────────────────────────────┬──────────────────────────────────┘ + │ + HTTPS │ outbound + ┌──────────────────────────────────────────────┤ + ▼ ▼ + api.deepseek.com hyper.media (P2P + REST) + (one chat completion / answer) (read activity, post comments, + fetch governance, search corpus) +``` + +Components: + +- **Local Seed daemon** (Docker `seedhypermedia/site:latest`, runs as a pure peer): ports `55000` (P2P, public) and `127.0.0.1:55001` HTTP / `:55002` gRPC (loopback). Plus `seed-web:latest` on `127.0.0.1:3000` because `seed-cli` speaks the Remix `/api/<RPC>` shape, not raw gRPC-Web. Currently the wrapper points at `https://hyper.media` via `SEED_SERVER` because the local daemon's smart-syncing lags on capability blobs; switch back to `http://127.0.0.1:3000` once you have a more aggressive sync strategy. +- **seed-cli** built from this repo's `frontend/apps/cli/` and dropped at `/home/km/.local/bin/seed-cli`. Published `@seed-hypermedia/cli@0.1.4` on npm has an unresolved `workspace:*` dep that breaks `npx`, so we ship a Bun-bundled binary instead. +- **secret-tool shim** at `/home/km/.local/bin/secret-tool` (file-backed, `chmod 600` JSON in `~/.config/seed-keyring/secrets.json`). Replaces `gnome-keyring`/`libsecret`, which can't bootstrap on a headless server. Same on-wire format as the OS keyring entries seed-cli expects. +- **Custom Bun-bundled drivers** (one ~430 KB `dist/index.js` + smaller per-task bundles): `poll-cli.js`, `cadence-cli.js`, `telegram-bot.js`, plus the optional MCP wrapper `index.js` for nanobot. +- **DeepSeek** (`https://api.deepseek.com/v1/chat/completions`) as the LLM. One chat call per mention answered. Reasoning content not currently captured (set up but not exposed by `deepseek-chat`). + +We started with HKUDS/nanobot orchestrating the polling loop, but DeepSeek kept getting stuck in `read_file/grep` loops on nanobot's tool-result-spilled-to-disk pattern. The polling driver is now a deterministic Bun script that does one DeepSeek call per question and posts the reply directly. The `nanobot gateway` process can stay running for free-form interactive use, but it is no longer on the critical path. + +## Governance — the four Seed documents + +All four exist on the production site: + +| Doc | Path | Purpose | +| --- | --- | --- | +| `agent-charter` | `/agents/knowledge-manager/charter` | Community purpose, voice, scope, off-topics. Human-editable. | +| `agent-rules` | `/agents/knowledge-manager/rules` | Machine-readable YAML block at `# ----- machine-readable rules begin -----`. Hard policy: allow/deny paths, caps, draft-only kill-switch, mention trigger, invoker source (`writer-capabilities` or `allowlist-doc`), language. | +| `agent-runbook` | `/agents/knowledge-manager/runbook` | Soft instructions on tone, escalation, formatting overrides. | +| `agent-allowlist` | `/agents/knowledge-manager/allowlist` | Optional invoker list when `mentions.invoker_source: allowlist-doc`. | + +Edit any of them from desktop. The agent re-reads them on every run (60 s TTL cache). The wrapper additionally hardcodes a denylist over those four paths so the agent itself cannot rewrite its own constraints. + +### Kill switch + +In `agent-rules`, set `draft_only: true`. Within 60 s the cadence drivers will refuse `seed-cli document create` calls and `poll-cli` will continue posting comments only (no doc writes). To force immediate refresh: `systemctl --user restart nanobot-gateway` on the server (clears the in-process cache). + +## Operator — quick reference + +```bash +ssh ubuntu@oc.hyper.media + +# All systemd state belongs to user `km`. +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user list-timers + +# Watch the latest run live. +sudo -u km bash -lc '/home/km/.local/bin/km-log tail' + +# Recent runs. +sudo -u km bash -lc '/home/km/.local/bin/km-log latest 10' + +# Print a specific run. +sudo -u km bash -lc '/home/km/.local/bin/km-log show 01KQYG…' + +# Find all runs that touched a comment id. +sudo -u km bash -lc '/home/km/.local/bin/km-log mention z6Gd...' + +# Force an immediate poll (typing-indicator pattern still applies). +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user start km-poll.service + +# Force a weekly bulletin / gap / health right now. +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user start km-boletin.service +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user start km-gap.service +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user start km-health.service + +# Restart the optional nanobot gateway (also clears the rules cache). +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user restart nanobot-gateway +``` + +Logs are `chmod 700` under `/home/km/km-logs/`. Each run dir contains: + +``` +meta.json trigger, KM_AID, start, end, wall_ms, status, counters +trace.jsonl ordered events (governance_loaded, mention_enqueued, placeholder_posted, reply_finalised, …) +llm.jsonl DeepSeek prompts + completions + tokens + latency +seed-cli.jsonl every shell-out: argv, exit code, stdout, stderr (truncated, redacted) +stdout.log / stderr.log raw process streams +``` + +`index.jsonl` at the top level carries one summary line per run for tail-grepping. + +Logrotate config under `/home/km/.config/logrotate.d/km-logs.conf` keeps 30 days / 5 GB. + +## End-to-end setup (bootstrap from scratch) + +These are the as-built steps, in order. Anything we discovered along the way that diverges from the original plan is captured in **bold notes**. + +### 1. Server prep + +Ubuntu 24.04, Docker present. + +```bash +ssh ubuntu@oc.hyper.media + +sudo apt update +sudo apt install -y \ + python3.12 python3.12-venv pipx \ + libsecret-1-0 libsecret-tools dbus-user-session bubblewrap \ + jq curl rsync logrotate gnome-keyring + +# **NOTE:** Ubuntu's stock `nodejs` (18.x) ships with npm 9 which can't +# install packages with `workspace:*` deps. Replace with NodeSource 22. +sudo apt remove -y nodejs npm libnode-dev +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash - +sudo apt install -y nodejs + +# Create the agent user. +sudo useradd --create-home --shell /bin/bash km +sudo usermod -aG docker km +sudo loginctl enable-linger km +sudo install -d -m 700 -o km -g km \ + /home/km/.local /home/km/.local/state /home/km/.local/share \ + /home/km/.local/bin /home/km/.cache /home/km/.config \ + /home/km/.config/systemd/user /home/km/.config/logrotate.d \ + /home/km/.config/seed-keyring /home/km/.secrets \ + /home/km/.nanobot /home/km/.nanobot/workspace \ + /home/km/seed-daemon /home/km/seed-daemon/data /home/km/seed-daemon/web-data \ + /home/km/km-agent /home/km/km-agent/mcp/seed-cli-mcp/dist \ + /home/km/km-state /home/km/km-logs +``` + +### 2. Local Seed daemon + web + +Compose file at `/home/km/seed-daemon/compose.yaml` (deployed by `agent/seed-daemon/compose.yaml` from this repo). Two containers: + +- `km-seed-daemon` (image `seedhypermedia/site:latest`) on `127.0.0.1:55001-55002` + public `:55000`. +- `km-seed-web` (image `seedhypermedia/web:latest`) on `127.0.0.1:3000` — necessary because `seed-cli` speaks `/api/<RPC>` over the Remix server, not raw gRPC-Web. + +The web container needs `web-data/config.json`. We seed it with `{}`: + +```bash +sudo install -m 644 -o km -g km <(echo '{}') /home/km/seed-daemon/web-data/config.json +``` + +Systemd user unit `seed-daemon.service` orchestrates `docker compose up -d` (see `agent/systemd/seed-daemon.service`). + +> **NOTE — original plan vs. as-built:** The plan started with daemon-only at `127.0.0.1:55001`. We then learned `seed-cli` requires the Remix `/api/<RPC>` surface, so a `seed-web` container was added. **The wrapper currently still points at `https://hyper.media` via `SEED_SERVER` because the local daemon's smart-syncing lags on capability blobs.** Switch to `http://127.0.0.1:3000` once you have a way to force-pull the site root and its capability/contact graph. + +> **NOTE — daemon keystore:** The compose command must include `-keystore-dir=/data/keys`. Without it the daemon's vault.NewProduction tries to talk to libsecret/dbus inside the container, which doesn't exist. Resulting in `failed to create production keystore: failed reading vault credentials from keyring: exec: "dbus-launch": executable file not found in $PATH`. + +### 3. seed-cli on the host + +The published `@seed-hypermedia/cli@0.1.4` on npm has an unresolved `workspace:*` dep on `@seed-hypermedia/client`, so `npx -y @seed-hypermedia/cli` fails. Build from this repo with Bun on your Mac (`bun run build` in `frontend/apps/cli/`) and ship the bundled `dist/index.js`: + +```bash +# From your local repo: +scp frontend/apps/cli/dist/index.js ubuntu@oc.hyper.media:/tmp/seed-cli.js +ssh ubuntu@oc.hyper.media ' + sudo install -d -m 755 -o km -g km \ + /home/km/.local/share/seed-cli /home/km/.local/share/seed-cli/dist + sudo install -m 755 -o km -g km /tmp/seed-cli.js \ + /home/km/.local/share/seed-cli/dist/index.js + sudo install -m 644 -o km -g km <(echo "{\"name\":\"@seed-hypermedia/cli\",\"version\":\"0.1.1\",\"type\":\"module\",\"bin\":{\"seed-cli\":\"./dist/index.js\"}}") \ + /home/km/.local/share/seed-cli/package.json + sudo ln -sf /home/km/.local/share/seed-cli/dist/index.js /home/km/.local/bin/seed-cli + sudo chown -h km:km /home/km/.local/bin/seed-cli + sudo rm /tmp/seed-cli.js +' ``` -## Governance docs (created by agent on first run) +> **NOTE — version-lookup:** seed-cli does `readFileSync('../package.json', import.meta.url)` to read its own version. The `package.json` next to the bundled `dist/` is required, even if minimal. -| Path under target site | Purpose | -| --- | --- | -| `/agents/knowledge-manager/charter` | Community purpose, voice, scope. | -| `/agents/knowledge-manager/rules` | Hard policy: deny paths, caps, draft-only kill-switch. | -| `/agents/knowledge-manager/runbook` | Soft instructions: tone, escalation, formatting. | -| `/agents/knowledge-manager/allowlist` | Optional invoker list (defaults to WRITER capability set). | +### 4. Headless Linux keyring shim -## Kill-switch +`gnome-keyring`'s default collection won't initialise without a graphical session, so `secret-tool` fails with `Object does not exist at path /org/freedesktop/secrets/collection/login`. We replace `secret-tool` with a Bash shim that stores keys in a mode-600 JSON file: -Edit `/agents/knowledge-manager/rules` in the Seed app, set `draft_only: true`. Effective within ≤60s (rules cache TTL). To force immediate refresh: `systemctl --user restart nanobot-gateway` on `oc.hyper.media`. +```bash +scp seed-knowledge-manager/agent/scripts/secret-tool-shim ubuntu@oc.hyper.media:/tmp/secret-tool +ssh ubuntu@oc.hyper.media ' + sudo install -m 755 -o km -g km /tmp/secret-tool /home/km/.local/bin/secret-tool + sudo rm /tmp/secret-tool +' +``` + +The shim emits `not found` to stderr on lookup miss so seed-cli's keyring.ts treats it as "no key" rather than as an error. It is on PATH before `/usr/bin/secret-tool`. -## Logs (browse from SSH) +### 5. Generate the agent identity +```bash +ssh ubuntu@oc.hyper.media ' + sudo -u km bash -lc " + /home/km/.local/bin/seed-cli key generate \ + --name knowledge-manager --show-mnemonic \ + > /home/km/.secrets/knowledge-manager.mnemonic 2>&1 + chmod 600 /home/km/.secrets/knowledge-manager.mnemonic + /home/km/.local/bin/seed-cli key list + " +' ``` -/home/km/km-logs/ -├── current -> runs/<latest> -├── runs/<UTC-ISO>__<trigger>__<ulid>/ -│ ├── meta.json # trigger, KM_AID, env hash, wall_ms -│ ├── trace.jsonl # ordered events with timestamps -│ ├── llm.jsonl # prompts, completions, DeepSeek reasoning, tokens -│ ├── tools.jsonl # MCP tool calls + latency -│ ├── seed-cli.jsonl # argv + stdout + stderr + exit + ms -│ ├── stdout.log -│ └── stderr.log -└── index.jsonl # one summary line per run + +> **Pull the mnemonic to your Mac, store in a vault (KeePass / paper) and `shred -u` the on-server copy.** Mnemonic = root signing key. Do not paste in chat or commit. It can be re-imported into the Seed Vault to set the agent's profile name + avatar. + +### 6. Capability grant + +The owner of the site root key issues a WRITER capability on `--path /` for `KM_AID`. From the owner's machine: + +```bash +seed-cli capability create \ + --delegate z6Mkh11xNzNLTrkDEjmPf19twBvAVsw3HoQtv5nPKVVbEUSJ \ + --role WRITER --path / --label knowledge-manager \ + --key <site-root-key> ``` -Helper installed at `~km/.local/bin/km-log`: +Verify on the gateway: `seed-cli account capabilities hm://<site>` should now list the new delegate. + +> **NOTE — desktop UI vs. on-chain truth:** The desktop app's "members" panel may show writer status regardless of capability blob propagation. Always verify with `seed-cli account capabilities hm://<site>`. Comments from accounts that *appear* to be writers in desktop but aren't on the gateway are correctly skipped by the wrapper. + +> **NOTE — agent profile:** The agent's profile (name, avatar) is account metadata published by the Seed Vault, not a document at the account root. To set it: import the agent's mnemonic into `https://hyper.media/vault`, click the Knowledge Manager account, hit "Edit Profile". Set name + description + icon. After ~30s of P2P sync, all sites resolve `<KM_AID>` to "Knowledge Manager". + +### 7. Bootstrap governance documents + +If the four governance docs don't exist on the site, create them. Either run as the agent (auto-creates from the templates in `agent/templates/`) or as a writer: ```bash -km-log tail # follow newest run -km-log show <runId> # full pretty-printed run -km-log grep <pattern> # rg across trace logs -km-log mention <id> # find run that processed a given mention +SITE=z6MkuBbsB1HbSNXLvJCRCrPhimY6g7tzhr4qvcYKPuSZzhno +KEY=<your-writer-key> +TPL=seed-knowledge-manager/agent/templates + +for slug in charter rules runbook allowlist; do + TITLE="Knowledge Manager — $(echo $slug | sed 's/.*/\u&/')" + seed-cli document create \ + --account "$SITE" \ + --path "/agents/knowledge-manager/$slug" \ + --file "$TPL/agent-$slug.md" \ + --name "$TITLE" \ + --key "$KEY" +done ``` -Retention: logrotate 30d / 5GB, compressed after 1d. Secrets redacted from all streams. +Also publish parent index docs at `/agents` and `/agents/knowledge-manager` for navigation. + +### 8. Build + deploy the wrapper drivers -## Environment variables (`/home/km/.nanobot/secrets.env`, mode 600) +On your Mac, in `seed-knowledge-manager/agent/mcp/seed-cli-mcp/`: +```bash +bun install +bun test src +bun run typecheck +bun run build # produces dist/{index,poll-cli,cadence-cli,telegram-bot}.js ``` -DEEPSEEK_API_KEY=... -SEED_SERVER=http://127.0.0.1:55001 -SEED_SITE=hm://... -TELEGRAM_TOKEN=... # Phase 7 -OPS_TELEGRAM_ID=... # Phase 7, numeric Telegram user ID + +Ship the four bundles to the server: + +```bash +scp seed-knowledge-manager/agent/mcp/seed-cli-mcp/dist/*.js \ + ubuntu@oc.hyper.media:/tmp/ +ssh ubuntu@oc.hyper.media ' + sudo install -m 755 -o km -g km /tmp/index.js /home/km/km-agent/mcp/seed-cli-mcp/dist/index.js + sudo install -m 755 -o km -g km /tmp/poll-cli.js /home/km/km-agent/mcp/seed-cli-mcp/dist/poll-cli.js + sudo install -m 755 -o km -g km /tmp/cadence-cli.js /home/km/km-agent/mcp/seed-cli-mcp/dist/cadence-cli.js + sudo install -m 755 -o km -g km /tmp/telegram-bot.js /home/km/km-agent/mcp/seed-cli-mcp/dist/telegram-bot.js + sudo rm /tmp/{index,poll-cli,cadence-cli,telegram-bot}.js +' +``` + +The skill methodology (`SKILL.md`, `references/`, `templates/`) is rsynced into `/home/km/.nanobot/workspace/skill/` for the optional nanobot gateway. The polling/cadence drivers don't read it directly — the LAFH framing lives in their hardcoded system prompts. + +### 9. Secrets + systemd units + +`/home/km/.nanobot/secrets.env` (mode 600): + ``` +DEEPSEEK_API_KEY=sk-... # required for replies + cadenced docs +SEED_SERVER=https://hyper.media # currently the gateway, not local daemon +SEED_SITE=hm://z6MkuBbsB1HbSNXLvJCRCrPhimY6g7tzhr4qvcYKPuSZzhno +KM_KEY_NAME=knowledge-manager +TELEGRAM_TOKEN=... # only needed for km-telegram.service +OPS_TELEGRAM_ID=12345,67890 # comma-sep allowlist of operator chat IDs +``` + +Systemd user units (all under `~/.config/systemd/user/`): -## Phase 1 — done +``` +seed-daemon.service # the docker compose stack (Phase 1) +nanobot-gateway.service # optional MCP gateway (Phases 4–6 development) +km-poll.timer + .service # mention polling, every 15s +km-boletin.timer + .service # weekly bulletin, Mon 09:00 UTC +km-gap.timer + .service # gap report, Wed 10:00 UTC +km-health.timer + .service # network health, 1st 09:00 UTC +km-telegram.service # operator Telegram bot (long-running) +``` -Server `oc.hyper.media` (Ubuntu 24.04, 6.8 kernel, 3.7 GiB RAM, Docker 29): +Enable + start everything: -- Apt deps installed: `python3.12 python3.12-venv pipx libsecret-1-0 libsecret-tools dbus-user-session bubblewrap jq curl rsync logrotate`. `nodejs` swapped from Ubuntu's `node-18` to NodeSource Node 22 (Ubuntu's npm 9 cannot install workspace deps). -- System user `km` created with `loginctl enable-linger km`, added to `docker` group. -- Docker compose stack at `/home/km/seed-daemon/compose.yaml` runs `seedhypermedia/site:latest` as a pure peer (`-data-dir=/data -keystore-dir=/data/keys -http.port=55001 -grpc.port=55002 -p2p.port=55000 -syncing.smart=true`). HTTP/gRPC bound to loopback, P2P public. -- User-systemd unit `seed-daemon.service` enabled, runs `docker compose up -d`. Survives logout (linger) and host reboot (Docker `restart: unless-stopped`). -- Verification: `curl http://127.0.0.1:55001/debug/version` returns build info. +```bash +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) bash -lc ' + systemctl --user daemon-reload + systemctl --user enable --now seed-daemon.service + systemctl --user enable --now km-poll.timer km-boletin.timer km-gap.timer km-health.timer + systemctl --user enable --now km-telegram.service + systemctl --user list-timers +' +``` -**Known issue (defers to Phase 2)**: published `@seed-hypermedia/cli@0.1.4` on npm has an unresolved `workspace:*` dep; `npx -y @seed-hypermedia/cli` fails. Phase 2 builds the CLI from this repo's `frontend/apps/cli/` instead. +> **NOTE — port 18790 collision:** The default nanobot gateway port `18790` is already taken by another service on `oc.hyper.media`. We pin it to `18791` via `--port 18791` in `nanobot-gateway.service`. Adjust if your host has different conflicts. -## TODO (filled in by later phases) +### 10. Telegram bot -- [ ] Phase 1: Docker / OS dep install steps + verification. -- [ ] Phase 2: key generation + capability grant exact commands. -- [ ] Phase 3: MCP wrapper build + test commands. -- [ ] Phase 4: nanobot install + bootstrap step-by-step. -- [ ] Phase 5: poll cadence + smoke test transcript. -- [ ] Phase 6: cadence schedules + manual triggers. -- [ ] Phase 7: Telegram bot setup + ops verbs. -- [ ] Phase 8: full verification checklist results. +Get a bot token from `@BotFather` and your numeric chat ID from `@userinfobot`. Drop both into `secrets.env` (`TELEGRAM_TOKEN`, `OPS_TELEGRAM_ID`). Restart `km-telegram.service`. From your phone, message the bot `/help`. Available commands: `/status`, `/last-runs`, `/show-rules`, `/poll-now`. Mutations to governance docs are intentionally NOT exposed — edit them from desktop instead. + +> **NOTE — original plan**: Phase 7 originally targeted nanobot's built-in Telegram channel. Since we ditched nanobot from the polling path, we replaced it with a 130-line Bun bot that long-polls the Telegram REST API directly. Same security guarantee (allowFrom enforced), much simpler. + +## Verification matrix (Phase 8) + +End-to-end checks. ✅ = verified live on production. + +| # | Check | Status | +| --- | --- | --- | +| 1 | Local Seed daemon healthy | ✅ `curl http://127.0.0.1:55001/debug/version` returns build info | +| 2 | Daemon survives reboot | ✅ Container `Up 47s` after host reboot, HTTP OK on first probe | +| 3 | seed-cli round-trip via local web server | ✅ `seed-cli -s http://127.0.0.1:3000 account list` returns `{"accounts":[]}` | +| 4 | secret-tool shim works | ✅ `seed-cli key list` returns the agent key | +| 5 | Agent identity on gateway | ✅ `account get z6Mkh11x...KVVbEUSJ` returns name "Knowledge Manager" + avatar | +| 6 | WRITER capability for KM_AID | ✅ `account capabilities hm://<site>` lists `z6Mkh11x...` (created 2026-05-05T21:49:33Z) | +| 7 | All four governance docs exist | ✅ /agents/knowledge-manager/{charter,rules,runbook,allowlist} resolve via gateway | +| 8 | MCP wrapper unit tests | ✅ `bun test src` — 44 tests / 91 expects, all green | +| 9 | Polling loop end-to-end with citation | ✅ Comment by `z6Mkvz9...` mentioning KM in lobby thread → placeholder within ~5s → finalised within ~15s with site context cited | +| 10 | Site-root mention also triggers reply | ✅ Comment mentioning the site (not KM directly) is picked up because agent holds WRITER cap | +| 11 | Non-writer mention skipped | ✅ Comment by `z6MkvqBa...` (no cap) → `mention_skipped_not_allowed` recorded | +| 12 | Typing-indicator (placeholder → edit) | ✅ Same comment id morphs from "Working on this — back in a moment. ⌛" to the final answer | +| 13 | Site search injection | ✅ `site_context_collected` event records `urls` array; reply text includes hm:// links to relevant docs | +| 14 | Weekly bulletin doc published | ✅ `/agents/knowledge-manager/state/boletin/2026-W19` | +| 15 | Gap report doc published | ✅ `/agents/knowledge-manager/state/gaps/2026-05-06` | +| 16 | Network health doc published | ✅ `/agents/knowledge-manager/state/network-health/2026-05` | +| 17 | `draft_only: true` blocks doc writes | ✅ Cadence runs return `denied` with `write_blocked_by_rules` event when toggled | +| 18 | Hardcoded denylist refuses self-edits | ✅ Path `/agents/knowledge-manager/rules` → `hardcoded-deny` (verified in `limits.test.ts`) | +| 19 | Audit log per run | ✅ Each invocation produces `meta.json` + `trace.jsonl` + `llm.jsonl` + `seed-cli.jsonl` | +| 20 | km-log helper works | ✅ `km-log latest 5`, `km-log show <id>`, `km-log mention <id>` all functional | +| 21 | Secrets redaction | ✅ Grepping `km-logs/` for `DEEPSEEK_API_KEY` value returns 0 hits | +| 22 | Telegram allowFrom enforced | ✅ Non-allowlisted chat IDs are silently ignored | + +## Known issues + workarounds + +- **Local daemon doesn't have current capability blob.** Wrapper uses `SEED_SERVER=https://hyper.media` (gateway) instead of `127.0.0.1:3000` (local) until a force-sync mechanism lands. +- **`seed-cli comment create --reply <id>` returns `✗ Non-base58btc character` for some parents.** Reproduces specifically when the parent comment's chain includes an edited comment. `poll-cli.ts` retries without `--reply` (top-level reply on the doc) and logs `placeholder_reply_fallback`. Filed in our internal seed-cli backlog. +- **`seed-cli document create --path /` returns `HTTP 500 from PublishBlobs`.** The CLI treats `--path ""` as falsy (slugifies the title). The Seed Vault publishes the agent's home-doc/profile metadata via a different RPC; the CLI can't currently publish at the account root. +- **`seed-cli` writes success messages to stderr.** `comment create` prints `✓ Comment published: <CID>` to stderr (not stdout), and the CID is the version, not the record id. `postPlaceholder` parses stderr, then resolves CID → record id via `comment get`. +- **Activity feed `--resource` is exact-match.** Filtering by the site root returns only events directly on the root doc, not on subdocuments (`/discussions/*`, `/agents/*`). The wrapper now pulls the unfiltered feed and post-filters by `comment.targetAccount`. +- **DeepSeek + nanobot don't compose for polling.** nanobot saves large tool-results to `~/.nanobot/workspace/.nanobot/tool-results/*.txt` and presents that to the LLM; DeepSeek then loops on `read_file`/`grep` instead of replying. The polling driver bypasses nanobot for this reason. +- **Cursor model.** The activity feed paginates reverse-chronologically; cursor token = "next older page", not "since last poll". State stores the newest event id we've classified and stops walking when the loop hits it. Field name `lastEventId` (was `token` in earlier versions). + +## Layout reference + +``` +agent/ +├── README.md ← this file +├── config/ ← optional nanobot config (used by phases 4–5 dev) +│ └── config.json +├── seed-daemon/ ← docker compose for the local stack +│ └── compose.yaml +├── mcp/seed-cli-mcp/ ← Bun-built drivers + MCP wrapper +│ ├── package.json +│ ├── tsconfig.json +│ ├── src/ +│ │ ├── audit.ts +│ │ ├── cadence-cli.ts ← weekly bulletin / gap / health driver +│ │ ├── config.ts +│ │ ├── governance.ts +│ │ ├── index.ts ← stdio MCP server entry point (optional) +│ │ ├── limits.ts +│ │ ├── mentions.ts +│ │ ├── poll-cli.ts ← polling + typing-indicator + grounded reply +│ │ ├── redact.ts +│ │ ├── seedcli.ts +│ │ ├── state.ts +│ │ ├── telegram-bot.ts ← operator chat surface +│ │ ├── tools.ts ← MCP tool registry (used by stdio server) +│ │ └── *.test.ts ← bun:test unit tests +│ └── dist/ ← bun build output (deployed to server) +├── systemd/ ← user-mode unit files +│ ├── seed-daemon.service +│ ├── nanobot-gateway.service ← optional, port 18791 +│ ├── km-poll.{service,timer} +│ ├── km-boletin.{service,timer} +│ ├── km-gap.{service,timer} +│ ├── km-health.{service,timer} +│ └── km-telegram.service +├── scripts/ +│ ├── install-phase1.sh ← idempotent server provisioning +│ ├── km-log ← log browsing helper for /home/km/.local/bin +│ └── secret-tool-shim ← file-backed libsecret replacement +├── templates/ ← bootstrap content for the four governance docs +│ ├── agent-charter.md +│ ├── agent-rules.md +│ ├── agent-runbook.md +│ └── agent-allowlist.md +└── logrotate/ + └── km-logs.conf +``` From f576d8e715dff144c5810f4003cb9852e1df3e2d Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Wed, 6 May 2026 17:07:15 +0200 Subject: [PATCH 10/17] fix(agent): drop cursor-based activity walker, enrich reply context with thread + linked docs Replace the cursor-based activity polling with a time-window scan using `processed.jsonl`/`placeholders.jsonl` for idempotency, fixing an eventually-consistency bug where new comments were missed. Extend `gatherCommentReplyContext` to include parent document body, full comment thread (walking replyParent chain), 1-hop linked documents/profiles, and site search results. Reduce systemd timer cadence from 15s to 30s. --- .../agent/mcp/seed-cli-mcp/src/poll-cli.ts | 27 ++- .../mcp/seed-cli-mcp/src/reply-engine.ts | 159 ++++++++++++++++++ .../agent/systemd/km-poll.timer | 2 +- 3 files changed, 178 insertions(+), 10 deletions(-) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts index db8a117e08..da4ed9c2b6 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts @@ -35,9 +35,10 @@ import { } from './mentions.js' import type {Mention, SeedComment} from './mentions.js' import {bump, checkCap} from './limits.js' -import {draftReply, gatherSiteContext} from './reply-engine.js' +import {draftReply, gatherCommentReplyContext} from './reply-engine.js' -const MAX_COMMENT_FETCHES = 25 +const ACTIVITY_LIMIT = 100 +const MAX_COMMENT_FETCHES = 60 const PLACEHOLDER_BODY = 'Working on this — back in a moment. ⌛' const FALLBACK_BODY = 'I tried to draft a reply but hit a snag. Please rephrase or wait for the next cadence.' @@ -88,11 +89,17 @@ async function main(): Promise<void> { audit.trace({ts: nowIso(), level: 'info', event: 'poll_collect_writers', data: {count: writers.size}}) // ── PASS A: discover new mentions and post placeholders. ─────────────── - const lastSeenId = state.getCursor() - const actR = await cli.runRead(['activity', '--limit', '50']) + // + // Hyper.media's activity feed is eventually-consistent: new comment + // events frequently take minutes to surface, by which point a + // cursor-based walker has already advanced past the slot they would + // have occupied. We dropped the cursor and rely instead on + // `processed.jsonl` + `placeholders.jsonl` for idempotency. Each + // poll scans the last ACTIVITY_LIMIT events and fetches comment + // bodies up to MAX_COMMENT_FETCHES. + const actR = await cli.runRead(['activity', '--limit', String(ACTIVITY_LIMIT)]) const events = ((actR.parsedJson as {events?: Array<{id?: string; type?: string; time?: string; author?: unknown}>}) ?.events) ?? [] - let newestEventId: string | undefined let scanned = 0 let placeholdersPosted = 0 let skippedNotAllowed = 0 @@ -101,8 +108,6 @@ async function main(): Promise<void> { const siteAccount = config.seedSite.replace(/^hm:\/\//, '').split('/')[0]! for (const ev of events) { - if (!newestEventId && ev.id) newestEventId = ev.id - if (lastSeenId && ev.id === lastSeenId) break if (scanned >= MAX_COMMENT_FETCHES) { exhaustedBudget = true break @@ -173,7 +178,6 @@ async function main(): Promise<void> { }) placeholdersPosted++ } - if (newestEventId) state.setCursor(newestEventId) // ── PASS B: finalise placeholders (DeepSeek + comment edit). ─────────── const pending = state.pendingPlaceholders() @@ -184,7 +188,12 @@ async function main(): Promise<void> { // counted each placeholder as a comment in Pass A, and `edit` does // not produce a new top-level comment. const question = rec.mention.text.replace(//g, ' ').trim() - const context = await gatherSiteContext(cli, question, siteAccount, audit) + const context = await gatherCommentReplyContext({ + cli, + mention: rec.mention, + siteAccount, + audit, + }) const reply = await draftReply(question, context, audit) const body = reply ?? FALLBACK_BODY const r = await cli.runWrite(['comment', 'edit', rec.placeholderId, '--body', body]) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts index 91e2b57878..573ec36522 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts @@ -7,6 +7,7 @@ import type {SeedCli} from './seedcli.js' import type {AuditRun} from './audit.js' +import type {Mention, SeedComment} from './mentions.js' const TOP_K = 5 const PER_DOC_CHARS = 600 @@ -26,6 +27,164 @@ const SYSTEM_INSPECTOR_PROMPT = export type ChatTurn = {role: 'user' | 'assistant'; content: string} +/** + * Builds the full context block used when answering a comment mention. + * Order (most-relevant first): + * 1. Parent document the comment was posted on (full body). + * 2. Comment thread (replyParent chain → root, plus the asker's comment). + * 3. Linked documents/profiles cited in the parent doc or any thread + * comment (1-hop). Both `seed-cli document get` and `account get` + * are tried; non-resolvable links are dropped. + * 4. Site-search hits relevant to the question text — keyword match + * only (Seed search is currently keyword-based, not semantic, so + * we send the raw question and let the LLM use whatever lands). + * + * No per-doc truncation — operator chose "no cap for now". DeepSeek's + * 128K context window absorbs realistic site sizes. + */ +export async function gatherCommentReplyContext(opts: { + cli: SeedCli + mention: Mention + siteAccount: string + audit?: AuditRun +}): Promise<string> { + const {cli, mention, siteAccount, audit} = opts + const sections: string[] = [] + const seenLinks = new Set<string>() + + // 1. Parent document. + const parentBody = await fetchDocOrProfile(cli, mention.docId) + if (parentBody) { + sections.push(`### Parent document — ${mention.docId}\n${parentBody}`) + collectHmLinks(parentBody, seenLinks) + } + + // 2. Comment thread (walk replyParent chain UP, capped at 30 hops). + const threadComments = await walkThread(cli, mention.commentId) + if (threadComments.length > 0) { + const renderedThread = threadComments + .map((c, i) => `(#${i + 1}) ${c.author}\n${commentText(c)}`) + .join('\n\n') + sections.push(`### Comment thread (oldest → newest)\n${renderedThread}`) + for (const c of threadComments) { + collectHmLinksFromComment(c, seenLinks) + } + } + + // 3. Linked documents cited in the parent doc and thread (1-hop). + // Avoid re-fetching the parent doc itself + the agent's own profile + // (would just be a self-reference). + const linksToFetch = Array.from(seenLinks).filter((href) => { + const stripped = stripVersionAndBlock(href) + return stripped !== stripVersionAndBlock(mention.docId) + }) + const linkedSections: string[] = [] + for (const href of linksToFetch) { + const body = await fetchDocOrProfile(cli, href) + if (body) linkedSections.push(`### Linked — ${href}\n${body}`) + } + if (linkedSections.length > 0) { + sections.push(linkedSections.join('\n\n')) + } + + // 4. Site search (keyword) for the asker's question text. + const search = await gatherSiteContext(cli, plainText(mention.text), siteAccount, audit) + if (search) sections.push(search) + + audit?.trace({ + ts: nowIso(), + level: 'info', + event: 'reply_context_built', + data: { + parentDocBytes: parentBody.length, + threadComments: threadComments.length, + linkedDocs: linkedSections.length, + hasSearch: Boolean(search), + }, + }) + + return sections.join('\n\n') +} + +/** + * Tries `document get` first, then `account get` (for hm://<accountId> + * profile links). Returns the markdown body or empty string. + */ +async function fetchDocOrProfile(cli: SeedCli, hmUrl: string): Promise<string> { + const stripped = stripVersionAndBlock(hmUrl) + const dr = await cli.runRead(['document', 'get', stripped]).catch(() => ({exitCode: -1, stdout: '', stderr: '', parsedJson: undefined as unknown})) + if (dr.exitCode === 0 && dr.stdout) { + return dr.stdout.replace(/<!--\s*id:[^>]+-->/g, '').trim() + } + // Account profile fallback. + const accountUid = extractAccountUid(stripped) + if (accountUid) { + const ar = await cli.runRead(['account', 'get', accountUid]).catch(() => ({exitCode: -1, stdout: '', stderr: '', parsedJson: undefined as unknown})) + if (ar.exitCode === 0 && ar.parsedJson) { + const meta = (ar.parsedJson as {metadata?: {name?: string; summary?: string; icon?: string}}).metadata ?? {} + if (meta.name || meta.summary) { + const lines = [`(profile metadata)`] + if (meta.name) lines.push(`name: ${meta.name}`) + if (meta.summary) lines.push(`summary: ${meta.summary}`) + return lines.join('\n') + } + } + } + return '' +} + +async function walkThread(cli: SeedCli, startCommentId: string | undefined): Promise<SeedComment[]> { + if (!startCommentId) return [] + const out: SeedComment[] = [] + let cur: string | undefined = startCommentId + for (let i = 0; i < 30 && cur; i++) { + const r = await cli.runRead(['comment', 'get', cur]).catch(() => ({exitCode: -1, parsedJson: undefined as unknown})) + if (r.exitCode !== 0 || !r.parsedJson) break + const c = r.parsedJson as SeedComment & {replyParent?: string} + out.unshift(c) + cur = (c.replyParent && c.replyParent.trim()) || undefined + } + return out +} + +function commentText(c: SeedComment): string { + const lines: string[] = [] + for (const item of c.content ?? []) { + if (item.block?.text) lines.push(item.block.text.replace(//g, '@…')) + } + return lines.join('\n') +} + +function collectHmLinks(text: string, into: Set<string>): void { + // Match hm:// URLs in markdown body (any prefix, may include path, + // version, block fragment). + const re = /hm:\/\/[A-Za-z0-9._~/?#&=:%-]+/g + for (const m of text.matchAll(re)) into.add(m[0]) +} + +function collectHmLinksFromComment(c: SeedComment, into: Set<string>): void { + for (const item of c.content ?? []) { + if (!item.block) continue + if (item.block.text) collectHmLinks(item.block.text, into) + for (const ann of item.block.annotations ?? []) { + if (typeof ann.link === 'string' && ann.link.startsWith('hm://')) into.add(ann.link) + } + } +} + +function plainText(s: string): string { + return s.replace(//g, ' ').trim() +} + +function stripVersionAndBlock(hmUrl: string): string { + return hmUrl.split('?')[0]!.split('#')[0]! +} + +function extractAccountUid(hmUrl: string): string | undefined { + const m = hmUrl.match(/^hm:\/\/([^/?#]+)/) + return m?.[1] +} + type SearchHit = {hmUrl: string; title?: string} export async function gatherSiteContext( diff --git a/seed-knowledge-manager/agent/systemd/km-poll.timer b/seed-knowledge-manager/agent/systemd/km-poll.timer index 1aa5d691b3..db230a3e98 100644 --- a/seed-knowledge-manager/agent/systemd/km-poll.timer +++ b/seed-knowledge-manager/agent/systemd/km-poll.timer @@ -3,7 +3,7 @@ Description=Knowledge Manager — poll mentions every 60s [Timer] OnBootSec=90 -OnUnitActiveSec=15 +OnUnitActiveSec=30 AccuracySec=5s Persistent=true Unit=km-poll.service From 0435392d6be2627ad0384f310896a0b774b3664d Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Thu, 7 May 2026 12:43:02 +0200 Subject: [PATCH 11/17] feat: add site subscription commands, agent subscription-hot-tier, and SEED-KM agent machinery --- backend/config/config.go | 5 + backend/hmnet/syncing/scheduler.go | 11 + backend/storage/sqlite.go | 6 + frontend/apps/cli/src/commands/site.ts | 165 ++++++++++ frontend/apps/cli/src/index.ts | 2 + frontend/packages/client/src/hm-types.ts | 76 +++++ .../packages/shared/src/api-force-sync.ts | 40 +++ .../packages/shared/src/api-subscriptions.ts | 49 +++ frontend/packages/shared/src/api.ts | 6 + seed-knowledge-manager/agent/README.md | 153 +++++++++- .../agent/mcp/seed-cli-mcp/bun.lock | 3 + .../agent/mcp/seed-cli-mcp/package.json | 3 +- .../seed-cli-mcp/src/agent/mastra-agent.ts | 286 ++++++++++++++++++ .../mcp/seed-cli-mcp/src/agent/prompts.ts | 39 +++ .../seed-cli-mcp/src/agent/tools-bridge.ts | 131 ++++++++ .../agent/mcp/seed-cli-mcp/src/config.ts | 16 + .../src/machines/mention-machine.ts | 222 ++++++++++++++ .../seed-cli-mcp/src/machines/poll-driver.ts | 146 +++++++++ .../seed-cli-mcp/src/machines/supervisor.ts | 162 ++++++++++ .../agent/mcp/seed-cli-mcp/src/poll-cli.ts | 37 ++- .../mcp/seed-cli-mcp/src/telegram-bot.ts | 41 ++- .../agent/mcp/seed-cli-mcp/src/tools.ts | 36 +++ .../agent/scripts/bootstrap-subscription.sh | 57 ++++ .../agent/systemd/km-reconcile.service | 17 ++ .../agent/systemd/km-reconcile.timer | 12 + 25 files changed, 1710 insertions(+), 11 deletions(-) create mode 100644 frontend/apps/cli/src/commands/site.ts create mode 100644 frontend/packages/shared/src/api-force-sync.ts create mode 100644 frontend/packages/shared/src/api-subscriptions.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/mastra-agent.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/tools-bridge.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/mention-machine.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts create mode 100755 seed-knowledge-manager/agent/scripts/bootstrap-subscription.sh create mode 100644 seed-knowledge-manager/agent/systemd/km-reconcile.service create mode 100644 seed-knowledge-manager/agent/systemd/km-reconcile.timer diff --git a/backend/config/config.go b/backend/config/config.go index e6b3a1fe68..4e3c02c9b5 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -306,6 +306,10 @@ type Syncing struct { NoPull bool NoDiscovery bool AllowPush bool + // SubscriptionHotTier promotes subscription tasks into the scheduler's hot + // tier so capability/comment/ref blobs converge in ~hotTTL instead of one + // Interval. Costs more bandwidth; intended for headless agent daemons. + SubscriptionHotTier bool } func (c Syncing) Default() Syncing { @@ -328,6 +332,7 @@ func (c *Syncing) BindFlags(fs *flag.FlagSet) { fs.BoolVar(&c.AllowPush, "syncing.allow-push", c.AllowPush, "Allows direct content push. Anyone could force push content") fs.BoolVar(&c.NoPull, "syncing.no-pull", c.NoPull, "Disables periodic content pulling.") fs.BoolVar(&c.NoDiscovery, "syncing.no-discovery", c.NoDiscovery, "Disables the ability to discover content from other peers") + fs.BoolVar(&c.SubscriptionHotTier, "syncing.subscription-hot-tier", c.SubscriptionHotTier, "Keep subscription tasks in the hot scheduler tier so capability blobs converge in ~hotTTL") // Deprecated flags. Still defined here to avoid errors if these flags are passed. fs.Bool("syncing.smart", true, "Deprecated (doesn't do anything): Enables subscription-based syncing and deactivates dumb syncing") diff --git a/backend/hmnet/syncing/scheduler.go b/backend/hmnet/syncing/scheduler.go index a57ef68981..035ba925c6 100644 --- a/backend/hmnet/syncing/scheduler.go +++ b/backend/hmnet/syncing/scheduler.go @@ -583,8 +583,19 @@ func (s *scheduler) scheduleNext(task *taskHandle, now time.Time, forceImmediate switch { case forceImmediate || task.runCount == 0: task.nextRunTime = now + if task.subscription && s.cfg.SubscriptionHotTier { + task.hotDeadline = now.Add(s.hotTTL) + } case task.subscription: task.nextRunTime = now.Add(s.cfg.Interval) + // When SubscriptionHotTier is enabled, keep the heartbeat alive past + // the next due time so dispatchReadyTasks' lazy migration promotes + // this task into the hot tier when it becomes due. Capability/comment + // blobs for subscribed sites then propagate ahead of cold ephemeral + // tasks instead of competing with them. + if s.cfg.SubscriptionHotTier { + task.hotDeadline = task.nextRunTime.Add(s.hotTTL) + } case task.IsHot(now): task.nextRunTime = now.Add(defaultHotCooldown) default: diff --git a/backend/storage/sqlite.go b/backend/storage/sqlite.go index 61651fb5da..4ef1062014 100644 --- a/backend/storage/sqlite.go +++ b/backend/storage/sqlite.go @@ -57,6 +57,12 @@ func OpenSQLite(uri string, flags sqlite.OpenFlags, poolSize int) (*sqlitex.Pool "PRAGMA synchronous = NORMAL;", "PRAGMA journal_mode = WAL;", "PRAGMA cache_size = -262144;", + // Wait up to 5s when another writer holds the lock instead of + // returning SQLITE_BUSY immediately. Without this, headless + // agent daemons running on small VMs frequently lose reconcile + // transactions to peer-store/connect writes that grab the lock + // for a few ms at a time. + "PRAGMA busy_timeout = 5000;", "PRAGMA temp_store = MEMORY;", // Push the foreground auto-checkpoint threshold well above what the // background goroutine should ever let the WAL reach. See doc comment diff --git a/frontend/apps/cli/src/commands/site.ts b/frontend/apps/cli/src/commands/site.ts new file mode 100644 index 0000000000..50cea3e13e --- /dev/null +++ b/frontend/apps/cli/src/commands/site.ts @@ -0,0 +1,165 @@ +/** + * Site commands — subscribe, unsubscribe, list-subscriptions, sync-status, + * reconcile (force-sync). Used to make a local daemon mirror a remote site. + */ + +import type {Command} from 'commander' +import {getClient, getOutputFormat, isPretty} from '../index' +import {formatOutput, printError, printSuccess} from '../output' +import {resolveIdWithClient} from '../utils/resolve-id' + +export function registerSiteCommands(program: Command) { + const site = program + .command('site') + .description('Manage site subscriptions on the local daemon (subscribe, sync-status, reconcile)') + + // ── subscribe ──────────────────────────────────────────────────────────── + + site + .command('subscribe <id>') + .description('Subscribe the local daemon to a site or document, mirroring its content') + .option('--recursive', 'Also subscribe to all documents in the directory', false) + .option('--wait', 'Wait for first sync to complete before returning (async=false)', false) + .action(async (id: string, options, cmd) => { + const globalOpts = cmd.optsWithGlobals() + const format = getOutputFormat(globalOpts) + const pretty = isPretty(globalOpts) + + try { + const {id: unpacked, client} = await resolveIdWithClient(id, globalOpts) + const path = (unpacked.path || []).filter(Boolean).join('/') + const result = await client.request('Subscribe', { + account: unpacked.uid, + path: path ? `/${path}` : '', + recursive: !!options.recursive, + async: !options.wait, + }) + if (globalOpts.quiet) { + printSuccess('subscribed') + } else { + console.log(formatOutput({status: 'subscribed', account: unpacked.uid, path: path ? `/${path}` : '', recursive: !!options.recursive, result}, format, pretty)) + } + } catch (error) { + printError((error as Error).message) + process.exit(1) + } + }) + + // ── unsubscribe ────────────────────────────────────────────────────────── + + site + .command('unsubscribe <id>') + .description('Unsubscribe the local daemon from a site or document') + .action(async (id: string, _options, cmd) => { + const globalOpts = cmd.optsWithGlobals() + + try { + const {id: unpacked, client} = await resolveIdWithClient(id, globalOpts) + const path = (unpacked.path || []).filter(Boolean).join('/') + await client.request('Unsubscribe', { + account: unpacked.uid, + path: path ? `/${path}` : '', + }) + if (!globalOpts.quiet) printSuccess('unsubscribed') + } catch (error) { + printError((error as Error).message) + process.exit(1) + } + }) + + // ── list-subscriptions ─────────────────────────────────────────────────── + + site + .command('list-subscriptions') + .description('List all active subscriptions on the local daemon') + .action(async (_options, cmd) => { + const globalOpts = cmd.optsWithGlobals() + const client = getClient(globalOpts) + const format = getOutputFormat(globalOpts) + const pretty = isPretty(globalOpts) + + try { + const result = await client.request('ListSubscriptions', {}) + if (globalOpts.quiet) { + for (const s of result.subscriptions) { + console.log(`hm://${s.account}${s.path}\t${s.recursive ? 'recursive' : 'single'}`) + } + } else { + console.log(formatOutput(result, format, pretty)) + } + } catch (error) { + printError((error as Error).message) + process.exit(1) + } + }) + + // ── sync-status ────────────────────────────────────────────────────────── + + site + .command('sync-status <id>') + .description('Report subscription state and writer-capability availability for a site') + .option('--writer <accountId>', 'Required writer account; ready_for_writes=true only when this account holds a WRITER capability locally') + .action(async (id: string, options, cmd) => { + const globalOpts = cmd.optsWithGlobals() + const format = getOutputFormat(globalOpts) + const pretty = isPretty(globalOpts) + + try { + const {id: unpacked, client} = await resolveIdWithClient(id, globalOpts) + const path = (unpacked.path || []).filter(Boolean).join('/') + const subPath = path ? `/${path}` : '' + + const subs = await client.request('ListSubscriptions', {}) + const matching = subs.subscriptions.find( + (s) => s.account === unpacked.uid && s.path === subPath, + ) + + const caps = await client.request('ListCapabilities', {targetId: unpacked}) + const writerCaps = caps.capabilities.filter((c) => { + const role = c.role || '' + return role.toUpperCase().includes('WRITER') + }) + + const readyForWrites = + !!matching && + (options.writer + ? writerCaps.some((c) => c.delegate === options.writer || c.account === options.writer) + : writerCaps.length > 0) + + const status = { + subscribed: !!matching, + recursive: matching?.recursive ?? false, + since: matching?.since, + writerCapCount: writerCaps.length, + ready_for_writes: readyForWrites, + } + + if (globalOpts.quiet) { + console.log(readyForWrites ? 'ready' : 'not-ready') + } else { + console.log(formatOutput(status, format, pretty)) + } + } catch (error) { + printError((error as Error).message) + process.exit(1) + } + }) + + // ── reconcile (force-sync) ─────────────────────────────────────────────── + + site + .command('reconcile') + .description('Force the daemon to run periodic background sync immediately (pulls capability/comment/ref blobs)') + .action(async (_options, cmd) => { + const globalOpts = cmd.optsWithGlobals() + + try { + const client = getClient(globalOpts) + await client.request('ForceSync', {}) + if (!globalOpts.quiet) printSuccess('sync triggered') + } catch (error) { + printError((error as Error).message) + process.exit(1) + } + }) +} diff --git a/frontend/apps/cli/src/index.ts b/frontend/apps/cli/src/index.ts index 98edd9fece..b7837af8a7 100644 --- a/frontend/apps/cli/src/index.ts +++ b/frontend/apps/cli/src/index.ts @@ -19,6 +19,7 @@ import {registerSearchCommand} from './commands/search' import {registerQueryCommands} from './commands/query' import {registerKeyCommands} from './commands/key' import {registerDraftCommands} from './commands/draft' +import {registerSiteCommands} from './commands/site' import {getCliVersion} from './version' const program = new Command() @@ -104,6 +105,7 @@ registerContactCommands(program) registerAccountCommands(program) registerKeyCommands(program) registerDraftCommands(program) +registerSiteCommands(program) // Register top-level commands registerSearchCommand(program) diff --git a/frontend/packages/client/src/hm-types.ts b/frontend/packages/client/src/hm-types.ts index 83cd7ed694..a329eed844 100644 --- a/frontend/packages/client/src/hm-types.ts +++ b/frontend/packages/client/src/hm-types.ts @@ -1704,6 +1704,74 @@ export const HMPrepareDocumentChangeOutputSchema = z.object({ }) export type HMPrepareDocumentChangeOutput = z.infer<typeof HMPrepareDocumentChangeOutputSchema> +// Subscribe / Unsubscribe / ListSubscriptions / ForceSync schemas + +export const HMSubscribeInputSchema = z.object({ + account: z.string(), + path: z.string().default(''), + recursive: z.boolean().optional(), + async: z.boolean().optional(), +}) +export type HMSubscribeInput = z.infer<typeof HMSubscribeInputSchema> +export const HMSubscribeOutputSchema = z.object({}) +export type HMSubscribeOutput = z.infer<typeof HMSubscribeOutputSchema> +export const HMSubscribeRequestSchema = z.object({ + key: z.literal('Subscribe'), + input: HMSubscribeInputSchema, + output: HMSubscribeOutputSchema, +}) +export type HMSubscribeRequest = z.infer<typeof HMSubscribeRequestSchema> + +export const HMUnsubscribeInputSchema = z.object({ + account: z.string(), + path: z.string().default(''), +}) +export type HMUnsubscribeInput = z.infer<typeof HMUnsubscribeInputSchema> +export const HMUnsubscribeOutputSchema = z.object({}) +export type HMUnsubscribeOutput = z.infer<typeof HMUnsubscribeOutputSchema> +export const HMUnsubscribeRequestSchema = z.object({ + key: z.literal('Unsubscribe'), + input: HMUnsubscribeInputSchema, + output: HMUnsubscribeOutputSchema, +}) +export type HMUnsubscribeRequest = z.infer<typeof HMUnsubscribeRequestSchema> + +export const HMSubscriptionSchema = z.object({ + account: z.string(), + path: z.string(), + recursive: z.boolean(), + since: z.string().optional(), +}) +export type HMSubscription = z.infer<typeof HMSubscriptionSchema> + +export const HMListSubscriptionsInputSchema = z.object({ + pageSize: z.number().int().optional(), + pageToken: z.string().optional(), +}) +export type HMListSubscriptionsInput = z.infer<typeof HMListSubscriptionsInputSchema> +export const HMListSubscriptionsOutputSchema = z.object({ + subscriptions: z.array(HMSubscriptionSchema), + nextPageToken: z.string().optional(), +}) +export type HMListSubscriptionsOutput = z.infer<typeof HMListSubscriptionsOutputSchema> +export const HMListSubscriptionsRequestSchema = z.object({ + key: z.literal('ListSubscriptions'), + input: HMListSubscriptionsInputSchema, + output: HMListSubscriptionsOutputSchema, +}) +export type HMListSubscriptionsRequest = z.infer<typeof HMListSubscriptionsRequestSchema> + +export const HMForceSyncInputSchema = z.object({}) +export type HMForceSyncInput = z.infer<typeof HMForceSyncInputSchema> +export const HMForceSyncOutputSchema = z.object({}) +export type HMForceSyncOutput = z.infer<typeof HMForceSyncOutputSchema> +export const HMForceSyncRequestSchema = z.object({ + key: z.literal('ForceSync'), + input: HMForceSyncInputSchema, + output: HMForceSyncOutputSchema, +}) +export type HMForceSyncRequest = z.infer<typeof HMForceSyncRequestSchema> + export const HMPrepareDocumentChangeRequestSchema = z.object({ key: z.literal('PrepareDocumentChange'), input: HMPrepareDocumentChangeInputSchema, @@ -1798,6 +1866,7 @@ export const HMGetRequestSchema = z.discriminatedUnion('key', [ HMListCommentVersionsRequestSchema, HMGetDomainRequestSchema, HMListDomainsRequestSchema, + HMListSubscriptionsRequestSchema, ]) export type HMGetRequest = z.infer<typeof HMGetRequestSchema> @@ -1805,6 +1874,9 @@ export type HMGetRequest = z.infer<typeof HMGetRequestSchema> export const HMActionSchema = z.discriminatedUnion('key', [ HMPublishBlobsRequestSchema, HMPrepareDocumentChangeRequestSchema, + HMSubscribeRequestSchema, + HMUnsubscribeRequestSchema, + HMForceSyncRequestSchema, ]) export type HMAction = z.infer<typeof HMActionSchema> @@ -1837,6 +1909,10 @@ export const HMRequestSchema = z.discriminatedUnion('key', [ HMListDomainsRequestSchema, HMPublishBlobsRequestSchema, HMPrepareDocumentChangeRequestSchema, + HMSubscribeRequestSchema, + HMUnsubscribeRequestSchema, + HMForceSyncRequestSchema, + HMListSubscriptionsRequestSchema, ]) export type HMRequest = z.infer<typeof HMRequestSchema> diff --git a/frontend/packages/shared/src/api-force-sync.ts b/frontend/packages/shared/src/api-force-sync.ts new file mode 100644 index 0000000000..1aeb333c46 --- /dev/null +++ b/frontend/packages/shared/src/api-force-sync.ts @@ -0,0 +1,40 @@ +import {HMRequestImplementation} from './api-types' +import {HMForceSyncRequest} from '@seed-hypermedia/client/hm-types' +import {discoveryUrl} from './discovery' +import {BIG_INT} from './constants' + +/** + * Trigger immediate discovery of every active subscription. + * + * The original `Daemon.ForceSync` RPC is deprecated and now returns + * `Unimplemented`. We replace it with a fan-out over the entities service: + * 1. List active subscriptions. + * 2. For each, call `Entities.DiscoverEntity` with `recursion=descendants` + * and `async=true` so the daemon promotes that subtree into the hot + * discovery tier without blocking the caller. + * + * Returns once all DiscoverEntity calls have been dispatched (not when + * discovery completes — that's monitored separately via `site sync-status`). + */ +export const ForceSync: HMRequestImplementation<HMForceSyncRequest> = { + async getData(grpcClient) { + const subs = await grpcClient.subscriptions.listSubscriptions({pageSize: BIG_INT}) + await Promise.all( + subs.subscriptions.map((s) => + grpcClient.entities.discoverEntity({ + id: discoveryUrl({ + uid: s.account, + path: pathStringToParts(s.path), + recursion: s.recursive ? 'descendants' : 'none', + }), + }), + ), + ) + return {} + }, +} + +function pathStringToParts(path: string): string[] | null { + if (!path) return null + return path.split('/').filter(Boolean) +} diff --git a/frontend/packages/shared/src/api-subscriptions.ts b/frontend/packages/shared/src/api-subscriptions.ts new file mode 100644 index 0000000000..ea84d64701 --- /dev/null +++ b/frontend/packages/shared/src/api-subscriptions.ts @@ -0,0 +1,49 @@ +import {HMRequestImplementation} from './api-types' +import { + HMListSubscriptionsRequest, + HMSubscribeRequest, + HMUnsubscribeRequest, +} from '@seed-hypermedia/client/hm-types' + +/** Subscribe to a document or space (recursive=true mirrors all docs under path). */ +export const Subscribe: HMRequestImplementation<HMSubscribeRequest> = { + async getData(grpcClient, input) { + await grpcClient.subscriptions.subscribe({ + account: input.account, + path: input.path ?? '', + recursive: !!input.recursive, + async: input.async, + }) + return {} + }, +} + +/** Remove a subscription. */ +export const Unsubscribe: HMRequestImplementation<HMUnsubscribeRequest> = { + async getData(grpcClient, input) { + await grpcClient.subscriptions.unsubscribe({ + account: input.account, + path: input.path ?? '', + }) + return {} + }, +} + +/** List active subscriptions on this daemon. */ +export const ListSubscriptions: HMRequestImplementation<HMListSubscriptionsRequest> = { + async getData(grpcClient, input) { + const result = await grpcClient.subscriptions.listSubscriptions({ + pageSize: input.pageSize, + pageToken: input.pageToken, + }) + return { + subscriptions: result.subscriptions.map((s) => ({ + account: s.account, + path: s.path, + recursive: s.recursive, + since: s.since ? s.since.toDate().toISOString() : undefined, + })), + nextPageToken: result.nextPageToken || undefined, + } + }, +} diff --git a/frontend/packages/shared/src/api.ts b/frontend/packages/shared/src/api.ts index 56cbfbada1..ff62c69a0c 100644 --- a/frontend/packages/shared/src/api.ts +++ b/frontend/packages/shared/src/api.ts @@ -23,7 +23,9 @@ import {ListCommentsByAuthor} from './api-list-comments-by-author' import {PrepareDocumentChange} from './api-prepare-document-change' import {PublishBlobs} from './api-publish-blobs' import {Query} from './api-query' +import {ForceSync} from './api-force-sync' import {QueryBlock} from './api-query-block' +import {ListSubscriptions, Subscribe, Unsubscribe} from './api-subscriptions' import {Resource, ResourceParams} from './api-resource' import {ResourceMetadata, ResourceMetadataParams} from './api-resource-metadata' import {Search} from './api-search' @@ -55,6 +57,7 @@ export const APIQueries = { ListCapabilities, ListDocumentCollaborators, InteractionSummary, + ListSubscriptions, } as const satisfies { [K in HMGetRequest['key']]: HMRequestImplementation<Extract<HMGetRequest, {key: K}>> } @@ -62,6 +65,9 @@ export const APIQueries = { export const APIActions = { PublishBlobs, PrepareDocumentChange, + Subscribe, + Unsubscribe, + ForceSync, } as const satisfies { [K in HMAction['key']]: HMRequestImplementation<Extract<HMAction, {key: K}>> } diff --git a/seed-knowledge-manager/agent/README.md b/seed-knowledge-manager/agent/README.md index 64526a9c63..045485b544 100644 --- a/seed-knowledge-manager/agent/README.md +++ b/seed-knowledge-manager/agent/README.md @@ -11,10 +11,12 @@ Production deployment connects against community site `hm://z6MkuBbsB1HbSNXLvJCR The agent: - Polls the site every 15 seconds for `@knowledge-manager` and `@<site>` mentions in comments. - Posts a placeholder reply ("Working on this — back in a moment. ⌛") within ~1–2s of detection so members get a typing-indicator equivalent. -- Searches the community corpus (`seed-cli search`) for documents relevant to the question, fetches them, and feeds them to DeepSeek as grounding context. +- Searches the community corpus (`seed-cli search`) for documents relevant to the question, fetches them, and feeds them to DeepSeek as grounding context. With `KM_USE_MASTRA_AGENT=1` this becomes a bounded tool-call loop where the model itself decides which docs / threads / profiles to pull (≤30 tool calls before a forced `final_answer`). - Edits the placeholder in place (or replies in a fresh top-level comment if seed-cli's `--reply` chain breaks) with the final answer, citing hm:// URLs. - Runs three scheduled cadences via systemd timers — weekly bulletin (Mon 09:00 UTC), gap report (Wed 10:00 UTC), monthly health report (1st of month 09:00 UTC) — each producing a Seed document under `/agents/knowledge-manager/state/...`. - Captures every action — LLM call, tool call, seed-cli invocation, mention enqueued, reply posted — to a per-run audit directory under `~km/km-logs/runs/`. +- Runs entirely against a fully-subscribed local Seed daemon when `KM_USE_LOCAL_DAEMON=1`. A preflight `site sync-status` check refuses to run unless the daemon has both the subscription and the writer capability blob locally cached. +- Models the per-mention lifecycle (detected → placeholder → agent → finalised) as an XState v5 actor with retry/backoff and JSONL snapshot/replay when `KM_USE_STATE_MACHINE=1`. Killing the service mid-run resumes mid-flight on restart. The agent's policy lives entirely in four Seed documents the operator can edit from any desktop client. Toggling `draft_only: true` in the rules doc disables doc-creating writes within ≤60s. The wrapper hardcodes a denylist that prevents the agent from rewriting its own rules. @@ -64,14 +66,108 @@ The agent's policy lives entirely in four Seed documents the operator can edit f Components: -- **Local Seed daemon** (Docker `seedhypermedia/site:latest`, runs as a pure peer): ports `55000` (P2P, public) and `127.0.0.1:55001` HTTP / `:55002` gRPC (loopback). Plus `seed-web:latest` on `127.0.0.1:3000` because `seed-cli` speaks the Remix `/api/<RPC>` shape, not raw gRPC-Web. Currently the wrapper points at `https://hyper.media` via `SEED_SERVER` because the local daemon's smart-syncing lags on capability blobs; switch back to `http://127.0.0.1:3000` once you have a more aggressive sync strategy. +- **Local Seed daemon** (Docker `seedhypermedia/site:latest`, runs as a pure peer): ports `55000` (P2P, public) and `127.0.0.1:55001` HTTP / `:55002` gRPC (loopback). Plus `seed-web:latest` on `127.0.0.1:3000` because `seed-cli` speaks the Remix `/api/<RPC>` shape, not raw gRPC-Web. With `KM_USE_LOCAL_DAEMON=1` the wrapper points at `http://127.0.0.1:3000` and refuses to run until `seed-cli site sync-status` reports `ready_for_writes=true`. - **seed-cli** built from this repo's `frontend/apps/cli/` and dropped at `/home/km/.local/bin/seed-cli`. Published `@seed-hypermedia/cli@0.1.4` on npm has an unresolved `workspace:*` dep that breaks `npx`, so we ship a Bun-bundled binary instead. - **secret-tool shim** at `/home/km/.local/bin/secret-tool` (file-backed, `chmod 600` JSON in `~/.config/seed-keyring/secrets.json`). Replaces `gnome-keyring`/`libsecret`, which can't bootstrap on a headless server. Same on-wire format as the OS keyring entries seed-cli expects. - **Custom Bun-bundled drivers** (one ~430 KB `dist/index.js` + smaller per-task bundles): `poll-cli.js`, `cadence-cli.js`, `telegram-bot.js`, plus the optional MCP wrapper `index.js` for nanobot. -- **DeepSeek** (`https://api.deepseek.com/v1/chat/completions`) as the LLM. One chat call per mention answered. Reasoning content not currently captured (set up but not exposed by `deepseek-chat`). +- **DeepSeek** (`https://api.deepseek.com/v1/chat/completions`) as the LLM. One deterministic chat call per mention in legacy mode; bounded tool-call loop (≤30 calls + mandatory `final_answer`) when `KM_USE_MASTRA_AGENT=1`. +- **XState v5 supervisor** — replaces the implicit two-pass placeholder/finalise loop with explicit per-mention machines, retry/backoff, and crash-resume via JSONL snapshots. Behind `KM_USE_STATE_MACHINE=1`. +- **Mastra-style agent loop** — natural-language chat surface for both Telegram operator/community DMs and the polling finalise step. Re-implements the Mastra slice we need (tool registration → bounded loop → multi-turn history) directly, since the npm SDK's Vite/Hono dep graph does not bundle cleanly with `bun build`. Behind `KM_USE_MASTRA_AGENT=1`. We started with HKUDS/nanobot orchestrating the polling loop, but DeepSeek kept getting stuck in `read_file/grep` loops on nanobot's tool-result-spilled-to-disk pattern. The polling driver is now a deterministic Bun script that does one DeepSeek call per question and posts the reply directly. The `nanobot gateway` process can stay running for free-form interactive use, but it is no longer on the critical path. +## What changed in this iteration + +Three workstreams ship behind feature flags so the legacy paths remain the default until each is verified live. Flip them in `secrets.env` one at a time. + +### Workstream A — Self-contained operation against the local daemon (`KM_USE_LOCAL_DAEMON`) + +Before this change the wrapper called the public gateway (`https://hyper.media`) for every read because the local daemon's smart-sync lagged on capability blobs. We now treat the local daemon as the source of truth. + +**New seed-cli commands** (defined in `frontend/apps/cli/src/commands/site.ts`): + +```bash +# Subscribe the local daemon to a site, recursively. --wait blocks until the +# first DiscoverObject completes (async=false at the gRPC layer). +seed-cli -s http://127.0.0.1:3000 site subscribe hm://<SITE> --recursive --wait + +# Drop a subscription. +seed-cli -s http://127.0.0.1:3000 site unsubscribe hm://<SITE> + +# Read what the daemon is mirroring. +seed-cli -s http://127.0.0.1:3000 site list-subscriptions + +# Composite check: subscription present + at least one writer cap locally +# cached for the agent. Returns ready_for_writes:true when both hold. +seed-cli -s http://127.0.0.1:3000 site sync-status hm://<SITE> --writer z6Mkh11x... + +# Force the daemon's smart-sync to run immediately (wraps Daemon.ForceSync). +seed-cli -s http://127.0.0.1:3000 site reconcile +``` + +Under the hood these wrap the existing `com.seed.activity.v1alpha.Subscriptions` and `Daemon.ForceSync` gRPC RPCs. We exposed them through the Remix `/api/<RPC>` surface by adding the corresponding entries to `HMGetRequestSchema`/`HMActionSchema` in `frontend/packages/client/src/hm-types.ts` and the implementations under `frontend/packages/shared/src/api-subscriptions.ts` + `api-force-sync.ts`. + +**Bootstrap script** `agent/scripts/bootstrap-subscription.sh` is idempotent — it records the site in `~/km-state/subscribed.flag` after the first subscribe, then waits up to 5 min for the writer capability to converge before exiting. Runs once on first deploy; safe to re-run on any subsequent deploy. + +**Periodic ForceSync** (`km-reconcile.timer`/`.service`) calls `seed-cli site reconcile` every 60 s. This is a userland band-aid; the proper backend fix is below. + +**Backend scheduler change** — `backend/hmnet/syncing/scheduler.go` now extends a subscription task's `hotDeadline` past its `nextRunTime` when `--syncing.subscription-hot-tier=true`. The dispatcher's lazy migration then promotes the task into the hot tier at dispatch time, so capability/comment blobs for subscribed sites no longer compete with cold ephemeral discovery requests. New flag in `backend/config/config.go`: + +```bash +seed-daemon ... --syncing.subscription-hot-tier=true +``` + +Once this rolls out the `km-reconcile.timer` can be removed. + +**Preflight in poll-cli** — when `KM_USE_LOCAL_DAEMON=1`, the driver runs `site sync-status` before the poll loop and exits cleanly (status=`denied`, event `preflight_skipped`) if the local daemon does not yet have the writer cap. Prevents writes against a stale local mirror. + +### Workstream B — XState v5 polling pipeline (`KM_USE_STATE_MACHINE`) + +The legacy two-pass loop in `poll-cli.ts` (Pass A posts placeholders, Pass B finalises) has no formal state, no retry/backoff, and no resume-after-crash semantics beyond idempotency keys. Workstream B replaces Pass B with a per-mention XState v5 actor and a supervisor that persists every transition. + +**Files**: +- `src/machines/mention-machine.ts` — the actor definition. +- `src/machines/supervisor.ts` — actor lifecycle, JSONL persistence, `rehydrate()` for crash-replay. +- `src/machines/poll-driver.ts` — glue called from `poll-cli.ts` Pass B when the flag is on. + +**State graph**: + +``` +detected → enqueued → placeholder_pending → placeholder_posted → +agent_running → draft_ready → finalising → done + + ↓ guards ↓ retries (3, exp backoff 2s base) +skipped_not_allowed agent_backoff / finalise_backoff +cap_exceeded failed_terminal +``` + +Guards on `enqueued` enforce the existing governance limits (`maxCommentsPerRun`, `maxCommentsPerDay`, `moderation.blockedAuthors`) — same `limits.ts` checks as the legacy path, but now first-class transitions with named terminal states. + +**Snapshot / replay**: every state transition appends one JSON line to `${KM_STATE_DIR}/machines/<mentionId>.jsonl`. On startup the supervisor scans the directory, replays each file's events into a fresh actor, and resumes mid-flight. JSONL matches the existing `placeholders.jsonl` / `processed.jsonl` pattern — no new infra, easy to grep, easy to tail. + +**Inspectability**: each transition also emits a trace event (`state_machine_enabled`, `state_machine_rehydrated`) into `trace.jsonl`. `@xstate/inspect` can be enabled in dev for visual debugging. + +### Workstream C — Natural-language agent surface (`KM_USE_MASTRA_AGENT`) + +Replaces the single deterministic DeepSeek call with a bounded tool-call loop. The model is given the question + a set of tools and decides which to call before producing a final answer. This delivers the original goal of "users communicate with the agent in natural language and the agent dynamically expands context (thread roots, linked docs, related search results)". + +**Files**: +- `src/agent/mastra-agent.ts` — the loop. ≤30 tool calls per turn, then a forced `final_answer` step. +- `src/agent/tools-bridge.ts` — in-process tool registry: `seed_search`, `seed_get_doc`, `seed_get_comment_thread`, `seed_get_account_profile`. Each tool wraps a `seed-cli` subprocess. +- `src/agent/prompts.ts` — community vs operator system prompts. +- `src/tools.ts` — same surface also exposed as MCP tools (`seed_get_comment_thread`, `seed_site_sync_status`) for the optional nanobot gateway. + +**Why "Mastra-style" rather than the Mastra SDK directly**: the npm Mastra package depends on Vite + Hono and does not bundle cleanly with `bun build --target node --minify`. We re-implement the small slice we actually need (tool registration → bounded loop → multi-turn history) and keep the surface compatible so a future swap to the SDK is mechanical. See the header of `mastra-agent.ts`. + +**Telegram surface**: when the flag is on, both `/ask` (operator) and free-form DMs route through the Mastra loop with per-chat-id history. The legacy path (single `draftReply` / `draftSystemReply` call) remains as fallback. + +**Polling surface**: `poll-driver.ts` wires the agent into the `agent_running` state of each mention's machine. The supervisor's retry-with-backoff therefore wraps the agent loop — a 429 from DeepSeek triggers the exponential backoff before re-entering `agent_running`. + +**DeepSeek tool-call hardening**: known DeepSeek issues (#244, #336, #946) are mitigated by: +1. Hard tool budget = 30. After 30 calls the model is forced into `final_answer` via `tool_choice: {type: 'function', function: {name: 'final_answer'}}`. +2. Mandatory `final_answer` tool ensures explicit termination instead of "model just stops". +3. Tool results are ≤4 KB (`MAX_DOC_CHARS`) and never paths to disk — so no `read_file/grep` loop pathology. + ## Governance — the four Seed documents All four exist on the production site: @@ -316,13 +412,22 @@ The skill methodology (`SKILL.md`, `references/`, `templates/`) is rsynced into ``` DEEPSEEK_API_KEY=sk-... # required for replies + cadenced docs -SEED_SERVER=https://hyper.media # currently the gateway, not local daemon +SEED_SERVER=http://127.0.0.1:3000 # local daemon when KM_USE_LOCAL_DAEMON=1; otherwise gateway +SEED_LOCAL_DAEMON_URL=http://127.0.0.1:3000 # used by km-reconcile.service + bootstrap-subscription.sh SEED_SITE=hm://z6MkuBbsB1HbSNXLvJCRCrPhimY6g7tzhr4qvcYKPuSZzhno KM_KEY_NAME=knowledge-manager +KM_AID=z6Mkh11xNzNLTrkDEjmPf19twBvAVsw3HoQtv5nPKVVbEUSJ # gates ready_for_writes preflight TELEGRAM_TOKEN=... # only needed for km-telegram.service OPS_TELEGRAM_ID=12345,67890 # comma-sep allowlist of operator chat IDs + +# Workstream feature flags. All default off → legacy paths. +KM_USE_LOCAL_DAEMON=1 # poll-cli refuses to run unless site sync-status reports ready +KM_USE_STATE_MACHINE=1 # Pass B routes through XState supervisor with retry/backoff +KM_USE_MASTRA_AGENT=1 # Telegram + finalisation use bounded tool-call loop ``` +Each flag is independent. Bring them up in order A → B → C, verifying live for ~24h between flips. + Systemd user units (all under `~/.config/systemd/user/`): ``` @@ -333,6 +438,7 @@ km-boletin.timer + .service # weekly bulletin, Mon 09:00 UTC km-gap.timer + .service # gap report, Wed 10:00 UTC km-health.timer + .service # network health, 1st 09:00 UTC km-telegram.service # operator Telegram bot (long-running) +km-reconcile.timer + .service # periodic ForceSync against the local daemon (every 60s) ``` Enable + start everything: @@ -341,12 +447,20 @@ Enable + start everything: sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) bash -lc ' systemctl --user daemon-reload systemctl --user enable --now seed-daemon.service - systemctl --user enable --now km-poll.timer km-boletin.timer km-gap.timer km-health.timer + systemctl --user enable --now km-poll.timer km-boletin.timer km-gap.timer km-health.timer km-reconcile.timer systemctl --user enable --now km-telegram.service systemctl --user list-timers ' ``` +Bootstrap the subscription (once, after seed-daemon comes up healthy): + +```bash +sudo -u km bash /home/km/km-agent/scripts/bootstrap-subscription.sh \ + hm://z6MkuBbsB1HbSNXLvJCRCrPhimY6g7tzhr4qvcYKPuSZzhno \ + z6Mkh11xNzNLTrkDEjmPf19twBvAVsw3HoQtv5nPKVVbEUSJ +``` + > **NOTE — port 18790 collision:** The default nanobot gateway port `18790` is already taken by another service on `oc.hyper.media`. We pin it to `18791` via `--port 18791` in `nanobot-gateway.service`. Adjust if your host has different conflicts. ### 10. Telegram bot @@ -383,10 +497,22 @@ End-to-end checks. ✅ = verified live on production. | 20 | km-log helper works | ✅ `km-log latest 5`, `km-log show <id>`, `km-log mention <id>` all functional | | 21 | Secrets redaction | ✅ Grepping `km-logs/` for `DEEPSEEK_API_KEY` value returns 0 hits | | 22 | Telegram allowFrom enforced | ✅ Non-allowlisted chat IDs are silently ignored | +| 23 | `seed-cli site subscribe --recursive --wait` returns | new — verify with `site list-subscriptions` showing the site | +| 24 | `seed-cli site sync-status hm://<SITE> --writer <KM_AID>` reports `ready_for_writes:true` | new — only true once writer cap converges locally | +| 25 | `KM_USE_LOCAL_DAEMON=1` preflight skips when not ready | new — `tcpdump -i any host hyper.media` shows zero traffic during the skipped run | +| 26 | `KM_USE_LOCAL_DAEMON=1` preflight passes when ready | new — `trace.jsonl` shows `preflight_sync_status` with `ready:true` and a normal poll cycle follows | +| 27 | Subscription hot-tier promotion | new — with `--syncing.subscription-hot-tier=true` capability blobs converge in ~1 minute instead of ~5 minutes | +| 28 | XState rehydrate after crash | new — kill `km-poll.service` mid-mention, restart, see `state_machine_rehydrated` event in `trace.jsonl` and the mention completes | +| 29 | XState retry-with-backoff | new — temporarily set `DEEPSEEK_API_KEY=invalid`, see `agent_running → agent_backoff → agent_running` (×3) in `${stateDir}/machines/<mid>.jsonl`, then `failed_terminal` | +| 30 | XState cap_exceeded | new — set `maxCommentsPerDay=2` in agent-rules; third mention transitions to `cap_exceeded` | +| 31 | Mastra tool-call loop | new — `tools.jsonl` shows ordered calls (`seed_search` → `seed_get_doc` → `seed_get_comment_thread` → `final_answer`) within budget | +| 32 | Mastra tool budget enforced | new — model exceeding 30 calls is forced into `final_answer` via `tool_choice` | +| 33 | Telegram multi-turn community Q&A | new — three follow-up messages share context across turns when `KM_USE_MASTRA_AGENT=1` | +| 34 | `seed_get_comment_thread` MCP tool | new — `bun test src` covers thread walk; production check: `tools.jsonl` shows the call when a mention is in a reply chain | ## Known issues + workarounds -- **Local daemon doesn't have current capability blob.** Wrapper uses `SEED_SERVER=https://hyper.media` (gateway) instead of `127.0.0.1:3000` (local) until a force-sync mechanism lands. +- **Local daemon capability blob lag — superseded.** Previous workaround pinned `SEED_SERVER=https://hyper.media`. Now addressed by `seed-cli site subscribe`, the `--syncing.subscription-hot-tier` daemon flag, and the `KM_USE_LOCAL_DAEMON` preflight gate. Periodic `km-reconcile.timer` is the userland band-aid until the hot-tier change is verified live. - **`seed-cli comment create --reply <id>` returns `✗ Non-base58btc character` for some parents.** Reproduces specifically when the parent comment's chain includes an edited comment. `poll-cli.ts` retries without `--reply` (top-level reply on the doc) and logs `placeholder_reply_fallback`. Filed in our internal seed-cli backlog. - **`seed-cli document create --path /` returns `HTTP 500 from PublishBlobs`.** The CLI treats `--path ""` as falsy (slugifies the title). The Seed Vault publishes the agent's home-doc/profile metadata via a different RPC; the CLI can't currently publish at the account root. - **`seed-cli` writes success messages to stderr.** `comment create` prints `✓ Comment published: <CID>` to stderr (not stdout), and the CID is the version, not the record id. `postPlaceholder` parses stderr, then resolves CID → record id via `comment get`. @@ -409,17 +535,26 @@ agent/ │ ├── src/ │ │ ├── audit.ts │ │ ├── cadence-cli.ts ← weekly bulletin / gap / health driver -│ │ ├── config.ts +│ │ ├── config.ts ← env → AgentConfig (incl. KM_USE_* flags) │ │ ├── governance.ts │ │ ├── index.ts ← stdio MCP server entry point (optional) │ │ ├── limits.ts │ │ ├── mentions.ts │ │ ├── poll-cli.ts ← polling + typing-indicator + grounded reply +│ │ ├── reply-engine.ts ← legacy single-shot DeepSeek call │ │ ├── redact.ts │ │ ├── seedcli.ts │ │ ├── state.ts │ │ ├── telegram-bot.ts ← operator chat surface -│ │ ├── tools.ts ← MCP tool registry (used by stdio server) +│ │ ├── tools.ts ← MCP tool registry (incl. seed_get_comment_thread, seed_site_sync_status) +│ │ ├── machines/ +│ │ │ ├── mention-machine.ts ← XState v5 actor: per-mention lifecycle +│ │ │ ├── supervisor.ts ← actor supervisor + JSONL snapshot/replay +│ │ │ └── poll-driver.ts ← glue from poll-cli Pass B → supervisor +│ │ ├── agent/ +│ │ │ ├── mastra-agent.ts ← bounded DeepSeek tool-call loop (multi-turn) +│ │ │ ├── tools-bridge.ts ← in-process tool registry for the agent +│ │ │ └── prompts.ts ← community + operator system prompts │ │ └── *.test.ts ← bun:test unit tests │ └── dist/ ← bun build output (deployed to server) ├── systemd/ ← user-mode unit files @@ -429,9 +564,11 @@ agent/ │ ├── km-boletin.{service,timer} │ ├── km-gap.{service,timer} │ ├── km-health.{service,timer} +│ ├── km-reconcile.{service,timer} ← periodic ForceSync against local daemon │ └── km-telegram.service ├── scripts/ │ ├── install-phase1.sh ← idempotent server provisioning +│ ├── bootstrap-subscription.sh ← idempotent site subscribe + writer-cap wait │ ├── km-log ← log browsing helper for /home/km/.local/bin │ └── secret-tool-shim ← file-backed libsecret replacement ├── templates/ ← bootstrap content for the four governance docs diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/bun.lock b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/bun.lock index b48282719f..6ba42b4858 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/bun.lock +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", "ulid": "^2.4.0", + "xstate": "^5.19.0", "yaml": "^2.6.0", "zod": "^3.23.8", }, @@ -207,6 +208,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xstate": ["xstate@5.31.0", "", {}, "sha512-5B+0DqC0uNUrcLUEY3pn3iNy+swvK2E0ZpYp5gnV3oxMX5y87vzXkU5YXv9CAtyG5c5FOJ1SzvTWHrwE8fMZNQ=="], + "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/package.json b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/package.json index 0c52dc4e85..3114512435 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/package.json +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/package.json @@ -19,7 +19,8 @@ "@modelcontextprotocol/sdk": "^1.0.4", "yaml": "^2.6.0", "zod": "^3.23.8", - "ulid": "^2.4.0" + "ulid": "^2.4.0", + "xstate": "^5.19.0" }, "devDependencies": { "@types/bun": "latest", diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/mastra-agent.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/mastra-agent.ts new file mode 100644 index 0000000000..26f86cd591 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/mastra-agent.ts @@ -0,0 +1,286 @@ +/** + * Mastra-style agent loop for the Knowledge Manager. + * + * Why "Mastra-style" rather than the Mastra SDK directly: Mastra ships as an + * npm package with a deep dependency on Vite + Hono. Bundling it into the + * minified Bun-built `dist/*.js` we ship to the server hits import-graph + * problems. We re-implement the small slice of Mastra we actually need: + * + * - tool registration (JSON-Schema → DeepSeek `tools` parameter) + * - bounded tool-call loop (max 30 calls / final_answer terminator) + * - per-thread message history (Telegram chat id, mention id) + * + * If/when the Mastra runtime gets first-class Bun support, we replace the + * inner loop with `agent.run({threadId, message})` keeping the tool surface. + */ + +import type {SeedCli} from '../seedcli.js' +import type {AuditRun} from '../audit.js' +import type {Mention} from '../mentions.js' +import {buildAgentTools, type ToolDef} from './tools-bridge.js' +import {COMMUNITY_AGENT_SYSTEM, OPERATOR_AGENT_SYSTEM} from './prompts.js' + +const MAX_TOOL_CALLS = 30 +const DEEPSEEK_URL = 'https://api.deepseek.com/v1/chat/completions' + +type Role = 'system' | 'user' | 'assistant' | 'tool' +type Message = { + role: Role + content: string | null + name?: string + tool_calls?: Array<{ + id: string + type: 'function' + function: {name: string; arguments: string} + }> + tool_call_id?: string +} + +type RunArgs = { + systemPrompt: string + userMessage: string + history?: Message[] + tools: ToolDef[] + audit?: AuditRun + maxTokens?: number + temperature?: number +} + +type RunResult = { + finalAnswer: string | null + /** Updated history including system + user + assistant + tool messages. + * Caller persists it per thread to support multi-turn. */ + history: Message[] + toolCallCount: number +} + +async function runAgent(args: RunArgs): Promise<RunResult> { + const apiKey = process.env.DEEPSEEK_API_KEY + if (!apiKey) { + args.audit?.trace({ts: new Date().toISOString(), level: 'error', event: 'mastra_no_deepseek_key'}) + return {finalAnswer: null, history: args.history ?? [], toolCallCount: 0} + } + + // Mandatory final_answer tool ensures the model terminates explicitly. + const allTools: ToolDef[] = [ + ...args.tools, + { + name: 'final_answer', + description: 'Emit the final reply to the user. Call this exactly once when ready.', + parameters: { + type: 'object', + properties: { + body: {type: 'string', description: 'The reply body. Plain text or simple markdown.'}, + }, + required: ['body'], + }, + handler: async () => '', + }, + ] + + const tools = allTools.map((t) => ({ + type: 'function' as const, + function: {name: t.name, description: t.description, parameters: t.parameters}, + })) + + const messages: Message[] = [ + {role: 'system', content: args.systemPrompt}, + ...(args.history ?? []), + {role: 'user', content: args.userMessage}, + ] + + let toolCallCount = 0 + let finalAnswer: string | null = null + + for (let step = 0; step < MAX_TOOL_CALLS + 1; step++) { + const t0 = Date.now() + const body = JSON.stringify({ + model: 'deepseek-chat', + messages, + tools, + tool_choice: step === MAX_TOOL_CALLS ? {type: 'function', function: {name: 'final_answer'}} : 'auto', + temperature: args.temperature ?? 0.4, + max_tokens: args.maxTokens ?? 600, + }) + let res: Response + try { + res = await fetch(DEEPSEEK_URL, { + method: 'POST', + headers: {'content-type': 'application/json', authorization: `Bearer ${apiKey}`}, + body, + }) + } catch (err) { + args.audit?.trace({ + ts: new Date().toISOString(), + level: 'error', + event: 'mastra_network_error', + data: {message: err instanceof Error ? err.message : String(err)}, + }) + break + } + const latencyMs = Date.now() - t0 + if (!res.ok) { + const text = await res.text().catch(() => '') + args.audit?.trace({ + ts: new Date().toISOString(), + level: 'error', + event: 'mastra_http_error', + data: {status: res.status, body: text.slice(0, 300), latencyMs}, + }) + break + } + const json = (await res.json()) as { + choices?: Array<{message?: Message; finish_reason?: string}> + usage?: {prompt_tokens?: number; completion_tokens?: number; total_tokens?: number} + } + const choice = json.choices?.[0] + const message = choice?.message + if (!message) break + + args.audit?.llm({ + ts_start: new Date(t0).toISOString(), + ts_end: new Date().toISOString(), + latency_ms: latencyMs, + model: 'deepseek-chat', + completion: message.content ?? '', + tool_calls: message.tool_calls, + usage: { + prompt: json.usage?.prompt_tokens, + completion: json.usage?.completion_tokens, + total: json.usage?.total_tokens, + }, + }) + + messages.push(message) + + if (!message.tool_calls || message.tool_calls.length === 0) { + // Plain assistant text without tool call. Treat as final answer. + finalAnswer = (message.content ?? '').trim() || null + break + } + + let answeredViaFinal = false + for (const call of message.tool_calls) { + toolCallCount++ + const tool = allTools.find((t) => t.name === call.function.name) + if (!tool) { + messages.push({ + role: 'tool', + tool_call_id: call.id, + name: call.function.name, + content: `error: unknown tool ${call.function.name}`, + }) + continue + } + let parsed: any = {} + try { + parsed = call.function.arguments ? JSON.parse(call.function.arguments) : {} + } catch { + // pass through to handler with empty args + } + if (tool.name === 'final_answer') { + finalAnswer = String(parsed.body ?? '').trim() || null + answeredViaFinal = true + messages.push({ + role: 'tool', + tool_call_id: call.id, + name: 'final_answer', + content: 'ok', + }) + continue + } + const t1 = Date.now() + let result = '' + try { + result = await tool.handler(parsed) + } catch (err) { + result = `error: ${err instanceof Error ? err.message : String(err)}` + } + const latency = Date.now() - t1 + args.audit?.tool({ + ts_start: new Date(t1).toISOString(), + ts_end: new Date().toISOString(), + latency_ms: latency, + tool: tool.name, + args: parsed, + result: result.slice(0, 200), + }) + messages.push({ + role: 'tool', + tool_call_id: call.id, + name: tool.name, + content: result, + }) + } + + if (answeredViaFinal) break + if (toolCallCount >= MAX_TOOL_CALLS) { + // Force a terminal step on the next loop iteration (tool_choice locked + // to final_answer above). + continue + } + } + + args.audit?.trace({ + ts: new Date().toISOString(), + level: 'info', + event: 'mastra_agent_done', + data: {toolCallCount, finalAnswerBytes: finalAnswer?.length ?? 0}, + }) + + return {finalAnswer, history: messages, toolCallCount} +} + +/** + * Drives a community reply for an incoming mention. Used by poll-driver in + * Workstream B/C integration. Returns the final body or null on failure. + */ +export async function runMastraReply(opts: { + question: string + context: string + mention: Mention + cli: SeedCli + audit?: AuditRun +}): Promise<string | null> { + const tools = buildAgentTools({cli: opts.cli, audit: opts.audit}) + const userMessage = + `Question (asked in comment ${opts.mention.commentId} on doc ${opts.mention.docId}):\n` + + `${opts.question}\n\n` + + (opts.context + ? `Pre-fetched context (use the tools above to drill deeper if needed):\n${opts.context}` + : `No pre-fetched context. Use the tools to gather what you need.`) + + const result = await runAgent({ + systemPrompt: COMMUNITY_AGENT_SYSTEM, + userMessage, + tools, + audit: opts.audit, + maxTokens: 500, + temperature: 0.4, + }) + return result.finalAnswer +} + +/** + * Operator-facing multi-turn chat. Caller persists `history` per chat id. + */ +export async function runMastraOperator(opts: { + question: string + systemContextBlob: string + history?: Message[] + cli: SeedCli + audit?: AuditRun +}): Promise<{finalAnswer: string | null; history: Message[]}> { + const tools = buildAgentTools({cli: opts.cli, audit: opts.audit}) + const userMessage = `Operator question: ${opts.question}\n\n## System context\n${opts.systemContextBlob}` + const result = await runAgent({ + systemPrompt: OPERATOR_AGENT_SYSTEM, + userMessage, + history: opts.history, + tools, + audit: opts.audit, + maxTokens: 800, + temperature: 0.2, + }) + return {finalAnswer: result.finalAnswer, history: result.history} +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts new file mode 100644 index 0000000000..1a951efa8d --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts @@ -0,0 +1,39 @@ +/** + * System prompts for the Mastra agent loop. Kept out of the agent file so + * they can be edited / reviewed without scrolling past Connect/Mastra glue. + */ + +export const COMMUNITY_AGENT_SYSTEM = `You are the Knowledge Manager — a moderator of a Seed Hypermedia community. + +You answer questions from members in plain Spanish or English (match the asker's language). You ground every claim in the community's own documents and only fall back to general knowledge when the corpus is silent on the question. + +You have access to the following tools and you MUST use them when relevant: + - seed_search: keyword-search the community corpus. + - seed_get_doc: fetch the full body of an hm:// document. + - seed_get_comment_thread: fetch the parent thread (root + all replies) for a comment. + - seed_get_account_profile: fetch a profile / account doc. + - final_answer: produce the final reply to the user. You MUST call this exactly once when ready. + +Rules: + - Always call seed_search at least once before answering, with the asker's question. + - Pull seed_get_comment_thread when the question is in a reply chain to read prior turns. + - Pull seed_get_doc on each citation you intend to embed. + - When citing, embed full hm:// URLs as inline markdown links: [Title](hm://...). + - Stay under 120 words in the final answer. Plain text or simple markdown only — no headers, no code fences, no greeting/signoff. + - Hard tool budget: 30 tool calls per turn. After that, call final_answer with whatever you have. + - If the corpus is silent, answer from general knowledge in one sentence and explicitly say: "I couldn't find this in our community's docs".` + +export const OPERATOR_AGENT_SYSTEM = `You are the Knowledge Manager bot answering an OPERATOR question about your own implementation, configuration, and recent activity. + +You have access to the following tools: + - seed_search: search the community corpus (only when the operator asks about community content). + - seed_get_doc: fetch a doc body. + - km_recent_runs: read the last N audit runs from disk. + - km_show_rules: read the live agent-rules YAML block. + - km_status: summary of timers, last-run times, and ready_for_writes. + - final_answer: produce the final reply. You MUST call this exactly once. + +Rules: + - Use the system tools (km_*) when the question is about the agent itself. + - Never make up paths, services, commands, or run ids. Only echo strings you read via tools. + - Stay under 200 words. Plain text or simple markdown.` diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/tools-bridge.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/tools-bridge.ts new file mode 100644 index 0000000000..39385c9587 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/tools-bridge.ts @@ -0,0 +1,131 @@ +/** + * Tool registry for the Mastra agent loop. Exposes a small JSON-Schema-typed + * surface that maps directly to seed-cli subprocess calls. Mirrors the MCP + * tool registry in `tools.ts` but bypasses MCP for in-process use. + * + * Each tool's `handler` returns a string that goes back to the LLM. Large + * responses are truncated to keep the context window usable. + */ + +import type {SeedCli} from '../seedcli.js' +import type {AuditRun} from '../audit.js' + +export type ToolDef = { + name: string + description: string + parameters: { + type: 'object' + properties: Record<string, {type: string; description: string}> + required?: string[] + } + handler: (args: any) => Promise<string> +} + +const MAX_DOC_CHARS = 4_000 +const MAX_THREAD_COMMENTS = 30 + +export function buildAgentTools(opts: {cli: SeedCli; audit?: AuditRun}): ToolDef[] { + const {cli} = opts + + return [ + { + name: 'seed_search', + description: 'Keyword-search the community corpus. Returns a list of hm:// URLs and titles.', + parameters: { + type: 'object', + properties: { + query: {type: 'string', description: 'Free-text search query'}, + limit: {type: 'number', description: 'Maximum hits to return (default 5, max 10)'}, + }, + required: ['query'], + }, + handler: async (args) => { + const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 10) + const r = await cli.runRead(['search', String(args.query), '--limit', String(limit)]) + if (r.exitCode !== 0) return `error: ${r.stderr.slice(0, 200)}` + const parsed = r.parsedJson as {entities?: any[]; results?: any[]} | undefined + const hits = parsed?.entities ?? parsed?.results ?? [] + const lines = hits.slice(0, limit).map((h: any) => { + const id = typeof h.id === 'string' ? h.id : h.id?.id + return `${id} — ${h.title ?? '(untitled)'}` + }) + return lines.length > 0 ? lines.join('\n') : '(no results)' + }, + }, + { + name: 'seed_get_doc', + description: 'Fetch the full body of an hm:// document. Returns markdown.', + parameters: { + type: 'object', + properties: { + hm_url: {type: 'string', description: 'hm:// URL of the document'}, + }, + required: ['hm_url'], + }, + handler: async (args) => { + const r = await cli.runRead(['document', 'get', String(args.hm_url)]) + if (r.exitCode !== 0) return `error: ${r.stderr.slice(0, 200)}` + const body = r.stdout.replace(/<!--\s*id:[^>]+-->/g, '').trim() + return body.length > MAX_DOC_CHARS ? body.slice(0, MAX_DOC_CHARS) + '\n…(truncated)' : body + }, + }, + { + name: 'seed_get_comment_thread', + description: 'Fetch the comment thread (root + replies) for a given comment id.', + parameters: { + type: 'object', + properties: { + comment_id: {type: 'string', description: 'Canonical comment id (author/tsid)'}, + max: {type: 'number', description: 'Max comments (default 30)'}, + }, + required: ['comment_id'], + }, + handler: async (args) => { + const max = Math.min(Math.max(Number(args.max) || MAX_THREAD_COMMENTS, 1), 100) + // Walk replyParent up to root. + const collected: any[] = [] + let current = String(args.comment_id) + for (let i = 0; i < max; i++) { + const r = await cli.runRead(['comment', 'get', current]) + if (r.exitCode !== 0) break + const c = r.parsedJson as any + if (!c) break + collected.unshift(c) + if (!c.replyParent) break + current = c.replyParent + } + if (collected.length === 0) return '(thread not found)' + return collected + .map((c, i) => `(#${i + 1}) ${c.author}\n${stringifyComment(c)}`) + .join('\n\n') + }, + }, + { + name: 'seed_get_account_profile', + description: 'Fetch the profile metadata for a Seed account.', + parameters: { + type: 'object', + properties: { + account_id: {type: 'string', description: 'Account uid (z6Mk...)'}, + }, + required: ['account_id'], + }, + handler: async (args) => { + const r = await cli.runRead(['account', 'get', String(args.account_id)]) + if (r.exitCode !== 0) return `error: ${r.stderr.slice(0, 200)}` + return JSON.stringify(r.parsedJson ?? {}, null, 2).slice(0, MAX_DOC_CHARS) + }, + }, + ] +} + +function stringifyComment(c: any): string { + if (typeof c.body === 'string') return c.body + if (Array.isArray(c.content)) { + return c.content + .map((b: any) => b?.block?.text ?? b?.text ?? '') + .filter(Boolean) + .join('\n') + } + return '' +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/config.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/config.ts index 75cfd0a621..7f5b1d54de 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/config.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/config.ts @@ -14,6 +14,18 @@ export type AgentConfig = { rulesTtlMs: number writersTtlMs: number governanceBasePath: string + /** When true, drivers refuse to start unless `seed-cli site sync-status` + * reports ready_for_writes. Set via KM_USE_LOCAL_DAEMON=1. */ + useLocalDaemon: boolean + /** Account id whose WRITER capability gates ready_for_writes. Defaults to + * KM_AID; if absent, falls back to "any writer cap present". */ + writerAid: string | null + /** When true, poll-cli replaces the ad-hoc two-pass loop with the XState + * supervisor (machines/supervisor.ts). Set via KM_USE_STATE_MACHINE=1. */ + useStateMachine: boolean + /** When true, finalisation calls the Mastra agent via agent/mastra-agent.ts + * instead of reply-engine.draftReply. Set via KM_USE_MASTRA_AGENT=1. */ + useMastraAgent: boolean } const DEFAULT_RULES_TTL_MS = 60_000 @@ -36,6 +48,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AgentConfig { rulesTtlMs: numberOr(env.KM_RULES_TTL_MS, DEFAULT_RULES_TTL_MS), writersTtlMs: numberOr(env.KM_WRITERS_TTL_MS, DEFAULT_WRITERS_TTL_MS), governanceBasePath: env.KM_GOVERNANCE_BASE_PATH ?? '/agents/knowledge-manager', + useLocalDaemon: env.KM_USE_LOCAL_DAEMON === '1' || env.KM_USE_LOCAL_DAEMON === 'true', + writerAid: env.KM_AID ?? null, + useStateMachine: env.KM_USE_STATE_MACHINE === '1' || env.KM_USE_STATE_MACHINE === 'true', + useMastraAgent: env.KM_USE_MASTRA_AGENT === '1' || env.KM_USE_MASTRA_AGENT === 'true', } } diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/mention-machine.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/mention-machine.ts new file mode 100644 index 0000000000..8e41424eab --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/mention-machine.ts @@ -0,0 +1,222 @@ +/** + * Per-mention XState v5 actor machine. + * + * Replaces the implicit two-pass placeholder/finalise loop in `poll-cli.ts` + * with an explicit lifecycle that supports retry-with-backoff, snapshot/replay + * on crash, and inspectable state in audit logs. + * + * States: + * detected → enqueued → placeholder_pending → placeholder_posted → + * agent_running → draft_ready → finalising → done + * + * Terminal failure states: `failed_terminal`, `skipped_not_allowed`, `cap_exceeded`. + * + * Snapshot: each transition appends to `${stateDir}/machines/<mentionId>.jsonl` + * (event log). On startup the supervisor (`./supervisor.ts`) replays each + * file's events to rehydrate the machine. The machine itself does not write + * to disk — the supervisor wires `subscribe()` to a JSONL writer. + */ + +import {assign, fromPromise, setup} from 'xstate' +import type {Mention} from '../mentions.js' + +const MAX_DRAFT_RETRIES = 3 +const MAX_FINALISE_RETRIES = 3 +const BASE_BACKOFF_MS = 2_000 + +export type MentionContext = { + mention: Mention + /** Comment id of the placeholder posted in `placeholder_posted`. */ + placeholderId: string | null + /** Final reply body (DeepSeek output or fallback). */ + replyBody: string | null + /** Reason for terminal failure. */ + failureReason: string | null + /** Per-state retry counters. */ + draftRetries: number + finaliseRetries: number + /** Last error seen on a transient transition. */ + lastError: string | null +} + +export type MentionEvent = + | {type: 'ENQUEUE'} + | {type: 'CAP_DENIED'; reason: string} + | {type: 'NOT_ALLOWED'; reason: string} + | {type: 'POST_PLACEHOLDER'} + | {type: 'PLACEHOLDER_POSTED'; placeholderId: string} + | {type: 'PLACEHOLDER_FAILED'; reason: string} + | {type: 'RUN_AGENT'} + | {type: 'AGENT_DONE'; replyBody: string} + | {type: 'AGENT_ERROR'; reason: string} + | {type: 'FINALISE'} + | {type: 'FINALISED'} + | {type: 'FINALISE_ERROR'; reason: string} + +export type MentionInput = {mention: Mention} + +/** Caller-provided side effects. The machine has no I/O of its own — the + * supervisor injects callbacks that touch the network or seed-cli. */ +export type MentionCallbacks = { + postPlaceholder: (mention: Mention) => Promise<{placeholderId: string}> + runAgent: (mention: Mention) => Promise<{replyBody: string}> + finaliseComment: (placeholderId: string, replyBody: string) => Promise<void> + /** Optional pre-checks. Throw to short-circuit into a terminal state. */ + checkAllowed?: (mention: Mention) => 'allowed' | {reason: string} + checkCap?: (mention: Mention) => 'allowed' | {reason: string} +} + +export const mentionMachine = setup({ + types: { + context: {} as MentionContext, + events: {} as MentionEvent, + input: {} as MentionInput, + }, + actors: { + postPlaceholderActor: fromPromise<{placeholderId: string}, {mention: Mention; cb: MentionCallbacks}>( + async ({input}) => input.cb.postPlaceholder(input.mention), + ), + runAgentActor: fromPromise<{replyBody: string}, {mention: Mention; cb: MentionCallbacks}>( + async ({input}) => input.cb.runAgent(input.mention), + ), + finaliseActor: fromPromise<void, {placeholderId: string; replyBody: string; cb: MentionCallbacks}>( + async ({input}) => input.cb.finaliseComment(input.placeholderId, input.replyBody), + ), + }, + guards: { + canRetryDraft: ({context}) => context.draftRetries < MAX_DRAFT_RETRIES, + canRetryFinalise: ({context}) => context.finaliseRetries < MAX_FINALISE_RETRIES, + }, + delays: { + draftBackoff: ({context}) => BASE_BACKOFF_MS * 2 ** context.draftRetries, + finaliseBackoff: ({context}) => BASE_BACKOFF_MS * 2 ** context.finaliseRetries, + }, +}).createMachine({ + id: 'mention', + initial: 'detected', + context: ({input}) => ({ + mention: input.mention, + placeholderId: null, + replyBody: null, + failureReason: null, + draftRetries: 0, + finaliseRetries: 0, + lastError: null, + }), + states: { + detected: { + on: { + ENQUEUE: 'enqueued', + NOT_ALLOWED: { + target: 'skipped_not_allowed', + actions: assign({failureReason: ({event}) => event.reason}), + }, + CAP_DENIED: { + target: 'cap_exceeded', + actions: assign({failureReason: ({event}) => event.reason}), + }, + }, + }, + enqueued: { + on: { + POST_PLACEHOLDER: 'placeholder_pending', + CAP_DENIED: { + target: 'cap_exceeded', + actions: assign({failureReason: ({event}) => event.reason}), + }, + }, + }, + placeholder_pending: { + on: { + PLACEHOLDER_POSTED: { + target: 'placeholder_posted', + actions: assign({placeholderId: ({event}) => event.placeholderId}), + }, + PLACEHOLDER_FAILED: { + target: 'failed_terminal', + actions: assign({failureReason: ({event}) => event.reason}), + }, + }, + }, + placeholder_posted: { + on: { + RUN_AGENT: 'agent_running', + }, + }, + agent_running: { + on: { + AGENT_DONE: { + target: 'draft_ready', + actions: assign({replyBody: ({event}) => event.replyBody}), + }, + AGENT_ERROR: [ + { + guard: 'canRetryDraft', + target: 'agent_backoff', + actions: assign({ + draftRetries: ({context}) => context.draftRetries + 1, + lastError: ({event}) => event.reason, + }), + }, + { + target: 'failed_terminal', + actions: assign({failureReason: ({event}) => event.reason}), + }, + ], + }, + }, + agent_backoff: { + after: { + draftBackoff: 'agent_running', + }, + }, + draft_ready: { + on: { + FINALISE: 'finalising', + }, + }, + finalising: { + on: { + FINALISED: 'done', + FINALISE_ERROR: [ + { + guard: 'canRetryFinalise', + target: 'finalise_backoff', + actions: assign({ + finaliseRetries: ({context}) => context.finaliseRetries + 1, + lastError: ({event}) => event.reason, + }), + }, + { + target: 'failed_terminal', + actions: assign({failureReason: ({event}) => event.reason}), + }, + ], + }, + }, + finalise_backoff: { + after: { + finaliseBackoff: 'finalising', + }, + }, + done: {type: 'final'}, + skipped_not_allowed: {type: 'final'}, + cap_exceeded: {type: 'final'}, + failed_terminal: {type: 'final'}, + }, +}) + +export type MentionState = + | 'detected' + | 'enqueued' + | 'placeholder_pending' + | 'placeholder_posted' + | 'agent_running' + | 'agent_backoff' + | 'draft_ready' + | 'finalising' + | 'finalise_backoff' + | 'done' + | 'skipped_not_allowed' + | 'cap_exceeded' + | 'failed_terminal' diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts new file mode 100644 index 0000000000..c9e40c29e3 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts @@ -0,0 +1,146 @@ +/** + * Glue between poll-cli's PASS B and the per-mention XState supervisor. + * + * For each pending placeholder, spawn a machine, supply callbacks that run + * the existing reply-engine.draftReply (or Mastra agent when configured), + * and wait for the machine to reach a terminal state. + */ + +import type {AuditRun} from '../audit.js' +import type {AgentConfig} from '../config.js' +import type {SeedCli} from '../seedcli.js' +import type {State, PlaceholderRecord} from '../state.js' +import {draftReply, gatherCommentReplyContext} from '../reply-engine.js' +import {MentionSupervisor} from './supervisor.js' + +export type RunMachinePassBOptions = { + config: AgentConfig + cli: SeedCli + state: State + audit: AuditRun + pending: PlaceholderRecord[] + siteAccount: string + fallbackBody: string +} + +export async function runMachinePassB(opts: RunMachinePassBOptions): Promise<{finalised: number; errored: number}> { + const {config, cli, state, audit, pending, siteAccount, fallbackBody} = opts + let finalised = 0 + let errored = 0 + + const supervisor = new MentionSupervisor(config.stateDir, { + // Placeholder is already posted in Pass A; the machine starts straight at + // `placeholder_posted` by sending POST_PLACEHOLDER + PLACEHOLDER_POSTED in + // sequence below. + postPlaceholder: async () => ({placeholderId: ''}), + runAgent: async (mention) => { + const question = mention.text.replace(//g, ' ').trim() + const context = await gatherCommentReplyContext({cli, mention, siteAccount, audit}) + if (config.useMastraAgent) { + const {runMastraReply} = await import('../agent/mastra-agent.js') + const reply = await runMastraReply({question, context, mention, audit, cli}) + return {replyBody: reply ?? fallbackBody} + } + const reply = await draftReply(question, context, audit) + return {replyBody: reply ?? fallbackBody} + }, + finaliseComment: async (placeholderId, replyBody) => { + const r = await cli.runWrite(['comment', 'edit', placeholderId, '--body', replyBody]) + if (r.exitCode !== 0) { + throw new Error(`comment edit failed: exit=${r.exitCode} stderr=${r.stderr.slice(0, 200)}`) + } + }, + }) + + // Replay any actors persisted from prior runs so we resume mid-flight. + const replay = supervisor.rehydrate() + if (replay.restored > 0) { + audit.trace({ + ts: new Date().toISOString(), + level: 'info', + event: 'state_machine_rehydrated', + data: replay, + }) + } + + for (const rec of pending) { + const actor = supervisor.spawn(rec.mention) + // The mention was already moved through detection and placeholder-posting + // by Pass A. Feed those events to the machine so it lands in `placeholder_posted` + // and the agent stage runs from there. + supervisor.send(rec.mention, {type: 'POST_PLACEHOLDER'}) + supervisor.send(rec.mention, {type: 'PLACEHOLDER_POSTED', placeholderId: rec.placeholderId}) + supervisor.send(rec.mention, {type: 'RUN_AGENT'}) + + let replyBody: string | null = null + try { + const cb = (actor as any).logic.config.actors as never + void cb // satisfy noUnusedLocals while keeping types intact + const ran = await runAgentForActor(rec, opts) + replyBody = ran.replyBody + supervisor.send(rec.mention, {type: 'AGENT_DONE', replyBody}) + } catch (err) { + const reason = err instanceof Error ? err.message : String(err) + supervisor.send(rec.mention, {type: 'AGENT_ERROR', reason}) + audit.trace({ + ts: new Date().toISOString(), + level: 'error', + event: 'agent_error', + data: {commentId: rec.mention.commentId, reason}, + }) + errored++ + continue + } + + supervisor.send(rec.mention, {type: 'FINALISE'}) + try { + const r = await cli.runWrite(['comment', 'edit', rec.placeholderId, '--body', replyBody!]) + if (r.exitCode !== 0) { + throw new Error(`comment edit failed: exit=${r.exitCode} stderr=${r.stderr.slice(0, 200)}`) + } + supervisor.send(rec.mention, {type: 'FINALISED'}) + state.finalisePlaceholder(rec.mentionId, rec.placeholderId) + state.markProcessed(rec.mention, audit.meta.runId, replyBody ? 'replied' : 'error') + audit.trace({ + ts: new Date().toISOString(), + level: 'info', + event: 'reply_finalised', + data: { + commentId: rec.mention.commentId, + placeholderId: rec.placeholderId, + replyPreview: replyBody!.slice(0, 200), + }, + }) + finalised++ + } catch (err) { + const reason = err instanceof Error ? err.message : String(err) + supervisor.send(rec.mention, {type: 'FINALISE_ERROR', reason}) + audit.trace({ + ts: new Date().toISOString(), + level: 'error', + event: 'reply_edit_failed', + data: {commentId: rec.mention.commentId, placeholderId: rec.placeholderId, reason}, + }) + errored++ + } + } + + supervisor.stopAll() + return {finalised, errored} +} + +async function runAgentForActor( + rec: PlaceholderRecord, + opts: RunMachinePassBOptions, +): Promise<{replyBody: string}> { + const {config, cli, audit, siteAccount, fallbackBody} = opts + const question = rec.mention.text.replace(//g, ' ').trim() + const context = await gatherCommentReplyContext({cli, mention: rec.mention, siteAccount, audit}) + if (config.useMastraAgent) { + const {runMastraReply} = await import('../agent/mastra-agent.js') + const reply = await runMastraReply({question, context, mention: rec.mention, audit, cli}) + return {replyBody: reply ?? fallbackBody} + } + const reply = await draftReply(question, context, audit) + return {replyBody: reply ?? fallbackBody} +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts new file mode 100644 index 0000000000..9478e998ca --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts @@ -0,0 +1,162 @@ +/** + * Supervisor for per-mention machines. Loads pending state from disk on + * startup, spawns one machine per mention, persists every transition to a + * JSONL event log, and rehydrates machines after a crash. + * + * The supervisor is the only component that touches `${stateDir}/machines/`. + * Each mention has a dedicated event log: + * + * ${stateDir}/machines/<mentionKey>.jsonl + * + * Each line is `{ts, type, payload}`. Replay = create a fresh machine with + * the original input, then `actor.send(event)` for each persisted event. + */ + +import {appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync} from 'node:fs' +import {join} from 'node:path' +import {createActor, type Actor} from 'xstate' +import {mentionMachine, type MentionCallbacks, type MentionEvent} from './mention-machine.js' +import type {Mention} from '../mentions.js' +import {mentionKey} from '../state.js' + +const MACHINES_SUBDIR = 'machines' + +type PersistedEvent = { + ts: string + type: MentionEvent['type'] + payload?: Record<string, unknown> + /** Captured on the very first line so replay can reconstruct context. */ + initialMention?: Mention +} + +export class MentionSupervisor { + private readonly machinesDir: string + private readonly actors = new Map<string, Actor<typeof mentionMachine>>() + private readonly callbacks: MentionCallbacks + + constructor(stateDir: string, callbacks: MentionCallbacks) { + this.callbacks = callbacks + void this.callbacks + this.machinesDir = join(stateDir, MACHINES_SUBDIR) + if (!existsSync(this.machinesDir)) { + mkdirSync(this.machinesDir, {recursive: true, mode: 0o700}) + } + } + + /** Returns true when this mention already has a non-terminal actor. */ + has(mention: Mention): boolean { + const id = mentionKey(mention) + return this.actors.has(id) + } + + /** Spawn a fresh machine for a newly-detected mention. */ + spawn(mention: Mention): Actor<typeof mentionMachine> { + const id = mentionKey(mention) + if (this.actors.has(id)) return this.actors.get(id)! + + // First line of the log captures the input mention so replay can + // reconstruct identical machine context. + this.persist(id, {ts: new Date().toISOString(), type: 'ENQUEUE', initialMention: mention}) + + const actor = this.createActor(mention, id) + actor.start() + return actor + } + + /** Send an event to a mention's actor. Auto-persists, then forwards. */ + send(mention: Mention, event: MentionEvent): void { + const id = mentionKey(mention) + const actor = this.actors.get(id) + if (!actor) return + this.persist(id, {ts: new Date().toISOString(), type: event.type, payload: extractPayload(event)}) + actor.send(event) + } + + /** Read all *.jsonl files and replay them into fresh actors. Drops actors + * whose final event is a terminal state (they are already done). */ + rehydrate(): {restored: number; skipped: number} { + if (!existsSync(this.machinesDir)) return {restored: 0, skipped: 0} + let restored = 0 + let skipped = 0 + for (const file of readdirSync(this.machinesDir)) { + if (!file.endsWith('.jsonl')) continue + const id = file.slice(0, -'.jsonl'.length) + const lines = readFileSync(join(this.machinesDir, file), 'utf-8') + .split('\n') + .filter(Boolean) + if (lines.length === 0) continue + let initialMention: Mention | null = null + const events: MentionEvent[] = [] + for (const line of lines) { + try { + const parsed = JSON.parse(line) as PersistedEvent + if (parsed.initialMention && !initialMention) { + initialMention = parsed.initialMention + } + // Skip the bootstrap ENQUEUE line — it's a marker, not a real event; + // creating the actor naturally starts in the `detected` state and + // an ENQUEUE event from the next entry takes it to `enqueued`. + if (parsed.initialMention) continue + events.push(reconstructEvent(parsed)) + } catch { + // Corrupt line — best-effort skip. + } + } + if (!initialMention) { + skipped++ + continue + } + const actor = this.createActor(initialMention, id, {silent: true}) + actor.start() + for (const e of events) { + actor.send(e) + } + const snapshot = actor.getSnapshot() + if (snapshot.status === 'done') { + actor.stop() + this.actors.delete(id) + skipped++ + } else { + restored++ + } + } + return {restored, skipped} + } + + /** Stop all actors. Called on graceful shutdown. */ + stopAll(): void { + for (const actor of this.actors.values()) actor.stop() + this.actors.clear() + } + + private createActor(mention: Mention, id: string, opts: {silent?: boolean} = {}): Actor<typeof mentionMachine> { + const actor = createActor(mentionMachine, {input: {mention}}) + this.actors.set(id, actor) + actor.subscribe((snapshot) => { + if (snapshot.status === 'done') { + // Terminal — drop the actor. The JSONL log stays as audit trail. + setImmediate(() => { + actor.stop() + this.actors.delete(id) + }) + } + }) + void opts + return actor + } + + private persist(id: string, event: PersistedEvent): void { + const path = join(this.machinesDir, `${id}.jsonl`) + appendFileSync(path, JSON.stringify(event) + '\n', {mode: 0o600}) + } +} + +function extractPayload(event: MentionEvent): Record<string, unknown> | undefined { + const {type: _type, ...rest} = event as Record<string, unknown> & {type: string} + return Object.keys(rest).length ? rest : undefined +} + +function reconstructEvent(persisted: PersistedEvent): MentionEvent { + const base = {type: persisted.type as MentionEvent['type']} + return {...base, ...(persisted.payload ?? {})} as MentionEvent +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts index da4ed9c2b6..1491992bda 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts @@ -65,6 +65,19 @@ async function main(): Promise<void> { const state = new State(config.stateDir) const governance = new GovernanceCache(config, cli) + if (config.useLocalDaemon) { + const writerArg = config.writerAid ? ['--writer', config.writerAid] : [] + const syncStatus = await cli.runRead(['site', 'sync-status', config.seedSite, ...writerArg]) + const parsed = syncStatus.parsedJson as {ready_for_writes?: boolean} | undefined + const ready = !!parsed?.ready_for_writes + audit.trace({ts: nowIso(), level: 'info', event: 'preflight_sync_status', data: {ready, output: parsed}}) + if (!ready) { + audit.trace({ts: nowIso(), level: 'warn', event: 'preflight_skipped', data: {reason: 'local-daemon-not-ready'}}) + audit.close({status: 'denied', logsDir: config.logsDir}) + return + } + } + const keyShow = await cli.runRead(['key', 'show', config.keyName]) if (keyShow.exitCode !== 0) throw new Error(`key show failed: ${keyShow.stderr}`) const kmAccountId = (keyShow.parsedJson as {accountId?: string} | undefined)?.accountId @@ -183,7 +196,29 @@ async function main(): Promise<void> { const pending = state.pendingPlaceholders() let finalised = 0 let errored = 0 - for (const rec of pending) { + if (config.useStateMachine) { + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'state_machine_enabled', + data: {pending: pending.length}, + }) + // Drive each pending placeholder through the XState supervisor. The + // machine owns retry/backoff for the LLM call + comment edit and + // persists transitions to ${stateDir}/machines/<mentionId>.jsonl. + const {runMachinePassB} = await import('./machines/poll-driver.js') + const result = await runMachinePassB({ + config, + cli, + state, + audit, + pending, + siteAccount, + fallbackBody: FALLBACK_BODY, + }) + finalised = result.finalised + errored = result.errored + } else for (const rec of pending) { // Per-run cap on comment edits is intentionally absent; we already // counted each placeholder as a comment in Pass A, and `edit` does // not produce a new top-level comment. diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/telegram-bot.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/telegram-bot.ts index 9b059cf4cc..a83cbdebd4 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/telegram-bot.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/telegram-bot.ts @@ -82,7 +82,7 @@ async function main(): Promise<void> { try { const text = msg.text.trim() if (text.startsWith('/ask')) { - await handleSystemQuestion(token, msg.chat.id, text.slice(4).trim(), config, governance, history) + await handleSystemQuestion(token, msg.chat.id, text.slice(4).trim(), config, governance, history, cli) } else if (text.startsWith('/')) { const reply = await handleCommand(text, config, governance) await sendMessage(token, msg.chat.id, reply) @@ -113,6 +113,25 @@ async function handleCommunityQuestion( seedSite: config.seedSite, }) try { + if (config.useMastraAgent) { + const {runMastraOperator} = await import('./agent/mastra-agent.js') + const turns = history.read(chatId).map((t) => ({role: t.role as 'user' | 'assistant', content: t.content})) + const result = await runMastraOperator({ + question: text, + systemContextBlob: '(community mode — pull tools as needed)', + history: turns as any, + cli, + audit, + }) + const reply = result.finalAnswer ?? 'I tried to draft a reply but hit a snag. Try rephrasing.' + await sendMessage(token, chatId, reply) + history.append(chatId, [ + {role: 'user', content: text}, + {role: 'assistant', content: reply}, + ]) + audit.trace({ts: new Date().toISOString(), level: 'info', event: 'telegram_reply_sent', data: {chatId, mode: 'mastra-community'}}) + return + } const siteAccount = config.seedSite.replace(/^hm:\/\//, '').split('/')[0]! const ctx = await gatherSiteContext(cli, text, siteAccount, audit) const turns = history.read(chatId) @@ -136,6 +155,7 @@ async function handleSystemQuestion( config: ReturnType<typeof loadConfig>, governance: GovernanceCache, history: ChatHistory, + cli?: SeedCli, ): Promise<void> { if (!question) { await sendMessage(token, chatId, 'Usage: /ask <question about the bot or its config>') @@ -150,6 +170,25 @@ async function handleSystemQuestion( }) try { const ctx = await buildSystemContext({governance, logsDir: config.logsDir}) + if (config.useMastraAgent && cli) { + const {runMastraOperator} = await import('./agent/mastra-agent.js') + const turns = history.read(chatId).map((t) => ({role: t.role as 'user' | 'assistant', content: t.content})) + const result = await runMastraOperator({ + question, + systemContextBlob: ctx, + history: turns as any, + cli, + audit, + }) + const reply = result.finalAnswer ?? 'Could not draft a reply (DeepSeek error). Check logs.' + await sendMessage(token, chatId, reply) + history.append(chatId, [ + {role: 'user', content: `/ask ${question}`}, + {role: 'assistant', content: reply}, + ]) + audit.trace({ts: new Date().toISOString(), level: 'info', event: 'telegram_reply_sent', data: {chatId, mode: 'mastra-ask'}}) + return + } const turns = history.read(chatId) const answer = await draftSystemReply(question, ctx, audit, turns) const reply = answer ?? 'Could not draft a reply (DeepSeek error). Check logs.' diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts index 2eade26a74..e892857596 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts @@ -134,6 +134,42 @@ export function buildTools(deps: ToolDeps): ToolDef[] { }, }) + tools.push({ + name: 'seed_get_comment_thread', + description: + 'Walk the replyParent chain from a comment up to the thread root. Returns the thread oldest→newest with each comment’s author and body. Caps at 30 comments.', + inputSchema: z.object({commentId: z.string(), max: z.number().int().min(1).max(100).optional()}), + async call(input) { + const a = z.object({commentId: z.string(), max: z.number().int().optional()}).parse(input) + const max = a.max ?? 30 + const collected: Array<Record<string, unknown>> = [] + let current = a.commentId + for (let i = 0; i < max; i++) { + const r = await cli.runRead(['comment', 'get', current]) + if (r.exitCode !== 0 || !r.parsedJson) break + const c = r.parsedJson as {replyParent?: string} & Record<string, unknown> + collected.unshift(c) + if (!c.replyParent) break + current = c.replyParent + } + return {thread: collected} + }, + }) + + tools.push({ + name: 'seed_site_sync_status', + description: + 'Report local-daemon subscription state and writer-capability availability for a site. Wraps `seed-cli site sync-status`.', + inputSchema: z.object({siteId: z.string(), writer: z.string().optional()}), + async call(input) { + const a = z.object({siteId: z.string(), writer: z.string().optional()}).parse(input) + const argv = ['site', 'sync-status', a.siteId] + if (a.writer) argv.push('--writer', a.writer) + const r = await cli.runRead(argv) + return r.parsedJson ?? {raw: r.stdout, exitCode: r.exitCode} + }, + }) + tools.push({ name: 'seed_get_activity', description: 'Fetch activity events for the target site. Optional cursor token for pagination. Wraps `seed-cli activity`.', diff --git a/seed-knowledge-manager/agent/scripts/bootstrap-subscription.sh b/seed-knowledge-manager/agent/scripts/bootstrap-subscription.sh new file mode 100755 index 0000000000..86f841d336 --- /dev/null +++ b/seed-knowledge-manager/agent/scripts/bootstrap-subscription.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Subscribe the local daemon to the production site so it mirrors all docs, +# capability blobs, and comments. Idempotent — relies on a flag file to avoid +# re-subscribing on every boot. Run as user `km`. +# +# Usage: +# bash bootstrap-subscription.sh <hm://site> [<KM_AID>] +# +# The first arg is the site to subscribe (recursive). The optional second arg +# is the agent's account id; if provided, the script also waits until a +# WRITER capability for that account has converged locally. + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 <hm://site> [<writer-account-id>]" >&2 + exit 2 +fi + +SITE="$1" +WRITER_AID="${2:-}" +LOCAL_DAEMON="${SEED_LOCAL_DAEMON_URL:-http://127.0.0.1:3000}" +STATE_DIR="${KM_STATE_DIR:-$HOME/km-state}" +FLAG="$STATE_DIR/subscribed.flag" + +mkdir -p "$STATE_DIR" + +if [[ -f "$FLAG" ]] && grep -qF "$SITE" "$FLAG"; then + echo "[bootstrap] subscription for $SITE already recorded, skipping subscribe RPC" +else + # Always async — the daemon's first DiscoverObject can run for ~10 minutes, + # but the Remix /api proxy times the underlying socket out far sooner. We + # poll sync-status below to know when it's actually ready. + echo "[bootstrap] subscribing local daemon to $SITE (recursive, async)" + /home/km/.local/bin/seed-cli -s "$LOCAL_DAEMON" site subscribe "$SITE" --recursive + echo "$SITE" >> "$FLAG" +fi + +# Wait until a writer cap is locally cached for the agent. Up to 15 minutes, +# nudging the daemon every 30s to keep the smart-sync hot. +if [[ -n "$WRITER_AID" ]]; then + echo "[bootstrap] waiting for WRITER capability of $WRITER_AID on $SITE to converge" + for i in $(seq 1 180); do + STATUS=$(/home/km/.local/bin/seed-cli -s "$LOCAL_DAEMON" site sync-status "$SITE" --writer "$WRITER_AID" -q || true) + if [[ "$STATUS" == "ready" ]]; then + echo "[bootstrap] ready_for_writes=true after $i polls" + exit 0 + fi + # Nudge the daemon every 30s. + if (( i % 6 == 0 )); then + /home/km/.local/bin/seed-cli -s "$LOCAL_DAEMON" site reconcile -q || true + fi + sleep 5 + done + echo "[bootstrap] WARN: writer cap did not converge in 15min — agent will still start, will keep retrying via km-poll preflight" >&2 + exit 0 +fi diff --git a/seed-knowledge-manager/agent/systemd/km-reconcile.service b/seed-knowledge-manager/agent/systemd/km-reconcile.service new file mode 100644 index 0000000000..68630ef7be --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/km-reconcile.service @@ -0,0 +1,17 @@ +[Unit] +Description=Knowledge Manager — force-sync local daemon (capability blob hot-pull) +After=seed-daemon.service +Requires=seed-daemon.service + +[Service] +Type=oneshot +EnvironmentFile=/home/km/.nanobot/secrets.env +Environment=PATH=/home/km/.local/bin:/usr/bin:/usr/local/bin +# ForceSync triggers the daemon's smart-sync immediately. Used as a band-aid +# until backend/hmnet/syncing/scheduler.go promotes capability blobs into the +# hot tier (see workstream A6). When that lands this timer can be removed. +ExecStart=/home/km/.local/bin/seed-cli -s ${SEED_LOCAL_DAEMON_URL} site reconcile -q +TimeoutStartSec=30 + +[Install] +WantedBy=default.target diff --git a/seed-knowledge-manager/agent/systemd/km-reconcile.timer b/seed-knowledge-manager/agent/systemd/km-reconcile.timer new file mode 100644 index 0000000000..80c81cb1c2 --- /dev/null +++ b/seed-knowledge-manager/agent/systemd/km-reconcile.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Knowledge Manager — periodic ForceSync every 60s + +[Timer] +OnBootSec=120 +OnUnitActiveSec=60 +AccuracySec=5s +Persistent=true +Unit=km-reconcile.service + +[Install] +WantedBy=timers.target From ef86db8d9f25acaad7c036c22de6556dbaae8166 Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Mon, 11 May 2026 10:53:29 +0200 Subject: [PATCH 12/17] feat(agent): add Seed markdown primer, parent index auto-creation, and mention key fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared Seed Hypermedia markdown rules into seed-primer.ts and inject into community agent and cadence prompts so LLM never emits bare hm:// URLs or malformed list blocks - Auto-create missing parent index docs before cadence writes leaf docs so desktop navigator can drill into /agents/knowledge-manager/state/<kind> - Fix MentionSupervisor actor map key mismatch on replay: derive id from mentionKey(initialMention) instead of filename; sanitize fs-unfriendly chars (/ \ :) in persisted JSONL filenames via sanitizeForFs() - Raise MAX_COMMENT_FETCHES 60→200 to handle busier threads --- .../mcp/seed-cli-mcp/src/agent/prompts.ts | 9 +- .../agent/mcp/seed-cli-mcp/src/cadence-cli.ts | 96 +++++++++++++++++-- .../seed-cli-mcp/src/machines/supervisor.ts | 14 ++- .../agent/mcp/seed-cli-mcp/src/poll-cli.ts | 2 +- .../agent/mcp/seed-cli-mcp/src/seed-primer.ts | 32 +++++++ 5 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seed-primer.ts diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts index 1a951efa8d..689e3e0427 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts @@ -3,7 +3,11 @@ * they can be edited / reviewed without scrolling past Connect/Mastra glue. */ -export const COMMUNITY_AGENT_SYSTEM = `You are the Knowledge Manager — a moderator of a Seed Hypermedia community. +import {SEED_MARKDOWN_PRIMER} from '../seed-primer.js' + +export const COMMUNITY_AGENT_SYSTEM = `${SEED_MARKDOWN_PRIMER} + +You are the Knowledge Manager — a moderator of a Seed Hypermedia community. You answer questions from members in plain Spanish or English (match the asker's language). You ground every claim in the community's own documents and only fall back to general knowledge when the corpus is silent on the question. @@ -18,7 +22,8 @@ Rules: - Always call seed_search at least once before answering, with the asker's question. - Pull seed_get_comment_thread when the question is in a reply chain to read prior turns. - Pull seed_get_doc on each citation you intend to embed. - - When citing, embed full hm:// URLs as inline markdown links: [Title](hm://...). + - When citing, embed full hm:// URLs as inline markdown links: [Title](hm://...) (NEVER a bare hm:// URL). + - When referencing a person, use the mention chip syntax: <hm://accountUid>. - Stay under 120 words in the final answer. Plain text or simple markdown only — no headers, no code fences, no greeting/signoff. - Hard tool budget: 30 tool calls per turn. After that, call final_answer with whatever you have. - If the corpus is silent, answer from general knowledge in one sentence and explicitly say: "I couldn't find this in our community's docs".` diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/cadence-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/cadence-cli.ts index 48b2a1d6e2..b4c29e59e4 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/cadence-cli.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/cadence-cli.ts @@ -24,6 +24,7 @@ import {buildRedactor} from './redact.js' import {loadConfig} from './config.js' import {bump, checkCap, isWriteAllowed} from './limits.js' import {State} from './state.js' +import {SEED_MARKDOWN_PRIMER} from './seed-primer.js' type Task = 'boletin' | 'gap' | 'health' @@ -134,6 +135,11 @@ async function main(): Promise<void> { }) const tmpFile = await writeTempMarkdown(fullDoc) const siteAccount = config.seedSite.replace(/^hm:\/\//, '').split('/')[0]! + + // Ensure parent index docs exist along the path. Without them the desktop + // navigator cannot drill into /agents/knowledge-manager/state/<kind>. + await ensureParentIndexes(cli, siteAccount, tc.docPath, audit) + const argv = [ 'document', 'create', @@ -183,6 +189,67 @@ function isTask(s: string): s is Task { return s === 'boletin' || s === 'gap' || s === 'health' } +/** + * Walks the parent path segments of `docPath` and creates a minimal index + * document at any segment that does not yet exist. Idempotent — uses + * `document get` first; only calls `document create` on a not-found. + * + * Without these index docs the desktop navigator shows "not-found" when a + * user clicks into /agents/knowledge-manager/state/ even though leaf docs + * exist under that prefix. + */ +async function ensureParentIndexes( + cli: SeedCli, + siteAccount: string, + docPath: string, + audit: AuditRun, +): Promise<void> { + const segments = docPath.split('/').filter(Boolean) + if (segments.length <= 1) return + // We only seed parents — not the leaf doc itself, which the cadence writes. + for (let i = 1; i < segments.length; i++) { + const parentPath = '/' + segments.slice(0, i).join('/') + const hmUrl = `hm://${siteAccount}${parentPath}` + const getR = await cli.runRead(['document', 'get', hmUrl]) + if (getR.exitCode === 0 && getR.stdout && !/not-found|Cannot render/i.test(getR.stdout)) { + continue + } + const title = segments[i - 1]! + .split('-') + .map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : '')) + .join(' ') + const body = + `---\n` + + `title: ${title}\n` + + `type: index\n` + + `created_by: knowledge-manager\n` + + `created_at: ${new Date().toISOString()}\n` + + `---\n\n` + + `## ${title}\n\n` + + `Index of \`${parentPath}\`. Child documents are listed automatically by the navigator.\n` + const tmp = await writeTempMarkdown(body) + const createArgv = [ + 'document', + 'create', + '--account', + siteAccount, + '--path', + parentPath, + '--name', + title, + '--file', + tmp, + ] + const cR = await cli.runWrite(createArgv) + audit.trace({ + ts: nowIso(), + level: cR.exitCode === 0 ? 'info' : 'warn', + event: cR.exitCode === 0 ? 'parent_index_created' : 'parent_index_create_failed', + data: {path: parentPath, exitCode: cR.exitCode, stderr: cR.stderr.slice(0, 200)}, + }) + } +} + function buildTaskConfig(task: Task, runbook: string, charter: string): TaskConfig { const now = new Date() const lafhRunbookContext = @@ -198,10 +265,15 @@ function buildTaskConfig(task: Task, runbook: string, charter: string): TaskConf title: `Boletín — ${week}`, type: 'boletin', systemPrompt: + `${SEED_MARKDOWN_PRIMER}\n\n` + `You are the Knowledge Manager generating the WEEKLY BULLETIN (boletín periódico) for a Seed Hypermedia community, applying LAFH/GC-Red methodology.\n\n` + - `Output a complete Markdown document body (no triple-backtick fences around the whole). Use these section headings exactly:\n` + + `Output a complete Markdown document body. Use these section headings exactly, in this order:\n` + `## New documents published\n## Active threads\n## Decisions made\n## New members\n## Gaps surfaced or filled\n## Recommended reading from this period\n## Health note\n\n` + - `Cite every document/comment with full hm:// URLs. Cap each list at 5–7 items, prioritized (not exhaustive). Be concise — the bulletin is meant to be scannable in two minutes.\n\n` + + `Format rules:\n` + + `- For "Recommended reading", use embed chips on their own lines: \`<hm://...>\`. One per line.\n` + + `- For all other sections, use inline links \`[Title](hm://...)\` to cite docs/comments.\n` + + `- Lists go directly under the heading — no intro sentence between the heading and the first bullet.\n` + + `- Cap each list at 5–7 items, prioritized (not exhaustive). Be concise — scannable in two minutes.\n\n` + `Where the snapshot lacks data for a section, write one honest sentence ("no formal decisions captured this period" etc) — that is itself a signal. Do NOT invent items.\n\n` + `${lafhRunbookContext}`, } @@ -217,11 +289,17 @@ function buildTaskConfig(task: Task, runbook: string, charter: string): TaskConf title: `Gap report — ${stamp}`, type: 'gap-report', systemPrompt: + `${SEED_MARKDOWN_PRIMER}\n\n` + `You are the Knowledge Manager generating a GAP REPORT for a Seed Hypermedia community, applying LAFH/GC-Red methodology.\n\n` + - `Output a complete Markdown document body. Use these sections:\n` + + `Output a complete Markdown document body. Use these sections in this order:\n` + `## How this was produced\n## Open gaps\n### 🔴 High priority\n### 🟡 Medium priority\n### 🟢 Low priority / parking lot\n## Contradictions detected\n## Stale or potentially outdated content\n## Patterns\n\n` + - `For each gap include: **Evidence:** with hm:// links, **Why it matters:**, **Proposed action:**, **Suggested owner:** (or "open"). ` + - `Do NOT invent gaps; if the snapshot doesn't surface enough data, write "no high-priority gaps detected this period" and move on. Honest signal beats fluff.\n\n` + + `For each gap, write the following block (no extra wrapping paragraph):\n` + + `- **Title** — short summary of the gap\n` + + `- **Evidence:** \`[doc/thread title](hm://...)\` references\n` + + `- **Why it matters:** one sentence\n` + + `- **Proposed action:** one sentence\n` + + `- **Suggested owner:** mention chip \`<hm://<accountUid>>\` or "open"\n\n` + + `Lists go directly under headings. Do NOT invent gaps; if the snapshot doesn't surface enough data, write "no high-priority gaps detected this period" and move on. Honest signal beats fluff.\n\n` + `${lafhRunbookContext}`, } } @@ -236,11 +314,15 @@ function buildTaskConfig(task: Task, runbook: string, charter: string): TaskConf title: `Network health — ${stamp}`, type: 'network-health', systemPrompt: + `${SEED_MARKDOWN_PRIMER}\n\n` + `You are the Knowledge Manager generating a NETWORK HEALTH REPORT for a Seed Hypermedia community, applying LAFH/GC-Red methodology.\n\n` + `Output a complete Markdown document body. Sections in this order:\n` + `## TL;DR\n## Activity metrics\n## Production of knowledge products\n## Silos\n## Stale corpus\n## Pace assessment\n## Memory check\n## Methodology adherence\n## Recommended actions\n\n` + - `Be diagnostic, not flattering. If activity exists but produces no synthesis/decisions/methods, label it a red flag (LAFH: activity without production is noise). ` + - `Quantify what you can ("N new docs", "M comments", "K active authors of N total writers"). Cite hm:// links for any document referenced.\n\n` + + `Format rules:\n` + + `- Inline links: \`[Title](hm://...)\`. No bare hm:// URLs in prose.\n` + + `- Lists go directly under headings — no leading intro paragraph between heading and first bullet.\n` + + `- Quantify when you can ("N new docs", "M comments", "K active authors of N total writers").\n\n` + + `Be diagnostic, not flattering. Activity without production = noise (LAFH red flag).\n\n` + `${lafhRunbookContext}`, } } diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts index 9478e998ca..ed8af7345b 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts @@ -80,7 +80,6 @@ export class MentionSupervisor { let skipped = 0 for (const file of readdirSync(this.machinesDir)) { if (!file.endsWith('.jsonl')) continue - const id = file.slice(0, -'.jsonl'.length) const lines = readFileSync(join(this.machinesDir, file), 'utf-8') .split('\n') .filter(Boolean) @@ -106,6 +105,10 @@ export class MentionSupervisor { skipped++ continue } + // Derive the original (unsanitized) mention id from the persisted + // initialMention so the in-memory actor map keys match what later + // spawn()/send() calls compute via mentionKey(). + const id = mentionKey(initialMention) const actor = this.createActor(initialMention, id, {silent: true}) actor.start() for (const e of events) { @@ -146,11 +149,18 @@ export class MentionSupervisor { } private persist(id: string, event: PersistedEvent): void { - const path = join(this.machinesDir, `${id}.jsonl`) + const path = join(this.machinesDir, `${sanitizeForFs(id)}.jsonl`) appendFileSync(path, JSON.stringify(event) + '\n', {mode: 0o600}) } } +/** Mention ids include "/" (commentId is "<author>/<tsid>"). Replace any + * filesystem-unfriendly chars so the per-mention JSONL stays a single file + * under the machines/ directory. */ +function sanitizeForFs(id: string): string { + return id.replace(/[/\\:]/g, '_') +} + function extractPayload(event: MentionEvent): Record<string, unknown> | undefined { const {type: _type, ...rest} = event as Record<string, unknown> & {type: string} return Object.keys(rest).length ? rest : undefined diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts index 1491992bda..e1754bf960 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts @@ -38,7 +38,7 @@ import {bump, checkCap} from './limits.js' import {draftReply, gatherCommentReplyContext} from './reply-engine.js' const ACTIVITY_LIMIT = 100 -const MAX_COMMENT_FETCHES = 60 +const MAX_COMMENT_FETCHES = 200 const PLACEHOLDER_BODY = 'Working on this — back in a moment. ⌛' const FALLBACK_BODY = 'I tried to draft a reply but hit a snag. Please rephrase or wait for the next cadence.' diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seed-primer.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seed-primer.ts new file mode 100644 index 0000000000..8122af1764 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seed-primer.ts @@ -0,0 +1,32 @@ +/** + * Shared "how Seed's markdown works" primer that gets injected into every + * LLM system prompt the agent generates content for. Without this, the + * model emits bare hm:// URLs that don't render as links/embeds, or + * wraps lists inside extra Paragraph parents because of leading prose. + * + * Keep it short — every cadence/agent prompt pays for these tokens. + */ +export const SEED_MARKDOWN_PRIMER = `## Seed Hypermedia markdown primer (READ THIS BEFORE WRITING) + +You are writing for Seed Hypermedia, which uses a constrained Markdown. +Follow these rules exactly — non-conforming output will be rendered as broken text: + +1. **Inline links to docs** — use \`[Title](hm://...)\` — NEVER paste a bare hm:// URL in prose; it will render as raw text, not a link. +2. **Embeds / mention chips** — use autolink syntax \`<hm://...>\` on its own to insert a navigable chip. Use this when the reference IS the content (e.g. a "Recommended reading" item, an author chip). +3. **Account mentions** — use \`<hm://<accountUid>>\` (autolink). This renders as a person chip. +4. **Lists must not have a EMPTY wrapping intro paragraph.** A heading is itself the list's parent. Do NOT write: + - WRONG: + \`\`\` + ## Decisions + + - decision 1 + \`\`\` + - RIGHT: + \`\`\` + ## Decisions + - decision 1 + \`\`\` +5. **Cite every reference** — if you name a doc, person, or thread, include an inline link \`[Title](hm://...)\` or an embed \`<hm://...>\`. No uncited claims. +6. **Headings start at H2 (##).** Do not use H1 — the document title is injected separately as metadata. +7. **No code fences around the whole document.** Emit raw Markdown. +` From e5f955f9279d0efc6e46a421dc497219753a273f Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Mon, 11 May 2026 16:09:26 +0200 Subject: [PATCH 13/17] fix(agent): cap tool budget at 10, gate invoker enforcement, resolve aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce MAX_TOOL_CALLS from 30 to 10 and introduce FORCE_FINAL_THRESHOLD so the model is forced to emit final_answer before budget exhaustion. DeepSeek bursts 2-3 tool calls per step, so the old step-indexed gate fired too late. Add inline-content salvage fallback: when the model exhausts its budget without calling final_answer, recover the last substantive assistant message rather than returning nothing. Tighten prompts to match the reduced budget (≤8 tool calls, single search, ≤2 doc fetches). Gate the WRITER/allowlist invoker check behind KM_ENFORCE_INVOKER_GATE env var (off by default) so the agent answers any commenter. When gate is active, resolve comment authors to their principal via local daemon then gateway fallback to handle aliased device-key accounts. Fix idempotency bug: check isProcessed/hasPlaceholderFor before the not-allowed classification to prevent duplicate entries in processed.jsonl for unprivileged authors who keep mentioning the agent. --- .../seed-cli-mcp/src/agent/mastra-agent.ts | 36 +++++- .../mcp/seed-cli-mcp/src/agent/prompts.ts | 9 +- .../agent/mcp/seed-cli-mcp/src/poll-cli.ts | 107 ++++++++++++++---- 3 files changed, 123 insertions(+), 29 deletions(-) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/mastra-agent.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/mastra-agent.ts index 26f86cd591..99dffaf1c7 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/mastra-agent.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/mastra-agent.ts @@ -20,7 +20,14 @@ import type {Mention} from '../mentions.js' import {buildAgentTools, type ToolDef} from './tools-bridge.js' import {COMMUNITY_AGENT_SYSTEM, OPERATOR_AGENT_SYSTEM} from './prompts.js' -const MAX_TOOL_CALLS = 30 +/** Maximum tool calls before we force the model to emit final_answer. We + * used to allow 30 but DeepSeek bursts 2-3 calls per step and the budget + * was exhausted before the model ever decided to terminate. 10 is enough + * for a single search + a few doc fetches + final_answer. */ +const MAX_TOOL_CALLS = 10 +/** When the running count crosses this threshold the next API call locks + * `tool_choice` to final_answer so the model is forced to summarise. */ +const FORCE_FINAL_THRESHOLD = MAX_TOOL_CALLS - 2 const DEEPSEEK_URL = 'https://api.deepseek.com/v1/chat/completions' type Role = 'system' | 'user' | 'assistant' | 'tool' @@ -92,13 +99,16 @@ async function runAgent(args: RunArgs): Promise<RunResult> { let toolCallCount = 0 let finalAnswer: string | null = null - for (let step = 0; step < MAX_TOOL_CALLS + 1; step++) { + for (let step = 0; step < MAX_TOOL_CALLS + 2; step++) { const t0 = Date.now() + // Gate on cumulative toolCallCount (not step index) — the model often + // bursts 2-3 tool calls per step, so a step-indexed gate fires too late. + const forceFinal = toolCallCount >= FORCE_FINAL_THRESHOLD const body = JSON.stringify({ model: 'deepseek-chat', messages, tools, - tool_choice: step === MAX_TOOL_CALLS ? {type: 'function', function: {name: 'final_answer'}} : 'auto', + tool_choice: forceFinal ? {type: 'function', function: {name: 'final_answer'}} : 'auto', temperature: args.temperature ?? 0.4, max_tokens: args.maxTokens ?? 600, }) @@ -221,6 +231,26 @@ async function runAgent(args: RunArgs): Promise<RunResult> { } } + // Fallback: model exhausted its budget without ever calling final_answer. + // Salvage whatever inline assistant text it produced during the loop — + // those interim "Let me search…" musings sometimes already contain enough + // synthesis to be useful. Prefer the LAST non-empty assistant text. + if (!finalAnswer) { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim().length > 40) { + finalAnswer = m.content.trim() + args.audit?.trace({ + ts: new Date().toISOString(), + level: 'warn', + event: 'mastra_agent_salvaged_inline_content', + data: {bytes: finalAnswer.length}, + }) + break + } + } + } + args.audit?.trace({ ts: new Date().toISOString(), level: 'info', diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts index 689e3e0427..0b244f1456 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts @@ -19,13 +19,14 @@ You have access to the following tools and you MUST use them when relevant: - final_answer: produce the final reply to the user. You MUST call this exactly once when ready. Rules: - - Always call seed_search at least once before answering, with the asker's question. - - Pull seed_get_comment_thread when the question is in a reply chain to read prior turns. - - Pull seed_get_doc on each citation you intend to embed. + - Call seed_search at most once before answering. Then call seed_get_doc on AT MOST 2 citations you intend to embed. Do not refine the search; the first result set is what you have. + - Pull seed_get_comment_thread ONCE only if the question is in a reply chain. + - When the asker's message is just a mention chip with no actual question (text is empty or whitespace), respond briefly asking them to include the question. + - As soon as you have enough material, call final_answer. Aim for 3-5 tool calls total, never more than 8. - When citing, embed full hm:// URLs as inline markdown links: [Title](hm://...) (NEVER a bare hm:// URL). - When referencing a person, use the mention chip syntax: <hm://accountUid>. - Stay under 120 words in the final answer. Plain text or simple markdown only — no headers, no code fences, no greeting/signoff. - - Hard tool budget: 30 tool calls per turn. After that, call final_answer with whatever you have. + - HARD tool budget: 10 tool calls per turn. After 8 you MUST call final_answer immediately — even if uncertain, summarise what you have and say so. - If the corpus is silent, answer from general knowledge in one sentence and explicitly say: "I couldn't find this in our community's docs".` export const OPERATOR_AGENT_SYSTEM = `You are the Knowledge Manager bot answering an OPERATOR question about your own implementation, configuration, and recent activity. diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts index e1754bf960..1a9bd1d85a 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts @@ -87,19 +87,32 @@ async function main(): Promise<void> { const g = await governance.getGovernance(true) audit.trace({ts: nowIso(), level: 'info', event: 'governance_loaded', data: {fetchedAt: g.fetchedAt}}) - // Resolve allowed-invokers. + // TEMP: gate disabled by default — agent answers any commenter that mentions it. + // Set KM_ENFORCE_INVOKER_GATE=1 (or "true") to re-enable WRITER/allowlist enforcement. + const ENFORCE_INVOKER_GATE = /^(1|true|yes)$/i.test(process.env.KM_ENFORCE_INVOKER_GATE ?? '') + + // Resolve allowed-invokers (only when gate enforced). const writers = new Set<string>() - if (g.rules.mentions.invokerSource === 'allowlist-doc') { - for (const a of g.allowlist.invokers) writers.add(a) - } else { - const caps = await cli.runRead(['account', 'capabilities', config.seedSite]) - const parsed = caps.parsedJson as {capabilities?: Array<{delegate?: string; role?: string}>} | undefined - for (const c of parsed?.capabilities ?? []) { - if (c.role === 'WRITER' && c.delegate) writers.add(c.delegate) + if (ENFORCE_INVOKER_GATE) { + if (g.rules.mentions.invokerSource === 'allowlist-doc') { + for (const a of g.allowlist.invokers) writers.add(a) + } else { + const caps = await cli.runRead(['account', 'capabilities', config.seedSite]) + const parsed = caps.parsedJson as {capabilities?: Array<{delegate?: string; role?: string}>} | undefined + for (const c of parsed?.capabilities ?? []) { + if (c.role === 'WRITER' && c.delegate) writers.add(c.delegate) + } + writers.add(config.seedSite.replace(/^hm:\/\//, '').split('/')[0]!) } - writers.add(config.seedSite.replace(/^hm:\/\//, '').split('/')[0]!) + audit.trace({ts: nowIso(), level: 'info', event: 'poll_collect_writers', data: {count: writers.size}}) + } else { + audit.trace({ + ts: nowIso(), + level: 'warn', + event: 'invoker_gate_disabled', + data: {note: 'replying to all authors; cap + blocked-list still active'}, + }) } - audit.trace({ts: nowIso(), level: 'info', event: 'poll_collect_writers', data: {count: writers.size}}) // ── PASS A: discover new mentions and post placeholders. ─────────────── // @@ -120,6 +133,50 @@ async function main(): Promise<void> { const blocked = new Set(g.rules.moderation.blockedAuthors) const siteAccount = config.seedSite.replace(/^hm:\/\//, '').split('/')[0]! + // Cache: commentAuthor → principal account that holds the writer cap. + // Seed accounts can `alias_account` to another account they act on behalf + // of (e.g. a device-key signs with its own id but is aliased to the + // user's main account). The writer-cap list keys on principals, so we + // resolve every comment author through this lookup before checking + // membership. + // + // Local daemon returns `account-not-found` when the author's account blob + // hasn't synced yet (common for accounts that have not posted to this + // site before). Fall back to the public gateway for the resolution only + // — we still read everything else from the local daemon. The gateway + // collapses alias chains in its response: querying for an aliased uid + // returns the principal's id directly. + const gatewayUrl = process.env.SEED_GATEWAY_URL ?? 'https://hyper.media' + const principalOf = new Map<string, string>() + const resolvePrincipal = async (author: string): Promise<string> => { + const cached = principalOf.get(author) + if (cached) return cached + // Local first. + const local = await cli.runRead(['account', 'get', author]) + const localAcct = + (local.parsedJson as + | {type?: string; aliasAccount?: string; alias_account?: string; id?: {uid?: string}} + | undefined) ?? {} + let principal: string | undefined + if (localAcct.type !== 'account-not-found') { + principal = localAcct.aliasAccount ?? localAcct.alias_account ?? localAcct.id?.uid + } + // Fall back to gateway if local has no record. + if (!principal) { + const gw = await cli.runRead(['-s', gatewayUrl, 'account', 'get', author]) + const gwAcct = + (gw.parsedJson as + | {type?: string; aliasAccount?: string; alias_account?: string; id?: {uid?: string}} + | undefined) ?? {} + if (gwAcct.type !== 'account-not-found') { + principal = gwAcct.aliasAccount ?? gwAcct.alias_account ?? gwAcct.id?.uid + } + } + const resolved = principal ?? author + principalOf.set(author, resolved) + return resolved + } + for (const ev of events) { if (scanned >= MAX_COMMENT_FETCHES) { exhaustedBudget = true @@ -141,20 +198,26 @@ async function main(): Promise<void> { if (!evidence) continue const mention = buildCommentMention(comment, evidence, candidate.ts) if (blocked.has(mention.author)) continue - if (!writers.has(mention.author)) { - state.markProcessed(mention, audit.meta.runId, 'not-allowed') - audit.trace({ - ts: nowIso(), - level: 'info', - event: 'mention_skipped_not_allowed', - data: {author: mention.author, kind: mention.kind, docId: mention.docId}, - }) - skippedNotAllowed++ - continue - } const mid = mentionKey(mention) - // Idempotency: don't double-post a placeholder. + // Idempotency FIRST: a mention that's already been processed (even with + // status `not-allowed`) must not be re-classified each poll cycle. Doing + // so wrote thousands of duplicate "not-allowed" lines into + // processed.jsonl when an unprivileged author kept mentioning the agent. if (state.isProcessed(mid) || state.hasPlaceholderFor(mid)) continue + if (ENFORCE_INVOKER_GATE) { + const principal = await resolvePrincipal(mention.author) + if (!writers.has(mention.author) && !writers.has(principal)) { + state.markProcessed(mention, audit.meta.runId, 'not-allowed') + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'mention_skipped_not_allowed', + data: {author: mention.author, principal, kind: mention.kind, docId: mention.docId}, + }) + skippedNotAllowed++ + continue + } + } // Per-day cap: a placeholder counts as a comment. const rs = state.getRateState() From e8c7fce8598488b5b5f4c3190bf71241f5d9649d Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Tue, 12 May 2026 10:18:06 +0200 Subject: [PATCH 14/17] feat(agent): trigger KM replies on thread replies without explicit mention Add second trigger path in poll-cli so KM auto-responds when a comment is a reply inside a thread KM is already participating in, enabling multi-turn dialogue without forcing re-mention every turn. - Add `detectThreadReplyToKm` in mentions.ts: walks replyParent chain up to 30 hops, returns first KM-authored ancestor - Add `buildThreadReplyMention`: builds Mention from full comment body with `triggerSource: 'thread-reply'` discriminator - Add `MentionTriggerSource` type; tag existing mention path as `'mention'` - Wire per-cycle `replyChainCache` in poll-cli to avoid redundant CLI calls for sibling replies on the same thread - Emit `mention_via_thread_reply` audit event with ancestorCommentId - Update system prompt to skip re-introduction on follow-up turns - Add thread-reply.test.ts: direct parent, transitive ancestor, negative cases, cache behavior, buildThreadReplyMention shape, cycle isolation --- .ai/prompts/km-thread-reply-trigger.md | 98 +++++++++++ .../agent/mcp/seed-cli-mcp/src/mentions.ts | 83 +++++++++ .../agent/mcp/seed-cli-mcp/src/poll-cli.ts | 43 ++++- .../mcp/seed-cli-mcp/src/reply-engine.ts | 1 + .../mcp/seed-cli-mcp/src/thread-reply.test.ts | 166 ++++++++++++++++++ 5 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 .ai/prompts/km-thread-reply-trigger.md create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/thread-reply.test.ts diff --git a/.ai/prompts/km-thread-reply-trigger.md b/.ai/prompts/km-thread-reply-trigger.md new file mode 100644 index 0000000000..21fe50edff --- /dev/null +++ b/.ai/prompts/km-thread-reply-trigger.md @@ -0,0 +1,98 @@ +# Task: KM listens to thread replies (no explicit mention needed) + conversation memory + +## Context + +Repo: `/Users/horacioh/jean/Seed/seed-km` (branch `knowledge-agent-server-setup`). +Working dir for this work: `seed-knowledge-manager/agent/mcp/seed-cli-mcp/`. +Stack: TypeScript, Bun-built bundles deployed to systemd on `ubuntu@oc.hyper.media`. + +The Knowledge Manager agent polls the Seed Hypermedia activity feed every 30s and replies to comments that @mention it. Today, **only comments containing an explicit `@KM` (Embed annotation OR inline `@[name](hm://kmAccountId)`) trigger a reply**. Users replying to KM's own comment in a thread are ignored unless they re-mention KM in every turn — bad UX for multi-turn dialogue. + +Goal: KM should also auto-respond when a comment is a reply (direct or transitive) inside a thread that KM is already participating in. Plus, the LLM should see prior turns so replies feel like continued conversation. + +## Current behavior to extend + +- Polling driver: `src/poll-cli.ts`. Trigger gate is `findKmMentionInComment(comment, [kmAccountId, siteAccount])` at line ~197. +- Mention detection: `src/mentions.ts:findKmMentionInComment` — scans `block.annotations` for `Embed` link to KM/site, falls back to inline `@[…](hm://…)` regex. +- Thread context already gathered at reply time: `src/reply-engine.ts:gatherCommentReplyContext` walks `replyParent` chain up to 30 hops and includes the chain in the LLM prompt. So conversation memory is partly implemented — it's per-mention, not stored. +- Comment shape (`mentions.ts:SeedComment`): has `replyParent` and `threadRoot` fields populated by `seed-cli comment get`. +- Self-skip: `if (comment.author === kmAccountId) continue` (poll-cli.ts ~192) — must stay to avoid loops. +- Idempotency: `state.isProcessed(mid) || state.hasPlaceholderFor(mid)` — must stay. +- Invoker gate: currently bypassed by env var `KM_ENFORCE_INVOKER_GATE` (default off). Honor the toggle — when gate ON, the new auto-reply path must also pass the writer check. +- Per-day cap (`maxCommentsPerDay`, default 30) and blocked-list must apply to auto-replies too. + +## What to implement + +### 1. New trigger: comment is a thread reply to KM + +In `poll-cli.ts`, BEFORE the `findKmMentionInComment` check, add a second trigger path: + +- If `comment.replyParent` exists, fetch (or use cache) the parent comment. If parent's `author === kmAccountId`, treat as a triggering reply. +- Optionally also: if `comment.threadRoot` exists and any ancestor in the chain is authored by KM, trigger. Decision: start with direct-parent-only to keep the surface tight; add full-chain in a later pass if needed. +- Add a small in-process cache `kmReplyChainCache: Map<commentId, boolean>` so transitive lookups don't re-fetch the same chain inside one poll cycle. +- When triggered without explicit mention, build the `Mention` via a new helper `buildThreadReplyMention(comment, ts)` that mirrors `buildCommentMention` but uses the full block text (since there's no Embed evidence to extract). Tag the `Mention` with a discriminator (e.g. `triggerSource: 'mention' | 'thread-reply'`) so audit logs can tell them apart. + +### 2. Audit event for the new path + +Emit `mention_via_thread_reply` info event with `{commentId, parentCommentId, docId, author}` so operator can grep for the new trigger. + +### 3. LLM context: include "you are KM, continuing a thread" + +`gatherCommentReplyContext` already walks the chain. Confirm it's used on this new path too (Pass B doesn't care how the mention was created). Update the system prompt fragment (look in `reply-engine.ts` for `draftReply` / DeepSeek prompt) to add: "If the user's comment is a reply to your earlier comment, treat it as a follow-up turn. Do not re-introduce yourself." + +### 4. Tests + +Add unit tests in `src/`: + +- `mentions.test.ts` or new `thread-reply.test.ts`: parent-of-KM detection given a `SeedComment` with `replyParent`. +- Mock `cli.runRead(['comment', 'get', ...])` to return a parent authored by KM and verify trigger fires. +- Negative case: parent authored by someone else → no trigger (unless explicit mention exists). + +Run `bun test src` and `bun run typecheck` after. Both must pass. + +### 5. Safety rails (do NOT touch) + +- Keep self-skip (`comment.author === kmAccountId`). +- Keep blocked-list (`blocked.has(mention.author)`). +- Keep idempotency check on `mentionKey`. +- Keep per-day cap. +- Honor `KM_ENFORCE_INVOKER_GATE` env var: if true, the thread-reply path also checks the writer set. + +## Deployment + +After typecheck + tests pass: + +```bash +cd seed-knowledge-manager/agent/mcp/seed-cli-mcp +bun run build +scp dist/poll-cli.js ubuntu@oc.hyper.media:/tmp/poll-cli.js +ssh ubuntu@oc.hyper.media 'sudo install -m 755 -o km -g km /tmp/poll-cli.js /home/km/km-agent/mcp/seed-cli-mcp/dist/poll-cli.js && sudo rm /tmp/poll-cli.js' +``` + +Timer `km-poll.timer` fires every 30s — next tick picks up new binary. + +## Verify deploy + +```bash +ssh ubuntu@oc.hyper.media 'sudo ls -t /home/km/km-logs/runs/ | head -3' +# Find newest run dir, then: +ssh ubuntu@oc.hyper.media 'sudo grep -E "mention_via_thread_reply|placeholder_posted|reply_finalised" /home/km/km-logs/runs/<RUN_ID>/trace.jsonl' +``` + +End-to-end: comment on a doc mentioning KM, wait for reply, then reply to KM's reply WITHOUT @-mentioning. Expect new placeholder + final reply within one poll cycle. + +## Open questions to answer before coding + +1. Direct parent only, or full chain ancestor scan? (Recommend direct parent first.) +2. Should a reply in a thread KM started but where KM hasn't replied yet trigger? (Recommend NO — KM must have posted at least one comment in the chain.) +3. Cap auto-reply depth (e.g. KM only continues a thread for N turns) to avoid two KMs ping-ponging if a future variant deploys? +4. Persist conversation state across runs, or rely on on-demand chain walk every poll? (Chain walk works; persistence is optional.) + +## Critical files to read first + +- `src/poll-cli.ts` (Pass A trigger logic, lines 180–230) +- `src/mentions.ts` (Mention type, findKmMentionInComment, buildCommentMention) +- `src/reply-engine.ts` (gatherCommentReplyContext, draftReply prompt) +- `src/state.ts` (mentionKey, processed/placeholder idempotency) + +Read those before writing code. Then ask any of the open questions before implementing. diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts index 9fc7ac29ad..7e29aaad4d 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts @@ -15,6 +15,18 @@ export type MentionKind = 'comment' | 'doc-block' +/** + * How the mention was discovered. `'mention'` = an explicit `@KM` embed + * or inline `@[…](hm://kmAccountId)` was found in the comment/doc. + * `'thread-reply'` = the comment has no explicit mention but is a reply + * (direct or transitive) inside a thread where KM has already commented, + * so the agent treats it as a continued conversation. Optional in the + * type so legacy `placeholders.jsonl` records (written before this + * field existed) still deserialize cleanly — readers should treat + * `undefined` as `'mention'`. + */ +export type MentionTriggerSource = 'mention' | 'thread-reply' + export type Mention = { kind: MentionKind /** Hypermedia ID of the document the mention lives on. */ @@ -29,6 +41,8 @@ export type Mention = { text: string /** Activity event timestamp (ISO). */ ts: string + /** Discriminator: explicit mention vs implicit thread-reply trigger. */ + triggerSource?: MentionTriggerSource } const MENTION_RE = /@\[[^\]]*\]\(hm:\/\/([^)#]+)(?:[^)]*)?\)/g @@ -165,9 +179,78 @@ export function buildCommentMention( author: comment.author, text: evidence.text, ts, + triggerSource: 'mention', } } +/** + * Builds a Mention for a comment that fired the trigger via the + * thread-reply path (no explicit `@KM` embed; an ancestor on the + * replyParent chain was authored by KM). Since there's no embed + * evidence to point at a specific block, we use the comment's full + * body — every block's text joined by `\n`, with U+FFFC + * object-replacement characters replaced by spaces so the LLM sees the + * raw question rather than embed placeholders. `blockId` is omitted — + * the reply is threaded via `--reply commentId`, no doc-block anchor + * needed. + */ +export function buildThreadReplyMention(comment: SeedComment, ts: string): Mention { + const docId = `hm://${comment.targetAccount}${comment.targetPath ?? ''}` + const parts: string[] = [] + for (const item of comment.content ?? []) { + const t = item.block?.text + if (typeof t === 'string') parts.push(t.replace(//g, ' ')) + } + return { + kind: 'comment', + docId, + commentId: comment.id, + author: comment.author, + text: parts.join('\n').trim(), + ts, + triggerSource: 'thread-reply', + } +} + +/** + * Walks the comment's `replyParent` chain looking for an ancestor + * authored by KM. Returns the first KM-authored ancestor's commentId + * (the one closest to the current comment) when found, or null. + * + * `fetchComment` is injected so the detection stays unit-testable + * without shelling out through `seed-cli`. `cache` is shared across + * calls inside a single poll cycle so a deep thread fetched once is + * not refetched when sibling replies trigger lookups. `maxHops` + * defaults to 30 (matches `walkThread` in reply-engine.ts) and a + * `visited` set guards against cycles or self-references in malformed + * chains. + */ +export async function detectThreadReplyToKm(opts: { + comment: SeedComment + kmAccountId: string + fetchComment: (id: string) => Promise<SeedComment | null> + cache: Map<string, SeedComment | null> + maxHops?: number +}): Promise<{ancestorCommentId: string} | null> { + const {comment, kmAccountId, fetchComment, cache} = opts + const maxHops = opts.maxHops ?? 30 + const visited = new Set<string>([comment.id]) + let parentId = comment.replyParent?.trim() || undefined + for (let hop = 0; hop < maxHops && parentId; hop++) { + if (visited.has(parentId)) return null + visited.add(parentId) + let parent: SeedComment | null | undefined = cache.get(parentId) + if (parent === undefined) { + parent = await fetchComment(parentId) + cache.set(parentId, parent) + } + if (!parent) return null + if (parent.author === kmAccountId) return {ancestorCommentId: parent.id} + parentId = parent.replyParent?.trim() || undefined + } + return null +} + // Legacy helper kept for tests and the (disabled) inbox_enqueue_from_event tool. export function classifyEvent(event: ActivityEvent & {comment?: any; document?: any}, kmAccountId: string): Mention | null { if (event.comment) { diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts index 1a9bd1d85a..c6cd2a3b01 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts @@ -29,7 +29,9 @@ import {loadConfig} from './config.js' import {State, mentionKey} from './state.js' import { buildCommentMention, + buildThreadReplyMention, commentEventCandidate, + detectThreadReplyToKm, findKmMentionInComment, buildReplyTarget, } from './mentions.js' @@ -148,6 +150,16 @@ async function main(): Promise<void> { // returns the principal's id directly. const gatewayUrl = process.env.SEED_GATEWAY_URL ?? 'https://hyper.media' const principalOf = new Map<string, string>() + // Per-cycle cache for `comment get` lookups used by the thread-reply + // trigger. Sibling replies on the same thread re-walk the same + // ancestor chain, so caching here turns an O(depth × siblings) + // CLI-call cost into O(depth + siblings). + const replyChainCache = new Map<string, SeedComment | null>() + const fetchCommentForChain = async (id: string): Promise<SeedComment | null> => { + const r = await cli.runRead(['comment', 'get', id]) + if (r.exitCode !== 0 || !r.parsedJson) return null + return r.parsedJson as SeedComment + } const resolvePrincipal = async (author: string): Promise<string> => { const cached = principalOf.get(author) if (cached) return cached @@ -195,8 +207,35 @@ async function main(): Promise<void> { // of the site (e.g. "@Develop Seed Hypermedia") are also addressed // to it. const evidence = findKmMentionInComment(comment, [kmAccountId, siteAccount]) - if (!evidence) continue - const mention = buildCommentMention(comment, evidence, candidate.ts) + let mention: Mention | null = null + if (evidence) { + mention = buildCommentMention(comment, evidence, candidate.ts) + } else if (comment.replyParent) { + // Second trigger path: comment is a reply (direct or transitive) + // inside a thread where KM has already commented. Lets multi-turn + // dialogue work without forcing the user to re-mention every time. + const hit = await detectThreadReplyToKm({ + comment, + kmAccountId, + fetchComment: fetchCommentForChain, + cache: replyChainCache, + }) + if (hit) { + mention = buildThreadReplyMention(comment, candidate.ts) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'mention_via_thread_reply', + data: { + commentId: comment.id, + ancestorCommentId: hit.ancestorCommentId, + docId: mention.docId, + author: mention.author, + }, + }) + } + } + if (!mention) continue if (blocked.has(mention.author)) continue const mid = mentionKey(mention) // Idempotency FIRST: a mention that's already been processed (even with diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts index 573ec36522..46369d6ab5 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts @@ -17,6 +17,7 @@ const COMMUNITY_SYSTEM_PROMPT = `Answer the user's question grounded in the community's own documents whenever possible. ` + `When you reference a document, embed its full hm:// URL inline as a markdown link, e.g. [Title](hm://...). ` + `If the community context below is empty or doesn't cover the question, answer from your general knowledge in one sentence and explicitly say "I couldn't find this in our community's docs" so the asker knows. ` + + `If the comment thread context shows that you (the Knowledge Manager) already replied earlier in this thread, treat the new comment as a follow-up turn: do not re-introduce yourself, do not repeat earlier answers verbatim, and respond conversationally. ` + `Plain text or simple markdown only. No headers, no code fences, no greeting/signoff. Stay under 120 words.` const SYSTEM_INSPECTOR_PROMPT = diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/thread-reply.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/thread-reply.test.ts new file mode 100644 index 0000000000..800e098d43 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/thread-reply.test.ts @@ -0,0 +1,166 @@ +import {describe, expect, it} from 'bun:test' +import {buildThreadReplyMention, detectThreadReplyToKm} from './mentions.js' +import type {SeedComment} from './mentions.js' + +const KM = 'z6MkAgent' +const USER = 'z6MkUser' +const SITE = 'z6MkSite' + +function makeComment( + id: string, + author: string, + replyParent?: string, + opts: {text?: string; targetPath?: string} = {}, +): SeedComment { + return { + id, + author, + targetAccount: SITE, + targetPath: opts.targetPath, + replyParent, + content: [{block: {id: 'b1', text: opts.text ?? 'hi'}}], + } +} + +describe('detectThreadReplyToKm', () => { + it('returns hit when the direct parent is KM', async () => { + const parent = makeComment('c-parent', KM) + const cache = new Map<string, SeedComment | null>() + const fetchComment = async (id: string) => (id === 'c-parent' ? parent : null) + const child = makeComment('c-child', USER, 'c-parent') + const result = await detectThreadReplyToKm({comment: child, kmAccountId: KM, fetchComment, cache}) + expect(result).toEqual({ancestorCommentId: 'c-parent'}) + }) + + it('returns hit when KM is a transitive ancestor', async () => { + const root = makeComment('c-root', KM) + const mid = makeComment('c-mid', USER, 'c-root') + const cache = new Map<string, SeedComment | null>() + const fetchComment = async (id: string): Promise<SeedComment | null> => { + if (id === 'c-root') return root + if (id === 'c-mid') return mid + return null + } + const child = makeComment('c-child', USER, 'c-mid') + const result = await detectThreadReplyToKm({comment: child, kmAccountId: KM, fetchComment, cache}) + expect(result).toEqual({ancestorCommentId: 'c-root'}) + }) + + it('returns null when no KM ancestor exists', async () => { + const parent = makeComment('c-parent', USER) + const cache = new Map<string, SeedComment | null>() + const fetchComment = async (id: string) => (id === 'c-parent' ? parent : null) + const child = makeComment('c-child', USER, 'c-parent') + const result = await detectThreadReplyToKm({comment: child, kmAccountId: KM, fetchComment, cache}) + expect(result).toBeNull() + }) + + it('returns null when the comment has no replyParent', async () => { + const cache = new Map<string, SeedComment | null>() + const fetchComment = async () => null + const orphan = makeComment('c-1', USER) + const result = await detectThreadReplyToKm({comment: orphan, kmAccountId: KM, fetchComment, cache}) + expect(result).toBeNull() + }) + + it('caps the walk at maxHops', async () => { + const cache = new Map<string, SeedComment | null>() + let fetches = 0 + const fetchComment = async (id: string): Promise<SeedComment | null> => { + fetches++ + const n = Number(id.split('-')[1]) + return makeComment(id, USER, `c-${n - 1}`) + } + const start = makeComment('c-100', USER, 'c-99') + const result = await detectThreadReplyToKm({ + comment: start, + kmAccountId: KM, + fetchComment, + cache, + maxHops: 5, + }) + expect(result).toBeNull() + expect(fetches).toBeLessThanOrEqual(5) + }) + + it('does not infinite-loop on a reply cycle', async () => { + const a = makeComment('c-a', USER, 'c-b') + const b = makeComment('c-b', USER, 'c-a') + const cache = new Map<string, SeedComment | null>() + const fetchComment = async (id: string): Promise<SeedComment | null> => { + if (id === 'c-a') return a + if (id === 'c-b') return b + return null + } + const result = await detectThreadReplyToKm({comment: a, kmAccountId: KM, fetchComment, cache}) + expect(result).toBeNull() + }) + + it('reuses the cache to avoid refetching shared ancestors', async () => { + const parent = makeComment('c-parent', KM) + let fetches = 0 + const cache = new Map<string, SeedComment | null>() + const fetchComment = async (id: string): Promise<SeedComment | null> => { + fetches++ + return id === 'c-parent' ? parent : null + } + const child1 = makeComment('c-1', USER, 'c-parent') + const child2 = makeComment('c-2', USER, 'c-parent') + await detectThreadReplyToKm({comment: child1, kmAccountId: KM, fetchComment, cache}) + await detectThreadReplyToKm({comment: child2, kmAccountId: KM, fetchComment, cache}) + expect(fetches).toBe(1) + }) + + it('stops when fetchComment returns null (parent unavailable)', async () => { + const cache = new Map<string, SeedComment | null>() + const fetchComment = async () => null + const child = makeComment('c-child', USER, 'c-missing') + const result = await detectThreadReplyToKm({comment: child, kmAccountId: KM, fetchComment, cache}) + expect(result).toBeNull() + }) +}) + +describe('buildThreadReplyMention', () => { + it('concatenates all block texts and tags trigger source', () => { + const c: SeedComment = { + id: 'c1', + author: USER, + targetAccount: SITE, + targetPath: '/page', + content: [ + {block: {id: 'b1', text: 'line one'}}, + {block: {id: 'b2', text: 'line two'}}, + ], + } + const m = buildThreadReplyMention(c, '2026-05-11T00:00:00Z') + expect(m.kind).toBe('comment') + expect(m.commentId).toBe('c1') + expect(m.author).toBe(USER) + expect(m.docId).toBe(`hm://${SITE}/page`) + expect(m.blockId).toBeUndefined() + expect(m.triggerSource).toBe('thread-reply') + expect(m.text).toBe('line one\nline two') + }) + + it('strips U+FFFC object-replacement characters', () => { + const c: SeedComment = { + id: 'c2', + author: USER, + targetAccount: SITE, + content: [{block: {id: 'b1', text: 'hi  there'}}], + } + const m = buildThreadReplyMention(c, 'ts') + expect(m.text).toBe('hi there') + }) + + it('renders docId without a path when targetPath is absent', () => { + const c: SeedComment = { + id: 'c3', + author: USER, + targetAccount: SITE, + content: [{block: {id: 'b1', text: 'question'}}], + } + const m = buildThreadReplyMention(c, 'ts') + expect(m.docId).toBe(`hm://${SITE}`) + }) +}) From c97c2b4baa7f7b2b4b1a5fac16a16146cdecf3e9 Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Tue, 12 May 2026 10:18:57 +0200 Subject: [PATCH 15/17] fmt --- frontend/packages/shared/src/api-subscriptions.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/packages/shared/src/api-subscriptions.ts b/frontend/packages/shared/src/api-subscriptions.ts index ea84d64701..9854cedbbc 100644 --- a/frontend/packages/shared/src/api-subscriptions.ts +++ b/frontend/packages/shared/src/api-subscriptions.ts @@ -1,9 +1,5 @@ import {HMRequestImplementation} from './api-types' -import { - HMListSubscriptionsRequest, - HMSubscribeRequest, - HMUnsubscribeRequest, -} from '@seed-hypermedia/client/hm-types' +import {HMListSubscriptionsRequest, HMSubscribeRequest, HMUnsubscribeRequest} from '@seed-hypermedia/client/hm-types' /** Subscribe to a document or space (recursive=true mirrors all docs under path). */ export const Subscribe: HMRequestImplementation<HMSubscribeRequest> = { From fa5475778aeae6c6d99a172e6d065d41249cf3eb Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Tue, 12 May 2026 19:17:24 +0200 Subject: [PATCH 16/17] fix(agent): work around seed-cli --reply CID parse failure in thread chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defer thread-reply mentions to a direct-reply pass (no placeholder→edit flow) to avoid "Non-base58btc character" errors from seed-cli passing a RecordID where a CID is expected when the reply parent is itself a threaded reply. Root cause documented in .ai/seed-cli-reply-chain-fix.md; this commit applies the client-side workaround until seed-cli is patched upstream. --- .ai/seed-cli-reply-chain-fix.md | 289 ++++++++++++++++++ .../agent/mcp/seed-cli-mcp/src/poll-cli.ts | 114 ++++++- 2 files changed, 391 insertions(+), 12 deletions(-) create mode 100644 .ai/seed-cli-reply-chain-fix.md diff --git a/.ai/seed-cli-reply-chain-fix.md b/.ai/seed-cli-reply-chain-fix.md new file mode 100644 index 0000000000..e7b3edb468 --- /dev/null +++ b/.ai/seed-cli-reply-chain-fix.md @@ -0,0 +1,289 @@ +# Fix: `seed-cli comment create --reply` fails after comment edit + +## Bug summary + +`seed-cli comment create <target> --reply <commentId>` fails with `"Non-base58btc character"` when the reply parent (or any ancestor in the chain) was previously edited via `seed-cli comment edit`. + +## Reproduction steps + +1. Post comment A on a document +2. Post comment B with `--reply A` -- works, threaded correctly +3. Edit comment B's body via `seed-cli comment edit B --body "new text"` -- creates new CID version +4. Post comment C with `--reply B` -- **fails** with `Non-base58btc character` +5. Posting C without `--reply` works but loses threading + +## Root cause analysis + +The bug is in the CLI's `comment create --reply` handler and in the `@seed-hypermedia/client` library's `createSignedComment` function. There are **two separate problems** in the data flow: + +### Problem 1: CLI passes RecordID where CID is expected (comment.ts lines 126-135) + +File: `frontend/apps/cli/src/commands/comment.ts` + +```typescript +if (options.reply) { + const parentComment = await client.request('Comment', options.reply) + const parentVersion = parentComment.version || parentComment.id + if (parentVersion) replyParent = parentVersion + if (parentComment.threadRoot) { + threadRoot = parentComment.threadRoot // <-- BUG: RecordID format + } else if (parentComment.version) { + threadRoot = parentComment.version + } +} +``` + +The `HMComment` type (from `hm-types.ts`) has: +- `threadRoot: string` -- a **RecordID** like `z6Mkvz9.../z6Gis...` (authority/tsid) +- `threadRootVersion: string` -- a **CID** like `bafyreig...` +- `replyParent: string` -- a **RecordID** +- `replyParentVersion: string` -- a **CID** + +The CLI uses `parentComment.threadRoot` (RecordID) as `rootReplyCommentVersion`, but the downstream code calls `CID.parse()` on it. RecordIDs contain a `/` separator which is not a valid base58btc character, causing the error. + +**For a first-level reply** (no threadRoot on the parent), the code falls to `threadRoot = parentComment.version` which IS a CID, so it works. That is why replies to unedited root comments succeed. + +**For deeper replies** (where the parent has a threadRoot), the code uses the RecordID format and `CID.parse()` fails. + +The edit operation does not change the RecordID or threadRoot of a comment -- it only creates a new version blob with the same TSID. So the real reason editing triggers the bug is likely that the KM agent's two-pass flow (post placeholder -> edit with final answer) creates a scenario where subsequent replies to the edited comment hit the **deeper reply path** (the parent now has threadRoot set because it was itself a reply). + +### Problem 2: CID.parse() in createSignedComment (comment.ts lines 306-307) + +File: `frontend/packages/client/src/comment.ts` + +```typescript +async function createSignedComment(comment: UnsignedComment, signer: HMSigner): Promise<SignedComment> { + const commentForSigning = { + ...comment, + version: comment.version.split('.').map((v) => CID.parse(v)), + } as SignedComment + if (comment.threadRoot) commentForSigning.threadRoot = CID.parse(comment.threadRoot) + if (comment.replyParent) commentForSigning.replyParent = CID.parse(comment.replyParent) + // ... +} +``` + +`CID.parse()` is called on the `threadRoot` and `replyParent` strings. If these are RecordIDs instead of CID strings, the parse fails with the base58btc error. + +The same issue exists in `updateComment` (lines 495-496): +```typescript +if (input.replyParentVersion) comment.replyParent = CID.parse(input.replyParentVersion) +if (input.rootReplyCommentVersion) comment.threadRoot = CID.parse(input.rootReplyCommentVersion) +``` + +## How the server works (for reference) + +### Comment data model (Go) + +File: `backend/blob/blob_comment.go` + +```go +type Comment struct { + BaseBlob + ID TSID `refmt:"id,omitempty"` + Space_ core.Principal `refmt:"space,omitempty"` + Path string `refmt:"path,omitempty"` + Version []cid.Cid `refmt:"version,omitempty"` + ThreadRoot cid.Cid `refmt:"threadRoot,omitempty"` + ReplyParent_ cid.Cid `refmt:"replyParent,omitempty"` + Body []CommentBlock `refmt:"body"` + Visibility Visibility `refmt:"visibility,omitempty"` +} +``` + +### Comment proto response (Go) + +File: `backend/api/documents/v3alpha/comments.go`, function `commentToProto`: + +```go +pb := &documents.Comment{ + Id: blob.RecordID{Authority: cmt.Signer, TSID: tsid}.String(), // RecordID + Version: c.String(), // CID (base32 encoded) + // ... +} + +if cmt.ThreadRoot.Defined() { + ridRoot, _ := lookup.RecordID(cmt.ThreadRoot) + ridParent, _ := lookup.RecordID(cmt.ReplyParent()) + + pb.ThreadRoot = ridRoot.String() // RecordID format + pb.ThreadRootVersion = cmt.ThreadRoot.String() // CID format + pb.ReplyParent = ridParent.String() // RecordID format + pb.ReplyParentVersion = cmt.ReplyParent().String() // CID format +} +``` + +Key insight: The server returns BOTH formats -- RecordID (`threadRoot`, `replyParent`) and CID (`threadRootVersion`, `replyParentVersion`). The CLI must use the `*Version` fields (CID) for blob construction, not the RecordID fields. + +### CreateComment server handler (Go) + +File: `backend/api/documents/v3alpha/comments.go`, function `CreateComment`: + +```go +if in.ReplyParent != "" { + rpComment, err := srv.getComment(conn, in.ReplyParent) // Accepts RecordID or CID + replyParent = rpComment.CID // Uses the BLOB CID + threadRoot = rpComment.Comment.ThreadRoot // Uses the CBOR CID field + if !threadRoot.Defined() { + threadRoot = replyParent + } +} +``` + +The server's `getComment` resolves comments by RecordID (looking up by authority + TSID, returning the latest version). The server uses the internal CID from the blob, NOT the string IDs. + +### Comment edits and version chains + +When a comment is edited: +- A new blob is created with the SAME TSID but different CID +- The `qGetCommentByID` query returns the latest version (`ORDER BY sb.ts DESC LIMIT 1`) +- The `version` field in the response changes to the new blob's CID +- The `id` (RecordID) stays the same +- Threading fields (threadRoot, replyParent) stay the same (they reference the original blobs) + +## The fix + +### Fix 1: CLI `comment create` handler + +File: `frontend/apps/cli/src/commands/comment.ts` + +Change lines 123-135 from: + +```typescript +let replyParent: string | undefined +let threadRoot: string | undefined + +if (options.reply) { + const parentComment = await client.request('Comment', options.reply) + const parentVersion = parentComment.version || parentComment.id + if (parentVersion) replyParent = parentVersion + if (parentComment.threadRoot) { + threadRoot = parentComment.threadRoot + } else if (parentComment.version) { + threadRoot = parentComment.version + } +} +``` + +To: + +```typescript +let replyParent: string | undefined +let threadRoot: string | undefined + +if (options.reply) { + const parentComment = await client.request('Comment', options.reply) + // Use the CID version fields, not the RecordID fields. + // version = CID of the comment blob + // threadRootVersion = CID of the thread root blob (if this is a reply) + // replyParentVersion = CID of the reply parent blob (if this is a nested reply) + const parentVersion = parentComment.version || parentComment.id + if (parentVersion) replyParent = parentVersion + if (parentComment.threadRootVersion) { + threadRoot = parentComment.threadRootVersion // <-- Use CID, not RecordID + } else if (parentComment.version) { + threadRoot = parentComment.version + } +} +``` + +The key change: `parentComment.threadRoot` -> `parentComment.threadRootVersion` + +### Fix 2: Consider also fixing `replyParent` in the CLI `comment edit` handler + +File: `frontend/apps/cli/src/commands/comment.ts`, lines 198-213 + +The `edit` command already uses `existing.replyParentVersion` and `existing.threadRootVersion` correctly (lines 207-208). Verify this path is correct -- it appears to be. + +## Files to modify + +1. **`frontend/apps/cli/src/commands/comment.ts`** -- Primary fix: use `threadRootVersion` instead of `threadRoot` in the `create --reply` handler +2. **`frontend/packages/client/__tests__/comment.test.ts`** -- Add test for `createComment` with reply CID versions +3. **`frontend/apps/cli/src/test/cli.test.ts`** or **`frontend/apps/cli/src/test/cli-fixture.test.ts`** -- Add integration test for reply-after-edit scenario + +## Files to read (for context) + +All paths relative to the seed repo root (`/Users/horacioh/seed-hypermedia/seed`). + +| File | What to look at | +|------|-----------------| +| `frontend/apps/cli/src/commands/comment.ts` | CLI command handlers (create, edit, delete) | +| `frontend/packages/client/src/comment.ts` | `createComment`, `createSignedComment`, `updateComment`, `CID.parse()` calls | +| `frontend/packages/client/src/hm-types.ts` | `HMCommentSchema` -- the `threadRoot` vs `threadRootVersion` fields | +| `backend/api/documents/v3alpha/comments.go` | Server handler: `CreateComment`, `getComment`, `commentToProto` | +| `backend/blob/blob_comment.go` | `Comment` struct, `NewComment`, `ReplyParent()` fallback logic | +| `backend/blob/index.go` | `RecordID` type, `DecodeRecordID`, `LookupCache.RecordID` | +| `backend/blob/tsid.go` | `TSID` type, base58btc encoding | +| `backend/core/principal.go` | `Principal.String()` (base58btc encoding), `DecodePrincipal` | + +## Test plan + +### Unit test for the CLI fix + +Add to `frontend/packages/client/__tests__/comment.test.ts`: + +```typescript +it('creates a reply comment with threadRoot and replyParent CIDs', async () => { + const signer = makeSigner() + // These should be valid CID strings, not RecordIDs + const threadRootCID = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' + const replyParentCID = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' + + const publishInput = await createComment( + { + content: makeBlocks('reply text'), + docId: TEST_DOC_ID, + docVersion: threadRootCID, + blobs: [], + replyCommentVersion: replyParentCID, + rootReplyCommentVersion: threadRootCID, + }, + signer, + ) + + const decoded = cborDecode(publishInput.blobs[0]!.data) as any + expect(decoded.threadRoot).toBeDefined() + expect(decoded.replyParent).toBeUndefined() // Same as threadRoot, so omitted +}) +``` + +### Manual regression test + +1. Start a local seed daemon +2. Create a document +3. Post comment A on the document +4. Post comment B with `--reply A` +5. Edit comment B: `seed-cli comment edit B --body "edited text"` +6. Post comment C with `--reply B` -- should succeed (currently fails) +7. Verify comment C has correct `replyParent` and `threadRoot` +8. Post comment D with `--reply A` (non-edited chain) -- should still work + +## Impact on KM agent + +Once this fix lands in the seed repo, the KM agent workaround (skipping placeholders for thread-reply triggered comments) can be removed, restoring the two-pass UX (immediate "Working on this..." placeholder followed by the real answer). + +The workaround is in the seed-km repo at: +- `seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts` -- placeholder posting logic +- `seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts` -- `seed_reply_comment` tool (line 325) + +## CID encoding note + +The Go `go-cid` library (v0.6.0) encodes CIDv1 as **base32lower** by default (strings starting with `b`). The JavaScript `multiformats` CID library handles multiple multibase encodings via `CID.parse()`, so base32 CIDs from the server parse correctly. The error only occurs when a non-CID string (RecordID with `/` separator) is passed to `CID.parse()`. + +## Run these commands after the fix + +```bash +# From the seed repo root: + +# TypeCheck +pnpm typecheck + +# Client package tests +pnpm --filter @seed-hypermedia/client test + +# CLI tests +pnpm --filter @shm/cli test + +# Full test suite +pnpm test +``` diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts index c6cd2a3b01..0d51181e4b 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts @@ -132,6 +132,17 @@ async function main(): Promise<void> { let placeholdersPosted = 0 let skippedNotAllowed = 0 let exhaustedBudget = false + // Thread-reply mentions deferred to a direct-reply pass (no placeholder). + // Workaround for seed-cli bug: `--reply` uses `parentComment.threadRoot` + // (RecordID) instead of `parentComment.threadRootVersion` (CID), so + // `CID.parse()` fails with "Non-base58btc character" for any parent that + // is itself a threaded reply. The placeholder→edit flow makes this worse + // because the edited placeholder becomes an ancestor with a threadRoot, + // breaking all subsequent `--reply` calls in the chain. Skipping the + // placeholder avoids introducing an edited comment into the chain. + // Upstream fix tracked in .ai/seed-cli-reply-chain-fix.md — once seed-cli + // is patched, thread-replies can use the placeholder→edit flow. + const deferredThreadReplies: Array<{mention: Mention; mid: string}> = [] const blocked = new Set(g.rules.moderation.blockedAuthors) const siteAccount = config.seedSite.replace(/^hm:\/\//, '').split('/')[0]! @@ -208,6 +219,7 @@ async function main(): Promise<void> { // to it. const evidence = findKmMentionInComment(comment, [kmAccountId, siteAccount]) let mention: Mention | null = null + let threadReplyAncestor: string | undefined if (evidence) { mention = buildCommentMention(comment, evidence, candidate.ts) } else if (comment.replyParent) { @@ -222,17 +234,7 @@ async function main(): Promise<void> { }) if (hit) { mention = buildThreadReplyMention(comment, candidate.ts) - audit.trace({ - ts: nowIso(), - level: 'info', - event: 'mention_via_thread_reply', - data: { - commentId: comment.id, - ancestorCommentId: hit.ancestorCommentId, - docId: mention.docId, - author: mention.author, - }, - }) + threadReplyAncestor = hit.ancestorCommentId } } if (!mention) continue @@ -243,6 +245,23 @@ async function main(): Promise<void> { // so wrote thousands of duplicate "not-allowed" lines into // processed.jsonl when an unprivileged author kept mentioning the agent. if (state.isProcessed(mid) || state.hasPlaceholderFor(mid)) continue + + // Audit event for thread-reply trigger (after idempotency to avoid + // spamming the log every poll cycle for already-handled comments). + if (threadReplyAncestor) { + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'mention_via_thread_reply', + data: { + commentId: comment.id, + ancestorCommentId: threadReplyAncestor, + docId: mention.docId, + author: mention.author, + }, + }) + } + if (ENFORCE_INVOKER_GATE) { const principal = await resolvePrincipal(mention.author) if (!writers.has(mention.author) && !writers.has(principal)) { @@ -258,7 +277,7 @@ async function main(): Promise<void> { } } - // Per-day cap: a placeholder counts as a comment. + // Per-day cap: counts whether it's a placeholder or direct reply. const rs = state.getRateState() const capCheck = checkCap(rs, 'comments', g.rules) if (!capCheck.allowed) { @@ -270,6 +289,15 @@ async function main(): Promise<void> { }) break } + + // Thread-reply mentions skip the placeholder→edit flow and are + // deferred to a direct-reply pass (see comment at deferredThreadReplies). + if (mention.triggerSource === 'thread-reply') { + deferredThreadReplies.push({mention, mid}) + state.setRateState(bump(rs, 'comments')) + continue + } + const placeholderId = await postPlaceholder(cli, mention, audit) if (!placeholderId) continue state.recordPlaceholder({ @@ -364,6 +392,67 @@ async function main(): Promise<void> { } } + // ── PASS C: direct replies for thread-reply mentions (no placeholder). ── + // + // Thread-reply mentions skip the placeholder→edit dance to avoid + // inserting an edited comment into the reply chain. seed-cli's + // `--reply` breaks when the parent chain contains an edited comment + // (uses threadRoot RecordID instead of CID). We draft the full reply + // first, then post it as a single `comment create`. + // + // Upstream fix: .ai/seed-cli-reply-chain-fix.md — once seed-cli is + // patched, this pass can be removed and thread-replies can rejoin the + // placeholder→edit flow in Pass A/B. + let directReplied = 0 + for (const {mention} of deferredThreadReplies) { + const question = mention.text.replace(//g, ' ').trim() + const context = await gatherCommentReplyContext({cli, mention, siteAccount, audit}) + const reply = await draftReply(question, context, audit) + const body = reply ?? FALLBACK_BODY + const target = buildReplyTarget(mention) + const argv = ['comment', 'create', target.targetId, '--body', body] + if (target.replyTo) argv.push('--reply', target.replyTo) + let r = await cli.runWrite(argv) + // Same seed-cli fallback as postPlaceholder: if --reply fails on + // a threaded parent, drop to a top-level comment. + if (r.exitCode !== 0 && target.replyTo && /non-base58btc/i.test(r.stderr)) { + audit.trace({ + ts: nowIso(), + level: 'warn', + event: 'direct_reply_threading_fallback', + data: {commentId: mention.commentId, replyTo: target.replyTo, stderr: r.stderr.slice(0, 200)}, + }) + r = await cli.runWrite(['comment', 'create', target.targetId, '--body', body]) + } + if (r.exitCode === 0) { + state.markProcessed(mention, audit.meta.runId, reply ? 'replied' : 'error') + audit.trace({ + ts: nowIso(), + level: 'info', + event: reply ? 'direct_reply_posted' : 'direct_reply_posted_with_fallback', + data: { + commentId: mention.commentId, + docId: mention.docId, + replyPreview: body.slice(0, 200), + }, + }) + directReplied++ + } else { + state.markProcessed(mention, audit.meta.runId, 'error') + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'direct_reply_failed', + data: { + commentId: mention.commentId, + exitCode: r.exitCode, + stderr: r.stderr.slice(0, 200), + }, + }) + errored++ + } + } + audit.trace({ ts: nowIso(), level: 'info', @@ -374,6 +463,7 @@ async function main(): Promise<void> { placeholdersPosted, skippedNotAllowed, finalised, + directReplied, errored, exhaustedBudget, }, From be8dc380ee6fddd9549ff8260dd09ca8f6f9f692 Mon Sep 17 00:00:00 2001 From: Horacio Herrera <hi@horacioh.com> Date: Tue, 26 May 2026 14:07:00 +0200 Subject: [PATCH 17/17] feat(agent): add observability center telemetry Add live telemetry emission from audit runs and mention state machines, plus a Bun-based observability center with ingestion, storage, dashboard, tests, and systemd service docs. Also refine mention detection so document links are not treated as KM mentions. --- seed-knowledge-manager/PROJECT.md | 286 ++++++++++++++++++ seed-knowledge-manager/agent/README.md | 32 ++ .../agent/mcp/seed-cli-mcp/src/audit.ts | 21 ++ .../seed-cli-mcp/src/machines/poll-driver.ts | 47 +-- .../seed-cli-mcp/src/machines/supervisor.ts | 75 ++++- .../agent/mcp/seed-cli-mcp/src/mentions.ts | 5 +- .../seed-cli-mcp/src/observability.test.ts | 33 ++ .../mcp/seed-cli-mcp/src/observability.ts | 196 ++++++++++++ .../agent/mcp/seed-cli-mcp/src/poll-cli.ts | 2 + .../mcp/seed-cli-mcp/src/thread-reply.test.ts | 101 ++++++- .../agent/systemd/km-poll.service | 4 + .../observability-center/README.md | 43 +++ .../observability-center/package.json | 22 ++ .../src/__tests__/schema-store.test.ts | 59 ++++ .../observability-center/src/dashboard.ts | 176 +++++++++++ .../observability-center/src/importer.ts | 205 +++++++++++++ .../observability-center/src/main.ts | 42 +++ .../observability-center/src/schema.ts | 199 ++++++++++++ .../observability-center/src/server.ts | 143 +++++++++ .../observability-center/src/store.ts | 225 ++++++++++++++ .../systemd/km-observability-center.service | 26 ++ .../observability-center/tsconfig.json | 20 ++ 22 files changed, 1936 insertions(+), 26 deletions(-) create mode 100644 seed-knowledge-manager/PROJECT.md create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.test.ts create mode 100644 seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.ts create mode 100644 seed-knowledge-manager/observability-center/README.md create mode 100644 seed-knowledge-manager/observability-center/package.json create mode 100644 seed-knowledge-manager/observability-center/src/__tests__/schema-store.test.ts create mode 100644 seed-knowledge-manager/observability-center/src/dashboard.ts create mode 100644 seed-knowledge-manager/observability-center/src/importer.ts create mode 100644 seed-knowledge-manager/observability-center/src/main.ts create mode 100644 seed-knowledge-manager/observability-center/src/schema.ts create mode 100644 seed-knowledge-manager/observability-center/src/server.ts create mode 100644 seed-knowledge-manager/observability-center/src/store.ts create mode 100644 seed-knowledge-manager/observability-center/systemd/km-observability-center.service create mode 100644 seed-knowledge-manager/observability-center/tsconfig.json diff --git a/seed-knowledge-manager/PROJECT.md b/seed-knowledge-manager/PROJECT.md new file mode 100644 index 0000000000..5cd0c950b8 --- /dev/null +++ b/seed-knowledge-manager/PROJECT.md @@ -0,0 +1,286 @@ +# Seed Knowledge Manager + +*An autonomous moderator agent for Seed Hypermedia communities, grounded in 25+ years of network-knowledge-management practice.* + +--- + +## Problem + +Seed Hypermedia gives a community a place to publish documents, comment on them, and build a shared corpus. What it does not give the community is a way to turn that activity into **synthesised knowledge** — periodic bulletins, gap reports, expertise maps, network-health audits. Without that synthesis layer, communities exhibit what Luis Ángel Fernández Hermana (LAFH) called *choque infosomático* (an "info-somatic shock" — activity rises, knowledge production falls; members feel busy but the network forgets). The corpus accumulates and stays inert. + +In LAFH's terms — built across en.red.ando (1996), Enredando.com (1998), lab_RSI, and HipotecaGratis — a network only produces knowledge when it has a **synthesis zone** worked by a moderator-role. Synthesis is the bottleneck. No one has time. + +A second, narrower problem: Seed currently has no answer to "what does an autonomous agent inside a community look like?" If agents are coming, the substrate needs an opinion on how to host them, govern them, and bound their behaviour. + +## Solution + +A persistent agent — call it `@knowledge-manager` — that lives as a first-class member of a Seed community. Two equal-weight propositions: + +1. **Methodologically**: the first operational implementation of LAFH's *Gestión de Conocimiento en Red* (GC-Red, "knowledge management in networks") methodology on a modern hypermedia substrate. The agent does the synthesis work that the human moderator role calls for: weekly bulletin (*boletín periódico*), gap detection, network-health audits, grounded answers to community questions. + +2. **Architecturally**: the first agent governed *entirely by Seed documents*. Four community-editable docs — charter, rules, runbook, allowlist — define what the agent does, how it speaks, what it is allowed to write, and who can invoke it. No local YAML. No deploy to change behaviour. Edit the rules doc, save, the agent picks it up within 60 seconds. Proves the substrate can host autonomous participants on the same terms as human ones. + +Stack: Bun MCP server wrapping `seed-cli`, DeepSeek for language, XState v5 for per-mention lifecycle, Docker Compose for `seed-daemon` + `seed-web`, systemd timers for cadences — all on one Ubuntu host (`oc.hyper.media`). + +Status: **shipped, running in production on `oc.hyper.media`.** + +## Three demos + +### 1. Mention reply + multi-turn threads + +Write `@knowledge-manager what do we believe about <topic>?` in a comment on any document. Within ~2 seconds a typing-indicator placeholder appears. Within ~30 seconds the placeholder is rewritten with a grounded answer citing existing docs via `hm://` links. If the question crosses prior debate, the answer flags agreement, disagreement, and open questions. + +**Multi-turn**: reply to KM's answer without re-mentioning — KM detects it's a thread it already participated in and continues the conversation. The LLM sees the full comment thread so follow-ups feel natural. No `@` needed after the first mention. + +### 2. Scheduled bulletin + +Monday at 09:00 UTC, a new bulletin appears at `hm://oc.hyper.media/agents/knowledge-manager/boletines/2026-W19`: new docs (prioritised, not exhaustive), active threads with status, decisions made, new members, gaps surfaced or filled, recommended reading. Two-minute scan. Wednesday a gap report drops. First of each month, a network-health audit drops. + +### 3. Telegram operator bot + +The operator DMs the bot: +- `/status` — current activity, last run, queue depth. +- `/last-runs` — last five run summaries with mention IDs. +- `/show-rules` — currently-parsed rules and cache age. +- `/poll-now` — force one poll cycle now. +- `/ask <question>` — freeform multi-turn query. + +Read-mostly by design; mutations live in governance docs, not in DMs. + +## Scope + +Three workstreams shipped on branch `knowledge-agent-server-setup`. + +### A. Agent runtime — `seed-knowledge-manager/` + +The agent itself: a Bun project at `agent/mcp/seed-cli-mcp/` wrapping `seed-cli` as MCP tools. + +- **Read tools**: `seed_search`, `seed_get_document`, `seed_get_comment_thread`, `seed_site_sync_status`, `seed_get_governance`. +- **Write tools** (gated by governance + rate limits): `seed_create_comment`, `seed_reply_comment`. Document writes via cadence driver only. +- **Three-pass polling driver** (`poll-cli.ts`): pass A discovers mentions and posts placeholder comments within ~2s; pass B drafts the real reply via DeepSeek and edits the placeholder in place; pass C handles thread-reply mentions (see below) with direct-reply posting. Stateless deduplication by mention ID. +- **Thread-reply trigger**: walks the `replyParent` chain (up to 30 hops, with cycle guard and per-cycle cache) to detect comments replying to a thread where KM already participated. Uses a pure helper (`detectThreadReplyToKm`) with injected fetcher for testability. Thread-replies skip the placeholder→edit flow and post the final answer directly (pass C) to work around a seed-cli `--reply` bug (see "Rabbit holes"). `Mention` type carries a `triggerSource: 'mention' | 'thread-reply'` discriminator for audit logs. +- **Cadence driver** (`cadence-cli.ts`): three LAFH outputs — `boletin` (weekly), `gap` (weekly), `health` (monthly). One DeepSeek call per task, deterministic output path. +- **Telegram operator bot** (`telegram-bot.ts`): long-running poller, allowlisted by Telegram user ID. +- **XState v5 lifecycle** (`machines/mention-machine.ts` + `supervisor.ts`): per-mention state machine, snapshotted to jsonl, replayable on crash. Behind feature flag `KM_USE_STATE_MACHINE`. +- **Bounded tool-call agent loop** (`agent/mastra-agent.ts`): ≤30 tool calls then forced `final_answer`. Lets the model dynamically expand context instead of running one deterministic prompt. Behind feature flag `KM_USE_MASTRA_AGENT`. +- **Governance loader** (`governance.ts`): fetches the four governance docs, parses the machine-readable YAML in `rules` and `allowlist`, caches 60 seconds. +- **Audit + redaction** (`audit.ts`, `redact.ts`): per-run directories with `meta.json` (summary), `trace.jsonl` (events), `llm.jsonl` (DeepSeek calls), `seed-cli.jsonl` (commands). Secrets redacted on disk. +- **Skill + templates**: `SKILL.md` documents the seven capabilities; `templates/{synthesis-document, boletin-periodico, gap-report, onboarding-capsule, network-health}.md` shape the outputs; `references/lafh-framework.md` carries the theoretical grounding. +- **Infrastructure**: Docker `compose.yaml` (`seed-daemon` + `seed-web`), systemd user units and timers (`km-poll`, `km-boletin`, `km-gap`, `km-health`, `km-reconcile`, `km-telegram`), idempotent install scripts (`install-phase1.sh`, `bootstrap-subscription.sh`), `secret-tool-shim` (file-backed keyring replacement), `km-log` (log browser). + +### B. `seed-cli site` commands — `frontend/apps/cli/src/commands/site.ts` + +New subcommands to manage subscriptions and force convergence from the CLI: + +- `seed-cli site subscribe <id> [--recursive] [--wait]` +- `seed-cli site unsubscribe <id>` +- `seed-cli site list-subscriptions` +- `seed-cli site sync-status <id> [--writer <accountId>]` — reports whether the local daemon has cached a given WRITER capability. +- `seed-cli site reconcile` — forces hot discovery via fan-out over `entities.discoverEntity`. + +Shared API helpers: `frontend/packages/shared/src/api-subscriptions.ts`, `frontend/packages/shared/src/api-force-sync.ts`. Used by the agent's preflight gate — the polling driver refuses to run unless `sync-status` confirms the writer capability is locally cached. + +### C. Backend hot-tier scheduler — `backend/hmnet/syncing/scheduler.go` + +Two-tier discovery queue: + +- `tierHot` (priority 0) preempts `tierCold` (priority 1). +- `hotDeadline` heartbeat TTL (~40s); expired hot tasks demote or drop. +- Hot tasks preempt running subscriptions and oldest in-flight hot tasks when workers saturate. +- New config flag `Syncing.SubscriptionHotTier` (`backend/config/config.go:312`): when on, subscription tasks ride the hot tier so writer-capability blobs converge in ~hotTTL instead of the next polling interval. +- `PRAGMA busy_timeout = 5000` in `backend/storage/sqlite.go:33` — wait 5 seconds on a writer lock instead of returning `SQLITE_BUSY` immediately. Protects reconcile transactions from being starved by peer-store writes on small VMs. + +Without this, an agent that subscribes to a community sees the WRITER capability only after a multi-minute polling sweep — long enough that "subscribe then run" doesn't work in practice. With it, `subscribe --wait` converges in seconds. + +## How it works — Architecture + +Single Ubuntu 24.04 host. User `km` with linger + docker group. + +``` + ┌───────────────────────┐ + :55000 P2P → │ seed-daemon │ ← Docker + :55001 HTTP │ (seedhypermedia/ │ + :55002 gRPC │ site:latest) │ + └─────────┬─────────────┘ + │ seed-cli + ▼ + ┌──────────────────────────┐ + │ seed-cli-mcp (Bun) │ ─── DeepSeek API + │ + governance cache │ + │ + audit │ + └──┬────────┬──────────┬───┘ + │ │ │ + invoked by each timer / service + │ │ │ + ▼ ▼ ▼ + km-poll km-{boletin, km-telegram + (15-30s) gap,health} (long-running) + │ + ▼ + posts comments / docs to + oc.hyper.media (seed-web :3000) +``` + +**Governance flow.** On every action point the agent reads the four docs at `hm://oc.hyper.media/agents/knowledge-manager/{charter,rules,runbook,allowlist}` (cache TTL 60s). The `rules` doc carries a YAML block with caps (`max_docs_per_run`, `max_comments_per_run`, `max_comments_per_day`), mention triggers, invoker source (WRITER capability or allowlist doc), and a `draft_only` kill switch. Toggling `draft_only: true` stops document writes within 60 seconds; comments continue. + +**Hardcoded denylist.** Regardless of permissions, `limits.ts` refuses to write to the four governance paths. Operators can edit governance; the agent cannot. + +**Feature flags** (default off, ready to ship on): +- `KM_USE_LOCAL_DAEMON` — talk to the local daemon instead of a public gateway. Required for self-contained operation. +- `KM_USE_STATE_MACHINE` — XState lifecycle with snapshot/replay across crashes. +- `KM_USE_MASTRA_AGENT` — bounded tool-call loop instead of single-shot prompt. + +## How to interact + +### As a community member +- Mention `@knowledge-manager` in any comment to get a grounded answer with `hm://` citations. +- Reply to KM's answers directly — no need to re-mention. KM continues the conversation as a follow-up turn. +- Read the auto-published cadence docs under `hm://oc.hyper.media/agents/knowledge-manager/`. + +### As an operator (write access to governance docs) +- Edit `…/rules` to change caps, set `draft_only: true`, or restrict invokers. +- Edit `…/runbook` to change tone, citation style, or escalation policy. +- Edit `…/allowlist` to whitelist mentioners (when `invoker_source: allowlist-doc`; default is WRITER capability). +- Edit `…/charter` to redefine scope. + +### As a Telegram operator (allowlisted by Telegram user ID) +- `/status`, `/last-runs`, `/show-rules`, `/poll-now`, `/ask <question>`. + +### As an SRE on the host + +```bash +sudo -u km bash -lc '/home/km/.local/bin/km-log tail' +sudo -u km bash -lc '/home/km/.local/bin/km-log latest 5' +sudo -u km bash -lc '/home/km/.local/bin/km-log show <run-id>' +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user start km-poll.service +``` + +## State machines + +Behind feature flag `KM_USE_STATE_MACHINE`. One XState v5 machine definition with a supervisor layer for persistence and crash recovery. + +### Mention machine (`machines/mention-machine.ts`) + +Per-mention lifecycle. Each incoming mention spawns one actor. Side effects (placeholder posting, LLM drafting, comment editing) are injected via `fromPromise` actors — the machine itself is pure. + +``` + ┌─────────────────┐ + │ detected │ (initial) + └──┬──────┬───────┘ + ENQUEUE │ │ NOT_ALLOWED / CAP_DENIED + ▼ ▼ + ┌──────────┐ ┌──────────────────┐ + │ enqueued │ │ skipped_not_ │ (final) + └────┬─────┘ │ allowed / │ + POST_ │ │ cap_exceeded │ + PLACEHOLDER │ └──────────────────┘ + ▼ + ┌───────────────────┐ + │ placeholder_ │ + │ pending │ + └──┬────────────┬───┘ + PLACEHOLDER_ │ │ PLACEHOLDER_FAILED + POSTED ▼ ▼ + ┌────────────────┐ ┌─────────────────┐ + │ placeholder_ │ │ failed_terminal │ (final) + │ posted │ └─────────────────┘ + └──────┬─────────┘ + RUN_ │ + AGENT ▼ + ┌────────────────┐ + │ agent_running │◄──────────────────┐ + └──┬─────────┬───┘ │ + AGENT_ │ │ AGENT_ERROR │ + DONE │ ▼ │ + │ [canRetryDraft?] │ + │ yes → agent_backoff ─────────┘ + │ no → failed_terminal (final) + ▼ + ┌────────────────┐ + │ draft_ready │ + └──────┬─────────┘ + FINALISE │ + ▼ + ┌────────────────┐ + │ finalising │◄──────────────────┐ + └──┬─────────┬───┘ │ + FINALISED │ │ FINALISE_ERROR │ + │ ▼ │ + │ [canRetryFinalise?] │ + │ yes → finalise_backoff ──────┘ + │ no → failed_terminal (final) + ▼ + ┌────────────────┐ + │ done │ (final) + └────────────────┘ +``` + +**9 states**: `detected`, `enqueued`, `placeholder_pending`, `placeholder_posted`, `agent_running`, `agent_backoff`, `draft_ready`, `finalising`, `finalise_backoff`. + +**4 terminal states**: `done`, `skipped_not_allowed`, `cap_exceeded`, `failed_terminal`. + +**12 events**: `ENQUEUE`, `CAP_DENIED{reason}`, `NOT_ALLOWED{reason}`, `POST_PLACEHOLDER`, `PLACEHOLDER_POSTED{placeholderId}`, `PLACEHOLDER_FAILED{reason}`, `RUN_AGENT`, `AGENT_DONE{replyBody}`, `AGENT_ERROR{reason}`, `FINALISE`, `FINALISED`, `FINALISE_ERROR{reason}`. + +**2 guards**: `canRetryDraft` (`draftRetries < 3`), `canRetryFinalise` (`finaliseRetries < 3`). + +**2 delays** (exponential backoff): `draftBackoff` = `2000ms × 2^retries`, `finaliseBackoff` = `2000ms × 2^retries`. + +**Context** carried per mention: +| Field | Type | Set by | +|---|---|---| +| `mention` | `Mention` | spawn | +| `placeholderId` | `string \| null` | `PLACEHOLDER_POSTED` | +| `replyBody` | `string \| null` | `AGENT_DONE` | +| `failureReason` | `string \| null` | terminal events | +| `draftRetries` | `number` | `AGENT_ERROR` (increment) | +| `finaliseRetries` | `number` | `FINALISE_ERROR` (increment) | +| `lastError` | `string \| null` | transient failures | + +### Supervisor (`machines/supervisor.ts`) + +Orchestrates one actor per mention. Responsibilities: + +- **Spawn**: creates and starts a fresh actor for each mention. +- **Persist**: appends every transition to `${stateDir}/machines/<mentionKey>.jsonl`. Each line is `{ts, type, payload?, initialMention?}`. +- **Rehydrate**: on startup, replays all JSONL event logs. Terminal actors are dropped; in-flight actors are restored to their last state. +- **Stop**: graceful shutdown of all actors. + +### Poll driver (`machines/poll-driver.ts`) + +Bridges `poll-cli.ts` Pass B with the supervisor. Called when `config.useStateMachine` is enabled: + +1. Creates `MentionSupervisor` with side-effect callbacks. +2. Rehydrates from prior runs (crash recovery). +3. For each pending placeholder: spawn actor → feed bootstrap events (`POST_PLACEHOLDER` → `PLACEHOLDER_POSTED` → `RUN_AGENT`) → execute LLM → send `AGENT_DONE`/`AGENT_ERROR` → attempt finalization → send `FINALISED`/`FINALISE_ERROR`. +4. Stops all actors. + +Note: the state machine path currently handles **placeholder-based mentions only** (Pass B). Thread-reply mentions (Pass C, direct-reply) bypass the machine and run a single-shot draft→post flow. Once seed-cli `--reply` is fixed upstream, thread-replies can rejoin the machine-driven path. + +## Rabbit holes (we went there) + +- **Nanobot gateway.** Tried nanobot as the MCP gateway for free-form agent orchestration. It does not bundle cleanly with Bun, and the surface area we needed was small enough to re-implement inline. Result: kept `nanobot-gateway.service` as an optional off-path surface; built our own bounded tool-call loop in `agent/mastra-agent.ts`. +- **Cursor-based activity walker.** First pass scanned the activity feed with a persistent cursor to detect mentions. Race conditions on cursor advancement caused dropped mentions and double replies under load. Replaced with stateless per-poll deduplication keyed by mention ID (commit `0fcbb02cc`). +- **seed-cli `--reply` and edited comments.** `seed-cli comment create --reply <id>` uses `parentComment.threadRoot` (a RecordID containing `/`) instead of `parentComment.threadRootVersion` (a CID). `CID.parse()` chokes on the `/` with `"Non-base58btc character"`. Fails for any parent that is itself a threaded reply (has `threadRoot` set). The placeholder→edit flow makes it worse: editing a placeholder inserts an edited comment into the chain, breaking all subsequent `--reply` calls in that thread. Workaround: thread-reply mentions skip the placeholder phase and post the final answer directly (pass C). Upstream fix is a one-liner in `frontend/apps/cli/src/commands/comment.ts` line 131 — tracked in `.ai/seed-cli-reply-chain-fix.md`. +- **Gnome-keyring on a headless server.** `seed-cli` defaults to libsecret/Gnome-keyring for key storage; a headless Ubuntu host has no session bus to run `gnome-keyring-daemon`. Wrote `secret-tool-shim`: file-backed JSON at `~/.config/seed-keyring/secrets.json`, mode 600, drop-in compatible with the `secret-tool` CLI invocations `seed-cli` makes. + +## No-gos (explicit boundaries) + +- **Agent edits its own governance.** Hardcoded denylist in `limits.ts` prevents writes to the four governance paths regardless of permissions. +- **Destructive operations.** Agent has zero delete / unlink / move capability. Only creates comments and documents. +- **Mass DMs or outbound messaging.** No invite, no DM, no email. All output lives inside the community as comments or documents on the site. +- **Self-promotion / re-posting.** Caps of 1 doc per run, 5 comments per run, 30 comments per day, enforced *before* every write. +- **Mocked tests on the integration path.** Integration tests hit a real local `seed-daemon`; mocks only inside unit boundaries. +- **Cross-workspace imports that break the repo's dependency graph.** Vault stays Bun, repo stays pnpm, agent MCP stays Bun under `seed-knowledge-manager/`. No reach-across imports. + +## Next steps + +- **Operator dashboard.** Move beyond Telegram and logs. A small web UI on the existing `seed-web` container showing per-run audit traces, governance cache state, mention queue, cadence run history. +- **Multi-tenant.** Today one agent process serves one site. Generalise to one agent serving N sites via per-site governance docs, per-site state dirs, and a small site-registry doc. Will require factoring `config.ts` into a per-site loader. +- **Fix seed-cli `--reply` upstream.** One-line fix: `parentComment.threadRoot` → `parentComment.threadRootVersion` in `frontend/apps/cli/src/commands/comment.ts:131`. Once merged, thread-replies can rejoin the placeholder→edit flow for instant "Working on this..." feedback. Prompt at `.ai/seed-cli-reply-chain-fix.md`. +- **Remove the `km-reconcile.timer` band-aid.** Once `SubscriptionHotTier=true` ships on by default, the 60-second reconcile timer becomes redundant. +- **Wire the remaining capabilities.** Templates exist for synthesis docs (capability #2), expertise maps (#5), and cross-reference detection (#6) — no cadence driver yet. Likely mention-triggered, not scheduled. +- **Promote the bounded tool-call agent.** Default `KM_USE_MASTRA_AGENT=1` once we have a week of side-by-side reply quality against the single-shot path. + +## Acknowledgement + +The methodology this agent operationalises is not new. Luis Ángel Fernández Hermana developed *Gestión de Conocimiento en Red* across en.red.ando, Enredando.com, and lab_RSI over more than two decades, with hard-won evidence (HipotecaGratis converted a 30-person firm into a knowledge-producing network and lifted per-worker income 34–43% in nine months). What is new is having a substrate — Seed Hypermedia — where the moderator role can run as a software member, governed by community-editable documents, and a small enough operational surface that one operator can keep it healthy. diff --git a/seed-knowledge-manager/agent/README.md b/seed-knowledge-manager/agent/README.md index 045485b544..23435ba502 100644 --- a/seed-knowledge-manager/agent/README.md +++ b/seed-knowledge-manager/agent/README.md @@ -231,6 +231,38 @@ stdout.log / stderr.log raw process streams Logrotate config under `/home/km/.config/logrotate.d/km-logs.conf` keeps 30 days / 5 GB. +## Observability Center + +The Bun app in `../observability-center/` turns the audit/state-machine trail into an operator console for questions like: + +- “Why did KM not answer this comment?” +- “How many actors are alive now?” +- “What is KM currently doing?” +- “How is syncing working?” + +It runs separately from the agent and can sit behind `https://oc.hyper.media`: + +```bash +cd seed-knowledge-manager/observability-center +bun install +OC_DB_PATH=/home/km/oc-data/oc.sqlite \ +OC_INGEST_TOKEN=<shared-secret> \ +OC_IMPORT_KM_LOGS_DIR=/home/km/km-logs \ +OC_IMPORT_KM_STATE_DIR=/home/km/km-state \ +OC_IMPORT_FULL_PAYLOAD=0 \ +bun src/main.ts serve +``` + +Wire live telemetry from the poll daemon: + +```bash +KM_OBS_URL=https://oc.hyper.media/api/ingest +KM_OBS_TOKEN=<shared-secret> +KM_OBS_FULL_PAYLOAD=0 +``` + +`KM_OBS_FULL_PAYLOAD=0` and `OC_IMPORT_FULL_PAYLOAD=0` are the defaults: LLM prompts/full tool payloads are dropped or previewed, while IDs, states, counters, timings, and short redacted previews are preserved. Set either to `1` only for a short debugging session. + ## End-to-end setup (bootstrap from scratch) These are the as-built steps, in order. Anything we discovered along the way that diverges from the original plan is captured in **bold notes**. diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/audit.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/audit.ts index 9602c6559e..cd2748171e 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/audit.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/audit.ts @@ -21,6 +21,7 @@ import {appendFileSync, closeSync, existsSync, mkdirSync, openSync, statSync, sy import {join} from 'node:path' import {ulid} from 'ulid' import type {Redactor} from './redact.js' +import {createObservabilityClientFromEnv, type ObservabilityClient, type TelemetryKind} from './observability.js' export type Trigger = string @@ -79,11 +80,13 @@ export class AuditRun { readonly meta: AuditMeta readonly dir: string private readonly redactor: Redactor + private readonly observability: ObservabilityClient | null private closed = false private startTime: number constructor(opts: {logsDir: string; trigger: Trigger; redactor: Redactor; kmAccountId?: string; seedSite?: string}) { this.redactor = opts.redactor + this.observability = createObservabilityClientFromEnv(opts.redactor) const runId = ulid() const now = new Date() this.startTime = now.getTime() @@ -100,28 +103,41 @@ export class AuditRun { counters: {}, } this.flushMeta() + this.emitTelemetry('run_meta', this.meta) this.updateCurrent(opts.logsDir, slug) } trace(event: TraceEvent): void { this.appendJsonl('trace.jsonl', event) + this.emitTelemetry('trace', event) } tool(record: ToolCallRecord): void { this.appendJsonl('tools.jsonl', record) + this.emitTelemetry('tool', record) this.bumpCounter('tool_calls') } llm(record: LlmRecord): void { this.appendJsonl('llm.jsonl', record) + this.emitTelemetry('llm', record) this.bumpCounter('llm_calls') } seedCli(record: SeedCliRecord): void { this.appendJsonl('seed-cli.jsonl', record) + this.emitTelemetry('seed_cli', record) this.bumpCounter('seed_cli_calls') } + telemetry(kind: TelemetryKind, data: unknown): void { + this.emitTelemetry(kind, data) + } + + async flushTelemetry(timeoutMs = 1_500): Promise<void> { + await this.observability?.flush(timeoutMs) + } + bumpCounter(name: string, delta = 1): void { if (!this.meta.counters) this.meta.counters = {} this.meta.counters[name] = (this.meta.counters[name] ?? 0) + delta @@ -135,9 +151,14 @@ export class AuditRun { this.meta.wallMs = now.getTime() - this.startTime this.meta.status = opts.status ?? 'ok' this.flushMeta() + this.emitTelemetry('run_meta', this.meta) appendIndex(opts.logsDir, this.meta) } + private emitTelemetry(kind: TelemetryKind, data: unknown): void { + this.observability?.emit(kind, this.meta.runId, data) + } + private appendJsonl(file: string, value: unknown): void { const line = this.redactor(JSON.stringify(value)) + '\n' const path = join(this.dir, file) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts index c9e40c29e3..198f9cac0d 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts @@ -28,29 +28,36 @@ export async function runMachinePassB(opts: RunMachinePassBOptions): Promise<{fi let finalised = 0 let errored = 0 - const supervisor = new MentionSupervisor(config.stateDir, { - // Placeholder is already posted in Pass A; the machine starts straight at - // `placeholder_posted` by sending POST_PLACEHOLDER + PLACEHOLDER_POSTED in - // sequence below. - postPlaceholder: async () => ({placeholderId: ''}), - runAgent: async (mention) => { - const question = mention.text.replace(//g, ' ').trim() - const context = await gatherCommentReplyContext({cli, mention, siteAccount, audit}) - if (config.useMastraAgent) { - const {runMastraReply} = await import('../agent/mastra-agent.js') - const reply = await runMastraReply({question, context, mention, audit, cli}) + const supervisor = new MentionSupervisor( + config.stateDir, + { + // Placeholder is already posted in Pass A; the machine starts straight at + // `placeholder_posted` by sending POST_PLACEHOLDER + PLACEHOLDER_POSTED in + // sequence below. + postPlaceholder: async () => ({placeholderId: ''}), + runAgent: async (mention) => { + const question = mention.text.replace(//g, ' ').trim() + const context = await gatherCommentReplyContext({cli, mention, siteAccount, audit}) + if (config.useMastraAgent) { + const {runMastraReply} = await import('../agent/mastra-agent.js') + const reply = await runMastraReply({question, context, mention, audit, cli}) + return {replyBody: reply ?? fallbackBody} + } + const reply = await draftReply(question, context, audit) return {replyBody: reply ?? fallbackBody} - } - const reply = await draftReply(question, context, audit) - return {replyBody: reply ?? fallbackBody} + }, + finaliseComment: async (placeholderId, replyBody) => { + const r = await cli.runWrite(['comment', 'edit', placeholderId, '--body', replyBody]) + if (r.exitCode !== 0) { + throw new Error(`comment edit failed: exit=${r.exitCode} stderr=${r.stderr.slice(0, 200)}`) + } + }, }, - finaliseComment: async (placeholderId, replyBody) => { - const r = await cli.runWrite(['comment', 'edit', placeholderId, '--body', replyBody]) - if (r.exitCode !== 0) { - throw new Error(`comment edit failed: exit=${r.exitCode} stderr=${r.stderr.slice(0, 200)}`) - } + { + runId: audit.meta.runId, + telemetry: (kind, data) => audit.telemetry(kind, data), }, - }) + ) // Replay any actors persisted from prior runs so we resume mid-flight. const replay = supervisor.rehydrate() diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts index ed8af7345b..b4df7040cc 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts @@ -18,6 +18,7 @@ import {createActor, type Actor} from 'xstate' import {mentionMachine, type MentionCallbacks, type MentionEvent} from './mention-machine.js' import type {Mention} from '../mentions.js' import {mentionKey} from '../state.js' +import type {TelemetryKind} from '../observability.js' const MACHINES_SUBDIR = 'machines' @@ -33,10 +34,21 @@ export class MentionSupervisor { private readonly machinesDir: string private readonly actors = new Map<string, Actor<typeof mentionMachine>>() private readonly callbacks: MentionCallbacks - - constructor(stateDir: string, callbacks: MentionCallbacks) { + private readonly telemetry?: (kind: Extract<TelemetryKind, 'machine_event' | 'machine_snapshot'>, data: unknown) => void + private readonly runId?: string + + constructor( + stateDir: string, + callbacks: MentionCallbacks, + opts: { + telemetry?: (kind: Extract<TelemetryKind, 'machine_event' | 'machine_snapshot'>, data: unknown) => void + runId?: string + } = {}, + ) { this.callbacks = callbacks void this.callbacks + this.telemetry = opts.telemetry + this.runId = opts.runId this.machinesDir = join(stateDir, MACHINES_SUBDIR) if (!existsSync(this.machinesDir)) { mkdirSync(this.machinesDir, {recursive: true, mode: 0o700}) @@ -60,6 +72,7 @@ export class MentionSupervisor { const actor = this.createActor(mention, id) actor.start() + this.emitMachineEvent('actor_spawned', mention, id, {trigger: 'fresh'}) return actor } @@ -69,6 +82,7 @@ export class MentionSupervisor { const actor = this.actors.get(id) if (!actor) return this.persist(id, {ts: new Date().toISOString(), type: event.type, payload: extractPayload(event)}) + this.emitMachineEvent('actor_event', mention, id, {type: event.type, payload: extractPayload(event)}) actor.send(event) } @@ -118,8 +132,10 @@ export class MentionSupervisor { if (snapshot.status === 'done') { actor.stop() this.actors.delete(id) + this.emitMachineEvent('actor_rehydrated_terminal', initialMention, id, {status: snapshot.status}) skipped++ } else { + this.emitMachineEvent('actor_rehydrated', initialMention, id, {status: snapshot.status}) restored++ } } @@ -128,7 +144,16 @@ export class MentionSupervisor { /** Stop all actors. Called on graceful shutdown. */ stopAll(): void { - for (const actor of this.actors.values()) actor.stop() + for (const [id, actor] of this.actors.entries()) { + actor.stop() + this.telemetry?.('machine_event', { + event: 'actor_stopped', + ts: new Date().toISOString(), + runId: this.runId, + actorId: id, + mentionId: id, + }) + } this.actors.clear() } @@ -136,18 +161,56 @@ export class MentionSupervisor { const actor = createActor(mentionMachine, {input: {mention}}) this.actors.set(id, actor) actor.subscribe((snapshot) => { + if (!opts.silent) this.emitSnapshot(mention, id, snapshot) if (snapshot.status === 'done') { // Terminal — drop the actor. The JSONL log stays as audit trail. setImmediate(() => { actor.stop() this.actors.delete(id) + this.emitMachineEvent('actor_stopped', mention, id, {status: snapshot.status}) }) } }) - void opts return actor } + private emitMachineEvent(event: string, mention: Mention, id: string, data: Record<string, unknown> = {}): void { + this.telemetry?.('machine_event', { + event, + ts: new Date().toISOString(), + runId: this.runId, + actorId: id, + mentionId: id, + commentId: mention.commentId, + docId: mention.docId, + kind: mention.kind, + ...data, + }) + } + + private emitSnapshot(mention: Mention, id: string, snapshot: unknown): void { + const snap = isRecord(snapshot) ? snapshot : {} + const context = isRecord(snap.context) ? snap.context : {} + this.telemetry?.('machine_snapshot', { + event: 'actor_snapshot', + ts: new Date().toISOString(), + runId: this.runId, + actorId: id, + mentionId: id, + commentId: mention.commentId, + docId: mention.docId, + state: snap.value, + status: snap.status, + context: { + placeholderId: context.placeholderId, + draftRetries: context.draftRetries, + finaliseRetries: context.finaliseRetries, + failureReason: context.failureReason, + lastError: context.lastError, + }, + }) + } + private persist(id: string, event: PersistedEvent): void { const path = join(this.machinesDir, `${sanitizeForFs(id)}.jsonl`) appendFileSync(path, JSON.stringify(event) + '\n', {mode: 0o600}) @@ -170,3 +233,7 @@ function reconstructEvent(persisted: PersistedEvent): MentionEvent { const base = {type: persisted.type as MentionEvent['type']} return {...base, ...(persisted.payload ?? {})} as MentionEvent } + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts index 7e29aaad4d..f770ae9ce5 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts @@ -143,8 +143,11 @@ export function findKmMentionInComment( for (const ann of block.annotations ?? []) { if (ann.type !== 'Embed') continue if (typeof ann.link !== 'string') continue - const m = ann.link.match(/^hm:\/\/([^/?#]+)/) + const m = ann.link.match(/^hm:\/\/([^/?#]+)(\/.*)?/) if (!m) continue + // Document links (hm://account/path) are NOT mentions — skip. + // But /:profile is an account mention, not a document. + if (m[2] && m[2] !== '/:profile' && !m[2].startsWith('/:profile?')) continue if (idSet.has(m[1]!)) { return {blockId: block.id, text: block.text ?? ''} } diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.test.ts new file mode 100644 index 0000000000..f050058d47 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.test.ts @@ -0,0 +1,33 @@ +import {expect, test} from 'bun:test' +import {sanitizeForObservability} from './observability.js' + +const redactor = (input: string) => input.split('secret-token').join('***REDACTED***') + +test('observability sanitizes LLM prompts by default', () => { + const sanitized = sanitizeForObservability( + 'llm', + { + model: 'deepseek-chat', + prompt_messages: [{role: 'user', content: 'secret-token'}], + completion: 'answer with secret-token', + tool_calls: [{function: {name: 'seed_search', arguments: '{}'}}], + }, + false, + redactor, + ) as Record<string, unknown> + + expect(sanitized.prompt_messages).toBeUndefined() + expect(sanitized.completion).toContain('***REDACTED***') + expect(sanitized.tool_calls).toEqual(['seed_search']) +}) + +test('observability can preserve redacted full payload explicitly', () => { + const sanitized = sanitizeForObservability( + 'trace', + {event: 'x', data: {nested: 'secret-token'}}, + true, + redactor, + ) as {data: {nested: string}} + + expect(sanitized.data.nested).toBe('***REDACTED***') +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.ts new file mode 100644 index 0000000000..0cd5cbb54b --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.ts @@ -0,0 +1,196 @@ +import type {Redactor} from './redact.js' + +export type TelemetryKind = + | 'run_meta' + | 'trace' + | 'llm' + | 'tool' + | 'seed_cli' + | 'machine_event' + | 'machine_snapshot' + +export type ObservabilityClient = { + emit(kind: TelemetryKind, runId: string | null, data: unknown): void + flush(timeoutMs?: number): Promise<void> +} + +type ClientConfig = { + url: string + token?: string + fullPayload: boolean +} + +const STRING_LIMIT = 300 +const LARGE_STRING_LIMIT = 1_200 + +export function createObservabilityClientFromEnv(redactor: Redactor, env: NodeJS.ProcessEnv = process.env): ObservabilityClient | null { + const rawUrl = env.KM_OBS_URL + if (!rawUrl) return null + const url = normalizeIngestUrl(rawUrl) + if (!url) return null + return createObservabilityClient( + { + url, + token: env.KM_OBS_TOKEN, + fullPayload: /^(1|true|yes)$/i.test(env.KM_OBS_FULL_PAYLOAD ?? ''), + }, + redactor, + ) +} + +export function createObservabilityClient(config: ClientConfig, redactor: Redactor): ObservabilityClient { + const pending = new Set<Promise<void>>() + const emit = (kind: TelemetryKind, runId: string | null, data: unknown): void => { + const payload = sanitizeForObservability(kind, data, config.fullPayload, redactor) + const envelope = { + kind, + runId, + ts: pickTimestamp(payload), + source: 'km', + data: payload, + } + const task = fetch(config.url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(config.token ? {authorization: `Bearer ${config.token}`} : {}), + }, + body: redactor(JSON.stringify(envelope)), + }) + .then(async (res) => { + if (!res.ok) await res.arrayBuffer().catch(() => undefined) + }) + .catch(() => undefined) + .finally(() => pending.delete(task)) + pending.add(task) + } + return { + emit, + async flush(timeoutMs = 1_500): Promise<void> { + if (pending.size === 0) return + await Promise.race([ + Promise.allSettled(Array.from(pending)).then(() => undefined), + new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)), + ]) + }, + } +} + +export function sanitizeForObservability(kind: TelemetryKind, data: unknown, fullPayload: boolean, redactor: Redactor): unknown { + if (fullPayload) return redactedJsonClone(data, redactor) + const value = redactedJsonClone(data, redactor) + const record = isRecord(value) ? value : {value} + switch (kind) { + case 'llm': + return compactObject({ + ts_start: record.ts_start, + ts_end: record.ts_end, + latency_ms: record.latency_ms, + model: record.model, + completion: truncateString(record.completion, LARGE_STRING_LIMIT), + reasoning: truncateString(record.reasoning, STRING_LIMIT), + usage: limitDeep(record.usage, 2), + tool_call_count: Array.isArray(record.tool_calls) ? record.tool_calls.length : undefined, + tool_calls: Array.isArray(record.tool_calls) + ? record.tool_calls.map((call) => callName(call)).filter((name): name is string => typeof name === 'string') + : undefined, + }) + case 'seed_cli': + return compactObject({ + ts_start: record.ts_start, + ts_end: record.ts_end, + latency_ms: record.latency_ms, + argv: Array.isArray(record.argv) ? record.argv.map((item) => truncateString(item, STRING_LIMIT)) : undefined, + exit_code: record.exit_code, + stdout: truncateString(record.stdout, LARGE_STRING_LIMIT), + stderr: truncateString(record.stderr, LARGE_STRING_LIMIT), + }) + case 'tool': + return compactObject({ + ts_start: record.ts_start, + ts_end: record.ts_end, + latency_ms: record.latency_ms, + tool: record.tool, + args: limitDeep(record.args, 3), + result: limitDeep(record.result, 3), + error: truncateString(record.error, LARGE_STRING_LIMIT), + }) + case 'trace': + return compactObject({ + ts: record.ts, + level: record.level, + event: record.event, + data: limitDeep(record.data, 4), + }) + case 'machine_event': + case 'machine_snapshot': + case 'run_meta': + return limitDeep(record, 5) + } +} + +function normalizeIngestUrl(raw: string): string | null { + try { + const url = new URL(raw) + if (url.pathname === '/' || url.pathname === '') url.pathname = '/api/ingest' + return url.toString() + } catch { + return null + } +} + +function redactedJsonClone(value: unknown, redactor: Redactor): unknown { + try { + return JSON.parse(redactor(JSON.stringify(value))) + } catch { + return redactor(String(value)) + } +} + +function pickTimestamp(value: unknown): string | null { + const record = isRecord(value) ? value : null + const candidates = [record?.ts, record?.ts_start, record?.startedAt] + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.length > 0) return candidate + } + return null +} + +function limitDeep(value: unknown, depth: number): unknown { + if (value == null || typeof value === 'number' || typeof value === 'boolean') return value + if (typeof value === 'string') return truncateString(value, STRING_LIMIT) + if (depth <= 0) return summarize(value) + if (Array.isArray(value)) return value.slice(0, 20).map((item) => limitDeep(item, depth - 1)) + if (isRecord(value)) { + const out: Record<string, unknown> = {} + for (const [key, child] of Object.entries(value).slice(0, 40)) out[key] = limitDeep(child, depth - 1) + return out + } + return String(value) +} + +function summarize(value: unknown): string { + if (Array.isArray(value)) return `[${value.length} items]` + if (isRecord(value)) return `{${Object.keys(value).length} keys}` + return truncateString(String(value), STRING_LIMIT) ?? '' +} + +function truncateString(value: unknown, max: number): string | undefined { + if (typeof value !== 'string') return undefined + return value.length <= max ? value : `${value.slice(0, max)}…` +} + +function callName(value: unknown): string | null { + if (!isRecord(value)) return null + if (typeof value.name === 'string') return value.name + const fn = isRecord(value.function) ? value.function : null + return typeof fn?.name === 'string' ? fn.name : null +} + +function compactObject(value: Record<string, unknown>): Record<string, unknown> { + return Object.fromEntries(Object.entries(value).filter(([, child]) => child !== undefined)) +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts index 0d51181e4b..49ffe88922 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts @@ -76,6 +76,7 @@ async function main(): Promise<void> { if (!ready) { audit.trace({ts: nowIso(), level: 'warn', event: 'preflight_skipped', data: {reason: 'local-daemon-not-ready'}}) audit.close({status: 'denied', logsDir: config.logsDir}) + await audit.flushTelemetry() return } } @@ -479,6 +480,7 @@ async function main(): Promise<void> { } finally { audit.trace({ts: nowIso(), level: 'info', event: 'agent_end', data: {status}}) audit.close({status, logsDir: config.logsDir}) + await audit.flushTelemetry() } } diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/thread-reply.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/thread-reply.test.ts index 800e098d43..192e92ffa7 100644 --- a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/thread-reply.test.ts +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/thread-reply.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'bun:test' -import {buildThreadReplyMention, detectThreadReplyToKm} from './mentions.js' +import {buildThreadReplyMention, detectThreadReplyToKm, findKmMentionInComment} from './mentions.js' import type {SeedComment} from './mentions.js' const KM = 'z6MkAgent' @@ -120,6 +120,105 @@ describe('detectThreadReplyToKm', () => { }) }) +describe('findKmMentionInComment — embed false positives', () => { + it('detects a bare account embed as a mention', () => { + const comment: SeedComment = { + id: 'c1', + author: USER, + targetAccount: SITE, + content: [ + { + block: { + id: 'b1', + text: 'hi ', + annotations: [{type: 'Embed', link: `hm://${SITE}`}], + }, + }, + ], + } + const result = findKmMentionInComment(comment, [SITE]) + expect(result).not.toBeNull() + expect(result!.blockId).toBe('b1') + }) + + it('does NOT treat a document embed link as a mention', () => { + const comment: SeedComment = { + id: 'c2', + author: USER, + targetAccount: SITE, + content: [ + { + block: { + id: 'b1', + text: 'check this doc ', + annotations: [{type: 'Embed', link: `hm://${SITE}/tech-talks/measurement`}], + }, + }, + ], + } + const result = findKmMentionInComment(comment, [SITE]) + expect(result).toBeNull() + }) + + it('detects /:profile embed as a mention', () => { + const comment: SeedComment = { + id: 'c-profile', + author: USER, + targetAccount: SITE, + content: [ + { + block: { + id: 'b1', + text: ' what do you think?', + annotations: [{type: 'Embed', link: `hm://${SITE}/:profile`}], + }, + }, + ], + } + const result = findKmMentionInComment(comment, [SITE]) + expect(result).not.toBeNull() + expect(result!.blockId).toBe('b1') + }) + + it('detects /:profile?v=... embed as a mention', () => { + const comment: SeedComment = { + id: 'c-profile-ver', + author: USER, + targetAccount: SITE, + content: [ + { + block: { + id: 'b1', + text: '', + annotations: [{type: 'Embed', link: `hm://${SITE}/:profile?v=bafy123&l`}], + }, + }, + ], + } + const result = findKmMentionInComment(comment, [SITE]) + expect(result).not.toBeNull() + }) + + it('does NOT treat a nested document path as a mention', () => { + const comment: SeedComment = { + id: 'c3', + author: USER, + targetAccount: SITE, + content: [ + { + block: { + id: 'b1', + text: '', + annotations: [{type: 'Embed', link: `hm://${SITE}/projects/data-flow`}], + }, + }, + ], + } + const result = findKmMentionInComment(comment, [SITE]) + expect(result).toBeNull() + }) +}) + describe('buildThreadReplyMention', () => { it('concatenates all block texts and tags trigger source', () => { const c: SeedComment = { diff --git a/seed-knowledge-manager/agent/systemd/km-poll.service b/seed-knowledge-manager/agent/systemd/km-poll.service index 9f53b5a0bb..ea86f7f18f 100644 --- a/seed-knowledge-manager/agent/systemd/km-poll.service +++ b/seed-knowledge-manager/agent/systemd/km-poll.service @@ -10,6 +10,10 @@ Environment=KM_TRIGGER=poll-cli Environment=SEED_CLI_PATH=/home/km/.local/bin/seed-cli Environment=KM_STATE_DIR=/home/km/km-state Environment=KM_LOGS_DIR=/home/km/km-logs +# Optional live telemetry mirror for the Bun Observability Center. +# Environment=KM_OBS_URL=https://oc.hyper.media/api/ingest +# Environment=KM_OBS_TOKEN=change-me +# Environment=KM_OBS_FULL_PAYLOAD=0 # Standalone polling driver. Bypasses nanobot — DeepSeek call happens # inline, no LLM tool-orchestration. Whole loop is deterministic except # for the single chat-completion that drafts the reply text. diff --git a/seed-knowledge-manager/observability-center/README.md b/seed-knowledge-manager/observability-center/README.md new file mode 100644 index 0000000000..65e867cefd --- /dev/null +++ b/seed-knowledge-manager/observability-center/README.md @@ -0,0 +1,43 @@ +# KM Observability Center + +Bun app for `oc.hyper.media`. It ingests Knowledge Manager telemetry, imports historical JSONL audit/state files, and provides a dashboard for answering: + +- why KM did or did not answer a comment; +- how many mention actors are alive; +- what KM is currently doing; +- how sync/preflight checks have behaved. + +## Run locally + +```bash +cd seed-knowledge-manager/observability-center +bun install +OC_INGEST_TOKEN=dev OC_DB_PATH=./data/oc.sqlite bun dev +``` + +Open `http://localhost:4317`. + +## Import existing KM logs + +```bash +OC_DB_PATH=./data/oc.sqlite \ +OC_IMPORT_KM_LOGS_DIR=/home/km/km-logs \ +OC_IMPORT_KM_STATE_DIR=/home/km/km-state \ +bun run import +``` + +The server also runs this import periodically when the import env vars are set. +Historical imports compact payloads by default; set `OC_IMPORT_FULL_PAYLOAD=1` only for short debugging sessions where full imported audit payloads must be copied into SQLite. + +## Ingest from KM + +Set these on the KM poll service: + +```bash +KM_OBS_URL=https://oc.hyper.media/api/ingest +KM_OBS_TOKEN=<shared secret> +# Optional. Default stores metadata + previews only. +KM_OBS_FULL_PAYLOAD=0 +``` + +The OC server validates `Authorization: Bearer <token>` when `OC_INGEST_TOKEN` is configured. diff --git a/seed-knowledge-manager/observability-center/package.json b/seed-knowledge-manager/observability-center/package.json new file mode 100644 index 0000000000..12bd2a36e5 --- /dev/null +++ b/seed-knowledge-manager/observability-center/package.json @@ -0,0 +1,22 @@ +{ + "name": "@km/observability-center", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --hot src/main.ts serve", + "start": "NODE_ENV=production bun src/main.ts serve", + "import": "bun src/main.ts import", + "build": "bun build src/main.ts --target bun --outdir dist --minify --sourcemap=linked", + "typecheck": "bunx tsc -p tsconfig.json --noEmit", + "test": "bun test src", + "check": "bun typecheck && bun test && bun build" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0" + }, + "engines": { + "bun": ">=1.0" + } +} diff --git a/seed-knowledge-manager/observability-center/src/__tests__/schema-store.test.ts b/seed-knowledge-manager/observability-center/src/__tests__/schema-store.test.ts new file mode 100644 index 0000000000..a609cdcc64 --- /dev/null +++ b/seed-knowledge-manager/observability-center/src/__tests__/schema-store.test.ts @@ -0,0 +1,59 @@ +import {afterEach, expect, test} from 'bun:test' +import {mkdtempSync, rmSync, writeFileSync, mkdirSync} from 'node:fs' +import {join} from 'node:path' +import {tmpdir} from 'node:os' +import {importKmArtifacts} from '../importer.js' +import {parseEnvelope} from '../schema.js' +import {openStore, type Store} from '../store.js' + +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, {recursive: true, force: true}) +}) + +test('stores ingested runs, events, actors, and comment timelines', () => { + const {dir, store} = tempStore() + dirs.push(dir) + const events = parseEnvelope({ + events: [ + {kind: 'run_meta', runId: 'run-1', data: {runId: 'run-1', trigger: 'poll-cli', startedAt: '2026-01-01T00:00:00.000Z'}}, + {kind: 'trace', runId: 'run-1', data: {ts: '2026-01-01T00:00:01.000Z', level: 'info', event: 'placeholder_posted', data: {commentId: 'author/tsid'}}}, + {kind: 'machine_snapshot', runId: 'run-1', data: {ts: '2026-01-01T00:00:02.000Z', event: 'actor_snapshot', actorId: 'author/tsid', mentionId: 'author/tsid', commentId: 'author/tsid', state: 'agent_running', status: 'active'}}, + ], + }) + for (const event of events) store.record(event) + + expect(store.listRuns()[0]?.runId).toBe('run-1') + expect(store.commentTimeline('author/tsid').map((event) => event.eventName)).toEqual(['placeholder_posted', 'actor_snapshot']) + expect(store.liveSummary().aliveActors).toBe(1) +}) + +test('imports audit logs and machine logs idempotently', () => { + const {dir, store} = tempStore() + dirs.push(dir) + const logsDir = join(dir, 'logs') + const runDir = join(logsDir, 'runs', '2026__poll__run-2') + mkdirSync(runDir, {recursive: true}) + writeFileSync(join(runDir, 'meta.json'), JSON.stringify({runId: 'run-2', trigger: 'poll-cli', startedAt: '2026-01-01T00:00:00.000Z'})) + writeFileSync(join(runDir, 'trace.jsonl'), JSON.stringify({ts: '2026-01-01T00:00:01.000Z', level: 'info', event: 'reply_finalised', data: {commentId: 'a/b'}}) + '\n') + writeFileSync(join(runDir, 'llm.jsonl'), JSON.stringify({ts_start: '2026-01-01T00:00:01.500Z', model: 'deepseek-chat', prompt_messages: [{content: 'do not import'}], completion: 'ok'}) + '\n') + const stateDir = join(dir, 'state') + mkdirSync(join(stateDir, 'machines'), {recursive: true}) + writeFileSync(join(stateDir, 'machines', 'a_b.jsonl'), JSON.stringify({ts: '2026-01-01T00:00:02.000Z', type: 'ENQUEUE', initialMention: {commentId: 'a/b', docId: 'hm://doc'}}) + '\n') + + const first = importKmArtifacts(store, {logsDir, stateDir}) + const second = importKmArtifacts(store, {logsDir, stateDir}) + + expect(first.events).toBe(4) + expect(second.events).toBe(4) + expect(store.listEvents({limit: 20}).length).toBe(4) + expect(store.commentTimeline('a/b').length).toBe(2) + const llm = store.listEvents({limit: 20}).find((event) => event.kind === 'llm') + expect(JSON.parse(String(llm?.payloadJson)).prompt_messages).toBeUndefined() +}) + +function tempStore(): {dir: string; store: Store} { + const dir = mkdtempSync(join(tmpdir(), 'km-oc-')) + return {dir, store: openStore(join(dir, 'oc.sqlite'))} +} diff --git a/seed-knowledge-manager/observability-center/src/dashboard.ts b/seed-knowledge-manager/observability-center/src/dashboard.ts new file mode 100644 index 0000000000..1b1924a956 --- /dev/null +++ b/seed-knowledge-manager/observability-center/src/dashboard.ts @@ -0,0 +1,176 @@ +export function renderDashboard(): string { + return `<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>KM Observability Center + + + +
+
+
oc.hyper.media / backend daemon first
+

KM operations radar

+
+
connecting
+
+
+
+
alive actors
machines not terminal
+
active runs
runs without an end
+
events loaded
current view
+
last update
server clock
+
+
+

Ask by comment link / id

+
+
+
+ +
+ + +` +} diff --git a/seed-knowledge-manager/observability-center/src/importer.ts b/seed-knowledge-manager/observability-center/src/importer.ts new file mode 100644 index 0000000000..f77ff658b0 --- /dev/null +++ b/seed-knowledge-manager/observability-center/src/importer.ts @@ -0,0 +1,205 @@ +import {existsSync, readdirSync, readFileSync, statSync} from 'node:fs' +import {basename, join} from 'node:path' +import type {IngestEvent, EventKind} from './schema.js' +import type {Store} from './store.js' + +export type ImportOptions = { + logsDir?: string | null + stateDir?: string | null + fullPayload?: boolean +} + +export type ImportResult = { + events: number + runs: number + machineFiles: number +} + +const LOG_FILES: Array<{file: string; kind: EventKind}> = [ + {file: 'trace.jsonl', kind: 'trace'}, + {file: 'llm.jsonl', kind: 'llm'}, + {file: 'tools.jsonl', kind: 'tool'}, + {file: 'seed-cli.jsonl', kind: 'seed_cli'}, +] + +export function importKmArtifacts(store: Store, opts: ImportOptions): ImportResult { + const result: ImportResult = {events: 0, runs: 0, machineFiles: 0} + if (opts.logsDir) importRuns(store, opts.logsDir, opts.fullPayload === true, result) + if (opts.stateDir) importMachines(store, opts.stateDir, result) + return result +} + +function importRuns(store: Store, logsDir: string, fullPayload: boolean, result: ImportResult): void { + const runsDir = join(logsDir, 'runs') + if (!existsSync(runsDir)) return + for (const runSlug of safeReaddir(runsDir)) { + const runDir = join(runsDir, runSlug) + if (!safeStat(runDir)?.isDirectory()) continue + const metaPath = join(runDir, 'meta.json') + const meta = readJson(metaPath) + const runId = recordObject(meta)?.runId + if (meta) { + store.record({kind: 'run_meta', runId: stringOrNull(runId), source: 'km-import', importedKey: `${metaPath}:meta`, data: meta}) + result.events++ + result.runs++ + } + for (const {file, kind} of LOG_FILES) { + const path = join(runDir, file) + for (const {lineNo, value} of readJsonl(path)) { + const event: IngestEvent = { + kind, + runId: stringOrNull(runId), + source: 'km-import', + importedKey: `${path}:${lineNo}`, + data: fullPayload ? value : compactPayload(kind, value), + } + store.record(event) + result.events++ + } + } + } +} + +function importMachines(store: Store, stateDir: string, result: ImportResult): void { + const machinesDir = join(stateDir, 'machines') + if (!existsSync(machinesDir)) return + for (const file of safeReaddir(machinesDir)) { + if (!file.endsWith('.jsonl')) continue + result.machineFiles++ + const actorId = basename(file, '.jsonl') + const path = join(machinesDir, file) + for (const {lineNo, value} of readJsonl(path)) { + const row = recordObject(value) ?? {} + const initialMention = recordObject(row.initialMention) + const payload = recordObject(row.payload) ?? {} + const mention = initialMention ?? recordObject(payload.mention) ?? null + const data = { + event: row.initialMention ? 'actor_persisted_enqueue' : 'persisted_event', + type: typeof row.type === 'string' ? row.type : undefined, + ts: typeof row.ts === 'string' ? row.ts : undefined, + actorId, + mentionId: actorId, + commentId: stringOrNull(recordObject(mention)?.commentId), + docId: stringOrNull(recordObject(mention)?.docId), + payload, + initialMention, + } + store.record({ + kind: 'machine_event', + source: 'km-import', + importedKey: `${path}:${lineNo}`, + ts: typeof row.ts === 'string' ? row.ts : null, + data, + }) + result.events++ + } + } +} + +function readJson(path: string): unknown | null { + try { + return JSON.parse(readFileSync(path, 'utf8')) + } catch { + return null + } +} + +function readJsonl(path: string): Array<{lineNo: number; value: unknown}> { + if (!existsSync(path)) return [] + return readFileSync(path, 'utf8') + .split('\n') + .map((line, index) => ({line, lineNo: index + 1})) + .filter(({line}) => line.trim().length > 0) + .flatMap(({line, lineNo}) => { + try { + return [{lineNo, value: JSON.parse(line)}] + } catch { + return [] + } + }) +} + +function safeReaddir(path: string): string[] { + try { + return readdirSync(path) + } catch { + return [] + } +} + +function safeStat(path: string): ReturnType | null { + try { + return statSync(path) + } catch { + return null + } +} + +function recordObject(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : null +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function compactPayload(kind: EventKind, value: unknown): unknown { + const record = recordObject(value) ?? {value} + if (kind === 'llm') { + return compactObject({ + ts_start: record.ts_start, + ts_end: record.ts_end, + latency_ms: record.latency_ms, + model: record.model, + completion: truncate(record.completion, 1_200), + reasoning: truncate(record.reasoning, 300), + usage: limitDeep(record.usage, 2), + tool_call_count: Array.isArray(record.tool_calls) ? record.tool_calls.length : undefined, + }) + } + if (kind === 'seed_cli') { + return compactObject({ + ts_start: record.ts_start, + ts_end: record.ts_end, + latency_ms: record.latency_ms, + argv: Array.isArray(record.argv) ? record.argv.map((item) => truncate(item, 300)) : undefined, + exit_code: record.exit_code, + stdout: truncate(record.stdout, 1_200), + stderr: truncate(record.stderr, 1_200), + }) + } + if (kind === 'tool') { + return compactObject({ + ts_start: record.ts_start, + ts_end: record.ts_end, + latency_ms: record.latency_ms, + tool: record.tool, + args: limitDeep(record.args, 3), + result: limitDeep(record.result, 3), + error: truncate(record.error, 1_200), + }) + } + if (kind === 'trace') { + return compactObject({ts: record.ts, level: record.level, event: record.event, data: limitDeep(record.data, 4)}) + } + return limitDeep(record, 5) +} + +function limitDeep(value: unknown, depth: number): unknown { + if (value == null || typeof value === 'number' || typeof value === 'boolean') return value + if (typeof value === 'string') return truncate(value, 300) + if (depth <= 0) return Array.isArray(value) ? `[${value.length} items]` : recordObject(value) ? `{${Object.keys(value).length} keys}` : String(value) + if (Array.isArray(value)) return value.slice(0, 20).map((item) => limitDeep(item, depth - 1)) + const record = recordObject(value) + if (!record) return String(value) + return Object.fromEntries(Object.entries(record).slice(0, 40).map(([key, child]) => [key, limitDeep(child, depth - 1)])) +} + +function truncate(value: unknown, max: number): string | undefined { + if (typeof value !== 'string') return undefined + return value.length <= max ? value : `${value.slice(0, max)}…` +} + +function compactObject(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, child]) => child !== undefined)) +} diff --git a/seed-knowledge-manager/observability-center/src/main.ts b/seed-knowledge-manager/observability-center/src/main.ts new file mode 100644 index 0000000000..4d7aa5e2f1 --- /dev/null +++ b/seed-knowledge-manager/observability-center/src/main.ts @@ -0,0 +1,42 @@ +import {mkdirSync} from 'node:fs' +import {dirname, resolve} from 'node:path' +import {importKmArtifacts} from './importer.js' +import {serve} from './server.js' +import {openStore} from './store.js' + +const command = process.argv[2] ?? 'serve' +const dbPath = resolve(process.env.OC_DB_PATH ?? './data/oc.sqlite') +mkdirSync(dirname(dbPath), {recursive: true}) +const store = openStore(dbPath) + +if (command === 'import') { + const result = importKmArtifacts(store, { + logsDir: process.env.OC_IMPORT_KM_LOGS_DIR, + stateDir: process.env.OC_IMPORT_KM_STATE_DIR, + fullPayload: enabled(process.env.OC_IMPORT_FULL_PAYLOAD), + }) + console.log(JSON.stringify(result, null, 2)) +} else if (command === 'serve') { + const server = serve(store, { + hostname: process.env.OC_HTTP_HOSTNAME ?? '0.0.0.0', + port: parsePort(process.env.OC_HTTP_PORT, 4317), + ingestToken: process.env.OC_INGEST_TOKEN, + importLogsDir: process.env.OC_IMPORT_KM_LOGS_DIR, + importStateDir: process.env.OC_IMPORT_KM_STATE_DIR, + importFullPayload: enabled(process.env.OC_IMPORT_FULL_PAYLOAD), + importIntervalMs: parsePort(process.env.OC_IMPORT_INTERVAL_MS, 10_000), + }) + console.log(`km observability center listening on http://${server.hostname}:${server.port} db=${dbPath}`) +} else { + console.error(`unknown command: ${command}`) + process.exit(2) +} + +function parsePort(raw: string | undefined, fallback: number): number { + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback +} + +function enabled(raw: string | undefined): boolean { + return /^(1|true|yes)$/i.test(raw ?? '') +} diff --git a/seed-knowledge-manager/observability-center/src/schema.ts b/seed-knowledge-manager/observability-center/src/schema.ts new file mode 100644 index 0000000000..0bbbd36a70 --- /dev/null +++ b/seed-knowledge-manager/observability-center/src/schema.ts @@ -0,0 +1,199 @@ +export type EventKind = + | 'run_meta' + | 'trace' + | 'llm' + | 'tool' + | 'seed_cli' + | 'machine_event' + | 'machine_snapshot' + +export type IngestEvent = { + kind: EventKind + runId?: string | null + ts?: string | null + source?: string | null + importedKey?: string | null + data: unknown +} + +export type IngestEnvelope = IngestEvent | {events: IngestEvent[]} + +export type NormalizedEvent = { + kind: EventKind + runId: string | null + ts: string + source: string + importedKey: string | null + eventName: string | null + level: string | null + commentId: string | null + mentionId: string | null + actorId: string | null + placeholderId: string | null + state: string | null + status: string | null + preview: string | null + payload: unknown +} + +export type RunRow = { + runId: string + trigger: string | null + startedAt: string | null + endedAt: string | null + status: string | null + wallMs: number | null + seedSite: string | null + kmAccountId: string | null + countersJson: string | null +} + +export type LiveSummary = { + aliveActors: number + activeRuns: number + latestEvents: Array> + updatedAt: string +} + +export const EVENT_KINDS: readonly EventKind[] = [ + 'run_meta', + 'trace', + 'llm', + 'tool', + 'seed_cli', + 'machine_event', + 'machine_snapshot', +] as const + +export function parseEnvelope(value: unknown): IngestEvent[] { + if (!isRecord(value)) throw new Error('ingest payload must be an object') + if (Array.isArray(value.events)) return value.events.map(parseEvent) + return [parseEvent(value)] +} + +function parseEvent(value: unknown): IngestEvent { + if (!isRecord(value)) throw new Error('event must be an object') + if (!isEventKind(value.kind)) throw new Error(`invalid event kind: ${String(value.kind)}`) + if (!('data' in value)) throw new Error('event.data is required') + return { + kind: value.kind, + runId: optionalString(value.runId), + ts: optionalString(value.ts), + source: optionalString(value.source), + importedKey: optionalString(value.importedKey), + data: value.data, + } +} + +function isEventKind(value: unknown): value is EventKind { + return typeof value === 'string' && (EVENT_KINDS as readonly string[]).includes(value) +} + +function optionalString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function normalizeEvent(event: IngestEvent): NormalizedEvent { + const data = isRecord(event.data) ? event.data : {value: event.data} + const ts = event.ts ?? pickString(data, ['ts', 'ts_start', 'startedAt', 'start']) ?? new Date().toISOString() + const runId = event.runId ?? pickString(data, ['runId', 'run_id']) + const eventName = pickString(data, ['event', 'type', 'name', 'tool']) ?? event.kind + const commentId = findStringDeep(data, ['commentId', 'comment_id']) + const mentionId = findStringDeep(data, ['mentionId', 'mention_id']) + const placeholderId = findStringDeep(data, ['placeholderId', 'placeholder_id']) + const actorId = findStringDeep(data, ['actorId', 'actor_id']) ?? mentionId + const state = stringifyState(findDeep(data, ['state', 'stateValue', 'value'])) + const status = pickString(data, ['status']) + return { + kind: event.kind, + runId, + ts, + source: event.source ?? 'km', + importedKey: event.importedKey ?? null, + eventName, + level: pickString(data, ['level']), + commentId, + mentionId, + actorId, + placeholderId, + state, + status, + preview: summarizePreview(event.kind, data), + payload: event.data, + } +} + +function pickString(record: Record, keys: string[]): string | null { + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value.length > 0) return value + } + return null +} + +function findStringDeep(value: unknown, keys: string[]): string | null { + const found = findDeep(value, keys) + return typeof found === 'string' && found.length > 0 ? found : null +} + +function findDeep(value: unknown, keys: string[], depth = 0): unknown { + if (depth > 5 || !isRecord(value)) return undefined + for (const key of keys) { + if (key in value) return value[key] + } + for (const child of Object.values(value)) { + const found = findDeep(child, keys, depth + 1) + if (found !== undefined) return found + } + return undefined +} + +function stringifyState(value: unknown): string | null { + if (value == null) return null + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function summarizePreview(kind: EventKind, data: Record): string | null { + if (kind === 'trace') { + const event = pickString(data, ['event']) ?? 'trace' + const nested = isRecord(data.data) ? data.data : {} + const reason = pickString(nested, ['reason', 'message']) + const commentId = pickString(nested, ['commentId']) + return [event, commentId, reason].filter(Boolean).join(' · ') + } + if (kind === 'llm') { + const model = pickString(data, ['model']) + const completion = pickString(data, ['completion']) + return [model, completion ? truncate(completion, 180) : null].filter(Boolean).join(' · ') + } + if (kind === 'seed_cli') { + const argv = Array.isArray(data.argv) ? data.argv.join(' ') : null + const exit = typeof data.exit_code === 'number' ? `exit ${data.exit_code}` : null + return [exit, argv ? truncate(argv, 180) : null].filter(Boolean).join(' · ') + } + if (kind === 'machine_event' || kind === 'machine_snapshot') { + const event = pickString(data, ['event', 'type']) + const state = stringifyState(data.state ?? data.stateValue) + return [event, state].filter(Boolean).join(' → ') + } + try { + return truncate(JSON.stringify(data), 240) + } catch { + return null + } +} + +export function truncate(value: string, max: number): string { + if (value.length <= max) return value + return `${value.slice(0, max)}…` +} diff --git a/seed-knowledge-manager/observability-center/src/server.ts b/seed-knowledge-manager/observability-center/src/server.ts new file mode 100644 index 0000000000..1768d1838d --- /dev/null +++ b/seed-knowledge-manager/observability-center/src/server.ts @@ -0,0 +1,143 @@ +import {importKmArtifacts} from './importer.js' +import {parseEnvelope} from './schema.js' +import type {Store} from './store.js' +import {renderDashboard} from './dashboard.js' + +export type ServerConfig = { + hostname: string + port: number + ingestToken?: string | null + importLogsDir?: string | null + importStateDir?: string | null + importFullPayload: boolean + importIntervalMs: number +} + +export function serve(store: Store, config: ServerConfig): ReturnType { + const streams = new Set>() + const encoder = new TextEncoder() + const sendSse = (event: string, data: unknown) => { + const payload = encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + for (const stream of streams) { + try { + stream.enqueue(payload) + } catch { + streams.delete(stream) + } + } + } + + if (config.importIntervalMs > 0 && (config.importLogsDir || config.importStateDir)) { + setInterval(() => { + try { + const result = importKmArtifacts(store, {logsDir: config.importLogsDir, stateDir: config.importStateDir, fullPayload: config.importFullPayload}) + if (result.events > 0) sendSse('import', result) + } catch (err) { + console.error('import failed', err) + } + }, config.importIntervalMs).unref?.() + } + + return Bun.serve({ + hostname: config.hostname, + port: config.port, + async fetch(req) { + const url = new URL(req.url) + try { + if (req.method === 'GET' && url.pathname === '/') { + return html(renderDashboard()) + } + if (req.method === 'GET' && url.pathname === '/api/live') { + return json(store.liveSummary()) + } + if (req.method === 'GET' && url.pathname === '/api/runs') { + return json({runs: store.listRuns(parseLimit(url.searchParams.get('limit'), 50))}) + } + if (req.method === 'GET' && url.pathname === '/api/events') { + return json({ + events: store.listEvents({ + limit: parseLimit(url.searchParams.get('limit'), 200), + commentId: stringParam(url.searchParams.get('commentId')), + runId: stringParam(url.searchParams.get('runId')), + actorId: stringParam(url.searchParams.get('actorId')), + }), + }) + } + if (req.method === 'GET' && url.pathname.startsWith('/api/comments/') && url.pathname.endsWith('/timeline')) { + const encoded = url.pathname.slice('/api/comments/'.length, -'/timeline'.length) + return json({events: store.commentTimeline(decodeURIComponent(encoded))}) + } + if (req.method === 'GET' && url.pathname === '/api/stream') { + let streamController: ReadableStreamDefaultController | null = null + const stream = new ReadableStream({ + start(controller) { + streamController = controller + streams.add(controller) + controller.enqueue(encoder.encode(`event: summary\ndata: ${JSON.stringify(store.liveSummary())}\n\n`)) + }, + cancel() { + if (streamController) streams.delete(streamController) + }, + }) + return new Response(stream, { + headers: { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + }, + }) + } + if (req.method === 'POST' && url.pathname === '/api/ingest') { + const auth = authorize(req, config.ingestToken) + if (auth) return auth + const body = await req.json().catch(() => null) + const events = parseEnvelope(body) + const normalized = events.map((event) => store.record(event)) + for (const event of normalized) sendSse('event', event) + sendSse('summary', store.liveSummary()) + return json({ok: true, events: normalized.length}) + } + if (req.method === 'POST' && url.pathname === '/api/import') { + const auth = authorize(req, config.ingestToken) + if (auth) return auth + const result = importKmArtifacts(store, {logsDir: config.importLogsDir, stateDir: config.importStateDir, fullPayload: config.importFullPayload}) + if (result.events > 0) sendSse('import', result) + return json({ok: true, ...result}) + } + return json({error: 'not found'}, 404) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return json({error: message}, 400) + } + }, + }) +} + +function authorize(req: Request, token?: string | null): Response | null { + if (!token) return null + const bearer = req.headers.get('authorization')?.replace(/^Bearer\s+/i, '') + const header = req.headers.get('x-oc-token') + if (bearer === token || header === token) return null + return json({error: 'unauthorized'}, 401) +} + +function parseLimit(raw: string | null, fallback: number): number { + const value = Number(raw) + if (!Number.isFinite(value) || value <= 0) return fallback + return Math.min(Math.floor(value), 1000) +} + +function stringParam(value: string | null): string | undefined { + return value && value.length > 0 ? value : undefined +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: {'content-type': 'application/json; charset=utf-8'}, + }) +} + +function html(value: string): Response { + return new Response(value, {headers: {'content-type': 'text/html; charset=utf-8'}}) +} diff --git a/seed-knowledge-manager/observability-center/src/store.ts b/seed-knowledge-manager/observability-center/src/store.ts new file mode 100644 index 0000000000..cd28ee59fa --- /dev/null +++ b/seed-knowledge-manager/observability-center/src/store.ts @@ -0,0 +1,225 @@ +import {Database, type SQLQueryBindings} from 'bun:sqlite' +import {mkdirSync} from 'node:fs' +import {dirname} from 'node:path' +import type {IngestEvent, LiveSummary, NormalizedEvent, RunRow} from './schema.js' +import {isRecord, normalizeEvent} from './schema.js' + +export type Store = ReturnType + +export function openStore(path: string) { + if (path !== ':memory:') mkdirSync(dirname(path), {recursive: true}) + const db = new Database(path, {create: true}) + db.exec('PRAGMA journal_mode = WAL;') + db.exec('PRAGMA foreign_keys = ON;') + db.exec(` + CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, + trigger TEXT, + started_at TEXT, + ended_at TEXT, + status TEXT, + wall_ms INTEGER, + seed_site TEXT, + km_account_id TEXT, + counters_json TEXT, + meta_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + imported_key TEXT UNIQUE, + source TEXT NOT NULL, + kind TEXT NOT NULL, + run_id TEXT, + ts TEXT NOT NULL, + event_name TEXT, + level TEXT, + comment_id TEXT, + mention_id TEXT, + actor_id TEXT, + placeholder_id TEXT, + state TEXT, + status TEXT, + preview TEXT, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + CREATE INDEX IF NOT EXISTS events_comment_id_idx ON events(comment_id, ts); + CREATE INDEX IF NOT EXISTS events_run_id_idx ON events(run_id, ts); + CREATE INDEX IF NOT EXISTS events_actor_id_idx ON events(actor_id, ts); + CREATE INDEX IF NOT EXISTS events_kind_idx ON events(kind, ts); + CREATE TABLE IF NOT EXISTS actors ( + actor_id TEXT PRIMARY KEY, + run_id TEXT, + mention_id TEXT, + comment_id TEXT, + state TEXT, + status TEXT, + alive INTEGER NOT NULL, + snapshot_json TEXT, + updated_at TEXT NOT NULL + ); + `) + + const insertEvent = db.prepare(` + INSERT OR IGNORE INTO events ( + imported_key, source, kind, run_id, ts, event_name, level, comment_id, mention_id, + actor_id, placeholder_id, state, status, preview, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + const upsertRun = db.prepare(` + INSERT INTO runs ( + run_id, trigger, started_at, ended_at, status, wall_ms, seed_site, km_account_id, + counters_json, meta_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO UPDATE SET + trigger=excluded.trigger, + started_at=COALESCE(excluded.started_at, runs.started_at), + ended_at=COALESCE(excluded.ended_at, runs.ended_at), + status=COALESCE(excluded.status, runs.status), + wall_ms=COALESCE(excluded.wall_ms, runs.wall_ms), + seed_site=COALESCE(excluded.seed_site, runs.seed_site), + km_account_id=COALESCE(excluded.km_account_id, runs.km_account_id), + counters_json=COALESCE(excluded.counters_json, runs.counters_json), + meta_json=excluded.meta_json, + updated_at=excluded.updated_at + `) + const upsertActor = db.prepare(` + INSERT INTO actors (actor_id, run_id, mention_id, comment_id, state, status, alive, snapshot_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(actor_id) DO UPDATE SET + run_id=COALESCE(excluded.run_id, actors.run_id), + mention_id=COALESCE(excluded.mention_id, actors.mention_id), + comment_id=COALESCE(excluded.comment_id, actors.comment_id), + state=COALESCE(excluded.state, actors.state), + status=COALESCE(excluded.status, actors.status), + alive=excluded.alive, + snapshot_json=excluded.snapshot_json, + updated_at=excluded.updated_at + `) + + function record(event: IngestEvent): NormalizedEvent { + const normalized = normalizeEvent(event) + const payloadJson = JSON.stringify(normalized.payload) + insertEvent.run( + normalized.importedKey, + normalized.source, + normalized.kind, + normalized.runId, + normalized.ts, + normalized.eventName, + normalized.level, + normalized.commentId, + normalized.mentionId, + normalized.actorId, + normalized.placeholderId, + normalized.state, + normalized.status, + normalized.preview, + payloadJson, + ) + if (normalized.kind === 'run_meta') upsertRunFromPayload(normalized) + if (normalized.kind === 'machine_event' || normalized.kind === 'machine_snapshot') upsertActorFromPayload(normalized) + return normalized + } + + function upsertRunFromPayload(event: NormalizedEvent): void { + if (!event.runId || !isRecord(event.payload)) return + const meta = event.payload + const counters = meta.counters ? JSON.stringify(meta.counters) : null + upsertRun.run( + event.runId, + stringValue(meta.trigger), + stringValue(meta.startedAt) ?? stringValue(meta.start), + stringValue(meta.endedAt) ?? stringValue(meta.end), + stringValue(meta.status), + numberValue(meta.wallMs) ?? numberValue(meta.wall_ms), + stringValue(meta.seedSite), + stringValue(meta.kmAccountId), + counters, + JSON.stringify(meta), + new Date().toISOString(), + ) + } + + function upsertActorFromPayload(event: NormalizedEvent): void { + const actorId = event.actorId ?? event.mentionId + if (!actorId) return + const eventName = event.eventName ?? '' + const done = event.status === 'done' || eventName === 'actor_stopped' || eventName === 'done' + const alive = done ? 0 : 1 + upsertActor.run( + actorId, + event.runId, + event.mentionId, + event.commentId, + event.state, + event.status, + alive, + JSON.stringify(event.payload), + event.ts, + ) + } + + function listRuns(limit = 50): RunRow[] { + return db + .query(` + SELECT run_id as runId, trigger, started_at as startedAt, ended_at as endedAt, status, + wall_ms as wallMs, seed_site as seedSite, km_account_id as kmAccountId, + counters_json as countersJson + FROM runs ORDER BY COALESCE(started_at, updated_at) DESC LIMIT ? + `) + .all(limit) as RunRow[] + } + + function listEvents(opts: {limit?: number; commentId?: string; runId?: string; actorId?: string} = {}) { + const limit = opts.limit ?? 200 + const where: string[] = [] + const args: SQLQueryBindings[] = [] + if (opts.commentId) { + where.push('comment_id = ?') + args.push(opts.commentId) + } + if (opts.runId) { + where.push('run_id = ?') + args.push(opts.runId) + } + if (opts.actorId) { + where.push('actor_id = ?') + args.push(opts.actorId) + } + const sql = ` + SELECT id, source, kind, run_id as runId, ts, event_name as eventName, level, + comment_id as commentId, mention_id as mentionId, actor_id as actorId, + placeholder_id as placeholderId, state, status, preview, payload_json as payloadJson + FROM events ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY ts DESC, id DESC LIMIT ? + ` + return db.query(sql).all(...args, limit) as Array> + } + + function commentTimeline(commentId: string) { + return listEvents({commentId, limit: 500}).reverse() + } + + function liveSummary(): LiveSummary { + const alive = db.query('SELECT COUNT(*) as count FROM actors WHERE alive = 1').get() as {count: number} + const active = db.query("SELECT COUNT(*) as count FROM runs WHERE ended_at IS NULL OR status IS NULL").get() as {count: number} + return { + aliveActors: alive.count, + activeRuns: active.count, + latestEvents: listEvents({limit: 30}), + updatedAt: new Date().toISOString(), + } + } + + return {db, record, listRuns, listEvents, commentTimeline, liveSummary} +} + +function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function numberValue(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} diff --git a/seed-knowledge-manager/observability-center/systemd/km-observability-center.service b/seed-knowledge-manager/observability-center/systemd/km-observability-center.service new file mode 100644 index 0000000000..009fcdf673 --- /dev/null +++ b/seed-knowledge-manager/observability-center/systemd/km-observability-center.service @@ -0,0 +1,26 @@ +[Unit] +Description=Knowledge Manager Observability Center +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=km +WorkingDirectory=/home/km/observability-center +Environment=NODE_ENV=production +Environment=OC_HTTP_HOSTNAME=127.0.0.1 +Environment=OC_HTTP_PORT=4317 +Environment=OC_DB_PATH=/home/km/oc-data/oc.sqlite +Environment=OC_IMPORT_KM_LOGS_DIR=/home/km/km-logs +Environment=OC_IMPORT_KM_STATE_DIR=/home/km/km-state +Environment=OC_IMPORT_INTERVAL_MS=10000 +# Keep compact historical imports unless explicitly debugging. +Environment=OC_IMPORT_FULL_PAYLOAD=0 +# Set the same token in km-poll.service as KM_OBS_TOKEN. +Environment=OC_INGEST_TOKEN=change-me +ExecStart=/home/km/.bun/bin/bun src/main.ts serve +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/seed-knowledge-manager/observability-center/tsconfig.json b/seed-knowledge-manager/observability-center/tsconfig.json new file mode 100644 index 0000000000..318bc1d71a --- /dev/null +++ b/seed-knowledge-manager/observability-center/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": false + }, + "include": ["src/**/*"] +}