toolnexus 0.7.0 → 0.9.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/dist/skill.js CHANGED
@@ -3,6 +3,11 @@
3
3
  * discover **\/SKILL.md, parse frontmatter, and expose ONE `skill` tool that
4
4
  * loads a skill's instructions + sampled resources on demand (progressive
5
5
  * disclosure).
6
+ *
7
+ * Beyond on-disk discovery the source also accepts skills supplied as data
8
+ * (`SkillDef`) — SPEC.md §3. Directory-sourced skills keep the exact `file://`
9
+ * base + on-disk sibling sampling (byte-identical); data-sourced skills use a
10
+ * logical `skill://name/` base + a supplied resource list and never touch disk.
6
11
  */
7
12
  import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
8
13
  import path from "node:path";
@@ -23,18 +28,25 @@ export const SKILLS_PROMPT_PREAMBLE = "Skills provide specialized instructions a
23
28
  * Parse YAML frontmatter (between the leading `---` fences) with a real YAML
24
29
  * parser, so folded (`>`)/literal (`|`) block scalars, quoting, and multi-line
25
30
  * values all resolve correctly. Scalar values are coerced to strings.
31
+ *
32
+ * `malformed` is true only when fences are present but the YAML fails to parse —
33
+ * distinguishing a malformed header from a body with no frontmatter, so the
34
+ * inventory (S3) can report the right skip reason. loadSkills' behavior is
35
+ * unchanged: a malformed header yields no name and the skill is skipped.
26
36
  */
27
37
  function parseFrontmatter(text) {
28
38
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
29
39
  if (!match)
30
- return { data: {}, content: text };
40
+ return { data: {}, content: text, malformed: false };
31
41
  const data = {};
32
42
  let parsed;
43
+ let malformed = false;
33
44
  try {
34
45
  parsed = parseYaml(match[1]);
35
46
  }
36
47
  catch {
37
48
  parsed = null;
49
+ malformed = true;
38
50
  }
39
51
  if (parsed && typeof parsed === "object") {
40
52
  for (const [key, value] of Object.entries(parsed)) {
@@ -45,7 +57,7 @@ function parseFrontmatter(text) {
45
57
  }
46
58
  }
47
59
  }
48
- return { data, content: match[2] };
60
+ return { data, content: match[2], malformed };
49
61
  }
50
62
  function walkSkillFiles(root) {
51
63
  const out = [];
@@ -150,45 +162,130 @@ function sampleSiblingFiles(dir, limit = 10) {
150
162
  }
151
163
  return out;
152
164
  }
153
- /** Discover skills under one or more roots and build the `skill` loader tool. */
154
- export function loadSkills(dirs) {
155
- const roots = Array.isArray(dirs) ? dirs : [dirs];
156
- const skills = {};
157
- for (const root of roots) {
158
- let isDir = false;
165
+ /** Discover candidates under one on-disk root (order = discovery order). */
166
+ function candidatesFromDir(root) {
167
+ const out = [];
168
+ let isDir = false;
169
+ try {
170
+ isDir = statSync(root).isDirectory();
171
+ }
172
+ catch {
173
+ isDir = false;
174
+ }
175
+ if (!isDir) {
176
+ console.warn(`[toolnexus] skills dir not found: ${root}`);
177
+ return out;
178
+ }
179
+ for (const file of walkSkillFiles(root)) {
180
+ let text;
159
181
  try {
160
- isDir = statSync(root).isDirectory();
182
+ text = readFileSync(file, "utf8");
161
183
  }
162
184
  catch {
163
- isDir = false;
185
+ out.push({ skip: { location: file, reason: "unreadable" } });
186
+ continue;
164
187
  }
165
- if (!isDir) {
166
- console.warn(`[toolnexus] skills dir not found: ${root}`);
188
+ const { data, content, malformed } = parseFrontmatter(text);
189
+ if (malformed) {
190
+ out.push({ skip: { location: file, reason: "malformed-frontmatter" } });
167
191
  continue;
168
192
  }
169
- for (const file of walkSkillFiles(root)) {
170
- let text;
171
- try {
172
- text = readFileSync(file, "utf8");
173
- }
174
- catch {
175
- continue;
176
- }
177
- const { data, content } = parseFrontmatter(text);
178
- if (!data.name)
179
- continue;
180
- if (skills[data.name]) {
181
- console.warn(`[toolnexus] duplicate skill name "${data.name}" (${file}) — keeping first`);
182
- continue;
183
- }
184
- skills[data.name] = {
185
- name: data.name,
186
- description: data.description,
187
- location: file,
188
- content,
189
- };
193
+ if (!data.name) {
194
+ out.push({ skip: { location: file, reason: "missing-name" } });
195
+ continue;
190
196
  }
197
+ out.push({ info: { name: data.name, description: data.description, location: file, content, origin: "fs" } });
191
198
  }
199
+ return out;
200
+ }
201
+ /** Turn data-supplied skill defs into candidates (logical origin). */
202
+ function candidatesFromDefs(defs) {
203
+ return defs.map((d) => {
204
+ if (!d.name)
205
+ return { skip: { location: d.base ?? "skill://", reason: "missing-name" } };
206
+ const base = d.base && d.base.length > 0 ? d.base : `skill://${d.name}/`;
207
+ return {
208
+ info: {
209
+ name: d.name,
210
+ description: d.description,
211
+ location: base,
212
+ content: d.content ?? "",
213
+ origin: "logical",
214
+ resources: d.resources ?? [],
215
+ base,
216
+ },
217
+ };
218
+ });
219
+ }
220
+ /** Collect ordered candidates from dirs then data defs. */
221
+ function collectCandidates(opts) {
222
+ const roots = opts.dirs === undefined ? [] : Array.isArray(opts.dirs) ? opts.dirs : [opts.dirs];
223
+ const cands = [];
224
+ for (const root of roots)
225
+ cands.push(...candidatesFromDir(root));
226
+ if (opts.skills)
227
+ cands.push(...candidatesFromDefs(opts.skills));
228
+ return cands;
229
+ }
230
+ /** Dedupe candidates by name (first-wins); later duplicates become skips. */
231
+ function mergeCandidates(cands) {
232
+ const skills = {};
233
+ const skipped = [];
234
+ for (const c of cands) {
235
+ if (c.skip) {
236
+ skipped.push(c.skip);
237
+ continue;
238
+ }
239
+ const info = c.info;
240
+ if (skills[info.name]) {
241
+ console.warn(`[toolnexus] duplicate skill name "${info.name}" (${info.location}) — keeping first`);
242
+ skipped.push({ location: info.location, reason: "duplicate-name" });
243
+ continue;
244
+ }
245
+ skills[info.name] = info;
246
+ }
247
+ return { skills, skipped };
248
+ }
249
+ /**
250
+ * Apply the per-agent skill allowlist (S2), semantics identical to the MCP
251
+ * per-server tools filter and builtins: nil/empty ⇒ all; ≥1 true ⇒ allowlist
252
+ * (only true-mapped names); only-false ⇒ drop-list over all-on; unknown names
253
+ * are ignored and warned once.
254
+ */
255
+ function applySkillsFilter(skills, filter) {
256
+ if (!filter)
257
+ return skills;
258
+ const keys = Object.keys(filter);
259
+ if (keys.length === 0)
260
+ return skills;
261
+ const hasTrue = keys.some((k) => filter[k] === true);
262
+ for (const k of keys) {
263
+ if (!(k in skills))
264
+ console.warn(`[toolnexus] skill filter name "${k}" matched no skill`);
265
+ }
266
+ const out = {};
267
+ for (const [name, info] of Object.entries(skills)) {
268
+ if (hasTrue ? filter[name] === true : filter[name] !== false)
269
+ out[name] = info;
270
+ }
271
+ return out;
272
+ }
273
+ /**
274
+ * Discover + validate skills from the same sources loadSkills accepts, returning
275
+ * parsed skills plus typed skip reasons — no toolkit wired (SPEC.md §3, S3). The
276
+ * inventory is UNFILTERED (it exists to author/validate the allowlist).
277
+ */
278
+ export function listSkills(input) {
279
+ const opts = typeof input === "string" || Array.isArray(input) ? { dirs: input } : input;
280
+ const merged = mergeCandidates(collectCandidates(opts));
281
+ return { skills: Object.values(merged.skills), skipped: merged.skipped };
282
+ }
283
+ /** Discover skills (dirs and/or data) and build the `skill` loader tool. */
284
+ export function loadSkills(input) {
285
+ const opts = typeof input === "string" || Array.isArray(input) ? { dirs: input } : input;
286
+ const sampleLimit = opts.sampleLimit ?? 0;
287
+ const merged = mergeCandidates(collectCandidates(opts));
288
+ const skills = applySkillsFilter(merged.skills, opts.filter);
192
289
  const tool = {
193
290
  name: "skill",
194
291
  description: SKILL_TOOL_DESCRIPTION,
@@ -208,10 +305,30 @@ export function loadSkills(dirs) {
208
305
  isError: true,
209
306
  };
210
307
  }
211
- const dir = path.dirname(info.location);
212
- const base = pathToFileURL(dir).href;
213
- const files = sampleSiblingFiles(dir);
214
- const output = [
308
+ // effLimit: 0 ⇒ default 10 (byte-identical to today), n>0 ⇒ cap, -1 ⇒ omit.
309
+ const effLimit = sampleLimit === 0 ? 10 : sampleLimit;
310
+ let base;
311
+ let files;
312
+ let metaDir;
313
+ // Emit the <skill_files> block: always for fs skills (byte-identical, even
314
+ // when empty), only when a data skill actually carries resources (an
315
+ // instruction-only data skill omits it). Never when sampling is disabled.
316
+ let emitFiles = effLimit !== -1;
317
+ if (info.origin === "logical") {
318
+ base = info.base ?? `skill://${info.name}/`;
319
+ const res = info.resources ?? [];
320
+ if (res.length === 0)
321
+ emitFiles = false;
322
+ files = effLimit > 0 ? res.slice(0, effLimit) : res;
323
+ metaDir = base;
324
+ }
325
+ else {
326
+ const dir = path.dirname(info.location);
327
+ base = pathToFileURL(dir).href;
328
+ files = effLimit === -1 ? [] : sampleSiblingFiles(dir, effLimit);
329
+ metaDir = dir;
330
+ }
331
+ const lines = [
215
332
  `<skill_content name="${info.name}">`,
216
333
  `# Skill: ${info.name}`,
217
334
  "",
@@ -219,14 +336,12 @@ export function loadSkills(dirs) {
219
336
  "",
220
337
  `Base directory for this skill: ${base}`,
221
338
  "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
222
- "Note: file list is sampled.",
223
- "",
224
- "<skill_files>",
225
- files.map((f) => `<file>${f}</file>`).join("\n"),
226
- "</skill_files>",
227
- "</skill_content>",
228
- ].join("\n");
229
- return { output, isError: false, metadata: { name: info.name, dir } };
339
+ ];
340
+ if (emitFiles) {
341
+ lines.push("Note: file list is sampled.", "", "<skill_files>", files.map((f) => `<file>${f}</file>`).join("\n"), "</skill_files>");
342
+ }
343
+ lines.push("</skill_content>");
344
+ return { output: lines.join("\n"), isError: false, metadata: { name: info.name, dir: metaDir } };
230
345
  },
231
346
  };
232
347
  return {
package/dist/toolkit.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * single uniform list, with provider adapters and a single execute() router.
4
4
  */
5
5
  import { type Tool, type ToolContext, type ToolResult, type McpStatus } from "./types.js";
6
+ import { type SkillDef } from "./skill.js";
6
7
  import { type BuiltinsConfig } from "./builtin.js";
7
8
  import { type Agent } from "./a2a.js";
8
9
  import { type A2AConfig, type OnTask, type ServeHandle } from "./serve.js";
@@ -11,6 +12,14 @@ import type { Client } from "./client.js";
11
12
  export interface ToolkitOptions {
12
13
  mcpConfig?: string | object;
13
14
  skillsDir?: string | string[];
15
+ /** Skills supplied as data, bypassing the filesystem (§3, S1). */
16
+ skills?: SkillDef[];
17
+ /** Lazy provider of data-supplied skills, resolved once at toolkit build (§3, S1). */
18
+ skillProvider?: () => SkillDef[] | Promise<SkillDef[]>;
19
+ /** Per-agent skill allowlist keyed on name; same semantics as the MCP tools filter (§3, S2). */
20
+ skillsFilter?: Record<string, boolean>;
21
+ /** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files> (§3, S5). */
22
+ skillSampleLimit?: number;
14
23
  extraTools?: Tool[];
15
24
  /** Built-in tools (§4A). On by default. false | { disabled:true } | { enabled:false } ⇒ off. */
16
25
  builtins?: BuiltinsConfig;
@@ -23,6 +32,9 @@ export interface ToolkitOptions {
23
32
  * elicitation is not advertised.
24
33
  */
25
34
  waitFor?: (request: import("./types.js").Request) => Promise<import("./types.js").Answer>;
35
+ /** §2 Gap 3. Bounds the MCP load: each connect/list is capped by the server timeout and the
36
+ * whole load aborts promptly if this signal fires. Omit ⇒ unbounded as before. */
37
+ signal?: AbortSignal;
26
38
  }
27
39
  export declare class Toolkit {
28
40
  private readonly mcp;
package/dist/toolkit.js CHANGED
@@ -34,8 +34,30 @@ export class Toolkit {
34
34
  }
35
35
  }
36
36
  static async create(opts) {
37
- const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined;
38
- const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : undefined;
37
+ const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal }) : undefined;
38
+ // Skills come from directories, in-memory data, and/or a lazy provider (§3,
39
+ // S1). The provider is resolved here (create() is async) and merged with the
40
+ // data list; a provider failure is isolated so other sources still load.
41
+ let skill;
42
+ if (opts.skillsDir !== undefined || opts.skills !== undefined || opts.skillProvider !== undefined) {
43
+ let providerDefs = [];
44
+ if (opts.skillProvider) {
45
+ try {
46
+ providerDefs = await opts.skillProvider();
47
+ }
48
+ catch (e) {
49
+ console.warn(`[toolnexus] skill provider failed: ${e instanceof Error ? e.message : String(e)}`);
50
+ providerDefs = [];
51
+ }
52
+ }
53
+ const dataDefs = [...(opts.skills ?? []), ...providerDefs];
54
+ skill = loadSkills({
55
+ dirs: opts.skillsDir,
56
+ skills: dataDefs.length > 0 ? dataDefs : undefined,
57
+ filter: opts.skillsFilter,
58
+ sampleLimit: opts.skillSampleLimit,
59
+ });
60
+ }
39
61
  // The toggle comes from the `builtins` option, or a top-level `builtins`
40
62
  // key on a parsed config object — same precedence as MCP's isEnabled.
41
63
  let builtinsCfg = opts.builtins;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/client.ts CHANGED
@@ -35,6 +35,18 @@ export interface ClientOptions {
35
35
  * (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
36
36
  * host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
37
37
  waitFor?: (request: Request) => Promise<Answer>
38
+ /** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
39
+ * its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
40
+ * default). `messages`/`tools`/`stream` are forbidden here (stripped + warned) — rewrite
41
+ * messages via bodyTransform. Applied on all four paths. Omit ⇒ body byte-identical to today. */
42
+ requestParams?: Record<string, unknown>
43
+ /** §8 Gap 1. Receives the fully assembled body (after requestParams merge) just before marshal
44
+ * and returns the body to send — the escape hatch for provider-specific rewriting (e.g. tool-role
45
+ * message preprocessing) without a proxy. Returning a falsy value ⇒ unchanged. Runs on every call. */
46
+ bodyTransform?: (body: Record<string, any>) => Record<string, any> | undefined | void
47
+ /** §8 Gap 2. HTTP transport for LLM requests (retries included). Omit ⇒ global fetch. Scope is the
48
+ * LLM path only; MCP transports use the SDK's own client. */
49
+ fetch?: typeof fetch
38
50
  }
39
51
 
40
52
  /**
@@ -281,6 +293,61 @@ export class Client {
281
293
  this.store = opts.store ?? new InMemoryConversationStore()
282
294
  }
283
295
 
296
+ /**
297
+ * §8 Gap 4. The client's conversation store — the exact instance passed in `opts.store`, else
298
+ * the default in-memory store the client created. Read (`get`) and write (`save`) it directly to
299
+ * share state with `ask`; no consumer needs a shadow copy.
300
+ */
301
+ conversationStore(): ConversationStore {
302
+ return this.store
303
+ }
304
+
305
+ /**
306
+ * §8 Gap 1. Apply the request-shaping contract to an assembled body: strip forbidden keys from
307
+ * requestParams, shallow-merge requestParams (caller wins), then run bodyTransform last. Called at
308
+ * every LLM body-build site so the ordering contract holds identically on all four paths.
309
+ */
310
+ private finalizeBody(body: Record<string, any>): Record<string, any> {
311
+ const rp = this.opts.requestParams
312
+ if (rp) {
313
+ for (const [k, v] of Object.entries(rp)) {
314
+ if (k === "messages" || k === "tools" || k === "stream") {
315
+ console.warn(`[toolnexus] requestParams key "${k}" is not allowed — ignored (use bodyTransform)`)
316
+ continue
317
+ }
318
+ body[k] = v
319
+ }
320
+ }
321
+ if (this.opts.bodyTransform) {
322
+ const out = this.opts.bodyTransform(body)
323
+ if (out) return out
324
+ }
325
+ return body
326
+ }
327
+
328
+ /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
329
+ * matches the pre-change body so a no-options call is byte-identical. */
330
+ private openaiBody(messages: any[], tools: any[], stream: boolean): Record<string, any> {
331
+ const b: Record<string, any> = { model: this.opts.model, messages }
332
+ if (tools.length) {
333
+ b.tools = tools
334
+ b.tool_choice = "auto"
335
+ }
336
+ if (stream) {
337
+ b.stream = true
338
+ b.stream_options = { include_usage: true }
339
+ }
340
+ return this.finalizeBody(b)
341
+ }
342
+
343
+ /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
344
+ private anthropicBody(system: string, messages: any[], tools: any[], stream: boolean): Record<string, any> {
345
+ const b: Record<string, any> = { model: this.opts.model, max_tokens: 4096, system, messages }
346
+ if (tools.length) b.tools = tools
347
+ if (stream) b.stream = true
348
+ return this.finalizeBody(b)
349
+ }
350
+
284
351
  /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
285
352
  metrics(): string {
286
353
  return this.registry.render()
@@ -379,7 +446,7 @@ export class Client {
379
446
  let lastErr: unknown
380
447
  for (let attempt = 0; attempt <= retries; attempt++) {
381
448
  try {
382
- const res = await fetch(url, { ...init, signal })
449
+ const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal })
383
450
  if (res.ok || !RETRYABLE.has(res.status) || attempt === retries) return res
384
451
  const ra = Number(res.headers.get("retry-after"))
385
452
  await delay(ra ? ra * 1000 : base * 2 ** attempt + Math.random() * 100, signal)
@@ -470,7 +537,7 @@ export class Client {
470
537
  const data: any = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
471
538
  method: "POST",
472
539
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
473
- body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto" }),
540
+ body: JSON.stringify(this.openaiBody(messages, tools, false)),
474
541
  }, signal, "openai")
475
542
  addUsage(usage, data.usage, "openai")
476
543
  if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
@@ -570,7 +637,7 @@ export class Client {
570
637
  "Content-Type": "application/json",
571
638
  ...this.opts.headers,
572
639
  },
573
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools }),
640
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, false)),
574
641
  }, signal, "anthropic")
575
642
  addUsage(usage, data.usage, "anthropic")
576
643
  if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
@@ -646,7 +713,7 @@ export class Client {
646
713
  const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
647
714
  method: "POST",
648
715
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
649
- body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto", stream: true, stream_options: { include_usage: true } }),
716
+ body: JSON.stringify(this.openaiBody(messages, tools, true)),
650
717
  }, signal)
651
718
  if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
652
719
  for await (const line of sseLines(res.body)) {
@@ -740,7 +807,7 @@ export class Client {
740
807
  const res = await this.llmFetch(endpoint, {
741
808
  method: "POST",
742
809
  headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
743
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools, stream: true }),
810
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
744
811
  }, signal)
745
812
  if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
746
813
  for await (const line of sseLines(res.body)) {