@coinrithm/mcp-trading 0.1.7 → 0.2.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,490 @@
1
+ // The agent resolver: compile a single agent.md OR a decomposed agent FOLDER
2
+ // into one deterministic { rawFrontmatter, mergedProse, provenance, hashes }.
3
+ //
4
+ // Design (Codex audit, adopted):
5
+ // - Single FILE input -> passthrough: frontmatter + body, exactly like a
6
+ // legacy SKILL.md. No $ref/include resolution.
7
+ // - DIRECTORY input -> full resolve: a keystone (agent.md | SKILL.md) whose
8
+ // config blocks may be inline OR a { $ref: "<path>" } to a part-file, plus
9
+ // an optional include[] of tactic skills.
10
+ // - FAIL-CLOSED on: missing keystone, missing $ref, path traversal, symlink,
11
+ // $ref cycle, invalid YAML, a tactic that widens a hard cap, and ANY
12
+ // secret found in merged frontmatter OR merged prose. All issues are
13
+ // collected, then thrown together.
14
+ // - The machine-read config and the prose the LLM reads never cross: only
15
+ // YAML/frontmatter feeds rawFrontmatter; only markdown bodies feed
16
+ // mergedProse.
17
+ import { readFileSync, existsSync, statSync, lstatSync } from "node:fs";
18
+ import { resolve as resolvePath, relative as relativePath, join, isAbsolute } from "node:path";
19
+ import { parse as parseYaml } from "yaml";
20
+ import { parseFrontmatter } from "./frontmatter.js";
21
+ import { sha256, toPosix, isPathInside, boundTail, scanForSecrets, } from "./util.js";
22
+ import { mergeCapPatch, RISK_CAPS, LIMIT_CAPS } from "./mergeRules.js";
23
+ export class ResolveError extends Error {
24
+ issues;
25
+ constructor(issues) {
26
+ super("agent resolve failed:\n" +
27
+ issues.map((i) => ` [${i.code}] ${i.message}`).join("\n"));
28
+ this.name = "ResolveError";
29
+ this.issues = issues;
30
+ }
31
+ }
32
+ const KEYSTONE_NAMES = ["agent.md", "SKILL.md"]; // SKILL.md kept as an alias
33
+ const CONFIG_BLOCKS = [
34
+ "trigger",
35
+ "model",
36
+ "venues",
37
+ "risk",
38
+ "sizing",
39
+ "limits",
40
+ "abstention",
41
+ "sync",
42
+ "killSwitch",
43
+ "objective",
44
+ "capabilities",
45
+ ];
46
+ const IDENTITY_KEYS = ["name", "description", "spec", "mode"];
47
+ const JOURNAL_MAX_LINES = 200;
48
+ const JOURNAL_MAX_BYTES = 8_000;
49
+ // Optional prose files (markdown the LLM reads), in assembly order.
50
+ const PROSE_FILES = ["character/thesis.md", "character/persona.md"];
51
+ // Enforced cap field names. sizing.yaml is SOFT guidance and must NOT contain
52
+ // any of these (or a user could think a limit binds when it does not).
53
+ const ENFORCED_FIELD_NAMES = new Set([
54
+ ...Object.keys(RISK_CAPS),
55
+ ...Object.keys(LIMIT_CAPS),
56
+ "maxDrawdownMusd",
57
+ "maxConsecutiveRejects",
58
+ "maxConsecutiveModelFailures",
59
+ "onRateLimitPressure",
60
+ ]);
61
+ // A $ref must be a LOCAL, RELATIVE path inside the agent folder — never a URL,
62
+ // an absolute path, a home/drive path, or a Windows backslash path.
63
+ function refSyntaxIssue(ref) {
64
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(ref))
65
+ return "remote/URL $ref is not allowed";
66
+ if (ref.startsWith("~"))
67
+ return "home-relative (~) $ref is not allowed";
68
+ if (ref.includes("\\"))
69
+ return "backslash in $ref is not allowed (use forward slashes)";
70
+ if (isAbsolute(ref) || /^[a-zA-Z]:/.test(ref))
71
+ return "absolute $ref is not allowed";
72
+ return null;
73
+ }
74
+ function rel(dir, abs) {
75
+ return toPosix(relativePath(dir, abs));
76
+ }
77
+ // Resolve a ref relative to the agent dir, enforcing: inside the folder, exists,
78
+ // not a symlink. Returns the absolute path or null (with an issue pushed).
79
+ function safePath(ctx, ref, label) {
80
+ const syn = refSyntaxIssue(ref);
81
+ if (syn) {
82
+ ctx.issues.push({ code: "unsafe_ref", path: ref, message: `${label} "${ref}": ${syn}` });
83
+ return null;
84
+ }
85
+ const target = resolvePath(ctx.dir, ref);
86
+ if (!isPathInside(ctx.dir, target)) {
87
+ ctx.issues.push({
88
+ code: "path_traversal",
89
+ path: ref,
90
+ message: `${label} "${ref}" escapes the agent folder`,
91
+ });
92
+ return null;
93
+ }
94
+ if (!existsSync(target)) {
95
+ ctx.issues.push({
96
+ code: "missing_ref",
97
+ path: ref,
98
+ message: `${label} target not found: "${ref}"`,
99
+ });
100
+ return null;
101
+ }
102
+ if (lstatSync(target).isSymbolicLink()) {
103
+ ctx.issues.push({
104
+ code: "symlink_rejected",
105
+ path: ref,
106
+ message: `${label} "${ref}" is a symlink (not allowed)`,
107
+ });
108
+ return null;
109
+ }
110
+ return target;
111
+ }
112
+ function readHashed(ctx, abs) {
113
+ const content = readFileSync(abs, "utf8");
114
+ const r = rel(ctx.dir, abs);
115
+ const lower = r.toLowerCase();
116
+ const prior = ctx.seenLower.get(lower);
117
+ if (prior && prior !== r) {
118
+ ctx.issues.push({
119
+ code: "path_case_collision",
120
+ path: r,
121
+ message: `"${r}" differs only by case from "${prior}" — ambiguous on case-insensitive filesystems`,
122
+ });
123
+ }
124
+ ctx.seenLower.set(lower, r);
125
+ ctx.hashes[r] = sha256(content);
126
+ if (!ctx.mergeOrder.includes(r))
127
+ ctx.mergeOrder.push(r);
128
+ return content;
129
+ }
130
+ // sizing.yaml is SOFT guidance: it must never carry an enforced cap name, or a
131
+ // user could believe a limit binds when only risk/limits/killSwitch actually do.
132
+ function checkSizing(ctx, rawFrontmatter) {
133
+ const sizing = rawFrontmatter.sizing;
134
+ if (sizing && typeof sizing === "object" && !Array.isArray(sizing)) {
135
+ for (const k of Object.keys(sizing)) {
136
+ if (ENFORCED_FIELD_NAMES.has(k)) {
137
+ ctx.issues.push({
138
+ code: "sizing_enforced_key",
139
+ path: "sizing",
140
+ message: `sizing is SOFT guidance and may not contain the enforced cap "${k}" — move it to risk/limits/killSwitch`,
141
+ });
142
+ }
143
+ }
144
+ }
145
+ }
146
+ function parseYamlSafe(ctx, content, label) {
147
+ try {
148
+ return parseYaml(content);
149
+ }
150
+ catch (err) {
151
+ ctx.issues.push({
152
+ code: "invalid_yaml",
153
+ path: label,
154
+ message: `invalid YAML in "${label}": ${err instanceof Error ? err.message : String(err)}`,
155
+ });
156
+ return undefined;
157
+ }
158
+ }
159
+ function isRef(value) {
160
+ return (!!value &&
161
+ typeof value === "object" &&
162
+ !Array.isArray(value) &&
163
+ Object.keys(value).length === 1 &&
164
+ typeof value.$ref === "string");
165
+ }
166
+ // Resolve a config-block value: inline values pass through; a { $ref } loads the
167
+ // part-file (recursively, with cycle detection) and parses it as YAML.
168
+ function resolveValue(ctx, value, visiting) {
169
+ if (!isRef(value))
170
+ return value;
171
+ const ref = value.$ref;
172
+ const abs = safePath(ctx, ref, "$ref");
173
+ if (!abs)
174
+ return undefined;
175
+ if (visiting.has(abs)) {
176
+ ctx.issues.push({
177
+ code: "include_cycle",
178
+ path: ref,
179
+ message: `$ref cycle detected at "${ref}"`,
180
+ });
181
+ return undefined;
182
+ }
183
+ const content = readHashed(ctx, abs);
184
+ const parsed = parseYamlSafe(ctx, content, rel(ctx.dir, abs));
185
+ visiting.add(abs);
186
+ const resolved = resolveValue(ctx, parsed, visiting);
187
+ visiting.delete(abs);
188
+ return resolved;
189
+ }
190
+ function scanSecrets(ctx, frontmatter, prose) {
191
+ for (const f of scanForSecrets(frontmatter)) {
192
+ ctx.issues.push({ code: "secret_in_frontmatter", message: f });
193
+ }
194
+ for (const f of scanForSecrets(prose)) {
195
+ ctx.issues.push({
196
+ code: "secret_in_prose",
197
+ message: `${f} (a key in a prose body would be sent to the model — remove it)`,
198
+ });
199
+ }
200
+ }
201
+ // ── single-file passthrough ──────────────────────────────────────────────────
202
+ function resolveSingleFile(abs) {
203
+ const issues = [];
204
+ const dir = abs; // single file: refs are not resolved, so dir is unused
205
+ const content = readFileSync(abs, "utf8");
206
+ let data = {};
207
+ let body = "";
208
+ try {
209
+ const fm = parseFrontmatter(content);
210
+ data = fm.data;
211
+ body = fm.body;
212
+ }
213
+ catch (err) {
214
+ throw new ResolveError([
215
+ {
216
+ code: "invalid_frontmatter",
217
+ path: toPosix(abs),
218
+ message: err instanceof Error ? err.message : String(err),
219
+ },
220
+ ]);
221
+ }
222
+ const ctx = { dir, issues, hashes: {}, mergeOrder: [], seenLower: new Map() };
223
+ const r = toPosix(abs);
224
+ ctx.hashes[r] = sha256(content);
225
+ ctx.mergeOrder.push(r);
226
+ checkSizing(ctx, data);
227
+ scanSecrets(ctx, data, body);
228
+ if (issues.length)
229
+ throw new ResolveError(issues);
230
+ const sources = {};
231
+ for (const k of Object.keys(data))
232
+ sources[k] = r;
233
+ return {
234
+ inputPath: abs,
235
+ isDirectory: false,
236
+ rawFrontmatter: data,
237
+ mergedProse: body,
238
+ proseParts: [{ source: r, text: body }],
239
+ provenance: { sources, mergeOrder: ctx.mergeOrder, includeOrder: [] },
240
+ contentHashes: ctx.hashes,
241
+ };
242
+ }
243
+ // ── directory resolve ────────────────────────────────────────────────────────
244
+ function findKeystone(dir) {
245
+ for (const name of KEYSTONE_NAMES) {
246
+ const p = join(dir, name);
247
+ if (existsSync(p))
248
+ return p;
249
+ }
250
+ return null;
251
+ }
252
+ function loadSkillList(ctx, frontmatter) {
253
+ // include[] in the keystone wins; else character/skills/_index.yaml `active`.
254
+ if (Array.isArray(frontmatter.include)) {
255
+ return frontmatter.include.filter((s) => typeof s === "string");
256
+ }
257
+ const indexPath = join(ctx.dir, "character/skills/_index.yaml");
258
+ if (existsSync(indexPath)) {
259
+ const abs = safePath(ctx, "character/skills/_index.yaml", "skills index");
260
+ if (!abs)
261
+ return [];
262
+ const parsed = parseYamlSafe(ctx, readHashed(ctx, abs), "character/skills/_index.yaml");
263
+ const active = parsed?.active;
264
+ if (Array.isArray(active))
265
+ return active.filter((s) => typeof s === "string");
266
+ }
267
+ return [];
268
+ }
269
+ function resolveDirectory(dir) {
270
+ const ctx = { dir, issues: [], hashes: {}, mergeOrder: [], seenLower: new Map() };
271
+ const keystoneAbs = findKeystone(dir);
272
+ if (!keystoneAbs) {
273
+ throw new ResolveError([
274
+ {
275
+ code: "missing_keystone",
276
+ message: `agent folder has no keystone (expected one of: ${KEYSTONE_NAMES.join(", ")})`,
277
+ },
278
+ ]);
279
+ }
280
+ let frontmatter = {};
281
+ let keystoneBody = "";
282
+ try {
283
+ const fm = parseFrontmatter(readHashed(ctx, keystoneAbs));
284
+ frontmatter = fm.data;
285
+ keystoneBody = fm.body;
286
+ }
287
+ catch (err) {
288
+ throw new ResolveError([
289
+ {
290
+ code: "invalid_frontmatter",
291
+ path: rel(dir, keystoneAbs),
292
+ message: err instanceof Error ? err.message : String(err),
293
+ },
294
+ ]);
295
+ }
296
+ const keystoneRel = rel(dir, keystoneAbs);
297
+ const rawFrontmatter = {};
298
+ const sources = {};
299
+ // `extends`: a base layer of whole config blocks (e.g. runtime.yaml provides
300
+ // model + trigger). Shallow + flat — an extends file is a YAML mapping of
301
+ // config blocks; inline frontmatter overrides it block-for-block. No nesting.
302
+ const baseBlocks = {};
303
+ const baseSource = {};
304
+ if (Array.isArray(frontmatter.extends)) {
305
+ for (const ext of frontmatter.extends) {
306
+ if (typeof ext !== "string")
307
+ continue;
308
+ const abs = safePath(ctx, ext, "extends");
309
+ if (!abs)
310
+ continue;
311
+ const parsed = parseYamlSafe(ctx, readHashed(ctx, abs), ext);
312
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
313
+ ctx.issues.push({
314
+ code: "invalid_extends",
315
+ path: ext,
316
+ message: `extends file "${ext}" must be a YAML mapping of config blocks`,
317
+ });
318
+ continue;
319
+ }
320
+ const map = parsed;
321
+ if ("extends" in map) {
322
+ ctx.issues.push({
323
+ code: "nested_extends",
324
+ path: ext,
325
+ message: `extends file "${ext}" may not itself use extends`,
326
+ });
327
+ }
328
+ for (const [k, v] of Object.entries(map)) {
329
+ if (k === "extends")
330
+ continue;
331
+ baseBlocks[k] = v; // later extends wins
332
+ baseSource[k] = ext;
333
+ }
334
+ }
335
+ }
336
+ // Copy EVERY keystone key (so the strict key-lint can also flag unknown keys
337
+ // in the folder path, not just single-file); resolve $ref pointers for config
338
+ // blocks. `extends` is a directive (handled above). `include` is copied here
339
+ // AND consumed for skills below.
340
+ for (const [key, value] of Object.entries(frontmatter)) {
341
+ if (key === "extends")
342
+ continue;
343
+ if (CONFIG_BLOCKS.includes(key)) {
344
+ const wasRef = isRef(value);
345
+ rawFrontmatter[key] = resolveValue(ctx, value, new Set([keystoneAbs]));
346
+ sources[key] = wasRef
347
+ ? (() => {
348
+ const t = safePath(ctx, value.$ref, "$ref");
349
+ return t ? rel(dir, t) : keystoneRel;
350
+ })()
351
+ : keystoneRel;
352
+ }
353
+ else {
354
+ rawFrontmatter[key] = value;
355
+ sources[key] = keystoneRel;
356
+ }
357
+ }
358
+ // Fill any config block the keystone did NOT set from the extends base layer.
359
+ for (const [k, v] of Object.entries(baseBlocks)) {
360
+ if (!(k in rawFrontmatter)) {
361
+ rawFrontmatter[k] = v;
362
+ sources[k] = baseSource[k];
363
+ }
364
+ }
365
+ // Skills (tactics): bodies → prose; optional risk/limits patch → tighten-only.
366
+ const includeOrder = [];
367
+ const skillProse = [];
368
+ const seenSkills = new Set();
369
+ for (const name of loadSkillList(ctx, frontmatter)) {
370
+ if (seenSkills.has(name)) {
371
+ ctx.issues.push({
372
+ code: "duplicate_include",
373
+ path: name,
374
+ message: `skill "${name}" included more than once`,
375
+ });
376
+ continue;
377
+ }
378
+ seenSkills.add(name);
379
+ const refPath = `character/skills/${name}.md`;
380
+ const abs = safePath(ctx, refPath, "skill");
381
+ if (!abs)
382
+ continue;
383
+ let patch = {};
384
+ let body = "";
385
+ try {
386
+ const fm = parseFrontmatter(readHashed(ctx, abs));
387
+ patch = fm.data;
388
+ body = fm.body;
389
+ }
390
+ catch {
391
+ // a skill file may be pure prose (no frontmatter) — treat whole as body.
392
+ body = readFileSync(abs, "utf8");
393
+ }
394
+ includeOrder.push(name);
395
+ skillProse.push({ source: refPath, text: body });
396
+ applySkillPatch(ctx, rawFrontmatter, patch, refPath);
397
+ }
398
+ // Prose assembly (machine-read config never enters here).
399
+ const proseParts = [];
400
+ if (keystoneBody.trim())
401
+ proseParts.push({ source: keystoneRel, text: keystoneBody });
402
+ for (const pf of PROSE_FILES) {
403
+ const p = join(dir, pf);
404
+ if (existsSync(p)) {
405
+ const abs = safePath(ctx, pf, "prose");
406
+ if (abs)
407
+ proseParts.push({ source: pf, text: readHashed(ctx, abs) });
408
+ }
409
+ }
410
+ proseParts.push(...skillProse);
411
+ // Bounded memory: most-recent-N of journal/notes.md. runs/ is NEVER loaded.
412
+ const journalPath = join(dir, "journal/notes.md");
413
+ if (existsSync(journalPath)) {
414
+ const abs = safePath(ctx, "journal/notes.md", "journal");
415
+ if (abs) {
416
+ const full = readHashed(ctx, abs);
417
+ proseParts.push({
418
+ source: "journal/notes.md",
419
+ text: boundTail(full, JOURNAL_MAX_LINES, JOURNAL_MAX_BYTES),
420
+ });
421
+ }
422
+ }
423
+ const mergedProse = proseParts
424
+ .map((p) => `<!-- ${p.source} -->\n${p.text.trim()}`)
425
+ .join("\n\n");
426
+ checkSizing(ctx, rawFrontmatter);
427
+ scanSecrets(ctx, rawFrontmatter, mergedProse);
428
+ if (ctx.issues.length)
429
+ throw new ResolveError(ctx.issues);
430
+ const provenance = {
431
+ sources,
432
+ mergeOrder: ctx.mergeOrder,
433
+ includeOrder,
434
+ };
435
+ return {
436
+ inputPath: dir,
437
+ isDirectory: true,
438
+ rawFrontmatter,
439
+ mergedProse,
440
+ proseParts,
441
+ provenance,
442
+ contentHashes: ctx.hashes,
443
+ };
444
+ }
445
+ // Apply a tactic module's frontmatter patch. Only risk/limits cap blocks are
446
+ // permitted, tighten-only; anything else is rejected (no permission expansion).
447
+ function applySkillPatch(ctx, rawFrontmatter, patch, sourceLabel) {
448
+ for (const key of Object.keys(patch)) {
449
+ if (key === "risk" || key === "limits") {
450
+ const caps = key === "risk" ? RISK_CAPS : LIMIT_CAPS;
451
+ const base = rawFrontmatter[key] ?? {};
452
+ const p = patch[key];
453
+ if (!p || typeof p !== "object" || Array.isArray(p)) {
454
+ ctx.issues.push({
455
+ code: "skill_patch_invalid",
456
+ path: sourceLabel,
457
+ message: `tactic "${sourceLabel}" ${key} patch must be a mapping`,
458
+ });
459
+ continue;
460
+ }
461
+ const { merged, issues } = mergeCapPatch(base, p, caps, sourceLabel);
462
+ rawFrontmatter[key] = merged;
463
+ ctx.issues.push(...issues);
464
+ }
465
+ else {
466
+ ctx.issues.push({
467
+ code: "skill_patch_forbidden_key",
468
+ path: sourceLabel,
469
+ message: `tactic "${sourceLabel}" may only tighten risk/limits caps (include order controls prompt order, not permissions); got "${key}"`,
470
+ });
471
+ }
472
+ }
473
+ }
474
+ // ── entry point ──────────────────────────────────────────────────────────────
475
+ export function resolveAgent(inputPath) {
476
+ const abs = resolvePath(inputPath);
477
+ if (!existsSync(abs)) {
478
+ throw new ResolveError([
479
+ { code: "input_missing", message: `no such path: ${inputPath}` },
480
+ ]);
481
+ }
482
+ const st = statSync(abs);
483
+ if (st.isFile())
484
+ return resolveSingleFile(abs);
485
+ if (st.isDirectory())
486
+ return resolveDirectory(abs);
487
+ throw new ResolveError([
488
+ { code: "input_invalid", message: "input must be a file or a directory" },
489
+ ]);
490
+ }
@@ -0,0 +1,23 @@
1
+ // Run-evidence helpers: one runId per process, one decisionId per cycle, the
2
+ // agentTrace stamped on every traced call, and the export at the end.
3
+ import { shortId } from "./util.js";
4
+ export function makeRunId(spec) {
5
+ const slug = (spec.name || "agent").replace(/[^a-z0-9-]+/gi, "-").toLowerCase();
6
+ return `${slug}-${shortId()}`;
7
+ }
8
+ export function makeDecisionId(cycle) {
9
+ return `cycle-${cycle}-${shortId()}`;
10
+ }
11
+ export function makeTrace(runId, decisionId, spec, confidence, rationaleSummary) {
12
+ return {
13
+ runId,
14
+ decisionId,
15
+ strategyLabel: spec.name || "agent",
16
+ confidence,
17
+ rationaleSummary,
18
+ };
19
+ }
20
+ export async function exportRunEvidence(client, runId) {
21
+ const r = await client.exportRunEvidence(runId);
22
+ return r.ok ? r.data : { error: `run-evidence export failed (HTTP ${r.status})` };
23
+ }