toolnexus 0.7.0 → 0.8.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/serve.js CHANGED
@@ -50,7 +50,13 @@ export class FileTaskStore {
50
50
  }
51
51
  }
52
52
  async save(task) {
53
- fs.writeFileSync(this.file(task.id), JSON.stringify(task));
53
+ // Atomic write: a concurrent get() must never read a half-written file (that
54
+ // would surface as a spurious "Task not found" mid-poll). Write a temp file in
55
+ // the same dir, then rename over the target.
56
+ const target = this.file(task.id);
57
+ const tmp = `${target}.tmp`;
58
+ fs.writeFileSync(tmp, JSON.stringify(task));
59
+ fs.renameSync(tmp, target);
54
60
  }
55
61
  }
56
62
  /** Map a `store` selector to a concrete TaskStore. `"file:<dir>"` → FileTaskStore. */
package/dist/skill.d.ts CHANGED
@@ -10,6 +10,42 @@ export interface SkillInfo {
10
10
  description?: string;
11
11
  location: string;
12
12
  content: string;
13
+ /** Internal origin discriminator; "fs" ⇒ on-disk (default), "logical" ⇒ data. */
14
+ origin?: "fs" | "logical";
15
+ /** Logical resource list (data-sourced skills only). */
16
+ resources?: string[];
17
+ /** Logical base, e.g. skill://name/ (data-sourced skills only). */
18
+ base?: string;
19
+ }
20
+ /** A skill supplied directly as data, bypassing the filesystem (SPEC.md §3, S1). */
21
+ export interface SkillDef {
22
+ name: string;
23
+ description?: string;
24
+ content: string;
25
+ /** Optional logical resource names surfaced in the invoke-time <skill_files> block. */
26
+ resources?: string[];
27
+ /** Optional logical base; defaults to `skill://<name>/`. */
28
+ base?: string;
29
+ }
30
+ /** Why a candidate SKILL.md did not become a skill (SPEC.md §3, S3). */
31
+ export type SkillSkipReason = "missing-name" | "malformed-frontmatter" | "duplicate-name" | "unreadable";
32
+ export interface SkillSkip {
33
+ location: string;
34
+ reason: SkillSkipReason;
35
+ }
36
+ /** Result of a list-only validate pass (SPEC.md §3, S3). */
37
+ export interface SkillInventory {
38
+ skills: SkillInfo[];
39
+ skipped: SkillSkip[];
40
+ }
41
+ /** Options for loadSkills / listSkills (SPEC.md §3, S1/S2/S5). */
42
+ export interface LoadSkillsOptions {
43
+ dirs?: string | string[];
44
+ skills?: SkillDef[];
45
+ /** Per-agent allowlist keyed on skill name; same semantics as the MCP tools filter. */
46
+ filter?: Record<string, boolean>;
47
+ /** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files>. */
48
+ sampleLimit?: number;
13
49
  }
14
50
  export interface SkillSource {
15
51
  skills: Record<string, SkillInfo>;
@@ -17,5 +53,11 @@ export interface SkillSource {
17
53
  /** Markdown catalog for the system prompt (mirrors opencode Skill.fmt). */
18
54
  prompt(): string;
19
55
  }
20
- /** Discover skills under one or more roots and build the `skill` loader tool. */
21
- export declare function loadSkills(dirs: string | string[]): SkillSource;
56
+ /**
57
+ * Discover + validate skills from the same sources loadSkills accepts, returning
58
+ * parsed skills plus typed skip reasons — no toolkit wired (SPEC.md §3, S3). The
59
+ * inventory is UNFILTERED (it exists to author/validate the allowlist).
60
+ */
61
+ export declare function listSkills(input: string | string[] | LoadSkillsOptions): SkillInventory;
62
+ /** Discover skills (dirs and/or data) and build the `skill` loader tool. */
63
+ export declare function loadSkills(input: string | string[] | LoadSkillsOptions): SkillSource;
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;
package/dist/toolkit.js CHANGED
@@ -35,7 +35,29 @@ export class Toolkit {
35
35
  }
36
36
  static async create(opts) {
37
37
  const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined;
38
- const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : 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.8.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/serve.ts CHANGED
@@ -62,7 +62,13 @@ export class FileTaskStore implements TaskStore {
62
62
  }
63
63
  }
64
64
  async save(task: A2ATask): Promise<void> {
65
- fs.writeFileSync(this.file(task.id), JSON.stringify(task))
65
+ // Atomic write: a concurrent get() must never read a half-written file (that
66
+ // would surface as a spurious "Task not found" mid-poll). Write a temp file in
67
+ // the same dir, then rename over the target.
68
+ const target = this.file(task.id)
69
+ const tmp = `${target}.tmp`
70
+ fs.writeFileSync(tmp, JSON.stringify(task))
71
+ fs.renameSync(tmp, target)
66
72
  }
67
73
  }
68
74
 
package/src/skill.ts 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"
@@ -27,24 +32,72 @@ export const SKILLS_PROMPT_PREAMBLE =
27
32
  export interface SkillInfo {
28
33
  name: string
29
34
  description?: string
30
- location: string // absolute path to SKILL.md
35
+ location: string // absolute path to SKILL.md (fs) or logical base (data)
31
36
  content: string // body after frontmatter
37
+ /** Internal origin discriminator; "fs" ⇒ on-disk (default), "logical" ⇒ data. */
38
+ origin?: "fs" | "logical"
39
+ /** Logical resource list (data-sourced skills only). */
40
+ resources?: string[]
41
+ /** Logical base, e.g. skill://name/ (data-sourced skills only). */
42
+ base?: string
43
+ }
44
+
45
+ /** A skill supplied directly as data, bypassing the filesystem (SPEC.md §3, S1). */
46
+ export interface SkillDef {
47
+ name: string
48
+ description?: string
49
+ content: string
50
+ /** Optional logical resource names surfaced in the invoke-time <skill_files> block. */
51
+ resources?: string[]
52
+ /** Optional logical base; defaults to `skill://<name>/`. */
53
+ base?: string
54
+ }
55
+
56
+ /** Why a candidate SKILL.md did not become a skill (SPEC.md §3, S3). */
57
+ export type SkillSkipReason = "missing-name" | "malformed-frontmatter" | "duplicate-name" | "unreadable"
58
+
59
+ export interface SkillSkip {
60
+ location: string
61
+ reason: SkillSkipReason
62
+ }
63
+
64
+ /** Result of a list-only validate pass (SPEC.md §3, S3). */
65
+ export interface SkillInventory {
66
+ skills: SkillInfo[]
67
+ skipped: SkillSkip[]
68
+ }
69
+
70
+ /** Options for loadSkills / listSkills (SPEC.md §3, S1/S2/S5). */
71
+ export interface LoadSkillsOptions {
72
+ dirs?: string | string[]
73
+ skills?: SkillDef[]
74
+ /** Per-agent allowlist keyed on skill name; same semantics as the MCP tools filter. */
75
+ filter?: Record<string, boolean>
76
+ /** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files>. */
77
+ sampleLimit?: number
32
78
  }
33
79
 
34
80
  /**
35
81
  * Parse YAML frontmatter (between the leading `---` fences) with a real YAML
36
82
  * parser, so folded (`>`)/literal (`|`) block scalars, quoting, and multi-line
37
83
  * values all resolve correctly. Scalar values are coerced to strings.
84
+ *
85
+ * `malformed` is true only when fences are present but the YAML fails to parse —
86
+ * distinguishing a malformed header from a body with no frontmatter, so the
87
+ * inventory (S3) can report the right skip reason. loadSkills' behavior is
88
+ * unchanged: a malformed header yields no name and the skill is skipped.
38
89
  */
39
- function parseFrontmatter(text: string): { data: Record<string, string>; content: string } {
90
+ function parseFrontmatter(text: string): { data: Record<string, string>; content: string; malformed: boolean } {
40
91
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
41
- if (!match) return { data: {}, content: text }
92
+ if (!match) return { data: {}, content: text, malformed: false }
42
93
  const data: Record<string, string> = {}
43
94
  let parsed: unknown
95
+ let malformed = false
44
96
  try {
45
97
  parsed = parseYaml(match[1])
46
98
  } catch {
47
99
  parsed = null
100
+ malformed = true
48
101
  }
49
102
  if (parsed && typeof parsed === "object") {
50
103
  for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
@@ -55,7 +108,7 @@ function parseFrontmatter(text: string): { data: Record<string, string>; content
55
108
  }
56
109
  }
57
110
  }
58
- return { data, content: match[2] }
111
+ return { data, content: match[2], malformed }
59
112
  }
60
113
 
61
114
  function walkSkillFiles(root: string): string[] {
@@ -150,50 +203,145 @@ function sampleSiblingFiles(dir: string, limit = 10): string[] {
150
203
  return out
151
204
  }
152
205
 
153
- export interface SkillSource {
154
- skills: Record<string, SkillInfo>
155
- tool: Tool
156
- /** Markdown catalog for the system prompt (mirrors opencode Skill.fmt). */
157
- prompt(): string
206
+ /** One raw candidate before cross-source dedupe: a parsed skill OR a typed skip. */
207
+ interface RawCandidate {
208
+ info?: SkillInfo
209
+ skip?: SkillSkip
158
210
  }
159
211
 
160
- /** Discover skills under one or more roots and build the `skill` loader tool. */
161
- export function loadSkills(dirs: string | string[]): SkillSource {
162
- const roots = Array.isArray(dirs) ? dirs : [dirs]
163
- const skills: Record<string, SkillInfo> = {}
164
-
165
- for (const root of roots) {
166
- let isDir = false
212
+ /** Discover candidates under one on-disk root (order = discovery order). */
213
+ function candidatesFromDir(root: string): RawCandidate[] {
214
+ const out: RawCandidate[] = []
215
+ let isDir = false
216
+ try {
217
+ isDir = statSync(root).isDirectory()
218
+ } catch {
219
+ isDir = false
220
+ }
221
+ if (!isDir) {
222
+ console.warn(`[toolnexus] skills dir not found: ${root}`)
223
+ return out
224
+ }
225
+ for (const file of walkSkillFiles(root)) {
226
+ let text: string
167
227
  try {
168
- isDir = statSync(root).isDirectory()
228
+ text = readFileSync(file, "utf8")
169
229
  } catch {
170
- isDir = false
230
+ out.push({ skip: { location: file, reason: "unreadable" } })
231
+ continue
171
232
  }
172
- if (!isDir) {
173
- console.warn(`[toolnexus] skills dir not found: ${root}`)
233
+ const { data, content, malformed } = parseFrontmatter(text)
234
+ if (malformed) {
235
+ out.push({ skip: { location: file, reason: "malformed-frontmatter" } })
174
236
  continue
175
237
  }
176
- for (const file of walkSkillFiles(root)) {
177
- let text: string
178
- try {
179
- text = readFileSync(file, "utf8")
180
- } catch {
181
- continue
182
- }
183
- const { data, content } = parseFrontmatter(text)
184
- if (!data.name) continue
185
- if (skills[data.name]) {
186
- console.warn(`[toolnexus] duplicate skill name "${data.name}" (${file}) — keeping first`)
187
- continue
188
- }
189
- skills[data.name] = {
190
- name: data.name,
191
- description: data.description,
192
- location: file,
193
- content,
194
- }
238
+ if (!data.name) {
239
+ out.push({ skip: { location: file, reason: "missing-name" } })
240
+ continue
195
241
  }
242
+ out.push({ info: { name: data.name, description: data.description, location: file, content, origin: "fs" } })
196
243
  }
244
+ return out
245
+ }
246
+
247
+ /** Turn data-supplied skill defs into candidates (logical origin). */
248
+ function candidatesFromDefs(defs: SkillDef[]): RawCandidate[] {
249
+ return defs.map((d): RawCandidate => {
250
+ if (!d.name) return { skip: { location: d.base ?? "skill://", reason: "missing-name" } }
251
+ const base = d.base && d.base.length > 0 ? d.base : `skill://${d.name}/`
252
+ return {
253
+ info: {
254
+ name: d.name,
255
+ description: d.description,
256
+ location: base,
257
+ content: d.content ?? "",
258
+ origin: "logical",
259
+ resources: d.resources ?? [],
260
+ base,
261
+ },
262
+ }
263
+ })
264
+ }
265
+
266
+ /** Collect ordered candidates from dirs then data defs. */
267
+ function collectCandidates(opts: LoadSkillsOptions): RawCandidate[] {
268
+ const roots = opts.dirs === undefined ? [] : Array.isArray(opts.dirs) ? opts.dirs : [opts.dirs]
269
+ const cands: RawCandidate[] = []
270
+ for (const root of roots) cands.push(...candidatesFromDir(root))
271
+ if (opts.skills) cands.push(...candidatesFromDefs(opts.skills))
272
+ return cands
273
+ }
274
+
275
+ /** Dedupe candidates by name (first-wins); later duplicates become skips. */
276
+ function mergeCandidates(cands: RawCandidate[]): { skills: Record<string, SkillInfo>; skipped: SkillSkip[] } {
277
+ const skills: Record<string, SkillInfo> = {}
278
+ const skipped: SkillSkip[] = []
279
+ for (const c of cands) {
280
+ if (c.skip) {
281
+ skipped.push(c.skip)
282
+ continue
283
+ }
284
+ const info = c.info!
285
+ if (skills[info.name]) {
286
+ console.warn(`[toolnexus] duplicate skill name "${info.name}" (${info.location}) — keeping first`)
287
+ skipped.push({ location: info.location, reason: "duplicate-name" })
288
+ continue
289
+ }
290
+ skills[info.name] = info
291
+ }
292
+ return { skills, skipped }
293
+ }
294
+
295
+ /**
296
+ * Apply the per-agent skill allowlist (S2), semantics identical to the MCP
297
+ * per-server tools filter and builtins: nil/empty ⇒ all; ≥1 true ⇒ allowlist
298
+ * (only true-mapped names); only-false ⇒ drop-list over all-on; unknown names
299
+ * are ignored and warned once.
300
+ */
301
+ function applySkillsFilter(
302
+ skills: Record<string, SkillInfo>,
303
+ filter: Record<string, boolean> | undefined,
304
+ ): Record<string, SkillInfo> {
305
+ if (!filter) return skills
306
+ const keys = Object.keys(filter)
307
+ if (keys.length === 0) return skills
308
+ const hasTrue = keys.some((k) => filter[k] === true)
309
+ for (const k of keys) {
310
+ if (!(k in skills)) console.warn(`[toolnexus] skill filter name "${k}" matched no skill`)
311
+ }
312
+ const out: Record<string, SkillInfo> = {}
313
+ for (const [name, info] of Object.entries(skills)) {
314
+ if (hasTrue ? filter[name] === true : filter[name] !== false) out[name] = info
315
+ }
316
+ return out
317
+ }
318
+
319
+ export interface SkillSource {
320
+ skills: Record<string, SkillInfo>
321
+ tool: Tool
322
+ /** Markdown catalog for the system prompt (mirrors opencode Skill.fmt). */
323
+ prompt(): string
324
+ }
325
+
326
+ /**
327
+ * Discover + validate skills from the same sources loadSkills accepts, returning
328
+ * parsed skills plus typed skip reasons — no toolkit wired (SPEC.md §3, S3). The
329
+ * inventory is UNFILTERED (it exists to author/validate the allowlist).
330
+ */
331
+ export function listSkills(input: string | string[] | LoadSkillsOptions): SkillInventory {
332
+ const opts: LoadSkillsOptions =
333
+ typeof input === "string" || Array.isArray(input) ? { dirs: input } : input
334
+ const merged = mergeCandidates(collectCandidates(opts))
335
+ return { skills: Object.values(merged.skills), skipped: merged.skipped }
336
+ }
337
+
338
+ /** Discover skills (dirs and/or data) and build the `skill` loader tool. */
339
+ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillSource {
340
+ const opts: LoadSkillsOptions =
341
+ typeof input === "string" || Array.isArray(input) ? { dirs: input } : input
342
+ const sampleLimit = opts.sampleLimit ?? 0
343
+ const merged = mergeCandidates(collectCandidates(opts))
344
+ const skills = applySkillsFilter(merged.skills, opts.filter)
197
345
 
198
346
  const tool: Tool = {
199
347
  name: "skill",
@@ -214,10 +362,28 @@ export function loadSkills(dirs: string | string[]): SkillSource {
214
362
  isError: true,
215
363
  }
216
364
  }
217
- const dir = path.dirname(info.location)
218
- const base = pathToFileURL(dir).href
219
- const files = sampleSiblingFiles(dir)
220
- const output = [
365
+ // effLimit: 0 ⇒ default 10 (byte-identical to today), n>0 ⇒ cap, -1 ⇒ omit.
366
+ const effLimit = sampleLimit === 0 ? 10 : sampleLimit
367
+ let base: string
368
+ let files: string[]
369
+ let metaDir: string
370
+ // Emit the <skill_files> block: always for fs skills (byte-identical, even
371
+ // when empty), only when a data skill actually carries resources (an
372
+ // instruction-only data skill omits it). Never when sampling is disabled.
373
+ let emitFiles = effLimit !== -1
374
+ if (info.origin === "logical") {
375
+ base = info.base ?? `skill://${info.name}/`
376
+ const res = info.resources ?? []
377
+ if (res.length === 0) emitFiles = false
378
+ files = effLimit > 0 ? res.slice(0, effLimit) : res
379
+ metaDir = base
380
+ } else {
381
+ const dir = path.dirname(info.location)
382
+ base = pathToFileURL(dir).href
383
+ files = effLimit === -1 ? [] : sampleSiblingFiles(dir, effLimit)
384
+ metaDir = dir
385
+ }
386
+ const lines = [
221
387
  `<skill_content name="${info.name}">`,
222
388
  `# Skill: ${info.name}`,
223
389
  "",
@@ -225,14 +391,18 @@ export function loadSkills(dirs: string | string[]): SkillSource {
225
391
  "",
226
392
  `Base directory for this skill: ${base}`,
227
393
  "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
228
- "Note: file list is sampled.",
229
- "",
230
- "<skill_files>",
231
- files.map((f) => `<file>${f}</file>`).join("\n"),
232
- "</skill_files>",
233
- "</skill_content>",
234
- ].join("\n")
235
- return { output, isError: false, metadata: { name: info.name, dir } }
394
+ ]
395
+ if (emitFiles) {
396
+ lines.push(
397
+ "Note: file list is sampled.",
398
+ "",
399
+ "<skill_files>",
400
+ files.map((f) => `<file>${f}</file>`).join("\n"),
401
+ "</skill_files>",
402
+ )
403
+ }
404
+ lines.push("</skill_content>")
405
+ return { output: lines.join("\n"), isError: false, metadata: { name: info.name, dir: metaDir } }
236
406
  },
237
407
  }
238
408
 
package/src/toolkit.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { type Tool, type ToolContext, type ToolResult, type McpStatus } from "./types.js"
6
6
  import { loadMcp, type McpSource } from "./mcp.js"
7
- import { loadSkills, type SkillSource } from "./skill.js"
7
+ import { loadSkills, type SkillDef, type SkillSource } from "./skill.js"
8
8
  import { selectBuiltins, type BuiltinsConfig } from "./builtin.js"
9
9
  import { agentTools, parseAgentsConfig, type Agent } from "./a2a.js"
10
10
  import { startA2AServer, type A2AConfig, type OnTask, type ServeHandle } from "./serve.js"
@@ -15,6 +15,14 @@ import type { Client } from "./client.js"
15
15
  export interface ToolkitOptions {
16
16
  mcpConfig?: string | object
17
17
  skillsDir?: string | string[]
18
+ /** Skills supplied as data, bypassing the filesystem (§3, S1). */
19
+ skills?: SkillDef[]
20
+ /** Lazy provider of data-supplied skills, resolved once at toolkit build (§3, S1). */
21
+ skillProvider?: () => SkillDef[] | Promise<SkillDef[]>
22
+ /** Per-agent skill allowlist keyed on name; same semantics as the MCP tools filter (§3, S2). */
23
+ skillsFilter?: Record<string, boolean>
24
+ /** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files> (§3, S5). */
25
+ skillSampleLimit?: number
18
26
  extraTools?: Tool[]
19
27
  /** Built-in tools (§4A). On by default. false | { disabled:true } | { enabled:false } ⇒ off. */
20
28
  builtins?: BuiltinsConfig
@@ -59,7 +67,28 @@ export class Toolkit {
59
67
 
60
68
  static async create(opts: ToolkitOptions): Promise<Toolkit> {
61
69
  const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined
62
- const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : undefined
70
+ // Skills come from directories, in-memory data, and/or a lazy provider (§3,
71
+ // S1). The provider is resolved here (create() is async) and merged with the
72
+ // data list; a provider failure is isolated so other sources still load.
73
+ let skill: SkillSource | undefined
74
+ if (opts.skillsDir !== undefined || opts.skills !== undefined || opts.skillProvider !== undefined) {
75
+ let providerDefs: SkillDef[] = []
76
+ if (opts.skillProvider) {
77
+ try {
78
+ providerDefs = await opts.skillProvider()
79
+ } catch (e) {
80
+ console.warn(`[toolnexus] skill provider failed: ${e instanceof Error ? e.message : String(e)}`)
81
+ providerDefs = []
82
+ }
83
+ }
84
+ const dataDefs = [...(opts.skills ?? []), ...providerDefs]
85
+ skill = loadSkills({
86
+ dirs: opts.skillsDir,
87
+ skills: dataDefs.length > 0 ? dataDefs : undefined,
88
+ filter: opts.skillsFilter,
89
+ sampleLimit: opts.skillSampleLimit,
90
+ })
91
+ }
63
92
  // The toggle comes from the `builtins` option, or a top-level `builtins`
64
93
  // key on a parsed config object — same precedence as MCP's isEnabled.
65
94
  let builtinsCfg = opts.builtins