toolnexus 0.18.1 → 0.19.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/client.js CHANGED
@@ -2,6 +2,75 @@ import { pendingOf } from "./types.js";
2
2
  import { isRetryableStatus, retryAfterMs } from "./retry.js";
3
3
  import { toOpenAIWire, toAnthropicWire, checkPromptParts } from "./wire.js";
4
4
  import { finishReasonFor, argsString, openAIMessagesToAnthropic, openAIToolsToAnthropic, openAIToolChoiceToAnthropic, hasSystemMessage, } from "./translate.js";
5
+ /**
6
+ * The §8 CLIENT status vocabulary, as a value. It is a DIFFERENT, smaller closed set than the
7
+ * §7D AGENT vocabulary (`agents.TASK_STATUSES`), which also contains `"timeout"`, `"closed"`,
8
+ * `"interrupted"` and `"error"`. Two fields are called `status`; they are not interchangeable.
9
+ * A run that fails at a deadline THROWS here — it never comes back as a `RunResult`.
10
+ */
11
+ export const RUN_STATUSES = ["done", "pending", "incomplete"];
12
+ /** Account-identifier keys blanked out of a provider error body before it reaches a message,
13
+ * a log or a metric. The shape survives; the value does not (ADR 0027 D3.2). */
14
+ export const REDACTED_BODY_KEYS = ["user_id", "account_id", "org_id", "organization"];
15
+ /** What a redacted value is replaced with — identical in all seven ports. */
16
+ export const REDACTION_PLACEHOLDER = "\u00abredacted\u00bb";
17
+ /** A redacted body is capped at this many characters IN THE MESSAGE ONLY. The typed `body`
18
+ * field carries the full redacted body — a host that opted into a typed error asked for the
19
+ * whole thing (ADR 0027 D3 / addendum A5). */
20
+ export const ERROR_BODY_CAP = 200;
21
+ /**
22
+ * Redact known account identifiers, then cap. A CAP IS NOT REDACTION: the body that leaked
23
+ * `user_2…` in issue #92 was 96 bytes, well inside the cap, so both steps are needed and
24
+ * redaction runs FIRST (capping first could split a key/value pair and hide it from the regex).
25
+ */
26
+ export function redactErrorBody(body) {
27
+ let out = body;
28
+ for (const key of REDACTED_BODY_KEYS) {
29
+ // JSON ("key": "value" / "key": 123) and form/query (key=value) spellings alike.
30
+ out = out.replace(new RegExp(`("${key}"\\s*:\\s*)("(?:[^"\\\\]|\\\\.)*"|-?\\d+(?:\\.\\d+)?|true|false|null)`, "g"), `$1"${REDACTION_PLACEHOLDER}"`);
31
+ out = out.replace(new RegExp(`\\b${key}=([^&\\s]+)`, "g"), `${key}=${REDACTION_PLACEHOLDER}`);
32
+ }
33
+ return out.trim();
34
+ }
35
+ /** Cap a already-redacted body for interpolation into a MESSAGE. Redaction is not a cap and a
36
+ * cap is not redaction: they are independent and both are needed. */
37
+ export function capErrorBody(body) {
38
+ return body.length > ERROR_BODY_CAP ? body.slice(0, ERROR_BODY_CAP) + "\u2026" : body;
39
+ }
40
+ /**
41
+ * A provider failure carried as a VALUE, not a sentence (ADR 0027 D3.1). A host that must
42
+ * decide what to log can read `status` / `retryAfter` instead of parsing a message.
43
+ *
44
+ * `body` is ALREADY redacted and capped, and is `""` on 401/403 — an auth body routinely
45
+ * reflects the credential or the header that was sent. `rawBody` is never populated by the
46
+ * library; the SPEC credentials guarantee now covers error messages too.
47
+ */
48
+ export class LlmHttpError extends Error {
49
+ status;
50
+ body;
51
+ retryAfter;
52
+ name = "LlmHttpError";
53
+ constructor(
54
+ /** The HTTP status the provider answered with. */
55
+ status,
56
+ /** The provider's FULL redacted body; `""` on 401/403 and when empty. Uncapped — the cap
57
+ * applies to `message` only. */
58
+ body,
59
+ /** The `Retry-After` header verbatim, when the provider sent one. */
60
+ retryAfter) {
61
+ super(`LLM ${status}${body ? ": " + capErrorBody(body) : ""}`);
62
+ this.status = status;
63
+ this.body = body;
64
+ this.retryAfter = retryAfter;
65
+ }
66
+ }
67
+ /** Build the typed error from a non-2xx provider response (reads the body once). */
68
+ export async function llmHttpError(res) {
69
+ const raw = await res.text().catch(() => "");
70
+ // 401/403: never echo the body — see `Classifier.cause`, now one policy, not two.
71
+ const body = res.status === 401 || res.status === 403 ? "" : redactErrorBody(raw);
72
+ return new LlmHttpError(res.status, body, res.headers.get("retry-after") ?? undefined);
73
+ }
5
74
  /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
6
75
  export class InMemoryConversationStore {
7
76
  map = new Map();
@@ -229,11 +298,17 @@ export class Client {
229
298
  emitRunError(runStart, toolCalls, turns, usage, e) {
230
299
  this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart, error: e instanceof Error ? e.message : String(e) });
231
300
  }
301
+ /**
302
+ * Run the loop. `ctx` — and `ctx.toolkit` inside it — are OPTIONAL: an absent toolkit is a
303
+ * COMPLETION (SPEC §0.10). The system message is then the system prompt alone, and the
304
+ * request body carries no `tools` and no `tool_choice` key (not an empty array). An empty
305
+ * toolkit and no toolkit are observably identical on the wire.
306
+ */
232
307
  async run(prompt, ctx) {
233
308
  checkPromptParts(prompt, this.wireOpts());
234
309
  return this.opts.style === "anthropic"
235
- ? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
236
- : this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history);
310
+ ? this.runAnthropic(prompt, ctx?.toolkit, ctx?.signal, ctx?.history)
311
+ : this.runOpenAI(prompt, ctx?.toolkit, ctx?.signal, ctx?.history);
237
312
  }
238
313
  /**
239
314
  * Single-turn translation (§11): exactly ONE provider call, returned in OpenAI shape.
@@ -390,7 +465,7 @@ export class Client {
390
465
  async ask(prompt, ctx) {
391
466
  // Block-style streaming: run the streaming loop, forward text deltas, still return the
392
467
  // final RunResult. Memory (id load/save) is handled by stream() itself, so no duplication.
393
- if (ctx.on_text) {
468
+ if (ctx?.on_text) {
394
469
  let result;
395
470
  for await (const ev of this.stream(prompt, { toolkit: ctx.toolkit, id: ctx.id, signal: ctx.signal })) {
396
471
  if (ev.type === "text")
@@ -400,8 +475,8 @@ export class Client {
400
475
  }
401
476
  return result;
402
477
  }
403
- if (!ctx.id)
404
- return this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal });
478
+ if (!ctx?.id)
479
+ return this.run(prompt, { toolkit: ctx?.toolkit, signal: ctx?.signal });
405
480
  const history = (await this.store.get(ctx.id)) ?? [];
406
481
  const result = await this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal, history });
407
482
  await this.store.save(ctx.id, result.messages);
@@ -409,7 +484,7 @@ export class Client {
409
484
  }
410
485
  /** A stateful multi-turn conversation that retains history across sends. */
411
486
  conversation(ctx) {
412
- return new Conversation(this, ctx.toolkit, ctx.signal);
487
+ return new Conversation(this, ctx?.toolkit, ctx?.signal);
413
488
  }
414
489
  /**
415
490
  * Streaming variant: async-iterate live events (text deltas, tool calls/results, usage, done).
@@ -418,18 +493,20 @@ export class Client {
418
493
  */
419
494
  async *stream(prompt, ctx) {
420
495
  checkPromptParts(prompt, this.wireOpts());
421
- const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined;
496
+ const history = ctx?.id ? (await this.store.get(ctx.id)) ?? [] : undefined;
422
497
  const gen = this.opts.style === "anthropic"
423
- ? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
424
- : this.streamOpenAI(prompt, ctx.toolkit, ctx.signal, history);
498
+ ? this.streamAnthropic(prompt, ctx?.toolkit, ctx?.signal, history)
499
+ : this.streamOpenAI(prompt, ctx?.toolkit, ctx?.signal, history);
425
500
  for await (const ev of gen) {
426
- if (ev.type === "done" && ctx.id)
501
+ if (ev.type === "done" && ctx?.id)
427
502
  await this.store.save(ctx.id, ev.result.messages);
428
503
  yield ev;
429
504
  }
430
505
  }
506
+ /** §0.10 system message. The toolkit is OPTIONAL — with none there is no skills prompt and
507
+ * the system message is the system prompt alone. This deref was issue #86 (ADR 0023). */
431
508
  system(toolkit) {
432
- return [this.opts.systemPrompt ?? "", toolkit.skillsPrompt()].filter(Boolean).join("\n\n");
509
+ return [this.opts.systemPrompt ?? "", toolkit?.skillsPrompt() ?? ""].filter(Boolean).join("\n\n");
433
510
  }
434
511
  /** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
435
512
  makeSignal(external) {
@@ -484,7 +561,7 @@ export class Client {
484
561
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
485
562
  async runTool(toolkit, name, args, id, turn) {
486
563
  const h = this.opts.hooks;
487
- const source = toolkit.get(name)?.source ?? "custom";
564
+ const source = toolkit?.get(name)?.source ?? "custom";
488
565
  const t0 = Date.now();
489
566
  let a = args;
490
567
  if (h?.beforeTool) {
@@ -498,7 +575,10 @@ export class Client {
498
575
  if (ov?.args)
499
576
  a = ov.args;
500
577
  }
501
- let result = await toolkit.execute(name, a);
578
+ // No toolkit ⇒ nothing was declared, so a tool call is the provider inventing one.
579
+ let result = toolkit
580
+ ? await toolkit.execute(name, a)
581
+ : { output: `unknown tool: ${name}`, isError: true };
502
582
  // A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
503
583
  // result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
504
584
  // a tool error.
@@ -521,7 +601,7 @@ export class Client {
521
601
  try {
522
602
  const res = await this.llmFetch(url, init, signal);
523
603
  if (!res.ok)
524
- throw new Error(`LLM ${res.status}: ${await res.text()}`);
604
+ throw await llmHttpError(res);
525
605
  const data = await res.json();
526
606
  const tok = perCall(data.usage, style);
527
607
  this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion });
@@ -544,7 +624,7 @@ export class Client {
544
624
  messages.push({ role: "system", content: system });
545
625
  }
546
626
  messages.push({ role: "user", content: prompt });
547
- let tools = toolkit.toOpenAI();
627
+ let tools = toolkit?.toOpenAI() ?? [];
548
628
  const toolCalls = [];
549
629
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
550
630
  let turns = 0;
@@ -624,13 +704,15 @@ export class Client {
624
704
  if (!answer?.ok)
625
705
  return { result: { output: `declined/expired: ${request.prompt}`, isError: true } };
626
706
  const t0 = Date.now();
627
- let result = await toolkit.execute(name, args, { answer });
707
+ let result = toolkit
708
+ ? await toolkit.execute(name, args, { answer })
709
+ : { output: `unknown tool: ${name}`, isError: true };
628
710
  if (this.opts.hooks?.afterTool) {
629
711
  const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn });
630
712
  if (ov?.result)
631
713
  result = ov.result;
632
714
  }
633
- this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0);
715
+ this.emitTool(name, toolkit?.get(name)?.source ?? "custom", result, t0);
634
716
  if (pendingOf(result))
635
717
  result = { output: `unresolved: ${request.prompt}`, isError: true }; // never loop forever
636
718
  return { result };
@@ -644,7 +726,7 @@ export class Client {
644
726
  const system = this.system(toolkit);
645
727
  const runStart = Date.now();
646
728
  let messages = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }];
647
- let tools = toolkit.toAnthropic();
729
+ let tools = toolkit?.toAnthropic() ?? [];
648
730
  const toolCalls = [];
649
731
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
650
732
  let turns = 0;
@@ -725,7 +807,7 @@ export class Client {
725
807
  messages.push({ role: "system", content: system });
726
808
  }
727
809
  messages.push({ role: "user", content: prompt });
728
- let tools = toolkit.toOpenAI();
810
+ let tools = toolkit?.toOpenAI() ?? [];
729
811
  const toolCalls = [];
730
812
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
731
813
  let turns = 0;
@@ -750,7 +832,7 @@ export class Client {
750
832
  body: JSON.stringify(this.openaiBody(messages, tools, true)),
751
833
  }, signal);
752
834
  if (!res.ok || !res.body)
753
- throw new Error(`LLM ${res.status}: ${await res.text()}`);
835
+ throw await llmHttpError(res);
754
836
  for await (const line of sseLines(res.body)) {
755
837
  if (!line.startsWith("data:"))
756
838
  continue;
@@ -832,7 +914,7 @@ export class Client {
832
914
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
833
915
  const system = this.system(toolkit);
834
916
  let messages = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }];
835
- let tools = toolkit.toAnthropic();
917
+ let tools = toolkit?.toAnthropic() ?? [];
836
918
  const toolCalls = [];
837
919
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
838
920
  let turns = 0;
@@ -857,7 +939,7 @@ export class Client {
857
939
  body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
858
940
  }, signal);
859
941
  if (!res.ok || !res.body)
860
- throw new Error(`LLM ${res.status}: ${await res.text()}`);
942
+ throw await llmHttpError(res);
861
943
  for await (const line of sseLines(res.body)) {
862
944
  if (!line.startsWith("data:"))
863
945
  continue;
@@ -942,19 +1024,18 @@ export function createClient(opts) {
942
1024
  * name can never resolve. */
943
1025
  const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1";
944
1026
  /**
945
- * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
946
- * HTTP types to construct.
947
- *
948
- * This is a second constructor, not a second seam: it builds on the same injectable
949
- * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
950
- * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
1027
+ * Build a `fetch`-shaped adapter over a semantic `generate` function (ADR 0030):
1028
+ * the reusable piece of {@link createInProcessClient} — assembles the wire request,
1029
+ * calls `generate`, and re-encodes its answer as an OpenAI-shaped `Response`. Every
1030
+ * caller that wants an in-process model behind a `fetch` slot (the top-level client,
1031
+ * or the agent runtime's `inProcess` option) goes through this ONE function, so there
1032
+ * is no second copy of the request/response assembly to drift.
951
1033
  *
952
1034
  * Streaming is refused rather than faked — see the thrown error.
953
1035
  */
954
- export function createInProcessClient(opts) {
955
- const { generate, ...rest } = opts;
1036
+ export function createInProcessFetch(generate) {
956
1037
  if (typeof generate !== "function") {
957
- throw new Error("toolnexus: createInProcessClient requires a `generate` function");
1038
+ throw new Error("toolnexus: createInProcessFetch requires a `generate` function");
958
1039
  }
959
1040
  const inProcessFetch = async (_url, init) => {
960
1041
  const body = JSON.parse(String(init?.body ?? "{}"));
@@ -999,6 +1080,23 @@ export function createInProcessClient(opts) {
999
1080
  },
1000
1081
  }), { status: 200, headers: { "content-type": "application/json" } });
1001
1082
  };
1083
+ return inProcessFetch;
1084
+ }
1085
+ /**
1086
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
1087
+ * HTTP types to construct.
1088
+ *
1089
+ * This is a second constructor, not a second seam: it builds on the same injectable
1090
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
1091
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
1092
+ * The `fetch`-shaped adapter itself is {@link createInProcessFetch} — this function
1093
+ * is just that adapter wired into a `Client`.
1094
+ *
1095
+ * Streaming is refused rather than faked — see {@link createInProcessFetch}.
1096
+ */
1097
+ export function createInProcessClient(opts) {
1098
+ const { generate, ...rest } = opts;
1099
+ const inProcessFetch = createInProcessFetch(generate);
1002
1100
  return new Client({
1003
1101
  // Retries default to ZERO here, unlike a network client. There is no wire, so
1004
1102
  // there is no transient failure to ride out: whatever `generate` throws will
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./builtin.js";
7
7
  export * from "./native.js";
8
8
  export * from "./http.js";
9
9
  export * from "./a2a.js";
10
+ export * from "./acp.js";
10
11
  export * from "./serve.js";
11
12
  export * from "./mcpserve.js";
12
13
  export * from "./adapters.js";
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export * from "./builtin.js";
7
7
  export * from "./native.js";
8
8
  export * from "./http.js";
9
9
  export * from "./a2a.js";
10
+ export * from "./acp.js";
10
11
  export * from "./serve.js";
11
12
  export * from "./mcpserve.js";
12
13
  export * from "./adapters.js";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Deterministic ordering for everything the library lists (addenda A1a, A15, A22, A24).
3
+ *
4
+ * INTERNAL — deliberately not re-exported from `index.ts`. These are not API; they are the one
5
+ * place the ordering rules live so no caller has to remember them.
6
+ *
7
+ * The rules, and why each exists:
8
+ *
9
+ * - **Never `localeCompare`.** It orders by the machine's ICU data, so the same build emits a
10
+ * different order on a different machine. A cross-port byte-identity claim cannot survive it.
11
+ * - **Never a bare `Array.sort()`.** JS's default compares UTF-16 CODE UNITS, which disagrees
12
+ * with code point above U+FFFF — the same divergence C#'s `StringComparer.Ordinal` has.
13
+ * - **Never an ABSENT sort over a directory read.** `readdirSync` is sorted on most platforms,
14
+ * but Node guarantees nothing, and a walk that breaks at a cap turns that accident into a
15
+ * CONTENT difference: which files appear, not merely their order.
16
+ */
17
+ /**
18
+ * Unicode CODE-POINT comparison — the one string ordering rule, identical in all seven ports.
19
+ * Iterates code points rather than code units, so a surrogate pair sorts after U+E000–U+FFFF
20
+ * (where `<`, `Array.sort()` and `StringComparer.Ordinal` all disagree with it).
21
+ */
22
+ export declare function compareCodePoints(a: string, b: string): number;
23
+ /**
24
+ * The SKILL DISCOVERY order (addendum A15): DEPTH (path segments) ascending, then code point,
25
+ * over a path relative to the root. First-wins then keeps the shallowest copy of a duplicated
26
+ * name, uniformly — code point alone makes the winner depend on the skill's own first letter
27
+ * (`docx` beats `synced/…`, but `xlsx` loses to it).
28
+ */
29
+ export declare function compareDiscovery(a: string, b: string, sep: string): number;
30
+ /**
31
+ * Sort a directory's entries BY NAME, in code-point order, in place.
32
+ *
33
+ * By NAME at each level, never by full path: that is what makes ports walking the same tree with
34
+ * the same stack produce the same sequence, which matters because a capped walk's cap decides
35
+ * WHICH entries are reached.
36
+ */
37
+ export declare function sortEntriesByName<T extends {
38
+ name: string;
39
+ }>(entries: T[]): T[];
40
+ /**
41
+ * The listing path form (addendum A28): a relative path with `/` as the separator ON EVERY
42
+ * PLATFORM. Every capped or ordered listing EMITS this string and SORTS ON THE SAME STRING —
43
+ * ordering by one thing and showing another is how a listing ends up looking unsorted to its
44
+ * reader while passing its own test. Takes an already-relative path (`path.relative(...)`).
45
+ */
46
+ export declare function toPosixPath(rel: string, sep: string): string;
package/dist/order.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Deterministic ordering for everything the library lists (addenda A1a, A15, A22, A24).
3
+ *
4
+ * INTERNAL — deliberately not re-exported from `index.ts`. These are not API; they are the one
5
+ * place the ordering rules live so no caller has to remember them.
6
+ *
7
+ * The rules, and why each exists:
8
+ *
9
+ * - **Never `localeCompare`.** It orders by the machine's ICU data, so the same build emits a
10
+ * different order on a different machine. A cross-port byte-identity claim cannot survive it.
11
+ * - **Never a bare `Array.sort()`.** JS's default compares UTF-16 CODE UNITS, which disagrees
12
+ * with code point above U+FFFF — the same divergence C#'s `StringComparer.Ordinal` has.
13
+ * - **Never an ABSENT sort over a directory read.** `readdirSync` is sorted on most platforms,
14
+ * but Node guarantees nothing, and a walk that breaks at a cap turns that accident into a
15
+ * CONTENT difference: which files appear, not merely their order.
16
+ */
17
+ /**
18
+ * Unicode CODE-POINT comparison — the one string ordering rule, identical in all seven ports.
19
+ * Iterates code points rather than code units, so a surrogate pair sorts after U+E000–U+FFFF
20
+ * (where `<`, `Array.sort()` and `StringComparer.Ordinal` all disagree with it).
21
+ */
22
+ export function compareCodePoints(a, b) {
23
+ if (a === b)
24
+ return 0;
25
+ const ai = a[Symbol.iterator]();
26
+ const bi = b[Symbol.iterator]();
27
+ for (;;) {
28
+ const x = ai.next();
29
+ const y = bi.next();
30
+ if (x.done)
31
+ return y.done ? 0 : -1;
32
+ if (y.done)
33
+ return 1;
34
+ const cx = x.value.codePointAt(0);
35
+ const cy = y.value.codePointAt(0);
36
+ if (cx !== cy)
37
+ return cx < cy ? -1 : 1;
38
+ }
39
+ }
40
+ /**
41
+ * The SKILL DISCOVERY order (addendum A15): DEPTH (path segments) ascending, then code point,
42
+ * over a path relative to the root. First-wins then keeps the shallowest copy of a duplicated
43
+ * name, uniformly — code point alone makes the winner depend on the skill's own first letter
44
+ * (`docx` beats `synced/…`, but `xlsx` loses to it).
45
+ */
46
+ export function compareDiscovery(a, b, sep) {
47
+ const da = a.split(sep).length;
48
+ const db = b.split(sep).length;
49
+ if (da !== db)
50
+ return da < db ? -1 : 1;
51
+ return compareCodePoints(a, b);
52
+ }
53
+ /**
54
+ * Sort a directory's entries BY NAME, in code-point order, in place.
55
+ *
56
+ * By NAME at each level, never by full path: that is what makes ports walking the same tree with
57
+ * the same stack produce the same sequence, which matters because a capped walk's cap decides
58
+ * WHICH entries are reached.
59
+ */
60
+ export function sortEntriesByName(entries) {
61
+ return entries.sort((x, y) => compareCodePoints(x.name, y.name));
62
+ }
63
+ /**
64
+ * The listing path form (addendum A28): a relative path with `/` as the separator ON EVERY
65
+ * PLATFORM. Every capped or ordered listing EMITS this string and SORTS ON THE SAME STRING —
66
+ * ordering by one thing and showing another is how a listing ends up looking unsorted to its
67
+ * reader while passing its own test. Takes an already-relative path (`path.relative(...)`).
68
+ */
69
+ export function toPosixPath(rel, sep) {
70
+ return sep === "/" ? rel : rel.split(sep).join("/");
71
+ }
package/dist/skill.d.ts CHANGED
@@ -32,6 +32,12 @@ export type SkillSkipReason = "missing-name" | "malformed-frontmatter" | "duplic
32
32
  export interface SkillSkip {
33
33
  location: string;
34
34
  reason: SkillSkipReason;
35
+ /**
36
+ * The NATIVE parser's own message ("Tabs are not allowed as indentation at line 3"), which is
37
+ * one keystroke from fixed and used to be discarded. Kept as a SEPARATE field so `reason`
38
+ * stays byte-identical across ports while this stays native — never compare it for parity.
39
+ */
40
+ detail?: string;
35
41
  }
36
42
  /** Result of a list-only validate pass (SPEC.md §3, S3). */
37
43
  export interface SkillInventory {
@@ -52,6 +58,13 @@ export interface SkillSource {
52
58
  tool: Tool;
53
59
  /** Markdown catalog for the system prompt (mirrors opencode Skill.fmt). */
54
60
  prompt(): string;
61
+ /**
62
+ * Candidate SKILL.md files that did NOT become skills, with the typed reason and the native
63
+ * parser `detail` (ADR 0028). Previously only `listSkills` reported these, so the path every
64
+ * README and example uses — `loadSkills` — could load 69 of 75 and say nothing. An omission
65
+ * that stops being reported is indistinguishable from something that was finished.
66
+ */
67
+ skipped: SkillSkip[];
55
68
  }
56
69
  /**
57
70
  * Discover + validate skills from the same sources loadSkills accepts, returning