/** * mirror-bridge.mjs — 独立镜像桥(不依赖 DSH 插件重启) * * 为什么不走插件:桌面壳(DSH Desktop)的 HTTP 网关对未认证请求一律 403,浮窗是独立进程拿不到 * cookie;而插件要重启 DSH 才会加载新版。所以这里直接读宿主自己的落盘数据: * * %DSH_HOME%/storages/session_projcache.json ← 每个会话的投影:标题 / 轮步 / 令牌 / 上下文压力 * %DSH_HOME%/sessions///session.jsonl.zstd ← 会话事件流(多帧 zstd)→ 消息行 * * 产物:%DSH_HOME%/float-window/snapshot.json(与插件写的同一份 schema),浮窗每秒读它渲染。 * 只有当插件没在写(mtime 超过 3s)时才写,避免两边打架。 * * 用法:node tools/mirror-bridge.mjs [--home ] [--interval 1200] [--once] */ import fs from 'node:fs' import path from 'node:path' import os from 'node:os' import zlib from 'node:zlib' const argv = process.argv.slice(2) const arg = (name, def) => { const i = argv.indexOf(name) return i >= 0 && argv[i + 1] ? argv[i + 1] : def } const HOME = arg('--home', process.env.DSH_HOME || path.join(os.homedir(), '.dsh')) const INTERVAL = Number(arg('--interval', '1200')) const ONCE = argv.includes('--once') const STATE_DIR = path.join(HOME, 'float-window') const SNAPSHOT = path.join(STATE_DIR, 'snapshot.json') const SNAPSHOT_BRIDGE = path.join(STATE_DIR, 'snapshot-bridge.json') const WATCH = path.join(STATE_DIR, 'watch.json') const log = (...a) => { if (!argv.includes('--quiet')) console.log('[bridge]', ...a) } const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]) /** DSH 往同一个 .zstd 文件追加多个帧,zstdDecompressSync 只吃第一帧 → 按魔数切开逐帧解 */ function decompressFrames(buf) { let out = '' let idx = buf.indexOf(ZSTD_MAGIC) while (idx !== -1) { const next = buf.indexOf(ZSTD_MAGIC, idx + 4) const end = next === -1 ? buf.length : next try { out += zlib.zstdDecompressSync(buf.subarray(idx, end)).toString('utf8') } catch { } idx = next } return out } function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null } } function listSessionLogs() { const root = path.join(HOME, 'sessions') const out = [] if (!fs.existsSync(root)) return out for (const proj of fs.readdirSync(root)) { const pdir = path.join(root, proj) let isDir = false try { isDir = fs.statSync(pdir).isDirectory() } catch { } if (!isDir) continue for (const sid of fs.readdirSync(pdir)) { const f = path.join(pdir, sid, 'session.jsonl.zstd') try { out.push({ file: f, sid, mtime: fs.statSync(f).mtimeMs }) } catch { } } } return out.sort((a, b) => b.mtime - a.mtime) } function textOf(content) { if (typeof content === 'string') return content if (Array.isArray(content)) return content.map((c) => (typeof c === 'string' ? c : (c?.text ?? ''))).join('') return '' } const clip = (s, n = 4000) => (s.length > n ? s.slice(0, n) + '…' : s) /** 事件流 → 浮窗消息行 + 实时指标(轮/步精确;token 用字符估算,标 ≈) */ function rowsFromLog(text) { const rows = [] const titles = new Map() let turns = 0 let steps = 0 let outChars = 0 let userChars = 0 let contextWindow = null let streamStart = 0 let streamChars = 0 let lastChunkAt = 0 const est = (chars) => Math.round(chars / 3.2) // 中英混排的粗略 token 估算 for (const line of text.split('\n')) { if (!line) continue let e try { e = JSON.parse(line) } catch { continue } const type = String(e?.type || '') const d = e?.data || e || {} const t = e?.time || 0 if (type === 'turn/start') turns++ else if (type === 'step/start') steps++ else if (type === 'request/context' && d.contextWindow) contextWindow = d.contextWindow else if (type === 'session/title' && d.title) titles.set('title', String(d.title)) else if (type === 'user/message') { if (d.source?.kind && d.source.kind !== 'user') continue const s = textOf(d.content) if (s) { userChars += s.length; rows.push({ role: 'user', text: clip(s) }) } } else if (type === 'assistant/message') { const s = textOf(d.content ?? d.message?.content) if (s) { outChars += s.length; rows.push({ role: 'assistant', text: clip(s) }) } } else if (type === 'assistant/chunk') { const c = d.chunk || {} const kind = String(c.type || '') const piece = c.text ?? c.delta ?? (kind.includes('text') ? c.content : '') if (piece) { const now = t || Date.now() if (!streamStart || now - lastChunkAt > 8000) { streamStart = now; streamChars = 0 } lastChunkAt = now streamChars += String(piece).length outChars += String(piece).length const last = rows[rows.length - 1] if (last && last.role === 'assistant' && last.streaming) last.text += piece else rows.push({ role: 'assistant', text: String(piece), streaming: true }) } else if (kind === 'finish' && c.reason?.kind === 'error') { rows.push({ role: 'assistant', text: '⚠️ ' + String(c.reason.failure?.message || c.reason.failure || 'error').slice(0, 300) }) } } else if (type === 'tool/call') { rows.push({ role: 'tool', text: `${d.name || 'tool'}(${JSON.stringify(d.args ?? '').slice(0, 120)})` }) } } for (const r of rows) delete r.streaming const streaming = lastChunkAt && Date.now() - lastChunkAt < 8000 const tps = streaming && streamStart ? est(streamChars) / Math.max(0.5, (lastChunkAt - streamStart) / 1000) : null return { rows, titles, turns, steps, streaming, metrics: { turns, steps, tps, tokens: outChars ? est(outChars + userChars) : null, cacheHit: null, contextUsed: contextWindow ? Math.min(1, est(userChars + outChars) / contextWindow) : null, }, } } function buildSnapshot() { const cache = readJson(path.join(HOME, 'storages', 'session_projcache.json')) const table = cache?.tables?.sessions || {} const watch = readJson(WATCH) const logs = listSessionLogs() // 会话清单:以**日志**为主(实时 mtime),投影缓存只用于补标题/历史统计 const entries = [] for (const l of logs) { const rows = table[l.sid]?.rows || table[l.sid.replace(/^session-/, '')]?.rows || {} const stats = rows.sessionStats?.val || {} entries.push({ id: l.sid, title: rows.title?.val || null, cacheTurns: stats.turns ?? null, cacheSteps: stats.steps ?? null, mtime: l.mtime, log: l, }) } // 当前会话:watch.json 指定优先,否则"日志最新"(真正在跑的那个) let active = null if (watch?.sessionId) active = entries.find((e) => e.id === watch.sessionId || e.id.replace(/^session-/, '') === watch.sessionId) || null if (!active) active = entries.sort((a, b) => b.mtime - a.mtime)[0] || null if (!active) return null let rows = [] let title = active.title let metrics = { turns: active.cacheTurns, steps: active.cacheSteps, tps: null, tokens: null, cacheHit: null, contextUsed: null } let streaming = false try { const parsed = rowsFromLog(decompressFrames(fs.readFileSync(active.log.file))) rows = parsed.rows.slice(-40) title = title || parsed.titles.get('title') || null streaming = parsed.streaming // 轮/步取实时计数(缓存的那份是周期性落盘的,可能滞后) metrics = { ...parsed.metrics, turns: Math.max(parsed.metrics.turns, active.cacheTurns || 0), steps: Math.max(parsed.metrics.steps, active.cacheSteps || 0), } } catch (e) { log('log read failed:', String(e?.message || e)) } return { ok: true, at: Date.now(), source: 'mirror-bridge', approx: true, // token/上下文是字符估算,界面上会带 ≈ streaming, sessionId: active.id, pinned: watch?.sessionId || null, title, messages: rows, metrics, sessions: entries.slice(0, 20).map((e) => ({ id: e.id, title: e.title, turns: e.cacheTurns || 0, steps: e.cacheSteps || 0, messages: 0, active: e.id === active.id })), } } function writeSnapshot() { try { const snap = buildSnapshot() if (!snap) return fs.mkdirSync(STATE_DIR, { recursive: true }) // 兜底文件总是写:插件缺的字段(token/上下文估算、标题)由浮窗用它补齐 fs.writeFileSync(SNAPSHOT_BRIDGE, JSON.stringify(snap)) // 插件在写就不抢 snapshot.json:mtime 3 秒内视为它的 try { const st = fs.statSync(SNAPSHOT) if (Date.now() - st.mtimeMs < 3000 && readJson(SNAPSHOT)?.source !== 'mirror-bridge') return } catch { } fs.writeFileSync(SNAPSHOT, JSON.stringify(snap)) log(`snapshot: ${snap.sessionId} "${(snap.title || '').slice(0, 24)}" turns=${snap.metrics.turns} steps=${snap.metrics.steps} rows=${snap.messages.length} ctx=${snap.metrics.contextUsed == null ? '-' : Math.round(snap.metrics.contextUsed * 100) + '%'}`) } catch (e) { log('write failed:', String(e?.message || e)) } } writeSnapshot() if (!ONCE) setInterval(writeSnapshot, INTERVAL)