@promigence/sdk 0.1.0-beta.6 → 0.1.0-beta.8

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/pty.js CHANGED
@@ -2,6 +2,8 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { CALLER_HEADER } from "./caller.js";
3
3
  import { requestVia } from "./proxy.js";
4
4
  export const PTY_INPUT_CHUNK = 64 * 1024;
5
+ export const PTY_INPUT_KEEP = 256 * 1024;
6
+ export const PTY_RESUME_HEADER = "promigence-resume";
5
7
  const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
6
8
  export const PTY_COMMAND_HEADER = "promigence-pty-command";
7
9
  export const PTY_SECRETS_HEADER = "promigence-pty-secrets";
@@ -92,11 +94,17 @@ export class PtySession {
92
94
  rows;
93
95
  closed = false;
94
96
  done;
97
+ pending;
98
+ resumeToken;
99
+ inputSent = 0;
100
+ inputBase = 0;
101
+ inputLog = Buffer.alloc(0);
95
102
  constructor(socket, open, done) {
96
103
  this.socket = socket;
97
104
  this.pid = open.pid;
98
105
  this.cols = open.cols;
99
106
  this.rows = open.rows;
107
+ this.resumeToken = open.resume;
100
108
  this.done = done;
101
109
  void done.then(() => { this.#ended = true; });
102
110
  }
@@ -104,10 +112,67 @@ export class PtySession {
104
112
  get ended() {
105
113
  return this.#ended;
106
114
  }
107
- send(opcode, payload) {
115
+ holdInput() {
116
+ this.pending ??= [];
117
+ }
118
+ rebind(socket, open) {
119
+ this.socket = socket;
120
+ this.resumeToken = open.resume;
121
+ this.inputSent = 0;
122
+ this.inputBase = 0;
123
+ this.inputLog = Buffer.alloc(0);
124
+ this.flush(open);
125
+ }
126
+ resumed(socket, open) {
127
+ this.socket = socket;
128
+ this.resumeToken = open.resume ?? this.resumeToken;
129
+ const took = open.input ?? this.inputSent;
130
+ if (took < this.inputSent) {
131
+ const from = Math.max(took, this.inputBase) - this.inputBase;
132
+ const again = this.inputLog.subarray(from);
133
+ this.inputSent = Math.max(took, this.inputBase);
134
+ for (let at = 0; at < again.length; at += PTY_INPUT_CHUNK) {
135
+ this.writeNow(0x2, again.subarray(at, at + PTY_INPUT_CHUNK));
136
+ }
137
+ }
138
+ this.flush(open);
139
+ }
140
+ flush(open) {
141
+ const queued = this.pending ?? [];
142
+ this.pending = undefined;
143
+ if (open.cols !== this.cols || open.rows !== this.rows) {
144
+ this.resize({ cols: this.cols, rows: this.rows });
145
+ }
146
+ for (const f of queued)
147
+ this.writeNow(f.opcode, f.payload);
148
+ }
149
+ writeNow(opcode, payload) {
108
150
  if (this.closed || this.socket.destroyed)
109
151
  return;
110
152
  this.socket.write(encodeFrame(opcode, payload));
153
+ if (opcode === 0x2) {
154
+ this.inputSent += payload.length;
155
+ this.inputLog = Buffer.concat([this.inputLog, payload]);
156
+ if (this.inputLog.length > PTY_INPUT_KEEP) {
157
+ const drop = this.inputLog.length - PTY_INPUT_KEEP;
158
+ this.inputLog = this.inputLog.subarray(drop);
159
+ this.inputBase += drop;
160
+ }
161
+ }
162
+ }
163
+ get hungUp() {
164
+ return this.closed;
165
+ }
166
+ send(opcode, payload) {
167
+ if (this.closed)
168
+ return;
169
+ if (this.pending) {
170
+ if (this.pending.reduce((n, f) => n + f.payload.length, 0) + payload.length <= 1 << 20) {
171
+ this.pending.push({ opcode, payload: Buffer.from(payload) });
172
+ }
173
+ return;
174
+ }
175
+ this.writeNow(opcode, payload);
111
176
  }
112
177
  write(data) {
113
178
  const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : data;
@@ -134,16 +199,49 @@ export class PtySession {
134
199
  disconnect() {
135
200
  if (this.closed)
136
201
  return;
202
+ this.pending = undefined;
137
203
  this.send(0x8, Buffer.from([0x03, 0xe8]));
138
204
  this.closed = true;
139
205
  this.socket.end();
140
206
  }
141
207
  }
142
- export function connectPty(opts) {
143
- const url = new URL(opts.url);
208
+ export const PTY_RECONNECT_CODE = 1012;
209
+ export const PTY_RECONNECT_FOR_MS = 30_000;
210
+ export const PTY_RECONNECT_FIRST_WAIT_MS = 250;
211
+ export const PTY_RECONNECT_MAX_WAIT_MS = 4_000;
212
+ export const PTY_RESTART_EXIT = {
213
+ exitCode: null,
214
+ signal: null,
215
+ error: {
216
+ code: "service_restart",
217
+ message: "the service restarted and the terminal could not reconnect within 30 s; open it again",
218
+ },
219
+ };
220
+ export const PTY_RESTART_CLOSED_EXIT = {
221
+ exitCode: null,
222
+ signal: null,
223
+ error: { code: "service_restart", message: "the service restarted and closed the terminal; open it again" },
224
+ };
225
+ const CLOSED_EXIT = {
226
+ exitCode: null,
227
+ signal: null,
228
+ error: { code: "closed", message: "the terminal connection closed without an exit record" },
229
+ };
230
+ class LegRefused {
231
+ status;
232
+ error;
233
+ restart;
234
+ constructor(status, error, restart = false) {
235
+ this.status = status;
236
+ this.error = error;
237
+ this.restart = restart;
238
+ }
239
+ }
240
+ function openLeg(opts, url, resume) {
144
241
  const key = randomBytes(16).toString("base64");
145
242
  const req = requestVia(url, {
146
243
  method: "GET",
244
+ agent: false,
147
245
  headers: {
148
246
  connection: "Upgrade",
149
247
  upgrade: "websocket",
@@ -152,58 +250,51 @@ export function connectPty(opts) {
152
250
  authorization: `Bearer ${opts.apiKey}`,
153
251
  "user-agent": opts.userAgent,
154
252
  ...(opts.caller ? { [CALLER_HEADER]: opts.caller } : {}),
155
- ...(opts.command
253
+ ...(resume ? { [PTY_RESUME_HEADER]: resume } : {}),
254
+ ...(opts.command && !resume
156
255
  ? { [PTY_COMMAND_HEADER]: Buffer.from(opts.command, "utf8").toString("base64") }
157
256
  : {}),
158
- ...(opts.secrets?.length
257
+ ...(opts.secrets?.length && !resume
159
258
  ? {
160
259
  [PTY_SECRETS_HEADER]: Buffer.from(JSON.stringify(opts.secrets), "utf8").toString("base64"),
161
260
  }
162
261
  : {}),
163
262
  },
164
263
  });
165
- return new Promise((resolve, reject) => {
264
+ let resolveEnded;
265
+ const ended = new Promise((r) => { resolveEnded = r; });
266
+ const opened = new Promise((resolve, reject) => {
166
267
  let settled = false;
167
- const fail = (e) => { if (!settled) {
268
+ const fail = (status, body, retryAfter, restart = false) => {
269
+ if (settled)
270
+ return;
168
271
  settled = true;
169
- reject(e);
170
- } };
272
+ reject(new LegRefused(status, opts.refuse(status, body, retryAfter), restart));
273
+ };
171
274
  const timer = setTimeout(() => {
172
275
  req.destroy();
173
- fail(opts.refuse(504, JSON.stringify({ error: { code: "timeout", message: `terminal handshake to ${url.host} took over ${opts.timeoutMs ?? 30_000} ms`, fix: "Retry; if it repeats, check the sandbox is ready (`promigence run report <run_id>`)." } })));
276
+ fail(504, JSON.stringify({ error: { code: "timeout", message: `terminal handshake to ${url.host} took over ${opts.timeoutMs ?? 30_000} ms`, fix: "Retry; if it repeats, check the sandbox is ready (`promigence run report <run_id>`)." } }));
174
277
  }, opts.timeoutMs ?? 30_000);
175
- req.on("error", (e) => { clearTimeout(timer); fail(opts.refuse(0, JSON.stringify({ error: { code: "unreachable", message: `cannot reach ${url.host}: ${e.code ?? e.message}` } }))); });
278
+ req.on("error", (e) => { clearTimeout(timer); fail(0, JSON.stringify({ error: { code: "unreachable", message: `cannot reach ${url.host}: ${e.code ?? e.message}` } })); });
176
279
  req.on("response", (res) => {
177
280
  clearTimeout(timer);
178
281
  const parts = [];
179
282
  res.on("data", (d) => parts.push(d));
180
283
  const retryAfter = res.headers["retry-after"];
181
- res.on("end", () => fail(opts.refuse(res.statusCode ?? 0, Buffer.concat(parts).toString("utf8"), typeof retryAfter === "string" ? retryAfter : undefined)));
284
+ res.on("end", () => fail(res.statusCode ?? 0, Buffer.concat(parts).toString("utf8"), typeof retryAfter === "string" ? retryAfter : undefined));
182
285
  });
183
286
  req.on("upgrade", (res, socket, head) => {
184
287
  clearTimeout(timer);
185
288
  if (res.headers["sec-websocket-accept"] !== wsAccept(key)) {
186
289
  socket.destroy();
187
- fail(opts.refuse(502, JSON.stringify({ error: { code: "internal", message: "the terminal server answered the WebSocket handshake with the wrong Sec-WebSocket-Accept" } })));
290
+ fail(502, JSON.stringify({ error: { code: "internal", message: "the terminal server answered the WebSocket handshake with the wrong Sec-WebSocket-Accept" } }));
188
291
  return;
189
292
  }
190
293
  socket.setNoDelay(true);
191
294
  const reader = new FrameReader();
192
295
  let exit;
193
- let session;
194
- let resolveDone;
195
- const done = new Promise((r) => { resolveDone = r; });
196
- let finished = false;
197
- const finish = () => {
198
- if (finished)
199
- return;
200
- finished = true;
201
- const e = exit ?? { exitCode: null, signal: null, error: { code: "closed", message: "the terminal connection closed without an exit record" } };
202
- opts.onExit?.(e);
203
- resolveDone(e);
204
- if (!settled)
205
- fail(opts.refuse(502, JSON.stringify({ error: { code: e.error?.code ?? "internal", message: e.error?.message ?? "the terminal ended before it opened" } })));
206
- };
296
+ let closeCode;
297
+ let wasOpen = false;
207
298
  const onFrames = (chunk) => {
208
299
  for (const f of reader.push(chunk)) {
209
300
  if (f.opcode === 0x2) {
@@ -217,14 +308,18 @@ export function connectPty(opts) {
217
308
  catch {
218
309
  continue;
219
310
  }
220
- if (v["event"] === "open" && !session) {
221
- session = new PtySession(socket, {
311
+ if (v["event"] === "open" && !wasOpen) {
312
+ wasOpen = true;
313
+ settled = true;
314
+ resolve({
315
+ socket,
222
316
  pid: typeof v["req_id"] === "number" ? v["req_id"] : 0,
223
317
  cols: Number(v["cols"] ?? 80),
224
318
  rows: Number(v["rows"] ?? 24),
225
- }, done);
226
- settled = true;
227
- resolve(session);
319
+ ...(typeof v["resume"] === "string" ? { resume: v["resume"] } : {}),
320
+ ...(v["reattached"] === true ? { reattached: true } : {}),
321
+ ...(typeof v["input"] === "number" ? { input: v["input"] } : {}),
322
+ });
228
323
  }
229
324
  else if (v["event"] === "exit") {
230
325
  exit = {
@@ -244,17 +339,158 @@ export function connectPty(opts) {
244
339
  socket.write(encodeFrame(0xa, f.payload));
245
340
  }
246
341
  else if (f.opcode === 0x8) {
342
+ if (closeCode === undefined)
343
+ closeCode = f.payload.length >= 2 ? f.payload.readUInt16BE(0) : 1005;
247
344
  if (!socket.destroyed)
248
345
  socket.end(encodeFrame(0x8, f.payload.subarray(0, 2)));
249
346
  }
250
347
  }
251
348
  };
252
349
  socket.on("data", onFrames);
253
- socket.on("close", finish);
350
+ socket.on("close", () => {
351
+ if (!wasOpen) {
352
+ const restart = closeCode === PTY_RECONNECT_CODE && exit === undefined;
353
+ const e = restart ? PTY_RESTART_CLOSED_EXIT : (exit ?? CLOSED_EXIT);
354
+ fail(restart ? 503 : 502, JSON.stringify({ error: { code: e.error?.code ?? "internal", message: e.error?.message ?? "the terminal ended before it opened" } }), undefined, restart);
355
+ return;
356
+ }
357
+ resolveEnded({ code: closeCode, exit });
358
+ });
254
359
  socket.on("error", () => { });
255
360
  if (head.length)
256
361
  onFrames(head);
257
362
  });
258
363
  req.end();
259
364
  });
365
+ return { opened, ended };
366
+ }
367
+ function sizedUrl(url, size) {
368
+ const u = new URL(url);
369
+ u.searchParams.set("cols", String(Math.floor(size.cols)));
370
+ u.searchParams.set("rows", String(Math.floor(size.rows)));
371
+ return u;
372
+ }
373
+ function resumeUrl(url, size, received) {
374
+ const u = sizedUrl(url, size);
375
+ u.searchParams.set("received", String(received));
376
+ return u;
377
+ }
378
+ function worthRetrying(status) {
379
+ return status === 0 || status === 429 || status >= 500;
380
+ }
381
+ export function connectPty(opts) {
382
+ const reconnect = opts.reconnect ?? opts.command === undefined;
383
+ const resumeOn = opts.resume ?? true;
384
+ let received = 0;
385
+ const legOpts = {
386
+ ...opts,
387
+ onData: (d) => {
388
+ received += d.length;
389
+ opts.onData?.(d);
390
+ },
391
+ };
392
+ let resolveDone;
393
+ const done = new Promise((r) => { resolveDone = r; });
394
+ let finished = false;
395
+ const finish = (e) => {
396
+ if (finished)
397
+ return;
398
+ finished = true;
399
+ opts.onExit?.(e);
400
+ resolveDone(e);
401
+ };
402
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
403
+ const follow = (session, leg) => {
404
+ void leg.ended.then(async ({ code, exit }) => {
405
+ if (exit !== undefined || session.hungUp) {
406
+ finish(exit ?? CLOSED_EXIT);
407
+ return;
408
+ }
409
+ const restart = code === PTY_RECONNECT_CODE;
410
+ const dropped = code === undefined || code === 1006;
411
+ let resumable = resumeOn && session.resumeToken !== undefined && (restart || dropped);
412
+ if (!restart && !resumable) {
413
+ finish(CLOSED_EXIT);
414
+ return;
415
+ }
416
+ if (!resumable && !reconnect) {
417
+ finish(PTY_RESTART_CLOSED_EXIT);
418
+ return;
419
+ }
420
+ session.holdInput();
421
+ const t0 = Date.now();
422
+ for (let attempt = 0;; attempt++) {
423
+ const wait = Math.min(PTY_RECONNECT_FIRST_WAIT_MS * 2 ** attempt, PTY_RECONNECT_MAX_WAIT_MS) * (0.5 + Math.random() / 2);
424
+ if (Date.now() - t0 + wait > (opts.reconnectForMs ?? PTY_RECONNECT_FOR_MS))
425
+ break;
426
+ await sleep(wait);
427
+ if (session.hungUp) {
428
+ finish(CLOSED_EXIT);
429
+ return;
430
+ }
431
+ const next = resumable
432
+ ? openLeg(legOpts, resumeUrl(opts.url, session, received), session.resumeToken)
433
+ : openLeg(legOpts, sizedUrl(opts.url, session));
434
+ try {
435
+ const o = await next.opened;
436
+ if (session.hungUp) {
437
+ o.socket.destroy();
438
+ finish(CLOSED_EXIT);
439
+ return;
440
+ }
441
+ if (resumable) {
442
+ session.resumed(o.socket, o);
443
+ }
444
+ else {
445
+ received = 0;
446
+ session.rebind(o.socket, o);
447
+ }
448
+ follow(session, next);
449
+ opts.onReconnected?.({ resumed: resumable });
450
+ return;
451
+ }
452
+ catch (e) {
453
+ const refused = e instanceof LegRefused ? e : undefined;
454
+ if (resumable && refused?.status === 404) {
455
+ resumable = false;
456
+ if (!restart) {
457
+ finish(CLOSED_EXIT);
458
+ return;
459
+ }
460
+ if (!reconnect) {
461
+ finish(PTY_RESTART_CLOSED_EXIT);
462
+ return;
463
+ }
464
+ continue;
465
+ }
466
+ if (refused && !worthRetrying(refused.status)) {
467
+ const err = refused.error;
468
+ finish({ exitCode: null, signal: null, error: { code: typeof err.code === "string" ? err.code : "internal", message: err.message } });
469
+ return;
470
+ }
471
+ }
472
+ }
473
+ finish(restart ? PTY_RESTART_EXIT : CLOSED_EXIT);
474
+ });
475
+ };
476
+ return (async () => {
477
+ const t0 = Date.now();
478
+ for (let attempt = 0;; attempt++) {
479
+ const first = openLeg(legOpts, new URL(opts.url));
480
+ try {
481
+ const o = await first.opened;
482
+ const session = new PtySession(o.socket, o, done);
483
+ follow(session, first);
484
+ return session;
485
+ }
486
+ catch (e) {
487
+ const wait = Math.min(PTY_RECONNECT_FIRST_WAIT_MS * 2 ** attempt, PTY_RECONNECT_MAX_WAIT_MS) * (0.5 + Math.random() / 2);
488
+ if (e instanceof LegRefused && e.restart && reconnect && Date.now() - t0 + wait <= (opts.reconnectForMs ?? PTY_RECONNECT_FOR_MS)) {
489
+ await sleep(wait);
490
+ continue;
491
+ }
492
+ throw e instanceof LegRefused ? e.error : e;
493
+ }
494
+ }
495
+ })();
260
496
  }
package/dist/sandbox.d.ts CHANGED
@@ -88,6 +88,24 @@ export interface PtyCreateOpts extends Partial<PtySize> {
88
88
  cmd?: string;
89
89
  /** Handshake bound (default 30 s). */
90
90
  timeoutMs?: number;
91
+ /**
92
+ * When the connection drops, or the service is updated, the same terminal is picked up again
93
+ * over a new connection, where it left off: the same shell or command, still running.
94
+ * Default: on.
95
+ */
96
+ resume?: boolean;
97
+ /**
98
+ * When the service is updated and the terminal cannot be picked up again, the session goes on
99
+ * over a new connection in a NEW shell (what ran in the old one has ended), with its callbacks
100
+ * and its size, for up to 30 s of trying; past that it ends with `error.code`
101
+ * `"service_restart"`. Default: on for the login shell, off with `cmd` (reconnecting would start
102
+ * the command again).
103
+ */
104
+ reconnect?: boolean;
105
+ /** Called after each reconnect: `resumed` says whether it is the same terminal or a new shell. */
106
+ onReconnected?: (how: {
107
+ resumed: boolean;
108
+ }) => void;
91
109
  }
92
110
  /** The default terminal command. */
93
111
  export declare const DEFAULT_PTY_CMD = "TERM=${TERM:-xterm-256color} exec /bin/sh -l";
@@ -134,7 +152,6 @@ export interface SandboxCreateOpts extends SandboxOpts {
134
152
  alias?: string;
135
153
  /** Name of a stored secret holding the registry credentials. */
136
154
  registrySecret?: string;
137
- cpuClass?: string;
138
155
  /** Bound on the first build of an image (default 15 min). Expiry cancels nothing. */
139
156
  buildTimeoutMs?: number;
140
157
  /**
@@ -157,6 +174,20 @@ export interface SandboxCreateOpts extends SandboxOpts {
157
174
  * nobody), then it rejects with `TimeoutError` ("aborted by the caller's signal").
158
175
  */
159
176
  signal?: AbortSignal;
177
+ /**
178
+ * Stored secrets the sandbox receives, as environment variables: a name binds the secret to a
179
+ * variable of the same name, `{ name, as }` to a different one. Every command run against the
180
+ * sandbox gets them. As `Sandbox.fork` takes them.
181
+ */
182
+ secrets?: SecretBinding[];
183
+ /**
184
+ * The only destinations the sandbox may reach: `["github.com", "*.npmjs.org"]`. Everything
185
+ * else is refused, and the refusal names the host. It wins over `allowInternetAccess`. As
186
+ * `Sandbox.fork` takes it.
187
+ */
188
+ allowHosts?: string[];
189
+ /** What to favour, `start` or `compute`. As `Sandbox.fork` takes it. */
190
+ optimizeFor?: OptimizeFor;
160
191
  }
161
192
  /**
162
193
  * Create-or-reuse the snapshot for an image digest and return its `snapshot_id`.
@@ -165,7 +196,7 @@ export interface SandboxCreateOpts extends SandboxOpts {
165
196
  * inputs) was built before, or 202 with a `build_id`. The **body**, not the status, decides: an
166
197
  * idempotency replay answers 202 with a document that may already be `verified`.
167
198
  */
168
- export declare function imageSnapshot(client: PromigenceClient, opts: Pick<SandboxCreateOpts, "image" | "setup" | "verify" | "start" | "tier" | "alias" | "registrySecret" | "cpuClass" | "buildTimeoutMs">, hooks?: {
199
+ export declare function imageSnapshot(client: PromigenceClient, opts: Pick<SandboxCreateOpts, "image" | "setup" | "verify" | "start" | "tier" | "alias" | "registrySecret" | "buildTimeoutMs">, hooks?: {
169
200
  /**
170
201
  * The `POST /snapshots` answer, before any wait: `build_price` and whether it was `deduped`
171
202
  * or `joined` (nothing new charged). An implicit build costs money, so a caller
@@ -422,8 +453,7 @@ export declare class Sandbox {
422
453
  ok: boolean;
423
454
  ms: number;
424
455
  checked: string | string[];
425
- first_bad_path: string | null;
426
- probe: Record<string, unknown>;
456
+ first_bad_path?: string | null;
427
457
  }>;
428
458
  isRunning(): Promise<boolean>;
429
459
  /**
package/dist/sandbox.js CHANGED
@@ -87,8 +87,6 @@ export async function imageSnapshot(client, opts, hooks = {}) {
87
87
  create.alias = opts.alias;
88
88
  if (opts.registrySecret !== undefined)
89
89
  create.registrySecret = opts.registrySecret;
90
- if (opts.cpuClass !== undefined)
91
- create.cpuClass = opts.cpuClass;
92
90
  let build = (await client.snapshots.create(create)) ?? {};
93
91
  if (hooks.onBuild)
94
92
  await hooks.onBuild(build);
@@ -319,6 +317,9 @@ export class Sandbox {
319
317
  ...(opts.secrets?.length ? { secrets: opts.secrets } : {}),
320
318
  ...(opts.onData ? { onData: opts.onData } : {}),
321
319
  ...(opts.timeoutMs === undefined ? {} : { timeoutMs: opts.timeoutMs }),
320
+ ...(opts.resume === undefined ? {} : { resume: opts.resume }),
321
+ reconnect: opts.reconnect ?? opts.cmd === undefined,
322
+ ...(opts.onReconnected ? { onReconnected: opts.onReconnected } : {}),
322
323
  onExit: (e) => { this.terminals.delete(pid); opts.onExit?.(e); },
323
324
  });
324
325
  pid = session.pid;
package/dist/ssh.js CHANGED
@@ -7,6 +7,7 @@ export function connectSshTunnel(opts) {
7
7
  const key = randomBytes(16).toString("base64");
8
8
  const req = requestVia(url, {
9
9
  method: "GET",
10
+ agent: false,
10
11
  headers: {
11
12
  connection: "Upgrade",
12
13
  upgrade: "websocket",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promigence/sdk",
3
- "version": "0.1.0-beta.6",
3
+ "version": "0.1.0-beta.8",
4
4
  "description": "The fastest sandboxes for AI agents and code. The Promigence TypeScript SDK: snapshot an environment once, fork it as often as you need, run commands in every copy. A simple Sandbox class and the full PromigenceClient, with zero runtime dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://www.promigence.ai",