@johpaz/hive-sdk 0.4.7 → 0.4.9

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 (29) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +1 -1
  3. package/docs/API-TOOLS-SKILLS-CHANNELS.md +81 -2
  4. package/docs/SECURITY-GUARDRAILS.md +21 -1
  5. package/package.json +1 -1
  6. package/packages/cli/templates/hive-app/hive.config.ts +7 -0
  7. package/packages/cli/templates/hive-app/src/main.ts +3 -0
  8. package/packages/core/src/agent/agent-loop.ts +10 -2
  9. package/packages/core/src/agent/capability-search.ts +72 -31
  10. package/packages/core/src/agent/compaction.ts +81 -35
  11. package/packages/core/src/channels/index.ts +1 -0
  12. package/packages/core/src/channels/manager.ts +40 -0
  13. package/packages/core/src/channels/whatsapp-cloud/channel.ts +334 -0
  14. package/packages/core/src/channels/whatsapp-cloud/client.ts +368 -0
  15. package/packages/core/src/channels/whatsapp-cloud/index.ts +3 -0
  16. package/packages/core/src/channels/whatsapp-cloud/webhook.ts +235 -0
  17. package/packages/core/src/channels/whatsapp.ts +58 -28
  18. package/packages/core/src/config/loader.ts +3 -1
  19. package/packages/core/src/gateway/server.ts +24 -0
  20. package/packages/core/src/index.ts +17 -0
  21. package/packages/core/src/mcp/MCPClient.ts +9 -0
  22. package/packages/core/src/mcp/config.ts +2 -1
  23. package/packages/core/src/mcp/transports/index.ts +44 -1
  24. package/packages/core/src/storage/catalog.ts +354 -0
  25. package/packages/core/src/storage/hive.ts +6 -1
  26. package/packages/core/src/storage/index.ts +13 -0
  27. package/packages/core/src/storage/seed.ts +161 -128
  28. package/packages/core/src/tools/cron/index.ts +2 -2
  29. package/packages/core/src/voice/index.ts +2 -0
package/CHANGELOG.md CHANGED
@@ -22,6 +22,74 @@
22
22
  `buildAgentContext`), de las que depende el aislamiento entre inquilinos del
23
23
  log causal descrito en *Corregido*.
24
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`.
92
+
25
93
  ### Corregido
26
94
 
27
95
  - **Con un tenant activo, el log causal se apagaba en lugar de acotarse.**
@@ -511,6 +579,29 @@
511
579
  job que genera un scaffold con `create-app` y lo typechequea contra el SDK de
512
580
  ese commit.
513
581
 
582
+ ## 0.4.9
583
+
584
+ ### Corregido
585
+
586
+ - **La compactación de historial dejaba de ser excepcional y corría casi en cada
587
+ turno.** `agent.context.compactionThreshold` es una proporción de la ventana
588
+ del modelo —su valor por defecto es `0.8`, o sea el 80 %— pero se leía como un
589
+ número de tokens: el umbral efectivo quedaba en 0.8 tokens. Cualquier hilo con
590
+ más de cinco mensajes se resumía en cada turno, lo que cuesta una llamada extra
591
+ al modelo por mensaje y reemplaza el historial por un resumen desde el primer
592
+ intercambio. Ahora un valor menor o igual a 1 se aplica sobre la ventana del
593
+ modelo y uno mayor se sigue leyendo como tokens, para quien fijó un número
594
+ absoluto. El umbral se calcula además con la ventana del modelo que corre el
595
+ turno, no con la del coordinador.
596
+ - **El resumen se pedía con una credencial global.** `compactThread` resolvía el
597
+ modelo con `getDefaultLLM()` y llamaba a `resolveProviderConfig` sin
598
+ credenciales, así que la llave salía del secret store, del llavero del sistema
599
+ o del entorno del proceso. En una instalación de un solo usuario da igual; en
600
+ una multi-inquilino significa resumir la conversación de un cliente con la
601
+ llave de la plataforma o de otro cliente. `maybeCompact` y `compactThread`
602
+ aceptan ahora el modelo y las credenciales del turno (`CompactionLLM`), y el
603
+ agent loop les pasa los suyos. Sin ese dato se comportan como antes.
604
+
514
605
  ## 0.1.5
515
606
 
516
607
  Sincronización del SDK con el runtime de agentes de `hive`. **Trae rupturas de
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.7 — MIT*
274
+ *Hive SDK v0.4.9 — 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
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johpaz/hive-sdk",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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}`);
@@ -357,12 +357,20 @@ export async function* runAgent(
357
357
  channel: opts.channel,
358
358
  source: opts.historySource ?? "message",
359
359
  })
360
- // Run compaction if conversation history is getting large
360
+ // Run compaction if conversation history is getting large.
361
+ // El modelo del turno viaja con sus credenciales: el resumen es una llamada
362
+ // al modelo como cualquier otra y tiene que cobrarse a la misma cuenta.
361
363
  await maybeCompact(
362
364
  opts.threadId,
363
365
  opts.channel && opts.userId
364
366
  ? { channel: opts.channel, userId: opts.userId }
365
- : undefined
367
+ : undefined,
368
+ {
369
+ provider: providerCfg.provider,
370
+ model: providerCfg.model,
371
+ credentials: opts.credentials,
372
+ contextWindow: providerCfg.contextWindow,
373
+ }
366
374
  )
367
375
  }
368
376
 
@@ -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
  }
@@ -26,7 +26,10 @@ import {
26
26
  type StoredMessage,
27
27
  } from "./conversation-store.ts"
28
28
  import { estimateTokens } from "../utils/toon.ts"
29
- import { callLLM, resolveProviderConfig, getDefaultLLM, type ContentPart } from "./llm-client.ts"
29
+ import {
30
+ callLLM, resolveProviderConfig, getDefaultLLM,
31
+ type ContentPart, type ProviderCredentials,
32
+ } from "./llm-client.ts"
30
33
  import { col, fromIndexable } from "../storage/hive.ts"
31
34
  import type { AgentDoc, ModelDoc } from "../storage/collections.ts"
32
35
  import { loadConfig } from "../config/loader.ts"
@@ -41,6 +44,61 @@ const KEEP_LAST_N_MESSAGES = 5 // always keep most recent N messages
41
44
  const TOOL_RESULT_MAX_CHARS = 200 // max chars for old tool results after clearing
42
45
  const MAX_TRANSCRIPT_MSGS = 30 // cap messages sent to summarizer (avoids OOM on small models)
43
46
  const MAX_MSG_CHARS = 300 // chars per message in transcript
47
+ /** Ventana asumida cuando no se conoce la del modelo: `COMPACT_TOKEN_THRESHOLD` es su 25 %. */
48
+ const ASSUMED_CONTEXT_WINDOW = 128_000
49
+ const DEFAULT_CONTEXT_RATIO = 0.25
50
+
51
+ /**
52
+ * El modelo con el que se pide el resumen: el del turno que disparó la
53
+ * compactación, con SUS credenciales.
54
+ */
55
+ export interface CompactionLLM {
56
+ provider?: string
57
+ model?: string
58
+ /** En multi-inquilino, la llave del cliente. Sin esto se usaría una global. */
59
+ credentials?: ProviderCredentials
60
+ /** La ventana del modelo, si quien llama ya la resolvió. */
61
+ contextWindow?: number
62
+ }
63
+
64
+ /**
65
+ * A partir de cuántos tokens de historial se compacta.
66
+ *
67
+ * `agent.context.compactionThreshold` es una PROPORCIÓN de la ventana del
68
+ * modelo —su valor por defecto es 0.8, o sea el 80 %—, pero se leía como si
69
+ * fueran tokens. Con la configuración por defecto el umbral quedaba en 0.8
70
+ * tokens: cualquier hilo con más de cinco mensajes se resumía en cada turno,
71
+ * pagando una llamada extra al modelo y reemplazando el historial por un
72
+ * resumen desde el primer intercambio. Un valor mayor que 1 se sigue leyendo
73
+ * como tokens, que es lo que espera quien fijó un número absoluto.
74
+ */
75
+ export function resolveCompactionThreshold(configured: number | undefined, contextWindow?: number): number {
76
+ const known = contextWindow && contextWindow > 0 ? contextWindow : undefined
77
+ if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
78
+ return configured <= 1
79
+ ? Math.floor((known ?? ASSUMED_CONTEXT_WINDOW) * configured)
80
+ : Math.floor(configured)
81
+ }
82
+ return known ? Math.floor(known * DEFAULT_CONTEXT_RATIO) : COMPACT_TOKEN_THRESHOLD
83
+ }
84
+
85
+ /** La ventana del modelo del turno; si no se sabe cuál es, la del coordinador. */
86
+ async function modelContextWindow(modelId?: string): Promise<number | undefined> {
87
+ try {
88
+ const modelsCol = await col<ModelDoc>("models")
89
+ if (modelId) return (await modelsCol.get(modelId))?.doc.context_window || undefined
90
+ const agentsCol = await col<AgentDoc>("agents")
91
+ const coordinators = await agentsCol.findBy("role", "coordinator", { limit: 1 })
92
+ // El id se busca completo: recortar el primer segmento rompía cualquier
93
+ // modelo cuyo nombre lleve barra (meta/llama-3.3-70b-instruct buscaba
94
+ // "llama-3.3-70b-instruct", no encontraba nada y caía al default).
95
+ const id = fromIndexable(coordinators[0]?.doc.model_id ?? null)
96
+ if (!id) return undefined
97
+ return (await modelsCol.get(id))?.doc.context_window || undefined
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
44
102
 
45
103
  /**
46
104
  * Check if compaction is needed and run it if so.
@@ -48,37 +106,16 @@ const MAX_MSG_CHARS = 300 // chars per message in transcript
48
106
  */
49
107
  export async function maybeCompact(
50
108
  threadId: string,
51
- notify?: { channel: string; userId: string }
109
+ notify?: { channel: string; userId: string },
110
+ llm?: CompactionLLM
52
111
  ): Promise<void> {
53
112
  try {
54
113
  const totalTokens = await getTotalTokens(threadId)
55
-
56
- // Orden de precedencia: lo que el usuario configuró gana sobre lo que se
57
- // deduce del modelo, y eso gana sobre la constante.
58
- //
59
- // `agent.context.compactionThreshold` estaba en el esquema de configuración
60
- // y **no lo leía nadie**: alguien podía ajustarlo y no pasaba nada. Una
61
- // opción que no hace nada es peor que no tenerla, porque el usuario cree
62
- // que cambió algo.
63
- let effectiveThreshold = COMPACT_TOKEN_THRESHOLD
64
- const configurado = loadConfig().agent?.context?.compactionThreshold
65
- try {
66
- const agentsCol = await col<AgentDoc>("agents")
67
- const coordinators = await agentsCol.findBy("role", "coordinator", { limit: 1 })
68
- const modelId = fromIndexable(coordinators[0]?.doc.model_id ?? null)
69
- if (modelId) {
70
- const modelsCol = await col<ModelDoc>("models")
71
- // El id se busca completo: recortar el primer segmento rompía cualquier
72
- // modelo cuyo nombre lleve barra (meta/llama-3.3-70b-instruct buscaba
73
- // "llama-3.3-70b-instruct", no encontraba nada y caía al default).
74
- const modelEntry = await modelsCol.get(modelId)
75
- if (modelEntry?.doc.context_window) {
76
- effectiveThreshold = Math.floor(modelEntry.doc.context_window * 0.25)
77
- }
78
- }
79
- } catch { /* use default threshold */ }
80
-
81
- if (configurado && configurado > 0) effectiveThreshold = configurado
114
+ const contextWindow = llm?.contextWindow ?? (await modelContextWindow(llm?.model))
115
+ const effectiveThreshold = resolveCompactionThreshold(
116
+ loadConfig().agent?.context?.compactionThreshold,
117
+ contextWindow,
118
+ )
82
119
 
83
120
  if (totalTokens < effectiveThreshold) return
84
121
 
@@ -96,8 +133,8 @@ export async function maybeCompact(
96
133
  // Already summarized up to near the current state
97
134
  if (summary && summary.last_message_id > totalMessages - KEEP_LAST_N_MESSAGES) return
98
135
 
99
- log.info(`[compaction] Compacting thread=${threadId} tokens=${totalTokens}`)
100
- await compactThread(threadId, notify)
136
+ log.info(`[compaction] Compacting thread=${threadId} tokens=${totalTokens} threshold=${effectiveThreshold}`)
137
+ await compactThread(threadId, notify, llm)
101
138
  } catch (err) {
102
139
  log.warn("[compaction] Error during compaction check:", err)
103
140
  }
@@ -139,7 +176,8 @@ export function renderTranscript(rows: StoredMessage[], maxMsgChars = MAX_MSG_CH
139
176
  */
140
177
  export async function compactThread(
141
178
  threadId: string,
142
- notify?: { channel: string; userId: string }
179
+ notify?: { channel: string; userId: string },
180
+ llm?: CompactionLLM
143
181
  ): Promise<void> {
144
182
  const allMessages = await getHistory(threadId)
145
183
  if (allMessages.length <= KEEP_LAST_N_MESSAGES) return
@@ -162,10 +200,18 @@ export async function compactThread(
162
200
  const capped = toSummarize.slice(-MAX_TRANSCRIPT_MSGS)
163
201
  const transcript = renderTranscript(capped)
164
202
 
165
- const defaultLLM = await getDefaultLLM()
166
- if (!defaultLLM) throw new Error("No active LLM providers/models configured in the database")
203
+ // El modelo del turno y SUS credenciales. Antes el resumen se pedía siempre
204
+ // con `getDefaultLLM()` y sin credenciales, así que `resolveProviderConfig`
205
+ // caía al secret store, al llavero del sistema o al entorno: en una
206
+ // instalación multi-inquilino eso resume la conversación de un cliente con
207
+ // la llave de la plataforma (o de otro cliente). Sin `llm` se comporta como
208
+ // antes, que es lo que necesita una instalación de un solo usuario.
209
+ const target = llm?.provider && llm.model
210
+ ? { provider: llm.provider, model: llm.model }
211
+ : await getDefaultLLM()
212
+ if (!target) throw new Error("No active LLM providers/models configured in the database")
167
213
 
168
- const providerCfg = await resolveProviderConfig(defaultLLM.provider, defaultLLM.model)
214
+ const providerCfg = await resolveProviderConfig(target.provider, target.model, llm?.credentials)
169
215
 
170
216
  const summaryResponse = await callLLM({
171
217
  ...providerCfg,
@@ -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";