@coinrithm/mcp-trading 0.7.4 → 0.7.6

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) {
@@ -37,6 +37,7 @@ export declare class CoinRithmClient {
37
37
  coinId?: string;
38
38
  }, trace?: AgentTrace): Promise<ApiResult>;
39
39
  resolve(q: string, trace?: AgentTrace): Promise<ApiResult>;
40
+ cryptoMovers(direction: "gainers" | "losers", limit: number, trace?: AgentTrace): Promise<ApiResult>;
40
41
  market(coinId: string, trace?: AgentTrace): Promise<ApiResult>;
41
42
  candles(coinId: string, range: string, trace?: AgentTrace): Promise<ApiResult>;
42
43
  trades(query?: {
@@ -18,6 +18,10 @@ function traceHeaders(trace) {
18
18
  h["X-CoinRithm-Strategy-Label"] = trace.strategyLabel;
19
19
  if (typeof trace.confidence === "number")
20
20
  h["X-CoinRithm-Confidence"] = String(trace.confidence);
21
+ if (trace.observationHash)
22
+ h["X-CoinRithm-Observation-Hash"] = trace.observationHash;
23
+ if (trace.indicatorVersion)
24
+ h["X-CoinRithm-Indicator-Version"] = trace.indicatorVersion;
21
25
  return h;
22
26
  }
23
27
  export class CoinRithmClient {
@@ -112,6 +116,13 @@ export class CoinRithmClient {
112
116
  resolve(q, trace) {
113
117
  return this.request("GET", "/api/agent/resolve", { query: { q }, trace });
114
118
  }
119
+ // Keyless public universe scan (top 24h movers). The Bearer header rides
120
+ // along harmlessly — the /api/coins routes are public and ignore it.
121
+ cryptoMovers(direction, limit, trace) {
122
+ return this.request("GET", direction === "losers"
123
+ ? "/api/coins/top-losers"
124
+ : "/api/coins/top-gainers", { query: { limit }, trace });
125
+ }
115
126
  market(coinId, trace) {
116
127
  return this.request("GET", `/api/agent/market/${encodeURIComponent(coinId)}`, { trace });
117
128
  }
@@ -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
@@ -1,3 +1,4 @@
1
+ export declare const INDICATOR_VERSION = "coinrithm.indicators.v1";
1
2
  export interface Candle {
2
3
  open: number;
3
4
  high: number;
@@ -10,6 +10,9 @@
10
10
  //
11
11
  // Every function returns null when there are too few candles, so callers can
12
12
  // omit an indicator from the observation rather than emit a misleading number.
13
+ // Bump whenever indicator inputs, defaults, formulas, rounding, or output
14
+ // semantics change. Stored beside each observation hash and in manifest.lock.
15
+ export const INDICATOR_VERSION = "coinrithm.indicators.v1";
13
16
  const closesOf = (c) => c.map((x) => x.close);
14
17
  const finite = (n) => Number.isFinite(n);
15
18
  // Simple moving average of the last `period` values.
@@ -4,6 +4,7 @@ export interface AgentManifest {
4
4
  spec: string;
5
5
  resolverVersion: string;
6
6
  runnerVersion: string;
7
+ indicatorVersion: string;
7
8
  resolvedConfig: Record<string, unknown>;
8
9
  resolvedSpec: AgentSpec;
9
10
  provenance: Provenance;
@@ -6,12 +6,14 @@ import { writeFileSync, mkdirSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { sha256, stableStringify, toPosix } from "./util.js";
8
8
  import { RESOLVER_VERSION, RUNNER_VERSION, MANIFEST_SCHEMA, } from "./version.js";
9
+ import { INDICATOR_VERSION } from "./indicators.js";
9
10
  export function buildManifest(resolved, spec) {
10
11
  // configHash binds the RESOLVED spec to the resolver + schema version, so a
11
12
  // run reproduces only against the same compile (not just the same files).
12
13
  const configHash = sha256(stableStringify({
13
14
  resolvedSpec: spec,
14
15
  resolverVersion: RESOLVER_VERSION,
16
+ indicatorVersion: INDICATOR_VERSION,
15
17
  schema: MANIFEST_SCHEMA,
16
18
  }));
17
19
  return {
@@ -19,6 +21,7 @@ export function buildManifest(resolved, spec) {
19
21
  spec: spec.spec || "coinrithm.agent.v1",
20
22
  resolverVersion: RESOLVER_VERSION,
21
23
  runnerVersion: RUNNER_VERSION,
24
+ indicatorVersion: INDICATOR_VERSION,
22
25
  resolvedConfig: resolved.rawFrontmatter,
23
26
  resolvedSpec: spec,
24
27
  provenance: resolved.provenance,
@@ -0,0 +1,6 @@
1
+ import type { Observation } from "./types.js";
2
+ export interface ObservationReceipt {
3
+ observationHash: string;
4
+ indicatorVersion: string;
5
+ }
6
+ export declare function buildObservationReceipt(observation: Observation): ObservationReceipt;
@@ -0,0 +1,10 @@
1
+ // A compact, privacy-safe receipt for the exact structured observation used by
2
+ // one decision. We persist the digest, never the full prompt or model output.
3
+ import { INDICATOR_VERSION } from "./indicators.js";
4
+ import { sha256, stableStringify } from "./util.js";
5
+ export function buildObservationReceipt(observation) {
6
+ return {
7
+ observationHash: sha256(stableStringify(observation)),
8
+ indicatorVersion: INDICATOR_VERSION,
9
+ };
10
+ }
@@ -8,6 +8,11 @@ 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
+ const UNIVERSE_SCAN_LIMIT = 15;
15
+ const UNIVERSE_RESOLVE_TOP = 3;
11
16
  // Watchlist symbols -> the coin NAMES prediction-market titles use, so an agent
12
17
  // discovers PM markets about the coins it actually has a price view on.
13
18
  const PM_COIN_NAMES = {
@@ -42,7 +47,16 @@ export function isCalibrationChurnMarket(market) {
42
47
  // Tolerant by design: any failure (HTTP error, malformed/sparse candles) returns
43
48
  // null so the cycle proceeds with price-only context rather than skipping.
44
49
  async function fetchIndicators(client, coinId, trace) {
45
- const cr = await client.candles(coinId, INDICATOR_RANGE, trace);
50
+ // The try honors the documented tolerance for SYNCHRONOUS throws too (an
51
+ // unexpected client error must degrade to price-only context, never kill
52
+ // the cycle).
53
+ let cr;
54
+ try {
55
+ cr = await client.candles(coinId, INDICATOR_RANGE, trace);
56
+ }
57
+ catch {
58
+ return null;
59
+ }
46
60
  if (!cr.ok)
47
61
  return null;
48
62
  // Endpoint shape: { candles: [{ t, o, h, l, c, v }] } ascending (oldest first).
@@ -272,6 +286,78 @@ export async function observe(client, spec, state, trace) {
272
286
  }
273
287
  watch.push(entry);
274
288
  }
289
+ // `universe_scan` capability (2026-08-18, direct user request): discover the
290
+ // top 24h movers across the whole tracked universe, resolve the strongest
291
+ // few into FULL watch entries (marked discovered) and pass the remainder as
292
+ // compact context. Bounds: one movers call + up to
293
+ // UNIVERSE_RESOLVE_TOP resolve/market(+candles) calls per cycle — the same
294
+ // per-symbol cost as ~3 extra watchlist rows, all against CoinRithm's own
295
+ // API (never the model quota). Failures degrade to "no universe section",
296
+ // never a skipped cycle. Watchlist + blocklist symbols are excluded up
297
+ // front so a discovered row can never duplicate or bypass the deny-list.
298
+ let universeMovers;
299
+ if (spec.capabilities.includes("universe_scan")) {
300
+ const mv = await client.cryptoMovers("gainers", UNIVERSE_SCAN_LIMIT, trace);
301
+ if (mv.ok && Array.isArray(mv.data)) {
302
+ const excluded = new Set([...spec.risk.watchlist, ...(spec.risk.blocklist ?? [])].map((s) => s.toUpperCase()));
303
+ const rows = mv.data
304
+ .map(asObj)
305
+ .map((r) => ({
306
+ symbol: (asStr(r.symbol) ?? "").toUpperCase(),
307
+ name: asStr(r.name),
308
+ change24hPct: asNum(r.change24h),
309
+ priceUsd: asNum(r.currentPrice),
310
+ // The movers row already carries the ucid, which IS the coinId every
311
+ // downstream call takes. Kept so the resolve round-trip below can be
312
+ // skipped — see the comment there.
313
+ coinId: asStr(r.ucid),
314
+ }))
315
+ .filter((r) => r.symbol && !excluded.has(r.symbol));
316
+ const resolveTop = rows.slice(0, UNIVERSE_RESOLVE_TOP);
317
+ for (const row of resolveTop) {
318
+ // Prefer the ucid the movers feed already gave us. Resolving the
319
+ // SYMBOL instead was both a wasted call per discovered mover and a
320
+ // correctness hazard: symbols collide across listings, so the resolver
321
+ // could hand back a different coin than the one that actually moved,
322
+ // and the agent would analyze (and trade) that other coin.
323
+ let coinId = row.coinId;
324
+ let resolvedName;
325
+ if (!coinId) {
326
+ const rs = await client.resolve(row.symbol, trace);
327
+ const match = asObj(asObj(rs.data).match);
328
+ coinId =
329
+ rs.ok && match.coinId != null ? String(match.coinId) : undefined;
330
+ resolvedName = asStr(match.name);
331
+ }
332
+ if (!coinId)
333
+ continue;
334
+ const mk = await client.market(coinId, trace);
335
+ const m = asObj(mk.data);
336
+ const price = asObj(m.price);
337
+ const entry = {
338
+ symbol: row.symbol,
339
+ coinId,
340
+ name: resolvedName ?? row.name ?? undefined,
341
+ priceUsd: asNum(price.usd) ?? row.priceUsd,
342
+ change1h: asNum(price.change1h),
343
+ change24h: asNum(price.change24h) ?? row.change24hPct,
344
+ change7d: asNum(price.change7d),
345
+ sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
346
+ freshness: freshnessOf(asObj(m.observation)),
347
+ discovered: true,
348
+ };
349
+ if (wantIndicators) {
350
+ const ind = await fetchIndicators(client, coinId, trace);
351
+ if (ind)
352
+ entry.indicators = ind;
353
+ }
354
+ watch.push(entry);
355
+ }
356
+ const context = rows.slice(UNIVERSE_RESOLVE_TOP);
357
+ if (context.length > 0)
358
+ universeMovers = context;
359
+ }
360
+ }
275
361
  // Spot resting orders (for cancel + affordability) — only if spot is enabled.
276
362
  const wantSpot = spec.venues.includes("spot");
277
363
  const wantPm = spec.venues.includes("pm");
@@ -451,12 +537,19 @@ export async function observe(client, spec, state, trace) {
451
537
  }
452
538
  }
453
539
  // 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).
540
+ // for the coins the agent is actually LOOKING AT this cycle — the watch array,
541
+ // which includes any `universe_scan`-discovered movers. Keying this to the
542
+ // static watchlist alone (the old behavior) starved exactly the case news
543
+ // exists for: a discovered pump whose catalyst the agent is supposed to
544
+ // investigate before acting (the pump-fade pattern, 2026-08-19). One cached
545
+ // call; degrades to no news on failure (never blocks a cycle).
457
546
  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);
547
+ const newsCoins = Array.from(new Set([
548
+ ...spec.risk.watchlist,
549
+ ...watch.map((w) => w.symbol.toUpperCase()),
550
+ ]));
551
+ if (wantNews && newsCoins.length > 0) {
552
+ const nr = await client.agentNews({ coins: newsCoins.join(","), limit: 8, hours: 48 }, trace);
460
553
  if (nr.ok) {
461
554
  news = asArr(asObj(nr.data).items)
462
555
  .map(asObj)
@@ -495,6 +588,7 @@ export async function observe(client, spec, state, trace) {
495
588
  syncCursor,
496
589
  newClosedTrades,
497
590
  polledBeforeWrite,
591
+ universeMovers,
498
592
  };
499
593
  // Skip only when there is NOTHING actionable: no coin resolved (futures/spot)
500
594
  // AND no PM candidate (pm). A pm-only agent proceeds on its discovered markets.
@@ -63,7 +63,15 @@ opts = {}) {
63
63
  `- venues you may act in: ${v.join(", ")}`,
64
64
  `- perTradeMarginMusd ${r.perTradeMarginMusd} is the per-trade SIZE cap (futures margin / spot buy notional / PM stake)`,
65
65
  `- futures: maxLeverage ${r.maxLeverage}, maxConcurrentPositions ${r.maxConcurrentPositions}, requireStopLoss ${r.requireStopLoss} (long stop below entry, short stop above)`,
66
- `- watchlist (spot + futures use ONLY these): ${r.watchlist.join(", ")}`,
66
+ // With universe_scan, the validator's gate is WATCH-membership (manual
67
+ // watchlist ∪ this cycle's discovered entries) — saying "ONLY these" here
68
+ // while the universe-scan section below calls discovered movers tradable
69
+ // made cap-obedient models refuse every discovered candidate (the caps
70
+ // header says proposing outside a cap wastes the cycle). Keep the two
71
+ // sections telling one story.
72
+ spec.capabilities.includes("universe_scan")
73
+ ? `- tradable symbols (spot + futures): your watchlist (${r.watchlist.join(", ")}) PLUS this cycle's watch entries marked \`discovered: true\` — nothing outside those`
74
+ : `- watchlist (spot + futures use ONLY these): ${r.watchlist.join(", ")}`,
67
75
  ...(r.blocklist && r.blocklist.length > 0
68
76
  ? [
69
77
  `- deny-list (NEVER open these, even if on the watchlist): ${r.blocklist.join(", ")}`,
@@ -87,6 +95,15 @@ opts = {}) {
87
95
  "- a null field = not enough data; ignore it. These INFORM your decision; they never widen a cap.",
88
96
  ]
89
97
  : []),
98
+ ...(spec.capabilities.includes("universe_scan")
99
+ ? [
100
+ "",
101
+ "## Universe scan (discovered movers) — candidates beyond your watchlist",
102
+ "Watch entries with `discovered: true` are today's strongest 24h movers across the WHOLE tracked universe, resolved with the same price/sentiment (and indicators) data as your watchlist. observation.universeMovers lists further movers as symbol + 24h change only (context — you cannot trade those directly this cycle).",
103
+ "- Treat a discovered candidate like any other symbol: analyze it for catalysts, exhaustion and reversal BEFORE acting. A big 24h pump is as often a top as a beginning — chasing green candles blind is how discovery loses money.",
104
+ "- All your normal risk rules apply unchanged: caps, stops, blocklist, confidence floor. Discovery widens what you can SEE, never what you may risk.",
105
+ ]
106
+ : []),
90
107
  ...(spec.capabilities.includes("news")
91
108
  ? [
92
109
  "",
@@ -169,6 +186,7 @@ export function buildUserPrompt(obs, journal) {
169
186
  watch: obs.watch,
170
187
  setups: obs.setups,
171
188
  news: obs.news,
189
+ universeMovers: obs.universeMovers,
172
190
  marketMood: obs.marketMood,
173
191
  newClosedTrades: obs.newClosedTrades.slice(0, 20),
174
192
  polledBeforeWrite: obs.polledBeforeWrite,
@@ -3,9 +3,21 @@ export declare class ResolveError extends Error {
3
3
  issues: ResolveIssue[];
4
4
  constructor(issues: ResolveIssue[]);
5
5
  }
6
+ export declare const proseBody: (raw: string) => string;
7
+ export declare const GUARDS_FILE = "character/guards.md";
8
+ export declare const GUARDS_HEADER = "## HARD BEHAVIORAL GUARDS \u2014 never violate these";
9
+ export declare 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.)";
10
+ export declare const wrapGuardsProse: (body: string) => string;
6
11
  export declare function isSkillProseSource(source: string): boolean;
7
12
  export declare function mergeProseParts(parts: Array<{
8
13
  source: string;
9
14
  text: string;
10
15
  }>): string;
11
16
  export declare function resolveAgent(inputPath: string): ResolvedAgent;
17
+ export declare const HOSTED_PROSE_MAX_CHARS = 8000;
18
+ /** PURE — exported for tests. Mirrors the backend's trim-then-measure. */
19
+ export declare const hostedProseBudget: (mergedProse: string) => {
20
+ used: number;
21
+ over: number;
22
+ fits: boolean;
23
+ };
@@ -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,21 @@ 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
+ export const HOSTED_PROSE_MAX_CHARS = 8000;
596
+ /** PURE — exported for tests. Mirrors the backend's trim-then-measure. */
597
+ export const hostedProseBudget = (mergedProse) => {
598
+ const used = mergedProse.replace(/\r\n/g, "\n").trim().length;
599
+ return {
600
+ used,
601
+ over: Math.max(0, used - HOSTED_PROSE_MAX_CHARS),
602
+ fits: used <= HOSTED_PROSE_MAX_CHARS,
603
+ };
604
+ };