@reefclaw/openclaw-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (371) hide show
  1. package/.gitignore +8 -0
  2. package/audit/mode-transition-audit.d.ts +11 -0
  3. package/audit/mode-transition-audit.js +29 -0
  4. package/balance-utils.d.ts +36 -0
  5. package/balance-utils.js +98 -0
  6. package/bridge/bridge.d.ts +109 -0
  7. package/bridge/bridge.js +1036 -0
  8. package/bridge/config.d.ts +43 -0
  9. package/bridge/config.js +176 -0
  10. package/bridge/connector.d.ts +52 -0
  11. package/bridge/connector.js +293 -0
  12. package/bridge/event-replay-buffer.d.ts +43 -0
  13. package/bridge/event-replay-buffer.js +109 -0
  14. package/bridge/gateway/event-parser.d.ts +209 -0
  15. package/bridge/gateway/event-parser.js +793 -0
  16. package/bridge/gateway/gateway-config.d.ts +39 -0
  17. package/bridge/gateway/gateway-config.js +86 -0
  18. package/bridge/gateway/gateway-http-client.d.ts +50 -0
  19. package/bridge/gateway/gateway-http-client.js +165 -0
  20. package/bridge/gateway/gateway-ws-client.d.ts +116 -0
  21. package/bridge/gateway/gateway-ws-client.js +417 -0
  22. package/bridge/gateway/poller.d.ts +146 -0
  23. package/bridge/gateway/poller.js +505 -0
  24. package/bridge/gateway/tool-discovery.d.ts +25 -0
  25. package/bridge/gateway/tool-discovery.js +198 -0
  26. package/bridge/index.d.ts +2 -0
  27. package/bridge/index.js +253 -0
  28. package/bridge/logger.d.ts +2 -0
  29. package/bridge/logger.js +2 -0
  30. package/bridge/provider.d.ts +156 -0
  31. package/bridge/provider.js +2 -0
  32. package/bridge/providers/emergency-commands.d.ts +54 -0
  33. package/bridge/providers/emergency-commands.js +235 -0
  34. package/bridge/providers/gateway.d.ts +322 -0
  35. package/bridge/providers/gateway.js +2302 -0
  36. package/bridge/providers/mock.d.ts +37 -0
  37. package/bridge/providers/mock.js +385 -0
  38. package/bridge/providers/onboarding-commands.d.ts +83 -0
  39. package/bridge/providers/onboarding-commands.js +213 -0
  40. package/bridge/providers/risk-calculator.d.ts +96 -0
  41. package/bridge/providers/risk-calculator.js +369 -0
  42. package/bridge/setup.d.ts +32 -0
  43. package/bridge/setup.js +226 -0
  44. package/bridge/types.d.ts +584 -0
  45. package/bridge/types.js +50 -0
  46. package/bridge/utils/reconnect.d.ts +6 -0
  47. package/bridge/utils/reconnect.js +6 -0
  48. package/bridge/utils/skill-signing.d.ts +51 -0
  49. package/bridge/utils/skill-signing.js +138 -0
  50. package/bridge/utils/skill-version.d.ts +17 -0
  51. package/bridge/utils/skill-version.js +74 -0
  52. package/ccxt/binance-ban-gate.d.ts +47 -0
  53. package/ccxt/binance-ban-gate.js +409 -0
  54. package/ccxt/binance-private.d.ts +325 -0
  55. package/ccxt/binance-private.js +1415 -0
  56. package/ccxt/binance-public.d.ts +18 -0
  57. package/ccxt/binance-public.js +147 -0
  58. package/config/agent-config-client.d.ts +55 -0
  59. package/config/agent-config-client.js +145 -0
  60. package/config/agent-config-poller.d.ts +25 -0
  61. package/config/agent-config-poller.js +100 -0
  62. package/config/brackets-config.d.ts +22 -0
  63. package/config/brackets-config.js +58 -0
  64. package/config/gate-store.d.ts +18 -0
  65. package/config/gate-store.js +61 -0
  66. package/config/plugin-config-io.d.ts +181 -0
  67. package/config/plugin-config-io.js +84 -0
  68. package/config/position-review-config.d.ts +35 -0
  69. package/config/position-review-config.js +105 -0
  70. package/config/tool-gate.d.ts +53 -0
  71. package/config/tool-gate.js +125 -0
  72. package/config/user-data-stream-config.d.ts +85 -0
  73. package/config/user-data-stream-config.js +224 -0
  74. package/connector-supervisor.d.ts +36 -0
  75. package/connector-supervisor.js +149 -0
  76. package/exchange-adapter.d.ts +49 -0
  77. package/exchange-adapter.js +4 -0
  78. package/index.d.ts +30 -0
  79. package/index.js +2030 -0
  80. package/ingest/pending-entry-metadata.d.ts +52 -0
  81. package/ingest/pending-entry-metadata.js +182 -0
  82. package/ingest/position-auto-capture.d.ts +98 -0
  83. package/ingest/position-auto-capture.js +394 -0
  84. package/ingest/position-decisions-client.d.ts +318 -0
  85. package/ingest/position-decisions-client.js +296 -0
  86. package/ingest/reconcile-db-vs-exchange.d.ts +13 -0
  87. package/ingest/reconcile-db-vs-exchange.js +114 -0
  88. package/ingest/reconciler-cleanup.d.ts +37 -0
  89. package/ingest/reconciler-cleanup.js +147 -0
  90. package/ingest/rest-gap-filler.d.ts +191 -0
  91. package/ingest/rest-gap-filler.js +565 -0
  92. package/ingest/touched-symbols-store.d.ts +25 -0
  93. package/ingest/touched-symbols-store.js +96 -0
  94. package/ingest/trade-store-client.d.ts +40 -0
  95. package/ingest/trade-store-client.js +125 -0
  96. package/ingest/ws-ingest.d.ts +43 -0
  97. package/ingest/ws-ingest.js +126 -0
  98. package/learning/setup-family.d.ts +21 -0
  99. package/learning/setup-family.js +103 -0
  100. package/lifecycle/install-signal-handlers.d.ts +33 -0
  101. package/lifecycle/install-signal-handlers.js +112 -0
  102. package/lifecycle/shutdown-coordinator.d.ts +43 -0
  103. package/lifecycle/shutdown-coordinator.js +131 -0
  104. package/live/bracket-id.d.ts +18 -0
  105. package/live/bracket-id.js +81 -0
  106. package/live/bracket-ledger.d.ts +54 -0
  107. package/live/bracket-ledger.js +267 -0
  108. package/live/bracket-manager.d.ts +82 -0
  109. package/live/bracket-manager.js +478 -0
  110. package/live/bracket-params.d.ts +22 -0
  111. package/live/bracket-params.js +124 -0
  112. package/live/bracket-reconciler.d.ts +95 -0
  113. package/live/bracket-reconciler.js +573 -0
  114. package/live/bracket-types.d.ts +102 -0
  115. package/live/bracket-types.js +8 -0
  116. package/live/deposit-tracker.d.ts +62 -0
  117. package/live/deposit-tracker.js +97 -0
  118. package/live/emergency-controls.d.ts +32 -0
  119. package/live/emergency-controls.js +226 -0
  120. package/live/exchange-errors.d.ts +12 -0
  121. package/live/exchange-errors.js +130 -0
  122. package/live/exchange-info-cache.d.ts +35 -0
  123. package/live/exchange-info-cache.js +119 -0
  124. package/live/fact-subscriber.d.ts +78 -0
  125. package/live/fact-subscriber.js +182 -0
  126. package/live/intent-journal.d.ts +42 -0
  127. package/live/intent-journal.js +122 -0
  128. package/live/listen-key-manager.d.ts +70 -0
  129. package/live/listen-key-manager.js +169 -0
  130. package/live/live-adapter.d.ts +264 -0
  131. package/live/live-adapter.js +1665 -0
  132. package/live/live-balance-enricher.d.ts +32 -0
  133. package/live/live-balance-enricher.js +104 -0
  134. package/live/live-bracket-api.d.ts +13 -0
  135. package/live/live-bracket-api.js +20 -0
  136. package/live/live-state-store.d.ts +194 -0
  137. package/live/live-state-store.js +450 -0
  138. package/live/local-signal-service.d.ts +57 -0
  139. package/live/local-signal-service.js +146 -0
  140. package/live/local-strategy-evaluator.d.ts +62 -0
  141. package/live/local-strategy-evaluator.js +127 -0
  142. package/live/microstructure-assembler.d.ts +54 -0
  143. package/live/microstructure-assembler.js +148 -0
  144. package/live/order-poller.d.ts +29 -0
  145. package/live/order-poller.js +125 -0
  146. package/live/position-state-store.d.ts +83 -0
  147. package/live/position-state-store.js +237 -0
  148. package/live/proposal-decision-listener.d.ts +64 -0
  149. package/live/proposal-decision-listener.js +288 -0
  150. package/live/proposal-manager.d.ts +76 -0
  151. package/live/proposal-manager.js +140 -0
  152. package/live/rate-limiter.d.ts +47 -0
  153. package/live/rate-limiter.js +159 -0
  154. package/live/reconciler.d.ts +39 -0
  155. package/live/reconciler.js +175 -0
  156. package/live/setup-buckets.d.ts +7 -0
  157. package/live/setup-buckets.js +33 -0
  158. package/live/slippage-tracker.d.ts +45 -0
  159. package/live/slippage-tracker.js +78 -0
  160. package/live/stop-watcher.d.ts +34 -0
  161. package/live/stop-watcher.js +158 -0
  162. package/live/user-data-active-probe.d.ts +54 -0
  163. package/live/user-data-active-probe.js +180 -0
  164. package/live/user-data-stream-controller.d.ts +200 -0
  165. package/live/user-data-stream-controller.js +579 -0
  166. package/live/user-data-stream-ws.d.ts +22 -0
  167. package/live/user-data-stream-ws.js +63 -0
  168. package/live/user-data-stream.d.ts +243 -0
  169. package/live/user-data-stream.js +704 -0
  170. package/logger.d.ts +2 -0
  171. package/logger.js +2 -0
  172. package/mfe.d.ts +21 -0
  173. package/mfe.js +68 -0
  174. package/onboarding/mode-ladder.d.ts +1 -0
  175. package/onboarding/mode-ladder.js +3 -0
  176. package/onboarding/runtime.d.ts +71 -0
  177. package/onboarding/runtime.js +153 -0
  178. package/openclaw.plugin.json +94 -0
  179. package/package.json +27 -0
  180. package/paper-adapter.d.ts +24 -0
  181. package/paper-adapter.js +91 -0
  182. package/persistence/state-manager.d.ts +42 -0
  183. package/persistence/state-manager.js +164 -0
  184. package/pinned-plan.d.ts +9 -0
  185. package/pinned-plan.js +23 -0
  186. package/risk/pre-trade-check.d.ts +38 -0
  187. package/risk/pre-trade-check.js +345 -0
  188. package/risk/pre-trade-types.d.ts +60 -0
  189. package/risk/pre-trade-types.js +3 -0
  190. package/scripts/assemble.mjs +114 -0
  191. package/shadow/shadow-tracker.d.ts +36 -0
  192. package/shadow/shadow-tracker.js +151 -0
  193. package/shadow/types.d.ts +42 -0
  194. package/shadow/types.js +20 -0
  195. package/shared/indicators-extended.d.ts +52 -0
  196. package/shared/indicators-extended.js +291 -0
  197. package/shared/indicators.d.ts +15 -0
  198. package/shared/indicators.js +114 -0
  199. package/signals/conditions/registry.d.ts +16 -0
  200. package/signals/conditions/registry.js +1274 -0
  201. package/signals/conditions/types.d.ts +1 -0
  202. package/signals/conditions/types.js +4 -0
  203. package/signals/direction-rules.d.ts +3 -0
  204. package/signals/direction-rules.js +24 -0
  205. package/signals/entry-rules.d.ts +6 -0
  206. package/signals/entry-rules.js +33 -0
  207. package/signals/serialize-context.d.ts +4 -0
  208. package/signals/serialize-context.js +39 -0
  209. package/signals/stop-rules.d.ts +3 -0
  210. package/signals/stop-rules.js +48 -0
  211. package/signals/strategy-adapter.d.ts +14 -0
  212. package/signals/strategy-adapter.js +122 -0
  213. package/signals/types.d.ts +1 -0
  214. package/signals/types.js +8 -0
  215. package/simulator/exchange-simulator.d.ts +93 -0
  216. package/simulator/exchange-simulator.js +684 -0
  217. package/simulator/fill-engine.d.ts +53 -0
  218. package/simulator/fill-engine.js +276 -0
  219. package/simulator/paper-market-feed.d.ts +26 -0
  220. package/simulator/paper-market-feed.js +104 -0
  221. package/simulator/realistic-fills.d.ts +59 -0
  222. package/simulator/realistic-fills.js +175 -0
  223. package/simulator/types.d.ts +219 -0
  224. package/simulator/types.js +43 -0
  225. package/skills/reefclaw/SKILL.md +97 -0
  226. package/strategy/builtin-strategies.d.ts +2 -0
  227. package/strategy/builtin-strategies.js +109 -0
  228. package/strategy/condition-registry.d.ts +3 -0
  229. package/strategy/condition-registry.js +153 -0
  230. package/strategy/evaluator.d.ts +67 -0
  231. package/strategy/evaluator.js +93 -0
  232. package/tools/assessment-validation.d.ts +118 -0
  233. package/tools/assessment-validation.js +415 -0
  234. package/tools/attach-brackets.d.ts +34 -0
  235. package/tools/attach-brackets.js +363 -0
  236. package/tools/audit-bracket-protection.d.ts +49 -0
  237. package/tools/audit-bracket-protection.js +527 -0
  238. package/tools/cancel-all-orders.d.ts +7 -0
  239. package/tools/cancel-all-orders.js +5 -0
  240. package/tools/cancel-order.d.ts +10 -0
  241. package/tools/cancel-order.js +14 -0
  242. package/tools/check-position-health.d.ts +46 -0
  243. package/tools/check-position-health.js +194 -0
  244. package/tools/clear-exchange-credentials.d.ts +24 -0
  245. package/tools/clear-exchange-credentials.js +70 -0
  246. package/tools/close-position.d.ts +22 -0
  247. package/tools/close-position.js +449 -0
  248. package/tools/create-order.d.ts +54 -0
  249. package/tools/create-order.js +338 -0
  250. package/tools/exit-gate.d.ts +58 -0
  251. package/tools/exit-gate.js +162 -0
  252. package/tools/fetch-balance.d.ts +5 -0
  253. package/tools/fetch-balance.js +4 -0
  254. package/tools/fetch-ohlcv.d.ts +11 -0
  255. package/tools/fetch-ohlcv.js +8 -0
  256. package/tools/fetch-open-orders.d.ts +7 -0
  257. package/tools/fetch-open-orders.js +4 -0
  258. package/tools/fetch-positions.d.ts +7 -0
  259. package/tools/fetch-positions.js +4 -0
  260. package/tools/fetch-ticker.d.ts +11 -0
  261. package/tools/fetch-ticker.js +5 -0
  262. package/tools/get-agent-profile.d.ts +4 -0
  263. package/tools/get-agent-profile.js +6 -0
  264. package/tools/get-analytics.d.ts +6 -0
  265. package/tools/get-analytics.js +7 -0
  266. package/tools/get-backtest.d.ts +12 -0
  267. package/tools/get-backtest.js +91 -0
  268. package/tools/get-basis.d.ts +7 -0
  269. package/tools/get-basis.js +7 -0
  270. package/tools/get-bracket-config.d.ts +11 -0
  271. package/tools/get-bracket-config.js +24 -0
  272. package/tools/get-cascade-risk.d.ts +7 -0
  273. package/tools/get-cascade-risk.js +8 -0
  274. package/tools/get-crypto-metrics.d.ts +18 -0
  275. package/tools/get-crypto-metrics.js +45 -0
  276. package/tools/get-cvd.d.ts +6 -0
  277. package/tools/get-cvd.js +6 -0
  278. package/tools/get-divergences.d.ts +6 -0
  279. package/tools/get-divergences.js +6 -0
  280. package/tools/get-funding-context.d.ts +6 -0
  281. package/tools/get-funding-context.js +16 -0
  282. package/tools/get-liquidation-levels.d.ts +7 -0
  283. package/tools/get-liquidation-levels.js +7 -0
  284. package/tools/get-liquidation-pulse.d.ts +9 -0
  285. package/tools/get-liquidation-pulse.js +22 -0
  286. package/tools/get-market-breadth.d.ts +6 -0
  287. package/tools/get-market-breadth.js +8 -0
  288. package/tools/get-market-intel.d.ts +19 -0
  289. package/tools/get-market-intel.js +116 -0
  290. package/tools/get-market-structure.d.ts +47 -0
  291. package/tools/get-market-structure.js +198 -0
  292. package/tools/get-my-mined-patterns.d.ts +20 -0
  293. package/tools/get-my-mined-patterns.js +61 -0
  294. package/tools/get-my-proposed-learnings.d.ts +20 -0
  295. package/tools/get-my-proposed-learnings.js +55 -0
  296. package/tools/get-my-recent-reviews.d.ts +22 -0
  297. package/tools/get-my-recent-reviews.js +66 -0
  298. package/tools/get-orderbook.d.ts +21 -0
  299. package/tools/get-orderbook.js +32 -0
  300. package/tools/get-pattern-scan.d.ts +7 -0
  301. package/tools/get-pattern-scan.js +8 -0
  302. package/tools/get-regime.d.ts +6 -0
  303. package/tools/get-regime.js +7 -0
  304. package/tools/get-relevant-learnings.d.ts +21 -0
  305. package/tools/get-relevant-learnings.js +65 -0
  306. package/tools/get-resting-liquidity.d.ts +6 -0
  307. package/tools/get-resting-liquidity.js +11 -0
  308. package/tools/get-risk-scenario.d.ts +29 -0
  309. package/tools/get-risk-scenario.js +47 -0
  310. package/tools/get-risk-summary.d.ts +51 -0
  311. package/tools/get-risk-summary.js +118 -0
  312. package/tools/get-sentiment.d.ts +4 -0
  313. package/tools/get-sentiment.js +6 -0
  314. package/tools/get-session-review.d.ts +7 -0
  315. package/tools/get-session-review.js +8 -0
  316. package/tools/get-setup-detail.d.ts +7 -0
  317. package/tools/get-setup-detail.js +303 -0
  318. package/tools/get-signals.d.ts +15 -0
  319. package/tools/get-signals.js +54 -0
  320. package/tools/get-sizing.d.ts +6 -0
  321. package/tools/get-sizing.js +6 -0
  322. package/tools/get-trade-feedback.d.ts +7 -0
  323. package/tools/get-trade-feedback.js +8 -0
  324. package/tools/get-trade-flow.d.ts +7 -0
  325. package/tools/get-trade-flow.js +7 -0
  326. package/tools/get-volume-analysis.d.ts +21 -0
  327. package/tools/get-volume-analysis.js +74 -0
  328. package/tools/get-volume-profile.d.ts +7 -0
  329. package/tools/get-volume-profile.js +7 -0
  330. package/tools/helpers.d.ts +25 -0
  331. package/tools/helpers.js +38 -0
  332. package/tools/intel-api.d.ts +14 -0
  333. package/tools/intel-api.js +52 -0
  334. package/tools/intel-cache.d.ts +25 -0
  335. package/tools/intel-cache.js +133 -0
  336. package/tools/list-strategies.d.ts +7 -0
  337. package/tools/list-strategies.js +6 -0
  338. package/tools/modify-stop.d.ts +17 -0
  339. package/tools/modify-stop.js +81 -0
  340. package/tools/modify-target.d.ts +17 -0
  341. package/tools/modify-target.js +71 -0
  342. package/tools/propose-learning.d.ts +22 -0
  343. package/tools/propose-learning.js +65 -0
  344. package/tools/query-review-outcomes.d.ts +30 -0
  345. package/tools/query-review-outcomes.js +64 -0
  346. package/tools/query-trades.d.ts +21 -0
  347. package/tools/query-trades.js +37 -0
  348. package/tools/record-position-reviews.d.ts +38 -0
  349. package/tools/record-position-reviews.js +147 -0
  350. package/tools/save-strategy.d.ts +16 -0
  351. package/tools/save-strategy.js +46 -0
  352. package/tools/scan-pairs.d.ts +18 -0
  353. package/tools/scan-pairs.js +220 -0
  354. package/tools/score-setup.d.ts +31 -0
  355. package/tools/score-setup.js +268 -0
  356. package/tools/set-bracket-requirement.d.ts +18 -0
  357. package/tools/set-bracket-requirement.js +81 -0
  358. package/tools/set-exchange-credentials.d.ts +25 -0
  359. package/tools/set-exchange-credentials.js +80 -0
  360. package/tools/set-trading-mode.d.ts +26 -0
  361. package/tools/set-trading-mode.js +135 -0
  362. package/tools/test-exchange-credentials.d.ts +16 -0
  363. package/tools/test-exchange-credentials.js +100 -0
  364. package/tools/toggle-strategy.d.ts +8 -0
  365. package/tools/toggle-strategy.js +8 -0
  366. package/trading-params-cache.d.ts +26 -0
  367. package/trading-params-cache.js +52 -0
  368. package/types.d.ts +110 -0
  369. package/types.js +7 -0
  370. package/util/plugin-paths.d.ts +3 -0
  371. package/util/plugin-paths.js +15 -0
@@ -0,0 +1,793 @@
1
+ // Agent event parser for the OpenClaw gateway.
2
+ // Consumes raw agent events (routed by GatewayWsClient) and transforms them
3
+ // into ProviderEvent[] that the GatewayProvider can fire to the bridge.
4
+ import { logger } from '../logger.js';
5
+ const TAG = 'event-parser';
6
+ // ---- CCXT → ReefClaw mapping functions ----
7
+ const CCXT_STATUS_MAP = {
8
+ open: 'SUBMITTED',
9
+ closed: 'FILLED',
10
+ canceled: 'CANCELLED',
11
+ cancelled: 'CANCELLED', // CCXT has both spellings across exchanges
12
+ expired: 'EXPIRED',
13
+ rejected: 'REJECTED',
14
+ pending: 'PENDING',
15
+ };
16
+ export function mapCcxtStatus(ccxtStatus) {
17
+ const mapped = CCXT_STATUS_MAP[ccxtStatus];
18
+ if (!mapped) {
19
+ logger.warn(TAG, `Unmapped CCXT status: "${ccxtStatus}", using uppercase`);
20
+ }
21
+ return mapped ?? ccxtStatus.toUpperCase();
22
+ }
23
+ export function mapCcxtSide(ccxtSide) {
24
+ return ccxtSide.toLowerCase() === 'sell' ? 'SELL' : 'BUY';
25
+ }
26
+ export function mapCcxtType(ccxtType) {
27
+ return ccxtType.toLowerCase() === 'limit' ? 'LIMIT' : 'MARKET';
28
+ }
29
+ /**
30
+ * Map a CCXT order to a ReefClaw OrderData.
31
+ * Returns null if the order has missing critical fields.
32
+ */
33
+ /**
34
+ * True when a CCXT order can only REDUCE/CLOSE a position (a protective bracket),
35
+ * never open/add. Detects ALL forms a Binance Futures stop/TP can take:
36
+ * - `reduceOnly` (unified or raw info)
37
+ * - `closePosition` (the position-bound form ReefClaw brackets actually use —
38
+ * see plugin bracket-params.ts; a `reduceOnly`-only check would MISS these
39
+ * and the kill switch would cancel them, leaving a naked position)
40
+ * - a STOP / TAKE_PROFIT order type (protective by nature)
41
+ * The kill switch uses this to preserve protection. Errs toward `true` only on
42
+ * real protective signals; a plain LIMIT/MARKET entry has none → false.
43
+ */
44
+ export function isProtectiveOrder(ccxt) {
45
+ const o = ccxt;
46
+ if (o.reduceOnly === true || o.info?.reduceOnly === true || o.info?.reduceOnly === 'true')
47
+ return true;
48
+ if (o.info?.closePosition === true || o.info?.closePosition === 'true')
49
+ return true;
50
+ const rawType = String(o.info?.origType ?? o.info?.type ?? o.type ?? '').toUpperCase();
51
+ if (rawType.includes('STOP') || rawType.includes('TAKE_PROFIT'))
52
+ return true;
53
+ return false;
54
+ }
55
+ export function mapCcxtOrder(ccxt) {
56
+ if (!ccxt.id || typeof ccxt.id !== 'string') {
57
+ logger.warn(TAG, `CCXT order missing valid ID: ${JSON.stringify(ccxt.id)}`);
58
+ return null;
59
+ }
60
+ if (!ccxt.symbol || typeof ccxt.symbol !== 'string') {
61
+ logger.warn(TAG, `CCXT order ${ccxt.id} missing symbol`);
62
+ return null;
63
+ }
64
+ if (ccxt.amount === null || ccxt.amount === undefined) {
65
+ logger.warn(TAG, `CCXT order ${ccxt.id} missing amount`);
66
+ return null;
67
+ }
68
+ const now = new Date().toISOString();
69
+ const ts = ccxt.datetime ?? (ccxt.timestamp ? new Date(ccxt.timestamp).toISOString() : now);
70
+ const protective = isProtectiveOrder(ccxt);
71
+ return {
72
+ id: ccxt.id,
73
+ symbol: ccxt.symbol,
74
+ side: mapCcxtSide(ccxt.side),
75
+ type: mapCcxtType(ccxt.type),
76
+ status: mapCcxtStatus(ccxt.status),
77
+ quantity: ccxt.amount,
78
+ filledQuantity: ccxt.filled ?? 0,
79
+ averageFillPrice: ccxt.average ?? 0,
80
+ timeInForce: (ccxt.timeInForce ?? 'GTC').toUpperCase(),
81
+ createdAt: ts,
82
+ updatedAt: ts,
83
+ ...(protective && { protective: true }),
84
+ };
85
+ }
86
+ /**
87
+ * Build a FillData from a CCXT order (when the order has fills).
88
+ * expectedPrice is the limit price (or average if market order).
89
+ */
90
+ export function buildFillFromCcxtOrder(ccxt, expectedPrice) {
91
+ if (!ccxt.filled || ccxt.filled <= 0)
92
+ return null;
93
+ const fillPrice = ccxt.average ?? 0;
94
+ if (fillPrice <= 0) {
95
+ logger.warn(TAG, `Order ${ccxt.id} has fill but no average price, skipping fill data`);
96
+ return null;
97
+ }
98
+ const expected = expectedPrice ?? ccxt.price ?? fillPrice;
99
+ const slippage = fillPrice - expected;
100
+ const slippageBps = expected > 0 ? (slippage / expected) * 10_000 : 0;
101
+ const now = new Date().toISOString();
102
+ const ts = ccxt.datetime ?? (ccxt.timestamp ? new Date(ccxt.timestamp).toISOString() : now);
103
+ // CCXT exposes `reduceOnly` on the unified order shape for futures; Binance
104
+ // Futures also surfaces it on `info.reduceOnly`. Take the unified value
105
+ // when present, fall back to the raw Binance field, leave undefined when
106
+ // neither is set (legacy paths / non-futures venues).
107
+ const ccxtReduceOnly = ccxt.reduceOnly;
108
+ const rawInfo = ccxt.info;
109
+ const reduceOnly = typeof ccxtReduceOnly === 'boolean'
110
+ ? ccxtReduceOnly
111
+ : typeof rawInfo?.reduceOnly === 'boolean'
112
+ ? rawInfo.reduceOnly
113
+ : undefined;
114
+ return {
115
+ id: `fill-${ccxt.id}-${ccxt.filled}-${ccxt.timestamp ?? Date.now()}`,
116
+ orderId: ccxt.id,
117
+ symbol: ccxt.symbol,
118
+ side: mapCcxtSide(ccxt.side),
119
+ quantity: ccxt.filled,
120
+ price: fillPrice,
121
+ expectedPrice: expected,
122
+ slippage: +slippage.toFixed(8),
123
+ slippageBps: +slippageBps.toFixed(2),
124
+ fee: ccxt.fee?.cost ?? 0,
125
+ feeCurrency: ccxt.fee?.currency ?? 'USDT',
126
+ timestamp: ts,
127
+ ...(reduceOnly !== undefined && { reduceOnly }),
128
+ };
129
+ }
130
+ /**
131
+ * Map a CCXT order into a full OrderUpdatePayload.
132
+ * Returns null if the order has invalid critical fields.
133
+ */
134
+ export function mapCcxtOrderToUpdate(ccxt, expectedPrice) {
135
+ const order = mapCcxtOrder(ccxt);
136
+ if (!order)
137
+ return null;
138
+ const fill = buildFillFromCcxtOrder(ccxt, expectedPrice);
139
+ return {
140
+ orderId: order.id,
141
+ status: order.status,
142
+ order,
143
+ ...(fill && { fill }),
144
+ timestamp: order.updatedAt,
145
+ };
146
+ }
147
+ /**
148
+ * Map a CCXT ticker to a ReefClaw TickerData.
149
+ * Returns null if the ticker has no valid price data.
150
+ */
151
+ export function mapCcxtTicker(ccxt) {
152
+ if (!ccxt.symbol || typeof ccxt.symbol !== 'string') {
153
+ logger.warn(TAG, `CCXT ticker missing symbol`);
154
+ return null;
155
+ }
156
+ if (ccxt.last === null && ccxt.bid === null && ccxt.ask === null) {
157
+ logger.warn(TAG, `Ticker ${ccxt.symbol} has no valid prices, skipping`);
158
+ return null;
159
+ }
160
+ return {
161
+ symbol: ccxt.symbol,
162
+ lastPrice: ccxt.last ?? 0,
163
+ bidPrice: ccxt.bid ?? 0,
164
+ askPrice: ccxt.ask ?? 0,
165
+ volume24h: ccxt.baseVolume ?? 0,
166
+ change24h: ccxt.change ?? 0,
167
+ change24hPercent: ccxt.percentage ?? 0,
168
+ timestamp: ccxt.datetime ?? new Date().toISOString(),
169
+ };
170
+ }
171
+ /**
172
+ * Map a CCXT balance response to ReefClaw balance shape.
173
+ * Extracts the balance for a given currency (default: USDT).
174
+ *
175
+ * On Binance Futures, prefer `info.assets[]` over CCXT's per-asset
176
+ * `free`/`used` fields. Two reasons:
177
+ *
178
+ * 1. Under cross-margin, per-asset `used` from CCXT is structurally 0 —
179
+ * margin is shared across the account, not booked against any one asset.
180
+ * The plugin backfills `free=total` when `used==0`, which sets
181
+ * `available` to the full wallet and hides the actual locked margin.
182
+ *
183
+ * 2. Multi-asset accounts settle realized PnL into a separate `BNFCR` line —
184
+ * negative when net-loss, positive when net-gain. It's independent of the
185
+ * funded stablecoin (USDT/USDC) and must be folded into both wallet and
186
+ * available reads so the dashboard's `Available + Locked = NAV`-style
187
+ * invariant holds rather than the figures reading off the USDC line alone.
188
+ *
189
+ * `info.assets[i].availableBalance` is Binance's authoritative
190
+ * available-after-margin number; `walletBalance` is the wallet-only line
191
+ * (excludes uPnl). We sum these across {funded stablecoin, BNFCR}; the
192
+ * gateway derives `Locked = NAV − Available` so the invariant holds.
193
+ *
194
+ * Falls back to the legacy per-currency / top-level shape when `info.assets`
195
+ * is absent (paper, non-Binance exchanges, mocks).
196
+ */
197
+ export function mapCcxtBalance(ccxt, currency = 'USDT') {
198
+ const fromInfo = mapBinanceFromInfoAssets(ccxt, currency);
199
+ if (fromInfo)
200
+ return fromInfo;
201
+ // Binance USDⓈ-M Futures settles in USDT or USDC — check both and sum.
202
+ const currencies = currency === 'USDT' ? ['USDT', 'USDC'] : [currency];
203
+ // CCXT balances can be in two shapes:
204
+ // 1. { USDT: { free: 1000, used: 200, total: 1200 } }
205
+ // 2. { free: { USDT: 1000 }, used: { USDT: 200 }, total: { USDT: 1200 } }
206
+ // Try shape 1 first: per-currency objects
207
+ let totalVal = 0, availableVal = 0, lockedVal = 0;
208
+ let foundShape1 = false;
209
+ for (const cur of currencies) {
210
+ const perCurrency = ccxt[cur];
211
+ if (perCurrency && typeof perCurrency === 'object' && 'free' in perCurrency) {
212
+ availableVal += perCurrency.free ?? 0;
213
+ lockedVal += perCurrency.used ?? 0;
214
+ totalVal += perCurrency.total ?? 0;
215
+ foundShape1 = true;
216
+ }
217
+ }
218
+ if (!foundShape1) {
219
+ // Fallback: top-level free/used/total maps
220
+ const free = ccxt.free;
221
+ const used = ccxt.used;
222
+ const total = ccxt.total;
223
+ for (const cur of currencies) {
224
+ availableVal += free?.[cur] ?? 0;
225
+ lockedVal += used?.[cur] ?? 0;
226
+ totalVal += total?.[cur] ?? 0;
227
+ }
228
+ }
229
+ return { currency, available: availableVal, locked: lockedVal, total: totalVal };
230
+ }
231
+ /**
232
+ * Extract liquidation-risk fields from a CCXT position. Reads the CCXT
233
+ * unified shape first (`position.liquidationPrice`, `position.leverage`,
234
+ * `position.maintenanceMargin`, `position.marginRatio`) and falls back to
235
+ * Binance's raw `info.*` shape — `info.liquidationPrice`, `info.leverage`,
236
+ * `info.maintMargin`, `info.marginRatio` — which arrives as stringy decimals
237
+ * on the futures REST endpoint. Returns an object of optional numbers so
238
+ * callers can spread it directly into the outgoing position payload.
239
+ *
240
+ * Verified against:
241
+ * - CCXT unified position fields (ccxt docs → "position structure")
242
+ * - Binance Futures `/fapi/v2/positionRisk` response
243
+ * (developers.binance.com → derivatives → positionRisk)
244
+ */
245
+ export function extractLiquidationFields(pos) {
246
+ const info = (pos.info ?? {});
247
+ const num = (v) => {
248
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
249
+ return v;
250
+ if (typeof v === 'string' && v.length > 0) {
251
+ const parsed = parseFloat(v);
252
+ if (Number.isFinite(parsed) && parsed > 0)
253
+ return parsed;
254
+ }
255
+ return undefined;
256
+ };
257
+ const liquidationPrice = num(pos.liquidationPrice) ?? num(info.liquidationPrice);
258
+ const leverage = num(pos.leverage) ?? num(info.leverage);
259
+ const maintenanceMargin = num(pos.maintenanceMargin) ?? num(info.maintMargin) ?? num(info.maintenanceMargin);
260
+ const marginRatio = num(pos.marginRatio) ?? num(info.marginRatio);
261
+ const out = {};
262
+ if (liquidationPrice !== undefined)
263
+ out.liquidationPrice = liquidationPrice;
264
+ if (leverage !== undefined)
265
+ out.leverage = leverage;
266
+ if (maintenanceMargin !== undefined)
267
+ out.maintenanceMargin = maintenanceMargin;
268
+ if (marginRatio !== undefined)
269
+ out.marginRatio = marginRatio;
270
+ return out;
271
+ }
272
+ /**
273
+ * Narrow plugin-side `pos.bracket` at the skill/snapshot boundary.
274
+ * The plugin attaches it via LiveAdapter.decoratePositionsWithMetadata when
275
+ * BracketLedger has an active/partial/attaching row for the symbol. The
276
+ * CcxtPosition index signature is `unknown`, so we re-validate shape here
277
+ * before forwarding into ReconciliationSnapshot.
278
+ *
279
+ * Returns undefined when the field is absent (paper mode, no bracket attached,
280
+ * terminal-state bracket) or malformed. The webapp hides bracket lines when
281
+ * the field is undefined.
282
+ */
283
+ export function extractBracketField(pos) {
284
+ const raw = pos.bracket;
285
+ if (!raw || typeof raw !== 'object')
286
+ return undefined;
287
+ const r = raw;
288
+ if (typeof r.state !== 'string' || r.state.length === 0)
289
+ return undefined;
290
+ const num = (v) => {
291
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
292
+ return v;
293
+ return undefined;
294
+ };
295
+ const slPrice = num(r.slPrice);
296
+ const tpPrice = num(r.tpPrice);
297
+ // A bracket row with neither leg is useless to the UI — treat as no bracket.
298
+ if (slPrice === undefined && tpPrice === undefined)
299
+ return undefined;
300
+ const out = { state: r.state };
301
+ if (slPrice !== undefined)
302
+ out.slPrice = slPrice;
303
+ if (tpPrice !== undefined)
304
+ out.tpPrice = tpPrice;
305
+ return out;
306
+ }
307
+ /**
308
+ * Build the wallet/available/locked tuple from Binance Futures `info.assets[]`
309
+ * when present. Returns null when the field is absent or unusable so the
310
+ * caller falls back to legacy per-currency reads.
311
+ */
312
+ function mapBinanceFromInfoAssets(ccxt, currency) {
313
+ const info = ccxt.info;
314
+ // Prefer Binance's own account-level aggregates when present. From
315
+ // `/fapi/v3/account` (passed through verbatim at `info`): in Multi-Assets
316
+ // Mode `totalWalletBalance` is the USD-denominated wallet across all margin
317
+ // assets, each non-USD collateral valued at Binance's live index — the
318
+ // exact number the app's Futures "Wallet Balance"/"Available" show
319
+ // (verified vs developers.binance.com → Account Information V3,
320
+ // 2026-05-16). The per-asset reconstruction below sums USDC at FACE 1.0,
321
+ // but Binance values it at the live USDC/USD index (~0.9998), leaving the
322
+ // dashboard ~$0.2 permanently high ("figures slightly off always"). Keep
323
+ // `locked = total - available` — identical relationship to the per-asset
324
+ // branch, so the Available/Locked invariant is unchanged.
325
+ if (info && info.totalWalletBalance != null && info.availableBalance != null) {
326
+ const total = parseFloat(String(info.totalWalletBalance));
327
+ const available = parseFloat(String(info.availableBalance));
328
+ if (Number.isFinite(total) && Number.isFinite(available)) {
329
+ return { currency, total, available, locked: Math.max(0, total - available) };
330
+ }
331
+ }
332
+ const assets = info?.assets;
333
+ if (!Array.isArray(assets) || assets.length === 0)
334
+ return null;
335
+ let usdtWallet = 0, usdtAvail = 0;
336
+ let usdcWallet = 0, usdcAvail = 0;
337
+ let bnfcrWallet = 0, bnfcrAvail = 0;
338
+ let sawBnfcrAvail = false;
339
+ let sawAny = false;
340
+ const pickFloat = (v) => {
341
+ const n = parseFloat(String(v ?? 0));
342
+ return Number.isFinite(n) ? n : 0;
343
+ };
344
+ for (const a of assets) {
345
+ if (typeof a?.asset !== 'string')
346
+ continue;
347
+ if (a.asset === 'USDT') {
348
+ usdtWallet = pickFloat(a.walletBalance);
349
+ usdtAvail = pickFloat(a.availableBalance);
350
+ sawAny = true;
351
+ }
352
+ else if (a.asset === 'USDC') {
353
+ usdcWallet = pickFloat(a.walletBalance);
354
+ usdcAvail = pickFloat(a.availableBalance);
355
+ sawAny = true;
356
+ }
357
+ else if (a.asset === 'BNFCR') {
358
+ bnfcrWallet = pickFloat(a.walletBalance);
359
+ bnfcrAvail = pickFloat(a.availableBalance);
360
+ sawBnfcrAvail = a.availableBalance !== undefined && Number.isFinite(parseFloat(String(a.availableBalance)));
361
+ sawAny = true;
362
+ }
363
+ }
364
+ if (!sawAny)
365
+ return null;
366
+ // Funded stablecoin = whichever line has the larger wallet balance.
367
+ // Avoids summing USDT + USDC (cross-margin can mirror a position's notional
368
+ // into the non-funded line as a phantom).
369
+ const useUsdt = usdtWallet >= usdcWallet;
370
+ const wallet = (useUsdt ? usdtWallet : usdcWallet) + bnfcrWallet;
371
+ const bnfcrAvailableContribution = sawBnfcrAvail && bnfcrAvail !== 0 ? bnfcrAvail : bnfcrWallet;
372
+ const available = (useUsdt ? usdtAvail : usdcAvail) + bnfcrAvailableContribution;
373
+ const locked = Math.max(0, wallet - available);
374
+ return { currency, total: wallet, available, locked };
375
+ }
376
+ // ---- Tool names that produce order-related results ----
377
+ const ORDER_TOOL_NAMES = new Set([
378
+ 'create_order', 'ccxt_create_order',
379
+ 'cancel_order', 'ccxt_cancel_order',
380
+ 'cancel_all_orders', 'ccxt_cancel_all_orders',
381
+ 'close_position', 'ccxt_close_position', 'close_all_positions',
382
+ 'edit_order', 'ccxt_edit_order',
383
+ ]);
384
+ const TICKER_TOOL_NAMES = new Set([
385
+ 'fetch_ticker', 'ccxt_fetch_ticker', 'binance_fetch_ticker', 'get_ticker',
386
+ ]);
387
+ const BALANCE_TOOL_NAMES = new Set([
388
+ 'fetch_balance', 'ccxt_fetch_balance', 'binance_fetch_balance', 'get_balance',
389
+ ]);
390
+ /**
391
+ * Check if a tool name matches a known set, handling namespaced names like
392
+ * "reefclaw-paper-trading:create_order" by stripping the prefix.
393
+ */
394
+ function matchesToolSet(name, knownNames) {
395
+ if (knownNames.has(name))
396
+ return true;
397
+ const colonIdx = name.lastIndexOf(':');
398
+ return colonIdx >= 0 ? knownNames.has(name.substring(colonIdx + 1)) : false;
399
+ }
400
+ // ---- Error rate tracking ----
401
+ const ERROR_WINDOW_MS = 3_600_000; // 1 hour rolling window
402
+ // ---- Assistant buffer expiry ----
403
+ const BUFFER_EXPIRY_MS = 300_000; // 5 minutes — prune abandoned runs
404
+ /** Inactivity timeout — if no new chunks arrive for this long, auto-flush the buffer.
405
+ * This handles the case where OpenClaw's lifecycle 'end' event never arrives
406
+ * (e.g., the run ends without a lifecycle event).
407
+ *
408
+ * NOTE: Set high because agents routinely interleave tool calls with text AND
409
+ * models can pause for extended periods during complex reasoning (30-60s+ between
410
+ * text chunks is normal for long responses). A premature flush splits the message,
411
+ * potentially causing content loss when the webapp merges or replaces partial content.
412
+ * The lifecycle 'end' event is the normal flush trigger — this timer is a LAST RESORT
413
+ * safety net for when 'end' never fires, and should almost never trigger during
414
+ * normal operation. */
415
+ const BUFFER_INACTIVITY_MS = 120_000; // 120 seconds — matches webapp streaming timeout
416
+ // ---- EventParser class ----
417
+ export class EventParser {
418
+ /** Buffered assistant text per runId, with timestamps for expiry */
419
+ assistantBuffers = new Map();
420
+ /** Last decision timestamp (set on lifecycle 'start') */
421
+ lastDecisionTs = null;
422
+ /** Error timestamps within the rolling window */
423
+ errorTimestamps = [];
424
+ /** Symbol for balance currency extraction */
425
+ quoteCurrency;
426
+ /** Callback to emit events from inactivity timer (outside parseAgentEvent flow) */
427
+ onAsyncEvents;
428
+ constructor(quoteCurrency = 'USDT', onAsyncEvents) {
429
+ this.quoteCurrency = quoteCurrency;
430
+ this.onAsyncEvents = onAsyncEvents ?? null;
431
+ }
432
+ /**
433
+ * Parse a raw agent event into zero or more ProviderEvents.
434
+ * GatewayProvider calls this on every 'agent' event from the WS client.
435
+ */
436
+ parseAgentEvent(payload) {
437
+ const { stream, runId, data } = payload;
438
+ switch (stream) {
439
+ case 'lifecycle':
440
+ return this.handleLifecycle(runId, data);
441
+ case 'assistant':
442
+ return this.handleAssistant(runId, data);
443
+ case 'tool':
444
+ return this.handleTool(data);
445
+ default:
446
+ logger.debug(TAG, `Unknown stream type: ${stream}`);
447
+ return [];
448
+ }
449
+ }
450
+ /** Get the last decision timestamp */
451
+ getLastDecision() {
452
+ return this.lastDecisionTs;
453
+ }
454
+ /** Get the rolling error rate (errors per hour) */
455
+ getErrorRate() {
456
+ this.pruneErrorTimestamps();
457
+ return this.errorTimestamps.length;
458
+ }
459
+ // ---- Stream handlers ----
460
+ handleLifecycle(runId, data) {
461
+ const phase = data?.phase;
462
+ if (!phase) {
463
+ logger.debug(TAG, `Lifecycle event missing phase for run ${runId}`);
464
+ return [];
465
+ }
466
+ switch (phase) {
467
+ case 'start':
468
+ this.lastDecisionTs = new Date().toISOString();
469
+ logger.debug(TAG, `Agent turn started: ${runId}`);
470
+ return [];
471
+ case 'end': {
472
+ logger.debug(TAG, `Agent turn ended: ${runId}`);
473
+ return this.flushAssistantBuffer(runId);
474
+ }
475
+ case 'error': {
476
+ const errMsg = data.error ?? 'Unknown agent error';
477
+ logger.warn(TAG, `Agent error (run ${runId}): ${errMsg}`);
478
+ this.errorTimestamps.push(Date.now());
479
+ // Still flush the buffer — partial responses are better than nothing
480
+ return this.flushAssistantBuffer(runId);
481
+ }
482
+ default:
483
+ logger.debug(TAG, `Unknown lifecycle phase: ${phase}`);
484
+ return [];
485
+ }
486
+ }
487
+ handleAssistant(runId, data) {
488
+ const chunk = data?.delta ?? data?.text ?? '';
489
+ if (!chunk)
490
+ return [];
491
+ // Prune expired buffers on each update to prevent unbounded growth
492
+ this.pruneExpiredBuffers();
493
+ const existing = this.assistantBuffers.get(runId);
494
+ const isFirst = !existing;
495
+ // Clear any existing inactivity timer (we got a new chunk, so reset the clock)
496
+ if (existing?.inactivityTimer) {
497
+ clearTimeout(existing.inactivityTimer);
498
+ }
499
+ const newText = (existing?.text ?? '') + chunk;
500
+ // Set up inactivity timer — if no more chunks arrive within BUFFER_INACTIVITY_MS,
501
+ // auto-flush the buffer and emit chatMessageEnd. This handles cases where the
502
+ // OpenClaw lifecycle 'end' event never fires.
503
+ // If a tool call is active, skip the flush — the tool may take a while and
504
+ // more assistant text will likely follow when it completes.
505
+ const inactivityTimer = setTimeout(() => {
506
+ const entry = this.assistantBuffers.get(runId);
507
+ if (entry) {
508
+ if (entry.toolActive) {
509
+ logger.debug(TAG, `Inactivity timeout for run ${runId} — suppressed (tool in-flight)`);
510
+ // Re-arm the timer: check again after another interval
511
+ entry.inactivityTimer = setTimeout(() => {
512
+ const e2 = this.assistantBuffers.get(runId);
513
+ if (e2 && !e2.toolActive) {
514
+ logger.info(TAG, `Inactivity timeout (re-armed) for run ${runId} — flushing (${e2.text.length} chars)`);
515
+ const events = this.flushAssistantBuffer(runId);
516
+ if (events.length > 0 && this.onAsyncEvents) {
517
+ this.onAsyncEvents(events);
518
+ }
519
+ }
520
+ }, BUFFER_INACTIVITY_MS);
521
+ return;
522
+ }
523
+ logger.info(TAG, `Inactivity timeout for run ${runId} — auto-flushing assistant buffer (${entry.text.length} chars)`);
524
+ const events = this.flushAssistantBuffer(runId);
525
+ if (events.length > 0 && this.onAsyncEvents) {
526
+ this.onAsyncEvents(events);
527
+ }
528
+ }
529
+ }, BUFFER_INACTIVITY_MS);
530
+ this.assistantBuffers.set(runId, { text: newText, lastUpdate: Date.now(), inactivityTimer, toolActive: existing?.toolActive ?? false });
531
+ logger.debug(TAG, `Buffered ${chunk.length} chars for run ${runId} (total: ${newText.length})`);
532
+ const events = [];
533
+ const msgId = `agent-msg-${runId}`;
534
+ // Emit start event on first chunk so UI can create the placeholder message
535
+ if (isFirst) {
536
+ events.push({
537
+ event: 'chatMessageStart',
538
+ data: { id: msgId, runId, timestamp: new Date().toISOString() },
539
+ });
540
+ }
541
+ // Emit delta chunk for streaming display
542
+ events.push({
543
+ event: 'chatMessageChunk',
544
+ data: { id: msgId, delta: chunk },
545
+ });
546
+ return events;
547
+ }
548
+ handleTool(data) {
549
+ if (!data)
550
+ return [];
551
+ const toolName = data.tool ?? data.name ?? '';
552
+ // Track tool start — mark all active buffers as having a tool in-flight.
553
+ // This prevents the inactivity timer from prematurely flushing the assistant
554
+ // buffer while a tool call is executing (which can take 10-20s).
555
+ if (data.phase === 'start') {
556
+ const events = [];
557
+ for (const [runId, entry] of this.assistantBuffers) {
558
+ if (!entry.toolActive) {
559
+ entry.toolActive = true;
560
+ logger.debug(TAG, `Tool started (${toolName}) — pausing inactivity timer for run ${runId}`);
561
+ }
562
+ // Emit keep-alive so the webapp resets its streaming timeout.
563
+ // Without this, long tool chains (60-120s+) cause the webapp's
564
+ // 120s watchdog to fire and truncate the response mid-stream.
565
+ const msgId = `agent-msg-${runId}`;
566
+ events.push({
567
+ event: 'chatStreamingKeepAlive',
568
+ data: { id: msgId, toolName },
569
+ });
570
+ }
571
+ return events;
572
+ }
573
+ // Track tool end — unmark tool-active so the inactivity timer can resume.
574
+ // Reset the inactivity timer for each buffer so it starts fresh after the tool completes.
575
+ if (data.phase === 'end') {
576
+ this.resumeBuffersAfterTool();
577
+ }
578
+ // Only care about tool end events (with results) for data parsing
579
+ if (data.phase !== 'end')
580
+ return [];
581
+ if (!toolName)
582
+ return [];
583
+ const result = data.result;
584
+ if (result === undefined || result === null)
585
+ return [];
586
+ logger.debug(TAG, `Tool end: name="${toolName}", resultType=${typeof result}, keys=${typeof result === 'object' && result !== null ? Object.keys(result).join(',') : 'N/A'}`);
587
+ // Parse the result — it may be a string (double-wrapped JSON) or an object
588
+ let parsed = result;
589
+ if (typeof result === 'string') {
590
+ try {
591
+ parsed = JSON.parse(result);
592
+ }
593
+ catch {
594
+ logger.debug(TAG, `Tool ${toolName} result is non-JSON string, skipping`);
595
+ return [];
596
+ }
597
+ }
598
+ // Also unwrap the nested envelope if present (result.content[0].text)
599
+ parsed = unwrapToolEnvelope(parsed);
600
+ // Route based on tool name (supports namespaced names like "plugin:create_order")
601
+ if (matchesToolSet(toolName, ORDER_TOOL_NAMES)) {
602
+ return this.parseOrderResult(toolName, parsed);
603
+ }
604
+ if (matchesToolSet(toolName, TICKER_TOOL_NAMES)) {
605
+ return this.parseTickerResult(parsed);
606
+ }
607
+ if (matchesToolSet(toolName, BALANCE_TOOL_NAMES)) {
608
+ return this.parseBalanceResult(parsed);
609
+ }
610
+ // Unrecognized tool — ignore
611
+ logger.debug(TAG, `Ignoring tool result: ${toolName}`);
612
+ return [];
613
+ }
614
+ // ---- Tool result parsers ----
615
+ parseOrderResult(toolName, result) {
616
+ if (!result || typeof result !== 'object')
617
+ return [];
618
+ // cancel_all_orders may return an array of cancelled orders
619
+ if (Array.isArray(result)) {
620
+ const events = [];
621
+ for (const item of result) {
622
+ if (item && typeof item === 'object' && 'id' in item) {
623
+ const parsed = this.parseSingleOrder(item);
624
+ if (parsed.length > 0)
625
+ events.push(...parsed);
626
+ }
627
+ }
628
+ return events;
629
+ }
630
+ // Single order result
631
+ if ('id' in result) {
632
+ return this.parseSingleOrder(result);
633
+ }
634
+ // Defense in depth: some envelopes carry the order in a `details` field
635
+ if ('details' in result) {
636
+ const details = result.details;
637
+ if (details && typeof details === 'object' && 'id' in details) {
638
+ return this.parseSingleOrder(details);
639
+ }
640
+ }
641
+ logger.debug(TAG, `Unexpected order result shape from ${toolName}`);
642
+ return [];
643
+ }
644
+ parseSingleOrder(ccxtOrder) {
645
+ try {
646
+ const update = mapCcxtOrderToUpdate(ccxtOrder);
647
+ if (!update)
648
+ return []; // Invalid order, already logged by mapper
649
+ logger.debug(TAG, `Order update: ${update.orderId} → ${update.status}`);
650
+ return [{ event: 'orderUpdate', data: update }];
651
+ }
652
+ catch (err) {
653
+ logger.warn(TAG, `Failed to map CCXT order: ${err instanceof Error ? err.message : String(err)}`);
654
+ return [];
655
+ }
656
+ }
657
+ parseTickerResult(result) {
658
+ if (!result || typeof result !== 'object')
659
+ return [];
660
+ try {
661
+ const ticker = mapCcxtTicker(result);
662
+ if (!ticker)
663
+ return []; // Invalid ticker, already logged by mapper
664
+ return [{ event: 'ticker', data: ticker }];
665
+ }
666
+ catch (err) {
667
+ logger.warn(TAG, `Failed to map CCXT ticker: ${err instanceof Error ? err.message : String(err)}`);
668
+ return [];
669
+ }
670
+ }
671
+ parseBalanceResult(_result) {
672
+ // Balance results are handled by the poller (Phase 7f), not the event parser.
673
+ // Tool invocation results for balance pass through here but we don't emit
674
+ // a provider event — the poller computes risk from balance + positions together.
675
+ logger.debug(TAG, 'Balance tool result received (handled by poller)');
676
+ return [];
677
+ }
678
+ // ---- Assistant buffer management ----
679
+ /**
680
+ * Called when a tool ends — resume inactivity timers on all active buffers.
681
+ * If more assistant text follows the tool call, the timer will be cleared
682
+ * again by handleAssistant. If not, the buffer flushes after BUFFER_INACTIVITY_MS.
683
+ */
684
+ resumeBuffersAfterTool() {
685
+ for (const [runId, entry] of this.assistantBuffers) {
686
+ if (!entry.toolActive)
687
+ continue;
688
+ entry.toolActive = false;
689
+ // Reset the inactivity timer to start fresh after tool completion
690
+ if (entry.inactivityTimer) {
691
+ clearTimeout(entry.inactivityTimer);
692
+ }
693
+ entry.inactivityTimer = setTimeout(() => {
694
+ const current = this.assistantBuffers.get(runId);
695
+ if (current && !current.toolActive) {
696
+ logger.info(TAG, `Post-tool inactivity timeout for run ${runId} — flushing (${current.text.length} chars)`);
697
+ const events = this.flushAssistantBuffer(runId);
698
+ if (events.length > 0 && this.onAsyncEvents) {
699
+ this.onAsyncEvents(events);
700
+ }
701
+ }
702
+ }, BUFFER_INACTIVITY_MS);
703
+ logger.debug(TAG, `Tool ended — resumed inactivity timer for run ${runId}`);
704
+ }
705
+ }
706
+ flushAssistantBuffer(runId) {
707
+ const entry = this.assistantBuffers.get(runId);
708
+ if (entry?.inactivityTimer) {
709
+ clearTimeout(entry.inactivityTimer);
710
+ }
711
+ this.assistantBuffers.delete(runId);
712
+ if (!entry || entry.text.trim().length === 0)
713
+ return [];
714
+ const msgId = `agent-msg-${runId}`;
715
+ logger.debug(TAG, `Flushing assistant buffer for run ${runId} (${entry.text.length} chars)`);
716
+ // Emit chatMessageEnd so the UI finalizes the streaming message.
717
+ // Also emit legacy chatMessage for backward compatibility (DB persistence, etc.).
718
+ const message = {
719
+ id: msgId,
720
+ role: 'agent',
721
+ content: entry.text,
722
+ timestamp: new Date().toISOString(),
723
+ };
724
+ return [
725
+ { event: 'chatMessageEnd', data: { id: msgId, content: entry.text } },
726
+ { event: 'chatMessage', data: { message } },
727
+ ];
728
+ }
729
+ /** Prune buffers older than BUFFER_EXPIRY_MS to prevent memory leaks from abandoned runs */
730
+ pruneExpiredBuffers() {
731
+ const cutoff = Date.now() - BUFFER_EXPIRY_MS;
732
+ for (const [runId, entry] of this.assistantBuffers) {
733
+ if (entry.lastUpdate < cutoff) {
734
+ logger.warn(TAG, `Pruning expired assistant buffer for run ${runId} (${entry.text.length} chars)`);
735
+ if (entry.inactivityTimer)
736
+ clearTimeout(entry.inactivityTimer);
737
+ this.assistantBuffers.delete(runId);
738
+ }
739
+ }
740
+ }
741
+ // ---- Error rate tracking ----
742
+ /** In-place prune of error timestamps outside the rolling window */
743
+ pruneErrorTimestamps() {
744
+ const cutoff = Date.now() - ERROR_WINDOW_MS;
745
+ // Timestamps are appended chronologically, so find first valid index
746
+ let firstValid = 0;
747
+ while (firstValid < this.errorTimestamps.length && this.errorTimestamps[firstValid] <= cutoff) {
748
+ firstValid++;
749
+ }
750
+ if (firstValid > 0) {
751
+ this.errorTimestamps.splice(0, firstValid);
752
+ }
753
+ }
754
+ }
755
+ // ---- Helpers ----
756
+ /**
757
+ * Unwrap the OpenClaw tool envelope if present.
758
+ * Tool results may be double-wrapped: { result: { content: [{ text: "..." }] } }
759
+ */
760
+ function unwrapToolEnvelope(data) {
761
+ if (!data || typeof data !== 'object')
762
+ return data;
763
+ const obj = data;
764
+ // Check for envelope shape: { result: { content: [{ type: "text", text: "..." }] } }
765
+ if (obj.result && typeof obj.result === 'object') {
766
+ const result = obj.result;
767
+ if (Array.isArray(result.content) && result.content.length > 0) {
768
+ const first = result.content[0];
769
+ if (typeof first.text === 'string') {
770
+ try {
771
+ return JSON.parse(first.text);
772
+ }
773
+ catch {
774
+ return first.text;
775
+ }
776
+ }
777
+ }
778
+ }
779
+ // Single-level envelope: { content: [{ type: "text", text: "..." }], details?: ... }
780
+ // (WS agent event stream — ToolResult from plugin)
781
+ if (Array.isArray(obj.content) && obj.content.length > 0) {
782
+ const first = obj.content[0];
783
+ if (typeof first.text === 'string') {
784
+ try {
785
+ return JSON.parse(first.text);
786
+ }
787
+ catch {
788
+ return first.text;
789
+ }
790
+ }
791
+ }
792
+ return data;
793
+ }