@coinrithm/mcp-trading 0.1.8 → 0.3.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +43 -19
  3. package/dist/agent/act.d.ts +4 -0
  4. package/dist/agent/act.js +114 -0
  5. package/dist/agent/capabilityGuard.d.ts +2 -0
  6. package/dist/agent/capabilityGuard.js +131 -0
  7. package/dist/agent/cli.d.ts +21 -0
  8. package/dist/agent/cli.js +382 -0
  9. package/dist/agent/client.d.ts +107 -0
  10. package/dist/agent/client.js +173 -0
  11. package/dist/agent/decision.d.ts +137 -0
  12. package/dist/agent/decision.js +118 -0
  13. package/dist/agent/decisionValidator.d.ts +16 -0
  14. package/dist/agent/decisionValidator.js +215 -0
  15. package/dist/agent/engine.d.ts +10 -0
  16. package/dist/agent/engine.js +16 -0
  17. package/dist/agent/extract.d.ts +4 -0
  18. package/dist/agent/extract.js +5 -0
  19. package/dist/agent/frontmatter.d.ts +5 -0
  20. package/dist/agent/frontmatter.js +19 -0
  21. package/dist/agent/index.d.ts +2 -0
  22. package/dist/agent/index.js +10 -0
  23. package/dist/agent/indicators.d.ts +44 -0
  24. package/dist/agent/indicators.js +135 -0
  25. package/dist/agent/manifest.d.ts +15 -0
  26. package/dist/agent/manifest.js +40 -0
  27. package/dist/agent/mergeRules.d.ts +11 -0
  28. package/dist/agent/mergeRules.js +82 -0
  29. package/dist/agent/observe.d.ts +7 -0
  30. package/dist/agent/observe.js +244 -0
  31. package/dist/agent/prompt.d.ts +3 -0
  32. package/dist/agent/prompt.js +76 -0
  33. package/dist/agent/providers.d.ts +25 -0
  34. package/dist/agent/providers.js +143 -0
  35. package/dist/agent/resolve.d.ts +11 -0
  36. package/dist/agent/resolve.js +499 -0
  37. package/dist/agent/runEvidence.d.ts +6 -0
  38. package/dist/agent/runEvidence.js +23 -0
  39. package/dist/agent/runner.d.ts +19 -0
  40. package/dist/agent/runner.js +280 -0
  41. package/dist/agent/skill.d.ts +12 -0
  42. package/dist/agent/skill.js +136 -0
  43. package/dist/agent/skillValidator.d.ts +7 -0
  44. package/dist/agent/skillValidator.js +123 -0
  45. package/dist/agent/state.d.ts +7 -0
  46. package/dist/agent/state.js +96 -0
  47. package/dist/agent/strictLint.d.ts +3 -0
  48. package/dist/agent/strictLint.js +165 -0
  49. package/dist/agent/templates.d.ts +14 -0
  50. package/dist/agent/templates.js +192 -0
  51. package/dist/agent/types.d.ts +286 -0
  52. package/dist/agent/types.js +88 -0
  53. package/dist/agent/util.d.ts +13 -0
  54. package/dist/agent/util.js +116 -0
  55. package/dist/agent/version.d.ts +11 -0
  56. package/dist/agent/version.js +16 -0
  57. package/dist/client.d.ts +162 -0
  58. package/dist/http.d.ts +2 -0
  59. package/dist/index.d.ts +2 -0
  60. package/dist/tools.d.ts +3 -0
  61. package/dist/version.d.ts +1 -0
  62. package/package.json +78 -67
@@ -0,0 +1,19 @@
1
+ import { CoinRithmClient } from "./client.js";
2
+ import { Provider } from "./providers.js";
3
+ import { AgentSpec, RunState, CycleResult } from "./types.js";
4
+ export interface RunnerDeps {
5
+ client: CoinRithmClient;
6
+ provider: Provider;
7
+ spec: AgentSpec;
8
+ mergedProse: string;
9
+ state: RunState;
10
+ live: boolean;
11
+ stateFile?: string;
12
+ log?: (line: string) => void;
13
+ }
14
+ export declare function runCycle(deps: RunnerDeps): Promise<CycleResult>;
15
+ export interface LoopOptions {
16
+ once?: boolean;
17
+ maxCycles?: number;
18
+ }
19
+ export declare function runLoop(deps: RunnerDeps, opts?: LoopOptions): Promise<CycleResult[]>;
@@ -0,0 +1,280 @@
1
+ // The execution loop: observe -> decide (BYO model) -> validate -> act across
2
+ // spot, futures, and prediction markets. Dry-run never writes. Live uses
3
+ // idempotency keys + agentTrace and exports run evidence. The client + provider
4
+ // are injected so the loop is fully unit-testable with no network/model calls.
5
+ import { spotBuyCost, } from "./types.js";
6
+ import { observe } from "./observe.js";
7
+ import { buildSystemPrompt, buildUserPrompt } from "./prompt.js";
8
+ import { parseDecision } from "./decision.js";
9
+ import { validateAction } from "./decisionValidator.js";
10
+ import { fetchQuote, executeAction } from "./act.js";
11
+ import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
12
+ import { rollDay, checkKillSwitch, accrueRealized, saveState, } from "./state.js";
13
+ import { asObj, asNum, asStr } from "./extract.js";
14
+ import { parseCadenceMs, sleep } from "./util.js";
15
+ // A stable idempotency-key component per distinct intent (so a lost response
16
+ // replays rather than re-trades, but a genuinely new intent gets a new key).
17
+ function intentKeyOf(action) {
18
+ if (action.type === "futures_open") {
19
+ return `open:${action.symbol.toUpperCase()}:${action.side}:${action.leverage}:${action.marginMusd}`;
20
+ }
21
+ if (action.type === "futures_close") {
22
+ return `close:${action.positionId}:${action.fraction ?? "full"}`;
23
+ }
24
+ if (action.type === "futures_set_sltp") {
25
+ return `sltp:${action.positionId}`;
26
+ }
27
+ if (action.type === "spot_order") {
28
+ return `spot:${action.symbol.toUpperCase()}:${action.side}:${action.orderType}:${action.quantity}:${action.limitPrice ?? ""}:${action.stopPrice ?? ""}`;
29
+ }
30
+ if (action.type === "spot_cancel") {
31
+ return `cancel:${action.orderId}`;
32
+ }
33
+ if (action.type === "pm_open") {
34
+ return `pm:${action.source.toLowerCase()}:${action.slug.toLowerCase()}:${action.outcomeExternalMarketId}:${action.stakeMusd}`;
35
+ }
36
+ return "other"; // unreachable: every action type is handled above
37
+ }
38
+ // Estimated cash a successful action consumes (for the running-cash guard):
39
+ // futures margin, spot buy notional, or a PM stake. Closes/cancels/sells free
40
+ // cash or are neutral, so they consume nothing here. Spot buys use the SAME
41
+ // `spotBuyCost` helper the validator gates on, so the gate and this decrement
42
+ // never diverge. The `?? 0` is unreachable for an EXECUTED buy: the validator
43
+ // fails closed (missing_quote_price) on any buy whose cost can't be sized, so
44
+ // nothing with an undefined cost ever reaches execution to be decremented.
45
+ function cashConsumed(action, quote) {
46
+ if (action.type === "futures_open")
47
+ return action.marginMusd;
48
+ if (action.type === "pm_open")
49
+ return action.stakeMusd;
50
+ if (action.type === "spot_order" && action.side === "buy") {
51
+ return spotBuyCost(action, quote) ?? 0;
52
+ }
53
+ return 0;
54
+ }
55
+ export async function runCycle(deps) {
56
+ const { client, provider, spec, mergedProse, state, live, stateFile } = deps;
57
+ const log = deps.log ?? (() => { });
58
+ state.cyclesRun += 1;
59
+ rollDay(state);
60
+ // Kill-switch pre-check: a disabled agent never observes, decides, or acts.
61
+ const tripped = checkKillSwitch(spec, state);
62
+ if (tripped) {
63
+ state.disabled = true;
64
+ state.disabledReason = tripped;
65
+ saveState(stateFile, state);
66
+ log(`disabled: ${tripped}`);
67
+ return {
68
+ decision: "skip",
69
+ planned: [],
70
+ disabled: true,
71
+ disabledReason: tripped,
72
+ live,
73
+ };
74
+ }
75
+ const runId = state.runId;
76
+ const decisionId = makeDecisionId(state.cyclesRun);
77
+ const baseTrace = makeTrace(runId, decisionId, spec);
78
+ // OBSERVE
79
+ const obs = await observe(client, spec, state, baseTrace);
80
+ const observation = obs.observation;
81
+ accrueRealized(state, observation.newClosedTrades);
82
+ state.cursor = observation.syncCursor;
83
+ for (const t of observation.newClosedTrades) {
84
+ state.seen.push(`${asStr(asObj(t).venue) ?? "futures"}:${asNum(asObj(t).id) ?? String(asObj(t).id)}`);
85
+ }
86
+ state.seen = state.seen.slice(-500);
87
+ // Equity-aware drawdown: open mark-to-market losses trip the kill-switch too,
88
+ // not only realized losses.
89
+ const unrealized = observation.openPositions.reduce((s, p) => s + (p.unrealizedPnlMusd ?? 0), 0);
90
+ if (spec.killSwitch.maxDrawdownMusd > 0 &&
91
+ state.peakRealizedMusd - (state.realizedPnlMusd + unrealized) >=
92
+ spec.killSwitch.maxDrawdownMusd) {
93
+ state.disabled = true;
94
+ state.disabledReason = `equity drawdown >= ${spec.killSwitch.maxDrawdownMusd}`;
95
+ saveState(stateFile, state);
96
+ log(`disabled: ${state.disabledReason}`);
97
+ return {
98
+ decision: "skip",
99
+ planned: [],
100
+ disabled: true,
101
+ disabledReason: state.disabledReason,
102
+ live,
103
+ };
104
+ }
105
+ if (obs.skip) {
106
+ state.consecutiveRejectCycles += 1;
107
+ saveState(stateFile, state);
108
+ log(`skip: ${obs.skip}`);
109
+ return { decision: "skip", skipReason: obs.skip, planned: [], live };
110
+ }
111
+ // DECIDE
112
+ const res = await provider.decide({
113
+ system: buildSystemPrompt(spec, mergedProse),
114
+ user: buildUserPrompt(observation),
115
+ });
116
+ if (!res.ok) {
117
+ state.consecutiveModelFailures += 1;
118
+ saveState(stateFile, state);
119
+ log(`model error: ${res.error}`);
120
+ return {
121
+ decision: "skip",
122
+ skipReason: `model error: ${res.error}`,
123
+ planned: [],
124
+ modelFailed: true,
125
+ live,
126
+ };
127
+ }
128
+ const parsed = parseDecision(res.text);
129
+ if (!parsed.ok) {
130
+ state.consecutiveModelFailures += 1;
131
+ saveState(stateFile, state);
132
+ log(`model output invalid: ${parsed.error}`);
133
+ return {
134
+ decision: "skip",
135
+ skipReason: `model output invalid: ${parsed.error}`,
136
+ planned: [],
137
+ modelFailed: true,
138
+ live,
139
+ };
140
+ }
141
+ state.consecutiveModelFailures = 0;
142
+ const decision = parsed.decision;
143
+ if (decision.decision === "skip" || decision.actions.length === 0) {
144
+ state.consecutiveRejectCycles += 1;
145
+ saveState(stateFile, state);
146
+ log(`model chose skip${decision.reason ? `: ${decision.reason}` : ""}`);
147
+ return {
148
+ decision: "skip",
149
+ skipReason: decision.reason ?? "model chose skip",
150
+ planned: [],
151
+ live,
152
+ };
153
+ }
154
+ // VALIDATE (+ ACT when live). Quote evidence is fetched by the runner.
155
+ const planned = [];
156
+ let writesThisCycle = 0;
157
+ let openCount = observation.openPositions.length;
158
+ // RUNNING totals so multiple opens in one cycle accumulate correctly.
159
+ let openMarginMusd = observation.openPositions
160
+ .filter((p) => p.venue === "futures")
161
+ .reduce((s, p) => s + (p.marginMusd ?? 0), 0);
162
+ let cashAvailableMusd = observation.cashAvailableMusd;
163
+ const realizedLossTodayMusd = Math.max(0, -state.realizedPnlTodayMusd);
164
+ const targetedPositionIds = [];
165
+ const targetedOrderIds = [];
166
+ let anyAccepted = false;
167
+ let anyExecuted = false;
168
+ let anyExecFailed = false;
169
+ for (const action of decision.actions) {
170
+ const quote = await fetchQuote(client, action, observation, baseTrace);
171
+ const ctx = {
172
+ spec,
173
+ // Inherit the decision-level confidence so the per-action abstention gate
174
+ // doesn't reject a model that reports conviction on the decision (the
175
+ // output contract) rather than on each action.
176
+ decisionConfidence: decision.confidence,
177
+ observation,
178
+ quote,
179
+ writesThisCycle,
180
+ writesToday: state.writesToday,
181
+ openCount,
182
+ cashAvailableMusd,
183
+ openMarginMusd,
184
+ realizedLossTodayMusd,
185
+ targetedPositionIds,
186
+ targetedOrderIds,
187
+ };
188
+ const v = validateAction(action, ctx);
189
+ if (!v.valid) {
190
+ planned.push({
191
+ action,
192
+ accepted: false,
193
+ code: v.code,
194
+ reason: v.reason,
195
+ quote,
196
+ });
197
+ log(`reject ${action.type}: ${v.code} (${v.reason})`);
198
+ continue;
199
+ }
200
+ anyAccepted = true;
201
+ if (action.type === "futures_close" || action.type === "futures_set_sltp") {
202
+ targetedPositionIds.push(action.positionId);
203
+ }
204
+ if (action.type === "spot_cancel") {
205
+ targetedOrderIds.push(action.orderId);
206
+ }
207
+ if (!live) {
208
+ planned.push({ action, accepted: true, quote, executed: false });
209
+ log(`DRY-RUN: would ${action.type}`);
210
+ continue;
211
+ }
212
+ // Deterministic idempotency key: stable per intent, advanced only on
213
+ // confirmed success — a lost response replays, a new intent gets a new key.
214
+ const intentKey = intentKeyOf(action);
215
+ const seq = state.intentSeq[intentKey] ?? 0;
216
+ const idem = `${runId}:${intentKey}:${seq}`;
217
+ const meta = action;
218
+ const trace = makeTrace(runId, decisionId, spec, meta.confidence ?? decision.confidence, meta.rationaleSummary);
219
+ const r = await executeAction(client, action, observation, trace, idem);
220
+ planned.push({
221
+ action,
222
+ accepted: true,
223
+ quote,
224
+ executed: r.ok,
225
+ result: r.data,
226
+ });
227
+ if (r.ok) {
228
+ anyExecuted = true;
229
+ state.intentSeq[intentKey] = seq + 1;
230
+ writesThisCycle += 1;
231
+ state.writesToday += 1;
232
+ if (action.type === "futures_open") {
233
+ openCount += 1;
234
+ openMarginMusd += action.marginMusd;
235
+ }
236
+ // Decrement running cash by what this action consumed (futures margin /
237
+ // spot buy notional / PM stake) so a later action this cycle sees it spent.
238
+ if (cashAvailableMusd != null)
239
+ cashAvailableMusd -= cashConsumed(action, quote);
240
+ }
241
+ else {
242
+ anyExecFailed = true;
243
+ }
244
+ log(`${r.ok ? "executed" : "FAILED"} ${action.type} (HTTP ${r.status})`);
245
+ }
246
+ // Reset the reject kill-switch only on real PROGRESS: an accepted-but-FAILED
247
+ // live write is not progress, or a persistently failing live agent would
248
+ // never trip the kill-switch.
249
+ const progressed = live ? anyExecuted : anyAccepted;
250
+ state.consecutiveRejectCycles = progressed
251
+ ? 0
252
+ : state.consecutiveRejectCycles + 1;
253
+ state.consecutiveExecFailures =
254
+ anyExecFailed && !anyExecuted ? state.consecutiveExecFailures + 1 : 0;
255
+ state.rateLimitHits = client.rateLimitHits ?? state.rateLimitHits;
256
+ saveState(stateFile, state);
257
+ if (live && anyExecuted)
258
+ await exportRunEvidence(client, runId);
259
+ return { decision: "act", planned, live };
260
+ }
261
+ export async function runLoop(deps, opts = {}) {
262
+ const results = [];
263
+ const cadenceMs = parseCadenceMs(deps.spec.trigger.cadence) ?? 3_600_000;
264
+ const log = deps.log ?? (() => { });
265
+ let cycles = 0;
266
+ for (;;) {
267
+ const r = await runCycle(deps);
268
+ results.push(r);
269
+ cycles += 1;
270
+ if (r.disabled)
271
+ break;
272
+ if (opts.once)
273
+ break;
274
+ if (opts.maxCycles && cycles >= opts.maxCycles)
275
+ break;
276
+ log(`sleeping ${Math.round(cadenceMs / 1000)}s until next cycle`);
277
+ await sleep(cadenceMs);
278
+ }
279
+ return results;
280
+ }
@@ -0,0 +1,12 @@
1
+ import { AgentSpec, ParsedSkill, ResolvedAgent, ResolveIssue } from "./types.js";
2
+ export declare function buildSpec(raw: Record<string, unknown>): AgentSpec;
3
+ export declare function parseSkill(text: string): ParsedSkill;
4
+ export declare function loadSkill(path: string): ParsedSkill;
5
+ export interface LoadedAgent {
6
+ resolved: ResolvedAgent;
7
+ spec: AgentSpec;
8
+ body: string;
9
+ raw: Record<string, unknown>;
10
+ lint: ResolveIssue[];
11
+ }
12
+ export declare function loadAgent(inputPath: string, mode?: "self-host" | "hosted"): LoadedAgent;
@@ -0,0 +1,136 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseFrontmatter } from "./frontmatter.js";
3
+ import { VENUES, PROVIDERS, ALLOWED_CAPABILITIES, } from "./types.js";
4
+ import { resolveAgent, ResolveError } from "./resolve.js";
5
+ import { strictLint } from "./strictLint.js";
6
+ import { checkCapabilityDrift } from "./capabilityGuard.js";
7
+ // Safe defaults for the OPTIONAL policy blocks. A minimal self-host skill
8
+ // (name/description/spec/trigger/model/venues/risk) runs under these. Hosted
9
+ // mode requires them to be explicit (see skillValidator).
10
+ const DEFAULT_LIMITS = {
11
+ maxTradesPerDay: 20,
12
+ maxWritesPerCycle: 2,
13
+ maxDailyLossMusd: 5_000,
14
+ maxOpenMarginMusd: 5_000,
15
+ };
16
+ const DEFAULT_ABSTENTION = {
17
+ onStaleData: true,
18
+ onWeakSignal: true,
19
+ onMissingQuote: true,
20
+ onInsufficientBalance: true,
21
+ minConfidence: 0,
22
+ };
23
+ const DEFAULT_SYNC = { requirePollBeforeWrite: true };
24
+ const DEFAULT_KILLSWITCH = {
25
+ maxDrawdownMusd: 0, // 0 = disabled
26
+ maxConsecutiveRejects: 0, // 0 = disabled
27
+ maxConsecutiveModelFailures: 5,
28
+ onRateLimitPressure: true,
29
+ };
30
+ const num = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
31
+ const bool = (v, fallback) => typeof v === "boolean" ? v : fallback;
32
+ const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
33
+ const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v)
34
+ ? v
35
+ : {};
36
+ function buildObjective(raw) {
37
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
38
+ return undefined;
39
+ const o = raw;
40
+ if (typeof o.primary !== "string")
41
+ return undefined;
42
+ return {
43
+ primary: o.primary,
44
+ secondary: strArr(o.secondary),
45
+ horizon: typeof o.horizon === "string" ? o.horizon : undefined,
46
+ };
47
+ }
48
+ function buildModel(raw) {
49
+ if (raw === undefined)
50
+ return undefined;
51
+ const m = obj(raw);
52
+ return {
53
+ provider: (PROVIDERS.includes(m.provider)
54
+ ? m.provider
55
+ : "openai-compatible"),
56
+ name: typeof m.name === "string" ? m.name : "",
57
+ baseUrl: typeof m.baseUrl === "string" ? m.baseUrl : undefined,
58
+ };
59
+ }
60
+ // Coerce raw frontmatter into a best-effort AgentSpec. This NEVER throws on bad
61
+ // values — it fills in what it can and lets validateSkill report problems
62
+ // against the raw frontmatter. The runner only proceeds when validation passes.
63
+ export function buildSpec(raw) {
64
+ const trigger = obj(raw.trigger);
65
+ const risk = obj(raw.risk);
66
+ const limits = obj(raw.limits);
67
+ const abst = obj(raw.abstention);
68
+ const sync = obj(raw.sync);
69
+ const ks = obj(raw.killSwitch);
70
+ const venues = strArr(raw.venues).filter((v) => VENUES.includes(v));
71
+ return {
72
+ name: typeof raw.name === "string" ? raw.name : "",
73
+ description: typeof raw.description === "string" ? raw.description : "",
74
+ spec: typeof raw.spec === "string" ? raw.spec : "",
75
+ trigger: {
76
+ cadence: typeof trigger.cadence === "string" ? trigger.cadence : "",
77
+ timezone: typeof trigger.timezone === "string" ? trigger.timezone : undefined,
78
+ },
79
+ model: buildModel(raw.model),
80
+ venues,
81
+ risk: {
82
+ maxLeverage: num(risk.maxLeverage, 1),
83
+ perTradeMarginMusd: num(risk.perTradeMarginMusd, 0),
84
+ maxConcurrentPositions: num(risk.maxConcurrentPositions, 0),
85
+ requireStopLoss: bool(risk.requireStopLoss, true),
86
+ watchlist: strArr(risk.watchlist),
87
+ blocklist: strArr(risk.blocklist),
88
+ },
89
+ limits: {
90
+ maxTradesPerDay: num(limits.maxTradesPerDay, DEFAULT_LIMITS.maxTradesPerDay),
91
+ maxWritesPerCycle: num(limits.maxWritesPerCycle, DEFAULT_LIMITS.maxWritesPerCycle),
92
+ maxDailyLossMusd: num(limits.maxDailyLossMusd, DEFAULT_LIMITS.maxDailyLossMusd),
93
+ maxOpenMarginMusd: num(limits.maxOpenMarginMusd, DEFAULT_LIMITS.maxOpenMarginMusd),
94
+ },
95
+ abstention: {
96
+ onStaleData: bool(abst.onStaleData, DEFAULT_ABSTENTION.onStaleData),
97
+ onWeakSignal: bool(abst.onWeakSignal, DEFAULT_ABSTENTION.onWeakSignal),
98
+ onMissingQuote: bool(abst.onMissingQuote, DEFAULT_ABSTENTION.onMissingQuote),
99
+ onInsufficientBalance: bool(abst.onInsufficientBalance, DEFAULT_ABSTENTION.onInsufficientBalance),
100
+ minConfidence: num(abst.minConfidence, DEFAULT_ABSTENTION.minConfidence),
101
+ },
102
+ sync: {
103
+ requirePollBeforeWrite: bool(sync.requirePollBeforeWrite, DEFAULT_SYNC.requirePollBeforeWrite),
104
+ },
105
+ killSwitch: {
106
+ maxDrawdownMusd: num(ks.maxDrawdownMusd, DEFAULT_KILLSWITCH.maxDrawdownMusd),
107
+ maxConsecutiveRejects: num(ks.maxConsecutiveRejects, DEFAULT_KILLSWITCH.maxConsecutiveRejects),
108
+ maxConsecutiveModelFailures: num(ks.maxConsecutiveModelFailures, DEFAULT_KILLSWITCH.maxConsecutiveModelFailures),
109
+ onRateLimitPressure: bool(ks.onRateLimitPressure, DEFAULT_KILLSWITCH.onRateLimitPressure),
110
+ },
111
+ objective: buildObjective(raw.objective),
112
+ capabilities: strArr(raw.capabilities).filter((c) => ALLOWED_CAPABILITIES.includes(c)),
113
+ };
114
+ }
115
+ export function parseSkill(text) {
116
+ const { data, body } = parseFrontmatter(text);
117
+ return { spec: buildSpec(data), body, raw: data };
118
+ }
119
+ export function loadSkill(path) {
120
+ return parseSkill(readFileSync(path, "utf8"));
121
+ }
122
+ // Compile an agent (single file OR decomposed folder) into a spec + the prose
123
+ // the LLM reads. Fail-closed: resolveAgent() throws on any structural/secret
124
+ // problem. In hosted mode the strict key/enum lint is ALSO fatal (no silent
125
+ // coercion of a typo'd knob); self-host returns lint as advisory issues.
126
+ export function loadAgent(inputPath, mode = "self-host") {
127
+ const resolved = resolveAgent(inputPath);
128
+ const raw = resolved.rawFrontmatter;
129
+ const spec = buildSpec(raw);
130
+ // strictLint = frontmatter keys/enums; capability drift = prose references to
131
+ // venues/actions/caps the runner (or this agent's venues) does not support.
132
+ const lint = [...strictLint(raw), ...checkCapabilityDrift(resolved, spec)];
133
+ if (mode === "hosted" && lint.length)
134
+ throw new ResolveError(lint);
135
+ return { resolved, spec, body: resolved.mergedProse, raw, lint };
136
+ }
@@ -0,0 +1,7 @@
1
+ import { ParsedSkill, ValidationResult } from "./types.js";
2
+ export type SkillValidationMode = "self-host" | "hosted";
3
+ export interface SkillValidation {
4
+ valid: boolean;
5
+ issues: ValidationResult[];
6
+ }
7
+ export declare function validateSkill(parsed: ParsedSkill, mode?: SkillValidationMode): SkillValidation;
@@ -0,0 +1,123 @@
1
+ import { fail, VENUES, PROVIDERS, SPEC_VERSION, OBJECTIVE_PRIMARIES, ALLOWED_CAPABILITIES, } from "./types.js";
2
+ import { parseCadenceMs, scanForSecrets } from "./util.js";
3
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4
+ const isPosNum = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
5
+ export function validateSkill(parsed, mode = "self-host") {
6
+ const raw = parsed.raw;
7
+ const issues = [];
8
+ const add = (code, reason) => issues.push(fail(code, reason));
9
+ // Secrets must NEVER live in a committable skill file.
10
+ for (const finding of scanForSecrets(raw))
11
+ add("skill_secret", finding);
12
+ // Identity
13
+ if (typeof raw.name !== "string" || !raw.name.trim())
14
+ add("skill_name", "name is required (string)");
15
+ if (typeof raw.description !== "string" || !raw.description.trim())
16
+ add("skill_description", "description is required (string)");
17
+ if (raw.spec !== SPEC_VERSION)
18
+ add("skill_spec_version", `spec must be "${SPEC_VERSION}"`);
19
+ // Trigger
20
+ if (!isObj(raw.trigger)) {
21
+ add("skill_trigger", "trigger block is required");
22
+ }
23
+ else if (parseCadenceMs(raw.trigger.cadence) === null) {
24
+ add("skill_cadence", `trigger.cadence must look like "15m" / "1h" / "4h" (got ${JSON.stringify(raw.trigger.cadence)})`);
25
+ }
26
+ // Venues
27
+ if (!Array.isArray(raw.venues) || raw.venues.length === 0) {
28
+ add("skill_venues", "venues must be a non-empty list");
29
+ }
30
+ else {
31
+ for (const v of raw.venues) {
32
+ if (!VENUES.includes(v))
33
+ add("skill_venue_unknown", `unknown venue "${String(v)}" (allowed: ${VENUES.join(", ")})`);
34
+ }
35
+ }
36
+ // Risk — always required
37
+ if (!isObj(raw.risk)) {
38
+ add("skill_risk", "risk block is required (the caps the agent runs under)");
39
+ }
40
+ else {
41
+ const r = raw.risk;
42
+ if (!isPosNum(r.maxLeverage))
43
+ add("skill_risk_leverage", "risk.maxLeverage must be a positive number");
44
+ else if (r.maxLeverage > 20)
45
+ add("skill_risk_leverage_cap", "risk.maxLeverage cannot exceed the server cap of 20");
46
+ if (!isPosNum(r.perTradeMarginMusd))
47
+ add("skill_risk_margin", "risk.perTradeMarginMusd must be a positive number");
48
+ if (!isPosNum(r.maxConcurrentPositions))
49
+ add("skill_risk_positions", "risk.maxConcurrentPositions must be a positive number");
50
+ if (typeof r.requireStopLoss !== "boolean")
51
+ add("skill_risk_sl", "risk.requireStopLoss must be true or false");
52
+ if (!Array.isArray(r.watchlist) || r.watchlist.length === 0)
53
+ add("skill_risk_watchlist", "risk.watchlist must be a non-empty list of symbols");
54
+ }
55
+ // Model
56
+ if (raw.model === undefined) {
57
+ if (mode === "self-host")
58
+ add("skill_model_required", "model is required for self-host (no hosted free-tier locally). Set model.provider + model.name.");
59
+ }
60
+ else if (!isObj(raw.model)) {
61
+ add("skill_model", "model must be an object with provider + name");
62
+ }
63
+ else {
64
+ if (!PROVIDERS.includes(raw.model.provider))
65
+ add("skill_model_provider", `model.provider must be one of: ${PROVIDERS.join(", ")}`);
66
+ if (typeof raw.model.name !== "string" || !raw.model.name.trim())
67
+ add("skill_model_name", "model.name is required");
68
+ if (raw.model.provider === "openai-compatible" &&
69
+ (typeof raw.model.baseUrl !== "string" || !raw.model.baseUrl.trim()))
70
+ add("skill_model_baseurl", "model.baseUrl is required for provider 'openai-compatible'");
71
+ }
72
+ // objective (optional)
73
+ if (raw.objective !== undefined) {
74
+ if (!isObj(raw.objective)) {
75
+ add("skill_objective", "objective must be an object");
76
+ }
77
+ else if (typeof raw.objective.primary !== "string" ||
78
+ !OBJECTIVE_PRIMARIES.includes(raw.objective.primary)) {
79
+ add("skill_objective_primary", `objective.primary must be one of: ${OBJECTIVE_PRIMARIES.join(", ")}`);
80
+ }
81
+ }
82
+ // capabilities (optional, opt-in, reserved for a later slice)
83
+ if (raw.capabilities !== undefined) {
84
+ if (!Array.isArray(raw.capabilities)) {
85
+ add("skill_capabilities", "capabilities must be a list");
86
+ }
87
+ else {
88
+ for (const c of raw.capabilities) {
89
+ if (!ALLOWED_CAPABILITIES.includes(c)) {
90
+ add("skill_capability_unknown", `unknown capability "${String(c)}" (allowed: ${ALLOWED_CAPABILITIES.join(", ")})`);
91
+ }
92
+ }
93
+ }
94
+ }
95
+ // Hosted mode: the safety policy blocks must be explicit.
96
+ if (mode === "hosted") {
97
+ if (!isObj(raw.limits)) {
98
+ add("skill_limits_required", "limits block is required in hosted mode");
99
+ }
100
+ else {
101
+ const l = raw.limits;
102
+ if (!isPosNum(l.maxTradesPerDay))
103
+ add("skill_limits_trades", "limits.maxTradesPerDay must be a positive number");
104
+ if (!isPosNum(l.maxWritesPerCycle))
105
+ add("skill_limits_writes", "limits.maxWritesPerCycle must be a positive number");
106
+ if (!isPosNum(l.maxDailyLossMusd))
107
+ add("skill_limits_loss", "limits.maxDailyLossMusd must be a positive number");
108
+ if (!isPosNum(l.maxOpenMarginMusd))
109
+ add("skill_limits_open", "limits.maxOpenMarginMusd must be a positive number");
110
+ }
111
+ if (!isObj(raw.abstention))
112
+ add("skill_abstention_required", "abstention block is required in hosted mode");
113
+ if (!isObj(raw.sync)) {
114
+ add("skill_sync_required", "sync block is required in hosted mode");
115
+ }
116
+ else if (typeof raw.sync.requirePollBeforeWrite !== "boolean") {
117
+ add("skill_sync_poll", "sync.requirePollBeforeWrite must be true or false");
118
+ }
119
+ if (!isObj(raw.killSwitch))
120
+ add("skill_killswitch_required", "killSwitch block is required in hosted mode");
121
+ }
122
+ return { valid: issues.length === 0, issues };
123
+ }
@@ -0,0 +1,7 @@
1
+ import { RunState, AgentSpec } from "./types.js";
2
+ export declare function newState(runId: string): RunState;
3
+ export declare function loadState(file: string | undefined, runId: string): RunState;
4
+ export declare function saveState(file: string | undefined, state: RunState): void;
5
+ export declare function rollDay(state: RunState): RunState;
6
+ export declare function accrueRealized(state: RunState, closedTrades: Record<string, unknown>[]): void;
7
+ export declare function checkKillSwitch(spec: AgentSpec, state: RunState): string | null;