loopengine 0.0.10 → 0.0.16

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,5 +1,5 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
- import type { ModelCall } from './run-agent.js';
2
+ import type { ModelCall } from '#run-agent.js';
3
3
  export interface AnthropicModelCallOptions {
4
4
  /** Defaults to the ANTHROPIC_API_KEY env var, same as the SDK itself. */
5
5
  apiKey?: string;
@@ -1,9 +1,11 @@
1
- // The one real ModelCall implementation loopengine ships. Every example
2
- // agent (file-agent.ts, customer-service-agent.ts, rag-agent.ts) still
3
- // uses a canned, turn-counting fake so the whole loop is runnable with no
4
- // API key — swap that fake for createAnthropicModelCall(...) and nothing
5
- // in run-agent.ts, the adapters, or any AgentConfig changes. ModelCall is
6
- // the only seam this needed.
1
+ // One of the real ModelCall implementations loopengine ships (see also
2
+ // openai-model-call.ts, deepseek-model-call.ts — the latter is what
3
+ // agents/customer-service/index.ts actually uses). file-agent.ts and
4
+ // rag-agent.ts still use a canned, turn-counting fake so the whole loop
5
+ // is runnable with no API key — swap that fake for
6
+ // createAnthropicModelCall(...) and nothing in run-agent.ts, the
7
+ // adapters, or any AgentConfig changes. ModelCall is the only seam this
8
+ // needed.
7
9
  //
8
10
  // loopengine's own Message type mirrors Anthropic's native shape closely
9
11
  // on purpose: `content` is either a plain string or a ModelContentBlock[]
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import type { ModelCall } from './run-agent.js';
2
+ import type { ModelCall } from '#run-agent.js';
3
3
  export interface DeepSeekModelCallOptions {
4
4
  /** Defaults to the DEEPSEEK_API_KEY env var. */
5
5
  apiKey?: string;
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import type { Message, ModelCall, ModelResponse } from './run-agent.js';
2
+ import type { Message, ModelCall, ModelResponse } from '#run-agent.js';
3
3
  type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam;
4
4
  export interface OpenAIModelCallOptions {
5
5
  /** Defaults to the OPENAI_API_KEY env var, same as the SDK itself. */
@@ -1,4 +1,5 @@
1
- import type { AgentConfig, ToolSchema } from './agent-config.js';
1
+ import { RuleSet } from 'actauth';
2
+ import type { AgentConfig, ToolDefinition, ToolSchema } from '#agent-config.js';
2
3
  export interface ModelContentBlock {
3
4
  type: string;
4
5
  /** text blocks. */
@@ -38,6 +39,13 @@ export type ModelCall = (messages: Message[], system: string, tools: ToolSchema[
38
39
  export interface RunAgentOptions {
39
40
  /** Emitted at each loop step for callers that want visibility (logging, UI, tests). Default: no-op. */
40
41
  onEvent?: (event: string, detail: unknown) => void;
42
+ /** The ActAuth tenant this call runs as — feeds the Gate's scope, so it
43
+ * governs which rules apply. runAgent() never sees a request, so it
44
+ * can't call AgentConfig.tenantFor itself; adapters/http.ts resolves it
45
+ * per request and passes the result here. Default `'default'`, same as
46
+ * omitting it — every standalone/CLI caller (which never has a request
47
+ * to resolve tenantFor from) gets that default automatically. */
48
+ tenant?: string;
41
49
  }
42
50
  export interface RunAgentResult {
43
51
  /** Final assistant text for this turn. */
@@ -59,4 +67,26 @@ export interface RunAgentResult {
59
67
  * not something the model said. Absent on a normal finish. */
60
68
  stopReason?: 'max_turns';
61
69
  }
70
+ /** Resolves AgentConfig.rules the same way skillsDirs resolves its own
71
+ * default: an inline array is used as-is (full 3-segment scopePattern,
72
+ * unchanged — see loadYamlRules for why only the YAML form gets the
73
+ * agent segment auto-appended); omitted entirely defaults to
74
+ * `agents/<name>/actauth.yml`, this repo's own folder-form convention.
75
+ * Unlike skillsDirs, a missing *default* file falls back to an empty
76
+ * ruleset rather than propagating the raw ENOENT — permissions failing
77
+ * shouldn't crash the whole call. That fallback defaults to `'deny'`, not
78
+ * the inline-array form's `'ask'`: no `actauth.yml` at all means this
79
+ * agent's permission story was never actually written, which is a
80
+ * stricter unknown than "written, but this one tool has no rule" — 'ask'
81
+ * still runs an Approver that could auto-approve, while 'deny' refuses
82
+ * outright until real rules exist. `defaultDecision`, if set, still wins
83
+ * over that. An *explicitly* given path that doesn't exist is a real
84
+ * configuration bug, though, and still throws — only the silent, implicit
85
+ * default gets this forgiveness.
86
+ *
87
+ * Exported (alongside loadDefaultTools below) so adapters/http.ts's
88
+ * GET /agents/:name/config route can show the same rules runAgent() would
89
+ * actually enforce, not a re-derived guess at them. */
90
+ export declare function loadRules(config: AgentConfig): RuleSet;
91
+ export declare function loadDefaultTools(config: AgentConfig): Promise<ToolDefinition[]>;
62
92
  export declare function runAgent(config: AgentConfig, modelCall: ModelCall, userMessage: string, history?: Message[], options?: RunAgentOptions): Promise<RunAgentResult>;
package/dist/run-agent.js CHANGED
@@ -3,11 +3,29 @@
3
3
  // differ. runAgent itself does no I/O and holds no state between calls:
4
4
  // callers own conversation history, which is what lets the same function
5
5
  // serve a one-shot CLI invocation and a long-lived chat session.
6
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { parse as parseYaml } from 'yaml';
6
10
  import { Gate, RuleSet, ConsoleApprover } from 'actauth';
7
11
  import { SkillGarden } from 'skillgarden';
8
12
  import { ContextClipper } from 'contextclip';
9
13
  import { ToolLane } from 'toollane';
10
14
  import { Reflow } from 'reflowkit';
15
+ import { loadAgentModule } from './discover-agents.js';
16
+ import { agentAsTool } from './agent-as-tool.js';
17
+ // Resolved relative to *this file's own location* (via import.meta.url),
18
+ // not process.cwd() — the same reasoning agent-registry.ts's own
19
+ // agentsDir uses. In dev (tsx), this file and agents/ are both source
20
+ // .ts, side by side. In the built dist/, this file is dist/run-agent.js
21
+ // and its sibling agents/ is dist/agents/**/*.js (tsc-compiled) — cwd is
22
+ // irrelevant to either case, only "next to this module" is reliably
23
+ // right in both. skillsDirs/rules don't need this: actauth.yml/SKILL.md
24
+ // are plain data copied verbatim into the image (see Dockerfile), so
25
+ // they exist at the same cwd-relative path either way; a tools/index.ts
26
+ // is real code that only exists compiled, at a different relative
27
+ // location, once dist/ is what's actually running.
28
+ const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), 'agents');
11
29
  /** ContextClip only ever needs a flat string per message to estimate
12
30
  * token usage — it doesn't need to understand tool_use/tool_result
13
31
  * structure, so this is a one-way, read-only projection, never something
@@ -29,20 +47,215 @@ function flattenForContextClip(message) {
29
47
  .join('\n');
30
48
  return { role: message.role, content: text };
31
49
  }
50
+ /** Parses a loopengine actauth.yml — same shape as examples/actauth.yml
51
+ * in the actauth package, except each rule's `scope` only needs
52
+ * tenant/environment (a wildcard/wildcard, wildcard/staging, or
53
+ * acme-corp/production pair, say) — the agent segment is appended
54
+ * automatically from AgentConfig.name, then handed to actauth's own
55
+ * RuleSet.fromRaw (not fromYamlFile, which
56
+ * expects the full 3-segment scope already baked into the file). A
57
+ * per-agent actauth.yml (this repo's own convention: one file, one
58
+ * agent, path derived from the agent's own name — see
59
+ * agents/customer-service/actauth.yml) can only ever describe rules for
60
+ * that one agent anyway, so repeating its name in every single rule is
61
+ * exactly the kind of boilerplate skillsDirs/rules/tools' own defaults
62
+ * already avoid elsewhere in this file. */
63
+ function loadYamlRules(path, agentName) {
64
+ const raw = (parseYaml(readFileSync(path, 'utf8')) ?? {});
65
+ // A rule already ending in /<agentName> — the old, full 3-segment habit,
66
+ // or examples/actauth.yml copied verbatim — is left alone rather than
67
+ // getting a second, invalid segment appended; only the new 2-segment
68
+ // (tenant/environment) form actually needs the suffix.
69
+ const rules = (raw.rules ?? []).map((r) => ({
70
+ ...r,
71
+ scope: r.scope.endsWith(`/${agentName}`) ? r.scope : `${r.scope}/${agentName}`,
72
+ }));
73
+ return RuleSet.fromRaw({ default_decision: raw.default_decision, rules });
74
+ }
75
+ /** Resolves AgentConfig.rules the same way skillsDirs resolves its own
76
+ * default: an inline array is used as-is (full 3-segment scopePattern,
77
+ * unchanged — see loadYamlRules for why only the YAML form gets the
78
+ * agent segment auto-appended); omitted entirely defaults to
79
+ * `agents/<name>/actauth.yml`, this repo's own folder-form convention.
80
+ * Unlike skillsDirs, a missing *default* file falls back to an empty
81
+ * ruleset rather than propagating the raw ENOENT — permissions failing
82
+ * shouldn't crash the whole call. That fallback defaults to `'deny'`, not
83
+ * the inline-array form's `'ask'`: no `actauth.yml` at all means this
84
+ * agent's permission story was never actually written, which is a
85
+ * stricter unknown than "written, but this one tool has no rule" — 'ask'
86
+ * still runs an Approver that could auto-approve, while 'deny' refuses
87
+ * outright until real rules exist. `defaultDecision`, if set, still wins
88
+ * over that. An *explicitly* given path that doesn't exist is a real
89
+ * configuration bug, though, and still throws — only the silent, implicit
90
+ * default gets this forgiveness.
91
+ *
92
+ * Exported (alongside loadDefaultTools below) so adapters/http.ts's
93
+ * GET /agents/:name/config route can show the same rules runAgent() would
94
+ * actually enforce, not a re-derived guess at them. */
95
+ export function loadRules(config) {
96
+ if (Array.isArray(config.rules))
97
+ return new RuleSet(config.rules, config.defaultDecision ?? 'ask');
98
+ const usingDefaultPath = config.rules === undefined;
99
+ const path = config.rules ?? `agents/${config.name}/actauth.yml`;
100
+ try {
101
+ return loadYamlRules(path, config.name);
102
+ }
103
+ catch (err) {
104
+ if (usingDefaultPath && err.code === 'ENOENT') {
105
+ return new RuleSet([], config.defaultDecision ?? 'deny');
106
+ }
107
+ throw err;
108
+ }
109
+ }
110
+ /** Resolves AgentConfig.tools the same way loadRules resolves its own
111
+ * default: an explicit array (including `[]`) is used as-is; omitted
112
+ * entirely defaults to importing `agents/<name>/tools/index.{ts,js}`
113
+ * (this repo's own aggregation-file convention — see
114
+ * `agents/customer-service/tools/index.ts`) and using its exported
115
+ * `tools`. A missing file there is not an error — a flat-file agent with
116
+ * no tools folder at all (or one merging in dynamically-loaded tools, like
117
+ * `agents/file-agent/index.ts`'s Composio ones, so it can't just rely on
118
+ * this default) just gets `[]`, same as if it had explicitly set that. A
119
+ * module that *does* exist but throws while importing, or doesn't export
120
+ * `tools` at all, is a real bug and is not swallowed. */
121
+ async function loadToolsFromDir(toolsDir) {
122
+ for (const indexName of ['index.ts', 'index.js']) {
123
+ const indexPath = join(toolsDir, indexName);
124
+ if (!existsSync(indexPath))
125
+ continue;
126
+ const mod = (await import(pathToFileURL(indexPath).href));
127
+ if (!Array.isArray(mod.tools)) {
128
+ throw new Error(`${indexPath} does not export a 'tools' array — every agents/<name>/tools/index file must.`);
129
+ }
130
+ return mod.tools;
131
+ }
132
+ return [];
133
+ }
134
+ export async function loadDefaultTools(config) {
135
+ return loadToolsFromDir(join(agentsRootDir, config.name, 'tools'));
136
+ }
137
+ /** Every other folder-form default in this file (loadDefaultTools,
138
+ * loadRules, the skillsDirs default below) resolves against
139
+ * `agents/<name>/` — correct for a top-level agent, where that's exactly
140
+ * where its module lives, but wrong for a subagent: its `config` only
141
+ * carries its own bare name (e.g. 'billing-agent'), not the nested folder
142
+ * loadSubagentTools actually found it in
143
+ * (`agents/support-orchestrator/subagents/billing-agent/`). Left alone,
144
+ * an omitted `tools`/`rules`/`skillsDirs` on a subagent would silently
145
+ * resolve against `agents/billing-agent/...` instead — empty, if no such
146
+ * top-level folder exists, or worse, some *unrelated* top-level agent's
147
+ * tools/rules/skills, if one happens to share the same name.
148
+ *
149
+ * This patches exactly those three fields — only when the subagent's own
150
+ * config left them unset — to resolve against `dir`, its real folder,
151
+ * before it's ever handed to agentAsTool/runAgent. An explicit value on
152
+ * the subagent's own config (its author opted out on purpose) is always
153
+ * left untouched. `rules` gets the same "missing file is fine, empty
154
+ * deny-everything ruleset" fallback loadRules's own default path gets —
155
+ * computed here instead of left to loadRules, since leaving `rules`
156
+ * unset would have it fall through to loadRules's *own* (wrong,
157
+ * name-based) default path instead of this one. */
158
+ async function resolveSubagentConfig(config, dir) {
159
+ const resolved = { ...config };
160
+ if (resolved.tools === undefined) {
161
+ resolved.tools = await loadToolsFromDir(join(dir, 'tools'));
162
+ }
163
+ if (resolved.rules === undefined) {
164
+ const rulesPath = join(dir, 'actauth.yml');
165
+ if (existsSync(rulesPath)) {
166
+ resolved.rules = rulesPath;
167
+ }
168
+ else {
169
+ resolved.rules = [];
170
+ resolved.defaultDecision = resolved.defaultDecision ?? 'deny';
171
+ }
172
+ }
173
+ if (resolved.skillsDirs === undefined) {
174
+ resolved.skillsDirs = [join(dir, 'skills')];
175
+ }
176
+ return resolved;
177
+ }
178
+ /** Auto-loads agents/<name>/subagents/<child>/index.{ts,js} — each one a
179
+ * full AgentConfig, wrapped with agentAsTool and merged into `config`'s
180
+ * own tools, no import or AgentConfig.tools edit required. Unlike
181
+ * loadDefaultTools, this always runs regardless of whether `config.tools`
182
+ * was left to its own default or set explicitly — see AgentConfig.tools's
183
+ * own doc comment for why subagents are a distinct concern from
184
+ * hand-written tools. A missing subagents/ folder is just `[]`, same
185
+ * missing-is-fine treatment tools/ and skills/ get; a subdirectory with
186
+ * neither index.ts nor index.js is skipped, same as resolveModulePath's
187
+ * own handling in discover-agents.ts.
188
+ *
189
+ * Each subagent is loaded via loadAgentModule — the same per-module
190
+ * resolution discoverAgents itself uses — then patched by
191
+ * resolveSubagentConfig (see its own doc comment for why) before being
192
+ * wrapped. Its own `config` goes through this exact same function again
193
+ * the next time *it* runs (inside agentAsTool's execute, via runAgent).
194
+ * That's what makes nesting (a subagent with its own subagents/ folder)
195
+ * work with no extra recursion code: it's just this function being
196
+ * called again, one level down. A folder can't be its own ancestor, so
197
+ * this can't cycle the way a hand-wired agentAsTool(getEntry(...)) call
198
+ * elsewhere could. */
199
+ async function loadSubagentTools(config) {
200
+ const subagentsDir = join(agentsRootDir, config.name, 'subagents');
201
+ if (!existsSync(subagentsDir))
202
+ return [];
203
+ const tools = [];
204
+ for (const dirent of readdirSync(subagentsDir, { withFileTypes: true })) {
205
+ if (!dirent.isDirectory())
206
+ continue;
207
+ const dir = join(subagentsDir, dirent.name);
208
+ const indexPath = ['index.ts', 'index.js'].map((n) => join(dir, n)).find((p) => existsSync(p));
209
+ if (!indexPath)
210
+ continue;
211
+ const label = `agents/${config.name}/subagents/${dirent.name}/index`;
212
+ const subagent = await loadAgentModule(indexPath, label);
213
+ const subagentConfig = await resolveSubagentConfig(subagent.config, dir);
214
+ tools.push(agentAsTool(subagentConfig, subagent.createModelCall));
215
+ }
216
+ return tools;
217
+ }
32
218
  export async function runAgent(config, modelCall, userMessage, history = [], options = {}) {
33
219
  const log = options.onEvent ?? (() => { });
34
- const scope = { tenant: 'default', environment: 'production', ...config.scope, agent: config.name };
35
- const skillGarden = config.skillsDirs?.length
36
- ? new SkillGarden({ dirs: config.skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
37
- : null;
220
+ // tenant: resolved by the caller (adapters/http.ts calls
221
+ // AgentConfig.tenantFor with the request's headers/body; every other
222
+ // caller — adapters/cli.ts, a standalone script — has no request to
223
+ // resolve it from, so it never passes this option and gets 'default').
224
+ // environment: a deployment-wide setting,
225
+ // not a per-agent or per-request one — always LOOPENGINE_ENV, same
226
+ // everywhere this process runs, never something an AgentConfig defines.
227
+ const scope = { tenant: options.tenant ?? 'default', environment: process.env.LOOPENGINE_ENV ?? 'production', agent: config.name };
228
+ // Optional on AgentConfig — an agent can have zero tools. Explicit
229
+ // (including `[]`) is used as-is; omitted entirely defaults to
230
+ // importing agents/<name>/tools/index.{ts,js} — see loadDefaultTools's
231
+ // own doc comment for the full reasoning and the cases that can't use it.
232
+ // agents/<name>/subagents/* is merged in on top either way — see
233
+ // loadSubagentTools's own doc comment for why that one isn't gated by
234
+ // whether `tools` was explicit.
235
+ const tools = [...(config.tools ?? (await loadDefaultTools(config))), ...(await loadSubagentTools(config))];
236
+ // Omitted entirely (undefined): default to this agent's own
237
+ // agents/<name>/skills — the folder-form convention every agent in
238
+ // this repo already follows (see agent-config.ts's skillsDirs doc
239
+ // comment). An explicit `[]` is a real opt-out, not "unset," and skips
240
+ // the default — SkillGarden's own missing-directory handling
241
+ // (discoverSkillFiles walks best-effort, no throw) is what makes
242
+ // pointing this at a folder that doesn't exist (a flat-file agent with
243
+ // no skills at all) harmless: an empty index, not an error.
244
+ const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`];
245
+ const skillGarden = skillsDirs.length ? new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 }) : null;
38
246
  const skillIndex = skillGarden?.buildIndex().included ?? [];
39
247
  // Also the tail-preservation window recover() below relies on — kept as
40
248
  // one named constant so the two can never drift out of sync.
41
249
  const tailMessages = 4;
42
250
  const contextClip = new ContextClipper({ budgetTokens: config.contextBudgetTokens ?? 8000, tailMessages });
43
- const rules = new RuleSet(config.rules, config.defaultDecision ?? 'ask');
251
+ const rules = loadRules(config);
44
252
  const gate = new Gate(rules, config.approver ?? new ConsoleApprover());
45
- const toolLane = new ToolLane({ isSafe: config.isSafeTool ?? (() => false) });
253
+ // No explicit isSafeTool: fall back to each called tool's own `safe`
254
+ // flag (looked up by name) rather than defaulting every tool to unsafe
255
+ // outright — see ToolDefinition.safe's own doc comment for why this is
256
+ // the less powerful of the two (name-only, no per-call nuance).
257
+ const isSafeTool = config.isSafeTool ?? ((call) => tools.some((t) => t.name === call.name && t.safe === true));
258
+ const toolLane = new ToolLane({ isSafe: isSafeTool });
46
259
  let messages = [...history, { role: 'user', content: userMessage }];
47
260
  // Tracked in parallel with `messages`, but never rewritten wholesale by
48
261
  // recovery the way `messages` is — this is the actual, authoritative
@@ -93,8 +306,6 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
93
306
  return recovered;
94
307
  },
95
308
  });
96
- // Optional on AgentConfig — an agent can have zero tools.
97
- const tools = config.tools ?? [];
98
309
  const toolsByName = new Map(tools.map((t) => [t.name, t]));
99
310
  const toolSchemas = tools.map(({ name, description, input_schema }) => ({
100
311
  name,
@@ -1,6 +1,6 @@
1
1
  import { Redis } from 'ioredis';
2
2
  import { type Storage } from 'sessionknit';
3
- import type { Message } from './run-agent.js';
3
+ import type { Message } from '#run-agent.js';
4
4
  export interface SessionResult<T> {
5
5
  /** Exactly what this turn added — durably appended as-is, in order.
6
6
  * This has to come from the caller explicitly (runAgent's own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.0.10",
3
+ "version": "0.0.16",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -11,9 +11,17 @@
11
11
  },
12
12
  "main": "dist/index.js",
13
13
  "types": "dist/index.d.ts",
14
+ "bin": {
15
+ "loopengine": "dist/cli.js"
16
+ },
17
+ "imports": {
18
+ "#*": "./*"
19
+ },
14
20
  "files": [
15
21
  "dist/index.js",
16
22
  "dist/index.d.ts",
23
+ "dist/cli.js",
24
+ "dist/cli.d.ts",
17
25
  "dist/run-agent.js",
18
26
  "dist/run-agent.d.ts",
19
27
  "dist/agent-config.js",
@@ -22,18 +30,20 @@
22
30
  "dist/session-store.d.ts",
23
31
  "dist/vector-index.js",
24
32
  "dist/vector-index.d.ts",
25
- "dist/anthropic-model-call.js",
26
- "dist/anthropic-model-call.d.ts",
27
- "dist/openai-model-call.js",
28
- "dist/openai-model-call.d.ts",
29
- "dist/deepseek-model-call.js",
30
- "dist/deepseek-model-call.d.ts",
33
+ "dist/model-calls/anthropic-model-call.js",
34
+ "dist/model-calls/anthropic-model-call.d.ts",
35
+ "dist/model-calls/openai-model-call.js",
36
+ "dist/model-calls/openai-model-call.d.ts",
37
+ "dist/model-calls/deepseek-model-call.js",
38
+ "dist/model-calls/deepseek-model-call.d.ts",
31
39
  "dist/discover-agents.js",
32
40
  "dist/discover-agents.d.ts",
41
+ "dist/agent-as-tool.js",
42
+ "dist/agent-as-tool.d.ts",
33
43
  "LICENSE"
34
44
  ],
35
45
  "scripts": {
36
- "build": "tsc",
46
+ "build": "tsc && chmod +x dist/cli.js",
37
47
  "prepublishOnly": "npm run build",
38
48
  "start": "node dist/adapters/http.js",
39
49
  "test": "vitest run"
@@ -47,8 +57,9 @@
47
57
  "openai": "^7.4.0",
48
58
  "reflowkit": "^0.0.1",
49
59
  "sessionknit": "^0.0.2",
50
- "skillgarden": "^0.0.4",
51
- "toollane": "^0.0.1"
60
+ "skillgarden": "^0.0.5",
61
+ "toollane": "^0.0.1",
62
+ "yaml": "^2.6.0"
52
63
  },
53
64
  "devDependencies": {
54
65
  "@types/node": "^26.2.0",