@coinrithm/mcp-trading 0.7.5 → 0.7.7

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.
@@ -47,6 +47,32 @@ const JOURNAL_MAX_LINES = 200;
47
47
  const JOURNAL_MAX_BYTES = 8_000;
48
48
  // Optional prose files (markdown the LLM reads), in assembly order.
49
49
  const PROSE_FILES = ["character/thesis.md", "character/persona.md"];
50
+ // Prose files carry an OPTIONAL YAML frontmatter block (type/title/description/
51
+ // tags) that is authoring metadata, not doctrine — the model gains nothing from
52
+ // `tags: [agent, persona, mean-reversion]`. It was being merged verbatim into
53
+ // the system prompt: pure noise, and on the hosted path it also consumed the
54
+ // 8,000-char strategy budget (measured 2026-08-19: ~1.0k chars across a
55
+ // decomposed bundle's thesis/persona/journal). Skill files already strip theirs
56
+ // (their frontmatter is parsed for the cap patch), and guards.md strips too —
57
+ // this brings the remaining prose files in line. Files WITHOUT frontmatter are
58
+ // returned unchanged.
59
+ export const proseBody = (raw) => {
60
+ const src = raw.replace(/\r\n/g, "\n");
61
+ const m = /^---\n[\s\S]*?\n---\n?([\s\S]*)$/.exec(src);
62
+ return (m ? m[1] : src).trim();
63
+ };
64
+ // First-class hard-guards file (2026-08-19, audit rank 7). User-authored
65
+ // behavioral borders that machine caps cannot express ("never open a short
66
+ // unless a qualifying pump preceded it") previously lived as an undocumented
67
+ // "Hard borders" paragraph buried mid-persona — present in only 5 of 8
68
+ // bundles and easy for a forker to miss. character/guards.md gets a dedicated
69
+ // slot: loaded LAST so the block lands at the END of the strategy prose,
70
+ // immediately adjacent to the system prompt's hard-caps section, wrapped in a
71
+ // high-salience header plus an explicit guards-win-conflicts rule.
72
+ export const GUARDS_FILE = "character/guards.md";
73
+ export const GUARDS_HEADER = "## HARD BEHAVIORAL GUARDS — never violate these";
74
+ export const GUARDS_FOOTER = "(These guards override every other instruction in this strategy. When a guard conflicts with an opportunity, the guard wins and the correct output is a skip that names the guard.)";
75
+ export const wrapGuardsProse = (body) => `${GUARDS_HEADER}\n\n${body.trim()}\n\n${GUARDS_FOOTER}`;
50
76
  const FUNCTIONALITY_PIN = "functionality/coinrithm.yaml";
51
77
  // Enforced cap field names. sizing.yaml is SOFT guidance and must NOT contain
52
78
  // any of these (or a user could think a limit binds when it does not).
@@ -427,8 +453,9 @@ function resolveDirectory(dir) {
427
453
  const p = join(dir, pf);
428
454
  if (existsSync(p)) {
429
455
  const abs = safePath(ctx, pf, "prose");
430
- if (abs)
431
- proseParts.push({ source: pf, text: readHashed(ctx, abs) });
456
+ if (abs) {
457
+ proseParts.push({ source: pf, text: proseBody(readHashed(ctx, abs)) });
458
+ }
432
459
  }
433
460
  }
434
461
  proseParts.push(...skillProse);
@@ -437,13 +464,28 @@ function resolveDirectory(dir) {
437
464
  if (existsSync(journalPath)) {
438
465
  const abs = safePath(ctx, "journal/notes.md", "journal");
439
466
  if (abs) {
440
- const full = readHashed(ctx, abs);
467
+ const full = proseBody(readHashed(ctx, abs));
441
468
  proseParts.push({
442
469
  source: "journal/notes.md",
443
470
  text: boundTail(full, JOURNAL_MAX_LINES, JOURNAL_MAX_BYTES),
444
471
  });
445
472
  }
446
473
  }
474
+ // Hard behavioral guards — see GUARDS_FILE above. Pushed AFTER the journal
475
+ // so the wrapped block is the final prose the model reads before the caps
476
+ // section. Frontmatter is optional and stripped (only the body is doctrine);
477
+ // an empty body contributes nothing.
478
+ const guardsAbsPath = join(dir, GUARDS_FILE);
479
+ if (existsSync(guardsAbsPath)) {
480
+ const abs = safePath(ctx, GUARDS_FILE, "guards");
481
+ if (abs) {
482
+ const raw = readHashed(ctx, abs);
483
+ const body = (raw.startsWith("---") ? parseFrontmatter(raw).body : raw).trim();
484
+ if (body) {
485
+ proseParts.push({ source: GUARDS_FILE, text: wrapGuardsProse(body) });
486
+ }
487
+ }
488
+ }
447
489
  // Optional API/tool contract pin. It is locked for reproducibility and stale
448
490
  // warnings, but it is not part of AgentSpec and is never sent to the model.
449
491
  const functionalityPath = join(dir, FUNCTIONALITY_PIN);
@@ -542,3 +584,40 @@ export function resolveAgent(inputPath) {
542
584
  { code: "input_invalid", message: "input must be a file or a directory" },
543
585
  ]);
544
586
  }
587
+ // ── Hosted strategy-prose budget ───────────────────────────────────────────
588
+ // The managed (Studio) deploy/edit API caps the merged strategy prose and
589
+ // reverts the save past the cap; the self-host runner has NO such limit. The
590
+ // cap is not arbitrary — the merged prose becomes the system prompt, and an
591
+ // oversized prompt is what previously pushed small free-tier models to ~69k
592
+ // tokens / 413s per cycle. Mirrored here (backend-v2
593
+ // controllers/agentManage.ts sanitizeStrategyProse) so `validate --hosted`
594
+ // can catch it before a user does.
595
+ //
596
+ // RAISED 8,000 -> 12,000 on 2026-08-21, from measurement rather than feel.
597
+ // 8,000 made the product's core promise impossible: forking a house template
598
+ // starts you at 7,967 (Olivia) / 7,931 (Carl) / 7,839 (Mia), so a user had
599
+ // 33 to 161 characters to write their own rules in. "Fork a template and make
600
+ // it yours" could not be done.
601
+ //
602
+ // The cap was justified by hosted inference cost. Measured over 8,060 LLM
603
+ // cycles in 24h on prod: average input is 9,038 tokens, of which the prose is
604
+ // only 12.6-40.2% (median ~25%) — the OBSERVATION is the other ~75%. Inputs
605
+ // already reached 17,065 tokens on Llama 3.1 8B and 16,437 on Nemotron 49B
606
+ // (measured on the since-retired NIM line; Nemotron 3 successors match), with
607
+ // ZERO rate-limit errors and estimated_cost_usd of 0.0000 (free NIM tier).
608
+ // +4,000 characters is ~+1,000 tokens/cycle (+11%), landing average input near
609
+ // 10,038 — still below what the fleet already handles at peak today.
610
+ //
611
+ // Self-host is deliberately NOT capped (runner.ts/prompt.ts enforce nothing):
612
+ // those agents run on the user's own model key, so their prompt size costs us
613
+ // nothing. This limit exists only where WE pay for the inference.
614
+ export const HOSTED_PROSE_MAX_CHARS = 12000;
615
+ /** PURE — exported for tests. Mirrors the backend's trim-then-measure. */
616
+ export const hostedProseBudget = (mergedProse) => {
617
+ const used = mergedProse.replace(/\r\n/g, "\n").trim().length;
618
+ return {
619
+ used,
620
+ over: Math.max(0, used - HOSTED_PROSE_MAX_CHARS),
621
+ fits: used <= HOSTED_PROSE_MAX_CHARS,
622
+ };
623
+ };
@@ -14,7 +14,7 @@ import { validateAction } from "./decisionValidator.js";
14
14
  import { resolvePmRef } from "./resolvePm.js";
15
15
  import { fetchQuote, executeAction } from "./act.js";
16
16
  import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
17
- import { rollDay, checkKillSwitch, accrueRealized, saveState, } from "./state.js";
17
+ import { rollDay, checkKillSwitch, accrueRealized, saveState, isPermanentModelError, isAuthFailureSkip, PERMANENT_MODEL_ERROR_THRESHOLD, AUTH_FAILURE_THRESHOLD, } from "./state.js";
18
18
  import { asObj, asNum, asStr } from "./extract.js";
19
19
  import { parseCadenceMs, sleep } from "./util.js";
20
20
  import { buildObservationReceipt } from "./observationReceipt.js";
@@ -429,6 +429,29 @@ export async function runCycle(deps) {
429
429
  }
430
430
  if (obs.skip) {
431
431
  state.consecutiveRejectCycles += 1;
432
+ // Permanent-failure classification: a revoked/invalid CoinRithm key
433
+ // answers 401 deterministically — after the threshold, disable with the
434
+ // machine-readable 'key_invalid' prefix the scheduler's self-heal exempts
435
+ // (the old path revived such agents into ~1,500 guaranteed-dead
436
+ // cycles/day). A transient rotation blip stays under the threshold.
437
+ if (isAuthFailureSkip(obs.skip)) {
438
+ state.consecutiveAuthFailures = (state.consecutiveAuthFailures ?? 0) + 1;
439
+ if (state.consecutiveAuthFailures >= AUTH_FAILURE_THRESHOLD) {
440
+ state.disabled = true;
441
+ state.disabledReason = `key_invalid: CoinRithm key rejected (HTTP 401) on ${state.consecutiveAuthFailures} consecutive cycles`;
442
+ saveState(stateFile, state);
443
+ log(`disabled: ${state.disabledReason}`);
444
+ return {
445
+ decision: "skip",
446
+ skipReason: obs.skip,
447
+ planned: [],
448
+ disabled: true,
449
+ disabledReason: state.disabledReason,
450
+ live,
451
+ ...observationReceipt,
452
+ };
453
+ }
454
+ }
432
455
  saveState(stateFile, state);
433
456
  log(`skip: ${obs.skip}`);
434
457
  return {
@@ -439,6 +462,8 @@ export async function runCycle(deps) {
439
462
  ...observationReceipt,
440
463
  };
441
464
  }
465
+ // A full observation implies /me succeeded — the auth-failure streak is over.
466
+ state.consecutiveAuthFailures = 0;
442
467
  // GATE (slice 2): only SPEND an LLM call when a deterministic trigger fires — a
443
468
  // flagged entry setup or an open position to manage. No trigger => a cheap
444
469
  // heartbeat (zero tokens). A heartbeat is neither a model reject nor a failure,
@@ -493,33 +518,112 @@ export async function runCycle(deps) {
493
518
  };
494
519
  }
495
520
  else {
496
- noteLlmCall(state, gate.codes, nowMs);
497
521
  const system = buildSystemPrompt(spec, mergedProse, {
498
522
  includeForecast: forecastEnabled,
499
523
  });
500
- const user = buildUserPrompt(observation, state.journal);
524
+ const user = buildUserPrompt(observation, state.journal, {
525
+ venues: spec.venues,
526
+ });
501
527
  const tokensInEst = Math.round((system.length + user.length) / 4);
502
528
  // Prompt-size + trigger visibility in the live terminal.
503
529
  log(`prompt ~${tokensInEst} tok ` +
504
530
  `(pm ${observation.pmMarkets.length}, trades ${observation.newClosedTrades.length}, watch ${observation.watch.length}, setups ${observation.setups.length}, triggers ${gate.codes.join("|") || "none"})`);
505
531
  const res = await provider.decide({ system, user });
532
+ const route = res.route;
533
+ const actualCallMade = route
534
+ ? route.attempts.some((attempt) => attempt.outcome !== "deferred")
535
+ : true;
536
+ // Capacity deferral made no provider call, so it must not consume the
537
+ // runner's debounce/LLM budget or delay recovery after capacity returns.
538
+ if (actualCallMade)
539
+ noteLlmCall(state, gate.codes, nowMs);
540
+ const effectiveProvider = actualCallMade
541
+ ? (route?.effectiveProvider ?? providerName)
542
+ : undefined;
506
543
  // Metering: prefer provider-reported usage; fall back to a chars/4 estimate.
507
- const tokensIn = res.ok
508
- ? (res.usage?.promptTokens ?? tokensInEst)
509
- : tokensInEst;
544
+ const tokensIn = !actualCallMade
545
+ ? 0
546
+ : res.ok
547
+ ? (res.usage?.promptTokens ?? tokensInEst)
548
+ : tokensInEst;
510
549
  const tokensOut = res.ok
511
550
  ? (res.usage?.completionTokens ?? Math.round(res.text.length / 4))
512
551
  : 0;
513
- const estimatedCostUsd = estimateCostUsd(providerName, tokensIn, tokensOut);
552
+ const estimatedCostUsd = estimateCostUsd(effectiveProvider ?? providerName, tokensIn, tokensOut);
514
553
  meter = {
515
554
  triggerCodes: gate.codes,
516
- llmCallMade: true,
555
+ llmCallMade: actualCallMade,
517
556
  tokensIn,
518
557
  tokensOut,
519
558
  estimatedCostUsd,
559
+ effectiveProvider,
560
+ effectiveModel: actualCallMade
561
+ ? (route?.effectiveModel ?? spec.model?.name)
562
+ : undefined,
563
+ routeReason: route?.reason,
564
+ routeAttempts: route?.attempts,
520
565
  };
521
566
  if (!res.ok) {
567
+ if (res.deferred || !actualCallMade) {
568
+ saveState(stateFile, state);
569
+ log(`capacity deferred: ${res.error}`);
570
+ return {
571
+ decision: "skip",
572
+ skipReason: "provider capacity deferred",
573
+ planned: [],
574
+ modelFailed: false,
575
+ live,
576
+ ...meter,
577
+ decisionType: "gate_skip",
578
+ writeAttempted: 0,
579
+ writeAccepted: 0,
580
+ ...observationReceipt,
581
+ };
582
+ }
522
583
  state.consecutiveModelFailures += 1;
584
+ // Permanent-failure classification: a 404/410/model_not_found is a
585
+ // DECOMMISSIONED or misconfigured model that will fail every cycle
586
+ // until something changes (live-measured 2026-08-26: NVIDIA EOL'd the
587
+ // whole Llama 3.x line and 35 agents died on the old disable path).
588
+ // Reliability slice 1: this class must NEVER disable the agent —
589
+ // provider failures are the PLATFORM's problem, not the user's. Three
590
+ // consecutive occurrences (rules out a routing fluke) now emit a
591
+ // providerHold: hosted, the scheduler folds holds into a fleet-wide
592
+ // (provider, model) circuit that skip-claims matching agents with
593
+ // backoff probes; self-host, the runner simply keeps retrying each
594
+ // cadence and recovers the moment the provider does. Disables remain
595
+ // for what deserves them: revoked credentials, drawdown, kill-switch,
596
+ // user action. Transient errors reset the permanent streak.
597
+ if (isPermanentModelError(res.error)) {
598
+ state.consecutivePermanentModelErrors =
599
+ (state.consecutivePermanentModelErrors ?? 0) + 1;
600
+ if (state.consecutivePermanentModelErrors >=
601
+ PERMANENT_MODEL_ERROR_THRESHOLD) {
602
+ const hold = {
603
+ provider: route?.effectiveProvider ?? spec.model?.provider ?? "unknown",
604
+ model: route?.effectiveModel ?? spec.model?.name ?? "unknown",
605
+ error: res.error.slice(0, 200),
606
+ };
607
+ saveState(stateFile, state);
608
+ log(`provider hold: ${hold.provider}/${hold.model} — ${res.error.slice(0, 120)}`);
609
+ return {
610
+ decision: "skip",
611
+ skipReason: `provider hold: ${res.error}`,
612
+ planned: [],
613
+ modelFailed: true,
614
+ providerHold: hold,
615
+ live,
616
+ ...meter,
617
+ decisionType: "model_error",
618
+ writeAttempted: 0,
619
+ writeAccepted: 0,
620
+ ...observationReceipt,
621
+ };
622
+ }
623
+ }
624
+ else {
625
+ state.consecutivePermanentModelErrors = 0;
626
+ }
523
627
  saveState(stateFile, state);
524
628
  log(`model error: ${res.error}`);
525
629
  return {
@@ -557,6 +661,7 @@ export async function runCycle(deps) {
557
661
  };
558
662
  }
559
663
  state.consecutiveModelFailures = 0;
664
+ state.consecutivePermanentModelErrors = 0;
560
665
  decision = parsed.decision;
561
666
  }
562
667
  // Reasoning captured for the Arena terminal (keystone transparency): the
@@ -92,6 +92,12 @@ export function buildSpec(raw) {
92
92
  requireStopLoss: bool(risk.requireStopLoss, true),
93
93
  watchlist: strArr(risk.watchlist),
94
94
  blocklist: strArr(risk.blocklist),
95
+ // Only the two exact values pass; anything else stays undefined here and
96
+ // FAILS validation (skillValidator) — a typo like "shorts_only" must
97
+ // never silently mean "unrestricted".
98
+ direction: risk.direction === "long_only" || risk.direction === "short_only"
99
+ ? risk.direction
100
+ : undefined,
95
101
  },
96
102
  limits: {
97
103
  maxTradesPerDay: normalizeTradeCap(num(limits.maxTradesPerDay, DEFAULT_LIMITS.maxTradesPerDay)),
@@ -53,6 +53,13 @@ export function validateSkill(parsed, mode = "self-host") {
53
53
  add("skill_risk_sl", "risk.requireStopLoss must be true or false");
54
54
  if (!Array.isArray(r.watchlist) || r.watchlist.length === 0)
55
55
  add("skill_risk_watchlist", "risk.watchlist must be a non-empty list of symbols");
56
+ // Fail-closed on the side restriction: a typo ("shorts_only") must never
57
+ // silently mean "unrestricted" — that is exactly how a prose-only
58
+ // constraint failed live on 2026-08-24.
59
+ if (r.direction !== undefined &&
60
+ r.direction !== "long_only" &&
61
+ r.direction !== "short_only")
62
+ add("skill_risk_direction", 'risk.direction must be "long_only" or "short_only" (omit for both)');
56
63
  }
57
64
  // Model
58
65
  if (raw.model === undefined) {
@@ -4,4 +4,9 @@ export declare function loadState(file: string | undefined, runId: string): RunS
4
4
  export declare function saveState(file: string | undefined, state: RunState): void;
5
5
  export declare function rollDay(state: RunState): RunState;
6
6
  export declare function accrueRealized(state: RunState, closedTrades: Record<string, unknown>[]): void;
7
+ export declare const PERMANENT_MODEL_ERROR_RE: RegExp;
8
+ export declare const PERMANENT_MODEL_ERROR_THRESHOLD = 3;
9
+ export declare const AUTH_FAILURE_THRESHOLD = 10;
10
+ export declare const isPermanentModelError: (error: string) => boolean;
11
+ export declare const isAuthFailureSkip: (skipReason: string) => boolean;
7
12
  export declare function checkKillSwitch(spec: AgentSpec, state: RunState): string | null;
@@ -84,6 +84,26 @@ export function accrueRealized(state, closedTrades) {
84
84
  // floored at this many consecutive failures regardless of an agent's own (lower)
85
85
  // setting. The scheduler additionally auto-revives any model-failure disable.
86
86
  const MODEL_FAILURE_FLOOR = 10;
87
+ // ── Permanent-failure classification (2026-08-19) ───────────────────────────
88
+ // The generic kill-switch treats every failure as transient — correct for
89
+ // timeouts/blips, catastrophic for DETERMINISTIC failures. Live-measured: one
90
+ // agent spent 93% of 782 cycles/24h on a Groq 404 (model decommissioned),
91
+ // revived 7 times in 3h by the self-heal; four others burned ~1,500 cycles/day
92
+ // on a revoked CoinRithm key (HTTP 401). These classifiers give such failures
93
+ // a fast, NON-revivable disable with a machine-readable reason prefix the
94
+ // scheduler's self-heal exempts ('model_unavailable' / 'key_invalid').
95
+ //
96
+ // Permanent model errors are deterministic, so the threshold is small — 3
97
+ // consecutive occurrences rules out a one-off routing fluke without burning a
98
+ // day. Auth failures get 10: a key rotation/propagation blip should not kill
99
+ // an agent, but nothing recovers from an actually-revoked key.
100
+ export const PERMANENT_MODEL_ERROR_RE = /model_not_found|model[_ ]decommissioned|has been decommissioned|\b404\b|\b410\b|reached (?:its )?end of life|no longer available|does not exist or you do not have access/i;
101
+ export const PERMANENT_MODEL_ERROR_THRESHOLD = 3;
102
+ export const AUTH_FAILURE_THRESHOLD = 10;
103
+ export const isPermanentModelError = (error) => PERMANENT_MODEL_ERROR_RE.test(error);
104
+ // The observe phase folds a rejected key into its required-reads skip reason
105
+ // as "... (HTTP 401)".
106
+ export const isAuthFailureSkip = (skipReason) => /HTTP 401/.test(skipReason);
87
107
  // Returns a disable reason if any kill-switch condition is tripped, else null.
88
108
  export function checkKillSwitch(spec, state) {
89
109
  const ks = spec.killSwitch;
@@ -25,8 +25,20 @@ const ALLOWED_KEYS = {
25
25
  "capabilities",
26
26
  "include",
27
27
  "watchlist",
28
+ // Load-bearing since OKF v2 (skill.ts builds the full TriggerPolicy from
29
+ // it) but was missing here, so any bundle actually SETTING it got an
30
+ // unknown_key lint — the knob existed and was unreachable (audit rank 10).
31
+ "triggerPolicy",
28
32
  ],
29
33
  trigger: ["cadence", "timezone", "events"],
34
+ triggerPolicy: [
35
+ "mode",
36
+ "skipLlmWhenNoTrigger",
37
+ "alwaysManageOpenPositions",
38
+ "maxLlmCallsPerHour",
39
+ "debounceMinutes",
40
+ "pmEvalCooldownMinutes",
41
+ ],
30
42
  model: ["provider", "name", "baseUrl"],
31
43
  risk: [
32
44
  "maxLeverage",
@@ -35,6 +47,7 @@ const ALLOWED_KEYS = {
35
47
  "requireStopLoss",
36
48
  "watchlist",
37
49
  "blocklist",
50
+ "direction",
38
51
  ],
39
52
  sizing: null,
40
53
  limits: [
@@ -107,6 +120,7 @@ export function strictLint(raw) {
107
120
  lintKeys("$root", raw, issues);
108
121
  for (const block of [
109
122
  "trigger",
123
+ "triggerPolicy",
110
124
  "model",
111
125
  "risk",
112
126
  "limits",
@@ -91,6 +91,12 @@ export function buildAgentObject(name, preset) {
91
91
  trigger: { cadence: p.cadence, timezone: "UTC" },
92
92
  model: { provider: "anthropic", name: "claude-sonnet-4-6" },
93
93
  venues: ["futures"],
94
+ // Without `indicators` the event_driven gate has no setups to fire on and
95
+ // a fresh flat agent heartbeats forever with ZERO model calls (audit
96
+ // 2026-08-19: every scaffold was born dormant). Optional extras a user
97
+ // can add: "universe_scan" (top-movers discovery beyond the watchlist)
98
+ // and "news" (catalyst context for its coins).
99
+ capabilities: ["indicators"],
94
100
  risk: {
95
101
  maxLeverage: p.leverage,
96
102
  perTradeMarginMusd: p.margin,
@@ -31,6 +31,7 @@ export interface RiskConfig {
31
31
  requireStopLoss: boolean;
32
32
  watchlist: string[];
33
33
  blocklist?: string[];
34
+ direction?: "long_only" | "short_only";
34
35
  }
35
36
  export interface LimitsConfig {
36
37
  maxTradesPerDay: number;
@@ -61,7 +62,7 @@ export interface ObjectiveConfig {
61
62
  secondary: string[];
62
63
  horizon?: string;
63
64
  }
64
- export declare const ALLOWED_CAPABILITIES: readonly ["websearch", "indicators", "news"];
65
+ export declare const ALLOWED_CAPABILITIES: readonly ["websearch", "indicators", "news", "universe_scan"];
65
66
  export type Capability = (typeof ALLOWED_CAPABILITIES)[number];
66
67
  export interface AgentSpec {
67
68
  name: string;
@@ -106,6 +107,7 @@ export interface WatchEntry {
106
107
  sentimentBullishPct?: number;
107
108
  freshness?: Freshness;
108
109
  indicators?: IndicatorSet;
110
+ discovered?: boolean;
109
111
  }
110
112
  export interface OpenPosition {
111
113
  venue: Venue;
@@ -197,6 +199,12 @@ export interface Observation {
197
199
  newClosedTrades: Array<Record<string, unknown>>;
198
200
  polledBeforeWrite: boolean;
199
201
  news?: NewsItem[];
202
+ universeMovers?: Array<{
203
+ symbol: string;
204
+ name?: string;
205
+ change24hPct?: number;
206
+ priceUsd?: number;
207
+ }>;
200
208
  }
201
209
  export type ProposedAction = {
202
210
  type: "futures_open";
@@ -290,6 +298,8 @@ export interface RunState {
290
298
  llmCallTimestamps?: number[];
291
299
  lastLlmCallAt?: number;
292
300
  lastTriggerFingerprint?: string;
301
+ consecutivePermanentModelErrors?: number;
302
+ consecutiveAuthFailures?: number;
293
303
  journal?: Array<{
294
304
  at: string;
295
305
  did: string;
@@ -331,6 +341,16 @@ export interface CycleResult {
331
341
  modelFailed?: boolean;
332
342
  disabled?: boolean;
333
343
  disabledReason?: string;
344
+ /** Reliability slice 1 (2026-08-26): a PERMANENT provider/model failure
345
+ * (404/410/decommission class) no longer disables the agent. The runner
346
+ * reports the hold; the scheduler aggregates holds into a fleet-wide
347
+ * provider circuit (skip-claiming + backoff probes). User pauses, revoked
348
+ * credentials, drawdown and safety stops keep using `disabled`. */
349
+ providerHold?: {
350
+ provider: string;
351
+ model: string;
352
+ error: string;
353
+ };
334
354
  live: boolean;
335
355
  observationHash?: string;
336
356
  indicatorVersion?: string;
@@ -339,6 +359,19 @@ export interface CycleResult {
339
359
  tokensIn?: number;
340
360
  tokensOut?: number;
341
361
  estimatedCostUsd?: number;
362
+ effectiveProvider?: string;
363
+ effectiveModel?: string;
364
+ routeReason?: string;
365
+ routeAttempts?: Array<{
366
+ provider: string;
367
+ model: string;
368
+ outcome: "success" | "failed" | "deferred";
369
+ failureClass?: "capacity" | "permanent" | "transient" | "malformed";
370
+ status?: number;
371
+ retryAfterMs?: number;
372
+ latencyMs: number;
373
+ error?: string;
374
+ }>;
342
375
  decisionType?: "act" | "skip" | "gate_skip" | "model_error";
343
376
  writeAttempted?: number;
344
377
  writeAccepted?: number;
@@ -50,10 +50,20 @@ export const OBJECTIVE_PRIMARIES = [
50
50
  // slice. `websearch` = external lookups (an injection surface + a cost — it can
51
51
  // inform reasoning but NEVER widen a cap, since caps live in the runner);
52
52
  // `indicators` = runner-computed RSI/MACD/etc. fed into the observation.
53
+ // `universe_scan` (2026-08-18, direct user request): each cycle the runner
54
+ // pulls the top 24h movers across CoinRithm's tracked coin universe, resolves
55
+ // the top few into FULL watch entries (price, sentiment, indicators when that
56
+ // capability is also on) and appends them to the observation marked
57
+ // `discovered: true`. Downstream is unchanged by design: a discovered entry
58
+ // passes through the exact same risk gates as a watchlist symbol (blocklist
59
+ // still wins, caps/SL rules unchanged) — the capability widens the CANDIDATE
60
+ // SET for one cycle, never any cap. Off by default; without it the universe
61
+ // is invisible and only manual watchlist pairs are analyzed.
53
62
  export const ALLOWED_CAPABILITIES = [
54
63
  "websearch",
55
64
  "indicators",
56
65
  "news",
66
+ "universe_scan",
57
67
  ];
58
68
  export const ok = () => ({ valid: true });
59
69
  export const fail = (code, reason) => ({
@@ -5,7 +5,7 @@ export declare const COINRITHM_API: {
5
5
  readonly kind: "coinrithm-agent-api";
6
6
  readonly baseUrl: "https://api.coinrithm.com";
7
7
  readonly mcpUrl: "https://mcp.coinrithm.com/mcp";
8
- readonly openapiVersion: "1.6.0";
8
+ readonly openapiVersion: "1.7.0";
9
9
  readonly mcpPackage: "@coinrithm/mcp-trading";
10
10
  readonly mcpVersion: string;
11
11
  };
@@ -20,7 +20,7 @@ export const COINRITHM_API = {
20
20
  mcpUrl: "https://mcp.coinrithm.com/mcp",
21
21
  // The API CONTRACT version (openapi.yaml info.version). Versioned independently
22
22
  // from the npm package below — hand-bump this when the OpenAPI contract changes.
23
- openapiVersion: "1.6.0",
23
+ openapiVersion: "1.7.0",
24
24
  mcpPackage: "@coinrithm/mcp-trading",
25
25
  mcpVersion: PACKAGE_VERSION,
26
26
  };
package/dist/client.d.ts CHANGED
@@ -83,6 +83,7 @@ export declare class CoinRithmClient {
83
83
  }): Promise<ApiResult>;
84
84
  getPublicPmCanonicalDetail(key: string): Promise<ApiResult>;
85
85
  getPublicPmVolumeHistory(): Promise<ApiResult>;
86
+ getPublicCryptoMovers(direction: "gainers" | "losers", limit?: number): Promise<ApiResult>;
86
87
  whoami(apiKey?: string, agentTrace?: AgentTrace): Promise<ApiResult>;
87
88
  getPortfolio(query?: {
88
89
  fiat?: string;
package/dist/client.js CHANGED
@@ -236,6 +236,16 @@ export class CoinRithmClient {
236
236
  getPublicPmVolumeHistory() {
237
237
  return this.publicRequest("/api/prediction-markets/volume-history");
238
238
  }
239
+ // ---- public crypto data (no key required) ----
240
+ // Top 24h movers across the tracked coin universe (user feature request,
241
+ // 2026-08-18: agents previously could only analyze manually-added pairs).
242
+ // Backend caps limit at 100; rows are {ucid, symbol, name, slug, change24h,
243
+ // currentPrice} ordered by 24h change.
244
+ getPublicCryptoMovers(direction, limit) {
245
+ return this.publicRequest(direction === "losers"
246
+ ? "/api/coins/top-losers"
247
+ : "/api/coins/top-gainers", { limit });
248
+ }
239
249
  // Every method takes an optional trailing `apiKey` (the per-request key for
240
250
  // the multi-user HTTP path). When omitted, the constructor key (stdio) is used.
241
251
  // ---- reads (scope: read) ----
package/dist/http.js CHANGED
@@ -79,6 +79,27 @@ async function main() {
79
79
  },
80
80
  });
81
81
  });
82
+ // robots.txt for THIS host. robots.txt is per-HOST, so www.coinrithm.com's
83
+ // file never governed mcp.coinrithm.com — a separate origin that had no
84
+ // route of its own. The origin 404'd and Cloudflare answered with its
85
+ // managed content-signals boilerplate: 1,248 bytes of comments carrying ZERO
86
+ // User-agent/Disallow/Allow lines, which a crawler reads as "crawl
87
+ // everything". That is the identical failure that cost api.coinrithm.com
88
+ // 15.4% of the site's 90-day crawl budget (4,468 of 29,100 GSC requests)
89
+ // before it was closed on 2026-08-20.
90
+ //
91
+ // Nothing here is indexable: GET / is a JSON service descriptor, GET /mcp is
92
+ // a 405, and the real surface is POST-only streamable HTTP. The human-facing
93
+ // documentation crawlers should index lives on www.coinrithm.com
94
+ // (/en/agentic-trading, /en/prediction-markets/api), which links here.
95
+ //
96
+ // SAFE FOR MCP CLIENTS AND REGISTRIES: robots.txt is advisory to CRAWLERS
97
+ // only. MCP clients, Smithery and the MCP registry POST /mcp or GET /healthz
98
+ // directly and never consult robots.txt, so this cannot gate discovery,
99
+ // initialization or tool listing. Do not "fix" a registry problem here.
100
+ app.get("/robots.txt", (_req, res) => {
101
+ res.type("text/plain").send("User-agent: *\nDisallow: /\n");
102
+ });
82
103
  app.get("/mcp", (_req, res) => {
83
104
  res.status(405).json({
84
105
  error: "method_not_allowed",
package/dist/tools.d.ts CHANGED
@@ -14,6 +14,7 @@ export declare function compactPublicPmEvents(data: unknown): unknown;
14
14
  */
15
15
  export declare function compactPublicPmEvent(data: unknown): unknown;
16
16
  export declare function compactPublicPmWhales(data: unknown, limit: number): unknown;
17
+ export declare function compactPublicCryptoMovers(data: unknown): unknown;
17
18
  /**
18
19
  * Keep cross-venue disagreement clusters small enough for an agent context
19
20
  * window: each event is reduced to eventSummary (drops descriptions, images,