@bolloon/bolloon-agent 0.4.26 → 0.4.27

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,473 @@
1
+ /**
2
+ * resource-contract.ts — 可执行资源契约 (Phase 2, 2026-09-18)
3
+ *
4
+ * 为什么需要: 之前的"买到资源"只是拿到一段**内容**; leo 的 Phase 2 要求买到的是**可执行资源** —
5
+ * 卖方必须声明: 输入要什么、输出长什么样、怎么执行、执行需要哪些工具、多久算超时、
6
+ * 验真看哪些字段/证据字段, 以及**保证什么/不保证什么** (不许把"schema 通过"吹成"生意成功")。
7
+ *
8
+ * 契约写进 SKILL.md frontmatter:
9
+ * ```yaml
10
+ * resource:
11
+ * inputSchema: {type: object, required: [product], properties: {...}}
12
+ * outputSchema: {type: object, required: [summary, sources] ...}
13
+ * execution: {entrypoint: run.mjs, requiredTools: [read_file], maxDurationMs: 60000}
14
+ * verification: {requiredFields: [summary, sources], evidenceFields: [sources]}
15
+ * guarantees: [schema_valid, source_declared, content_hash_bound]
16
+ * doesNotGuarantee: [business_success, market_profit]
17
+ * ```
18
+ *
19
+ * 三条硬规则:
20
+ * ① 输入不合 inputSchema → **不执行、不付款** (协议层拒绝, 不是"执行后报错")
21
+ * ② 输出不合 outputSchema / 缺证据字段 → 交易**不能** verified (只能 verification_failed)
22
+ * ③ 资源执行失败 ≠ 资源可用; 买到资源但没改善 Goal → 不计入 Goal 成功证据
23
+ */
24
+ import * as crypto from 'crypto';
25
+ import * as fs from 'fs';
26
+ import * as fsp from 'fs/promises';
27
+ import * as path from 'path';
28
+ import { pathToFileURL } from 'url';
29
+ import { sha256Hex } from './paid-info-protocol.js';
30
+ const CAPABILITY_WORDS = ['schema_valid', 'source_declared', 'content_hash_bound', 'executable', 'deterministic_entrypoint'];
31
+ /**
32
+ * 从 SKILL.md frontmatter 解析资源契约。
33
+ * 兼容两种写法: `resource: {...}` 或直接平铺在 frontmatter 顶层。
34
+ * 缺 name/version 视为**不是**可执行资源 (普通技能照旧可用)。
35
+ */
36
+ export function parseResourceContract(frontmatter, opts = {}) {
37
+ const fm = frontmatter || {};
38
+ const raw = fm.resource && typeof fm.resource === 'object' ? fm.resource : fm;
39
+ const issues = [];
40
+ const name = String(raw?.name || opts.skillName || fm.name || '').trim();
41
+ const version = String(raw?.version || opts.skillVersion || fm.version || '').trim();
42
+ const hasAnyContractField = !!(raw?.inputSchema || raw?.outputSchema || raw?.execution || raw?.verification || raw?.guarantees || raw?.doesNotGuarantee);
43
+ if (!hasAnyContractField)
44
+ return { ok: false, issues: ['没有资源契约字段 (inputSchema/outputSchema/execution/verification/guarantees)'] };
45
+ if (!name)
46
+ issues.push('缺少 name');
47
+ if (!version)
48
+ issues.push('缺少 version');
49
+ if (raw?.guarantees !== undefined && !Array.isArray(raw.guarantees))
50
+ issues.push('guarantees 必须是数组');
51
+ if (raw?.doesNotGuarantee !== undefined && !Array.isArray(raw.doesNotGuarantee))
52
+ issues.push('doesNotGuarantee 必须是数组');
53
+ const exec = raw?.execution || {};
54
+ if (exec.maxDurationMs !== undefined && (!Number.isFinite(Number(exec.maxDurationMs)) || Number(exec.maxDurationMs) <= 0))
55
+ issues.push('execution.maxDurationMs 必须是正数');
56
+ if (exec.requiredTools !== undefined && !Array.isArray(exec.requiredTools))
57
+ issues.push('execution.requiredTools 必须是数组');
58
+ if (exec.entrypoint !== undefined && typeof exec.entrypoint !== 'string')
59
+ issues.push('execution.entrypoint 必须是字符串');
60
+ // 可执行资源必须声明"不保证什么" —— 否则就是把"能跑"吹成"能赚"
61
+ if (Array.isArray(raw?.guarantees) && raw.guarantees.length > 0 && (!Array.isArray(raw?.doesNotGuarantee) || raw.doesNotGuarantee.length === 0)) {
62
+ issues.push('声明了 guarantees 就必须声明 doesNotGuarantee (不许把能力边界说满)');
63
+ }
64
+ if (issues.length)
65
+ return { ok: false, issues };
66
+ const contract = {
67
+ name,
68
+ version,
69
+ inputSchema: raw?.inputSchema,
70
+ outputSchema: raw?.outputSchema,
71
+ execution: {
72
+ entrypoint: exec.entrypoint,
73
+ requiredTools: Array.isArray(exec.requiredTools) ? exec.requiredTools.map(String) : [],
74
+ maxDurationMs: exec.maxDurationMs !== undefined ? Number(exec.maxDurationMs) : 60_000,
75
+ kind: exec.entrypoint ? 'js-module' : (exec.kind || 'declared'),
76
+ },
77
+ verification: {
78
+ requiredFields: Array.isArray(raw?.verification?.requiredFields) ? raw.verification.requiredFields.map(String) : [],
79
+ evidenceFields: Array.isArray(raw?.verification?.evidenceFields) ? raw.verification.evidenceFields.map(String) : [],
80
+ },
81
+ guarantees: Array.isArray(raw?.guarantees) ? raw.guarantees.map(String).filter((g) => CAPABILITY_WORDS.includes(g) || g.length > 0) : [],
82
+ doesNotGuarantee: Array.isArray(raw?.doesNotGuarantee) ? raw.doesNotGuarantee.map(String) : [],
83
+ };
84
+ return { ok: true, contract, issues: [] };
85
+ }
86
+ // ── 受限 JSON Schema 校验 (确定性, 无依赖) ─────────────────────────────────
87
+ export function validateJsonSchema(schema, value, at = '$') {
88
+ if (!schema)
89
+ return [];
90
+ const out = [];
91
+ const type = schema.type;
92
+ if (type) {
93
+ const t = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
94
+ const ok = type === 'integer' ? (t === 'number' && Number.isInteger(value)) : (t === type);
95
+ if (!ok) {
96
+ out.push(`${at} 类型应为 ${type}, 实际 ${t}`);
97
+ return out;
98
+ }
99
+ }
100
+ if (schema.enum && !schema.enum.includes(value))
101
+ out.push(`${at} 取值不在 enum 里 (${JSON.stringify(schema.enum)})`);
102
+ if (typeof value === 'number') {
103
+ if (schema.minimum !== undefined && value < schema.minimum)
104
+ out.push(`${at} 小于最小值 ${schema.minimum}`);
105
+ if (schema.maximum !== undefined && value > schema.maximum)
106
+ out.push(`${at} 大于最大值 ${schema.maximum}`);
107
+ }
108
+ if (typeof value === 'string') {
109
+ if (schema.minLength !== undefined && value.length < schema.minLength)
110
+ out.push(`${at} 长度小于 ${schema.minLength}`);
111
+ if (schema.maxLength !== undefined && value.length > schema.maxLength)
112
+ out.push(`${at} 长度大于 ${schema.maxLength}`);
113
+ if (schema.pattern && !new RegExp(schema.pattern).test(value))
114
+ out.push(`${at} 不匹配 pattern ${schema.pattern}`);
115
+ }
116
+ if (Array.isArray(value) && schema.items) {
117
+ value.forEach((v, i) => out.push(...validateJsonSchema(schema.items, v, `${at}[${i}]`)));
118
+ }
119
+ if (type === 'object' && value && typeof value === 'object' && !Array.isArray(value)) {
120
+ const obj = value;
121
+ for (const req of schema.required || []) {
122
+ if (obj[req] === undefined || obj[req] === null)
123
+ out.push(`${at}.${req} 缺失 (required)`);
124
+ }
125
+ for (const [k, sub] of Object.entries(schema.properties || {})) {
126
+ if (obj[k] !== undefined)
127
+ out.push(...validateJsonSchema(sub, obj[k], `${at}.${k}`));
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ /** ① 输入校验: 不合 schema 就不该执行 (更不该付款) */
133
+ export function validateResourceInput(contract, input) {
134
+ const issues = validateJsonSchema(contract.inputSchema, input);
135
+ return { ok: issues.length === 0, issues };
136
+ }
137
+ /** ② 输出校验: schema + 必填字段 + 证据字段 (缺证据只能算"没验真") */
138
+ export function validateResourceOutput(contract, output) {
139
+ const issues = validateJsonSchema(contract.outputSchema, output);
140
+ const obj = (output && typeof output === 'object' && !Array.isArray(output)) ? output : {};
141
+ const missingFields = (contract.verification.requiredFields || []).filter((f) => obj[f] === undefined || obj[f] === null);
142
+ const missingEvidence = (contract.verification.evidenceFields || []).filter((f) => {
143
+ const v = obj[f];
144
+ if (v === undefined || v === null)
145
+ return true;
146
+ if (Array.isArray(v))
147
+ return v.length === 0;
148
+ if (typeof v === 'string')
149
+ return v.trim().length === 0;
150
+ return false;
151
+ });
152
+ return { ok: issues.length === 0 && missingFields.length === 0 && missingEvidence.length === 0, issues, missingFields, missingEvidence };
153
+ }
154
+ /**
155
+ * 在契约与 Harness 约束下执行资源。
156
+ * - 输入不合 inputSchema → 直接返回 ok:false (不执行)
157
+ * - requiredTools 超出允许清单 → 拒绝执行 (不静默降级)
158
+ * - 超时 → 判定失败 (不留"可能跑完了"的模糊态)
159
+ * - 输出不合 outputSchema / 缺证据字段 → schemaOk/sourceDeclared 如实置 false
160
+ */
161
+ export async function executeContractSkill(opts) {
162
+ const { contract, skillDir, input } = opts;
163
+ const issues = [];
164
+ const inChk = validateResourceInput(contract, input);
165
+ if (!inChk.ok) {
166
+ return {
167
+ issues: inChk.issues,
168
+ execution: { ok: false, tool: 'skill_exec', reason: `输入不符合 inputSchema: ${inChk.issues[0]}`, schemaOk: false, sourceDeclared: false },
169
+ };
170
+ }
171
+ const required = contract.execution.requiredTools || [];
172
+ if (opts.allowedTools && required.some((t) => !opts.allowedTools.includes(t))) {
173
+ const bad = required.filter((t) => !opts.allowedTools.includes(t));
174
+ return {
175
+ issues: [`requiredTools 超出允许清单: ${bad.join(', ')}`],
176
+ execution: { ok: false, tool: 'skill_exec', reason: `执行需要未获准的工具: ${bad.join(', ')}`, schemaOk: false, sourceDeclared: false },
177
+ };
178
+ }
179
+ const entry = contract.execution.entrypoint;
180
+ if (!entry) {
181
+ return {
182
+ issues: ['契约没有 execution.entrypoint (声明式资源: 由宿主实现, 不能在这里真跑)'],
183
+ execution: { ok: false, tool: 'skill_exec', reason: '没有可执行入口', schemaOk: false, sourceDeclared: false },
184
+ };
185
+ }
186
+ if (!opts.allowCodeExecution) {
187
+ return {
188
+ issues: ['未显式同意执行下载来的代码 (allowCodeExecution=false)'],
189
+ execution: { ok: false, tool: 'skill_exec', reason: '宿主未同意执行资源代码', schemaOk: false, sourceDeclared: false },
190
+ };
191
+ }
192
+ const entryPath = path.resolve(skillDir, entry);
193
+ if (!entryPath.startsWith(path.resolve(skillDir) + path.sep)) {
194
+ return {
195
+ issues: [`entrypoint 越出技能目录: ${entry}`],
196
+ execution: { ok: false, tool: 'skill_exec', reason: 'entrypoint 越出技能目录 (拒绝加载)', schemaOk: false, sourceDeclared: false },
197
+ };
198
+ }
199
+ if (!fs.existsSync(entryPath)) {
200
+ return {
201
+ issues: [`entrypoint 不存在: ${entry}`],
202
+ execution: { ok: false, tool: 'skill_exec', reason: '入口文件不存在', schemaOk: false, sourceDeclared: false },
203
+ };
204
+ }
205
+ const maxMs = opts.maxDurationMs ?? contract.execution.maxDurationMs ?? 60_000;
206
+ const startedAt = new Date().toISOString();
207
+ const t0 = Date.now();
208
+ let mod;
209
+ try {
210
+ mod = await import(pathToFileURL(entryPath).href);
211
+ }
212
+ catch (err) {
213
+ return {
214
+ issues: [`入口加载失败: ${String(err?.message || err).slice(0, 160)}`],
215
+ execution: { ok: false, tool: 'skill_exec', startedAt, durationMs: Date.now() - t0, reason: `入口加载失败: ${String(err?.message || err).slice(0, 120)}`, schemaOk: false, sourceDeclared: false },
216
+ };
217
+ }
218
+ const fn = mod?.execute || mod?.default?.execute || (typeof mod?.default === 'function' ? mod.default : undefined);
219
+ if (typeof fn !== 'function') {
220
+ return {
221
+ issues: ['入口没有导出 execute(params, ctx)'],
222
+ execution: { ok: false, tool: 'skill_exec', startedAt, durationMs: Date.now() - t0, reason: '入口未导出 execute', schemaOk: false, sourceDeclared: false },
223
+ };
224
+ }
225
+ let timer = null;
226
+ let raw;
227
+ try {
228
+ const toolsUsed = [];
229
+ raw = await Promise.race([
230
+ Promise.resolve(fn(input, { skillDir, contract, toolsUsed, tool: (name, args) => {
231
+ if (opts.allowedTools && !opts.allowedTools.includes(name))
232
+ throw new Error(`工具 ${name} 未获准`);
233
+ toolsUsed.push(name);
234
+ return args;
235
+ } })),
236
+ new Promise((_r, rej) => { timer = setTimeout(() => rej(new Error(`执行超时 (>${maxMs}ms)`)), maxMs); }),
237
+ ]);
238
+ }
239
+ catch (err) {
240
+ if (timer)
241
+ clearTimeout(timer);
242
+ const msg = String(err?.message || err).slice(0, 200);
243
+ return {
244
+ issues: [`执行失败: ${msg}`],
245
+ execution: { ok: false, tool: 'skill_exec', startedAt, durationMs: Date.now() - t0, reason: msg, schemaOk: false, sourceDeclared: false },
246
+ };
247
+ }
248
+ if (timer)
249
+ clearTimeout(timer);
250
+ const durationMs = Date.now() - t0;
251
+ const output = typeof raw === 'string' ? safeJson(raw) ?? raw : raw;
252
+ const outChk = validateResourceOutput(contract, output);
253
+ const outputHash = sha256Hex(typeof raw === 'string' ? raw : JSON.stringify(raw ?? null));
254
+ const evidence = {
255
+ ok: outChk.ok,
256
+ tool: 'skill_exec',
257
+ startedAt,
258
+ durationMs,
259
+ outputHash,
260
+ schemaOk: outChk.issues.length === 0 && outChk.missingFields.length === 0,
261
+ sourceDeclared: outChk.missingEvidence.length === 0,
262
+ reason: outChk.ok ? undefined : `输出不达标: ${[...outChk.issues.slice(0, 2), ...outChk.missingFields.map((f) => `缺字段 ${f}`), ...outChk.missingEvidence.map((f) => `缺证据 ${f}`)].join('; ')}`,
263
+ };
264
+ return { execution: evidence, output, rawOutput: typeof raw === 'string' ? raw : undefined, issues: outChk.ok ? [] : [String(evidence.reason)] };
265
+ }
266
+ function safeJson(s) {
267
+ try {
268
+ return JSON.parse(s);
269
+ }
270
+ catch {
271
+ return null;
272
+ }
273
+ }
274
+ /** 购买后固定快照: 版本 + 内容哈希 + 契约哈希 (漂移就能检出) */
275
+ export async function buildResourceSnapshot(skillDir, contract, source) {
276
+ const { hashSkillDir } = await import('../skills-manager.js');
277
+ const h = await hashSkillDir(skillDir);
278
+ return {
279
+ name: contract.name,
280
+ version: contract.version,
281
+ contentHash: h.hash,
282
+ fileCount: h.fileCount,
283
+ bytes: h.bytes,
284
+ source,
285
+ resolvedAt: new Date().toISOString(),
286
+ contractHash: sha256Hex(JSON.stringify(contract)),
287
+ };
288
+ }
289
+ /**
290
+ * 交易的 itemId / contentHash / providerDid 必须与**实际拿到的技能**一致
291
+ * (否则"买到的是可执行资源"就是空话: 可能买到的是另一份东西)。
292
+ */
293
+ export function verifyResourceAgainstTransaction(rec, snapshot, expected) {
294
+ const issues = [];
295
+ if (expected.itemId && rec.itemId && String(rec.itemId) !== String(expected.itemId))
296
+ issues.push(`交易 itemId (${rec.itemId}) 与预期 (${expected.itemId}) 不一致`);
297
+ if (expected.providerDid && rec.providerDid && String(rec.providerDid) !== String(expected.providerDid))
298
+ issues.push(`交易 providerDid 与预期不一致`);
299
+ if (expected.version && snapshot.version !== expected.version)
300
+ issues.push(`技能版本 (${snapshot.version}) 与预期 (${expected.version}) 不一致`);
301
+ // 内容绑定**不在这里**用两种口径硬比: 交易里的 contentHash 是协议哈希 (sha256:<hex> of content),
302
+ // snapshot.contentHash 是技能目录哈希。两者的关系由 verifyInstallFidelity() 做检查链证明。
303
+ return { ok: issues.length === 0, issues };
304
+ }
305
+ /** 执行前漂移检查: 快照内容哈希必须和当前盘上一致 */
306
+ export async function checkResourceDrift(skillDir, snapshot) {
307
+ const { hashSkillDir } = await import('../skills-manager.js');
308
+ const h = await hashSkillDir(skillDir);
309
+ if (h.hash !== snapshot.contentHash) {
310
+ return { ok: false, issues: [`技能已漂移: 快照 ${snapshot.contentHash.slice(0, 12)}… ≠ 当前 ${h.hash.slice(0, 12)}… (购买时的内容不是现在这份)`] };
311
+ }
312
+ return { ok: true, issues: [] };
313
+ }
314
+ // ── 安装保真: "买到的是这份技能" 的**可检查链** ────────────────────────────
315
+ /**
316
+ * 技能包文件集的确定性哈希 —— 与 `hashSkillDir` 用**同一算法** (相对路径 + \0 + 文件内容 sha256)。
317
+ * 这样"包里的文件"和"盘上装出来的目录"才可比。
318
+ */
319
+ export function hashBundleFiles(files) {
320
+ const h = crypto.createHash('sha256');
321
+ for (const rel of dfsOrder(Object.keys(files))) {
322
+ h.update(rel);
323
+ h.update('\0');
324
+ h.update(crypto.createHash('sha256').update(Buffer.from(String(files[rel]), 'utf8')).digest());
325
+ }
326
+ return h.digest('hex').slice(0, 32);
327
+ }
328
+ /**
329
+ * 复刻 `hashSkillDir` 的遍历顺序: 每层 `localeCompare` 排序的 DFS (目录在其兄弟位置被递归进去)。
330
+ * 顺序必须一致, 否则同样内容会算出不同哈希 (真跑抓到过: 默认 sort() 与 localeCompare 对
331
+ * 'SKILL.md' vs 'run.mjs' 给出相反顺序)。
332
+ */
333
+ export function dfsOrder(keys) {
334
+ const tree = new Map();
335
+ const ensure = (p) => { if (!tree.has(p))
336
+ tree.set(p, { files: new Set(), dirs: new Set() }); return tree.get(p); };
337
+ ensure('');
338
+ for (const k of keys) {
339
+ const parts = String(k).split('/');
340
+ let cur = '';
341
+ for (let i = 0; i < parts.length; i++) {
342
+ const node = ensure(cur);
343
+ const isLast = i === parts.length - 1;
344
+ if (isLast)
345
+ node.files.add(parts[i]);
346
+ else {
347
+ node.dirs.add(parts[i]);
348
+ cur = cur ? `${cur}/${parts[i]}` : parts[i];
349
+ }
350
+ }
351
+ }
352
+ const out = [];
353
+ const walk = (prefix) => {
354
+ const node = tree.get(prefix);
355
+ const entries = [...node.dirs, ...node.files].sort((a, b) => a.localeCompare(b));
356
+ for (const e of entries) {
357
+ const rel = prefix ? `${prefix}/${e}` : e;
358
+ if (node.dirs.has(e))
359
+ walk(rel);
360
+ else
361
+ out.push(rel);
362
+ }
363
+ };
364
+ walk('');
365
+ return out;
366
+ }
367
+ /**
368
+ * 买到 → 装上 的保真检查:
369
+ * ① 手里这份 content 重算协议哈希 == 交易记录 contentHash (证明:**就是这份内容**, 没被换过)
370
+ * ② 解包后落盘的目录哈希 == 包内文件集哈希 (证明:**装的时候没丢没加**)
371
+ * 两句都成立, 才能说"买到的可执行资源 = 现在能执行的这份"。
372
+ */
373
+ export async function verifyInstallFidelity(input) {
374
+ const issues = [];
375
+ const recomputed = `sha256:${sha256Hex(input.content)}`;
376
+ const contentHashOk = !!input.rec.contentHash && recomputed === input.rec.contentHash;
377
+ if (!contentHashOk)
378
+ issues.push(`内容重算哈希 (${recomputed.slice(0, 20)}…) 与交易记录 (${String(input.rec.contentHash).slice(0, 20)}…) 不一致 — 手里这份不是当时交付的那份`);
379
+ let installLossless = false;
380
+ try {
381
+ const { hashSkillDir } = await import('../skills-manager.js');
382
+ const { parseSkillBundle } = await import('../skill-share.js');
383
+ const parsed = parseSkillBundle(input.content);
384
+ if (!parsed.ok || !parsed.bundle) {
385
+ issues.push(`技能包解析失败: ${parsed.error || 'unknown'}`);
386
+ }
387
+ else {
388
+ const bundleHash = hashBundleFiles(parsed.bundle.files);
389
+ const dirHash = (await hashSkillDir(input.installDir)).hash;
390
+ installLossless = bundleHash === dirHash;
391
+ if (!installLossless)
392
+ issues.push(`解包落盘后内容变了: 包内 ${bundleHash.slice(0, 12)}… ≠ 目录 ${dirHash.slice(0, 12)}… (装的过程丢/加了东西)`);
393
+ }
394
+ }
395
+ catch (err) {
396
+ issues.push(`保真检查失败: ${String(err?.message || err).slice(0, 120)}`);
397
+ }
398
+ return { ok: contentHashOk && installLossless, issues, contentHashOk, installLossless };
399
+ }
400
+ /** 从技能目录读 SKILL.md → 解析 frontmatter → 资源契约 */
401
+ export async function loadResourceContract(skillDir) {
402
+ const skillFile = path.join(skillDir, 'SKILL.md');
403
+ let text;
404
+ try {
405
+ text = await fsp.readFile(skillFile, 'utf8');
406
+ }
407
+ catch {
408
+ return { ok: false, dir: skillDir, issues: [`读不到 ${skillFile}`] };
409
+ }
410
+ const fm = parseFrontmatter(text);
411
+ const r = parseResourceContract(fm.data, { skillName: path.basename(skillDir) });
412
+ return { ok: r.ok, dir: skillDir, contract: r.contract, issues: r.issues, raw: text };
413
+ }
414
+ /** 极简 YAML frontmatter 解析 (够用: 标量 / 数组 / 一层嵌套对象 / 内联 JSON) */
415
+ export function parseFrontmatter(text) {
416
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(String(text || ''));
417
+ if (!m)
418
+ return { data: {}, body: String(text || '') };
419
+ const body = String(text).slice(m[0].length);
420
+ const data = {};
421
+ const lines = m[1].split(/\r?\n/);
422
+ let curKey = null;
423
+ let curIndent = 0;
424
+ for (const rawLine of lines) {
425
+ if (!rawLine.trim() || rawLine.trim().startsWith('#'))
426
+ continue;
427
+ const indent = rawLine.match(/^\s*/)[0].length;
428
+ const line = rawLine.trim();
429
+ const kv = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(line);
430
+ if (!kv)
431
+ continue;
432
+ const [, key, rest] = kv;
433
+ if (rest === '') {
434
+ curKey = key;
435
+ curIndent = indent;
436
+ data[key] = {};
437
+ continue;
438
+ }
439
+ const value = parseScalar(rest);
440
+ if (curKey && indent > curIndent) {
441
+ if (typeof data[curKey] === 'object' && data[curKey] !== null)
442
+ data[curKey][key] = value;
443
+ else
444
+ data[curKey] = { [key]: value };
445
+ }
446
+ else {
447
+ data[key] = value;
448
+ curKey = null;
449
+ }
450
+ }
451
+ return { data, body };
452
+ }
453
+ function parseScalar(rest) {
454
+ const v = rest.trim();
455
+ if (v.startsWith('{') || v.startsWith('[')) {
456
+ try {
457
+ return JSON.parse(v);
458
+ }
459
+ catch { /* fallthrough */ }
460
+ }
461
+ if (v === 'true')
462
+ return true;
463
+ if (v === 'false')
464
+ return false;
465
+ if (/^-?\d+(\.\d+)?$/.test(v))
466
+ return Number(v);
467
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
468
+ return v.slice(1, -1);
469
+ if (v.startsWith('[') && v.endsWith(']')) {
470
+ return v.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
471
+ }
472
+ return v;
473
+ }