@senpi/runtime 3.0.0-dev.openclaw-upgrade-mai.20260623210452 → 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.
- package/README.md +9 -10
- package/dist/actions/decision-engine.d.ts.map +1 -1
- package/dist/actions/decision-engine.js +0 -3
- package/dist/actions/decision-engine.js.map +1 -1
- package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
- package/dist/actions/open-position/open-position.action.js +2 -6
- package/dist/actions/open-position/open-position.action.js.map +1 -1
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +56 -43
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +108 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/constants/dsl/index.d.ts +0 -2
- package/dist/constants/dsl/index.d.ts.map +1 -1
- package/dist/constants/dsl/index.js +0 -2
- package/dist/constants/dsl/index.js.map +1 -1
- package/dist/dsl/engine-client/config-mapping.d.ts +0 -7
- package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
- package/dist/dsl/engine-client/config-mapping.js +2 -16
- package/dist/dsl/engine-client/config-mapping.js.map +1 -1
- package/dist/dsl/events/handlers.d.ts +2 -18
- package/dist/dsl/events/handlers.d.ts.map +1 -1
- package/dist/dsl/events/handlers.js +213 -302
- package/dist/dsl/events/handlers.js.map +1 -1
- package/dist/dsl/monitor/create-monitor.d.ts +0 -15
- package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
- package/dist/dsl/monitor/create-monitor.js +9 -44
- package/dist/dsl/monitor/create-monitor.js.map +1 -1
- package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
- package/dist/dsl/monitor/process-one-position.js +1 -24
- package/dist/dsl/monitor/process-one-position.js.map +1 -1
- package/dist/dsl/plugin/index.d.ts +1 -16
- package/dist/dsl/plugin/index.d.ts.map +1 -1
- package/dist/dsl/plugin/index.js +4 -35
- package/dist/dsl/plugin/index.js.map +1 -1
- package/dist/dsl/plugin/options.d.ts +0 -18
- package/dist/dsl/plugin/options.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -4
- package/dist/index.js.map +1 -1
- package/dist/runtime/create-scanner-module.d.ts +3 -4
- package/dist/runtime/create-scanner-module.d.ts.map +1 -1
- package/dist/runtime/create-scanner-module.js +5 -14
- package/dist/runtime/create-scanner-module.js.map +1 -1
- package/dist/runtime/create-scanner-providers-for-run.d.ts.map +1 -1
- package/dist/runtime/create-scanner-providers-for-run.js +1 -12
- package/dist/runtime/create-scanner-providers-for-run.js.map +1 -1
- package/dist/runtime/load-runtime-config.js +1 -1
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime/map-strategies-registration.js +8 -7
- package/dist/runtime/map-strategies-registration.js.map +1 -1
- package/dist/runtime/map-strategies.d.ts.map +1 -1
- package/dist/runtime/map-strategies.js +3 -12
- package/dist/runtime/map-strategies.js.map +1 -1
- package/dist/runtime/reconcile-state.d.ts +0 -44
- package/dist/runtime/reconcile-state.d.ts.map +1 -1
- package/dist/runtime/reconcile-state.js +44 -80
- package/dist/runtime/reconcile-state.js.map +1 -1
- package/dist/runtime/risk-guard.d.ts.map +1 -1
- package/dist/runtime/risk-guard.js +12 -17
- package/dist/runtime/risk-guard.js.map +1 -1
- package/dist/runtime/runtime-schema.d.ts +995 -2855
- package/dist/runtime/runtime-schema.d.ts.map +1 -1
- package/dist/runtime/runtime-schema.js +40 -133
- package/dist/runtime/runtime-schema.js.map +1 -1
- package/dist/runtime/runtime.d.ts +0 -26
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +5 -233
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/scanner-registry.d.ts +1 -1
- package/dist/runtime/scanner-registry.d.ts.map +1 -1
- package/dist/runtime/scanner-registry.js +12 -3
- package/dist/runtime/scanner-registry.js.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.js +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
- package/dist/runtime/supervisor/orphan-sweep.js +1 -1
- package/dist/runtime/supervisor/orphan-sweep.js.map +1 -1
- package/dist/runtime/supervisor/supervisor-utils.d.ts +1 -1
- package/dist/runtime/supervisor/supervisor-utils.d.ts.map +1 -1
- package/dist/runtime/supervisor/supervisor-utils.js +1 -1
- package/dist/runtime/supervisor/supervisor-utils.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +36 -28
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.js +0 -0
- package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.d.ts +4 -18
- package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.js +3 -24
- package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
- package/dist/runtime-api/intake/types.d.ts +0 -6
- package/dist/runtime-api/intake/types.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.d.ts +0 -1
- package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.js +0 -5
- package/dist/runtime-api/routes/signals.schema.js.map +1 -1
- package/dist/scanners/external/liquidation-watchdog/README.md +107 -0
- package/dist/scanners/external/liquidation-watchdog/liquidation-watchdog.yaml +48 -0
- package/dist/scanners/external/liquidation-watchdog/producer.mjs +336 -0
- package/dist/scanners/external/momentum/README.md +148 -0
- package/dist/scanners/external/momentum/momentum.yaml +116 -0
- package/dist/scanners/external/momentum/producer.mjs +251 -0
- package/dist/scanners/external/scaffold/__main__.py +24 -65
- package/dist/scanners/external/scaffold/delivery.py +22 -56
- package/dist/scanners/external/scaffold/entrypoint_loader.py +48 -42
- package/dist/scanners/external/scaffold/journal.py +2 -13
- package/dist/scanners/external/scaffold/scaffold.py +128 -159
- package/dist/scanners/external/scaffold/signal_envelope.py +34 -177
- package/dist/scanners/external/shared/ingest.mjs +112 -0
- package/dist/scanners/external-scanner-receiver.d.ts +0 -6
- package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
- package/dist/scanners/external-scanner-receiver.js +1 -14
- package/dist/scanners/external-scanner-receiver.js.map +1 -1
- package/dist/scripts/run-dsl-standalone-live.d.ts +1 -1
- package/dist/scripts/run-dsl-standalone-live.js +3 -5
- package/dist/scripts/run-dsl-standalone-live.js.map +1 -1
- package/dist/senpi/client.d.ts +0 -12
- package/dist/senpi/client.d.ts.map +1 -1
- package/dist/senpi/client.js +2 -31
- package/dist/senpi/client.js.map +1 -1
- package/dist/state/state-manager.d.ts +0 -17
- package/dist/state/state-manager.d.ts.map +1 -1
- package/dist/state/state-manager.js +0 -28
- package/dist/state/state-manager.js.map +1 -1
- package/dist/strategy/strategy-state.d.ts.map +1 -1
- package/dist/strategy/strategy-state.js +1 -7
- package/dist/strategy/strategy-state.js.map +1 -1
- package/dist/tracking/constants.d.ts.map +1 -1
- package/dist/tracking/constants.js +0 -2
- package/dist/tracking/constants.js.map +1 -1
- package/dist/types/dsl/index.d.ts +1 -1
- package/dist/types/dsl/index.d.ts.map +1 -1
- package/dist/types/scanner.d.ts +0 -7
- package/dist/types/scanner.d.ts.map +1 -1
- package/examples/README.md +89 -0
- package/examples/scanners-consumer-quickstart.mjs +134 -0
- package/examples/scanners-emerging-movers-v4.mjs +287 -0
- package/examples/scanners-liquidation-watchdog.mjs +341 -0
- package/examples/scanners-live-mcp.mjs +208 -0
- package/examples/scanners-multi-strategy.mjs +165 -0
- package/examples/scanners-position-tracker-e2e.mjs +225 -0
- package/examples/scanners-scheduled-events.mjs +96 -0
- package/examples/scanners-senpi-provider-client.mjs +182 -0
- package/examples/scanners-single-strategy.mjs +198 -0
- package/examples/strategies/README.md +12 -17
- package/examples/strategies/dsl-showcase.yaml +68 -0
- package/examples/strategies/dsl.yaml +51 -0
- package/examples/strategies/fox-minimal-loosened.yaml +240 -0
- package/examples/strategies/fox-minimal.yaml +242 -0
- package/examples/strategies/minimal.yaml +46 -0
- package/examples/strategies/momentum/how-it-works.md +48 -0
- package/examples/strategies/momentum/momentum-guarded.yaml +161 -0
- package/examples/strategies/risk-showcase.yaml +93 -0
- package/examples/strategies/viper.yaml +67 -0
- package/examples/strategies/wolf.yaml +72 -0
- package/examples/yamls/sample-with-dsl-and-emerging-movers.yaml +123 -0
- package/package.json +1 -1
- package/dist/runtime/reconcile-decide.d.ts +0 -73
- package/dist/runtime/reconcile-decide.d.ts.map +0 -1
- package/dist/runtime/reconcile-decide.js +0 -166
- package/dist/runtime/reconcile-decide.js.map +0 -1
- package/dist/runtime/reconcile-run.d.ts +0 -62
- package/dist/runtime/reconcile-run.d.ts.map +0 -1
- package/dist/runtime/reconcile-run.js +0 -173
- package/dist/runtime/reconcile-run.js.map +0 -1
- package/dist/scanners/external/scaffold/ctx.py +0 -78
- package/dist/scanners/external/scaffold/requirements.txt +0 -11
- package/dist/scanners/external/scaffold/senpi_mcp.py +0 -250
- package/dist/scanners/external/scaffold/state_manager.py +0 -175
- package/dist/utils/keyed-mutex.d.ts +0 -37
- package/dist/utils/keyed-mutex.d.ts.map +0 -1
- package/dist/utils/keyed-mutex.js +0 -77
- package/dist/utils/keyed-mutex.js.map +0 -1
- package/examples/strategies/iguana/README.md +0 -135
- package/examples/strategies/iguana/recipe.yaml +0 -137
- package/examples/strategies/iguana/scanners/scan.py +0 -243
- package/examples/strategies/iguana/scanners/scoring.py +0 -152
- package/examples/strategies/raptor/README.md +0 -156
- package/examples/strategies/raptor/recipe.yaml +0 -154
- package/examples/strategies/raptor/scanners/scan.py +0 -349
- package/examples/strategies/raptor/scanners/scoring.py +0 -452
- package/examples/strategies/spider/README.md +0 -97
- package/examples/strategies/spider/scalp/recipe.yaml +0 -129
- package/examples/strategies/spider/scalp/scanners/scan.py +0 -219
- package/examples/strategies/spider/scalp/scanners/scoring.py +0 -179
- package/examples/strategies/spider/swing/recipe.yaml +0 -141
- package/examples/strategies/spider/swing/scanners/scan.py +0 -334
- package/examples/strategies/spider/swing/scanners/scoring.py +0 -184
- package/examples/strategies/turbine/README.md +0 -167
- package/examples/strategies/turbine/recipe-runners.yaml +0 -123
- package/examples/strategies/turbine/recipe-volume.yaml +0 -129
- package/examples/strategies/turbine/scanners/scan.py +0 -283
- package/examples/strategies/turbine/scanners/scoring.py +0 -190
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Impure reconcile executor: wires decideReconcile (pure) with the I/O side-effects
|
|
3
|
-
* (adoptPosition, resizePosition, archiveGoneState).
|
|
4
|
-
*
|
|
5
|
-
* Design contract:
|
|
6
|
-
* - decideReconcile is the SOLE decision source (no second gone-computation here).
|
|
7
|
-
* - CH fetch returning null/undefined → success:false, zero mutations (R7 fail-safe).
|
|
8
|
-
* - getOpenPositions undefined (CH never fetched) → success:false, zero mutations.
|
|
9
|
-
* - Execution order within the serialize pass: archive gone FIRST, then adopt/resize
|
|
10
|
-
* (frees state before re-adopting; mirrors monitor order).
|
|
11
|
-
* - Each action is individually try/caught; one failure does not abort the rest.
|
|
12
|
-
* - Only successfully applied actions count toward ReconcileCounts.
|
|
13
|
-
*/
|
|
14
|
-
import { decideReconcile } from "./reconcile-decide.js";
|
|
15
|
-
import { archiveGoneState } from "./reconcile-state.js";
|
|
16
|
-
import { createLogger } from "../utils/logger.js";
|
|
17
|
-
const logger = createLogger("[senpi-core][reconcile-run]");
|
|
18
|
-
// ─── OpenPosition → PositionOpenedEvent mapping ───────────────────────────────
|
|
19
|
-
/**
|
|
20
|
-
* Map an OpenPosition (from clearinghouse) to the PositionOpenedEvent shape expected
|
|
21
|
-
* by adoptPosition / resizePosition.
|
|
22
|
-
*
|
|
23
|
-
* Field mapping:
|
|
24
|
-
* address → address (strategy wallet; same as deps.address)
|
|
25
|
-
* wallet → position.wallet if set, else address (clearinghouse rows rarely carry wallet)
|
|
26
|
-
* coin → position.coin
|
|
27
|
-
* szi → String(position.szi) — signed; positive=LONG, negative=SHORT
|
|
28
|
-
* entryPx → String(position.entryPx ?? 0)
|
|
29
|
-
* leverage → position.leverage ?? 1
|
|
30
|
-
* dex → position.dex if non-empty, else undefined (handlePositionOpened normalizes "")
|
|
31
|
-
*
|
|
32
|
-
* Note: PositionOpenedEvent.szi is a string (the handler parses it); OpenPosition.szi is a number.
|
|
33
|
-
*/
|
|
34
|
-
export function openPositionToPayload(pos, address) {
|
|
35
|
-
const dex = (pos.dex ?? "").trim();
|
|
36
|
-
return {
|
|
37
|
-
address,
|
|
38
|
-
wallet: pos.wallet ?? address,
|
|
39
|
-
coin: pos.coin,
|
|
40
|
-
szi: String(pos.szi ?? 0),
|
|
41
|
-
entryPx: String(pos.entryPx ?? 0),
|
|
42
|
-
leverage: pos.leverage ?? 1,
|
|
43
|
-
...(dex ? { dex } : { dex: "" }),
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
// ─── Executor ─────────────────────────────────────────────────────────────────
|
|
47
|
-
export async function runReconcile(deps) {
|
|
48
|
-
const { address, strategyState, senpi, stateManager, adopter, bus, serialize = (fn) => fn(), } = deps;
|
|
49
|
-
const zeroCounts = { adopted: 0, resized: 0, archived: 0 };
|
|
50
|
-
// Fetch the exchange snapshot + list DSL state + decide + execute, ALL inside the
|
|
51
|
-
// serialize lock, so the exchange view and the DSL view are captured atomically vs
|
|
52
|
-
// the trade loop.
|
|
53
|
-
//
|
|
54
|
-
// BUG 3 fix: list DSL state + decide INSIDE the lock so the plan is computed against
|
|
55
|
-
// the same DSL snapshot the execute pass mutates.
|
|
56
|
-
// BUG 5 fix (bugbot #156): fetch the exchange snapshot INSIDE the lock too. Capturing
|
|
57
|
-
// it outside (a caller prefetch, or a pre-lock fetch) let a concurrent new-entry land
|
|
58
|
-
// between snapshot and decision — reconcile then saw a DSL position absent from the
|
|
59
|
-
// STALE exchange set, emitted "gone", and archived a LIVE managed position. Fetching
|
|
60
|
-
// here means no trade can interleave between the exchange read and the DSL read. Cost:
|
|
61
|
-
// one extra clearinghouse fetch per address per tick (the monitor's per-tick gate fetch
|
|
62
|
-
// is the first; the dropped `prefetched` flag reused it, but that snapshot was the stale
|
|
63
|
-
// one). Negligible against the monitor cadence (30s default, 5s floor).
|
|
64
|
-
const outcome = await serialize(async () => {
|
|
65
|
-
// PROPOSED (deferred — larger change, not implemented here):
|
|
66
|
-
// This clearinghouse fetch runs INSIDE the reconcile lock and has no time limit. A
|
|
67
|
-
// genuinely hung MCP connection (a freeze, not just slowness) would hold the lock
|
|
68
|
-
// indefinitely and could stall stop() (which drains the consumer that contends this
|
|
69
|
-
// lock). The risk is pre-existing and shared by EVERY in-lock exchange call (trade-path
|
|
70
|
-
// create/close, etc.), not just this one — so the fix belongs at that whole layer.
|
|
71
|
-
// Proposed policy (callTool already accepts an AbortSignal; we just don't pass one):
|
|
72
|
-
// - while running: bound each in-lock exchange call with a timeout + up to 3 retries,
|
|
73
|
-
// then give up. A clean give-up is safe — the gate stays closed and self-heals on
|
|
74
|
-
// the next boot-retry / monitor tick once MCP recovers.
|
|
75
|
-
// - on shutdown: give up after ~30s (no retries) so stop() can never hang.
|
|
76
|
-
const ch = await strategyState.fetchClearinghouseState();
|
|
77
|
-
if (ch == null) {
|
|
78
|
-
logger.warn("reconcile-run: fetchClearinghouseState returned null/undefined — skipping (R7 fail-safe)", {
|
|
79
|
-
address,
|
|
80
|
-
});
|
|
81
|
-
return { unavailable: true };
|
|
82
|
-
}
|
|
83
|
-
const exchangePositions = strategyState.getOpenPositions();
|
|
84
|
-
if (exchangePositions === undefined) {
|
|
85
|
-
logger.warn("reconcile-run: getOpenPositions returned undefined (clearinghouse never fetched) — skipping", {
|
|
86
|
-
address,
|
|
87
|
-
});
|
|
88
|
-
return { unavailable: true };
|
|
89
|
-
}
|
|
90
|
-
const runtimeDslStates = await stateManager.listDslStates(address);
|
|
91
|
-
const decision = decideReconcile({ address, exchangePositions, runtimeDslStates });
|
|
92
|
-
const c = { adopted: 0, resized: 0, archived: 0 };
|
|
93
|
-
// BUG 2 fix: track action failures so success reflects whether the reconcile
|
|
94
|
-
// ACTIONS actually applied, not merely that CH was fetched + a decision ran.
|
|
95
|
-
let f = 0;
|
|
96
|
-
// Archive gone FIRST (frees state slots before re-adopting).
|
|
97
|
-
for (const action of decision.actions) {
|
|
98
|
-
if (action.kind !== "gone")
|
|
99
|
-
continue;
|
|
100
|
-
try {
|
|
101
|
-
const outcome = await archiveGoneState(stateManager, senpi, action.state, {
|
|
102
|
-
bus,
|
|
103
|
-
strategyState,
|
|
104
|
-
});
|
|
105
|
-
if (outcome === "archived")
|
|
106
|
-
c.archived++;
|
|
107
|
-
// "skipped" is a by-design no-op (a different live position now holds the key, or
|
|
108
|
-
// the state is already gone) — NOT a failure. Only "failed" blocks the gate.
|
|
109
|
-
else if (outcome === "failed")
|
|
110
|
-
f++;
|
|
111
|
-
}
|
|
112
|
-
catch (err) {
|
|
113
|
-
f++;
|
|
114
|
-
logger.warn("reconcile-run: archiveGoneState threw unexpectedly", {
|
|
115
|
-
address,
|
|
116
|
-
coin: action.coin,
|
|
117
|
-
dex: action.dex,
|
|
118
|
-
err,
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
// Adopt orphaned positions.
|
|
123
|
-
for (const action of decision.actions) {
|
|
124
|
-
if (action.kind !== "adopt")
|
|
125
|
-
continue;
|
|
126
|
-
try {
|
|
127
|
-
await adopter.adoptPosition(openPositionToPayload(action.position, address));
|
|
128
|
-
c.adopted++;
|
|
129
|
-
}
|
|
130
|
-
catch (err) {
|
|
131
|
-
f++;
|
|
132
|
-
logger.warn("reconcile-run: adoptPosition failed", {
|
|
133
|
-
address,
|
|
134
|
-
coin: action.coin,
|
|
135
|
-
dex: action.dex,
|
|
136
|
-
err,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
// Resize drifted positions.
|
|
141
|
-
for (const action of decision.actions) {
|
|
142
|
-
if (action.kind !== "resize")
|
|
143
|
-
continue;
|
|
144
|
-
try {
|
|
145
|
-
await adopter.resizePosition(openPositionToPayload(action.position, address));
|
|
146
|
-
c.resized++;
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
f++;
|
|
150
|
-
logger.warn("reconcile-run: resizePosition failed", {
|
|
151
|
-
address,
|
|
152
|
-
coin: action.coin,
|
|
153
|
-
dex: action.dex,
|
|
154
|
-
err,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return { unavailable: false, counts: c, failures: f, decided: decision.actions.length };
|
|
159
|
-
});
|
|
160
|
-
// CH unavailable / never fetched → R7 fail-safe (success:false, no mutation).
|
|
161
|
-
if (outcome.unavailable) {
|
|
162
|
-
return { success: false, counts: zeroCounts };
|
|
163
|
-
}
|
|
164
|
-
const { counts, failures, decided } = outcome;
|
|
165
|
-
// success only when every decided action applied. Zero actions (converged) → success.
|
|
166
|
-
const success = failures === 0;
|
|
167
|
-
if (decided === 0) {
|
|
168
|
-
return { success: true, counts: zeroCounts };
|
|
169
|
-
}
|
|
170
|
-
logger.info("reconcile-run: complete", { address, counts, failures, success });
|
|
171
|
-
return { success, counts };
|
|
172
|
-
}
|
|
173
|
-
//# sourceMappingURL=reconcile-run.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"reconcile-run.js","sourceRoot":"","sources":["../../src/runtime/reconcile-run.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
"""The author-facing `ctx` object handed to scan(inputs, ctx).
|
|
2
|
-
|
|
3
|
-
`ScanContext` is the second argument to the author's `scan(inputs, ctx)`. It is
|
|
4
|
-
two tiers (design §2.1):
|
|
5
|
-
|
|
6
|
-
Capabilities you CALL:
|
|
7
|
-
state — the StateManager (B2): a disk-persisted, bounded, transactional
|
|
8
|
-
series. Passed in AS-IS (never wrapped/copied) so author mutations
|
|
9
|
-
and the scaffold's commit/rollback hit the SAME object. May be None
|
|
10
|
-
when history is disabled (state_history_max_count 0/unset).
|
|
11
|
-
senpi_mcp — the MCP client capability. PRESENT but EMPTY this block (None);
|
|
12
|
-
B5 fills it. The slot exists now so B5 is purely additive — no
|
|
13
|
-
structural change to ctx later.
|
|
14
|
-
|
|
15
|
-
Read-only metadata you READ:
|
|
16
|
-
wallet — str, the strategy wallet (routing identity).
|
|
17
|
-
scanner_name — str, this scanner's recipe name.
|
|
18
|
-
interval_seconds — int, tick cadence in seconds.
|
|
19
|
-
|
|
20
|
-
The metadata is FROZEN: mutating, deleting, or adding any attribute raises. The
|
|
21
|
-
author can read `ctx.wallet` but cannot rebind it (or scribble a backdoor onto
|
|
22
|
-
ctx). `state` and `senpi_mcp` are likewise immutable bindings on ctx — the author
|
|
23
|
-
uses them through their own APIs, never by reassigning the slot.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
from __future__ import annotations
|
|
27
|
-
|
|
28
|
-
from typing import Any, Optional
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class ScanContext:
|
|
32
|
-
"""Frozen, read-only metadata + capability handles for scan(inputs, ctx).
|
|
33
|
-
|
|
34
|
-
Construction seam the scaffold uses:
|
|
35
|
-
|
|
36
|
-
ScanContext(
|
|
37
|
-
*,
|
|
38
|
-
state, # a StateManager instance (or None when history disabled)
|
|
39
|
-
wallet: str,
|
|
40
|
-
scanner_name: str,
|
|
41
|
-
interval_seconds: int,
|
|
42
|
-
senpi_mcp=None, # B5 fills this; None/absent this block
|
|
43
|
-
)
|
|
44
|
-
"""
|
|
45
|
-
|
|
46
|
-
# Lock the attribute set: no __dict__, so any unknown attribute (mutate / add /
|
|
47
|
-
# delete) raises AttributeError. The five documented slots are the only ones.
|
|
48
|
-
__slots__ = ("state", "senpi_mcp", "wallet", "scanner_name", "interval_seconds")
|
|
49
|
-
|
|
50
|
-
def __init__(
|
|
51
|
-
self,
|
|
52
|
-
*,
|
|
53
|
-
state: Any,
|
|
54
|
-
wallet: str,
|
|
55
|
-
scanner_name: str,
|
|
56
|
-
interval_seconds: int,
|
|
57
|
-
senpi_mcp: Optional[Any] = None,
|
|
58
|
-
) -> None:
|
|
59
|
-
# Bypass our own frozen __setattr__ to populate the slots once at
|
|
60
|
-
# construction. object.__setattr__ writes the slot directly.
|
|
61
|
-
object.__setattr__(self, "state", state)
|
|
62
|
-
object.__setattr__(self, "senpi_mcp", senpi_mcp)
|
|
63
|
-
object.__setattr__(self, "wallet", wallet)
|
|
64
|
-
object.__setattr__(self, "scanner_name", scanner_name)
|
|
65
|
-
object.__setattr__(self, "interval_seconds", interval_seconds)
|
|
66
|
-
|
|
67
|
-
# -- frozen: block every post-construction mutation --------------------
|
|
68
|
-
|
|
69
|
-
def __setattr__(self, name: str, value: Any) -> None:
|
|
70
|
-
raise AttributeError(
|
|
71
|
-
f"ctx is read-only: cannot set {name!r}. The metadata "
|
|
72
|
-
"(wallet/scanner_name/interval_seconds) is frozen, and the "
|
|
73
|
-
"capability slots (state/senpi_mcp) are used through their own APIs, "
|
|
74
|
-
"never reassigned."
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
def __delattr__(self, name: str) -> None:
|
|
78
|
-
raise AttributeError(f"ctx is read-only: cannot delete {name!r}.")
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
# Runtime dependencies for the scaffold harness (src/scanners/external/scaffold/).
|
|
2
|
-
#
|
|
3
|
-
# The scaffold is shipped to the scanner box and run via `python3 -m scaffold`.
|
|
4
|
-
# `mcp` (the official Model Context Protocol Python SDK) backs ctx.senpi_mcp:
|
|
5
|
-
# `streamablehttp_client` + a single persistent `ClientSession` (see senpi_mcp.py).
|
|
6
|
-
# It is LAZY-IMPORTED at first use, so the package still imports where `mcp` is
|
|
7
|
-
# absent (e.g. unit tests inject a fake SDK) — install this on the box that runs
|
|
8
|
-
# scanners that call ctx.senpi_mcp.
|
|
9
|
-
#
|
|
10
|
-
# `mcp[cli]` is NOT needed — only the client transport + ClientSession surface.
|
|
11
|
-
mcp>=1.9.0
|
|
@@ -1,250 +0,0 @@
|
|
|
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
|