@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
@@ -4,6 +4,9 @@ import { dirname, join } from "node:path"
4
4
  import { availableParallelism } from "node:os"
5
5
  import type { Config } from "../config/loader.ts"
6
6
  import { loadConfig } from "../config/loader.ts"
7
+ import { logger } from "../utils/logger.ts"
8
+ import { embeddedToolWorkerPath } from "./embedded-worker.generated.ts"
9
+ import { hasHooks, runBeforeToolCall, runAfterToolCall } from "../hooks/index.ts"
7
10
 
8
11
  export type ToolCallLike = {
9
12
  id: string
@@ -105,7 +108,19 @@ type WorkerSlot = {
105
108
  job?: QueuedJob
106
109
  }
107
110
 
108
- function resolveWorkerEntry(): string {
111
+ let cachedWorkerEntry: string | null | undefined
112
+
113
+ /**
114
+ * Locate the tool worker entry, or null when this build ships without one.
115
+ *
116
+ * Returning null instead of throwing is deliberate: workers are an
117
+ * optimization, and a packaging gap must never take a whole turn down with it
118
+ * (it did until v1.0.3 — every desktop install failed each multi-tool turn
119
+ * with "Tool worker entry not found"). The caller degrades to the main thread.
120
+ */
121
+ function resolveWorkerEntry(): string | null {
122
+ if (cachedWorkerEntry !== undefined) return cachedWorkerEntry
123
+
109
124
  const candidates = [
110
125
  new URL("./tool-worker.js", import.meta.url),
111
126
  new URL("./tool-worker.ts", import.meta.url),
@@ -115,7 +130,8 @@ function resolveWorkerEntry(): string {
115
130
 
116
131
  for (const candidate of candidates) {
117
132
  if (existsSync(fileURLToPath(candidate))) {
118
- return candidate.href
133
+ cachedWorkerEntry = candidate.href
134
+ return cachedWorkerEntry
119
135
  }
120
136
  }
121
137
 
@@ -135,13 +151,25 @@ function resolveWorkerEntry(): string {
135
151
 
136
152
  for (const filePath of fallbacks) {
137
153
  if (existsSync(filePath)) {
138
- return filePath
154
+ cachedWorkerEntry = filePath
155
+ return cachedWorkerEntry
139
156
  }
140
157
  }
141
158
 
142
- throw new Error(
143
- `Tool worker entry not found. Tried: ${[...candidates.map((candidate) => fileURLToPath(candidate)), ...fallbacks].join(", ")}`
159
+ // Standalone executable: the worker was embedded at compile time and lives in
160
+ // the virtual bunfs, where existsSync() reports false so it is taken on
161
+ // trust. It is only ever set by scripts/build-gateway.ts.
162
+ if (embeddedToolWorkerPath) {
163
+ cachedWorkerEntry = embeddedToolWorkerPath
164
+ return cachedWorkerEntry
165
+ }
166
+
167
+ logger.warn(
168
+ "[tool-runtime] No tool worker entry found — running tool batches on the main thread (sequentially)",
169
+ { tried: [...candidates.map((candidate) => fileURLToPath(candidate)), ...fallbacks] }
144
170
  )
171
+ cachedWorkerEntry = null
172
+ return null
145
173
  }
146
174
 
147
175
  function serializeError(error: unknown): SerializedError {
@@ -187,6 +215,7 @@ const DEFAULT_MAIN_THREAD_TOOL_NAMES = new Set([
187
215
  "browser_navigate",
188
216
  "browser_screenshot",
189
217
  "artifact_inspect",
218
+ "artifact_read",
190
219
  "browser_click",
191
220
  "browser_type",
192
221
  "browser_extract",
@@ -255,10 +284,12 @@ class ToolWorkerPool {
255
284
  private workers: WorkerSlot[] = []
256
285
  private queue: QueuedJob[] = []
257
286
  private readonly maxWorkers: number
287
+ private readonly workerEntry: string
258
288
  private disposed = false
259
289
 
260
- constructor(maxWorkers: number) {
290
+ constructor(maxWorkers: number, workerEntry: string) {
261
291
  this.maxWorkers = Math.max(1, maxWorkers)
292
+ this.workerEntry = workerEntry
262
293
  }
263
294
 
264
295
  execute(job: Omit<QueuedJob, "resolve" | "settled" | "startedAt">): Promise<ToolBatchResult> {
@@ -385,7 +416,7 @@ class ToolWorkerPool {
385
416
  }
386
417
 
387
418
  private createSlot(): WorkerSlot {
388
- const worker = new Worker(resolveWorkerEntry(), { type: "module" })
419
+ const worker = new Worker(this.workerEntry, { type: "module" })
389
420
  const slot: WorkerSlot = { worker, busy: false }
390
421
 
391
422
  worker.onmessage = (event: MessageEvent<WorkerMessage>) => {
@@ -500,11 +531,15 @@ class ToolWorkerPool {
500
531
  job.resolve(result)
501
532
 
502
533
  if (restart) {
534
+ // Un worker que abortó o murió se descarta, y el reemplazo lo crea
535
+ // `drain()` solo si queda trabajo. Antes se levantaba uno nuevo en el
536
+ // acto: sobre un aborto —el caso típico al terminar un turno o una
537
+ // suite— eso deja un worker recién arrancado que nadie va a usar y que
538
+ // hay que terminar a mitad del arranque. Bun se cae con SIGSEGV al
539
+ // cerrar el proceso en ese estado (CI: workers_spawned 13, terminated 11).
503
540
  slot.worker.terminate()
504
541
  const index = this.workers.indexOf(slot)
505
- if (index >= 0) {
506
- this.workers[index] = this.createSlot()
507
- }
542
+ if (index >= 0) this.workers.splice(index, 1)
508
543
  } else {
509
544
  slot.busy = false
510
545
  slot.job = undefined
@@ -550,15 +585,90 @@ function resolveToolTimeout(
550
585
  return baseTimeoutMs
551
586
  }
552
587
 
553
- function getPool(maxWorkers: number): ToolWorkerPool {
588
+ function getPool(maxWorkers: number): ToolWorkerPool | null {
589
+ const workerEntry = resolveWorkerEntry()
590
+ if (!workerEntry) return null
591
+
554
592
  if (!sharedPool || sharedPoolSize !== maxWorkers) {
555
- sharedPool = new ToolWorkerPool(maxWorkers)
593
+ sharedPool = new ToolWorkerPool(maxWorkers, workerEntry)
556
594
  sharedPoolSize = maxWorkers
557
595
  }
558
596
  return sharedPool
559
597
  }
560
598
 
599
+ /**
600
+ * Ejecuta un lote de tool calls, aplicando los hooks alrededor.
601
+ *
602
+ * Los hooks se resuelven acá y no dentro del despacho porque hay dos caminos
603
+ * —hilo principal y pool de workers— con puntos de retorno distintos.
604
+ * Engancharlos abajo dejaría la mitad de las llamadas sin revisar, que en un
605
+ * hook de política es peor que no tenerlo.
606
+ *
607
+ * Una tool bloqueada **no se ejecuta**: se saca del lote y su lugar se rellena
608
+ * con el motivo, para que el modelo sepa por qué no se hizo en vez de
609
+ * reintentar a ciegas.
610
+ */
561
611
  export async function executeToolBatch(options: ExecuteToolBatchOptions): Promise<ToolBatchResult[]> {
612
+ const bloqueadas = new Map<string, string>()
613
+
614
+ if (hasHooks("beforeToolCall")) {
615
+ for (const toolCall of options.toolCalls) {
616
+ const args = typeof toolCall.function.arguments === "string"
617
+ ? (() => { try { return JSON.parse(toolCall.function.arguments) } catch { return {} } })()
618
+ : (toolCall.function.arguments ?? {})
619
+
620
+ const motivo = await runBeforeToolCall({
621
+ toolName: toolCall.function.name,
622
+ args: args as Record<string, unknown>,
623
+ agentId: options.toolConfig?.agent_id as string | undefined,
624
+ userId: options.toolConfig?.user_id as string | undefined,
625
+ threadId: options.toolConfig?.thread_id as string | undefined,
626
+ })
627
+ if (motivo) bloqueadas.set(toolCall.id, motivo)
628
+ }
629
+ }
630
+
631
+ const permitidas = options.toolCalls.filter((tc) => !bloqueadas.has(tc.id))
632
+ const ejecutadas = permitidas.length > 0
633
+ ? await executeToolBatchInner({ ...options, toolCalls: permitidas })
634
+ : []
635
+
636
+ // Se reconstruye el orden original: el modelo espera una respuesta por cada
637
+ // llamada que hizo, en el orden en que las hizo.
638
+ const porId = new Map(ejecutadas.map((r) => [r.toolCall.id, r]))
639
+ const resultados = options.toolCalls.map((toolCall) => {
640
+ const bloqueo = bloqueadas.get(toolCall.id)
641
+ if (bloqueo) {
642
+ return {
643
+ toolCall,
644
+ toolName: toolCall.function.name,
645
+ result: toolErrorResult(toolCall.function.name, bloqueo),
646
+ ok: false,
647
+ durationMs: 0,
648
+ } as ToolBatchResult
649
+ }
650
+ return porId.get(toolCall.id)!
651
+ })
652
+
653
+ if (hasHooks("afterToolCall")) {
654
+ for (const r of resultados) {
655
+ await runAfterToolCall({
656
+ toolName: r.toolName,
657
+ args: {},
658
+ result: r.result,
659
+ ok: r.ok,
660
+ durationMs: r.durationMs,
661
+ agentId: options.toolConfig?.agent_id as string | undefined,
662
+ userId: options.toolConfig?.user_id as string | undefined,
663
+ threadId: options.toolConfig?.thread_id as string | undefined,
664
+ })
665
+ }
666
+ }
667
+
668
+ return resultados
669
+ }
670
+
671
+ async function executeToolBatchInner(options: ExecuteToolBatchOptions): Promise<ToolBatchResult[]> {
562
672
  const runtimeConfig = resolveRuntimeConfig(options.workerPool)
563
673
  const hiveConfig = options.hiveConfig ?? loadConfig()
564
674
  const mainThreadToolNames = [
@@ -578,7 +688,13 @@ export async function executeToolBatch(options: ExecuteToolBatchOptions): Promis
578
688
  }))
579
689
  }
580
690
 
581
- if (!runtimeConfig.enabled || !runtimeConfig.parallelToolCalls || options.toolCalls.length <= 1) {
691
+ // A null pool means workers are disabled, unnecessary (single call), or
692
+ // unavailable in this build — all three degrade to the main thread.
693
+ const pool = runtimeConfig.enabled && runtimeConfig.parallelToolCalls && options.toolCalls.length > 1
694
+ ? getPool(runtimeConfig.maxWorkers)
695
+ : null
696
+
697
+ if (!pool) {
582
698
  const results: ToolBatchResult[] = []
583
699
  for (const toolCall of options.toolCalls) {
584
700
  const startedAt = performance.now()
@@ -605,7 +721,6 @@ export async function executeToolBatch(options: ExecuteToolBatchOptions): Promis
605
721
  return results
606
722
  }
607
723
 
608
- const pool = getPool(runtimeConfig.maxWorkers)
609
724
  const batchId = crypto.randomUUID()
610
725
  const abortHandler = () => pool.abortBatch(batchId, "Tool execution aborted")
611
726
  options.signal?.addEventListener("abort", abortHandler, { once: true })
@@ -33,22 +33,9 @@ export const memoryWriteTool: Tool = {
33
33
  required: ["title", "content"],
34
34
  },
35
35
  execute: async (params: Record<string, unknown>) => {
36
- const title = params.title as string;
37
- const content = params.content as string;
38
-
39
36
  try {
40
- const memoryCol = await col<MemoryDoc>("memory");
41
- const existing = await memoryCol.get(title);
42
- const now = Date.now();
43
- await memoryCol.put(title, {
44
- id: title,
45
- title,
46
- content,
47
- created_at: existing?.doc.created_at ?? now,
48
- updated_at: now,
49
- }, existing ? { expectedVersion: existing.version } : { expectedVersion: 0 });
50
-
51
- return { ok: true, title, message: "Memory saved." };
37
+ const entry = await writeMemory(params.title as string, params.content as string);
38
+ return { ok: true, title: entry.title, message: "Memory saved." };
52
39
  } catch (error) {
53
40
  return { ok: false, error: `Failed to save memory: ${(error as Error).message}` };
54
41
  }
@@ -69,21 +56,15 @@ export const memoryReadTool: Tool = {
69
56
  },
70
57
  execute: async (params: Record<string, unknown>) => {
71
58
  const title = params.title as string;
72
-
73
59
  try {
74
- const memoryCol = await col<MemoryDoc>("memory");
75
- const entry = await memoryCol.get(title);
76
-
77
- if (!entry) {
78
- return { ok: false, error: `Memory not found: ${title}` };
79
- }
80
-
60
+ const entry = await readMemory(title);
61
+ if (!entry) return { ok: false, error: `Memory not found: ${title}` };
81
62
  return {
82
63
  ok: true,
83
- title: entry.doc.title,
84
- content: entry.doc.content,
85
- createdAt: new Date(entry.doc.created_at).toISOString(),
86
- updatedAt: new Date(entry.doc.updated_at).toISOString(),
64
+ title: entry.title,
65
+ content: entry.content,
66
+ createdAt: new Date(entry.createdAt).toISOString(),
67
+ updatedAt: new Date(entry.updatedAt).toISOString(),
87
68
  };
88
69
  } catch (error) {
89
70
  return { ok: false, error: `Failed to read memory: ${(error as Error).message}` };
@@ -102,15 +83,11 @@ export const memoryListTool: Tool = {
102
83
  },
103
84
  execute: async () => {
104
85
  try {
105
- const memoryCol = await col<MemoryDoc>("memory");
106
- const notes = (await memoryCol.scan({}))
107
- .map(e => e.doc)
108
- .sort((a, b) => b.updated_at - a.updated_at);
109
-
86
+ const entries = await listMemories();
110
87
  return {
111
88
  ok: true,
112
- count: notes.length,
113
- entries: notes.map((n) => ({ title: n.title, createdAt: new Date(n.created_at).toISOString() })),
89
+ count: entries.length,
90
+ entries: entries.map((n) => ({ title: n.title, createdAt: new Date(n.createdAt).toISOString() })),
114
91
  };
115
92
  } catch (error) {
116
93
  return { ok: false, error: `Failed to list memories: ${(error as Error).message}` };
@@ -132,23 +109,9 @@ export const memorySearchTool: Tool = {
132
109
  },
133
110
  execute: async (params: Record<string, unknown>) => {
134
111
  const query = params.query as string;
135
- const needle = query.toLowerCase();
136
-
137
112
  try {
138
- const memoryCol = await col<MemoryDoc>("memory");
139
- const notes = (await memoryCol.scan({}))
140
- .map(e => e.doc)
141
- .filter(n => n.content.toLowerCase().includes(needle) || n.title.toLowerCase().includes(needle));
142
-
143
- return {
144
- ok: true,
145
- query,
146
- count: notes.length,
147
- results: notes.map((n) => ({
148
- title: n.title,
149
- snippet: n.content.slice(0, 200) + (n.content.length > 200 ? "..." : ""),
150
- })),
151
- };
113
+ const results = await searchMemories(query);
114
+ return { ok: true, query, count: results.length, results };
152
115
  } catch (error) {
153
116
  return { ok: false, error: `Failed to search memories: ${(error as Error).message}` };
154
117
  }
@@ -169,17 +132,9 @@ export const memoryDeleteTool: Tool = {
169
132
  },
170
133
  execute: async (params: Record<string, unknown>) => {
171
134
  const title = params.title as string;
172
-
173
135
  try {
174
- const memoryCol = await col<MemoryDoc>("memory");
175
- const existing = await memoryCol.get(title);
176
-
177
- if (!existing) {
178
- return { ok: false, error: `Memory not found: ${title}` };
179
- }
180
-
181
- await memoryCol.delete(title);
182
-
136
+ const borrada = await deleteMemory(title);
137
+ if (!borrada) return { ok: false, error: `Memory not found: ${title}` };
183
138
  return { ok: true, title, message: "Memory deleted." };
184
139
  } catch (error) {
185
140
  return { ok: false, error: `Failed to delete memory: ${(error as Error).message}` };
@@ -1202,6 +1157,9 @@ export const busReadTool: Tool = {
1202
1157
 
1203
1158
  import crypto from "crypto";
1204
1159
  import { getAvailableModelsTool } from "./get-available-models.ts";
1160
+ // Las tools de memoria son envoltorios: la implementación vive en services/memory.ts,
1161
+ // para que una UI pueda usarla sin pasar por el formato que espera el modelo.
1162
+ import { writeMemory, readMemory, listMemories, searchMemories, deleteMemory } from "../../services/memory.ts";
1205
1163
 
1206
1164
  export function createTools(): Tool[] {
1207
1165
  return [
@@ -10,6 +10,7 @@ import type { Tool } from "../types.ts";
10
10
  import { logger } from "../../utils/logger.ts";
11
11
  import { resolveInWorkspace, getWorkspace, expandPath } from "../filesystem/workspace-guard.ts";
12
12
  import * as fs from "node:fs";
13
+ import { loadConfig } from "../../config/loader.ts";
13
14
 
14
15
  const log = logger.child("cli-exec");
15
16
 
@@ -28,6 +29,53 @@ const BLOCKED_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
28
29
  { pattern: /format\s+[a-z]:/i, reason: "disk format (Windows)" },
29
30
  ];
30
31
 
32
+ /** El primer token del comando: `git status` → `git`. Es lo que se compara. */
33
+ function commandName(command: string): string {
34
+ const limpio = command.trim().replace(/^\s*(sudo|env|nohup)\s+/i, "");
35
+ return (limpio.split(/[\s;|&<>]/)[0] ?? "").split("/").pop() ?? "";
36
+ }
37
+
38
+ /**
39
+ * Aplica la política de comandos que el usuario configuró.
40
+ *
41
+ * `tools.exec.allowlist` y `.denylist` existían en el esquema de configuración y
42
+ * **no las leía nadie**: alguien podía escribir `denylist: ["curl", "rm"]`
43
+ * creyendo que restringía a sus agentes, y no restringía nada. En una opción de
44
+ * seguridad eso es peor que no tenerla, porque da confianza falsa.
45
+ *
46
+ * `BLOCKED_PATTERNS` sigue siendo incondicional: lo catastrófico se bloquea
47
+ * haya o no configuración. Esta capa es la que el usuario controla.
48
+ *
49
+ * Precedencia: si hay allowlist, sólo eso se permite (es la postura más
50
+ * restrictiva y la que alguien espera al escribirla). La denylist se aplica
51
+ * después, para poder tener una allowlist amplia con excepciones puntuales.
52
+ *
53
+ * Devuelve el motivo del rechazo, o `null` si no hay objeción.
54
+ */
55
+ function checkExecPolicy(command: string): string | null {
56
+ const exec = loadConfig().tools?.exec;
57
+ if (!exec) return null;
58
+
59
+ if (exec.enabled === false) {
60
+ return "La ejecución de comandos está deshabilitada en la configuración";
61
+ }
62
+
63
+ const nombre = commandName(command).toLowerCase();
64
+ if (!nombre) return null;
65
+
66
+ const permitidos = exec.allowlist?.map((c) => c.toLowerCase());
67
+ if (permitidos?.length && !permitidos.includes(nombre)) {
68
+ return `Comando no permitido: "${nombre}". Permitidos: ${permitidos.join(", ")}`;
69
+ }
70
+
71
+ const denegados = exec.denylist?.map((c) => c.toLowerCase());
72
+ if (denegados?.includes(nombre)) {
73
+ return `Comando denegado por la configuración: "${nombre}"`;
74
+ }
75
+
76
+ return null; // sin objeción
77
+ }
78
+
31
79
  export const cliExecTool: Tool = {
32
80
  name: "cli_exec",
33
81
  // Long-running commands need a generous runtime ceiling (the tool allows
@@ -75,6 +123,13 @@ export const cliExecTool: Tool = {
75
123
  return { ok: false, error: `Working directory not found: ${cwd}` };
76
124
  }
77
125
 
126
+ // ── Política configurable por el usuario ──────────────────────────────────
127
+ const objecion = checkExecPolicy(command);
128
+ if (objecion) {
129
+ log.warn(`bloqueado por configuración: ${command}`);
130
+ return { ok: false, error: objecion };
131
+ }
132
+
78
133
  // ── Dangerous pattern check ────────────────────────────────────────────────
79
134
  for (const { pattern, reason } of BLOCKED_PATTERNS) {
80
135
  if (pattern.test(command)) {
@@ -136,6 +136,40 @@ function translateQueryToEnglish(query: string): string {
136
136
 
137
137
  // ─── search_knowledge ────────────────────────────────────────────────────────
138
138
 
139
+ /**
140
+ * Las tools que el agente que pregunta puede realmente usar.
141
+ *
142
+ * El descubrimiento buscaba contra el índice global y devolvía cualquier
143
+ * coincidencia, sin mirar la lista blanca de quien preguntaba. La ejecución sí
144
+ * estaba protegida —`context-compiler.ts` recorta `allTools`, así que una tool
145
+ * fuera de la lista no se puede resolver ni llamar— pero el agente igual veía su
146
+ * nombre y su descripción. Filtrar acá cierra esa fuga y, de paso, deja de
147
+ * ofrecerle al modelo capacidades que no va a poder usar, que es una forma
148
+ * segura de hacerle perder un turno.
149
+ *
150
+ * Sin agente en el contexto (una llamada suelta, un test) no se filtra nada.
151
+ */
152
+ async function allowedToolNames(agentId?: string): Promise<Set<string> | null> {
153
+ if (!agentId) return null;
154
+ try {
155
+ const agents = await col<AgentDoc>("agents");
156
+ const entry = await agents.get(agentId);
157
+ if (!entry) return null;
158
+
159
+ const raw = entry.doc.tool_allowlist_json ?? entry.doc.tools_json;
160
+ if (!raw) return null; // sin lista declarada, descubrimiento abierto
161
+
162
+ const { expandToolAllowlist } = await import("../../agent/delegation-runtime.ts");
163
+ const patrones = JSON.parse(raw) as string[];
164
+ if (!Array.isArray(patrones) || patrones.length === 0) return new Set();
165
+
166
+ const { MINIMAL_TOOLS } = await import("../../agent/minimal-loadout.ts");
167
+ return new Set([...MINIMAL_TOOLS, ...expandToolAllowlist(patrones)]);
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
139
173
  export const searchKnowledgeTool: Tool = {
140
174
  name: "search_knowledge",
141
175
  description: "Busca en TODO el conocimiento de Hive: tools nativas, MCP, skills, agentes de catálogo y playbook.",
@@ -158,10 +192,11 @@ export const searchKnowledgeTool: Tool = {
158
192
  },
159
193
  required: ["query"],
160
194
  },
161
- execute: async (params: Record<string, unknown>) => {
195
+ execute: async (params: Record<string, unknown>, config?: any) => {
162
196
  const query = params.query as string;
163
197
  const type = (params.type as string) ?? "all";
164
198
  const limit = (params.limit as number) ?? 10;
199
+ const usuarioActual = (config?.configurable?.user_id as string) ?? "";
165
200
  const MIN_RESULTS_FOR_BILINGUAL = 2;
166
201
 
167
202
  // Map the tool's `type` param onto capability types
@@ -187,6 +222,8 @@ export const searchKnowledgeTool: Tool = {
187
222
 
188
223
  const result: any = { query, type, tools: [], skills: [], playbook: [], toolsmcp: [], agents: [] };
189
224
 
225
+ const permitidas = await allowedToolNames(config?.configurable?.agent_id);
226
+
190
227
  // ─── Hydration from HiveDB collections (index stores only ids + search text) ──
191
228
 
192
229
  const coreCatalog = new Map(CORE_TOOL_CATALOG.map(t => [t.name, t]));
@@ -198,6 +235,10 @@ export const searchKnowledgeTool: Tool = {
198
235
  const agentsCol = await col<AgentDoc>("agents");
199
236
 
200
237
  async function hydrateTool(hit: CapabilityHit): Promise<any | null> {
238
+ // Ofrecerle al modelo una tool que no puede ejecutar es hacerle perder
239
+ // un turno, además de contarle qué existe fuera de su alcance.
240
+ if (permitidas && !permitidas.has(hit.rawId)) return null;
241
+
201
242
  const entry = await toolsCol.get(hit.rawId);
202
243
  if (entry) {
203
244
  const row = entry.doc;
@@ -232,6 +273,10 @@ export const searchKnowledgeTool: Tool = {
232
273
  const entry = await playbookCol.get(hit.rawId);
233
274
  const p = entry?.doc;
234
275
  if (!p || !p.active) return null;
276
+ // Mismo alcance que la inyección en el prompt: lo global más lo propio.
277
+ // Sin esto, el agente puede buscar en el playbook y leerle a un usuario
278
+ // lo que aprendió de otro, por la puerta de al lado.
279
+ if (p.user_id !== "" && p.user_id !== usuarioActual) return null;
235
280
  return {
236
281
  id: p.id, rule: p.rule, category: p.category,
237
282
  applicable_to: p.applicable_to ? JSON.parse(p.applicable_to) : null,
@@ -353,7 +398,10 @@ export const notifyTool: Tool = {
353
398
 
354
399
  log.info(`[notify] Sending to ${channel}/${userId}: ${message.substring(0, 80)}`);
355
400
 
356
- const result = await sendToUserChannel(channel, userId, message)
401
+ // El aviso tiene que volver al hilo del que salió: sin el threadId,
402
+ // `notifyChannel` no sabe a qué conversación del canal responder.
403
+ const threadId = config?.configurable?.thread_id as string | undefined;
404
+ const result = await sendToUserChannel(channel, userId, message, { threadId })
357
405
  if (!result.ok) throw new Error(`Channel send failed: ${result.error}`)
358
406
  return result
359
407
  },
@@ -12,7 +12,7 @@ import type { Tool } from "../types.ts";
12
12
  import { col, toIndexable } from "../../storage/hive.ts";
13
13
  import type { UserDoc, UserIdentityDoc, ChannelDoc, CronJobDoc, TaskRunDoc } from "../../storage/collections.ts";
14
14
  import { logger } from "../../utils/logger.ts";
15
- import { Cron } from "croner";
15
+ import { Cron } from "../../scheduler/cron/index.ts";
16
16
 
17
17
  const log = logger.child("CronTools");
18
18
 
@@ -114,9 +114,9 @@ export const cronCreateTool: Tool = {
114
114
  tool_name: { type: "string", description: "Specific tool to execute (optional)" },
115
115
  max_runs: { type: "number", description: "Maximum executions (optional, null = unlimited)" },
116
116
  channel: { type: "string", description: "Notification channel (system, telegram, discord, whatsapp, cli)" },
117
- start_at: { type: "string", description: "ISO 8601 datetime: start of execution window (Croner startAt). Optional." },
118
- stop_at: { type: "string", description: "ISO 8601 datetime: end of execution window (Croner stopAt). Optional." },
119
- dom_and_dow: { type: "boolean", description: "If true, both day-of-month AND day-of-week must match (Croner domAndDow). Default: false (OR logic)" },
117
+ start_at: { type: "string", description: "ISO 8601 datetime: start of execution window. Optional." },
118
+ stop_at: { type: "string", description: "ISO 8601 datetime: end of execution window. Optional." },
119
+ dom_and_dow: { type: "boolean", description: "If true, both day-of-month AND day-of-week must match. Default: false (OR logic)" },
120
120
  },
121
121
  required: ["name", "task", "task_type"],
122
122
  },