@johpaz/hive-sdk 0.2.0 → 0.3.0

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 (86) 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/compaction.ts +20 -1
  6. package/packages/core/src/agent/context-compiler.ts +6 -3
  7. package/packages/core/src/agent/conversation-store.ts +136 -2
  8. package/packages/core/src/agent/curator.ts +12 -3
  9. package/packages/core/src/agent/llm-providers/nvidia.ts +39 -0
  10. package/packages/core/src/agent/llm-providers/openai-compat-base.ts +38 -2
  11. package/packages/core/src/agent/playbook-selector.ts +18 -3
  12. package/packages/core/src/agent/prompt-builder.ts +2 -2
  13. package/packages/core/src/agent/providers/index.ts +36 -1
  14. package/packages/core/src/agent/reflector.ts +32 -9
  15. package/packages/core/src/agent/skill-selector.ts +2 -2
  16. package/packages/core/src/agent/thread-store.ts +43 -0
  17. package/packages/core/src/agent/tool-selector.ts +2 -0
  18. package/packages/core/src/api/createAgent.ts +66 -1
  19. package/packages/core/src/artifacts/index.ts +15 -0
  20. package/packages/core/src/artifacts/store.ts +77 -2
  21. package/packages/core/src/canvas/index.ts +9 -0
  22. package/packages/core/src/ethics/EthicsGuard.ts +7 -1
  23. package/packages/core/src/events/index.ts +18 -0
  24. package/packages/core/src/events/tool-narration.ts +4 -0
  25. package/packages/core/src/gateway/channel-notify.ts +103 -6
  26. package/packages/core/src/gateway/durable-queue.ts +13 -1
  27. package/packages/core/src/gateway/index.ts +3 -0
  28. package/packages/core/src/gateway/job-store.ts +6 -0
  29. package/packages/core/src/harness/executors.ts +493 -0
  30. package/packages/core/src/harness/index.ts +12 -2
  31. package/packages/core/src/hooks/index.ts +203 -0
  32. package/packages/core/src/images/index.ts +161 -0
  33. package/packages/core/src/index.ts +1 -0
  34. package/packages/core/src/multimodal/vision-service.ts +45 -13
  35. package/packages/core/src/resilience/index.ts +13 -0
  36. package/packages/core/src/scheduler/CronScheduler.ts +48 -21
  37. package/packages/core/src/scheduler/cron/expression.ts +165 -0
  38. package/packages/core/src/scheduler/cron/index.ts +10 -0
  39. package/packages/core/src/scheduler/cron/job.ts +339 -0
  40. package/packages/core/src/scheduler/cron/next-run.ts +121 -0
  41. package/packages/core/src/scheduler/cron/zoned-time.ts +138 -0
  42. package/packages/core/src/scheduler/index.ts +21 -3
  43. package/packages/core/src/scheduler/integration.ts +16 -5
  44. package/packages/core/src/scheduler/types.ts +3 -18
  45. package/packages/core/src/services/agents.ts +268 -0
  46. package/packages/core/src/services/cron.ts +257 -0
  47. package/packages/core/src/services/endpoints.ts +289 -0
  48. package/packages/core/src/services/ethics.ts +107 -0
  49. package/packages/core/src/services/images.ts +212 -0
  50. package/packages/core/src/services/index.ts +112 -0
  51. package/packages/core/src/services/mcp.ts +201 -0
  52. package/packages/core/src/services/memory.ts +133 -0
  53. package/packages/core/src/services/models.ts +179 -0
  54. package/packages/core/src/services/providers.ts +152 -0
  55. package/packages/core/src/services/setup.ts +222 -0
  56. package/packages/core/src/services/skills.ts +241 -0
  57. package/packages/core/src/services/swarms.ts +307 -0
  58. package/packages/core/src/services/tools.ts +106 -0
  59. package/packages/core/src/sessions/index.ts +5 -3
  60. package/packages/core/src/sessions/resolve.ts +108 -0
  61. package/packages/core/src/skills/SkillLoader.ts +8 -1
  62. package/packages/core/src/skills/bundled/artifacts/artifact_reader/SKILL.md +105 -0
  63. package/packages/core/src/skills/bundled/cron_manager/SKILL.md +21 -11
  64. package/packages/core/src/skills/bundled/images/image_editor/SKILL.md +120 -0
  65. package/packages/core/src/skills/bundled/web/browser_automate/SKILL.md +12 -3
  66. package/packages/core/src/skills/bundled/web/browser_scrape/SKILL.md +22 -7
  67. package/packages/core/src/skills/bundled-data.generated.ts +110 -12
  68. package/packages/core/src/storage/bootstrap.ts +74 -5
  69. package/packages/core/src/storage/collections.ts +106 -1
  70. package/packages/core/src/storage/crypto.ts +24 -7
  71. package/packages/core/src/storage/index.ts +2 -1
  72. package/packages/core/src/storage/onboarding.ts +59 -43
  73. package/packages/core/src/storage/reconcile.ts +6 -1
  74. package/packages/core/src/storage/seed.ts +89 -11
  75. package/packages/core/src/swarm/types.ts +3 -18
  76. package/packages/core/src/tool-runtime/embedded-worker.generated.ts +21 -0
  77. package/packages/core/src/tool-runtime/index.ts +129 -14
  78. package/packages/core/src/tools/agents/index.ts +18 -60
  79. package/packages/core/src/tools/cli/index.ts +55 -0
  80. package/packages/core/src/tools/core/index.ts +50 -2
  81. package/packages/core/src/tools/cron/index.ts +4 -4
  82. package/packages/core/src/tools/images/index.ts +130 -0
  83. package/packages/core/src/tools/index.ts +14 -1
  84. package/packages/core/src/tools/office/office-escribir-xlsx.ts +2 -1
  85. package/packages/core/src/tools/office/office-leer-xlsx.ts +2 -1
  86. package/packages/core/src/tools/office/xlsx-loader.ts +19 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Reloj de pared ↔ instante, en una zona horaria IANA.
3
+ *
4
+ * Es la parte difícil de un cron con zona horaria y la razón por la que no
5
+ * alcanza con `Bun.cron.parse()`, que sólo trabaja en UTC. "Todos los días a
6
+ * las 9" significa las 9 **del reloj de la pared en Bogotá**, y ese instante se
7
+ * corre una hora dos veces al año en las zonas con horario de verano. Calcular
8
+ * el offset una sola vez y sumarlo produce un cron que se desfasa un día al año
9
+ * en marzo y otro en octubre — el tipo de error que aparece un domingo a las
10
+ * 2am y nadie sabe de dónde salió.
11
+ *
12
+ * Acá no se guarda ningún offset: se le pregunta a `Intl` en cada conversión,
13
+ * que es la única fuente que conoce las reglas de cada zona y sus cambios.
14
+ */
15
+
16
+ /** Los campos que muestra un reloj colgado en la pared de esa zona. */
17
+ export interface WallClock {
18
+ year: number
19
+ /** 1-12, no 0-11: acá no hay meses de base cero. */
20
+ month: number
21
+ day: number
22
+ hour: number
23
+ minute: number
24
+ second: number
25
+ }
26
+
27
+ /** Día de la semana, 0 = domingo. */
28
+ export function wallClockWeekday(wall: WallClock): number {
29
+ return new Date(Date.UTC(wall.year, wall.month - 1, wall.day)).getUTCDay()
30
+ }
31
+
32
+ const formatters = new Map<string, Intl.DateTimeFormat>()
33
+
34
+ function formatter(timeZone: string): Intl.DateTimeFormat {
35
+ let f = formatters.get(timeZone)
36
+ if (!f) {
37
+ f = new Intl.DateTimeFormat("en-US", {
38
+ timeZone,
39
+ hourCycle: "h23",
40
+ year: "numeric",
41
+ month: "2-digit",
42
+ day: "2-digit",
43
+ hour: "2-digit",
44
+ minute: "2-digit",
45
+ second: "2-digit",
46
+ })
47
+ formatters.set(timeZone, f)
48
+ }
49
+ return f
50
+ }
51
+
52
+ /** Valida una zona IANA. Lanza con un mensaje que dice cuál falló. */
53
+ export function assertTimeZone(timeZone: string): void {
54
+ try {
55
+ formatter(timeZone)
56
+ } catch {
57
+ throw new Error(`Zona horaria desconocida: "${timeZone}"`)
58
+ }
59
+ }
60
+
61
+ /** Qué marca el reloj de esa zona en ese instante. */
62
+ export function toWallClock(instant: Date, timeZone: string): WallClock {
63
+ const parts = formatter(timeZone).formatToParts(instant)
64
+ const campo = (tipo: string) => {
65
+ const p = parts.find((x) => x.type === tipo)
66
+ return p ? Number(p.value) : 0
67
+ }
68
+ return {
69
+ year: campo("year"),
70
+ month: campo("month"),
71
+ day: campo("day"),
72
+ hour: campo("hour"),
73
+ minute: campo("minute"),
74
+ second: campo("second"),
75
+ }
76
+ }
77
+
78
+ function asUTCMillis(wall: WallClock): number {
79
+ return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second)
80
+ }
81
+
82
+ function mismoReloj(a: WallClock, b: WallClock): boolean {
83
+ return (
84
+ a.year === b.year && a.month === b.month && a.day === b.day &&
85
+ a.hour === b.hour && a.minute === b.minute && a.second === b.second
86
+ )
87
+ }
88
+
89
+ /**
90
+ * El instante en que el reloj de esa zona marca `wall`.
91
+ *
92
+ * Devuelve `null` cuando ese momento **no existe**: al adelantar el horario de
93
+ * verano el reloj salta de 01:59 a 03:00, así que "las 2:30" no ocurre ese día.
94
+ * Un cron agendado ahí no debe correr a una hora inventada; el que llama decide
95
+ * si lo salta o lo corre al día siguiente.
96
+ *
97
+ * Cuando el reloj se atrasa, la misma hora ocurre dos veces y se devuelve la
98
+ * primera — correr una vez es lo que espera quien agendó, no dos.
99
+ */
100
+ export function toInstant(wall: WallClock, timeZone: string): Date | null {
101
+ const objetivo = asUTCMillis(wall)
102
+
103
+ // Punto de partida: tratar el reloj de pared como si fuera UTC y corregir con
104
+ // la diferencia que reporte la zona. Dos pasadas alcanzan siempre — la primera
105
+ // acerca al offset correcto, la segunda absorbe el caso en que la corrección
106
+ // cruzó un cambio de horario y el offset de destino era otro.
107
+ let ts = objetivo
108
+ for (let i = 0; i < 3; i++) {
109
+ const diferencia = objetivo - asUTCMillis(toWallClock(new Date(ts), timeZone))
110
+ if (diferencia === 0) break
111
+ ts += diferencia
112
+ }
113
+
114
+ // La comprobación no es defensiva: es cómo se detecta el hueco del cambio de
115
+ // horario. Si la hora pedida no existe, ninguna corrección converge y el
116
+ // reloj de `ts` marca otra cosa.
117
+ if (!mismoReloj(toWallClock(new Date(ts), timeZone), wall)) return null
118
+
119
+ // Cuando el reloj se atrasa, ese mismo reloj de pared ocurre dos veces y la
120
+ // convergencia de arriba aterriza en la segunda. Se busca hacia atrás la
121
+ // primera: correr al instante más temprano es lo que espera quien agendó "a
122
+ // las 2:30", y así el job no se corre una hora ese día del año.
123
+ for (const atras of DESFASES_POSIBLES) {
124
+ const antes = ts - atras
125
+ if (mismoReloj(toWallClock(new Date(antes), timeZone), wall)) return new Date(antes)
126
+ }
127
+
128
+ return new Date(ts)
129
+ }
130
+
131
+ /**
132
+ * Cuánto puede saltar un reloj al cambiar de horario, de mayor a menor.
133
+ *
134
+ * Casi todas las zonas mueven una hora, pero no todas: Lord Howe mueve media, y
135
+ * hubo zonas con saltos de dos. Se prueban de mayor a menor para quedarse con
136
+ * la ocurrencia más temprana.
137
+ */
138
+ const DESFASES_POSIBLES = [2 * 3_600_000, 3_600_000, 1_800_000]
@@ -1,11 +1,30 @@
1
1
  /**
2
- * Scheduler — cron con Croner sobre HiveDB.
2
+ * Scheduler — cron sobre HiveDB, con motor propio y sin dependencias.
3
3
  *
4
4
  * Soporta jobs recurrentes y de una sola vez. La persistencia pasó de SQLite a
5
- * las colecciones `cronJobs` / `taskRuns` de HiveDB en 0.1.5.
5
+ * las colecciones `cronJobs` / `taskRuns` de HiveDB en 0.1.5, y el motor pasó de
6
+ * `croner` a `./cron` —sólo `setTimeout` e `Intl` del runtime— en 0.3.0.
6
7
  */
7
8
 
8
9
  export { CronScheduler } from "./CronScheduler.ts";
10
+
11
+ // El motor por separado, para quien quiera calcular o validar sin montar un
12
+ // scheduler: una UI que muestra "próximas corridas" mientras se escribe la
13
+ // expresión, por ejemplo.
14
+ export {
15
+ Cron,
16
+ parseCronExpression,
17
+ isValidCronExpression,
18
+ nextOccurrence,
19
+ toWallClock,
20
+ toInstant,
21
+ assertTimeZone,
22
+ type CronOptions,
23
+ type CronFunction,
24
+ type CronFields,
25
+ type NextOccurrenceOptions,
26
+ type WallClock,
27
+ } from "./cron/index.ts";
9
28
  // `executeScheduledTask` dejó de ser público: la ejecución entra por
10
29
  // `createTaskHandler()`, que es lo que el scheduler engancha.
11
30
  export { createTaskHandler, notifyTaskCompletion, setSchedulerForCleanup } from "./integration.ts";
@@ -20,5 +39,4 @@ export type {
20
39
  TaskType,
21
40
  TaskStatus,
22
41
  TaskRunStatus,
23
- CronerOptions,
24
42
  } from "./types.ts";
@@ -12,6 +12,8 @@ import { resolveAgentId } from "../storage/onboarding.ts";
12
12
  import { sendToUserChannel } from "../gateway/channel-notify.ts";
13
13
  import { getNarration } from "../events/tool-narration.ts";
14
14
  import { addMessage } from "../agent/conversation-store.ts";
15
+ import { makeThreadId, CRON_CHANNEL } from "../agent/thread-id.ts";
16
+ import { threadForChannel } from "../agent/thread-store.ts";
15
17
  import { resolveBestChannel } from "../tools/cron/index.ts";
16
18
  import { col } from "../storage/hive.ts";
17
19
  import type { UserDoc, CronJobDoc } from "../storage/collections.ts";
@@ -123,7 +125,11 @@ ${prompt || `Execute tool: ${job.tool_name}`}`;
123
125
  try {
124
126
  const agentLoop = buildAgentLoop({ mcpManager: undefined });
125
127
 
126
- const sessionId = `sched_${job.id}_${Date.now()}`;
128
+ // Un hilo estable por tarea programada. Antes era
129
+ // `sched_${job.id}_${Date.now()}`: cada disparo estrenaba un hilo, así que
130
+ // la tarea nunca recordaba sus ejecuciones anteriores y cada una dejaba
131
+ // mensajes huérfanos en `conversations` que ya nadie volvía a leer.
132
+ const sessionId = makeThreadId(user?.id || "default", CRON_CHANNEL, job.id);
127
133
 
128
134
  const agentChannel = (job.channel && job.channel !== "system")
129
135
  ? job.channel
@@ -136,10 +142,10 @@ ${prompt || `Execute tool: ${job.tool_name}`}`;
136
142
  if (!agentChannel) return;
137
143
  try {
138
144
  if (step.type === "tool_call" && step.toolName) {
139
- await sendToUserChannel(agentChannel, user?.id || "", getNarration(step.toolName));
145
+ await sendToUserChannel(agentChannel, user?.id || "", getNarration(step.toolName), { threadId: sessionId });
140
146
  } else if (step.type === "text" && step.message) {
141
147
  const trimmed = step.message.trim();
142
- if (trimmed) await sendToUserChannel(agentChannel, user?.id || "", trimmed);
148
+ if (trimmed) await sendToUserChannel(agentChannel, user?.id || "", trimmed, { threadId: sessionId });
143
149
  }
144
150
  } catch (err) {
145
151
  log.warn(`[execute] Narration send failed: ${(err as Error).message}`);
@@ -239,13 +245,18 @@ export async function notifyTaskCompletion(
239
245
 
240
246
  log.info(`[notify] Sending notification to ${notifyChannel}: "${message.slice(0, 50)}..."`);
241
247
 
248
+ // El aviso se guarda en el hilo donde el usuario lo va a leer —su conversación
249
+ // activa en la web, o el chat del canal— y no en el hilo del usuario, que desde
250
+ // la separación por canal ya no es donde mira nadie.
251
+ const notifyThreadId = (await threadForChannel(userId, notifyChannel)) ?? userId;
252
+
242
253
  try {
243
- await addMessage(userId, "assistant", message, { channel: notifyChannel });
254
+ await addMessage(notifyThreadId, "assistant", message, { channel: notifyChannel });
244
255
  } catch (e) {
245
256
  log.warn(`[notify] Failed to persist notification to DB: ${(e as Error).message}`);
246
257
  }
247
258
 
248
- await sendToUserChannel(notifyChannel, userId, message);
259
+ await sendToUserChannel(notifyChannel, userId, message, { threadId: notifyThreadId });
249
260
  log.info(`[notify] Notification sent to ${notifyChannel}`);
250
261
  }
251
262
 
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Hive Scheduler - Type Definitions
3
3
  *
4
- * Type interfaces for the Croner-based scheduling system.
4
+ * Type interfaces for the scheduling system.
5
5
  * All names use "CronJob" terminology (formerly ScheduledTask).
6
6
  */
7
7
 
8
- import type { Cron } from "croner";
8
+ import type { Cron } from "./cron/index.ts";
9
9
 
10
10
  /**
11
11
  * Task type: recurring uses cron expression, one_shot uses fire_at
@@ -146,24 +146,9 @@ export interface CronJobExecutionResult {
146
146
  }
147
147
 
148
148
  /**
149
- * Internal job wrapper holding Croner instance and metadata
149
+ * Internal job wrapper holding the scheduled job and its metadata
150
150
  */
151
151
  export interface CronJobEntry {
152
152
  job: CronJob;
153
153
  cron: Cron;
154
154
  }
155
-
156
- /**
157
- * Options for Croner job creation
158
- */
159
- export interface CronerOptions {
160
- timezone: string;
161
- protect: boolean;
162
- catch: boolean | ((error: Error) => void);
163
- name: string;
164
- maxRuns?: number;
165
- interval?: number;
166
- startAt?: string;
167
- stopAt?: string;
168
- domAndDow?: boolean;
169
- }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Agentes — la API, no la tool.
3
+ *
4
+ * Crear y editar agentes sólo se podía por dos caminos, y ninguno servía para
5
+ * una UI: `agentCreateTool` (argumentos con forma de LLM) o consultas crudas a
6
+ * la colección `agents`. En hive el CRUD vive inline en `gateway/routes/agents.ts`.
7
+ *
8
+ * Una diferencia deliberada con hive: **acá se valida que las referencias
9
+ * existan**. Su ruta guarda `tools_json`, `skills_json` y `mcp_server_ids_json`
10
+ * tal cual llegan, sin comprobar nada, así que un id mal escrito no falla al
11
+ * guardar sino más tarde, cuando el agente intenta usar una capacidad que no
12
+ * existe — lejos de donde está el error. Validar al escribir cuesta tres
13
+ * consultas y ahorra ese viaje.
14
+ *
15
+ * Lo que NO hace, a propósito: tocar las filas de `tools`/`skills` al crear o
16
+ * borrar un agente. Son colecciones globales compartidas entre todos los
17
+ * agentes; borrar las de uno rompería a otro que las usa.
18
+ */
19
+
20
+ import { col, toIndexable, fromIndexable, NO_PARENT } from "../storage/hive.ts";
21
+ import type { AgentDoc, ToolDoc, SkillDoc, McpServerDoc } from "../storage/collections.ts";
22
+ import { deleteAgentSecrets } from "../storage/crypto.ts";
23
+ import { expandToolAllowlist } from "../agent/delegation-runtime.ts";
24
+ import { logger } from "../utils/logger.ts";
25
+
26
+ const log = logger.child("services/agents");
27
+
28
+ export interface AgentSummary {
29
+ id: string;
30
+ name: string;
31
+ description: string | null;
32
+ role: AgentDoc["role"];
33
+ status: string;
34
+ enabled: boolean;
35
+ providerId: string | null;
36
+ modelId: string | null;
37
+ /** Patrones declarados (`fs_*`), no la expansión. */
38
+ toolPatterns: string[];
39
+ skills: string[];
40
+ mcpServerIds: string[];
41
+ /** `"catalog"` = persona sembrada; `"user"` = creada por alguien. */
42
+ source: "user" | "catalog";
43
+ systemPrompt: string | null;
44
+ createdAt: number;
45
+ updatedAt: number;
46
+ }
47
+
48
+ export interface CreateAgentInput {
49
+ id?: string;
50
+ name: string;
51
+ description?: string | null;
52
+ systemPrompt?: string | null;
53
+ role?: AgentDoc["role"];
54
+ providerId?: string | null;
55
+ modelId?: string | null;
56
+ /** Acepta globs (`fs_*`); se validan tras expandir. */
57
+ toolPatterns?: string[];
58
+ skills?: string[];
59
+ mcpServerIds?: string[];
60
+ userId?: string;
61
+ maxIterations?: number;
62
+ enabled?: boolean;
63
+ }
64
+
65
+ export type UpdateAgentInput = Partial<Omit<CreateAgentInput, "id">>;
66
+
67
+ /**
68
+ * Convierte un nombre en id.
69
+ *
70
+ * Normaliza los acentos antes de filtrar: sin eso "Efímero" queda como
71
+ * `ef_mero` y "Diseño" como `dise_o`, porque la í y la ñ no son `[a-z0-9]`.
72
+ * Para un producto en español eso no es un detalle cosmético.
73
+ */
74
+ export function slugify(name: string): string {
75
+ return name
76
+ .normalize("NFD")
77
+ .replace(/[\u0300-\u036f]/g, "")
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9]+/g, "_")
80
+ .replace(/^_|_$/g, "");
81
+ }
82
+
83
+ function parseList(json: string | null | undefined): string[] {
84
+ try {
85
+ const v = json ? JSON.parse(json) : [];
86
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ function toSummary(doc: AgentDoc): AgentSummary {
93
+ return {
94
+ id: doc.id,
95
+ name: doc.name,
96
+ description: doc.description,
97
+ role: doc.role,
98
+ status: doc.status,
99
+ enabled: doc.enabled,
100
+ providerId: fromIndexable(doc.provider_id),
101
+ modelId: fromIndexable(doc.model_id),
102
+ toolPatterns: parseList(doc.tool_allowlist_json ?? doc.tools_json),
103
+ skills: parseList(doc.skills_json),
104
+ mcpServerIds: parseList(doc.mcp_server_ids_json),
105
+ source: doc.source ?? "user",
106
+ systemPrompt: doc.system_prompt,
107
+ createdAt: doc.created_at,
108
+ updatedAt: doc.updated_at,
109
+ };
110
+ }
111
+
112
+ async function agentsCol() {
113
+ return col<AgentDoc>("agents");
114
+ }
115
+
116
+ /**
117
+ * Comprueba que lo que se le asigna al agente exista de verdad.
118
+ *
119
+ * Los patrones de tools se expanden primero contra el registro vivo
120
+ * (`expandToolAllowlist`): un `fs_*` que no case con ninguna tool es un error
121
+ * de quien lo escribió, no una lista vacía silenciosa.
122
+ */
123
+ async function validateReferences(input: {
124
+ toolPatterns?: string[];
125
+ skills?: string[];
126
+ mcpServerIds?: string[];
127
+ }): Promise<void> {
128
+ const problemas: string[] = [];
129
+
130
+ if (input.toolPatterns?.length) {
131
+ const expandidas = expandToolAllowlist(input.toolPatterns);
132
+ if (expandidas.length === 0) {
133
+ problemas.push(`ninguna tool coincide con ${JSON.stringify(input.toolPatterns)}`);
134
+ } else {
135
+ const c = await col<ToolDoc>("tools");
136
+ const faltan: string[] = [];
137
+ for (const name of expandidas) if (!(await c.get(name))) faltan.push(name);
138
+ if (faltan.length) problemas.push(`tools inexistentes: ${faltan.join(", ")}`);
139
+ }
140
+ }
141
+
142
+ if (input.skills?.length) {
143
+ const c = await col<SkillDoc>("skills");
144
+ const faltan: string[] = [];
145
+ for (const id of input.skills) if (!(await c.get(id))) faltan.push(id);
146
+ if (faltan.length) problemas.push(`skills inexistentes: ${faltan.join(", ")}`);
147
+ }
148
+
149
+ if (input.mcpServerIds?.length) {
150
+ const c = await col<McpServerDoc>("mcpServers");
151
+ const faltan: string[] = [];
152
+ for (const id of input.mcpServerIds) if (!(await c.get(id))) faltan.push(id);
153
+ if (faltan.length) problemas.push(`servidores MCP inexistentes: ${faltan.join(", ")}`);
154
+ }
155
+
156
+ if (problemas.length) throw new Error(problemas.join("; "));
157
+ }
158
+
159
+ export async function createAgent(input: CreateAgentInput): Promise<AgentSummary> {
160
+ if (!input.name?.trim()) throw new Error("El agente necesita un nombre");
161
+ await validateReferences(input);
162
+
163
+ const c = await agentsCol();
164
+ const id = input.id ?? slugify(input.name);
165
+ if (await c.get(id)) throw new Error(`Ya existe un agente con id "${id}"`);
166
+
167
+ const now = Date.now();
168
+ const doc: AgentDoc = {
169
+ id,
170
+ user_id: input.userId ?? "",
171
+ name: input.name,
172
+ description: input.description ?? null,
173
+ system_prompt: input.systemPrompt ?? null,
174
+ tone: null,
175
+ role: input.role ?? "worker",
176
+ status: "idle",
177
+ enabled: input.enabled ?? true,
178
+ provider_id: toIndexable(input.providerId ?? null),
179
+ model_id: toIndexable(input.modelId ?? null),
180
+ tools_json: input.toolPatterns ? JSON.stringify(expandToolAllowlist(input.toolPatterns)) : null,
181
+ skills_json: input.skills ? JSON.stringify(input.skills) : null,
182
+ parent_id: NO_PARENT,
183
+ max_iterations: input.maxIterations ?? 10,
184
+ workspace: null,
185
+ lastTraceAt: null,
186
+ created_at: now,
187
+ updated_at: now,
188
+ source: "user",
189
+ // Los patrones se guardan sin expandir: `task_delegate` los vuelve a
190
+ // expandir en cada delegación, así una tool registrada después igual entra.
191
+ tool_allowlist_json: input.toolPatterns ? JSON.stringify(input.toolPatterns) : null,
192
+ mcp_server_ids_json: input.mcpServerIds ? JSON.stringify(input.mcpServerIds) : null,
193
+ };
194
+
195
+ await c.put(id, doc, { expectedVersion: 0 });
196
+ log.info(`agente "${input.name}" creado (${id})`);
197
+ return toSummary(doc);
198
+ }
199
+
200
+ export async function getAgent(id: string): Promise<AgentSummary | null> {
201
+ const entry = await (await agentsCol()).get(id);
202
+ return entry ? toSummary(entry.doc) : null;
203
+ }
204
+
205
+ export interface ListAgentsOptions {
206
+ role?: AgentDoc["role"];
207
+ source?: "user" | "catalog";
208
+ includeDisabled?: boolean;
209
+ }
210
+
211
+ export async function listAgents(opts?: ListAgentsOptions): Promise<AgentSummary[]> {
212
+ const rows = await (await agentsCol()).scan({});
213
+ return rows
214
+ .map((e) => e.doc)
215
+ .filter((d) => (opts?.role ? d.role === opts.role : true))
216
+ .filter((d) => (opts?.source ? (d.source ?? "user") === opts.source : true))
217
+ .filter((d) => (opts?.includeDisabled ? true : d.enabled))
218
+ .map(toSummary)
219
+ .sort((a, b) => a.name.localeCompare(b.name));
220
+ }
221
+
222
+ export async function updateAgent(id: string, changes: UpdateAgentInput): Promise<AgentSummary> {
223
+ await validateReferences(changes);
224
+
225
+ const c = await agentsCol();
226
+ const entry = await c.get(id);
227
+ if (!entry) throw new Error(`No existe el agente "${id}"`);
228
+
229
+ const doc: AgentDoc = { ...entry.doc, updated_at: Date.now() };
230
+ if (changes.name !== undefined) doc.name = changes.name;
231
+ if (changes.description !== undefined) doc.description = changes.description;
232
+ if (changes.systemPrompt !== undefined) doc.system_prompt = changes.systemPrompt;
233
+ if (changes.role !== undefined) doc.role = changes.role;
234
+ if (changes.enabled !== undefined) doc.enabled = changes.enabled;
235
+ if (changes.maxIterations !== undefined) doc.max_iterations = changes.maxIterations;
236
+ if (changes.providerId !== undefined) doc.provider_id = toIndexable(changes.providerId);
237
+ if (changes.modelId !== undefined) doc.model_id = toIndexable(changes.modelId);
238
+ if (changes.toolPatterns !== undefined) {
239
+ doc.tool_allowlist_json = JSON.stringify(changes.toolPatterns);
240
+ doc.tools_json = JSON.stringify(expandToolAllowlist(changes.toolPatterns));
241
+ }
242
+ if (changes.skills !== undefined) doc.skills_json = JSON.stringify(changes.skills);
243
+ if (changes.mcpServerIds !== undefined) doc.mcp_server_ids_json = JSON.stringify(changes.mcpServerIds);
244
+
245
+ await c.put(id, doc, { expectedVersion: entry.version });
246
+ return toSummary(doc);
247
+ }
248
+
249
+ /** Atajos legibles sobre `updateAgent`, que es lo que una UI llama al editar. */
250
+ export const assignTools = (id: string, toolPatterns: string[]) => updateAgent(id, { toolPatterns });
251
+ export const assignSkills = (id: string, skills: string[]) => updateAgent(id, { skills });
252
+ export const assignMcpServers = (id: string, mcpServerIds: string[]) => updateAgent(id, { mcpServerIds });
253
+
254
+ export const enableAgent = (id: string) => updateAgent(id, { enabled: true });
255
+ export const disableAgent = (id: string) => updateAgent(id, { enabled: false });
256
+
257
+ /**
258
+ * Borra el agente y sus secretos. **No toca sus tools ni sus skills**: son
259
+ * filas globales que otros agentes comparten.
260
+ */
261
+ export async function deleteAgent(id: string): Promise<boolean> {
262
+ const c = await agentsCol();
263
+ if (!(await c.get(id))) return false;
264
+ await c.delete(id);
265
+ await deleteAgentSecrets(id).catch(() => {});
266
+ log.info(`agente ${id} borrado (sus tools y skills quedan: son compartidas)`);
267
+ return true;
268
+ }