@johpaz/hive-sdk 0.4.3 → 0.4.4

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.
package/README.md CHANGED
@@ -230,4 +230,4 @@ npm view @johpaz/hive-sdk dist-tags # verificar después del release
230
230
 
231
231
  ---
232
232
 
233
- *Hive SDK v0.4.3 — MIT*
233
+ *Hive SDK v0.4.4 — MIT*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johpaz/hive-sdk",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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",
@@ -80,7 +80,7 @@
80
80
  "LICENSE"
81
81
  ],
82
82
  "engines": {
83
- "bun": ">=1.4.0"
83
+ "bun": ">=1.4.2"
84
84
  },
85
85
  "workspaces": [
86
86
  "packages/core",
@@ -115,15 +115,15 @@
115
115
  "mammoth": "^1.12.0",
116
116
  "ollama": "^0.6.3",
117
117
  "openai": "^6.18.0",
118
- "pdfjs-dist": "^5.6.205",
118
+ "pdfjs-dist": "^6.3.289",
119
119
  "pptxgenjs": "^4.0.1",
120
120
  "qrcode-terminal": "^0.12.0",
121
121
  "toon-format-parser": "^1.1.0",
122
- "xlsx": "^0.18.5",
122
+ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
123
123
  "zod": "^4.4.3"
124
124
  },
125
125
  "devDependencies": {
126
- "@types/bun": "^1.3.13",
126
+ "@types/bun": "^1.4.1",
127
127
  "@types/jsonwebtoken": "^9.0.10",
128
128
  "typescript": "6.0.2"
129
129
  }
@@ -12,5 +12,8 @@
12
12
  },
13
13
  "devDependencies": {
14
14
  "@types/bun": "latest"
15
+ },
16
+ "engines": {
17
+ "bun": ">=1.4.2"
15
18
  }
16
19
  }
@@ -1,3 +1,4 @@
1
+ import { resolvePort } from "../utils/port.ts";
1
2
  import * as z from "zod";
2
3
  import { mkdirSync, existsSync, readFileSync } from "node:fs";
3
4
  import * as path from "node:path";
@@ -407,7 +408,7 @@ function buildDefaultConfig(): Config {
407
408
  return {
408
409
  gateway: {
409
410
  host: process.env.HIVE_HOST || "127.0.0.1",
410
- port: parseInt(process.env.HIVE_PORT || "18790", 10),
411
+ port: resolvePort(process.env.HIVE_PORT, 18790),
411
412
  pidFile: path.join(hiveDir, "gateway.pid"),
412
413
  authToken: process.env.HIVE_AUTH_TOKEN || undefined,
413
414
  tools: {
@@ -96,11 +96,17 @@ export class SSETransport implements Transport {
96
96
  this.sessionId = sessionId;
97
97
  }
98
98
 
99
- // Track cookies for session affinity (important for n8n/proxies)
100
- const setCookie = response.headers.get("set-cookie");
101
- if (setCookie) {
102
- // Simple cookie extraction: just keep the keys and values
103
- const newCookies = setCookie.split(',').map(c => c.split(';')[0].trim());
99
+ // Track cookies for session affinity (important for n8n/proxies).
100
+ //
101
+ // `getSetCookie()` y no `get("set-cookie")`: Set-Cookie es la única cabecera
102
+ // que puede repetirse sin combinarse, y `get()` devuelve las repeticiones
103
+ // unidas con ", " (Bun 1.4 lo alineó con la spec de Fetch). Partir eso por
104
+ // coma rompe cualquier cookie cuyo valor lleve una —`Expires=Wed, 09 Jun
105
+ // 2027 10:18:14 GMT` es el caso de todos los días— y dejaba fragmentos como
106
+ // "09 Jun 2027 10:18:14 GMT" haciéndose pasar por cookies.
107
+ const setCookies = response.headers.getSetCookie();
108
+ if (setCookies.length > 0) {
109
+ const newCookies = setCookies.map(c => c.split(";")[0].trim());
104
110
  this.cookies = [...new Set([...this.cookies, ...newCookies])];
105
111
  }
106
112
  }
@@ -7,9 +7,11 @@
7
7
  * El motor de cron es propio (`./cron`), sin dependencias: sólo `setTimeout` e
8
8
  * `Intl` del runtime. Antes era `croner`.
9
9
  *
10
- * `Bun.cron()` no sirve como reemplazo —se evaluó contra el runtime 1.4.0—:
10
+ * `Bun.cron()` no sirve como reemplazo —reevaluado contra el runtime 1.4.2—:
11
11
  * acepta sólo 5 campos y rechaza el sexto, no admite una fecha ISO como patrón
12
- * (que es como se agendan los jobs `one_shot`), ignora la zona horaria, y su
12
+ * (que es como se agendan los jobs `one_shot`), no toma una zona por job (usa la
13
+ * local del proceso desde 1.4; antes era UTC, y ese cambio silencioso es
14
+ * justamente por qué no conviene delegarle la conversión), y su
13
15
  * handle no expone la próxima corrida, que es de donde sale `next_run_at` y con
14
16
  * lo que se detectan las corridas perdidas al arrancar. Tampoco tiene
15
17
  * equivalente de `protect`, `maxRuns`, `interval`, `startAt`/`stopAt` ni
@@ -8,7 +8,8 @@
8
8
  * habría sido migrar la base para no ganar nada.
9
9
  *
10
10
  * No usa `Bun.cron()`: ese sólo acepta 5 campos, no admite una fecha ISO como
11
- * patrón —que es como se agendan los jobs `one_shot`—, ignora la zona horaria y
11
+ * patrón —que es como se agendan los jobs `one_shot`—, no toma una zona por job
12
+ * (usa la local del proceso desde Bun 1.4; antes era UTC) y
12
13
  * su handle no expone la próxima corrida, que es de donde sale `next_run_at` y
13
14
  * con lo que el scheduler detecta las corridas perdidas al arrancar. Lo que sí
14
15
  * se usa de Bun es el runtime pelado: `setTimeout` e `Intl`.
@@ -2,7 +2,8 @@
2
2
  * Reloj de pared ↔ instante, en una zona horaria IANA.
3
3
  *
4
4
  * Es la parte difícil de un cron con zona horaria y la razón por la que no
5
- * alcanza con `Bun.cron.parse()`, que sólo trabaja en UTC. "Todos los días a
5
+ * alcanza con `Bun.cron.parse()`, que resuelve en una sola zona —UTC hasta Bun
6
+ * 1.3, la local del proceso desde 1.4— y nunca en la del job. "Todos los días a
6
7
  * las 9" significa las 9 **del reloj de la pared en Bogotá**, y ese instante se
7
8
  * corre una hora dos veces al año en las zonas con horario de verano. Calcular
8
9
  * el offset una sola vez y sumarlo produce un cron que se desfasa un día al año
@@ -10,6 +10,14 @@ import type { Tool } from "../types.ts";
10
10
  import { logger } from "../../utils/logger.ts";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import {
15
+ assertBeforeDeadline,
16
+ MAX_PDF_INPUT_BYTES,
17
+ MAX_PDF_PAGES_PER_REQUEST,
18
+ OFFICE_PROCESSING_TIMEOUT_MS,
19
+ validateOfficeInput,
20
+ } from "./security-limits.ts";
13
21
 
14
22
  const log = logger.child("office-leer-pdf");
15
23
 
@@ -48,61 +56,102 @@ export const officeLeerPdfTool: Tool = {
48
56
  return { ok: false, error: `Archivo no encontrado: ${rutaAbsoluta}` };
49
57
  }
50
58
 
59
+ const inputError = validateOfficeInput(
60
+ rutaAbsoluta,
61
+ MAX_PDF_INPUT_BYTES,
62
+ "El PDF",
63
+ );
64
+ if (inputError) return { ok: false, error: inputError };
65
+
51
66
  const buffer = fs.readFileSync(rutaAbsoluta);
52
67
  const uint8Array = new Uint8Array(buffer);
53
68
 
54
- // Importar pdfjs-dist (compatible con Bun, sin worker)
69
+ // La build legacy conserva compatibilidad con Bun. PDF.js configura su
70
+ // propio fake worker en Node/Bun; borrar workerSrc rompe PDF.js 6+.
55
71
  const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs" as any).catch(
56
72
  () => import("pdfjs-dist" as any)
57
73
  );
58
74
 
59
- // Desactivar worker para entorno Node/Bun
60
75
  const lib = pdfjsLib.default ?? pdfjsLib;
61
- if (lib.GlobalWorkerOptions) {
62
- lib.GlobalWorkerOptions.workerSrc = "";
63
- }
64
-
65
- const doc = await lib.getDocument({ data: uint8Array, disableWorker: true }).promise;
66
- const totalPaginas = doc.numPages;
76
+ const pdfjsPackageUrl = import.meta.resolve("pdfjs-dist/package.json");
77
+ const loadingTask = lib.getDocument({
78
+ data: uint8Array,
79
+ enableScripting: false,
80
+ isEvalSupported: false,
81
+ // Bun's process.getBuiltinModule("fs/promises") expects a filesystem
82
+ // path here; a file:// URL string is not accepted.
83
+ standardFontDataUrl: fileURLToPath(
84
+ new URL("./standard_fonts/", pdfjsPackageUrl),
85
+ ),
86
+ });
67
87
 
68
- // Metadata
69
- let titulo: string | undefined;
70
88
  try {
71
- const meta = await doc.getMetadata();
72
- titulo = (meta?.info as any)?.Title ?? undefined;
73
- } catch {
74
- // metadata opcional
89
+ const deadline = Date.now() + OFFICE_PROCESSING_TIMEOUT_MS;
90
+ const doc = await loadingTask.promise;
91
+ const totalPaginas = doc.numPages;
92
+
93
+ // Metadata
94
+ let titulo: string | undefined;
95
+ try {
96
+ const meta = await doc.getMetadata();
97
+ titulo = (meta?.info as any)?.Title ?? undefined;
98
+ } catch {
99
+ // metadata opcional
100
+ }
101
+
102
+ const inicio = paginaInicio;
103
+ if (!Number.isInteger(inicio) || inicio > totalPaginas) {
104
+ return {
105
+ ok: false,
106
+ error: `La página inicial debe estar entre 1 y ${totalPaginas}`,
107
+ };
108
+ }
109
+ if (paginaFin !== undefined && (!Number.isInteger(paginaFin) || paginaFin < inicio)) {
110
+ return {
111
+ ok: false,
112
+ error: "La página final debe ser un entero mayor o igual a la página inicial",
113
+ };
114
+ }
115
+
116
+ const fin = paginaFin ? Math.min(paginaFin, totalPaginas) : totalPaginas;
117
+ const paginasSolicitadas = fin - inicio + 1;
118
+ if (paginasSolicitadas > MAX_PDF_PAGES_PER_REQUEST) {
119
+ return {
120
+ ok: false,
121
+ error: `Se pueden leer como máximo ${MAX_PDF_PAGES_PER_REQUEST} páginas por solicitud`,
122
+ };
123
+ }
124
+
125
+ const textosPorPagina: Array<{ pagina: number; texto: string }> = [];
126
+
127
+ for (let i = inicio; i <= fin; i++) {
128
+ assertBeforeDeadline(deadline, "La lectura del PDF");
129
+ const pagina = await doc.getPage(i);
130
+ const contenido = await pagina.getTextContent();
131
+ const texto = (contenido.items as any[])
132
+ .map((item: any) => item.str ?? "")
133
+ .join(" ")
134
+ .replace(/\s+/g, " ")
135
+ .trim();
136
+ textosPorPagina.push({ pagina: i, texto });
137
+ }
138
+
139
+ const textoCompleto = textosPorPagina.map((p) => p.texto).join("\n\n");
140
+
141
+ log.info(`PDF leído: ${totalPaginas} páginas, ${textoCompleto.length} caracteres`);
142
+
143
+ return {
144
+ ok: true,
145
+ ruta: rutaAbsoluta,
146
+ totalPaginas,
147
+ paginasLeidas: paginasSolicitadas,
148
+ titulo,
149
+ texto: textoCompleto,
150
+ paginas: textosPorPagina,
151
+ };
152
+ } finally {
153
+ await loadingTask.destroy();
75
154
  }
76
-
77
- const inicio = paginaInicio;
78
- const fin = paginaFin ? Math.min(paginaFin, totalPaginas) : totalPaginas;
79
-
80
- const textosPorPagina: Array<{ pagina: number; texto: string }> = [];
81
-
82
- for (let i = inicio; i <= fin; i++) {
83
- const pagina = await doc.getPage(i);
84
- const contenido = await pagina.getTextContent();
85
- const texto = (contenido.items as any[])
86
- .map((item: any) => item.str ?? "")
87
- .join(" ")
88
- .replace(/\s+/g, " ")
89
- .trim();
90
- textosPorPagina.push({ pagina: i, texto });
91
- }
92
-
93
- const textoCompleto = textosPorPagina.map((p) => p.texto).join("\n\n");
94
-
95
- log.info(`PDF leído: ${totalPaginas} páginas, ${textoCompleto.length} caracteres`);
96
-
97
- return {
98
- ok: true,
99
- ruta: rutaAbsoluta,
100
- totalPaginas,
101
- paginasLeidas: fin - inicio + 1,
102
- titulo,
103
- texto: textoCompleto,
104
- paginas: textosPorPagina,
105
- };
106
155
  } catch (error) {
107
156
  log.error(`Error leyendo PDF: ${(error as Error).message}`);
108
157
  return {
@@ -11,6 +11,14 @@ import { logger } from "../../utils/logger.ts";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { cargarXlsx } from "./xlsx-loader.ts";
14
+ import {
15
+ assertBeforeDeadline,
16
+ MAX_XLSX_INPUT_BYTES,
17
+ MAX_XLSX_ROWS_PER_SHEET,
18
+ MAX_XLSX_SHEETS,
19
+ OFFICE_PROCESSING_TIMEOUT_MS,
20
+ validateOfficeInput,
21
+ } from "./security-limits.ts";
14
22
 
15
23
  const log = logger.child("office-leer-xlsx");
16
24
 
@@ -56,32 +64,43 @@ export const officeLeerXlsxTool: Tool = {
56
64
  return { ok: false, error: `Archivo no encontrado: ${rutaAbsoluta}` };
57
65
  }
58
66
 
67
+ const inputError = validateOfficeInput(
68
+ rutaAbsoluta,
69
+ MAX_XLSX_INPUT_BYTES,
70
+ "El XLSX",
71
+ );
72
+ if (inputError) return { ok: false, error: inputError };
73
+
59
74
  const XLSX = await cargarXlsx();
60
75
  const buffer = fs.readFileSync(rutaAbsoluta);
61
- const workbook = XLSX.read(buffer, { type: "buffer" });
76
+ const deadline = Date.now() + OFFICE_PROCESSING_TIMEOUT_MS;
77
+ const workbook = XLSX.read(buffer, {
78
+ type: "buffer",
79
+ sheetRows: MAX_XLSX_ROWS_PER_SHEET + 2,
80
+ sheets: hojaFiltro,
81
+ });
62
82
 
63
83
  const nombresHojas = hojaFiltro
64
84
  ? [hojaFiltro]
65
85
  : workbook.SheetNames;
66
86
 
87
+ if (nombresHojas.length > MAX_XLSX_SHEETS) {
88
+ return {
89
+ ok: false,
90
+ error: `El XLSX contiene más de ${MAX_XLSX_SHEETS} hojas`,
91
+ };
92
+ }
93
+
67
94
  const hojas: Record<string, any[]> = {};
68
95
 
69
96
  for (const nombreHoja of nombresHojas) {
97
+ assertBeforeDeadline(deadline, "La lectura del XLSX");
70
98
  const hoja = workbook.Sheets[nombreHoja];
71
99
  if (!hoja) {
72
100
  log.warn(`Hoja '${nombreHoja}' no encontrada en el archivo`);
73
101
  continue;
74
102
  }
75
103
 
76
- const opciones: any = {
77
- header: incluirEncabezados ? 1 : 1,
78
- defval: "",
79
- };
80
-
81
- if (rango) {
82
- opciones.range = rango;
83
- }
84
-
85
104
  if (incluirEncabezados) {
86
105
  // La primera fila se usa como encabezados
87
106
  hojas[nombreHoja] = XLSX.utils.sheet_to_json(hoja, {
@@ -96,6 +115,13 @@ export const officeLeerXlsxTool: Tool = {
96
115
  range: rango,
97
116
  });
98
117
  }
118
+
119
+ if (hojas[nombreHoja].length > MAX_XLSX_ROWS_PER_SHEET) {
120
+ return {
121
+ ok: false,
122
+ error: `La hoja '${nombreHoja}' supera el máximo de 10.000 filas`,
123
+ };
124
+ }
99
125
  }
100
126
 
101
127
  const totalFilas = Object.values(hojas).reduce(
@@ -0,0 +1,28 @@
1
+ import { statSync } from "node:fs";
2
+
3
+ export const MAX_PDF_INPUT_BYTES = 25 * 1024 * 1024;
4
+ export const MAX_XLSX_INPUT_BYTES = 15 * 1024 * 1024;
5
+ export const MAX_PDF_PAGES_PER_REQUEST = 200;
6
+ export const MAX_XLSX_SHEETS = 50;
7
+ export const MAX_XLSX_ROWS_PER_SHEET = 10_000;
8
+ export const OFFICE_PROCESSING_TIMEOUT_MS = 30_000;
9
+
10
+ export function validateOfficeInput(
11
+ filePath: string,
12
+ maxBytes: number,
13
+ label: string,
14
+ ): string | null {
15
+ const stat = statSync(filePath);
16
+ if (!stat.isFile()) return `${label} debe ser un archivo regular`;
17
+ if (stat.size > maxBytes) {
18
+ const maxMiB = maxBytes / (1024 * 1024);
19
+ return `${label} excede el límite de ${maxMiB} MiB`;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export function assertBeforeDeadline(deadline: number, label: string): void {
25
+ if (Date.now() > deadline) {
26
+ throw new Error(`${label} excedió el límite de procesamiento de 30 segundos`);
27
+ }
28
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Resolución de puertos desde el entorno.
3
+ *
4
+ * Bun 1.4 endureció `Bun.serve`: un puerto fuera de `[0, 65535]` —o `NaN`, que
5
+ * es lo que devuelve `parseInt("no-es-un-numero")`— ahora lanza `RangeError` en
6
+ * vez de recortar el valor. Un `HIVE_PORT` mal escrito dejó de degradar y pasó a
7
+ * tumbar el arranque con una excepción sin capturar:
8
+ *
9
+ * RangeError: The value of "options.port" is out of range.
10
+ * It must be an integer. Received NaN
11
+ *
12
+ * Este helper vuelve a la degradación explícita: avisa y sigue con el puerto por
13
+ * defecto, que para una variable de entorno mal tipeada es el comportamiento
14
+ * útil. El `0` se deja pasar a propósito: `Bun.serve` lo interpreta como "asigná
15
+ * un puerto libre" y hay código que se apoya en eso.
16
+ *
17
+ * Avisa con `console.warn` y no con el logger del proyecto a propósito: esto lo
18
+ * usa `config/loader.ts`, y el logger importa `config/loader.ts` para saber
19
+ * dónde escribir. Importarlo acá cierra el ciclo y rompe con "Cannot access
20
+ * 'logger' before initialization". Además, cuando se resuelve un puerto el
21
+ * logger todavía no está configurado.
22
+ */
23
+
24
+ export function resolvePort(raw: unknown, fallback: number): number {
25
+ if (raw === undefined || raw === null || raw === "") return fallback
26
+
27
+ const n = typeof raw === "number" ? raw : Number(String(raw).trim())
28
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
29
+ console.warn(`[config] Puerto inválido ${JSON.stringify(raw)}; se usa ${fallback}`)
30
+ return fallback
31
+ }
32
+ return n
33
+ }
@@ -1,3 +1,4 @@
1
+ import { resolvePort } from "../utils/port.ts";
1
2
  import { col } from "../storage/hive.ts";
2
3
  import type { ChannelDoc, ModelDoc } from "../storage/collections.ts";
3
4
  import { loadProviderApiKey } from "../storage/crypto.ts";
@@ -281,7 +282,7 @@ class VoiceService {
281
282
 
282
283
  private async speakWithPiper(text: string, voiceId?: string): Promise<AudioOutput> {
283
284
  const cleanText = cleanTextForTTS(text);
284
- const port = Number(process.env.TTS_PORT ?? 5500);
285
+ const port = resolvePort(process.env.TTS_PORT, 5500);
285
286
  const res = await fetch(`http://localhost:${port}/tts`, {
286
287
  method: "POST",
287
288
  headers: { "Content-Type": "application/json" },