@reefclaw/openclaw-plugin 0.1.12 → 0.1.14
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/gateway/event-parser.d.ts +19 -0
- package/bridge/gateway/event-parser.js +52 -0
- package/bridge/gateway/gateway-config.d.ts +16 -5
- package/bridge/gateway/gateway-config.js +68 -12
- package/bridge/gateway/heartbeat-cron.d.ts +1 -0
- package/bridge/gateway/heartbeat-cron.js +22 -1
- package/bridge/gateway/poller.js +18 -8
- package/bridge/providers/emergency-commands.d.ts +9 -1
- package/bridge/providers/emergency-commands.js +38 -1
- package/bridge/providers/gateway.d.ts +72 -1
- package/bridge/providers/gateway.js +241 -29
- package/bridge/providers/onboarding-commands.d.ts +8 -0
- package/bridge/providers/onboarding-commands.js +4 -4
- package/bridge/types.d.ts +30 -0
- package/bridge/utils/identity-name.d.ts +24 -0
- package/bridge/utils/identity-name.js +54 -0
- package/ccxt/binance-public.d.ts +21 -7
- package/ccxt/binance-public.js +70 -6
- package/config/operator-provenance.d.ts +6 -0
- package/config/operator-provenance.js +50 -0
- package/config/plugin-config-io.d.ts +15 -1
- package/config/plugin-config-io.js +24 -0
- package/index.js +216 -173
- package/ingest/event-loop-monitor.d.ts +11 -0
- package/ingest/event-loop-monitor.js +113 -0
- package/ingest/position-auto-capture.d.ts +5 -0
- package/ingest/position-auto-capture.js +14 -5
- package/ingest/readiness-reporter.d.ts +17 -6
- package/ingest/readiness-reporter.js +88 -9
- package/ingest/skill-version-reader.d.ts +16 -0
- package/ingest/skill-version-reader.js +64 -0
- package/live/approval-lifecycle.d.ts +30 -0
- package/live/approval-lifecycle.js +80 -0
- package/live/bracket-types.d.ts +9 -0
- package/live/live-adapter.d.ts +0 -1
- package/live/proposal-decision-listener.d.ts +20 -0
- package/live/proposal-decision-listener.js +211 -48
- package/onboarding/runtime.d.ts +34 -1
- package/onboarding/runtime.js +56 -5
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/simulator/exchange-simulator.d.ts +45 -2
- package/simulator/exchange-simulator.js +96 -4
- package/simulator/types.d.ts +17 -0
- package/tools/attach-brackets.js +50 -1
- package/tools/create-order.d.ts +11 -0
- package/tools/create-order.js +23 -2
- package/tools/get-risk-summary.d.ts +4 -0
- package/tools/get-risk-summary.js +62 -23
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
- package/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
- package/venues/hyperliquid/hl-brackets.d.ts +10 -0
- package/venues/hyperliquid/hl-brackets.js +45 -13
- package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
- package/venues/hyperliquid/hl-fill-ingest.js +69 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
- package/venues/hyperliquid/hl-live-adapter.js +155 -12
- package/venues/hyperliquid/hl-order.d.ts +35 -0
- package/venues/hyperliquid/hl-order.js +123 -0
- package/venues/hyperliquid/hl-position.d.ts +36 -0
- package/venues/hyperliquid/hl-position.js +127 -0
- package/venues/hyperliquid/hl-private.d.ts +20 -3
- package/venues/hyperliquid/hl-private.js +37 -6
- package/venues/hyperliquid/hl-public.d.ts +12 -5
- package/venues/hyperliquid/hl-public.js +24 -3
- package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
- package/venues/hyperliquid/hl-user-stream.js +4 -1
|
@@ -6,12 +6,14 @@ import { join } from 'path';
|
|
|
6
6
|
import { homedir } from 'os';
|
|
7
7
|
import { logger, formatError } from '../logger.js';
|
|
8
8
|
import { isTradingMode } from '../types.js';
|
|
9
|
+
import { toIntelSymbol } from '@reefclaw/shared';
|
|
9
10
|
import { GatewayHttpClient } from '../gateway/gateway-http-client.js';
|
|
10
11
|
import { GatewayWsClient } from '../gateway/gateway-ws-client.js';
|
|
11
12
|
import { discoverTools } from '../gateway/tool-discovery.js';
|
|
12
13
|
import { EventParser, mapCcxtBalance, mapCcxtOrder, extractLiquidationFields, extractBracketField, extractPlannedLevels, } from '../gateway/event-parser.js';
|
|
13
14
|
import { Poller } from '../gateway/poller.js';
|
|
14
|
-
import { ensureHeartbeatCron } from '../gateway/heartbeat-cron.js';
|
|
15
|
+
import { ensureHeartbeatCron, isHeartbeatLikeName } from '../gateway/heartbeat-cron.js';
|
|
16
|
+
import { parseIdentityName } from '../utils/identity-name.js';
|
|
15
17
|
import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, } from './risk-calculator.js';
|
|
16
18
|
import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
|
|
17
19
|
import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, } from './onboarding-commands.js';
|
|
@@ -130,6 +132,8 @@ function saveSessionStartNav(nav) {
|
|
|
130
132
|
}
|
|
131
133
|
// ---- Agent state emission interval ----
|
|
132
134
|
const AGENT_STATE_INTERVAL_MS = 5_000;
|
|
135
|
+
/** How often IDENTITY.md is re-read for the agent's display name (local file read). */
|
|
136
|
+
const AGENT_NAME_TTL_MS = 60_000;
|
|
133
137
|
// ---- GatewayProvider ----
|
|
134
138
|
export class GatewayProvider {
|
|
135
139
|
config;
|
|
@@ -154,12 +158,21 @@ export class GatewayProvider {
|
|
|
154
158
|
agentIdentity = {};
|
|
155
159
|
/** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
|
|
156
160
|
loggedAgentIdentityProbe = false;
|
|
161
|
+
/** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
|
|
162
|
+
agentNameReadAtMs = 0;
|
|
157
163
|
/** One-shot latch for the boot-time heartbeat-cron ensure (re-armed on failure). */
|
|
158
164
|
heartbeatCronEnsureStarted = false;
|
|
159
165
|
/** Live heartbeat cadence (seconds), read from the OpenClaw cron store; cached 60s. */
|
|
160
166
|
heartbeatSeconds;
|
|
161
167
|
heartbeatReadAtMs = 0;
|
|
162
168
|
loggedHeartbeat = false;
|
|
169
|
+
/** Heartbeat-job health read from `cron.list` (RPC — the filesystem jobs.json
|
|
170
|
+
* is absent on 2026.7.x). Surfaced so the dashboard shows a failing/stale
|
|
171
|
+
* beat instead of a silent gap (a beat failed 10× over ~2 days undetected,
|
|
172
|
+
* 2026-07-24). undefined until first resolved; refreshed lazily (120s). */
|
|
173
|
+
heartbeatHealth;
|
|
174
|
+
heartbeatHealthReadAtMs = 0;
|
|
175
|
+
heartbeatHealthRefreshing = false;
|
|
163
176
|
lastTicker = null;
|
|
164
177
|
positions = [];
|
|
165
178
|
balance = { currency: 'USDT', total: 0, available: 0, locked: 0 };
|
|
@@ -230,6 +243,13 @@ export class GatewayProvider {
|
|
|
230
243
|
// ---- Trade tracking ----
|
|
231
244
|
/** Timestamp of the most recent trade (fill event) — used in buildAgentState */
|
|
232
245
|
lastTradeTs = null;
|
|
246
|
+
/** Epoch ms of the most recent LIVE fill observed this process (issue #248).
|
|
247
|
+
* Deliberately separate from `lastTradeTs`: that one is a display string and
|
|
248
|
+
* is also seeded from the historical trades ledger at startup, which must
|
|
249
|
+
* NOT count here — this field exists solely to prove whether the current
|
|
250
|
+
* `positions` snapshot predates a fill, and a historical seed would make an
|
|
251
|
+
* untorn snapshot look torn forever. 0 = no fill seen yet this process. */
|
|
252
|
+
lastFillAt = 0;
|
|
233
253
|
/** Tracks open position entries for computing trade results on close.
|
|
234
254
|
* Key: `symbol:side` (e.g. "BTC/USDT:long"), Value: { entryPrice, quantity, side, entryTime, missionId?, setupType?, regime?, confluenceScore? } */
|
|
235
255
|
openTradeEntries = new Map();
|
|
@@ -553,10 +573,11 @@ export class GatewayProvider {
|
|
|
553
573
|
const ctx = { http: this.http, toolMap: this.toolMap, symbol: this.config.symbol };
|
|
554
574
|
// Pass a FRESH, all-symbols order snapshot so Kill cancels every working
|
|
555
575
|
// order portfolio-wide and correctly identifies (and preserves) protective
|
|
556
|
-
// reduceOnly brackets.
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
const
|
|
576
|
+
// reduceOnly brackets. On a failed fresh read Kill still runs against the
|
|
577
|
+
// ≤60s cache (never blocked) but reports executed:false — a stale cache
|
|
578
|
+
// cannot verify coverage (audit 2026-07-26 F6).
|
|
579
|
+
const freshOrders = await this.fetchFreshOpenOrdersOrNull();
|
|
580
|
+
const result = await _executeKill(ctx, freshOrders, this.openOrders);
|
|
560
581
|
this.agentMode = 'STOPPED';
|
|
561
582
|
this.emitAgentState();
|
|
562
583
|
return result;
|
|
@@ -641,16 +662,23 @@ export class GatewayProvider {
|
|
|
641
662
|
// Thin wrappers — real logic lives in providers/onboarding-commands.ts
|
|
642
663
|
// so unit tests can drive pure functions with a mocked HTTP client.
|
|
643
664
|
async setTradingMode(mode, acknowledged) {
|
|
644
|
-
return executeSetTradingMode(
|
|
665
|
+
return executeSetTradingMode(
|
|
666
|
+
// operatorToken = the rc_* connection token — the plugin refuses the
|
|
667
|
+
// four state-mutating operator tools without it (audit F12).
|
|
668
|
+
{ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, mode, acknowledged);
|
|
645
669
|
}
|
|
646
670
|
async setExchangeCredentials(apiKey, secret, testnet) {
|
|
647
|
-
return executeSetExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
|
|
671
|
+
return executeSetExchangeCredentials({ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, apiKey, secret, testnet);
|
|
648
672
|
}
|
|
649
673
|
async testExchangeCredentials(apiKey, secret, testnet) {
|
|
650
674
|
return executeTestExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
|
|
651
675
|
}
|
|
652
676
|
async clearExchangeCredentials() {
|
|
653
|
-
return executeClearExchangeCredentials({
|
|
677
|
+
return executeClearExchangeCredentials({
|
|
678
|
+
http: this.http,
|
|
679
|
+
toolMap: this.toolMap,
|
|
680
|
+
operatorToken: this.config.connectionToken,
|
|
681
|
+
});
|
|
654
682
|
}
|
|
655
683
|
/** Operator-only. Read the current bracket-orders config from the plugin. */
|
|
656
684
|
async getBracketConfig() {
|
|
@@ -658,7 +686,7 @@ export class GatewayProvider {
|
|
|
658
686
|
}
|
|
659
687
|
/** Operator-only. Flip requireStopLoss or requireTakeProfit. */
|
|
660
688
|
async setBracketRequirement(flag, value) {
|
|
661
|
-
return executeSetBracketRequirement({ http: this.http, toolMap: this.toolMap }, flag, value);
|
|
689
|
+
return executeSetBracketRequirement({ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, flag, value);
|
|
662
690
|
}
|
|
663
691
|
async getSnapshot() {
|
|
664
692
|
logger.info(TAG, 'Reconciliation snapshot requested');
|
|
@@ -1152,6 +1180,10 @@ export class GatewayProvider {
|
|
|
1152
1180
|
// Track last trade timestamp for agent state
|
|
1153
1181
|
if (data.status === 'FILLED') {
|
|
1154
1182
|
this.lastTradeTs = data.timestamp ?? new Date().toISOString();
|
|
1183
|
+
// A fill debits the paper wallet immediately; `positions` catches up
|
|
1184
|
+
// on its own poll. Record when, so the RED-zone guard can tell a torn
|
|
1185
|
+
// wallet/positions snapshot from a real drawdown (issue #248).
|
|
1186
|
+
this.lastFillAt = Date.now();
|
|
1155
1187
|
}
|
|
1156
1188
|
}
|
|
1157
1189
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -1172,6 +1204,28 @@ export class GatewayProvider {
|
|
|
1172
1204
|
canonicalSymbol(s) {
|
|
1173
1205
|
return typeof s === 'string' ? s.replace(/:[A-Z]+$/, '') : s;
|
|
1174
1206
|
}
|
|
1207
|
+
/** Canonical symbol → the intel DB symbol for this box's venue.
|
|
1208
|
+
* binance: 'BTC/USDT' → 'BTCUSDT'; hyperliquid: 'BTC/USDC' → 'HL_BTC'.
|
|
1209
|
+
*
|
|
1210
|
+
* ★ The old venue-blind `.replace('/','')` produced 'BTCUSDC' on a
|
|
1211
|
+
* hyperliquid box — a symbol no intel row has ever carried (intel
|
|
1212
|
+
* namespaces every HL row under the 'HL_' prefix). That silently broke the
|
|
1213
|
+
* regime/signal/mission/analytics pollers AND wrote every trade result to a
|
|
1214
|
+
* symbol Kelly sizing would never read back, so a hyperliquid box stayed at
|
|
1215
|
+
* `insufficient_history` even once the reporting path itself worked.
|
|
1216
|
+
*
|
|
1217
|
+
* FAILS OPEN: an unmappable symbol falls back to the legacy concatenation
|
|
1218
|
+
* so intel answers "no data for <echo>" — honest and debuggable — rather
|
|
1219
|
+
* than the caller throwing and dropping the report entirely. */
|
|
1220
|
+
toIntelSymbol(symbol) {
|
|
1221
|
+
const canonical = this.canonicalSymbol(symbol);
|
|
1222
|
+
try {
|
|
1223
|
+
return toIntelSymbol(this.config.venue ?? 'binance', canonical);
|
|
1224
|
+
}
|
|
1225
|
+
catch {
|
|
1226
|
+
return canonical.replace('/', '');
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1175
1229
|
async onPollerPositions(positions) {
|
|
1176
1230
|
// Canonicalize symbols at this single position-ingress chokepoint so
|
|
1177
1231
|
// the ENTIRE skill (position-diff, openTradeEntries keys, snapshots,
|
|
@@ -1423,6 +1477,10 @@ export class GatewayProvider {
|
|
|
1423
1477
|
};
|
|
1424
1478
|
logger.info(TAG, `Synthetic fill: ${side} ${quantity} ${pos.symbol} @ ${fillPrice} (from position poll)`);
|
|
1425
1479
|
this.lastTradeTs = now;
|
|
1480
|
+
// Same reasoning as the orderUpdate path (issue #248) — this one is derived
|
|
1481
|
+
// FROM a positions poll, so the snapshot already covers it and the guard
|
|
1482
|
+
// clears on the next refresh rather than latching.
|
|
1483
|
+
this.lastFillAt = Date.now();
|
|
1426
1484
|
this.fire('orderUpdate', payload);
|
|
1427
1485
|
// Track open entries for trade result reporting
|
|
1428
1486
|
const tradeSide = pos.side === 'short' ? 'short' : 'long';
|
|
@@ -1710,10 +1768,15 @@ export class GatewayProvider {
|
|
|
1710
1768
|
return this.balance;
|
|
1711
1769
|
}
|
|
1712
1770
|
}
|
|
1713
|
-
|
|
1771
|
+
/** Strict fresh read — null on ANY failure (missing tool, thrown fetch,
|
|
1772
|
+
* non-array garbage). Callers whose verdict claims order-state coverage
|
|
1773
|
+
* (Kill) MUST distinguish "confirmed list" from "unknown"; collapsing a
|
|
1774
|
+
* failed read into the ≤60s cache here was how a stale-empty cache became
|
|
1775
|
+
* a green "No open orders to cancel" (audit 2026-07-26 F6). */
|
|
1776
|
+
async fetchFreshOpenOrdersOrNull() {
|
|
1714
1777
|
const tool = this.toolMap.fetch_open_orders;
|
|
1715
1778
|
if (!tool || !this.http)
|
|
1716
|
-
return
|
|
1779
|
+
return null;
|
|
1717
1780
|
try {
|
|
1718
1781
|
// Fetch ALL open orders (no symbol filter). Multi-symbol orphan orders
|
|
1719
1782
|
// submitted while the browser was disconnected must show up in the
|
|
@@ -1722,7 +1785,7 @@ export class GatewayProvider {
|
|
|
1722
1785
|
const result = await this.http.invoke(tool, {});
|
|
1723
1786
|
if (!Array.isArray(result.data)) {
|
|
1724
1787
|
logger.warn(TAG, `fetch_open_orders returned non-array: ${typeof result.data}`);
|
|
1725
|
-
return
|
|
1788
|
+
return null;
|
|
1726
1789
|
}
|
|
1727
1790
|
const mapped = result.data
|
|
1728
1791
|
.map((o) => mapCcxtOrder(o))
|
|
@@ -1734,10 +1797,15 @@ export class GatewayProvider {
|
|
|
1734
1797
|
return mapped;
|
|
1735
1798
|
}
|
|
1736
1799
|
catch (err) {
|
|
1737
|
-
logger.warn(TAG, `Fresh open orders fetch failed
|
|
1738
|
-
return
|
|
1800
|
+
logger.warn(TAG, `Fresh open orders fetch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1801
|
+
return null;
|
|
1739
1802
|
}
|
|
1740
1803
|
}
|
|
1804
|
+
/** Cached-fallback wrapper for DISPLAY surfaces (the reconcile snapshot):
|
|
1805
|
+
* stale data beats a blank panel there. Never use for emergency verdicts. */
|
|
1806
|
+
async fetchFreshOpenOrders() {
|
|
1807
|
+
return (await this.fetchFreshOpenOrdersOrNull()) ?? this.openOrders;
|
|
1808
|
+
}
|
|
1741
1809
|
async fetchAgentHealth() {
|
|
1742
1810
|
const ws = this.wsClient;
|
|
1743
1811
|
if (!ws)
|
|
@@ -1799,6 +1867,70 @@ export class GatewayProvider {
|
|
|
1799
1867
|
this.emitAgentState();
|
|
1800
1868
|
}
|
|
1801
1869
|
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Read the heartbeat cron job's health from `cron.list` (RPC — the
|
|
1872
|
+
* filesystem `jobs.json` the cadence reader uses is absent on 2026.7.x).
|
|
1873
|
+
* Extracts `state.consecutiveErrors` + last-run info from the heartbeat-like
|
|
1874
|
+
* job and caches it. Best-effort, fail-open (a version/scope that hides
|
|
1875
|
+
* cron.list simply leaves health undefined → the dashboard shows nothing new,
|
|
1876
|
+
* byte-identical to before). Re-emits agent_state when the value changes so a
|
|
1877
|
+
* newly-failing beat surfaces without waiting for the next tick.
|
|
1878
|
+
*
|
|
1879
|
+
* Why this matters: on 2026-07-24 a customer's beat failed 10× over ~2 days
|
|
1880
|
+
* (bad delivery target, then a heap crash-loop) and NOTHING surfaced it —
|
|
1881
|
+
* `consecutiveErrors` was sitting right here in the cron state the whole time.
|
|
1882
|
+
*/
|
|
1883
|
+
async refreshHeartbeatHealth() {
|
|
1884
|
+
const ws = this.wsClient;
|
|
1885
|
+
if (!ws)
|
|
1886
|
+
return;
|
|
1887
|
+
this.heartbeatHealthRefreshing = true;
|
|
1888
|
+
try {
|
|
1889
|
+
const resp = await ws.sendRpc('cron.list', { includeDisabled: true });
|
|
1890
|
+
const jobs = resp?.jobs;
|
|
1891
|
+
if (!Array.isArray(jobs))
|
|
1892
|
+
return; // unknown shape — leave last-known
|
|
1893
|
+
const hb = jobs.find((j) => isHeartbeatLikeName(j?.name));
|
|
1894
|
+
const state = hb?.state;
|
|
1895
|
+
if (!state || typeof state !== 'object')
|
|
1896
|
+
return;
|
|
1897
|
+
const s = state;
|
|
1898
|
+
const next = {
|
|
1899
|
+
consecutiveErrors: typeof s.consecutiveErrors === 'number' && s.consecutiveErrors >= 0 ? s.consecutiveErrors : 0,
|
|
1900
|
+
lastRunAtMs: typeof s.lastRunAtMs === 'number' ? s.lastRunAtMs : undefined,
|
|
1901
|
+
lastRunStatus: typeof s.lastRunStatus === 'string' ? s.lastRunStatus : undefined,
|
|
1902
|
+
};
|
|
1903
|
+
const prev = this.heartbeatHealth;
|
|
1904
|
+
const changed = !prev ||
|
|
1905
|
+
prev.consecutiveErrors !== next.consecutiveErrors ||
|
|
1906
|
+
prev.lastRunAtMs !== next.lastRunAtMs ||
|
|
1907
|
+
prev.lastRunStatus !== next.lastRunStatus;
|
|
1908
|
+
this.heartbeatHealth = next;
|
|
1909
|
+
this.heartbeatHealthReadAtMs = Date.now();
|
|
1910
|
+
if (changed) {
|
|
1911
|
+
if (next.consecutiveErrors > 0) {
|
|
1912
|
+
logger.warn(TAG, `Heartbeat health: ${next.consecutiveErrors} consecutive error(s), lastStatus=${next.lastRunStatus ?? 'unknown'}`);
|
|
1913
|
+
}
|
|
1914
|
+
this.emitAgentState();
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
catch {
|
|
1918
|
+
/* cron.list unavailable (version/scope) — fail open, keep last-known */
|
|
1919
|
+
}
|
|
1920
|
+
finally {
|
|
1921
|
+
this.heartbeatHealthRefreshing = false;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
/** Kick a lazy heartbeat-health refresh when the cache is stale and no
|
|
1925
|
+
* refresh is in flight. Fire-and-forget: the RPC completion re-emits
|
|
1926
|
+
* agent_state, so callers stay synchronous. */
|
|
1927
|
+
maybeRefreshHeartbeatHealth() {
|
|
1928
|
+
if (this.heartbeatHealthRefreshing)
|
|
1929
|
+
return;
|
|
1930
|
+
if (Date.now() - this.heartbeatHealthReadAtMs < 120_000)
|
|
1931
|
+
return;
|
|
1932
|
+
void this.refreshHeartbeatHealth();
|
|
1933
|
+
}
|
|
1802
1934
|
/** Merge newly-resolved identity fields into the cache. Returns true if anything changed. */
|
|
1803
1935
|
mergeAgentIdentity(found) {
|
|
1804
1936
|
let changed = false;
|
|
@@ -1856,15 +1988,9 @@ export class GatewayProvider {
|
|
|
1856
1988
|
].filter((p) => !!p);
|
|
1857
1989
|
for (const p of candidates) {
|
|
1858
1990
|
try {
|
|
1859
|
-
const
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
if (!name)
|
|
1863
|
-
continue;
|
|
1864
|
-
name = name.replace(/^[_*`]+|[_*`]+$/g, '').trim(); // strip stray markdown emphasis
|
|
1865
|
-
if (!name || /^\d+$/.test(name) || /\btbd\b|fill this in/i.test(name))
|
|
1866
|
-
continue;
|
|
1867
|
-
return name;
|
|
1991
|
+
const name = parseIdentityName(readFileSync(p, 'utf8'));
|
|
1992
|
+
if (name)
|
|
1993
|
+
return name;
|
|
1868
1994
|
}
|
|
1869
1995
|
catch {
|
|
1870
1996
|
/* not found / unreadable — try the next candidate */
|
|
@@ -1872,6 +1998,31 @@ export class GatewayProvider {
|
|
|
1872
1998
|
}
|
|
1873
1999
|
return undefined;
|
|
1874
2000
|
}
|
|
2001
|
+
/**
|
|
2002
|
+
* Keep the display name fresh WITHOUT waiting for a reconnect.
|
|
2003
|
+
*
|
|
2004
|
+
* Naming the agent is a first-conversation ritual, so IDENTITY.md is usually
|
|
2005
|
+
* written minutes-to-days AFTER the bridge connected. Resolving only on
|
|
2006
|
+
* (re)connect meant the header served the unfilled template for the whole
|
|
2007
|
+
* onboarding session (2026-07-26: rig showed `(pick something you like)` for
|
|
2008
|
+
* ~24h after the agent wrote `Rook`). This is a small local file read, so a
|
|
2009
|
+
* 60s TTL keeps it honest at negligible cost — unlike the model, which needs
|
|
2010
|
+
* an RPC and legitimately only changes across a gateway restart.
|
|
2011
|
+
*
|
|
2012
|
+
* Called from buildAgentState(), so a newly-resolved name rides the payload
|
|
2013
|
+
* being built right now — no extra emit needed.
|
|
2014
|
+
*/
|
|
2015
|
+
maybeRefreshAgentName() {
|
|
2016
|
+
const now = Date.now();
|
|
2017
|
+
if (now - this.agentNameReadAtMs < AGENT_NAME_TTL_MS)
|
|
2018
|
+
return;
|
|
2019
|
+
this.agentNameReadAtMs = now;
|
|
2020
|
+
const name = this.resolveAgentNameFromIdentity();
|
|
2021
|
+
// Fail-open: an unreadable/placeholder file keeps the last known good name.
|
|
2022
|
+
if (name && this.mergeAgentIdentity({ name })) {
|
|
2023
|
+
logger.info(TAG, `Agent identity updated: name=${name} (IDENTITY.md re-read)`);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
1875
2026
|
/**
|
|
1876
2027
|
* Live heartbeat cadence in seconds — the agent's TRUE beat interval, read
|
|
1877
2028
|
* from the OpenClaw cron store (`~/.openclaw/cron/jobs.json` → the enabled
|
|
@@ -1934,6 +2085,11 @@ export class GatewayProvider {
|
|
|
1934
2085
|
}
|
|
1935
2086
|
/** Build agent state from internal fields (used by both emitAgentState and getSnapshot). */
|
|
1936
2087
|
buildAgentState() {
|
|
2088
|
+
// Lazily keep the heartbeat-failure signal fresh (fire-and-forget, 120s).
|
|
2089
|
+
this.maybeRefreshHeartbeatHealth();
|
|
2090
|
+
// Lazily re-read IDENTITY.md (60s) so a name filled in after connect shows up.
|
|
2091
|
+
this.maybeRefreshAgentName();
|
|
2092
|
+
const hb = this.heartbeatHealth;
|
|
1937
2093
|
return {
|
|
1938
2094
|
mode: this.agentMode,
|
|
1939
2095
|
strategy: this.lastKnownStrategy,
|
|
@@ -1948,6 +2104,18 @@ export class GatewayProvider {
|
|
|
1948
2104
|
errorRate: this.eventParser?.getErrorRate() ?? 0,
|
|
1949
2105
|
thinkingInterval: this.resolveHeartbeatSeconds() ?? 1800,
|
|
1950
2106
|
gatewayWs: this.wsClient?.getState() ?? 'disconnected',
|
|
2107
|
+
// Conditional spread: absent until cron.list resolves the beat's state,
|
|
2108
|
+
// so the payload stays byte-identical to the pre-feature shape when the
|
|
2109
|
+
// gateway/version doesn't expose it.
|
|
2110
|
+
...(hb
|
|
2111
|
+
? {
|
|
2112
|
+
heartbeatHealth: {
|
|
2113
|
+
consecutiveErrors: hb.consecutiveErrors,
|
|
2114
|
+
...(hb.lastRunAtMs !== undefined ? { lastRunAtMs: hb.lastRunAtMs } : {}),
|
|
2115
|
+
...(hb.lastRunStatus !== undefined ? { lastRunStatus: hb.lastRunStatus } : {}),
|
|
2116
|
+
},
|
|
2117
|
+
}
|
|
2118
|
+
: {}),
|
|
1951
2119
|
},
|
|
1952
2120
|
};
|
|
1953
2121
|
}
|
|
@@ -1994,12 +2162,34 @@ export class GatewayProvider {
|
|
|
1994
2162
|
const IMPOSSIBLE_DRAWDOWN = -0.5;
|
|
1995
2163
|
const REQUIRED_RED_STREAK = 3;
|
|
1996
2164
|
const MIN_RED_PERSISTENCE_MS = 25_000;
|
|
2165
|
+
/** Ceiling on the issue #248 torn-snapshot deferral — see below. */
|
|
2166
|
+
const MAX_TORN_SNAPSHOT_DEFER_MS = 5 * 60_000;
|
|
1997
2167
|
if (metrics.drawdownZone === 'RED' && this.agentMode === 'ACTIVE') {
|
|
1998
2168
|
if (this.redZoneStreak === 0)
|
|
1999
2169
|
this.redZoneSince = Date.now();
|
|
2000
2170
|
this.redZoneStreak++;
|
|
2001
2171
|
const redForMs = Date.now() - this.redZoneSince;
|
|
2002
2172
|
const survivedPositionsRefresh = this.positionsUpdatedAt > this.redZoneSince;
|
|
2173
|
+
// ★ issue #248: `survivedPositionsRefresh` proves a refresh HAPPENED, not
|
|
2174
|
+
// that it INCORPORATED the fill that debited the wallet. When a fill has
|
|
2175
|
+
// landed since the last positions refresh the snapshot is torn BY
|
|
2176
|
+
// DEFINITION — paper deducts full notional at open, so equity is
|
|
2177
|
+
// understated by exactly that notional and any entry >2.5% of NAV reads
|
|
2178
|
+
// as RED. Measured on a live box: entries of 8.25% / 8.11% of NAV
|
|
2179
|
+
// produced -8.09% / -7.93% verdicts 4s after each fill, on an account
|
|
2180
|
+
// that was UP on the day. This condition can only ever WIDEN deferral —
|
|
2181
|
+
// it never licenses a flatten the old code would have refused.
|
|
2182
|
+
const positionsCoverLatestFill = this.lastFillAt === 0 || this.positionsUpdatedAt > this.lastFillAt;
|
|
2183
|
+
// ...but BOUND that deferral. The tear resolves within one positions poll
|
|
2184
|
+
// (~10s), so a RED verdict still standing after MAX_TORN_SNAPSHOT_DEFER_MS
|
|
2185
|
+
// is not a snapshot artifact and must be honoured. Without a ceiling, a
|
|
2186
|
+
// flapping synthetic-fill detector would keep `lastFillAt` ahead of every
|
|
2187
|
+
// refresh and disarm the auto-flatten INDEFINITELY — and that detector is
|
|
2188
|
+
// known to fabricate (it invented a duplicate fill 8s after a real one on
|
|
2189
|
+
// 2026-07-25). Disarming a safety control on the word of an unreliable
|
|
2190
|
+
// component is the worse failure direction, so the tear only ever BUYS
|
|
2191
|
+
// TIME; past the ceiling the ordinary #213 debounce governs again.
|
|
2192
|
+
const tornSnapshotBlocks = !positionsCoverLatestFill && redForMs < MAX_TORN_SNAPSHOT_DEFER_MS;
|
|
2003
2193
|
if (uptimeMs < STARTUP_GRACE_PERIOD_MS) {
|
|
2004
2194
|
logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) but within startup grace period (${Math.round(uptimeMs / 1000)}s) — skipping auto-flatten`);
|
|
2005
2195
|
}
|
|
@@ -2011,8 +2201,9 @@ export class GatewayProvider {
|
|
|
2011
2201
|
}
|
|
2012
2202
|
else if (this.redZoneStreak < REQUIRED_RED_STREAK ||
|
|
2013
2203
|
redForMs < MIN_RED_PERSISTENCE_MS ||
|
|
2014
|
-
!survivedPositionsRefresh
|
|
2015
|
-
|
|
2204
|
+
!survivedPositionsRefresh ||
|
|
2205
|
+
tornSnapshotBlocks) {
|
|
2206
|
+
logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — deferring auto-flatten until the verdict persists (streak ${this.redZoneStreak}/${REQUIRED_RED_STREAK}, ${Math.round(redForMs / 1000)}s/${MIN_RED_PERSISTENCE_MS / 1000}s, survivedPositionsRefresh=${survivedPositionsRefresh}, positionsCoverLatestFill=${positionsCoverLatestFill}, tornSnapshotBlocks=${tornSnapshotBlocks}). A fresh entry's notional reads as phantom drawdown until the position snapshot catches up (issues #213/#248); a real drawdown will persist and flatten on a later evaluation.`);
|
|
2016
2207
|
}
|
|
2017
2208
|
else {
|
|
2018
2209
|
logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — auto-flattening all positions`);
|
|
@@ -2159,7 +2350,13 @@ export class GatewayProvider {
|
|
|
2159
2350
|
const url = this.config.intelligenceUrl;
|
|
2160
2351
|
const token = this.config.connectionToken;
|
|
2161
2352
|
if (!url || !token) {
|
|
2162
|
-
|
|
2353
|
+
// NEVER debug-level. This is the only writer of intel's `trade_results`,
|
|
2354
|
+
// and those rows are what `minTradesForKelly` counts and what
|
|
2355
|
+
// `get_agent_profile` derives its tier from. Silently skipping here is
|
|
2356
|
+
// indistinguishable from "the agent has never traded" — which is exactly
|
|
2357
|
+
// how it read for months. See DEFAULT_INTELLIGENCE_URL in gateway-config.
|
|
2358
|
+
logger.warn(TAG, `Intelligence not configured (url=${url ? 'set' : 'MISSING'}, token=${token ? 'set' : 'MISSING'}) — DROPPING trade result for ${symbol}. `
|
|
2359
|
+
+ 'Kelly sizing and the agent profile are computed from these rows and will stay pinned at their no-history defaults.');
|
|
2163
2360
|
return;
|
|
2164
2361
|
}
|
|
2165
2362
|
const direction = entry.side === 'long' ? 'LONG' : 'SHORT';
|
|
@@ -2171,7 +2368,7 @@ export class GatewayProvider {
|
|
|
2171
2368
|
const durationSeconds = Math.floor((Date.now() - new Date(entry.entryTime).getTime()) / 1000);
|
|
2172
2369
|
const tradeResult = {
|
|
2173
2370
|
missionId: entry.missionId ?? `trade-${Date.now()}`,
|
|
2174
|
-
symbol:
|
|
2371
|
+
symbol: this.toIntelSymbol(symbol), // venue-aware: BTC/USDT → BTCUSDT, BTC/USDC → HL_BTC
|
|
2175
2372
|
direction,
|
|
2176
2373
|
strategy: this.lastKnownStrategy.name,
|
|
2177
2374
|
regime: entry.regime ?? this.currentRegime,
|
|
@@ -2256,9 +2453,9 @@ export class GatewayProvider {
|
|
|
2256
2453
|
}
|
|
2257
2454
|
}
|
|
2258
2455
|
// ---- Intelligence service polling ----
|
|
2259
|
-
/**
|
|
2456
|
+
/** The configured symbol as the intel DB spells it. */
|
|
2260
2457
|
get intelligenceSymbol() {
|
|
2261
|
-
return this.config.symbol
|
|
2458
|
+
return this.toIntelSymbol(this.config.symbol);
|
|
2262
2459
|
}
|
|
2263
2460
|
/** Shared intelligence poller with circuit-breaker (exponential backoff on errors). */
|
|
2264
2461
|
startIntelligencePoller(name, endpoint, intervalMs, onData, intervalRef, delayFirstMs = 0) {
|
|
@@ -2270,6 +2467,21 @@ export class GatewayProvider {
|
|
|
2270
2467
|
}
|
|
2271
2468
|
return;
|
|
2272
2469
|
}
|
|
2470
|
+
// These five read-pollers (regime 60s, signals 10s, missions 10s,
|
|
2471
|
+
// analytics 30s, decision-trace 30s ≈ 22 req/min per box) were dormant for
|
|
2472
|
+
// as long as `intelligenceUrl` had no default. Giving it one turns them on
|
|
2473
|
+
// everywhere, which is the intended behaviour — they feed the dashboard's
|
|
2474
|
+
// regime/signals/missions panels — but it IS new load on an intel API with
|
|
2475
|
+
// a known heap leak. Kill-switch so that load can be shed in one restart
|
|
2476
|
+
// WITHOUT taking the trade_results writer down with it: the writer is what
|
|
2477
|
+
// Kelly sizing and the agent tier are computed from, and it does not go
|
|
2478
|
+
// through this function.
|
|
2479
|
+
if (process.env.RC_SKILL_INTEL_POLL === 'off') {
|
|
2480
|
+
if (name === 'regime') {
|
|
2481
|
+
logger.warn(TAG, 'RC_SKILL_INTEL_POLL=off — intelligence read-polling disabled (trade-result reporting is unaffected)');
|
|
2482
|
+
}
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2273
2485
|
const symbol = this.intelligenceSymbol;
|
|
2274
2486
|
const fullUrl = `${url}/api/${endpoint}/${symbol}`;
|
|
2275
2487
|
logger.info(TAG, `Starting ${name} poller: ${fullUrl}`);
|
|
@@ -38,6 +38,14 @@ export interface ClearExchangeCredentialsOutcome {
|
|
|
38
38
|
export interface OnboardingContext {
|
|
39
39
|
http: GatewayHttpClient | null;
|
|
40
40
|
toolMap: ToolMap;
|
|
41
|
+
/** rc_* connection token, injected as `operator_token` on the four
|
|
42
|
+
* state-MUTATING operator tools (set_trading_mode /
|
|
43
|
+
* set_exchange_credentials / clear_exchange_credentials /
|
|
44
|
+
* set_bracket_requirement). The plugin refuses those calls without it
|
|
45
|
+
* (audit 2026-07-26 F12) — this is the operator-provenance proof that the
|
|
46
|
+
* request came through the dashboard path, which the agent cannot forge
|
|
47
|
+
* conversationally (chat redaction strips rc_* tokens). */
|
|
48
|
+
operatorToken?: string;
|
|
41
49
|
}
|
|
42
50
|
export type BracketMode = 'off' | 'observe' | 'enforce';
|
|
43
51
|
export type BracketRequirementFlag = 'requireStopLoss' | 'requireTakeProfit';
|
|
@@ -23,7 +23,7 @@ export async function executeSetTradingMode(ctx, mode, acknowledged) {
|
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
25
|
try {
|
|
26
|
-
const result = await ctx.http.invoke(tool, { mode, acknowledged });
|
|
26
|
+
const result = await ctx.http.invoke(tool, { mode, acknowledged, operator_token: ctx.operatorToken });
|
|
27
27
|
return result.data;
|
|
28
28
|
}
|
|
29
29
|
catch (err) {
|
|
@@ -93,7 +93,7 @@ export async function executeClearExchangeCredentials(ctx) {
|
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
try {
|
|
96
|
-
const result = await ctx.http.invoke(tool, { confirm: true });
|
|
96
|
+
const result = await ctx.http.invoke(tool, { confirm: true, operator_token: ctx.operatorToken });
|
|
97
97
|
return result.data;
|
|
98
98
|
}
|
|
99
99
|
catch (err) {
|
|
@@ -162,7 +162,7 @@ export async function executeSetBracketRequirement(ctx, flag, value) {
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
try {
|
|
165
|
-
const result = await ctx.http.invoke(tool, { flag, value });
|
|
165
|
+
const result = await ctx.http.invoke(tool, { flag, value, operator_token: ctx.operatorToken });
|
|
166
166
|
return result.data;
|
|
167
167
|
}
|
|
168
168
|
catch (err) {
|
|
@@ -194,7 +194,7 @@ export async function executeSetExchangeCredentials(ctx, apiKey, secret, testnet
|
|
|
194
194
|
};
|
|
195
195
|
}
|
|
196
196
|
try {
|
|
197
|
-
const result = await ctx.http.invoke(tool, { apiKey, secret, testnet });
|
|
197
|
+
const result = await ctx.http.invoke(tool, { apiKey, secret, testnet, operator_token: ctx.operatorToken });
|
|
198
198
|
return result.data;
|
|
199
199
|
}
|
|
200
200
|
catch (err) {
|
package/bridge/types.d.ts
CHANGED
|
@@ -54,6 +54,25 @@ export interface OrderData {
|
|
|
54
54
|
* paper / legacy paths (treated as a working order → cancellable).
|
|
55
55
|
*/
|
|
56
56
|
protective?: boolean;
|
|
57
|
+
/** Limit price. Undefined for market and trigger-only orders. */
|
|
58
|
+
price?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Trigger price for a stop / take-profit order — Binance `stopPrice`,
|
|
61
|
+
* Hyperliquid `triggerPx`.
|
|
62
|
+
*
|
|
63
|
+
* ★ Without this the dashboard cannot see a resting protective leg AT ALL:
|
|
64
|
+
* `type` is collapsed to MARKET|LIMIT here, so a STOP_MARKET is
|
|
65
|
+
* indistinguishable from a market order on the wire. That is what left an HL
|
|
66
|
+
* micro-live book — every position bracketed on the exchange — drawing an
|
|
67
|
+
* entry line and nothing else, under a false "NO STOP" alarm.
|
|
68
|
+
*/
|
|
69
|
+
stopPrice?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Which protective leg this order IS, normalised at the venue boundary so no
|
|
72
|
+
* consumer has to parse venue type strings ('STOP_MARKET' on Binance,
|
|
73
|
+
* 'Stop Market' on Hyperliquid). Undefined for working entry orders.
|
|
74
|
+
*/
|
|
75
|
+
protectiveRole?: 'stop' | 'target';
|
|
57
76
|
}
|
|
58
77
|
export interface FillData {
|
|
59
78
|
id: string;
|
|
@@ -103,6 +122,17 @@ export interface AgentStateData {
|
|
|
103
122
|
thinkingInterval: number;
|
|
104
123
|
/** Gateway WebSocket state — helps diagnose chat delivery failures */
|
|
105
124
|
gatewayWs?: string;
|
|
125
|
+
/** Heartbeat-cron health from `cron.list`. Absent on gateway versions
|
|
126
|
+
* that don't expose cron state (byte-identical to pre-feature). Lets the
|
|
127
|
+
* dashboard surface a failing/stale beat instead of a silent gap. */
|
|
128
|
+
heartbeatHealth?: {
|
|
129
|
+
/** Consecutive failed runs; >0 means the beat isn't delivering. */
|
|
130
|
+
consecutiveErrors: number;
|
|
131
|
+
/** Epoch ms of the last run start (any outcome). */
|
|
132
|
+
lastRunAtMs?: number;
|
|
133
|
+
/** Last run outcome, e.g. "ok" | "error". */
|
|
134
|
+
lastRunStatus?: string;
|
|
135
|
+
};
|
|
106
136
|
};
|
|
107
137
|
};
|
|
108
138
|
timestamp: string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the agent's display name out of an OpenClaw workspace `IDENTITY.md`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from GatewayProvider so the placeholder rules are unit-testable
|
|
5
|
+
* against the real template text — a placeholder that slips through is shown
|
|
6
|
+
* to the user AS the agent's name, which is exactly the bug this guards
|
|
7
|
+
* (2026-07-26: a dashboard header read `(pick something you like)` while the
|
|
8
|
+
* file said `Rook`). See docs/CLAUDE/agent-runtime.md §6.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Extract `- **Name:** <X>` from IDENTITY.md markdown.
|
|
12
|
+
*
|
|
13
|
+
* Returns `undefined` when the file has no name, the value is an unfilled
|
|
14
|
+
* template placeholder, or it looks like an id rather than a name — callers
|
|
15
|
+
* fail open to their own UI placeholder.
|
|
16
|
+
*
|
|
17
|
+
* The value may sit on the line BELOW the label (the template's own layout):
|
|
18
|
+
*
|
|
19
|
+
* - **Name:**
|
|
20
|
+
* Rook
|
|
21
|
+
*
|
|
22
|
+
* which the `\s*` between `**` and the capture spans.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseIdentityName(markdown: string): string | undefined;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the agent's display name out of an OpenClaw workspace `IDENTITY.md`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from GatewayProvider so the placeholder rules are unit-testable
|
|
5
|
+
* against the real template text — a placeholder that slips through is shown
|
|
6
|
+
* to the user AS the agent's name, which is exactly the bug this guards
|
|
7
|
+
* (2026-07-26: a dashboard header read `(pick something you like)` while the
|
|
8
|
+
* file said `Rook`). See docs/CLAUDE/agent-runtime.md §6.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Values the template ships (or a half-filled file leaves behind) that must
|
|
12
|
+
* never reach the UI. Matched AFTER markdown emphasis is stripped.
|
|
13
|
+
*/
|
|
14
|
+
const PLACEHOLDER_PATTERNS = [
|
|
15
|
+
/\btbd\b/i,
|
|
16
|
+
/fill this in/i,
|
|
17
|
+
// OpenClaw's own template values are parenthesised prompts, e.g.
|
|
18
|
+
// _(pick something you like)_
|
|
19
|
+
// _(AI? robot? familiar? ghost in the machine? something weirder?)_
|
|
20
|
+
// _(your signature — pick one that feels right)_
|
|
21
|
+
// Any value that is ENTIRELY a bracketed aside is a prompt, not a name.
|
|
22
|
+
/^\(.*\)$/s,
|
|
23
|
+
/^\[.*\]$/s,
|
|
24
|
+
/^<.*>$/s,
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Extract `- **Name:** <X>` from IDENTITY.md markdown.
|
|
28
|
+
*
|
|
29
|
+
* Returns `undefined` when the file has no name, the value is an unfilled
|
|
30
|
+
* template placeholder, or it looks like an id rather than a name — callers
|
|
31
|
+
* fail open to their own UI placeholder.
|
|
32
|
+
*
|
|
33
|
+
* The value may sit on the line BELOW the label (the template's own layout):
|
|
34
|
+
*
|
|
35
|
+
* - **Name:**
|
|
36
|
+
* Rook
|
|
37
|
+
*
|
|
38
|
+
* which the `\s*` between `**` and the capture spans.
|
|
39
|
+
*/
|
|
40
|
+
export function parseIdentityName(markdown) {
|
|
41
|
+
const m = markdown.match(/\*\*\s*Name\s*:?\s*\*\*\s*:?\s*(.+)/i);
|
|
42
|
+
let name = m?.[1]?.trim();
|
|
43
|
+
if (!name)
|
|
44
|
+
return undefined;
|
|
45
|
+
// Strip stray markdown emphasis (the template wraps placeholders in `_`).
|
|
46
|
+
name = name.replace(/^[_*`]+|[_*`]+$/g, '').trim();
|
|
47
|
+
if (!name)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (/^\d+$/.test(name))
|
|
50
|
+
return undefined; // an id, not a name
|
|
51
|
+
if (PLACEHOLDER_PATTERNS.some((re) => re.test(name)))
|
|
52
|
+
return undefined;
|
|
53
|
+
return name;
|
|
54
|
+
}
|