toolnexus 0.6.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/builtin.js CHANGED
@@ -11,6 +11,7 @@ import { spawn } from "node:child_process";
11
11
  import fs from "node:fs/promises";
12
12
  import { existsSync, readdirSync } from "node:fs";
13
13
  import path from "node:path";
14
+ import { pending } from "./types.js";
14
15
  /**
15
16
  * Whether the builtin source is on. Default ON. Same precedence as MCP:
16
17
  * `disabled:true` wins, else `enabled:false` disables, otherwise enabled.
@@ -388,8 +389,25 @@ function webfetchTool() {
388
389
  }
389
390
  });
390
391
  }
392
+ /**
393
+ * Render the questions into a human-readable `Request.prompt` (§10). Byte-identical across ports:
394
+ * each question's text in order, `" (options: a, b, c)"` appended when it has non-empty options,
395
+ * joined by "\n" (no trailing newline). `header` is not rendered — it survives in `data.questions`.
396
+ */
397
+ function renderQuestionPrompt(questions) {
398
+ return questions
399
+ .map((q) => {
400
+ const item = q;
401
+ let line = typeof item?.question === "string" ? item.question : "";
402
+ const opts = Array.isArray(item?.options) ? item.options : [];
403
+ if (opts.length > 0)
404
+ line += ` (options: ${opts.join(", ")})`;
405
+ return line;
406
+ })
407
+ .join("\n");
408
+ }
391
409
  function questionTool() {
392
- return builtin("question", "Ask the host one or more questions. Returns the questions as structured output for the host to answer.", {
410
+ return builtin("question", "Ask the host one or more questions. Suspends via a kind:\"question\" Request (§10); the host's waitFor resolves it and the answer is returned to the model.", {
393
411
  type: "object",
394
412
  properties: {
395
413
  questions: {
@@ -409,9 +427,14 @@ function questionTool() {
409
427
  },
410
428
  required: ["questions"],
411
429
  additionalProperties: false,
412
- }, async (args) => {
430
+ }, async (args, ctx) => {
413
431
  const questions = Array.isArray(args.questions) ? args.questions : [];
414
- return ok(JSON.stringify(questions), { questions });
432
+ // Re-executed after the host's waitFor resolved (§10 loop rule): the resolution IS the
433
+ // answer, as with kind:"input" — forward it verbatim to the model.
434
+ if (ctx?.answer)
435
+ return ok(JSON.stringify(ctx.answer.data ?? {}));
436
+ // First call: suspend. A question is just a §10 Request with kind:"question".
437
+ return pending({ kind: "question", prompt: renderQuestionPrompt(questions), data: { questions } });
415
438
  });
416
439
  }
417
440
  function todowriteTool() {
package/dist/client.d.ts CHANGED
@@ -51,6 +51,7 @@ export type MetricEvent = {
51
51
  source: string;
52
52
  isError: boolean;
53
53
  ms: number;
54
+ pending?: boolean;
54
55
  } | {
55
56
  event: "run";
56
57
  model: string;
@@ -237,6 +238,8 @@ export declare class Client {
237
238
  private llmFetch;
238
239
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
239
240
  private runTool;
241
+ /** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
242
+ private emitTool;
240
243
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
241
244
  private llmCallJson;
242
245
  private runOpenAI;
package/dist/client.js CHANGED
@@ -98,7 +98,7 @@ class MetricsRegistry {
98
98
  observe(this.llmDuration, labelStr([["model", ev.model]]), ev.ms / 1000);
99
99
  }
100
100
  else if (ev.event === "tool") {
101
- inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)]]));
101
+ inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)], ["pending", String(ev.pending ?? false)]]));
102
102
  observe(this.toolDuration, labelStr([["tool", ev.tool]]), ev.ms / 1000);
103
103
  }
104
104
  else if (ev.event === "run") {
@@ -270,22 +270,31 @@ export class Client {
270
270
  if (h?.beforeTool) {
271
271
  const ov = await h.beforeTool({ name, args: a, id, turn });
272
272
  if (ov?.result) {
273
- // short-circuit (deny/cache/dry-run) — still a tool call for metrics purposes
274
- this.emit({ event: "tool", tool: name, source, isError: ov.result.isError, ms: Date.now() - t0 });
273
+ // short-circuit (deny/cache/dry-run, or a guard-raised suspension — path B); still a tool
274
+ // call for metrics, but a suspension is classified pending, never an error.
275
+ this.emitTool(name, source, ov.result, t0);
275
276
  return { args: a, result: ov.result };
276
277
  }
277
278
  if (ov?.args)
278
279
  a = ov.args;
279
280
  }
280
281
  let result = await toolkit.execute(name, a);
281
- if (h?.afterTool) {
282
+ // A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
283
+ // result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
284
+ // a tool error.
285
+ if (h?.afterTool && !pendingOf(result)) {
282
286
  const ov = await h.afterTool({ name, args: a, result, id, turn });
283
287
  if (ov?.result)
284
288
  result = ov.result;
285
289
  }
286
- this.emit({ event: "tool", tool: name, source, isError: result.isError, ms: Date.now() - t0 });
290
+ this.emitTool(name, source, result, t0);
287
291
  return { args: a, result };
288
292
  }
293
+ /** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
294
+ emitTool(name, source, result, t0) {
295
+ const req = pendingOf(result);
296
+ this.emit({ event: "tool", tool: name, source, isError: req ? false : result.isError, ms: Date.now() - t0, ...(req ? { pending: true } : {}) });
297
+ }
289
298
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
290
299
  async llmCallJson(url, init, signal, style) {
291
300
  const t0 = Date.now();
@@ -343,22 +352,26 @@ export class Client {
343
352
  if (calls.length === 0)
344
353
  return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage);
345
354
  // execute all tool calls in this turn concurrently (true parallel tool calling)
346
- let halted;
347
- const results = await Promise.all(calls.map(async (call) => {
355
+ const settled = await Promise.all(calls.map(async (call) => {
348
356
  let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
357
+ let halted;
349
358
  const req = pendingOf(result);
350
359
  if (req) {
351
360
  const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn);
352
361
  result = r.result;
353
- if (r.halted)
354
- halted = r.halted;
362
+ halted = r.halted;
355
363
  }
356
- toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
357
- return { role: "tool", tool_call_id: call.id, content: result.output };
364
+ return { call, args, result, halted };
358
365
  }));
359
- messages.push(...results);
360
- if (halted)
361
- return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
366
+ // Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
367
+ // and the later suspensions' placeholder results never enter the transcript (they re-suspend
368
+ // on resume). Mirrors the streaming path.
369
+ for (const s of settled) {
370
+ toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata });
371
+ messages.push({ role: "tool", tool_call_id: s.call.id, content: s.result.output });
372
+ if (s.halted)
373
+ return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage);
374
+ }
362
375
  }
363
376
  return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage);
364
377
  }
@@ -393,7 +406,7 @@ export class Client {
393
406
  if (ov?.result)
394
407
  result = ov.result;
395
408
  }
396
- this.emit({ event: "tool", tool: name, source: toolkit.get(name)?.source ?? "custom", isError: result.isError, ms: Date.now() - t0 });
409
+ this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0);
397
410
  if (pendingOf(result))
398
411
  result = { output: `unresolved: ${request.prompt}`, isError: true }; // never loop forever
399
412
  return { result };
@@ -441,20 +454,31 @@ export class Client {
441
454
  return this.endRun(runStart, text, messages, toolCalls, turns, usage);
442
455
  }
443
456
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
444
- let halted;
445
- const results = await Promise.all(uses.map(async (use) => {
457
+ const settled = await Promise.all(uses.map(async (use) => {
446
458
  let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn);
459
+ let halted;
447
460
  const req = pendingOf(result);
448
461
  if (req) {
449
462
  const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn);
450
463
  result = r.result;
451
- if (r.halted)
452
- halted = r.halted;
464
+ halted = r.halted;
453
465
  }
454
- toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
455
- return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError };
466
+ return { use, args, result, halted };
456
467
  }));
457
- messages.push({ role: "user", content: results });
468
+ // Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
469
+ // building the content, and surface it — deterministic (G3), later suspensions re-suspend on
470
+ // resume. Mirrors the OpenAI path and the streaming loops.
471
+ const content = [];
472
+ let halted;
473
+ for (const s of settled) {
474
+ toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata });
475
+ content.push({ type: "tool_result", tool_use_id: s.use.id, content: s.result.output, is_error: s.result.isError });
476
+ if (s.halted) {
477
+ halted = s.halted;
478
+ break;
479
+ }
480
+ }
481
+ messages.push({ role: "user", content });
458
482
  if (halted)
459
483
  return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
460
484
  }
package/dist/mcp.d.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { type Tool, type McpStatus } from "./types.js";
1
+ import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import { type Tool, type McpStatus, type Request, type Answer } from "./types.js";
3
+ /** Map an MCP `elicitation/create` request onto a §10 `Request`. */
4
+ export declare function elicitationToRequest(params: ElicitRequest["params"]): Request;
5
+ /** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
6
+ export declare function answerToElicitResult(answer: Answer): ElicitResult;
2
7
  export interface LocalServer {
3
8
  type?: "local";
4
9
  command: string[];
@@ -32,4 +37,6 @@ export interface McpSource {
32
37
  close(): Promise<void>;
33
38
  }
34
39
  /** Connect to every enabled server, list + convert tools. Failures are isolated. */
35
- export declare function loadMcp(input: string | object): Promise<McpSource>;
40
+ export declare function loadMcp(input: string | object, opts?: {
41
+ waitFor?: (request: Request) => Promise<Answer>;
42
+ }): Promise<McpSource>;
package/dist/mcp.js CHANGED
@@ -8,7 +8,35 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
8
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
9
9
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
10
10
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
11
+ import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
11
12
  import { sanitize } from "./types.js";
13
+ // ---------------------------------------------------------------------------
14
+ // MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
15
+ // (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
16
+ // URL mode → kind:"authorization". The two mappings are pure + byte-parity-tested.
17
+ // ---------------------------------------------------------------------------
18
+ let _elcSeq = 0;
19
+ /** Map an MCP `elicitation/create` request onto a §10 `Request`. */
20
+ export function elicitationToRequest(params) {
21
+ const p = params;
22
+ const isUrl = p.mode === "url";
23
+ const req = {
24
+ id: `elc-${Date.now().toString(36)}-${++_elcSeq}`,
25
+ kind: isUrl ? "authorization" : "input",
26
+ prompt: String(p.message ?? ""),
27
+ };
28
+ if (isUrl && p.url)
29
+ req.url = p.url;
30
+ if (!isUrl && p.requestedSchema)
31
+ req.data = { schema: p.requestedSchema };
32
+ return req;
33
+ }
34
+ /** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
35
+ export function answerToElicitResult(answer) {
36
+ if (answer.ok)
37
+ return { action: "accept", content: (answer.data ?? {}) };
38
+ return { action: answer.reason === "declined" ? "decline" : "cancel" };
39
+ }
12
40
  const DEFAULT_TIMEOUT = 30_000;
13
41
  const MAX_LIST_PAGES = 1_000;
14
42
  function isRemote(cfg) {
@@ -117,18 +145,24 @@ function convertTool(server, def, client, timeout) {
117
145
  };
118
146
  }
119
147
  /** Connect to every enabled server, list + convert tools. Failures are isolated. */
120
- export async function loadMcp(input) {
148
+ export async function loadMcp(input, opts) {
121
149
  const config = parseMcpConfig(input);
122
150
  const tools = [];
123
151
  const status = {};
124
152
  const clients = [];
153
+ const waitFor = opts?.waitFor;
125
154
  await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
126
155
  if (!isEnabled(cfg)) {
127
156
  status[name] = "disabled";
128
157
  return;
129
158
  }
130
159
  const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
131
- const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: {} });
160
+ // Advertise elicitation + register the bridge ONLY when a waitFor exists to satisfy it —
161
+ // a waitFor-less host degrades cleanly (the server won't elicit). (§10 elicitation bridge)
162
+ const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} });
163
+ if (waitFor) {
164
+ client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
165
+ }
132
166
  try {
133
167
  if (isRemote(cfg)) {
134
168
  const url = new URL(cfg.url);
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. */
@@ -239,12 +245,23 @@ async function fulfil(id, text, store, runTask, onTask, contextId) {
239
245
  // contextId groups a peer's turns into one conversation — thread it so the
240
246
  // client's ConversationStore remembers across tasks in the same context.
241
247
  result = await runTask(text, contextId);
242
- const artifact = {
243
- artifactId: randomUUID(),
244
- parts: [{ kind: "text", text: result.text }],
245
- };
246
- task = { id, status: { state: "completed" }, artifacts: [artifact] };
247
- state = "completed";
248
+ if (result.status === "pending" && result.pending) {
249
+ // §10 suspension over A2A: the run halted waiting on out-of-band input. Surface the
250
+ // protocol's `input-required` state carrying the request prompt — never a false `completed`.
251
+ task = {
252
+ id,
253
+ status: { state: "input-required", message: { role: "agent", parts: [{ kind: "text", text: result.pending.prompt }] } },
254
+ };
255
+ state = "input-required";
256
+ }
257
+ else {
258
+ const artifact = {
259
+ artifactId: randomUUID(),
260
+ parts: [{ kind: "text", text: result.text }],
261
+ };
262
+ task = { id, status: { state: "completed" }, artifacts: [artifact] };
263
+ state = "completed";
264
+ }
248
265
  }
249
266
  catch (e) {
250
267
  const detail = e instanceof Error ? e.message : String(e);
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 {