@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.
package/dist/agent/cli.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import { mkdirSync, writeFileSync, existsSync, statSync, readFileSync, openSync, closeSync, unlinkSync, } from "node:fs";
8
8
  import { resolve as resolvePath, dirname, join, basename } from "node:path";
9
9
  import { parse as parseYaml } from "yaml";
10
- import { resolveAgent, ResolveError, mergeProseParts, isSkillProseSource, } from "./resolve.js";
10
+ import { resolveAgent, ResolveError, mergeProseParts, isSkillProseSource, hostedProseBudget, HOSTED_PROSE_MAX_CHARS, } from "./resolve.js";
11
11
  import { buildSpec, loadAgent } from "./skill.js";
12
12
  import { validateSkill } from "./skillValidator.js";
13
13
  import { strictLint } from "./strictLint.js";
@@ -101,6 +101,27 @@ export function cmdValidate(path, mode = "self-host") {
101
101
  const spec = buildSpec(raw);
102
102
  const lint = [...strictLint(raw), ...checkCapabilityDrift(resolved, spec)];
103
103
  const v = validateSkill({ spec, body: resolved.mergedProse, raw }, mode);
104
+ // Hosted-only: the managed deploy/edit API caps the merged strategy prose at
105
+ // HOSTED_PROSE_MAX_CHARS and REVERTS the save when it is exceeded, so a
106
+ // bundle that resolves and lints perfectly can still be undeployable through
107
+ // the Studio. Measured 2026-08-19 after a user hit the wall: 4 of 9 example
108
+ // bundles were over (contrarian-carl 8,159, mia 8,175, olivia 8,587,
109
+ // pia-pump-fader 11,787) while the corpus README claimed they all pass
110
+ // `validate --hosted`. Checking it here is what makes that claim true and
111
+ // stops the corpus drifting back over the wall.
112
+ if (mode === "hosted") {
113
+ const budget = hostedProseBudget(resolved.mergedProse);
114
+ if (!budget.fits) {
115
+ lint.push({
116
+ code: "hosted_prose_too_long",
117
+ path: "character/*.md",
118
+ message: `merged strategy prose is ${budget.used} chars, ${budget.over} over the hosted ` +
119
+ `limit of ${HOSTED_PROSE_MAX_CHARS} — the managed deploy would reject this and ` +
120
+ `revert to the template. Self-host has no such cap. Note the count includes a ` +
121
+ `"<!-- path -->" header per prose file, not just the bodies.`,
122
+ });
123
+ }
124
+ }
104
125
  const lintFatal = mode === "hosted";
105
126
  const lines = [];
106
127
  for (const i of lint) {
@@ -21,6 +21,7 @@ export interface ClientConfig {
21
21
  fetchFn?: typeof fetch;
22
22
  sleepFn?: (ms: number) => Promise<void>;
23
23
  maxRetries?: number;
24
+ extraHeaders?: Record<string, string>;
24
25
  }
25
26
  export declare class CoinRithmClient {
26
27
  private readonly apiKey;
@@ -28,6 +29,7 @@ export declare class CoinRithmClient {
28
29
  private readonly fetchFn;
29
30
  private readonly sleepFn;
30
31
  private readonly maxRetries;
32
+ private readonly extraHeaders?;
31
33
  rateLimitHits: number;
32
34
  constructor(cfg: ClientConfig);
33
35
  private request;
@@ -37,6 +39,7 @@ export declare class CoinRithmClient {
37
39
  coinId?: string;
38
40
  }, trace?: AgentTrace): Promise<ApiResult>;
39
41
  resolve(q: string, trace?: AgentTrace): Promise<ApiResult>;
42
+ cryptoMovers(direction: "gainers" | "losers", limit: number, trace?: AgentTrace): Promise<ApiResult>;
40
43
  market(coinId: string, trace?: AgentTrace): Promise<ApiResult>;
41
44
  candles(coinId: string, range: string, trace?: AgentTrace): Promise<ApiResult>;
42
45
  trades(query?: {
@@ -30,6 +30,7 @@ export class CoinRithmClient {
30
30
  fetchFn;
31
31
  sleepFn;
32
32
  maxRetries;
33
+ extraHeaders;
33
34
  // Every 429 seen this session (read or write, retried or not) — feeds the
34
35
  // rate-limit-pressure kill-switch, which a write-only counter would miss.
35
36
  rateLimitHits = 0;
@@ -39,6 +40,7 @@ export class CoinRithmClient {
39
40
  this.fetchFn = cfg.fetchFn ?? fetch;
40
41
  this.sleepFn = cfg.sleepFn ?? realSleep;
41
42
  this.maxRetries = cfg.maxRetries ?? 3;
43
+ this.extraHeaders = cfg.extraHeaders;
42
44
  }
43
45
  async request(method, path, opts = {}) {
44
46
  const url = new URL(this.baseUrl + path);
@@ -48,7 +50,9 @@ export class CoinRithmClient {
48
50
  url.searchParams.set(k, String(v));
49
51
  }
50
52
  }
53
+ // extraHeaders first: auth, accept and trace can never be clobbered by it.
51
54
  const headers = {
55
+ ...this.extraHeaders,
52
56
  Authorization: `Bearer ${this.apiKey}`,
53
57
  Accept: "application/json",
54
58
  ...traceHeaders(opts.trace),
@@ -116,6 +120,13 @@ export class CoinRithmClient {
116
120
  resolve(q, trace) {
117
121
  return this.request("GET", "/api/agent/resolve", { query: { q }, trace });
118
122
  }
123
+ // Keyless public universe scan (top 24h movers). The Bearer header rides
124
+ // along harmlessly — the /api/coins routes are public and ignore it.
125
+ cryptoMovers(direction, limit, trace) {
126
+ return this.request("GET", direction === "losers"
127
+ ? "/api/coins/top-losers"
128
+ : "/api/coins/top-gainers", { query: { limit }, trace });
129
+ }
119
130
  market(coinId, trace) {
120
131
  return this.request("GET", `/api/agent/market/${encodeURIComponent(coinId)}`, { trace });
121
132
  }
@@ -53,16 +53,22 @@ export declare const actionSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<
53
53
  positionId: z.ZodEffects<z.ZodTypeAny, any, unknown>;
54
54
  stopLossPrice: z.ZodOptional<z.ZodNullable<z.ZodEffects<z.ZodTypeAny, any, unknown>>>;
55
55
  takeProfitPrice: z.ZodOptional<z.ZodNullable<z.ZodEffects<z.ZodTypeAny, any, unknown>>>;
56
+ confidence: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodEffects<z.ZodTypeAny, any, unknown>>>, any, unknown>;
57
+ rationaleSummary: z.ZodOptional<z.ZodString>;
56
58
  }, "strict", z.ZodTypeAny, {
57
59
  type: "futures_set_sltp";
58
60
  stopLossPrice?: any;
59
61
  takeProfitPrice?: any;
60
62
  positionId?: any;
63
+ confidence?: any;
64
+ rationaleSummary?: string | undefined;
61
65
  }, {
62
66
  type: "futures_set_sltp";
63
67
  stopLossPrice?: unknown;
64
68
  takeProfitPrice?: unknown;
65
69
  positionId?: unknown;
70
+ confidence?: unknown;
71
+ rationaleSummary?: string | undefined;
66
72
  }>, z.ZodObject<{
67
73
  type: z.ZodLiteral<"spot_order">;
68
74
  symbol: z.ZodString;
@@ -96,11 +102,17 @@ export declare const actionSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<
96
102
  }>, z.ZodObject<{
97
103
  type: z.ZodLiteral<"spot_cancel">;
98
104
  orderId: z.ZodEffects<z.ZodTypeAny, any, unknown>;
105
+ confidence: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodEffects<z.ZodTypeAny, any, unknown>>>, any, unknown>;
106
+ rationaleSummary: z.ZodOptional<z.ZodString>;
99
107
  }, "strict", z.ZodTypeAny, {
100
108
  type: "spot_cancel";
109
+ confidence?: any;
110
+ rationaleSummary?: string | undefined;
101
111
  orderId?: any;
102
112
  }, {
103
113
  type: "spot_cancel";
114
+ confidence?: unknown;
115
+ rationaleSummary?: string | undefined;
104
116
  orderId?: unknown;
105
117
  }>, z.ZodObject<{
106
118
  type: z.ZodLiteral<"pm_open">;
@@ -59,6 +59,13 @@ const futuresSetSltp = z
59
59
  positionId: num(z.number()),
60
60
  stopLossPrice: num(z.number()).nullable().optional(),
61
61
  takeProfitPrice: num(z.number()).nullable().optional(),
62
+ // Accepted-and-unused: the output contract asks for per-action confidence,
63
+ // so models copy it onto EVERY action. The two schemas that lacked it were
64
+ // fail-closing whole decisions under .strict() (~180-200 discarded
65
+ // fleet-wide per day, live-measured 2026-08-19). Same tolerance the four
66
+ // trade actions already have.
67
+ confidence,
68
+ rationaleSummary: z.string().optional(),
62
69
  })
63
70
  .strict();
64
71
  const spotOrder = z
@@ -78,6 +85,9 @@ const spotCancel = z
78
85
  .object({
79
86
  type: z.literal("spot_cancel"),
80
87
  orderId: num(z.number()),
88
+ // Same accepted-and-unused tolerance as futures_set_sltp above.
89
+ confidence,
90
+ rationaleSummary: z.string().optional(),
81
91
  })
82
92
  .strict();
83
93
  // pm_open accepts EITHER a short ref (pm1…pmN, what the prompt now asks for) OR
@@ -0,0 +1,17 @@
1
+ import { ProviderName } from "./types.js";
2
+ export interface ProbeRoute {
3
+ provider: ProviderName;
4
+ model: string;
5
+ baseUrl?: string | null;
6
+ key: string;
7
+ }
8
+ export type ProbeDecisionResult = {
9
+ ok: true;
10
+ } | {
11
+ ok: false;
12
+ stage: "http" | "empty" | "parse";
13
+ error: string;
14
+ status?: number;
15
+ retryAfterMs?: number;
16
+ };
17
+ export declare function probeDecisionContract(route: ProbeRoute, fetchFn?: typeof fetch): Promise<ProbeDecisionResult>;
@@ -0,0 +1,70 @@
1
+ // Representative decision probe (reliability slice A, contract frozen on
2
+ // Telegram 2026-08-26). An HTTP-200 chat ping is NOT proof a route can run an
3
+ // agent: the 62f3a12 incident had 200s all round while every cycle failed with
4
+ // "Unexpected token W" (think-chain in the JSON slot), and Codex's gpt-5-nano
5
+ // probe at 256 completion tokens returned EMPTY content with a length finish
6
+ // because reasoning consumed the budget. A route is eligible only when a real
7
+ // call comes back parseable through the REAL decision parser with a non-empty
8
+ // decision — using the exact request shape a cycle would send
9
+ // (providerForRoute -> the same provider classes as the runner).
10
+ //
11
+ // Uses: boot eligibility of fallback-chain targets (slice B), circuit
12
+ // half-open reopens, any future model migration (D18: probe before adopt).
13
+ // The key is used for the one call and never logged; provider error text is
14
+ // sanitized before it can reach any log or ledger row.
15
+ import { providerForRoute } from "./providers.js";
16
+ import { chatShapeFor } from "./providerCapabilities.js";
17
+ import { parseDecision } from "./decision.js";
18
+ // A canned mini-observation whose ONLY correct answer is a tiny skip decision.
19
+ // Small enough to cost nothing, real enough to exercise the full JSON contract.
20
+ const PROBE_SYSTEM = [
21
+ "You are a trading agent contract probe.",
22
+ 'Reply with EXACTLY one JSON object: {"decision":"skip","reason":"contract probe"}.',
23
+ "No prose, no code fences, no additional keys.",
24
+ ].join(" ");
25
+ const PROBE_USER = "Observation: BTC 24h change 0.0%. Confirm the decision contract.";
26
+ const PROBE_TIMEOUT_MS = 30_000;
27
+ /** Strip the key (and bearer echoes) out of any text a probe might surface. */
28
+ function sanitize(text, key) {
29
+ let out = (text ?? "").slice(0, 400);
30
+ if (key)
31
+ out = out.split(key).join("***");
32
+ out = out.replace(/Bearer\s+[A-Za-z0-9._-]{8,}/g, "Bearer ***");
33
+ return out.slice(0, 200);
34
+ }
35
+ export async function probeDecisionContract(route, fetchFn = fetch) {
36
+ const shape = chatShapeFor(route.provider, route.model, route.baseUrl ?? undefined);
37
+ const provider = providerForRoute(route, route.key, fetchFn);
38
+ const res = await provider.decide({
39
+ system: PROBE_SYSTEM,
40
+ user: PROBE_USER,
41
+ // Reasoning models spend hidden tokens first — grant at least the family
42
+ // floor (1024) or the empty-with-length-finish false negative comes back.
43
+ maxTokens: Math.max(1024, shape.minProbeCompletionTokens),
44
+ timeoutMs: PROBE_TIMEOUT_MS,
45
+ });
46
+ if (!res.ok) {
47
+ const error = sanitize(res.error, route.key);
48
+ // Provider classes report empty 2xx content as "... returned empty content".
49
+ const stage = /returned empty content/i.test(res.error) ? "empty" : "http";
50
+ return {
51
+ ok: false,
52
+ stage,
53
+ error,
54
+ status: res.status,
55
+ retryAfterMs: res.retryAfterMs,
56
+ };
57
+ }
58
+ if (!res.text.trim()) {
59
+ return { ok: false, stage: "empty", error: "empty completion" };
60
+ }
61
+ const parsed = parseDecision(res.text);
62
+ if (!parsed.ok) {
63
+ return {
64
+ ok: false,
65
+ stage: "parse",
66
+ error: sanitize(parsed.error, route.key),
67
+ };
68
+ }
69
+ return { ok: true };
70
+ }
@@ -36,6 +36,29 @@ export function validateAction(action, ctx) {
36
36
  }
37
37
  }
38
38
  }
39
+ // Direction constraint (2026-08-24): a strategy's side restriction is a HARD
40
+ // cap, not prose-obedience. Live incident: a short-only fade agent opened
41
+ // two momentum LONGS when the flagged-setups act-pressure outweighed its
42
+ // prose. Closes/SL-TP/cancels are never direction-gated — reducing or
43
+ // protecting an existing position is not a directional bet.
44
+ const direction = spec.risk.direction;
45
+ if (direction) {
46
+ if (action.type === "futures_open") {
47
+ if (direction === "short_only" && action.side !== "short") {
48
+ return fail("direction_constraint", `direction ${direction}: futures_open side must be "short", got "${action.side}"`);
49
+ }
50
+ if (direction === "long_only" && action.side !== "long") {
51
+ return fail("direction_constraint", `direction ${direction}: futures_open side must be "long", got "${action.side}"`);
52
+ }
53
+ }
54
+ // Spot buys are long exposure; a short_only agent must not accumulate
55
+ // them. Spot sells reduce a holding and stay allowed.
56
+ if (action.type === "spot_order" &&
57
+ direction === "short_only" &&
58
+ action.side === "buy") {
59
+ return fail("direction_constraint", `direction ${direction}: spot buys are long exposure`);
60
+ }
61
+ }
39
62
  if (action.type === "futures_open") {
40
63
  // Daily realized-loss stop: once today's loss hits the cap, open no new risk.
41
64
  if (spec.limits.maxDailyLossMusd > 0 &&
@@ -1,5 +1,8 @@
1
1
  export { runCycle, type RunnerDeps } from "./runner.js";
2
- export { selectProvider, type ProviderEnv, type Provider, } from "./providers.js";
2
+ export { selectProvider, providerForRoute, type ProviderEnv, type Provider, type DecideInput, type DecideResult, type DecideRouteAttempt, type DecideRouteMeta, } from "./providers.js";
3
+ export { parseDecision } from "./decision.js";
4
+ export { chatShapeFor, buildChatBody, type ChatShape, } from "./providerCapabilities.js";
5
+ export { probeDecisionContract, type ProbeRoute, type ProbeDecisionResult, } from "./decisionProbe.js";
3
6
  export { CoinRithmClient } from "./client.js";
4
7
  export { loadAgent, buildSpec, type LoadedAgent } from "./skill.js";
5
8
  export { resolveAgent } from "./resolve.js";
@@ -6,7 +6,13 @@
6
6
  // This barrel is the ONE import a host scheduler needs; it re-exports only the
7
7
  // stable engine pieces, never the CLI.
8
8
  export { runCycle } from "./runner.js";
9
- export { selectProvider, } from "./providers.js";
9
+ export { selectProvider, providerForRoute, } from "./providers.js";
10
+ export { parseDecision } from "./decision.js";
11
+ // Reliability slice A: the declarative request-capability table and the
12
+ // representative decision probe (route eligibility = a REAL parsed decision,
13
+ // never a bare HTTP 200 — the 62f3a12 lesson).
14
+ export { chatShapeFor, buildChatBody, } from "./providerCapabilities.js";
15
+ export { probeDecisionContract, } from "./decisionProbe.js";
10
16
  export { CoinRithmClient } from "./client.js";
11
17
  export { loadAgent, buildSpec } from "./skill.js";
12
18
  export { resolveAgent } from "./resolve.js";
@@ -8,6 +8,29 @@ import { scanSetups } from "./setups.js";
8
8
  // (~5-min fresh, ~288 bars — ample for EMA50/RSI14/Bollinger20), which suits the
9
9
  // short cadence the hosted house agents run on. Probe-verified 2026-06-17.
10
10
  const INDICATOR_RANGE = "1D";
11
+ // `universe_scan` bounds: how many top movers to pull, and how many of those
12
+ // to fully resolve into tradable watch entries (each resolved row costs a
13
+ // resolve + market [+ candles] call).
14
+ //
15
+ // RESOLVE_TOP 3 -> 6 on 2026-08-21. Only a resolved row carries indicators, and
16
+ // therefore a `setups` flag; the unresolved remainder is bare symbol + 24h
17
+ // change + price. At 3, a discovery-driven strategy could reason properly about
18
+ // exactly three coins per cycle out of fifteen surfaced.
19
+ //
20
+ // That bit a real user case. A pump-fade agent identifies a candidate from a
21
+ // `stretched`/`fade-short` setup (RSI14 >= 68) and then waits for exhaustion,
22
+ // which by definition means RSI is NO LONGER extreme. Nothing persists between
23
+ // cycles, so the candidate has to still be a resolved row at the moment the
24
+ // exhaustion evidence appears. Discovery is gainers-ranked, so a retracing coin
25
+ // slides down the list — at 3 it fell out almost immediately and went blind
26
+ // exactly when the strategy needed to look at it.
27
+ //
28
+ // 6 roughly doubles how far a coin can slide before losing its indicators. Cost
29
+ // is 3 extra market+candles calls per cycle against CoinRithm's own API (never
30
+ // the model quota) and ~3 more watch entries in the prompt, and only for agents
31
+ // that declare universe_scan.
32
+ const UNIVERSE_SCAN_LIMIT = 15;
33
+ const UNIVERSE_RESOLVE_TOP = 6;
11
34
  // Watchlist symbols -> the coin NAMES prediction-market titles use, so an agent
12
35
  // discovers PM markets about the coins it actually has a price view on.
13
36
  const PM_COIN_NAMES = {
@@ -42,7 +65,16 @@ export function isCalibrationChurnMarket(market) {
42
65
  // Tolerant by design: any failure (HTTP error, malformed/sparse candles) returns
43
66
  // null so the cycle proceeds with price-only context rather than skipping.
44
67
  async function fetchIndicators(client, coinId, trace) {
45
- const cr = await client.candles(coinId, INDICATOR_RANGE, trace);
68
+ // The try honors the documented tolerance for SYNCHRONOUS throws too (an
69
+ // unexpected client error must degrade to price-only context, never kill
70
+ // the cycle).
71
+ let cr;
72
+ try {
73
+ cr = await client.candles(coinId, INDICATOR_RANGE, trace);
74
+ }
75
+ catch {
76
+ return null;
77
+ }
46
78
  if (!cr.ok)
47
79
  return null;
48
80
  // Endpoint shape: { candles: [{ t, o, h, l, c, v }] } ascending (oldest first).
@@ -272,6 +304,78 @@ export async function observe(client, spec, state, trace) {
272
304
  }
273
305
  watch.push(entry);
274
306
  }
307
+ // `universe_scan` capability (2026-08-18, direct user request): discover the
308
+ // top 24h movers across the whole tracked universe, resolve the strongest
309
+ // few into FULL watch entries (marked discovered) and pass the remainder as
310
+ // compact context. Bounds: one movers call + up to
311
+ // UNIVERSE_RESOLVE_TOP resolve/market(+candles) calls per cycle — the same
312
+ // per-symbol cost as ~3 extra watchlist rows, all against CoinRithm's own
313
+ // API (never the model quota). Failures degrade to "no universe section",
314
+ // never a skipped cycle. Watchlist + blocklist symbols are excluded up
315
+ // front so a discovered row can never duplicate or bypass the deny-list.
316
+ let universeMovers;
317
+ if (spec.capabilities.includes("universe_scan")) {
318
+ const mv = await client.cryptoMovers("gainers", UNIVERSE_SCAN_LIMIT, trace);
319
+ if (mv.ok && Array.isArray(mv.data)) {
320
+ const excluded = new Set([...spec.risk.watchlist, ...(spec.risk.blocklist ?? [])].map((s) => s.toUpperCase()));
321
+ const rows = mv.data
322
+ .map(asObj)
323
+ .map((r) => ({
324
+ symbol: (asStr(r.symbol) ?? "").toUpperCase(),
325
+ name: asStr(r.name),
326
+ change24hPct: asNum(r.change24h),
327
+ priceUsd: asNum(r.currentPrice),
328
+ // The movers row already carries the ucid, which IS the coinId every
329
+ // downstream call takes. Kept so the resolve round-trip below can be
330
+ // skipped — see the comment there.
331
+ coinId: asStr(r.ucid),
332
+ }))
333
+ .filter((r) => r.symbol && !excluded.has(r.symbol));
334
+ const resolveTop = rows.slice(0, UNIVERSE_RESOLVE_TOP);
335
+ for (const row of resolveTop) {
336
+ // Prefer the ucid the movers feed already gave us. Resolving the
337
+ // SYMBOL instead was both a wasted call per discovered mover and a
338
+ // correctness hazard: symbols collide across listings, so the resolver
339
+ // could hand back a different coin than the one that actually moved,
340
+ // and the agent would analyze (and trade) that other coin.
341
+ let coinId = row.coinId;
342
+ let resolvedName;
343
+ if (!coinId) {
344
+ const rs = await client.resolve(row.symbol, trace);
345
+ const match = asObj(asObj(rs.data).match);
346
+ coinId =
347
+ rs.ok && match.coinId != null ? String(match.coinId) : undefined;
348
+ resolvedName = asStr(match.name);
349
+ }
350
+ if (!coinId)
351
+ continue;
352
+ const mk = await client.market(coinId, trace);
353
+ const m = asObj(mk.data);
354
+ const price = asObj(m.price);
355
+ const entry = {
356
+ symbol: row.symbol,
357
+ coinId,
358
+ name: resolvedName ?? row.name ?? undefined,
359
+ priceUsd: asNum(price.usd) ?? row.priceUsd,
360
+ change1h: asNum(price.change1h),
361
+ change24h: asNum(price.change24h) ?? row.change24hPct,
362
+ change7d: asNum(price.change7d),
363
+ sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
364
+ freshness: freshnessOf(asObj(m.observation)),
365
+ discovered: true,
366
+ };
367
+ if (wantIndicators) {
368
+ const ind = await fetchIndicators(client, coinId, trace);
369
+ if (ind)
370
+ entry.indicators = ind;
371
+ }
372
+ watch.push(entry);
373
+ }
374
+ const context = rows.slice(UNIVERSE_RESOLVE_TOP);
375
+ if (context.length > 0)
376
+ universeMovers = context;
377
+ }
378
+ }
275
379
  // Spot resting orders (for cancel + affordability) — only if spot is enabled.
276
380
  const wantSpot = spec.venues.includes("spot");
277
381
  const wantPm = spec.venues.includes("pm");
@@ -451,12 +555,19 @@ export async function observe(client, spec, state, trace) {
451
555
  }
452
556
  }
453
557
  // News context (only with the `news` capability): recent high-importance news
454
- // for the watchlist coins, fed into the decide prompt as a market-catalyst
455
- // layer the price chart can't show. One cached call; degrades to no news on
456
- // failure (never blocks a cycle).
558
+ // for the coins the agent is actually LOOKING AT this cycle — the watch array,
559
+ // which includes any `universe_scan`-discovered movers. Keying this to the
560
+ // static watchlist alone (the old behavior) starved exactly the case news
561
+ // exists for: a discovered pump whose catalyst the agent is supposed to
562
+ // investigate before acting (the pump-fade pattern, 2026-08-19). One cached
563
+ // call; degrades to no news on failure (never blocks a cycle).
457
564
  let news;
458
- if (wantNews && spec.risk.watchlist.length > 0) {
459
- const nr = await client.agentNews({ coins: spec.risk.watchlist.join(","), limit: 8, hours: 48 }, trace);
565
+ const newsCoins = Array.from(new Set([
566
+ ...spec.risk.watchlist,
567
+ ...watch.map((w) => w.symbol.toUpperCase()),
568
+ ]));
569
+ if (wantNews && newsCoins.length > 0) {
570
+ const nr = await client.agentNews({ coins: newsCoins.join(","), limit: 8, hours: 48 }, trace);
460
571
  if (nr.ok) {
461
572
  news = asArr(asObj(nr.data).items)
462
573
  .map(asObj)
@@ -495,6 +606,7 @@ export async function observe(client, spec, state, trace) {
495
606
  syncCursor,
496
607
  newClosedTrades,
497
608
  polledBeforeWrite,
609
+ universeMovers,
498
610
  };
499
611
  // Skip only when there is NOTHING actionable: no coin resolved (futures/spot)
500
612
  // AND no PM candidate (pm). A pm-only agent proceeds on its discovered markets.
@@ -6,4 +6,6 @@ export declare function buildSystemPrompt(spec: AgentSpec, mergedProse: string,
6
6
  export declare function buildUserPrompt(obs: Observation, journal?: Array<{
7
7
  at: string;
8
8
  did: string;
9
- }>): string;
9
+ }>, opts?: {
10
+ venues?: AgentSpec["venues"];
11
+ }): string;