@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.
- package/CHANGELOG.md +306 -0
- package/README.md +11 -3
- package/package.json +10 -4
- package/packages/core/src/agent/agent-catalog.ts +81 -24
- package/packages/core/src/agent/agent-loop.ts +2 -2
- package/packages/core/src/agent/compaction.ts +20 -1
- package/packages/core/src/agent/context-compiler.ts +7 -4
- package/packages/core/src/agent/conversation-store.ts +136 -2
- package/packages/core/src/agent/curator.ts +12 -3
- package/packages/core/src/agent/llm-providers/nvidia.ts +39 -0
- package/packages/core/src/agent/llm-providers/openai-compat-base.ts +38 -2
- package/packages/core/src/agent/playbook-selector.ts +18 -3
- package/packages/core/src/agent/prompt-builder.ts +2 -2
- package/packages/core/src/agent/providers/index.ts +37 -2
- package/packages/core/src/agent/reflector.ts +32 -9
- package/packages/core/src/agent/skill-selector.ts +2 -2
- package/packages/core/src/agent/thread-store.ts +43 -0
- package/packages/core/src/agent/tool-selector.ts +2 -0
- package/packages/core/src/api/createAgent.ts +68 -2
- package/packages/core/src/artifacts/index.ts +15 -0
- package/packages/core/src/artifacts/store.ts +77 -2
- package/packages/core/src/canvas/index.ts +9 -0
- package/packages/core/src/ethics/EthicsGuard.ts +7 -1
- package/packages/core/src/events/index.ts +18 -0
- package/packages/core/src/events/tool-narration.ts +4 -0
- package/packages/core/src/gateway/channel-notify.ts +103 -6
- package/packages/core/src/gateway/durable-queue.ts +13 -1
- package/packages/core/src/gateway/index.ts +3 -0
- package/packages/core/src/gateway/job-store.ts +6 -0
- package/packages/core/src/harness/executors.ts +493 -0
- package/packages/core/src/harness/index.ts +12 -2
- package/packages/core/src/hooks/index.ts +203 -0
- package/packages/core/src/images/index.ts +161 -0
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/multimodal/vision-service.ts +45 -13
- package/packages/core/src/resilience/index.ts +13 -0
- package/packages/core/src/scheduler/CronScheduler.ts +48 -21
- package/packages/core/src/scheduler/cron/expression.ts +165 -0
- package/packages/core/src/scheduler/cron/index.ts +10 -0
- package/packages/core/src/scheduler/cron/job.ts +339 -0
- package/packages/core/src/scheduler/cron/next-run.ts +121 -0
- package/packages/core/src/scheduler/cron/zoned-time.ts +138 -0
- package/packages/core/src/scheduler/index.ts +21 -3
- package/packages/core/src/scheduler/integration.ts +16 -5
- package/packages/core/src/scheduler/types.ts +3 -18
- package/packages/core/src/services/agents.ts +268 -0
- package/packages/core/src/services/cron.ts +257 -0
- package/packages/core/src/services/endpoints.ts +289 -0
- package/packages/core/src/services/ethics.ts +107 -0
- package/packages/core/src/services/images.ts +212 -0
- package/packages/core/src/services/index.ts +112 -0
- package/packages/core/src/services/mcp.ts +201 -0
- package/packages/core/src/services/memory.ts +133 -0
- package/packages/core/src/services/models.ts +179 -0
- package/packages/core/src/services/providers.ts +152 -0
- package/packages/core/src/services/setup.ts +222 -0
- package/packages/core/src/services/skills.ts +241 -0
- package/packages/core/src/services/swarms.ts +307 -0
- package/packages/core/src/services/tools.ts +106 -0
- package/packages/core/src/sessions/index.ts +5 -3
- package/packages/core/src/sessions/resolve.ts +108 -0
- package/packages/core/src/skills/SkillLoader.ts +8 -1
- package/packages/core/src/skills/bundled/artifacts/artifact_reader/SKILL.md +105 -0
- package/packages/core/src/skills/bundled/cron_manager/SKILL.md +21 -11
- package/packages/core/src/skills/bundled/images/image_editor/SKILL.md +120 -0
- package/packages/core/src/skills/bundled/web/browser_automate/SKILL.md +12 -3
- package/packages/core/src/skills/bundled/web/browser_scrape/SKILL.md +22 -7
- package/packages/core/src/skills/bundled-data.generated.ts +110 -12
- package/packages/core/src/storage/bootstrap.ts +74 -5
- package/packages/core/src/storage/collections.ts +106 -1
- package/packages/core/src/storage/crypto.ts +24 -7
- package/packages/core/src/storage/hive.ts +9 -3
- package/packages/core/src/storage/index.ts +2 -1
- package/packages/core/src/storage/onboarding.ts +59 -43
- package/packages/core/src/storage/reconcile.ts +6 -1
- package/packages/core/src/storage/seed.ts +98 -14
- package/packages/core/src/swarm/types.ts +3 -18
- package/packages/core/src/tool-runtime/embedded-worker.generated.ts +21 -0
- package/packages/core/src/tool-runtime/index.ts +129 -14
- package/packages/core/src/tools/agents/index.ts +18 -60
- package/packages/core/src/tools/cli/index.ts +55 -0
- package/packages/core/src/tools/core/index.ts +50 -2
- package/packages/core/src/tools/cron/index.ts +4 -4
- package/packages/core/src/tools/images/index.ts +130 -0
- package/packages/core/src/tools/index.ts +14 -1
- package/packages/core/src/tools/office/office-escribir-xlsx.ts +2 -1
- package/packages/core/src/tools/office/office-leer-xlsx.ts +2 -1
- 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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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 "
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
222
|
-
|
|
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 = {
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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);
|