create-loopengine 0.1.11 → 0.1.13

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.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  // Template ships in `template/` next to `dist/`, not inside it — it's a
@@ -15,6 +15,10 @@ export function scaffold(options) {
15
15
  }
16
16
  mkdirSync(destination, { recursive: true });
17
17
  cpSync(templateDir, destination, { recursive: true });
18
+ // npm strips any .gitignore (even nested ones) when publishing a package,
19
+ // so the template ships it as `_gitignore` and this renames it back —
20
+ // same workaround create-vite/create-react-app use for the same reason.
21
+ renameSync(join(destination, '_gitignore'), join(destination, '.gitignore'));
18
22
  const pkgPath = join(destination, 'package.json');
19
23
  const pkg = readFileSync(pkgPath, 'utf8').replace('__PROJECT_NAME__', name);
20
24
  writeFileSync(pkgPath, pkg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ dist
3
+ .sessions
4
+ .env
5
+ *.log
6
+ .DS_Store
@@ -29,14 +29,21 @@
29
29
  // file's call — see AgentConfig.sessionIdFor and defaultSessionIdFor below.
30
30
  import { randomUUID, timingSafeEqual } from 'node:crypto'
31
31
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
32
+ import { pathToFileURL } from 'node:url'
32
33
  import { getEntry, listAgents, projectDir, registerAgent, updateAgent, type RegistryEntry } from '../agent-registry.js'
33
34
  import {
34
35
  loadAgentModule,
35
36
  synthesizeCreateModelCall,
36
37
  editAgentFile,
38
+ createHttpTool,
39
+ HttpToolNameError,
40
+ HttpToolExistsError,
41
+ HttpToolIndexShapeError,
42
+ type HttpToolSpec,
37
43
  AgentEditNotSupportedError,
38
44
  AgentFileNotFoundError,
39
45
  type AgentEditResult,
46
+ type AgentEditableFields,
40
47
  createSessionStore,
41
48
  createCheckpointStore,
42
49
  type TurnCheckpoint,
@@ -73,6 +80,10 @@ import {
73
80
  deleteSkill,
74
81
  SkillInvalidIdError,
75
82
  SkillNotFoundError,
83
+ listSkillgardenCatalog,
84
+ readSkillgardenCatalogEntry,
85
+ addSkillgardenSkillToAgent,
86
+ SkillgardenUnavailableError,
76
87
  readActauthConfig,
77
88
  addActauthRule,
78
89
  updateActauthRule,
@@ -308,9 +319,14 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
308
319
  // configured for *this* agent, so the config page's own Skills tab
309
320
  // deliberately doesn't mix it into skills/skillsDirs below.
310
321
  const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`]
311
- const skillGarden = new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
322
+ // Same defaults, same reasoning, as loopengine's own run-agent.ts
323
+ // SkillGarden/BudgetTracker construction — kept in sync there and here
324
+ // since this is what a "Skills"/"Limits & budgets" tab shows an
325
+ // operator, and it has to describe what runAgent() would actually
326
+ // resolve, not a second guess (see this function's own header comment).
327
+ const skillGarden = new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 2000 })
312
328
  const skillIndex = skillGarden.buildIndex().included
313
- const systemSkillGarden = new SkillGarden({ dirs: [systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
329
+ const systemSkillGarden = new SkillGarden({ dirs: [systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 2000 })
314
330
  const systemSkillIndex = systemSkillGarden.buildIndex().included
315
331
 
316
332
  return {
@@ -320,8 +336,8 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
320
336
  ? { provider: config.model.provider, model: config.model.model, maxTokens: config.model.maxTokens }
321
337
  : 'custom (module exports its own createModelCall)',
322
338
  maxTurns: config.maxTurns ?? 25,
323
- contextBudgetTokens: config.contextBudgetTokens ?? 8000,
324
- skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 200,
339
+ contextBudgetTokens: config.contextBudgetTokens ?? 100000,
340
+ skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 2000,
325
341
  skillsDirs: skillsDirs,
326
342
  skills: skillIndex.map((s) => ({ name: s.name, description: s.description })),
327
343
  systemSkills: systemSkillIndex.map((s) => ({ name: s.name, description: s.description })),
@@ -441,6 +457,105 @@ async function handleGatewayToolSlugDelete(res: ServerResponse, agentName: strin
441
457
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ sources }))
442
458
  }
443
459
 
460
+ function parseHttpToolBody(body: Record<string, unknown>): { ok: true; value: HttpToolSpec; decision?: Decision } | { ok: false; error: string } {
461
+ if (typeof body.name !== 'string' || !body.name) return { ok: false, error: 'name is required' }
462
+ if (typeof body.description !== 'string' || !body.description) return { ok: false, error: 'description is required' }
463
+ if (!Array.isArray(body.fields) || !body.fields.every((f) => f && typeof f === 'object')) {
464
+ return { ok: false, error: 'fields must be an array' }
465
+ }
466
+ const fields = body.fields as Record<string, unknown>[]
467
+ for (const f of fields) {
468
+ if (typeof f.name !== 'string' || !f.name) return { ok: false, error: 'every field needs a name' }
469
+ if (f.type !== 'string' && f.type !== 'number' && f.type !== 'boolean') return { ok: false, error: `field "${f.name}" has an invalid type` }
470
+ }
471
+ const method = body.method
472
+ if (method !== 'GET' && method !== 'POST' && method !== 'PUT' && method !== 'PATCH' && method !== 'DELETE') {
473
+ return { ok: false, error: 'method must be one of GET, POST, PUT, PATCH, DELETE' }
474
+ }
475
+ if (typeof body.url !== 'string' || !body.url) return { ok: false, error: 'url is required' }
476
+ if (!Array.isArray(body.headers) || !body.headers.every((h) => h && typeof h === 'object' && typeof (h as Record<string, unknown>).key === 'string' && typeof (h as Record<string, unknown>).value === 'string')) {
477
+ return { ok: false, error: 'headers must be an array of {key, value} strings' }
478
+ }
479
+ if (body.responseJsonPath !== undefined && typeof body.responseJsonPath !== 'string') {
480
+ return { ok: false, error: 'responseJsonPath must be a string' }
481
+ }
482
+ if (body.decision !== undefined && !isDecision(body.decision)) {
483
+ return { ok: false, error: 'decision must be allow, ask, or deny' }
484
+ }
485
+
486
+ return {
487
+ ok: true,
488
+ value: {
489
+ name: body.name,
490
+ description: body.description,
491
+ fields: fields as unknown as HttpToolSpec['fields'],
492
+ method,
493
+ url: body.url,
494
+ headers: body.headers as HttpToolSpec['headers'],
495
+ sendFieldsAsJsonBody: body.sendFieldsAsJsonBody === true,
496
+ responseJsonPath: body.responseJsonPath as string | undefined,
497
+ },
498
+ decision: body.decision as Decision | undefined,
499
+ }
500
+ }
501
+
502
+ // Backs the Tools tab's "Create an HTTP tool" form — generates a real
503
+ // agents/:name/tools/<tool>.ts file (loopengine's own createHttpTool),
504
+ // then splices the freshly-imported ToolDefinition straight into this
505
+ // process's own live registry via updateAgent (../agent-registry.js) —
506
+ // the same "no restart needed" mechanism a newly-created agent already
507
+ // gets (see registerAgent's own doc comment), and deliberately not a
508
+ // reliance on tsx watch picking up the new file: this way the tool is
509
+ // callable immediately under `serve` (no watch) too, not just `dev`. An
510
+ // optional `decision` seeds one actauth.yml rule via the already-existing
511
+ // addActauthRule, mirroring gateway-tools.ts's own "a rule is opt-in,
512
+ // omitting it falls through to default_decision" precedent — never a
513
+ // hardcoded default here.
514
+ async function handleHttpToolPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
515
+ const entry = getEntry(agentName)
516
+ if (!entry) {
517
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
518
+ return
519
+ }
520
+ const body = await readJsonBody(req)
521
+ const parsed = parseHttpToolBody(body)
522
+ if (!parsed.ok) {
523
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
524
+ return
525
+ }
526
+
527
+ let tool: ToolDefinition
528
+ try {
529
+ const result = await createHttpTool(agentName, parsed.value)
530
+ tool = result.tool
531
+ } catch (err) {
532
+ const status = err instanceof HttpToolExistsError ? 409 : err instanceof HttpToolNameError || err instanceof HttpToolIndexShapeError ? 400 : 500
533
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
534
+ return
535
+ }
536
+
537
+ const currentTools = entry.config.tools ?? (await loadDefaultTools(entry.config))
538
+ updateAgent(agentName, { config: { tools: [...currentTools, tool] } })
539
+
540
+ if (parsed.decision) {
541
+ try {
542
+ addActauthRule(agentName, { name: `${parsed.value.name}-admin-created`, scope: '*/*', tool: parsed.value.name, decision: parsed.decision })
543
+ } catch (err) {
544
+ // The tool file and live registration above already succeeded —
545
+ // a rule-seeding failure (near-impossible: only real cause is a
546
+ // same-named rule already existing) shouldn't roll either back,
547
+ // just surface as a warning-shaped success so the operator can
548
+ // add the rule by hand via the Actauth tab instead.
549
+ res.writeHead(200, { 'content-type': 'application/json' }).end(
550
+ JSON.stringify({ tool: describeTool(tool), ruleWarning: err instanceof Error ? err.message : String(err) }),
551
+ )
552
+ return
553
+ }
554
+ }
555
+
556
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ tool: describeTool(tool) }))
557
+ }
558
+
444
559
  // Backs GET /composio/connections and GET /composio/tools — not scoped
445
560
  // under /agents/:name/ like the routes above, deliberately: which apps
446
561
  // are connected and what they offer isn't a property of any one agent,
@@ -539,6 +654,33 @@ function parseAgentTemplateOptions(body: Record<string, unknown>): { ok: true; v
539
654
  return { ok: true, value: options }
540
655
  }
541
656
 
657
+ // The Overview tab's "Limits & budgets" edit form — kept separate from
658
+ // parseAgentTemplateOptions since that one's shape (AgentTemplateOptions)
659
+ // is shared with handleCreateAgent/scaffoldAgent, which has no notion of
660
+ // these three at all (a freshly scaffolded agent always starts on
661
+ // loopengine's own defaults — see AgentConfig's own doc comments). Each
662
+ // of the three is optional and independent, same as systemPrompt/model
663
+ // above.
664
+ function parsePositiveInt(value: unknown, field: string): { ok: true; value: number | undefined } | { ok: false; error: string } {
665
+ if (value === undefined || value === null) return { ok: true, value: undefined }
666
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
667
+ return { ok: false, error: `${field} must be a positive integer` }
668
+ }
669
+ return { ok: true, value }
670
+ }
671
+
672
+ function parseAgentLimitsFields(
673
+ body: Record<string, unknown>,
674
+ ): { ok: true; value: Pick<AgentEditableFields, 'maxTurns' | 'contextBudgetTokens' | 'skillIndexBudgetTokens'> } | { ok: false; error: string } {
675
+ const value: Pick<AgentEditableFields, 'maxTurns' | 'contextBudgetTokens' | 'skillIndexBudgetTokens'> = {}
676
+ for (const field of ['maxTurns', 'contextBudgetTokens', 'skillIndexBudgetTokens'] as const) {
677
+ const parsed = parsePositiveInt(body[field], field)
678
+ if (!parsed.ok) return parsed
679
+ if (parsed.value !== undefined) value[field] = parsed.value
680
+ }
681
+ return { ok: true, value }
682
+ }
683
+
542
684
  async function handleCreateAgent(req: IncomingMessage, res: ServerResponse): Promise<void> {
543
685
  const body = await readJsonBody(req)
544
686
  if (typeof body.name !== 'string' || !body.name) {
@@ -572,9 +714,11 @@ async function handleCreateAgent(req: IncomingMessage, res: ServerResponse): Pro
572
714
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ path: indexPath, registered: true }))
573
715
  }
574
716
 
575
- // Backs the Overview tab's System prompt / Model edit forms — either
576
- // field can be sent independently (see parseAgentTemplateOptions), the
577
- // other stays untouched both on disk and live. Persists via
717
+ // Backs the Overview tab's System prompt / Model / Limits & budgets edit
718
+ // forms — any subset of systemPrompt, model, maxTurns, contextBudgetTokens,
719
+ // skillIndexBudgetTokens can be sent independently (see
720
+ // parseAgentTemplateOptions/parseAgentLimitsFields), every other field
721
+ // stays untouched both on disk and live. Persists via
578
722
  // agent-file-admin.ts's editAgentFile (see its own doc comment for why
579
723
  // this is a surgical AST edit, not a full file regeneration), then
580
724
  // applies the exact same resolved values to *this* running process via
@@ -594,14 +738,22 @@ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentN
594
738
  res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedOptions.error }))
595
739
  return
596
740
  }
597
- if (parsedOptions.value.systemPrompt === undefined && parsedOptions.value.model === undefined) {
598
- res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'systemPrompt or model is required' }))
741
+ const parsedLimits = parseAgentLimitsFields(body)
742
+ if (!parsedLimits.ok) {
743
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedLimits.error }))
744
+ return
745
+ }
746
+ const editFields: AgentEditableFields = { ...parsedOptions.value, ...parsedLimits.value }
747
+ if (Object.keys(editFields).length === 0) {
748
+ res
749
+ .writeHead(400, { 'content-type': 'application/json' })
750
+ .end(JSON.stringify({ error: 'systemPrompt, model, maxTurns, contextBudgetTokens, or skillIndexBudgetTokens is required' }))
599
751
  return
600
752
  }
601
753
 
602
754
  let result: AgentEditResult
603
755
  try {
604
- result = editAgentFile(agentName, parsedOptions.value)
756
+ result = editAgentFile(agentName, editFields)
605
757
  } catch (err) {
606
758
  const status =
607
759
  err instanceof AgentEditNotSupportedError || err instanceof AgentModelError ? 400 : err instanceof AgentFileNotFoundError ? 404 : 500
@@ -611,6 +763,9 @@ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentN
611
763
 
612
764
  const configPatch: Partial<AgentConfig> = {}
613
765
  if (result.systemPrompt !== undefined) configPatch.systemPrompt = result.systemPrompt
766
+ if (result.maxTurns !== undefined) configPatch.maxTurns = result.maxTurns
767
+ if (result.contextBudgetTokens !== undefined) configPatch.contextBudgetTokens = result.contextBudgetTokens
768
+ if (result.skillIndexBudgetTokens !== undefined) configPatch.skillIndexBudgetTokens = result.skillIndexBudgetTokens
614
769
  let createModelCall: RegistryEntry['createModelCall'] | undefined
615
770
  if (result.model) {
616
771
  configPatch.model = result.model
@@ -677,6 +832,69 @@ function handleSkillDelete(res: ServerResponse, agentName: string, skillId: stri
677
832
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
678
833
  }
679
834
 
835
+ // Backs the Skills tab's "Browse skillgarden catalog" section — a
836
+ // read-only, agent-independent list of everything skillgarden ships in
837
+ // its own bundled registry (see loopengine's own web/skillgarden-admin.ts).
838
+ // Not wrapped in a per-agent :name segment since the catalog itself
839
+ // isn't scoped to any one agent; only the POST below (actually
840
+ // installing an entry) is.
841
+ function handleSkillgardenCatalogGet(res: ServerResponse): void {
842
+ try {
843
+ // Computed before writeHead, not inlined into .end()'s own argument —
844
+ // res.writeHead(200, ...) commits the status line the instant it's
845
+ // called, so a throw evaluated *after* it (as it would be if it were
846
+ // inlined as .end()'s argument) leaves headers already sent as 200
847
+ // with no body, and the catch block's own writeHead(status, ...)
848
+ // then fails outright (ERR_HTTP_HEADERS_SENT) instead of ever
849
+ // reaching the client.
850
+ const catalog = listSkillgardenCatalog()
851
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(catalog))
852
+ } catch (err) {
853
+ const status = err instanceof SkillgardenUnavailableError ? 503 : 500
854
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
855
+ }
856
+ }
857
+
858
+ // Full SKILL.md body for the catalog's own "View" toggle — kept as a
859
+ // separate route from the list above (rather than inlining every body
860
+ // into it) for the same reason the Skills tab's own edit form fetches a
861
+ // skill's body lazily instead of shipping it in the initial list.
862
+ function handleSkillgardenCatalogEntryGet(res: ServerResponse, category: string, id: string): void {
863
+ try {
864
+ // See handleSkillgardenCatalogGet's own comment for why this is
865
+ // computed before writeHead rather than inlined into .end()'s
866
+ // argument.
867
+ const entry = readSkillgardenCatalogEntry(category, id)
868
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(entry))
869
+ } catch (err) {
870
+ const status = err instanceof SkillgardenUnavailableError ? 503 : 404
871
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
872
+ }
873
+ }
874
+
875
+ // Installs a catalog entry into agents/:name/skills/ — delegates to
876
+ // skillgarden's own addSkill (see skillgarden-admin.ts), which throws
877
+ // plain Errors (no typed classes) for "unknown skill"/"already exists",
878
+ // so those two are told apart by message shape rather than instanceof,
879
+ // same as any other dependency that doesn't export typed errors for its
880
+ // own failure modes.
881
+ async function handleSkillgardenCatalogAdd(req: IncomingMessage, res: ServerResponse, agentName: string, category: string, id: string): Promise<void> {
882
+ if (!getEntry(agentName)) {
883
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
884
+ return
885
+ }
886
+ const body = await readJsonBody(req)
887
+ const force = body.force === true
888
+ try {
889
+ const result = addSkillgardenSkillToAgent(agentName, category, id, force)
890
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, id: result.id }))
891
+ } catch (err) {
892
+ const message = err instanceof Error ? err.message : String(err)
893
+ const status = err instanceof SkillgardenUnavailableError ? 503 : /already exists/.test(message) ? 409 : /^Unknown skill/.test(message) ? 404 : 500
894
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: message }))
895
+ }
896
+ }
897
+
680
898
  // Backs the Actauth tab's rule editor — parses a rule body shared by both
681
899
  // the add (POST) and update (PUT, minus `name`) routes below.
682
900
  function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolean): { ok: true; value: { name: string; scope: string; tool: string; decision: Decision } } | { ok: false; error: string } {
@@ -1779,6 +1997,12 @@ const server = createServer(async (req, res) => {
1779
1997
  return
1780
1998
  }
1781
1999
 
2000
+ const httpToolMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/tools\/http$/)
2001
+ if (httpToolMatch) {
2002
+ await handleHttpToolPost(req, res, decodeURIComponent(httpToolMatch[1]))
2003
+ return
2004
+ }
2005
+
1782
2006
  // Backs the Skills tab's edit form — see skills-admin.ts for why
1783
2007
  // :skillId is restricted to a flat (non-nested) id.
1784
2008
  const skillMatch = pathname.match(/^\/agents\/([^/]+)\/skills\/([^/]+)$/)
@@ -1795,6 +2019,23 @@ const server = createServer(async (req, res) => {
1795
2019
  return
1796
2020
  }
1797
2021
 
2022
+ // Backs the Skills tab's "Browse skillgarden catalog" section — see
2023
+ // the three handlers above it.
2024
+ if (req.method === 'GET' && pathname === '/skillgarden-catalog') {
2025
+ handleSkillgardenCatalogGet(res)
2026
+ return
2027
+ }
2028
+ const skillgardenEntryMatch = req.method === 'GET' && pathname.match(/^\/skillgarden-catalog\/([^/]+)\/([^/]+)$/)
2029
+ if (skillgardenEntryMatch) {
2030
+ handleSkillgardenCatalogEntryGet(res, decodeURIComponent(skillgardenEntryMatch[1]), decodeURIComponent(skillgardenEntryMatch[2]))
2031
+ return
2032
+ }
2033
+ const skillgardenAddMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/skillgarden-catalog\/([^/]+)\/([^/]+)$/)
2034
+ if (skillgardenAddMatch) {
2035
+ await handleSkillgardenCatalogAdd(req, res, decodeURIComponent(skillgardenAddMatch[1]), decodeURIComponent(skillgardenAddMatch[2]), decodeURIComponent(skillgardenAddMatch[3]))
2036
+ return
2037
+ }
2038
+
1798
2039
  // Backs the Actauth tab's rule editor and default_decision control.
1799
2040
  // The default-decision route is checked before the :ruleName routes
1800
2041
  // right below since 'default-decision' would otherwise itself match
@@ -9,9 +9,9 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.4",
12
+ "loopengine": "^0.1.6",
13
13
  "actauth": "^0.0.12",
14
- "skillgarden": "^0.0.5"
14
+ "skillgarden": "^0.0.9"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@types/node": "^22.10.0",