dsh-hyperliquid 0.3.0 → 0.4.1

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/lib/scan.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * dsh-hyperliquid — market radar (市场雷达). Scans the most liquid tradable perps (core + USDC HIP-3)
3
+ * for a few plain, verifiable signals and returns the top few of each with the data time, so the
4
+ * agent opens a conversation with something concrete instead of a questionnaire.
5
+ * Kept deliberately small (codex review): ~20 markets, cached, no OI-anomaly claims without a baseline.
6
+ * @module lib/scan
7
+ */
8
+
9
+ import { closedCandles } from './indicators.js'
10
+
11
+ const num = (value) => value == null || value === '' || !Number.isFinite(Number(value)) ? null : Number(value)
12
+
13
+ /**
14
+ * Pure part: rank markets by signal. `ranges` maps coin → { high, low, bars } of the last closed 1h bars.
15
+ * @returns {{ funding, movers, premium, levels }} top entries per signal
16
+ */
17
+ export function rankSignals(markets, ranges = {}, { top = 3 } = {}) {
18
+ const rows = markets.map((m) => {
19
+ const ctx = m.ctx ?? {}
20
+ const mark = num(ctx.markPx), prev = num(ctx.prevDayPx), oracle = num(ctx.oraclePx), funding = num(ctx.funding)
21
+ return { coin: m.name, kind: m.kind, mark, change24h: mark && prev ? mark / prev - 1 : null,
22
+ fundingApr: funding == null ? null : funding * 24 * 365, premium: mark && oracle ? mark / oracle - 1 : null, volume: num(ctx.dayNtlVlm) }
23
+ }).filter((row) => row.mark != null)
24
+ const pick = (list, score) => list.filter((row) => score(row) != null).sort((a, b) => Math.abs(score(b)) - Math.abs(score(a))).slice(0, top)
25
+ const levels = rows.map((row) => {
26
+ const range = ranges[row.coin]
27
+ if (!range || !(range.high > range.low)) return null
28
+ const toHigh = range.high / row.mark - 1, toLow = row.mark / range.low - 1
29
+ const side = toHigh <= toLow ? 'high' : 'low'
30
+ return { ...row, side, distance: Math.min(Math.abs(toHigh), Math.abs(toLow)), high: range.high, low: range.low, bars: range.bars, broken: row.mark > range.high || row.mark < range.low }
31
+ }).filter(Boolean).sort((a, b) => (Number(b.broken) - Number(a.broken)) || a.distance - b.distance).slice(0, top)
32
+ return {
33
+ funding: pick(rows, (row) => row.fundingApr),
34
+ movers: pick(rows, (row) => row.change24h),
35
+ premium: pick(rows.filter((row) => row.kind === 'hip3' || Math.abs(row.premium ?? 0) > 0.001), (row) => row.premium),
36
+ levels,
37
+ }
38
+ }
39
+
40
+ const cache = new Map()
41
+
42
+ /** Live part: pick the liquid tradable perps, read their last 20 closed 1h bars, rank. Cached 60s. */
43
+ export async function scanMarkets(remote, { limit = 20, minVolumeUsd = 1_000_000 } = {}) {
44
+ const network = remote.cfg?.network, client = remote.client
45
+ const assertCurrent = () => { if (remote.cfg?.network !== network || remote.client !== client) throw new Error('网络已切换,请重新扫描') }
46
+ const key = `${network}:${limit}:${minVolumeUsd}`
47
+ const hit = cache.get(key)
48
+ if (hit && Date.now() - hit.at < 60_000) return hit.result
49
+ const catalog = await remote.marketCatalog()
50
+ assertCurrent()
51
+ // HIP-3 contexts normally arrive over the websocket; when the catalog has none, read each dex once.
52
+ const missing = [...new Set(catalog.markets.filter((m) => m.kind === 'hip3' && m.tradable && !m.ctx).map((m) => m.name.split(':')[0]))]
53
+ const filled = new Map()
54
+ await Promise.all(missing.map(async (dex) => {
55
+ try {
56
+ const [meta, ctxs] = await client.info('metaAndAssetCtxs', { dex })
57
+ for (const [index, asset] of (meta?.universe ?? []).entries()) if (ctxs?.[index]) filled.set(asset.name, ctxs[index])
58
+ } catch { /* that dex simply stays out of this scan */ }
59
+ }))
60
+ assertCurrent()
61
+ if (filled.size) catalog.markets = catalog.markets.map((m) => !m.ctx && filled.has(m.name) ? { ...m, ctx: filled.get(m.name) } : m)
62
+ // Core perps dominate volume; reserve a quarter of the slots for HIP-3 (indices, stocks, commodities).
63
+ const liquid = (kind) => catalog.markets.filter((m) => m.kind === kind && m.tradable && num(m.ctx?.dayNtlVlm) >= minVolumeUsd)
64
+ .sort((a, b) => num(b.ctx.dayNtlVlm) - num(a.ctx.dayNtlVlm))
65
+ const hip3 = liquid('hip3').slice(0, Math.floor(limit / 4))
66
+ const markets = [...liquid('perp').slice(0, limit - hip3.length), ...hip3]
67
+ const ranges = {}
68
+ await Promise.all(markets.map(async (m) => {
69
+ try {
70
+ const { candles } = await remote.candles({ coin: m.name, interval: '1h' })
71
+ // Closed bars only: the forming bar is not part of the range it might be breaking.
72
+ const rows = closedCandles(candles, { interval: '1h' }).rows.slice(-20)
73
+ if (rows.length >= 20) ranges[m.name] = { high: Math.max(...rows.map((r) => r.h)), low: Math.min(...rows.map((r) => r.l)), bars: rows.length }
74
+ } catch { /* one market's candles must not sink the scan */ }
75
+ }))
76
+ assertCurrent()
77
+ const result = { ...rankSignals(markets, ranges), scanned: markets.length, observedAt: catalog.ctxObservedAt ?? catalog.observedAt ?? Date.now(), network }
78
+ cache.set(key, { at: Date.now(), result })
79
+ return result
80
+ }
81
+
82
+ export function scanText(result) {
83
+ const pct = (v, d = 2) => v == null ? '—' : `${v >= 0 ? '+' : ''}${(v * 100).toFixed(d)}%`
84
+ const lines = [`市场雷达 · 扫描 ${result.scanned} 个可交易永续(成交额最大的核心永续,另留 1/4 名额给成交额最大的 HIP-3)· 数据时间 ${new Date(result.observedAt).toISOString().slice(0, 16)}Z`]
85
+ if (result.levels.length) lines.push('贴近/突破 20 根 1h 区间:' + result.levels.map((r) => `${r.coin} ${r.broken ? (r.mark > r.high ? '已上破' : '已下破') : `距${r.side === 'high' ? '高点' : '低点'} ${(r.distance * 100).toFixed(2)}%`}(区间 ${r.low}–${r.high})`).join(';'))
86
+ if (result.funding.length) lines.push('资金费极值(年化):' + result.funding.map((r) => `${r.coin} ${pct(r.fundingApr, 1)}${r.fundingApr > 0 ? '(多头付费)' : '(空头付费)'}`).join(';'))
87
+ if (result.movers.length) lines.push('24h 涨跌最大:' + result.movers.map((r) => `${r.coin} ${pct(r.change24h)}`).join(';'))
88
+ if (result.premium.length) lines.push('标记价相对预言机:' + result.premium.map((r) => `${r.coin} ${pct(r.premium, 3)}`).join(';'))
89
+ lines.push('下一步:只挑 1 个与用户偏好最匹配的机会,用 hyper_market candles/indicators 核实,再用 hyper_chart 画出区间和 long/short 头寸框给用户看;雷达只是线索,不是交易信号。')
90
+ return lines.join('\n')
91
+ }
package/lib/signing.js CHANGED
@@ -213,3 +213,61 @@ export async function addressFromPrivateKey(privateKey) {
213
213
  const hash = c.keccak256(pub.slice(1))
214
214
  return '0x' + c.bytesToHex(hash.slice(-20))
215
215
  }
216
+
217
+ // ── user-signed actions (EIP-712 "HyperliquidSignTransaction" domain) ──
218
+ // Field lists and order follow the official Python SDK (utils/signing.py); the main wallet signs them.
219
+ export const SEND_ASSET_TYPES = [
220
+ { name: 'hyperliquidChain', type: 'string' },
221
+ { name: 'destination', type: 'string' },
222
+ { name: 'sourceDex', type: 'string' },
223
+ { name: 'destinationDex', type: 'string' },
224
+ { name: 'token', type: 'string' },
225
+ { name: 'amount', type: 'string' },
226
+ { name: 'fromSubAccount', type: 'string' },
227
+ { name: 'nonce', type: 'uint64' },
228
+ ]
229
+ /** The SDK signs user actions on chain 0x66eee; hyperliquidChain inside the message prevents replay across networks. */
230
+ export const USER_SIGNATURE_CHAIN_ID = '0x66eee'
231
+
232
+ /**
233
+ * A `sendAsset` action (move collateral between perp dexes / spot) and its EIP-712 payload.
234
+ * @returns {{ action, typed: { domain, types, value } }}
235
+ */
236
+ export function buildSendAsset({ destination, sourceDex, destinationDex, token, amount, nonce, isMainnet = true }) {
237
+ const action = { type: 'sendAsset', signatureChainId: USER_SIGNATURE_CHAIN_ID, hyperliquidChain: isMainnet ? 'Mainnet' : 'Testnet',
238
+ destination, sourceDex, destinationDex, token, amount, fromSubAccount: '', nonce }
239
+ const value = Object.fromEntries(SEND_ASSET_TYPES.map(({ name }) => [name, action[name]]))
240
+ return {
241
+ action,
242
+ typed: {
243
+ domain: { name: 'HyperliquidSignTransaction', version: '1', chainId: parseInt(USER_SIGNATURE_CHAIN_ID, 16), verifyingContract: '0x0000000000000000000000000000000000000000' },
244
+ types: { 'HyperliquidTransaction:SendAsset': SEND_ASSET_TYPES },
245
+ value,
246
+ },
247
+ }
248
+ }
249
+
250
+ /** EIP-712 digest + secp256k1 signature for a single-struct payload of string / uint / bool / address fields. */
251
+ export async function signTypedDataLocal(privateKey, { domain, types, value }) {
252
+ const c = await loadCrypto()
253
+ const [primary, fields] = Object.entries(types)[0]
254
+ const encode = (type, item) => {
255
+ if (type === 'string') return c.hashText(String(item))
256
+ if (type === 'bool') return u256be(item ? 1 : 0)
257
+ if (type === 'address') { const out = new Uint8Array(32); out.set(addressBytes(item), 12); return out }
258
+ if (/^uint\d+$/.test(type)) return u256be(item)
259
+ throw new Error(`unsupported EIP-712 field type ${type}`)
260
+ }
261
+ const typeHash = c.hashText(`${primary}(${fields.map((f) => `${f.type} ${f.name}`).join(',')})`)
262
+ const structHash = c.keccak256(concat(typeHash, ...fields.map((f) => encode(f.type, value[f.name]))))
263
+ const domainHash = c.keccak256(concat(c.hashText('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
264
+ c.hashText(domain.name), c.hashText(domain.version), u256be(domain.chainId), encode('address', domain.verifyingContract)))
265
+ const digest = c.keccak256(concat(new Uint8Array([0x19, 0x01]), domainHash, structHash))
266
+ const priv = c.hexToBytes(privateKey)
267
+ if (priv.length !== 32) throw new Error('钱包私钥格式错误(需要 32 字节 hex)')
268
+ const sig = c.sign(digest, priv)
269
+ const recovery = typeof sig.recovery === 'number' ? sig.recovery : sig.v - 27
270
+ const rHex = c.bytesToHex(typeof sig.r === 'bigint' ? u256be(sig.r) : sig.r).padStart(64, '0').slice(-64)
271
+ const sHex = c.bytesToHex(typeof sig.s === 'bigint' ? u256be(sig.s) : sig.s).padStart(64, '0').slice(-64)
272
+ return { r: '0x' + rHex, s: '0x' + sHex, v: 27 + recovery }
273
+ }
package/lib/skill.js CHANGED
@@ -10,8 +10,8 @@ export function registerHyperSkill(skills) {
10
10
  if (typeof skills?.register !== 'function') return null
11
11
  return skills.register({
12
12
  name: 'hyperliquid-trading',
13
- description: '分析 Hyperliquid 实时行情、账户和风险,并在 DSH 交易会话中安全决策、下单、撤单和对账。',
14
- whenToUse: '讨论当前交易台、Hyperliquid 市场、持仓、订单、paper 或 testnet/live 交易时使用。',
13
+ description: '交易副手:用 Hyperliquid 实时数据分析、规划、执行、对账与复盘,积累和迭代交易员的策略库;说明可交易市场、白名单、HIP-3 保证金等能力边界。',
14
+ whenToUse: '讨论当前交易台、Hyperliquid 市场、持仓、订单、策略、复盘、paper 或 testnet/live 交易时使用。',
15
15
  source: 'bundled',
16
16
  path: fileURLToPath(skillUrl),
17
17
  content,
package/lib/store.js CHANGED
@@ -18,7 +18,8 @@ const WORKSPACE_DEFAULTS = Object.freeze({
18
18
  layout: { sessionsWidth: 232, chatWidth: 480, sessionsCollapsed: false, deskCollapsed: false, chatCollapsed: false },
19
19
  })
20
20
 
21
- /** Drawings per chart (session + coin, all intervals); the pan budget is verified at this size. */
21
+ /** Drawings per chart (session + coin, all intervals); the pan budget is verified at this size. */import { emptyJournal } from './journal.js'
22
+
22
23
  export const MAX_CHART_MARKS = 100
23
24
 
24
25
  export class HyperStore {
@@ -342,6 +343,35 @@ export class HyperStore {
342
343
  this.#writeJson(join(this.dataDir, 'paper-costs.json'), costs)
343
344
  return costs
344
345
  }
346
+ /** Signing set on the desk (signer, web3Account, tradingAddress); overrides plugin config, no restart. */
347
+ walletSettings() { return this.#readJson(join(this.dataDir, 'wallet-settings.json'), null) }
348
+ saveWalletSettings(settings) { this.#writeJson(join(this.dataDir, 'wallet-settings.json'), { ...settings, at: Date.now() }); return settings }
349
+
350
+ /** Whether a session's current turn was started by an alert (see lib/turn-origin.js); survives a plugin reload. */
351
+ setAlertOrigin(sessionId, alert) {
352
+ const rows = { ...this.#readJson(join(this.dataDir, 'turn-origin.json'), {}), [sessionId]: { alert: Boolean(alert), at: Date.now() } }
353
+ // Only cleared rows are pruned: a live alert origin stays until a human message retires it.
354
+ const cleared = Object.entries(rows).filter(([, row]) => !row.alert).sort((a, b) => b[1].at - a[1].at).slice(0, 500)
355
+ const kept = Object.fromEntries([...Object.entries(rows).filter(([, row]) => row.alert), ...cleared])
356
+ this.#writeJson(join(this.dataDir, 'turn-origin.json'), kept)
357
+ }
358
+ alertOrigin(sessionId) { const row = this.#readJson(join(this.dataDir, 'turn-origin.json'), {})[sessionId]; return row ? row.alert === true : undefined }
359
+
360
+ /** Custody mode chosen on the desk: 'managed' (全托管) or 'assisted' (半托管); null = not chosen yet. */
361
+ custody() {
362
+ const saved = this.#readJson(join(this.dataDir, 'custody.json'), null)
363
+ return saved?.mode === 'managed' || saved?.mode === 'assisted' ? saved.mode : null
364
+ }
365
+ saveCustody(mode) { this.#writeJson(join(this.dataDir, 'custody.json'), { mode, at: Date.now() }); return mode }
366
+
367
+ /** The trader's journal (profile, strategy book, trade/review log); see lib/journal.js. */
368
+ journal() {
369
+ const saved = this.#readJson(join(this.dataDir, 'journal.json'), null)
370
+ return saved && typeof saved === 'object' && Array.isArray(saved.strategies) && Array.isArray(saved.entries)
371
+ ? { profile: saved.profile ?? {}, strategies: saved.strategies, entries: saved.entries } : emptyJournal()
372
+ }
373
+ saveJournal(journal) { this.#writeJson(join(this.dataDir, 'journal.json'), journal); return journal }
374
+
345
375
  indicatorTemplates() { return this.#readJson(join(this.dataDir, 'indicator-templates.json'), []) }
346
376
  saveIndicatorTemplate(template) {
347
377
  const rows = this.indicatorTemplates()
package/lib/tools.js CHANGED
@@ -10,10 +10,13 @@ import { closedCandles, computeIndicators } from './indicators.js'
10
10
  import { MA_KINDS, MA_SOURCES, maLabel, maLatest, normalizeMaLine, normalizeMaLines } from './moving-average.js'
11
11
  import { INDICATOR_IDS, computeStudies, normalizeIndicatorSettings, studyDefinitions } from './studies.js'
12
12
  import { reviewLedger } from './review.js'
13
- import { DRAWING_KINDS, volumeProfile } from './chart-drawings.js'
13
+ import { DRAWING_KINDS, drawingStyle, volumeProfile } from './chart-drawings.js'
14
14
 
15
15
  const DRAWING_NAMES = { vprofile: '固定范围成交量分布', level: '水平线', hray: '水平射线', vline: '垂直线', text: '文本', trend: '趋势线', ray: '射线', extended: '延长线', channel: '平行通道', fib: '斐波那契回撤', rect: '矩形', measure: '价格区间', long: '多头头寸', short: '空头头寸', fibext: '斐波那契扩展', fibchannel: '斐波那契通道', fibtime: '斐波那契时间周期', gannfan: '江恩扇形', pitchfork: '安德鲁叉子线', regression: '回归趋势', arrow: '箭头', callout: '标注气泡', circle: '圆形', triangle: '三角形', brush: '画笔', crossline: '十字线', pricelabel: '价格标签', infoline: '信息线', trendangle: '趋势角度', pricerange: '价格区间', daterange: '日期区间', elliott: '艾略特推动浪', elliottabc: '艾略特调整浪', xabcd: 'XABCD 谐波', abcd: 'ABCD 形态', headshoulders: '头肩形态', gannbox: '江恩箱', gannsquare: '江恩方格', schiff: 'Schiff 叉子线', modschiff: '改良 Schiff 叉子线', icon: '图标' }
16
16
  import { canonicalMarketCoin } from './market-symbol.js'
17
+ import { addEntry, journalDigest, removeStrategy, updateProfile, upsertStrategy } from './journal.js'
18
+ import { analyzeTrading, checkupText } from './analysis.js'
19
+ import { scanMarkets, scanText } from './scan.js'
17
20
 
18
21
  /** Resolve the calling session id from a tool run context. */
19
22
  function sessionIdOf(exec) {
@@ -39,8 +42,8 @@ export function losslessJson(value) {
39
42
  return JSON.parse(JSON.stringify(value, (_key, item) => typeof item === 'number' && !Number.isFinite(item) ? null : item))
40
43
  }
41
44
 
42
- export function buildToolDefinitions(remote) {
43
- return buildTools(remote).map((tool) => ({ ...tool, async execute(...args) { return losslessJson(await tool.execute(...args)) } }))
45
+ export function buildToolDefinitions(remote, authorize = () => {}) {
46
+ return buildTools(remote).map((tool) => ({ ...tool, async execute(...args) { authorize(tool.name, args[0], args[1]); return losslessJson(await tool.execute(...args)) } }))
44
47
  }
45
48
 
46
49
  function buildTools(remote) {
@@ -68,10 +71,14 @@ function buildTools(remote) {
68
71
  const bid = snap.book?.levels?.[0]?.[0]
69
72
  const ask = snap.book?.levels?.[1]?.[0]
70
73
  const candles = candleResult.candles.slice(-24)
71
- const tradable = Boolean(coreMarket)
74
+ // Core perps and USDC-collateral HIP-3 perps are tradable; spot and outcomes stay view only.
75
+ const tradable = Boolean(coreMarket) || (detail?.market?.kind === 'hip3' && detail.market.tradable === true)
72
76
  const position = account.positions.find((p) => p.coin === coin) ?? null
73
- const mid = Number(snap.mids?.mids?.[coin] ?? snap.mids?.[coin])
74
- const midFresh = tradable && snap.observedAt?.mids && Date.now() - snap.observedAt.mids < 10_000 && Number.isFinite(mid) && mid > 0
77
+ // HIP-3 mids are not in the core allMids snapshot: read the market's own dex.
78
+ const dexMid = tradable && !coreMarket ? await remote.midOf(coin).then((value) => ({ value, at: Date.now() }), () => null) : null
79
+ const mid = dexMid ? dexMid.value : Number(snap.mids?.mids?.[coin] ?? snap.mids?.[coin])
80
+ const midsAt = dexMid ? dexMid.at : snap.observedAt?.mids
81
+ const midFresh = tradable && midsAt && Date.now() - midsAt < 10_000 && Number.isFinite(mid) && mid > 0
75
82
  const mark = market.ctx?.markPx != null ? Number(market.ctx.markPx) : null
76
83
  const change24hPct = Number(market.ctx?.prevDayPx) > 0 && mark != null ? (mark / Number(market.ctx.prevDayPx) - 1) * 100 : null
77
84
  const context = {
@@ -93,8 +100,8 @@ function buildTools(remote) {
93
100
  position, positions: account.positions, openOrders: (account.openOrders ?? []).slice(0, 20), candles,
94
101
  recentTrades: (Array.isArray(snap.trades) ? snap.trades : []).slice(0, 10),
95
102
  bookDepth: { bids: (snap.book?.levels?.[0] ?? []).slice(0, 5), asks: (snap.book?.levels?.[1] ?? []).slice(0, 5) },
96
- riskLimits: status.riskLimits, autonomousLive: status.autonomousLive, walletConfigured: status.walletConfigured,
97
- observedAt: { ...snap.observedAt, market: detail?.observedAt ?? markets.observedAt ?? null }, ws: snap.ws, assembledAt: Date.now(),
103
+ riskLimits: status.riskLimits, autonomousLive: status.autonomousLive, custody: status.custody, walletConfigured: status.walletConfigured,
104
+ observedAt: { ...snap.observedAt, ...(dexMid ? { mids: dexMid.at } : {}), market: detail?.observedAt ?? markets.observedAt ?? null }, ws: snap.ws, assembledAt: Date.now(),
98
105
  }
99
106
  const bookAge = context.observedAt?.book ? Math.round((context.assembledAt - context.observedAt.book) / 1000) : null
100
107
  const midsAge = context.observedAt?.mids ? Math.round((context.assembledAt - context.observedAt.mids) / 1000) : null
@@ -111,7 +118,7 @@ function buildTools(remote) {
111
118
  ? '当前账户数据不代表该只读市场的余额、仓位或可交易额度。'
112
119
  : `当前市场仅可分析;${context.positionsCoverage === 'all-dexs' ? '仓位来自全部永续 DEX 快照' : '账户仓位仅覆盖所选 DEX'}${position ? `;当前仓位=${fmt(position.size, 6)} ${coin}` : ''},不能据此在该市场下单。`
113
120
  : position ? `仓位 ${position.size > 0 ? 'LONG' : 'SHORT'} ${fmt(Math.abs(position.size), 6)} ${coin} entry=${fmt(position.entryPx)} uPnL=${fmt(position.uPnl)}` : '当前币对无持仓',
114
- `风险:允许币对=${(context.riskLimits?.allowedCoins ?? []).join(',')} 单笔≤${fmt(context.riskLimits?.maxOrderUsd)} USD 每日≤${fmt(context.riskLimits?.maxDailyUsd)} USD 杠杆≤${fmt(context.riskLimits?.maxLeverage, 0)}x · 自主执行=${context.autonomousLive ? '开启' : '关闭'}${account.mode === 'paper' ? ' · 模拟盘配置的滑点与手续费,未模拟盘口深度、资金费或强平' : ''}`,
121
+ `风险:允许币对=${(context.riskLimits?.allowedCoins ?? []).join(',')} 单笔≤${fmt(context.riskLimits?.maxOrderUsd)} USD 每日≤${fmt(context.riskLimits?.maxDailyUsd)} USD 杠杆≤${fmt(context.riskLimits?.maxLeverage, 0)}x · 托管模式=${context.custody === 'managed' ? '全托管(可自主执行、调整限额与划转)' : '半托管(实盘与划转需用户批准,限额由用户修改)'}${account.mode === 'paper' ? ' · 模拟盘配置的滑点与手续费,未模拟盘口深度、资金费或强平' : ''}`,
115
122
  `近 ${candles.length} 根 K 线价格(末根可能未收盘): ${candles.map((c) => fmt(c.c)).join(', ')}`,
116
123
  ].join('\n')
117
124
  return { text, context }
@@ -120,19 +127,20 @@ function buildTools(remote) {
120
127
  {
121
128
  name: 'hyper_market',
122
129
  description:
123
- '查询 Hyperliquid 核心永续、HIP-3、现货与 Outcome 行情。action=overview 列出核心永续的 24h 涨跌/成交/资金费;action=book 查盘口;' +
124
- 'action=catalog 搜索所有市场,支持官方 perpCategories 分类;action=candles 查 K 线(支持 Hyperliquid 官方 1m 至 1M 的 14 种周期);action=indicators 计算 SMA/EMA/RSI/MACD/布林带/ATR/Stochastic/ADX/OBV/支撑阻力,遵循当前会话指标参数;action=trades 查最近成交。非核心永续目前只读。',
130
+ '查询 Hyperliquid 核心永续、HIP-3、现货与 Outcome 行情。action=scan 市场雷达:扫描约 20 个可交易永续(成交额最大的核心永续 + 前 5 个 HIP-3),给出贴近/突破区间、资金费极值、24h 涨跌、溢价的前几名(用户问“今天有什么机会”时先用它);action=overview 列出核心永续的 24h 涨跌/成交/资金费;action=book 查盘口;' +
131
+ 'action=catalog 搜索所有市场,支持官方 perpCategories 分类;action=candles 查 K 线(支持 Hyperliquid 官方 1m 至 1M 的 14 种周期);action=indicators 计算 SMA/EMA/RSI/MACD/布林带/ATR/Stochastic/ADX/OBV/支撑阻力,遵循当前会话指标参数;action=trades 查最近成交。核心永续与 USDC 保证金的 HIP-3 永续(如 xyz:XYZ100)可交易,现货与 Outcome 目前只读;HIP-3 每个 dex 保证金独立,需要该 dex 有 USDC 或开启统一账户。',
125
132
  parameters: {
126
133
  type: 'object',
127
134
  properties: {
128
- action: { type: 'string', enum: ['overview', 'catalog', 'book', 'candles', 'indicators', 'trades'], description: '行情查询类型' },
135
+ action: { type: 'string', enum: ['overview', 'scan', 'catalog', 'book', 'candles', 'indicators', 'trades'], description: '行情查询类型' },
129
136
  ma: { type: 'array', description: 'action=indicators 可选:额外计算的均线,最多 8 条;省略时返回当前会话图表上已叠加的均线', items: { type: 'object', properties: { kind: { type: 'string', enum: MA_KINDS }, period: { type: 'number' }, source: { type: 'string', enum: MA_SOURCES }, smoothing: { type: 'object', properties: { kind: { type: 'string', enum: MA_KINDS }, period: { type: 'number' } } } }, required: ['kind', 'period'] } },
130
137
  coin: { type: 'string', description: '官方合约名,以 action=catalog 返回的 name 为准,如 BTC 或 xyz:XYZ100;overview 时可省略' },
131
138
  kind: { type: 'string', enum: ['perp', 'hip3', 'spot', 'outcome'], description: 'catalog 市场类型过滤' },
132
139
  category: { type: 'string', description: 'catalog 官方分类过滤,如 crypto、stocks、commodities、indices、fx、rates、preipo;tradfi 聚合这些传统金融标签' },
133
140
  query: { type: 'string', description: 'catalog 名称搜索' },
134
141
  interval: { type: 'string', description: 'K 线周期,默认 1h' },
135
- limit: { type: 'number', description: 'candles/trades 返回条数上限,默认 48' },
142
+ limit: { type: 'number', description: 'candles/trades 返回条数上限,默认 48,candles 最多 200' },
143
+ endMs: { type: 'number', description: 'action=candles 可选:返回这一时刻(epoch 毫秒)之前的 K 线,用于往前翻页取历史;交易所每个周期只保留最近约 5000 根' },
136
144
  },
137
145
  required: ['action'],
138
146
  additionalProperties: false,
@@ -158,6 +166,10 @@ function buildTools(remote) {
158
166
  }))
159
167
  return { text: scored.map((s) => `${s.coin} mark=${fmt(s.mark)} 24h=${s.change24hPct.toFixed(2)}% vol=${fmt(s.vol24hUsd, 0)} funding=${s.fundingPct1h.toFixed(4)}%/h OI=${fmt(s.openInterestUsd, 0)} maxLev=${s.maxLeverage}x`).join('\n'), coins: scored }
160
168
  }
169
+ if (action === 'scan') {
170
+ const result = await scanMarkets(remote)
171
+ return { text: scanText(result), ...result }
172
+ }
161
173
  if (action === 'catalog') {
162
174
  const result = await remote.marketCatalog()
163
175
  const query = String(args?.query ?? '').trim().toLowerCase()
@@ -178,9 +190,12 @@ function buildTools(remote) {
178
190
  return { text: `${coin} 盘口\nbids: ${levels(0).map((l) => `${l.px}×${l.sz}`).join(' | ')}\nasks: ${levels(1).map((l) => `${l.px}×${l.sz}`).join(' | ')}`, book: snap.book }
179
191
  }
180
192
  if (action === 'candles') {
181
- const { candles } = await remote.candles({ coin, interval: args?.interval ?? '1h' })
182
193
  const limit = Math.max(1, Math.min(Number(args?.limit) || 48, 200))
183
- const tail = candles.slice(-limit)
194
+ const iv = args?.interval ?? '1h'
195
+ // Paging back: ask for exactly the window that ends at endMs.
196
+ const endMs = Number(args?.endMs) > 0 ? Number(args.endMs) : null
197
+ const { candles } = await remote.candles(endMs ? { coin, interval: iv, endMs, startMs: endMs - (INTERVAL_MS[iv] ?? 3600e3) * (limit + 1) } : { coin, interval: iv })
198
+ const tail = candles.filter((c) => !endMs || c.t < endMs).slice(-limit)
184
199
  return { text: tail.map((c) => new Date(c.t).toISOString().slice(5, 16) + ` O=${fmt(c.o)} H=${fmt(c.h)} L=${fmt(c.l)} C=${fmt(c.c)} V=${fmt(c.v, 3)}`).join('\n'), coin, interval: args?.interval ?? '1h', candles: tail }
185
200
  }
186
201
  if (action === 'indicators') {
@@ -237,6 +252,7 @@ function buildTools(remote) {
237
252
  smoothingPeriod: { type: 'number', description: 'kind=ma 可选:平滑周期' },
238
253
  color: { type: 'string', description: '可选 #RRGGBB 颜色(画线或均线)' },
239
254
  lineWidth: { type: 'number', description: '画线线宽 1-4' },
255
+ levels: { type: 'array', items: { type: 'number' }, description: 'kind=fib/fibext/fibchannel:自定义斐波那契层级,如 [0,0.382,0.5,0.618,1,1.618]' },
240
256
  rows: { type: 'number', description: 'kind=vprofile:价格行数 6-200,默认 48' },
241
257
  valueArea: { type: 'number', description: 'kind=vprofile:价值区成交量占比 0.5-0.95,默认 0.7' },
242
258
  lineStyle: { type: 'string', enum: ['solid', 'dashed', 'dotted'], description: '画线线型' },
@@ -290,7 +306,7 @@ function buildTools(remote) {
290
306
  return { text: `已移除均线:${maLabel(removed)}(${removed.id})`, line: removed }
291
307
  }
292
308
  if (args?.action === 'update') {
293
- const patch = Object.fromEntries(['points', 'price', 'fromTime', 'fromPrice', 'toTime', 'toPrice', 'label', 'color', 'lineWidth', 'lineStyle', 'locked', 'hidden', 'shared', 'alert', 'rows', 'valueArea'].filter((key) => args?.[key] != null).map((key) => [key, args[key]]))
309
+ const patch = Object.fromEntries(['points', 'price', 'fromTime', 'fromPrice', 'toTime', 'toPrice', 'label', 'color', 'lineWidth', 'lineStyle', 'locked', 'hidden', 'shared', 'alert', 'rows', 'valueArea', 'levels'].filter((key) => args?.[key] != null).map((key) => [key, args[key]]))
294
310
  const mark = remote.editChartMark(args?.id, patch, sessionId)
295
311
  return { text: `已更新${DRAWING_NAMES[mark.kind] ?? mark.kind}:${mark.label}(${mark.id})`, mark }
296
312
  }
@@ -298,13 +314,15 @@ function buildTools(remote) {
298
314
  // Fetch the range first: a failed market request must not leave a saved drawing behind.
299
315
  let summary = null
300
316
  if (args?.kind === 'vprofile') {
317
+ // Validate rows / valueArea before they size any computation.
318
+ drawingStyle({ kind: 'vprofile', rows: args?.rows, valueArea: args?.valueArea })
301
319
  const times = (Array.isArray(args?.points) ? args.points : []).map((point) => Number(point?.time)).filter(Number.isFinite).sort((x, y) => x - y)
302
320
  if (times.length === 2) {
303
321
  const { candles } = await remote.candles({ coin, interval, startMs: times[0], endMs: times[1] + 1 })
304
322
  const inRange = candles.filter((c) => c.t >= times[0] && c.t <= times[1])
305
323
  const profile = volumeProfile(inRange.map((c) => ({ open: c.o, high: c.h, low: c.l, close: c.c, volume: c.v })), { rows: Number(args?.rows) || 48, valueArea: Number(args?.valueArea) || 0.7 })
306
324
  const step = INTERVAL_MS[interval] ?? (inRange.length > 1 ? inRange[1].t - inRange[0].t : 0)
307
- const partial = !inRange.length || (step > 0 && inRange[0].t - times[0] > step)
325
+ const partial = !inRange.length || (step > 0 && inRange[0].t - times[0] >= step)
308
326
  summary = { profile, partial, bars: inRange.length, from: inRange[0]?.t ?? null }
309
327
  }
310
328
  }
@@ -325,11 +343,20 @@ function buildTools(remote) {
325
343
  },
326
344
  {
327
345
  name: 'hyper_account',
328
- description: '查询交易账户。action=state 返回权益/持仓;orders 返回挂单;fills 返回成交;orderStatus 用 oid 或 cloid 核对某笔 live 订单,尤其在网络超时后。review 返回最近 7/30/90 天成交复盘、订单数量核对、费用和资金费覆盖缺口;不是完整账户收益率。',
346
+ description: '查询交易账户。action=state 返回权益/持仓;orders 返回挂单;fills 返回成交;orderStatus 用 oid 或 cloid 核对某笔 live 订单,尤其在网络超时后。review 返回最近 7/30/90 天成交复盘、订单数量核对、费用和资金费覆盖缺口;不是完整账户收益率。' +
347
+ 'action=analyze 交易体检(默认 90 天):按完整往返统计胜率、盈亏比、期望、持仓时长、按币种/时段/星期的表现、手续费与资金费,并给出按金额排序的习惯问题和建议规则——第一次见面或用户问“我做得怎么样”时用它,不要自己从成交里手算。' +
348
+ 'action=limits 修改允许币对(addCoins/removeCoins)与单笔/每日/仓位/杠杆上限——仅全托管模式可用,半托管时由用户在 设置 → 交易限额与签名规则 修改。' +
349
+ 'action=transfer 在主永续账户与 HIP-3 交易所之间划转 USDC(dex,amount,direction=in 转入 / out 转回)——全托管直接执行,半托管每次需用户批准;仅 live。',
329
350
  parameters: {
330
351
  type: 'object',
331
352
  properties: {
332
- action: { type: 'string', enum: ['state', 'orders', 'fills', 'orderStatus', 'review'], description: '账户查询类型,默认 state' },
353
+ action: { type: 'string', enum: ['state', 'orders', 'fills', 'orderStatus', 'review', 'analyze', 'limits', 'transfer'], description: '账户动作,默认 state' },
354
+ addCoins: { type: 'array', items: { type: 'string' }, description: 'action=limits:加入允许币对,如 ["xyz:XYZ100"]' },
355
+ removeCoins: { type: 'array', items: { type: 'string' }, description: 'action=limits:移出允许币对' },
356
+ maxOrderUsd: { type: 'number' }, maxDailyUsd: { type: 'number' }, maxPositionUsd: { type: 'number' }, maxLeverage: { type: 'number' },
357
+ dex: { type: 'string', description: 'action=transfer:HIP-3 交易所名,如 xyz' },
358
+ amount: { type: 'number', description: 'action=transfer:USDC 数量' },
359
+ direction: { type: 'string', enum: ['in', 'out'], description: 'action=transfer:in=主账户转入该交易所(默认),out=转回主账户' },
333
360
  oid: { type: 'string', description: 'orderStatus 必填;交易所 oid 或 0x 开头 128 位 cloid' },
334
361
  days: { type: 'number', enum: [7, 30, 90], description: 'review 窗口,默认 30 天' },
335
362
  coin: { type: 'string', description: 'review 可选,仅核对该标的;省略为全部' },
@@ -338,8 +365,27 @@ function buildTools(remote) {
338
365
  },
339
366
  output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: v.text }] },
340
367
  timeoutMs: 60_000,
341
- async execute(args) {
368
+ async execute(args, exec) {
342
369
  const action = args?.action ?? 'state'
370
+ if (action === 'analyze') {
371
+ const days = args?.days ?? 90
372
+ if (![7, 30, 90].includes(days)) throw Error('交易体检支持最近 7/30/90 天')
373
+ const evidence = await remote.tradeHistory({ includeFunding: true, days })
374
+ const fills = args?.coin ? (evidence.fills ?? []).filter((row) => row.coin === canonicalMarketCoin(args.coin)) : evidence.fills
375
+ const funding = evidence.coverage?.fundingStatus === 'complete' ? evidence.funding : null
376
+ const result = analyzeTrading(fills, { funding, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai' })
377
+ const limited = evidence.coverage?.fillsLimited ? '(交易所只返回最近 2000 笔成交,更早的未计入)' : ''
378
+ return { text: checkupText(result, { days, mode: `${evidence.mode}/${evidence.network}` }) + limited, ...result, coverage: { ...result.coverage, ...evidence.coverage } }
379
+ }
380
+ if (action === 'limits') {
381
+ const { before, after } = remote.agentUpdateRiskSettings(args)
382
+ const diff = Object.keys(after).filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
383
+ return { text: diff.length ? `已更新交易限额:${diff.map((key) => `${key} ${JSON.stringify(before[key])} → ${JSON.stringify(after[key])}`).join(';')}` : '交易限额未变化', before, after }
384
+ }
385
+ if (action === 'transfer') {
386
+ const res = await remote.transferCollateral(args, exec)
387
+ return { text: `已划转 ${res.amount} USDC:${res.direction === 'in' ? `主永续账户 → ${res.dex}` : `${res.dex} → 主永续账户`}`, ...res }
388
+ }
343
389
  if (action === 'state') {
344
390
  const a = await remote.account({ coin: remote.desk?.coin })
345
391
  const lines = [`mode=${a.mode}${a.address ? ' ' + a.address : ''}${a.accountDex ? ` DEX=${a.accountDex}` : ''}${a.positionsCoverage ? ` positions=${a.positionsCoverage}` : ''}`]
@@ -383,7 +429,7 @@ function buildTools(remote) {
383
429
  },
384
430
  {
385
431
  name: 'hyper_decide',
386
- description: '对一个 live 交易意图做服务端预检:读取实时价格、账户权益和仓位,执行币对/单笔/每日/仓位/杠杆硬限制,并生成 30 秒、仅本会话可用的一次性 decisionId。可选 takeProfitPx/stopLossPx 会随主单作为 normalTpsl 分组保护单提交;父单未完全成交时保护单尚未挂出,部分成交后撤销父单会撤销子单,必须核对真实仓位与保护挂单。paper 暂不支持触发单。先独立分析行情与风险,再提交方向、数量和依据;预检通过不等于交易所已成交。',
432
+ description: '对一个 live 交易意图做服务端预检:读取实时价格、账户权益和仓位,执行币对/单笔/每日/仓位/杠杆硬限制,并生成 30 秒、仅本会话可用的一次性 decisionId。可选 takeProfitPx/stopLossPx 会随主单作为 normalTpsl 分组保护单提交(目前仅 testnet;mainnet 传入会被拒绝);父单未完全成交时保护单尚未挂出,部分成交后撤销父单会撤销子单,必须核对真实仓位与保护挂单。paper 暂不支持触发单。先独立分析行情与风险,再提交方向、数量和依据;预检通过不等于交易所已成交。',
387
433
  parameters: {
388
434
  type: 'object',
389
435
  properties: {
@@ -406,8 +452,8 @@ function buildTools(remote) {
406
452
  {
407
453
  name: 'hyper_order',
408
454
  description:
409
- '交易下单/撤单/杠杆。action=place:paper 直接执行;live 必须先调用 hyper_decide 并提供同一订单的一次性 decisionId。live 可带 takeProfitPx/stopLossPx,必须与预检完全一致,作为 normalTpsl 分组保护单提交;父单未完全成交时保护单尚未挂出,部分成交后撤销父单会撤销子单,提交后须核对仓位与实际保护挂单。自主 live 仅在宿主显式开启且钱包策略允许时执行,否则触发人工审批。' +
410
- 'action=cancel 撤单(oid);action=leverage 设置杠杆(1-50)。autonomousLive=false 时 live 撤单和杠杆变更需人工审批;否则仍受 dsh-web3 钱包策略约束。',
455
+ '交易下单/撤单/杠杆。action=place:paper 直接执行;live 必须先调用 hyper_decide 并提供同一订单的一次性 decisionId。live 可带 takeProfitPx/stopLossPx(目前仅 testnet),必须与预检完全一致,作为 normalTpsl 分组保护单提交;父单未完全成交时保护单尚未挂出,部分成交后撤销父单会撤销子单,提交后须核对仓位与实际保护挂单。全托管模式下 live 直接执行(仍受钱包策略约束),半托管触发人工审批。' +
456
+ 'action=cancel 撤单(oid);action=leverage 设置杠杆(1-50)。半托管时 live 下单、撤单和杠杆变更需人工审批;全托管时直接执行,但仍受 dsh-web3 钱包策略约束。',
411
457
  parameters: {
412
458
  type: 'object',
413
459
  properties: {
@@ -513,5 +559,70 @@ function buildTools(remote) {
513
559
  return { text: lines.join('\n'), session: row, status: st }
514
560
  },
515
561
  },
562
+ {
563
+ name: 'hyper_journal',
564
+ description:
565
+ '交易员日志(跨会话、重启保留,摘要会自动出现在每个会话的系统提示里)。action=read 读取交易员档案、策略库(含按 paper/live 分开统计的笔数、胜率、期望 R)、最近教训与记录;' +
566
+ 'action=profile 用 fields 更新档案(如 {"风险偏好":"单笔最多亏 1%","持仓周期":"日内到 3 天","打扰偏好":"只在需要决策时提醒"},null 删除字段);' +
567
+ 'action=strategy 新建或修订策略(不传 id 为新建,至少 name+thesis;修订必须传 why 说明改动原因,版本号自动递增),action=removeStrategy 删除;' +
568
+ 'action=log 追加记录:type=plan 交易计划 / trade 已平仓结果(必须 resultR=盈亏÷计划风险)/ review 复盘 / lesson 可复用的教训 / note 其他,可关联 strategyId 与 coin。' +
569
+ '用户表达偏好、纠正你、或一笔交易结束时就写入,不要等用户要求。',
570
+ parameters: {
571
+ type: 'object',
572
+ properties: {
573
+ action: { type: 'string', enum: ['read', 'profile', 'strategy', 'removeStrategy', 'log'], description: '默认 read' },
574
+ fields: { type: 'object', additionalProperties: true, description: 'action=profile:{字段: 内容 | null}' },
575
+ id: { type: 'string', description: 'strategy 修订 / removeStrategy 的策略 id' },
576
+ name: { type: 'string' }, markets: { type: 'string', description: '适用市场,如 BTC,ETH,xyz:XYZ100' }, timeframe: { type: 'string', description: '周期,如 4h 结构 + 15m 入场' },
577
+ thesis: { type: 'string', description: '为什么能赚钱:市场行为与优势来源' }, entry: { type: 'string', description: '入场条件(可核对的)' },
578
+ exit: { type: 'string', description: '止盈与离场规则' }, invalidation: { type: 'string', description: '止损与作废条件' },
579
+ sizing: { type: 'string', description: '仓位与风险规则,如 单笔风险 0.5% 权益' }, notes: { type: 'string' },
580
+ status: { type: 'string', enum: ['idea', 'testing', 'active', 'paused', 'retired'] },
581
+ why: { type: 'string', description: '修订策略时必填:依据哪些交易或复盘做了什么改动' },
582
+ type: { type: 'string', enum: ['plan', 'trade', 'review', 'lesson', 'note'], description: 'action=log 的记录类型' },
583
+ text: { type: 'string', description: 'action=log 的内容' }, coin: { type: 'string' }, strategyId: { type: 'string' },
584
+ strategyVersion: { type: 'integer', minimum: 1, description: 'action=log:默认当前策略版本;修订前的交易平仓时填原版本,战绩按版本分别统计' },
585
+ resultR: { type: 'number', description: 'type=trade:结果的 R 倍数(亏损为负)' }, pnlUsd: { type: 'number', description: 'type=trade:可选,实际盈亏 USD' },
586
+ },
587
+ additionalProperties: false,
588
+ },
589
+ output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: v.text }] },
590
+ timeoutMs: 10_000,
591
+ async execute(args, exec) {
592
+ const action = args?.action ?? 'read'
593
+ let sessionId = null
594
+ try { sessionId = sessionIdOf(exec) } catch { /* journal is not bound to a trading session */ }
595
+ let journal = remote.store.journal()
596
+ if (action === 'read') {
597
+ const digest = journalDigest(journal)
598
+ const text = [
599
+ Object.keys(digest.profile).length ? '档案:' + Object.entries(digest.profile).map(([k, v]) => `${k}=${v}`).join(';') : '档案:空(与用户交流时逐步记录偏好)',
600
+ digest.strategies.length ? '策略:\n' + digest.strategies.map((row) => `[${row.id}] ${row.name} ${row.status} v${row.version} ${row.markets ?? ''} ${row.timeframe ?? ''}\n 依据:${row.thesis}${row.entry ? `\n 入场:${row.entry}` : ''}${row.exit ? `\n 离场:${row.exit}` : ''}${row.invalidation ? `\n 作废:${row.invalidation}` : ''}${row.sizing ? `\n 仓位:${row.sizing}` : ''}\n 战绩:${Object.entries(row.stats).map(([mode, s]) => `${mode} ${s.trades}笔 胜率${Math.round(s.winRate * 100)}% 期望${fmt(s.expectancyR, 2)}R 最差${fmt(s.worstR, 2)}R`).join(';') || '暂无已平仓记录'}`).join('\n') : '策略:空',
601
+ digest.lessons.length ? '教训:' + digest.lessons.map((entry) => entry.text).join(';') : '',
602
+ digest.recent.length ? '最近记录:\n' + digest.recent.map((entry) => `${new Date(entry.at).toISOString().slice(0, 16)} ${entry.type}${entry.coin ? ' ' + entry.coin : ''}${entry.strategyId ? ' [' + entry.strategyId + ']' : ''}${entry.resultR != null ? ' ' + fmt(entry.resultR, 2) + 'R' : ''} ${entry.text.slice(0, 160)}`).join('\n') : '',
603
+ ].filter(Boolean).join('\n')
604
+ return { text, ...digest }
605
+ }
606
+ if (action === 'profile') {
607
+ journal = remote.store.saveJournal(updateProfile(journal, args?.fields))
608
+ return { text: `已更新交易员档案:${Object.keys(args?.fields ?? {}).join('、')}`, profile: journalDigest(journal).profile }
609
+ }
610
+ if (action === 'strategy') {
611
+ const result = upsertStrategy(journal, args)
612
+ remote.store.saveJournal(result.journal)
613
+ return { text: `${result.strategy.version === 1 ? '已新建' : '已修订'}策略 [${result.strategy.id}] ${result.strategy.name}(${result.strategy.status},v${result.strategy.version})`, strategy: result.strategy }
614
+ }
615
+ if (action === 'removeStrategy') {
616
+ remote.store.saveJournal(removeStrategy(journal, String(args?.id ?? '')))
617
+ return { text: `已删除策略 ${args?.id}` }
618
+ }
619
+ if (action === 'log') {
620
+ const result = addEntry(journal, args, { mode: remote.cfg.tradingMode, network: remote.cfg.network, sessionId: sessionId ?? undefined })
621
+ remote.store.saveJournal(result.journal)
622
+ return { text: `已记录 ${result.entry.type}${result.entry.resultR != null ? ` ${fmt(result.entry.resultR, 2)}R` : ''}`, entry: result.entry }
623
+ }
624
+ throw new Error(`未知 action: ${action}`)
625
+ },
626
+ },
516
627
  ]
517
628
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Track who started the current turn, so an automatic alert can never authorize money operations
3
+ * (orders, pre-checks, limit changes, transfers).
4
+ *
5
+ * In memory: `turn/start` opens a turn; an event message from this plugin marks it as an alert turn.
6
+ * On disk (`origin.get/set`): the session's last known origin — set to "alert" before the waker delivers,
7
+ * cleared only when a human message is seen. A turn the guard did not observe (plugin reloaded mid-turn)
8
+ * falls back to that record, so a reload never turns an alert turn into an authorised one, however long
9
+ * it has been; the trader sending a new message is what clears it.
10
+ */
11
+
12
+ const isPluginEvent = (source) => source?.kind === 'plugin:dsh-hyperliquid' && source.form === 'event'
13
+ const sessionIdOf = (session) => session?.id ?? null
14
+
15
+ /**
16
+ * @param ctx - host context (`session/event`).
17
+ * @param options.origin - persisted `{ get(sessionId): boolean | undefined, set(sessionId, alert: boolean) }`.
18
+ */
19
+ export function createTurnOriginGuard(ctx, { origin = { get: () => undefined, set: () => {} } } = {}) {
20
+ const turns = new WeakMap()
21
+ ctx.on('session/event', (session, event) => {
22
+ // A turn is trusted only once a human message is seen in it; until then the persisted origin decides.
23
+ if (event.type === 'turn/start') turns.set(session, { alert: false, human: false })
24
+ if (event.type !== 'user/message') return
25
+ const source = event.data?.source
26
+ if (isPluginEvent(source)) {
27
+ turns.set(session, { alert: true, human: false })
28
+ if (sessionIdOf(session)) origin.set(sessionIdOf(session), true)
29
+ } else if (source?.kind === 'user' && !turns.get(session)?.alert) {
30
+ // A human instruction makes this a trusted turn and retires the persisted alert origin.
31
+ turns.set(session, { alert: false, human: true })
32
+ if (sessionIdOf(session)) origin.set(sessionIdOf(session), false)
33
+ }
34
+ })
35
+ return (tool, args, exec) => {
36
+ const money = tool === 'hyper_order' || tool === 'hyper_decide' || (tool === 'hyper_account' && ['limits', 'transfer'].includes(args?.action))
37
+ if (!money) return
38
+ const turn = turns.get(exec?.agent?.session)
39
+ if (turn?.alert) throw new Error('交易台自动通知不能授权下单、改限额或划转;请等待用户的新指令')
40
+ if (turn?.human) return
41
+ // No human message seen in this turn (not observed at all, only turn/start, or a scheduled turn):
42
+ // the persisted origin decides until a human message clears it.
43
+ const sessionId = exec?.agent?.id ?? sessionIdOf(exec?.agent?.session)
44
+ if (sessionId && origin.get(sessionId) === true) throw new Error('无法确认本轮是否来自自动通知,资金操作已阻止;请用户直接发一条新指令')
45
+ }
46
+ }