@reefclaw/openclaw-plugin 0.1.23 → 0.1.24

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 (57) hide show
  1. package/bridge/bridge.js +72 -5
  2. package/bridge/connector.js +14 -2
  3. package/bridge/gateway/heartbeat-cron.js +30 -7
  4. package/bridge/gateway/poller.d.ts +5 -0
  5. package/bridge/gateway/poller.js +9 -0
  6. package/bridge/provider.d.ts +15 -0
  7. package/bridge/providers/connector-update.d.ts +89 -0
  8. package/bridge/providers/connector-update.js +212 -0
  9. package/bridge/providers/emergency-commands.d.ts +36 -0
  10. package/bridge/providers/emergency-commands.js +91 -0
  11. package/bridge/providers/gateway.d.ts +26 -1
  12. package/bridge/providers/gateway.js +159 -8
  13. package/bridge/providers/mock.js +1 -0
  14. package/bridge/types.d.ts +1 -1
  15. package/bridge/types.js +5 -0
  16. package/ccxt/binance-private.js +2 -1
  17. package/ccxt/binance-public.js +6 -1
  18. package/config/agent-config-client.d.ts +5 -2
  19. package/config/agent-config-client.js +13 -0
  20. package/config/agent-config-poller.js +5 -1
  21. package/config/gate-store.d.ts +9 -0
  22. package/config/gate-store.js +17 -2
  23. package/config/plugin-config-io.js +24 -2
  24. package/http/keepalive-fetch.d.ts +5 -0
  25. package/http/keepalive-fetch.js +50 -0
  26. package/index.js +48 -6
  27. package/ingest/position-decisions-client.d.ts +6 -0
  28. package/ingest/position-decisions-client.js +27 -9
  29. package/live/approval-lifecycle.d.ts +10 -0
  30. package/live/approval-lifecycle.js +16 -2
  31. package/live/microstructure-assembler.js +11 -2
  32. package/live/proposal-decision-listener.d.ts +21 -0
  33. package/live/proposal-decision-listener.js +39 -0
  34. package/live/proposal-manager.d.ts +12 -0
  35. package/live/proposal-manager.js +47 -0
  36. package/live/stop-watcher.d.ts +16 -1
  37. package/live/stop-watcher.js +48 -8
  38. package/openclaw.plugin.json +1 -1
  39. package/package.json +38 -38
  40. package/persistence/state-manager.d.ts +7 -0
  41. package/persistence/state-manager.js +28 -1
  42. package/simulator/exchange-simulator.d.ts +22 -0
  43. package/simulator/exchange-simulator.js +74 -32
  44. package/tools/audit-bracket-protection.js +11 -7
  45. package/tools/create-order.js +49 -7
  46. package/tools/get-funding-context.js +6 -1
  47. package/tools/get-liquidation-levels.js +5 -1
  48. package/tools/get-liquidation-pulse.js +7 -1
  49. package/tools/get-market-intel.js +2 -1
  50. package/tools/get-relevant-learnings.js +20 -1
  51. package/tools/get-resting-liquidity.js +6 -1
  52. package/tools/get-wave9-status.js +17 -0
  53. package/tools/intel-api.d.ts +9 -0
  54. package/tools/intel-api.js +32 -1
  55. package/tools/record-position-reviews.js +2 -2
  56. package/tools/scan-pairs.js +20 -11
  57. package/types.d.ts +7 -0
@@ -12,6 +12,7 @@
12
12
  // unchanged. This bounds the threat — it can't make the agent place a naked
13
13
  // order (the create_order gate still applies) — and makes injected directives
14
14
  // far less likely to be followed.
15
+ import { keepAliveFetch } from '../http/keepalive-fetch.js';
15
16
  /** Categories whose payloads contain attacker-influenceable free text. */
16
17
  const UNTRUSTED_TEXT_CATEGORIES = new Set(['news', 'social', 'calendar']);
17
18
  const MAX_TEXT_LEN = 2000;
@@ -88,7 +89,7 @@ export async function getMarketIntelTool(args, deps) {
88
89
  url.searchParams.set('symbols', symbols.join(','));
89
90
  }
90
91
  try {
91
- const res = await fetch(url.toString(), {
92
+ const res = await keepAliveFetch(url.toString(), {
92
93
  headers: { Authorization: `Bearer ${connectionToken}` },
93
94
  signal: AbortSignal.timeout(10_000),
94
95
  });
@@ -15,6 +15,13 @@
15
15
  import { logger, formatError } from '../logger.js';
16
16
  const TAG = 'get-relevant-learnings';
17
17
  const VALID_APPLIES_AT = new Set(['entry', 'heartbeat', 'close']);
18
+ // Curated learnings change on operator-curation timescales, but the agent is
19
+ // instructed to read them at several points per heartbeat — and a looping
20
+ // model can spam identical calls (observed live 2026-08-04: ~230 identical
21
+ // calls in one beat drove a 272k-token context overflow). A short TTL keyed
22
+ // on the exact query bounds both.
23
+ const CACHE_TTL_MS = 30_000;
24
+ const cache = new Map();
18
25
  export async function getRelevantLearningsTool(args, deps) {
19
26
  if (!deps.decisionsClient || !deps.userId) {
20
27
  // Ingest not wired — return empty, callable. Same shape as
@@ -32,6 +39,11 @@ export async function getRelevantLearningsTool(args, deps) {
32
39
  error: "applies_at is required and must be one of: 'entry', 'heartbeat', 'close'.",
33
40
  };
34
41
  }
42
+ const cacheKey = `${deps.userId}:${args.applies_at}:${args.setup_type ?? ''}:${args.regime ?? ''}:${args.verdict ?? ''}`;
43
+ const hit = cache.get(cacheKey);
44
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) {
45
+ return structuredClone(hit.result);
46
+ }
35
47
  let response;
36
48
  try {
37
49
  response = await deps.decisionsClient.getRelevantLearnings(deps.userId, {
@@ -56,10 +68,17 @@ export async function getRelevantLearningsTool(args, deps) {
56
68
  note: 'Webapp /api/internal/learnings returned no result; proceeding without curated learnings.',
57
69
  };
58
70
  }
59
- return {
71
+ const result = {
60
72
  ok: true,
61
73
  learnings: response.learnings,
62
74
  total_candidates: response.totalCandidates,
63
75
  returned_count: response.returnedCount,
64
76
  };
77
+ cache.set(cacheKey, { at: Date.now(), result });
78
+ if (cache.size > 64) {
79
+ const oldest = cache.keys().next().value;
80
+ if (oldest !== undefined)
81
+ cache.delete(oldest);
82
+ }
83
+ return structuredClone(result);
65
84
  }
@@ -7,5 +7,10 @@
7
7
  // `microstructure.bandedLiquidity` flag is off (= columns are NULL).
8
8
  import { fetchIntelApi, enc, resolveIntelSymbol } from './intel-api.js';
9
9
  export async function getRestingLiquidityTool(args, deps) {
10
- return fetchIntelApi(`/api/resting-liquidity/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps);
10
+ // Cached: the microstructure assembler hits the same endpoint on the review
11
+ // path each heartbeat; advisory read, so a short TTL + tight timeout.
12
+ return fetchIntelApi(`/api/resting-liquidity/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps, {
13
+ cacheTtlMs: 15_000,
14
+ timeoutMs: 10_000,
15
+ });
11
16
  }
@@ -695,6 +695,23 @@ export async function getWave9StatusTool(deps) {
695
695
  const reversalDue = position
696
696
  ? (position.side === 'long' ? daily.exitLong : daily.exitShort)
697
697
  : false;
698
+ // Quiet-row compaction: no candidate and no held position — the evidence
699
+ // numerics (returns / ATR / reference close) are ~740B per symbol the
700
+ // agent never acts on; × 8 symbols × every heartbeat this was ~68% of
701
+ // the whole status payload. Rows with entries or a position keep the
702
+ // full shape unchanged. Deliberately NOT gated on daily.exitLong/
703
+ // exitShort: those flags are set for most symbols on any
704
+ // negative-momentum day, and with no held position there is nothing to
705
+ // exit — the first shipped version kept them and saved nothing
706
+ // (verified live 2026-08-04: 72 currentReturn mentions per beat).
707
+ if (entries.length === 0 && !position) {
708
+ return {
709
+ symbol,
710
+ capDecision: { status: 'not_candidate', reason: 'no_completed_daily_zero_cross' },
711
+ entries: [],
712
+ reversal: { due: false, timing: 'not_due' },
713
+ };
714
+ }
698
715
  return {
699
716
  symbol,
700
717
  currentReturn: daily.currentReturn,
@@ -26,7 +26,16 @@ export interface FetchOptions {
26
26
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
27
27
  body?: unknown;
28
28
  timeoutMs?: number;
29
+ /** Opt-in TTL cache for GET reads. The same intel endpoints get hit several
30
+ * times per heartbeat by different callers (agent tool + microstructure
31
+ * assembler + repeated agent calls) — a short shared TTL collapses those
32
+ * into one round-trip. Never set on polling reads that must observe fresh
33
+ * server state (e.g. backtest status). */
34
+ cacheTtlMs?: number;
29
35
  }
36
+ /** Test support: drop every cached GET response (the cache is module-level,
37
+ * so suites that stub fetch with per-test responses must clear it). */
38
+ export declare function clearIntelGetCache(): void;
30
39
  /** Encode a value for safe use in a URL path segment */
31
40
  export declare const enc: typeof encodeURIComponent;
32
41
  export declare function fetchIntelApi(path: string, deps: IntelApiDeps, options?: FetchOptions): Promise<Record<string, unknown> | {
@@ -1,5 +1,6 @@
1
1
  // Shared helper for Intelligence API calls.
2
2
  // All intelligence tools use Bearer token auth against intel.reefclaw.com.
3
+ import { keepAliveFetch } from '../http/keepalive-fetch.js';
3
4
  import { HL_INTEL_PREFIX, fromIntelSymbol, toIntelSymbol } from '../venues/symbols.js';
4
5
  // ─── Venue-aware intel-symbol mapping (plan §5.3: the agent keeps using
5
6
  // canonical symbols like BTC/USDC; these helpers translate at the intel
@@ -67,6 +68,13 @@ export function intelSymbolOnVenue(deps, intelSymbol) {
67
68
  const isHl = intelSymbol.startsWith(HL_INTEL_PREFIX);
68
69
  return (deps.venue ?? 'binance') === 'hyperliquid' ? isHl : !isHl;
69
70
  }
71
+ const getCache = new Map();
72
+ const GET_CACHE_MAX_ENTRIES = 256;
73
+ /** Test support: drop every cached GET response (the cache is module-level,
74
+ * so suites that stub fetch with per-test responses must clear it). */
75
+ export function clearIntelGetCache() {
76
+ getCache.clear();
77
+ }
70
78
  /** Encode a value for safe use in a URL path segment */
71
79
  export const enc = encodeURIComponent;
72
80
  export async function fetchIntelApi(path, deps, options) {
@@ -78,6 +86,29 @@ export async function fetchIntelApi(path, deps, options) {
78
86
  return { error: 'No intelligence URL configured. Set intelligenceUrl in openclaw.json plugin config.' };
79
87
  }
80
88
  const url = `${intelligenceUrl}${path}`;
89
+ const ttl = options?.cacheTtlMs ?? 0;
90
+ if (ttl > 0 && (options?.method ?? 'GET') === 'GET') {
91
+ const key = `${connectionToken}:${url}`;
92
+ const hit = getCache.get(key);
93
+ if (hit && Date.now() - hit.at < ttl) {
94
+ // structuredClone: callers must never alias each other's response object.
95
+ return hit.value.then((v) => structuredClone(v));
96
+ }
97
+ const value = fetchIntelApiUncached(url, connectionToken, options);
98
+ getCache.set(key, { at: Date.now(), value });
99
+ // Failures don't stick for the TTL — next caller retries fresh.
100
+ void value.then((v) => { if (v && typeof v === 'object' && 'error' in v)
101
+ getCache.delete(key); }, () => getCache.delete(key));
102
+ if (getCache.size > GET_CACHE_MAX_ENTRIES) {
103
+ const oldest = getCache.keys().next().value;
104
+ if (oldest !== undefined)
105
+ getCache.delete(oldest);
106
+ }
107
+ return value.then((v) => structuredClone(v));
108
+ }
109
+ return fetchIntelApiUncached(url, connectionToken, options);
110
+ }
111
+ async function fetchIntelApiUncached(url, connectionToken, options) {
81
112
  const headers = { Authorization: `Bearer ${connectionToken}` };
82
113
  const fetchInit = {
83
114
  method: options?.method ?? 'GET',
@@ -89,7 +120,7 @@ export async function fetchIntelApi(path, deps, options) {
89
120
  fetchInit.body = JSON.stringify(options.body);
90
121
  }
91
122
  try {
92
- const res = await fetch(url, fetchInit);
123
+ const res = await keepAliveFetch(url, fetchInit);
93
124
  if (res.status === 401) {
94
125
  return { error: 'Invalid or expired ReefClaw connection token.' };
95
126
  }
@@ -94,7 +94,6 @@ export async function recordPositionReviewsTool(args, deps) {
94
94
  // not load-bearing (each row carries its own reviewAt).
95
95
  let filed = 0;
96
96
  if (deps.decisionsClient && deps.userId) {
97
- const promises = [];
98
97
  for (const r of reviews) {
99
98
  const stateEntry = deps.stateStore?.get(r.symbol);
100
99
  const positionId = stateEntry?.webappPositionId;
@@ -137,7 +136,8 @@ export async function recordPositionReviewsTool(args, deps) {
137
136
  deps.stateStore.recordReview(r.symbol, r.verdict, undefined, r.thesis_status);
138
137
  }
139
138
  }
140
- await Promise.allSettled(promises);
139
+ // POSTs above are fire-and-forget by design — durability is the client's
140
+ // in-flight set + drain(), not an await here.
141
141
  }
142
142
  else {
143
143
  logger.info(TAG, `record_position_reviews validated ${reviews.length} reviews; no decisionsClient configured (off-mode).`);
@@ -129,8 +129,20 @@ function learningMatches(learning, setupType, regime) {
129
129
  }
130
130
  export async function scanPairsTool(args, deps, decisionsDeps) {
131
131
  const minScore = args.min_score ?? 4;
132
- // 1. Fetch all symbol facts (cached, shared with get_setup_detail)
133
- const factsRes = await getAllFactsCached(deps);
132
+ // Facts, strategies, and entry learnings are three independent reads (two
133
+ // intel, one webapp) — start them all now and await in the original order,
134
+ // so error precedence (facts → strategies → learnings-fail-quiet) and every
135
+ // early-return body stay byte-identical while the round-trips overlap.
136
+ const factsPromise = getAllFactsCached(deps);
137
+ const stratPromise = getStrategiesCached(deps);
138
+ const learningsPromise = decisionsDeps?.decisionsClient && decisionsDeps.userId
139
+ ? getEntryLearningsCached(decisionsDeps.decisionsClient, decisionsDeps.userId)
140
+ : Promise.resolve([]);
141
+ // An early return below must not leave a floating rejection behind.
142
+ stratPromise.catch(() => { });
143
+ learningsPromise.catch(() => { });
144
+ // 1. All symbol facts (cached, shared with get_setup_detail)
145
+ const factsRes = await factsPromise;
134
146
  if ('error' in factsRes)
135
147
  return factsRes;
136
148
  // Venue scope: only rank symbols this box can actually trade. Without this
@@ -154,8 +166,8 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
154
166
  : 'No symbol facts available. Intelligence service may still be computing initial data.',
155
167
  };
156
168
  }
157
- // 2. Fetch user's active strategies (cached, shared with get_setup_detail)
158
- const stratRes = await getStrategiesCached(deps);
169
+ // 2. User's active strategies (cached, shared with get_setup_detail)
170
+ const stratRes = await stratPromise;
159
171
  if ('error' in stratRes)
160
172
  return stratRes;
161
173
  const strategies = stratRes;
@@ -171,13 +183,10 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
171
183
  }
172
184
  // 3. Evaluate all strategies against all facts
173
185
  const results = scanAllPairs(strategies, facts, minScore);
174
- // 4. Fetch user's confirmed entry-time learnings (optional path — fail
175
- // quietly when the decisions client isn't wired, e.g. in dev or in
176
- // tests that exercise the pre-learning behaviour).
177
- let entryLearnings = [];
178
- if (decisionsDeps?.decisionsClient && decisionsDeps.userId) {
179
- entryLearnings = await getEntryLearningsCached(decisionsDeps.decisionsClient, decisionsDeps.userId);
180
- }
186
+ // 4. User's confirmed entry-time learnings (optional path — resolves []
187
+ // when the decisions client isn't wired, e.g. in dev or in tests that
188
+ // exercise the pre-learning behaviour).
189
+ const entryLearnings = await learningsPromise;
181
190
  const rankings = [];
182
191
  const vetoed = [];
183
192
  for (const r of results) {
package/types.d.ts CHANGED
@@ -63,6 +63,13 @@ export interface CcxtPosition {
63
63
  contractSize: number;
64
64
  entryPrice: number;
65
65
  markPrice: number;
66
+ /** Paper only: true when NO quote was available for this symbol, so
67
+ * `markPrice` is a fabricated fallback (entryPrice) rather than a real mark.
68
+ * Consumers that make protective decisions MUST NOT read `markPrice` as
69
+ * truth when this is set — a fabricated "flat since entry" mark reads as a
70
+ * healthy position and is how a breached stop stayed invisible for 35h.
71
+ * Undefined on live (real marks come from the exchange). */
72
+ markPriceStale?: boolean;
66
73
  notional: number;
67
74
  unrealizedPnl: number;
68
75
  percentage: number;