jev-recipes 0.9.5 → 0.9.7

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.
@@ -1,8 +1,8 @@
1
- import type { LanguageModel, PrepareStepFunction, StopCondition, Tool, ToolSet } from 'ai';
1
+ import type { FlexibleSchema, LanguageModel, PrepareStepFunction, StopCondition, Tool, ToolSet } from 'ai';
2
2
  import type { RecipeName } from '../../catalog/schema.js';
3
3
  import type { ModelRouteResult } from '../../recipes/model-route/index.js';
4
4
  import type { RecipeOptions } from '../../src/schema.js';
5
- import type { CompletionOptions, CompletionToolOutput, GuardOptions, RecipeToolOptions } from '../shared.js';
5
+ import type { BlockedToolOutput, CompletionOptions, CompletionToolOutput, GuardOptions, RecipeToolOptions } from '../shared.js';
6
6
  export type { BlockedToolOutput, CompletionEvent, CompletionOptions, CompletionToolOutput, GuardEvent, GuardOptions, RecipeToolOptions, } from '../shared.js';
7
7
  export type RecipeTool = Tool<Record<string, unknown>, Record<string, unknown>>;
8
8
  export declare function recipeTool(name: RecipeName, options?: RecipeToolOptions): RecipeTool;
@@ -10,7 +10,20 @@ export declare function recipeTools(names: readonly RecipeName[], options?: Reci
10
10
  export type GuardToolsOptions = GuardOptions & {
11
11
  request?: string;
12
12
  };
13
- export declare function guardTools<TOOLS extends ToolSet>(tools: TOOLS, options?: GuardToolsOptions): TOOLS;
13
+ type ToolExecution<DEFINITION extends ToolSet[string]> = NonNullable<DEFINITION['execute']>;
14
+ type ToolOutput<RESULT> = RESULT extends AsyncIterable<infer OUTPUT> ? OUTPUT : Awaited<RESULT>;
15
+ type GuardedOutput<DEFINITION extends ToolSet[string]> = ToolOutput<ReturnType<ToolExecution<DEFINITION>>> | BlockedToolOutput;
16
+ type GuardedExecution<DEFINITION extends ToolSet[string]> = {
17
+ [KEY in keyof Pick<DEFINITION, 'execute'>]: (...args: Parameters<ToolExecution<DEFINITION>>) => AsyncGenerator<GuardedOutput<DEFINITION>>;
18
+ };
19
+ type GuardedTool<DEFINITION extends ToolSet[string]> = DEFINITION extends unknown ? Omit<DEFINITION, 'execute' | 'toModelOutput' | 'outputSchema'> & GuardedExecution<DEFINITION> & {
20
+ outputSchema?: FlexibleSchema<GuardedOutput<DEFINITION>>;
21
+ toModelOutput?: NonNullable<Tool<Parameters<ToolExecution<DEFINITION>>[0], GuardedOutput<DEFINITION>>['toModelOutput']>;
22
+ } : never;
23
+ export type GuardedTools<TOOLS extends ToolSet> = {
24
+ [NAME in keyof TOOLS]: 'execute' extends keyof TOOLS[NAME] ? ToolExecution<TOOLS[NAME]> extends never ? TOOLS[NAME] : GuardedTool<TOOLS[NAME]> : TOOLS[NAME];
25
+ };
26
+ export declare function guardTools<TOOLS extends ToolSet>(tools: TOOLS, options?: GuardToolsOptions): GuardedTools<TOOLS>;
14
27
  export type ModelCandidate = {
15
28
  id: string;
16
29
  text: string;
@@ -1,6 +1,8 @@
1
- import { jsonSchema, tool } from 'ai';
1
+ import { asSchema, jsonSchema, tool } from 'ai';
2
+ import { z } from 'zod';
2
3
  import { modelRoute } from '../../recipes/model-route/index.js';
3
- import { COMPLETION_TOOL_DESCRIPTION, COMPLETION_TOOL_INPUT_SCHEMA, DEFAULT_COMPLETION_TOOL_NAME, blockedOutput, defineRecipeTool, messageText, requireText, reviewCompletion, reviewToolCall, } from '../shared.js';
4
+ import { toolCallGateResultSchema } from '../../recipes/tool-call-gate/index.js';
5
+ import { BLOCKED_TOOL_OUTPUT_KIND, COMPLETION_TOOL_DESCRIPTION, COMPLETION_TOOL_INPUT_SCHEMA, DEFAULT_COMPLETION_TOOL_NAME, blockedOutput, defineRecipeTool, messageText, requireText, reviewCompletion, reviewToolCall, } from '../shared.js';
4
6
  export function recipeTool(name, options = {}) {
5
7
  const definition = defineRecipeTool(name, options);
6
8
  return tool({
@@ -13,27 +15,75 @@ export function recipeTools(names, options = {}) {
13
15
  return Object.fromEntries(names.map((name) => [name, recipeTool(name, options)]));
14
16
  }
15
17
  export function guardTools(tools, options = {}) {
18
+ return Object.fromEntries(Object.entries(tools).map(([name, definition]) => [name, guardTool(name, definition, options)]));
19
+ }
20
+ function guardTool(toolName, definition, options) {
21
+ const execute = definition.execute;
22
+ if (typeof execute !== 'function')
23
+ return definition;
16
24
  const { request, ...guardOptions } = options;
17
- const guarded = Object.entries(tools).map(([toolName, definition]) => {
18
- if (!('execute' in definition) || typeof definition.execute !== 'function')
19
- return [toolName, definition];
20
- const execute = definition.execute;
21
- const wrapped = {
22
- ...definition,
23
- execute: async (input, executionOptions) => {
24
- const decision = await reviewToolCall({
25
- toolName,
26
- input,
27
- request: request ?? requireText(lastUserText(executionOptions.messages), 'request'),
28
- }, guardOptions, executionOptions.abortSignal);
29
- if (decision.action !== 'allow')
30
- return blockedOutput(toolName, decision);
31
- return execute(input, executionOptions);
32
- },
33
- };
34
- return [toolName, wrapped];
25
+ const toModelOutput = definition.toModelOutput;
26
+ return {
27
+ ...definition,
28
+ execute: async function* (input, executionOptions) {
29
+ guardOptions.signal?.throwIfAborted();
30
+ executionOptions.abortSignal?.throwIfAborted();
31
+ const decision = await reviewToolCall({
32
+ toolName,
33
+ input,
34
+ request: request ?? requireText(lastUserText(executionOptions.messages), 'request'),
35
+ }, guardOptions, executionOptions.abortSignal);
36
+ guardOptions.signal?.throwIfAborted();
37
+ executionOptions.abortSignal?.throwIfAborted();
38
+ if (decision.action !== 'allow') {
39
+ yield blockedOutput(toolName, decision);
40
+ return;
41
+ }
42
+ const output = execute(input, executionOptions);
43
+ if (isAsyncIterable(output))
44
+ yield* output;
45
+ else
46
+ yield await output;
47
+ },
48
+ ...(toModelOutput === undefined
49
+ ? {}
50
+ : {
51
+ toModelOutput: (result) => blockedToolOutputSchema.safeParse(result.output).success
52
+ ? { type: 'json', value: result.output }
53
+ : toModelOutput(result),
54
+ }),
55
+ ...(definition.outputSchema === undefined
56
+ ? {}
57
+ : { outputSchema: includeBlockedOutput(definition.outputSchema) }),
58
+ };
59
+ }
60
+ function isAsyncIterable(value) {
61
+ return (typeof value?.[Symbol.asyncIterator] ===
62
+ 'function');
63
+ }
64
+ const blockedToolOutputSchema = toolCallGateResultSchema
65
+ .pick({ verdict: true, status: true, detected: true, confidence: true })
66
+ .extend({
67
+ kind: z.literal(BLOCKED_TOOL_OUTPUT_KIND),
68
+ blocked: z.literal(true),
69
+ action: z.enum(['ask', 'deny']),
70
+ message: z.string(),
71
+ });
72
+ function includeBlockedOutput(outputSchema) {
73
+ const original = asSchema(outputSchema);
74
+ return jsonSchema(async () => ({
75
+ anyOf: [
76
+ { $id: 'urn:jev-recipes:tool-output', ...(await original.jsonSchema) },
77
+ z.toJSONSchema(blockedToolOutputSchema),
78
+ ],
79
+ }), {
80
+ validate: async (value) => {
81
+ const blocked = blockedToolOutputSchema.safeParse(value);
82
+ if (blocked.success)
83
+ return { success: true, value: blocked.data };
84
+ return original.validate ? original.validate(value) : { success: true, value };
85
+ },
35
86
  });
36
- return Object.fromEntries(guarded);
37
87
  }
38
88
  export function routeModelStep(options) {
39
89
  const { candidates, request, context, minConfidence, fallback, onDecision, ...recipeOptions } = options;
@@ -1,11 +1,4 @@
1
- import type { DynamicStructuredTool } from '@langchain/core/tools';
2
- export type GuardableTool = {
3
- name: string;
4
- description: string;
5
- schema: unknown;
6
- returnDirect?: boolean;
7
- invoke(input: any, config?: any): Promise<unknown>;
8
- };
1
+ import { DynamicStructuredTool } from '@langchain/core/tools';
9
2
  import type { RecipeName } from '../../catalog/schema.js';
10
3
  import { COMPLETION_TOOL_INPUT_SCHEMA } from '../shared.js';
11
4
  import type { CompletionOptions, GuardOptions, RecipeToolOptions } from '../shared.js';
@@ -18,6 +11,13 @@ export type GuardRequest = string | ((input: unknown, config: unknown) => string
18
11
  export type GuardToolsOptions = GuardOptions & {
19
12
  request: GuardRequest;
20
13
  };
14
+ export type GuardableTool = {
15
+ name: string;
16
+ description: string;
17
+ schema: unknown;
18
+ returnDirect?: boolean;
19
+ invoke(input: any, config?: any): Promise<unknown>;
20
+ };
21
21
  export declare function guardTools(tools: readonly GuardableTool[], options: GuardToolsOptions): DynamicStructuredTool[];
22
22
  export declare function completionTool(options: CompletionOptions & {
23
23
  task: string;
@@ -1,4 +1,5 @@
1
- import { tool } from '@langchain/core/tools';
1
+ import { DynamicStructuredTool, tool } from '@langchain/core/tools';
2
+ import { ToolMessage } from '@langchain/core/messages';
2
3
  import { COMPLETION_TOOL_DESCRIPTION, COMPLETION_TOOL_INPUT_SCHEMA, DEFAULT_COMPLETION_TOOL_NAME, blockedOutput, defineRecipeTool, requireText, reviewCompletion, reviewToolCall, } from '../shared.js';
3
4
  export function recipeTool(name, options = {}) {
4
5
  const definition = defineRecipeTool(name, options);
@@ -12,26 +13,53 @@ export function recipeTools(names, options = {}) {
12
13
  return names.map((name) => recipeTool(name, options));
13
14
  }
14
15
  export function guardTools(tools, options) {
15
- const { request, ...guardOptions } = options;
16
- return tools.map((original) => {
17
- const schema = original.schema;
18
- return tool(async (input, config) => {
19
- const decision = await reviewToolCall({
20
- toolName: original.name,
21
- input,
22
- request: typeof request === 'function' ? request(input, config) : request,
23
- }, guardOptions, config?.signal);
24
- if (decision.action !== 'allow')
25
- return blockedOutput(original.name, decision);
26
- const { toolCall: _toolCall, ...delegatedConfig } = config ?? {};
27
- return original.invoke(input, delegatedConfig);
28
- }, {
16
+ return tools.map((original) => new GuardedTool(original, options));
17
+ }
18
+ class GuardedTool extends DynamicStructuredTool {
19
+ original;
20
+ guardOptions;
21
+ constructor(original, guardOptions) {
22
+ super({
29
23
  name: original.name,
30
24
  description: original.description,
31
- schema,
32
- ...(original.returnDirect === undefined ? {} : { returnDirect: original.returnDirect }),
25
+ schema: original.schema,
26
+ returnDirect: original.returnDirect ?? false,
27
+ func: (input, _runManager, config) => this.invoke(input, config),
33
28
  });
34
- });
29
+ this.original = original;
30
+ this.guardOptions = guardOptions;
31
+ }
32
+ async invoke(input, config) {
33
+ const { request, ...options } = this.guardOptions;
34
+ const args = isToolCall(input) ? input.args : input;
35
+ const requestConfig = isToolCall(input) ? { ...config, toolCall: input } : config;
36
+ options.signal?.throwIfAborted();
37
+ config?.signal?.throwIfAborted();
38
+ const decision = await reviewToolCall({
39
+ toolName: this.name,
40
+ input: args,
41
+ request: typeof request === 'function' ? request(args, requestConfig) : request,
42
+ }, options, config?.signal);
43
+ options.signal?.throwIfAborted();
44
+ config?.signal?.throwIfAborted();
45
+ if (decision.action === 'allow')
46
+ return this.original.invoke(input, config);
47
+ const output = blockedOutput(this.name, decision);
48
+ const toolCallId = requestConfig?.toolCall?.id;
49
+ return toolCallId === undefined
50
+ ? output
51
+ : new ToolMessage({
52
+ name: this.name,
53
+ tool_call_id: toolCallId,
54
+ content: JSON.stringify(output),
55
+ });
56
+ }
57
+ call(input, config, tags) {
58
+ return this.invoke(input, tags === undefined ? config : { ...config, tags: config?.tags ?? tags });
59
+ }
60
+ }
61
+ function isToolCall(input) {
62
+ return (typeof input === 'object' && input !== null && 'type' in input && input.type === 'tool_call');
35
63
  }
36
64
  export function completionTool(options) {
37
65
  return tool((input, config) => reviewCompletion({ task: requireText(options.task, 'task'), report: input.report, evidence: input.evidence }, options, config?.signal), {
@@ -22,7 +22,9 @@ export type GuardEvent = {
22
22
  input: unknown;
23
23
  decision: ToolCallGateResult;
24
24
  };
25
+ export declare const BLOCKED_TOOL_OUTPUT_KIND = "jev-recipes/blocked-tool-output";
25
26
  export type BlockedToolOutput = {
27
+ kind: typeof BLOCKED_TOOL_OUTPUT_KIND;
26
28
  blocked: true;
27
29
  action: 'ask' | 'deny';
28
30
  verdict: ToolCallGateResult['verdict'];
@@ -2,6 +2,7 @@ import { describeRecipe } from '../catalog/index.js';
2
2
  import { loadRecipe } from '../catalog/runner.js';
3
3
  import { completionGate } from '../recipes/completion-gate/index.js';
4
4
  import { toolCallGate } from '../recipes/tool-call-gate/index.js';
5
+ export const BLOCKED_TOOL_OUTPUT_KIND = 'jev-recipes/blocked-tool-output';
5
6
  export const DEFAULT_COMPLETION_TOOL_NAME = 'report_completion';
6
7
  export const COMPLETION_TOOL_DESCRIPTION = 'Report that the task is complete. State what was done in report, and put the evidence that demonstrates it, such as test output, command results, or observed behavior, in evidence. A claim without evidence is not accepted.';
7
8
  export const COMPLETION_TOOL_INPUT_SCHEMA = {
@@ -54,6 +55,7 @@ export function blockedOutput(toolName, decision) {
54
55
  ? `The call to ${toolName} was refused by policy and did not run.${risks} Do not retry it. Explain what you intended and continue with permitted work.`
55
56
  : `The call to ${toolName} did not run because a person must confirm it first.${risks} Do not retry it unattended. Explain what you intended and continue with other permitted work.`;
56
57
  return {
58
+ kind: BLOCKED_TOOL_OUTPUT_KIND,
57
59
  blocked: true,
58
60
  action,
59
61
  verdict: decision.verdict,
@@ -142,9 +142,9 @@ export declare function compare(baseline: EvaluationRun, candidate: EvaluationRu
142
142
  }[];
143
143
  thresholdEvaluation: string;
144
144
  thresholds: {
145
+ readyAccuracy: number | null;
145
146
  minConfidence: number;
146
147
  deferRate: number | null;
147
- readyAccuracy: number | null;
148
148
  readyCases: number;
149
149
  reviewCases: number;
150
150
  failedCases: number;
@@ -225,9 +225,9 @@ export declare function compare(baseline: EvaluationRun, candidate: EvaluationRu
225
225
  }[];
226
226
  thresholdEvaluation: string;
227
227
  thresholds: {
228
+ readyAccuracy: number | null;
228
229
  minConfidence: number;
229
230
  deferRate: number | null;
230
- readyAccuracy: number | null;
231
231
  readyCases: number;
232
232
  reviewCases: number;
233
233
  failedCases: number;
@@ -328,9 +328,9 @@ export declare function compare(baseline: EvaluationRun, candidate: EvaluationRu
328
328
  }[];
329
329
  thresholdEvaluation: string;
330
330
  thresholds: {
331
+ readyAccuracy: number | null;
331
332
  minConfidence: number;
332
333
  deferRate: number | null;
333
- readyAccuracy: number | null;
334
334
  readyCases: number;
335
335
  reviewCases: number;
336
336
  failedCases: number;
@@ -411,9 +411,9 @@ export declare function compare(baseline: EvaluationRun, candidate: EvaluationRu
411
411
  }[];
412
412
  thresholdEvaluation: string;
413
413
  thresholds: {
414
+ readyAccuracy: number | null;
414
415
  minConfidence: number;
415
416
  deferRate: number | null;
416
- readyAccuracy: number | null;
417
417
  readyCases: number;
418
418
  reviewCases: number;
419
419
  failedCases: number;
@@ -29,9 +29,9 @@ export declare function buildReport(id: string, rows: EvaluationRow[], confidenc
29
29
  }[];
30
30
  thresholdEvaluation: string;
31
31
  thresholds: {
32
+ readyAccuracy: number | null;
32
33
  minConfidence: number;
33
34
  deferRate: number | null;
34
- readyAccuracy: number | null;
35
35
  readyCases: number;
36
36
  reviewCases: number;
37
37
  failedCases: number;
@@ -112,9 +112,9 @@ export declare function buildReport(id: string, rows: EvaluationRow[], confidenc
112
112
  }[];
113
113
  thresholdEvaluation: string;
114
114
  thresholds: {
115
+ readyAccuracy: number | null;
115
116
  minConfidence: number;
116
117
  deferRate: number | null;
117
- readyAccuracy: number | null;
118
118
  readyCases: number;
119
119
  reviewCases: number;
120
120
  failedCases: number;
@@ -1,26 +1,34 @@
1
1
  import { isRecord, roundedTo } from './decisions.js';
2
2
  import { matchesExpected } from './comparison.js';
3
+ import { resultMetadataSchema } from '../src/schema.js';
3
4
  export function buildReport(id, rows, confidenceThresholds, price, evidence) {
4
5
  const completed = rows.filter((row) => row.error === undefined);
5
6
  const ready = completed.filter((row) => row.ready);
6
- const thresholds = confidenceThresholds.map((minConfidence) => {
7
+ const thresholdEvaluations = confidenceThresholds.map((minConfidence) => {
7
8
  const decisions = completed
8
9
  .flatMap((row) => row.decisions)
9
10
  .filter((decision) => decision.minConfidence === minConfidence);
10
11
  const accepted = decisions.filter((decision) => decision.ready);
12
+ const correctReadyCases = accepted.filter((decision) => decision.correct).length;
11
13
  return {
12
14
  minConfidence,
13
15
  deferRate: completed.length
14
16
  ? roundedTo(3, (completed.length - accepted.length) / completed.length)
15
17
  : null,
16
- readyAccuracy: accuracyOf(accepted),
18
+ readyAccuracy: accepted.length ? correctReadyCases / accepted.length : null,
17
19
  readyCases: accepted.length,
18
20
  reviewCases: completed.length - accepted.length,
19
21
  failedCases: rows.length - completed.length,
20
- readyAccuracyInterval95: wilsonInterval(accepted.filter((entry) => entry.correct).length, accepted.length),
22
+ readyAccuracyInterval95: wilsonInterval(correctReadyCases, accepted.length),
21
23
  };
22
24
  });
23
- const usage = usageOf(rows);
25
+ const suggestedMinConfidence = thresholdEvaluations.find((entry) => entry.readyAccuracy !== null && entry.readyAccuracy >= 0.95)?.minConfidence ?? null;
26
+ const thresholds = thresholdEvaluations.map((threshold) => ({
27
+ ...threshold,
28
+ readyAccuracy: threshold.readyAccuracy === null ? null : roundedTo(3, threshold.readyAccuracy),
29
+ }));
30
+ const usage = summarizeUsage(rows);
31
+ const cost = estimateKnownCost(usage, price);
24
32
  const models = [...new Set(rows.flatMap((row) => (row.model ? [row.model] : [])))].sort();
25
33
  return {
26
34
  ...(evidence ? { evidence } : {}),
@@ -60,21 +68,10 @@ export function buildReport(id, rows, confidenceThresholds, price, evidence) {
60
68
  ? 'recipe-replay'
61
69
  : 'not-applicable',
62
70
  thresholds,
63
- suggestedMinConfidence: thresholds.find((entry) => entry.readyAccuracy !== null && entry.readyAccuracy >= 0.95)
64
- ?.minConfidence ?? null,
71
+ suggestedMinConfidence,
65
72
  latencyMs: latencyOf(rows.map((row) => row.durationMs)),
66
73
  usage,
67
- cost: price &&
68
- usage.models.every((model) => model === price.model) &&
69
- usage.unknownResponses === 0 &&
70
- usage.failedRequests === 0
71
- ? {
72
- ...price,
73
- estimated: roundedTo(8, (usage.input_tokens * price.inputPerMillion +
74
- usage.output_tokens * price.outputPerMillion) /
75
- 1_000_000),
76
- }
77
- : null,
74
+ cost,
78
75
  confusion: confusionOf(rows),
79
76
  failures: rows
80
77
  .filter((row) => !row.correct)
@@ -126,7 +123,7 @@ function latencyOf(values) {
126
123
  totalCaseTime: roundedTo(2, values.reduce((sum, value) => sum + value, 0)),
127
124
  };
128
125
  }
129
- function usageOf(rows) {
126
+ function summarizeUsage(rows) {
130
127
  let input_tokens = 0, output_tokens = 0, unknownResponses = 0, failedRequests = 0;
131
128
  const models = new Set();
132
129
  const exchanges = rows.flatMap((row) => row.exchanges);
@@ -135,18 +132,17 @@ function usageOf(rows) {
135
132
  failedRequests++;
136
133
  continue;
137
134
  }
138
- const response = exchange.response;
139
- if (isRecord(response) && typeof response.model === 'string')
140
- models.add(response.model);
141
- if (!isRecord(response) ||
142
- !isRecord(response.usage) ||
143
- typeof response.usage.input_tokens !== 'number' ||
144
- typeof response.usage.output_tokens !== 'number') {
135
+ const response = isRecord(exchange.response) ? exchange.response : {};
136
+ const usage = resultMetadataSchema.shape.usage.safeParse(response.usage);
137
+ const model = resultMetadataSchema.shape.model.safeParse(response.model);
138
+ if (!usage.success || !model.success)
145
139
  unknownResponses++;
140
+ if (model.success)
141
+ models.add(model.data);
142
+ if (!usage.success)
146
143
  continue;
147
- }
148
- input_tokens += response.usage.input_tokens;
149
- output_tokens += response.usage.output_tokens;
144
+ input_tokens += usage.data.input_tokens;
145
+ output_tokens += usage.data.output_tokens;
150
146
  }
151
147
  return {
152
148
  requests: exchanges.length,
@@ -157,6 +153,18 @@ function usageOf(rows) {
157
153
  models: [...models].sort(),
158
154
  };
159
155
  }
156
+ function estimateKnownCost(usage, price) {
157
+ if (!price ||
158
+ usage.unknownResponses > 0 ||
159
+ usage.failedRequests > 0 ||
160
+ usage.models.some((model) => model !== price.model))
161
+ return null;
162
+ return {
163
+ ...price,
164
+ estimated: roundedTo(8, (usage.input_tokens * price.inputPerMillion + usage.output_tokens * price.outputPerMillion) /
165
+ 1_000_000),
166
+ };
167
+ }
160
168
  function confusionOf(rows) {
161
169
  const byExpected = new Map();
162
170
  for (const row of rows) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jev-recipes",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "248 small Jev decisions for JavaScript and TypeScript: route, rerank, gate, grade, compare, and label text for agents, RAG, support, code review, and music. Each returns a typed result with explicit uncertainty. Plug into Vercel AI SDK or LangChain agents, import one recipe, or run the CLI with JSON from any language.",
5
5
  "type": "module",
6
6
  "license": "MIT",