arkna-sdk 0.1.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.
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +562 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +257 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +948 -0
- package/dist/client.js.map +1 -0
- package/dist/enforcement.d.ts +67 -0
- package/dist/enforcement.d.ts.map +1 -0
- package/dist/enforcement.js +303 -0
- package/dist/enforcement.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +74 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +333 -0
- package/dist/init.js.map +1 -0
- package/dist/instrumentations/langchain.d.ts +79 -0
- package/dist/instrumentations/langchain.d.ts.map +1 -0
- package/dist/instrumentations/langchain.js +398 -0
- package/dist/instrumentations/langchain.js.map +1 -0
- package/dist/instrumentations/vercel-ai.d.ts +40 -0
- package/dist/instrumentations/vercel-ai.d.ts.map +1 -0
- package/dist/instrumentations/vercel-ai.js +212 -0
- package/dist/instrumentations/vercel-ai.js.map +1 -0
- package/dist/license.d.ts +89 -0
- package/dist/license.d.ts.map +1 -0
- package/dist/license.js +198 -0
- package/dist/license.js.map +1 -0
- package/dist/types.d.ts +402 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +41 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ARKNA SDK Client
|
|
4
|
+
*
|
|
5
|
+
* Core client for the ARKNA AI Trust Gateway.
|
|
6
|
+
* Zero runtime dependencies — uses Node 18+ built-in fetch.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.ArknaClient = void 0;
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const crypto = __importStar(require("crypto"));
|
|
46
|
+
const enforcement_1 = require("./enforcement");
|
|
47
|
+
const license_1 = require("./license");
|
|
48
|
+
/** Default request timeout (30s) */
|
|
49
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
50
|
+
/** Tools cache TTL (5 minutes) */
|
|
51
|
+
const TOOLS_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
52
|
+
/**
|
|
53
|
+
* Read .arkna/.env from current or parent directories.
|
|
54
|
+
* Returns parsed key-value pairs, or empty object if not found.
|
|
55
|
+
*/
|
|
56
|
+
function readArknaEnv(startDir) {
|
|
57
|
+
let dir = startDir || process.cwd();
|
|
58
|
+
const root = path.parse(dir).root;
|
|
59
|
+
while (dir !== root) {
|
|
60
|
+
const envPath = path.join(dir, '.arkna', '.env');
|
|
61
|
+
try {
|
|
62
|
+
const content = fs.readFileSync(envPath, 'utf-8');
|
|
63
|
+
const vars = {};
|
|
64
|
+
for (const line of content.split(/\r?\n/)) {
|
|
65
|
+
const trimmed = line.trim();
|
|
66
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
67
|
+
continue;
|
|
68
|
+
const eqIdx = trimmed.indexOf('=');
|
|
69
|
+
if (eqIdx === -1)
|
|
70
|
+
continue;
|
|
71
|
+
const key = trimmed.substring(0, eqIdx).trim();
|
|
72
|
+
let value = trimmed.substring(eqIdx + 1).trim();
|
|
73
|
+
// Strip surrounding quotes
|
|
74
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
75
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
76
|
+
value = value.slice(1, -1);
|
|
77
|
+
}
|
|
78
|
+
vars[key] = value;
|
|
79
|
+
}
|
|
80
|
+
return vars;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// File not found — walk up
|
|
84
|
+
}
|
|
85
|
+
dir = path.dirname(dir);
|
|
86
|
+
}
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
/** Status codes that are safe to retry (transient server/rate-limit errors) */
|
|
90
|
+
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
91
|
+
class ArknaClient {
|
|
92
|
+
token;
|
|
93
|
+
baseUrl;
|
|
94
|
+
timeoutMs;
|
|
95
|
+
maxRetries;
|
|
96
|
+
retryDelayMs;
|
|
97
|
+
agentId;
|
|
98
|
+
// Tools cache
|
|
99
|
+
toolsCache = null;
|
|
100
|
+
toolsCacheTime = 0;
|
|
101
|
+
// Enforcement guard (mandatory when governance.json exists)
|
|
102
|
+
enforcementGuard = null;
|
|
103
|
+
// Secret for nonce-based enforcement proof (generated per session)
|
|
104
|
+
enforcementSecret = '';
|
|
105
|
+
// Last nonce received from server (for enforcement proof)
|
|
106
|
+
lastNonce = null;
|
|
107
|
+
// Compiled license manager (V3: agent-side evaluation + stamp generation)
|
|
108
|
+
licenseManager = new license_1.LicenseManager();
|
|
109
|
+
licenseFetched = false;
|
|
110
|
+
constructor(config) {
|
|
111
|
+
// Resolution priority: explicit > .arkna/.env > env vars
|
|
112
|
+
const arknaEnv = readArknaEnv();
|
|
113
|
+
this.token = config?.token
|
|
114
|
+
|| arknaEnv['ARKNA_TOKEN']
|
|
115
|
+
|| process.env['ARKNA_TOKEN']
|
|
116
|
+
|| '';
|
|
117
|
+
const rawUrl = config?.gatewayUrl
|
|
118
|
+
|| arknaEnv['ARKNA_URL']
|
|
119
|
+
|| process.env['ARKNA_URL']
|
|
120
|
+
|| '';
|
|
121
|
+
// Normalize: strip trailing /api or /api/gateway and ensure /api/gateway
|
|
122
|
+
this.baseUrl = normalizeGatewayUrl(rawUrl);
|
|
123
|
+
this.agentId = config?.agentId
|
|
124
|
+
|| arknaEnv['ARKNA_AGENT_ID']
|
|
125
|
+
|| process.env['ARKNA_AGENT_ID']
|
|
126
|
+
|| undefined;
|
|
127
|
+
this.timeoutMs = config?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
128
|
+
this.maxRetries = config?.maxRetries ?? 3;
|
|
129
|
+
this.retryDelayMs = config?.retryDelayMs ?? 500;
|
|
130
|
+
if (!this.token) {
|
|
131
|
+
throw new Error('ARKNA token not found. Set ARKNA_TOKEN env var, pass { token } to constructor, or run `arkna connect`.');
|
|
132
|
+
}
|
|
133
|
+
if (!this.baseUrl) {
|
|
134
|
+
throw new Error('ARKNA gateway URL not found. Set ARKNA_URL env var, pass { gatewayUrl } to constructor, or run `arkna connect`.');
|
|
135
|
+
}
|
|
136
|
+
// Enable enforcement — mandatory when governance contract exists.
|
|
137
|
+
// Patches fetch/http to intercept direct API calls to governed domains.
|
|
138
|
+
// Generates an enforcement secret used for nonce-based proof on heartbeats.
|
|
139
|
+
this.enforcementSecret = crypto.randomBytes(32).toString('hex');
|
|
140
|
+
try {
|
|
141
|
+
this.enforcementGuard = (0, enforcement_1.enableEnforcement)(this, {
|
|
142
|
+
mode: config?.enforcementMode,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// No governance.json found — enforcement not active.
|
|
147
|
+
// Compliance checker will detect this via missing heartbeat metadata.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Whether HTTP enforcement is active (governed domain interception) */
|
|
151
|
+
get enforcementActive() {
|
|
152
|
+
return this.enforcementGuard !== null && this.enforcementGuard.contract !== null;
|
|
153
|
+
}
|
|
154
|
+
/** Number of intercepted direct API calls since client creation */
|
|
155
|
+
get interceptedCalls() {
|
|
156
|
+
return this.enforcementGuard?.interceptedCalls ?? 0;
|
|
157
|
+
}
|
|
158
|
+
// ────────────────────────────────────────────────────────────
|
|
159
|
+
// Tools
|
|
160
|
+
// ────────────────────────────────────────────────────────────
|
|
161
|
+
/** List available tools for this agent (cached 5 min). */
|
|
162
|
+
async tools(forceRefresh = false) {
|
|
163
|
+
if (!forceRefresh && this.toolsCache && Date.now() - this.toolsCacheTime < TOOLS_CACHE_TTL_MS) {
|
|
164
|
+
return this.toolsCache;
|
|
165
|
+
}
|
|
166
|
+
const resp = await this.request('GET', '/tools');
|
|
167
|
+
this.toolsCache = resp.tools;
|
|
168
|
+
this.toolsCacheTime = Date.now();
|
|
169
|
+
return resp.tools;
|
|
170
|
+
}
|
|
171
|
+
// ────────────────────────────────────────────────────────────
|
|
172
|
+
// Execute
|
|
173
|
+
// ────────────────────────────────────────────────────────────
|
|
174
|
+
/**
|
|
175
|
+
* Execute a tool through the ARKNA governance gateway.
|
|
176
|
+
*
|
|
177
|
+
* @param toolName - Tool identifier (e.g. 'send_email', 'check_calendly_availability')
|
|
178
|
+
* @param parameters - Tool input parameters
|
|
179
|
+
* @param opts - Optional: custom requestId, dryRun mode
|
|
180
|
+
* @returns ActionResult with decision, result, and signed permit
|
|
181
|
+
*/
|
|
182
|
+
async execute(toolName, parameters, opts) {
|
|
183
|
+
const requestId = opts?.requestId || `req_${crypto.randomUUID().replace(/-/g, '').substring(0, 12)}`;
|
|
184
|
+
const body = {
|
|
185
|
+
tool: toolName,
|
|
186
|
+
parameters,
|
|
187
|
+
request_id: requestId,
|
|
188
|
+
};
|
|
189
|
+
if (opts?.dryRun) {
|
|
190
|
+
body.dry_run = true;
|
|
191
|
+
}
|
|
192
|
+
// ── Compiled License Fast Path ──
|
|
193
|
+
// If we have a valid license and the action is covered, generate a stamp
|
|
194
|
+
// and send it with the request. Gateway validates in <1ms instead of 100-400ms.
|
|
195
|
+
if (!opts?.dryRun) {
|
|
196
|
+
await this.ensureLicense();
|
|
197
|
+
if (this.licenseManager.isLicensed(toolName, parameters)) {
|
|
198
|
+
const stamp = this.licenseManager.generateStamp(toolName, parameters);
|
|
199
|
+
if (stamp) {
|
|
200
|
+
return this.requestRaw('POST', '/actions', body, { 'X-Arkna-Stamp': stamp });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// POST /actions returns 200 (ALLOW), 202 (NEEDS_APPROVAL), or 403 (DENY).
|
|
205
|
+
// All three are valid governance decisions — not errors.
|
|
206
|
+
return this.requestRaw('POST', '/actions', body);
|
|
207
|
+
}
|
|
208
|
+
// ────────────────────────────────────────────────────────────
|
|
209
|
+
// Heartbeat
|
|
210
|
+
// ────────────────────────────────────────────────────────────
|
|
211
|
+
/** Send a single heartbeat. Returns kill switch status.
|
|
212
|
+
* Automatically includes enforcement metadata and cryptographic proof
|
|
213
|
+
* that enforcement is genuinely active (nonce-based, server-verified).
|
|
214
|
+
* @param metadata - Optional metadata to include
|
|
215
|
+
* @param tools - Updated tool declarations (synced on each heartbeat)
|
|
216
|
+
*/
|
|
217
|
+
async heartbeat(metadata, tools) {
|
|
218
|
+
// Auto-enrich with enforcement telemetry
|
|
219
|
+
const enriched = { ...metadata };
|
|
220
|
+
enriched.enforcement_active = this.enforcementActive;
|
|
221
|
+
enriched.intercepted_calls = this.interceptedCalls;
|
|
222
|
+
enriched.sdk_version = '0.1.0';
|
|
223
|
+
if (this.enforcementGuard?.contract) {
|
|
224
|
+
enriched.enforcement_mode = this.enforcementGuard.contract.enforcement_mode;
|
|
225
|
+
enriched.contract_version = this.enforcementGuard.contract.version;
|
|
226
|
+
// Send contract signature prefix so backend can verify contract integrity
|
|
227
|
+
enriched.contract_signature_prefix = this.enforcementGuard.contract.signature.substring(0, 16);
|
|
228
|
+
}
|
|
229
|
+
// Nonce-based enforcement proof: proves enforcement is genuinely running.
|
|
230
|
+
// Both sides use SHA256(secret) as the HMAC key so the backend can verify.
|
|
231
|
+
const secretHash = crypto.createHash('sha256').update(this.enforcementSecret).digest('hex');
|
|
232
|
+
if (this.lastNonce && this.enforcementActive) {
|
|
233
|
+
enriched.enforcement_proof = crypto
|
|
234
|
+
.createHmac('sha256', secretHash)
|
|
235
|
+
.update(this.lastNonce)
|
|
236
|
+
.digest('hex');
|
|
237
|
+
enriched.enforcement_proof_nonce = this.lastNonce;
|
|
238
|
+
}
|
|
239
|
+
// Send the secret hash on first heartbeat so backend can verify future proofs
|
|
240
|
+
if (!this.lastNonce) {
|
|
241
|
+
enriched.enforcement_secret_hash = secretHash;
|
|
242
|
+
}
|
|
243
|
+
const body = { metadata: enriched };
|
|
244
|
+
if (tools) {
|
|
245
|
+
body.tools = tools;
|
|
246
|
+
}
|
|
247
|
+
const result = await this.request('POST', '/heartbeat', body);
|
|
248
|
+
// Store nonce from server for next heartbeat proof
|
|
249
|
+
if (result.enforcement_nonce) {
|
|
250
|
+
this.lastNonce = result.enforcement_nonce;
|
|
251
|
+
}
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Collect system telemetry (CPU, memory). Returns empty object on failure.
|
|
256
|
+
* Used by startHeartbeat when enriched=true. Safe on all platforms.
|
|
257
|
+
*/
|
|
258
|
+
collectSystemTelemetry() {
|
|
259
|
+
try {
|
|
260
|
+
const os = require('os');
|
|
261
|
+
const loadAvg = os.loadavg();
|
|
262
|
+
const totalMem = os.totalmem();
|
|
263
|
+
const freeMem = os.freemem();
|
|
264
|
+
return {
|
|
265
|
+
system: {
|
|
266
|
+
load_1m: Math.round(loadAvg[0] * 100) / 100,
|
|
267
|
+
load_5m: Math.round(loadAvg[1] * 100) / 100,
|
|
268
|
+
load_15m: Math.round(loadAvg[2] * 100) / 100,
|
|
269
|
+
memory_used_pct: Math.round((1 - freeMem / totalMem) * 100),
|
|
270
|
+
memory_total_mb: Math.round(totalMem / (1024 * 1024)),
|
|
271
|
+
platform: os.platform(),
|
|
272
|
+
uptime_hours: Math.round(os.uptime() / 3600 * 10) / 10,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return {};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Start a background heartbeat interval.
|
|
282
|
+
* @param intervalMs - Interval in ms (default: 60000 = 1 min)
|
|
283
|
+
* @param options.enriched - Include system telemetry (CPU, memory) in heartbeats
|
|
284
|
+
* @returns Handle with stop() method
|
|
285
|
+
*/
|
|
286
|
+
startHeartbeat(intervalMs = 60_000, options) {
|
|
287
|
+
const enriched = options?.enriched ?? false;
|
|
288
|
+
const sendHeartbeat = () => {
|
|
289
|
+
const extra = enriched ? this.collectSystemTelemetry() : {};
|
|
290
|
+
this.heartbeat(extra).catch(() => {
|
|
291
|
+
// Heartbeat failures are non-critical
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
const id = setInterval(sendHeartbeat, intervalMs);
|
|
295
|
+
// Send first heartbeat immediately
|
|
296
|
+
sendHeartbeat();
|
|
297
|
+
return {
|
|
298
|
+
stop: () => clearInterval(id),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// ────────────────────────────────────────────────────────────
|
|
302
|
+
// Registration
|
|
303
|
+
// ────────────────────────────────────────────────────────────
|
|
304
|
+
/**
|
|
305
|
+
* Register this agent with the ARKNA gateway.
|
|
306
|
+
* @param name - Agent display name
|
|
307
|
+
* @param platform - Agent platform (e.g. 'crewai', 'langchain', 'custom')
|
|
308
|
+
* @param opts - Optional: description, capabilities, reply_url, tools
|
|
309
|
+
*/
|
|
310
|
+
async register(name, platform, opts) {
|
|
311
|
+
const body = {
|
|
312
|
+
name,
|
|
313
|
+
platform,
|
|
314
|
+
description: opts?.description,
|
|
315
|
+
capabilities: opts?.capabilities,
|
|
316
|
+
reply_url: opts?.reply_url,
|
|
317
|
+
metadata: opts?.metadata,
|
|
318
|
+
};
|
|
319
|
+
if (opts?.tools) {
|
|
320
|
+
body.tools = opts.tools;
|
|
321
|
+
}
|
|
322
|
+
const result = await this.request('POST', '/register', body);
|
|
323
|
+
// Store agent ID from registration
|
|
324
|
+
if (result.agent?.id) {
|
|
325
|
+
this.agentId = result.agent.id;
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
// ────────────────────────────────────────────────────────────
|
|
330
|
+
// Declared Tools
|
|
331
|
+
// ────────────────────────────────────────────────────────────
|
|
332
|
+
/**
|
|
333
|
+
* Declare this agent's native tools for ARKNA governance tracking.
|
|
334
|
+
* Convenience method that sends a heartbeat with a tools declaration.
|
|
335
|
+
* ARKNA auto-classifies read-only tools and queues others for admin approval.
|
|
336
|
+
*/
|
|
337
|
+
async declareTools(tools) {
|
|
338
|
+
return this.heartbeat(undefined, tools);
|
|
339
|
+
}
|
|
340
|
+
// ────────────────────────────────────────────────────────────
|
|
341
|
+
// Governance
|
|
342
|
+
// ────────────────────────────────────────────────────────────
|
|
343
|
+
/** Fetch the full governance envelope (tools, policies, permissions, constitution). */
|
|
344
|
+
async governance() {
|
|
345
|
+
return this.request('GET', '/governance');
|
|
346
|
+
}
|
|
347
|
+
// ────────────────────────────────────────────────────────────
|
|
348
|
+
// Messages (SSE + Reply)
|
|
349
|
+
// ────────────────────────────────────────────────────────────
|
|
350
|
+
/**
|
|
351
|
+
* Listen for visitor messages via SSE.
|
|
352
|
+
* Calls the callback for each message event.
|
|
353
|
+
* Returns an AbortController to stop listening.
|
|
354
|
+
*/
|
|
355
|
+
onMessage(callback) {
|
|
356
|
+
const controller = new AbortController();
|
|
357
|
+
const listen = async () => {
|
|
358
|
+
try {
|
|
359
|
+
const resp = await fetch(`${this.baseUrl}/messages/stream`, {
|
|
360
|
+
headers: {
|
|
361
|
+
'X-Integration-Token': this.token,
|
|
362
|
+
},
|
|
363
|
+
signal: controller.signal,
|
|
364
|
+
});
|
|
365
|
+
if (!resp.ok || !resp.body) {
|
|
366
|
+
throw new Error(`SSE connection failed: ${resp.status}`);
|
|
367
|
+
}
|
|
368
|
+
const reader = resp.body.getReader();
|
|
369
|
+
const decoder = new TextDecoder();
|
|
370
|
+
let buffer = '';
|
|
371
|
+
let currentEvent = '';
|
|
372
|
+
let dataLines = [];
|
|
373
|
+
while (true) {
|
|
374
|
+
const { done, value } = await reader.read();
|
|
375
|
+
if (done)
|
|
376
|
+
break;
|
|
377
|
+
buffer += decoder.decode(value, { stream: true });
|
|
378
|
+
const lines = buffer.split('\n');
|
|
379
|
+
buffer = lines.pop() || '';
|
|
380
|
+
for (const line of lines) {
|
|
381
|
+
if (line.startsWith('event:')) {
|
|
382
|
+
currentEvent = line.substring(6).trim();
|
|
383
|
+
}
|
|
384
|
+
else if (line.startsWith('data:')) {
|
|
385
|
+
// SSE spec: accumulate multiple data: lines
|
|
386
|
+
dataLines.push(line.substring(5).trim());
|
|
387
|
+
}
|
|
388
|
+
else if (line === '') {
|
|
389
|
+
// Empty line = end of event. Dispatch accumulated data.
|
|
390
|
+
if (dataLines.length > 0) {
|
|
391
|
+
const dataStr = dataLines.join('\n');
|
|
392
|
+
try {
|
|
393
|
+
const data = JSON.parse(dataStr);
|
|
394
|
+
callback({ event: currentEvent || 'message', data });
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// Skip malformed JSON
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
currentEvent = '';
|
|
401
|
+
dataLines = [];
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
if (err instanceof Error && err.name === 'AbortError')
|
|
408
|
+
return;
|
|
409
|
+
// Reconnect after 5s on error
|
|
410
|
+
if (!controller.signal.aborted) {
|
|
411
|
+
setTimeout(listen, 5000);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
listen();
|
|
416
|
+
return controller;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Reply to a visitor message.
|
|
420
|
+
* @param messageId - The message_id from the SSE event
|
|
421
|
+
* @param content - Reply text content
|
|
422
|
+
*/
|
|
423
|
+
async reply(messageId, content) {
|
|
424
|
+
return this.request('POST', `/messages/${messageId}/reply`, { content });
|
|
425
|
+
}
|
|
426
|
+
// ────────────────────────────────────────────────────────────
|
|
427
|
+
// Action Status Polling
|
|
428
|
+
// ────────────────────────────────────────────────────────────
|
|
429
|
+
/** Poll for the result of a queued action (may return denied status). */
|
|
430
|
+
async getActionStatus(requestId) {
|
|
431
|
+
return this.requestRaw('GET', `/actions/${requestId}`);
|
|
432
|
+
}
|
|
433
|
+
// ────────────────────────────────────────────────────────────
|
|
434
|
+
// Agent Settings
|
|
435
|
+
// ────────────────────────────────────────────────────────────
|
|
436
|
+
/** Update agent settings (e.g. reply_url). */
|
|
437
|
+
async updateAgent(settings) {
|
|
438
|
+
return this.request('PATCH', '/agent', settings);
|
|
439
|
+
}
|
|
440
|
+
/** Push an agent manifest (self-description, capabilities, tools). */
|
|
441
|
+
async pushManifest(manifest) {
|
|
442
|
+
return this.request('POST', '/manifest', manifest);
|
|
443
|
+
}
|
|
444
|
+
// ────────────────────────────────────────────────────────────
|
|
445
|
+
// Standing Permits
|
|
446
|
+
// ────────────────────────────────────────────────────────────
|
|
447
|
+
/** List active standing permits for this agent. */
|
|
448
|
+
async standingPermits() {
|
|
449
|
+
return this.request('GET', '/standing-permits');
|
|
450
|
+
}
|
|
451
|
+
// ────────────────────────────────────────────────────────────
|
|
452
|
+
// Agent Context (Adaptive Governance)
|
|
453
|
+
// ────────────────────────────────────────────────────────────
|
|
454
|
+
/** Agent context — experience log, semantic graph, working context. */
|
|
455
|
+
context = {
|
|
456
|
+
/** Store an experience log entry. */
|
|
457
|
+
store: async (entry) => {
|
|
458
|
+
return this.request('POST', '/context', entry);
|
|
459
|
+
},
|
|
460
|
+
/** Recall experience log entries with optional filters. */
|
|
461
|
+
recall: async (opts) => {
|
|
462
|
+
const params = new URLSearchParams();
|
|
463
|
+
if (opts?.type)
|
|
464
|
+
params.set('type', opts.type);
|
|
465
|
+
if (opts?.session)
|
|
466
|
+
params.set('session', opts.session);
|
|
467
|
+
if (opts?.since)
|
|
468
|
+
params.set('since', opts.since);
|
|
469
|
+
if (opts?.limit)
|
|
470
|
+
params.set('limit', String(opts.limit));
|
|
471
|
+
if (opts?.offset)
|
|
472
|
+
params.set('offset', String(opts.offset));
|
|
473
|
+
const qs = params.toString();
|
|
474
|
+
const result = await this.request('GET', `/context/recall${qs ? `?${qs}` : ''}`);
|
|
475
|
+
return result.episodes;
|
|
476
|
+
},
|
|
477
|
+
/** Build working context (experience + semantic + shared). */
|
|
478
|
+
working: async (opts) => {
|
|
479
|
+
const params = new URLSearchParams();
|
|
480
|
+
if (opts?.query)
|
|
481
|
+
params.set('query', opts.query);
|
|
482
|
+
if (opts?.max_tokens)
|
|
483
|
+
params.set('max_tokens', String(opts.max_tokens));
|
|
484
|
+
if (opts?.include_shared === false)
|
|
485
|
+
params.set('include_shared', 'false');
|
|
486
|
+
const qs = params.toString();
|
|
487
|
+
return this.request('GET', `/context/working${qs ? `?${qs}` : ''}`);
|
|
488
|
+
},
|
|
489
|
+
/** Query the semantic knowledge graph. */
|
|
490
|
+
graph: async (opts) => {
|
|
491
|
+
const params = new URLSearchParams();
|
|
492
|
+
if (opts?.query)
|
|
493
|
+
params.set('query', opts.query);
|
|
494
|
+
if (opts?.node_type)
|
|
495
|
+
params.set('node_type', opts.node_type);
|
|
496
|
+
if (opts?.limit)
|
|
497
|
+
params.set('limit', String(opts.limit));
|
|
498
|
+
const qs = params.toString();
|
|
499
|
+
const result = await this.request('GET', `/context/graph${qs ? `?${qs}` : ''}`);
|
|
500
|
+
return result.nodes;
|
|
501
|
+
},
|
|
502
|
+
/** Share a semantic node with another agent. */
|
|
503
|
+
share: async (nodeId, targetAgentId) => {
|
|
504
|
+
await this.request('POST', '/context/share', { node_id: nodeId, target_agent_id: targetAgentId });
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
/** @deprecated Use client.context instead */
|
|
508
|
+
get memory() { return this.context; }
|
|
509
|
+
// ────────────────────────────────────────────────────────────
|
|
510
|
+
// Session Management
|
|
511
|
+
// ────────────────────────────────────────────────────────────
|
|
512
|
+
/**
|
|
513
|
+
* Close a session explicitly.
|
|
514
|
+
* @param sessionId - The session ID to close
|
|
515
|
+
* @param status - 'completed' or 'abandoned'
|
|
516
|
+
* @returns CloseSessionResult with sessionId and status
|
|
517
|
+
*/
|
|
518
|
+
async closeSession(sessionId, status) {
|
|
519
|
+
const result = await this.ingestionRequest('PATCH', `/sessions/${sessionId}`, { status });
|
|
520
|
+
return { sessionId: result.session_id, status: result.status };
|
|
521
|
+
}
|
|
522
|
+
// ────────────────────────────────────────────────────────────
|
|
523
|
+
// Step Recording
|
|
524
|
+
// ────────────────────────────────────────────────────────────
|
|
525
|
+
/**
|
|
526
|
+
* Record multiple steps in a single batch request.
|
|
527
|
+
* @param runId - The run ID to record steps for
|
|
528
|
+
* @param steps - Array of step inputs (max 100)
|
|
529
|
+
* @returns StepBatchResult with step IDs, sequences, and hashes
|
|
530
|
+
*/
|
|
531
|
+
async recordStepBatch(runId, steps) {
|
|
532
|
+
return this.ingestionRequest('POST', `/runs/${runId}/steps/batch`, { steps });
|
|
533
|
+
}
|
|
534
|
+
// ────────────────────────────────────────────────────────────
|
|
535
|
+
// Approval Polling
|
|
536
|
+
// ────────────────────────────────────────────────────────────
|
|
537
|
+
/**
|
|
538
|
+
* Wait for a queued action to be approved or denied.
|
|
539
|
+
* Polls getActionStatus with exponential backoff until resolved or timeout.
|
|
540
|
+
*
|
|
541
|
+
* @param requestId - The request_id from an execute() that returned NEEDS_APPROVAL
|
|
542
|
+
* @param opts - Optional: timeoutMs (default 300000 = 5 min), pollIntervalMs (default 2000)
|
|
543
|
+
* @returns The final ActionResult once resolved
|
|
544
|
+
* @throws Error if timeout is reached
|
|
545
|
+
*/
|
|
546
|
+
async waitForApproval(requestId, opts) {
|
|
547
|
+
const timeoutMs = opts?.timeoutMs ?? 300_000;
|
|
548
|
+
const basePollMs = opts?.pollIntervalMs ?? 2_000;
|
|
549
|
+
const start = Date.now();
|
|
550
|
+
let attempt = 0;
|
|
551
|
+
while (Date.now() - start < timeoutMs) {
|
|
552
|
+
const result = await this.getActionStatus(requestId);
|
|
553
|
+
// Resolved: executed or denied
|
|
554
|
+
if (result.status === 'executed' || result.status === 'denied') {
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
// Still queued — backoff with cap
|
|
558
|
+
attempt++;
|
|
559
|
+
const delay = Math.min(basePollMs * Math.pow(1.5, attempt), 30_000);
|
|
560
|
+
await this.sleep(delay);
|
|
561
|
+
}
|
|
562
|
+
throw new Error(`Approval timeout: action ${requestId} not resolved within ${timeoutMs}ms`);
|
|
563
|
+
}
|
|
564
|
+
// ────────────────────────────────────────────────────────────
|
|
565
|
+
// License Management
|
|
566
|
+
// ────────────────────────────────────────────────────────────
|
|
567
|
+
/**
|
|
568
|
+
* Fetch the compiled license from the gateway. Called lazily on first execute.
|
|
569
|
+
* Non-fatal: if fetching fails, falls through to full pipeline.
|
|
570
|
+
*/
|
|
571
|
+
async fetchLicense() {
|
|
572
|
+
try {
|
|
573
|
+
const resp = await this.request('GET', '/license');
|
|
574
|
+
if (resp.license && resp.stamp_secret) {
|
|
575
|
+
this.licenseManager.setLicense(resp.license, resp.stamp_secret);
|
|
576
|
+
}
|
|
577
|
+
this.licenseFetched = true;
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// Non-fatal — agent continues without license (full pipeline)
|
|
581
|
+
this.licenseFetched = true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/** Ensure we've attempted to fetch the license at least once. */
|
|
585
|
+
async ensureLicense() {
|
|
586
|
+
if (!this.licenseFetched) {
|
|
587
|
+
await this.fetchLicense();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// ────────────────────────────────────────────────────────────
|
|
591
|
+
// Internal HTTP Helpers
|
|
592
|
+
// ────────────────────────────────────────────────────────────
|
|
593
|
+
/** Whether a status code is safe to retry */
|
|
594
|
+
isRetryable(status) {
|
|
595
|
+
return RETRYABLE_STATUSES.has(status);
|
|
596
|
+
}
|
|
597
|
+
/** Calculate retry delay with exponential backoff + jitter, respecting Retry-After */
|
|
598
|
+
getRetryDelay(attempt, retryAfterHeader) {
|
|
599
|
+
// Respect Retry-After header if present
|
|
600
|
+
if (retryAfterHeader) {
|
|
601
|
+
const retryAfterSec = parseInt(retryAfterHeader, 10);
|
|
602
|
+
if (!isNaN(retryAfterSec) && retryAfterSec > 0) {
|
|
603
|
+
return retryAfterSec * 1000;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// Exponential backoff: base * 2^attempt + jitter
|
|
607
|
+
const exponential = this.retryDelayMs * Math.pow(2, attempt);
|
|
608
|
+
const jitter = Math.random() * this.retryDelayMs;
|
|
609
|
+
return exponential + jitter;
|
|
610
|
+
}
|
|
611
|
+
/** Sleep helper */
|
|
612
|
+
sleep(ms) {
|
|
613
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Standard request — throws on non-2xx responses.
|
|
617
|
+
* Used for tools, heartbeat, register, governance, etc.
|
|
618
|
+
* Retries on transient errors (429, 5xx) with exponential backoff.
|
|
619
|
+
*/
|
|
620
|
+
async request(method, path, body) {
|
|
621
|
+
const url = `${this.baseUrl}${path}`;
|
|
622
|
+
const headers = {
|
|
623
|
+
'X-Integration-Token': this.token,
|
|
624
|
+
};
|
|
625
|
+
if (body !== undefined) {
|
|
626
|
+
headers['Content-Type'] = 'application/json';
|
|
627
|
+
}
|
|
628
|
+
let lastError;
|
|
629
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
630
|
+
const controller = new AbortController();
|
|
631
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
632
|
+
try {
|
|
633
|
+
const resp = await fetch(url, {
|
|
634
|
+
method,
|
|
635
|
+
headers,
|
|
636
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
637
|
+
signal: controller.signal,
|
|
638
|
+
});
|
|
639
|
+
clearTimeout(timeout);
|
|
640
|
+
const json = await resp.json();
|
|
641
|
+
if (!resp.ok) {
|
|
642
|
+
const errMsg = json.error
|
|
643
|
+
|| json.message
|
|
644
|
+
|| `HTTP ${resp.status}`;
|
|
645
|
+
const err = new Error(errMsg);
|
|
646
|
+
err.status = resp.status;
|
|
647
|
+
err.body = json;
|
|
648
|
+
// Retry on transient errors
|
|
649
|
+
if (this.isRetryable(resp.status) && attempt < this.maxRetries) {
|
|
650
|
+
lastError = err;
|
|
651
|
+
const delay = this.getRetryDelay(attempt, resp.headers.get('retry-after'));
|
|
652
|
+
await this.sleep(delay);
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
throw err;
|
|
656
|
+
}
|
|
657
|
+
return json;
|
|
658
|
+
}
|
|
659
|
+
catch (err) {
|
|
660
|
+
clearTimeout(timeout);
|
|
661
|
+
// Retry on timeout/network errors
|
|
662
|
+
if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TypeError') && attempt < this.maxRetries) {
|
|
663
|
+
lastError = err;
|
|
664
|
+
const delay = this.getRetryDelay(attempt);
|
|
665
|
+
await this.sleep(delay);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
// Rethrow retryable errors with status (from above) or non-retryable errors
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Should not reach here, but safety net
|
|
673
|
+
throw lastError || new Error('Request failed after retries');
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Raw request — returns parsed JSON for ANY response with a JSON body.
|
|
677
|
+
* Used for execute() where 200 (ALLOW), 202 (NEEDS_APPROVAL), and 403 (DENY)
|
|
678
|
+
* are all valid governance decisions, not errors.
|
|
679
|
+
* Only throws on network/timeout errors or non-JSON responses.
|
|
680
|
+
* Retries on transient errors but NEVER retries governance decisions.
|
|
681
|
+
*/
|
|
682
|
+
async requestRaw(method, path, body, extraHeaders) {
|
|
683
|
+
const url = `${this.baseUrl}${path}`;
|
|
684
|
+
const headers = {
|
|
685
|
+
'X-Integration-Token': this.token,
|
|
686
|
+
...extraHeaders,
|
|
687
|
+
};
|
|
688
|
+
if (body !== undefined) {
|
|
689
|
+
headers['Content-Type'] = 'application/json';
|
|
690
|
+
}
|
|
691
|
+
let lastError;
|
|
692
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
693
|
+
const controller = new AbortController();
|
|
694
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
695
|
+
try {
|
|
696
|
+
const resp = await fetch(url, {
|
|
697
|
+
method,
|
|
698
|
+
headers,
|
|
699
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
700
|
+
signal: controller.signal,
|
|
701
|
+
});
|
|
702
|
+
clearTimeout(timeout);
|
|
703
|
+
const json = await resp.json();
|
|
704
|
+
// 200 (ALLOW), 202 (NEEDS_APPROVAL), 403 (DENY) are all valid decisions — never retry
|
|
705
|
+
if (resp.status === 200 || resp.status === 202 || resp.status === 403) {
|
|
706
|
+
return json;
|
|
707
|
+
}
|
|
708
|
+
// Other errors (400, 401, 429, 500) are real errors
|
|
709
|
+
const errMsg = json.error
|
|
710
|
+
|| json.message
|
|
711
|
+
|| `HTTP ${resp.status}`;
|
|
712
|
+
const err = new Error(errMsg);
|
|
713
|
+
err.status = resp.status;
|
|
714
|
+
err.body = json;
|
|
715
|
+
// Retry on transient errors
|
|
716
|
+
if (this.isRetryable(resp.status) && attempt < this.maxRetries) {
|
|
717
|
+
lastError = err;
|
|
718
|
+
const delay = this.getRetryDelay(attempt, resp.headers.get('retry-after'));
|
|
719
|
+
await this.sleep(delay);
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
throw err;
|
|
723
|
+
}
|
|
724
|
+
catch (err) {
|
|
725
|
+
clearTimeout(timeout);
|
|
726
|
+
// Retry on timeout/network errors
|
|
727
|
+
if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TypeError') && attempt < this.maxRetries) {
|
|
728
|
+
lastError = err;
|
|
729
|
+
const delay = this.getRetryDelay(attempt);
|
|
730
|
+
await this.sleep(delay);
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
throw err;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
throw lastError || new Error('Request failed after retries');
|
|
737
|
+
}
|
|
738
|
+
// ────────────────────────────────────────────────────────────
|
|
739
|
+
// Webhook Signature Verification
|
|
740
|
+
// ────────────────────────────────────────────────────────────
|
|
741
|
+
/**
|
|
742
|
+
* Verify an ARKNA webhook signature.
|
|
743
|
+
*
|
|
744
|
+
* @param body - Raw request body string
|
|
745
|
+
* @param signatureHeader - Value of X-Arkna-Signature header (e.g. "v1=abc123...")
|
|
746
|
+
* @param timestampHeader - Value of X-Arkna-Timestamp header (epoch seconds)
|
|
747
|
+
* @param secret - Your webhook_secret (from token creation response)
|
|
748
|
+
* @param toleranceSeconds - Max age of the request in seconds (default: 300 = 5 min)
|
|
749
|
+
* @returns true if signature is valid and timestamp is within tolerance
|
|
750
|
+
*/
|
|
751
|
+
static verifyWebhookSignature(body, signatureHeader, timestampHeader, secret, toleranceSeconds = 300) {
|
|
752
|
+
// Check timestamp freshness (replay protection)
|
|
753
|
+
const ts = parseInt(timestampHeader, 10);
|
|
754
|
+
if (isNaN(ts))
|
|
755
|
+
return false;
|
|
756
|
+
const now = Math.floor(Date.now() / 1000);
|
|
757
|
+
if (Math.abs(now - ts) > toleranceSeconds)
|
|
758
|
+
return false;
|
|
759
|
+
// Compute expected signature
|
|
760
|
+
const payload = `${timestampHeader}.${body}`;
|
|
761
|
+
const expected = crypto
|
|
762
|
+
.createHmac('sha256', secret)
|
|
763
|
+
.update(payload)
|
|
764
|
+
.digest('hex');
|
|
765
|
+
// Extract signature value (strip "v1=" prefix)
|
|
766
|
+
const sig = signatureHeader.startsWith('v1=')
|
|
767
|
+
? signatureHeader.slice(3)
|
|
768
|
+
: signatureHeader;
|
|
769
|
+
// Constant-time comparison
|
|
770
|
+
try {
|
|
771
|
+
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// ────────────────────────────────────────────────────────────
|
|
778
|
+
// Observability — Run/Step/Tool Capture
|
|
779
|
+
// ────────────────────────────────────────────────────────────
|
|
780
|
+
/** Get the ingestion API base URL (derived from gateway URL) */
|
|
781
|
+
get ingestionBaseUrl() {
|
|
782
|
+
// baseUrl ends with /api/gateway — strip /gateway for /api, then append /v1
|
|
783
|
+
return this.baseUrl.replace(/\/gateway$/, '/v1');
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Convert camelCase keys to snake_case (shallow, one level deep).
|
|
787
|
+
* Leaves keys that are already snake_case unchanged.
|
|
788
|
+
*/
|
|
789
|
+
static camelToSnake(obj) {
|
|
790
|
+
const result = {};
|
|
791
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
792
|
+
if (value === undefined)
|
|
793
|
+
continue;
|
|
794
|
+
const snakeKey = key.replace(/[A-Z]/g, (ch) => '_' + ch.toLowerCase());
|
|
795
|
+
result[snakeKey] = value;
|
|
796
|
+
}
|
|
797
|
+
return result;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Convert snake_case keys to camelCase (shallow, one level deep).
|
|
801
|
+
*/
|
|
802
|
+
static snakeToCamel(obj) {
|
|
803
|
+
const result = {};
|
|
804
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
805
|
+
const camelKey = key.replace(/_([a-z])/g, (_, ch) => ch.toUpperCase());
|
|
806
|
+
result[camelKey] = value;
|
|
807
|
+
}
|
|
808
|
+
return result;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Request to the ingestion API (/api/v1/...).
|
|
812
|
+
* Same retry/timeout logic as the gateway request method.
|
|
813
|
+
*/
|
|
814
|
+
async ingestionRequest(method, path, body) {
|
|
815
|
+
const url = `${this.ingestionBaseUrl}${path}`;
|
|
816
|
+
const headers = {
|
|
817
|
+
'X-Integration-Token': this.token,
|
|
818
|
+
};
|
|
819
|
+
if (body !== undefined) {
|
|
820
|
+
headers['Content-Type'] = 'application/json';
|
|
821
|
+
}
|
|
822
|
+
let lastError;
|
|
823
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
824
|
+
const controller = new AbortController();
|
|
825
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
826
|
+
try {
|
|
827
|
+
const resp = await fetch(url, {
|
|
828
|
+
method,
|
|
829
|
+
headers,
|
|
830
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
831
|
+
signal: controller.signal,
|
|
832
|
+
});
|
|
833
|
+
clearTimeout(timeout);
|
|
834
|
+
const json = await resp.json();
|
|
835
|
+
if (!resp.ok) {
|
|
836
|
+
const errMsg = json.error
|
|
837
|
+
|| json.message
|
|
838
|
+
|| `HTTP ${resp.status}`;
|
|
839
|
+
const err = new Error(errMsg);
|
|
840
|
+
err.status = resp.status;
|
|
841
|
+
err.body = json;
|
|
842
|
+
if (this.isRetryable(resp.status) && attempt < this.maxRetries) {
|
|
843
|
+
lastError = err;
|
|
844
|
+
const delay = this.getRetryDelay(attempt, resp.headers.get('retry-after'));
|
|
845
|
+
await this.sleep(delay);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
throw err;
|
|
849
|
+
}
|
|
850
|
+
return json;
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
clearTimeout(timeout);
|
|
854
|
+
if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TypeError') && attempt < this.maxRetries) {
|
|
855
|
+
lastError = err;
|
|
856
|
+
const delay = this.getRetryDelay(attempt);
|
|
857
|
+
await this.sleep(delay);
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
throw err;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
throw lastError || new Error('Request failed after retries');
|
|
864
|
+
}
|
|
865
|
+
/** Start a new agent run. Returns a handle with the run ID. */
|
|
866
|
+
async startRun(options = {}) {
|
|
867
|
+
const opts = { triggerType: 'api', ...options };
|
|
868
|
+
const body = ArknaClient.camelToSnake(opts);
|
|
869
|
+
const resp = await this.ingestionRequest('POST', '/runs', body);
|
|
870
|
+
return ArknaClient.snakeToCamel(resp);
|
|
871
|
+
}
|
|
872
|
+
/** Record a step within a run. */
|
|
873
|
+
async recordStep(runId, options) {
|
|
874
|
+
const body = ArknaClient.camelToSnake(options);
|
|
875
|
+
const resp = await this.ingestionRequest('POST', `/runs/${runId}/steps`, body);
|
|
876
|
+
return ArknaClient.snakeToCamel(resp);
|
|
877
|
+
}
|
|
878
|
+
/** Record a tool call within a run. */
|
|
879
|
+
async recordToolCall(runId, options) {
|
|
880
|
+
const opts = { status: 'success', ...options };
|
|
881
|
+
const body = ArknaClient.camelToSnake(opts);
|
|
882
|
+
const resp = await this.ingestionRequest('POST', `/runs/${runId}/tools`, body);
|
|
883
|
+
return ArknaClient.snakeToCamel(resp);
|
|
884
|
+
}
|
|
885
|
+
/** Complete or fail a run. */
|
|
886
|
+
async completeRun(runId, options) {
|
|
887
|
+
const body = ArknaClient.camelToSnake(options);
|
|
888
|
+
const resp = await this.ingestionRequest('PATCH', `/runs/${runId}`, body);
|
|
889
|
+
return ArknaClient.snakeToCamel(resp);
|
|
890
|
+
}
|
|
891
|
+
/** Capture a context snapshot for a run. */
|
|
892
|
+
async captureContext(runId, options) {
|
|
893
|
+
const body = ArknaClient.camelToSnake(options);
|
|
894
|
+
const resp = await this.ingestionRequest('POST', `/runs/${runId}/context`, body);
|
|
895
|
+
return ArknaClient.snakeToCamel(resp);
|
|
896
|
+
}
|
|
897
|
+
/** Create a new session to group related runs. */
|
|
898
|
+
async createSession(metadata) {
|
|
899
|
+
const body = metadata ? { metadata } : {};
|
|
900
|
+
const resp = await this.ingestionRequest('POST', '/sessions', body);
|
|
901
|
+
return ArknaClient.snakeToCamel(resp);
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Convenience: trace a complete run with automatic lifecycle management.
|
|
905
|
+
* Starts a run, calls your function, records the result, completes the run.
|
|
906
|
+
*/
|
|
907
|
+
async trace(name, fn, options) {
|
|
908
|
+
const run = await this.startRun({ triggerType: 'api', ...options, input: name });
|
|
909
|
+
try {
|
|
910
|
+
const result = await fn({
|
|
911
|
+
runId: run.runId,
|
|
912
|
+
step: (opts) => this.recordStep(run.runId, opts),
|
|
913
|
+
tool: (opts) => this.recordToolCall(run.runId, opts),
|
|
914
|
+
});
|
|
915
|
+
await this.completeRun(run.runId, {
|
|
916
|
+
status: 'completed',
|
|
917
|
+
output: typeof result === 'string' ? result : JSON.stringify(result),
|
|
918
|
+
});
|
|
919
|
+
return result;
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
await this.completeRun(run.runId, {
|
|
923
|
+
status: 'failed',
|
|
924
|
+
errorType: error.name || 'Error',
|
|
925
|
+
errorMessage: error.message,
|
|
926
|
+
}).catch(() => { });
|
|
927
|
+
throw error;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
exports.ArknaClient = ArknaClient;
|
|
932
|
+
// ────────────────────────────────────────────────────────────
|
|
933
|
+
// URL normalization
|
|
934
|
+
// ────────────────────────────────────────────────────────────
|
|
935
|
+
function normalizeGatewayUrl(raw) {
|
|
936
|
+
if (!raw)
|
|
937
|
+
return '';
|
|
938
|
+
let url = raw.replace(/\/+$/, '');
|
|
939
|
+
// If it already ends with /api/gateway, use as-is
|
|
940
|
+
if (url.endsWith('/api/gateway'))
|
|
941
|
+
return url;
|
|
942
|
+
// If it ends with /api, append /gateway
|
|
943
|
+
if (url.endsWith('/api'))
|
|
944
|
+
return url + '/gateway';
|
|
945
|
+
// Otherwise append /api/gateway
|
|
946
|
+
return url + '/api/gateway';
|
|
947
|
+
}
|
|
948
|
+
//# sourceMappingURL=client.js.map
|