@hmharness/kernel 0.6.6 → 0.6.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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loop.js +13 -1
- package/dist/mcp.js +33 -1
- package/dist/provider.d.ts +17 -2
- package/dist/provider.js +66 -13
- package/dist/shellgate.d.ts +24 -0
- package/dist/shellgate.js +34 -0
- package/dist/types.d.ts +12 -2
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/loop.js
CHANGED
|
@@ -72,6 +72,18 @@ export async function runLoop(opts) {
|
|
|
72
72
|
});
|
|
73
73
|
usage.promptTokens += chatRes.usage?.prompt_tokens ?? 0;
|
|
74
74
|
usage.completionTokens += chatRes.usage?.completion_tokens ?? 0;
|
|
75
|
+
// Usage fallback: many OpenAI-compatible gateways never report usage
|
|
76
|
+
// (even with stream_options.include_usage), which used to leave the token
|
|
77
|
+
// valve counting zero - a runaway loop had NO stop. Estimate from the
|
|
78
|
+
// wire text instead (chars/4 heuristic): coarse, but the valve only needs
|
|
79
|
+
// an order of magnitude to trip at 50M.
|
|
80
|
+
if (!chatRes.usage || (!chatRes.usage.prompt_tokens && !chatRes.usage.completion_tokens)) {
|
|
81
|
+
const promptChars = compacted.reduce((n, m) => n + (m.content?.length ?? 0), 0);
|
|
82
|
+
const replyChars = (chatRes.message.content?.length ?? 0)
|
|
83
|
+
+ (chatRes.message.tool_calls ?? []).reduce((n, c) => n + c.function.arguments.length, 0);
|
|
84
|
+
usage.promptTokens += Math.ceil(promptChars / 4);
|
|
85
|
+
usage.completionTokens += Math.ceil(replyChars / 4);
|
|
86
|
+
}
|
|
75
87
|
const { message } = chatRes;
|
|
76
88
|
events?.onAssistant?.(message);
|
|
77
89
|
const calls = message.tool_calls ?? [];
|
|
@@ -105,7 +117,7 @@ export async function runLoop(opts) {
|
|
|
105
117
|
p.isError = true;
|
|
106
118
|
p.skip = true;
|
|
107
119
|
}
|
|
108
|
-
else if (tool.needsApproval?.(args)) {
|
|
120
|
+
else if (tool.needsApproval?.(args, ctx)) {
|
|
109
121
|
// Safe default: with no gate wired in, risky tools are denied.
|
|
110
122
|
const granted = opts.approval ? await opts.approval.ask(name, args) : false;
|
|
111
123
|
events?.onApproval?.(name, args, granted);
|
package/dist/mcp.js
CHANGED
|
@@ -14,6 +14,11 @@ function nextId() {
|
|
|
14
14
|
nextId.n = (nextId.n ?? 0) + 1;
|
|
15
15
|
return nextId.n;
|
|
16
16
|
}
|
|
17
|
+
/** Transport-level failure worth one retry (and worth explaining in the tool
|
|
18
|
+
* result instead of leaking a bare "TypeError: fetch failed"). */
|
|
19
|
+
function isTransientTransport(msg) {
|
|
20
|
+
return /fetch failed|terminated|ECONN|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|timeout|abort|socket|premature close|UND_ERR/i.test(msg);
|
|
21
|
+
}
|
|
17
22
|
(function (nextId) {
|
|
18
23
|
})(nextId || (nextId = {}));
|
|
19
24
|
export class McpClient {
|
|
@@ -161,7 +166,25 @@ export class McpClient {
|
|
|
161
166
|
const ctrl = new AbortController();
|
|
162
167
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
163
168
|
try {
|
|
164
|
-
|
|
169
|
+
// one retry for transient transport failures: a dead/restarting MCP
|
|
170
|
+
// server used to surface a bare "TypeError: fetch failed" straight to
|
|
171
|
+
// the model as the tool result
|
|
172
|
+
let res;
|
|
173
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
174
|
+
try {
|
|
175
|
+
res = await fetch(cfg.url, { method: 'POST', headers, body: JSON.stringify(payload), signal: ctrl.signal });
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
if (attempt === 0 && isTransientTransport(String(err))) {
|
|
180
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!res)
|
|
187
|
+
throw new Error('no response');
|
|
165
188
|
const sid = res.headers.get('mcp-session-id');
|
|
166
189
|
if (sid)
|
|
167
190
|
this.sessionId = sid;
|
|
@@ -177,6 +200,15 @@ export class McpClient {
|
|
|
177
200
|
}
|
|
178
201
|
return body;
|
|
179
202
|
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
const msg = String(err);
|
|
205
|
+
if (/^mcp\//.test(msg))
|
|
206
|
+
throw err; // already annotated (HTTP status, parse)
|
|
207
|
+
const hint = isTransientTransport(msg)
|
|
208
|
+
? ` - the MCP server at ${cfg.url} is unreachable or cut the connection`
|
|
209
|
+
: '';
|
|
210
|
+
throw new Error(`mcp/${this.serverName}: request failed (${msg})${hint}`);
|
|
211
|
+
}
|
|
180
212
|
finally {
|
|
181
213
|
clearTimeout(timer);
|
|
182
214
|
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -14,11 +14,26 @@ export interface ChatResponse {
|
|
|
14
14
|
completion_tokens?: number;
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
-
export type DeltaKind = 'text' | 'reasoning';
|
|
17
|
+
export type DeltaKind = 'text' | 'reasoning' | 'reset';
|
|
18
|
+
/** Parse a Retry-After header into a delay in ms (MDN: exactly two legal
|
|
19
|
+
* forms - delta-seconds or HTTP-date), clamped to [0, 120s]. Returns 0 when
|
|
20
|
+
* absent/illegal (caller falls back to exponential backoff). Deliberately
|
|
21
|
+
* does NOT read x-ratelimit-reset: that header is an epoch timestamp in the
|
|
22
|
+
* wild, which naive Number() parsing turned into a ~1.7e12 ms setTimeout
|
|
23
|
+
* that overflowed and fired immediately - a retry storm. */
|
|
24
|
+
export declare function parseRetryAfterMs(raw: string | null | undefined, maxMs?: number): number;
|
|
18
25
|
export interface ChatOptions {
|
|
19
26
|
timeoutMs?: number;
|
|
20
|
-
/** Streaming callback; presence switches the request to stream:true.
|
|
27
|
+
/** Streaming callback; presence switches the request to stream:true.
|
|
28
|
+
* kind 'reset' carries an empty chunk and means: the previous attempt died
|
|
29
|
+
* mid-stream and is being retried - discard what you streamed for it. */
|
|
21
30
|
onDelta?(kind: DeltaKind, chunk: string): void;
|
|
31
|
+
/** Retry policy for transient failures. Defaults: 6 attempts, 3s base with
|
|
32
|
+
* exponential backoff and jitter, capped at 60s per wait. */
|
|
33
|
+
retry?: {
|
|
34
|
+
attempts?: number;
|
|
35
|
+
baseMs?: number;
|
|
36
|
+
};
|
|
22
37
|
}
|
|
23
38
|
export declare function chat(cfg: ProviderConfig, messages: ChatMessage[], tools?: unknown[], opts?: ChatOptions): Promise<ChatResponse>;
|
|
24
39
|
/** Accept bases with any /vN suffix (v1 OpenAI convention, v4 zhipu coding
|
package/dist/provider.js
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
/** Transient failures worth retrying. `terminated` is undici's TypeError for
|
|
2
|
+
* a socket cut mid-body (gateway dropped a long stream, proxy reset, server
|
|
3
|
+
* restart) - the case that used to kill the whole task with a raw
|
|
4
|
+
* `TypeError: terminated` because it matched none of the old patterns. */
|
|
5
|
+
const TRANSIENT_RE = /abort|terminated|fetch failed|ECONN|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|timeout|socket hang up|other side closed|premature close|UND_ERR|EPIPE|no response body/i;
|
|
6
|
+
/** Human hint for the failure classes users actually hit. */
|
|
7
|
+
function transientHint(msg) {
|
|
8
|
+
if (/terminated|socket hang up|other side closed|premature close/i.test(msg)) {
|
|
9
|
+
return ' - the connection was cut mid-response (gateway dropped a long stream or a proxy reset it)';
|
|
10
|
+
}
|
|
11
|
+
if (/abort|timeout/i.test(msg))
|
|
12
|
+
return ' - the provider stopped sending data (idle/timeout)';
|
|
13
|
+
if (/fetch failed|ECONN|EAI_AGAIN|ENOTFOUND|UND_ERR/i.test(msg))
|
|
14
|
+
return ' - the provider endpoint was unreachable (network/DNS)';
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
/** Parse a Retry-After header into a delay in ms (MDN: exactly two legal
|
|
18
|
+
* forms - delta-seconds or HTTP-date), clamped to [0, 120s]. Returns 0 when
|
|
19
|
+
* absent/illegal (caller falls back to exponential backoff). Deliberately
|
|
20
|
+
* does NOT read x-ratelimit-reset: that header is an epoch timestamp in the
|
|
21
|
+
* wild, which naive Number() parsing turned into a ~1.7e12 ms setTimeout
|
|
22
|
+
* that overflowed and fired immediately - a retry storm. */
|
|
23
|
+
export function parseRetryAfterMs(raw, maxMs = 120_000) {
|
|
24
|
+
if (!raw)
|
|
25
|
+
return 0;
|
|
26
|
+
const v = raw.trim();
|
|
27
|
+
if (/^\d{1,10}$/.test(v))
|
|
28
|
+
return Math.min(maxMs, Number(v) * 1000);
|
|
29
|
+
const at = Date.parse(v);
|
|
30
|
+
if (!Number.isNaN(at))
|
|
31
|
+
return Math.min(maxMs, Math.max(0, at - Date.now()));
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
1
34
|
export async function chat(cfg, messages, tools, opts = {}) {
|
|
2
35
|
const streaming = typeof opts.onDelta === 'function';
|
|
3
36
|
const body = { model: cfg.model, messages };
|
|
@@ -16,13 +49,21 @@ export async function chat(cfg, messages, tools, opts = {}) {
|
|
|
16
49
|
let authScheme = cfg.authHeader ?? 'bearer';
|
|
17
50
|
let lastError = '';
|
|
18
51
|
// Resilient retry: exponential backoff with jitter, up to 6 attempts
|
|
19
|
-
// (Codex-style fire-and-forget: network hiccups, 429s,
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
const
|
|
52
|
+
// (Codex-style fire-and-forget: network hiccups, 429s, provider restarts,
|
|
53
|
+
// and mid-stream socket cuts should NEVER kill a long-running task).
|
|
54
|
+
const MAX_RETRIES = opts.retry?.attempts ?? 6;
|
|
55
|
+
const BASE_MS = opts.retry?.baseMs ?? 3000;
|
|
23
56
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
24
57
|
const ctrl = new AbortController();
|
|
25
58
|
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? cfg.timeoutMs ?? 120_000);
|
|
59
|
+
// Did this attempt stream anything to the UI before dying? A mid-stream
|
|
60
|
+
// cut leaves partial deltas on screen; retrying would duplicate them, so
|
|
61
|
+
// clients are told to drop the partial block first (DeltaKind 'reset').
|
|
62
|
+
let emitted = false;
|
|
63
|
+
const emit = streaming
|
|
64
|
+
? (kind, chunk) => { if (kind !== 'reset')
|
|
65
|
+
emitted = true; opts.onDelta(kind, chunk); }
|
|
66
|
+
: undefined;
|
|
26
67
|
try {
|
|
27
68
|
const res = await fetch(endpoint(cfg.baseUrl), {
|
|
28
69
|
method: 'POST',
|
|
@@ -31,9 +72,10 @@ export async function chat(cfg, messages, tools, opts = {}) {
|
|
|
31
72
|
signal: ctrl.signal,
|
|
32
73
|
});
|
|
33
74
|
if (res.status === 429) {
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
75
|
+
// Retry-After per RFC/MDN has two legal forms: delta-seconds ("120")
|
|
76
|
+
// or HTTP-date ("Wed, 21 Oct 2015 07:28:00 GMT"). See
|
|
77
|
+
// parseRetryAfterMs for why x-ratelimit-reset is deliberately ignored.
|
|
78
|
+
const delay = parseRetryAfterMs(res.headers.get('retry-after')) || Math.min(30_000, 2000 * Math.pow(2, attempt));
|
|
37
79
|
lastError = `HTTP 429 (rate limited): ${(await res.text()).slice(0, 200)}`;
|
|
38
80
|
await sleep(delay + Math.random() * 1000); // jitter
|
|
39
81
|
continue;
|
|
@@ -54,7 +96,7 @@ export async function chat(cfg, messages, tools, opts = {}) {
|
|
|
54
96
|
}
|
|
55
97
|
if (streaming) {
|
|
56
98
|
clearTimeout(timer);
|
|
57
|
-
return await consumeStream(res,
|
|
99
|
+
return await consumeStream(res, emit);
|
|
58
100
|
}
|
|
59
101
|
const data = (await res.json());
|
|
60
102
|
const choice = data.choices?.[0];
|
|
@@ -71,10 +113,14 @@ export async function chat(cfg, messages, tools, opts = {}) {
|
|
|
71
113
|
}
|
|
72
114
|
catch (err) {
|
|
73
115
|
lastError = String(err);
|
|
74
|
-
// transient (network, timeout, connection reset): retry
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
116
|
+
// transient (network, timeout, connection reset, mid-stream cut): retry
|
|
117
|
+
if (TRANSIENT_RE.test(lastError)) {
|
|
118
|
+
// the attempt already painted partial text: tell clients to drop it so
|
|
119
|
+
// the retry does not append a second copy of the same answer
|
|
120
|
+
if (emitted)
|
|
121
|
+
opts.onDelta?.('reset', '');
|
|
122
|
+
const delay = Math.min(60_000, BASE_MS * Math.pow(2, attempt));
|
|
123
|
+
await sleep(delay + Math.random() * Math.min(2000, BASE_MS));
|
|
78
124
|
continue;
|
|
79
125
|
}
|
|
80
126
|
throw err; // permanent error (bad JSON, logic error): don't retry
|
|
@@ -83,10 +129,12 @@ export async function chat(cfg, messages, tools, opts = {}) {
|
|
|
83
129
|
clearTimeout(timer);
|
|
84
130
|
}
|
|
85
131
|
}
|
|
86
|
-
throw new Error(`provider: failed after ${MAX_RETRIES}
|
|
132
|
+
throw new Error(`provider: failed after ${MAX_RETRIES} attempts (${cfg.baseUrl}): ${lastError}${transientHint(lastError)}`);
|
|
87
133
|
}
|
|
88
134
|
/** Assemble a ChatResponse from an SSE stream, emitting deltas as they land. */
|
|
89
135
|
async function consumeStream(res, onDelta) {
|
|
136
|
+
if (!res.body)
|
|
137
|
+
throw new TypeError('provider: no response body (streaming requested)');
|
|
90
138
|
const reader = res.body.getReader();
|
|
91
139
|
const decoder = new TextDecoder();
|
|
92
140
|
let buf = '';
|
|
@@ -166,6 +214,11 @@ async function consumeStream(res, onDelta) {
|
|
|
166
214
|
}
|
|
167
215
|
finally {
|
|
168
216
|
clearTimeout(idleTimer);
|
|
217
|
+
// Cancel the body rather than releaseLock(): when the idle guard won the
|
|
218
|
+
// race, reader.read() is still outstanding and releaseLock() throws
|
|
219
|
+
// "Cannot release a readable stream reader..." - which would REPLACE the
|
|
220
|
+
// real error (a mid-stream cut / idle timeout) with a meaningless one.
|
|
221
|
+
await reader.cancel().catch(() => undefined);
|
|
169
222
|
reader.releaseLock();
|
|
170
223
|
}
|
|
171
224
|
const ordered = [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([, c]) => c);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/kernel - shell fast-path gate
|
|
3
|
+
*
|
|
4
|
+
* One shared implementation for every "run this without an approval card"
|
|
5
|
+
* decision over remotely/exec-ed shell strings (the agent's ssh_run tool and
|
|
6
|
+
* the web /api/ssh proxy). String allowlists are bypassable in general -
|
|
7
|
+
* documented bypass classes include command substitution `$()`/backticks
|
|
8
|
+
* (cli-mcp-server 0.2.5, CVE-2026-28470), `&&`-blind-spots (Claude Code),
|
|
9
|
+
* and argument-level execution such as `find -exec`/`-delete`
|
|
10
|
+
* (GHSA-cv3g-hj65-pcfh). The design here closes all of them at once:
|
|
11
|
+
*
|
|
12
|
+
* fast path = ONE bare read-only verb + plain arguments + ZERO shell
|
|
13
|
+
* metacharacters anywhere (no $ ` ( ) { } < > | ; & \ quotes/newlines)
|
|
14
|
+
* and no verb whose own ARGUMENTS can execute or mutate (find, echo,
|
|
15
|
+
* xargs, awk, date -s are excluded outright).
|
|
16
|
+
*
|
|
17
|
+
* Everything else returns false - i.e. needs an approval card. Fail closed:
|
|
18
|
+
* an unknown or oddly-shaped command is never a fast path.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* True only for a bare read-only probe (e.g. `df -h`, `ps aux`, `cat /etc/hosts`).
|
|
22
|
+
* Callers: `if (!isBareProbe(cmd)) → approval required`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isBareProbe(cmd: string): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/kernel - shell fast-path gate
|
|
3
|
+
*
|
|
4
|
+
* One shared implementation for every "run this without an approval card"
|
|
5
|
+
* decision over remotely/exec-ed shell strings (the agent's ssh_run tool and
|
|
6
|
+
* the web /api/ssh proxy). String allowlists are bypassable in general -
|
|
7
|
+
* documented bypass classes include command substitution `$()`/backticks
|
|
8
|
+
* (cli-mcp-server 0.2.5, CVE-2026-28470), `&&`-blind-spots (Claude Code),
|
|
9
|
+
* and argument-level execution such as `find -exec`/`-delete`
|
|
10
|
+
* (GHSA-cv3g-hj65-pcfh). The design here closes all of them at once:
|
|
11
|
+
*
|
|
12
|
+
* fast path = ONE bare read-only verb + plain arguments + ZERO shell
|
|
13
|
+
* metacharacters anywhere (no $ ` ( ) { } < > | ; & \ quotes/newlines)
|
|
14
|
+
* and no verb whose own ARGUMENTS can execute or mutate (find, echo,
|
|
15
|
+
* xargs, awk, date -s are excluded outright).
|
|
16
|
+
*
|
|
17
|
+
* Everything else returns false - i.e. needs an approval card. Fail closed:
|
|
18
|
+
* an unknown or oddly-shaped command is never a fast path.
|
|
19
|
+
*/
|
|
20
|
+
/** Metacharacters that enable substitution, chaining, or redirection. */
|
|
21
|
+
const SHELL_METACHARS = /[$`(){}<>|;&'"\\\n\r]/;
|
|
22
|
+
/** Verbs safe to run with plain arguments only (read-only, no exec-in-args). */
|
|
23
|
+
const BARE_PROBE_VERBS = /^(ls|cat|head|tail|df|du|free|uptime|whoami|hostname|uname|ps|grep|wc|id|pwd|date)(\s+[A-Za-z0-9_.:@/=,-]+)*\s*$/;
|
|
24
|
+
/**
|
|
25
|
+
* True only for a bare read-only probe (e.g. `df -h`, `ps aux`, `cat /etc/hosts`).
|
|
26
|
+
* Callers: `if (!isBareProbe(cmd)) → approval required`.
|
|
27
|
+
*/
|
|
28
|
+
export function isBareProbe(cmd) {
|
|
29
|
+
if (!cmd || SHELL_METACHARS.test(cmd))
|
|
30
|
+
return false;
|
|
31
|
+
if (/\bdate\b/.test(cmd) && /\s--?s(e[rt])?\b/.test(cmd))
|
|
32
|
+
return false; // clock set
|
|
33
|
+
return BARE_PROBE_VERBS.test(cmd);
|
|
34
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface Tool {
|
|
|
33
33
|
* false means read-only / safe. Remote (MCP) tools default to needing
|
|
34
34
|
* approval unless their server is marked trusted.
|
|
35
35
|
*/
|
|
36
|
-
needsApproval?(args: Record<string, unknown
|
|
36
|
+
needsApproval?(args: Record<string, unknown>, ctx?: ToolContext): boolean;
|
|
37
37
|
execute(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
|
|
38
38
|
}
|
|
39
39
|
/** OpenAI-style chat message, reused across provider adapters. */
|
|
@@ -106,8 +106,18 @@ export interface HmhConfig {
|
|
|
106
106
|
* error self-notes (every task, zero cost), Tier 2 = one model-call
|
|
107
107
|
* lesson per erroring task (instant reflection), Tier 3 = this - full
|
|
108
108
|
* cycle with bench gate. Guards unchanged: double-gate, holdout, poison
|
|
109
|
-
* screen
|
|
109
|
+
* screen; writes skills/ + memory/ + evolution logs, and repo code only
|
|
110
|
+
* when evolution.autoPatch is explicitly enabled. */
|
|
110
111
|
autoEvolveEvery?: number;
|
|
112
|
+
/** Evolution-cycle options. autoPatch (default FALSE) opts into code-level
|
|
113
|
+
* self-evolution (the DGM-style sandboxed patch loop). Off by default:
|
|
114
|
+
* self-modifying systems are unsafe by default (the DGM paper's own
|
|
115
|
+
* framing) - with it off, evolution writes only skills/ + memory/ + its
|
|
116
|
+
* own logs, never repo code. */
|
|
117
|
+
evolution?: {
|
|
118
|
+
/** Enable the code-patch (self-modification) step. Default false. */
|
|
119
|
+
autoPatch?: boolean;
|
|
120
|
+
};
|
|
111
121
|
/** Named vendor endpoints for multi-provider routing. */
|
|
112
122
|
providers?: Record<string, ProviderConfig>;
|
|
113
123
|
/** Per-purpose provider names resolved against `providers`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/kernel",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "hmharness kernel: tool registry, provider adapters, the agent loop, session log, config. Zero runtime dependencies (Node >=22 native fetch).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|