@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
@@ -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
- chat(message: string, opts?: { threadId?: string; channel?: string }): AsyncGenerator<AgentEvent>;
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
- if (row.doc.status !== "active" || row.doc.expires_at > now) continue;
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 — stub for SDK compatibility.
3
- * In the full harness this sends messages back to channels.
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
- // TODO: integrate with ChannelManager for full functionality
13
- console.log(`[channel-notify] ${channel}: ${message}`);
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, "", message);
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
- async function runTerminalHook(job: JobDoc, outcome: JobTerminalOutcome): Promise<void> {
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);