@namzu/sdk 15.0.0 → 15.1.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.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The one frontmatter reader.
3
+ *
4
+ * A `SKILL.md`, a command file, and anything else this kernel reads from a
5
+ * markdown file with a `---` fence all come through here. There were three
6
+ * readers before this one and two of them disagreed on the same input: this
7
+ * one **threw** on malformed frontmatter, and a second **silently returned no
8
+ * metadata**, so the same file was a hard error in one code path and a skill
9
+ * named after its own directory with "(no description)" in the other. Refuse
10
+ * versus degrade, on one file shape, is the divergence this module exists to
11
+ * end — see `docs/conventions/refuse-do-not-degrade.md`.
12
+ *
13
+ * **Deliberately not a YAML parser.** It is a flat key/value splitter with one
14
+ * level of nesting, and it refuses the constructs in {@link UNSUPPORTED_YAML}
15
+ * rather than mangling them. That refusal is the design: a reader that
16
+ * half-understands YAML produces a value that passes validation and means
17
+ * nothing.
18
+ *
19
+ * **Known gap, stated rather than implied.** The refusal is not yet total. A
20
+ * *block* sequence
21
+ *
22
+ * ```yaml
23
+ * allowed-tools:
24
+ * - Read
25
+ * ```
26
+ *
27
+ * is silently dropped — its lines carry no `:` and are skipped — while the
28
+ * flow form `[Read, Grep]` throws. Both readers this replaced behaved that
29
+ * way, so it is inherited rather than introduced, but it contradicts
30
+ * `docs/conventions/refuse-do-not-degrade.md` and is tracked as a follow-up.
31
+ * Do not read the paragraph above as a guarantee it does not make.
32
+ *
33
+ * **Vocabulary belongs to the caller.** This returns the parsed map; it does
34
+ * not know what a skill needs or what a command needs, and it validates no
35
+ * field names. Widening one caller's metadata type to cover another's is how a
36
+ * skill-shaped API comes to mean something it does not.
37
+ */
38
+ /**
39
+ * A closing fence is a line of its own, not `---` wherever it appears.
40
+ *
41
+ * An unanchored search found `---` inside a quoted value, inside a URL, inside
42
+ * prose — and cut the frontmatter there, which both truncated the metadata AND
43
+ * spilled the rest of it into the body, where it reaches the system prompt
44
+ * verbatim.
45
+ *
46
+ * `\r?` is explicit rather than incidental. `$` under `/m` already matches
47
+ * before a `\r` because JavaScript counts `\r` as a line terminator, so this
48
+ * pattern worked on CRLF by accident before it worked on purpose. Naming the
49
+ * carriage return keeps the next edit from removing a property nobody knew was
50
+ * being relied on — a file authored on Windows is the ordinary case, not the
51
+ * exotic one.
52
+ */
53
+ const FRONTMATTER_FENCE = /^---[ \t]*\r?$/m;
54
+ const FRONTMATTER_DELIMITER = '---';
55
+ /**
56
+ * Splits on any of the three line endings — CRLF, LF, and a lone CR.
57
+ *
58
+ * The `\r?\n` half is defence in depth and was measured as such: reducing it to
59
+ * `/\n/` fails no test, because {@link normalizeScalar} trims the stray `\r`
60
+ * off every value anyway. The fence is the load-bearing half for CRLF, and that
61
+ * one has a mutation profile.
62
+ *
63
+ * The lone `\r` is not decoration. Without it a CR-only file is one single
64
+ * "line", and the whole frontmatter collapses into the first key: `name` came
65
+ * back as `"a-skill\rdescription: d"` — a *wrong value*, silently, which is the
66
+ * failure this module exists to end.
67
+ *
68
+ * `loadSkill` was accidentally protected, though not in the way first written
69
+ * here: the collapse leaves no `description` key at all, so the required-field
70
+ * check refused the file before any value could be used. A caller that
71
+ * validates nothing — which is every caller this is now exported for — would
72
+ * have taken the mangled name.
73
+ */
74
+ const LINE_SPLIT = /\r\n|\r|\n/;
75
+ /**
76
+ * YAML this reader does not implement, refused rather than mangled.
77
+ *
78
+ * The documented contract says "YAML frontmatter" with no restriction — so an
79
+ * author has every reason to write a block scalar or a flow sequence, and no
80
+ * reason to expect what happened next. A `description: >-` followed by an
81
+ * indented paragraph produced the literal string `">-"`, which passed
82
+ * validation and registered with no warning; the skill then existed and was
83
+ * never selected, because its description said nothing. A `[Read, Grep]`
84
+ * became that literal text and was interpolated straight into the prompt.
85
+ *
86
+ * Refusing names the line and the file. That is worse for exactly one file —
87
+ * the one already silently broken — and better for everyone looking for it.
88
+ */
89
+ const UNSUPPORTED_YAML = [
90
+ { pattern: /^[>|][-+]?\s*$/, what: 'a block scalar (`>` or `|`)' },
91
+ { pattern: /^\[.*\]$/, what: 'a flow sequence (`[a, b]`)' },
92
+ { pattern: /^\{.*\}$/, what: 'a flow mapping (`{a: b}`)' },
93
+ ];
94
+ /**
95
+ * Parse a markdown file's `---` frontmatter.
96
+ *
97
+ * @param raw The file's full contents. LF and CRLF both parse.
98
+ * @param source A label for error messages — a path, or a phrase naming the
99
+ * file. Used verbatim, so the caller controls how its own errors read.
100
+ * @throws If the frontmatter is absent, unclosed, or uses YAML this reader
101
+ * does not implement. It never returns a partial or empty result to stand in
102
+ * for a file it could not read.
103
+ */
104
+ export function parseFrontmatter(raw, source) {
105
+ const trimmed = raw.trimStart();
106
+ if (!trimmed.startsWith(FRONTMATTER_DELIMITER)) {
107
+ throw new Error(`${source} has no YAML frontmatter`);
108
+ }
109
+ const closing = FRONTMATTER_FENCE.exec(trimmed.slice(FRONTMATTER_DELIMITER.length));
110
+ if (!closing) {
111
+ throw new Error(`${source} has unclosed frontmatter`);
112
+ }
113
+ const endIdx = FRONTMATTER_DELIMITER.length + closing.index;
114
+ const frontmatterRaw = trimmed.slice(FRONTMATTER_DELIMITER.length, endIdx).trim();
115
+ const body = trimmed.slice(endIdx + closing[0].length).trim();
116
+ // `Map`, not an object literal, because the keys come from an untrusted
117
+ // file. `blocks[key] = …` on a plain object with `key === '__proto__'`
118
+ // reaches `Object.prototype` through the inheritance chain and writes
119
+ // **there** — a frontmatter file could set `Object.prototype.metadata` and
120
+ // poison every object in the process. That is not theoretical: it was
121
+ // caught here by an adversarial pass, and the poisoned prototype then
122
+ // showed up in the metadata of an unrelated skill loaded afterwards.
123
+ // A `Map` has no prototype chain for string keys, and `Object.fromEntries`
124
+ // *defines* own properties rather than assigning through setters, so the
125
+ // round trip is safe at both ends.
126
+ const data = new Map();
127
+ const blocks = new Map();
128
+ let currentKey;
129
+ for (const line of frontmatterRaw.split(LINE_SPLIT)) {
130
+ if (!line.trim() || line.trimStart().startsWith('#'))
131
+ continue;
132
+ if (/^\s/.test(line)) {
133
+ if (!currentKey)
134
+ continue;
135
+ const colonIdx = line.indexOf(':');
136
+ if (colonIdx === -1)
137
+ continue;
138
+ const key = line.slice(0, colonIdx).trim();
139
+ const value = normalizeScalar(line.slice(colonIdx + 1));
140
+ if (!key || !value)
141
+ continue;
142
+ let block = blocks.get(currentKey);
143
+ if (!block) {
144
+ block = new Map();
145
+ blocks.set(currentKey, block);
146
+ }
147
+ block.set(key, value);
148
+ continue;
149
+ }
150
+ const colonIdx = line.indexOf(':');
151
+ if (colonIdx === -1)
152
+ continue;
153
+ const key = line.slice(0, colonIdx).trim();
154
+ const value = normalizeScalar(line.slice(colonIdx + 1));
155
+ assertReadableScalar(key, value, source);
156
+ currentKey = key;
157
+ if (value)
158
+ data.set(key, value);
159
+ }
160
+ // A key cannot be a scalar and a mapping at once — no YAML file can say
161
+ // that — so refusing here is what makes the illegal state unrepresentable
162
+ // in the returned type rather than merely undocumented. The alternative,
163
+ // picking a precedence, would silently drop half of what the author wrote.
164
+ for (const key of blocks.keys()) {
165
+ if (!data.has(key))
166
+ continue;
167
+ throw new Error(`${source}: "${key}" has both a value and an indented block. A key is one or the other — remove the value, or un-indent the lines beneath it.`);
168
+ }
169
+ const values = new Map();
170
+ for (const [key, value] of data) {
171
+ values.set(key, { kind: 'scalar', value });
172
+ }
173
+ for (const [key, entries] of blocks) {
174
+ values.set(key, { kind: 'mapping', entries: Object.fromEntries(entries) });
175
+ }
176
+ return { values: Object.fromEntries(values), body };
177
+ }
178
+ function normalizeScalar(value) {
179
+ return value
180
+ .trim()
181
+ .replace(/^["']|["']$/g, '')
182
+ .trim();
183
+ }
184
+ function assertReadableScalar(key, value, source) {
185
+ for (const { pattern, what } of UNSUPPORTED_YAML) {
186
+ if (!pattern.test(value))
187
+ continue;
188
+ throw new Error(`${source}: "${key}" uses ${what}, which this reader does not support. Write it as a single-line value instead. Refusing rather than accepting a "${key}" that would read as ${JSON.stringify(value)}.`);
189
+ }
190
+ }
191
+ //# sourceMappingURL=frontmatter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frontmatter.js","sourceRoot":"","sources":["../../src/utils/frontmatter.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namzu/sdk",
3
- "version": "15.0.0",
3
+ "version": "15.1.0",
4
4
  "description": "Open-source AI agent SDK with a built-in runtime. Nothing between you and your agents.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "type": "module",
@@ -142,6 +142,11 @@ export {
142
142
  resolveSkillChain,
143
143
  SkillRegistry,
144
144
  } from './skills/index.js'
145
+ // The one frontmatter reader. `loadSkill` is built on it, and a host reading
146
+ // its own markdown — a command file, a prompt template — uses the same one
147
+ // rather than hand-rolling a second that disagrees about CRLF or about whether
148
+ // a malformed file throws or quietly returns nothing.
149
+ export { parseFrontmatter } from './utils/frontmatter.js'
145
150
 
146
151
  // ─── the agent directory ─────────────────────────────────────────────────
147
152
  //
@@ -99,6 +99,7 @@ export type {
99
99
  export type { AdvisoryCallContext, AdvisoryExecutionResult } from './advisory/index.js'
100
100
 
101
101
  export type { ModelPricing } from './utils/cost.js'
102
+ export type { FrontmatterValue, ParsedFrontmatter } from './utils/frontmatter.js'
102
103
  export type { Logger } from './utils/logger.js'
103
104
  export type { ShellCompressOptions, ShellCompressResult } from './utils/shell-compress.js'
104
105
 
@@ -6,151 +6,98 @@ import type {
6
6
  SkillLoadResult,
7
7
  SkillMetadata,
8
8
  } from '../types/skills/index.js'
9
+ import { type ParsedFrontmatter, parseFrontmatter } from '../utils/frontmatter.js'
9
10
  import { getRootLogger } from '../utils/logger.js'
10
11
 
11
12
  const logger = getRootLogger().child({ component: 'SkillLoader' })
12
13
 
13
14
  const SKILL_FILENAME = 'SKILL.md'
14
- const FRONTMATTER_DELIMITER = '---'
15
15
 
16
- interface ParsedSkillMd {
17
- metadata: SkillMetadata
18
- body: string
16
+ /**
17
+ * How this file's errors name themselves. Passed to the shared reader so a
18
+ * frontmatter failure still reads as a `SKILL.md` failure — the reader is
19
+ * generic, the message is not.
20
+ */
21
+ function sourceLabel(dirPath: string): string {
22
+ return `SKILL.md at "${dirPath}"`
19
23
  }
20
24
 
21
- function parseSkillMd(raw: string, dirPath: string): ParsedSkillMd {
22
- const trimmed = raw.trimStart()
23
-
24
- if (!trimmed.startsWith(FRONTMATTER_DELIMITER)) {
25
- throw new Error(`SKILL.md at "${dirPath}" has no YAML frontmatter`)
26
- }
27
-
28
- // Anchored to a line of its own. An unanchored search found `---`
29
- // anywhere inside a quoted value, inside a URL, inside prose and cut
30
- // the frontmatter there, which both truncated the metadata AND spilled
31
- // the rest of the frontmatter into `body`, where it reaches the system
32
- // prompt verbatim.
33
- const closing = FRONTMATTER_FENCE.exec(trimmed.slice(FRONTMATTER_DELIMITER.length))
34
- if (!closing) {
35
- throw new Error(`SKILL.md at "${dirPath}" has unclosed frontmatter`)
36
- }
37
-
38
- const endIdx = FRONTMATTER_DELIMITER.length + closing.index
39
- const frontmatterRaw = trimmed.slice(FRONTMATTER_DELIMITER.length, endIdx).trim()
40
- const body = trimmed.slice(endIdx + closing[0].length).trim()
41
-
42
- const metadata = parseFlatYaml(frontmatterRaw, dirPath)
43
-
44
- return { metadata, body }
25
+ /**
26
+ * The skill vocabulary, applied to a generic parse.
27
+ *
28
+ * Splitting the file is {@link parseFrontmatter}'s job and knowing what a
29
+ * skill requires is this function's. A command file goes through the same
30
+ * reader and validates an entirely different set of keys.
31
+ */
32
+ /**
33
+ * Read one key, narrowed to the shape this field is declared to have.
34
+ *
35
+ * `Object.hasOwn`, not a bare `values[key]`: a plain property read walks the
36
+ * prototype chain, so a `Object.prototype.description` poisoned by anything
37
+ * else in the process would be picked up here as though the file had declared
38
+ * it. The parser can no longer create that poison; this is the other end of
39
+ * the same guarantee.
40
+ */
41
+ function scalarAt(values: ParsedFrontmatter['values'], key: string): string | undefined {
42
+ if (!Object.hasOwn(values, key)) return undefined
43
+ const found = values[key]
44
+ return found?.kind === 'scalar' ? found.value : undefined
45
45
  }
46
46
 
47
- function parseFlatYaml(raw: string, dirPath: string): SkillMetadata {
48
- const lines = raw.split('\n')
49
- const kv: Record<string, string> = {}
50
- const metadata: Record<string, string> = {}
51
- let section: 'metadata' | undefined
52
-
53
- for (const line of lines) {
54
- if (!line.trim() || line.trimStart().startsWith('#')) continue
55
-
56
- if (/^\s/.test(line)) {
57
- if (section !== 'metadata') continue
58
- const colonIdx = line.indexOf(':')
59
- if (colonIdx === -1) continue
60
- const key = line.slice(0, colonIdx).trim()
61
- const value = normalizeYamlScalar(line.slice(colonIdx + 1).trim())
62
- if (key && value) metadata[key] = value
63
- continue
64
- }
65
-
66
- const colonIdx = line.indexOf(':')
67
- if (colonIdx === -1) continue
68
- const key = line.slice(0, colonIdx).trim()
69
- const value = normalizeYamlScalar(line.slice(colonIdx + 1).trim())
47
+ function mappingAt(
48
+ values: ParsedFrontmatter['values'],
49
+ key: string,
50
+ ): Readonly<Record<string, string>> | undefined {
51
+ if (!Object.hasOwn(values, key)) return undefined
52
+ const found = values[key]
53
+ return found?.kind === 'mapping' ? found.entries : undefined
54
+ }
70
55
 
71
- assertReadableScalar(key, value, dirPath)
56
+ function toSkillMetadata(parsed: ParsedFrontmatter, dirPath: string): SkillMetadata {
57
+ const source = sourceLabel(dirPath)
58
+ const { values } = parsed
72
59
 
73
- section = key === 'metadata' ? 'metadata' : undefined
74
- if (value) kv[key] = value
75
- }
60
+ const name = scalarAt(values, 'name')
61
+ const description = scalarAt(values, 'description')
76
62
 
77
- if (!kv.name) {
78
- throw new Error(`SKILL.md at "${dirPath}" missing required field: name`)
63
+ if (!name) {
64
+ throw new Error(`${source} missing required field: name`)
79
65
  }
80
- if (!kv.description) {
81
- throw new Error(`SKILL.md at "${dirPath}" missing required field: description`)
66
+ if (!description) {
67
+ throw new Error(`${source} missing required field: description`)
82
68
  }
83
69
 
84
- validateSkillName(kv.name, dirPath)
85
- validateDescription(kv.description, dirPath)
70
+ validateSkillName(name, dirPath)
71
+ validateDescription(description, dirPath)
86
72
 
87
- const skillMetadata: SkillMetadata = {
88
- name: kv.name,
89
- description: kv.description,
90
- }
73
+ const skillMetadata: SkillMetadata = { name, description }
91
74
 
92
- if (kv.license) {
93
- skillMetadata.license = kv.license
75
+ const license = scalarAt(values, 'license')
76
+ if (license) {
77
+ skillMetadata.license = license
94
78
  }
95
79
 
96
- if (kv.compatibility) {
97
- if (kv.compatibility.length > 500) {
98
- throw new Error(`SKILL.md at "${dirPath}": compatibility exceeds 500 characters`)
80
+ const compatibility = scalarAt(values, 'compatibility')
81
+ if (compatibility) {
82
+ if (compatibility.length > 500) {
83
+ throw new Error(`${source}: compatibility exceeds 500 characters`)
99
84
  }
100
- skillMetadata.compatibility = kv.compatibility
85
+ skillMetadata.compatibility = compatibility
101
86
  }
102
87
 
103
- if (kv['allowed-tools']) {
104
- skillMetadata.allowedTools = kv['allowed-tools']
88
+ const allowedTools = scalarAt(values, 'allowed-tools')
89
+ if (allowedTools) {
90
+ skillMetadata.allowedTools = allowedTools
105
91
  }
106
92
 
107
- if (Object.keys(metadata).length > 0) {
108
- skillMetadata.metadata = metadata
93
+ const extra = mappingAt(values, 'metadata')
94
+ if (extra && Object.keys(extra).length > 0) {
95
+ skillMetadata.metadata = { ...extra }
109
96
  }
110
97
 
111
98
  return skillMetadata
112
99
  }
113
100
 
114
- function normalizeYamlScalar(value: string): string {
115
- return value.replace(/^["']|["']$/g, '').trim()
116
- }
117
-
118
- /** A closing fence is a line of its own, not `---` wherever it appears. */
119
- const FRONTMATTER_FENCE = /^---[ \t]*$/m
120
-
121
- /**
122
- * YAML this reader does not implement, refused rather than mangled.
123
- *
124
- * The frontmatter reader here is a flat key/value splitter, and the
125
- * documented contract says "YAML frontmatter" with no restriction — so an
126
- * author has every reason to write a block scalar or a flow sequence, and
127
- * no reason to expect what happened next. A `description: >-` followed by
128
- * an indented paragraph produced the literal string `">-"`, which passed
129
- * validation and registered with no warning; the skill then existed and
130
- * was never selected, because its description said nothing. A
131
- * `[Read, Grep]` became that literal text and was interpolated straight
132
- * into the prompt.
133
- *
134
- * Refusing names the line and the file. That is worse for exactly one
135
- * skill — the one already silently broken — and better for everyone
136
- * looking for it.
137
- */
138
- const UNSUPPORTED_YAML = [
139
- { pattern: /^[>|][-+]?\s*$/, what: 'a block scalar (`>` or `|`)' },
140
- { pattern: /^\[.*\]$/, what: 'a flow sequence (`[a, b]`)' },
141
- { pattern: /^\{.*\}$/, what: 'a flow mapping (`{a: b}`)' },
142
- ] as const
143
-
144
- function assertReadableScalar(key: string, rawValue: string, dirPath: string): void {
145
- const value = rawValue.trim()
146
- for (const { pattern, what } of UNSUPPORTED_YAML) {
147
- if (!pattern.test(value)) continue
148
- throw new Error(
149
- `SKILL.md at "${dirPath}": "${key}" uses ${what}, which this reader does not support. Write it as a single-line value instead. Refusing rather than registering a skill whose "${key}" would read as ${JSON.stringify(value)}.`,
150
- )
151
- }
152
- }
153
-
154
101
  const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
155
102
 
156
103
  function validateSkillName(name: string, dirPath: string): void {
@@ -191,10 +138,11 @@ export async function loadSkill(
191
138
  ): Promise<SkillLoadResult> {
192
139
  const skillMdPath = join(dirPath, SKILL_FILENAME)
193
140
  const raw = await readFile(skillMdPath, 'utf-8')
194
- const parsed = parseSkillMd(raw, dirPath)
141
+ const parsed = parseFrontmatter(raw, sourceLabel(dirPath))
142
+ const metadata = toSkillMetadata(parsed, dirPath)
195
143
 
196
144
  const skill: Skill = {
197
- metadata: parsed.metadata,
145
+ metadata,
198
146
  dirPath,
199
147
  }
200
148
 
@@ -202,11 +150,11 @@ export async function loadSkill(
202
150
  skill.body = parsed.body
203
151
  }
204
152
 
205
- const metadataTokens = estimateTokens(`${parsed.metadata.name}: ${parsed.metadata.description}`)
153
+ const metadataTokens = estimateTokens(`${metadata.name}: ${metadata.description}`)
206
154
  const bodyTokens = skill.body ? estimateTokens(skill.body) : 0
207
155
 
208
156
  logger.debug('Loaded skill', {
209
- name: parsed.metadata.name,
157
+ name: metadata.name,
210
158
  level,
211
159
  tokens: metadataTokens + bodyTokens,
212
160
  })