@senpi/runtime 2.0.2-dev.openclaw-upgrade-mai.20260622175851 → 3.0.0-dev.runtime-phase-2.20260623210537

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 (209) hide show
  1. package/README.md +9 -10
  2. package/dist/actions/decision-engine.d.ts.map +1 -1
  3. package/dist/actions/decision-engine.js +0 -3
  4. package/dist/actions/decision-engine.js.map +1 -1
  5. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  6. package/dist/actions/open-position/open-position.action.js +2 -6
  7. package/dist/actions/open-position/open-position.action.js.map +1 -1
  8. package/dist/cli/guide-commands.d.ts.map +1 -1
  9. package/dist/cli/guide-commands.js +56 -43
  10. package/dist/cli/guide-commands.js.map +1 -1
  11. package/dist/cli/senpi-commands.d.ts.map +1 -1
  12. package/dist/cli/senpi-commands.js +108 -0
  13. package/dist/cli/senpi-commands.js.map +1 -1
  14. package/dist/constants/dsl/index.d.ts +0 -2
  15. package/dist/constants/dsl/index.d.ts.map +1 -1
  16. package/dist/constants/dsl/index.js +0 -2
  17. package/dist/constants/dsl/index.js.map +1 -1
  18. package/dist/dsl/engine-client/config-mapping.d.ts +0 -7
  19. package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
  20. package/dist/dsl/engine-client/config-mapping.js +2 -16
  21. package/dist/dsl/engine-client/config-mapping.js.map +1 -1
  22. package/dist/dsl/events/handlers.d.ts +2 -18
  23. package/dist/dsl/events/handlers.d.ts.map +1 -1
  24. package/dist/dsl/events/handlers.js +213 -302
  25. package/dist/dsl/events/handlers.js.map +1 -1
  26. package/dist/dsl/monitor/create-monitor.d.ts +0 -15
  27. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  28. package/dist/dsl/monitor/create-monitor.js +9 -44
  29. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  30. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  31. package/dist/dsl/monitor/process-one-position.js +1 -24
  32. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  33. package/dist/dsl/plugin/index.d.ts +1 -16
  34. package/dist/dsl/plugin/index.d.ts.map +1 -1
  35. package/dist/dsl/plugin/index.js +4 -35
  36. package/dist/dsl/plugin/index.js.map +1 -1
  37. package/dist/dsl/plugin/options.d.ts +0 -18
  38. package/dist/dsl/plugin/options.d.ts.map +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +10 -4
  41. package/dist/index.js.map +1 -1
  42. package/dist/runtime/create-scanner-module.d.ts +3 -4
  43. package/dist/runtime/create-scanner-module.d.ts.map +1 -1
  44. package/dist/runtime/create-scanner-module.js +5 -14
  45. package/dist/runtime/create-scanner-module.js.map +1 -1
  46. package/dist/runtime/create-scanner-providers-for-run.d.ts.map +1 -1
  47. package/dist/runtime/create-scanner-providers-for-run.js +1 -12
  48. package/dist/runtime/create-scanner-providers-for-run.js.map +1 -1
  49. package/dist/runtime/load-runtime-config.js +1 -1
  50. package/dist/runtime/load-runtime-config.js.map +1 -1
  51. package/dist/runtime/map-strategies-registration.js +8 -7
  52. package/dist/runtime/map-strategies-registration.js.map +1 -1
  53. package/dist/runtime/map-strategies.d.ts.map +1 -1
  54. package/dist/runtime/map-strategies.js +3 -12
  55. package/dist/runtime/map-strategies.js.map +1 -1
  56. package/dist/runtime/reconcile-state.d.ts +0 -44
  57. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  58. package/dist/runtime/reconcile-state.js +44 -80
  59. package/dist/runtime/reconcile-state.js.map +1 -1
  60. package/dist/runtime/risk-guard.d.ts.map +1 -1
  61. package/dist/runtime/risk-guard.js +12 -17
  62. package/dist/runtime/risk-guard.js.map +1 -1
  63. package/dist/runtime/runtime-schema.d.ts +995 -2855
  64. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  65. package/dist/runtime/runtime-schema.js +40 -133
  66. package/dist/runtime/runtime-schema.js.map +1 -1
  67. package/dist/runtime/runtime.d.ts +0 -26
  68. package/dist/runtime/runtime.d.ts.map +1 -1
  69. package/dist/runtime/runtime.js +5 -233
  70. package/dist/runtime/runtime.js.map +1 -1
  71. package/dist/runtime/scanner-registry.d.ts +1 -1
  72. package/dist/runtime/scanner-registry.d.ts.map +1 -1
  73. package/dist/runtime/scanner-registry.js +12 -3
  74. package/dist/runtime/scanner-registry.js.map +1 -1
  75. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  76. package/dist/runtime/supervisor/long-running-supervisor.js +1 -1
  77. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  78. package/dist/runtime/supervisor/orphan-sweep.js +1 -1
  79. package/dist/runtime/supervisor/orphan-sweep.js.map +1 -1
  80. package/dist/runtime/supervisor/supervisor-utils.d.ts +3 -3
  81. package/dist/runtime/supervisor/supervisor-utils.d.ts.map +1 -1
  82. package/dist/runtime/supervisor/supervisor-utils.js +3 -3
  83. package/dist/runtime/supervisor/supervisor-utils.js.map +1 -1
  84. package/dist/runtime-api/intake/intake-mount.d.ts +3 -3
  85. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  86. package/dist/runtime-api/intake/intake-mount.js +36 -28
  87. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  88. package/dist/runtime-api/intake/liveness-store.d.ts +6 -2
  89. package/dist/runtime-api/intake/liveness-store.d.ts.map +1 -1
  90. package/dist/runtime-api/intake/liveness-store.js +6 -2
  91. package/dist/runtime-api/intake/liveness-store.js.map +1 -1
  92. package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
  93. package/dist/runtime-api/intake/process-signal-post.js +0 -0
  94. package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
  95. package/dist/runtime-api/intake/scanner-launcher.d.ts +29 -24
  96. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  97. package/dist/runtime-api/intake/scanner-launcher.js +3 -24
  98. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  99. package/dist/runtime-api/intake/types.d.ts +0 -6
  100. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  101. package/dist/runtime-api/routes/signals.d.ts +6 -0
  102. package/dist/runtime-api/routes/signals.d.ts.map +1 -1
  103. package/dist/runtime-api/routes/signals.js +14 -0
  104. package/dist/runtime-api/routes/signals.js.map +1 -1
  105. package/dist/runtime-api/routes/signals.schema.d.ts +0 -1
  106. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  107. package/dist/runtime-api/routes/signals.schema.js +0 -5
  108. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  109. package/dist/runtime-api/server.d.ts.map +1 -1
  110. package/dist/runtime-api/server.js +1 -0
  111. package/dist/runtime-api/server.js.map +1 -1
  112. package/dist/scanners/external/liquidation-watchdog/README.md +107 -0
  113. package/dist/scanners/external/liquidation-watchdog/liquidation-watchdog.yaml +48 -0
  114. package/dist/scanners/external/liquidation-watchdog/producer.mjs +336 -0
  115. package/dist/scanners/external/momentum/README.md +148 -0
  116. package/dist/scanners/external/momentum/momentum.yaml +116 -0
  117. package/dist/scanners/external/momentum/producer.mjs +251 -0
  118. package/dist/scanners/external/scaffold/__main__.py +24 -65
  119. package/dist/scanners/external/scaffold/delivery.py +22 -56
  120. package/dist/scanners/external/scaffold/entrypoint_loader.py +48 -42
  121. package/dist/scanners/external/scaffold/journal.py +22 -15
  122. package/dist/scanners/external/scaffold/scaffold.py +128 -159
  123. package/dist/scanners/external/scaffold/signal_envelope.py +34 -177
  124. package/dist/scanners/external/shared/ingest.mjs +112 -0
  125. package/dist/scanners/external-scanner-receiver.d.ts +0 -6
  126. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  127. package/dist/scanners/external-scanner-receiver.js +1 -14
  128. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  129. package/dist/scripts/run-dsl-standalone-live.d.ts +1 -1
  130. package/dist/scripts/run-dsl-standalone-live.js +3 -5
  131. package/dist/scripts/run-dsl-standalone-live.js.map +1 -1
  132. package/dist/senpi/client.d.ts +0 -12
  133. package/dist/senpi/client.d.ts.map +1 -1
  134. package/dist/senpi/client.js +2 -31
  135. package/dist/senpi/client.js.map +1 -1
  136. package/dist/state/state-manager.d.ts +0 -17
  137. package/dist/state/state-manager.d.ts.map +1 -1
  138. package/dist/state/state-manager.js +0 -28
  139. package/dist/state/state-manager.js.map +1 -1
  140. package/dist/strategy/strategy-state.d.ts.map +1 -1
  141. package/dist/strategy/strategy-state.js +1 -7
  142. package/dist/strategy/strategy-state.js.map +1 -1
  143. package/dist/tracking/constants.d.ts.map +1 -1
  144. package/dist/tracking/constants.js +0 -2
  145. package/dist/tracking/constants.js.map +1 -1
  146. package/dist/types/dsl/index.d.ts +1 -1
  147. package/dist/types/dsl/index.d.ts.map +1 -1
  148. package/dist/types/scanner.d.ts +0 -7
  149. package/dist/types/scanner.d.ts.map +1 -1
  150. package/examples/README.md +89 -0
  151. package/examples/scanners-consumer-quickstart.mjs +134 -0
  152. package/examples/scanners-emerging-movers-v4.mjs +287 -0
  153. package/examples/scanners-liquidation-watchdog.mjs +341 -0
  154. package/examples/scanners-live-mcp.mjs +208 -0
  155. package/examples/scanners-multi-strategy.mjs +165 -0
  156. package/examples/scanners-position-tracker-e2e.mjs +225 -0
  157. package/examples/scanners-scheduled-events.mjs +96 -0
  158. package/examples/scanners-senpi-provider-client.mjs +182 -0
  159. package/examples/scanners-single-strategy.mjs +198 -0
  160. package/examples/strategies/README.md +12 -17
  161. package/examples/strategies/dsl-showcase.yaml +68 -0
  162. package/examples/strategies/dsl.yaml +51 -0
  163. package/examples/strategies/fox-minimal-loosened.yaml +240 -0
  164. package/examples/strategies/fox-minimal.yaml +242 -0
  165. package/examples/strategies/minimal.yaml +46 -0
  166. package/examples/strategies/momentum/how-it-works.md +48 -0
  167. package/examples/strategies/momentum/momentum-guarded.yaml +161 -0
  168. package/examples/strategies/risk-showcase.yaml +93 -0
  169. package/examples/strategies/viper.yaml +67 -0
  170. package/examples/strategies/wolf.yaml +72 -0
  171. package/examples/yamls/sample-with-dsl-and-emerging-movers.yaml +123 -0
  172. package/openclaw.plugin.json +1 -1
  173. package/package.json +1 -1
  174. package/dist/runtime/reconcile-decide.d.ts +0 -73
  175. package/dist/runtime/reconcile-decide.d.ts.map +0 -1
  176. package/dist/runtime/reconcile-decide.js +0 -166
  177. package/dist/runtime/reconcile-decide.js.map +0 -1
  178. package/dist/runtime/reconcile-run.d.ts +0 -62
  179. package/dist/runtime/reconcile-run.d.ts.map +0 -1
  180. package/dist/runtime/reconcile-run.js +0 -173
  181. package/dist/runtime/reconcile-run.js.map +0 -1
  182. package/dist/scanners/external/scaffold/ctx.py +0 -78
  183. package/dist/scanners/external/scaffold/requirements.txt +0 -11
  184. package/dist/scanners/external/scaffold/senpi_mcp.py +0 -250
  185. package/dist/scanners/external/scaffold/state_manager.py +0 -175
  186. package/dist/utils/keyed-mutex.d.ts +0 -37
  187. package/dist/utils/keyed-mutex.d.ts.map +0 -1
  188. package/dist/utils/keyed-mutex.js +0 -77
  189. package/dist/utils/keyed-mutex.js.map +0 -1
  190. package/examples/strategies/iguana/README.md +0 -135
  191. package/examples/strategies/iguana/recipe.yaml +0 -137
  192. package/examples/strategies/iguana/scanners/scan.py +0 -243
  193. package/examples/strategies/iguana/scanners/scoring.py +0 -152
  194. package/examples/strategies/raptor/README.md +0 -156
  195. package/examples/strategies/raptor/recipe.yaml +0 -154
  196. package/examples/strategies/raptor/scanners/scan.py +0 -349
  197. package/examples/strategies/raptor/scanners/scoring.py +0 -452
  198. package/examples/strategies/spider/README.md +0 -97
  199. package/examples/strategies/spider/scalp/recipe.yaml +0 -129
  200. package/examples/strategies/spider/scalp/scanners/scan.py +0 -219
  201. package/examples/strategies/spider/scalp/scanners/scoring.py +0 -179
  202. package/examples/strategies/spider/swing/recipe.yaml +0 -141
  203. package/examples/strategies/spider/swing/scanners/scan.py +0 -334
  204. package/examples/strategies/spider/swing/scanners/scoring.py +0 -184
  205. package/examples/strategies/turbine/README.md +0 -167
  206. package/examples/strategies/turbine/recipe-runners.yaml +0 -123
  207. package/examples/strategies/turbine/recipe-volume.yaml +0 -129
  208. package/examples/strategies/turbine/scanners/scan.py +0 -283
  209. package/examples/strategies/turbine/scanners/scoring.py +0 -190
@@ -0,0 +1,251 @@
1
+ /**
2
+ * External momentum producer.
3
+ *
4
+ * A standalone momentum scanner that:
5
+ * 1. Pulls instruments + latest candles from the live Senpi MCP server.
6
+ * 2. Computes last-vs-prev close % move against configurable thresholds.
7
+ * 3. Pushes MOMENTUM_BREAKOUT signals into a running runtime via the
8
+ * `openclaw senpi external-scanner ingest` CLI command.
9
+ *
10
+ * Prereqs:
11
+ * - `npm run build` so dist/ is current (used only for the MCP provider bridge).
12
+ * - A runtime installed from ./momentum.yaml is running for STRATEGY_ADDRESS.
13
+ * - `openclaw` CLI on PATH and authenticated against that runtime.
14
+ * - SENPI_API_KEY and SENPI_MCP_URL env vars set.
15
+ *
16
+ * Run: node src/scanners/external/momentum/producer.mjs
17
+ */
18
+
19
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
20
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
21
+ import { createSenpiScannerProviders } from "../../../../dist/scanners/index.js";
22
+ import { pushScannerSignals } from "../shared/ingest.mjs";
23
+
24
+ // ─── Config ─────────────────────────────────────────────────────────────────
25
+
26
+ const SENPI_API_KEY = process.env.SENPI_API_KEY;
27
+ const SENPI_MCP_URL = process.env.SENPI_MCP_URL;
28
+ const STRATEGY_ADDRESS = process.env.STRATEGY_ADDRESS;
29
+ const SCANNER_NAME =
30
+ process.env.EXTERNAL_SCANNER_NAME ?? "external_momentum";
31
+ const OPENCLAW_BIN = process.env.OPENCLAW_BIN ?? "openclaw";
32
+ const SCANNER_ID = "external-momentum-producer";
33
+
34
+ const config = {
35
+ timeframe: process.env.TIMEFRAME ?? "1h",
36
+ minMovePct: numEnv("MIN_MOVE_PCT", 1.5),
37
+ minDayVolume: numEnv("MIN_DAY_VOLUME", 1_000_000),
38
+ minLeverage: numEnv("MIN_LEVERAGE", 5),
39
+ maxCandidates: numEnv("MAX_CANDIDATES", 20),
40
+ minSignalScore: numEnv("MIN_SIGNAL_SCORE", 0.2),
41
+ };
42
+
43
+ if (!SENPI_API_KEY || !SENPI_MCP_URL || !STRATEGY_ADDRESS) {
44
+ console.error(
45
+ "Missing required env vars. Set SENPI_API_KEY, SENPI_MCP_URL, and STRATEGY_ADDRESS.\n" +
46
+ " export SENPI_API_KEY='...'\n" +
47
+ " export SENPI_MCP_URL='https://mcp.prod.senpi.ai/mcp'\n" +
48
+ " export STRATEGY_ADDRESS='0x...'",
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ function numEnv(name, fallback) {
54
+ const raw = process.env[name];
55
+ if (raw === undefined || raw === "") return fallback;
56
+ const parsed = Number(raw);
57
+ if (!Number.isFinite(parsed)) {
58
+ throw new Error(`Env ${name} must be a finite number, got "${raw}"`);
59
+ }
60
+ return parsed;
61
+ }
62
+
63
+ // ─── MCP → SenpiToolClient bridge ───────────────────────────────────────────
64
+
65
+ function createSenpiToolClient(mcpClient) {
66
+ return {
67
+ async callTool(name, args) {
68
+ const result = await mcpClient.callTool({ name, arguments: args });
69
+
70
+ if (result.isError) {
71
+ const errorText =
72
+ Array.isArray(result.content) &&
73
+ result.content.length > 0 &&
74
+ result.content[0].type === "text"
75
+ ? result.content[0].text
76
+ : JSON.stringify(result.content);
77
+ throw new Error(`MCP tool "${name}" returned error: ${errorText}`);
78
+ }
79
+
80
+ if (result.structuredContent !== undefined) {
81
+ return result.structuredContent;
82
+ }
83
+
84
+ if (Array.isArray(result.content) && result.content.length > 0) {
85
+ const first = result.content[0];
86
+ if (first.type === "text") {
87
+ try {
88
+ return JSON.parse(first.text);
89
+ } catch {
90
+ return first.text;
91
+ }
92
+ }
93
+ }
94
+
95
+ return result.content;
96
+ },
97
+ };
98
+ }
99
+
100
+ // ─── Momentum logic ─────────────────────────────────────────────────────────
101
+ // Self-contained port of src/scanners/implementations/momentum.ts. Kept free
102
+ // of any runtime-internal imports.
103
+
104
+ function clamp01(value) {
105
+ if (!Number.isFinite(value)) return 0;
106
+ if (value < 0) return 0;
107
+ if (value > 1) return 1;
108
+ return value;
109
+ }
110
+
111
+ function selectCandidates(instruments, cfg) {
112
+ const eligible = instruments.filter((inst) => {
113
+ if (inst.isDelisted) return false;
114
+ if ((inst.maxLeverage ?? 0) < cfg.minLeverage) return false;
115
+ const vol = inst.context?.dayNtlVlm ?? 0;
116
+ return vol >= cfg.minDayVolume;
117
+ });
118
+ eligible.sort(
119
+ (a, b) => (b.context?.dayNtlVlm ?? 0) - (a.context?.dayNtlVlm ?? 0),
120
+ );
121
+ return eligible.slice(0, cfg.maxCandidates);
122
+ }
123
+
124
+ function computeMovePct(series) {
125
+ if (!series || series.length < 2) return null;
126
+ const prev = series[series.length - 2]?.close;
127
+ const last = series[series.length - 1]?.close;
128
+ if (!Number.isFinite(prev) || !Number.isFinite(last) || prev <= 0) {
129
+ return null;
130
+ }
131
+ return { prev, last, movePct: ((last - prev) / prev) * 100 };
132
+ }
133
+
134
+ function buildSignal({ instrument, move, timeframe, now }) {
135
+ const movePct = move.movePct;
136
+ const absMove = Math.abs(movePct);
137
+ const direction = movePct > 0 ? "LONG" : "SHORT";
138
+ // Score scales from 0 at minMovePct to 1 at 5% move, clamped.
139
+ const score = clamp01((absMove - config.minMovePct) / (5 - config.minMovePct));
140
+
141
+ return {
142
+ scannerId: SCANNER_ID,
143
+ signalType: "MOMENTUM_BREAKOUT",
144
+ asset: instrument.name,
145
+ direction,
146
+ score,
147
+ timestamp: now,
148
+ factors: {
149
+ breakout: true,
150
+ large_move: absMove >= 3,
151
+ },
152
+ meta: {
153
+ timeframe,
154
+ movePct,
155
+ prev: move.prev,
156
+ last: move.last,
157
+ dayNtlVlm: instrument.context?.dayNtlVlm ?? 0,
158
+ maxLeverage: instrument.maxLeverage,
159
+ },
160
+ };
161
+ }
162
+
163
+ // ─── Main ───────────────────────────────────────────────────────────────────
164
+
165
+ async function main() {
166
+ console.log(`External momentum producer`);
167
+ console.log(` MCP: ${SENPI_MCP_URL}`);
168
+ console.log(` address: ${STRATEGY_ADDRESS}`);
169
+ console.log(` scanner: ${SCANNER_NAME}`);
170
+ console.log(` config: ${JSON.stringify(config)}`);
171
+
172
+ const transport = new StreamableHTTPClientTransport(new URL(SENPI_MCP_URL), {
173
+ requestInit: { headers: { Authorization: `Bearer ${SENPI_API_KEY}` } },
174
+ });
175
+ const mcpClient = new Client({
176
+ name: "external-momentum",
177
+ version: "0.0.1",
178
+ });
179
+ await mcpClient.connect(transport);
180
+
181
+ const providers = createSenpiScannerProviders({
182
+ client: createSenpiToolClient(mcpClient),
183
+ logLevel: "warn",
184
+ cacheTtlMs: { instruments: 0, smData: 0, candles: 0 },
185
+ candleIntervals: [config.timeframe],
186
+ maxCandleAssets: config.maxCandidates,
187
+ retries: 2,
188
+ });
189
+
190
+ try {
191
+ const instruments = await providers.getInstruments(STRATEGY_ADDRESS);
192
+ const candidates = selectCandidates(instruments, config);
193
+ console.log(
194
+ `\nInstruments: ${instruments.length}, candidates: ${candidates.length}`,
195
+ );
196
+
197
+ if (candidates.length === 0) {
198
+ console.log("No candidates after filtering — skipping ingest.");
199
+ return;
200
+ }
201
+
202
+ const candles = await providers.getCandles(STRATEGY_ADDRESS, candidates, {
203
+ scannerId: SCANNER_ID,
204
+ preferredAssets: candidates.map((c) => c.name),
205
+ });
206
+
207
+ const now = Date.now();
208
+ const signals = [];
209
+ for (const inst of candidates) {
210
+ const series = candles[inst.name]?.candles?.[config.timeframe];
211
+ const move = computeMovePct(series);
212
+ if (!move) continue;
213
+ if (Math.abs(move.movePct) < config.minMovePct) continue;
214
+ const signal = buildSignal({
215
+ instrument: inst,
216
+ move,
217
+ timeframe: config.timeframe,
218
+ now,
219
+ });
220
+ if (signal.score < config.minSignalScore) continue;
221
+ signals.push(signal);
222
+ }
223
+
224
+ console.log(`\nSignals: ${signals.length}`);
225
+ for (const s of signals) {
226
+ console.log(
227
+ ` ${s.asset} ${s.direction} move=${s.meta.movePct.toFixed(2)}% score=${s.score.toFixed(2)}`,
228
+ );
229
+ }
230
+
231
+ if (signals.length === 0) {
232
+ console.log("\nNothing to ingest — skipping gateway call.");
233
+ return;
234
+ }
235
+
236
+ const response = await pushScannerSignals({
237
+ address: STRATEGY_ADDRESS,
238
+ scanner: SCANNER_NAME,
239
+ signals,
240
+ openclawBin: OPENCLAW_BIN,
241
+ });
242
+ console.log("\nIngest response:", JSON.stringify(response));
243
+ } finally {
244
+ await transport.close();
245
+ }
246
+ }
247
+
248
+ main().catch((error) => {
249
+ console.error("\nProducer failed:", error.message ?? error);
250
+ process.exit(1);
251
+ });
@@ -1,25 +1,17 @@
1
- """Process entry for the scaffold core (v2).
1
+ """Process entry for the scaffold core.
2
2
 
3
3
  Usage: python3 -m scaffold <launch-config.json>
4
4
 
5
- The launch config JSON is the FROZEN launcher<->scaffold contract (design §4a).
6
- This side READS every §4a key the B1 launcher writes and threads them into
7
- run_scaffold. Hard cut: old key names are gone (`interval` -> `interval_seconds`,
8
- `params` -> `inputs`).
9
-
10
- Required keys (missing => fail loud, non-zero exit):
11
- path — directory added to sys.path (scanner working dir).
12
- entrypoint — module name to import from `path`.
13
- interval_seconds — tick cadence in seconds (was `interval`).
14
- default_signal_validity_seconds — fallback signal TTL (seconds); no magic default.
15
- wallet — strategy wallet (-> ctx.wallet).
16
- scanner_name — this scanner's recipe name (-> ctx.scanner_name).
17
-
18
- Optional keys:
19
- timeout_seconds — per-tick wall-clock budget (default: interval_seconds).
20
- state_path — JSON state file path (None => no persistence).
21
- inputs — tunables dict for scan() (was `params`); default {}.
22
- state_history_max_count — state series bound; default 0 = history disabled.
5
+ The launch config JSON must contain:
6
+ path — directory added to sys.path (the scanner's working directory).
7
+ entrypoint — module name to import from `path`.
8
+ interval — seconds between tick starts.
9
+
10
+ Optional fields:
11
+ timeout_seconds — per-tick wall-clock budget (default: interval).
12
+ params — tunables dict passed to scan() as the first argument.
13
+ state_path — path to the JSON state file.
14
+ max_ticks — cap on tick count (for tests).
23
15
 
24
16
  Delivery sink is wired when EXTERNAL_SCANNER_ID and SIGNAL_INGESTION_URL are set in
25
17
  the environment. If either is absent the scaffold runs with the default no-op sink
@@ -31,18 +23,15 @@ import os
31
23
  import sys
32
24
 
33
25
 
34
- def _main(argv=None) -> int:
35
- if argv is None:
36
- argv = sys.argv
37
-
38
- if len(argv) < 2:
26
+ def _main() -> int:
27
+ if len(sys.argv) < 2:
39
28
  print(
40
29
  "Usage: python3 -m scaffold <launch-config.json>",
41
30
  file=sys.stderr,
42
31
  )
43
32
  return 1
44
33
 
45
- config_path = argv[1]
34
+ config_path = sys.argv[1]
46
35
  try:
47
36
  with open(config_path, "r", encoding="utf-8") as fh:
48
37
  config = json.load(fh)
@@ -50,43 +39,22 @@ def _main(argv=None) -> int:
50
39
  print(f"[scaffold] launch config error: {exc}", file=sys.stderr)
51
40
  return 1
52
41
 
53
- # ---- Required §4a keys: missing => fail loud (no magic default) ----
54
42
  try:
55
43
  path = config["path"]
56
44
  entrypoint = config["entrypoint"]
57
- interval = float(config["interval_seconds"])
58
- default_signal_validity_seconds = int(config["default_signal_validity_seconds"])
59
- wallet = config["wallet"]
60
- scanner_name = config["scanner_name"]
45
+ interval = float(config["interval"])
61
46
  except (KeyError, TypeError, ValueError) as exc:
62
- print(
63
- f"[scaffold] launch config missing/invalid required field: {exc}",
64
- file=sys.stderr,
65
- )
47
+ print(f"[scaffold] launch config missing required field: {exc}", file=sys.stderr)
66
48
  return 1
67
49
 
68
- # ---- Optional §4a keys ----
69
50
  timeout_seconds = config.get("timeout_seconds")
70
- # Hard cut: only `inputs` is read. The legacy `params` key is NOT honored.
71
- inputs = config.get("inputs") or {}
51
+ params = config.get("params") or {}
72
52
  state_path = config.get("state_path")
73
- state_history_max_count = config.get("state_history_max_count") or 0
74
- # Per-signal data{} schema (§4a). None / absent => no scaffold-side validation
75
- # (intake still validates authoritatively). A dict turns on early, loud
76
- # scaffold-side validation that POSTs a violation to /errors.
77
- signal_data_schema = config.get("signal_data_schema")
78
- if signal_data_schema is not None and not isinstance(signal_data_schema, dict):
79
- signal_data_schema = None
80
53
  max_ticks = config.get("max_ticks")
81
54
 
82
- import scaffold as scaffold_pkg
83
- from scaffold import ScanEntrypointError, ScanSignatureError
55
+ from scaffold import ScanEntrypointError, ScanSignatureError, run_scaffold
84
56
  from scaffold.loop_primitives import log_event
85
57
 
86
- # Resolve run_scaffold via the package symbol so a test stub patched onto the
87
- # package (or this module) is picked up.
88
- run_scaffold = globals().get("run_scaffold") or scaffold_pkg.run_scaffold
89
-
90
58
  # Build the delivery sink when scanner_id and intake URL are available.
91
59
  sink = None
92
60
  scanner_id = os.environ.get("EXTERNAL_SCANNER_ID", "").strip()
@@ -99,24 +67,19 @@ def _main(argv=None) -> int:
99
67
  post_timeout = float(os.environ.get("SIGNAL_POST_TIMEOUT_SECONDS", "5"))
100
68
 
101
69
  # Derive journal path from the state file directory when available.
102
- journal_path = None
70
+ journal_path: str | None = None
103
71
  if state_path:
104
- journal_path = os.path.join(
105
- os.path.dirname(os.path.abspath(state_path)),
72
+ import os as _os
73
+ journal_path = _os.path.join(
74
+ _os.path.dirname(_os.path.abspath(state_path)),
106
75
  "delivery-journal.jsonl",
107
76
  )
108
77
 
109
- # §4a threads the per-data-key schema (`signal_data_schema`) to the
110
- # scaffold so the envelope validates each signal's `data` scaffold-side
111
- # and POSTs a violation to /errors (early, loud feedback). None ⇒ no
112
- # scaffold-side validation. The intake still performs authoritative
113
- # server-side schema validation regardless (this is additive).
114
78
  sink = build_delivery_sink(
115
79
  scanner_id=scanner_id,
116
80
  intake_url=intake_url,
81
+ interval=interval,
117
82
  journal_path=journal_path,
118
- signal_data_schema=signal_data_schema,
119
- default_signal_validity_seconds=default_signal_validity_seconds,
120
83
  timeout=post_timeout,
121
84
  )
122
85
  log_event("delivery_sink_enabled", scanner_id=scanner_id, intake_url=intake_url)
@@ -135,12 +98,8 @@ def _main(argv=None) -> int:
135
98
  entrypoint,
136
99
  interval=interval,
137
100
  timeout_seconds=timeout_seconds,
138
- inputs=inputs,
101
+ params=params,
139
102
  state_path=state_path,
140
- scanner_name=scanner_name,
141
- wallet=wallet,
142
- default_signal_validity_seconds=default_signal_validity_seconds,
143
- state_history_max_count=state_history_max_count,
144
103
  sink=sink,
145
104
  max_ticks=max_ticks,
146
105
  )
@@ -4,9 +4,8 @@ build_delivery_sink(
4
4
  *,
5
5
  scanner_id: str,
6
6
  intake_url: str,
7
+ interval: float, # seconds; valid_until default = produced_at + 2*interval
7
8
  journal_path: str | None,
8
- signal_data_schema: dict | None, # per-data-key {type, required?}; None = no validation
9
- default_signal_validity_seconds: int, # fallback signal TTL (seconds)
10
9
  timeout: float = 5.0,
11
10
  clock: Callable[[], float] = time.time,
12
11
  ) -> sink
@@ -33,7 +32,6 @@ from __future__ import annotations
33
32
 
34
33
  import sys
35
34
  import time
36
- import uuid
37
35
  from typing import Any, Callable
38
36
 
39
37
  from .http_post import ERRORS_PATH, SIGNALS_PATH, TransportError, post_json
@@ -49,14 +47,14 @@ def build_delivery_sink(
49
47
  *,
50
48
  scanner_id: str,
51
49
  intake_url: str,
50
+ interval: float,
52
51
  journal_path: str | None,
53
- signal_data_schema: dict | None,
54
- default_signal_validity_seconds: int,
55
52
  timeout: float = 5.0,
56
53
  clock: Callable[[], float] = time.time,
57
54
  ):
58
55
  """Factory: build and return the delivery sink callable."""
59
56
 
57
+ interval_ms = int(interval * 1000)
60
58
  journal = Journal(journal_path)
61
59
  replayed = [False] # mutable cell; replay runs once on first invocation
62
60
 
@@ -89,35 +87,16 @@ def build_delivery_sink(
89
87
  log_event("delivery_dropped_expired", entry_id=entry.id, asset=entry.asset)
90
88
  return to_send
91
89
 
92
- def _on_reject(reason: dict) -> None:
93
- """Loud rejection: log AND POST to /errors (design §4 — never a silent drop)."""
94
- log_event("delivery_candidate_invalid", **reason)
95
- _post_error({"type": "candidate_rejected", **reason})
96
-
97
90
  def _wrap_candidates(candidates, produced_at_ms: int) -> list[tuple]:
98
- """Wrap candidates into (signal, entry_id) pairs, skipping invalid ones.
99
-
100
- Schema-rejected candidates are NOT silently dropped: candidate_to_signal
101
- calls _on_reject, which logs AND POSTs to /errors.
102
- """
91
+ """Wrap candidates into (signal, entry_id) pairs, skipping invalid ones."""
103
92
  result = []
104
93
  for c in candidates:
105
- # Mint the stable signal_id (the journal eid) up front and thread it
106
- # through the envelope so the POSTed signal carries it. record_produced
107
- # re-uses this same id as the entry id, so wire signal_id == journal eid.
108
- eid = uuid.uuid4().hex
109
- sig = candidate_to_signal(
110
- c,
111
- produced_at_ms,
112
- signal_id=eid,
113
- signal_data_schema=signal_data_schema,
114
- default_signal_validity_seconds=default_signal_validity_seconds,
115
- on_reject=_on_reject,
116
- )
94
+ sig = candidate_to_signal(c, produced_at_ms, interval_ms)
117
95
  if sig is None:
96
+ log_event("delivery_candidate_invalid", candidate=repr(c))
118
97
  continue
119
- entry_id = journal.record_produced(sig, now_ms=produced_at_ms)
120
- result.append((sig, entry_id))
98
+ eid = journal.record_produced(sig, now_ms=produced_at_ms)
99
+ result.append((sig, eid))
121
100
  return result
122
101
 
123
102
  def _post_signals(signals: list[dict]) -> tuple[int, Any]:
@@ -146,33 +125,20 @@ def build_delivery_sink(
146
125
  if not isinstance(per_signal, list):
147
126
  per_signal = []
148
127
 
149
- # Match per-signal outcomes BY signal_id, NEVER by array position. The wire
150
- # signal_id IS the journal entry id, so a pending eid set is the lookup key.
151
- # Index-matching would cross-attribute outcomes (asset A's "accepted"
152
- # recorded against asset B) under out-of-order or partial responses —
153
- # capital-sensitive. Robust to: out-of-order, partial (fewer statuses than
154
- # signals), and unknown/missing signal_id (ignored safely, never applied by
155
- # position). An unmatched entry stays pending and is retried.
156
- pending = set(pending_eids)
157
- for sig_status in per_signal:
158
- if not isinstance(sig_status, dict):
159
- continue
160
- eid = sig_status.get("signal_id")
161
- # Missing or unknown signal_id: cannot be matched -> ignore safely.
162
- if not isinstance(eid, str) or eid not in pending:
163
- log_event("delivery_status_unmatched", signal_id=eid)
164
- continue
165
- st = sig_status.get("status")
166
- if st == "accepted":
167
- journal.record_outcome(eid, "delivered", correlationId=sig_status.get("correlationId", ""))
168
- elif st == "rejected":
169
- journal.record_outcome(eid, "rejected", reason=sig_status.get("reason", ""))
170
- elif st in ("expired", "superseded"):
171
- journal.record_outcome(eid, st)
172
- else:
173
- # Unknown status — treat as undelivered (no outcome written)
174
- log_event("delivery_unknown_status", entry_id=eid, status=st)
175
- # Entries with no matching status returned stay pending (retry next tick).
128
+ for i, eid in enumerate(pending_eids):
129
+ if i < len(per_signal):
130
+ sig_status = per_signal[i]
131
+ st = sig_status.get("status") if isinstance(sig_status, dict) else None
132
+ if st == "accepted":
133
+ journal.record_outcome(eid, "delivered", correlationId=sig_status.get("correlationId", ""))
134
+ elif st == "rejected":
135
+ journal.record_outcome(eid, "rejected", reason=sig_status.get("reason", ""))
136
+ elif st in ("expired", "superseded"):
137
+ journal.record_outcome(eid, st)
138
+ else:
139
+ # Unknown status — treat as undelivered (no outcome written)
140
+ log_event("delivery_unknown_status", entry_id=eid, status=st)
141
+ # If no per-signal status returned for this entry, leave it pending (retry)
176
142
 
177
143
  return True
178
144
 
@@ -20,7 +20,7 @@ class ScanEntrypointError(Exception):
20
20
 
21
21
 
22
22
  class ScanSignatureError(Exception):
23
- """scan's call shape is incompatible with scan(inputs, ctx)."""
23
+ """scan's call shape is incompatible with scaffold(params, state[, client])."""
24
24
 
25
25
 
26
26
  def add_path_to_sys_path(path: str) -> None:
@@ -32,22 +32,16 @@ def add_path_to_sys_path(path: str) -> None:
32
32
  def import_entrypoint(path: str, entrypoint: str):
33
33
  """Put `path` on sys.path, then import `entrypoint` by module name.
34
34
 
35
- The recipe expresses `entrypoint` as a FILENAME (e.g. `scan.py`, the shape the
36
- B1 launcher writes verbatim into the launch config and the §4a entrypoint FS
37
- check validates). importlib needs a MODULE name, so a trailing `.py` is
38
- stripped here — the only place the filename<->module-name bridge lives.
39
-
40
35
  Raises ScanEntrypointError on any import/syntax/missing-sibling failure,
41
36
  chaining the underlying exception.
42
37
  """
43
38
  add_path_to_sys_path(path)
44
- module_name = entrypoint[:-3] if entrypoint.endswith(".py") else entrypoint
45
39
  try:
46
40
  # Force re-import when the module has been previously imported so that
47
41
  # tests can write fresh modules to tmp_path without cache collisions.
48
- if module_name in sys.modules:
49
- del sys.modules[module_name]
50
- return importlib.import_module(module_name)
42
+ if entrypoint in sys.modules:
43
+ del sys.modules[entrypoint]
44
+ return importlib.import_module(entrypoint)
51
45
  except Exception as exc:
52
46
  raise ScanEntrypointError(
53
47
  f"Failed to import entrypoint '{entrypoint}' from '{path}': {exc}"
@@ -73,25 +67,19 @@ def locate_scan(module) -> Callable:
73
67
 
74
68
 
75
69
  def verify_scan_signature(scan: Callable) -> None:
76
- """Verify scan's call shape is compatible with the v2 contract scan(inputs, ctx).
77
-
78
- v2 calls scan with EXACTLY two positionals (inputs, ctx). A signature must be
79
- callable that way.
70
+ """Verify scan's call shape is compatible with scan(params, state[, client]).
80
71
 
81
72
  Accepted shapes:
82
- scan(inputs, ctx) — 2 required (the canonical shape)
83
- scan(inputs, ctx, extra=None) — 2 required + defaulted extra
84
- scan(inputs, ctx, *args) — variadic absorbs nothing extra here
73
+ scan(params, state) — 2 required
74
+ scan(params, state, client) — 3 required
75
+ scan(params, state, client=None) — 2 required, 1 optional
76
+ scan(params, state, *args) — variadic absorbs client
85
77
  scan(*args, **kwargs) — fully variadic, accepts anything
86
78
 
87
79
  Rejected shapes:
88
80
  scan() — 0 required
89
- scan(inputs) — 1 required
90
- scan(params, state, client) — 3 required (the OLD v1 client shape)
91
- scan(a, b, c, d) — >=4 required
92
-
93
- The contract is now EXACTLY two positionals: a 3rd REQUIRED positional cannot
94
- be satisfied by scan(inputs, ctx) and is rejected.
81
+ scan(params) — 1 required
82
+ scan(a, b, c, d) — >=4 fixed required
95
83
 
96
84
  Raises ScanSignatureError naming the found arity on mismatch.
97
85
  """
@@ -106,39 +94,57 @@ def verify_scan_signature(scan: Callable) -> None:
106
94
  has_var_positional = any(
107
95
  p.kind == inspect.Parameter.VAR_POSITIONAL for p in params
108
96
  )
97
+ has_var_keyword = any(
98
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in params
99
+ )
109
100
 
110
- # Required (non-defaulted) fixed positional params.
111
- required_fixed = [
101
+ # A *args/**kwargs-only signature accepts any positional call.
102
+ if has_var_positional or has_var_keyword:
103
+ # Check that it actually accepts at least 2 positional args (i.e., no
104
+ # required positional params ahead of *args that block the call).
105
+ required_before_var = [
106
+ p for p in params
107
+ if p.kind in (
108
+ inspect.Parameter.POSITIONAL_ONLY,
109
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
110
+ )
111
+ and p.default is inspect.Parameter.empty
112
+ ]
113
+ # If there are >=4 required positionals even WITH *args, reject.
114
+ if len(required_before_var) >= 4:
115
+ raise ScanSignatureError(
116
+ f"scan requires {len(required_before_var)} fixed positional params; "
117
+ f"expected 2 or 3 (found arity: {len(required_before_var)})."
118
+ )
119
+ # Otherwise, variadic accepts our call.
120
+ return
121
+
122
+ # Count fixed positional params (POSITIONAL_ONLY or POSITIONAL_OR_KEYWORD).
123
+ fixed_positional = [
112
124
  p for p in params
113
125
  if p.kind in (
114
126
  inspect.Parameter.POSITIONAL_ONLY,
115
127
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
116
128
  )
117
- and p.default is inspect.Parameter.empty
118
129
  ]
119
- n_required = len(required_fixed)
130
+ required_fixed = [
131
+ p for p in fixed_positional
132
+ if p.default is inspect.Parameter.empty
133
+ ]
120
134
 
121
- # A *args (or *args/**kwargs) signature accepts the 2-positional call as long
122
- # as it has no MORE than 2 required fixed positionals ahead of *args.
123
- if has_var_positional:
124
- if n_required > 2:
125
- raise ScanSignatureError(
126
- f"scan requires {n_required} fixed positional params; the v2 "
127
- f"contract calls scan(inputs, ctx) — exactly 2. Found arity: {n_required}."
128
- )
129
- return
135
+ n_required = len(required_fixed)
136
+ n_fixed = len(fixed_positional)
130
137
 
131
138
  if n_required < 2:
132
139
  raise ScanSignatureError(
133
- f"scan has {n_required} required positional param(s); the v2 contract "
134
- f"requires exactly 2 (inputs, ctx). Found arity: {n_required}."
140
+ f"scan has {n_required} required positional param(s); "
141
+ f"expected at least 2 (params, state). Found arity: {n_required}."
135
142
  )
136
143
 
137
- if n_required > 2:
144
+ if n_required >= 4:
138
145
  raise ScanSignatureError(
139
- f"scan has {n_required} required positional params; the v2 contract "
140
- f"calls scan(inputs, ctx) — exactly 2. A 3rd required positional "
141
- f"(the old v1 client shape) is no longer accepted. Found arity: {n_required}."
146
+ f"scan has {n_required} required positional params; "
147
+ f"expected at most 3 (params, state, client). Found arity: {n_required}."
142
148
  )
143
149
 
144
150