@coinrithm/mcp-trading 0.7.15 → 0.7.16

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 (53) hide show
  1. package/CHANGELOG.md +127 -8
  2. package/README.md +18 -3
  3. package/dist/agent/bench/bench.d.ts +59 -0
  4. package/dist/agent/bench/bench.js +319 -0
  5. package/dist/agent/bench/cassette.d.ts +97 -0
  6. package/dist/agent/bench/cassette.js +181 -0
  7. package/dist/agent/bench/costs.d.ts +28 -0
  8. package/dist/agent/bench/costs.js +60 -0
  9. package/dist/agent/bench/labelBuilder.d.ts +123 -0
  10. package/dist/agent/bench/labelBuilder.js +282 -0
  11. package/dist/agent/bench/labels.d.ts +75 -0
  12. package/dist/agent/bench/labels.js +259 -0
  13. package/dist/agent/bench/recordingClient.d.ts +51 -0
  14. package/dist/agent/bench/recordingClient.js +193 -0
  15. package/dist/agent/bench/replayClient.d.ts +41 -0
  16. package/dist/agent/bench/replayClient.js +262 -0
  17. package/dist/agent/bench/report.d.ts +115 -0
  18. package/dist/agent/bench/report.js +435 -0
  19. package/dist/agent/cli.d.ts +25 -0
  20. package/dist/agent/cli.js +300 -2
  21. package/dist/agent/client.d.ts +1 -0
  22. package/dist/agent/client.js +6 -0
  23. package/dist/agent/configurationWarnings.js +11 -0
  24. package/dist/agent/decision.d.ts +3 -0
  25. package/dist/agent/decision.js +85 -3
  26. package/dist/agent/decisionValidator.d.ts +30 -0
  27. package/dist/agent/decisionValidator.js +147 -2
  28. package/dist/agent/engine.d.ts +3 -3
  29. package/dist/agent/engine.js +3 -3
  30. package/dist/agent/observe.d.ts +27 -2
  31. package/dist/agent/observe.js +529 -89
  32. package/dist/agent/prompt.d.ts +4 -1
  33. package/dist/agent/prompt.js +98 -7
  34. package/dist/agent/promptTables.d.ts +1 -0
  35. package/dist/agent/promptTables.js +33 -0
  36. package/dist/agent/providerCapabilities.d.ts +6 -0
  37. package/dist/agent/providerCapabilities.js +23 -0
  38. package/dist/agent/providers.d.ts +25 -7
  39. package/dist/agent/providers.js +188 -48
  40. package/dist/agent/resolve.js +3 -0
  41. package/dist/agent/runner.d.ts +8 -0
  42. package/dist/agent/runner.js +59 -15
  43. package/dist/agent/setups.d.ts +2 -1
  44. package/dist/agent/setups.js +17 -16
  45. package/dist/agent/signals.d.ts +33 -0
  46. package/dist/agent/signals.js +89 -0
  47. package/dist/agent/skill.js +37 -0
  48. package/dist/agent/skillValidator.js +33 -0
  49. package/dist/agent/strictLint.js +29 -0
  50. package/dist/agent/types.d.ts +149 -12
  51. package/dist/agent/universe.d.ts +78 -0
  52. package/dist/agent/universe.js +215 -0
  53. package/package.json +3 -2
package/dist/agent/cli.js CHANGED
@@ -18,12 +18,17 @@ import { buildManifest, writeManifest } from "./manifest.js";
18
18
  import { parseFrontmatter } from "./frontmatter.js";
19
19
  import { renderFolderOfOne, ejectFiles, PRESET_NAMES, } from "./templates.js";
20
20
  import { COINRITHM_API } from "./version.js";
21
- import { stableStringify, envFlag } from "./util.js";
22
- import { CoinRithmClient } from "./client.js";
21
+ import { stableStringify, envFlag, parseCadenceMs, sleep } from "./util.js";
22
+ import { CoinRithmClient, DEFAULT_BASE_URL } from "./client.js";
23
23
  import { selectProvider } from "./providers.js";
24
24
  import { runLoop } from "./runner.js";
25
25
  import { loadState, saveState } from "./state.js";
26
26
  import { makeRunId } from "./runEvidence.js";
27
+ import { readCorpus, writeCassette } from "./bench/cassette.js";
28
+ import { recordCassette } from "./bench/recordingClient.js";
29
+ import { runBench } from "./bench/bench.js";
30
+ import { buildPmLabels, buildPriceLabels, DEFAULT_LABEL_HORIZON_HOURS, } from "./bench/labelBuilder.js";
31
+ import { parseLabelFile } from "./bench/labels.js";
27
32
  const fail = (lines) => ({ ok: false, code: 1, lines });
28
33
  function issuesResult(issues, header) {
29
34
  return {
@@ -438,8 +443,254 @@ export async function cmdRun(path, opts = {}) {
438
443
  release();
439
444
  }
440
445
  }
446
+ const MAX_RECORD_CYCLES = 10_000;
447
+ // Record bench cassettes (see bench/cassette.ts). Read-only by construction:
448
+ // every cycle runs DRY-RUN through a recording transport that refuses all
449
+ // non-GET requests, with a brain that never calls a model, so only
450
+ // COINRITHM_API_KEY is needed and no model key is read. The agent's own state
451
+ // file is not touched; each recorded cycle starts from a fresh run state.
452
+ export async function cmdRecord(path, opts = {}) {
453
+ if (!opts.out)
454
+ return fail(["record needs --out <dir> for the cassettes"]);
455
+ const cycles = opts.cycles ?? 1;
456
+ if (!Number.isInteger(cycles) || cycles < 1 || cycles > MAX_RECORD_CYCLES)
457
+ return fail([`--cycles must be an integer from 1 to ${MAX_RECORD_CYCLES}`]);
458
+ let loaded;
459
+ try {
460
+ loaded = loadAgent(path, "self-host");
461
+ }
462
+ catch (e) {
463
+ if (e instanceof ResolveError)
464
+ return issuesResult(e.issues, "resolve failed");
465
+ throw e;
466
+ }
467
+ const everyMs = opts.every !== undefined
468
+ ? parseCadenceMs(opts.every)
469
+ : (parseCadenceMs(loaded.spec.trigger.cadence) ?? 3_600_000);
470
+ if (everyMs === null)
471
+ return fail([`--every "${opts.every}" is not a cadence like 5m or 1h`]);
472
+ const apiKey = process.env.COINRITHM_API_KEY;
473
+ if (!apiKey)
474
+ return fail([
475
+ "COINRITHM_API_KEY is not set (recording reads your paper account; it never writes)",
476
+ ]);
477
+ const mergedProse = runtimeProse(loaded.resolved);
478
+ const lines = [
479
+ `record DRY-RUN (reads only, no model call): ${loaded.spec.name}, ${cycles} cycle(s)`,
480
+ ];
481
+ const files = [];
482
+ for (let i = 0; i < cycles; i++) {
483
+ let cassette;
484
+ try {
485
+ cassette = await recordCassette({
486
+ spec: loaded.spec,
487
+ mergedProse,
488
+ apiKey,
489
+ baseUrl: process.env.COINRITHM_API_URL || undefined,
490
+ fetchFn: opts.fetchFn,
491
+ });
492
+ }
493
+ catch (e) {
494
+ lines.push(`✗ cycle ${i + 1} failed: ${e.message}`);
495
+ return { ok: false, code: 1, lines, data: files };
496
+ }
497
+ files.push(writeCassette(resolvePath(opts.out), cassette));
498
+ const cycle = cassette.recordCycle;
499
+ lines.push(`recorded ${cassette.id}: ${cassette.responses.length} read(s), asOf ${cassette.asOf}, cycle ${cycle.decisionType ?? cycle.decision}${cycle.skipReason ? ` (${cycle.skipReason})` : ""}` +
500
+ (cassette.refusedRequests.length
501
+ ? `, ${cassette.refusedRequests.length} write(s) refused`
502
+ : ""));
503
+ if (i < cycles - 1)
504
+ await (opts.sleepFn ?? sleep)(everyMs);
505
+ }
506
+ return { ok: true, code: 0, lines, data: files };
507
+ }
508
+ // Write outcome labels for a recorded corpus (bench/labelBuilder.ts), reads
509
+ // only: price bars from candles published after each cassette's asOf (with
510
+ // COINRITHM_API_KEY), and PM settlement from the public event verdict
511
+ // (settlementEligible + "settle" + a provider won/lost outcome result; any
512
+ // other state stays unlabelled).
513
+ export async function cmdLabel(opts = {}) {
514
+ if (!opts.corpus)
515
+ return fail(["label needs --corpus <dir>"]);
516
+ const horizonHours = opts.horizonHours ?? DEFAULT_LABEL_HORIZON_HOURS;
517
+ if (!Number.isFinite(horizonHours) || horizonHours <= 0 || horizonHours > 720)
518
+ return fail(["--horizon-hours must be a number in (0, 720]"]);
519
+ const apiKey = process.env.COINRITHM_API_KEY;
520
+ if (!apiKey)
521
+ return fail([
522
+ "COINRITHM_API_KEY is not set (labels read public market candles through your key; nothing is written)",
523
+ ]);
524
+ const dir = resolvePath(opts.corpus);
525
+ let corpus;
526
+ try {
527
+ corpus = readCorpus(dir);
528
+ }
529
+ catch (e) {
530
+ return fail([e.message]);
531
+ }
532
+ const client = new CoinRithmClient({
533
+ apiKey,
534
+ baseUrl: process.env.COINRITHM_API_URL || undefined,
535
+ fetchFn: opts.fetchFn,
536
+ });
537
+ const results = await buildPriceLabels(corpus.cassettes, {
538
+ fetchCandles: (coinId, range) => client.candles(coinId, range),
539
+ horizonHours,
540
+ nowMs: opts.nowMs ?? Date.now(),
541
+ existing: corpus.labels,
542
+ overwrite: opts.overwrite,
543
+ });
544
+ const base = (process.env.COINRITHM_API_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
545
+ const fetchFn = opts.fetchFn ?? fetch;
546
+ const pmResults = await buildPmLabels(corpus.cassettes, {
547
+ // The public event detail: the same verdict the event page shows.
548
+ fetchEvent: async (source, slug) => {
549
+ const res = await fetchFn(`${base}/api/prediction-markets/events/${encodeURIComponent(source)}/${encodeURIComponent(slug)}`, { method: "GET", signal: AbortSignal.timeout(15_000) });
550
+ return {
551
+ ok: res.ok,
552
+ status: res.status,
553
+ data: res.ok ? await res.json() : null,
554
+ };
555
+ },
556
+ existing: corpus.labels,
557
+ overwrite: opts.overwrite,
558
+ });
559
+ const lines = [
560
+ `label (reads only): ${corpus.cassettes.length} cassette(s), horizon ${horizonHours} h`,
561
+ ];
562
+ let written = 0;
563
+ for (const r of results) {
564
+ const pm = pmResults.find((p) => p.id === r.id);
565
+ const notes = [];
566
+ let file;
567
+ if (r.status === "built") {
568
+ file = { ...r.file };
569
+ notes.push(`${r.range} bars for ${r.symbols.length} symbol(s)${r.missing.length ? `, missing ${r.missing.join(",")}` : ""}`);
570
+ }
571
+ else {
572
+ notes.push(`prices ${r.status}${r.status === "not_yet" ? ` (labelable after ${r.labelableAfter})` : ""}${r.status === "horizon_mismatch" ? ` (existing file labels ${r.existingHorizonHours ?? "no"} h, not ${horizonHours} h; rerun with --overwrite)` : ""}`);
573
+ }
574
+ if (pm) {
575
+ notes.push(`pm ${pm.labelled} new settled outcome(s) from ${pm.events} event(s)${pm.failed ? `, ${pm.failed} read(s) failed` : ""}`);
576
+ const hasPm = Object.keys(pm.pm).length > 0;
577
+ if (opts.overwrite) {
578
+ // --overwrite REPLACES the PM part, even with nothing: a label that
579
+ // was settled before but is void/unresolved now must not survive.
580
+ const base = file ?? corpus.labels[r.id];
581
+ if (base || hasPm) {
582
+ const { pm: _replaced, ...rest } = base ?? {};
583
+ file = hasPm ? { ...rest, pm: pm.pm } : rest;
584
+ }
585
+ }
586
+ else if (pm.labelled > 0 || (file && hasPm))
587
+ file = { ...(file ?? corpus.labels[r.id] ?? {}), pm: pm.pm };
588
+ }
589
+ lines.push(`${r.id}: ${notes.join("; ")}`);
590
+ if (!file)
591
+ continue;
592
+ const path = join(dir, "labels", `${r.id}.json`);
593
+ // The bench reads it back through the same validator; fail closed here.
594
+ parseLabelFile(file, `labels/${r.id}.json`);
595
+ mkdirSync(dirname(path), { recursive: true });
596
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, "utf8");
597
+ written += 1;
598
+ }
599
+ lines.push(`wrote ${written} label file(s); PM outcomes without a settled platform verdict stay unlabelled`);
600
+ return { ok: true, code: 0, lines, data: { prices: results, pm: pmResults } };
601
+ }
602
+ // Compare agent variants on a recorded corpus (see bench/bench.ts). Each
603
+ // variant runs with its own model, built exactly as `run` builds it from the
604
+ // variant's model config and the environment's keys. Never writes.
605
+ export async function cmdBench(opts = {}) {
606
+ if (!opts.corpus)
607
+ return fail(["bench needs --corpus <dir>"]);
608
+ if (!opts.variants || opts.variants.length === 0)
609
+ return fail(["bench needs at least one --variant name=<agentPath>"]);
610
+ if (opts.seed !== undefined &&
611
+ (!Number.isSafeInteger(opts.seed) || opts.seed < 0))
612
+ return fail(["--seed must be a non-negative integer"]);
613
+ let corpus;
614
+ try {
615
+ corpus = readCorpus(resolvePath(opts.corpus));
616
+ }
617
+ catch (e) {
618
+ return fail([e.message]);
619
+ }
620
+ const variants = [];
621
+ for (const arg of opts.variants) {
622
+ const eq = arg.indexOf("=");
623
+ if (eq <= 0 || eq === arg.length - 1)
624
+ return fail([`--variant "${arg}" must look like name=<agentPath>`]);
625
+ const name = arg.slice(0, eq);
626
+ const agentPath = arg.slice(eq + 1);
627
+ let loaded;
628
+ try {
629
+ loaded = loadAgent(agentPath, "self-host");
630
+ }
631
+ catch (e) {
632
+ if (e instanceof ResolveError)
633
+ return issuesResult(e.issues, `variant ${name}: resolve failed`);
634
+ throw e;
635
+ }
636
+ let provider;
637
+ try {
638
+ provider = opts.providerFor
639
+ ? opts.providerFor(loaded.spec)
640
+ : selectProvider(loaded.spec, process.env, fetch);
641
+ }
642
+ catch (e) {
643
+ return fail([`variant ${name}: ${e.message}`]);
644
+ }
645
+ variants.push({
646
+ name,
647
+ spec: loaded.spec,
648
+ mergedProse: runtimeProse(loaded.resolved),
649
+ provider,
650
+ });
651
+ }
652
+ const lines = [
653
+ `bench DRY-RUN: ${corpus.cassettes.length} cassette(s) x ${variants.length} variant(s) x ${opts.repeats ?? 3} repeat(s)`,
654
+ ];
655
+ if (corpus.ignored.length)
656
+ lines.push(`ignored non-cassette file(s): ${corpus.ignored.join(", ")}`);
657
+ let report;
658
+ try {
659
+ report = await runBench({
660
+ cassettes: corpus.cassettes,
661
+ labels: corpus.labels,
662
+ variants,
663
+ repeats: opts.repeats,
664
+ seed: opts.seed,
665
+ baselines: opts.baselines,
666
+ });
667
+ }
668
+ catch (e) {
669
+ return fail([...lines, `✗ ${e.message}`]);
670
+ }
671
+ const summary = report.variants;
672
+ for (const [name, v] of Object.entries(summary)) {
673
+ const codes = Object.entries(v.all.actions.rejectCodes)
674
+ .map(([c, n]) => `${c} ${n}`)
675
+ .join(", ");
676
+ lines.push(`${name}: ${v.all.cycles} cycle(s), accepted ${v.all.actions.accepted}, rejected ${v.all.actions.rejected}${codes ? ` (${codes})` : ""}, model failures ${v.all.modelFailures}, cycles missing inputs ${v.all.cyclesWithMissingInputs}, repeat consistency ${v.all.repeatConsistency ?? "n/a"}`);
677
+ }
678
+ for (const c of report.comparisons) {
679
+ const accepted = c.all.metrics.acceptedActions;
680
+ lines.push(`${c.a} vs ${c.b}: overlap ${c.all.actionOverlapJaccard ?? "n/a"}, accepted diff ${accepted.meanDiff ?? "n/a"} (95% CI ${accepted.ci95 ? `${accepted.ci95[0]}..${accepted.ci95[1]}` : "n/a"}, n=${accepted.n})`);
681
+ }
682
+ if (opts.out) {
683
+ writeFileSync(resolvePath(opts.out), `${JSON.stringify(report, null, 2)}\n`, "utf8");
684
+ lines.push(`wrote ${resolvePath(opts.out)}`);
685
+ }
686
+ lines.push(`contentHash ${report.contentHash}`);
687
+ return { ok: true, code: 0, lines, data: report };
688
+ }
441
689
  function parseFlags(args) {
442
690
  const out = { _: [] };
691
+ // A missing or non-numeric value becomes NaN so the command rejects it,
692
+ // never silently falls back to its default.
693
+ const num = (v) => (v === undefined ? NaN : Number(v));
443
694
  for (let i = 0; i < args.length; i++) {
444
695
  const a = args[i];
445
696
  if (a === "--hosted")
@@ -462,6 +713,26 @@ function parseFlags(args) {
462
713
  out.state = args[++i];
463
714
  else if (a === "--expect-definition")
464
715
  out.expectDefinition = args[++i] ?? "";
716
+ else if (a === "--out")
717
+ out.out = args[++i];
718
+ else if (a === "--cycles")
719
+ out.cycles = num(args[++i]);
720
+ else if (a === "--every")
721
+ out.every = args[++i] ?? "";
722
+ else if (a === "--corpus")
723
+ out.corpus = args[++i];
724
+ else if (a === "--variant")
725
+ out.variant = [...(out.variant ?? []), args[++i] ?? ""];
726
+ else if (a === "--repeats")
727
+ out.repeats = num(args[++i]);
728
+ else if (a === "--seed")
729
+ out.seed = num(args[++i]);
730
+ else if (a === "--no-baselines")
731
+ out.noBaselines = true;
732
+ else if (a === "--horizon-hours")
733
+ out.horizonHours = num(args[++i]);
734
+ else if (a === "--overwrite")
735
+ out.overwrite = true;
465
736
  else
466
737
  out._.push(a);
467
738
  }
@@ -476,6 +747,9 @@ function usageLines() {
476
747
  " eject <agent.md | dir>",
477
748
  " lock <path>",
478
749
  " run <path> [--once] [--live] [--dry-run] [--state <file>] [--expect-definition sha256:...] (dry-run by default)",
750
+ " record <path> --out <dir> [--cycles N] [--every 5m] (bench inputs; reads only, no model call)",
751
+ " bench --corpus <dir> --variant a=<path> --variant b=<path> [--repeats 3] [--seed N] [--no-baselines] [--out report.json]",
752
+ " label --corpus <dir> [--horizon-hours 24] [--overwrite] (price outcome labels after asOf; reads only)",
479
753
  ];
480
754
  }
481
755
  export async function main(argv) {
@@ -513,6 +787,30 @@ export async function main(argv) {
513
787
  });
514
788
  break;
515
789
  }
790
+ case "record":
791
+ r = await cmdRecord(pos[0] ?? ".", {
792
+ out: flags.out,
793
+ cycles: flags.cycles,
794
+ every: flags.every,
795
+ });
796
+ break;
797
+ case "label":
798
+ r = await cmdLabel({
799
+ corpus: flags.corpus,
800
+ horizonHours: flags.horizonHours,
801
+ overwrite: flags.overwrite,
802
+ });
803
+ break;
804
+ case "bench":
805
+ r = await cmdBench({
806
+ corpus: flags.corpus,
807
+ variants: flags.variant,
808
+ repeats: flags.repeats,
809
+ seed: flags.seed,
810
+ out: flags.out,
811
+ baselines: !flags.noBaselines,
812
+ });
813
+ break;
516
814
  case undefined:
517
815
  case "help":
518
816
  case "--help":
@@ -53,6 +53,7 @@ export declare class CoinRithmClient {
53
53
  }, trace?: AgentTrace): Promise<ApiResult>;
54
54
  resolve(q: string, trace?: AgentTrace): Promise<ApiResult>;
55
55
  cryptoMovers(direction: "gainers" | "losers", limit: number, trace?: AgentTrace): Promise<ApiResult>;
56
+ agentUniverse(query: Record<string, string | number>, trace?: AgentTrace): Promise<ApiResult>;
56
57
  getPublicPmWhales(options?: {
57
58
  timeoutMs?: number;
58
59
  limit?: number;
@@ -283,6 +283,12 @@ export class CoinRithmClient {
283
283
  ? "/api/coins/top-losers"
284
284
  : "/api/coins/top-gainers", { query: { limit }, trace });
285
285
  }
286
+ // Market-boundaries screener (GET /api/agent/universe, read scope): rows
287
+ // inside the agent's declared rank band / volume floor / sectors, sorted by
288
+ // the agent's notion of an opportunity. Shared 60 s server cache.
289
+ agentUniverse(query, trace) {
290
+ return this.request("GET", "/api/agent/universe", { query, trace });
291
+ }
286
292
  getPublicPmWhales(options) {
287
293
  return this.publicRequest("/api/prediction-markets/whales", { limit: options?.limit }, options?.timeoutMs);
288
294
  }
@@ -1,6 +1,17 @@
1
+ // Per-day counters roll over on the UTC date (util.ts dayKey), whatever the
2
+ // bundle declares. trigger.timezone is parsed but read by nothing else, so a
3
+ // non-UTC value would silently promise local-day limits.
4
+ const UTC_NAMES = new Set(["utc", "etc/utc", "z", "gmt", "etc/gmt"]);
1
5
  // Advisory only: these fields remain loadable for backwards compatibility.
2
6
  export function configurationWarnings(raw) {
3
7
  const warnings = [];
8
+ const trigger = raw.trigger;
9
+ if (trigger && typeof trigger === "object" && !Array.isArray(trigger)) {
10
+ const tz = trigger.timezone;
11
+ if (typeof tz === "string" && !UTC_NAMES.has(tz.trim().toLowerCase())) {
12
+ warnings.push(`trigger.timezone "${tz}" is informational: maxTradesPerDay and maxDailyLossMusd reset at 00:00 UTC, not in this timezone`);
13
+ }
14
+ }
4
15
  if (Array.isArray(raw.capabilities) &&
5
16
  raw.capabilities.includes("websearch")) {
6
17
  warnings.push("capabilities.websearch is reserved: the runner does not perform web searches");
@@ -537,11 +537,14 @@ export declare const DECISION_JSON_SCHEMA: {
537
537
  };
538
538
  }];
539
539
  };
540
+ export declare const ACTIONS_STRING_DIAGNOSTICS: readonly ["json_array_empty_valid_decision", "json_array_nonempty_valid_decision", "json_array_invalid_decision", "json_nonarray", "invalid_json", "empty_string_valid_skip", "empty_string_other_decision", "non_json_array_text", "json5_array_valid_decision", "json5_array_invalid_decision", "over_size_limit"];
541
+ export type ActionsStringDiagnostic = (typeof ACTIONS_STRING_DIAGNOSTICS)[number];
540
542
  export type ParseDecisionResult = {
541
543
  ok: true;
542
544
  decision: Decision;
543
545
  } | {
544
546
  ok: false;
545
547
  error: string;
548
+ actionsStringDiagnostic?: ActionsStringDiagnostic;
546
549
  };
547
550
  export declare function parseDecision(text: string): ParseDecisionResult;
@@ -3,6 +3,7 @@
3
3
  // JSON, an unknown action type, a free-form endpoint/tool name, extra unknown
4
4
  // fields, or a missing required field — fails closed (the runner skips).
5
5
  import { z } from "zod";
6
+ import JSON5 from "json5";
6
7
  import { coerceThesis } from "./thesis.js";
7
8
  // Small models (especially Llama 3.1 8B) frequently emit numbers as JSON strings
8
9
  // ("12345", "0.8", "62000"). Coerce a *clean* numeric string to a number before
@@ -363,15 +364,95 @@ function coerceJson(text) {
363
364
  }
364
365
  return JSON.parse(s); // throws on invalid JSON -> caller treats as fail-closed
365
366
  }
367
+ // Fixed, non-content diagnostics only. Never retain model text, identifiers,
368
+ // action values or reasoning to investigate a provider's encoding failures.
369
+ export const ACTIONS_STRING_DIAGNOSTICS = [
370
+ "json_array_empty_valid_decision",
371
+ "json_array_nonempty_valid_decision",
372
+ "json_array_invalid_decision",
373
+ "json_nonarray",
374
+ "invalid_json",
375
+ "empty_string_valid_skip",
376
+ "empty_string_other_decision",
377
+ "non_json_array_text",
378
+ "json5_array_valid_decision",
379
+ "json5_array_invalid_decision",
380
+ "over_size_limit",
381
+ ];
382
+ function diagnoseActionsString(obj) {
383
+ if (!obj || typeof obj !== "object" || Array.isArray(obj))
384
+ return undefined;
385
+ const record = obj;
386
+ if (typeof record.actions !== "string")
387
+ return undefined;
388
+ if (record.actions.length > 65_536)
389
+ return "over_size_limit";
390
+ const text = record.actions.trim();
391
+ if (text === "") {
392
+ return record.decision === "skip" &&
393
+ decisionSchema.safeParse({ ...record, actions: [] }).success
394
+ ? "empty_string_valid_skip"
395
+ : "empty_string_other_decision";
396
+ }
397
+ let actions;
398
+ try {
399
+ actions = JSON.parse(record.actions);
400
+ }
401
+ catch {
402
+ if (!text.startsWith("["))
403
+ return "invalid_json";
404
+ // Diagnostic only: identify alternate literal encodings without accepting
405
+ // them or retaining content. JSON5 never evaluates expressions. Requiring
406
+ // finite JSON values keeps Infinity/NaN from looking like usable actions.
407
+ try {
408
+ const candidate = JSON5.parse(text);
409
+ JSON.stringify(candidate, (key, value) => {
410
+ if (["__proto__", "constructor", "prototype"].includes(key) ||
411
+ (typeof value === "number" && !Number.isFinite(value)))
412
+ throw new Error("Unsafe diagnostic value");
413
+ return value;
414
+ });
415
+ return Array.isArray(candidate) &&
416
+ decisionSchema.safeParse({ ...record, actions: candidate }).success
417
+ ? "json5_array_valid_decision"
418
+ : "json5_array_invalid_decision";
419
+ }
420
+ catch {
421
+ return "non_json_array_text";
422
+ }
423
+ }
424
+ if (!Array.isArray(actions))
425
+ return "json_nonarray";
426
+ if (!decisionSchema.safeParse({ ...record, actions }).success)
427
+ return "json_array_invalid_decision";
428
+ return actions.length === 0
429
+ ? "json_array_empty_valid_decision"
430
+ : "json_array_nonempty_valid_decision";
431
+ }
432
+ function safeIssueMessage(issue) {
433
+ // These Zod messages echo model-supplied values or unknown field names.
434
+ // Other messages in this fixed schema describe types or contract limits.
435
+ switch (issue.code) {
436
+ case "invalid_enum_value":
437
+ return "Invalid enum value";
438
+ case "invalid_literal":
439
+ return "Invalid literal value";
440
+ case "unrecognized_keys":
441
+ return "Unrecognized field(s)";
442
+ default:
443
+ return issue.message;
444
+ }
445
+ }
366
446
  export function parseDecision(text) {
367
447
  let obj;
368
448
  try {
369
449
  obj = normalizeDecisionVerb(coerceJson(text));
370
450
  }
371
- catch (err) {
451
+ catch {
372
452
  return {
373
453
  ok: false,
374
- error: `model output is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
454
+ // Node's JSON syntax errors can include excerpts of private model text.
455
+ error: "model output is not valid JSON",
375
456
  };
376
457
  }
377
458
  const res = decisionSchema.safeParse(obj);
@@ -379,8 +460,9 @@ export function parseDecision(text) {
379
460
  return {
380
461
  ok: false,
381
462
  error: res.error.issues
382
- .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
463
+ .map((i) => `${i.path.join(".") || "(root)"}: ${safeIssueMessage(i)}`)
383
464
  .join("; "),
465
+ actionsStringDiagnostic: diagnoseActionsString(obj),
384
466
  };
385
467
  }
386
468
  const d = res.data;
@@ -20,7 +20,37 @@ export interface DecisionContext {
20
20
  realizedLossTodayMusd: number;
21
21
  targetedPositionIds: number[];
22
22
  targetedOrderIds: number[];
23
+ pmEventsOpenedThisCycle?: string[];
24
+ pmCoinHorizonsOpenedThisCycle?: PmCoinHorizon[];
25
+ nowMs?: number;
23
26
  }
27
+ /**
28
+ * Edge (points) risk.pmMinEdgeGapPct requires at a fee-inclusive entry cost:
29
+ * pct% of the room left to 100. Cost at or above 100 leaves no room, so any
30
+ * positive gap rule makes the requirement unreachable rather than zero.
31
+ */
32
+ export declare function requiredGapEdgePoints(entryPct: number, gapPct: number): number;
33
+ /** Ends within this of each other share a coin horizon (venue date splits). */
34
+ export declare const PM_COIN_HORIZON_WINDOW_MS: number;
35
+ /** An end beyond this is a venue sentinel, never a real horizon. */
36
+ export declare const PM_PLAUSIBLE_END_MAX_MS: number;
37
+ export interface PmCoinHorizon {
38
+ coins: string[];
39
+ endMs: number;
40
+ }
41
+ /** The later of observation.asOf and the validation clock, when known. */
42
+ export declare function pmReferenceMs(asOf: string, nowMs?: number): number;
43
+ /**
44
+ * The coin horizon of a market or held position, or null when it cannot be
45
+ * grouped: no linked coin, or an end that is missing, unparseable, not after
46
+ * the reference time, or more than 5 years after it (venue sentinels).
47
+ */
48
+ export declare function pmCoinHorizonOf(item: {
49
+ coins?: string[];
50
+ endDate?: string;
51
+ }, refMs: number): PmCoinHorizon | null;
52
+ /** Event key shared by pm_open actions, held positions and markets. */
53
+ export declare function pmEventKey(source: string, slug: string): string;
24
54
  /**
25
55
  * True when a thesis says, in so many words, that it is betting AGAINST the
26
56
  * outcome the action is buying. Deliberately narrow: it fires only when the