@reefclaw/openclaw-plugin 0.1.2 → 0.1.4

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/bridge/bridge.js CHANGED
@@ -6,7 +6,7 @@ import { join } from 'path';
6
6
  import { homedir } from 'os';
7
7
  import { logger } from './logger.js';
8
8
  import { Connector } from './connector.js';
9
- import { readLocalSkillVersion, validateSkillContent, compareSemver } from './utils/skill-version.js';
9
+ import { readLocalSkillVersion, readAgentVisibleSkillVersions, validateSkillContent, compareSemver } from './utils/skill-version.js';
10
10
  import { verifySkillSignature, signatureRequired, readLastAppliedSignedAtMs, recordAppliedSignedAt, } from './utils/skill-signing.js';
11
11
  import { isTradingMode, validateModeTransition, redactTokens } from '@reefclaw/shared';
12
12
  import { OPERATOR_WRITE_METHODS } from './types.js';
@@ -837,6 +837,37 @@ export class Bridge {
837
837
  logger.warn(TAG, `Failed to update workspace skill SKILL.md: ${scanErr instanceof Error ? scanErr.message : String(scanErr)}`);
838
838
  }
839
839
  }
840
+ // 2d. Also write to the plugin-extension bundled skill directory — the
841
+ // chat-channel install layout (`/plugins install clawhub:...`) loads the
842
+ // agent-visible skill from ~/.openclaw/extensions/<plugin>/skills/, and a
843
+ // plugin (re)install resets that copy to the bundled bootstrap while the
844
+ // workspace copy above (our version tracker) survives. Without this
845
+ // write the agent stays on the bootstrap forever while the pull reports
846
+ // "already up to date" (observed live 2026-07-11).
847
+ const extensionsDir = join(homedir(), '.openclaw', 'extensions');
848
+ if (existsSync(extensionsDir)) {
849
+ try {
850
+ for (const ext of readdirSync(extensionsDir, { withFileTypes: true })) {
851
+ if (!ext.isDirectory() || !ext.name.includes('reefclaw'))
852
+ continue;
853
+ const extSkillsDir = join(extensionsDir, ext.name, 'skills');
854
+ if (!existsSync(extSkillsDir))
855
+ continue;
856
+ for (const skillEntry of readdirSync(extSkillsDir, { withFileTypes: true })) {
857
+ if (!skillEntry.isDirectory() || !skillEntry.name.includes('reefclaw'))
858
+ continue;
859
+ const extSkillMd = join(extSkillsDir, skillEntry.name, 'SKILL.md');
860
+ if (existsSync(extSkillMd)) {
861
+ writeFileSync(extSkillMd, content, 'utf-8');
862
+ logger.info(TAG, `Updated extension skill SKILL.md: ${extSkillMd}`);
863
+ }
864
+ }
865
+ }
866
+ }
867
+ catch (scanErr) {
868
+ logger.warn(TAG, `Failed to update extension skill SKILL.md: ${scanErr instanceof Error ? scanErr.message : String(scanErr)}`);
869
+ }
870
+ }
840
871
  // 3. Invalidate skillsSnapshot cache WITHOUT wiping sessions.json
841
872
  // Previously we wrote '{}' to sessions.json which destroyed OpenClaw chat history.
842
873
  // Now we surgically remove only the skillsSnapshot key from each session entry,
@@ -979,12 +1010,24 @@ export class Bridge {
979
1010
  return;
980
1011
  }
981
1012
  this.currentSkillVersion = readLocalSkillVersion();
982
- if (compareSemver(body.version, this.currentSkillVersion) <= 0) {
1013
+ // The workspace tracker alone is NOT proof the agent has the full skill:
1014
+ // a plugin (re)install resets the agent-visible extension copy to the
1015
+ // bundled bootstrap while the workspace copy survives (observed live
1016
+ // 2026-07-11 — agent stranded on 0.0.5 with the tracker at 2.20.4). If
1017
+ // ANY agent-visible copy is behind the webapp version, re-apply anyway;
1018
+ // applySkillUpdate is idempotent across all copies.
1019
+ const staleAgentCopies = readAgentVisibleSkillVersions().filter((v) => compareSemver(body.version, v) > 0);
1020
+ if (compareSemver(body.version, this.currentSkillVersion) <= 0 && staleAgentCopies.length === 0) {
983
1021
  logger.info(TAG, `SKILL.md webapp pull: local v${this.currentSkillVersion ?? 'unknown'} already >= webapp v${body.version} — nothing to do`);
984
1022
  this.skillPullInFlight = false;
985
1023
  return;
986
1024
  }
987
- logger.info(TAG, `SKILL.md webapp pull: local v${this.currentSkillVersion ?? 'unknown'} behind webapp v${body.version} — applying`);
1025
+ if (staleAgentCopies.length > 0 && compareSemver(body.version, this.currentSkillVersion) <= 0) {
1026
+ logger.info(TAG, `SKILL.md webapp pull: agent-visible skill copy at v${staleAgentCopies[0]} behind webapp v${body.version} (plugin reinstall reset it) — re-applying`);
1027
+ }
1028
+ else {
1029
+ logger.info(TAG, `SKILL.md webapp pull: local v${this.currentSkillVersion ?? 'unknown'} behind webapp v${body.version} — applying`);
1030
+ }
988
1031
  const result = await this.applySkillUpdate({ version: body.version, content: body.content });
989
1032
  this.emit('agent_state', 'skill_update_applied', {
990
1033
  event: 'skill_update_applied',
@@ -2,6 +2,15 @@
2
2
  export declare function parseSkillVersion(content: string): string | null;
3
3
  /** Read current SKILL.md version from disk */
4
4
  export declare function readLocalSkillVersion(): string | null;
5
+ /** Versions of the AGENT-VISIBLE reefclaw SKILL.md copies under the
6
+ * plugin-extension layout (`~/.openclaw/extensions/<plugin>/skills/<skill>/`)
7
+ * — what OpenClaw's skill loader actually reads on chat-channel installs.
8
+ * A plugin (re)install resets these to the bundled bootstrap while the
9
+ * workspace copy (the version tracker above) survives, so the tracker alone
10
+ * cannot be trusted for "already up to date" (observed live 2026-07-11: agent
11
+ * stranded on bootstrap 0.0.5 while the tracker read 2.20.4). Fail-soft:
12
+ * unreadable dirs/files are skipped; no extensions dir → empty list. */
13
+ export declare function readAgentVisibleSkillVersions(): string[];
5
14
  /**
6
15
  * Compare two semver strings.
7
16
  * Returns 1 if a > b, -1 if a < b, 0 if equal.
@@ -1,5 +1,5 @@
1
1
  // SKILL.md version parsing utility for OTA updates
2
- import { readFileSync } from 'fs';
2
+ import { readFileSync, readdirSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
  const SKILL_MD_PATH = join(homedir(), '.openclaw', 'workspace', 'SKILL.md');
@@ -18,6 +18,50 @@ export function readLocalSkillVersion() {
18
18
  return null;
19
19
  }
20
20
  }
21
+ /** Versions of the AGENT-VISIBLE reefclaw SKILL.md copies under the
22
+ * plugin-extension layout (`~/.openclaw/extensions/<plugin>/skills/<skill>/`)
23
+ * — what OpenClaw's skill loader actually reads on chat-channel installs.
24
+ * A plugin (re)install resets these to the bundled bootstrap while the
25
+ * workspace copy (the version tracker above) survives, so the tracker alone
26
+ * cannot be trusted for "already up to date" (observed live 2026-07-11: agent
27
+ * stranded on bootstrap 0.0.5 while the tracker read 2.20.4). Fail-soft:
28
+ * unreadable dirs/files are skipped; no extensions dir → empty list. */
29
+ export function readAgentVisibleSkillVersions() {
30
+ const versions = [];
31
+ const extensionsDir = join(homedir(), '.openclaw', 'extensions');
32
+ let extEntries;
33
+ try {
34
+ extEntries = readdirSync(extensionsDir, { withFileTypes: true });
35
+ }
36
+ catch {
37
+ return versions;
38
+ }
39
+ for (const ext of extEntries) {
40
+ if (!ext.isDirectory() || !ext.name.includes('reefclaw'))
41
+ continue;
42
+ const skillsDir = join(extensionsDir, ext.name, 'skills');
43
+ let skillEntries;
44
+ try {
45
+ skillEntries = readdirSync(skillsDir, { withFileTypes: true });
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ for (const s of skillEntries) {
51
+ if (!s.isDirectory() || !s.name.includes('reefclaw'))
52
+ continue;
53
+ try {
54
+ const v = parseSkillVersion(readFileSync(join(skillsDir, s.name, 'SKILL.md'), 'utf-8'));
55
+ if (v)
56
+ versions.push(v);
57
+ }
58
+ catch {
59
+ // unreadable/absent copy — nothing to report for this entry
60
+ }
61
+ }
62
+ }
63
+ return versions;
64
+ }
21
65
  // 120KB — the full SKILL.md crossed 100KB at v2.20.0 (103.7KB), which silently
22
66
  // broke every OTA apply against the old 100KB cap. Keep comfortably under the
23
67
  // 128KiB Cloudflare Durable-Object per-value hard limit the relay stores into.
@@ -128,6 +128,8 @@ const CONTEXT_WEIGHT = {
128
128
  fetchOHLCV: 2,
129
129
  fetchFundingRate: 1,
130
130
  fetchOpenInterest: 1,
131
+ // Readiness gate's host→Binance reachability probe (fapiPublicGetTime, wt 1).
132
+ reachabilityProbe: 1,
131
133
  // ExchangeInfoCache boot load — ccxt loadMarkets() → GET /fapi/v1/exchangeInfo,
132
134
  // IP weight 1 (doc-verified developers.binance.com 2026-07-10). Was the
133
135
  // known "ungated_ip" ccxt-internal call named in the window-summary comment.
@@ -200,7 +202,7 @@ const NEVER_PACE = new Set([
200
202
  const SHED_FIRST = new Set([
201
203
  'fetchTodayIncomeBreakdown', 'fetchTransfers', 'fetchRecentTradedSymbols',
202
204
  'fetchTicker', 'fetchTickerRaw', 'fetchOHLCV', 'fetchOrderBook',
203
- 'fetchFundingRate', 'fetchOpenInterest',
205
+ 'fetchFundingRate', 'fetchOpenInterest', 'reachabilityProbe',
204
206
  ]);
205
207
  let weightWindowStart = 0;
206
208
  let weightUsed = 0;
@@ -1,6 +1,7 @@
1
1
  import type { CcxtTicker, CcxtOHLCV } from '../types.js';
2
2
  import type { OrderBookDepth } from '../simulator/types.js';
3
- export declare class BinancePublicApi {
3
+ import type { PublicMarketDataApi } from './public-market-data-api.js';
4
+ export declare class BinancePublicApi implements PublicMarketDataApi {
4
5
  private exchange;
5
6
  constructor();
6
7
  /** Fetch raw CCXT ticker (includes funding rate, OI, info). Returns null on error. */
@@ -15,4 +16,19 @@ export declare class BinancePublicApi {
15
16
  fetchOrderBook(symbol: string, limit?: number): Promise<OrderBookDepth | null>;
16
17
  /** Fetch OHLCV candles. Returns null on error. */
17
18
  fetchOHLCV(symbol: string, timeframe?: string, limit?: number): Promise<CcxtOHLCV[] | null>;
19
+ /** Probe Binance USD-M FUTURES reachability from this host — the readiness
20
+ * gate's core signal. Calls the futures-explicit implicit method so it hits
21
+ * `fapi.binance.com` (a bare `fetchTime()` on this instance resolves to spot
22
+ * `api.binance.com`, since the public client doesn't set defaultType:'future').
23
+ * HTTP 451 = Binance geo-restriction; the ban gate does NOT classify 451, so
24
+ * we inspect the message here. Ban-gate compliant (assertNotBanned/noteSuccess/
25
+ * noteBinanceError). Outcomes:
26
+ * - 'reachable' clean response (driftMs = serverTime − localTime)
27
+ * - 'geo_blocked' HTTP 451 — host is in a restricted region (actionable)
28
+ * - 'unknown' the ban/weight gate paused us — NOT a host problem
29
+ * - 'unreachable' network / DNS / timeout / other error */
30
+ probeReachability(): Promise<{
31
+ outcome: 'reachable' | 'geo_blocked' | 'unreachable' | 'unknown';
32
+ driftMs: number | null;
33
+ }>;
18
34
  }
@@ -2,7 +2,7 @@
2
2
  // No API keys required — only uses public market data.
3
3
  import { createRequire } from 'node:module';
4
4
  import { logger } from '../logger.js';
5
- import { assertNotBanned, noteBinanceError, noteSuccess } from './binance-ban-gate.js';
5
+ import { assertNotBanned, noteBinanceError, noteSuccess, BinanceBannedError } from './binance-ban-gate.js';
6
6
  const TAG = 'binance-public';
7
7
  // Load ccxt via CJS require — OpenClaw's ESM loader gives wrong module shape
8
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -144,4 +144,37 @@ export class BinancePublicApi {
144
144
  return null;
145
145
  }
146
146
  }
147
+ /** Probe Binance USD-M FUTURES reachability from this host — the readiness
148
+ * gate's core signal. Calls the futures-explicit implicit method so it hits
149
+ * `fapi.binance.com` (a bare `fetchTime()` on this instance resolves to spot
150
+ * `api.binance.com`, since the public client doesn't set defaultType:'future').
151
+ * HTTP 451 = Binance geo-restriction; the ban gate does NOT classify 451, so
152
+ * we inspect the message here. Ban-gate compliant (assertNotBanned/noteSuccess/
153
+ * noteBinanceError). Outcomes:
154
+ * - 'reachable' clean response (driftMs = serverTime − localTime)
155
+ * - 'geo_blocked' HTTP 451 — host is in a restricted region (actionable)
156
+ * - 'unknown' the ban/weight gate paused us — NOT a host problem
157
+ * - 'unreachable' network / DNS / timeout / other error */
158
+ async probeReachability() {
159
+ try {
160
+ assertNotBanned('reachabilityProbe');
161
+ const r = await this.exchange.fapiPublicGetTime({});
162
+ noteSuccess();
163
+ const serverTime = Number(r?.serverTime);
164
+ const driftMs = Number.isFinite(serverTime) ? serverTime - Date.now() : null;
165
+ return { outcome: 'reachable', driftMs };
166
+ }
167
+ catch (err) {
168
+ noteBinanceError(err);
169
+ if (err instanceof BinanceBannedError) {
170
+ // Gate paused us (418/429 backoff or weight pacer) — we didn't actually
171
+ // reach the host, so the result is UNKNOWN, not a reachability failure.
172
+ return { outcome: 'unknown', driftMs: null };
173
+ }
174
+ const msg = err instanceof Error ? err.message : String(err);
175
+ const geo = msg.includes('451');
176
+ logger.warn(TAG, `probeReachability failed${geo ? ' (HTTP 451 geo-block)' : ''}: ${msg}`);
177
+ return { outcome: geo ? 'geo_blocked' : 'unreachable', driftMs: null };
178
+ }
179
+ }
147
180
  }
@@ -0,0 +1,25 @@
1
+ import { type IntelApiDeps } from '../tools/intel-api.js';
2
+ import type { CcxtTicker, CcxtOHLCV } from '../types.js';
3
+ import type { OrderBookDepth } from '../simulator/types.js';
4
+ import type { PublicMarketDataApi } from './public-market-data-api.js';
5
+ type RawCcxt = Record<string, any>;
6
+ export declare class IntelPublicApi implements PublicMarketDataApi {
7
+ private readonly deps;
8
+ constructor(deps: IntelApiDeps);
9
+ /** Latest price for `symbol` as a CcxtTicker (last=bid=ask=intel 1m close;
10
+ * zero modeled spread — the paper fill engine models slippage itself).
11
+ * Returns null on any intel error, matching BinancePublicApi's contract. */
12
+ fetchTicker(symbol: string): Promise<CcxtTicker | null>;
13
+ /** Intel has no raw-ticker/funding/OI HTTP route yet — null (same as a 451). */
14
+ fetchTickerRaw(): Promise<RawCcxt | null>;
15
+ fetchFundingRate(): Promise<RawCcxt | null>;
16
+ fetchOpenInterest(): Promise<RawCcxt | null>;
17
+ /** No intel depth route yet — null. The paper fill engine falls back to
18
+ * random slippage when the book is absent (see helpers.ts fetchOrderBook). */
19
+ fetchOrderBook(): Promise<OrderBookDepth | null>;
20
+ /** No intel candle route yet — null (candle-consuming analysis tools degrade
21
+ * exactly as they do under a 451; the agent's decisioning reads intel
22
+ * signals/scan tools, not raw candles). */
23
+ fetchOHLCV(): Promise<CcxtOHLCV[] | null>;
24
+ }
25
+ export {};
@@ -0,0 +1,80 @@
1
+ // Intel-backed public market-data source for PAPER mode on hosts that Binance
2
+ // geo-blocks (HTTP 451). Paper execution is fully simulated; it only needs a
3
+ // price, which the intel service serves from its own (permitted-region) feeds
4
+ // via GET /api/price/:symbol (latest 1m close, ≤~60s stale). See
5
+ // docs/AGENT_READINESS_GATE_PLAN.md sibling + the wisekid 451 findings.
6
+ //
7
+ // Selected only when plugin-config `paperMarketDataSource: 'intel'` AND paper
8
+ // mode (index.ts). Live trading NEVER uses this — live needs the real exchange.
9
+ //
10
+ // Increment 1 serves PRICE only (fetchTicker), which is the trading-critical
11
+ // path: create_order / close_position price + the PaperMarketFeed mark loop.
12
+ // Candles / order book / funding / OI have no intel HTTP route yet, so those
13
+ // return null here — the SAME shape a 451 produces today (no regression), and
14
+ // order-book absence is already handled by the fill engine's random-slippage
15
+ // fallback. Candle/book/OI intel routes are a follow-up.
16
+ import { logger } from '../logger.js';
17
+ import { fetchIntelApi } from '../tools/intel-api.js';
18
+ const TAG = 'intel-public';
19
+ /** CCXT symbol (`BTC/USDT`, `BTC/USDT:USDT`) → intel format (`BTCUSDT`). */
20
+ function toIntelSymbol(symbol) {
21
+ return symbol.replace('/', '').replace(/:.*$/, '');
22
+ }
23
+ export class IntelPublicApi {
24
+ deps;
25
+ constructor(deps) {
26
+ this.deps = deps;
27
+ logger.info(TAG, 'Intel public market-data source active (paper mode, Binance reads routed to intel)');
28
+ }
29
+ /** Latest price for `symbol` as a CcxtTicker (last=bid=ask=intel 1m close;
30
+ * zero modeled spread — the paper fill engine models slippage itself).
31
+ * Returns null on any intel error, matching BinancePublicApi's contract. */
32
+ async fetchTicker(symbol) {
33
+ const res = await fetchIntelApi(`/api/price/${toIntelSymbol(symbol)}`, this.deps);
34
+ if ('error' in res) {
35
+ logger.warn(TAG, `fetchTicker(${symbol}) via intel failed: ${res.error}`);
36
+ return null;
37
+ }
38
+ const body = res;
39
+ const price = Number(body.price);
40
+ if (!Number.isFinite(price) || price <= 0) {
41
+ logger.warn(TAG, `fetchTicker(${symbol}) via intel returned a bad price: ${body.price}`);
42
+ return null;
43
+ }
44
+ const timestamp = Number.isFinite(body.time) ? body.time : Date.now();
45
+ return {
46
+ // Key on the ORIGINAL ccxt symbol so the simulator's per-symbol maps line up.
47
+ symbol,
48
+ last: price,
49
+ bid: price,
50
+ ask: price,
51
+ baseVolume: 0,
52
+ quoteVolume: 0,
53
+ change: 0,
54
+ percentage: 0,
55
+ timestamp,
56
+ datetime: new Date(timestamp).toISOString(),
57
+ };
58
+ }
59
+ /** Intel has no raw-ticker/funding/OI HTTP route yet — null (same as a 451). */
60
+ async fetchTickerRaw() {
61
+ return null;
62
+ }
63
+ async fetchFundingRate() {
64
+ return null;
65
+ }
66
+ async fetchOpenInterest() {
67
+ return null;
68
+ }
69
+ /** No intel depth route yet — null. The paper fill engine falls back to
70
+ * random slippage when the book is absent (see helpers.ts fetchOrderBook). */
71
+ async fetchOrderBook() {
72
+ return null;
73
+ }
74
+ /** No intel candle route yet — null (candle-consuming analysis tools degrade
75
+ * exactly as they do under a 451; the agent's decisioning reads intel
76
+ * signals/scan tools, not raw candles). */
77
+ async fetchOHLCV() {
78
+ return null;
79
+ }
80
+ }
@@ -0,0 +1,12 @@
1
+ import type { CcxtTicker, CcxtOHLCV } from '../types.js';
2
+ import type { OrderBookDepth } from '../simulator/types.js';
3
+ type RawCcxt = Record<string, any>;
4
+ export interface PublicMarketDataApi {
5
+ fetchTickerRaw(symbol: string): Promise<RawCcxt | null>;
6
+ fetchTicker(symbol: string): Promise<CcxtTicker | null>;
7
+ fetchFundingRate(symbol: string): Promise<RawCcxt | null>;
8
+ fetchOpenInterest(symbol: string): Promise<RawCcxt | null>;
9
+ fetchOrderBook(symbol: string, limit?: number): Promise<OrderBookDepth | null>;
10
+ fetchOHLCV(symbol: string, timeframe?: string, limit?: number): Promise<CcxtOHLCV[] | null>;
11
+ }
12
+ export {};
@@ -0,0 +1,9 @@
1
+ // The public market-data surface paper mode reads (prices, candles, book,
2
+ // funding, OI). Both BinancePublicApi (direct Binance) and IntelPublicApi
3
+ // (via the intel service, for Binance-451-geo-blocked hosts) implement it, so
4
+ // the paper wiring can select a source without any consumer knowing which.
5
+ //
6
+ // Deliberately EXCLUDES probeReachability: that is Binance-specific and only
7
+ // the readiness reporter uses it — it must always probe the REAL Binance host
8
+ // to detect the 451, so it keeps a concrete BinancePublicApi, never this.
9
+ export {};
@@ -21,8 +21,23 @@ export interface PluginConfigFile {
21
21
  * prod runs the bridge under systemd and a supervisor there would
22
22
  * double-connect the relay room. Kill-switch: RC_CONNECTOR_SUPERVISOR=off. */
23
23
  connectorSupervisor?: 'on' | 'off';
24
+ /** Where PAPER mode reads market-data prices from. 'binance' (default) hits
25
+ * Binance public endpoints directly; 'intel' routes them through the intel
26
+ * service (GET /api/price/:symbol) so a host that Binance geo-blocks (HTTP
27
+ * 451) can still paper-trade. PAPER-ONLY + advisory: live trading always
28
+ * uses the real exchange, and readiness still probes real Binance to detect
29
+ * the 451. Kill-switch: RC_PAPER_MARKET_DATA=binance forces the default. */
30
+ paperMarketDataSource?: 'binance' | 'intel';
24
31
  apiBaseUrl?: string;
25
32
  intelligenceUrl?: string;
33
+ /** Exchange credentials + venue selection. `exchange.venue` picks the
34
+ * trading venue ('binance' default | 'hyperliquid'); it is LOCAL mechanism
35
+ * (never central-pushable) per docs/HYPERLIQUID_INTEGRATION_PLAN.md §5.2.
36
+ * Until Phase 3 ships the hyperliquid adapter, a non-PAPER mode on
37
+ * venue='hyperliquid' falls back to PAPER at boot. NOTE: updatePluginConfig
38
+ * replaces this block wholesale (the documented `exchange: null` clears
39
+ * credentials), so any writer (set_exchange_credentials, SSH edits) must
40
+ * carry `venue` through or it resets to the binance default. */
26
41
  exchange?: ExchangeConfig;
27
42
  tradingMode?: TradingMode;
28
43
  microLive?: {
package/index.js CHANGED
@@ -11,6 +11,7 @@ import { homedir } from 'node:os';
11
11
  import { join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { BinancePublicApi } from './ccxt/binance-public.js';
14
+ import { IntelPublicApi } from './ccxt/intel-public.js';
14
15
  import { BinancePrivateApi } from './ccxt/binance-private.js';
15
16
  import { ExchangeSimulator } from './simulator/exchange-simulator.js';
16
17
  import { ShadowTracker } from './shadow/shadow-tracker.js';
@@ -18,7 +19,8 @@ import { StateManager } from './persistence/state-manager.js';
18
19
  import { logger, setLogLevel, formatError } from './logger.js';
19
20
  import { DEFAULT_CONFIG } from './types.js';
20
21
  import { PaperAdapter } from './paper-adapter.js';
21
- import { LiveAdapter } from './live/live-adapter.js';
22
+ import { createLiveAdapter, fillExchangeId, isLiveVenueSupported, parseVenue, } from './venues/registry.js';
23
+ import { HyperliquidPublicApi } from './venues/hyperliquid/hl-public.js';
22
24
  import { loadBracketMode } from './config/brackets-config.js';
23
25
  import { loadUserDataStreamMode, loadUserDataStreamTunables, loadUserDataStreamDbWrite, getUserDataStreamIngestBaseUrl, resolveIngestToken, resolveReefclawUserId, } from './config/user-data-stream-config.js';
24
26
  import { TradeStoreClient } from './ingest/trade-store-client.js';
@@ -29,6 +31,7 @@ import { PositionStateStore } from './live/position-state-store.js';
29
31
  import { PendingEntryStore } from './ingest/pending-entry-metadata.js';
30
32
  import { onReconcilerObservedClose, reconcileStateStoreOnStartup } from './ingest/reconciler-cleanup.js';
31
33
  import { reconcileDbOpenVsExchange } from './ingest/reconcile-db-vs-exchange.js';
34
+ import { startReadinessReporter } from './ingest/readiness-reporter.js';
32
35
  import { IntelMicrostructureAssembler } from './live/microstructure-assembler.js';
33
36
  import { recordPositionReviewsTool } from './tools/record-position-reviews.js';
34
37
  import { getMyRecentReviewsTool } from './tools/get-my-recent-reviews.js';
@@ -911,6 +914,14 @@ const paperTradingPlugin = {
911
914
  let intelligenceUrl = 'https://intel.reefclaw.com';
912
915
  let exchangeConfig = null;
913
916
  let tradingMode = 'PAPER';
917
+ // Trading venue (multi-venue Phase 0 — docs/HYPERLIQUID_INTEGRATION_PLAN.md).
918
+ // Absent config field = 'binance', so every pre-venue install is untouched.
919
+ let venue = 'binance';
920
+ // exchange.testnet as written on disk — needed independently of
921
+ // exchangeConfig because the hyperliquid venue never builds a Binance
922
+ // credential config (Phase 0 gate) but its keyless public API still needs
923
+ // the mainnet/testnet routing.
924
+ let venueTestnet = false;
914
925
  let signalsEvaluator = 'central';
915
926
  let signalsSymbols = [];
916
927
  try {
@@ -927,9 +938,18 @@ const paperTradingPlugin = {
927
938
  if (Array.isArray(rcConfig?.signals?.symbols)) {
928
939
  signalsSymbols = rcConfig.signals.symbols.map((s) => String(s).toUpperCase());
929
940
  }
930
- // Read exchange credentials (Phase 9b)
941
+ // Read exchange credentials (Phase 9b) + venue (multi-venue Phase 0)
931
942
  const exchange = rcConfig?.exchange;
932
- if (exchange?.apiKey && exchange?.secret) {
943
+ const venueParse = parseVenue(exchange?.venue);
944
+ venue = venueParse.venue;
945
+ venueTestnet = exchange?.testnet === true;
946
+ if (venueParse.unrecognized) {
947
+ logger.warn(TAG, `Unrecognized exchange.venue '${venueParse.unrecognized}' in plugin-config — treating as 'binance'`);
948
+ }
949
+ // apiKey/secret are a BINANCE credential pair; never build a Binance
950
+ // client config for another venue (a hyperliquid block carries
951
+ // walletAddress/agentPrivateKey instead — consumed from Phase 3 on).
952
+ if (venue === 'binance' && exchange?.apiKey && exchange?.secret) {
933
953
  exchangeConfig = {
934
954
  apiKey: exchange.apiKey,
935
955
  secret: exchange.secret,
@@ -963,6 +983,14 @@ const paperTradingPlugin = {
963
983
  else {
964
984
  logger.warn(TAG, 'No connection token found — get_market_intel will return errors');
965
985
  }
986
+ // Venue support gate (multi-venue Phase 0). Checked BEFORE the credentials
987
+ // gate so a hyperliquid config gets the accurate diagnosis ("venue not
988
+ // supported yet"), not a misleading "missing API keys". Fallback-to-PAPER,
989
+ // never a throw — register() must stay non-crashing.
990
+ if (tradingMode !== 'PAPER' && !isLiveVenueSupported(venue)) {
991
+ logger.warn(TAG, `Trading mode ${tradingMode} on venue '${venue}' is not supported in this build — falling back to PAPER (live support arrives in Phase 3 of docs/HYPERLIQUID_INTEGRATION_PLAN.md)`);
992
+ tradingMode = 'PAPER';
993
+ }
966
994
  // Validate trading mode vs credentials
967
995
  if (tradingMode !== 'PAPER' && !exchangeConfig) {
968
996
  logger.warn(TAG, `Trading mode ${tradingMode} requires exchange API keys — falling back to PAPER`);
@@ -1062,6 +1090,9 @@ const paperTradingPlugin = {
1062
1090
  // closure — only invoked on a fill, long after `runtime` is built), so it
1063
1091
  // follows a live<->paper reconnect.
1064
1092
  resolveMode: () => (runtime.adapter.isLive ? 'live' : 'paper'),
1093
+ // Venue tag (positions.exchange, migration 0058) — static per process;
1094
+ // a venue change requires config edit + restart.
1095
+ venue,
1065
1096
  };
1066
1097
  if (positionDecisionsClient) {
1067
1098
  logger.info(TAG, `Journal close-on-reduce-only-fill ${closeOnReduceOnlyFill ? 'ENABLED' : 'disabled'}`);
@@ -1145,6 +1176,11 @@ const paperTradingPlugin = {
1145
1176
  tradeIngest = {
1146
1177
  client: new TradeStoreClient({ baseUrl: ingestBaseUrl, ingestToken }),
1147
1178
  userId: reefclawUserId,
1179
+ // Venue-derived FillEvent.exchange — 'binance_futures' for the
1180
+ // binance venue, i.e. byte-identical to the pre-venue literal
1181
+ // WsIngest defaulted to. Half of the (exchange, exchange_trade_id)
1182
+ // audit-trail idempotency key; never a fresh string literal.
1183
+ exchange: fillExchangeId(venue),
1148
1184
  };
1149
1185
  logger.info(TAG, `User-data stream dbWrite=on — WS audit-trail ingest wired to ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1150
1186
  }
@@ -1202,7 +1238,12 @@ const paperTradingPlugin = {
1202
1238
  logger.warn(TAG, `approval mode=${approvalModeForTool} but WEBAPP_INGEST_TOKEN or REEFCLAW_USER_ID env missing — proposal path disabled`);
1203
1239
  approvalModeForTool = 'off';
1204
1240
  }
1205
- const liveAdapter = new LiveAdapter(exchangeConfig, tradingMode, microLiveConfig, bracketMode, userDataStreamMode, userDataStreamTunables, tradeIngest, autoCapture);
1241
+ // Venue-dispatched construction (multi-venue Phase 0). For 'binance'
1242
+ // this is a pure pass-through to `new LiveAdapter(...)` — identical
1243
+ // args, identical behavior; the unsupported-venue arm is unreachable
1244
+ // here because the isLiveVenueSupported() gate above already fell back
1245
+ // to PAPER. Phase 3 adds the hyperliquid adapter inside the factory.
1246
+ const liveAdapter = createLiveAdapter(venue, exchangeConfig, tradingMode, microLiveConfig, bracketMode, userDataStreamMode, userDataStreamTunables, tradeIngest, autoCapture);
1206
1247
  adapter = liveAdapter;
1207
1248
  // ---- Approval-mode Phase B — start ProposalDecisionListener ----
1208
1249
  // Started only when approval.mode='per_trade' AND proposalManagerCtx
@@ -1319,11 +1360,64 @@ const paperTradingPlugin = {
1319
1360
  else {
1320
1361
  adapter = new PaperAdapter(simulator);
1321
1362
  }
1363
+ // PAPER market-data source selection.
1364
+ //
1365
+ // Venue precedence (Hyperliquid Phase 1 — docs/HYPERLIQUID_INTEGRATION_PLAN.md):
1366
+ // the configured VENUE decides which exchange prices paper mode, full stop.
1367
+ // venue='hyperliquid' → every public read (PaperMarketFeed marks, the
1368
+ // simulator's fill prices, fetch_ticker/fetch_ohlcv/get_orderbook tools)
1369
+ // comes from Hyperliquid's keyless /info endpoints; the Binance-only
1370
+ // `paperMarketDataSource:'intel'` escape hatch does not apply (intel has
1371
+ // no HL rows until Phase 2 — silently serving Binance prices for a
1372
+ // Hyperliquid book would be a lie, the exact class the symbol-translation
1373
+ // rule forbids).
1374
+ //
1375
+ // Binance venue (default) is unchanged: a host Binance geo-blocks (HTTP
1376
+ // 451) can route PRICE reads through the intel service by setting
1377
+ // plugin-config `paperMarketDataSource:'intel'` (GET /api/price/:symbol).
1378
+ // LIVE always uses the real exchange, and the readiness reporter probes
1379
+ // the CONFIGURED venue. Kill-switch RC_PAPER_MARKET_DATA=binance forces
1380
+ // the Binance-direct default (binance venue only).
1381
+ //
1382
+ // hlPublicApi is constructed once per process when the venue is
1383
+ // hyperliquid — shared by the paper data path and the readiness probe.
1384
+ const hlPublicApi = venue === 'hyperliquid' ? new HyperliquidPublicApi({ testnet: venueTestnet }) : null;
1385
+ let configuredPaperSource;
1386
+ try {
1387
+ configuredPaperSource = readPluginConfig().paperMarketDataSource;
1388
+ }
1389
+ catch {
1390
+ configuredPaperSource = undefined;
1391
+ }
1392
+ const paperDataSource = process.env.RC_PAPER_MARKET_DATA === 'binance'
1393
+ ? 'binance'
1394
+ : configuredPaperSource ?? 'binance';
1395
+ const useIntelPaperData = venue === 'binance' &&
1396
+ !adapter.isLive && paperDataSource === 'intel' && connectionToken.length > 0;
1397
+ const marketDataApi = !adapter.isLive && hlPublicApi
1398
+ ? hlPublicApi
1399
+ : useIntelPaperData
1400
+ ? new IntelPublicApi({ connectionToken, intelligenceUrl })
1401
+ : binanceApi;
1402
+ if (!adapter.isLive && hlPublicApi) {
1403
+ logger.info(TAG, `PAPER market data sourced from Hyperliquid${venueTestnet ? ' TESTNET' : ''} (venue=hyperliquid) — use USDC pairs (e.g. BTC/USDC)`);
1404
+ if (paperDataSource === 'intel') {
1405
+ logger.warn(TAG, 'paperMarketDataSource=intel is a Binance-venue option — ignored on venue=hyperliquid (intel has no Hyperliquid rows until Phase 2)');
1406
+ }
1407
+ }
1408
+ else if (useIntelPaperData) {
1409
+ logger.warn(TAG, 'PAPER market data routed through intel (paperMarketDataSource=intel) — prices from GET /api/price; live + readiness still use Binance directly');
1410
+ }
1411
+ else if (venue === 'binance' && !adapter.isLive && paperDataSource === 'intel' && connectionToken.length === 0) {
1412
+ logger.warn(TAG, 'paperMarketDataSource=intel requested but no connection token — falling back to Binance direct (which will 451 on a geo-blocked host)');
1413
+ }
1322
1414
  // Build tool dependencies
1323
1415
  // `simDeps` for tools that need the simulator directly (ticker, market structure, risk scenario)
1324
1416
  // `adapterDeps` for the 7 adapter-based trading tools
1325
- const simDeps = { binanceApi, simulator };
1326
- const adapterDeps = { binanceApi, adapter };
1417
+ // Both carry `marketDataApi` (Binance direct, or intel in paper mode) so
1418
+ // every paper market-data read follows the selected source at once.
1419
+ const simDeps = { binanceApi: marketDataApi, simulator };
1420
+ const adapterDeps = { binanceApi: marketDataApi, adapter };
1327
1421
  // ---- Plugin runtime (mutable holder) ----
1328
1422
  // Owns the adapter + mode + stop-watcher and exposes `reconnect()` so the
1329
1423
  // operator can swap credentials / trading mode from the dashboard without
@@ -1356,7 +1450,7 @@ const paperTradingPlugin = {
1356
1450
  // resting limits fill. Paper-only — started here when the boot adapter is
1357
1451
  // paper; PluginRuntime.reconnect() stops it on a switch to live and
1358
1452
  // restarts it on a switch back to paper. Reuses the ban-gated fetchTicker.
1359
- const marketFeed = new PaperMarketFeed(binanceApi, simulator);
1453
+ const marketFeed = new PaperMarketFeed(marketDataApi, simulator);
1360
1454
  if (!adapter.isLive) {
1361
1455
  marketFeed.start();
1362
1456
  }
@@ -2020,6 +2114,22 @@ const paperTradingPlugin = {
2020
2114
  pluginToolNames = toolNames;
2021
2115
  pluginInitialised = true;
2022
2116
  logger.info(TAG, `Registered ${gatedTools.length} tools (gate mode=${toolGate.getMode()}): ${toolNames.join(', ')}. Plugin v3.8.0 (${runtime.mode} mode)`);
2117
+ // Agent-readiness reporter (docs/AGENT_READINESS_GATE_PLAN.md Phase 1):
2118
+ // probe host→venue reachability (Binance HTTP 451 geo-block / Hyperliquid
2119
+ // /info) + clock drift on the host and POST a plain-English report to the
2120
+ // webapp, so a silently-broken agent shows an actionable dashboard alert
2121
+ // instead of a false green. Probes ONLY the configured venue — on a
2122
+ // Binance-451 host trading Hyperliquid a Binance probe would be a
2123
+ // permanent false alarm. Advisory + fire-and-forget; no token → no-op.
2124
+ // Runs here once (guarded by the pluginInitialised early-return → once per
2125
+ // process) + on an unref'd interval inside the reporter.
2126
+ startReadinessReporter({
2127
+ apiBaseUrl,
2128
+ token: resolveIngestToken({ connectionToken }),
2129
+ venue,
2130
+ publicApi: hlPublicApi ?? binanceApi,
2131
+ toolCount: toolNames.length,
2132
+ });
2023
2133
  maybeStartConnectorSupervisor();
2024
2134
  },
2025
2135
  };
@@ -21,6 +21,11 @@ export interface AutoCaptureContext {
21
21
  * from the current adapter (so it follows a runtime reconnect). Tags the
22
22
  * journal position row so paper and live entries can be segregated. */
23
23
  resolveMode?: () => 'paper' | 'live';
24
+ /** Venue this gateway trades ('binance' | 'hyperliquid'). Static per process
25
+ * — changing venue requires a config edit + restart (plan §5.2), so unlike
26
+ * `resolveMode` this is a plain value, not a resolver. Tags journal rows
27
+ * (positions.exchange, migration 0058). */
28
+ venue?: 'binance' | 'hyperliquid';
24
29
  }
25
30
  export interface CreateOrderInputs {
26
31
  symbol: string;