@senpi/runtime 3.0.0-dev.runtime-phase-2.20260623210537 → 3.0.1-dev.openclaw-upgrade-mai.20260625140818

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 (215) hide show
  1. package/README.md +10 -9
  2. package/dist/actions/action-runtime-module.d.ts.map +1 -1
  3. package/dist/actions/action-runtime-module.js +1 -1
  4. package/dist/actions/action-runtime-module.js.map +1 -1
  5. package/dist/actions/decision-engine.d.ts.map +1 -1
  6. package/dist/actions/decision-engine.js +3 -0
  7. package/dist/actions/decision-engine.js.map +1 -1
  8. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  9. package/dist/actions/open-position/open-position.action.js +54 -14
  10. package/dist/actions/open-position/open-position.action.js.map +1 -1
  11. package/dist/actions/types.d.ts +10 -2
  12. package/dist/actions/types.d.ts.map +1 -1
  13. package/dist/actions/types.js +8 -0
  14. package/dist/actions/types.js.map +1 -1
  15. package/dist/cli/guide-commands.d.ts.map +1 -1
  16. package/dist/cli/guide-commands.js +80 -74
  17. package/dist/cli/guide-commands.js.map +1 -1
  18. package/dist/cli/senpi-commands.d.ts.map +1 -1
  19. package/dist/cli/senpi-commands.js +0 -108
  20. package/dist/cli/senpi-commands.js.map +1 -1
  21. package/dist/constants/dsl/index.d.ts +2 -0
  22. package/dist/constants/dsl/index.d.ts.map +1 -1
  23. package/dist/constants/dsl/index.js +2 -0
  24. package/dist/constants/dsl/index.js.map +1 -1
  25. package/dist/dsl/config/parse-tiers.d.ts.map +1 -1
  26. package/dist/dsl/config/parse-tiers.js +6 -2
  27. package/dist/dsl/config/parse-tiers.js.map +1 -1
  28. package/dist/dsl/engine-client/config-mapping.d.ts +7 -0
  29. package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
  30. package/dist/dsl/engine-client/config-mapping.js +16 -2
  31. package/dist/dsl/engine-client/config-mapping.js.map +1 -1
  32. package/dist/dsl/events/handlers.d.ts +18 -2
  33. package/dist/dsl/events/handlers.d.ts.map +1 -1
  34. package/dist/dsl/events/handlers.js +302 -213
  35. package/dist/dsl/events/handlers.js.map +1 -1
  36. package/dist/dsl/monitor/create-monitor.d.ts +15 -0
  37. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  38. package/dist/dsl/monitor/create-monitor.js +46 -9
  39. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  40. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  41. package/dist/dsl/monitor/process-one-position.js +138 -77
  42. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  43. package/dist/dsl/plugin/index.d.ts +16 -1
  44. package/dist/dsl/plugin/index.d.ts.map +1 -1
  45. package/dist/dsl/plugin/index.js +35 -4
  46. package/dist/dsl/plugin/index.js.map +1 -1
  47. package/dist/dsl/plugin/options.d.ts +18 -0
  48. package/dist/dsl/plugin/options.d.ts.map +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/runtime/create-scanner-module.d.ts +4 -3
  53. package/dist/runtime/create-scanner-module.d.ts.map +1 -1
  54. package/dist/runtime/create-scanner-module.js +14 -5
  55. package/dist/runtime/create-scanner-module.js.map +1 -1
  56. package/dist/runtime/create-scanner-providers-for-run.d.ts.map +1 -1
  57. package/dist/runtime/create-scanner-providers-for-run.js +12 -1
  58. package/dist/runtime/create-scanner-providers-for-run.js.map +1 -1
  59. package/dist/runtime/load-runtime-config.js +1 -1
  60. package/dist/runtime/load-runtime-config.js.map +1 -1
  61. package/dist/runtime/map-strategies-registration.js +7 -8
  62. package/dist/runtime/map-strategies-registration.js.map +1 -1
  63. package/dist/runtime/map-strategies.d.ts.map +1 -1
  64. package/dist/runtime/map-strategies.js +12 -3
  65. package/dist/runtime/map-strategies.js.map +1 -1
  66. package/dist/runtime/reconcile-decide.d.ts +73 -0
  67. package/dist/runtime/reconcile-decide.d.ts.map +1 -0
  68. package/dist/runtime/reconcile-decide.js +186 -0
  69. package/dist/runtime/reconcile-decide.js.map +1 -0
  70. package/dist/runtime/reconcile-run.d.ts +62 -0
  71. package/dist/runtime/reconcile-run.d.ts.map +1 -0
  72. package/dist/runtime/reconcile-run.js +173 -0
  73. package/dist/runtime/reconcile-run.js.map +1 -0
  74. package/dist/runtime/reconcile-state.d.ts +44 -0
  75. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  76. package/dist/runtime/reconcile-state.js +80 -44
  77. package/dist/runtime/reconcile-state.js.map +1 -1
  78. package/dist/runtime/risk-guard.d.ts.map +1 -1
  79. package/dist/runtime/risk-guard.js +17 -12
  80. package/dist/runtime/risk-guard.js.map +1 -1
  81. package/dist/runtime/runtime-schema.d.ts +2912 -1052
  82. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  83. package/dist/runtime/runtime-schema.js +136 -42
  84. package/dist/runtime/runtime-schema.js.map +1 -1
  85. package/dist/runtime/runtime.d.ts +26 -0
  86. package/dist/runtime/runtime.d.ts.map +1 -1
  87. package/dist/runtime/runtime.js +254 -8
  88. package/dist/runtime/runtime.js.map +1 -1
  89. package/dist/runtime/scanner-registry.d.ts +1 -1
  90. package/dist/runtime/scanner-registry.d.ts.map +1 -1
  91. package/dist/runtime/scanner-registry.js +3 -12
  92. package/dist/runtime/scanner-registry.js.map +1 -1
  93. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  94. package/dist/runtime/supervisor/long-running-supervisor.js +1 -1
  95. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  96. package/dist/runtime/supervisor/orphan-sweep.js +1 -1
  97. package/dist/runtime/supervisor/orphan-sweep.js.map +1 -1
  98. package/dist/runtime/supervisor/supervisor-utils.d.ts +1 -1
  99. package/dist/runtime/supervisor/supervisor-utils.d.ts.map +1 -1
  100. package/dist/runtime/supervisor/supervisor-utils.js +1 -1
  101. package/dist/runtime/supervisor/supervisor-utils.js.map +1 -1
  102. package/dist/runtime-api/intake/acceptance-record.d.ts +10 -0
  103. package/dist/runtime-api/intake/acceptance-record.d.ts.map +1 -1
  104. package/dist/runtime-api/intake/acceptance-record.js +28 -0
  105. package/dist/runtime-api/intake/acceptance-record.js.map +1 -1
  106. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  107. package/dist/runtime-api/intake/intake-mount.js +28 -36
  108. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  109. package/dist/runtime-api/intake/process-signal-post.d.ts +20 -1
  110. package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
  111. package/dist/runtime-api/intake/process-signal-post.js +57 -6
  112. package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
  113. package/dist/runtime-api/intake/scanner-launcher.d.ts +18 -4
  114. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  115. package/dist/runtime-api/intake/scanner-launcher.js +24 -3
  116. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  117. package/dist/runtime-api/intake/types.d.ts +16 -0
  118. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  119. package/dist/runtime-api/routes/signals.schema.d.ts +4 -0
  120. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  121. package/dist/runtime-api/routes/signals.schema.js +12 -0
  122. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  123. package/dist/scanners/external/scaffold/__main__.py +65 -24
  124. package/dist/scanners/external/scaffold/ctx.py +78 -0
  125. package/dist/scanners/external/scaffold/delivery.py +56 -22
  126. package/dist/scanners/external/scaffold/entrypoint_loader.py +42 -48
  127. package/dist/scanners/external/scaffold/journal.py +13 -2
  128. package/dist/scanners/external/scaffold/requirements.txt +11 -0
  129. package/dist/scanners/external/scaffold/scaffold.py +159 -128
  130. package/dist/scanners/external/scaffold/senpi_mcp.py +250 -0
  131. package/dist/scanners/external/scaffold/signal_envelope.py +177 -34
  132. package/dist/scanners/external/scaffold/state_manager.py +175 -0
  133. package/dist/scanners/external-scanner-receiver.d.ts +6 -0
  134. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  135. package/dist/scanners/external-scanner-receiver.js +14 -1
  136. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  137. package/dist/scripts/run-dsl-standalone-live.d.ts +1 -1
  138. package/dist/scripts/run-dsl-standalone-live.js +5 -3
  139. package/dist/scripts/run-dsl-standalone-live.js.map +1 -1
  140. package/dist/senpi/client.d.ts +37 -4
  141. package/dist/senpi/client.d.ts.map +1 -1
  142. package/dist/senpi/client.js +101 -24
  143. package/dist/senpi/client.js.map +1 -1
  144. package/dist/state/state-manager.d.ts +17 -0
  145. package/dist/state/state-manager.d.ts.map +1 -1
  146. package/dist/state/state-manager.js +50 -4
  147. package/dist/state/state-manager.js.map +1 -1
  148. package/dist/strategy/strategy-state.d.ts +9 -1
  149. package/dist/strategy/strategy-state.d.ts.map +1 -1
  150. package/dist/strategy/strategy-state.js +62 -9
  151. package/dist/strategy/strategy-state.js.map +1 -1
  152. package/dist/tracking/constants.d.ts.map +1 -1
  153. package/dist/tracking/constants.js +2 -0
  154. package/dist/tracking/constants.js.map +1 -1
  155. package/dist/types/dsl/index.d.ts +1 -1
  156. package/dist/types/dsl/index.d.ts.map +1 -1
  157. package/dist/types/scanner.d.ts +7 -0
  158. package/dist/types/scanner.d.ts.map +1 -1
  159. package/dist/types/strategy.d.ts +3 -1
  160. package/dist/types/strategy.d.ts.map +1 -1
  161. package/dist/utils/keyed-mutex.d.ts +37 -0
  162. package/dist/utils/keyed-mutex.d.ts.map +1 -0
  163. package/dist/utils/keyed-mutex.js +77 -0
  164. package/dist/utils/keyed-mutex.js.map +1 -0
  165. package/examples/strategies/README.md +17 -12
  166. package/examples/strategies/iguana/README.md +135 -0
  167. package/examples/strategies/iguana/recipe.yaml +137 -0
  168. package/examples/strategies/iguana/scanners/scan.py +243 -0
  169. package/examples/strategies/iguana/scanners/scoring.py +152 -0
  170. package/examples/strategies/raptor/README.md +156 -0
  171. package/examples/strategies/raptor/recipe.yaml +154 -0
  172. package/examples/strategies/raptor/scanners/scan.py +349 -0
  173. package/examples/strategies/raptor/scanners/scoring.py +452 -0
  174. package/examples/strategies/spider/README.md +89 -0
  175. package/examples/strategies/spider/scalp/recipe.yaml +129 -0
  176. package/examples/strategies/spider/scalp/scanners/scan.py +219 -0
  177. package/examples/strategies/spider/scalp/scanners/scoring.py +179 -0
  178. package/examples/strategies/spider/swing/recipe.yaml +137 -0
  179. package/examples/strategies/spider/swing/scanners/scan.py +334 -0
  180. package/examples/strategies/spider/swing/scanners/scoring.py +184 -0
  181. package/examples/strategies/turbine/README.md +166 -0
  182. package/examples/strategies/turbine/recipe-runners.yaml +123 -0
  183. package/examples/strategies/turbine/recipe-volume.yaml +129 -0
  184. package/examples/strategies/turbine/scanners/scan.py +283 -0
  185. package/examples/strategies/turbine/scanners/scoring.py +190 -0
  186. package/openclaw.plugin.json +1 -1
  187. package/package.json +1 -1
  188. package/dist/scanners/external/liquidation-watchdog/README.md +0 -107
  189. package/dist/scanners/external/liquidation-watchdog/liquidation-watchdog.yaml +0 -48
  190. package/dist/scanners/external/liquidation-watchdog/producer.mjs +0 -336
  191. package/dist/scanners/external/momentum/README.md +0 -148
  192. package/dist/scanners/external/momentum/momentum.yaml +0 -116
  193. package/dist/scanners/external/momentum/producer.mjs +0 -251
  194. package/dist/scanners/external/shared/ingest.mjs +0 -112
  195. package/examples/README.md +0 -89
  196. package/examples/scanners-consumer-quickstart.mjs +0 -134
  197. package/examples/scanners-emerging-movers-v4.mjs +0 -287
  198. package/examples/scanners-liquidation-watchdog.mjs +0 -341
  199. package/examples/scanners-live-mcp.mjs +0 -208
  200. package/examples/scanners-multi-strategy.mjs +0 -165
  201. package/examples/scanners-position-tracker-e2e.mjs +0 -225
  202. package/examples/scanners-scheduled-events.mjs +0 -96
  203. package/examples/scanners-senpi-provider-client.mjs +0 -182
  204. package/examples/scanners-single-strategy.mjs +0 -198
  205. package/examples/strategies/dsl-showcase.yaml +0 -68
  206. package/examples/strategies/dsl.yaml +0 -51
  207. package/examples/strategies/fox-minimal-loosened.yaml +0 -240
  208. package/examples/strategies/fox-minimal.yaml +0 -242
  209. package/examples/strategies/minimal.yaml +0 -46
  210. package/examples/strategies/momentum/how-it-works.md +0 -48
  211. package/examples/strategies/momentum/momentum-guarded.yaml +0 -161
  212. package/examples/strategies/risk-showcase.yaml +0 -93
  213. package/examples/strategies/viper.yaml +0 -67
  214. package/examples/strategies/wolf.yaml +0 -72
  215. package/examples/yamls/sample-with-dsl-and-emerging-movers.yaml +0 -123
@@ -0,0 +1,250 @@
1
+ """`ctx.senpi_mcp` — the MCP client capability (runtime v2.0.0, Block B5).
2
+
3
+ The author's `scan(inputs, ctx)` is SYNC. `ctx.senpi_mcp.call_tool(name, args)`
4
+ is therefore a SYNC surface: the author never sees `await`. Internally it bridges
5
+ the OFFICIAL MCP Python SDK (`mcp`), which is async-only:
6
+
7
+ from mcp import ClientSession
8
+ from mcp.client.streamable_http import streamablehttp_client
9
+
10
+ async with streamablehttp_client(url, headers=...) as (read, write, _):
11
+ async with ClientSession(read, write) as session:
12
+ await session.initialize()
13
+ result = await session.call_tool(name, arguments=args)
14
+
15
+ DECISION (locked, execution-plan B5): use the SDK the STANDARD way — a SINGLE
16
+ PERSISTENT `ClientSession` created ONCE (and `initialize()`d once), reused across
17
+ every call. NO extra guards: no custom timeout, no reconnect/retry wrapper, no
18
+ pooling toggle, no fresh-connection-per-call. The async context managers must
19
+ STAY OPEN for the client's lifetime, so we drive them on a dedicated event loop
20
+ running in a background thread; `call_tool` submits work onto that loop and blocks
21
+ for the result (the sync↔async bridge).
22
+
23
+ Response normalization MATCHES producer.mjs `createSenpiToolClient`:
24
+ 1. isError truthy -> RAISE with the error text (never a silent None).
25
+ 2. structuredContent present -> return it.
26
+ 3. else first text content -> json.loads it (fallback to the raw text).
27
+ 4. else -> return content as-is.
28
+
29
+ The `mcp` package is LAZY-IMPORTED on first connect (NOT at module top), so
30
+ `import scaffold.senpi_mcp` works even where `mcp` is not installed. Missing creds
31
+ are LOUD AT FIRST USE: `call_tool` raises, construction never does.
32
+
33
+ TEARDOWN (intentional, one-process-per-scanner model): there is NO explicit
34
+ close()/shutdown for the persistent session. The background loop runs on a DAEMON
35
+ thread, so it dies with the process. The scaffold runs exactly ONE client per
36
+ process for that process's whole life — the supervisor restarts by spawning a
37
+ FRESH child process (not by re-running run_scaffold in-place), so a single client
38
+ is never replaced in-process and there is no loop/session leak across restart.
39
+ On graceful stop (SIGTERM/SIGINT) run_scaffold exits its loop and the process
40
+ returns; on a hard kill the OS reclaims the thread. We deliberately do NOT drive
41
+ an AsyncExitStack.aclose() from a shutdown hook: it buys nothing over the dropped
42
+ TCP connection (streamable-http is request/response, no protocol goodbye) and a
43
+ cross-thread aclose() on a wedged transport could itself hang a path that today
44
+ cannot hang.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import asyncio
50
+ import json
51
+ import threading
52
+ from typing import Any, Iterable, Optional
53
+
54
+
55
+ # --------------------------------------------------------------------------
56
+ # Producer MCP write boundary — read-only by default.
57
+ # --------------------------------------------------------------------------
58
+ #
59
+ # A supervised producer's scan(inputs, ctx) runs in-process with the strategy's
60
+ # full-capability SENPI_API_KEY. Without a boundary it could call ANY MCP tool —
61
+ # including money/state mutations — bypassing every runtime guard (risk-guard,
62
+ # DSL, slots, dedup). MUTATION_TOOLS enumerates those money/state-mutating tools;
63
+ # call_tool BLOCKS them by default unless the EXACT tool name is on the client's
64
+ # write_allowlist (least privilege — one permitted tool does not unlock others).
65
+ #
66
+ # Read tools (discovery_*, leaderboard_*, market_*, strategy_get*, strategy_list,
67
+ # execution_get*, account_get*, user_get*, arena_*, audit_*, get_*,
68
+ # list_senpi_guides, read_senpi_guide, ratchet_stop_get/list/events) are NOT in
69
+ # this set and therefore always pass.
70
+ MUTATION_TOOLS: frozenset[str] = frozenset(
71
+ {
72
+ "create_position",
73
+ "close_position",
74
+ "edit_position",
75
+ "cancel_order",
76
+ "send_usdc",
77
+ "transfer_spot_to_perps",
78
+ "strategy_create",
79
+ "strategy_create_custom_strategy",
80
+ "strategy_close",
81
+ "strategy_close_positions",
82
+ "strategy_update",
83
+ "strategy_pause",
84
+ "strategy_top_up",
85
+ "strategy_withdraw_funds",
86
+ "strategy_bridge_funds_from_hyperliquid_to_evm",
87
+ "ratchet_stop_add",
88
+ "ratchet_stop_edit",
89
+ "ratchet_stop_delete",
90
+ "user_claim_referral_rewards",
91
+ }
92
+ )
93
+
94
+ # THE allowlist a scaffold maintainer edits to permit a mutation tool for
95
+ # producers. EMPTY BY DEFAULT — every MUTATION_TOOLS entry is blocked. The ONLY
96
+ # way to permit a mutation is to add its exact tool name to this frozenset
97
+ # (e.g. {"create_position"}). This is scaffold-maintainer-owned source: it is
98
+ # not operator/env-configurable and is not reachable by authored producer code.
99
+ PRODUCER_WRITE_ALLOWLIST: frozenset[str] = frozenset()
100
+
101
+
102
+ class SenpiMcpClient:
103
+ """Sync MCP client over the official async SDK with one persistent session.
104
+
105
+ Construction is cheap and never touches the network or imports `mcp`. The
106
+ first `call_tool` lazily imports the SDK, spins up a private event loop on a
107
+ background thread, opens the transport + ClientSession ONCE, and initializes
108
+ it ONCE. Every subsequent call reuses that same session.
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ api_key: Optional[str] = None,
114
+ mcp_url: Optional[str] = None,
115
+ write_allowlist: Iterable[str] = (),
116
+ ) -> None:
117
+ self._api_key = api_key
118
+ self._mcp_url = mcp_url
119
+ # Mutation tools the operator has explicitly permitted. EMPTY by default
120
+ # → every MUTATION_TOOLS entry is blocked (read-only producer boundary).
121
+ self._write_allowlist: frozenset[str] = frozenset(write_allowlist)
122
+
123
+ # Persistent bridge state — created lazily on first call_tool.
124
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
125
+ self._thread: Optional[threading.Thread] = None
126
+ self._session: Any = None
127
+ self._stack: Any = None
128
+ self._connected = False
129
+ self._connect_lock = threading.Lock()
130
+
131
+ # -- public sync surface ----------------------------------------------
132
+
133
+ def call_tool(self, name: str, args: Optional[dict] = None) -> Any:
134
+ """Invoke an MCP tool synchronously; return the unwrapped result.
135
+
136
+ Raises (loud, never a silent None) when the tool is a money/state
137
+ mutation not on the write allowlist, when creds are missing, or when the
138
+ tool reports `isError`.
139
+ """
140
+ # Read-only producer boundary, enforced PRE-CONNECT (no transport opened
141
+ # for a blocked write). A mutation tool passes only if its exact name is
142
+ # on the write allowlist (least privilege — one tool ≠ all).
143
+ if name in MUTATION_TOOLS and name not in self._write_allowlist:
144
+ raise PermissionError(
145
+ f'senpi_mcp: tool "{name}" is a money/state-mutating MCP tool and '
146
+ "is blocked — producers are READ-ONLY by default. The ONLY way to "
147
+ f'permit it is to add "{name}" to the PRODUCER_WRITE_ALLOWLIST '
148
+ "frozenset in src/scanners/external/scaffold/senpi_mcp.py "
149
+ "(scaffold-maintainer-owned source; not operator/env-configurable; "
150
+ "not reachable by authored producer code)."
151
+ )
152
+
153
+ self._ensure_connected()
154
+ result = self._run(self._session.call_tool(name, arguments=args))
155
+ return self._unwrap(name, result)
156
+
157
+ # -- persistent session lifecycle -------------------------------------
158
+
159
+ def _ensure_connected(self) -> None:
160
+ if self._connected:
161
+ return
162
+ with self._connect_lock:
163
+ if self._connected:
164
+ return
165
+
166
+ # Loud at first use: creds are required to reach the MCP server.
167
+ if not self._api_key:
168
+ raise RuntimeError(
169
+ "senpi_mcp: SENPI_API_KEY is not set; cannot call MCP tools "
170
+ "(creds are resolved at launch from SENPI_API_KEY/SENPI_MCP_URL)."
171
+ )
172
+ if not self._mcp_url:
173
+ raise RuntimeError(
174
+ "senpi_mcp: SENPI_MCP_URL is not set; cannot call MCP tools "
175
+ "(creds are resolved at launch from SENPI_API_KEY/SENPI_MCP_URL)."
176
+ )
177
+
178
+ # Dedicated event loop on a background thread. The async context
179
+ # managers (transport + session) must stay OPEN for the client's
180
+ # lifetime, so we cannot use asyncio.run() per call — that would
181
+ # tear the session down between calls.
182
+ self._loop = asyncio.new_event_loop()
183
+ self._thread = threading.Thread(
184
+ target=self._loop.run_forever,
185
+ name="senpi-mcp-loop",
186
+ daemon=True,
187
+ )
188
+ self._thread.start()
189
+
190
+ self._run(self._connect())
191
+ self._connected = True
192
+
193
+ async def _connect(self) -> None:
194
+ """Open the transport + ClientSession ONCE and initialize ONCE.
195
+
196
+ Lazy-import the SDK here so `import scaffold.senpi_mcp` succeeds in envs
197
+ without `mcp` installed (tests inject a fake `mcp` into sys.modules).
198
+ """
199
+ from mcp import ClientSession
200
+ from mcp.client.streamable_http import streamablehttp_client
201
+
202
+ # Keep the async context managers alive by holding their __aenter__
203
+ # values on self and only __aexit__-ing them at close. We use
204
+ # AsyncExitStack so both nest correctly and unwind in order.
205
+ from contextlib import AsyncExitStack
206
+
207
+ self._stack = AsyncExitStack()
208
+ headers = {"Authorization": f"Bearer {self._api_key}"}
209
+ read, write, _ = await self._stack.enter_async_context(
210
+ streamablehttp_client(self._mcp_url, headers=headers)
211
+ )
212
+ session = await self._stack.enter_async_context(ClientSession(read, write))
213
+ await session.initialize()
214
+ self._session = session
215
+
216
+ def _run(self, coro: Any) -> Any:
217
+ """Drive a coroutine to completion on the persistent loop, blocking."""
218
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
219
+ return future.result()
220
+
221
+ # -- response normalization (matches producer.mjs createSenpiToolClient) --
222
+
223
+ @staticmethod
224
+ def _unwrap(name: str, result: Any) -> Any:
225
+ if getattr(result, "isError", False):
226
+ content = getattr(result, "content", None)
227
+ if (
228
+ isinstance(content, list)
229
+ and len(content) > 0
230
+ and getattr(content[0], "type", None) == "text"
231
+ ):
232
+ error_text = content[0].text
233
+ else:
234
+ error_text = json.dumps(content, default=str)
235
+ raise RuntimeError(f'MCP tool "{name}" returned error: {error_text}')
236
+
237
+ structured = getattr(result, "structuredContent", None)
238
+ if structured is not None:
239
+ return structured
240
+
241
+ content = getattr(result, "content", None)
242
+ if isinstance(content, list) and len(content) > 0:
243
+ first = content[0]
244
+ if getattr(first, "type", None) == "text":
245
+ try:
246
+ return json.loads(first.text)
247
+ except (ValueError, TypeError):
248
+ return first.text
249
+
250
+ return content
@@ -1,75 +1,218 @@
1
- """Pure candidate -> wire-signal mapping.
1
+ """Pure candidate -> wire-signal mapping (v2: lenient + loud).
2
+
3
+ candidate_to_signal(
4
+ candidate,
5
+ produced_at_ms,
6
+ *,
7
+ signal_id=None, # stable production id (journal eid); minted if absent
8
+ signal_data_schema, # dict {key: {type, required?}} | None
9
+ default_signal_validity_seconds, # int REQUIRED fallback TTL (seconds)
10
+ on_reject=None, # callback(reason_dict) on reject -> /errors
11
+ ) -> dict | None
12
+
13
+ Returns the wire dict on success:
14
+ {signal_id, asset, direction, produced_at, valid_until, data}
15
+ plus signal_type only when the author set a non-empty string, and the canonical
16
+ sizing fields `marginUsd` (USD margin, not a percent) and/or `leverage` at the
17
+ TOP LEVEL (not inside `data`) when the candidate sets them — the runtime reads
18
+ signal.marginUsd / signal.leverage directly. A present-but-non-positive-number
19
+ marginUsd/leverage is a LOUD reject.
20
+
21
+ Returns None (never raises for a bad CANDIDATE) when the candidate is invalid,
22
+ and — when on_reject is supplied — calls on_reject(reason) so the rejection is
23
+ LOUD (logged AND posted to /errors), never a silent drop.
24
+
25
+ v2 contract (design §4):
26
+ - direction is NORMALIZED case: "long"/"LONG"/"Long" -> "LONG"; "short"/.. -> "SHORT".
27
+ Surrounding whitespace is stripped. "buy"/"sell"/""/non-string -> REJECT.
28
+ - asset + direction are MANDATORY (non-empty string asset).
29
+ - `data` is VALIDATED against signal_data_schema (when a dict is supplied):
30
+ missing-required / wrong-type / extra (unknown) keys all REJECT. A None schema
31
+ means "no validation" (passthrough — used when no schema is threaded in).
32
+ - valid_until = produced_at_ms + (valid_for_seconds else
33
+ default_signal_validity_seconds) * 1000. The v1 produced_at + 2*interval default
34
+ is GONE; there is no interval_ms arg. A non-positive / non-int valid_for_seconds
35
+ is REJECTED. A MISSING default_signal_validity_seconds (None) when it is NEEDED
36
+ fails LOUD (raises) — no magic fallback.
37
+ - author absolute `valid_until` is DROPPED (never honored, never leaks).
38
+ - The keys on the wire are {signal_id, asset, direction, produced_at,
39
+ valid_until, data} (+ optional signal_type, marginUsd, leverage). `marginUsd`/
40
+ `leverage` are the canonical top-level sizing fields (validated positive numbers
41
+ when present; `marginUsd` is a USD value, not a percent). `signal_id` is the
42
+ scaffold-minted
43
+ stable production id (the journal eid), threaded in by the delivery sink — it is
44
+ the intake dedup key. An author-supplied `signal_id` in the candidate dict is
45
+ IGNORED: the scaffold owns the dedup key, an author cannot spoof it. Apart from
46
+ signal_id/signal_type, author bookkeeping never leaks.
47
+ """
2
48
 
3
- candidate_to_signal(candidate, produced_at_ms, interval_ms) -> dict | None
49
+ from __future__ import annotations
4
50
 
5
- Returns the six-key wire dict on success:
6
- {asset, direction, produced_at, valid_until, data}
7
- plus signal_type only when the author set a non-empty string.
51
+ import uuid
52
+ from typing import Any, Optional
8
53
 
9
- Returns None (never raises) when the candidate is invalid:
10
- - non-mapping candidate
11
- - missing / blank / non-string asset
12
- - missing / non-LONG/SHORT direction (no case coercion)
13
54
 
14
- Wire rules (TS intake: additionalProperties:false):
15
- - direction must be exactly "LONG" or "SHORT"
16
- - produced_at / valid_until are int epoch ms
17
- - valid_until = author override verbatim when present, else produced_at + 2*interval_ms
18
- - data is the author's open record passed through as-is (missing -> {})
19
- - NO extra keys on the wire
20
- """
55
+ # JSON type name -> Python type(s) for schema validation. `bool` is excluded from
56
+ # "number" because a bool is an int subclass in Python but is not a numeric value.
57
+ def _is_number(value: Any) -> bool:
58
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
59
+
60
+
61
+ def _is_string(value: Any) -> bool:
62
+ return isinstance(value, str)
63
+
64
+
65
+ def _is_boolean(value: Any) -> bool:
66
+ return isinstance(value, bool)
67
+
68
+
69
+ def _is_object(value: Any) -> bool:
70
+ return isinstance(value, dict)
71
+
72
+
73
+ def _is_array(value: Any) -> bool:
74
+ return isinstance(value, list)
21
75
 
22
- from __future__ import annotations
23
76
 
24
- from typing import Any
77
+ _TYPE_CHECKS = {
78
+ "number": _is_number,
79
+ "string": _is_string,
80
+ "boolean": _is_boolean,
81
+ "object": _is_object,
82
+ "array": _is_array,
83
+ }
84
+
85
+
86
+ def _validate_data_against_schema(data: dict, schema: dict) -> Optional[str]:
87
+ """Validate `data` against `schema`. Returns a reason string on violation,
88
+ or None when valid. additionalProperties:false — unknown keys reject."""
89
+ allowed = set(schema.keys())
90
+
91
+ # Extra (unknown) keys reject.
92
+ for key in data:
93
+ if key not in allowed:
94
+ return f"data has unknown key {key!r} (not in signal_data_schema)"
95
+
96
+ # Missing-required and wrong-type.
97
+ for key, spec in schema.items():
98
+ spec = spec if isinstance(spec, dict) else {}
99
+ required = spec.get("required", True) # required unless explicitly False
100
+ if key not in data:
101
+ if required:
102
+ return f"data missing required key {key!r}"
103
+ continue
104
+ declared_type = spec.get("type")
105
+ check = _TYPE_CHECKS.get(declared_type)
106
+ if check is not None and not check(data[key]):
107
+ return (
108
+ f"data key {key!r} has wrong type "
109
+ f"(expected {declared_type}, got {type(data[key]).__name__})"
110
+ )
111
+ return None
25
112
 
26
113
 
27
114
  def candidate_to_signal(
28
115
  candidate: Any,
29
116
  produced_at_ms: int,
30
- interval_ms: int,
117
+ *,
118
+ signal_id: Optional[str] = None,
119
+ signal_data_schema: Optional[dict],
120
+ default_signal_validity_seconds: Optional[int],
121
+ on_reject=None,
31
122
  ) -> dict | None:
32
- """Wrap a scan() candidate into a wire signal dict.
123
+ """Wrap a scan() candidate into a wire signal dict (v2).
33
124
 
34
- Returns the signal dict on success, None when the candidate is invalid.
35
- Never raises.
125
+ Returns the signal dict on success, None when the candidate is invalid (and
126
+ calls on_reject(reason) so the rejection is loud). Raises only when the
127
+ OPERATOR config is wrong (missing default_signal_validity_seconds when needed).
36
128
  """
129
+
130
+ def _reject(reason: str) -> None:
131
+ if on_reject is not None:
132
+ on_reject({"reason": reason, "candidate": repr(candidate)})
133
+
37
134
  if not isinstance(candidate, dict):
135
+ _reject(f"candidate is not a mapping (got {type(candidate).__name__})")
38
136
  return None
39
137
 
138
+ # -- asset: mandatory, non-empty string --------------------------------
40
139
  asset = candidate.get("asset")
41
140
  if not isinstance(asset, str) or not asset.strip():
141
+ _reject(f"asset missing/blank/non-string (got {asset!r})")
42
142
  return None
43
143
 
144
+ # -- direction: mandatory string, case-normalized to LONG / SHORT ------
44
145
  direction = candidate.get("direction")
45
- if direction not in ("LONG", "SHORT"):
146
+ if not isinstance(direction, str) or isinstance(direction, bool):
147
+ _reject(f"direction is not a string (got {direction!r})")
148
+ return None
149
+ normalized = direction.strip().upper()
150
+ if normalized not in ("LONG", "SHORT"):
151
+ _reject(f"direction must be long/short (got {direction!r})")
46
152
  return None
47
153
 
48
- # valid_until: author override verbatim when a real number, or default.
49
- # bool is a subclass of int in Python, but is not a valid epoch ms value.
50
- if "valid_until" in candidate:
51
- valid_until = candidate["valid_until"]
52
- if not isinstance(valid_until, (int, float)) or isinstance(valid_until, bool):
53
- from .loop_primitives import log_event
54
- log_event("delivery_candidate_invalid",
55
- reason="valid_until_not_numeric",
56
- candidate=repr(candidate))
154
+ # -- valid_until from valid_for_seconds else the default ---------------
155
+ # (compute BEFORE schema so a missing operator default still fails loud.)
156
+ if "valid_for_seconds" in candidate:
157
+ vfs = candidate["valid_for_seconds"]
158
+ # int only (bool excluded), strictly positive.
159
+ if not isinstance(vfs, int) or isinstance(vfs, bool) or vfs <= 0:
160
+ _reject(f"valid_for_seconds must be a positive int (got {vfs!r})")
57
161
  return None
162
+ validity_seconds = vfs
58
163
  else:
59
- valid_until = produced_at_ms + 2 * interval_ms
164
+ if default_signal_validity_seconds is None:
165
+ # OPERATOR error, not a candidate bug — fail LOUD (no magic fallback).
166
+ raise ValueError(
167
+ "default_signal_validity_seconds is required when a candidate has "
168
+ "no valid_for_seconds; got None (no magic fallback in v2)."
169
+ )
170
+ validity_seconds = default_signal_validity_seconds
171
+
172
+ valid_until = produced_at_ms + validity_seconds * 1000
60
173
 
174
+ # -- data: validate against schema (when supplied) ---------------------
61
175
  data = candidate.get("data", {})
62
176
  if not isinstance(data, dict):
63
177
  data = {}
178
+ if signal_data_schema is not None:
179
+ violation = _validate_data_against_schema(data, signal_data_schema)
180
+ if violation is not None:
181
+ _reject(violation)
182
+ return None
183
+
184
+ # -- canonical sizing: marginUsd + leverage ride at the TOP LEVEL of the wire
185
+ # signal, NOT inside `data`. `marginUsd` is the USD margin (not a percent);
186
+ # the runtime consumes signal.marginUsd / signal.leverage directly (data is
187
+ # freeform context only), so per-signal sizing/leverage from the producer is
188
+ # honored instead of silently dropped. Present-but-invalid is a LOUD reject —
189
+ # sizing must never fall back silently.
190
+ sizing: dict = {}
191
+ for field in ("marginUsd", "leverage"):
192
+ if candidate.get(field) is None:
193
+ continue
194
+ value = candidate[field]
195
+ if not _is_number(value) or value <= 0:
196
+ _reject(f"{field} must be a positive number when set (got {value!r})")
197
+ return None
198
+ sizing[field] = value
64
199
 
65
200
  sig: dict = {
201
+ # scaffold-minted stable production id (journal eid); the author cannot
202
+ # override it via the candidate dict — the kwarg is authoritative. The
203
+ # delivery sink threads the eid in; when no id is supplied a stable one is
204
+ # minted so the wire ALWAYS carries a signal_id (required end-to-end).
205
+ "signal_id": signal_id if (isinstance(signal_id, str) and signal_id) else uuid.uuid4().hex,
66
206
  "asset": asset,
67
- "direction": direction,
207
+ "direction": normalized,
68
208
  "produced_at": produced_at_ms,
69
209
  "valid_until": valid_until,
70
210
  "data": data,
71
211
  }
72
212
 
213
+ # Lift the validated sizing fields onto the wire signal at the top level.
214
+ sig.update(sizing)
215
+
73
216
  signal_type = candidate.get("signal_type")
74
217
  if signal_type and isinstance(signal_type, str):
75
218
  sig["signal_type"] = signal_type
@@ -0,0 +1,175 @@
1
+ """Disk-backed, bounded, transactional state series for the scaffold core.
2
+
3
+ `StateManager` is the `ctx.state` capability the supervised external scanner
4
+ author sees: a list-like series of dict records, persisted between ticks and
5
+ bounded by `state_history_max_count`.
6
+
7
+ Shape (per scanner-and-ctx-design.md §2.3):
8
+
9
+ StateManager(state_path, state_history_max_count)
10
+
11
+ Author surface:
12
+ append(record) record MUST be a dict; a reserved `recorded_at` (float
13
+ SECONDS, time.time() magnitude — never ms) is injected.
14
+ scaffold wins on a `recorded_at` collision. returns None.
15
+ RAISES when state_history_max_count is 0/unset — history
16
+ disabled is a hard error, never a silent no-op.
17
+ recent(n) last n entries, chronological (oldest->newest); n<=0 -> [].
18
+ last() most recent entry, or None if empty.
19
+ __len__ / __getitem__ (index + slice) / __iter__ (oldest -> newest).
20
+
21
+ Internals: backed by collections.deque(maxlen=state_history_max_count).
22
+ Loading an on-disk series longer than the bound keeps the NEWEST entries
23
+ (deque eviction drops the oldest).
24
+
25
+ Transactional (one commit per tick):
26
+ commit() persists the working buffer via the atomic write_state.
27
+ JSON-serializability is enforced LOUD here — a failed persist
28
+ RAISES and does NOT advance the committed snapshot, leaving the
29
+ prior on-disk state intact.
30
+ rollback() discards mid-tick mutations: the working buffer is restored to
31
+ the last committed snapshot.
32
+ read-your-writes within a tick: appended records are visible via
33
+ len/recent/last/iter/getitem BEFORE commit.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import copy
39
+ import time
40
+ from collections import deque
41
+
42
+ from .scaffold_state import read_state, write_state
43
+
44
+ # Reserved, scaffold-owned key injected into every record. Float seconds.
45
+ RECORDED_AT_KEY = "recorded_at"
46
+
47
+
48
+ class StateManager:
49
+ """A disk-backed, bounded, transactional series of dict records."""
50
+
51
+ def __init__(self, state_path: str, state_history_max_count):
52
+ self._state_path = state_path
53
+ # 0 / None / falsy => history disabled. Kept as-is so append can raise.
54
+ self._max_count = state_history_max_count
55
+
56
+ # Load any persisted series. read_state never raises: missing/corrupt
57
+ # -> None. A non-list payload is treated as "no usable history".
58
+ # state_path None => no disk persistence (commit advances the in-memory
59
+ # snapshot but never writes). read_state needs a real path.
60
+ loaded = read_state(state_path) if state_path is not None else None
61
+ if not isinstance(loaded, list):
62
+ loaded = []
63
+
64
+ # The committed snapshot is the last durably-persisted series; the
65
+ # working buffer is what the current tick reads/mutates. Both are
66
+ # bounded the same way (deque(maxlen=...)); loading a longer-than-bound
67
+ # series keeps the NEWEST entries (deque evicts from the left).
68
+ self._committed = self._new_deque(loaded)
69
+ self._working = self._snapshot(self._committed)
70
+
71
+ # -- internals ---------------------------------------------------------
72
+
73
+ def _new_deque(self, initial=()):
74
+ """A deque bounded by max_count. When history is disabled (max_count is
75
+ 0/None) the bound is 0 — nothing can ever be buffered."""
76
+ maxlen = self._max_count if self._max_count else 0
77
+ return deque(initial, maxlen=maxlen)
78
+
79
+ def _snapshot(self, source):
80
+ """An independent bounded deque whose records share NO mutable state
81
+ with `source`. Committed and working buffers must never alias the same
82
+ dict objects — otherwise a mid-tick mutation of a record (an author
83
+ scribbling on a dict handed back by last()/recent()/getitem) would
84
+ survive a rollback and corrupt the very snapshot the rollback restores.
85
+ Deep-copy at every snapshot boundary (load / commit / rollback)."""
86
+ return self._new_deque(copy.deepcopy(entry) for entry in source)
87
+
88
+ # -- author surface ----------------------------------------------------
89
+
90
+ def append(self, record) -> None:
91
+ """Append a dict record to the working buffer.
92
+
93
+ Injects the reserved `recorded_at` (float seconds, scaffold wins on
94
+ collision). Raises TypeError if record is not a dict. Raises if history
95
+ is disabled (state_history_max_count 0/unset) — never a silent no-op.
96
+ """
97
+ if not self._max_count:
98
+ raise ValueError(
99
+ "state is disabled: state_history_max_count is 0 or unset "
100
+ "(set it > 0 to use ctx.state.append)"
101
+ )
102
+ # bool is an int subclass but never a dict; explicit dict check rejects
103
+ # lists/tuples/sets/strings/numbers/None/objects/bool alike.
104
+ if not isinstance(record, dict):
105
+ raise TypeError(
106
+ f"state record must be a dict, got {type(record).__name__}"
107
+ )
108
+
109
+ # Copy so the author's dict is never mutated, then stamp recorded_at.
110
+ # scaffold wins on collision: assign AFTER copying author keys.
111
+ entry = dict(record)
112
+ entry[RECORDED_AT_KEY] = time.time()
113
+ self._working.append(entry)
114
+ return None
115
+
116
+ def recent(self, n) -> list:
117
+ """The last n entries, chronological (oldest->newest).
118
+
119
+ n <= 0 returns []; n greater than the buffer returns the whole buffer.
120
+ Negative n is NOT a from-the-end slice — it is meaningless for "last n"
121
+ and yields [].
122
+ """
123
+ if n <= 0:
124
+ return []
125
+ items = list(self._working)
126
+ return items[-n:]
127
+
128
+ def last(self):
129
+ """The most recent entry, or None if empty."""
130
+ if not self._working:
131
+ return None
132
+ return self._working[-1]
133
+
134
+ # -- sequence protocol -------------------------------------------------
135
+
136
+ def __len__(self) -> int:
137
+ return len(self._working)
138
+
139
+ def __getitem__(self, key):
140
+ # deque supports integer indexing (raising IndexError out of range) but
141
+ # not slicing; materialise a list so slices work with Python semantics
142
+ # (clamping, steps, negative bounds) and index errors still propagate.
143
+ if isinstance(key, slice):
144
+ return list(self._working)[key]
145
+ return self._working[key]
146
+
147
+ def __iter__(self):
148
+ return iter(self._working)
149
+
150
+ # -- transaction -------------------------------------------------------
151
+
152
+ def commit(self) -> None:
153
+ """Persist the working buffer atomically via write_state.
154
+
155
+ JSON-serializability is enforced LOUD: write_state returns False on a
156
+ non-serializable value (or IO failure) without touching the existing
157
+ file. We raise on that False so a failed persist never silently
158
+ advances the committed snapshot — the prior on-disk state stays intact.
159
+ """
160
+ payload = list(self._working)
161
+ # No disk path => commit advances the in-memory snapshot only.
162
+ ok = True if self._state_path is None else write_state(self._state_path, payload)
163
+ if not ok:
164
+ raise RuntimeError(
165
+ f"failed to persist state to {self._state_path} "
166
+ "(non-serializable value or IO error); state not advanced"
167
+ )
168
+ # Durable: advance the committed snapshot to match what is on disk.
169
+ # Deep-copy so the committed snapshot does not alias working records.
170
+ self._committed = self._snapshot(self._working)
171
+
172
+ def rollback(self) -> None:
173
+ """Discard mid-tick mutations: restore the working buffer to the last
174
+ committed snapshot. Never touches disk."""
175
+ self._working = self._snapshot(self._committed)