@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
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Catálogo compartido, activación por inquilino.
3
+ *
4
+ * ## El problema
5
+ *
6
+ * `tools`, `skills` y `ethics` guardan dos cosas en la misma fila: el CONTENIDO
7
+ * —el nombre de la tool, el cuerpo de la skill, el texto de la regla— y la
8
+ * ELECCIÓN de quien la usa, en `active`/`enabled`. Mientras hubo una instalación
9
+ * por usuario eso era correcto y simple.
10
+ *
11
+ * Con varios inquilinos en una sola HiveDB deja de serlo. `ensureHiveDb()`
12
+ * siembra los catálogos en la partición de CADA enjambre, así que las mismas 62
13
+ * tools y las mismas skills se copian tantas veces como enjambres haya, y se
14
+ * vuelven a escribir en cada arranque. El contenido es idéntico en todas: la
15
+ * única diferencia real entre dos particiones es qué eligió cada una.
16
+ *
17
+ * ## La separación
18
+ *
19
+ * **El contenido vive una sola vez**, en la colección sin prefijo de inquilino
20
+ * —la misma que ve la app de escritorio—, y lo escribe quien instala: el seed
21
+ * del SDK o, en Hive Cloud, la sincronización desde Postgres.
22
+ *
23
+ * **La elección vive en el inquilino**, en `catalogActivations`: una fila por
24
+ * elemento tocado, con `active`/`enabled`. Sin fila, se hereda lo que diga el
25
+ * catálogo. Un enjambre nuevo arranca entonces con CERO escrituras.
26
+ *
27
+ * **Lo propio del inquilino sigue siendo suyo.** No todo lo que cae en estas
28
+ * colecciones es catálogo: las tools de un endpoint de API (`services/endpoints`)
29
+ * y las skills o códigos de ética que alguien cree se escriben en la partición,
30
+ * como siempre. Compartir la colección entera se los filtraría a los demás
31
+ * inquilinos, que es exactamente lo que el prefijo vino a impedir.
32
+ *
33
+ * {@link catalogView} compone las tres capas detrás de la misma interfaz de
34
+ * colección, así que los ~30 sitios que hacen `col("tools")` no se enteran.
35
+ *
36
+ * Sin inquilino en scope nada de esto se activa: `col()` devuelve la colección
37
+ * de siempre y el modo local se comporta exactamente igual que antes.
38
+ */
39
+
40
+ import type { DocEntry, PutDocOptions, ScanOptions } from "@johpaz/hive-db";
41
+ import { getHiveDb } from "./hivedb.ts";
42
+ import { currentTenant, qualify } from "./tenant.ts";
43
+
44
+ /**
45
+ * Las colecciones cuyo contenido es catálogo de la instalación y no dato de un
46
+ * inquilino.
47
+ *
48
+ * `providers` y `models` NO están acá, y es deliberado: lo que baja a la
49
+ * partición de un enjambre no es el catálogo entero sino el subconjunto que su
50
+ * workspace configuró, con su `base_url` y su `context_window`. Ahí la fila por
51
+ * inquilino ES el dato correcto; lo que sobraba era que el SDK sembrara además
52
+ * su catálogo estático encima (ver `seedAllData`).
53
+ */
54
+ export const CATALOG_COLLECTIONS = new Set(["tools", "skills", "ethics"]);
55
+
56
+ /** Dónde vive la elección de cada inquilino. Se prefija como cualquier otra. */
57
+ const ACTIVATIONS = "catalogActivations";
58
+
59
+ /** Campos que son elección del inquilino y no contenido del catálogo. */
60
+ const ACTIVATION_FIELDS = ["active", "enabled"] as const;
61
+
62
+ /**
63
+ * Lo que un inquilino decidió sobre una fila del catálogo.
64
+ *
65
+ * `hidden` cubre el borrado: un inquilino no puede borrar una fila compartida
66
+ * —es de todos—, pero sí sacarla de su vista. Es lo que permite que
67
+ * `pruneRetired()` y el borrado de una tool de endpoint sigan funcionando
68
+ * dentro de una partición sin tocar a nadie más.
69
+ */
70
+ interface ActivationDoc {
71
+ id: string;
72
+ collection: string;
73
+ item_id: string;
74
+ active: boolean;
75
+ enabled: boolean;
76
+ hidden: boolean;
77
+ updated_at: number;
78
+ }
79
+
80
+ /**
81
+ * La parte de `Collection` que usa el SDK.
82
+ *
83
+ * Existe porque `Collection` de hive-db es una clase con campos privados: nada
84
+ * que no salga de `db.collection()` puede hacerse pasar por ella, ni siquiera
85
+ * implementando los mismos métodos. La clase real satisface esta interfaz tal
86
+ * cual, así que tipar `col()` con ella no cambia nada para quien la recibe.
87
+ */
88
+ export interface DocStore<T> {
89
+ put(id: string, doc: T, options?: PutDocOptions): Promise<number>;
90
+ get(id: string): Promise<DocEntry<T> | undefined>;
91
+ delete(id: string): Promise<boolean>;
92
+ scan(options?: ScanOptions): Promise<DocEntry<T>[]>;
93
+ count(): Promise<number>;
94
+ createIndex(field: string, options?: { unique?: boolean }): Promise<void>;
95
+ findBy(field: string, value: string | number | boolean, options?: ScanOptions): Promise<DocEntry<T>[]>;
96
+ }
97
+
98
+ function activationId(collection: string, itemId: string): string {
99
+ return `${collection}:${itemId}`;
100
+ }
101
+
102
+ /** Aplica la elección del inquilino sobre la fila compartida. */
103
+ function withActivation<T>(doc: T, overlay: ActivationDoc | undefined): T {
104
+ if (!overlay) return doc;
105
+ const merged = { ...(doc as Record<string, unknown>) };
106
+ if ("active" in merged) merged.active = overlay.active;
107
+ if ("enabled" in merged) merged.enabled = overlay.enabled;
108
+ return merged as T;
109
+ }
110
+
111
+ /**
112
+ * `true` si lo único que cambia entre las dos filas es la elección.
113
+ *
114
+ * `updated_at` se ignora a propósito: todos los caminos de activación lo tocan
115
+ * (`toggleTool`, `applySeedPlan`, `updateSkill`), y tomarlo como contenido haría
116
+ * que encender una tool se guardara como una copia entera del catálogo por
117
+ * inquilino — justo lo que esto viene a evitar.
118
+ */
119
+ function soloCambiaLaEleccion(nuevo: unknown, compartido: unknown): boolean {
120
+ const ignorar = new Set<string>([...ACTIVATION_FIELDS, "updated_at"]);
121
+ const a = nuevo as Record<string, unknown>;
122
+ const b = compartido as Record<string, unknown>;
123
+ const claves = new Set([...Object.keys(a), ...Object.keys(b)]);
124
+ for (const clave of claves) {
125
+ if (ignorar.has(clave)) continue;
126
+ if (JSON.stringify(a[clave]) !== JSON.stringify(b[clave])) return false;
127
+ }
128
+ return true;
129
+ }
130
+
131
+ function ordenarPorId<T>(entries: DocEntry<T>[]): DocEntry<T>[] {
132
+ return entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
133
+ }
134
+
135
+ function aplicarOpciones<T>(entries: DocEntry<T>[], options: ScanOptions = {}): DocEntry<T>[] {
136
+ let salida = ordenarPorId(entries);
137
+ if (options.prefix) salida = salida.filter((e) => e.id.startsWith(options.prefix!));
138
+ if (options.start) salida = salida.filter((e) => e.id >= options.start!);
139
+ if (options.reverse) salida = salida.reverse();
140
+ if (options.offset) salida = salida.slice(options.offset);
141
+ if (options.limit != null) salida = salida.slice(0, options.limit);
142
+ return salida;
143
+ }
144
+
145
+ /**
146
+ * Vista de una colección de catálogo para el inquilino activo: lo compartido
147
+ * con su elección aplicada, más lo que el inquilino haya creado.
148
+ *
149
+ * **Versiones.** Para una fila compartida se devuelve la versión de la fila
150
+ * compartida, y `put()` la ignora al escribir la activación (que lleva su propia
151
+ * versión). El control optimista sigue siendo real dentro de cada capa; lo que
152
+ * no hay es una versión única que cubra a las dos a la vez. Los escritores de
153
+ * activación son toggles de usuario —leer, cambiar un booleano, escribir—, así
154
+ * que lo que se pierde es la detección de dos toggles simultáneos sobre el mismo
155
+ * elemento del mismo inquilino, y lo que se gana es no copiar el catálogo.
156
+ */
157
+ export function catalogView<T>(
158
+ compartida: DocStore<T>,
159
+ propia: DocStore<T>,
160
+ activaciones: DocStore<ActivationDoc>,
161
+ nombre: string,
162
+ ): DocStore<T> {
163
+ const leerActivacion = async (id: string): Promise<ActivationDoc | undefined> =>
164
+ (await activaciones.get(activationId(nombre, id)))?.doc;
165
+
166
+ const escribirActivacion = async (id: string, doc: T): Promise<void> => {
167
+ const actual = await activaciones.get(activationId(nombre, id));
168
+ const fila = doc as Record<string, unknown>;
169
+ await activaciones.put(activationId(nombre, id), {
170
+ id: activationId(nombre, id),
171
+ collection: nombre,
172
+ item_id: id,
173
+ active: fila.active === true,
174
+ enabled: "enabled" in fila ? fila.enabled === true : true,
175
+ hidden: false,
176
+ updated_at: Date.now(),
177
+ }, actual ? { expectedVersion: actual.version } : { expectedVersion: 0 });
178
+ };
179
+
180
+ return {
181
+ async get(id) {
182
+ const local = await propia.get(id);
183
+ if (local) return local;
184
+ const compartido = await compartida.get(id);
185
+ if (!compartido) return undefined;
186
+ const overlay = await leerActivacion(id);
187
+ if (overlay?.hidden) return undefined;
188
+ return { ...compartido, doc: withActivation(compartido.doc, overlay) };
189
+ },
190
+
191
+ async put(id, doc, options) {
192
+ const local = await propia.get(id);
193
+ if (local) return propia.put(id, doc, options);
194
+
195
+ const compartido = await compartida.get(id);
196
+ if (compartido) {
197
+ if (soloCambiaLaEleccion(doc, compartido.doc)) {
198
+ await escribirActivacion(id, doc);
199
+ return compartido.version;
200
+ }
201
+ // El inquilino editó el CONTENIDO de una fila del catálogo: se queda con
202
+ // su propia copia. `expectedVersion` venía de la fila compartida y acá
203
+ // no aplica —la copia todavía no existe—, así que no se reenvía.
204
+ return propia.put(id, doc);
205
+ }
206
+
207
+ return propia.put(id, doc, options);
208
+ },
209
+
210
+ async delete(id) {
211
+ const local = await propia.get(id);
212
+ if (local) {
213
+ await activaciones.delete(activationId(nombre, id)).catch(() => false);
214
+ return propia.delete(id);
215
+ }
216
+ const compartido = await compartida.get(id);
217
+ if (!compartido) return false;
218
+ const actual = await activaciones.get(activationId(nombre, id));
219
+ if (actual?.doc.hidden) return false;
220
+ await activaciones.put(activationId(nombre, id), {
221
+ id: activationId(nombre, id),
222
+ collection: nombre,
223
+ item_id: id,
224
+ active: false,
225
+ enabled: false,
226
+ hidden: true,
227
+ updated_at: Date.now(),
228
+ }, actual ? { expectedVersion: actual.version } : { expectedVersion: 0 });
229
+ return true;
230
+ },
231
+
232
+ async scan(options) {
233
+ const [compartidos, propios, overlays] = await Promise.all([
234
+ compartida.scan({}),
235
+ propia.scan({}),
236
+ activaciones.scan({ prefix: `${nombre}:` }),
237
+ ]);
238
+ const porItem = new Map(overlays.map((e) => [e.doc.item_id, e.doc]));
239
+
240
+ const porId = new Map<string, DocEntry<T>>();
241
+ for (const entrada of compartidos) {
242
+ const overlay = porItem.get(entrada.id);
243
+ if (overlay?.hidden) continue;
244
+ porId.set(entrada.id, { ...entrada, doc: withActivation(entrada.doc, overlay) });
245
+ }
246
+ // Lo propio gana: si el inquilino editó una fila del catálogo, su copia es
247
+ // la que vale para él.
248
+ for (const entrada of propios) porId.set(entrada.id, entrada);
249
+
250
+ return aplicarOpciones([...porId.values()], options);
251
+ },
252
+
253
+ async count() {
254
+ return (await this.scan({})).length;
255
+ },
256
+
257
+ async createIndex(field, options) {
258
+ // Los índices de la colección compartida los crea el bootstrap sin
259
+ // inquilino, una sola vez. Acá sólo corresponde el de la partición.
260
+ await propia.createIndex(field, options);
261
+ },
262
+
263
+ async findBy(field, value, options) {
264
+ // Sobre la vista combinada no hay un índice del motor que cubra las dos
265
+ // capas, así que se filtra en memoria. El catálogo son decenas de filas,
266
+ // no millones, y el único llamador es la búsqueda de skills por categoría.
267
+ const todas = await this.scan({});
268
+ const filtradas = todas.filter(
269
+ (e) => (e.doc as Record<string, unknown>)[field] === value,
270
+ );
271
+ return aplicarOpciones(filtradas, options);
272
+ },
273
+ };
274
+ }
275
+
276
+ /** Handle a la colección de catálogo compartida (sin prefijo de inquilino). */
277
+ export async function sharedCatalogCol<T>(name: string): Promise<DocStore<T>> {
278
+ const db = await getHiveDb();
279
+ return db.collection<T>(name);
280
+ }
281
+
282
+ /**
283
+ * La vista de catálogo de esta colección para el inquilino activo, ya cableada.
284
+ *
285
+ * Es lo que devuelve `col()` cuando corresponde; vive acá para que `hive.ts` no
286
+ * tenga que conocer ni la colección de activaciones ni cómo se componen las
287
+ * capas.
288
+ */
289
+ export async function catalogCol<T>(name: string): Promise<DocStore<T>> {
290
+ const db = await getHiveDb();
291
+ return catalogView<T>(
292
+ db.collection<T>(name),
293
+ db.collection<T>(qualify(name)),
294
+ db.collection<ActivationDoc>(qualify(ACTIVATIONS)),
295
+ name,
296
+ );
297
+ }
298
+
299
+ /**
300
+ * Enciende o apaga un elemento del catálogo para el inquilino activo, sin tocar
301
+ * la fila compartida.
302
+ *
303
+ * Es lo que usa un host multi-inquilino —Hive Cloud— para bajar a cada enjambre
304
+ * lo que su workspace activó, en vez de escribirle una copia del catálogo.
305
+ */
306
+ export async function setCatalogActivation(
307
+ collection: string,
308
+ itemId: string,
309
+ eleccion: { active: boolean; enabled?: boolean },
310
+ ): Promise<void> {
311
+ if (!CATALOG_COLLECTIONS.has(collection)) {
312
+ throw new Error(`setCatalogActivation: "${collection}" no es una colección de catálogo`);
313
+ }
314
+ const db = await getHiveDb();
315
+ const activaciones = db.collection<ActivationDoc>(qualify(ACTIVATIONS));
316
+ const id = activationId(collection, itemId);
317
+ const actual = await activaciones.get(id);
318
+ await activaciones.put(id, {
319
+ id,
320
+ collection,
321
+ item_id: itemId,
322
+ active: eleccion.active,
323
+ enabled: eleccion.enabled ?? eleccion.active,
324
+ hidden: false,
325
+ updated_at: Date.now(),
326
+ }, actual ? { expectedVersion: actual.version } : { expectedVersion: 0 });
327
+ }
328
+
329
+ /** Devuelve al inquilino a lo que diga el catálogo para ese elemento. */
330
+ export async function clearCatalogActivation(collection: string, itemId: string): Promise<boolean> {
331
+ const db = await getHiveDb();
332
+ const activaciones = db.collection<ActivationDoc>(qualify(ACTIVATIONS));
333
+ return activaciones.delete(activationId(collection, itemId));
334
+ }
335
+
336
+ /** Lo que este inquilino tiene decidido sobre una colección del catálogo. */
337
+ export async function listCatalogActivations(
338
+ collection: string,
339
+ ): Promise<Array<{ itemId: string; active: boolean; enabled: boolean; hidden: boolean }>> {
340
+ const db = await getHiveDb();
341
+ const activaciones = db.collection<ActivationDoc>(qualify(ACTIVATIONS));
342
+ const filas = await activaciones.scan({ prefix: `${collection}:` });
343
+ return filas.map((e) => ({
344
+ itemId: e.doc.item_id,
345
+ active: e.doc.active,
346
+ enabled: e.doc.enabled,
347
+ hidden: e.doc.hidden,
348
+ }));
349
+ }
350
+
351
+ /** `true` si esta colección se resuelve como catálogo compartido ahora mismo. */
352
+ export function esCatalogoCompartido(name: string): boolean {
353
+ return currentTenant() !== null && CATALOG_COLLECTIONS.has(name);
354
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { getHiveDb } from "./hivedb.ts";
11
11
  import { qualify } from "./tenant.ts";
12
+ import { catalogCol, esCatalogoCompartido, type DocStore } from "./catalog.ts";
12
13
 
13
14
  const MAX_RETRIES = 5;
14
15
 
@@ -46,7 +47,11 @@ export function fromIndexable(value: string | null | undefined): string | null {
46
47
  * El handle se construye en cada llamada a propósito: cachearlo en una variable
47
48
  * de módulo lo dejaría atado al tenant que lo creó primero.
48
49
  */
49
- export async function col<T>(name: string) {
50
+ export async function col<T>(name: string): Promise<DocStore<T>> {
51
+ // El catálogo (`tools`, `skills`, `ethics`) es contenido de la instalación, no
52
+ // de un inquilino: con tenant activo se sirve compartido, con la elección de
53
+ // este inquilino aplicada encima. Ver storage/catalog.ts.
54
+ if (esCatalogoCompartido(name)) return catalogCol<T>(name);
50
55
  const db = await getHiveDb();
51
56
  return db.collection<T>(qualify(name));
52
57
  }
@@ -41,6 +41,19 @@ export {
41
41
  BROADCAST,
42
42
  } from "./hive.ts";
43
43
 
44
+ // ─── Catálogo compartido y activación por inquilino ──────────────────────────
45
+ // El contenido del catálogo (tools, skills, ética) se instala una sola vez; cada
46
+ // inquilino guarda sólo lo que activó — ver storage/catalog.ts.
47
+ export {
48
+ CATALOG_COLLECTIONS,
49
+ setCatalogActivation,
50
+ clearCatalogActivation,
51
+ listCatalogActivations,
52
+ sharedCatalogCol,
53
+ esCatalogoCompartido,
54
+ } from "./catalog.ts";
55
+ export type { DocStore } from "./catalog.ts";
56
+
44
57
  // ─── Shapes de documento ─────────────────────────────────────────────────────
45
58
  export type * from "./collections.ts";
46
59