@johpaz/hive-sdk 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +1 -1
  3. package/docs/API-TOOLS-SKILLS-CHANNELS.md +83 -4
  4. package/docs/SECURITY-GUARDRAILS.md +21 -1
  5. package/docs/UPGRADING.md +20 -0
  6. package/package.json +1 -1
  7. package/packages/cli/templates/hive-app/hive.config.ts +7 -0
  8. package/packages/cli/templates/hive-app/src/main.ts +3 -0
  9. package/packages/core/src/agent/agent-loop.ts +3 -1
  10. package/packages/core/src/agent/capability-search.ts +72 -31
  11. package/packages/core/src/agent/context-compiler.ts +6 -2
  12. package/packages/core/src/agent/reflector.ts +23 -10
  13. package/packages/core/src/channels/index.ts +1 -0
  14. package/packages/core/src/channels/manager.ts +40 -0
  15. package/packages/core/src/channels/whatsapp-cloud/channel.ts +334 -0
  16. package/packages/core/src/channels/whatsapp-cloud/client.ts +368 -0
  17. package/packages/core/src/channels/whatsapp-cloud/index.ts +3 -0
  18. package/packages/core/src/channels/whatsapp-cloud/webhook.ts +235 -0
  19. package/packages/core/src/channels/whatsapp.ts +58 -28
  20. package/packages/core/src/config/loader.ts +3 -1
  21. package/packages/core/src/gateway/server.ts +24 -0
  22. package/packages/core/src/index.ts +17 -0
  23. package/packages/core/src/mcp/MCPClient.ts +9 -0
  24. package/packages/core/src/mcp/config.ts +2 -1
  25. package/packages/core/src/mcp/transports/index.ts +44 -1
  26. package/packages/core/src/storage/catalog.ts +354 -0
  27. package/packages/core/src/storage/causal-events.ts +50 -19
  28. package/packages/core/src/storage/hive.ts +6 -1
  29. package/packages/core/src/storage/index.ts +14 -1
  30. package/packages/core/src/storage/seed.ts +161 -128
  31. package/packages/core/src/tools/cron/index.ts +2 -2
  32. package/packages/core/src/voice/index.ts +2 -0
package/CHANGELOG.md CHANGED
@@ -17,9 +17,94 @@
17
17
  procedencia, hash y una prueba funcional del OOXML generado.
18
18
  - Añadidas las guías `docs/UPGRADING.md` y
19
19
  `docs/SECURITY-GUARDRAILS.md` para operación y auditoría.
20
+ - **Requiere `@johpaz/hive-db` ^0.5.1** (antes ^0.4.0). Trae las lecturas del
21
+ log causal acotadas por agente (`agents` en `causalThread`, `toolStats` y
22
+ `buildAgentContext`), de las que depende el aislamiento entre inquilinos del
23
+ log causal descrito en *Corregido*.
24
+
25
+ ### WhatsApp por la API oficial de Meta
26
+
27
+ - **Canal nuevo `whatsapp_cloud`**: WhatsApp Business por la Cloud API, que es
28
+ lo que puede usar una empresa. Incluye `WhatsAppCloudClient` (texto con
29
+ partido automático en 4096 caracteres, plantillas, audio, marcar leído con
30
+ "escribiendo…" y descarga de medios), las funciones de webhook
31
+ (`verifyWhatsAppSignature`, `verifyWhatsAppChallenge`, `parseWhatsAppWebhook`)
32
+ y el canal `WhatsAppCloudChannel`. Sin dependencias nuevas: sólo `fetch`.
33
+ - El gateway enruta `GET|POST /webhooks/whatsapp-cloud/:accountId` cuando se le
34
+ pasa `channelManager`. Meta exige HTTPS público, así que hace falta un proxy
35
+ inverso o un túnel por delante.
36
+ - La versión del Graph se resuelve en un solo lugar (`META_GRAPH_API_VERSION`,
37
+ por defecto **v26.0**). Cada versión caduca a los ~2 años y Meta redirige en
38
+ silencio a la más vieja que siga viva; tenerla centralizada es lo que evita
39
+ enterarse tarde.
40
+ - Fuera de la ventana de 24 h el canal manda la plantilla de
41
+ `windowFallbackTemplate`, y si no hay ninguna configurada lanza un error que
42
+ lo dice (131047). La narración de progreso no se envía por defecto
43
+ (`sendProgress: false`): desde el 1/10/2026 Meta cobra cada mensaje de
44
+ servicio dentro de la ventana.
45
+ - El parser recorre todas las `entry` y `changes` del webhook, y el canal
46
+ descarta los reintentos de Meta por id de mensaje.
47
+ - **Baileys se carga recién al conectar el canal `whatsapp`.** Antes se
48
+ importaba al cargar el índice del SDK, así que cualquier consumidor —aunque
49
+ no usara canales— se traía Baileys entero y su parche de
50
+ `process.stderr.write` en cada proceso. Quien usa el canal por código QR no
51
+ ve ningún cambio.
52
+
53
+ ### Catálogo compartido entre inquilinos
54
+
55
+ - **El catálogo se instala una sola vez.** `tools`, `skills` y `ethics` dejan de
56
+ copiarse en la partición de cada inquilino: su contenido vive en la colección
57
+ sin prefijo —la misma que ve una instalación local— y cada inquilino guarda
58
+ únicamente lo que activó, en `catalogActivations`. Un enjambre nuevo arranca
59
+ con **cero escrituras** de catálogo, y encender una tool guarda una fila de
60
+ elección en lugar de una copia de la fila entera.
61
+ - **Providers y modelos ya no se resiembran dentro de un inquilino.** En un host
62
+ multi-inquilino el catálogo que vale es el del host, así que `seedAllData()`
63
+ saltea el estático cuando hay tenant activo. Antes borraba y recreaba los 139
64
+ modelos de `SEED_DATA` en la partición de cada enjambre, en cada arranque, para
65
+ que un instante después los pisara el host.
66
+ - Lo que un inquilino **crea** sigue siendo suyo y privado: las tools de un
67
+ endpoint de API, o una skill o un código de ética propios, se escriben en su
68
+ partición como siempre. Editar el contenido de una fila del catálogo también
69
+ deja una copia privada, y borrarla la oculta sólo para él.
70
+ - API nueva en `@johpaz/hive-sdk/storage`: `setCatalogActivation`,
71
+ `clearCatalogActivation`, `listCatalogActivations`, `sharedCatalogCol`,
72
+ `CATALOG_COLLECTIONS` y el tipo `DocStore`, que es lo que ahora devuelve
73
+ `col()` — la clase `Collection` de hive-db lo satisface tal cual, así que no
74
+ cambia nada para quien la recibe.
75
+ - **Sin inquilino en scope no cambia nada**: una instalación local sigue viendo
76
+ una sola partición, con el catálogo y su `active` en la misma fila.
77
+ - **El índice de capacidades también se comparte, y eso destapa dos fallas que
78
+ ya existían.** Todo documento declara ahora su ámbito —el inquilino que lo
79
+ escribió, o `_` si es del catálogo— y `searchCapabilities()` consulta los dos
80
+ cuando hay inquilino activo:
81
+ - Antes el catálogo se indexaba **sin** filtro de inquilino y la búsqueda
82
+ desde un enjambre filtraba **por** su inquilino, así que dentro de un
83
+ enjambre no se encontraba NADA del catálogo: ni una tool ni una skill. El
84
+ agente sólo descubría sus tools de MCP y las de sus endpoints.
85
+ - Y un reindexado del catálogo —el que corre en cada arranque del gateway—
86
+ borraba por `type` a secas, llevándose por delante lo que cada inquilino
87
+ tenía indexado. Ahora el borrado va acotado a su ámbito.
88
+ - Encima de la búsqueda manda la elección: una capacidad que el inquilino
89
+ apagó no se le ofrece, aunque esté en el catálogo y puntúe primero.
90
+ Cubierto por `packages/core/src/agent/capability-search.test.ts` y
91
+ `packages/core/src/storage/catalog.test.ts`.
20
92
 
21
93
  ### Corregido
22
94
 
95
+ - **Con un tenant activo, el log causal se apagaba en lugar de acotarse.**
96
+ `causalThread`, `toolStats` y `buildAgentContext` recorrían todos los shards
97
+ de la base, así que con un tenant en scope `causalReadsEnabled()` las apagaba:
98
+ en un host multi-inquilino el reflector G9 y el contexto causal del
99
+ compilador no corrían nunca. Ahora las tres lecturas van siempre acotadas a
100
+ los agentes que corresponden —el agente del turno, o los del lote de trazas— y
101
+ el apagado desaparece. Además el shard de cada evento pasa a ser
102
+ `causalAgentKey(agentId)`: con tenant lleva el tenant delante (`t_…:agente`,
103
+ la misma forma que los ids del índice BM25), así que dos inquilinos con un
104
+ agente del mismo id ya no comparten shard. Una lista de agentes vacía se salta
105
+ la lectura en vez de pasarse al motor, que la trataría como "todos los
106
+ shards". Cubierto por `test/causal-tenant-scope.test.ts`.
107
+
23
108
  - **`browser_scrape` extraía con una tool que no ve lo que el navegador
24
109
  renderizó.** La skill existe para sitios dinámicos, y su paso de extracción
25
110
  usaba `web_fetch`, que vuelve a pedir la URL al servidor y recibe el HTML sin
@@ -194,6 +279,11 @@
194
279
 
195
280
  ### Cambiado
196
281
 
282
+ - **El `toolStats` del reflector se acota a los agentes del lote**, también sin
283
+ tenant. Antes sumaba el historial de la tool de todos los agentes de la base;
284
+ ahora el de los agentes cuyas trazas se están analizando. Es la misma
285
+ semántica con y sin tenant, y no recorre el log entero.
286
+
197
287
  - **Los tests que manejan un navegador real son opt-in (`BROWSER_TESTS=1`).**
198
288
  Su guarda era `isWebViewSupported()`, que sólo comprueba que exista un binario
199
289
  de Chromium — no que arranque. En un runner de CI (contenedor, a menudo root)
package/README.md CHANGED
@@ -271,4 +271,4 @@ npm view @johpaz/hive-sdk dist-tags # verificar después del release
271
271
 
272
272
  ---
273
273
 
274
- *Hive SDK v0.4.6 — MIT*
274
+ *Hive SDK v0.4.8 — MIT*
@@ -353,6 +353,7 @@ await manager.initialize();
353
353
  import {
354
354
  TelegramChannel,
355
355
  DiscordChannel,
356
+ WhatsAppCloudChannel,
356
357
  WhatsAppChannel,
357
358
  SlackChannel,
358
359
  WebChatChannel,
@@ -364,8 +365,20 @@ const telegram = new TelegramChannel({ botToken: process.env.TELEGRAM_BOT_TOKEN!
364
365
  // Discord
365
366
  const discord = new DiscordChannel({ botToken: process.env.DISCORD_BOT_TOKEN! });
366
367
 
367
- // WhatsApp
368
- const whatsapp = new WhatsAppChannel();
368
+ // WhatsApp por la API oficial de Meta — el camino para un negocio
369
+ const whatsapp = new WhatsAppCloudChannel({
370
+ enabled: true,
371
+ accountId: "ventas",
372
+ phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID!,
373
+ accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
374
+ appSecret: process.env.WHATSAPP_APP_SECRET!,
375
+ verifyToken: process.env.WHATSAPP_VERIFY_TOKEN!,
376
+ dmPolicy: "open",
377
+ allowFrom: [],
378
+ });
379
+
380
+ // WhatsApp por código QR (Baileys). No es oficial: uso personal.
381
+ const whatsappQr = new WhatsAppChannel();
369
382
 
370
383
  // Slack
371
384
  const slack = new SlackChannel({ botToken: process.env.SLACK_BOT_TOKEN! });
@@ -374,6 +387,45 @@ const slack = new SlackChannel({ botToken: process.env.SLACK_BOT_TOKEN! });
374
387
  const webchat = new WebChatChannel();
375
388
  ```
376
389
 
390
+ ### WhatsApp: cuál de los dos
391
+
392
+ | | `whatsapp_cloud` | `whatsapp` |
393
+ |---|---|---|
394
+ | Qué es | La API oficial de Meta (Cloud API) | Baileys, WhatsApp Web por código QR |
395
+ | Para quién | Un negocio: número de WhatsApp Business propio, plantillas de marketing y atribución de los anuncios que abren la conversación | Uso personal |
396
+ | Riesgo | Ninguno: es el camino soportado | Va contra los términos de WhatsApp; el número puede terminar bloqueado |
397
+ | Cómo recibe | Webhook: hace falta una URL HTTPS pública | Conexión propia, sin URL pública |
398
+
399
+ El canal oficial no abre ninguna conexión: Meta empuja los mensajes a una URL.
400
+ El gateway la sirve en `/webhooks/whatsapp-cloud/<cuenta>` si se le pasa el
401
+ manager, y esa es la que se registra en el panel de Meta junto con el
402
+ `verifyToken`.
403
+
404
+ ```typescript
405
+ const channelManager = new ChannelManager(await loadConfig());
406
+ await channelManager.initialize();
407
+
408
+ // Sin esto, el gateway no tiene a quién entregarle lo que manda Meta.
409
+ await startGateway({ channelManager });
410
+ ```
411
+
412
+ Tres cosas que conviene saber antes de ponerlo en producción:
413
+
414
+ - **La ventana de 24 h.** Pasado ese tiempo desde el último mensaje del cliente,
415
+ Meta sólo acepta plantillas aprobadas (error 131047). Con
416
+ `windowFallbackTemplate` el canal manda esa plantilla; sin ella, lanza un
417
+ error que lo explica en vez de fallar en silencio.
418
+ - **Cada mensaje se cobra.** Desde el 1 de octubre de 2026 los mensajes de
419
+ servicio dentro de la ventana también se pagan, así que la narración de
420
+ progreso del agente no se envía salvo que se encienda `sendProgress`. En su
421
+ lugar se renueva el "escribiendo…", que es gratis.
422
+ - **4096 caracteres por mensaje.** El cliente parte las respuestas largas solo,
423
+ cortando entre párrafos.
424
+
425
+ La versión del Graph sale de `META_GRAPH_API_VERSION` (por defecto la estable
426
+ más reciente). Conviene fijarla a conciencia: cada versión caduca a los ~2 años
427
+ y Meta redirige las llamadas a la más vieja que siga viva, sin avisar.
428
+
377
429
  ---
378
430
 
379
431
  ## Tool Runtime
@@ -444,8 +496,8 @@ unsubscribeCanvas(handler);
444
496
 
445
497
  ## Storage
446
498
 
447
- HiveDB (`@johpaz/hive-db`), un motor embebido con colecciones de documentos e
448
- índice BM25. Reemplazó a SQLite + FTS5 en 0.1.5.
499
+ HiveDB (`@johpaz/hive-db` 0.5.1 o posterior), un motor embebido con colecciones
500
+ de documentos e índice BM25. Reemplazó a SQLite + FTS5 en 0.1.5.
449
501
 
450
502
  ```typescript
451
503
  import { ensureHiveDb, col } from "@johpaz/hive-sdk";
@@ -483,6 +535,33 @@ const skills = await selectSkills("investigar en la web");
483
535
  Una tool declarada con `defineTool` y pasada a `createAgent` queda indexada
484
536
  automáticamente, así que el modelo puede descubrirla igual que a las nativas.
485
537
 
538
+ ### Catálogo compartido entre inquilinos
539
+
540
+ En un host multi-inquilino (varios enjambres en una sola HiveDB, ver
541
+ `runInTenant`) el catálogo —`tools`, `skills` y `ethics`— es contenido de la
542
+ INSTALACIÓN, no de cada inquilino: su contenido se instala una sola vez en la
543
+ colección sin prefijo, y cada inquilino guarda únicamente lo que activó.
544
+
545
+ ```typescript
546
+ import { col, runInTenant, setCatalogActivation } from "@johpaz/hive-sdk";
547
+
548
+ // Sin inquilino: esto instala el catálogo, una vez para todos.
549
+ await ensureHiveDb();
550
+
551
+ await runInTenant(tenantKey, async () => {
552
+ const tools = await col<ToolDoc>("tools");
553
+ await tools.scan({}); // ve el catálogo entero
554
+
555
+ // Encender una tool para ESTE inquilino: guarda su elección, no una copia.
556
+ await setCatalogActivation("tools", "web_search", { active: true });
557
+ });
558
+ ```
559
+
560
+ Lo que el inquilino crea —la tool de un endpoint de API, una skill propia— se
561
+ escribe en su partición y no lo ve nadie más. Editar el contenido de una fila del
562
+ catálogo deja una copia privada; borrarla la oculta sólo para él. Sin inquilino
563
+ en scope nada de esto se activa y `col()` se comporta como siempre.
564
+
486
565
  ---
487
566
 
488
567
  ## Config
@@ -14,13 +14,33 @@ de proceso.
14
14
  - SheetJS CE se instala desde la distribución oficial 0.20.3; el paquete npm
15
15
  abandonado en 0.18.5 no forma parte del grafo.
16
16
  - Baileys está fijado exactamente en `7.0.0-rc14` para evitar retroceder a una
17
- release candidate vulnerable.
17
+ release candidate vulnerable. Además se **carga bajo demanda**: entra en el
18
+ proceso sólo si alguien conecta el canal `whatsapp` por código QR. Un
19
+ consumidor que no lo use —hive-cloud, por ejemplo, que usa el canal oficial—
20
+ no carga su grafo ni su parche de `process.stderr.write`.
18
21
  - PptxGenJS 4.0.1 se conserva como artefacto ESM vendorizado, con licencia,
19
22
  procedencia y SHA-256. No se instala su dependencia muerta `image-size`.
20
23
 
21
24
  Toda actualización del artefacto PPTX debe verificar su origen y hash, ejecutar
22
25
  la prueba OOXML y volver a ejecutar el audit.
23
26
 
27
+ ## Webhooks de WhatsApp (Cloud API)
28
+
29
+ El canal `whatsapp_cloud` recibe por una URL pública, así que todo lo que llega
30
+ es no confiable hasta comprobar dos cosas:
31
+
32
+ - **La firma.** `verifySignature` recalcula el HMAC-SHA256 del cuerpo **crudo**
33
+ con el secreto de la app de Meta y lo compara con `X-Hub-Signature-256`
34
+ usando `timingSafeEqual`. Sin cabecera, sin secreto o con una firma que no
35
+ coincide, el webhook responde 401 y no se procesa nada. El cuerpo se lee como
36
+ texto antes de parsearlo: firmar el JSON reserializado daría distinto.
37
+ - **El token de verificación.** El `hub.verify_token` del alta se compara
38
+ también con `timingSafeEqual`; si no coincide, 403.
39
+
40
+ El canal además sólo atiende eventos de su propio `phone_number_id` —una misma
41
+ cuenta de WhatsApp Business puede tener varios números apuntando a la misma
42
+ URL— y descarta por id los reintentos de Meta.
43
+
24
44
  ## Archivos PDF
25
45
 
26
46
  Antes de leer un PDF, `office_leer_pdf` exige un archivo regular y limita el
package/docs/UPGRADING.md CHANGED
@@ -54,6 +54,26 @@ Estos adaptadores están en la frontera con el runtime. No deben reemplazarse po
54
54
  `any`, `@ts-ignore` o `@ts-expect-error`: hacerlo convertiría una incompatibilidad
55
55
  real de plataforma en un falso resultado verde.
56
56
 
57
+ ## hive-db 0.5.1 y log causal por tenant
58
+
59
+ Hive SDK requiere **`@johpaz/hive-db` 0.5.1 o posterior**. Llega como
60
+ dependencia del SDK, así que una aplicación consumidora no la declara. Lo que
61
+ sigue sólo importa con el log causal encendido (`HIVE_CAUSAL_LOG=true` o
62
+ `causalLog.enabled`):
63
+
64
+ - Con un tenant activo (`runInTenant`) el reflector y el contexto causal del
65
+ compilador vuelven a funcionar. Antes se apagaban; ahora leen acotado a los
66
+ agentes del turno o del lote de trazas.
67
+ - La clave de shard de cada evento es `causalAgentKey(agentId)`: sin tenant, el
68
+ id del agente tal cual; con tenant, `t_…:agentId`. Los eventos que un host
69
+ haya escrito con tenant antes de esta versión quedaron con el id crudo y las
70
+ lecturas acotadas ya no los ven. Sin tenant no cambia nada.
71
+ - El `toolStats` del reflector cuenta el historial de los agentes del lote, no
72
+ el de toda la base, con y sin tenant.
73
+ - `watchCausalEvents` con tenant sigue exigiendo `agentId`: se le pasa el id
74
+ crudo y el SDK lo califica. Los eventos que entrega traen en `agentId` la
75
+ clave del shard; `formatCausalEvent` la muestra sin el tenant.
76
+
57
77
  ## Compatibilidad y CI
58
78
 
59
79
  Los workflows fijan Bun 1.4.2, instalan con `--frozen-lockfile`, ejecutan el
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johpaz/hive-sdk",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "private": false,
5
5
  "description": "Hive SDK — The Agent Harness SDK. Build, deploy, and scale AI agent applications with multi-channel support, context engineering, and swarm orchestration.",
6
6
  "license": "MIT",
@@ -16,6 +16,13 @@ export default {
16
16
  webchat: { enabled: true },
17
17
  telegram: { enabled: false },
18
18
  discord: { enabled: false },
19
+ // WhatsApp por la API oficial de Meta. Es el camino para un negocio:
20
+ // número propio de WhatsApp Business, plantillas y atribución de los
21
+ // anuncios que abren la conversación. Recibe por webhook, así que hace
22
+ // falta exponer `/webhooks/whatsapp-cloud/<cuenta>` con HTTPS público.
23
+ whatsapp_cloud: { enabled: false },
24
+ // WhatsApp por código QR (Baileys). No es oficial: sirve para uso
25
+ // personal, no para un negocio, y el número puede terminar bloqueado.
19
26
  whatsapp: { enabled: false },
20
27
  slack: { enabled: false },
21
28
  },
@@ -31,6 +31,9 @@ async function main() {
31
31
  host: config.gateway?.host,
32
32
  port: config.gateway?.port,
33
33
  agentId: coordinatorAgent.id,
34
+ // Los canales que reciben por webhook —WhatsApp por la API oficial de
35
+ // Meta— entran por el gateway, así que necesita el manager.
36
+ channelManager,
34
37
  });
35
38
 
36
39
  log.info(`{{APP_NAME}} is running at http://${gateway.hostname}:${gateway.port}`);
@@ -16,6 +16,7 @@
16
16
  import { logger } from "../utils/logger.ts"
17
17
  import { col, fromIndexable } from "../storage/hive.ts"
18
18
  import { getHiveDb } from "../storage/hivedb.ts"
19
+ import { causalAgentKey } from "../storage/causal-events.ts"
19
20
  import type { HiveDB, EventInput } from "@johpaz/hive-db"
20
21
  import type { AgentDoc, TurnSource } from "../storage/collections.ts"
21
22
  import { callLLM, resolveProviderConfig, getDefaultLLM, type LLMMessage, type ProviderCredentials } from "./llm-client.ts"
@@ -170,7 +171,8 @@ async function appendCausalEvent(
170
171
  ): Promise<number | undefined> {
171
172
  try {
172
173
  return await db.append({
173
- agentId: input.agentId,
174
+ // Shard calificado con el tenant: ver causalAgentKey en causal-events.ts.
175
+ agentId: causalAgentKey(input.agentId),
174
176
  streamId: input.streamId,
175
177
  kind: input.kind,
176
178
  payload: JSON.stringify(input.payload),
@@ -21,10 +21,27 @@
21
21
  import type { IndexDoc } from "@johpaz/hive-db";
22
22
  import { getHiveDb } from "../storage/hivedb.ts";
23
23
  import { currentTenant, qualifyDocId, unqualifyDocId, scopedFilterValue } from "../storage/tenant.ts";
24
+ import { listCatalogActivations } from "../storage/catalog.ts";
24
25
  import { logger } from "../utils/logger.ts";
25
26
 
26
27
  const log = logger.child("capability-search");
27
28
 
29
+ /**
30
+ * Ámbito de los documentos que no son de ningún inquilino: el catálogo.
31
+ *
32
+ * Es el mismo `"_"` que `scopedFilterValue()` usa cuando no hay tenant, y por
33
+ * eso sirve de las dos maneras: como valor del filtro `tenant` para poder
34
+ * BUSCAR el catálogo desde dentro de un inquilino, y como parte de
35
+ * `tenant__type` para que un reindexado del catálogo BORRE sólo lo suyo.
36
+ *
37
+ * Antes el catálogo se indexaba sin filtro `tenant` alguno, y eso rompía las dos
38
+ * cosas: desde un enjambre la búsqueda filtraba por su tenant y no encontraba
39
+ * NADA del catálogo —ni una tool, ni una skill—, y un reindexado desde fuera
40
+ * borraba por `type` a secas, llevándose por delante los documentos de todos los
41
+ * inquilinos (las tools de sus endpoints, sus tools de MCP).
42
+ */
43
+ const CATALOGO = "_";
44
+
28
45
  export type CapabilityType = "tool" | "skill" | "playbook" | "mcp" | "agent";
29
46
 
30
47
  export interface CapabilityHit {
@@ -91,17 +108,22 @@ export async function searchCapabilities(
91
108
  // type; the single-type and all-types cases are one engine call.
92
109
  //
93
110
  // El índice semántico es UNO solo para todos los inquilinos (no hay
94
- // "colección" que prefijar), así que el tenant entra como un filtro más que
95
- // el motor AND-ea con el resto. Sin tenant no se añade nada: así un índice ya
96
- // construido por la app de escritorio sigue respondiendo igual.
111
+ // "colección" que prefijar), así que el ámbito entra como un filtro más que el
112
+ // motor AND-ea con el resto.
113
+ //
114
+ // Con inquilino activo se consulta DOS veces: lo suyo —las tools de sus
115
+ // endpoints, sus tools de MCP— y el catálogo compartido, que se instala una
116
+ // sola vez y es de todos (ver storage/catalog.ts). Sin inquilino se consulta
117
+ // sin filtro de ámbito: una instalación local tiene una sola partición y así
118
+ // un índice ya construido sigue respondiendo igual.
97
119
  const tenant = currentTenant();
98
- const tenantFilter = tenant ? [{ field: "tenant", value: tenant }] : [];
99
- const queries = types
100
- ? types.map((t) => ({
101
- type: t,
102
- filters: [...tenantFilter, { field: "type", value: t }],
103
- }))
104
- : [{ type: undefined, filters: tenantFilter.length ? tenantFilter : undefined }];
120
+ const ambitos = tenant ? [tenant, CATALOGO] : [null];
121
+ const queries = ambitos.flatMap((ambito) => {
122
+ const filtroAmbito = ambito ? [{ field: "tenant", value: ambito }] : [];
123
+ return types
124
+ ? types.map((t) => ({ filters: [...filtroAmbito, { field: "type", value: t }] }))
125
+ : [{ filters: filtroAmbito.length ? filtroAmbito : undefined }];
126
+ });
105
127
 
106
128
  const merged = new Map<string, CapabilityHit>();
107
129
  for (const q of queries) {
@@ -129,7 +151,10 @@ export async function searchCapabilities(
129
151
  }
130
152
  }
131
153
 
154
+ const apagados = tenant && merged.size > 0 ? await apagadosParaElInquilino() : null;
155
+
132
156
  const results = Array.from(merged.values())
157
+ .filter((hit) => !apagados?.has(hit.id))
133
158
  .sort((a, b) => b.score - a.score)
134
159
  .slice(0, k);
135
160
 
@@ -140,6 +165,27 @@ export async function searchCapabilities(
140
165
  return results;
141
166
  }
142
167
 
168
+ /**
169
+ * Lo que este inquilino apagó del catálogo compartido.
170
+ *
171
+ * El índice del catálogo es uno solo, así que no puede llevar la elección de
172
+ * nadie: se filtra al leer. Sólo se descarta lo que el inquilino decidió
173
+ * explícitamente —apagar u ocultar—; lo que nunca tocó hereda lo que diga el
174
+ * catálogo, igual que en la colección (ver storage/catalog.ts).
175
+ *
176
+ * Ofrecerle al modelo una capacidad que su workspace apagó es hacerle perder un
177
+ * turno, además de contarle que existe algo que no puede usar.
178
+ */
179
+ async function apagadosParaElInquilino(): Promise<Set<string>> {
180
+ const apagados = new Set<string>();
181
+ for (const [tipo, coleccion] of [["tool", "tools"], ["skill", "skills"]] as const) {
182
+ for (const eleccion of await listCatalogActivations(coleccion)) {
183
+ if (!eleccion.active || eleccion.hidden) apagados.add(`${tipo}:${eleccion.itemId}`);
184
+ }
185
+ }
186
+ return apagados;
187
+ }
188
+
143
189
  /**
144
190
  * Keep only hits scoring at least `ratio` of the top hit. This replaces the
145
191
  * old absolute negative-bm25 thresholds: relevance is relative to the best
@@ -169,12 +215,12 @@ export async function replaceCapabilityDocs(
169
215
  // `deleteByFilter` acepta UN SOLO filtro, así que en una base compartida
170
216
  // borrar por `type` se llevaría por delante los documentos de todos los
171
217
  // inquilinos. El campo sintético `tenant__type` (ver scopedFilterValue)
172
- // mantiene el borrado en un filtro y acotado a este tenant.
173
- await db.deleteByFilter(
174
- currentTenant()
175
- ? { field: "tenant__type", value: scopedFilterValue(type) }
176
- : { field: "type", value: type }
177
- );
218
+ // mantiene el borrado en un filtro y acotado a un ámbito.
219
+ //
220
+ // Sin inquilino el ámbito es el catálogo (`_`), no "todo": reindexar el
221
+ // catálogo al arrancar borraba las tools de los endpoints y las de MCP de cada
222
+ // inquilino, que nadie volvía a escribir hasta que ese enjambre se reconectara.
223
+ await db.deleteByFilter({ field: "tenant__type", value: scopedFilterValue(type) });
178
224
  if (docs.length === 0) return;
179
225
  await db.upsertBatch(docs.map(toIndexDoc));
180
226
  }
@@ -198,7 +244,6 @@ export async function deleteCapabilitiesByServer(serverId: string): Promise<void
198
244
 
199
245
  function toIndexDoc(doc: CapabilityDoc): IndexDoc {
200
246
  const extra = doc.extraFilters ?? [];
201
- const tenant = currentTenant();
202
247
  return {
203
248
  id: qualifyDocId(`${doc.type}:${doc.rawId}`),
204
249
  name: doc.name,
@@ -207,20 +252,16 @@ function toIndexDoc(doc: CapabilityDoc): IndexDoc {
207
252
  filters: [
208
253
  { field: "type", value: doc.type },
209
254
  ...extra,
210
- // Con tenant activo, cada filtro lleva además un gemelo `tenant__<campo>`.
211
- // Es lo que permite que los borrados masivos —que sólo aceptan un
212
- // filtro— sigan acotados a este inquilino. Sin tenant no se emite nada,
213
- // así que un índice ya construido conserva exactamente su forma.
214
- ...(tenant
215
- ? [
216
- { field: "tenant", value: tenant },
217
- { field: "tenant__type", value: scopedFilterValue(doc.type) },
218
- ...extra.map((f) => ({
219
- field: `tenant__${f.field}`,
220
- value: scopedFilterValue(f.value),
221
- })),
222
- ]
223
- : []),
255
+ // Todo documento declara su ámbito —el inquilino que lo escribió, o `_` si
256
+ // es del catálogo—, y cada filtro lleva además su gemelo
257
+ // `tenant__<campo>`. Con eso la búsqueda puede pedir un ámbito concreto y
258
+ // los borrados masivos —que aceptan un solo filtro— quedan acotados a él.
259
+ { field: "tenant", value: currentTenant() ?? CATALOGO },
260
+ { field: "tenant__type", value: scopedFilterValue(doc.type) },
261
+ ...extra.map((f) => ({
262
+ field: `tenant__${f.field}`,
263
+ value: scopedFilterValue(f.value),
264
+ })),
224
265
  ],
225
266
  };
226
267
  }
@@ -38,7 +38,7 @@ import { getMCPManager as getSingletonMCPManager } from "../mcp/singleton.ts"
38
38
  import { syncMCPToolsToDB, syncMCPToolsToIndex } from "../mcp/tool-sync.ts"
39
39
  import { getUserDate, getUserTime } from "../utils/date.ts"
40
40
  import { getHiveDb } from "../storage/hivedb.ts"
41
- import { causalReadsEnabled } from "../storage/causal-events.ts"
41
+ import { causalReadsEnabled, causalScope } from "../storage/causal-events.ts"
42
42
  import { listCatalogAgents, renderAgentRoutingCatalog } from "./catalog-selector.ts"
43
43
  import { expandToolAllowlist } from "./delegation-runtime.ts"
44
44
  import { MINIMAL_TOOLS } from "./minimal-loadout.ts"
@@ -588,7 +588,10 @@ export async function compileContext(opts: {
588
588
  // applies this turn (a real DB round-trip, not a per-turn cost) and
589
589
  // there's a causal stream to build it from. episodicSimilarity is omitted:
590
590
  // it requires embeddings hive doesn't generate anywhere yet.
591
- if (summaryApplies && opts.causalStreamId && causalReadsEnabled()) {
591
+ // Acotado al shard de este agente: el stream es de una sola invocación suya,
592
+ // así que el hilo es el mismo y no se recorre el log de nadie más.
593
+ const causalAgents = causalScope([opts.agentId])
594
+ if (summaryApplies && opts.causalStreamId && causalAgents && causalReadsEnabled()) {
592
595
  try {
593
596
  const causalDb = await getHiveDb()
594
597
  const objectiveSource = taskContext || userMessage
@@ -605,6 +608,7 @@ export async function compileContext(opts: {
605
608
  currentObjective: currentObjective.slice(0, 2000),
606
609
  maxTokens: causalMaxTokens,
607
610
  strategy: { causalAnchors: true, compressCompletedPhases: true },
611
+ agents: causalAgents,
608
612
  })) as AgentContextShape
609
613
 
610
614
  const causalLines = [...(causalCtx.items ?? []), ...(causalCtx.anomalies ?? [])]
@@ -14,7 +14,8 @@
14
14
  import { logger } from "../utils/logger.ts"
15
15
  import { col, nextId } from "../storage/hive.ts"
16
16
  import { getHiveDb } from "../storage/hivedb.ts"
17
- import { causalReadsEnabled } from "../storage/causal-events.ts"
17
+ import { causalReadsEnabled, causalScope } from "../storage/causal-events.ts"
18
+ import { unqualifyDocId } from "../storage/tenant.ts"
18
19
  import type { HiveDB, ToolStats } from "@johpaz/hive-db"
19
20
  import type { TraceDoc, ReflectionDoc, CursorDoc } from "../storage/collections.ts"
20
21
  import { parseThreadId } from "./thread-id.ts"
@@ -183,10 +184,16 @@ async function analyzeCausalThreads(traces: TraceDoc[], causalDb: HiveDB | null)
183
184
 
184
185
  for (const streamId of streamIds) {
185
186
  try {
186
- const thread = (await causalDb.causalThread(streamId)) as CausalThreadShape
187
+ // El stream es de una sola invocación, así que sus trazas nombran a los
188
+ // agentes que escribieron en él. Sin ninguno no hay con qué acotar, y una
189
+ // lectura sin acotar recorre los shards de todos los inquilinos.
190
+ const subset = traces.filter((t) => t.causal_stream_id === streamId)
191
+ const agents = causalScope(subset.map((t) => t.agent_id))
192
+ if (!agents) continue
193
+
194
+ const thread = (await causalDb.causalThread(streamId, agents)) as CausalThreadShape
187
195
  if (!thread.decisions?.length && !thread.toolCalls?.length) continue
188
196
 
189
- const subset = traces.filter((t) => t.causal_stream_id === streamId)
190
197
  const originalIntent = subset[0]?.input_summary ?? ""
191
198
  const success = subset.every((t) => t.success)
192
199
 
@@ -204,13 +211,16 @@ async function analyzeCausalThreads(traces: TraceDoc[], causalDb: HiveDB | null)
204
211
  // underlying root cause mint a brand new playbook rule instead of
205
212
  // reinforcing one (confirmed via a local before/after canary run).
206
213
  if (evaluation.rootCause) {
214
+ // El log guarda la clave del shard (t_…:agente con tenant); a la regla de
215
+ // playbook y a affected_agents les llega el id que el host conoce.
216
+ const rootAgent = unqualifyDocId(evaluation.rootCause.agent)
207
217
  const decision = thread.decisions?.find((d) => d.seq === evaluation.rootCause!.seq)
208
218
  insights.push({
209
219
  type: "root_cause",
210
220
  description: decision
211
- ? `Root cause: decision "${decision.description}" (agent ${evaluation.rootCause.agent}) preceded a tool failure.`
212
- : `Root cause: a decision by agent ${evaluation.rootCause.agent} preceded a tool failure.`,
213
- affectedAgents: [evaluation.rootCause.agent],
221
+ ? `Root cause: decision "${decision.description}" (agent ${rootAgent}) preceded a tool failure.`
222
+ : `Root cause: a decision by agent ${rootAgent} preceded a tool failure.`,
223
+ affectedAgents: [rootAgent],
214
224
  confidence: 0.6,
215
225
  })
216
226
  }
@@ -248,14 +258,17 @@ async function analyzeCausalThreads(traces: TraceDoc[], causalDb: HiveDB | null)
248
258
  async function analyzeTracesLocally(traces: TraceDoc[], causalDb: HiveDB | null): Promise<Insight[]> {
249
259
  const insights: Insight[] = []
250
260
 
251
- // G9: whole-history stats per tool touched by this batch (undefined when
252
- // disabled, or when the tool has no events in the log yet).
261
+ // G9: historial completo, por tool, de los agentes de este lote (undefined si
262
+ // el log está apagado o la tool todavía no tiene eventos). Acotado a esos
263
+ // agentes: con tenant, sin esto se sumarían llamadas de otros inquilinos; sin
264
+ // tenant, es el historial de estos agentes y no el de toda la base.
253
265
  const statsByTool = new Map<string, ToolStats>()
254
- if (causalDb) {
266
+ const agents = causalDb ? causalScope(traces.map((t) => t.agent_id)) : null
267
+ if (causalDb && agents) {
255
268
  const distinctTools = new Set(traces.map((t) => t.tool_used).filter((t): t is string => !!t))
256
269
  for (const tool of distinctTools) {
257
270
  try {
258
- const stats = await causalDb.toolStats(tool)
271
+ const stats = await causalDb.toolStats(tool, agents)
259
272
  if (stats) statsByTool.set(tool, stats)
260
273
  } catch (err) {
261
274
  log.warn(`[reflector] toolStats(${tool}) failed: ${(err as Error).message}`)
@@ -3,5 +3,6 @@ export * from "./telegram.ts";
3
3
  export * from "./discord.ts";
4
4
  export * from "./webchat.ts";
5
5
  export * from "./whatsapp.ts";
6
+ export * from "./whatsapp-cloud/index.ts";
6
7
  export * from "./slack.ts";
7
8
  export * from "./manager.ts";