toolnexus 0.18.1 → 0.20.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.
@@ -274,7 +349,7 @@ export class Client {
274
349
  if (req.maxTokens)
275
350
  body.max_tokens = req.maxTokens;
276
351
  const signal = this.makeSignal(req.signal);
277
- {
352
+ try {
278
353
  const data = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
279
354
  method: "POST",
280
355
  headers: { "content-type": "application/json", authorization: `Bearer ${key}`, ...this.opts.headers },
@@ -299,6 +374,9 @@ export class Client {
299
374
  raw: data,
300
375
  };
301
376
  }
377
+ finally {
378
+ this.releaseSignal(signal);
379
+ }
302
380
  }
303
381
  /** Anthropic-style upstream: the real translation. */
304
382
  async translateAnthropic(req) {
@@ -332,7 +410,7 @@ export class Client {
332
410
  if (tc)
333
411
  body.tool_choice = tc;
334
412
  const signal = this.makeSignal(req.signal);
335
- {
413
+ try {
336
414
  const data = await this.llmCallJson(endpoint, {
337
415
  method: "POST",
338
416
  headers: {
@@ -369,6 +447,9 @@ export class Client {
369
447
  raw: data,
370
448
  };
371
449
  }
450
+ finally {
451
+ this.releaseSignal(signal);
452
+ }
372
453
  }
373
454
  /**
374
455
  * Renders a translate result's tool calls as an OpenAI `tool_calls` array, ready to put
@@ -390,7 +471,7 @@ export class Client {
390
471
  async ask(prompt, ctx) {
391
472
  // Block-style streaming: run the streaming loop, forward text deltas, still return the
392
473
  // final RunResult. Memory (id load/save) is handled by stream() itself, so no duplication.
393
- if (ctx.on_text) {
474
+ if (ctx?.on_text) {
394
475
  let result;
395
476
  for await (const ev of this.stream(prompt, { toolkit: ctx.toolkit, id: ctx.id, signal: ctx.signal })) {
396
477
  if (ev.type === "text")
@@ -400,8 +481,8 @@ export class Client {
400
481
  }
401
482
  return result;
402
483
  }
403
- if (!ctx.id)
404
- return this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal });
484
+ if (!ctx?.id)
485
+ return this.run(prompt, { toolkit: ctx?.toolkit, signal: ctx?.signal });
405
486
  const history = (await this.store.get(ctx.id)) ?? [];
406
487
  const result = await this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal, history });
407
488
  await this.store.save(ctx.id, result.messages);
@@ -409,7 +490,7 @@ export class Client {
409
490
  }
410
491
  /** A stateful multi-turn conversation that retains history across sends. */
411
492
  conversation(ctx) {
412
- return new Conversation(this, ctx.toolkit, ctx.signal);
493
+ return new Conversation(this, ctx?.toolkit, ctx?.signal);
413
494
  }
414
495
  /**
415
496
  * Streaming variant: async-iterate live events (text deltas, tool calls/results, usage, done).
@@ -418,25 +499,43 @@ export class Client {
418
499
  */
419
500
  async *stream(prompt, ctx) {
420
501
  checkPromptParts(prompt, this.wireOpts());
421
- const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined;
502
+ const history = ctx?.id ? (await this.store.get(ctx.id)) ?? [] : undefined;
422
503
  const gen = this.opts.style === "anthropic"
423
- ? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
424
- : this.streamOpenAI(prompt, ctx.toolkit, ctx.signal, history);
504
+ ? this.streamAnthropic(prompt, ctx?.toolkit, ctx?.signal, history)
505
+ : this.streamOpenAI(prompt, ctx?.toolkit, ctx?.signal, history);
425
506
  for await (const ev of gen) {
426
- if (ev.type === "done" && ctx.id)
507
+ if (ev.type === "done" && ctx?.id)
427
508
  await this.store.save(ctx.id, ev.result.messages);
428
509
  yield ev;
429
510
  }
430
511
  }
512
+ /** §0.10 system message. The toolkit is OPTIONAL — with none there is no skills prompt and
513
+ * the system message is the system prompt alone. This deref was issue #86 (ADR 0023). */
431
514
  system(toolkit) {
432
- return [this.opts.systemPrompt ?? "", toolkit.skillsPrompt()].filter(Boolean).join("\n\n");
515
+ return [this.opts.systemPrompt ?? "", toolkit?.skillsPrompt() ?? ""].filter(Boolean).join("\n\n");
433
516
  }
434
- /** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
517
+ /** Run-scoped timeout timers, cleared by `releaseSignal` when the run settles. */
518
+ runTimers = new WeakMap();
519
+ /** Drop a run's timeout timer. Idempotent, and safe on a signal that never had one. */
520
+ releaseSignal(signal) {
521
+ const t = this.runTimers.get(signal);
522
+ if (t !== undefined) {
523
+ clearTimeout(t);
524
+ this.runTimers.delete(signal);
525
+ }
526
+ }
527
+ /** Build a run-scoped abort signal from the optional run-level timeout + an external signal.
528
+ *
529
+ * The timer is deliberately NOT `unref()`d. An unref'd timer cannot keep the event loop
530
+ * alive, so a run whose only pending work is a request that never settles — an injected
531
+ * `fetch`, an in-process transport, a stalled stream — drains the loop and the timeout never
532
+ * fires at all, which is the one case `timeoutMs` exists for. It is RELEASED instead: the
533
+ * caller clears it however the run leaves, so a finished run holds nothing open. */
435
534
  makeSignal(external) {
436
535
  const ctrl = new AbortController();
437
536
  if (this.opts.timeoutMs) {
438
537
  const t = setTimeout(() => ctrl.abort(new Error(`run timeout after ${this.opts.timeoutMs}ms`)), this.opts.timeoutMs);
439
- t.unref?.();
538
+ this.runTimers.set(ctrl.signal, t);
440
539
  }
441
540
  if (external) {
442
541
  if (external.aborted)
@@ -484,7 +583,7 @@ export class Client {
484
583
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
485
584
  async runTool(toolkit, name, args, id, turn) {
486
585
  const h = this.opts.hooks;
487
- const source = toolkit.get(name)?.source ?? "custom";
586
+ const source = toolkit?.get(name)?.source ?? "custom";
488
587
  const t0 = Date.now();
489
588
  let a = args;
490
589
  if (h?.beforeTool) {
@@ -498,7 +597,10 @@ export class Client {
498
597
  if (ov?.args)
499
598
  a = ov.args;
500
599
  }
501
- let result = await toolkit.execute(name, a);
600
+ // No toolkit ⇒ nothing was declared, so a tool call is the provider inventing one.
601
+ let result = toolkit
602
+ ? await toolkit.execute(name, a)
603
+ : { output: `unknown tool: ${name}`, isError: true };
502
604
  // A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
503
605
  // result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
504
606
  // a tool error.
@@ -521,7 +623,7 @@ export class Client {
521
623
  try {
522
624
  const res = await this.llmFetch(url, init, signal);
523
625
  if (!res.ok)
524
- throw new Error(`LLM ${res.status}: ${await res.text()}`);
626
+ throw await llmHttpError(res);
525
627
  const data = await res.json();
526
628
  const tok = perCall(data.usage, style);
527
629
  this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion });
@@ -532,10 +634,47 @@ export class Client {
532
634
  throw e;
533
635
  }
534
636
  }
535
- // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
637
+ // The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
638
+ // run leaves: return, throw, or a consumer abandoning the stream half-way.
536
639
  async runOpenAI(prompt, toolkit, external, history) {
537
- const key = resolveKey(this.opts);
538
640
  const signal = this.makeSignal(external);
641
+ try {
642
+ return await this.runOpenAIWithSignal(signal, prompt, toolkit, history);
643
+ }
644
+ finally {
645
+ this.releaseSignal(signal);
646
+ }
647
+ }
648
+ async runAnthropic(prompt, toolkit, external, history) {
649
+ const signal = this.makeSignal(external);
650
+ try {
651
+ return await this.runAnthropicWithSignal(signal, prompt, toolkit, history);
652
+ }
653
+ finally {
654
+ this.releaseSignal(signal);
655
+ }
656
+ }
657
+ async *streamOpenAI(prompt, toolkit, external, history) {
658
+ const signal = this.makeSignal(external);
659
+ try {
660
+ yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history);
661
+ }
662
+ finally {
663
+ this.releaseSignal(signal);
664
+ }
665
+ }
666
+ async *streamAnthropic(prompt, toolkit, external, history) {
667
+ const signal = this.makeSignal(external);
668
+ try {
669
+ yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history);
670
+ }
671
+ finally {
672
+ this.releaseSignal(signal);
673
+ }
674
+ }
675
+ // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
676
+ async runOpenAIWithSignal(signal, prompt, toolkit, history) {
677
+ const key = resolveKey(this.opts);
539
678
  const runStart = Date.now();
540
679
  let messages = history && history.length ? [...history] : [];
541
680
  if (!messages.length) {
@@ -544,7 +683,7 @@ export class Client {
544
683
  messages.push({ role: "system", content: system });
545
684
  }
546
685
  messages.push({ role: "user", content: prompt });
547
- let tools = toolkit.toOpenAI();
686
+ let tools = toolkit?.toOpenAI() ?? [];
548
687
  const toolCalls = [];
549
688
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
550
689
  let turns = 0;
@@ -624,27 +763,28 @@ export class Client {
624
763
  if (!answer?.ok)
625
764
  return { result: { output: `declined/expired: ${request.prompt}`, isError: true } };
626
765
  const t0 = Date.now();
627
- let result = await toolkit.execute(name, args, { answer });
766
+ let result = toolkit
767
+ ? await toolkit.execute(name, args, { answer })
768
+ : { output: `unknown tool: ${name}`, isError: true };
628
769
  if (this.opts.hooks?.afterTool) {
629
770
  const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn });
630
771
  if (ov?.result)
631
772
  result = ov.result;
632
773
  }
633
- this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0);
774
+ this.emitTool(name, toolkit?.get(name)?.source ?? "custom", result, t0);
634
775
  if (pendingOf(result))
635
776
  result = { output: `unresolved: ${request.prompt}`, isError: true }; // never loop forever
636
777
  return { result };
637
778
  }
638
779
  // ---- Anthropic-style: POST {baseUrl}/messages ----
639
- async runAnthropic(prompt, toolkit, external, history) {
780
+ async runAnthropicWithSignal(signal, prompt, toolkit, history) {
640
781
  const key = resolveKey(this.opts);
641
- const signal = this.makeSignal(external);
642
782
  const base = this.opts.baseUrl.replace(/\/$/, "");
643
783
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
644
784
  const system = this.system(toolkit);
645
785
  const runStart = Date.now();
646
786
  let messages = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }];
647
- let tools = toolkit.toAnthropic();
787
+ let tools = toolkit?.toAnthropic() ?? [];
648
788
  const toolCalls = [];
649
789
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
650
790
  let turns = 0;
@@ -714,9 +854,8 @@ export class Client {
714
854
  }
715
855
  }
716
856
  // ---- Streaming: OpenAI-style ----
717
- async *streamOpenAI(prompt, toolkit, external, history) {
857
+ async *streamOpenAIWithSignal(signal, prompt, toolkit, history) {
718
858
  const key = resolveKey(this.opts);
719
- const signal = this.makeSignal(external);
720
859
  const runStart = Date.now();
721
860
  let messages = history && history.length ? [...history] : [];
722
861
  if (!messages.length) {
@@ -725,7 +864,7 @@ export class Client {
725
864
  messages.push({ role: "system", content: system });
726
865
  }
727
866
  messages.push({ role: "user", content: prompt });
728
- let tools = toolkit.toOpenAI();
867
+ let tools = toolkit?.toOpenAI() ?? [];
729
868
  const toolCalls = [];
730
869
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
731
870
  let turns = 0;
@@ -750,7 +889,7 @@ export class Client {
750
889
  body: JSON.stringify(this.openaiBody(messages, tools, true)),
751
890
  }, signal);
752
891
  if (!res.ok || !res.body)
753
- throw new Error(`LLM ${res.status}: ${await res.text()}`);
892
+ throw await llmHttpError(res);
754
893
  for await (const line of sseLines(res.body)) {
755
894
  if (!line.startsWith("data:"))
756
895
  continue;
@@ -824,15 +963,14 @@ export class Client {
824
963
  }
825
964
  }
826
965
  // ---- Streaming: Anthropic-style ----
827
- async *streamAnthropic(prompt, toolkit, external, history) {
966
+ async *streamAnthropicWithSignal(signal, prompt, toolkit, history) {
828
967
  const key = resolveKey(this.opts);
829
- const signal = this.makeSignal(external);
830
968
  const runStart = Date.now();
831
969
  const base = this.opts.baseUrl.replace(/\/$/, "");
832
970
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
833
971
  const system = this.system(toolkit);
834
972
  let messages = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }];
835
- let tools = toolkit.toAnthropic();
973
+ let tools = toolkit?.toAnthropic() ?? [];
836
974
  const toolCalls = [];
837
975
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
838
976
  let turns = 0;
@@ -857,7 +995,7 @@ export class Client {
857
995
  body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
858
996
  }, signal);
859
997
  if (!res.ok || !res.body)
860
- throw new Error(`LLM ${res.status}: ${await res.text()}`);
998
+ throw await llmHttpError(res);
861
999
  for await (const line of sseLines(res.body)) {
862
1000
  if (!line.startsWith("data:"))
863
1001
  continue;
@@ -942,19 +1080,18 @@ export function createClient(opts) {
942
1080
  * name can never resolve. */
943
1081
  const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1";
944
1082
  /**
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.
1083
+ * Build a `fetch`-shaped adapter over a semantic `generate` function (ADR 0030):
1084
+ * the reusable piece of {@link createInProcessClient} — assembles the wire request,
1085
+ * calls `generate`, and re-encodes its answer as an OpenAI-shaped `Response`. Every
1086
+ * caller that wants an in-process model behind a `fetch` slot (the top-level client,
1087
+ * or the agent runtime's `inProcess` option) goes through this ONE function, so there
1088
+ * is no second copy of the request/response assembly to drift.
951
1089
  *
952
1090
  * Streaming is refused rather than faked — see the thrown error.
953
1091
  */
954
- export function createInProcessClient(opts) {
955
- const { generate, ...rest } = opts;
1092
+ export function createInProcessFetch(generate) {
956
1093
  if (typeof generate !== "function") {
957
- throw new Error("toolnexus: createInProcessClient requires a `generate` function");
1094
+ throw new Error("toolnexus: createInProcessFetch requires a `generate` function");
958
1095
  }
959
1096
  const inProcessFetch = async (_url, init) => {
960
1097
  const body = JSON.parse(String(init?.body ?? "{}"));
@@ -999,6 +1136,23 @@ export function createInProcessClient(opts) {
999
1136
  },
1000
1137
  }), { status: 200, headers: { "content-type": "application/json" } });
1001
1138
  };
1139
+ return inProcessFetch;
1140
+ }
1141
+ /**
1142
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
1143
+ * HTTP types to construct.
1144
+ *
1145
+ * This is a second constructor, not a second seam: it builds on the same injectable
1146
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
1147
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
1148
+ * The `fetch`-shaped adapter itself is {@link createInProcessFetch} — this function
1149
+ * is just that adapter wired into a `Client`.
1150
+ *
1151
+ * Streaming is refused rather than faked — see {@link createInProcessFetch}.
1152
+ */
1153
+ export function createInProcessClient(opts) {
1154
+ const { generate, ...rest } = opts;
1155
+ const inProcessFetch = createInProcessFetch(generate);
1002
1156
  return new Client({
1003
1157
  // Retries default to ZERO here, unlike a network client. There is no wire, so
1004
1158
  // 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