@johpaz/hive-sdk 0.0.17 → 0.1.3

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 (63) hide show
  1. package/README.md +83 -203
  2. package/bun.lock +833 -0
  3. package/bunfig.toml +7 -0
  4. package/docs/API-TOOLS-SKILLS-CHANNELS.md +60 -0
  5. package/docs/HIVE-HARNESS.md +113 -0
  6. package/package.json +36 -2
  7. package/packages/cli/package.json +1 -1
  8. package/packages/core/package.json +13 -2
  9. package/packages/core/src/ace/Tracer.ts +1 -1
  10. package/packages/core/src/agent/AgentRunner.ts +12 -0
  11. package/packages/core/src/agent/ContextCompiler.ts +4 -4
  12. package/packages/core/src/agent/ConversationStore.ts +30 -20
  13. package/packages/core/src/agent/selectors/PlaybookSelector.ts +50 -76
  14. package/packages/core/src/agent/selectors/SkillSelector.ts +106 -262
  15. package/packages/core/src/agent/selectors/ToolSelector.ts +54 -89
  16. package/packages/core/src/api/createAgent.ts +10 -0
  17. package/packages/core/src/auth/auth.ts +36 -23
  18. package/packages/core/src/config/loader.ts +2 -2
  19. package/packages/core/src/harness/boot-id.ts +20 -0
  20. package/packages/core/src/harness/collections.ts +98 -0
  21. package/packages/core/src/harness/db-helpers.ts +87 -0
  22. package/packages/core/src/harness/durable-queue.ts +337 -0
  23. package/packages/core/src/harness/goal-verifier.ts +141 -0
  24. package/packages/core/src/harness/harness.test.ts +236 -0
  25. package/packages/core/src/harness/index.ts +34 -0
  26. package/packages/core/src/harness/job-store.ts +399 -0
  27. package/packages/core/src/harness/proof-packet.ts +69 -0
  28. package/packages/core/src/harness/reconcile.ts +149 -0
  29. package/packages/core/src/harness/run-epoch.ts +32 -0
  30. package/packages/core/src/harness/run-store.ts +334 -0
  31. package/packages/core/src/index.ts +19 -0
  32. package/packages/core/src/memory/Scratchpad.test.ts +23 -21
  33. package/packages/core/src/memory/Scratchpad.ts +41 -24
  34. package/packages/core/src/skills/bundled-data.generated.ts +50 -0
  35. package/packages/core/src/skills/skills.test.ts +21 -0
  36. package/packages/core/src/storage/HiveDBStorage.ts +64 -0
  37. package/packages/core/src/storage/SQLiteStorage.ts +7 -0
  38. package/packages/core/src/storage/hiveSeed.ts +308 -0
  39. package/packages/core/src/storage/hiveStorage.test.ts +38 -0
  40. package/packages/core/src/storage/index.ts +10 -0
  41. package/packages/core/src/storage/seed.ts +5 -1
  42. package/packages/core/src/storage/usage.ts +106 -167
  43. package/packages/core/src/tool-runtime/tool-runtime.test.ts +11 -3
  44. package/packages/core/src/tools/agents/get-available-models.ts +52 -56
  45. package/packages/core/src/tools/agents/index.ts +77 -60
  46. package/packages/core/src/tools/core/index.ts +106 -291
  47. package/packages/core/src/tools/index.ts +1 -0
  48. package/packages/core/src/tools/meeting/index.ts +83 -93
  49. package/packages/core/src/tools/web/api-request.test.ts +170 -0
  50. package/packages/core/src/tools/web/api-request.ts +239 -0
  51. package/packages/core/src/tools/web/browser-click.ts +2 -2
  52. package/packages/core/src/tools/web/browser-extract.ts +22 -6
  53. package/packages/core/src/tools/web/browser-navigate.ts +34 -18
  54. package/packages/core/src/tools/web/browser-screenshot.ts +40 -8
  55. package/packages/core/src/tools/web/browser-script.ts +2 -2
  56. package/packages/core/src/tools/web/browser-service.test.ts +83 -0
  57. package/packages/core/src/tools/web/browser-service.ts +290 -341
  58. package/packages/core/src/tools/web/browser-type.ts +2 -2
  59. package/packages/core/src/tools/web/browser-wait.ts +2 -2
  60. package/packages/core/src/tools/web/index.ts +3 -0
  61. package/packages/core/src/utils/toon.ts +4 -4
  62. package/CHANGELOG.md +0 -72
  63. package/docs/README.md +0 -161
@@ -2,20 +2,33 @@
2
2
  * Meeting Tools - 4 tools para transcripción de reuniones
3
3
  *
4
4
  * @category meeting
5
- *
6
- * - meeting_start — Inicia una sesión de transcripción
7
- * - meeting_add_segment — Transcribe un chunk de audio y lo agrega a la sesión
8
- * - meeting_stop — Detiene la sesión de transcripción
9
- * - meeting_report — Obtiene el transcript completo para que el agente genere el reporte
10
5
  */
11
6
 
12
7
  import type { Tool } from "../types.ts";
13
- import { getDb } from "../../storage/SQLiteStorage.ts";
8
+ import { getHiveDB } from "../../storage/HiveDBStorage.ts";
14
9
  import { voiceService, type AudioInput } from "../../voice/index.ts";
15
10
  import { logger } from "../../utils/logger.ts";
16
11
 
17
12
  const log = logger.child("meeting");
18
13
 
14
+ interface MeetingSession {
15
+ id: string;
16
+ title: string;
17
+ status: string;
18
+ sttModel: string;
19
+ startedAt: number;
20
+ stoppedAt?: number;
21
+ }
22
+
23
+ interface MeetingSegment {
24
+ sessionId: string;
25
+ seq: number;
26
+ speaker?: string;
27
+ text: string;
28
+ durationMs?: number;
29
+ createdAt: number;
30
+ }
31
+
19
32
  // ─── meeting_start ───────────────────────────────────────────────────────────
20
33
 
21
34
  export const meetingStartTool: Tool = {
@@ -43,30 +56,27 @@ export const meetingStartTool: Tool = {
43
56
  (params.stt_model as string) || "whisper-large-v3-turbo";
44
57
 
45
58
  try {
46
- const db = getDb();
47
- const result = db
48
- .query(
49
- `INSERT INTO meeting_sessions (title, stt_model)
50
- VALUES (?, ?)
51
- RETURNING id, title, status, stt_model, started_at`
52
- )
53
- .get(title, sttModel) as {
54
- id: string;
55
- title: string;
56
- status: string;
57
- stt_model: string;
58
- started_at: number;
59
+ const db = await getHiveDB();
60
+ const sessionsCol = db.collection<MeetingSession>("meeting_sessions");
61
+ const id = crypto.randomUUID();
62
+ const session: MeetingSession = {
63
+ id,
64
+ title,
65
+ status: "active",
66
+ sttModel,
67
+ startedAt: Math.floor(Date.now() / 1000),
59
68
  };
69
+ await sessionsCol.put(id, session);
60
70
 
61
- log.info(`Meeting session started: ${result.id} — "${title}"`);
71
+ log.info(`Meeting session started: ${id} — "${title}"`);
62
72
 
63
73
  return {
64
74
  ok: true,
65
- session_id: result.id,
66
- title: result.title,
67
- status: result.status,
68
- stt_model: result.stt_model,
69
- message: `✅ Sesión de reunión iniciada. ID: ${result.id}\nTítulo: "${title}"\nModelo STT: ${sttModel}`,
75
+ session_id: id,
76
+ title,
77
+ status: session.status,
78
+ stt_model: sttModel,
79
+ message: `✅ Sesión de reunión iniciada. ID: ${id}\nTítulo: "${title}"\nModelo STT: ${sttModel}`,
70
80
  };
71
81
  } catch (error) {
72
82
  log.error(`meeting_start error: ${(error as Error).message}`);
@@ -106,21 +116,19 @@ export const meetingAddSegmentTool: Tool = {
106
116
  execute: async (params) => {
107
117
  const sessionId = params.session_id as string;
108
118
  const audioBase64 = params.audio_base64 as string;
109
- const speaker = (params.speaker as string) || null;
119
+ const speaker = (params.speaker as string) || undefined;
110
120
  const mimeType = (params.mime_type as string) || "audio/webm";
111
121
 
112
122
  try {
113
- const db = getDb();
123
+ const db = await getHiveDB();
124
+ const sessionsCol = db.collection<MeetingSession>("meeting_sessions");
125
+ const segmentsCol = db.collection<MeetingSegment>("meeting_segments");
114
126
 
115
- const session = db
116
- .query(
117
- `SELECT id, stt_model, status FROM meeting_sessions WHERE id = ?`
118
- )
119
- .get(sessionId) as { id: string; stt_model: string; status: string } | undefined;
120
-
121
- if (!session) {
127
+ const sessionEntry = await sessionsCol.get(sessionId);
128
+ if (!sessionEntry) {
122
129
  return { ok: false, error: `Sesión ${sessionId} no encontrada.` };
123
130
  }
131
+ const session = sessionEntry.doc;
124
132
  if (session.status !== "active") {
125
133
  return {
126
134
  ok: false,
@@ -136,7 +144,7 @@ export const meetingAddSegmentTool: Tool = {
136
144
 
137
145
  let transcription: string;
138
146
  try {
139
- transcription = await voiceService.transcribe(audioInput, session.stt_model);
147
+ transcription = await voiceService.transcribe(audioInput, session.sttModel);
140
148
  } catch (transcribeError) {
141
149
  return {
142
150
  ok: false,
@@ -144,18 +152,19 @@ export const meetingAddSegmentTool: Tool = {
144
152
  };
145
153
  }
146
154
 
147
- const seqResult = db
148
- .query(
149
- `SELECT COALESCE(MAX(seq) + 1, 0) as next_seq FROM meeting_segments WHERE session_id = ?`
150
- )
151
- .get(sessionId) as { next_seq: number };
152
-
153
- const seq = seqResult.next_seq;
155
+ const entries = await segmentsCol.scan();
156
+ const seq = entries
157
+ .filter(e => e.doc.sessionId === sessionId)
158
+ .reduce((max, e) => Math.max(max, e.doc.seq), -1) + 1;
154
159
 
155
- db.query(
156
- `INSERT INTO meeting_segments (session_id, seq, speaker, text, duration_ms)
157
- VALUES (?, ?, ?, ?, NULL)`
158
- ).run(sessionId, seq, speaker, transcription);
160
+ const segmentId = `${sessionId}:${seq}`;
161
+ await segmentsCol.put(segmentId, {
162
+ sessionId,
163
+ seq,
164
+ speaker,
165
+ text: transcription,
166
+ createdAt: Math.floor(Date.now() / 1000),
167
+ });
159
168
 
160
169
  log.info(`Segment ${seq} added to session ${sessionId}: "${transcription.substring(0, 60)}..."`);
161
170
 
@@ -196,15 +205,15 @@ export const meetingStopTool: Tool = {
196
205
  const sessionId = params.session_id as string;
197
206
 
198
207
  try {
199
- const db = getDb();
200
-
201
- const session = db
202
- .query(`SELECT id, title, status FROM meeting_sessions WHERE id = ?`)
203
- .get(sessionId) as { id: string; title: string; status: string } | undefined;
208
+ const db = await getHiveDB();
209
+ const sessionsCol = db.collection<MeetingSession>("meeting_sessions");
210
+ const segmentsCol = db.collection<MeetingSegment>("meeting_segments");
204
211
 
205
- if (!session) {
212
+ const sessionEntry = await sessionsCol.get(sessionId);
213
+ if (!sessionEntry) {
206
214
  return { ok: false, error: `Sesión ${sessionId} no encontrada.` };
207
215
  }
216
+ const session = sessionEntry.doc;
208
217
  if (session.status === "stopped" || session.status === "report_ready") {
209
218
  return {
210
219
  ok: true,
@@ -213,22 +222,20 @@ export const meetingStopTool: Tool = {
213
222
  };
214
223
  }
215
224
 
216
- db.query(
217
- `UPDATE meeting_sessions SET status = 'stopped', stopped_at = unixepoch() WHERE id = ?`
218
- ).run(sessionId);
225
+ const stoppedAt = Math.floor(Date.now() / 1000);
226
+ await sessionsCol.put(sessionId, { ...session, status: "stopped", stoppedAt });
219
227
 
220
- const countResult = db
221
- .query(`SELECT COUNT(*) as count FROM meeting_segments WHERE session_id = ?`)
222
- .get(sessionId) as { count: number };
228
+ const entries = await segmentsCol.scan();
229
+ const count = entries.filter(e => e.doc.sessionId === sessionId).length;
223
230
 
224
- log.info(`Meeting session stopped: ${sessionId} — ${countResult.count} segments`);
231
+ log.info(`Meeting session stopped: ${sessionId} — ${count} segments`);
225
232
 
226
233
  return {
227
234
  ok: true,
228
235
  session_id: sessionId,
229
236
  title: session.title,
230
- segment_count: countResult.count,
231
- message: `⏹️ Sesión "${session.title}" detenida.\n${countResult.count} segmentos transcritos.\n\nPuedes pedir el reporte con: "Genera el reporte de la reunión ${sessionId}"`,
237
+ segment_count: count,
238
+ message: `⏹️ Sesión "${session.title}" detenida.\n${count} segmentos transcritos.\n\nPuedes pedir el reporte con: "Genera el reporte de la reunión ${sessionId}"`,
232
239
  };
233
240
  } catch (error) {
234
241
  log.error(`meeting_stop error: ${(error as Error).message}`);
@@ -257,38 +264,21 @@ export const meetingReportTool: Tool = {
257
264
  const sessionId = params.session_id as string;
258
265
 
259
266
  try {
260
- const db = getDb();
261
-
262
- const session = db
263
- .query(
264
- `SELECT id, title, status, stt_model, started_at, stopped_at
265
- FROM meeting_sessions WHERE id = ?`
266
- )
267
- .get(sessionId) as {
268
- id: string;
269
- title: string;
270
- status: string;
271
- stt_model: string;
272
- started_at: number;
273
- stopped_at: number | null;
274
- } | undefined;
275
-
276
- if (!session) {
267
+ const db = await getHiveDB();
268
+ const sessionsCol = db.collection<MeetingSession>("meeting_sessions");
269
+ const segmentsCol = db.collection<MeetingSegment>("meeting_segments");
270
+
271
+ const sessionEntry = await sessionsCol.get(sessionId);
272
+ if (!sessionEntry) {
277
273
  return { ok: false, error: `Sesión ${sessionId} no encontrada.` };
278
274
  }
275
+ const session = sessionEntry.doc;
279
276
 
280
- const segments = db
281
- .query(
282
- `SELECT seq, speaker, text, created_at
283
- FROM meeting_segments WHERE session_id = ?
284
- ORDER BY seq ASC`
285
- )
286
- .all(sessionId) as {
287
- seq: number;
288
- speaker: string | null;
289
- text: string;
290
- created_at: number;
291
- }[];
277
+ const entries = await segmentsCol.scan();
278
+ const segments = entries
279
+ .filter(e => e.doc.sessionId === sessionId)
280
+ .map(e => e.doc)
281
+ .sort((a, b) => a.seq - b.seq);
292
282
 
293
283
  if (segments.length === 0) {
294
284
  return {
@@ -301,9 +291,9 @@ export const meetingReportTool: Tool = {
301
291
  .map((s) => (s.speaker ? `[${s.speaker}]: ${s.text}` : s.text))
302
292
  .join("\n");
303
293
 
304
- const durationSec = session.stopped_at
305
- ? session.stopped_at - session.started_at
306
- : Math.floor(Date.now() / 1000) - session.started_at;
294
+ const durationSec = session.stoppedAt
295
+ ? session.stoppedAt - session.startedAt
296
+ : Math.floor(Date.now() / 1000) - session.startedAt;
307
297
  const durationMin = Math.floor(durationSec / 60);
308
298
  const durationSecRem = durationSec % 60;
309
299
 
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { apiRequestTool } from "./api-request.ts";
3
+
4
+ describe("apiRequestTool", () => {
5
+ let originalFetch: typeof fetch;
6
+
7
+ beforeEach(() => {
8
+ originalFetch = globalThis.fetch;
9
+ });
10
+
11
+ afterEach(() => {
12
+ globalThis.fetch = originalFetch;
13
+ });
14
+
15
+ function mockFetch(response: Response) {
16
+ globalThis.fetch = Object.assign(async () => response, { preconnect: async () => undefined }) as typeof fetch;
17
+ }
18
+
19
+ function mockResponse(
20
+ body: BodyInit,
21
+ init: ResponseInit & { headers?: Record<string, string> } = {}
22
+ ): Response {
23
+ return new Response(body, {
24
+ status: init.status ?? 200,
25
+ statusText: init.statusText ?? "OK",
26
+ headers: init.headers ?? {},
27
+ });
28
+ }
29
+
30
+ it("executes a simple GET request", async () => {
31
+ let capturedUrl = "";
32
+ let capturedInit: RequestInit = {};
33
+ globalThis.fetch = Object.assign(async (url, init) => {
34
+ capturedUrl = url.toString();
35
+ capturedInit = init ?? {};
36
+ return mockResponse(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json" } });
37
+ }, { preconnect: async () => undefined }) as typeof fetch;
38
+
39
+ const result = await apiRequestTool.execute({ url: "https://api.example.com/data" });
40
+
41
+ expect(capturedUrl).toBe("https://api.example.com/data");
42
+ expect(capturedInit.method).toBe("GET");
43
+ expect(result).toMatchObject({
44
+ ok: true,
45
+ status: 200,
46
+ data: { ok: true },
47
+ });
48
+ });
49
+
50
+ it("sends POST with JSON body", async () => {
51
+ let capturedInit: RequestInit = {};
52
+ globalThis.fetch = Object.assign(async (_url, init) => {
53
+ capturedInit = init ?? {};
54
+ return mockResponse(JSON.stringify({ id: 1 }), { headers: { "content-type": "application/json" } });
55
+ }, { preconnect: async () => undefined }) as typeof fetch;
56
+
57
+ const result = await apiRequestTool.execute({
58
+ url: "https://api.example.com/items",
59
+ method: "POST",
60
+ body: { name: "test" },
61
+ });
62
+
63
+ expect(capturedInit.method).toBe("POST");
64
+ expect(capturedInit.headers).toMatchObject({ "content-type": "application/json" });
65
+ expect(capturedInit.body).toBe(JSON.stringify({ name: "test" }));
66
+ expect(result).toMatchObject({ ok: true, data: { id: 1 } });
67
+ });
68
+
69
+ it("applies bearer auth", async () => {
70
+ let capturedInit: RequestInit = {};
71
+ globalThis.fetch = Object.assign(async (_url, init) => {
72
+ capturedInit = init ?? {};
73
+ return mockResponse("{}");
74
+ }, { preconnect: async () => undefined }) as typeof fetch;
75
+
76
+ await apiRequestTool.execute({
77
+ url: "https://api.example.com/private",
78
+ auth: { type: "bearer", token: "secret-token" },
79
+ });
80
+
81
+ expect(new Headers(capturedInit.headers).get("Authorization")).toBe("Bearer secret-token");
82
+ });
83
+
84
+ it("applies basic auth", async () => {
85
+ let capturedInit: RequestInit = {};
86
+ globalThis.fetch = Object.assign(async (_url, init) => {
87
+ capturedInit = init ?? {};
88
+ return mockResponse("{}");
89
+ }, { preconnect: async () => undefined }) as typeof fetch;
90
+
91
+ await apiRequestTool.execute({
92
+ url: "https://api.example.com/private",
93
+ auth: { type: "basic", username: "alice", password: "secret" },
94
+ });
95
+
96
+ expect(new Headers(capturedInit.headers).get("Authorization")).toBe(`Basic ${btoa("alice:secret")}`);
97
+ });
98
+
99
+ it("applies api_key in header", async () => {
100
+ let capturedInit: RequestInit = {};
101
+ globalThis.fetch = Object.assign(async (_url, init) => {
102
+ capturedInit = init ?? {};
103
+ return mockResponse("{}");
104
+ }, { preconnect: async () => undefined }) as typeof fetch;
105
+
106
+ await apiRequestTool.execute({
107
+ url: "https://api.example.com/private",
108
+ auth: { type: "api_key", in: "header", name: "X-API-Key", value: "abc123" },
109
+ });
110
+
111
+ expect(new Headers(capturedInit.headers).get("X-API-Key")).toBe("abc123");
112
+ });
113
+
114
+ it("applies api_key in query", async () => {
115
+ let capturedUrl = "";
116
+ globalThis.fetch = Object.assign(async (url, _init) => {
117
+ capturedUrl = url.toString();
118
+ return mockResponse("{}");
119
+ }, { preconnect: async () => undefined }) as typeof fetch;
120
+
121
+ await apiRequestTool.execute({
122
+ url: "https://api.example.com/private",
123
+ auth: { type: "api_key", in: "query", name: "api_key", value: "abc123" },
124
+ });
125
+
126
+ expect(capturedUrl).toBe("https://api.example.com/private?api_key=abc123");
127
+ });
128
+
129
+ it("returns error for non-ok HTTP response", async () => {
130
+ globalThis.fetch = Object.assign(async () => mockResponse("Not found", { status: 404, statusText: "Not Found" }), { preconnect: async () => undefined }) as typeof fetch;
131
+
132
+ const result = await apiRequestTool.execute({ url: "https://api.example.com/missing" });
133
+
134
+ expect(result).toMatchObject({
135
+ ok: false,
136
+ status: 404,
137
+ error: "HTTP 404: Not Found",
138
+ });
139
+ });
140
+
141
+ it("rejects non-http URLs", async () => {
142
+ const result = await apiRequestTool.execute({ url: "file:///etc/passwd" });
143
+
144
+ expect(result).toMatchObject({
145
+ ok: false,
146
+ error: "Invalid URL. Only http:// and https:// are allowed.",
147
+ });
148
+ });
149
+
150
+ it("respects timeout", async () => {
151
+ globalThis.fetch = Object.assign(async (_url, init) => {
152
+ expect(init?.signal).toBeDefined();
153
+ return mockResponse("{}");
154
+ }, { preconnect: async () => undefined }) as typeof fetch;
155
+
156
+ const result = await apiRequestTool.execute({ url: "https://api.example.com/data", timeoutMs: 5000 });
157
+ expect(result).toMatchObject({ ok: true });
158
+ });
159
+
160
+ it("returns text for non-json responses", async () => {
161
+ globalThis.fetch = Object.assign(async () => mockResponse("hello world", { headers: { "content-type": "text/plain" } }), { preconnect: async () => undefined }) as typeof fetch;
162
+
163
+ const result = await apiRequestTool.execute({ url: "https://api.example.com/text" });
164
+
165
+ expect(result).toMatchObject({
166
+ ok: true,
167
+ data: "hello world",
168
+ });
169
+ });
170
+ });
@@ -0,0 +1,239 @@
1
+ /**
2
+ * api_request - Make generic HTTP requests to connect REST APIs
3
+ *
4
+ * @category web
5
+ * @seedId api_request
6
+ * @spanish conectar api, peticion http, llamada api, rest api, endpoint
7
+ */
8
+
9
+ import type { Tool } from "../types.ts";
10
+ import { logger } from "../../utils/logger.ts";
11
+
12
+ const log = logger.child("api-request");
13
+
14
+ const DEFAULT_TIMEOUT_MS = 30_000;
15
+ const MAX_RESPONSE_CHARS = 100_000;
16
+
17
+ export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
18
+ export type ResponseFormat = "auto" | "json" | "text" | "binary";
19
+
20
+ export interface ApiAuthBearer {
21
+ type: "bearer";
22
+ token: string;
23
+ }
24
+
25
+ export interface ApiAuthBasic {
26
+ type: "basic";
27
+ username: string;
28
+ password: string;
29
+ }
30
+
31
+ export interface ApiAuthApiKey {
32
+ type: "api_key";
33
+ in: "header" | "query";
34
+ name: string;
35
+ value: string;
36
+ }
37
+
38
+ export type ApiAuth = ApiAuthBearer | ApiAuthBasic | ApiAuthApiKey;
39
+
40
+ function applyAuth(
41
+ url: string,
42
+ init: RequestInit,
43
+ auth?: ApiAuth
44
+ ): { url: string; init: RequestInit } {
45
+ if (!auth) return { url, init };
46
+
47
+ const headers = new Headers(init.headers);
48
+
49
+ switch (auth.type) {
50
+ case "bearer":
51
+ headers.set("Authorization", `Bearer ${auth.token}`);
52
+ break;
53
+ case "basic": {
54
+ const credentials = btoa(`${auth.username}:${auth.password}`);
55
+ headers.set("Authorization", `Basic ${credentials}`);
56
+ break;
57
+ }
58
+ case "api_key":
59
+ if (auth.in === "query") {
60
+ const parsed = new URL(url);
61
+ parsed.searchParams.set(auth.name, auth.value);
62
+ url = parsed.toString();
63
+ } else {
64
+ headers.set(auth.name, auth.value);
65
+ }
66
+ break;
67
+ }
68
+
69
+ return { url, init: { ...init, headers } };
70
+ }
71
+
72
+ function isValidHttpUrl(url: string): boolean {
73
+ try {
74
+ const parsed = new URL(url);
75
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ async function parseResponse(
82
+ response: Response,
83
+ format: ResponseFormat
84
+ ): Promise<{ data: unknown; contentType: string }> {
85
+ const contentType = response.headers.get("content-type") || "";
86
+
87
+ if (format === "json" || (format === "auto" && contentType.includes("application/json"))) {
88
+ const json = await response.json();
89
+ return { data: json, contentType };
90
+ }
91
+
92
+ if (format === "text" || (format === "auto" && contentType.includes("text/"))) {
93
+ const text = await response.text();
94
+ return { data: text.slice(0, MAX_RESPONSE_CHARS), contentType };
95
+ }
96
+
97
+ if (format === "binary") {
98
+ const arrayBuffer = await response.arrayBuffer();
99
+ const base64 = Buffer.from(arrayBuffer).toString("base64");
100
+ return { data: base64, contentType };
101
+ }
102
+
103
+ // Fallback: try text, then JSON
104
+ const text = await response.text();
105
+ if (text.trim().startsWith("{") || text.trim().startsWith("[")) {
106
+ try {
107
+ return { data: JSON.parse(text), contentType };
108
+ } catch {
109
+ /* fallthrough */
110
+ }
111
+ }
112
+ return { data: text.slice(0, MAX_RESPONSE_CHARS), contentType };
113
+ }
114
+
115
+ export const apiRequestTool: Tool = {
116
+ name: "api_request",
117
+ description:
118
+ "Connect to REST APIs: make HTTP requests with methods, headers, body and authentication. Spanish: conectar api, peticion http, llamada api, rest api, endpoint, bearer, api key",
119
+ parameters: {
120
+ type: "object",
121
+ properties: {
122
+ url: {
123
+ type: "string",
124
+ description: "The API endpoint URL (http:// or https://)",
125
+ },
126
+ method: {
127
+ type: "string",
128
+ enum: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
129
+ description: "HTTP method (default: GET)",
130
+ },
131
+ headers: {
132
+ type: "object",
133
+ additionalProperties: { type: "string" },
134
+ description: "Optional HTTP headers as key-value pairs",
135
+ },
136
+ body: {
137
+ type: "string",
138
+ description: "Request body. Objects should be passed as JSON strings; strings are sent as-is",
139
+ },
140
+ auth: {
141
+ type: "object",
142
+ description: "Optional authentication configuration",
143
+ properties: {
144
+ type: {
145
+ type: "string",
146
+ enum: ["bearer", "basic", "api_key"],
147
+ },
148
+ token: { type: "string" },
149
+ username: { type: "string" },
150
+ password: { type: "string" },
151
+ in: { type: "string", enum: ["header", "query"] },
152
+ name: { type: "string" },
153
+ value: { type: "string" },
154
+ },
155
+ required: ["type"],
156
+ },
157
+ timeoutMs: {
158
+ type: "number",
159
+ description: "Request timeout in milliseconds (default: 30000)",
160
+ },
161
+ responseFormat: {
162
+ type: "string",
163
+ enum: ["auto", "json", "text", "binary"],
164
+ description: "How to parse the response (default: auto)",
165
+ },
166
+ },
167
+ required: ["url"],
168
+ },
169
+ execute: async (params: Record<string, unknown>) => {
170
+ const url = params.url as string;
171
+ const method = (params.method as HttpMethod) ?? "GET";
172
+ const headers = (params.headers as Record<string, string>) ?? {};
173
+ const bodyParam = params.body;
174
+ const auth = params.auth as ApiAuth | undefined;
175
+ const timeoutMs = (params.timeoutMs as number) ?? DEFAULT_TIMEOUT_MS;
176
+ const responseFormat = (params.responseFormat as ResponseFormat) ?? "auto";
177
+
178
+ if (!isValidHttpUrl(url)) {
179
+ return { ok: false, error: "Invalid URL. Only http:// and https:// are allowed." };
180
+ }
181
+
182
+ log.info(`API request: ${method} ${url}`);
183
+
184
+ let body: BodyInit | undefined;
185
+ const finalHeaders: Record<string, string> = { ...headers };
186
+
187
+ if (bodyParam !== undefined) {
188
+ if (typeof bodyParam === "string") {
189
+ body = bodyParam;
190
+ } else {
191
+ body = JSON.stringify(bodyParam);
192
+ if (!finalHeaders["content-type"] && !finalHeaders["Content-Type"]) {
193
+ finalHeaders["content-type"] = "application/json";
194
+ }
195
+ }
196
+ }
197
+
198
+ let init: RequestInit = {
199
+ method,
200
+ headers: finalHeaders,
201
+ body,
202
+ signal: AbortSignal.timeout(timeoutMs),
203
+ };
204
+
205
+ const final = applyAuth(url, init, auth);
206
+
207
+ try {
208
+ const response = await fetch(final.url, final.init);
209
+ const { data, contentType } = await parseResponse(response, responseFormat);
210
+
211
+ const responseHeaders: Record<string, string> = {};
212
+ response.headers.forEach((value, key) => {
213
+ responseHeaders[key] = value;
214
+ });
215
+
216
+ const result = {
217
+ ok: response.ok,
218
+ status: response.status,
219
+ statusText: response.statusText,
220
+ url: final.url,
221
+ contentType,
222
+ headers: responseHeaders,
223
+ data,
224
+ };
225
+
226
+ if (!response.ok) {
227
+ log.warn(`API request returned ${response.status} for ${final.url}`);
228
+ return { ok: false, error: `HTTP ${response.status}: ${response.statusText}`, ...result };
229
+ }
230
+
231
+ log.info(`API request successful: ${response.status} ${final.url}`);
232
+ return result;
233
+ } catch (error) {
234
+ const message = (error as Error).message;
235
+ log.error(`API request failed: ${message}`);
236
+ return { ok: false, error: `API request failed: ${message}` };
237
+ }
238
+ },
239
+ };