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/src/order.ts ADDED
@@ -0,0 +1,70 @@
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
+ /**
19
+ * Unicode CODE-POINT comparison — the one string ordering rule, identical in all seven ports.
20
+ * Iterates code points rather than code units, so a surrogate pair sorts after U+E000–U+FFFF
21
+ * (where `<`, `Array.sort()` and `StringComparer.Ordinal` all disagree with it).
22
+ */
23
+ export function compareCodePoints(a: string, b: string): number {
24
+ if (a === b) 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) return y.done ? 0 : -1
31
+ if (y.done) return 1
32
+ const cx = x.value.codePointAt(0)!
33
+ const cy = y.value.codePointAt(0)!
34
+ if (cx !== cy) return cx < cy ? -1 : 1
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The SKILL DISCOVERY order (addendum A15): DEPTH (path segments) ascending, then code point,
40
+ * over a path relative to the root. First-wins then keeps the shallowest copy of a duplicated
41
+ * name, uniformly — code point alone makes the winner depend on the skill's own first letter
42
+ * (`docx` beats `synced/…`, but `xlsx` loses to it).
43
+ */
44
+ export function compareDiscovery(a: string, b: string, sep: string): number {
45
+ const da = a.split(sep).length
46
+ const db = b.split(sep).length
47
+ if (da !== db) return da < db ? -1 : 1
48
+ return compareCodePoints(a, b)
49
+ }
50
+
51
+ /**
52
+ * Sort a directory's entries BY NAME, in code-point order, in place.
53
+ *
54
+ * By NAME at each level, never by full path: that is what makes ports walking the same tree with
55
+ * the same stack produce the same sequence, which matters because a capped walk's cap decides
56
+ * WHICH entries are reached.
57
+ */
58
+ export function sortEntriesByName<T extends { name: string }>(entries: T[]): T[] {
59
+ return entries.sort((x, y) => compareCodePoints(x.name, y.name))
60
+ }
61
+
62
+ /**
63
+ * The listing path form (addendum A28): a relative path with `/` as the separator ON EVERY
64
+ * PLATFORM. Every capped or ordered listing EMITS this string and SORTS ON THE SAME STRING —
65
+ * ordering by one thing and showing another is how a listing ends up looking unsorted to its
66
+ * reader while passing its own test. Takes an already-relative path (`path.relative(...)`).
67
+ */
68
+ export function toPosixPath(rel: string, sep: string): string {
69
+ return sep === "/" ? rel : rel.split(sep).join("/")
70
+ }
package/src/skill.ts CHANGED
@@ -13,6 +13,7 @@ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"
13
13
  import path from "node:path"
14
14
  import { pathToFileURL } from "node:url"
15
15
  import { parse as parseYaml } from "yaml"
16
+ import { compareCodePoints, compareDiscovery, sortEntriesByName } from "./order.js"
16
17
  import type { Tool, ToolResult } from "./types.js"
17
18
 
18
19
  export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
@@ -59,6 +60,12 @@ export type SkillSkipReason = "missing-name" | "malformed-frontmatter" | "duplic
59
60
  export interface SkillSkip {
60
61
  location: string
61
62
  reason: SkillSkipReason
63
+ /**
64
+ * The NATIVE parser's own message ("Tabs are not allowed as indentation at line 3"), which is
65
+ * one keystroke from fixed and used to be discarded. Kept as a SEPARATE field so `reason`
66
+ * stays byte-identical across ports while this stays native — never compare it for parity.
67
+ */
68
+ detail?: string
62
69
  }
63
70
 
64
71
  /** Result of a list-only validate pass (SPEC.md §3, S3). */
@@ -78,37 +85,80 @@ export interface LoadSkillsOptions {
78
85
  }
79
86
 
80
87
  /**
81
- * Parse YAML frontmatter (between the leading `---` fences) with a real YAML
82
- * parser, so folded (`>`)/literal (`|`) block scalars, quoting, and multi-line
83
- * values all resolve correctly. Scalar values are coerced to strings.
88
+ * Parse YAML frontmatter (between the leading `---` fences) with a real YAML parser, so folded
89
+ * (`>`)/literal (`|`) block scalars, quoting and multi-line values all resolve correctly.
90
+ * Scalar values are coerced to strings.
84
91
  *
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.
92
+ * LENIENT FALLBACK (ADR 0028, issue #93) — **YAML FIRST**. A line-wise `key: rest-of-line` read
93
+ * of `name`/`description` runs ONLY on frontmatter a real YAML parser has ALREADY refused.
94
+ * The order is the whole point: line-wise FIRST misparses legitimate YAML (`description: |`
95
+ * becomes `"|"`, a continued plain scalar truncates to its first line), and running it only
96
+ * after a refusal means it can never see a file whose YAML semantics matter — by construction
97
+ * those files have none left to preserve. No file that parses today changes value.
98
+ *
99
+ * `malformed` is true only when fences are present, YAML refused, AND the rescue found no name.
89
100
  */
90
- function parseFrontmatter(text: string): { data: Record<string, string>; content: string; malformed: boolean } {
101
+ const LENIENT_KEYS = ["name", "description"] as const
102
+ /** A value opening one of these is a construct the rescue does not implement — refused, never
103
+ * guessed, so a half-broken block scalar degrades to "no description", not to `"|"`. */
104
+ const LENIENT_REFUSED_OPENERS = new Set(["|", ">", "&", "*", "[", "{", "!"])
105
+ /** Column 0, ordinary key characters, one `:`; the value is the rest of the line. */
106
+ const LENIENT_LINE = /^([A-Za-z0-9_][A-Za-z0-9_.-]*):[ \t]*(.*)$/
107
+
108
+ function unquoteScalar(v: string): string {
109
+ const t = v.trim()
110
+ if (t.length > 1 && (t[0] === '"' || t[0] === "'") && t[t.length - 1] === t[0]) return t.slice(1, -1)
111
+ return t
112
+ }
113
+
114
+ /** Rescue `name`/`description` from a frontmatter block YAML refused. First-wins, column 0. */
115
+ function lineWiseFrontmatter(block: string): Record<string, string> {
116
+ const data: Record<string, string> = {}
117
+ for (const raw of block.split(/\r?\n/)) {
118
+ const head = raw.slice(0, 1)
119
+ // indented continuation, comment, or blank — not a top-level key
120
+ if (head === " " || head === "\t" || head === "#" || head === "") continue
121
+ const m = LENIENT_LINE.exec(raw)
122
+ if (!m) continue
123
+ const key = m[1]
124
+ const value = m[2].trim()
125
+ if (!(LENIENT_KEYS as readonly string[]).includes(key) || key in data) continue
126
+ if (!value || LENIENT_REFUSED_OPENERS.has(value[0])) continue
127
+ data[key] = unquoteScalar(value)
128
+ }
129
+ return data
130
+ }
131
+
132
+ function parseFrontmatter(text: string): { data: Record<string, string>; content: string; malformed: boolean; detail?: string } {
91
133
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
92
134
  if (!match) return { data: {}, content: text, malformed: false }
93
135
  const data: Record<string, string> = {}
94
136
  let parsed: unknown
95
- let malformed = false
137
+ let detail: string | undefined
96
138
  try {
97
139
  parsed = parseYaml(match[1])
98
- } catch {
140
+ } catch (e) {
99
141
  parsed = null
100
- malformed = true
142
+ detail = e instanceof Error ? e.message : String(e)
101
143
  }
102
- if (parsed && typeof parsed === "object") {
144
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
103
145
  for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
104
146
  // Trim so block-scalar trailing newlines (chomping differs subtly between
105
- // YAML libs) don't leak — keeps the five ports byte-identical.
147
+ // YAML libs) don't leak — keeps the ports byte-identical.
106
148
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
107
149
  data[key] = String(value).trim()
108
150
  }
109
151
  }
152
+ return { data, content: match[2], malformed: false }
153
+ }
154
+ // YAML refused (threw, or produced something that is not a mapping): rescue the two scalars.
155
+ const rescued = lineWiseFrontmatter(match[1])
156
+ return {
157
+ data: rescued,
158
+ content: match[2],
159
+ malformed: !rescued.name,
160
+ detail: detail ?? (rescued.name ? undefined : "frontmatter is not a YAML mapping"),
110
161
  }
111
- return { data, content: match[2], malformed }
112
162
  }
113
163
 
114
164
  function walkSkillFiles(root: string): string[] {
@@ -121,10 +171,15 @@ function walkSkillFiles(root: string): string[] {
121
171
  const dir = stack.pop()!
122
172
  let entries
123
173
  try {
124
- entries = readdirSync(dir, { withFileTypes: true })
174
+ // EXPLICIT (A24): `readdirSync` is sorted on most platforms, but Node guarantees
175
+ // nothing. An absent sort over a directory read is the same defect class as a wrong one,
176
+ // and it is invisible to a grep for comparators.
177
+ entries = sortEntriesByName(readdirSync(dir, { withFileTypes: true }))
125
178
  } catch {
126
179
  continue
127
180
  }
181
+ // Subdirectories are pushed REVERSED so the LIFO stack pops them in name order.
182
+ for (const entry of [...entries].reverse().filter((e) => e.isDirectory() || e.isSymbolicLink()).reverse().concat([])) void entry
128
183
  for (const entry of entries) {
129
184
  const full = path.join(dir, entry.name)
130
185
  let isDir = entry.isDirectory()
@@ -158,11 +213,23 @@ function walkSkillFiles(root: string): string[] {
158
213
  return out
159
214
  }
160
215
 
216
+ /**
217
+ * The `<skill_files>` SAMPLE LIST: sibling resources shown to the model when a skill is loaded.
218
+ *
219
+ * This is SHIPPED OUTPUT, so its order is part of the cross-port contract (addendum A22). It
220
+ * used to be raw `readdirSync` order over a LIFO `stack.pop()` walk with the cap applied MID
221
+ * TRAVERSAL — so the filesystem decided not merely how the sample was ordered but WHICH files
222
+ * appeared in it at all. Now: collect everything, then order by DEPTH ascending then Unicode
223
+ * CODE POINT over the path relative to the skill dir (the same `compareDiscovery` rule as
224
+ * discovery), and only then apply the cap. The sample is therefore a function of the tree and
225
+ * the cap alone — never of readdir order, and stable across machines and platforms. The
226
+ * ordering is PLAIN Unicode code point over the relative path (A25).
227
+ */
161
228
  function sampleSiblingFiles(dir: string, limit = 10): string[] {
162
- const out: string[] = []
229
+ const found: string[] = []
163
230
  const stack = [dir]
164
231
  const seen = new Set<string>()
165
- while (stack.length && out.length < limit) {
232
+ while (stack.length) {
166
233
  const cur = stack.pop()!
167
234
  let entries
168
235
  try {
@@ -171,7 +238,6 @@ function sampleSiblingFiles(dir: string, limit = 10): string[] {
171
238
  continue
172
239
  }
173
240
  for (const entry of entries) {
174
- if (out.length >= limit) break
175
241
  const full = path.join(cur, entry.name)
176
242
  let isDir = entry.isDirectory()
177
243
  let isFile = entry.isFile()
@@ -196,11 +262,17 @@ function sampleSiblingFiles(dir: string, limit = 10): string[] {
196
262
  seen.add(real)
197
263
  stack.push(full)
198
264
  } else if (isFile && entry.name !== "SKILL.md") {
199
- out.push(full)
265
+ found.push(full)
200
266
  }
201
267
  }
202
268
  }
203
- return out
269
+ // Order FIRST, cap SECOND: capping during the walk let readdir order pick the sample.
270
+ // PLAIN code point over the path relative to the skill dir (A25) — NOT the discovery rule.
271
+ // A15's depth rule exists to decide WHICH FILE a duplicated skill NAME resolves to; it has no
272
+ // business ordering a flat listing, where it would interleave `a-root.txt, z-root.txt,
273
+ // alpha/f.txt` instead of `a-root.txt, alpha/f.txt, z-root.txt`.
274
+ found.sort((a, b) => compareCodePoints(path.relative(dir, a), path.relative(dir, b)))
275
+ return found.slice(0, limit)
204
276
  }
205
277
 
206
278
  /** One raw candidate before cross-source dedupe: a parsed skill OR a typed skip. */
@@ -222,17 +294,26 @@ function candidatesFromDir(root: string): RawCandidate[] {
222
294
  console.warn(`[toolnexus] skills dir not found: ${root}`)
223
295
  return out
224
296
  }
225
- for (const file of walkSkillFiles(root)) {
297
+ // DISCOVERY ORDER (addendum A1/A1a/A15): dirs in the order the CALLER passed them; within a
298
+ // dir, by DEPTH ASCENDING, then Unicode CODE-POINT comparison of the path relative to that dir.
299
+ // A symlink sorts at its DISCOVERED path, never its target.
300
+ //
301
+ // Depth comes FIRST, and that is the whole rule: code point alone makes the winner depend on
302
+ // the skill's own first letter. `docx`/`pdf`/`pptx` beat `synced/<uuid>/…` because d/p < s —
303
+ // but `xlsx` LOSES to it, because x > s. Depth-first makes "a shallower path beats a nested
304
+ // copy of the same name" true for EVERY name, which is what SPEC §3 already claims.
305
+ const files = walkSkillFiles(root).sort((a, b) => compareDiscovery(path.relative(root, a), path.relative(root, b), path.sep))
306
+ for (const file of files) {
226
307
  let text: string
227
308
  try {
228
309
  text = readFileSync(file, "utf8")
229
- } catch {
230
- out.push({ skip: { location: file, reason: "unreadable" } })
310
+ } catch (e) {
311
+ out.push({ skip: { location: file, reason: "unreadable", detail: e instanceof Error ? e.message : String(e) } })
231
312
  continue
232
313
  }
233
- const { data, content, malformed } = parseFrontmatter(text)
314
+ const { data, content, malformed, detail } = parseFrontmatter(text)
234
315
  if (malformed) {
235
- out.push({ skip: { location: file, reason: "malformed-frontmatter" } })
316
+ out.push({ skip: { location: file, reason: "malformed-frontmatter", ...(detail ? { detail } : {}) } })
236
317
  continue
237
318
  }
238
319
  if (!data.name) {
@@ -321,6 +402,13 @@ export interface SkillSource {
321
402
  tool: Tool
322
403
  /** Markdown catalog for the system prompt (mirrors opencode Skill.fmt). */
323
404
  prompt(): string
405
+ /**
406
+ * Candidate SKILL.md files that did NOT become skills, with the typed reason and the native
407
+ * parser `detail` (ADR 0028). Previously only `listSkills` reported these, so the path every
408
+ * README and example uses — `loadSkills` — could load 69 of 75 and say nothing. An omission
409
+ * that stops being reported is indistinguishable from something that was finished.
410
+ */
411
+ skipped: SkillSkip[]
324
412
  }
325
413
 
326
414
  /**
@@ -343,6 +431,7 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
343
431
  const merged = mergeCandidates(collectCandidates(opts))
344
432
  const skills = applySkillsFilter(merged.skills, opts.filter)
345
433
 
434
+ const skipped = merged.skipped
346
435
  const tool: Tool = {
347
436
  name: "skill",
348
437
  description: SKILL_TOOL_DESCRIPTION,
@@ -358,7 +447,7 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
358
447
  const info = skills[name]
359
448
  if (!info) {
360
449
  return {
361
- output: `Skill "${name}" not found. Available skills: ${Object.keys(skills).sort().join(", ") || "none"}`,
450
+ output: `Skill "${name}" not found. Available skills: ${Object.keys(skills).sort(compareCodePoints).join(", ") || "none"}`,
362
451
  isError: true,
363
452
  }
364
453
  }
@@ -409,10 +498,15 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
409
498
  return {
410
499
  skills,
411
500
  tool,
501
+ skipped,
412
502
  prompt() {
413
503
  const described = Object.values(skills)
414
504
  .filter((s) => s.description !== undefined)
415
- .sort((a, b) => a.name.localeCompare(b.name))
505
+ // Unicode CODE POINT over the skill name, never `localeCompare` (addendum A22): a
506
+ // locale collation orders by the machine's ICU data, so the same build emitted a
507
+ // DIFFERENTLY ORDERED prompt on two machines. SPEC §0.10 pins this prompt as
508
+ // byte-identical across ports; it was not even stable within js.
509
+ .sort((a, b) => compareCodePoints(a.name, b.name))
416
510
  if (described.length === 0) return "No skills are currently available."
417
511
  return [
418
512
  SKILLS_PROMPT_PREAMBLE,
package/src/types.ts CHANGED
@@ -60,6 +60,28 @@ export interface Answer {
60
60
  reason?: "declined" | "cancelled" | "expired"
61
61
  }
62
62
 
63
+ /**
64
+ * Typed constructor for the common resume payload: "here is the tool's output" (ADR 0026 D2).
65
+ * Hand-building `{ id, ok: true, data: { output } }` is where hosts get the key wrong; this
66
+ * removes the key from the host's hands entirely.
67
+ *
68
+ * `output` MUST be a string. A non-string is an ERROR, never a silent degrade to `""` — a
69
+ * fabricated empty result is indistinguishable from an answer the human never gave.
70
+ */
71
+ export function answerOutput(id: string, output: string): Answer {
72
+ if (typeof output !== "string") {
73
+ throw new TypeError(`answerOutput: output must be a string, got ${output === null ? "null" : typeof output}`)
74
+ }
75
+ if (!id) throw new TypeError("answerOutput: id is required (it must match the Request's id)")
76
+ return { id, ok: true, data: { output } }
77
+ }
78
+
79
+ /** The matching refusal: `ok:false` with an advisory reason the loop does not branch on. */
80
+ export function answerDeclined(id: string, reason: Answer["reason"] = "declined"): Answer {
81
+ if (!id) throw new TypeError("answerDeclined: id is required (it must match the Request's id)")
82
+ return { id, ok: false, reason }
83
+ }
84
+
63
85
  let _pendingSeq = 0
64
86
  /** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
65
87
  export function pending(request: Omit<Request, "id"> & { id?: string }): ToolResult {