@johpaz/hive-sdk 0.4.7 → 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.
@@ -1,5 +1,6 @@
1
1
  import { col, toIndexable, nextId } from "./hive.ts"
2
- import type { Collection } from "@johpaz/hive-db"
2
+ import type { DocStore } from "./catalog.ts"
3
+ import { currentTenant } from "./tenant.ts"
3
4
  import { logger } from "../utils/logger.ts"
4
5
  import { catalogModelKey } from "./model-id.ts"
5
6
  import { invalidateModelPricingCache } from "./usage.ts"
@@ -444,6 +445,7 @@ export const SEED_DATA: SeedData = {
444
445
  { id: "telegram", type: "telegram" },
445
446
  { id: "discord", type: "discord" },
446
447
  { id: "slack", type: "slack" },
448
+ { id: "whatsapp_cloud", type: "whatsapp_cloud" },
447
449
  { id: "whatsapp", type: "whatsapp" },
448
450
  ],
449
451
 
@@ -486,7 +488,7 @@ import { expandToolAllowlist } from "../agent/delegation-runtime.ts"
486
488
  const log = logger.child("seed");
487
489
 
488
490
  /** Insert-only-if-absent — the HiveDB equivalent of SQL `INSERT OR IGNORE`. */
489
- async function putIfAbsent<T>(c: Collection<T>, id: string, doc: T): Promise<void> {
491
+ async function putIfAbsent<T>(c: DocStore<T>, id: string, doc: T): Promise<void> {
490
492
  if (!(await c.get(id))) await c.put(id, doc)
491
493
  }
492
494
 
@@ -791,146 +793,177 @@ export interface SeedOptions {
791
793
  specialists?: SpecialistSeedMode
792
794
  }
793
795
 
796
+ /**
797
+ * Resiembra el catálogo estático de providers y modelos.
798
+ *
799
+ * Vive aparte porque con inquilino activo NO se llama. En un host
800
+ * multi-inquilino el catálogo que vale es el del host —Hive Cloud le baja a cada
801
+ * enjambre los providers que su workspace configuró, con su `base_url` y su
802
+ * `context_window`—, así que resembrar el estático encima significaba borrar y
803
+ * recrear los 139 modelos de `SEED_DATA` en la partición de cada enjambre, en
804
+ * cada arranque, para que un instante después los pisara Postgres.
805
+ */
806
+ async function reseedProvidersAndModels(): Promise<void> {
807
+ const now = Date.now();
808
+
809
+ // 4️⃣ Providers
810
+ const providersCol = await col<ProviderDoc>("providers");
811
+ let providerCount = 0;
812
+ for (const provider of SEED_DATA.providers) {
813
+ await putIfAbsent(providersCol, provider.id, {
814
+ id: provider.id, name: provider.name, base_url: provider.baseUrl || null,
815
+ category: (provider.category || "llm") as ProviderDoc["category"],
816
+ num_ctx: null, num_gpu: -1, enabled: false, active: false, created_at: now,
817
+ });
818
+ providerCount++;
819
+ }
820
+ // If OLLAMA_HOST is set (e.g. Docker pointing to host machine), always update Ollama's base_url
821
+ const ollamaHost = process.env.OLLAMA_HOST;
822
+ if (ollamaHost) {
823
+ const ollama = await providersCol.get("ollama");
824
+ if (ollama) {
825
+ await providersCol.put("ollama", { ...ollama.doc, base_url: ollamaHost }, { expectedVersion: ollama.version });
826
+ log.info(`[seed] ✅ Ollama base_url set to ${ollamaHost} (from OLLAMA_HOST env)`);
827
+ }
828
+ }
829
+ // Older DBs were seeded with enabled=true hardcoded regardless of the user's own
830
+ // activation (active). enabled/active are otherwise always kept in sync by every
831
+ // mutation path (toggle, update, voice key save), so reconciling them here undoes
832
+ // exactly that stale default without touching providers the user genuinely activated.
833
+ let reconciledCount = 0;
834
+ for (const row of await providersCol.scan({})) {
835
+ if (row.doc.enabled !== row.doc.active) {
836
+ await providersCol.put(row.id, { ...row.doc, enabled: row.doc.active }, { expectedVersion: row.version });
837
+ reconciledCount++;
838
+ }
839
+ }
840
+ if (reconciledCount > 0) {
841
+ log.info(`[seed] 🔧 Reconciled ${reconciledCount} provider(s) with a stale enabled≠active state`);
842
+ }
843
+ log.info(`[seed] ✅ ${providerCount} providers procesados`);
844
+
845
+ // 5️⃣ Models — wipe & recreate.
846
+ //
847
+ // Actualizar el catálogo es editar SEED_DATA.models y arrancar: las filas de
848
+ // catálogo se borran enteras y se vuelven a crear, así ningún campo viejo
849
+ // (context_window, capabilities, precio) sobrevive a una entrada renombrada
850
+ // o corregida. Un upsert en sitio no daba esa garantía.
851
+ //
852
+ // Dos cosas sí se preservan a propósito:
853
+ // - enabled/active por id, para no desactivar el modelo que el usuario
854
+ // eligió cada vez que se publica un catálogo nuevo;
855
+ // - las filas source != "catalog" (Ollama tags, /v1/models), que no tienen
856
+ // origen canónico desde el que recrearse.
857
+ log.info("[seed] 🔄 Re-seeding models (wipe & recreate)...");
858
+ const modelsCol = await col<ModelDoc>("models");
859
+ const beforeWipe = await modelsCol.scan({});
860
+
861
+ const activationState = new Map(
862
+ beforeWipe.map((e) => [e.id, { enabled: e.doc.enabled, active: e.doc.active }])
863
+ );
864
+
865
+ let wipedModels = 0;
866
+ for (const row of beforeWipe) {
867
+ if (row.doc.source !== "catalog") continue;
868
+ await modelsCol.delete(row.id);
869
+ wipedModels++;
870
+ }
871
+
872
+ let modelCount = 0;
873
+ for (const model of SEED_DATA.models) {
874
+ const key = catalogModelKey(model.providerId, model.id);
875
+ const previous = activationState.get(key);
876
+ await modelsCol.put(key, {
877
+ id: key, provider_id: model.providerId, name: model.name,
878
+ model_type: model.modelType as ModelDoc["model_type"],
879
+ context_window: model.contextWindow || 0, capabilities: model.capabilities || null,
880
+ enabled: previous?.enabled ?? true, active: previous?.active ?? false,
881
+ source: "catalog",
882
+ input_per_1m: model.inputPer1M ?? null,
883
+ output_per_1m: model.outputPer1M ?? null,
884
+ });
885
+ modelCount++;
886
+ }
887
+ log.info(`[seed] 🗑️ ${wipedModels} modelo(s) de catálogo borrados y recreados desde SEED_DATA`);
888
+ invalidateModelPricingCache();
889
+
890
+ // An agent pointing at a model that just got removed would otherwise keep
891
+ // a dangling model_id — unlink it so loadAgentConfigFromDB() falls back to
892
+ // getDefaultLLM() instead of resolving a model_id that isn't there.
893
+ const liveModelIds = new Set((await modelsCol.scan({})).map((e) => e.id));
894
+ const agentsCol = await col<AgentDoc>("agents");
895
+ const allAgents = await agentsCol.scan({});
896
+ let unlinkedCount = 0;
897
+ for (const a of allAgents) {
898
+ if (a.doc.model_id && a.doc.model_id !== "__none__" && !liveModelIds.has(a.doc.model_id)) {
899
+ await agentsCol.put(a.id, { ...a.doc, model_id: toIndexable(null) }, { expectedVersion: a.version });
900
+ unlinkedCount++;
901
+ }
902
+ }
903
+ if (unlinkedCount > 0) {
904
+ log.info(`[seed] 🔗 Unlinked ${unlinkedCount} agent(s) from model(s) no longer in the catalog`);
905
+ }
906
+
907
+ // Los canales guardan el id del modelo de voz, no una FK: si el modelo salió
908
+ // del catálogo hay que repuntarlos a mano o la voz queda rota en silencio.
909
+ const voiceChannelsCol = await col<ChannelDoc>("channels");
910
+ let migratedVoice = 0;
911
+ for (const c of await voiceChannelsCol.scan({})) {
912
+ const patch: Partial<ChannelDoc> = {};
913
+ for (const field of ["stt_provider", "tts_provider"] as const) {
914
+ const current = c.doc[field];
915
+ if (current && current in RETIRED_VOICE_MODELS) {
916
+ patch[field] = RETIRED_VOICE_MODELS[current];
917
+ }
918
+ }
919
+ if (Object.keys(patch).length === 0) continue;
920
+ await voiceChannelsCol.put(c.id, { ...c.doc, ...patch }, { expectedVersion: c.version });
921
+ migratedVoice++;
922
+ log.info(`[seed] 🎙️ Canal ${c.id}: modelo de voz deprecado migrado ${JSON.stringify(patch)}`);
923
+ }
924
+ if (migratedVoice > 0) {
925
+ log.info(`[seed] 🎙️ ${migratedVoice} canal(es) repuntados a modelos de voz vigentes`);
926
+ }
927
+ log.info(`[seed] ✅ ${modelCount} models procesados`);
928
+ }
929
+
794
930
  export async function seedAllData(opts?: SeedOptions): Promise<void> {
795
931
  log.info("[seed] 🌱 Iniciando seed de datos predeterminados...")
796
932
  const especialistas = opts?.specialists ?? "all"
797
933
 
798
- await reseedToolsAndSkills(especialistas);
934
+ // El catálogo —tools, skills y ética— es contenido de la INSTALACIÓN y vive
935
+ // una sola vez: con inquilino activo `col()` lo sirve compartido y cada
936
+ // enjambre guarda únicamente lo que activó (ver storage/catalog.ts).
937
+ // Resembrarlo acá escribiría una copia por enjambre, que es exactamente lo que
938
+ // esa separación viene a quitar. Lo mismo con providers y modelos, que en un
939
+ // host multi-inquilino los pone el host.
940
+ const enInquilino = currentTenant() !== null;
941
+
942
+ if (!enInquilino) await reseedToolsAndSkills(especialistas);
799
943
 
800
944
  try {
801
945
  const now = Date.now();
802
946
 
803
947
  // 3️⃣ Ethics templates (globales)
804
- const ethicsCol = await col<EthicsDoc>("ethics");
805
- let ethicsCount = 0;
806
- for (const ethics of SEED_DATA.ethics) {
807
- await putIfAbsent(ethicsCol, ethics.id, {
808
- id: ethics.id, name: ethics.name, description: ethics.description, content: ethics.content,
809
- is_default: ethics.isDefault, enabled: true, active: ethics.isDefault,
810
- });
811
- ethicsCount++;
812
- }
813
- log.info(`[seed] ✅ ${ethicsCount} ethics templates procesados`);
814
-
815
- // 4️⃣ Providers
816
- const providersCol = await col<ProviderDoc>("providers");
817
- let providerCount = 0;
818
- for (const provider of SEED_DATA.providers) {
819
- await putIfAbsent(providersCol, provider.id, {
820
- id: provider.id, name: provider.name, base_url: provider.baseUrl || null,
821
- category: (provider.category || "llm") as ProviderDoc["category"],
822
- num_ctx: null, num_gpu: -1, enabled: false, active: false, created_at: now,
823
- });
824
- providerCount++;
825
- }
826
- // If OLLAMA_HOST is set (e.g. Docker pointing to host machine), always update Ollama's base_url
827
- const ollamaHost = process.env.OLLAMA_HOST;
828
- if (ollamaHost) {
829
- const ollama = await providersCol.get("ollama");
830
- if (ollama) {
831
- await providersCol.put("ollama", { ...ollama.doc, base_url: ollamaHost }, { expectedVersion: ollama.version });
832
- log.info(`[seed] ✅ Ollama base_url set to ${ollamaHost} (from OLLAMA_HOST env)`);
833
- }
834
- }
835
- // Older DBs were seeded with enabled=true hardcoded regardless of the user's own
836
- // activation (active). enabled/active are otherwise always kept in sync by every
837
- // mutation path (toggle, update, voice key save), so reconciling them here undoes
838
- // exactly that stale default without touching providers the user genuinely activated.
839
- let reconciledCount = 0;
840
- for (const row of await providersCol.scan({})) {
841
- if (row.doc.enabled !== row.doc.active) {
842
- await providersCol.put(row.id, { ...row.doc, enabled: row.doc.active }, { expectedVersion: row.version });
843
- reconciledCount++;
948
+ if (!enInquilino) {
949
+ const ethicsCol = await col<EthicsDoc>("ethics");
950
+ let ethicsCount = 0;
951
+ for (const ethics of SEED_DATA.ethics) {
952
+ await putIfAbsent(ethicsCol, ethics.id, {
953
+ id: ethics.id, name: ethics.name, description: ethics.description, content: ethics.content,
954
+ is_default: ethics.isDefault, enabled: true, active: ethics.isDefault,
955
+ });
956
+ ethicsCount++;
844
957
  }
958
+ log.info(`[seed] ✅ ${ethicsCount} ethics templates procesados`);
845
959
  }
846
- if (reconciledCount > 0) {
847
- log.info(`[seed] 🔧 Reconciled ${reconciledCount} provider(s) with a stale enabled≠active state`);
848
- }
849
- log.info(`[seed] ✅ ${providerCount} providers procesados`);
850
960
 
851
- // 5️⃣ Models — wipe & recreate.
852
- //
853
- // Actualizar el catálogo es editar SEED_DATA.models y arrancar: las filas de
854
- // catálogo se borran enteras y se vuelven a crear, así ningún campo viejo
855
- // (context_window, capabilities, precio) sobrevive a una entrada renombrada
856
- // o corregida. Un upsert en sitio no daba esa garantía.
857
- //
858
- // Dos cosas sí se preservan a propósito:
859
- // - enabled/active por id, para no desactivar el modelo que el usuario
860
- // eligió cada vez que se publica un catálogo nuevo;
861
- // - las filas source != "catalog" (Ollama tags, /v1/models), que no tienen
862
- // origen canónico desde el que recrearse.
863
- log.info("[seed] 🔄 Re-seeding models (wipe & recreate)...");
864
- const modelsCol = await col<ModelDoc>("models");
865
- const beforeWipe = await modelsCol.scan({});
866
-
867
- const activationState = new Map(
868
- beforeWipe.map((e) => [e.id, { enabled: e.doc.enabled, active: e.doc.active }])
869
- );
870
-
871
- let wipedModels = 0;
872
- for (const row of beforeWipe) {
873
- if (row.doc.source !== "catalog") continue;
874
- await modelsCol.delete(row.id);
875
- wipedModels++;
876
- }
877
-
878
- let modelCount = 0;
879
- for (const model of SEED_DATA.models) {
880
- const key = catalogModelKey(model.providerId, model.id);
881
- const previous = activationState.get(key);
882
- await modelsCol.put(key, {
883
- id: key, provider_id: model.providerId, name: model.name,
884
- model_type: model.modelType as ModelDoc["model_type"],
885
- context_window: model.contextWindow || 0, capabilities: model.capabilities || null,
886
- enabled: previous?.enabled ?? true, active: previous?.active ?? false,
887
- source: "catalog",
888
- input_per_1m: model.inputPer1M ?? null,
889
- output_per_1m: model.outputPer1M ?? null,
890
- });
891
- modelCount++;
892
- }
893
- log.info(`[seed] 🗑️ ${wipedModels} modelo(s) de catálogo borrados y recreados desde SEED_DATA`);
894
- invalidateModelPricingCache();
961
+ // 4️⃣ y 5️⃣ Providers y modelos
962
+ if (!enInquilino) await reseedProvidersAndModels();
895
963
 
896
- // An agent pointing at a model that just got removed would otherwise keep
897
- // a dangling model_id — unlink it so loadAgentConfigFromDB() falls back to
898
- // getDefaultLLM() instead of resolving a model_id that isn't there.
899
- const liveModelIds = new Set((await modelsCol.scan({})).map((e) => e.id));
964
+ // Los agentes son del inquilino: cada enjambre tiene los suyos, y por eso
965
+ // esto sí corre siempre. Antes se declaraba dentro del bloque de modelos.
900
966
  const agentsCol = await col<AgentDoc>("agents");
901
- const allAgents = await agentsCol.scan({});
902
- let unlinkedCount = 0;
903
- for (const a of allAgents) {
904
- if (a.doc.model_id && a.doc.model_id !== "__none__" && !liveModelIds.has(a.doc.model_id)) {
905
- await agentsCol.put(a.id, { ...a.doc, model_id: toIndexable(null) }, { expectedVersion: a.version });
906
- unlinkedCount++;
907
- }
908
- }
909
- if (unlinkedCount > 0) {
910
- log.info(`[seed] 🔗 Unlinked ${unlinkedCount} agent(s) from model(s) no longer in the catalog`);
911
- }
912
-
913
- // Los canales guardan el id del modelo de voz, no una FK: si el modelo salió
914
- // del catálogo hay que repuntarlos a mano o la voz queda rota en silencio.
915
- const voiceChannelsCol = await col<ChannelDoc>("channels");
916
- let migratedVoice = 0;
917
- for (const c of await voiceChannelsCol.scan({})) {
918
- const patch: Partial<ChannelDoc> = {};
919
- for (const field of ["stt_provider", "tts_provider"] as const) {
920
- const current = c.doc[field];
921
- if (current && current in RETIRED_VOICE_MODELS) {
922
- patch[field] = RETIRED_VOICE_MODELS[current];
923
- }
924
- }
925
- if (Object.keys(patch).length === 0) continue;
926
- await voiceChannelsCol.put(c.id, { ...c.doc, ...patch }, { expectedVersion: c.version });
927
- migratedVoice++;
928
- log.info(`[seed] 🎙️ Canal ${c.id}: modelo de voz deprecado migrado ${JSON.stringify(patch)}`);
929
- }
930
- if (migratedVoice > 0) {
931
- log.info(`[seed] 🎙️ ${migratedVoice} canal(es) repuntados a modelos de voz vigentes`);
932
- }
933
- log.info(`[seed] ✅ ${modelCount} models procesados`);
934
967
 
935
968
  // 6️⃣ MCP servers
936
969
  const mcpCol = await col<McpServerDoc>("mcpServers");
@@ -78,7 +78,7 @@ export async function resolveBestChannel(userId: string, explicitChannel?: strin
78
78
  }
79
79
 
80
80
  if (!bestChannel) {
81
- const preferred = ["telegram", "discord", "slack", "whatsapp", "webchat"];
81
+ const preferred = ["telegram", "discord", "slack", "whatsapp_cloud", "whatsapp", "webchat"];
82
82
  for (const p of preferred) {
83
83
  if (identities.includes(p)) {
84
84
  bestChannel = p;
@@ -113,7 +113,7 @@ export const cronCreateTool: Tool = {
113
113
  agent_id: { type: "string", description: "Target agent ID (optional, defaults to Coordinator)" },
114
114
  tool_name: { type: "string", description: "Specific tool to execute (optional)" },
115
115
  max_runs: { type: "number", description: "Maximum executions (optional, null = unlimited)" },
116
- channel: { type: "string", description: "Notification channel (system, telegram, discord, whatsapp, cli)" },
116
+ channel: { type: "string", description: "Notification channel (system, telegram, discord, whatsapp_cloud, whatsapp, cli)" },
117
117
  start_at: { type: "string", description: "ISO 8601 datetime: start of execution window. Optional." },
118
118
  stop_at: { type: "string", description: "ISO 8601 datetime: end of execution window. Optional." },
119
119
  dom_and_dow: { type: "boolean", description: "If true, both day-of-month AND day-of-week must match. Default: false (OR logic)" },
@@ -593,6 +593,8 @@ class VoiceService {
593
593
  case "discord":
594
594
  return this.normalizeDiscordAudio(audioData);
595
595
  case "whatsapp":
596
+ case "whatsapp_cloud":
597
+ // Las notas de voz son ogg/opus en los dos casos.
596
598
  return this.normalizeWhatsAppAudio(audioData);
597
599
  case "slack":
598
600
  return this.normalizeSlackAudio(audioData);