melchizedek-agents 0.17.0 → 0.18.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/.env.example +23 -3
- package/AGENT_SETUP.md +2 -1
- package/CHANGELOG.md +104 -0
- package/DOCUMENTATION.md +75 -9
- package/config/agents/syndicateSchema.yaml +13 -0
- package/db/hardening.sql +4 -3
- package/db/migrations/0012_audit_log.sql +98 -0
- package/dist/lib/a2a/app.d.ts +20 -0
- package/dist/lib/a2a/app.js +94 -4
- package/dist/lib/a2a/executor.d.ts +34 -4
- package/dist/lib/a2a/executor.js +68 -9
- package/dist/lib/compile.d.ts +1 -2
- package/dist/lib/compile.js +33 -6
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.js +7 -0
- package/dist/lib/loadEnv.d.ts +5 -0
- package/dist/lib/loadEnv.js +7 -0
- package/dist/lib/loadSyndicate.d.ts +4 -0
- package/dist/lib/memory/providers.d.ts +27 -0
- package/dist/lib/memory/providers.js +28 -0
- package/dist/lib/models/fallback.d.ts +41 -0
- package/dist/lib/models/fallback.js +105 -0
- package/dist/lib/observability/audit.d.ts +21 -0
- package/dist/lib/observability/audit.js +39 -0
- package/dist/lib/observability/metrics.d.ts +6 -0
- package/dist/lib/observability/tracer.d.ts +13 -0
- package/dist/lib/observability/tracer.js +16 -2
- package/dist/lib/runtime/syndicateTurn.d.ts +3 -1
- package/dist/lib/runtime/syndicateTurn.js +3 -1
- package/dist/lib/storage/postgres/index.d.ts +3 -0
- package/dist/lib/storage/postgres/index.js +2 -0
- package/dist/lib/syndicateSchema.d.ts +8 -0
- package/dist/lib/syndicateSchema.js +19 -4
- package/dist/lib/tools/mcpToolFactory.d.ts +26 -0
- package/dist/lib/tools/mcpToolFactory.js +61 -4
- package/dist/lib/tools/openapiTools.d.ts +6 -0
- package/dist/lib/tools/openapiTools.js +31 -1
- package/dist/scripts/a2a_server.js +51 -8
- package/dist/scripts/db.d.ts +9 -0
- package/dist/scripts/db.js +30 -15
- package/package.json +3 -3
|
@@ -20,7 +20,12 @@
|
|
|
20
20
|
* (ADR 0028), so a POST or a DELETE can wait for a person.
|
|
21
21
|
* - SECRETS FROM THE ENVIRONMENT. `auth` names an environment variable,
|
|
22
22
|
* never a value; a variable that is not set fails the compile, so an
|
|
23
|
-
* agent never runs half-authenticated.
|
|
23
|
+
* agent never runs half-authenticated. A YAML (possibly registry-stored)
|
|
24
|
+
* chooses both the variable and the host, so it may never name one of
|
|
25
|
+
* the framework's own secrets (credentialEnvProblem): the database URL,
|
|
26
|
+
* a provider key or an A2A secret cannot be sent to an API.
|
|
27
|
+
* OPENAPI_CREDENTIAL_ENVS, when the operator sets it, is the exact list
|
|
28
|
+
* of variables an `auth` may name.
|
|
24
29
|
* - THE SSRF GUARD. Every server URL the tools will call must be http(s)
|
|
25
30
|
* and pass lib/net/addressGuard.ts: its literal rules at compile time
|
|
26
31
|
* (offline), and the full check with DNS before each call (a name can
|
|
@@ -83,10 +88,35 @@ async function hostProblem(baseUrl, resolve) {
|
|
|
83
88
|
const reason = resolve ? await checkHost(url.hostname) : blockedHostReason(url.hostname);
|
|
84
89
|
return reason ? `refusing ${url.hostname}: ${reason} (set ALLOW_PRIVATE_OPENAPI=true for local development)` : null;
|
|
85
90
|
}
|
|
91
|
+
/** Prefixes and names of variables the framework itself reads: never an API credential. */
|
|
92
|
+
const FRAMEWORK_ENV_PREFIXES = [
|
|
93
|
+
'A2A_', 'SUPABASE_', 'DATABASE_', 'MCP_', 'MODEL_', 'MEMORY_', 'OTEL_', 'TELEMETRY_', 'MELCHIZEDEK_',
|
|
94
|
+
'GOOGLE_', 'GEMINI_', 'ANTHROPIC_', 'OPENAI_', 'AZURE_', 'AWS_', 'XAI_', 'MOONSHOT_', 'OLLAMA_', 'WIKI_',
|
|
95
|
+
'ALLOW_', 'OPENAPI_',
|
|
96
|
+
];
|
|
97
|
+
const FRAMEWORK_ENV_NAMES = new Set(['PUBLIC_URL', 'HOST', 'PORT', 'PATH', 'HOME', 'NODE_OPTIONS', 'WEB_EXTRACT_CHAR_LIMIT']);
|
|
98
|
+
/**
|
|
99
|
+
* Why an `auth` may not read this variable, or null. With
|
|
100
|
+
* OPENAPI_CREDENTIAL_ENVS set, only the names it lists are allowed; without
|
|
101
|
+
* it, anything but the framework's own variables is.
|
|
102
|
+
*/
|
|
103
|
+
export function credentialEnvProblem(name, env = process.env) {
|
|
104
|
+
const allow = env.OPENAPI_CREDENTIAL_ENVS?.split(',').map((s) => s.trim()).filter(Boolean);
|
|
105
|
+
if (allow?.length) {
|
|
106
|
+
return allow.includes(name) ? null : `${name} is not in OPENAPI_CREDENTIAL_ENVS (${allow.join(', ')})`;
|
|
107
|
+
}
|
|
108
|
+
if (FRAMEWORK_ENV_NAMES.has(name) || FRAMEWORK_ENV_PREFIXES.some((p) => name.startsWith(p))) {
|
|
109
|
+
return `${name} is one of the framework's own settings and may not be sent to an API; give the API its own variable (or list it in OPENAPI_CREDENTIAL_ENVS)`;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
86
113
|
function credentialFor(auth, spec) {
|
|
87
114
|
if (!auth)
|
|
88
115
|
return {};
|
|
89
116
|
const read = (env) => {
|
|
117
|
+
const refused = credentialEnvProblem(env);
|
|
118
|
+
if (refused)
|
|
119
|
+
throw new Error(`openapi ${spec}: ${refused}`);
|
|
90
120
|
const value = process.env[env]?.trim();
|
|
91
121
|
if (!value)
|
|
92
122
|
throw new Error(`openapi ${spec}: ${env} is not set (auth reads it from the environment)`);
|
|
@@ -25,7 +25,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
25
25
|
* with A2A_AUTH=callers it stays valid beside the caller
|
|
26
26
|
* tokens, with its old scoping, until you remove it
|
|
27
27
|
* A2A_AUTH who a caller is (ADR 0025): secret (default) | callers |
|
|
28
|
-
* jwt | header
|
|
28
|
+
* jwt | header (required with PUBLIC_URL, ADR 0039)
|
|
29
29
|
* A2A_CALLERS callers: `name:sha256[:scope]; …` — one token per calling
|
|
30
30
|
* backend. Mint: melchizedek-serve --new-caller <name>
|
|
31
31
|
* A2A_JWT_JWKS_URL jwt: your identity provider's JWKS (or A2A_JWT_SECRET, HS256)
|
|
@@ -39,20 +39,25 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
39
39
|
* ALLOW_UNHARDENED_DB "true" to accept an unhardened Supabase schema
|
|
40
40
|
* A2A_TASK_TIMEOUT_MS per-task wall-clock budget (900000; 0 = none)
|
|
41
41
|
* A2A_MAX_CONCURRENT_TASKS concurrent tasks across all agents (0 = unlimited)
|
|
42
|
+
* A2A_MAX_CONCURRENT_PER_SCOPE concurrent tasks for one end user (4; 0 = unlimited)
|
|
43
|
+
* A2A_MAX_CONCURRENT_PER_CALLER concurrent tasks for one caller (0 = unlimited)
|
|
42
44
|
* A2A_RATE_LIMIT_MAX task submissions per window per IP (60)
|
|
43
45
|
* A2A_RATE_LIMIT_WINDOW_MS rate-limit window (900000)
|
|
44
46
|
* A2A_AUTH_FAILURE_MAX failed authentications per window per IP (30)
|
|
45
|
-
* A2A_TRUST_PROXY Express trust-proxy: hop count, true/false, or subnets
|
|
47
|
+
* A2A_TRUST_PROXY Express trust-proxy: hop count, true/false, or subnets
|
|
48
|
+
* (false; required with PUBLIC_URL)
|
|
46
49
|
* A2A_BODY_LIMIT JSON body limit ("1mb")
|
|
47
50
|
* A2A_KEY_MODE server (default): models run on the server's keys;
|
|
48
51
|
* byok: the caller's X-API-Key pays. Under A2A_AUTH=secret
|
|
49
52
|
* the mode also scopes data: X-User-Id in server mode,
|
|
50
53
|
* the key's hash in byok (the pre-0.16 behaviour)
|
|
51
54
|
* A2A_SERVED_AGENTS comma list: the only agent ids /:agentId/ serves;
|
|
52
|
-
* only these may fall back to examples/ and templates
|
|
55
|
+
* only these may fall back to examples/ and templates/;
|
|
56
|
+
* * serves every one (required with PUBLIC_URL)
|
|
53
57
|
* A2A_REGISTRY_AGENTS comma list: bare ids that load from adk_agent_registry
|
|
54
58
|
* (others are files; registry:<id> always is the registry)
|
|
55
59
|
* A2A_SHUTDOWN_GRACE_MS how long SIGTERM waits for running tasks (25000)
|
|
60
|
+
* A2A_SHUTDOWN_DELAY_MS of that, how long /readyz fails while requests are still served (5000)
|
|
56
61
|
* A2A_BUDGETS daily budgets per UTC day (ADR 0026), JSON:
|
|
57
62
|
* {"perCaller":{"tasks":500,"llmCalls":5000,"tokens":5000000},
|
|
58
63
|
* "callers":{"reports":{"tasks":100}},"perScope":{"tasks":50}}.
|
|
@@ -101,8 +106,10 @@ function envInt(name, fallback) {
|
|
|
101
106
|
}
|
|
102
107
|
function envTrustProxy() {
|
|
103
108
|
const raw = process.env.A2A_TRUST_PROXY?.trim();
|
|
109
|
+
// Unset: trust no proxy, so a client cannot choose its own IP with
|
|
110
|
+
// X-Forwarded-For. A public server must set it (the posture check below).
|
|
104
111
|
if (!raw)
|
|
105
|
-
return
|
|
112
|
+
return false;
|
|
106
113
|
if (/^\d+$/.test(raw))
|
|
107
114
|
return Number(raw);
|
|
108
115
|
if (raw === 'true')
|
|
@@ -219,7 +226,28 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
|
|
|
219
226
|
}
|
|
220
227
|
return n;
|
|
221
228
|
};
|
|
222
|
-
|
|
229
|
+
// `*` serves every file in the agents directory and every registry row,
|
|
230
|
+
// stated on purpose; unset means the same off a public URL (ADR 0039).
|
|
231
|
+
const servedList = list('A2A_SERVED_AGENTS');
|
|
232
|
+
const servedAgents = servedList?.length === 1 && servedList[0] === '*' ? undefined : servedList;
|
|
233
|
+
// ── Public posture (ADR 0039) ──────────────────────────────────────────────
|
|
234
|
+
// A server on a public URL states how it authenticates, which agents it
|
|
235
|
+
// answers, and how many proxies stand in front of it. Each default was the
|
|
236
|
+
// permissive choice, so none is taken silently here.
|
|
237
|
+
if (publicUrl) {
|
|
238
|
+
const missing = [];
|
|
239
|
+
if (!process.env.A2A_AUTH?.trim()) {
|
|
240
|
+
missing.push('A2A_AUTH: callers or jwt (one identity per caller; recommended), header (a gateway authenticates users), or secret (one shared secret; any holder can act as any user by naming them in X-User-Id)');
|
|
241
|
+
}
|
|
242
|
+
if (!servedList?.length) {
|
|
243
|
+
missing.push('A2A_SERVED_AGENTS: the agent ids this server answers, comma-separated, or * for every file in the agents directory and every registry row');
|
|
244
|
+
}
|
|
245
|
+
if (!process.env.A2A_TRUST_PROXY?.trim()) {
|
|
246
|
+
missing.push('A2A_TRUST_PROXY: how many proxies stand in front of this server (1 behind one load balancer), or false when clients connect directly');
|
|
247
|
+
}
|
|
248
|
+
if (missing.length)
|
|
249
|
+
fatal(`PUBLIC_URL is set, so this server must state its security posture. Set:\n - ${missing.join('\n - ')}`);
|
|
250
|
+
}
|
|
223
251
|
const registryAgents = list('A2A_REGISTRY_AGENTS');
|
|
224
252
|
// Storage: Postgres when DATABASE_URL is set (every durable store, safe
|
|
225
253
|
// across instances); otherwise createA2AApp's default (Supabase, else memory).
|
|
@@ -305,6 +333,8 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
|
|
|
305
333
|
allowSchemaMismatch: process.env.ALLOW_SCHEMA_MISMATCH?.trim().toLowerCase() === 'true',
|
|
306
334
|
turnLockWaitMs: envInt('A2A_TURN_LOCK_WAIT_MS', 30_000),
|
|
307
335
|
maxConcurrentTasks: envInt('A2A_MAX_CONCURRENT_TASKS', 0),
|
|
336
|
+
maxConcurrentPerScope: envInt('A2A_MAX_CONCURRENT_PER_SCOPE', 4),
|
|
337
|
+
maxConcurrentPerCaller: envInt('A2A_MAX_CONCURRENT_PER_CALLER', 0),
|
|
308
338
|
rateLimit: { windowMs: envInt('A2A_RATE_LIMIT_WINDOW_MS', 15 * 60 * 1000), max: envInt('A2A_RATE_LIMIT_MAX', 60) },
|
|
309
339
|
authFailureLimit: { windowMs: 15 * 60 * 1000, max: envInt('A2A_AUTH_FAILURE_MAX', 30) },
|
|
310
340
|
trustProxy: envTrustProxy(),
|
|
@@ -334,7 +364,7 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
|
|
|
334
364
|
const authMode = `${identityLabel || (secret ? 'bearer secret' : 'no secret (loopback only)')}; keys: ${keyMode === 'byok' ? "caller's X-API-Key (byok)" : "server's own"}`;
|
|
335
365
|
// The secret is not needed past this point; drop the reference.
|
|
336
366
|
secret = undefined;
|
|
337
|
-
const { app, config, sessionBackend, shutdown } = built;
|
|
367
|
+
const { app, config, sessionBackend, shutdown, markUnready } = built;
|
|
338
368
|
const server = await new Promise((resolve) => {
|
|
339
369
|
const s = host ? app.listen(port, host, () => resolve(s)) : app.listen(port, () => resolve(s));
|
|
340
370
|
});
|
|
@@ -364,13 +394,26 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
|
|
|
364
394
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
365
395
|
let stopping = false;
|
|
366
396
|
const graceMs = envInt('A2A_SHUTDOWN_GRACE_MS', 25_000);
|
|
397
|
+
// A load balancer keeps sending requests for a few seconds after SIGTERM,
|
|
398
|
+
// until it sees /readyz fail and deregisters this instance. Closing the
|
|
399
|
+
// listener at once would refuse those requests; fail readiness first and
|
|
400
|
+
// keep serving for the delay, then close and drain. The delay is part of
|
|
401
|
+
// the grace budget (running tasks keep running through it), so a stop still
|
|
402
|
+
// takes at most A2A_SHUTDOWN_GRACE_MS, inside Kubernetes' default 30 s.
|
|
403
|
+
const delayMs = Math.min(envInt('A2A_SHUTDOWN_DELAY_MS', 5_000), graceMs);
|
|
367
404
|
const stop = async (signal) => {
|
|
368
405
|
if (stopping)
|
|
369
406
|
return;
|
|
370
407
|
stopping = true;
|
|
371
|
-
|
|
408
|
+
markUnready();
|
|
409
|
+
if (delayMs > 0) {
|
|
410
|
+
emit('info', `[A2A] ${signal}: /readyz now fails; still serving for ${delayMs} ms while the load balancer deregisters this instance.`);
|
|
411
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
412
|
+
}
|
|
413
|
+
const drainMs = graceMs - delayMs;
|
|
414
|
+
emit('info', `[A2A] ${signal}: draining — no new tasks; waiting up to ${drainMs} ms for running ones.`);
|
|
372
415
|
server.close();
|
|
373
|
-
const canceled = await shutdown(
|
|
416
|
+
const canceled = await shutdown(drainMs);
|
|
374
417
|
if (canceled > 0)
|
|
375
418
|
emit('warn', `[A2A] ⚠ ${canceled} task(s) did not finish in time and were canceled.`);
|
|
376
419
|
// Give canceled tasks a moment to publish their final status, then flush
|
package/dist/scripts/db.d.ts
CHANGED
|
@@ -22,5 +22,14 @@
|
|
|
22
22
|
/** db/migrations/NNNN_name.sql, in numeric order. */
|
|
23
23
|
export declare function migrationFiles(dir?: string): string[];
|
|
24
24
|
export declare function installOrder(withTelemetry: boolean): string[];
|
|
25
|
+
/**
|
|
26
|
+
* The whole install as one psql script: an advisory lock first, then every
|
|
27
|
+
* file in order with a progress line before each. psql runs it with
|
|
28
|
+
* --single-transaction and ON_ERROR_STOP, so a failure anywhere rolls the
|
|
29
|
+
* whole install back (no half-applied migration), and a second `apply`
|
|
30
|
+
* started meanwhile waits on the lock instead of interleaving with this one.
|
|
31
|
+
*/
|
|
32
|
+
export declare const APPLY_LOCK_KEY = "melchizedek-db apply";
|
|
33
|
+
export declare function applyScript(files: string[], sqlOf: (file: string) => string, label?: (file: string) => string): string;
|
|
25
34
|
/** postgres://user:pass@host:port/db?sslmode=… → libpq environment variables. */
|
|
26
35
|
export declare function pgEnv(url: string): Record<string, string>;
|
package/dist/scripts/db.js
CHANGED
|
@@ -42,6 +42,20 @@ export function installOrder(withTelemetry) {
|
|
|
42
42
|
const base = [...migrationFiles(), 'hardening.sql'];
|
|
43
43
|
return withTelemetry ? [...base, 'telemetry.sql', 'hardening.sql'] : base;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* The whole install as one psql script: an advisory lock first, then every
|
|
47
|
+
* file in order with a progress line before each. psql runs it with
|
|
48
|
+
* --single-transaction and ON_ERROR_STOP, so a failure anywhere rolls the
|
|
49
|
+
* whole install back (no half-applied migration), and a second `apply`
|
|
50
|
+
* started meanwhile waits on the lock instead of interleaving with this one.
|
|
51
|
+
*/
|
|
52
|
+
export const APPLY_LOCK_KEY = 'melchizedek-db apply';
|
|
53
|
+
export function applyScript(files, sqlOf, label = (f) => `db/${f}`) {
|
|
54
|
+
const parts = [`SELECT pg_advisory_xact_lock(hashtext('${APPLY_LOCK_KEY}'));\n`];
|
|
55
|
+
for (const file of files)
|
|
56
|
+
parts.push(`\\echo '→ applying ${label(file).replace(/'/g, '')}'\n`, sqlOf(file), '\n');
|
|
57
|
+
return parts.join('');
|
|
58
|
+
}
|
|
45
59
|
/** postgres://user:pass@host:port/db?sslmode=… → libpq environment variables. */
|
|
46
60
|
export function pgEnv(url) {
|
|
47
61
|
const u = new URL(url);
|
|
@@ -100,21 +114,22 @@ async function main() {
|
|
|
100
114
|
console.error(' or set DATABASE_URL to the Postgres connection string (Supabase: Settings → Database).');
|
|
101
115
|
process.exit(1);
|
|
102
116
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
// One transaction under one lock (see applyScript): all of it, or none.
|
|
118
|
+
const script = applyScript(installOrder(withTelemetry), sqlOf, (f) => `db/${f}${schema === 'public' ? '' : ` into schema ${schema}`}`);
|
|
119
|
+
const r = spawnSync('psql', ['-v', 'ON_ERROR_STOP=1', '-q', '--single-transaction', '-f', '-'], {
|
|
120
|
+
input: script,
|
|
121
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
122
|
+
// The connection goes to psql as PG* variables, never as an
|
|
123
|
+
// argument, so the password stays out of the process list.
|
|
124
|
+
env: { ...process.env, ...pgEnv(url) },
|
|
125
|
+
});
|
|
126
|
+
if (r.error) {
|
|
127
|
+
console.error(`✗ could not run psql (${r.error.message}). Install the Postgres client, or use \`print\`.`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
if (r.status !== 0) {
|
|
131
|
+
console.error('✗ the install failed and was rolled back: the database is as it was before `apply`.');
|
|
132
|
+
process.exit(r.status ?? 1);
|
|
118
133
|
}
|
|
119
134
|
console.log('✓ database is installed and hardened');
|
|
120
135
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "melchizedek-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "A multi-model, multi-agent orchestration framework on Google ADK — YAML-defined syndicates, Supabase session persistence, pgvector long-term memory. Companion repo to the lyceumagents.com curriculum.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"bugs": {
|
|
13
13
|
"url": "https://github.com/jhwadman/melch-ai/issues"
|
|
14
14
|
},
|
|
15
|
-
"//": "adk + genai are pinned exact in dev: newer genai minors change response part shapes and silently break the chat printer. Bump deliberately, verify a live run, then update the pin. adk itself is a PEER dependency — the consumer's app owns the ADK instance so the model registry is a singleton.",
|
|
15
|
+
"//": "adk + genai are pinned exact in dev: newer genai minors change response part shapes and silently break the chat printer. Bump deliberately, verify a live run, then update the pin. adk itself is a PEER dependency — the consumer's app owns the ADK instance so the model registry is a singleton. The adk peer range is capped to the tested minor (~2.2.0): a new adk minor can pull a second genai copy, so the range widens only after a release is tested against it.",
|
|
16
16
|
"engines": {
|
|
17
17
|
"node": ">=22.6.0"
|
|
18
18
|
},
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"@anthropic-ai/bedrock-sdk": ">=0.20.0",
|
|
127
127
|
"@anthropic-ai/vertex-sdk": ">=0.11.0",
|
|
128
128
|
"@azure/identity": ">=4.0.0",
|
|
129
|
-
"@google/adk": "
|
|
129
|
+
"@google/adk": "~2.2.0",
|
|
130
130
|
"redis": ">=4.6.0"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|