@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
|
@@ -14,6 +14,7 @@ import { col, updateDoc } from "../storage/hive.ts"
|
|
|
14
14
|
import { logger } from "../utils/logger.ts"
|
|
15
15
|
import type { ConversationThreadDoc, ConversationDoc, SummaryDoc } from "../storage/collections.ts"
|
|
16
16
|
import { makeThreadId, parseThreadId, newWebConversationId } from "./thread-id.ts"
|
|
17
|
+
import { runSessionStart, runSessionEnd } from "../hooks/index.ts"
|
|
17
18
|
|
|
18
19
|
const log = logger.child("thread-store")
|
|
19
20
|
|
|
@@ -65,7 +66,17 @@ export async function ensureThread(input: EnsureThreadInput): Promise<string> {
|
|
|
65
66
|
}, { expectedVersion: 0 })
|
|
66
67
|
} catch {
|
|
67
68
|
// Otro turno del mismo canal la creó primero — ambos quieren lo mismo.
|
|
69
|
+
return threadId
|
|
68
70
|
}
|
|
71
|
+
|
|
72
|
+
// `sessionStart` se dispara acá y no en `createSession` porque esa función es
|
|
73
|
+
// idempotente y se llama en cada turno: engancharla ahí haría que el hook
|
|
74
|
+
// corriera con cada mensaje. El `put` con `expectedVersion: 0` es el punto de
|
|
75
|
+
// serialización, así que dispararlo justo después de que ese put haya salido
|
|
76
|
+
// bien evita el doble disparo cuando dos turnos del mismo canal llegan juntos
|
|
77
|
+
// — el perdedor se fue por el catch de arriba. Cubre además a quien llame
|
|
78
|
+
// `ensureThread` directamente desde un canal, sin pasar por `sessions`.
|
|
79
|
+
await runSessionStart({ threadId, userId: input.userId, channel: input.channel })
|
|
69
80
|
return threadId
|
|
70
81
|
}
|
|
71
82
|
|
|
@@ -176,6 +187,29 @@ export async function renameThread(threadId: string, title: string): Promise<voi
|
|
|
176
187
|
await updateDoc<ConversationThreadDoc>("conversationThreads", threadId, { title: clean || null })
|
|
177
188
|
}
|
|
178
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Archiva el hilo: sale de la lista pero no se pierde nada.
|
|
192
|
+
*
|
|
193
|
+
* Vive acá y no en `sessions/index.ts` para que las cuatro transiciones del
|
|
194
|
+
* ciclo de vida —crear, archivar, reabrir, borrar— disparen sus hooks desde un
|
|
195
|
+
* solo archivo. Repartidas, la próxima que se agregue se olvida de disparar.
|
|
196
|
+
*/
|
|
197
|
+
export async function archiveThread(threadId: string): Promise<void> {
|
|
198
|
+
const anterior = await getThread(threadId)
|
|
199
|
+
await updateDoc<ConversationThreadDoc>("conversationThreads", threadId, { archived: true })
|
|
200
|
+
// Archivar dos veces es un no-op, y el hook no debería enterarse dos veces.
|
|
201
|
+
if (anterior?.archived) return
|
|
202
|
+
await runSessionEnd({ threadId, userId: anterior?.user_id, channel: anterior?.channel })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Reabre un hilo archivado: vuelve a estar activo, y eso es un `sessionStart`. */
|
|
206
|
+
export async function unarchiveThread(threadId: string): Promise<void> {
|
|
207
|
+
const anterior = await getThread(threadId)
|
|
208
|
+
await updateDoc<ConversationThreadDoc>("conversationThreads", threadId, { archived: false })
|
|
209
|
+
if (anterior && !anterior.archived) return
|
|
210
|
+
await runSessionStart({ threadId, userId: anterior?.user_id, channel: anterior?.channel })
|
|
211
|
+
}
|
|
212
|
+
|
|
179
213
|
/**
|
|
180
214
|
* Borra la conversación entera: mensajes, resumen, notas y la fila del registro.
|
|
181
215
|
* Los mensajes se localizan por prefijo, igual que los lee conversation-store.
|
|
@@ -193,9 +227,18 @@ export async function deleteThread(threadId: string): Promise<void> {
|
|
|
193
227
|
for (const n of notes) await scratchpad.delete(n.id)
|
|
194
228
|
|
|
195
229
|
const c = await threadsCol()
|
|
230
|
+
// La fila se lee antes de borrarla: después no hay de dónde sacar el usuario
|
|
231
|
+
// ni el canal para el contexto del hook. El threadId los lleva codificados,
|
|
232
|
+
// pero el hilo legacy es id = userId pelado y no parsea.
|
|
233
|
+
const fila = (await c.get(threadId))?.doc
|
|
196
234
|
await c.delete(threadId).catch(() => {})
|
|
197
235
|
|
|
198
236
|
log.info(`conversación ${threadId} borrada (${messages.length} mensajes)`)
|
|
237
|
+
await runSessionEnd({
|
|
238
|
+
threadId,
|
|
239
|
+
userId: fila?.user_id ?? parseThreadId(threadId)?.userId,
|
|
240
|
+
channel: fila?.channel ?? parseThreadId(threadId)?.channel,
|
|
241
|
+
})
|
|
199
242
|
}
|
|
200
243
|
|
|
201
244
|
/**
|
|
@@ -203,6 +203,8 @@ export const CORE_TOOL_CATALOG: ToolDescriptor[] = [
|
|
|
203
203
|
{ name: "computer_use_task", description: "Operate the browser by looking at the screen: click by coordinates, type and navigate when no stable CSS selector exists (canvas, generated UIs, embedded viewers). Acts on Hive's own browser, never on the user screen. Spanish keywords: usar el navegador, hacer clic donde veas, operar una página, rellenar formulario, computer use, mirar la pantalla", category: "browser", abstractionLevel: "orchestration" },
|
|
204
204
|
{ name: "artifact_inspect", description: "Inspect a managed artifact's integrity and metadata without modifying it. Spanish keywords: inspeccionar artefacto, verificar archivo generado, metadatos artefacto, comprobar entrega", category: "web", abstractionLevel: "atomic" },
|
|
205
205
|
{ name: "artifact_read", description: "Read a managed artifact's text content in slices, or search inside it — the way to open any artifact_ref a tool returned. Spanish keywords: leer artefacto, ver contenido del artefacto, abrir resultado grande, buscar dentro del artefacto", category: "web", abstractionLevel: "atomic" },
|
|
206
|
+
{ name: "image_metadata", description: "Read a stored image's dimensions and format without loading it into context. Spanish keywords: medir imagen, dimensiones de la imagen, tamaño de la foto, formato de imagen", category: "images", abstractionLevel: "atomic" },
|
|
207
|
+
{ name: "image_transform", description: "Resize, rotate or convert a stored image, returning a new artifact — the original is untouched. Spanish keywords: redimensionar imagen, cambiar tamaño, convertir a webp, comprimir imagen, rotar foto, achicar imagen", category: "images", abstractionLevel: "atomic" },
|
|
206
208
|
|
|
207
209
|
// Office documents — read
|
|
208
210
|
{ name: "office_leer_pdf", description: "Read and extract text from a PDF document. Spanish keywords: leer pdf, extraer texto pdf, abrir pdf, contenido pdf", category: "office", abstractionLevel: "atomic" },
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { z } from "zod";
|
|
12
|
+
import type { MCPClientManager } from "../mcp/index.ts";
|
|
12
13
|
import type { ToolDefinition } from "../tools/ToolRegistry.ts";
|
|
13
14
|
import type { SkillDefinition } from "../skills/defineSkill.ts";
|
|
14
15
|
import type { Tool, ToolParameter } from "../tools/types.ts";
|
|
@@ -35,11 +36,28 @@ export interface Agent {
|
|
|
35
36
|
readonly name: string;
|
|
36
37
|
readonly id: string;
|
|
37
38
|
readonly config: AgentConfig;
|
|
38
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Con `stream: true` se emiten eventos `token` con los deltas del proveedor
|
|
41
|
+
* a medida que llegan, además del `text` con la respuesta completa del turno.
|
|
42
|
+
*/
|
|
43
|
+
chat(
|
|
44
|
+
message: string,
|
|
45
|
+
opts?: { threadId?: string; channel?: string; stream?: boolean },
|
|
46
|
+
): AsyncGenerator<AgentEvent>;
|
|
39
47
|
run(task: string, opts?: { threadId?: string; channel?: string }): Promise<string>;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
export type AgentEvent =
|
|
51
|
+
/**
|
|
52
|
+
* Un fragmento recién llegado del proveedor, para pintar la respuesta
|
|
53
|
+
* mientras se genera.
|
|
54
|
+
*
|
|
55
|
+
* Sólo aparece si se pide `stream: true`. Los proveedores ya emitían estos
|
|
56
|
+
* deltas —el mecanismo estaba implementado— pero ningún punto de entrada los
|
|
57
|
+
* pasaba, así que nunca llegaban a nadie: la respuesta aparecía de golpe al
|
|
58
|
+
* terminar el turno.
|
|
59
|
+
*/
|
|
60
|
+
| { type: "token"; content: string }
|
|
43
61
|
| { type: "text"; content: string }
|
|
44
62
|
| { type: "tool_call"; name: string; args: Record<string, unknown> }
|
|
45
63
|
| { type: "tool_result"; name: string; result: unknown }
|
|
@@ -128,6 +146,40 @@ export async function createAgent(config: AgentConfig): Promise<Agent> {
|
|
|
128
146
|
}
|
|
129
147
|
}
|
|
130
148
|
|
|
149
|
+
// Las skills declaradas seguían el mismo camino que las tools —y hasta acá no
|
|
150
|
+
// lo seguían: `config.skills` estaba tipado y se descartaba en silencio, así
|
|
151
|
+
// que declarar una skill no hacía absolutamente nada. Una skill no necesita
|
|
152
|
+
// ejecutor (es instruccional: metadatos más el cuerpo que se le inyecta al
|
|
153
|
+
// agente), pero sí necesita su fila y su entrada en el índice, o el modelo
|
|
154
|
+
// nunca la descubre.
|
|
155
|
+
if (config.skills?.length) {
|
|
156
|
+
const { createSkill, getSkill, updateSkill } = await import("../services/skills.ts");
|
|
157
|
+
|
|
158
|
+
for (const skill of config.skills) {
|
|
159
|
+
// El cuerpo se arma con los pasos declarados: es lo que lee el agente.
|
|
160
|
+
const body = [
|
|
161
|
+
skill.description,
|
|
162
|
+
"",
|
|
163
|
+
...skill.steps.map((s, i) => `${i + 1}. **${s.action}** — ${s.instruction}`),
|
|
164
|
+
].join("\n");
|
|
165
|
+
|
|
166
|
+
const campos = {
|
|
167
|
+
name: skill.name,
|
|
168
|
+
description: skill.description,
|
|
169
|
+
category: skill.category,
|
|
170
|
+
body,
|
|
171
|
+
tools: skill.tools,
|
|
172
|
+
triggers: skill.triggers,
|
|
173
|
+
version: skill.version,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Idempotente: declarar la misma skill dos veces la actualiza.
|
|
177
|
+
const existente = await getSkill(skill.name);
|
|
178
|
+
if (existente) await updateSkill(skill.name, campos);
|
|
179
|
+
else await createSkill({ id: skill.name, ...campos });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
131
183
|
// ─── Provider y modelo ────────────────────────────────────────────────────
|
|
132
184
|
// El loop los lee de la fila del agente, así que hay que dejarlos escritos.
|
|
133
185
|
const providerId = config.provider ?? "";
|
|
@@ -209,7 +261,7 @@ export async function createAgent(config: AgentConfig): Promise<Agent> {
|
|
|
209
261
|
await syncCapabilityIndexes();
|
|
210
262
|
|
|
211
263
|
// ─── MCP ──────────────────────────────────────────────────────────────────
|
|
212
|
-
let mcpManager = null;
|
|
264
|
+
let mcpManager: MCPClientManager | null = null;
|
|
213
265
|
if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
|
|
214
266
|
const { MCPClientManager } = await import("../mcp/index.ts");
|
|
215
267
|
const mcpConfig = {
|
|
@@ -241,6 +293,17 @@ export async function createAgent(config: AgentConfig): Promise<Agent> {
|
|
|
241
293
|
const threadId = opts?.threadId ?? crypto.randomUUID();
|
|
242
294
|
let response = "";
|
|
243
295
|
|
|
296
|
+
// `onToken` es un callback y esto es un generador: los deltas se
|
|
297
|
+
// encolan y se drenan entre chunks. Sin buffer habría que elegir entre
|
|
298
|
+
// perder tokens o bloquear al proveedor mientras el consumidor lee.
|
|
299
|
+
const pendientes: string[] = [];
|
|
300
|
+
const onToken = opts?.stream ? (t: string) => { pendientes.push(t); } : undefined;
|
|
301
|
+
const drenar = function* () {
|
|
302
|
+
while (pendientes.length > 0) {
|
|
303
|
+
yield { type: "token" as const, content: pendientes.shift()! };
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
244
307
|
for await (const chunk of runAgent({
|
|
245
308
|
agentId,
|
|
246
309
|
userMessage: message,
|
|
@@ -248,7 +311,9 @@ export async function createAgent(config: AgentConfig): Promise<Agent> {
|
|
|
248
311
|
channel: opts?.channel ?? "cli",
|
|
249
312
|
mcpManager,
|
|
250
313
|
userId,
|
|
314
|
+
onToken,
|
|
251
315
|
})) {
|
|
316
|
+
yield* drenar();
|
|
252
317
|
for (const msg of chunk.agent?.messages ?? []) {
|
|
253
318
|
if (typeof msg.content === "string" && msg.content) {
|
|
254
319
|
response = msg.content;
|
|
@@ -268,6 +333,7 @@ export async function createAgent(config: AgentConfig): Promise<Agent> {
|
|
|
268
333
|
}
|
|
269
334
|
}
|
|
270
335
|
|
|
336
|
+
yield* drenar();
|
|
271
337
|
yield { type: "done" as const, response };
|
|
272
338
|
},
|
|
273
339
|
async run(task, opts) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifacts — los archivos que produce un agente, fuera de la ventana de contexto.
|
|
3
|
+
*
|
|
4
|
+
* Cuando una tool devuelve algo grande —una imagen, un PDF, la salida enorme de
|
|
5
|
+
* un servidor MCP— serializarlo entero al prompt es lo peor que se puede hacer:
|
|
6
|
+
* se come el contexto y no aporta. En su lugar se guarda como artefacto y al
|
|
7
|
+
* modelo le llega una referencia (`artifact_ref`) que puede abrir con la tool
|
|
8
|
+
* `artifact_read` si de verdad necesita el contenido.
|
|
9
|
+
*
|
|
10
|
+
* `createArtifact` guarda; `inspectArtifact` da metadatos sin abrirlo;
|
|
11
|
+
* `readArtifactText` lo lee para consumo del propio proceso; `readArtifactBytes`
|
|
12
|
+
* devuelve los bytes crudos, que es lo que usa un canal para mandar la imagen.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export * from "./store.ts";
|
|
@@ -52,6 +52,11 @@ export async function createArtifact(input: {
|
|
|
52
52
|
width?: number | null;
|
|
53
53
|
height?: number | null;
|
|
54
54
|
now?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Cuándo caduca. `null` = nunca, para lo que es del usuario y no basura
|
|
57
|
+
* transitoria. Por omisión, los 7 días de siempre.
|
|
58
|
+
*/
|
|
59
|
+
expiresAt?: number | null;
|
|
55
60
|
}): Promise<ArtifactDoc> {
|
|
56
61
|
const now = input.now ?? Date.now();
|
|
57
62
|
const id = randomUUID();
|
|
@@ -77,7 +82,7 @@ export async function createArtifact(input: {
|
|
|
77
82
|
height: input.height ?? null,
|
|
78
83
|
status: "active",
|
|
79
84
|
created_at: now,
|
|
80
|
-
expires_at: now + ARTIFACT_RETENTION_MS,
|
|
85
|
+
expires_at: input.expiresAt !== undefined ? input.expiresAt : now + ARTIFACT_RETENTION_MS,
|
|
81
86
|
expired_at: null,
|
|
82
87
|
};
|
|
83
88
|
|
|
@@ -222,12 +227,82 @@ export async function inspectArtifact(
|
|
|
222
227
|
};
|
|
223
228
|
}
|
|
224
229
|
|
|
230
|
+
export interface ListArtifactsOptions {
|
|
231
|
+
/** Filtra por tipo: `image`, `document`… */
|
|
232
|
+
kind?: string;
|
|
233
|
+
includeExpired?: boolean;
|
|
234
|
+
limit?: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Los artefactos de un usuario, del más reciente al más viejo.
|
|
239
|
+
*
|
|
240
|
+
* No existía, y sin esto una interfaz no puede mostrarle a alguien lo que tiene
|
|
241
|
+
* guardado — ni una galería de imágenes ni la lista de adjuntos de una
|
|
242
|
+
* conversación.
|
|
243
|
+
*/
|
|
244
|
+
export async function listArtifacts(
|
|
245
|
+
userId: string,
|
|
246
|
+
opts: ListArtifactsOptions = {},
|
|
247
|
+
): Promise<ArtifactDoc[]> {
|
|
248
|
+
const artifacts = await col<ArtifactDoc>("artifacts");
|
|
249
|
+
const rows = await artifacts.findBy("user_id", userId);
|
|
250
|
+
return rows
|
|
251
|
+
.map((e) => e.doc)
|
|
252
|
+
.filter((d) => (opts.includeExpired ? true : d.status === "active"))
|
|
253
|
+
.filter((d) => (opts.kind ? d.kind === opts.kind : true))
|
|
254
|
+
.sort((a, b) => b.created_at - a.created_at)
|
|
255
|
+
.slice(0, opts.limit ?? Number.MAX_SAFE_INTEGER);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Cambia cuándo caduca un artefacto. `null` = conservarlo indefinidamente.
|
|
260
|
+
*
|
|
261
|
+
* Es lo que le da al usuario el control: puede marcar como permanente algo que
|
|
262
|
+
* nació temporal, o ponerle fecha a algo que ya no necesita.
|
|
263
|
+
*/
|
|
264
|
+
export async function setArtifactRetention(
|
|
265
|
+
artifactId: string,
|
|
266
|
+
expiresAt: number | null,
|
|
267
|
+
): Promise<ArtifactDoc | null> {
|
|
268
|
+
const artifacts = await col<ArtifactDoc>("artifacts");
|
|
269
|
+
const entry = await artifacts.get(artifactId);
|
|
270
|
+
if (!entry) return null;
|
|
271
|
+
|
|
272
|
+
const doc: ArtifactDoc = { ...entry.doc, expires_at: expiresAt };
|
|
273
|
+
await artifacts.put(artifactId, doc, { expectedVersion: entry.version });
|
|
274
|
+
return doc;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Borra el artefacto y su archivo, sin esperar a que caduque.
|
|
279
|
+
*
|
|
280
|
+
* A diferencia de `expireArtifacts`, que marca `status: "expired"` y conserva la
|
|
281
|
+
* fila como registro, esto la elimina: es un borrado pedido por el usuario, y
|
|
282
|
+
* dejar el rastro de algo que pidió borrar sería lo contrario de lo que pidió.
|
|
283
|
+
*/
|
|
284
|
+
export async function deleteArtifact(artifactId: string): Promise<boolean> {
|
|
285
|
+
const artifacts = await col<ArtifactDoc>("artifacts");
|
|
286
|
+
const entry = await artifacts.get(artifactId);
|
|
287
|
+
if (!entry) return false;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
if (existsSync(entry.doc.path)) unlinkSync(entry.doc.path);
|
|
291
|
+
} catch {
|
|
292
|
+
// El archivo puede haber desaparecido; la fila igual se va.
|
|
293
|
+
}
|
|
294
|
+
await artifacts.delete(artifactId);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
225
298
|
export async function expireArtifacts(now = Date.now()): Promise<{ expired: number }> {
|
|
226
299
|
const artifacts = await col<ArtifactDoc>("artifacts");
|
|
227
300
|
const rows = await artifacts.scan({});
|
|
228
301
|
let expired = 0;
|
|
229
302
|
for (const row of rows) {
|
|
230
|
-
|
|
303
|
+
// `null` es explícito: el usuario pidió conservarlo. Saltarlo ANTES de
|
|
304
|
+
// comparar fechas evita que un `null > now` (que es false) lo borre.
|
|
305
|
+
if (row.doc.status !== "active" || row.doc.expires_at === null || row.doc.expires_at > now) continue;
|
|
231
306
|
try {
|
|
232
307
|
if (existsSync(row.doc.path)) unlinkSync(row.doc.path);
|
|
233
308
|
} catch {
|
|
@@ -1 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas — el estado visual de un enjambre corriendo.
|
|
3
|
+
*
|
|
4
|
+
* `canvas-manager.ts` guarda y sirve el snapshot; `emitter.ts` es por donde el
|
|
5
|
+
* runtime publica los cambios (un nodo que empieza a pensar, una delegación que
|
|
6
|
+
* arranca o termina). Quien construya una UI sobre el SDK consume ambos.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
export * from "./canvas-manager.ts";
|
|
10
|
+
export * from "./emitter.ts";
|
|
@@ -37,8 +37,12 @@ export class EthicsGuard {
|
|
|
37
37
|
* Con `agentRole` filtra por las que lo declaran en `applicable_to`; si
|
|
38
38
|
* ninguna coincide devuelve todas, para no dejar al agente sin capa por un
|
|
39
39
|
* `applicable_to` mal cargado.
|
|
40
|
+
*
|
|
41
|
+
* `userId` acota lo aprendido a quien corresponde: entran las globales
|
|
42
|
+
* (`user_id === ""`, sembradas con el producto) y las que salieron de las
|
|
43
|
+
* trazas de ese mismo usuario. Omitirlo deja sólo las globales.
|
|
40
44
|
*/
|
|
41
|
-
async getRules(agentRole?: string): Promise<EthicsRule[]> {
|
|
45
|
+
async getRules(agentRole?: string, userId?: string): Promise<EthicsRule[]> {
|
|
42
46
|
const playbookCol = await col<PlaybookDoc>("playbook");
|
|
43
47
|
const all = (await playbookCol.scan({}))
|
|
44
48
|
.map((e) => ({
|
|
@@ -48,8 +52,10 @@ export class EthicsGuard {
|
|
|
48
52
|
applicable_to: e.doc.applicable_to,
|
|
49
53
|
helpful_count: e.doc.helpful_count ?? 0,
|
|
50
54
|
active: e.doc.active,
|
|
55
|
+
user_id: e.doc.user_id ?? "",
|
|
51
56
|
}))
|
|
52
57
|
.filter((r) => r.active && r.category === RESPONSE_QUALITY)
|
|
58
|
+
.filter((r) => r.user_id === "" || r.user_id === (userId ?? ""))
|
|
53
59
|
.sort(byUsefulness);
|
|
54
60
|
|
|
55
61
|
if (!agentRole) return all;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Events — el bus de eventos del runtime y la narración de lo que hace un agente.
|
|
3
|
+
*
|
|
4
|
+
* Dos buses con propósitos distintos:
|
|
5
|
+
* - `eventBus`: eventos del proceso, tipados (`event-bus.ts`).
|
|
6
|
+
* - `agentBus`: mensajería entre workers de un enjambre, con respaldo
|
|
7
|
+
* persistente en HiveDB para que un worker lea lo que le dejaron mientras
|
|
8
|
+
* no estaba (`agent-bus.ts`).
|
|
9
|
+
*
|
|
10
|
+
* La narración traduce una tool call a una frase que se le puede mostrar a
|
|
11
|
+
* alguien ("Buscando en la web...") en vez del nombre crudo de la tool.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export * from "./event-bus.ts";
|
|
15
|
+
export * from "./agent-bus.ts";
|
|
16
|
+
export * from "./narration.ts";
|
|
17
|
+
export * from "./tool-narration.ts";
|
|
18
|
+
export * from "./channel-narration.ts";
|
|
@@ -43,7 +43,11 @@ const TOOL_NARRATIONS: Record<string, string> = {
|
|
|
43
43
|
browser_click: "Haciendo clic...",
|
|
44
44
|
browser_type: "Escribiendo en la página...",
|
|
45
45
|
browser_screenshot: "Tomando captura de pantalla...",
|
|
46
|
+
computer_use_task: "Operando el navegador...",
|
|
46
47
|
browser_extract: "Extrayendo información de la página...",
|
|
48
|
+
// Artefactos
|
|
49
|
+
artifact_inspect: "Verificando el archivo generado...",
|
|
50
|
+
artifact_read: "Leyendo el resultado completo...",
|
|
47
51
|
// Canvas
|
|
48
52
|
canvas_add_node: "Actualizando canvas...",
|
|
49
53
|
canvas_update: "Actualizando canvas...",
|
|
@@ -1,16 +1,110 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Channel Notify —
|
|
3
|
-
*
|
|
2
|
+
* Channel Notify — el camino de salida hacia el usuario.
|
|
3
|
+
*
|
|
4
|
+
* Esto era un stub que sólo hacía `console.log`, y estaba en el camino real: la
|
|
5
|
+
* tool `notify`, los reportes de progreso, el aviso de que una tarea programada
|
|
6
|
+
* terminó, el de un turno interrumpido por un crash y el de compactación pasan
|
|
7
|
+
* todos por acá. Es decir, un agente sobre el SDK **no podía hablarle al
|
|
8
|
+
* usuario por ningún canal** — mientras `channels/manager.ts` tenía adaptadores
|
|
9
|
+
* funcionales de Slack, Discord, Telegram y WhatsApp, sin nada que los conectara.
|
|
10
|
+
*
|
|
11
|
+
* El cableado es explícito y opcional: la app registra su `ChannelManager` con
|
|
12
|
+
* `setChannelManager()`. Sin registro se conserva el comportamiento anterior
|
|
13
|
+
* —un log— porque un proceso que no maneja canales (un script, un test) no
|
|
14
|
+
* debería fallar por intentar notificar.
|
|
15
|
+
*
|
|
16
|
+
* Resolver a quién enviar es la otra mitad. `ChannelManager.send` necesita un
|
|
17
|
+
* `sessionId`, que es el contacto o grupo dentro del canal. Se obtiene del
|
|
18
|
+
* `threadId` (`${userId}/${canal}/${peer}`) cuando viene, y si no, buscando la
|
|
19
|
+
* conversación de ese usuario en ese canal. Sin eso el mensaje no sabe a qué
|
|
20
|
+
* chat volver.
|
|
4
21
|
*/
|
|
5
22
|
|
|
23
|
+
import { logger } from "../utils/logger.ts";
|
|
24
|
+
import { parseThreadId } from "../agent/thread-id.ts";
|
|
25
|
+
import { threadForChannel, listThreads } from "../agent/thread-store.ts";
|
|
26
|
+
|
|
27
|
+
const log = logger.child("channel-notify");
|
|
28
|
+
|
|
29
|
+
/** Lo mínimo que se necesita de un ChannelManager, para no atarse a su clase. */
|
|
30
|
+
export interface ChannelSender {
|
|
31
|
+
send(channelName: string, sessionId: string, message: unknown, accountId?: string): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let _sender: ChannelSender | null = null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Conecta el manager de canales. Llamalo una vez al arrancar, después de
|
|
38
|
+
* `channelManager.initialize()`.
|
|
39
|
+
*/
|
|
40
|
+
export function setChannelManager(sender: ChannelSender | null): void {
|
|
41
|
+
_sender = sender;
|
|
42
|
+
log.info(sender ? "canales conectados: las notificaciones salen de verdad" : "canales desconectados");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getChannelManager(): ChannelSender | null {
|
|
46
|
+
return _sender;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A qué conversación del canal enviar.
|
|
51
|
+
*
|
|
52
|
+
* El `threadId` ya lleva el peer adentro, así que si viene se usa. Si no, se
|
|
53
|
+
* busca el hilo del usuario en ese canal; y como último recurso se usa el
|
|
54
|
+
* `userId`, que es lo que hacían las instalaciones anteriores a la separación
|
|
55
|
+
* por canal.
|
|
56
|
+
*/
|
|
57
|
+
async function resolveSessionId(
|
|
58
|
+
channel: string,
|
|
59
|
+
userId: string,
|
|
60
|
+
threadId?: string,
|
|
61
|
+
): Promise<string | null> {
|
|
62
|
+
if (threadId) {
|
|
63
|
+
const parts = parseThreadId(threadId);
|
|
64
|
+
if (parts?.peerId) return parts.peerId;
|
|
65
|
+
}
|
|
66
|
+
// `threadForChannel` mira `userIdentities`, que es el registro canónico de
|
|
67
|
+
// "por dónde se alcanza a este usuario".
|
|
68
|
+
const delCanal = await threadForChannel(userId, channel).catch(() => null);
|
|
69
|
+
if (delCanal) {
|
|
70
|
+
const parts = parseThreadId(delCanal);
|
|
71
|
+
if (parts?.peerId) return parts.peerId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Si no hay identidad registrada pero sí una conversación abierta en ese
|
|
75
|
+
// canal, ahí es donde responder: es evidencia igual de válida de dónde está
|
|
76
|
+
// el usuario, y evita perder el aviso por un registro que nadie llenó.
|
|
77
|
+
const hilos = await listThreads(userId, { channel }).catch(() => []);
|
|
78
|
+
const reciente = hilos[0];
|
|
79
|
+
if (reciente) {
|
|
80
|
+
const parts = parseThreadId(reciente.id);
|
|
81
|
+
if (parts?.peerId) return parts.peerId;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return userId || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
6
87
|
export async function notifyChannel(
|
|
7
88
|
channel: string,
|
|
8
89
|
userId: string,
|
|
9
90
|
message: string,
|
|
10
91
|
opts?: { threadId?: string; metadata?: Record<string, unknown> }
|
|
11
92
|
): Promise<void> {
|
|
12
|
-
|
|
13
|
-
|
|
93
|
+
if (!_sender) {
|
|
94
|
+
// Sin canales conectados no es un error: hay procesos que legítimamente no
|
|
95
|
+
// los tienen. Pero conviene que se note, porque un `notify` que no llega es
|
|
96
|
+
// silencioso por naturaleza.
|
|
97
|
+
log.warn(`sin ChannelManager conectado — el mensaje para ${channel} no sale: ${message.slice(0, 80)}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const sessionId = await resolveSessionId(channel, userId, opts?.threadId);
|
|
102
|
+
if (!sessionId) {
|
|
103
|
+
log.warn(`no pude resolver a qué conversación de ${channel} enviarle a ${userId}`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await _sender.send(channel, sessionId, message);
|
|
14
108
|
}
|
|
15
109
|
|
|
16
110
|
export async function sendToUserChannel(
|
|
@@ -23,15 +117,18 @@ export async function sendToUserChannel(
|
|
|
23
117
|
await notifyChannel(channel, userId, message, opts);
|
|
24
118
|
return { ok: true };
|
|
25
119
|
} catch (err) {
|
|
120
|
+
// Un canal caído no debe tumbar el turno que estaba notificando.
|
|
121
|
+
log.warn(`falló el envío a ${channel}: ${(err as Error).message}`);
|
|
26
122
|
return { ok: false, error: (err as Error).message };
|
|
27
123
|
}
|
|
28
124
|
}
|
|
29
125
|
|
|
30
126
|
export async function broadcastNotification(
|
|
31
127
|
channels: string[],
|
|
32
|
-
message: string
|
|
128
|
+
message: string,
|
|
129
|
+
userId = "",
|
|
33
130
|
): Promise<void> {
|
|
34
131
|
for (const channel of channels) {
|
|
35
|
-
await notifyChannel(channel,
|
|
132
|
+
await notifyChannel(channel, userId, message).catch(() => {});
|
|
36
133
|
}
|
|
37
134
|
}
|
|
@@ -76,6 +76,17 @@ export function registerExecutor(type: JobType, executor: JobExecutor): void {
|
|
|
76
76
|
log.info(`[registerExecutor] Registered executor for type=${type}`);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Los tipos de job que este proceso sabe ejecutar.
|
|
81
|
+
*
|
|
82
|
+
* El registro era privado, así que no había forma de comprobar desde fuera si
|
|
83
|
+
* un tipo quedó cableado — y un job encolado sin ejecutor no falla al encolarse
|
|
84
|
+
* sino al tomarse, que es tarde y lejos de donde está el error.
|
|
85
|
+
*/
|
|
86
|
+
export function getRegisteredExecutorTypes(): JobType[] {
|
|
87
|
+
return [...executors.keys()];
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
export interface JobTerminalOutcome {
|
|
80
91
|
ok: boolean;
|
|
81
92
|
result?: unknown;
|
|
@@ -92,7 +103,8 @@ export function registerTerminalHook(type: JobType, hook: JobTerminalHook): void
|
|
|
92
103
|
terminalHooks.set(type, hook);
|
|
93
104
|
}
|
|
94
105
|
|
|
95
|
-
|
|
106
|
+
/** Exported so job-store.ts's reclaimOrInterrupt can fire it too (dynamic import there — see its call site). */
|
|
107
|
+
export async function runTerminalHook(job: JobDoc, outcome: JobTerminalOutcome): Promise<void> {
|
|
96
108
|
const hook = terminalHooks.get(job.type);
|
|
97
109
|
if (!hook) return;
|
|
98
110
|
try {
|
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
export { startGateway } from "./server.ts";
|
|
2
2
|
export type { GatewayConfig } from "./server.ts";
|
|
3
|
+
|
|
4
|
+
// Salida hacia el usuario: la app conecta su ChannelManager con setChannelManager().
|
|
5
|
+
export { setChannelManager, getChannelManager, notifyChannel, sendToUserChannel, broadcastNotification, type ChannelSender } from "./channel-notify.ts";
|
|
@@ -337,6 +337,12 @@ export async function reclaimOrInterrupt(jobId: string, opts?: { force?: boolean
|
|
|
337
337
|
try {
|
|
338
338
|
await c.put(jobId, updated, { expectedVersion: entry.version });
|
|
339
339
|
log.warn(`[reclaimOrInterrupt] Job ${jobId} interrupted (attempts exhausted)`);
|
|
340
|
+
// Terminal via lease-expiry, not executeJob's normal fail path — that
|
|
341
|
+
// path already fires the hook, this one didn't before. Dynamic import
|
|
342
|
+
// avoids a static circular import (durable-queue.ts imports
|
|
343
|
+
// reclaimOrInterrupt from this module).
|
|
344
|
+
const { runTerminalHook } = await import("./durable-queue.ts");
|
|
345
|
+
await runTerminalHook(updated, { ok: false, error: updated.error ?? "Job interrupted after lease expiry" }).catch(() => {});
|
|
340
346
|
return updated;
|
|
341
347
|
} catch {
|
|
342
348
|
await occRetryDelay(attempt);
|