@johpaz/hive-sdk 0.2.0 → 0.3.1

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 (88) hide show
  1. package/CHANGELOG.md +306 -0
  2. package/README.md +11 -3
  3. package/package.json +10 -4
  4. package/packages/core/src/agent/agent-catalog.ts +81 -24
  5. package/packages/core/src/agent/agent-loop.ts +2 -2
  6. package/packages/core/src/agent/compaction.ts +20 -1
  7. package/packages/core/src/agent/context-compiler.ts +7 -4
  8. package/packages/core/src/agent/conversation-store.ts +136 -2
  9. package/packages/core/src/agent/curator.ts +12 -3
  10. package/packages/core/src/agent/llm-providers/nvidia.ts +39 -0
  11. package/packages/core/src/agent/llm-providers/openai-compat-base.ts +38 -2
  12. package/packages/core/src/agent/playbook-selector.ts +18 -3
  13. package/packages/core/src/agent/prompt-builder.ts +2 -2
  14. package/packages/core/src/agent/providers/index.ts +37 -2
  15. package/packages/core/src/agent/reflector.ts +32 -9
  16. package/packages/core/src/agent/skill-selector.ts +2 -2
  17. package/packages/core/src/agent/thread-store.ts +43 -0
  18. package/packages/core/src/agent/tool-selector.ts +2 -0
  19. package/packages/core/src/api/createAgent.ts +68 -2
  20. package/packages/core/src/artifacts/index.ts +15 -0
  21. package/packages/core/src/artifacts/store.ts +77 -2
  22. package/packages/core/src/canvas/index.ts +9 -0
  23. package/packages/core/src/ethics/EthicsGuard.ts +7 -1
  24. package/packages/core/src/events/index.ts +18 -0
  25. package/packages/core/src/events/tool-narration.ts +4 -0
  26. package/packages/core/src/gateway/channel-notify.ts +103 -6
  27. package/packages/core/src/gateway/durable-queue.ts +13 -1
  28. package/packages/core/src/gateway/index.ts +3 -0
  29. package/packages/core/src/gateway/job-store.ts +6 -0
  30. package/packages/core/src/harness/executors.ts +493 -0
  31. package/packages/core/src/harness/index.ts +12 -2
  32. package/packages/core/src/hooks/index.ts +203 -0
  33. package/packages/core/src/images/index.ts +161 -0
  34. package/packages/core/src/index.ts +1 -0
  35. package/packages/core/src/multimodal/vision-service.ts +45 -13
  36. package/packages/core/src/resilience/index.ts +13 -0
  37. package/packages/core/src/scheduler/CronScheduler.ts +48 -21
  38. package/packages/core/src/scheduler/cron/expression.ts +165 -0
  39. package/packages/core/src/scheduler/cron/index.ts +10 -0
  40. package/packages/core/src/scheduler/cron/job.ts +339 -0
  41. package/packages/core/src/scheduler/cron/next-run.ts +121 -0
  42. package/packages/core/src/scheduler/cron/zoned-time.ts +138 -0
  43. package/packages/core/src/scheduler/index.ts +21 -3
  44. package/packages/core/src/scheduler/integration.ts +16 -5
  45. package/packages/core/src/scheduler/types.ts +3 -18
  46. package/packages/core/src/services/agents.ts +268 -0
  47. package/packages/core/src/services/cron.ts +257 -0
  48. package/packages/core/src/services/endpoints.ts +289 -0
  49. package/packages/core/src/services/ethics.ts +107 -0
  50. package/packages/core/src/services/images.ts +212 -0
  51. package/packages/core/src/services/index.ts +112 -0
  52. package/packages/core/src/services/mcp.ts +201 -0
  53. package/packages/core/src/services/memory.ts +133 -0
  54. package/packages/core/src/services/models.ts +179 -0
  55. package/packages/core/src/services/providers.ts +152 -0
  56. package/packages/core/src/services/setup.ts +222 -0
  57. package/packages/core/src/services/skills.ts +241 -0
  58. package/packages/core/src/services/swarms.ts +307 -0
  59. package/packages/core/src/services/tools.ts +106 -0
  60. package/packages/core/src/sessions/index.ts +5 -3
  61. package/packages/core/src/sessions/resolve.ts +108 -0
  62. package/packages/core/src/skills/SkillLoader.ts +8 -1
  63. package/packages/core/src/skills/bundled/artifacts/artifact_reader/SKILL.md +105 -0
  64. package/packages/core/src/skills/bundled/cron_manager/SKILL.md +21 -11
  65. package/packages/core/src/skills/bundled/images/image_editor/SKILL.md +120 -0
  66. package/packages/core/src/skills/bundled/web/browser_automate/SKILL.md +12 -3
  67. package/packages/core/src/skills/bundled/web/browser_scrape/SKILL.md +22 -7
  68. package/packages/core/src/skills/bundled-data.generated.ts +110 -12
  69. package/packages/core/src/storage/bootstrap.ts +74 -5
  70. package/packages/core/src/storage/collections.ts +106 -1
  71. package/packages/core/src/storage/crypto.ts +24 -7
  72. package/packages/core/src/storage/hive.ts +9 -3
  73. package/packages/core/src/storage/index.ts +2 -1
  74. package/packages/core/src/storage/onboarding.ts +59 -43
  75. package/packages/core/src/storage/reconcile.ts +6 -1
  76. package/packages/core/src/storage/seed.ts +98 -14
  77. package/packages/core/src/swarm/types.ts +3 -18
  78. package/packages/core/src/tool-runtime/embedded-worker.generated.ts +21 -0
  79. package/packages/core/src/tool-runtime/index.ts +129 -14
  80. package/packages/core/src/tools/agents/index.ts +18 -60
  81. package/packages/core/src/tools/cli/index.ts +55 -0
  82. package/packages/core/src/tools/core/index.ts +50 -2
  83. package/packages/core/src/tools/cron/index.ts +4 -4
  84. package/packages/core/src/tools/images/index.ts +130 -0
  85. package/packages/core/src/tools/index.ts +14 -1
  86. package/packages/core/src/tools/office/office-escribir-xlsx.ts +2 -1
  87. package/packages/core/src/tools/office/office-leer-xlsx.ts +2 -1
  88. package/packages/core/src/tools/office/xlsx-loader.ts +19 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Hooks — engancharse al ciclo de vida sin bifurcar el SDK.
3
+ *
4
+ * `HooksConfigSchema` (`config/loader.ts`) declaraba 14 hooks y **ninguno se
5
+ * invocaba**: era un esquema sin implementación. Quien lo encontrara asumiría
6
+ * que funciona.
7
+ *
8
+ * Acá se implementan, con dos formas y una decisión de diseño detrás:
9
+ *
10
+ * - **Callbacks en proceso** (`registerHook`) — tipados, sin costo de arranque,
11
+ * y pueden **devolver una decisión**: `beforeToolCall` puede impedir que una
12
+ * tool se ejecute. Es el primitivo, porque quien consume el SDK ya está en el
13
+ * mismo proceso.
14
+ * - **Scripts externos** (`hooks.scripts` en la configuración) — para quien no
15
+ * escribe TypeScript. Se montan encima del primitivo: cada script declarado
16
+ * se registra como un callback que lo ejecuta. Cuestan un `Bun.spawn` por
17
+ * invocación, así que conviene reservarlos para lo que no ocurre en cada
18
+ * tool call.
19
+ *
20
+ * Sólo están los hooks que tienen un uso claro. Los otros nueve del esquema
21
+ * original se dejaron fuera a propósito: cada hook es una promesa que después
22
+ * hay que sostener, y uno que nadie usa es superficie que envejece mal.
23
+ */
24
+
25
+ import { logger } from "../utils/logger.ts";
26
+ import { loadConfig } from "../config/loader.ts";
27
+
28
+ const log = logger.child("hooks");
29
+
30
+ /** Lo que recibe un hook de tool. */
31
+ export interface ToolCallContext {
32
+ toolName: string;
33
+ args: Record<string, unknown>;
34
+ agentId?: string;
35
+ userId?: string;
36
+ threadId?: string;
37
+ }
38
+
39
+ export interface ToolResultContext extends ToolCallContext {
40
+ result: unknown;
41
+ ok: boolean;
42
+ durationMs: number;
43
+ }
44
+
45
+ export interface CompactionContext {
46
+ threadId: string;
47
+ messageCount: number;
48
+ totalTokens: number;
49
+ }
50
+
51
+ export interface SessionContext {
52
+ threadId: string;
53
+ userId?: string;
54
+ channel?: string;
55
+ }
56
+
57
+ /**
58
+ * Lo que puede devolver `beforeToolCall`.
59
+ *
60
+ * `void` o `undefined` = seguir adelante. Devolver `{ block }` impide la
61
+ * ejecución y el motivo le llega al modelo como resultado de la tool, para que
62
+ * sepa por qué no se hizo en vez de reintentar a ciegas.
63
+ */
64
+ export type BeforeToolCallResult = void | undefined | { block: true; reason: string };
65
+
66
+ export interface HookMap {
67
+ beforeToolCall: (ctx: ToolCallContext) => BeforeToolCallResult | Promise<BeforeToolCallResult>;
68
+ afterToolCall: (ctx: ToolResultContext) => void | Promise<void>;
69
+ beforeCompaction: (ctx: CompactionContext) => void | Promise<void>;
70
+ sessionStart: (ctx: SessionContext) => void | Promise<void>;
71
+ sessionEnd: (ctx: SessionContext) => void | Promise<void>;
72
+ }
73
+
74
+ export type HookName = keyof HookMap;
75
+
76
+ const registry: { [K in HookName]: Array<HookMap[K]> } = {
77
+ beforeToolCall: [],
78
+ afterToolCall: [],
79
+ beforeCompaction: [],
80
+ sessionStart: [],
81
+ sessionEnd: [],
82
+ };
83
+
84
+ /**
85
+ * Registra un hook. Devuelve la función para quitarlo.
86
+ *
87
+ * Se pueden registrar varios del mismo tipo: corren en orden de registro.
88
+ */
89
+ export function registerHook<K extends HookName>(name: K, fn: HookMap[K]): () => void {
90
+ registry[name].push(fn);
91
+ return () => {
92
+ const i = registry[name].indexOf(fn);
93
+ if (i >= 0) registry[name].splice(i, 1);
94
+ };
95
+ }
96
+
97
+ /** Quita todos los hooks. Pensado para tests. */
98
+ export function clearHooks(name?: HookName): void {
99
+ if (name) registry[name] = [];
100
+ else for (const k of Object.keys(registry) as HookName[]) registry[k] = [];
101
+ }
102
+
103
+ export function hasHooks(name: HookName): boolean {
104
+ return registry[name].length > 0;
105
+ }
106
+
107
+ /**
108
+ * Corre los `beforeToolCall` y devuelve el motivo del bloqueo, si alguno objeta.
109
+ *
110
+ * El primero que bloquea gana: no tiene sentido seguir preguntando cuando ya
111
+ * hay una negativa. Un hook que lanza **no** bloquea la ejecución —un error en
112
+ * el observador no debería impedir el trabajo— pero se registra.
113
+ */
114
+ export async function runBeforeToolCall(ctx: ToolCallContext): Promise<string | null> {
115
+ for (const fn of registry.beforeToolCall) {
116
+ try {
117
+ const r = await fn(ctx);
118
+ if (r && typeof r === "object" && r.block) return r.reason;
119
+ } catch (err) {
120
+ log.warn(`beforeToolCall falló para ${ctx.toolName}: ${(err as Error).message}`);
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /** Corre los hooks de observación. Nunca lanza: son observadores. */
127
+ async function runObservers<K extends "afterToolCall" | "beforeCompaction" | "sessionStart" | "sessionEnd">(
128
+ name: K,
129
+ ctx: Parameters<HookMap[K]>[0],
130
+ ): Promise<void> {
131
+ for (const fn of registry[name]) {
132
+ try {
133
+ await (fn as (c: unknown) => unknown)(ctx);
134
+ } catch (err) {
135
+ log.warn(`${name} falló: ${(err as Error).message}`);
136
+ }
137
+ }
138
+ }
139
+
140
+ export const runAfterToolCall = (ctx: ToolResultContext) => runObservers("afterToolCall", ctx);
141
+ export const runBeforeCompaction = (ctx: CompactionContext) => runObservers("beforeCompaction", ctx);
142
+ export const runSessionStart = (ctx: SessionContext) => runObservers("sessionStart", ctx);
143
+ export const runSessionEnd = (ctx: SessionContext) => runObservers("sessionEnd", ctx);
144
+
145
+ // ─── Scripts declarados en la configuración ──────────────────────────────────
146
+
147
+ /** Nombres del esquema de config → hooks implementados. */
148
+ const SCRIPT_MAP: Record<string, HookName> = {
149
+ before_tool_call: "beforeToolCall",
150
+ after_tool_call: "afterToolCall",
151
+ before_compaction: "beforeCompaction",
152
+ session_start: "sessionStart",
153
+ session_end: "sessionEnd",
154
+ };
155
+
156
+ /**
157
+ * Ejecuta un script pasándole el contexto como JSON por stdin.
158
+ *
159
+ * Convención para `before_tool_call`: **salir con código distinto de 0 bloquea**
160
+ * la ejecución, y lo que el script escriba en stdout es el motivo que se le
161
+ * cuenta al modelo. Es el equivalente en procesos a devolver `{ block }`.
162
+ */
163
+ async function runScript(path: string, ctx: unknown): Promise<{ blocked: boolean; reason: string }> {
164
+ const proc = Bun.spawn(["bun", path], { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
165
+ proc.stdin.write(JSON.stringify(ctx));
166
+ proc.stdin.end();
167
+
168
+ const [salida, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
169
+ return { blocked: code !== 0, reason: salida.trim() || `el hook ${path} salió con código ${code}` };
170
+ }
171
+
172
+ let scriptsCargados = false;
173
+
174
+ /**
175
+ * Registra los scripts declarados en `hooks.scripts`.
176
+ *
177
+ * Es opt-in y explícito: ejecutar procesos externos no es algo que deba pasar
178
+ * por el solo hecho de importar el SDK. Idempotente.
179
+ */
180
+ export function loadConfiguredHookScripts(): number {
181
+ if (scriptsCargados) return 0;
182
+ const scripts = loadConfig().hooks?.scripts;
183
+ if (!scripts) return 0;
184
+
185
+ let n = 0;
186
+ for (const [clave, hook] of Object.entries(SCRIPT_MAP)) {
187
+ const path = (scripts as Record<string, string | undefined>)[clave];
188
+ if (!path) continue;
189
+
190
+ if (hook === "beforeToolCall") {
191
+ registerHook("beforeToolCall", async (ctx) => {
192
+ const r = await runScript(path, ctx);
193
+ return r.blocked ? { block: true as const, reason: r.reason } : undefined;
194
+ });
195
+ } else {
196
+ registerHook(hook as "afterToolCall", async (ctx) => { await runScript(path, ctx); });
197
+ }
198
+ n++;
199
+ log.info(`hook ${clave} → ${path}`);
200
+ }
201
+ scriptsCargados = true;
202
+ return n;
203
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Imágenes — redimensionar, convertir y medir, sin dependencias nativas.
3
+ *
4
+ * Usa `Bun.Image` (Bun 1.4), que es sharp integrado en el runtime: no hay que
5
+ * instalar nada ni compilar bindings. El SDK ya exige Bun ≥ 1.4, así que esto
6
+ * no agrega ningún requisito.
7
+ *
8
+ * Sirve para dos cosas distintas y conviene no confundirlas:
9
+ *
10
+ * 1. **Tools activables** para el agente (`tools/images/`) — recortar, convertir
11
+ * de formato, leer dimensiones. Es una capacidad de producto.
12
+ * 2. **Normalizar lo que entra** — una foto de 4 MB que un usuario manda por
13
+ * WhatsApp no tiene por qué viajar entera al modelo. Redimensionarla antes
14
+ * cuesta una fracción de los tokens y no cambia lo que el modelo puede ver.
15
+ *
16
+ * Nota sobre `Bun.Image`: las transformaciones son **diferidas**. `metadata()`
17
+ * sobre una cadena sin materializar devuelve las dimensiones del origen, no las
18
+ * del resultado — hay que pedir los bytes y releerlos. `measureImage()` lo hace
19
+ * por vos cuando hace falta.
20
+ */
21
+
22
+ import { logger } from "../utils/logger.ts";
23
+
24
+ const log = logger.child("images");
25
+
26
+ /** Los que `Bun.Image` sabe escribir. */
27
+ export type ImageFormat = "jpeg" | "png" | "webp" | "avif" | "heic";
28
+
29
+ export interface ImageMetadata {
30
+ width: number;
31
+ height: number;
32
+ format: string;
33
+ }
34
+
35
+ export interface TransformOptions {
36
+ /** Ancho máximo; la altura se ajusta manteniendo la proporción si no se da. */
37
+ width?: number;
38
+ height?: number;
39
+ format?: ImageFormat;
40
+ /** 1–100. Sólo lo respetan los formatos con pérdida. */
41
+ quality?: number;
42
+ /** Grados: 90, 180, 270. */
43
+ rotate?: number;
44
+ flip?: boolean;
45
+ flop?: boolean;
46
+ }
47
+
48
+ function bunImage(): any {
49
+ const I = (Bun as any).Image;
50
+ if (!I) {
51
+ throw new Error("Bun.Image no está disponible: se necesita Bun >= 1.4");
52
+ }
53
+ return I;
54
+ }
55
+
56
+ function toBytes(input: Uint8Array | ArrayBuffer | Buffer | string): Uint8Array {
57
+ if (typeof input === "string") return Uint8Array.from(Buffer.from(input, "base64"));
58
+ if (input instanceof Uint8Array) return input;
59
+ return new Uint8Array(input as ArrayBuffer);
60
+ }
61
+
62
+ /** Dimensiones y formato sin decodificar la imagen entera. */
63
+ export async function measureImage(input: Uint8Array | ArrayBuffer | Buffer | string): Promise<ImageMetadata> {
64
+ const meta = await new (bunImage())(toBytes(input)).metadata();
65
+ return { width: meta.width, height: meta.height, format: meta.format };
66
+ }
67
+
68
+ /**
69
+ * Aplica las transformaciones y devuelve los bytes resultantes.
70
+ *
71
+ * Sin `format` conserva el de origen. Sin nada que hacer devuelve la entrada tal
72
+ * cual, para no recomprimir de gusto.
73
+ */
74
+ export async function transformImage(
75
+ input: Uint8Array | ArrayBuffer | Buffer | string,
76
+ opts: TransformOptions,
77
+ ): Promise<{ bytes: Uint8Array; metadata: ImageMetadata }> {
78
+ const bytes = toBytes(input);
79
+ const hayQueHacer = opts.width || opts.height || opts.format || opts.rotate || opts.flip || opts.flop;
80
+ if (!hayQueHacer) return { bytes, metadata: await measureImage(bytes) };
81
+
82
+ let img = new (bunImage())(bytes);
83
+
84
+ if (opts.width || opts.height) img = img.resize(opts.width, opts.height);
85
+ if (opts.rotate) img = img.rotate(opts.rotate);
86
+ if (opts.flip) img = img.flip();
87
+ if (opts.flop) img = img.flop();
88
+
89
+ const formato = opts.format ?? ((await measureImage(bytes)).format as ImageFormat);
90
+ const args = opts.quality !== undefined ? [{ quality: opts.quality }] : [];
91
+ img = typeof img[formato] === "function" ? img[formato](...args) : img;
92
+
93
+ const salida: Uint8Array = await img.bytes();
94
+ // Se releen los bytes porque `metadata()` sobre la cadena diferida informa el
95
+ // origen, no el resultado.
96
+ return { bytes: salida, metadata: await measureImage(salida) };
97
+ }
98
+
99
+ export interface NormalizeOptions {
100
+ /** Lado mayor permitido. Por encima se reduce manteniendo la proporción. */
101
+ maxDimension?: number;
102
+ /** Formato de salida; `webp` pesa mucho menos que un JPEG equivalente. */
103
+ format?: ImageFormat;
104
+ quality?: number;
105
+ }
106
+
107
+ /** Por defecto: 1024 px de lado mayor y webp al 80 — el punto donde un modelo de visión deja de ganar detalle. */
108
+ const NORMALIZE_DEFAULTS: Required<NormalizeOptions> = {
109
+ maxDimension: 1024,
110
+ format: "webp",
111
+ quality: 80,
112
+ };
113
+
114
+ export interface NormalizeResult {
115
+ bytes: Uint8Array;
116
+ metadata: ImageMetadata;
117
+ /** Bytes originales, para saber cuánto se ahorró. */
118
+ originalBytes: number;
119
+ /** true si hubo que tocarla; false si ya era chica. */
120
+ changed: boolean;
121
+ }
122
+
123
+ /**
124
+ * Deja una imagen entrante en un tamaño razonable para mandársela a un modelo.
125
+ *
126
+ * Una foto de teléfono son varios megabytes y unos cuantos miles de tokens; a
127
+ * 1024 px el modelo ve lo mismo por una fracción del costo. Si ya está por
128
+ * debajo del límite no se toca — recomprimir una imagen chica sólo la empeora.
129
+ */
130
+ export async function normalizeForModel(
131
+ input: Uint8Array | ArrayBuffer | Buffer | string,
132
+ opts: NormalizeOptions = {},
133
+ ): Promise<NormalizeResult> {
134
+ const cfg = { ...NORMALIZE_DEFAULTS, ...opts };
135
+ const bytes = toBytes(input);
136
+ const meta = await measureImage(bytes);
137
+ const mayor = Math.max(meta.width, meta.height);
138
+
139
+ if (mayor <= cfg.maxDimension && meta.format === cfg.format) {
140
+ return { bytes, metadata: meta, originalBytes: bytes.length, changed: false };
141
+ }
142
+
143
+ const escala = mayor > cfg.maxDimension ? cfg.maxDimension / mayor : 1;
144
+ const { bytes: salida, metadata } = await transformImage(bytes, {
145
+ width: Math.round(meta.width * escala),
146
+ height: Math.round(meta.height * escala),
147
+ format: cfg.format,
148
+ quality: cfg.quality,
149
+ });
150
+
151
+ log.info(
152
+ `imagen normalizada: ${meta.width}x${meta.height} ${meta.format} (${bytes.length}b) → ` +
153
+ `${metadata.width}x${metadata.height} ${metadata.format} (${salida.length}b)`,
154
+ );
155
+ return { bytes: salida, metadata, originalBytes: bytes.length, changed: true };
156
+ }
157
+
158
+ /** true si este runtime puede procesar imágenes. */
159
+ export function imagesSupported(): boolean {
160
+ return typeof (Bun as any).Image === "function";
161
+ }
@@ -22,6 +22,7 @@ export type { Skill, SkillStep, OutputFormat, SkillsConfig } from "./skills/inde
22
22
  export { runAgent, runAgentIsolated, AgentLoop, getAgentLoop, buildAgentLoop, rebuildAgentLoop } from "./agent/agent-loop.ts";
23
23
  export type { AgentLoopOptions, StepEvent, StreamChunk } from "./agent/agent-loop.ts";
24
24
  export type { Provider } from "./agent/providers/index.ts";
25
+ export { AgentRunner, createAgentRunner } from "./agent/providers/index.ts";
25
26
 
26
27
  // El cliente LLM es parte de la superficie pública: hasta 0.1.5 sólo se exportaba
27
28
  // el wrapper `AgentRunner`, así que no había forma de llamar a un provider ni de
@@ -35,6 +35,19 @@ class MultimodalService {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * Convierte una imagen entrante en las partes que ve el modelo.
40
+ *
41
+ * Antes de mandarla la **normaliza**: una foto de teléfono son varios
42
+ * megabytes y unos cuantos miles de tokens, y a 1024 px el modelo ve lo mismo
43
+ * por una fracción del costo. Esa imagen no viaja una sola vez — queda en el
44
+ * historial y se reenvía en cada turno siguiente, así que el ahorro se
45
+ * multiplica por la longitud de la conversación.
46
+ *
47
+ * Es best-effort: si el runtime no puede procesarla (Bun < 1.4) o la imagen
48
+ * está corrupta, se manda tal cual. Perder la imagen sería peor que mandarla
49
+ * grande.
50
+ */
38
51
  async processImage(image: ImageInput, visionModelId?: string): Promise<ContentPart[]> {
39
52
  const parts: ContentPart[] = []
40
53
 
@@ -43,25 +56,44 @@ class MultimodalService {
43
56
  }
44
57
 
45
58
  if (image.type === "url") {
59
+ // Una URL no ocupa contexto: la descarga la hace el proveedor.
46
60
  parts.push({ type: "image_url", image_url: { url: image.data as string } })
47
- } else if (image.type === "base64") {
48
- parts.push({
49
- type: "image_base64",
50
- base64: image.data as string,
51
- mimeType: image.mimeType || "image/jpeg",
52
- })
53
- } else if (image.type === "buffer") {
54
- const base64 = Buffer.from(image.data as Buffer).toString("base64")
55
- parts.push({
56
- type: "image_base64",
57
- base64,
58
- mimeType: image.mimeType || "image/jpeg",
59
- })
61
+ return parts
60
62
  }
61
63
 
64
+ const crudo = image.type === "base64"
65
+ ? (image.data as string)
66
+ : Buffer.from(image.data as Buffer).toString("base64")
67
+
68
+ const { base64, mimeType } = await this.normalizeIncoming(crudo, image.mimeType)
69
+ parts.push({ type: "image_base64", base64, mimeType })
70
+
62
71
  return parts
63
72
  }
64
73
 
74
+ /** Achica la imagen para el modelo; ante cualquier problema devuelve la original. */
75
+ private async normalizeIncoming(
76
+ base64: string,
77
+ mimeType?: string,
78
+ ): Promise<{ base64: string; mimeType: string }> {
79
+ const original = { base64, mimeType: mimeType || "image/jpeg" }
80
+ try {
81
+ const { imagesSupported, normalizeForModel } = await import("../images/index.ts")
82
+ if (!imagesSupported()) return original
83
+
84
+ const r = await normalizeForModel(base64)
85
+ if (!r.changed) return original
86
+
87
+ return {
88
+ base64: Buffer.from(r.bytes).toString("base64"),
89
+ mimeType: `image/${r.metadata.format}`,
90
+ }
91
+ } catch (err) {
92
+ log.warn(`no pude normalizar la imagen entrante, va sin achicar: ${(err as Error).message}`)
93
+ return original
94
+ }
95
+ }
96
+
65
97
  async ocrImage(image: ImageInput, providerId?: string): Promise<string> {
66
98
  const resolved = providerId || "openai"
67
99
 
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Resilience — reintentos y circuit breakers.
3
+ *
4
+ * `withRetry` aplica backoff exponencial con jitter y respeta `Retry-After`
5
+ * cuando el proveedor lo manda; `isRetryableError` decide qué merece otro
6
+ * intento (429/5xx/timeout/red) y qué no (un 400 no mejora reintentando).
7
+ *
8
+ * El `CircuitBreaker` corta las llamadas a un servicio que ya viene fallando,
9
+ * en vez de seguir gastando intentos contra algo caído.
10
+ */
11
+
12
+ export * from "./retry.ts";
13
+ export * from "./circuit-breaker.ts";
@@ -1,11 +1,22 @@
1
1
  /**
2
2
  * Hive CronScheduler
3
3
  *
4
- * Croner-based scheduler for Hive with HiveDB persistence.
5
- * Manages recurring and one-shot cron jobs that execute through the agent pipeline.
4
+ * Manages recurring and one-shot cron jobs that execute through the agent pipeline,
5
+ * persisted in HiveDB.
6
+ *
7
+ * El motor de cron es propio (`./cron`), sin dependencias: sólo `setTimeout` e
8
+ * `Intl` del runtime. Antes era `croner`.
9
+ *
10
+ * `Bun.cron()` no sirve como reemplazo —se evaluó contra el runtime 1.4.0—:
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
13
+ * handle no expone la próxima corrida, que es de donde sale `next_run_at` y con
14
+ * lo que se detectan las corridas perdidas al arrancar. Tampoco tiene
15
+ * equivalente de `protect`, `maxRuns`, `interval`, `startAt`/`stopAt` ni
16
+ * `domAndDow`, todos campos persistidos de `CronJobDoc`.
6
17
  */
7
18
 
8
- import { Cron } from "croner";
19
+ import { Cron } from "./cron/index.ts";
9
20
  import { logger } from "../utils/logger.ts";
10
21
  import { notifyTaskCompletion } from "./integration.ts";
11
22
  import { col, toIndexable, fromIndexable } from "../storage/hive.ts";
@@ -76,7 +87,7 @@ export class CronScheduler {
76
87
 
77
88
  if (misfirePolicy === "fire_once" && withinGrace) {
78
89
  log.info(`[boot:misfire] Job "${task.name}" (${task.id}) misfired at ${misfireTime.toISOString()} — executing now (fire_once, within grace)`);
79
- // Activate the job first (so the Croner handle exists for future runs)
90
+ // Activate the job first (so it stays scheduled for future runs)
80
91
  await this.activate(task);
81
92
  // Then execute it immediately
82
93
  this.execute(task.id).catch((err) => {
@@ -100,7 +111,7 @@ export class CronScheduler {
100
111
  });
101
112
  await this.activate(task);
102
113
  } else {
103
- // skip policy — recurring re-schedules its next occurrence via Croner
114
+ // skip policy — recurring re-schedules its next occurrence on its own
104
115
  log.info(`[boot:misfire] Job "${task.name}" (${task.id}) misfired at ${misfireTime.toISOString()} — skipping (misfire_policy=skip)`);
105
116
  await this.updateJob(task.id, {
106
117
  last_error: `Missed run at ${misfireTime.toISOString()} (policy: skip)`,
@@ -120,7 +131,7 @@ export class CronScheduler {
120
131
  }
121
132
 
122
133
  /**
123
- * Activate a cron job - create or recreate its Croner instance
134
+ * Activate a cron job - create or recreate its scheduled instance
124
135
  */
125
136
  async activate(task: CronJob): Promise<void> {
126
137
  const existingJob = this.jobs.get(task.id);
@@ -218,13 +229,29 @@ export class CronScheduler {
218
229
  }
219
230
  }
220
231
 
221
- /** Read-modify-write helper for cron_jobs partial updates, retrying on OCC conflict. */
222
- private async updateJob(taskId: string, patch: Partial<CronJobDoc>): Promise<CronJobDoc | null> {
232
+ /**
233
+ * Read-modify-write helper for cron_jobs partial updates, retrying on OCC conflict.
234
+ *
235
+ * El parche puede ser una función, y para los contadores **tiene que serlo**:
236
+ * un objeto fijo se calcula una sola vez, fuera del bucle, así que al
237
+ * reintentar por conflicto de versión se vuelve a escribir el valor viejo y
238
+ * el incremento se pierde. Con dos ejecuciones solapadas del mismo job —algo
239
+ * normal en uno que tarda más que su intervalo y no declara `protect`— ambas
240
+ * leen `error_count: 4` y ambas escriben 5: el job nunca llega al umbral de
241
+ * auto-pausa y sigue fallando para siempre.
242
+ */
243
+ private async updateJob(
244
+ taskId: string,
245
+ patch: Partial<CronJobDoc> | ((actual: CronJobDoc) => Partial<CronJobDoc>),
246
+ ): Promise<CronJobDoc | null> {
223
247
  const cronJobsCol = await col<CronJobDoc>("cronJobs");
224
248
  for (let attempt = 0; attempt < 5; attempt++) {
225
249
  const existing = await cronJobsCol.get(taskId);
226
250
  if (!existing) return null;
227
- const merged = { ...existing.doc, ...patch };
251
+ const merged = {
252
+ ...existing.doc,
253
+ ...(typeof patch === "function" ? patch(existing.doc) : patch),
254
+ };
228
255
  try {
229
256
  await cronJobsCol.put(taskId, merged, { expectedVersion: existing.version });
230
257
  return merged;
@@ -284,11 +311,11 @@ export class CronScheduler {
284
311
  agent_response: result.response?.slice(0, 1000) || null,
285
312
  });
286
313
 
287
- const refreshed = await this.updateJob(task.id, {
288
- run_count: task.run_count + 1,
314
+ const refreshed = await this.updateJob(task.id, (actual) => ({
315
+ run_count: actual.run_count + 1,
289
316
  last_run_at: finishedAt,
290
317
  last_error: null,
291
- });
318
+ }));
292
319
 
293
320
  const job = this.jobs.get(task.id);
294
321
  if (job) {
@@ -322,10 +349,10 @@ export class CronScheduler {
322
349
  error_message: errorMessage,
323
350
  });
324
351
 
325
- const updated = await this.updateJob(task.id, {
326
- error_count: task.error_count + 1,
352
+ const updated = await this.updateJob(task.id, (actual) => ({
353
+ error_count: actual.error_count + 1,
327
354
  last_error: errorMessage,
328
- });
355
+ }));
329
356
 
330
357
  log.error(`[execute] Job "${task.name}" (${task.id}) failed: ${errorMessage}`);
331
358
 
@@ -358,12 +385,12 @@ export class CronScheduler {
358
385
  }
359
386
 
360
387
  /**
361
- * Handle errors from Croner
388
+ * Handle errors raised by the scheduled job
362
389
  */
363
390
  private handleError(task: CronJob, error: Error): void {
364
391
  log.error(`[error] Job "${task.name}" (${task.id}) error: ${error.message}`);
365
392
 
366
- // Fix 3: record Croner-level errors in task_runs for full history
393
+ // Fix 3: record scheduler-level errors in task_runs for full history
367
394
  Promise.resolve().then(async () => {
368
395
  const runId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
369
396
  const now = new Date().toISOString();
@@ -384,10 +411,10 @@ export class CronScheduler {
384
411
  log.warn(`[handleError] Failed to insert task_run: ${(e as Error).message}`);
385
412
  }
386
413
 
387
- await this.updateJob(task.id, {
388
- error_count: task.error_count + 1,
414
+ await this.updateJob(task.id, (actual) => ({
415
+ error_count: actual.error_count + 1,
389
416
  last_error: error.message,
390
- });
417
+ }));
391
418
  });
392
419
  }
393
420
 
@@ -431,7 +458,7 @@ export class CronScheduler {
431
458
  }
432
459
 
433
460
  /**
434
- * Deactivate a cron job - stop Croner instance but keep in DB
461
+ * Deactivate a cron job - stop the scheduled instance but keep it in DB
435
462
  */
436
463
  deactivate(taskId: string): void {
437
464
  const job = this.jobs.get(taskId);