dsh-superinput 0.0.0-stage → 0.12.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/server.js ADDED
@@ -0,0 +1,4482 @@
1
+ /* dsh-superinput 服务端核心(由 scripts/build-suite.mjs 生成;勿手改)。
2
+ * 入口拆分:index.js 是零依赖探针(先 trace 再动态 import 本文件并捕获失败)——
3
+ * 顶层静态 import 的失败发生在入口任何语句之前,静默且永远取不到证据。 */
4
+ import { appendFileSync as diagAppend, mkdirSync as diagMkdir } from 'node:fs'
5
+ import { homedir as diagHomedir } from 'node:os'
6
+ import { dirname as diagDirname, join as pathJoin } from 'node:path'
7
+ import Schema from '@deepseek-ai/schemastery'
8
+ import { AsyncLocalStorage } from 'node:async_hooks'
9
+ import { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
10
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, utimesSync, writeSync, writeFileSync, appendFileSync } from 'node:fs'
11
+ import { createRequire } from 'node:module'
12
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
13
+ import { defineTool } from '@deepseek-ai/dsh-tools'
14
+ import { dirname, join } from 'node:path'
15
+ import { homedir } from 'node:os'
16
+ import { randomUUID } from 'node:crypto'
17
+
18
+ const SUITE_DIAG = pathJoin(diagHomedir(), '.dsh', 'superinput-debug.log')
19
+ const trace = (line) => {
20
+ try {
21
+ diagMkdir(diagDirname(SUITE_DIAG), { recursive: true })
22
+ diagAppend(SUITE_DIAG, `${new Date().toISOString()} ${line}\n`, 'utf8')
23
+ } catch { /* ignore */ }
24
+ }
25
+
26
+ /* ==== module: cost(原 dsh-cost) ==== */
27
+ const costModule = (() => {
28
+ /* 由 scripts/build-suite.mjs 从 dsh-cost/index.js 机械生成;勿手改,重跑脚本再生成。 */
29
+ /**
30
+ * dsh-cost — DeepSeek Harness (dsh) 会话费用统计插件
31
+ *
32
+ * 借鉴 Han-1413141/dsh-cost-meter(v1.9.39)的核心数据模式,MVP 落地:
33
+ * - 主通道 ctx.on('llm/stream', listener, { global: true }):llm 服务官方
34
+ * 瀑布事件,链尾旁路观察逐 chunk 透传,捕获 usage 块(五桶
35
+ * input/output/cacheRead/cacheWrite/reasoning,宿主的 input 与
36
+ * cacheRead/cacheWrite 是互斥桶),流结束后按 DeepSeek 官方价格入账。
37
+ * - 计费数学与峰谷规则照抄 cost-meter lib/pricing.js:现行价(2026-09-10
38
+ * 北京时间中午后)flash 谷 $0.003/$0.15/$0.60、pro 谷 $0.022/$0.66/$1.98,
39
+ * 峰时 ×2;峰窗 UTC [01,04)∪[06,10)(北京 09-12 / 14-18 点),北京周六
40
+ * 日与法定节假日全天谷价;历史流量按当时档位(rateHistory / legacyBase)。
41
+ * - 账本 ~/.dsh/storages/dsh-cost/ledger.json:按日聚合(总/按模型/按会话),
42
+ * 金额恒为美元存储(与展示币种解耦,cost-meter 同款),临时文件原子
43
+ * 重命名 + 防抖写盘。
44
+ * - HTTP 端点 GET /api/dsh-cost/state|prices 供客户端渲染。
45
+ * - 异步局部存储(ALS)深度标记防包装路由插件嵌套重复入账(cost-meter
46
+ * 同款;本插件自己从不发起 llm 调用)。
47
+ *
48
+ * 已核对的 dsh API(对照 dsh-reference 与 cost-meter 源码):
49
+ * - ctx.on('llm/stream', (options, next) => AsyncIterable, { global: true })
50
+ * 监听器契约:必须调用 next() 推进瀑布并返回(包装的)流;嵌套时原样
51
+ * 返回 next() 的下游
52
+ * - GenerateOptions:{ sessionId?, model, provider, messages, ... }
53
+ * - StreamChunk:{ type:'usage', usage:{inputTokens,outputTokens,
54
+ * cacheReadTokens,cacheWriteTokens,reasoningTokens} }(usage 位于 finish 前)
55
+ * - webServer.register({ kind:'prefix', path, handler }, label)
56
+ */
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+ const name = 'cost'
66
+
67
+ const DIAG_LOG = join(homedir(), '.dsh', 'cost-debug.log')
68
+ const STORE_DIR = join(homedir(), '.dsh', 'storages', 'dsh-cost')
69
+ const LEDGER_PATH = join(STORE_DIR, 'ledger.json')
70
+ const JOURNAL_PATH = join(STORE_DIR, 'journal.jsonl')
71
+
72
+ const report = async (line) => {
73
+ try {
74
+ await mkdir(dirname(DIAG_LOG), { recursive: true })
75
+ await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
76
+ } catch { /* ignore */ }
77
+ }
78
+
79
+ /* ------------------------------------------------------------------ */
80
+ /* 价格表(USD / 1M tokens;照抄 cost-meter lib/pricing.js 现行档) */
81
+ /* ------------------------------------------------------------------ */
82
+
83
+ /** 峰谷时代分界(2026-08-16 16:00 UTC):此前按历史基础价,无峰谷概念。 */
84
+ const LEGACY_BASE_BOUNDARY = Date.parse('2026-08-16T16:00:00Z')
85
+ /** Flash 调价边界(2026-09-10 北京时间中午 12:00 = UTC 04:00);Pro 未调价。 */
86
+ const FLASH_PRICE_EFFECTIVE_AT = Date.parse('2026-09-10T04:00:00Z')
87
+ /** 周末全天谷价生效时刻(2026-08-23 北京时间零点)。 */
88
+ const WEEKEND_OFFPEAK_EFFECTIVE_AT = Date.parse('2026-08-22T16:00:00Z')
89
+
90
+ /** 峰时段窗口(UTC 小时,半开区间 [start,end))= 北京 09-12 / 14-18 点。 */
91
+ const PEAK_WINDOWS = [{ start: 1, end: 4 }, { start: 6, end: 10 }]
92
+ /** 国务院办公厅 2026 年安排中峰谷规则生效后的剩余公众假期(北京日历日)。 */
93
+ const PEAK_HOLIDAYS = new Set([
94
+ '2026-09-25', '2026-09-26', '2026-09-27',
95
+ '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04',
96
+ '2026-10-05', '2026-10-06', '2026-10-07',
97
+ ])
98
+
99
+ const double = (tier) => Object.fromEntries(Object.entries(tier).map(([key, value]) => [key, value * 2]))
100
+
101
+ /** 档位形状:{ legacyBase, august, offPeak, peak }(后三者带峰谷两档)。 */
102
+ const deepseekFlash = {
103
+ legacyBase: { cacheHit: 0.0028, cacheMiss: 0.14, output: 0.28 },
104
+ august: { cacheHit: 0.007, cacheMiss: 0.22, output: 0.66, peak: double({ cacheHit: 0.007, cacheMiss: 0.22, output: 0.66 }) },
105
+ offPeak: { cacheHit: 0.003, cacheMiss: 0.15, output: 0.6 },
106
+ peak: double({ cacheHit: 0.003, cacheMiss: 0.15, output: 0.6 }),
107
+ }
108
+ const deepseekPro = {
109
+ legacyBase: { cacheHit: 0.003625, cacheMiss: 0.435, output: 0.87 },
110
+ august: { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98, peak: double({ cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 }) },
111
+ offPeak: { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 },
112
+ peak: double({ cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 }),
113
+ }
114
+ /** 已下架旧模型的唯一历史价(只可能出现在峰谷时代之前)。 */
115
+ const legacyOnlyModels = {
116
+ deepseekchat: { legacyBase: { cacheHit: 0.07, cacheMiss: 0.27, output: 1.1 } },
117
+ deepseekreasoner: { legacyBase: { cacheHit: 0.14, cacheMiss: 0.55, output: 2.19 } },
118
+ }
119
+
120
+ const normalizeModelId = (value) => String(value ?? '').toLowerCase().replace(/[._\-\s]/g, '')
121
+
122
+ function isPeakAt(atMs) {
123
+ // 北京日界 = UTC+8;1970-01-01 为周四(weekday 4)。
124
+ const day = Math.floor((atMs + 8 * 3600000) / 86400000)
125
+ const weekday = (day + 4) % 7
126
+ if (atMs >= WEEKEND_OFFPEAK_EFFECTIVE_AT && (weekday === 6 || weekday === 0)) return false
127
+ const key = new Date(day * 86400000).toISOString().slice(0, 10)
128
+ if (atMs >= WEEKEND_OFFPEAK_EFFECTIVE_AT && PEAK_HOLIDAYS.has(key)) return false
129
+ const hour = new Date(atMs).getUTCHours()
130
+ return PEAK_WINDOWS.some(({ start, end }) => hour >= start && hour < end)
131
+ }
132
+
133
+ /** 选档:历史时刻回落历史价(rateHistory 语义的简化实现)。 */
134
+ function tierFor(entry, atMs, peak) {
135
+ if (entry.legacyBase && atMs < LEGACY_BASE_BOUNDARY) return entry.legacyBase
136
+ if (entry.august && atMs < FLASH_PRICE_EFFECTIVE_AT) return entry.august
137
+ return (peak && entry.peak) || entry.offPeak || entry.peak || entry.august || entry.legacyBase || { cacheHit: 0, cacheMiss: 0, output: 0 }
138
+ }
139
+
140
+ /**
141
+ * 单次调用成本(USD)。数学照抄 cost-meter costOf:五桶互斥,
142
+ * cacheWrite 缺价按 cacheHit,reasoning 缺价按 0(DeepSeek 推理并入输出)。
143
+ */
144
+ function costOfBuckets(buckets, tier) {
145
+ const amount = (value) => { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : 0 }
146
+ const cost = (amount(buckets.input) * tier.cacheMiss
147
+ + amount(buckets.output) * tier.output
148
+ + amount(buckets.cacheRead) * tier.cacheHit
149
+ + amount(buckets.cacheWrite) * (tier.cacheWrite ?? tier.cacheHit)
150
+ + amount(buckets.reasoning) * (typeof tier.reasoning === 'number' ? tier.reasoning : 0)) / 1_000_000
151
+ return Number.isFinite(cost) && cost > 0 ? cost : 0
152
+ }
153
+
154
+ /* ------------------------------------------------------------------ */
155
+ /* 用量指纹去重(cost-meter issue #70/#76 同款兜底) */
156
+ /* ------------------------------------------------------------------ */
157
+
158
+ /** 桶名 → 价格键(costOfBuckets 同款映射;cacheWrite 缺价按 cacheHit)。 */
159
+ const BUCKET_PRICE_KEYS = { input: 'cacheMiss', output: 'output', cacheRead: 'cacheHit', cacheWrite: 'cacheWrite', reasoning: 'reasoning' }
160
+
161
+ /** 一次调用的分桶计价行(cost-meter costFromSamples 同形状),供回合明细。 */
162
+ function bucketRows(buckets, tier) {
163
+ const rows = []
164
+ for (const [bucket, priceKey] of Object.entries(BUCKET_PRICE_KEYS)) {
165
+ const tokens = Number(buckets[bucket]) > 0 ? Number(buckets[bucket]) : 0
166
+ if (!tokens) continue
167
+ const rate = Number(tier[priceKey] ?? (priceKey === 'cacheWrite' ? tier.cacheHit : 0))
168
+ rows.push({ bucket, tokens, rate, cost: tokens * rate / 1_000_000 })
169
+ }
170
+ return rows
171
+ }
172
+
173
+ /**
174
+ * (model, 五桶指纹) 10s 窗互斥:包装路由插件在 ALS 标记逃逸的异步上下文里
175
+ * 发起上游调用时,同一请求的两份 usage 都会到达这里——五桶逐位相同即判重,
176
+ * 无论到达顺序只记第一次。正常独立调用五桶逐位相同的概率可忽略。
177
+ */
178
+ function createUsageDeduper(windowMs = 10_000) {
179
+ const seen = new Map()
180
+ return (model, buckets, atMs) => {
181
+ const fingerprint = `${normalizeModelId(model)}|${buckets.input}|${buckets.output}|${buckets.cacheRead}|${buckets.cacheWrite}|${buckets.reasoning}`
182
+ const last = seen.get(fingerprint)
183
+ if (last !== undefined && atMs - last < windowMs) return false
184
+ seen.set(fingerprint, atMs)
185
+ if (seen.size > 512) {
186
+ const cutoff = atMs - windowMs
187
+ for (const [key, seenAt] of seen) if (seenAt < cutoff) seen.delete(key)
188
+ }
189
+ return true
190
+ }
191
+ }
192
+
193
+ /* ------------------------------------------------------------------ */
194
+ /* 调用明细 journal(JSONL 追加;rotate 保留最近 3 卷) */
195
+ /* ------------------------------------------------------------------ */
196
+
197
+ const JOURNAL_MAX_BYTES = 8 * 1024 * 1024
198
+ const JOURNAL_KEEP = 3
199
+
200
+ function createJournal() {
201
+ let lines = 0
202
+ return {
203
+ /** 明细纯记录,写失败绝不影响聚合账本。 */
204
+ async write(record) {
205
+ try {
206
+ lines += 1
207
+ if (lines % 500 === 0) {
208
+ const info = await stat(JOURNAL_PATH).catch(() => null)
209
+ if (info && info.size > JOURNAL_MAX_BYTES) {
210
+ await rename(JOURNAL_PATH, join(STORE_DIR, `journal-${Date.now()}.jsonl`)).catch(() => {})
211
+ try {
212
+ const entries = await readdir(STORE_DIR)
213
+ const archives = entries.filter((name) => /^journal-\d+\.jsonl$/.test(name)).sort()
214
+ while (archives.length > JOURNAL_KEEP) {
215
+ await unlink(join(STORE_DIR, archives.shift())).catch(() => {})
216
+ }
217
+ } catch { /* ignore */ }
218
+ }
219
+ }
220
+ await appendFile(JOURNAL_PATH, `${JSON.stringify(record)}\n`, 'utf8')
221
+ } catch { /* ignore */ }
222
+ },
223
+ /** 读当前卷的倒序明细(归档卷不参与,最近 UI 只看当下)。 */
224
+ async recent({ sessionId = '', day = '', limit = 20 } = {}) {
225
+ let text = ''
226
+ try { text = await readFile(JOURNAL_PATH, 'utf8') } catch { return [] }
227
+ const rows = []
228
+ for (const line of text.split('\n')) {
229
+ if (!line) continue
230
+ try {
231
+ const row = JSON.parse(line)
232
+ if (sessionId && row.session !== sessionId) continue
233
+ if (day && row.day !== day) continue
234
+ rows.push(row)
235
+ } catch { /* 半行跳过 */ }
236
+ }
237
+ rows.reverse()
238
+ return rows.slice(0, Math.min(200, Math.max(1, limit)))
239
+ },
240
+ /**
241
+ * 增量读取:从字节偏移起新增的完整行(字节级,避免多字节字符错位;
242
+ * 轮转后新文件比偏移小 → 从头读新卷,天然不会重放旧卷)。
243
+ * 供锁持有者把 journal 折叠进 ledger(跨进程单写者模型)。
244
+ */
245
+ async recordsAfter(offset = 0) {
246
+ try {
247
+ const info = await stat(JOURNAL_PATH)
248
+ if (info.size === offset) return { rows: [], offset }
249
+ if (info.size < offset) offset = 0
250
+ const buf = await readFile(JOURNAL_PATH)
251
+ let start = offset
252
+ if (offset > 0) {
253
+ // 偏移已在行边界(前一字节是换行)则直接用;落在行中间才跳到下一行。
254
+ if (buf[offset - 1] !== 10) {
255
+ const nl = buf.indexOf(10, offset)
256
+ if (nl < 0) return { rows: [], offset: buf.length }
257
+ start = nl + 1
258
+ }
259
+ }
260
+ const rows = []
261
+ for (const line of buf.slice(start).toString('utf8').split('\n')) {
262
+ if (!line) continue
263
+ try { rows.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
264
+ }
265
+ return { rows, offset: buf.length }
266
+ } catch { return { rows: [], offset } }
267
+ },
268
+
269
+ /**
270
+ * 回合费用:会话在 [from,to] 时间窗内的调用聚合(客户端 turnTail 槽位
271
+ * 传宿主 turn/start~turn/end 事件时间)。journal 只保当前卷——轮转掉的
272
+ * 旧回合查不到,金额对 UI 恒标「≈」估算口径。
273
+ */ async range({ sessionId = '', from = 0, to = Number.MAX_SAFE_INTEGER } = {}) {
274
+ let text = ''
275
+ try { text = await readFile(JOURNAL_PATH, 'utf8') } catch { return { found: false, cost: 0, rows: [], calls: [] } }
276
+ const calls = []
277
+ const parts = new Map()
278
+ for (const line of text.split('\n')) {
279
+ if (!line) continue
280
+ let row
281
+ try { row = JSON.parse(line) } catch { continue }
282
+ if (row.session !== sessionId || !(row.at >= from && row.at <= to)) continue
283
+ calls.push({ model: row.model, provider: row.provider, at: row.at, cost: row.cost })
284
+ for (const item of Array.isArray(row.rows) ? row.rows : []) {
285
+ const key = JSON.stringify([row.provider, row.model, item.bucket])
286
+ const part = parts.get(key) ?? { provider: row.provider, model: row.model, bucket: item.bucket, tokens: 0, rate: item.rate, cost: 0 }
287
+ part.tokens += item.tokens
288
+ part.cost += item.cost
289
+ parts.set(key, part)
290
+ }
291
+ }
292
+ calls.sort((a, b) => a.at - b.at)
293
+ const rows = [...parts.values()].sort((a, b) => b.cost - a.cost)
294
+ return { found: calls.length > 0, cost: calls.reduce((sum, call) => sum + call.cost, 0), rows, calls }
295
+ },
296
+ }
297
+ }
298
+
299
+ /* ------------------------------------------------------------------ */
300
+ /* 账本 */
301
+ /* ------------------------------------------------------------------ */
302
+
303
+ const zeroBuckets = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 })
304
+ const localDayKey = (atMs) => {
305
+ const d = new Date(atMs)
306
+ const pad = (n) => String(n).padStart(2, '0')
307
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
308
+ }
309
+
310
+ function createLedger(retentionDays) {
311
+ let state = { version: 1, days: {}, imported: {} }
312
+ let dirty = false
313
+ let flushTimer = null
314
+ /** 最近调用明细(内存,重启清空):费用弹卡按时间倒序展示。 */
315
+ const RECENT_CALLS_MAX = 200
316
+ const recentCalls = []
317
+
318
+ const load = () => {
319
+ try {
320
+ const raw = JSON.parse(readFileSync(LEDGER_PATH, 'utf8'))
321
+ if (raw && typeof raw === 'object' && raw.days && typeof raw.days === 'object') state = raw
322
+ } catch { /* 首次运行或损坏:从空账本开始 */ }
323
+ }
324
+
325
+ const flushSync = () => {
326
+ if (!dirty) return
327
+ try {
328
+ // 保留天数裁剪:超龄日直接移除(cost-meter 同口径)。
329
+ if (Number.isFinite(retentionDays) && retentionDays > 0) {
330
+ const cutoff = localDayKey(Date.now() - retentionDays * 86400000)
331
+ for (const key of Object.keys(state.days)) if (key < cutoff) delete state.days[key]
332
+ }
333
+ mkdirSync(STORE_DIR, { recursive: true })
334
+ const tmp = `${LEDGER_PATH}.${process.pid}.tmp`
335
+ writeFileSync(tmp, JSON.stringify(state), 'utf8')
336
+ renameSync(tmp, LEDGER_PATH)
337
+ dirty = false
338
+ } catch (error) {
339
+ void report(`ledger flush failed: ${String(error?.message ?? error).slice(0, 200)}`)
340
+ }
341
+ }
342
+
343
+ const scheduleFlush = () => {
344
+ dirty = true
345
+ if (flushTimer) return
346
+ flushTimer = setTimeout(() => { flushTimer = null; flushSync() }, 800)
347
+ flushTimer.unref?.()
348
+ }
349
+
350
+ const dayOf = (atMs) => {
351
+ const key = localDayKey(atMs)
352
+ let day = state.days[key]
353
+ if (!day) {
354
+ day = { ...zeroBuckets(), calls: 0, cost: 0, byModel: {}, sessions: {} }
355
+ state.days[key] = day
356
+ }
357
+ return day
358
+ }
359
+
360
+ return {
361
+ load,
362
+ flushSync,
363
+ dispose() { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null } flushSync() },
364
+ /** 回填游标:该会话日志已覆盖到的事件时刻(实时记账同样推进,幂等切割)。 */
365
+ importedAt(sessionKey) {
366
+ return state.imported?.[sessionKey] ?? 0
367
+ },
368
+ markImported(sessionKey, atMs) {
369
+ if (!state.imported) state.imported = {}
370
+ const current = state.imported[sessionKey] ?? 0
371
+ if (atMs <= current) return false
372
+ state.imported[sessionKey] = atMs
373
+ // 裁剪:只保留最近 500 个会话的游标。
374
+ const keys = Object.keys(state.imported)
375
+ if (keys.length > 500) {
376
+ for (const key of keys.slice(0, keys.length - 500)) delete state.imported[key]
377
+ }
378
+ scheduleFlush()
379
+ return true
380
+ },
381
+ /** journal 折叠进度(字节偏移,随 ledger 一起落盘 → 崩溃安全,不重不漏)。 */
382
+ journalOffset() { return state.journalOffset ?? 0 },
383
+ setJournalOffset(offset) {
384
+ if (!Number.isFinite(offset) || offset <= (state.journalOffset ?? 0)) return
385
+ state.journalOffset = offset
386
+ scheduleFlush()
387
+ },
388
+ /** 官方同步的现行价格(flash/pro 峰谷档)与汇率——覆盖内置表,历史档不动。 */
389
+ livePrices() { return state.live?.prices ?? null },
390
+ setLivePrices(tiers) {
391
+ state.live = { ...(state.live ?? {}), prices: tiers }
392
+ scheduleFlush()
393
+ },
394
+ liveExchangeRate() { return state.live?.exchangeRate ?? null },
395
+ setExchangeRate(rate, fetchedAt) {
396
+ state.live = { ...(state.live ?? {}), exchangeRate: { rate, fetchedAt } }
397
+ scheduleFlush()
398
+ },
399
+ /** 最近调用明细,时间倒序(费用弹卡用)。 */
400
+ recentCalls(limit = 50) {
401
+ return recentCalls.slice(-Math.max(1, limit)).reverse()
402
+ },
403
+ /** 累加一次模型调用的用量与费用。source 区分对话 / 增强 / 建议生成。 */
404
+ account({ sessionId, model, buckets, cost, atMs, source }) {
405
+ const day = dayOf(atMs)
406
+ const src = typeof source === 'string' && source ? source : 'chat'
407
+ const addTo = (target) => {
408
+ for (const key of Object.keys(zeroBuckets())) target[key] += buckets[key] ?? 0
409
+ target.calls += 1
410
+ target.cost += cost
411
+ }
412
+ addTo(day)
413
+ // 按来源分账,费用弹卡才能把「增强」和「对话」分开显示。
414
+ const bySource = day.bySource ?? (day.bySource = {})
415
+ const srcEntry = bySource[src] ?? (bySource[src] = { ...zeroBuckets(), calls: 0, cost: 0 })
416
+ addTo(srcEntry)
417
+ // 最近调用明细(内存环形缓冲,重启清空;历史总额仍走 days)。
418
+ recentCalls.push({
419
+ at: atMs, session: sessionId || '', model: model || '', source: src,
420
+ tokens: (buckets.input ?? 0) + (buckets.output ?? 0) + (buckets.cacheRead ?? 0) + (buckets.cacheWrite ?? 0),
421
+ input: buckets.input ?? 0, output: buckets.output ?? 0,
422
+ cacheRead: buckets.cacheRead ?? 0, cost,
423
+ })
424
+ if (recentCalls.length > RECENT_CALLS_MAX) recentCalls.splice(0, recentCalls.length - RECENT_CALLS_MAX)
425
+ if (model) {
426
+ const entry = day.byModel[model] ?? (day.byModel[model] = { ...zeroBuckets(), calls: 0, cost: 0 })
427
+ addTo(entry)
428
+ }
429
+ if (sessionId) {
430
+ const session = day.sessions[sessionId] ?? (day.sessions[sessionId] = { ...zeroBuckets(), calls: 0, cost: 0, at: 0 })
431
+ addTo(session)
432
+ const sessionBySource = session.bySource ?? (session.bySource = {})
433
+ const sessionSrc = sessionBySource[src] ?? (sessionBySource[src] = { ...zeroBuckets(), calls: 0, cost: 0 })
434
+ addTo(sessionSrc)
435
+ session.at = Math.max(session.at, atMs)
436
+ // 回填切割游标同步推进:回填只补「游标之后」的日志事件,幂等不重复。
437
+ if (!state.imported) state.imported = {}
438
+ if (atMs > (state.imported[sessionId] ?? 0)) state.imported[sessionId] = atMs
439
+ }
440
+ scheduleFlush()
441
+ },
442
+ snapshot() { return state },
443
+ }
444
+ }
445
+
446
+ /* ------------------------------------------------------------------ */
447
+ /* 历史回填(cost-meter backfill 同思路:重放会话日志的 usage 事件) */
448
+ /* ------------------------------------------------------------------ */
449
+
450
+ const SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
451
+ /** 单日志解压上限(大会话整串解压占内存,超限跳过并留痕)。 */
452
+ const BACKFILL_MAX_LOG_BYTES = 64 * 1024 * 1024
453
+ const num = (value) => { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : 0 }
454
+
455
+ /** 多帧 zstd(追加写每帧独立):按魔数切段逐帧解压,坏帧跳过。 */
456
+ async function decompressSessionLog(file) {
457
+ const info = await stat(file).catch(() => null)
458
+ if (!info || info.size > BACKFILL_MAX_LOG_BYTES) return null
459
+ if (file.endsWith('.zstd')) {
460
+ const zlib = await import('node:zlib')
461
+ if (typeof zlib.zstdDecompressSync !== 'function') return null
462
+ const buf = await readFile(file)
463
+ const idxs = [0]
464
+ for (let i = 4; i <= buf.length - 4; i += 1) {
465
+ if (buf[i] === 0x28 && buf[i + 1] === 0xB5 && buf[i + 2] === 0x2F && buf[i + 3] === 0xFD) idxs.push(i)
466
+ }
467
+ let out = ''
468
+ for (let k = 0; k < idxs.length; k += 1) {
469
+ try { out += zlib.zstdDecompressSync(buf.subarray(idxs[k], idxs[k + 1] ?? buf.length)).toString('utf8') } catch { /* 截断/坏帧跳过 */ }
470
+ }
471
+ return out
472
+ }
473
+ return readFile(file, 'utf8')
474
+ }
475
+
476
+ /** 目录内最新的会话日志(版本号最大、zstd 优先)。 */
477
+ async function pickSessionLog(dir) {
478
+ const entries = await readdir(dir).catch(() => [])
479
+ const logs = entries.filter((name) => /^session(\.v\d+)?\.jsonl(\.zstd)?$/.test(name))
480
+ if (logs.length === 0) return null
481
+ logs.sort((a, b) => {
482
+ const versionOf = (name) => Number((name.match(/\.v(\d+)\./) ?? [])[1] ?? 0)
483
+ const zstdOf = (name) => (name.endsWith('.zstd') ? 1 : 0)
484
+ return (versionOf(a) - versionOf(b)) || (zstdOf(a) - zstdOf(b))
485
+ })
486
+ return join(dir, logs[logs.length - 1])
487
+ }
488
+
489
+ /**
490
+ * 重放会话事件流 → 用量样本(cost-meter replaySessionRecords 核心子集):
491
+ * request/header 切换当前路由;usage 取自 assistant/chunk(usage 块) /
492
+ * assistant/message(data.usage) / compaction/summary,按「回合位置 + 五桶
493
+ * 指纹」去重(同一步的 chunk 与 message 双份只记一次,retry 用量不同照记);
494
+ * 只取游标之后的事件(与实时计量幂等切割)。
495
+ */
496
+ function samplesFromEvents(events, cursorAtMs) {
497
+ let provider = 'deepseek'
498
+ let model = 'default'
499
+ let sessionId = ''
500
+ const seen = new Set()
501
+ const samples = []
502
+ for (const event of events) {
503
+ if (event.type === 'request/header') {
504
+ const header = event.data?.header?.config
505
+ if (typeof header?.model === 'string' && header.model) model = header.model
506
+ if (typeof header?.provider === 'string' && header.provider) provider = header.provider
507
+ continue
508
+ }
509
+ if (event.type === 'session' && typeof event.id === 'string' && !sessionId) sessionId = event.id
510
+ let usage = null
511
+ let positionKey = ''
512
+ if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage' && event.data.chunk.usage != null) {
513
+ usage = event.data.chunk.usage
514
+ positionKey = `s:${num(event.data?.turn)}:${num(event.data?.step)}`
515
+ } else if (event.type === 'assistant/message' && event.data?.usage != null) {
516
+ usage = event.data.usage
517
+ positionKey = `s:${num(event.data?.turn)}:${num(event.data?.step)}`
518
+ const source = event.data?.message?.source
519
+ if (typeof source?.model === 'string' && source.model) model = source.model
520
+ if (typeof source?.provider === 'string' && source.provider) provider = source.provider
521
+ } else if (event.type === 'compaction/summary' && event.data?.usage != null) {
522
+ usage = event.data.usage
523
+ positionKey = `c:${event.seq ?? ''}`
524
+ } else {
525
+ continue
526
+ }
527
+ const atMs = Number(event.time)
528
+ if (!Number.isFinite(atMs) || atMs <= 0 || atMs <= cursorAtMs) continue
529
+ const buckets = {
530
+ input: num(usage.inputTokens),
531
+ output: num(usage.outputTokens),
532
+ cacheRead: num(usage.cacheReadTokens),
533
+ cacheWrite: num(usage.cacheWriteTokens),
534
+ reasoning: num(usage.reasoningTokens),
535
+ }
536
+ const fingerprint = `${positionKey}|${buckets.input}|${buckets.output}|${buckets.cacheRead}|${buckets.cacheWrite}|${buckets.reasoning}`
537
+ if (seen.has(fingerprint)) continue
538
+ seen.add(fingerprint)
539
+ samples.push({ provider, model, buckets, atMs })
540
+ }
541
+ return { sessionId, samples }
542
+ }
543
+
544
+ /**
545
+ * 扫描全部会话日志回填历史(仅锁持有者,boot 后延迟触发;游标幂等)。
546
+ * 0.7.0 起回填样本写 journal(与实时计量同源),由 ingest 统一折叠——
547
+ * 回填与实时对同一调用的重叠由 ingest 的指纹去重互斥,永不双计。
548
+ */
549
+ async function runBackfill(journal, ledger, priceEntryFor) {
550
+ const workspaces = await readdir(SESSIONS_ROOT, { withFileTypes: true }).catch(() => [])
551
+ let touchedSessions = 0
552
+ let importedCalls = 0
553
+ for (const workspace of workspaces) {
554
+ if (!workspace.isDirectory()) continue
555
+ const workspaceDir = join(SESSIONS_ROOT, workspace.name)
556
+ const sessionDirs = await readdir(workspaceDir, { withFileTypes: true }).catch(() => [])
557
+ for (const sessionDir of sessionDirs) {
558
+ if (!sessionDir.isDirectory()) continue
559
+ const logFile = await pickSessionLog(join(workspaceDir, sessionDir.name))
560
+ if (!logFile) continue
561
+ try {
562
+ const text = await decompressSessionLog(logFile)
563
+ if (text === null) continue
564
+ const events = []
565
+ for (const line of text.split('\n')) {
566
+ if (!line) continue
567
+ try { events.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
568
+ }
569
+ const probe = samplesFromEvents(events, 0)
570
+ const sessionId = probe.sessionId
571
+ const { samples } = samplesFromEvents(events, sessionId ? ledger.importedAt(sessionId) : 0)
572
+ let maxAt = 0
573
+ for (const sample of samples) {
574
+ const entry = priceEntryFor(sample.model)
575
+ if (!entry) continue
576
+ const peak = isPeakAt(sample.atMs)
577
+ const tier = tierFor(entry, sample.atMs, peak)
578
+ const cost = costOfBuckets(sample.buckets, tier)
579
+ await journal.write({
580
+ at: sample.atMs,
581
+ day: localDayKey(sample.atMs),
582
+ session: sessionId || '',
583
+ model: sample.model,
584
+ provider: sample.provider,
585
+ ...sample.buckets,
586
+ rows: bucketRows(sample.buckets, tier),
587
+ cost,
588
+ peak,
589
+ })
590
+ maxAt = Math.max(maxAt, sample.atMs)
591
+ importedCalls += 1
592
+ }
593
+ if (sessionId && maxAt > 0 && ledger.markImported(sessionId, maxAt)) touchedSessions += 1
594
+ // 让出事件循环:几百个会话的解压不能卡住宿主启动。
595
+ await new Promise((resolve) => setImmediate(resolve))
596
+ } catch (error) {
597
+ void report(`backfill ${sessionDir.name} failed: ${String(error?.message ?? error).slice(0, 160)}`)
598
+ }
599
+ }
600
+ }
601
+ if (importedCalls > 0) void report(`backfill appended ${importedCalls} journal rows across ${touchedSessions} sessions`)
602
+ else void report('backfill done: nothing new')
603
+ }
604
+
605
+ /** 下一次峰谷状态翻转时刻(48h 内逐分钟扫描;周末/节假日边界自然覆盖)。 */
606
+ function nextPeakSwitch(now) {
607
+ const current = isPeakAt(now)
608
+ const start = Math.floor(now / 60_000) * 60_000 + 60_000
609
+ for (let ms = start; ms <= now + 48 * 3_600_000; ms += 60_000) {
610
+ if (isPeakAt(ms) !== current) return { atMs: ms, isPeak: isPeakAt(ms) }
611
+ }
612
+ return null
613
+ }
614
+
615
+ /** 官方定价页(预渲染 SSR)解析 → flash/pro 峰谷八字段(USD/1M)。 */
616
+ const OFFICIAL_PRICING_URL = 'https://api-docs.deepseek.com/quick_start/pricing/'
617
+
618
+ function parseOfficialPricing(html) {
619
+ const text = html.replace(/<script[\s\S]*?<\/script>/g, ' ').replace(/<[^>]*>/g, ' ')
620
+ .replace(/&amp;/g, '&').replace(/\s+/g, ' ')
621
+ const startIdx = text.indexOf('PRICING')
622
+ const endIdx = text.indexOf('Concurrency Limit', startIdx)
623
+ if (startIdx < 0 || endIdx < 0) throw new Error('pricing section not found')
624
+ const section = text.slice(startIdx, endIdx)
625
+ const nums = [...section.matchAll(/\$([0-9.]+)/g)].map((m) => Number(m[1]))
626
+ if (nums.length < 12) throw new Error(`pricing layout changed: expected 12 rates, got ${nums.length}`)
627
+ // 页面顺序(deepseek-flash | deepseek-v4-pro 两列):
628
+ // CACHE HIT off(a,b) peak(c,d) · CACHE MISS off(e,f) peak(g,h) · OUTPUT off(i,j) peak(k,l)
629
+ const [fhOff, phOff, fhPk, phPk, fmOff, pmOff, fmPk, pmPk, foOff, poOff, foPk, poPk] = nums
630
+ const pair = (flash, pro, label) => {
631
+ if (!(flash > 0 && pro > 0)) throw new Error(`pricing layout changed: invalid ${label}`)
632
+ return { flash, pro }
633
+ }
634
+ return {
635
+ fetchedAt: Date.now(),
636
+ currency: 'USD',
637
+ cacheHit: { offPeak: pair(fhOff, phOff, 'cacheHit.offPeak'), peak: pair(fhPk, phPk, 'cacheHit.peak') },
638
+ cacheMiss: { offPeak: pair(fmOff, pmOff, 'cacheMiss.offPeak'), peak: pair(fmPk, pmPk, 'cacheMiss.peak') },
639
+ output: { offPeak: pair(foOff, poOff, 'output.offPeak'), peak: pair(foPk, poPk, 'output.peak') },
640
+ }
641
+ }
642
+
643
+ async function fetchOfficialPricing() {
644
+ const res = await fetch(OFFICIAL_PRICING_URL, { headers: { 'user-agent': 'dsh-cost' }, signal: AbortSignal.timeout(15_000) })
645
+ if (!res.ok) throw new Error(`official pricing page HTTP ${res.status}`)
646
+ return parseOfficialPricing(await res.text())
647
+ }
648
+
649
+ /** 汇率(USD→CNY),frankfurter.dev 免钥公开端点。 */
650
+ async function fetchExchangeRate() {
651
+ const res = await fetch('https://api.frankfurter.dev/v1/latest?base=USD&symbols=CNY', { signal: AbortSignal.timeout(10_000) })
652
+ if (!res.ok) throw new Error(`frankfurter HTTP ${res.status}`)
653
+ const data = await res.json()
654
+ const rate = Number(data?.rates?.CNY)
655
+ if (!(rate > 0)) throw new Error('frankfurter returned no CNY rate')
656
+ return rate
657
+ }
658
+
659
+ /** 把官方解析结果折成 tier 形状(flash/pro 各一),并校验峰=谷×2(±1%)。 */
660
+ function tiersFromOfficial(parsed) {
661
+ const build = (family) => {
662
+ const offPeak = { cacheHit: parsed.cacheHit.offPeak[family], cacheMiss: parsed.cacheMiss.offPeak[family], output: parsed.output.offPeak[family] }
663
+ const peak = { cacheHit: parsed.cacheHit.peak[family], cacheMiss: parsed.cacheMiss.peak[family], output: parsed.output.peak[family] }
664
+ for (const key of Object.keys(offPeak)) {
665
+ if (Math.abs(peak[key] - offPeak[key] * 2) > offPeak[key] * 0.011) throw new Error(`peak != 2×offPeak for ${family}.${key} (official layout changed)`)
666
+ }
667
+ return { offPeak, peak: double(offPeak) }
668
+ }
669
+ return { fetchedAt: parsed.fetchedAt, currency: parsed.currency, flash: build('flash'), pro: build('pro') }
670
+ }
671
+
672
+ /* ------------------------------------------------------------------ */
673
+ /* 跨进程单写者锁(双进程同 boot 实锤;非持有者只 append journal) */
674
+ /* ------------------------------------------------------------------ */
675
+
676
+ const LOCK_STALE_MS = 30_000
677
+
678
+ function acquireLedgerLock(path) {
679
+ const tryWrite = () => {
680
+ try {
681
+ const fd = openSync(path, 'wx')
682
+ writeSync(fd, String(process.pid))
683
+ closeSync(fd)
684
+ return true
685
+ } catch (error) {
686
+ if (error?.code === 'EEXIST') return false
687
+ throw error
688
+ }
689
+ }
690
+ if (tryWrite()) return { owner: true }
691
+ try {
692
+ const info = statSync(path)
693
+ const holder = Number(readFileSync(path, 'utf8'))
694
+ let alive = false
695
+ if (Number.isSafeInteger(holder) && holder > 0) {
696
+ try { process.kill(holder, 0); alive = true } catch (error) { alive = error?.code === 'EPERM' }
697
+ }
698
+ if (!alive || Date.now() - info.mtimeMs > LOCK_STALE_MS) {
699
+ // 持有者已死或心跳超时:抢占(万一并发抢占,后写者败走非持有者,无害)。
700
+ try { unlinkSync(path) } catch { /* ignore */ }
701
+ return { owner: tryWrite() }
702
+ }
703
+ } catch { return { owner: tryWrite() } }
704
+ return { owner: false }
705
+ }
706
+
707
+ function touchLedgerLock(path) {
708
+ try { utimesSync(path, new Date(), new Date()) } catch { /* ignore */ }
709
+ }
710
+
711
+ function releaseLedgerLock(path) {
712
+ try {
713
+ if (Number(readFileSync(path, 'utf8')) === process.pid) unlinkSync(path)
714
+ } catch { /* ignore */ }
715
+ }
716
+
717
+ /* ------------------------------------------------------------------ */
718
+ /* 插件入口 */
719
+ /* ------------------------------------------------------------------ */
720
+ const moduleInject = []
721
+
722
+ /** 官方余额查询:host 白名单(安全约束——只允许官方域名,内网/环回不可能出现)。 */
723
+ const BALANCE_HOST = 'api.deepseek.com'
724
+
725
+ /* ------------------------------------------------------------------ */
726
+ /* Coding Plan 订阅配额(对标 dsh-cost-meter lib/coding-plans.js 的 */
727
+ /* Bearer 子集:端点/解析/降级链照抄上游,全部官方域名硬编码白名单) */
728
+ /* ------------------------------------------------------------------ */
729
+
730
+ /** 0-100 已用百分比(保留 1 位)。 */
731
+ const clampPct = (p) => Math.max(0, Math.min(100, Math.round(p * 10) / 10))
732
+
733
+ /** 归一化百分比:0-1 视为小数,>=1 视为已是百分数;非法 → null。 */
734
+ function normalizePercent(value) {
735
+ const n = Number(value)
736
+ if (!Number.isFinite(n) || n < 0) return null
737
+ return clampPct(n <= 1 ? n * 100 : n)
738
+ }
739
+
740
+ /** 归一化重置时刻:unix 秒 / 毫秒 / ISO 字符串 → ISO 字符串;非法 → ''。 */
741
+ function normalizeResetAt(value) {
742
+ if (typeof value === 'string' && value.length > 0) {
743
+ const ms = Date.parse(value)
744
+ if (Number.isFinite(ms)) return new Date(ms).toISOString()
745
+ const asNum = Number(value)
746
+ if (Number.isFinite(asNum) && asNum > 0) return new Date(asNum > 1e12 ? asNum : asNum * 1000).toISOString()
747
+ return ''
748
+ }
749
+ const n = Number(value)
750
+ if (!Number.isFinite(n) || n <= 0) return ''
751
+ return new Date(n > 1e12 ? n : n * 1000).toISOString()
752
+ }
753
+
754
+ /** 组装文本窗口(余额等无百分比的量):text 空 → null。 */
755
+ const textWindowOf = (text) => {
756
+ const s = String(text ?? '').trim()
757
+ return s.length > 0 ? { resetsAt: '', text: s } : null
758
+ }
759
+
760
+ /** 单窗口:utilization(0-100 或 0-1)+ resets(unix 秒/毫秒/ISO)。 */
761
+ function windowOf(utilization, resetsAt) {
762
+ const percent = normalizePercent(utilization)
763
+ return percent === null ? null : { percent, resetsAt: normalizeResetAt(resetsAt) }
764
+ }
765
+
766
+ /** Anthropic:{ five_hour: { utilization, resets_at }, seven_day: {...}, ... };子配额窗口丢弃。 */
767
+ function parseAnthropicUsage(data) {
768
+ if (data === null || typeof data !== 'object') return null
769
+ const windows = {}
770
+ for (const [name, raw] of Object.entries(data)) {
771
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
772
+ if (/^(five[_ -]?hour|seven[_ -]?day)[_ -]/i.test(name)) continue
773
+ if (name === 'iguana_necktie') continue
774
+ const win = windowOf(raw.utilization ?? raw.used_percentage, raw.resets_at ?? raw.reset_at)
775
+ if (win !== null) windows[name] = win
776
+ }
777
+ return Object.keys(windows).length > 0 ? windows : null
778
+ }
779
+
780
+ /** Z.ai / 智谱 GLM:monitor 端点 limits 数组(unit 3=小时档/6=周档)→ plans 数组 → 扁平窗口。 */
781
+ function parseZaiUsage(data) {
782
+ if (data === null || typeof data !== 'object') return null
783
+ const windows = {}
784
+ const limits = data.data !== null && typeof data.data === 'object' && Array.isArray(data.data.limits)
785
+ ? data.data.limits : null
786
+ if (limits !== null) {
787
+ const monWindows = {}
788
+ const rest = []
789
+ for (const limit of limits) {
790
+ if (limit === null || typeof limit !== 'object' || (limit.type !== 'TOKENS_LIMIT' && limit.type !== 'CREDIT_LIMIT')) continue
791
+ const pct = limit.percentage !== undefined ? clampPct(Number(limit.percentage))
792
+ : Number.isFinite(Number(limit.usage)) && Number(limit.usage) > 0 && Number.isFinite(Number(limit.currentValue))
793
+ ? clampPct((Number(limit.currentValue) / Number(limit.usage)) * 100)
794
+ : null
795
+ if (pct === null) continue
796
+ const resetsAt = normalizeResetAt(limit.nextResetTime)
797
+ const unit = Number(limit.unit)
798
+ if (unit === 3 && monWindows.fiveHour === undefined) monWindows.fiveHour = { percent: pct, resetsAt }
799
+ else if (unit === 6 && monWindows.weekly === undefined) monWindows.weekly = { percent: pct, resetsAt }
800
+ else if (!Number.isFinite(unit)) rest.push({ pct, resetsAt, resetMs: Number(limit.nextResetTime) })
801
+ }
802
+ if (rest.length > 0) {
803
+ rest.sort((a, b) => {
804
+ const av = Number.isFinite(a.resetMs) && a.resetMs > 0 ? a.resetMs : 0
805
+ const bv = Number.isFinite(b.resetMs) && b.resetMs > 0 ? b.resetMs : 0
806
+ return av - bv
807
+ })
808
+ for (const item of rest) {
809
+ if (monWindows.fiveHour === undefined) monWindows.fiveHour = { percent: item.pct, resetsAt: item.resetsAt }
810
+ else if (monWindows.weekly === undefined) monWindows.weekly = { percent: item.pct, resetsAt: item.resetsAt }
811
+ }
812
+ }
813
+ if (Object.keys(monWindows).length > 0) return monWindows
814
+ }
815
+ if (Array.isArray(data.plans)) {
816
+ for (const plan of data.plans) {
817
+ if (plan === null || typeof plan !== 'object') continue
818
+ const total = Number(plan.total_units)
819
+ const used = Number(plan.used_units)
820
+ const pct = Number.isFinite(total) && total > 0 && Number.isFinite(used)
821
+ ? clampPct((used / total) * 100)
822
+ : normalizePercent(plan.utilization ?? plan.percent ?? plan.used_percentage)
823
+ if (pct === null) continue
824
+ const spanMs = Number(plan.period_end) * 1000 - Date.now()
825
+ const key = Number.isFinite(spanMs) && spanMs > 24 * 3600_000 ? 'weekly' : 'fiveHour'
826
+ windows[key] = { percent: pct, resetsAt: normalizeResetAt(plan.period_end) }
827
+ }
828
+ }
829
+ for (const [name, raw] of Object.entries(data)) {
830
+ if (name === 'plans' || raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
831
+ const win = windowOf(raw.utilization ?? raw.percent ?? raw.used_percentage, raw.resets_at ?? raw.reset_at ?? raw.resetsAt)
832
+ if (win !== null) windows[name] = win
833
+ }
834
+ return Object.keys(windows).length > 0 ? windows : null
835
+ }
836
+
837
+ /** MiniMax 单条记录的剩余百分比(优先 *_remaining_percent,total 常为 0)。 */
838
+ function remainingPercentOf(row, remainPctKey, totalKey, usedKey, remainKey) {
839
+ if (row === null || typeof row !== 'object') return null
840
+ const numeric = (key) => (row[key] == null || row[key] === '' ? NaN : Number(row[key]))
841
+ const rp = numeric(remainPctKey)
842
+ if (Number.isFinite(rp)) return Math.min(100, Math.max(0, rp <= 1 ? rp * 100 : rp))
843
+ const total = numeric(totalKey)
844
+ const used = numeric(usedKey)
845
+ const remain = numeric(remainKey)
846
+ if (Number.isFinite(total) && total > 0) {
847
+ if (Number.isFinite(remain)) return (remain / total) * 100
848
+ if (Number.isFinite(used)) return ((total - used) / total) * 100
849
+ }
850
+ return null
851
+ }
852
+
853
+ /** MiniMax 单条记录 → 5h/7d 窗口;status=3 为不限量,保留显式 ∞。 */
854
+ function windowsFromMiniMaxRecord(row) {
855
+ if (row === null || typeof row !== 'object') return {}
856
+ const windows = {}
857
+ if (Number(row.current_interval_status) === 3) {
858
+ windows['5h'] = { unlimited: true, text: '∞', resetsAt: '' }
859
+ } else {
860
+ const remain = remainingPercentOf(row, 'current_interval_remaining_percent', 'current_interval_total_count', 'current_interval_usage_count', 'current_interval_remain_count')
861
+ if (remain !== null) {
862
+ windows['5h'] = { percent: clampPct(100 - remain), resetsAt: normalizeResetAt(row.end_time ?? row.reset_time ?? row.next_reset_time) }
863
+ }
864
+ }
865
+ if (Number(row.current_weekly_status) === 3) {
866
+ windows['7d'] = { unlimited: true, text: '∞', resetsAt: '' }
867
+ } else {
868
+ const remain = remainingPercentOf(row, 'current_weekly_remaining_percent', 'current_weekly_total_count', 'current_weekly_usage_count', 'current_weekly_remain_count')
869
+ if (remain !== null) {
870
+ windows['7d'] = { percent: clampPct(100 - remain), resetsAt: normalizeResetAt(row.weekly_end_time) }
871
+ }
872
+ }
873
+ return windows
874
+ }
875
+
876
+ /** 选 MiniMax 通用额度行:general → MiniMax-M* → 第一条可解析行。 */
877
+ function pickMiniMaxModelRow(rows) {
878
+ const list = rows.filter((row) => row !== null && typeof row === 'object' && !/^(video|speech|audio|image)(?:$|[-_])/i.test(String(row.model_name ?? '')))
879
+ const byName = (name) => list.find((row) => String(row.model_name ?? '').toLowerCase() === name)
880
+ return byName('general')
881
+ ?? list.find((row) => /^minimax-m/i.test(String(row.model_name ?? '')))
882
+ ?? list.find((row) => Object.keys(windowsFromMiniMaxRecord(row)).length > 0)
883
+ ?? list[0]
884
+ ?? null
885
+ }
886
+
887
+ /** MiniMax:token_plan/remains(现行 model_remains)→ 平铺 → 旧数组形态。 */
888
+ function parseMiniMaxRemains(data) {
889
+ if (data === null || typeof data !== 'object') return null
890
+ const payload = data.data !== null && typeof data.data === 'object' && !Array.isArray(data.data) ? data.data : data
891
+ const pickArray = (...keys) => {
892
+ for (const key of keys) {
893
+ if (Array.isArray(data?.[key])) return data[key]
894
+ if (Array.isArray(data?.data?.[key])) return data.data[key]
895
+ }
896
+ return null
897
+ }
898
+ const modelRows = pickArray('model_remains')
899
+ if (modelRows !== null) {
900
+ const fromRow = windowsFromMiniMaxRecord(pickMiniMaxModelRow(modelRows))
901
+ if (Object.keys(fromRow).length > 0) return fromRow
902
+ // 旧计数制:无 remaining_percent、靠 total>0 汇总。
903
+ let total = 0
904
+ let used = 0
905
+ let found = false
906
+ for (const item of modelRows) {
907
+ if (item === null || typeof item !== 'object') continue
908
+ const t = Number(item.current_interval_total_count ?? item.total)
909
+ const u = Number(item.current_interval_usage_count ?? item.used)
910
+ if (!Number.isFinite(t) || t <= 0) continue
911
+ found = true
912
+ total += t
913
+ used += Number.isFinite(u) ? u : 0
914
+ }
915
+ if (found && total > 0) return { current: { percent: clampPct((used / total) * 100), resetsAt: '' } }
916
+ }
917
+ const flat = windowsFromMiniMaxRecord(payload)
918
+ if (Object.keys(flat).length > 0) return flat
919
+ const windows = {}
920
+ const planRows = pickArray('token_plan_remains', 'plan_remains', 'remains', 'windows')
921
+ if (planRows !== null) {
922
+ planRows.forEach((row, index) => {
923
+ if (row === null || typeof row !== 'object') return
924
+ const total = Number(row.current_interval_total_count ?? row.total_count ?? row.total ?? row.limit)
925
+ const used = Number(row.current_interval_usage_count ?? row.used_count ?? row.usage_count ?? row.used)
926
+ const remain = Number(row.current_interval_remain_count ?? row.remain_count ?? row.remain ?? row.remaining)
927
+ let pct = null
928
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) pct = (used / total) * 100
929
+ else if (Number.isFinite(total) && total > 0 && Number.isFinite(remain)) pct = ((total - remain) / total) * 100
930
+ else pct = normalizePercent(row.utilization ?? row.percent ?? row.used_percentage)
931
+ if (pct === null) return
932
+ const labelRaw = row.interval ?? row.interval_type ?? row.window_type ?? row.type ?? row.name
933
+ const label = typeof labelRaw === 'string' && labelRaw.length > 0 ? labelRaw : `window${index + 1}`
934
+ windows[label] = { percent: clampPct(pct), resetsAt: normalizeResetAt(row.reset_time ?? row.resets_at ?? row.next_reset_time ?? row.reset_at) }
935
+ })
936
+ }
937
+ return Object.keys(windows).length > 0 ? windows : null
938
+ }
939
+
940
+ /** Kimi Code 订阅配额(api.kimi.com/coding/v1/usages,Kimi CLI 同款接口)。 */
941
+ function parseKimiCodingUsage(data) {
942
+ if (data === null || typeof data !== 'object') return null
943
+ const payload = data.data && typeof data.data === 'object' ? data.data : data
944
+ const quota = payload.quota && typeof payload.quota === 'object' ? payload.quota : payload
945
+ const windows = {}
946
+ const numeric = (value) => (value == null || typeof value === 'boolean' || String(value).trim() === '' ? NaN : Number(value))
947
+ const counter = (row) => {
948
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return null
949
+ const total = numeric(row.limit)
950
+ const used = numeric(row.used)
951
+ const remaining = numeric(row.remaining)
952
+ const pct = total > 0 && Number.isFinite(total)
953
+ ? Number.isFinite(used) && used >= 0 ? (used / total) * 100
954
+ : Number.isFinite(remaining) && remaining >= 0 ? ((total - remaining) / total) * 100 : NaN
955
+ : NaN
956
+ return Number.isFinite(pct) ? { percent: clampPct(pct), resetsAt: normalizeResetAt(row.resetTime ?? row.reset_time ?? row.resetAt ?? row.resetsAt) } : null
957
+ }
958
+ const usages = quota.usages ?? {}
959
+ const shapes = [['5h', ['limit_5h', 'limit5h']], ['weekly', ['limit_7d', 'limit7d']], ['monthly', ['limit_month_total', 'limitMonthTotal']]]
960
+ for (const [name, aliases] of shapes) {
961
+ const raw = aliases.map((key) => usages[key]).find((row) => row && typeof row === 'object' && !Array.isArray(row))
962
+ if (!raw) continue
963
+ const counted = counter(raw)
964
+ const ratio = numeric(raw.usedRatio ?? raw.used_ratio)
965
+ if (counted) windows[name] = counted
966
+ else if (Number.isFinite(ratio) && ratio >= 0 && ratio <= 1) {
967
+ windows[name] = { percent: clampPct(ratio * 100), resetsAt: normalizeResetAt(raw.resetTime ?? raw.reset_time ?? raw.resetAt) }
968
+ }
969
+ }
970
+ const usage = counter(quota.usage)
971
+ if (usage && (!Object.keys(usages).length || usages.limit_7d || usages.limit7d)) windows.weekly = usage
972
+ const limits = Array.isArray(quota.limits) ? quota.limits : []
973
+ limits.forEach((row, index) => {
974
+ const parsed = counter(row?.detail)
975
+ if (!parsed) return
976
+ const duration = numeric(row.window?.duration)
977
+ const unitRaw = String(row.window?.timeUnit ?? '').toLowerCase().replace(/^time_unit_/, '')
978
+ const unit = unitRaw.startsWith('hour') ? 'h' : unitRaw.startsWith('day') ? 'd'
979
+ : unitRaw.startsWith('week') ? 'w' : unitRaw.startsWith('minute') ? 'm'
980
+ : unitRaw.startsWith('month') ? 'mo' : unitRaw.slice(0, 4)
981
+ const name = unit === 'm' && duration === 300 ? '5h'
982
+ : Number.isFinite(duration) && duration > 0 && unit.length > 0 ? `${duration}${unit}` : `window${index + 1}`
983
+ windows[name] = parsed
984
+ })
985
+ return Object.keys(windows).length > 0 ? windows : null
986
+ }
987
+
988
+ /** Kimi / Moonshot PAYG 余额(人民币分;<100 视为已是元)。 */
989
+ function parseKimiBalance(data) {
990
+ if (data === null || typeof data !== 'object') return null
991
+ const raw = data.available_balance ?? data.balance ?? data.cash_balance ?? data.data?.available_balance
992
+ const n = Number(raw)
993
+ if (!Number.isFinite(n) || n < 0) return null
994
+ const cny = n >= 100 ? n / 100 : n
995
+ const win = textWindowOf(`¥${(Math.round(cny * 100) / 100).toFixed(2)}`)
996
+ return win === null ? null : { balance: win }
997
+ }
998
+
999
+ /** OpenRouter:{ data: { total_credits, total_usage } }(美元)→ 已用%。 */
1000
+ function parseOpenRouterCredits(data) {
1001
+ if (data === null || typeof data !== 'object') return null
1002
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
1003
+ const total = Number(d.total_credits ?? d.credits)
1004
+ const used = Number(d.total_usage ?? d.usage)
1005
+ if (!Number.isFinite(total) || total <= 0 || !Number.isFinite(used)) return null
1006
+ return { credits: { percent: clampPct((used / total) * 100), resetsAt: normalizeResetAt(d.resets_at ?? d.next_reset_time) } }
1007
+ }
1008
+
1009
+ /** SiliconFlow:balance/amount/remain(人民币)→ 文本窗口。 */
1010
+ function parseSiliconFlowInfo(data) {
1011
+ if (data === null || typeof data !== 'object') return null
1012
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
1013
+ const n = Number(d.balance ?? d.amount ?? d.remain ?? d.remaining)
1014
+ if (!Number.isFinite(n) || n < 0) return null
1015
+ const win = textWindowOf(`¥${(Math.round(n * 100) / 100).toFixed(2)}`)
1016
+ return win === null ? null : { balance: win }
1017
+ }
1018
+
1019
+ /** CommandCode:windowLimits(used/cap)+ 月度 Credits 余额文本。 */
1020
+ function parseCommandCodeCredits(data) {
1021
+ if (data === null || typeof data !== 'object') return null
1022
+ const windows = {}
1023
+ const limits = data.windowLimits
1024
+ if (limits !== null && typeof limits === 'object' && !Array.isArray(limits)) {
1025
+ for (const [name, raw] of Object.entries(limits)) {
1026
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
1027
+ const used = Number(raw.used)
1028
+ const cap = Number(raw.cap)
1029
+ if (!Number.isFinite(used) || !Number.isFinite(cap) || cap <= 0 || used < 0) continue
1030
+ windows[name] = { percent: clampPct((used / cap) * 100), resetsAt: normalizeResetAt(raw.resetAt) }
1031
+ }
1032
+ }
1033
+ const monthly = Number(data.credits !== null && typeof data.credits === 'object' ? data.credits.monthlyCredits : undefined)
1034
+ if (Number.isFinite(monthly) && monthly >= 0) {
1035
+ const win = textWindowOf(`$${(Math.round(monthly * 100) / 100).toFixed(2)}`)
1036
+ if (win !== null) windows.monthly = win
1037
+ }
1038
+ return Object.keys(windows).length > 0 ? windows : null
1039
+ }
1040
+
1041
+ /** Cline Pass:limits[].percentUsed 已是 0-100 已用百分比,不走 0-1 猜测。 */
1042
+ function parseClinePassUsage(data) {
1043
+ if (data === null || typeof data !== 'object') return null
1044
+ const limits = data.data !== null && typeof data.data === 'object' ? data.data.limits : undefined
1045
+ if (!Array.isArray(limits)) return null
1046
+ const names = new Map([['five_hour', 'fiveHour'], ['weekly', 'weekly'], ['monthly', 'monthly']])
1047
+ const windows = {}
1048
+ for (const entry of limits) {
1049
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
1050
+ const name = names.get(entry.type)
1051
+ const raw = entry.percentUsed
1052
+ const percent = typeof raw === 'number'
1053
+ ? raw
1054
+ : typeof raw === 'string' && /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(raw.trim()) ? Number(raw) : Number.NaN
1055
+ if (name === undefined || !Number.isFinite(percent) || percent < 0) continue
1056
+ windows[name] = { percent: clampPct(percent), resetsAt: normalizeResetAt(entry.resetsAt) }
1057
+ }
1058
+ return Object.keys(windows).length > 0 ? windows : null
1059
+ }
1060
+
1061
+ const CODING_PLAN_PARSERS = {
1062
+ anthropic: parseAnthropicUsage,
1063
+ zai: parseZaiUsage,
1064
+ minimax: parseMiniMaxRemains,
1065
+ openrouter: parseOpenRouterCredits,
1066
+ siliconflow: parseSiliconFlowInfo,
1067
+ commandcode: parseCommandCodeCredits,
1068
+ clinepass: parseClinePassUsage,
1069
+ }
1070
+
1071
+ /**
1072
+ * 官方端点白名单(硬编码,配置不可改;MiniMax 是唯一例外——允许显式 HTTPS
1073
+ * origin 覆盖,且经 normalizeMiniMaxOrigin 校验为公网域名)。顺序即降级链:
1074
+ * 认证失败(401/403)与解析成功立即返回,其余错误换下一端点。
1075
+ */
1076
+ const CODING_PLAN_ENDPOINTS = {
1077
+ anthropic: ['https://api.anthropic.com/api/oauth/usage'],
1078
+ zai: [
1079
+ 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
1080
+ 'https://api.z.ai/api/monitor/usage/quota/limit',
1081
+ 'https://api.z.ai/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
1082
+ 'https://open.bigmodel.cn/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
1083
+ ],
1084
+ minimax: [
1085
+ 'https://www.minimax.cn/v1/token_plan/remains',
1086
+ 'https://www.minimax.io/v1/token_plan/remains',
1087
+ 'https://www.minimaxi.com/v1/token_plan/remains',
1088
+ ],
1089
+ kimi: [
1090
+ 'https://api.kimi.com/coding/v1/usages',
1091
+ 'https://api.kimi.com/coding/v1/usage',
1092
+ 'https://api.moonshot.cn/v1/users/me/balance',
1093
+ ],
1094
+ openrouter: ['https://openrouter.ai/api/v1/credits'],
1095
+ siliconflow: ['https://api.siliconflow.cn/v1/user/info'],
1096
+ commandcode: ['https://api.commandcode.ai/alpha/billing/credits'],
1097
+ clinepass: ['https://api.cline.bot/api/v1/users/me/plan/usage-limits'],
1098
+ }
1099
+
1100
+ const CODING_PLAN_IDS = Object.keys(CODING_PLAN_ENDPOINTS)
1101
+
1102
+ /**
1103
+ * MiniMax 自定源归一化:只接受 HTTPS origin(无路径/凭据/空白),并拒绝
1104
+ * 环回/内网/链路本地/裸 IP —— SSRF 硬化比上游更严(上游只查 HTTPS origin)。
1105
+ */
1106
+ function normalizeMiniMaxOrigin(value) {
1107
+ if (value === undefined || value === null || value === '') return ''
1108
+ if (typeof value !== 'string' || value.length > 2048) return null
1109
+ const text = value.trim()
1110
+ if (!text) return ''
1111
+ if (!/^https:\/\/[^/?#]+\/?$/i.test(text) || /[\s\\@?#]/.test(text)) return null
1112
+ let url
1113
+ try { url = new URL(text) } catch { return null }
1114
+ const host = url.hostname.toLowerCase()
1115
+ if (!host || url.username || url.password || url.pathname !== '/') return null
1116
+ if (host === '::1' || host.endsWith('.local') || host.endsWith('.internal')) return null
1117
+ if (/^(localhost|0\.|10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return null
1118
+ if (/^\[?[0-9a-f:]+\]?$/i.test(host) && host.includes(':')) return null
1119
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null
1120
+ return url.origin
1121
+ }
1122
+
1123
+ /** 单端点抓取:Bearer 鉴权;Kimi 订阅端点须带官方 CLI 的 UA(上游实测约束)。 */
1124
+ async function fetchPlanJson(url, key, { kimiCoding = false, redirect } = {}) {
1125
+ const headers = {
1126
+ authorization: `Bearer ${key.trim()}`,
1127
+ accept: 'application/json',
1128
+ 'user-agent': 'dsh-cost (DeepSeek Harness plugin)',
1129
+ }
1130
+ if (kimiCoding) headers['user-agent'] = 'KimiCLI/1.6'
1131
+ return fetch(url, { headers, redirect, signal: AbortSignal.timeout(15_000) })
1132
+ }
1133
+
1134
+ /** 响应体有界 JSON(256KiB 上限,防异常端点拖内存)。 */
1135
+ async function readJsonBounded(res, cap = 262_144) {
1136
+ const text = await res.text()
1137
+ if (text.length > cap) throw new Error('response too large')
1138
+ return JSON.parse(text)
1139
+ }
1140
+
1141
+ /**
1142
+ * 查询单家 Coding Plan 额度:按白名单端点顺序尝试。401/403 与解析成功立即
1143
+ * 定论,其余错误换下一端点;zai/minimax 双域 Key 不互通、kimi 订阅端点 401
1144
+ * 视为「非订阅 Key」继续降级到 PAYG 余额端点(上游 issue #42/#53 同语义)。
1145
+ * 全部错误均为 soft(未订阅/Key 无效属预期场景,面板中性提示)。
1146
+ */
1147
+ async function queryCodingPlan(provider, key, origin) {
1148
+ const urls = provider === 'minimax'
1149
+ ? (() => {
1150
+ const base = normalizeMiniMaxOrigin(origin)
1151
+ if (base === null) throw Object.assign(new Error('minimax: invalid HTTPS origin'), { soft: true })
1152
+ return base ? [`${base}/v1/token_plan/remains`, `${base}/v1/api/openplatform/coding_plan/remains`] : CODING_PLAN_ENDPOINTS.minimax
1153
+ })()
1154
+ : CODING_PLAN_ENDPOINTS[provider]
1155
+ let lastError = 'HTTP error'
1156
+ for (const url of urls) {
1157
+ const kimiCoding = provider === 'kimi' && new URL(url).hostname === 'api.kimi.com'
1158
+ let res
1159
+ try {
1160
+ res = await fetchPlanJson(url, key, {
1161
+ kimiCoding,
1162
+ redirect: provider === 'minimax' ? 'error' : provider === 'zai' ? 'manual' : undefined,
1163
+ })
1164
+ } catch (error) {
1165
+ lastError = String(error?.message ?? error).slice(0, 120)
1166
+ continue
1167
+ }
1168
+ if (res.status === 401 || res.status === 403) {
1169
+ lastError = `HTTP ${res.status}`
1170
+ if (provider === 'zai' || provider === 'minimax' || kimiCoding) continue
1171
+ throw Object.assign(new Error(lastError), { soft: true })
1172
+ }
1173
+ if (!res.ok) { lastError = `HTTP ${res.status}`; continue }
1174
+ let data = null
1175
+ try { data = await readJsonBounded(res) } catch { lastError = 'invalid JSON response'; continue }
1176
+ const windows = (kimiCoding ? parseKimiCodingUsage(data) : CODING_PLAN_PARSERS[provider](data))
1177
+ if (windows) return { windows, endpoint: url }
1178
+ lastError = 'no usage windows in response'
1179
+ }
1180
+ throw Object.assign(new Error(lastError), { soft: true })
1181
+ }
1182
+
1183
+
1184
+ const Config = Schema.object({
1185
+ enabled: Schema.boolean().default(true).description('是否启用费用统计'),
1186
+ /** 展示汇率(USD → CNY);金额账本恒存美元,展示时换算。 */
1187
+ exchangeRate: Schema.number().default(7.2).description('展示汇率(USD→CNY)'),
1188
+ /** 自定义单价覆盖:{ "模型id": { cacheHit, cacheMiss, output } }(USD/1M)。 */
1189
+ customPrices: Schema.object({}).default({}).description('自定义模型单价(USD/1M tokens)'),
1190
+ /** 账本保留天数。 */
1191
+ retentionDays: Schema.number().default(180).description('历史保留天数'),
1192
+ budgetEnabled: Schema.boolean().default(false).description('启用预算'),
1193
+ budgetAmount: Schema.number().default(50).description('预算金额(CNY)'),
1194
+ budgetPeriod: Schema.union(['day', 'month', 'all']).default('month').description('预算周期'),
1195
+ backfillEnabled: Schema.boolean().default(true).description('启动时回填历史会话费用'),
1196
+ peakAlertEnabled: Schema.boolean().default(false).description('峰谷切换前提醒'),
1197
+ peakAlertLeadMinutes: Schema.number().default(2).description('峰谷切换提前量(分钟,1-30)'),
1198
+ peakAlertNotify: Schema.boolean().default(false).description('同时发送系统通知'),
1199
+ /** DeepSeek 开放平台 API Key(选填):配置后费用弹卡显示官方账户余额。 */
1200
+ apiKey: Schema.string().default('').description('DeepSeek 开放平台 API Key(查官方余额用,可选)'),
1201
+ /** Coding Plan 订阅配额:{ 厂商: { key, origin? } },Key 在费用设置页配置、不回显。 */
1202
+ codingPlans: Schema.object({}).default({}).description('Coding Plan 订阅配额(在费用设置页配置)'),
1203
+ })
1204
+
1205
+ function apply(ctx, config) {
1206
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
1207
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
1208
+ const live = { ...config }
1209
+ // 官方余额缓存:10 分钟内直接回缓存;换 key 即失效。
1210
+ let balanceCache = null
1211
+ if (!live.enabled) return
1212
+
1213
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
1214
+ const SETTINGS_KEYS = ['budgetEnabled', 'budgetAmount', 'budgetPeriod', 'peakAlertEnabled', 'peakAlertNotify', 'peakAlertLeadMinutes', 'exchangeRate', 'apiKey', 'codingPlans']
1215
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-cost', 'settings.json')
1216
+ try {
1217
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
1218
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
1219
+ } catch { /* 无存档:全部跟随配置 */ }
1220
+ let settingsTimer = null
1221
+ const persistSettings = () => {
1222
+ if (settingsTimer) return
1223
+ settingsTimer = setTimeout(() => {
1224
+ settingsTimer = null
1225
+ try {
1226
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
1227
+ const out = {}
1228
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
1229
+ const tmp = `${SETTINGS_PATH}.tmp`
1230
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
1231
+ renameSync(tmp, SETTINGS_PATH)
1232
+ } catch { /* ignore */ }
1233
+ }, 400)
1234
+ }
1235
+
1236
+ /* -- Coding Plan 配额缓存与查询编排 -------------------------------- */
1237
+
1238
+ const PLAN_TTL_MS = 600_000
1239
+ const planCache = new Map() // provider → { at, result }
1240
+ const planKeyOf = (id) => {
1241
+ const entry = live.codingPlans?.[id]
1242
+ const key = typeof entry?.key === 'string' ? entry.key.trim() : ''
1243
+ return key.length > 0 && key.length <= 4096 ? key : ''
1244
+ }
1245
+ async function refreshCodingPlan(id, force = false) {
1246
+ const key = planKeyOf(id)
1247
+ if (!key) return { hasKey: false }
1248
+ const cached = planCache.get(id)
1249
+ if (!force && cached && Date.now() - cached.at < PLAN_TTL_MS) return cached.result
1250
+ let result
1251
+ try {
1252
+ const { windows } = await queryCodingPlan(id, key, live.codingPlans?.[id]?.origin)
1253
+ result = { hasKey: true, windows, at: Date.now() }
1254
+ void report(`coding plan ${id} refreshed: ${Object.keys(windows).join(',')}`)
1255
+ } catch (error) {
1256
+ result = { hasKey: true, error: String(error?.message ?? error).slice(0, 160), at: Date.now() }
1257
+ void report(`coding plan ${id} refresh failed: ${result.error}`)
1258
+ }
1259
+ planCache.set(id, { at: Date.now(), result })
1260
+ return result
1261
+ }
1262
+
1263
+ const lockPath = join(STORE_DIR, 'ledger.lock')
1264
+ // 存储目录可能不存在(全新安装):锁文件 openSync(wx) 要求父目录已建。
1265
+ try { mkdirSync(STORE_DIR, { recursive: true }) } catch { /* ignore */ }
1266
+ const lock = acquireLedgerLock(lockPath)
1267
+ void report(`server boot v0.10.0 pid=${process.pid} ledgerOwner=${lock.owner} retention=${live.retentionDays} rate=${live.exchangeRate} budget=${live.budgetEnabled ? `${live.budgetAmount}/${live.budgetPeriod}` : 'off'} backfill=${live.backfillEnabled && lock.owner ? 'on' : 'off'} plans=${CODING_PLAN_IDS.filter((id) => planKeyOf(id)).length}`)
1268
+
1269
+ const ledger = createLedger(live.retentionDays)
1270
+ if (lock.owner) ledger.load()
1271
+ ctx.effect(() => { ledger.dispose(); if (lock.owner) releaseLedgerLock(lockPath) }, 'dsh-cost: ledger flush + lock release')
1272
+
1273
+ const admitUsage = createUsageDeduper()
1274
+ const journal = createJournal()
1275
+
1276
+ /**
1277
+ * 锁持有者把 journal 增量折叠进 ledger(0.7.0 单写者模型):
1278
+ * 两个进程都会 boot 并各听各的 llm/stream——都只 append journal(O_APPEND
1279
+ * 单行跨进程安全);只有锁持有者写 ledger.json,彻底消除整文件互踩。
1280
+ * 指纹去重兜底:同一事件被两进程捕获 / 回填与实时重叠,10s 窗内互斥。
1281
+ */
1282
+ const ingestDedup = createUsageDeduper()
1283
+ let ingestedCount = 0
1284
+ let ingestBusy = false
1285
+ async function ingestJournal() {
1286
+ if (!lock.owner || ingestBusy) return
1287
+ ingestBusy = true
1288
+ try {
1289
+ const { rows, offset } = await journal.recordsAfter(ledger.journalOffset())
1290
+ for (const row of rows) {
1291
+ if (!row || !Number.isFinite(row.at) || !Number.isFinite(row.cost) || !row.model) continue
1292
+ const buckets = { input: row.input ?? 0, output: row.output ?? 0, cacheRead: row.cacheRead ?? 0, cacheWrite: row.cacheWrite ?? 0, reasoning: row.reasoning ?? 0 }
1293
+ if (!ingestDedup(`${row.session ?? ''}|${row.model}`, buckets, row.at)) continue
1294
+ ledger.account({ sessionId: row.session, model: row.model, buckets, cost: row.cost, atMs: row.at, source: row.source })
1295
+ // +60s 余量:日志事件记的是消息完成时刻,晚于实时流发起时刻;
1296
+ // 游标盖住整个调用时长,防回填重放同一条。
1297
+ if (row.session) ledger.markImported(row.session, row.at + 60_000)
1298
+ ingestedCount += 1
1299
+ }
1300
+ ledger.setJournalOffset(offset)
1301
+ if (rows.length > 0) touchLedgerLock(lockPath)
1302
+ } catch (error) {
1303
+ void report(`ingest failed: ${String(error?.message ?? error).slice(0, 160)}`)
1304
+ } finally {
1305
+ ingestBusy = false
1306
+ }
1307
+ }
1308
+
1309
+ if (lock.owner) {
1310
+ const ingestTimer = setInterval(() => { void ingestJournal() }, 5000)
1311
+ ingestTimer.unref?.()
1312
+ ctx.effect(() => () => clearInterval(ingestTimer), 'dsh-cost: journal ingest timer')
1313
+ }
1314
+
1315
+ // 历史回填:仅锁持有者执行(boot 后 3s,避开宿主启动高峰);0.7.0 起
1316
+ // 改写 journal(与实时同源),由 ingest 统一折叠 + 指纹去重,天然不重。
1317
+ if (live.backfillEnabled && lock.owner) {
1318
+ const backfillTimer = setTimeout(() => { void runBackfill(journal, ledger, priceEntryFor).catch((error) => { void report(`backfill crashed: ${String(error?.message ?? error).slice(0, 200)}`) }) }, 3000)
1319
+ backfillTimer.unref?.()
1320
+ ctx.effect(() => () => clearTimeout(backfillTimer), 'dsh-cost: backfill timer')
1321
+ }
1322
+
1323
+ // 汇率自动刷新:仅锁持有者,启动 5s 后首刷,之后每小时。失败静默(用旧值)。
1324
+ if (lock.owner) {
1325
+ const refreshRate = async () => {
1326
+ try {
1327
+ const rate = await fetchExchangeRate()
1328
+ ledger.setExchangeRate(rate, Date.now())
1329
+ void report(`exchange rate auto: ${rate}`)
1330
+ } catch (error) {
1331
+ void report(`exchange rate auto failed: ${String(error?.message ?? error).slice(0, 120)}`)
1332
+ }
1333
+ }
1334
+ const rateStart = setTimeout(() => { void refreshRate() }, 5000)
1335
+ rateStart.unref?.()
1336
+ const rateTimer = setInterval(() => { void refreshRate() }, 3_600_000)
1337
+ rateTimer.unref?.()
1338
+ ctx.effect(() => () => { clearTimeout(rateStart); clearInterval(rateTimer) }, 'dsh-cost: exchange rate timers')
1339
+ }
1340
+
1341
+ /** 深度标记:存在即「已知会、不需要再入账」的 llm 流(嵌套排除)。 */
1342
+ const llmDepth = new AsyncLocalStorage()
1343
+
1344
+ /**
1345
+ * 跨插件的调用来源标记。prompt-enhancer / followup 在发起自己的
1346
+ * llm.stream 前写 globalThis.__dshPluginCall,这里按 5 秒窗口读回:
1347
+ * 落在窗口内算它们的调用,否则一律算对话。读一次就清,避免串到下一次。
1348
+ */
1349
+ const CALL_SOURCE_TTL_MS = 5000
1350
+
1351
+ /** 各插件自己的 prompt 特征:跨进程时 globalThis 不共享,只能看内容。 */
1352
+ const SOURCE_SIGNATURES = [
1353
+ { source: 'followup', needle: 'You generate follow-up suggestions' },
1354
+ { source: 'enhance', needle: "You are a Prompt Engineering Expert" },
1355
+ ]
1356
+
1357
+ function callSource(options) {
1358
+ // 1) 同进程时的显式标记(prompt-enhancer / followup 调用前写入)
1359
+ const marker = globalThis.__dshPluginCall
1360
+ if (marker && typeof marker.at === 'number' && Date.now() - marker.at < CALL_SOURCE_TTL_MS) {
1361
+ const source = typeof marker.source === 'string' && marker.source ? marker.source : 'chat'
1362
+ globalThis.__dshPluginCall = undefined
1363
+ return source
1364
+ }
1365
+ // 2) 兜底:按 prompt 特征识别,插件跑在别的进程时也能分开记
1366
+ try {
1367
+ const messages = options?.messages
1368
+ if (messages) {
1369
+ const text = JSON.stringify(messages)
1370
+ for (const entry of SOURCE_SIGNATURES) {
1371
+ if (text.includes(entry.needle)) return entry.source
1372
+ }
1373
+ }
1374
+ } catch { /* 序列化失败就当普通对话 */ }
1375
+ return 'chat'
1376
+ }
1377
+
1378
+ const unmatchedModels = new Set()
1379
+ let streamArmed = false
1380
+ let accountCount = 0
1381
+
1382
+ function priceEntryFor(model) {
1383
+ if (model && live.customPrices && live.customPrices[model]) return { custom: true, ...live.customPrices[model] }
1384
+ const norm = normalizeModelId(model)
1385
+ if (legacyOnlyModels[norm]) return legacyOnlyModels[norm]
1386
+ if (norm.includes('deepseek')) {
1387
+ // 官方同步的现行价优先(fetch-prices 成功后覆盖内置表;历史档不变)。
1388
+ const live = ledger.livePrices()
1389
+ if (live && live.currency === 'USD') {
1390
+ const family = norm.includes('pro') ? 'pro' : 'flash'
1391
+ const t = live[family]
1392
+ return { legacyBase: (norm.includes('pro') ? deepseekPro : deepseekFlash).legacyBase, august: undefined, offPeak: t.offPeak, peak: t.peak, live: true }
1393
+ }
1394
+ return norm.includes('pro') ? deepseekPro : deepseekFlash
1395
+ }
1396
+ return null
1397
+ }
1398
+
1399
+ ctx.on('llm/stream', (options, next) => {
1400
+ // 包装路由插件在嵌套上下文里再发起的上游调用:原样透传,不重复入账。
1401
+ if (llmDepth.getStore() !== undefined) return next()
1402
+ const downstream = next()
1403
+ if (!streamArmed) {
1404
+ streamArmed = true
1405
+ void report(`llm/stream first fire pid=${process.pid} — capture channel ALIVE in this process`)
1406
+ }
1407
+ const startedAtMs = Date.now()
1408
+ const model = typeof options?.model === 'string' ? options.model : ''
1409
+ const provider = typeof options?.provider === 'string' ? options.provider : ''
1410
+ const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
1411
+ let usage = null
1412
+ let downstreamIter = null
1413
+ let completed = false
1414
+ return (async function* costObserve() {
1415
+ try {
1416
+ downstreamIter = downstream[Symbol.asyncIterator]()
1417
+ for (;;) {
1418
+ // 深度标记内拉取:下游适配器(包装路由)在消费上下文里再发起的
1419
+ // ctx.llm.stream() 继承标记,内层监听器判定为嵌套、透传。
1420
+ const result = await llmDepth.run(1, () => downstreamIter.next())
1421
+ if (result.done) break
1422
+ const chunk = result.value
1423
+ // usage 块位于 finish 之前;多块时最后一块生效(cost-meter 同款)。
1424
+ if (chunk?.type === 'usage' && chunk.usage != null) usage = chunk.usage
1425
+ yield chunk
1426
+ }
1427
+ completed = true
1428
+ } finally {
1429
+ // 消费方提前中断也向下游传播 return(),防上游 HTTP 流悬挂。
1430
+ if (!completed) {
1431
+ try { await downstreamIter?.return?.() } catch { /* ignore */ }
1432
+ }
1433
+ try {
1434
+ if (usage) {
1435
+ const buckets = {
1436
+ input: usage.inputTokens ?? 0,
1437
+ output: usage.outputTokens ?? 0,
1438
+ cacheRead: usage.cacheReadTokens ?? 0,
1439
+ cacheWrite: usage.cacheWriteTokens ?? 0,
1440
+ reasoning: usage.reasoningTokens ?? 0,
1441
+ }
1442
+ const entry = priceEntryFor(model)
1443
+ if (entry) {
1444
+ // 指纹去重兜底:本进程内 ALS 逃逸/同流双 usage 互斥(10s 窗)。
1445
+ if (admitUsage(model, buckets, startedAtMs)) {
1446
+ const peak = isPeakAt(startedAtMs)
1447
+ const tier = tierFor(entry, startedAtMs, peak)
1448
+ const cost = costOfBuckets(buckets, tier)
1449
+ // 0.7.0 单写者模型:所有进程只 append journal(跨进程安全),
1450
+ // ledger 由锁持有者的 ingest 统一折叠。
1451
+ void journal.write({
1452
+ at: startedAtMs,
1453
+ day: localDayKey(startedAtMs),
1454
+ session: sessionId || '',
1455
+ model,
1456
+ provider,
1457
+ ...buckets,
1458
+ rows: bucketRows(buckets, tier),
1459
+ cost,
1460
+ peak,
1461
+ source: callSource(options),
1462
+ })
1463
+ accountCount += 1
1464
+ // 采样留痕(前 10 条全记,之后 1/20),足以判定通路与发现异常。
1465
+ if (accountCount <= 10 || accountCount % 20 === 0) {
1466
+ void report(`journaled #${accountCount} pid=${process.pid} owner=${lock.owner ? 1 : 0} model=${model} provider=${provider} session=${sessionId ? sessionId.slice(0, 40) : '(none)'} peak=${peak ? 1 : 0} buckets=${JSON.stringify(buckets)} cost=$${cost.toFixed(6)}`)
1467
+ }
1468
+ } else {
1469
+ void report(`dedup skip model=${model} buckets=${JSON.stringify(buckets)}`)
1470
+ }
1471
+ } else if (model && !unmatchedModels.has(model)) {
1472
+ // 未内置价格的第三方模型按 default 档(flash 现行价)计费,
1473
+ // 首见留痕提醒用户在 customPrices 里补充精确单价。
1474
+ unmatchedModels.add(model)
1475
+ void report(`unmatched model "${model}" billed at default tier; add customPrices entry for accuracy`)
1476
+ }
1477
+ }
1478
+ } catch (error) {
1479
+ void report(`account failed: ${String(error?.message ?? error).slice(0, 200)}`)
1480
+ }
1481
+ }
1482
+ })()
1483
+ }, { global: true })
1484
+
1485
+ /* -- HTTP 端点 ------------------------------------------------------ */
1486
+
1487
+ ctx.inject(['webServer'], (webCtx) => {
1488
+ /** /state、/export 共用:锁持有者先折叠+落盘再读文件;非持有者直读
1489
+ (持有者 5s ingest + 800ms 防抖,两个进程的 UI 数据都足够新鲜)。 */
1490
+ async function ledgerSnapshotFresh() {
1491
+ if (lock.owner) {
1492
+ await ingestJournal()
1493
+ ledger.flushSync()
1494
+ }
1495
+ try { return JSON.parse(await readFile(LEDGER_PATH, 'utf8')) } catch { return { days: {} } }
1496
+ }
1497
+ webCtx.effect(() => webCtx.webServer.register({
1498
+ kind: 'prefix',
1499
+ path: '/api/dsh-cost',
1500
+ handler: async (req, res) => {
1501
+ const send = (status, body) => {
1502
+ res.statusCode = status
1503
+ res.setHeader('content-type', 'application/json')
1504
+ res.end(JSON.stringify(body))
1505
+ }
1506
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-cost/, '')
1507
+ if (path === '/settings' && req.method === 'GET') {
1508
+ send(200, {
1509
+ enabled: live.enabled,
1510
+ exchangeRate: live.exchangeRate,
1511
+ budgetEnabled: live.budgetEnabled,
1512
+ budgetAmount: live.budgetAmount,
1513
+ budgetPeriod: live.budgetPeriod,
1514
+ peakAlertEnabled: live.peakAlertEnabled,
1515
+ peakAlertLeadMinutes: live.peakAlertLeadMinutes,
1516
+ peakAlertNotify: live.peakAlertNotify,
1517
+ retentionDays: live.retentionDays,
1518
+ })
1519
+ return
1520
+ }
1521
+ // 运行时改参数:retentionDays 与 customPrices 不在白名单——前者启动时
1522
+ // 已传给 createLedger,后者是对象结构,运行时改要么无效要么要重建缓存。
1523
+ if (path === '/settings' && req.method === 'POST') {
1524
+ let raw = ''
1525
+ for await (const chunk of req) raw += chunk
1526
+ let body
1527
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
1528
+ if (body.budgetEnabled !== undefined) live.budgetEnabled = body.budgetEnabled === true
1529
+ if (body.budgetPeriod === 'day' || body.budgetPeriod === 'month') live.budgetPeriod = body.budgetPeriod
1530
+ if (body.peakAlertEnabled !== undefined) live.peakAlertEnabled = body.peakAlertEnabled === true
1531
+ if (body.peakAlertNotify !== undefined) live.peakAlertNotify = body.peakAlertNotify === true
1532
+ if (body.budgetAmount !== undefined) {
1533
+ const value = Number(body.budgetAmount)
1534
+ if (Number.isFinite(value)) live.budgetAmount = Math.min(1_000_000, Math.max(0, value))
1535
+ }
1536
+ if (body.exchangeRate !== undefined) {
1537
+ const value = Number(body.exchangeRate)
1538
+ if (Number.isFinite(value) && value > 0) live.exchangeRate = Math.min(20, Math.max(1, value))
1539
+ }
1540
+ if (body.peakAlertLeadMinutes !== undefined) {
1541
+ const value = Number(body.peakAlertLeadMinutes)
1542
+ if (Number.isFinite(value)) live.peakAlertLeadMinutes = Math.min(30, Math.max(1, Math.round(value)))
1543
+ }
1544
+ if (body.apiKey !== undefined) {
1545
+ live.apiKey = String(body.apiKey ?? '').trim()
1546
+ balanceCache = null // 换 key 立即失效缓存
1547
+ }
1548
+ persistSettings()
1549
+ send(200, { ok: true })
1550
+ return
1551
+ }
1552
+
1553
+
1554
+ if (path === '/state' && req.method === 'GET') {
1555
+ const snap = await ledgerSnapshotFresh()
1556
+ const now = Date.now()
1557
+ const todayKey = localDayKey(now)
1558
+ const monthPrefix = todayKey.slice(0, 7)
1559
+ const weekStartKey = localDayKey(now - 6 * 86400000)
1560
+ let month = null
1561
+ const total = { ...zeroBuckets(), calls: 0, cost: 0 }
1562
+ const recent = []
1563
+ const sessions = []
1564
+ for (const key of Object.keys(snap.days).sort()) {
1565
+ const day = snap.days[key]
1566
+ for (const bucketKey of Object.keys(zeroBuckets())) total[bucketKey] += day[bucketKey] ?? 0
1567
+ total.calls += day.calls ?? 0
1568
+ total.cost += day.cost ?? 0
1569
+ if (key.startsWith(monthPrefix)) month = month ? mergeDay(month, day) : summarize(day)
1570
+ if (key >= weekStartKey) {
1571
+ recent.push({ date: key, calls: day.calls ?? 0, cost: day.cost ?? 0, tokens: (day.input ?? 0) + (day.output ?? 0) + (day.cacheRead ?? 0) + (day.cacheWrite ?? 0) })
1572
+ for (const [id, session] of Object.entries(day.sessions ?? {})) {
1573
+ sessions.push({ id, date: key, calls: session.calls ?? 0, cost: session.cost ?? 0, input: session.input ?? 0, output: session.output ?? 0, at: session.at ?? 0 })
1574
+ }
1575
+ }
1576
+ }
1577
+ sessions.sort((a, b) => b.at - a.at)
1578
+ // 预算:以展示币种(CNY)计,used 由对应周期的账本金额换算。
1579
+ const budgetReady = live.budgetEnabled && Number(live.budgetAmount) > 0
1580
+ const budgetUsd = budgetReady
1581
+ ? (live.budgetPeriod === 'day'
1582
+ ? (snap.days[todayKey]?.cost ?? 0)
1583
+ : live.budgetPeriod === 'month'
1584
+ ? (month?.cost ?? 0)
1585
+ : total.cost)
1586
+ : 0
1587
+ send(200, {
1588
+ serverNow: now,
1589
+ display: { currency: 'CNY', rate: ledger.liveExchangeRate()?.rate ?? live.exchangeRate, rateSource: ledger.liveExchangeRate() ? 'auto' : 'config', rateFetchedAt: ledger.liveExchangeRate()?.fetchedAt ?? null },
1590
+ peak: { isPeak: isPeakAt(now), windows: PEAK_WINDOWS, nextSwitchAtMs: nextPeakSwitch(now)?.atMs ?? null, nextIsPeak: nextPeakSwitch(now)?.isPeak ?? null },
1591
+ today: todayKey in snap.days ? summarize(snap.days[todayKey]) : null,
1592
+ month,
1593
+ total: summarize(total),
1594
+ budget: budgetReady
1595
+ ? { amount: live.budgetAmount, period: live.budgetPeriod, used: budgetUsd * live.exchangeRate, usedPct: budgetUsd * live.exchangeRate / live.budgetAmount }
1596
+ : null,
1597
+ recent,
1598
+ sessions: sessions.slice(0, 20),
1599
+ unmatched: [...unmatchedModels],
1600
+ streamAlive: streamArmed,
1601
+ alerts: {
1602
+ peakEnabled: live.peakAlertEnabled === true,
1603
+ leadMinutes: Math.min(30, Math.max(1, Math.round(live.peakAlertLeadMinutes ?? 2))),
1604
+ notify: live.peakAlertNotify === true,
1605
+ },
1606
+ })
1607
+ return
1608
+ }
1609
+
1610
+ /**
1611
+ * GET /forecast —— 月底花费预测。
1612
+ *
1613
+ * 用最近 7 个**完整**历史日的日均推算,不用今天(半天数据会把均值拉低)。
1614
+ * 样本少于 3 天时标 basis=insufficient,前端据此提示「数据不足」而不是
1615
+ * 显示一个不可信的数字。
1616
+ */
1617
+ if (path === '/forecast' && req.method === 'GET') {
1618
+ const snap = await ledgerSnapshotFresh()
1619
+ const now = Date.now()
1620
+ const todayKey = localDayKey(now)
1621
+ const monthPrefix = todayKey.slice(0, 7)
1622
+
1623
+ let monthCost = 0
1624
+ let monthCalls = 0
1625
+ const recent = []
1626
+
1627
+ for (const key of Object.keys(snap.days).sort()) {
1628
+ const day = snap.days[key]
1629
+ if (key.startsWith(monthPrefix)) {
1630
+ monthCost += day.cost ?? 0
1631
+ monthCalls += day.calls ?? 0
1632
+ }
1633
+ // 只取完整历史日,今天的数据还在增长
1634
+ if (key < todayKey && key >= localDayKey(now - 7 * 86400000)) {
1635
+ recent.push({ date: key, cost: day.cost ?? 0, calls: day.calls ?? 0 })
1636
+ }
1637
+ }
1638
+
1639
+ const n = recent.length
1640
+ const sumCost = recent.reduce((acc, d) => acc + d.cost, 0)
1641
+ const sumCalls = recent.reduce((acc, d) => acc + d.calls, 0)
1642
+ const avgDaily = n > 0 ? sumCost / n : 0
1643
+ const avgCalls = n > 0 ? sumCalls / n : 0
1644
+
1645
+ const nowDate = new Date(now)
1646
+ const daysInMonth = new Date(nowDate.getFullYear(), nowDate.getMonth() + 1, 0).getDate()
1647
+ const daysLeft = daysInMonth - nowDate.getDate()
1648
+ const projected = monthCost + avgDaily * daysLeft
1649
+
1650
+ send(200, {
1651
+ monthCost,
1652
+ monthCalls,
1653
+ avgDaily,
1654
+ avgCalls,
1655
+ daysLeft,
1656
+ projected,
1657
+ avgPerCall: monthCalls > 0 ? monthCost / monthCalls : 0,
1658
+ sampleDays: n,
1659
+ basis: n >= 3 ? 'recent' : 'insufficient',
1660
+ recent,
1661
+ })
1662
+ return
1663
+ }
1664
+
1665
+ if (path === '/calls' && req.method === 'GET') {
1666
+ const url = new URL(req.url ?? '/api/dsh-cost/calls', 'http://localhost')
1667
+ const sessionId = url.searchParams.get('session') ?? ''
1668
+ const day = url.searchParams.get('day') ?? ''
1669
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 20)))
1670
+ // 全量按来源汇总(对话 / 增强 / 建议生成),供状态行费用弹卡分组显示。
1671
+ const snap = await ledgerSnapshotFresh()
1672
+ const bySource = {}
1673
+ let totalCost = 0
1674
+ let totalCalls = 0
1675
+ for (const key of Object.keys(snap.days)) {
1676
+ const d = snap.days[key]
1677
+ totalCost += d.cost ?? 0
1678
+ totalCalls += d.calls ?? 0
1679
+ for (const [src, entry] of Object.entries(d.bySource ?? {})) {
1680
+ const t = bySource[src] ?? (bySource[src] = { cost: 0, calls: 0 })
1681
+ t.cost += entry.cost ?? 0
1682
+ t.calls += entry.calls ?? 0
1683
+ }
1684
+ }
1685
+ send(200, { total: { cost: totalCost, calls: totalCalls, bySource }, calls: await journal.recent({ sessionId, day, limit }) })
1686
+ return
1687
+ }
1688
+
1689
+ // 本对话累计费用:跨所有日期聚合 day.sessions[sessionId],含来源分组。
1690
+ if (path === '/session' && req.method === 'GET') {
1691
+ const url = new URL(req.url ?? '/api/dsh-cost/session', 'http://localhost')
1692
+ const sessionId = url.searchParams.get('session') ?? ''
1693
+ if (!sessionId) { send(400, { error: 'missing session' }); return }
1694
+ const snap = await ledgerSnapshotFresh()
1695
+ const total = { ...zeroBuckets(), calls: 0, cost: 0, bySource: {} }
1696
+ for (const key of Object.keys(snap.days)) {
1697
+ const entry = snap.days[key].sessions?.[sessionId]
1698
+ if (!entry) continue
1699
+ for (const bucketKey of Object.keys(zeroBuckets())) total[bucketKey] += entry[bucketKey] ?? 0
1700
+ total.calls += entry.calls ?? 0
1701
+ total.cost += entry.cost ?? 0
1702
+ for (const [src, sub] of Object.entries(entry.bySource ?? {})) {
1703
+ const target = total.bySource[src] ?? (total.bySource[src] = { cost: 0, calls: 0 })
1704
+ target.cost += sub.cost ?? 0
1705
+ target.calls += sub.calls ?? 0
1706
+ }
1707
+ }
1708
+ send(200, {
1709
+ session: sessionId,
1710
+ total,
1711
+ calls: ledger.recentCalls(100).filter((call) => call.session === sessionId),
1712
+ })
1713
+ return
1714
+ }
1715
+
1716
+ // 回合费用:turnTail 槽位传宿主 turn/start~turn/end 事件时间窗。
1717
+ if (path === '/turn' && req.method === 'GET') {
1718
+ const url = new URL(req.url ?? '/api/dsh-cost/turn', 'http://localhost')
1719
+ const sessionId = url.searchParams.get('session') ?? ''
1720
+ const from = Number(url.searchParams.get('from') ?? 0)
1721
+ const to = Number(url.searchParams.get('to') ?? Number.MAX_SAFE_INTEGER)
1722
+ if (!sessionId || !Number.isFinite(from) || !Number.isFinite(to)) { send(400, { error: 'missing session/from/to' }); return }
1723
+ send(200, await journal.range({ sessionId, from, to }))
1724
+ return
1725
+ }
1726
+
1727
+ // 客户端调试上报:锚定/装配事件落 debug log(真机取证)。
1728
+ if (path === '/client-debug' && req.method === 'POST') {
1729
+ let body = ''
1730
+ for await (const chunk of req) body += chunk
1731
+ let message = ''
1732
+ try { message = String(JSON.parse(body).message ?? '').slice(0, 300) } catch { message = '' }
1733
+ if (message) void report(`client: ${message}`)
1734
+ send(200, { ok: true })
1735
+ return
1736
+ }
1737
+
1738
+ // 导出:全量 JSON(含日/模型/会话聚合)或日汇总 CSV。
1739
+ if (path === '/export' && req.method === 'GET') {
1740
+ const url = new URL(req.url ?? '/api/dsh-cost/export', 'http://localhost')
1741
+ const snap = await ledgerSnapshotFresh()
1742
+ if ((url.searchParams.get('format') ?? 'json') === 'csv') {
1743
+ const lines = ['date,input,output,cache_read,cache_write,reasoning,calls,cost_usd']
1744
+ for (const key of Object.keys(snap.days).sort()) {
1745
+ const day = snap.days[key]
1746
+ lines.push([key, day.input ?? 0, day.output ?? 0, day.cacheRead ?? 0, day.cacheWrite ?? 0, day.reasoning ?? 0, day.calls ?? 0, (day.cost ?? 0).toFixed(6)].join(','))
1747
+ }
1748
+ res.statusCode = 200
1749
+ res.setHeader('content-type', 'text/csv; charset=utf-8')
1750
+ res.setHeader('content-disposition', 'attachment; filename="dsh-cost-days.csv"')
1751
+ res.end(lines.join('\n'))
1752
+ return
1753
+ }
1754
+ res.statusCode = 200
1755
+ res.setHeader('content-type', 'application/json')
1756
+ res.setHeader('content-disposition', 'attachment; filename="dsh-cost-ledger.json"')
1757
+ res.end(JSON.stringify({ exportedAt: Date.now(), display: { currency: 'CNY', rate: ledger.liveExchangeRate()?.rate ?? live.exchangeRate }, days: snap.days }))
1758
+ return
1759
+ }
1760
+
1761
+ if (path === '/prices' && req.method === 'GET') {
1762
+ const now = Date.now()
1763
+ const peak = isPeakAt(now)
1764
+ const live = ledger.livePrices()
1765
+ send(200, {
1766
+ currency: 'USD',
1767
+ peak,
1768
+ peakWindowsUtc: PEAK_WINDOWS,
1769
+ holidays: [...PEAK_HOLIDAYS],
1770
+ source: live ? 'official-sync' : 'built-in',
1771
+ fetchedAt: live?.fetchedAt ?? null,
1772
+ models: {
1773
+ 'deepseek-v4.1-flash / deepseek-flash': live ? tierFor({ offPeak: live.flash.offPeak, peak: live.flash.peak }, now, peak) : currentTier(deepseekFlash, now, peak),
1774
+ 'deepseek-v4-pro': live ? tierFor({ offPeak: live.pro.offPeak, peak: live.pro.peak }, now, peak) : currentTier(deepseekPro, now, peak),
1775
+ },
1776
+ default: live ? tierFor({ offPeak: live.flash.offPeak, peak: live.flash.peak }, now, peak) : currentTier(deepseekFlash, now, peak),
1777
+ })
1778
+ return
1779
+ }
1780
+
1781
+ // 官方价格同步:抓官方定价页解析,校验峰=谷×2 后覆盖现行档。
1782
+ if (path === '/fetch-prices' && req.method === 'POST') {
1783
+ try {
1784
+ const parsed = await fetchOfficialPricing()
1785
+ const tiers = tiersFromOfficial(parsed)
1786
+ ledger.setLivePrices(tiers)
1787
+ void report(`price sync ok: flash off ${JSON.stringify(tiers.flash.offPeak)} pro off ${JSON.stringify(tiers.pro.offPeak)}`)
1788
+ send(200, { ok: true, fetchedAt: tiers.fetchedAt, flash: tiers.flash, pro: tiers.pro })
1789
+ } catch (error) {
1790
+ void report(`price sync failed: ${String(error?.message ?? error).slice(0, 200)}`)
1791
+ send(502, { ok: false, error: String(error?.message ?? error).slice(0, 200) })
1792
+ }
1793
+ return
1794
+ }
1795
+
1796
+ // 官方余额(选配 apiKey):host 白名单写死官方域名,https 证书校验兜底。
1797
+ if (path === '/balance' && req.method === 'GET') {
1798
+ const key = String(live.apiKey ?? '').trim()
1799
+ if (!key) { send(200, { ok: false, reason: 'no-key' }); return }
1800
+ if (balanceCache && Date.now() - balanceCache.at < 600_000) { send(200, balanceCache.data); return }
1801
+ if (BALANCE_HOST !== 'api.deepseek.com') { send(200, { ok: false, reason: 'bad-host' }); return }
1802
+ try {
1803
+ const res = await fetch(`https://${BALANCE_HOST}/user/balance`, {
1804
+ headers: { accept: 'application/json', authorization: `Bearer ${key}`, 'user-agent': 'dsh-cost' },
1805
+ signal: AbortSignal.timeout(8000),
1806
+ })
1807
+ if (!res.ok) throw new Error(`balance ${res.status}`)
1808
+ const data = await res.json()
1809
+ const info = Array.isArray(data.balance_infos) ? data.balance_infos[0] : null
1810
+ const out = info
1811
+ ? { ok: true, currency: info.currency ?? 'CNY', total: String(info.total_balance ?? ''), granted: String(info.granted_balance ?? ''), toppedUp: String(info.topped_up_balance ?? ''), at: Date.now() }
1812
+ : { ok: false, reason: 'empty' }
1813
+ balanceCache = { at: Date.now(), data: out }
1814
+ send(200, out)
1815
+ } catch (error) {
1816
+ const out = { ok: false, reason: String(error?.message ?? error).slice(0, 120) }
1817
+ balanceCache = { at: Date.now(), data: out }
1818
+ send(200, out)
1819
+ }
1820
+ return
1821
+ }
1822
+
1823
+ // 汇率手动刷新(自动刷新由每小时定时器执行)。
1824
+ if (path === '/fetch-rate' && req.method === 'POST') {
1825
+ try {
1826
+ const rate = await fetchExchangeRate()
1827
+ ledger.setExchangeRate(rate, Date.now())
1828
+ void report(`exchange rate ok: ${rate}`)
1829
+ send(200, { ok: true, rate })
1830
+ } catch (error) {
1831
+ void report(`exchange rate failed: ${String(error?.message ?? error).slice(0, 200)}`)
1832
+ send(502, { ok: false, error: String(error?.message ?? error).slice(0, 200) })
1833
+ }
1834
+ return
1835
+ }
1836
+
1837
+ // 热力图:近 N 天逐日 { cost, calls, tokens }(GitHub 贡献图样式)。
1838
+ if (path === '/heatmap' && req.method === 'GET') {
1839
+ const url = new URL(req.url ?? '/api/dsh-cost/heatmap', 'http://localhost')
1840
+ const days = Math.min(365, Math.max(28, Number(url.searchParams.get('days') ?? 182)))
1841
+ const snap = await ledgerSnapshotFresh()
1842
+ const since = localDayKey(Date.now() - days * 86400000)
1843
+ const rows = []
1844
+ for (const key of Object.keys(snap.days).sort()) {
1845
+ if (key < since) continue
1846
+ const day = snap.days[key]
1847
+ rows.push({
1848
+ date: key,
1849
+ cost: day.cost ?? 0,
1850
+ calls: day.calls ?? 0,
1851
+ tokens: (day.input ?? 0) + (day.output ?? 0) + (day.cacheRead ?? 0) + (day.cacheWrite ?? 0) + (day.reasoning ?? 0),
1852
+ })
1853
+ }
1854
+ send(200, { days: rows })
1855
+ return
1856
+ }
1857
+
1858
+ // Coding Plan 配额:只下发窗口/错误/时间戳,Key 永不出服务端。
1859
+ const planState = () => {
1860
+ const providers = {}
1861
+ for (const id of CODING_PLAN_IDS) providers[id] = { hasKey: planKeyOf(id).length > 0 }
1862
+ for (const id of CODING_PLAN_IDS) {
1863
+ if (!providers[id].hasKey) continue
1864
+ const cached = planCache.get(id)
1865
+ if (cached) providers[id] = { ...providers[id], ...cached.result, stale: Date.now() - cached.at >= PLAN_TTL_MS }
1866
+ }
1867
+ return providers
1868
+ }
1869
+ if (path === '/coding-plans' && req.method === 'GET') {
1870
+ // 已配置且缓存过期的后台补刷:GET 不阻塞,下一轮轮询取到新值。
1871
+ for (const id of CODING_PLAN_IDS) {
1872
+ if (!planKeyOf(id)) continue
1873
+ const cached = planCache.get(id)
1874
+ if (!cached || Date.now() - cached.at >= PLAN_TTL_MS) void refreshCodingPlan(id)
1875
+ }
1876
+ send(200, { providers: planState() })
1877
+ return
1878
+ }
1879
+ if (path === '/coding-plans/refresh' && req.method === 'POST') {
1880
+ let raw = ''
1881
+ for await (const chunk of req) raw += chunk
1882
+ let body = {}
1883
+ try { body = raw ? JSON.parse(raw) : {} } catch { send(400, { error: 'invalid-json' }); return }
1884
+ const only = typeof body.provider === 'string' ? body.provider : ''
1885
+ const ids = only ? [only] : CODING_PLAN_IDS.filter((id) => planKeyOf(id))
1886
+ for (const id of ids) await refreshCodingPlan(id, true)
1887
+ send(200, { ok: true, providers: planState() })
1888
+ return
1889
+ }
1890
+ if (path === '/coding-plans/config' && req.method === 'POST') {
1891
+ let raw = ''
1892
+ for await (const chunk of req) raw += chunk
1893
+ let body
1894
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
1895
+ const id = typeof body.provider === 'string' ? body.provider : ''
1896
+ if (!CODING_PLAN_IDS.includes(id)) { send(400, { error: 'unknown-provider' }); return }
1897
+ const key = typeof body.key === 'string' ? body.key.trim().slice(0, 4096) : ''
1898
+ const merged = { ...(live.codingPlans ?? {}) }
1899
+ if (key) {
1900
+ const origin = normalizeMiniMaxOrigin(typeof body.origin === 'string' ? body.origin : undefined)
1901
+ if (origin === null) { send(400, { error: 'invalid-origin' }); return }
1902
+ merged[id] = origin ? { key, origin } : { key }
1903
+ } else {
1904
+ delete merged[id] // 空 key = 清除配置
1905
+ }
1906
+ live.codingPlans = merged
1907
+ planCache.delete(id)
1908
+ persistSettings()
1909
+ void report(`coding plan config updated: ${id} ${key ? 'set' : 'cleared'}`)
1910
+ send(200, { ok: true })
1911
+ return
1912
+ }
1913
+
1914
+ send(404, { error: 'not-found' })
1915
+ },
1916
+ }), 'dsh-cost: /api/dsh-cost/*')
1917
+ })
1918
+
1919
+ function summarize(row) {
1920
+ return {
1921
+ input: row.input ?? 0, output: row.output ?? 0, cacheRead: row.cacheRead ?? 0,
1922
+ cacheWrite: row.cacheWrite ?? 0, reasoning: row.reasoning ?? 0,
1923
+ calls: row.calls ?? 0, cost: row.cost ?? 0,
1924
+ }
1925
+ }
1926
+
1927
+ function mergeDay(a, b) {
1928
+ const merged = { ...zeroBuckets(), calls: 0, cost: 0 }
1929
+ for (const part of [a, b]) {
1930
+ for (const key of Object.keys(zeroBuckets())) merged[key] += part[key] ?? 0
1931
+ merged.calls += part.calls ?? 0
1932
+ merged.cost += part.cost ?? 0
1933
+ }
1934
+ return merged
1935
+ }
1936
+
1937
+ function currentTier(entry, now, peak) {
1938
+ return tierFor(entry, now, peak)
1939
+ }
1940
+ }
1941
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
1942
+ })()
1943
+
1944
+ /* ==== module: picker(原 dsh-power-picker) ==== */
1945
+ const pickerModule = (() => {
1946
+ /* 由 scripts/build-suite.mjs 从 dsh-power-picker/index.js 机械生成;勿手改,重跑脚本再生成。 */
1947
+ /**
1948
+ * dsh-power-picker — Codex 风格的模型 + 推理强度选择器(DeepSeek Harness / dsh)
1949
+ *
1950
+ * 服务端半很薄:`enabled` 开关 + 诊断落盘。所有真正的逻辑都在客户端——
1951
+ * 以更低 priority 注册 `conversation.input.model` 槽位遮蔽内置选择器,
1952
+ * 数据走官方 modelDirectories 会话模型目录。服务端唯一的职责:
1953
+ * 1. 让「关闭插件」有一个不用卸载的开关(客户端注册遮蔽前先来确认);
1954
+ * 2. 接收客户端诊断事件,落到 ~/.dsh/power-picker-debug.log,
1955
+ * 座位异常时看这份文件就能定位卡在哪一环(服务端起没起、客户端
1956
+ * 模块加载没有、inject 是否失败、快照字段长什么样)。
1957
+ *
1958
+ * 已核对的 dsh API(0.2.1-rc.3 已装运行时反推 + dsh-reference 对照):
1959
+ * - 客户端 ctx.modelDirectories.directoryFor(sessionId) → 目录
1960
+ * - 组件拿到的是 directory.store(SnapshotStore):subscribe/getSnapshot
1961
+ * - 快照:{ current: {provider, model, reasoningEffort?} | null,
1962
+ * groups: [{ id, name, models: [{ id, name, reasoning? }] }],
1963
+ * retainedEffort?, pending, ... }
1964
+ * 档位阶梯在 groups[].models[].reasoning = { defaultEffort?, efforts: [{id, name}] }
1965
+ * - directory.select({ provider, model, reasoningEffort? }) 官方切换
1966
+ * - conversation.input.model 是 single 槽位,多注册按 priority 只渲染最低者
1967
+ */
1968
+
1969
+
1970
+
1971
+
1972
+
1973
+
1974
+
1975
+ const name = 'power-picker'
1976
+
1977
+ const DIAG_LOG = join(homedir(), '.dsh', 'power-picker-debug.log')
1978
+
1979
+ const Config = Schema.object({
1980
+ /** 关闭后客户端不再注册任何入口,模型选择恢复原样(无需卸载插件)。 */
1981
+ enabled: Schema.boolean().default(true).description('是否启用 Codex 风格选择器'),
1982
+ /**
1983
+ * Codex 实测形态:入口就是输入栏右下角「选择推理强度」按钮,面板从其
1984
+ * 上方弹出。默认 true;false 时输入栏不渲染入口(无任何入口)。
1985
+ */
1986
+ composerSeat: Schema.boolean().default(true).description('输入栏保留模型入口(Codex 同位:右下角,发送键旁)'),
1987
+ })
1988
+
1989
+ function apply(ctx, config) {
1990
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
1991
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
1992
+ const live = { ...config }
1993
+
1994
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
1995
+ const SETTINGS_KEYS = ['enabled', 'composerSeat']
1996
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-power-picker', 'settings.json')
1997
+ try {
1998
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
1999
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
2000
+ } catch { /* 无存档:全部跟随配置 */ }
2001
+ let settingsTimer = null
2002
+ const persistSettings = () => {
2003
+ if (settingsTimer) return
2004
+ settingsTimer = setTimeout(() => {
2005
+ settingsTimer = null
2006
+ try {
2007
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
2008
+ const out = {}
2009
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
2010
+ const tmp = `${SETTINGS_PATH}.tmp`
2011
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
2012
+ renameSync(tmp, SETTINGS_PATH)
2013
+ } catch { /* ignore */ }
2014
+ }, 400)
2015
+ }
2016
+
2017
+ const say = (message) => {
2018
+ try { console.info(`[dsh-power-picker] ${message}`) } catch { /* ignore */ }
2019
+ }
2020
+ const report = async (line) => {
2021
+ try {
2022
+ await mkdir(dirname(DIAG_LOG), { recursive: true })
2023
+ await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
2024
+ } catch { /* ignore */ }
2025
+ }
2026
+
2027
+ say(`server boot, enabled=${live.enabled !== false}`)
2028
+ void report(`server boot enabled=${live.enabled !== false}`)
2029
+
2030
+ ctx.inject(['webServer'], (webCtx) => {
2031
+ webCtx.effect(() => webCtx.webServer.register({
2032
+ kind: 'prefix',
2033
+ path: '/api/dsh-power-picker',
2034
+ handler: async (req, res) => {
2035
+ const send = (status, body) => {
2036
+ res.statusCode = status
2037
+ res.setHeader('content-type', 'application/json')
2038
+ res.end(JSON.stringify(body))
2039
+ }
2040
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-power-picker/, '')
2041
+ if (path === '/settings' && req.method === 'POST') {
2042
+ let raw = ''
2043
+ for await (const chunk of req) raw += chunk
2044
+ let body
2045
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
2046
+ if (body.enabled !== undefined) live.enabled = body.enabled === true
2047
+ if (typeof body.composerSeat === 'string') live.composerSeat = body.composerSeat.slice(0, 40)
2048
+ persistSettings()
2049
+ send(200, { ...live })
2050
+ return
2051
+ }
2052
+
2053
+
2054
+ // 客户端注册前读取设置:enabled 总开关 + composerSeat 入口位置。
2055
+ if (path === '/settings' && req.method === 'GET') {
2056
+ send(200, {
2057
+ enabled: live.enabled !== false,
2058
+ composerSeat: live.composerSeat === true,
2059
+ name,
2060
+ })
2061
+ return
2062
+ }
2063
+
2064
+ // 诊断上报:一行一条 JSON,出问题时看 ~/.dsh/power-picker-debug.log。
2065
+ if (path === '/diag' && req.method === 'POST') {
2066
+ let raw = ''
2067
+ for await (const chunk of req) raw += chunk
2068
+ await report(raw.slice(0, 2000))
2069
+ send(200, { ok: true })
2070
+ return
2071
+ }
2072
+
2073
+ send(404, { error: 'not-found' })
2074
+ },
2075
+ }), 'dsh-power-picker: /api/dsh-power-picker/*')
2076
+ })
2077
+ }
2078
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
2079
+ })()
2080
+
2081
+ /* ==== module: enhancer(原 dsh-prompt-enhancer) ==== */
2082
+ const enhancerModule = (() => {
2083
+ /* 由 scripts/build-suite.mjs 从 dsh-prompt-enhancer/index.js 机械生成;勿手改,重跑脚本再生成。 */
2084
+ /**
2085
+ * dsh-prompt-enhancer — 对话框提示词自动增强插件(DeepSeek Harness / dsh)
2086
+ *
2087
+ * 工作原理:
2088
+ * 挂载 `agent/pre-step` 瀑布流。每一步开始前,dsh 会把「即将进入本轮的
2089
+ * 用户消息批次」交给所有 pre-step 监听器裁决(PreStepDecision)。本插件
2090
+ * 在拿到默认裁决(kind === 'enter')后,把批次里的纯文本用户消息先发给
2091
+ * 一次轻量模型调用,重写成清晰、具体、自包含的提示词,再放行进入会话——
2092
+ * 即 WorkBuddy / ChatGPT 风格的「发送前提示词增强」。
2093
+ *
2094
+ * 安全设计(fail-open):
2095
+ * 增强调用失败、超时、找不到模型路由时,一律原样放行原始消息,
2096
+ * 绝不阻塞用户的正常对话。
2097
+ *
2098
+ * 已核对的 dsh API(deepseek-ai/deepseek-harness,developer preview):
2099
+ * - PreStepDecision = { kind: 'reject' } | { kind: 'enter', messages: UserMessage[], startsRequestSeries?: true }
2100
+ * - ctx.on('agent/pre-step', async ({ agent }, next) => PreStepDecision)
2101
+ * - ctx.llm.stream({ provider, model, messages }) → AsyncIterable<StreamChunk>
2102
+ * (文本块为 { type: 'text-delta', text };终帧为 { type: 'finish', reason })
2103
+ * - createUserMessage({ content, source }) 来自 '@deepseek-ai/dsh-llm'
2104
+ * - defineTool(...) 来自 '@deepseek-ai/dsh-tools';服务名 'llm' / 'tools'
2105
+ */
2106
+
2107
+
2108
+
2109
+
2110
+
2111
+
2112
+
2113
+
2114
+
2115
+
2116
+ /** 排查日志:客户端把拦截决策写到这里,出问题时直接看文件。 */
2117
+ const DEBUG_LOG = join(homedir(), '.dsh', 'prompt-enhancer-debug.log')
2118
+
2119
+ const name = 'prompt-enhancer'
2120
+
2121
+ const moduleInject = ['llm', 'tools']
2122
+
2123
+ const Config = Schema.object({
2124
+ /** 总开关:关闭后插件完全不参与 pre-step,也不注册工具行为变化。 */
2125
+ enabled: Schema.boolean().default(true).description('是否启用自动提示词增强'),
2126
+ /** auto:每条人类消息发送前自动增强;manual:只注册 enhance_prompt 工具,不自动改写。 */
2127
+ mode: Schema.union(['auto', 'manual']).default('auto').description('auto=自动增强, manual=仅工具'),
2128
+ /** 默认增强强度;前端选择器会覆盖它。off 表示默认不增强。 */
2129
+ level: Schema.union(['off', 'low', 'high', 'max']).default('high').description('默认增强强度:off/low/high/max'),
2130
+ /**
2131
+ * pre-step 兜底增强。默认关闭:前端拦截路径已经负责增强,服务端再动一次
2132
+ * 会让「自动增强关闭」失效(用户看不到,消息却被改了)。
2133
+ * 只有当前端拦截失效、又确实想要兜底时才手动打开。
2134
+ */
2135
+ preStepAuto: Schema.boolean().default(false).description('是否在 pre-step 兜底自动增强(默认关闭,前端已负责增强)'),
2136
+ /** 留空则跟随当前会话 Agent 的 provider 路由。 */
2137
+ provider: Schema.string().description('增强调用使用的 provider(留空跟随会话)'),
2138
+ /** 留空则跟随当前会话 Agent 的 model。建议配一个快模型降低延迟。 */
2139
+ model: Schema.string().description('增强调用使用的模型(留空跟随会话,建议快模型)'),
2140
+ /** 单次增强调用的超时时间,超时即放弃增强、原样放行。 */
2141
+ timeoutMs: Schema.number().default(20000).description('增强调用超时(毫秒)'),
2142
+ /** 短于该长度(字符)的输入不做增强(如「继续」「ok」)。 */
2143
+ minPromptLength: Schema.number().default(4).description('触发增强的最小输入长度(字符)'),
2144
+ /** 超过该长度的输入视为已经是完整提示词,不再增强。 */
2145
+ maxInputChars: Schema.number().default(4000).description('超过此长度不再增强(字符)'),
2146
+ /** 增强后提示词的语言:auto=跟随原始输入。 */
2147
+ language: Schema.union(['auto', 'zh', 'en']).default('auto').description('输出语言'),
2148
+ /** 追加到内置改写规则之后的自定义风格要求(可为空)。 */
2149
+ styleHint: Schema.string().default('').description('自定义风格要求(追加到改写规则后)'),
2150
+ /** 增强时把最近一轮会话拼进改写参考:代词/指代(它、这个)能被上下文消解。 */
2151
+ useContext: Schema.boolean().default(false).description('参考最近会话上下文(输入框按钮生效)'),
2152
+ })
2153
+
2154
+ /* ------------------------------------------------------------------ */
2155
+ /* 增强强度分级 */
2156
+ /* ------------------------------------------------------------------ */
2157
+
2158
+ /**
2159
+ * 四档强度的差异不在「想得多深」,而在「改多少、补多少、要不要改结构」。
2160
+ * 每档给独立的规则、token 上限和长度上限,避免 Max 档把一句话膨胀成论文。
2161
+ */
2162
+ const LEVELS = {
2163
+ off: null,
2164
+ low: {
2165
+ label: 'Low',
2166
+ maxTokens: 900,
2167
+ maxRatio: 1.4,
2168
+ rules: [
2169
+ 'INTENSITY: LOW (minimal edit).',
2170
+ 'Change only what is necessary to remove ambiguity. Keep the original wording, tone and length as much as possible.',
2171
+ 'Do: fix vague pronouns, unclear verbs, missing objects, redundancy, obvious typos. Split run-on sentences.',
2172
+ 'Do NOT: add new sections, add headings, invent requirements, add constraints the user did not imply, expand into a spec.',
2173
+ 'Target length: at most 1.4x the original. Shorter is better.',
2174
+ 'If the input is already clear, return it almost unchanged.',
2175
+ ],
2176
+ },
2177
+ high: {
2178
+ label: 'High',
2179
+ maxTokens: 1800,
2180
+ maxRatio: 2.2,
2181
+ rules: [
2182
+ 'INTENSITY: HIGH (structured completion).',
2183
+ 'Rewrite into a clear, self-contained, directly executable request. Add the context, constraints and output expectation that a competent assistant would need.',
2184
+ 'Do: state the goal explicitly, make implicit requirements explicit, specify the expected output form, keep the user\'s intent and scope.',
2185
+ 'Do NOT: invent facts, technologies, file names or numbers the user never mentioned. When a detail is genuinely unknown, express it conditionally ("if X, then Y") or mark a default as "(default: ...)".',
2186
+ 'Do NOT: reply with "please confirm these details first" or ask the user anything. That converts the prompt into a conversation. Fill the gap yourself and keep the request executable.',
2187
+ 'Do NOT: turn the request into a tutorial, a plan, or an answer. It must stay a request.',
2188
+ 'Prefer flowing paragraphs over markdown headings unless the task is clearly multi-part.',
2189
+ 'Target length: 1.3x to 2.2x the original. For very short inputs (under 20 characters), 2-4x is fine — a bare phrase needs more scaffolding than a half-written spec.',
2190
+ ],
2191
+ },
2192
+ max: {
2193
+ label: 'Max',
2194
+ maxTokens: 3200,
2195
+ maxRatio: 3.5,
2196
+ rules: [
2197
+ 'INTENSITY: MAX (first-principles reconstruction).',
2198
+ 'First reason about what outcome the user actually wants, then rebuild the request from that outcome rather than patching the wording.',
2199
+ 'Do: decompose into goal, context, constraints, deliverables, acceptance criteria when the task warrants it; surface hidden assumptions and mark each one inline as "(assumption: ...)"; make success verifiable.',
2200
+ 'Do: point out missing critical information as conditional requirements inside the prompt, never as a question back to the user.',
2201
+ 'Do NOT: invent facts. Every assumption must be labeled.',
2202
+ 'Do NOT: pad with generic prompt-engineering boilerplate ("You are a world-class expert..."), role-play scaffolding, or restated instructions.',
2203
+ 'FORMAT: plain text only, because the output lands in a plain-text composer. No "#" headings, no "**bold**", no "-" or "*" bullets, no code fences.',
2204
+ 'Structure with short labeled lines instead: 目标:/ 背景:/ 约束:/ 产出:/ 验收: (or Goal:/Context:/Constraints:/Deliverables: when writing English). One requirement per line, sub-points indented with two spaces.',
2205
+ 'Target length: 2x to 3.5x the original. Never exceed 3500 characters.',
2206
+ ],
2207
+ },
2208
+ }
2209
+
2210
+ /* ------------------------------------------------------------------ */
2211
+ /* 内置改写元提示词 */
2212
+ /* ------------------------------------------------------------------ */
2213
+
2214
+ // 基础规则来自 WorkBuddy 客户端内置的 enhance-prompt 代理(逆向自 app.asar,
2215
+ // agentName = 'enhance-prompt'),改写为通用 agent 场景,并按强度分级。
2216
+ const BASE_RULES = [
2217
+ 'You are a Prompt Engineering Expert. You rewrite a user\'s rough input into a more effective prompt for an AI assistant that can read files, run commands and write code.',
2218
+ '',
2219
+ 'CORE PRINCIPLES:',
2220
+ '- Preserve the user\'s original intent and scope. You are rewriting a request, not answering it.',
2221
+ '- Never fabricate specifics the user did not provide: no invented tech stack, file paths, numbers, names, or requirements.',
2222
+ '- Keep the output in the exact same language as the input. If the input mixes languages, keep the mix.',
2223
+ '- Remove redundancy, resolve ambiguity, make the ask actionable.',
2224
+ '- Do NOT explain HOW to do the task, do NOT answer the question, do NOT add a preamble like "Here is the enhanced prompt".',
2225
+ '- Do NOT request guides, tutorials, or code snippets unless the user asked for them.',
2226
+ '- Do NOT suggest specific technologies unless they appear in the input.',
2227
+ '',
2228
+ 'MISSING INFORMATION — the most important rule:',
2229
+ '- NEVER turn a gap into a question for the user. You are writing a prompt that gets executed immediately, not starting a conversation. Output containing "please confirm...", "请先确认...", "我需要知道..." is a failure.',
2230
+ '- Handle a gap in one of two ways: state a reasonable default inline as "(default: ...)", or phrase it as a conditional requirement inside the prompt ("if the site requires login, ...").',
2231
+ '- Prefer filling in generally applicable constraints (runnable output, dependencies, error handling, acceptance criteria) over interrogating the user about specifics.',
2232
+ '- The rewritten prompt must be executable as-is. Whoever reads it should be able to start working without replying first.',
2233
+ '',
2234
+ 'OUTPUT: only the rewritten prompt text. No commentary, no code fences, no quotes around it.',
2235
+ ].join('\n')
2236
+
2237
+ function buildMetaPrompt(raw, config, level, contextTurn) {
2238
+ const spec = LEVELS[level] ?? LEVELS.high
2239
+ const lines = [BASE_RULES, '', spec.rules.join('\n')]
2240
+ // 参考上下文:只用于消解指代、贴合讨论,绝不据此发明新需求。
2241
+ if (config.useContext && contextTurn?.userText && contextTurn?.assistantText) {
2242
+ lines.push(
2243
+ '',
2244
+ 'Recent conversation context (the most recent user/assistant turn). Use it ONLY to resolve references (it / that / 上面提到的) and align the rewrite with the ongoing discussion; never invent requirements from it:',
2245
+ '<context>',
2246
+ `User: ${contextTurn.userText.slice(-1200)}`,
2247
+ `Assistant: ${contextTurn.assistantText.slice(-1200)}`,
2248
+ '</context>',
2249
+ )
2250
+ }
2251
+ // WorkBuddy 的提示词已内置「跟随输入语言」;这两个配置只用于强制覆盖。
2252
+ if (config.language === 'zh') lines.push('', 'OVERRIDE: write the rewritten prompt in Simplified Chinese.')
2253
+ else if (config.language === 'en') lines.push('', 'OVERRIDE: write the rewritten prompt in English.')
2254
+ if (config.styleHint && config.styleHint.trim().length > 0) {
2255
+ lines.push('', `Additional style requirements from the host config: ${config.styleHint.trim()}`)
2256
+ }
2257
+ // 用标签包住原文:即使原文本身长得像指令,也不会和改写规则混在一起。
2258
+ lines.push('', 'Raw input:', '<original_prompt>', raw, '</original_prompt>')
2259
+ return lines.join('\n')
2260
+ }
2261
+
2262
+ /* ------------------------------------------------------------------ */
2263
+ /* 最近一轮会话捕获(参考上下文用;深度标记模式照搬 followup 模块) */
2264
+ /* ------------------------------------------------------------------ */
2265
+
2266
+ const llmDepth = new AsyncLocalStorage()
2267
+ const turnCache = new Map()
2268
+ const TURN_CACHE_MAX = 24
2269
+ const TURN_CACHE_TTL_MS = 10 * 60_000
2270
+
2271
+ /** options.messages 里最后一条用户消息的纯文本。 */
2272
+ function lastUserTextOf(messages) {
2273
+ if (!Array.isArray(messages)) return ''
2274
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
2275
+ const m = messages[i]
2276
+ if (m?.role !== 'user') continue
2277
+ const blocks = Array.isArray(m.content) ? m.content : []
2278
+ const joined = blocks
2279
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
2280
+ .map((b) => b.text)
2281
+ .join('')
2282
+ if (joined.trim()) return joined.trim()
2283
+ }
2284
+ return ''
2285
+ }
2286
+
2287
+ function cachedTurnOf(sessionId) {
2288
+ const hit = turnCache.get(sessionId)
2289
+ if (!hit) return null
2290
+ if (Date.now() - hit.at > TURN_CACHE_TTL_MS) { turnCache.delete(sessionId); return null }
2291
+ return hit
2292
+ }
2293
+
2294
+ function rememberTurn(sessionId, userText, assistantText) {
2295
+ if (!sessionId || !userText || !assistantText || assistantText.trim().length === 0) return
2296
+ const prev = turnCache.get(sessionId)
2297
+ // 同一轮的多步工具链 / 重试续流 userText 相同:助手文本追加累积。
2298
+ const merged = prev && prev.userText === userText
2299
+ ? `${prev.assistantText}\n\n${assistantText}`
2300
+ : assistantText
2301
+ turnCache.set(sessionId, { userText, assistantText: merged.slice(-6000), at: Date.now() })
2302
+ if (turnCache.size > TURN_CACHE_MAX) {
2303
+ let oldestKey = null
2304
+ let oldestAt = Infinity
2305
+ for (const [key, value] of turnCache) {
2306
+ if (value.at < oldestAt) { oldestAt = value.at; oldestKey = key }
2307
+ }
2308
+ if (oldestKey !== null) turnCache.delete(oldestKey)
2309
+ }
2310
+ }
2311
+
2312
+ /* ------------------------------------------------------------------ */
2313
+ /* 工具函数 */
2314
+ /* ------------------------------------------------------------------ */
2315
+
2316
+ /** 提取消息里的纯文本内容;非「聊天框用户消息 / 单个文本块」返回 null(不处理)。 */
2317
+ function singleTextOf(message) {
2318
+ if (!message || message.role !== 'user') return null
2319
+ // dsh 内部还会产生 role 为 user、source.kind 为 goal / session-reference /
2320
+ // notice 等的消息;它们不是用户在对话框里输入的内容,绝不能改写。
2321
+ if (message.source?.kind !== 'user') return null
2322
+ if (!Array.isArray(message.content)) return null
2323
+ if (message.content.length !== 1) return null
2324
+ const block = message.content[0]
2325
+ return block && block.type === 'text' && typeof block.text === 'string' ? block.text : null
2326
+ }
2327
+
2328
+ /** 判断是否应该跳过增强。 */
2329
+ function shouldSkip(text, config) {
2330
+ if (!text) return true
2331
+ const trimmed = text.trim()
2332
+ if (trimmed.length === 0) return true
2333
+ // 斜杠命令(/plan、/goal 等)绝不改写
2334
+ if (trimmed.startsWith('/')) return true
2335
+ if (trimmed.length < config.minPromptLength) return true
2336
+ if (trimmed.length > config.maxInputChars) return true
2337
+ return false
2338
+ }
2339
+
2340
+ /** 去掉模型输出里常见的包裹物:代码围栏、引号、前缀说明行。 */
2341
+ function cleanModelOutput(text) {
2342
+ let out = String(text ?? '').trim()
2343
+ // 去掉成对代码围栏 ```...```(含语言标注)
2344
+ const fence = out.match(/^```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n?```$/)
2345
+ if (fence) out = fence[1].trim()
2346
+ // 去掉「增强后提示词:」「Enhanced prompt:」之类前缀行
2347
+ out = out.replace(/^(?:enhanced prompt|提示词增强|增强后的提示词|改写后(?:的提示词)?)\s*[::]\s*/i, '')
2348
+ // 去掉最外层成对引号
2349
+ const q = out.match(/^"([\s\S]*)"$|^“([\s\S]*)”$/)
2350
+ if (q) out = (q[1] ?? q[2]).trim()
2351
+ // 兜底去 markdown:输入框是纯文本,留着 # 和 ** 只会变成噪音
2352
+ out = out
2353
+ .replace(/^#{1,6}\s+/gm, '')
2354
+ .replace(/\*\*(.+?)\*\*/g, '$1')
2355
+ .replace(/^(\s*)[-*]\s+/gm, '$1· ')
2356
+ return out
2357
+ }
2358
+
2359
+ /** 调一次 LLM 做改写;任何失败都抛错,由上层兜底放行。 */
2360
+ async function rewriteWithLlm(ctx, route, raw, config, level, outerSignal, contextTurn) {
2361
+ const spec = LEVELS[level] ?? LEVELS.high
2362
+ // 超时与外部取消(用户中断 pre-step)收敛到同一个内部 signal,
2363
+ // 底层流会被及时中断,不会在后台继续烧 token。
2364
+ const controller = new AbortController()
2365
+ const forwardAbort = () => controller.abort(outerSignal.reason)
2366
+ if (outerSignal?.aborted) forwardAbort()
2367
+ else outerSignal?.addEventListener('abort', forwardAbort, { once: true })
2368
+ const timer = setTimeout(
2369
+ () => controller.abort(new Error('prompt-enhancer timeout')),
2370
+ config.timeoutMs,
2371
+ )
2372
+
2373
+ const aborted = new Promise((_, reject) => {
2374
+ const fail = () => reject(controller.signal.reason ?? new Error('prompt-enhancer aborted'))
2375
+ // 外层 signal 可能一开始就已 abort:先检查,再挂监听。
2376
+ if (controller.signal.aborted) fail()
2377
+ else controller.signal.addEventListener('abort', fail, { once: true })
2378
+ })
2379
+ // 成功路径下 aborted 不会被消费,挂空 catch 避免 unhandled rejection。
2380
+ void aborted.catch(() => {})
2381
+
2382
+ // 给 dsh-cost 的计量通道打来源标记:增强调用单独归类,费用弹卡里才能
2383
+ // 把「增强」和「对话」分开算。5 秒窗口足够覆盖一次调用的启动。
2384
+ globalThis.__dshPluginCall = { source: 'enhance', at: Date.now() }
2385
+
2386
+ const iterator = ctx.llm.stream({
2387
+ provider: route.provider,
2388
+ model: route.model,
2389
+ messages: [{ role: 'user', content: [{ type: 'text', text: buildMetaPrompt(raw, config, level, contextTurn) }] }],
2390
+ maxTokens: spec.maxTokens,
2391
+ signal: controller.signal,
2392
+ })[Symbol.asyncIterator]()
2393
+
2394
+ async function collect() {
2395
+ let text = ''
2396
+ for (;;) {
2397
+ // 深度标记内拉取:改写调用自己的流不进会话轮缓存。
2398
+ const result = await llmDepth.run(1, () => iterator.next())
2399
+ const { done, value } = result
2400
+ if (done) break
2401
+ if (value.type === 'text-delta') text += value.text
2402
+ else if (value.type === 'finish' && value.reason && (value.reason.kind === 'error' || value.reason.kind === 'aborted')) {
2403
+ throw new Error(`prompt-enhancer llm ${value.reason.kind}: ${value.reason.failure?.message ?? 'unknown'}`)
2404
+ }
2405
+ }
2406
+ return text
2407
+ }
2408
+
2409
+ try {
2410
+ const collected = await Promise.race([collect(), aborted])
2411
+ const cleaned = cleanModelOutput(collected)
2412
+ if (!cleaned) throw new Error('prompt-enhancer got empty output')
2413
+ // 失控保护:模型把一句话膨胀成小作文时判失败,回退原文而不是塞给用户。
2414
+ // 短输入天然膨胀率高(12 字补成 300 字是合理的),所以保底 600 字;
2415
+ // 长输入按倍数算,但封顶 4000 字,避免 4000 字输入算出 2 万字上限。
2416
+ const limit = Math.min(Math.max(Math.round(raw.length * spec.maxRatio * 1.6), 600), 4000)
2417
+ if (cleaned.length > limit) {
2418
+ throw new Error(`prompt-enhancer output runaway: ${cleaned.length} chars > ${limit}`)
2419
+ }
2420
+ return cleaned
2421
+ } catch (err) {
2422
+ // 中断底层流,避免失败后还在后台烧 token。
2423
+ // 不 await:底层若卡死,等待 return() 会拖住 fail-open。
2424
+ try {
2425
+ const closing = iterator.return?.()
2426
+ if (closing && typeof closing.catch === 'function') closing.catch(() => {})
2427
+ } catch { /* ignore */ }
2428
+ throw err
2429
+ } finally {
2430
+ clearTimeout(timer)
2431
+ outerSignal?.removeEventListener('abort', forwardAbort)
2432
+ }
2433
+ }
2434
+
2435
+ /* ------------------------------------------------------------------ */
2436
+ /* 插件入口 */
2437
+ /* ------------------------------------------------------------------ */
2438
+
2439
+ function apply(ctx, config) {
2440
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
2441
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
2442
+ const live = { ...config }
2443
+
2444
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。必须先回放
2445
+ // 再初始化 currentLevel,重启后上次的强度档位才生效。
2446
+ const SETTINGS_KEYS = ['level', 'mode', 'preStepAuto', 'language', 'styleHint', 'timeoutMs', 'minPromptLength', 'maxInputChars', 'useContext']
2447
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'prompt-enhancer', 'settings.json')
2448
+ try {
2449
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
2450
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
2451
+ } catch { /* 无存档:全部跟随配置 */ }
2452
+ let settingsTimer = null
2453
+ const persistSettings = () => {
2454
+ if (settingsTimer) return
2455
+ settingsTimer = setTimeout(() => {
2456
+ settingsTimer = null
2457
+ try {
2458
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
2459
+ const out = {}
2460
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
2461
+ const tmp = `${SETTINGS_PATH}.tmp`
2462
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
2463
+ renameSync(tmp, SETTINGS_PATH)
2464
+ } catch { /* ignore */ }
2465
+ }, 400)
2466
+ }
2467
+
2468
+ const log = (level, msg) => console[level === 'error' ? 'error' : 'log'](`[dsh-prompt-enhancer] ${msg}`)
2469
+
2470
+ /** 最近一次见到的会话路由,供 enhance_prompt 工具和 web 端点在没有显式配置时复用。 */
2471
+ let lastRoute = null
2472
+
2473
+ /**
2474
+ * 运行时增强强度。前端选择器通过 /api/prompt-enhancer/settings 改这里,
2475
+ * 让 pre-step 与手动按钮共用同一档位。初始值来自配置。
2476
+ */
2477
+ let currentLevel = Object.prototype.hasOwnProperty.call(LEVELS, live.level) ? live.level : 'high'
2478
+
2479
+ /**
2480
+ * 前端刚增强过的文本指纹。发送拦截路径增强完再 submit 时,pre-step 会再看到
2481
+ * 这条消息;用它去重,避免同一句话被改两遍。两分钟自动过期。
2482
+ */
2483
+ const recentEnhanced = new Map()
2484
+ const DEDUPE_TTL = 120_000
2485
+
2486
+ function fingerprint(text) {
2487
+ let h = 0
2488
+ for (let i = 0; i < text.length; i++) {
2489
+ h = (h * 31 + text.charCodeAt(i)) | 0
2490
+ }
2491
+ return `${text.length}:${h}`
2492
+ }
2493
+
2494
+ function rememberEnhanced(text) {
2495
+ const now = Date.now()
2496
+ for (const [key, ts] of recentEnhanced) {
2497
+ if (now - ts > DEDUPE_TTL) recentEnhanced.delete(key)
2498
+ }
2499
+ recentEnhanced.set(fingerprint(text), now)
2500
+ }
2501
+
2502
+ function wasJustEnhanced(text) {
2503
+ const ts = recentEnhanced.get(fingerprint(text))
2504
+ return ts !== undefined && Date.now() - ts < DEDUPE_TTL
2505
+ }
2506
+
2507
+ /**
2508
+ * 全局默认模型选择(composer 里的模型选择器改的就是它),
2509
+ * 让输入框按钮在「会话还没创建、没有 agent」时也能拿到路由。
2510
+ */
2511
+ function defaultRoute() {
2512
+ const defaults = ctx.get('agentDefaultModel')
2513
+ const selection = defaults?.currentSelection?.()
2514
+ return selection?.provider && selection?.model
2515
+ ? { provider: selection.provider, model: selection.model }
2516
+ : null
2517
+ }
2518
+
2519
+ /** 配置里显式指定的路由。 */
2520
+ function configRoute() {
2521
+ return live.provider && live.model ? { provider: live.provider, model: live.model } : null
2522
+ }
2523
+
2524
+ function resolveRoute(agent) {
2525
+ const provider = agent?.options?.provider
2526
+ const model = agent?.options?.model
2527
+ return configRoute()
2528
+ ?? (provider && model ? { provider, model } : null)
2529
+ ?? defaultRoute()
2530
+ }
2531
+
2532
+ /** web 端点没有 agent:配置 > 最近一次会话路由 > 全局默认选择。 */
2533
+ function endpointRoute() {
2534
+ return configRoute() ?? lastRoute ?? defaultRoute()
2535
+ }
2536
+
2537
+ // 会话一建立就记住路由,这样输入框按钮在「还没发过消息」时也能工作。
2538
+ ctx.on('agent/created', ({ agent }) => {
2539
+ const route = resolveRoute(agent)
2540
+ if (route) lastRoute = route
2541
+ })
2542
+
2543
+ /** 对单条消息做增强;不需要增强时原样返回同一个对象。 */
2544
+ async function enhanceOne(message, agent, signal) {
2545
+ const raw = singleTextOf(message)
2546
+ if (raw === null || shouldSkip(raw, config)) return message
2547
+ // 前端发送拦截路径已经增强过,这里放行,避免二次改写。
2548
+ if (wasJustEnhanced(raw.trim())) return message
2549
+ const level = currentLevel
2550
+ if (!level || level === 'off') return message
2551
+ const route = resolveRoute(agent) ?? lastRoute
2552
+ if (!route) return message
2553
+ if (!llmRef) return message // llm 服务尚未就绪(inject 未解析完):fail-open 放行原文
2554
+ const enhanced = await rewriteWithLlm({ llm: llmRef }, route, raw.trim(), config, level, signal)
2555
+ log('log', `enhanced[${level}] ${raw.trim().length} -> ${enhanced.length} chars`)
2556
+ // 用官方工厂重建消息:获得全新稳定 id,source 沿用原消息
2557
+ return createUserMessage({
2558
+ content: [{ type: 'text', text: enhanced }],
2559
+ source: message.source,
2560
+ })
2561
+ }
2562
+
2563
+ /** 批量增强;没有任何改动时原样返回原数组。 */
2564
+ async function enhanceBatch(messages, agent, signal) {
2565
+ const out = []
2566
+ let changed = false
2567
+ for (const message of messages) {
2568
+ let next = message
2569
+ try {
2570
+ next = await enhanceOne(message, agent, signal)
2571
+ } catch (err) {
2572
+ // fail-open:增强失败绝不阻塞对话
2573
+ log('error', `enhance failed, pass through original: ${err?.message ?? err}`)
2574
+ next = message
2575
+ }
2576
+ if (next !== message) changed = true
2577
+ out.push(next)
2578
+ }
2579
+ return changed ? out : messages
2580
+ }
2581
+
2582
+ // pre-step 自动增强用的 llm 引用:在下方嵌套 inject 作用域里捕获。
2583
+ // 套件宿主不按插件级 inject 并集给顶层作用域授权属性访问——真机 0.3.0:
2584
+ // suite 联合声明了 tools,apply 里 ctx.tools 仍报 cannot get property
2585
+ // without inject;0.3.2 同款实锤蔓延到请求期 ctx.llm(/rewrite 全挂)。
2586
+ // 凡要用的服务,必须写进所在嵌套作用域自己的 inject 数组。
2587
+ let llmRef = null
2588
+
2589
+ if (live.enabled) {
2590
+ // 最近一轮会话捕获:输入框按钮带 sessionId 来取(参考上下文开关)。
2591
+ // 自己的改写调用经 llmDepth 标记透传,不会被误当成对话轮。
2592
+ ctx.on('llm/stream', (options, next) => {
2593
+ if (llmDepth.getStore() !== undefined) return next()
2594
+ const downstream = next()
2595
+ const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
2596
+ const userText = lastUserTextOf(options?.messages)
2597
+ let assistantText = ''
2598
+ let downstreamIter = null
2599
+ let completed = false
2600
+ return (async function* peObserve() {
2601
+ try {
2602
+ downstreamIter = downstream[Symbol.asyncIterator]()
2603
+ for (;;) {
2604
+ const result = await llmDepth.run(1, () => downstreamIter.next())
2605
+ if (result.done) break
2606
+ const chunk = result.value
2607
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
2608
+ assistantText += chunk.text
2609
+ if (assistantText.length > 8000) assistantText = assistantText.slice(-4000)
2610
+ }
2611
+ yield chunk
2612
+ }
2613
+ completed = true
2614
+ } finally {
2615
+ if (!completed) {
2616
+ try { await downstreamIter?.return?.() } catch { /* ignore */ }
2617
+ }
2618
+ try { rememberTurn(sessionId, userText, assistantText) } catch { /* 缓存失败不影响主对话流 */ }
2619
+ }
2620
+ })()
2621
+ }, { global: true })
2622
+
2623
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
2624
+ const decision = await next()
2625
+ if (decision.kind !== 'enter') return decision
2626
+ const route = resolveRoute(agent)
2627
+ if (route) lastRoute = route
2628
+ if (live.mode !== 'auto') return decision
2629
+ // 前端拦截发送并自行增强时,这里不再二次增强(否则同一句话被改两遍)。
2630
+ if (!live.preStepAuto) return decision
2631
+ const messages = await enhanceBatch(decision.messages, agent, signal)
2632
+ if (messages === decision.messages) return decision
2633
+ // spread 保留未来新增的 decision 字段
2634
+ return { ...decision, messages }
2635
+ })
2636
+
2637
+ // 注册模型可调用的工具:agent 自己也能主动增强某段提示词。
2638
+ // 显式嵌套声明 ['llm','tools'](原因见上方 llmRef 注释);顺手捕获
2639
+ // llm 引用,execute 里也不能用外层 ctx(同款报错,见 0.3.2)。
2640
+ ctx.inject(['llm', 'tools'], (scope) => {
2641
+ llmRef = scope.llm
2642
+ scope.tools.register(defineTool({
2643
+ name: 'enhance_prompt',
2644
+ description: [
2645
+ 'Rewrite a rough user prompt into a clear, specific, self-contained prompt before using it.',
2646
+ 'Use it when the user asks to improve/expand their prompt, or before re-running a vague earlier request.',
2647
+ ].join(' '),
2648
+ parameters: {
2649
+ prompt: { type: 'string', required: true, description: 'The raw prompt text to enhance' },
2650
+ },
2651
+ output: {
2652
+ schema: { type: 'string' },
2653
+ render: (_args, value) => [{ type: 'text', text: value }],
2654
+ },
2655
+ async execute(args, exec) {
2656
+ const route = endpointRoute()
2657
+ if (!route) throw new Error('No model route available: set provider/model in the plugin config.')
2658
+ const level = currentLevel === 'off' ? 'high' : currentLevel
2659
+ const enhanced = await rewriteWithLlm(scope, route, String(args.prompt ?? '').trim(), config, level, exec?.signal)
2660
+ return enhanced
2661
+ },
2662
+ }))
2663
+ })
2664
+
2665
+ log('log', `loaded (mode=${live.mode}, timeout=${live.timeoutMs}ms)`)
2666
+ } else {
2667
+ log('log', 'loaded but disabled by config')
2668
+ }
2669
+
2670
+ /* ------------------------------------------------------------------ */
2671
+ /* web 端「增强提示词」端点(只在有 webServer 的宿主里注册) */
2672
+ /* ------------------------------------------------------------------ */
2673
+
2674
+ // 可选注入:CLI / TUI 等没有 webServer 的宿主照常加载,只是没有输入框按钮。
2675
+ // webServer 作用域里请求期要调 llm:必须写进这个嵌套作用域自己的 inject
2676
+ // (真机 0.3.1:/rewrite 里 rewriteWithLlm(ctx) 报 cannot get property
2677
+ // "llm" without inject——顶层并集不授权属性访问,见上方 llmRef 注释)。
2678
+ ctx.inject(['webServer', 'llm'], (webCtx) => {
2679
+ webCtx.effect(() => webCtx.webServer.register({
2680
+ kind: 'prefix',
2681
+ path: '/api/prompt-enhancer',
2682
+ handler: async (req, res) => {
2683
+ const send = (status, body) => {
2684
+ res.statusCode = status
2685
+ res.setHeader('content-type', 'application/json')
2686
+ res.end(JSON.stringify(body))
2687
+ }
2688
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/prompt-enhancer/, '')
2689
+
2690
+ // 前端初始化:读当前档位,避免刷新后选择器回落到默认值。
2691
+ if (path === '/settings' && req.method === 'GET') {
2692
+ send(200, { ...live, level: currentLevel, levels: Object.keys(LEVELS) })
2693
+ return
2694
+ }
2695
+
2696
+ // 前端改档位:写进运行时状态,让 pre-step 与按钮共用同一档。
2697
+ if (path === '/settings' && req.method === 'POST') {
2698
+ let raw = ''
2699
+ for await (const chunk of req) raw += chunk
2700
+ let body
2701
+ try {
2702
+ body = JSON.parse(raw)
2703
+ } catch {
2704
+ send(400, { error: 'invalid-json' })
2705
+ return
2706
+ }
2707
+ // level 是运行时主开关,必须合法;其余字段宽松校验后写入 live。
2708
+ if (body?.level !== undefined) {
2709
+ const next = body.level
2710
+ if (typeof next !== 'string' || !(next in LEVELS)) {
2711
+ send(400, { error: `level must be one of ${Object.keys(LEVELS).join('/')}` })
2712
+ return
2713
+ }
2714
+ currentLevel = next
2715
+ live.level = next
2716
+ log('log', `level -> ${next}`)
2717
+ }
2718
+ if (body?.mode === 'auto' || body?.mode === 'manual') live.mode = body.mode
2719
+ if (body?.preStepAuto !== undefined) live.preStepAuto = body.preStepAuto === true
2720
+ if (body?.useContext !== undefined) live.useContext = body.useContext === true
2721
+ if (body?.language === 'auto' || body?.language === 'zh' || body?.language === 'en') live.language = body.language
2722
+ if (typeof body?.styleHint === 'string') live.styleHint = body.styleHint.slice(0, 500)
2723
+ for (const [key, min, max] of [['timeoutMs', 1000, 120000], ['minPromptLength', 1, 200], ['maxInputChars', 100, 100000]]) {
2724
+ if (body?.[key] === undefined) continue
2725
+ const value = Number(body[key])
2726
+ if (Number.isFinite(value)) live[key] = Math.min(max, Math.max(min, Math.round(value)))
2727
+ }
2728
+ persistSettings()
2729
+ send(200, { ...live, level: currentLevel })
2730
+ return
2731
+ }
2732
+
2733
+ // 前端把拦截决策写进 ~/.dsh/prompt-enhancer-debug.log,排查用。
2734
+ if (path === '/debug' && req.method === 'POST') {
2735
+ let raw = ''
2736
+ for await (const chunk of req) raw += chunk
2737
+ try {
2738
+ await mkdir(dirname(DEBUG_LOG), { recursive: true })
2739
+ await appendFile(DEBUG_LOG, `${new Date().toISOString()} ${raw}\n`, 'utf8')
2740
+ } catch (err) {
2741
+ log('error', `debug log failed: ${err?.message ?? err}`)
2742
+ }
2743
+ send(200, { ok: true })
2744
+ return
2745
+ }
2746
+
2747
+ if (path !== '/rewrite') {
2748
+ send(404, { error: 'not-found' })
2749
+ return
2750
+ }
2751
+ if (req.method !== 'POST') {
2752
+ res.setHeader('allow', 'POST')
2753
+ send(405, { error: 'method-not-allowed' })
2754
+ return
2755
+ }
2756
+ let raw = ''
2757
+ for await (const chunk of req) raw += chunk
2758
+ let body
2759
+ try {
2760
+ body = JSON.parse(raw)
2761
+ } catch {
2762
+ send(400, { error: 'invalid-json' })
2763
+ return
2764
+ }
2765
+ const text = typeof body?.text === 'string' ? body.text.trim() : ''
2766
+ if (text.length === 0) {
2767
+ send(400, { error: 'text must be a non-empty string' })
2768
+ return
2769
+ }
2770
+ if (text.length > live.maxInputChars) {
2771
+ send(413, { error: `text exceeds ${live.maxInputChars} chars` })
2772
+ return
2773
+ }
2774
+ // 单次请求可以覆盖档位(发送拦截路径会带上当前选择),否则用运行时档位。
2775
+ const requested = typeof body?.level === 'string' && body.level in LEVELS ? body.level : currentLevel
2776
+ const level = requested === 'off' ? null : requested
2777
+ if (!level) {
2778
+ send(200, { text, skipped: 'level-off' })
2779
+ return
2780
+ }
2781
+ const route = endpointRoute()
2782
+ if (!route) {
2783
+ send(409, { error: 'no model route available; set provider/model in the plugin config' })
2784
+ return
2785
+ }
2786
+ // 参考上下文开关:前端带 sessionId,服务端取该会话最近一轮。
2787
+ const contextTurn = live.useContext && typeof body?.sessionId === 'string'
2788
+ ? cachedTurnOf(body.sessionId)
2789
+ : null
2790
+ try {
2791
+ const enhanced = await rewriteWithLlm(webCtx, route, text, config, level, undefined, contextTurn)
2792
+ // 记下指纹:这条文本随后经 submit 进入 pre-step 时不再二次增强。
2793
+ rememberEnhanced(enhanced)
2794
+ send(200, { text: enhanced, level })
2795
+ } catch (err) {
2796
+ log('error', `composer enhance failed: ${err?.message ?? err}`)
2797
+ // 落盘:服务端错误只进 console 等于没有(排障教训)。
2798
+ try {
2799
+ await mkdir(dirname(DEBUG_LOG), { recursive: true })
2800
+ await appendFile(DEBUG_LOG, `${new Date().toISOString()} ${JSON.stringify({ phase: 'rewrite-error', error: String(err?.message ?? err).slice(0, 200) })}\n`, 'utf8')
2801
+ } catch { /* ignore */ }
2802
+ send(502, { error: 'enhance-failed' })
2803
+ }
2804
+ },
2805
+ }), 'prompt-enhancer: /api/prompt-enhancer/*')
2806
+ })
2807
+ }
2808
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
2809
+ })()
2810
+
2811
+ /* ==== module: followup(原 dsh-followup) ==== */
2812
+ const followupModule = (() => {
2813
+ /* 由 scripts/build-suite.mjs 从 dsh-followup/index.js 机械生成;勿手改,重跑脚本再生成。 */
2814
+ /**
2815
+ * dsh-followup — 每轮对话结束后的智能跟进推荐(DeepSeek Harness / dsh)
2816
+ *
2817
+ * Trae「智能跟进推荐」的同款交互:助手回复落定后,用一次轻量模型调用
2818
+ * 生成 2-3 条短跟进建议,缓存在内存里;web 端在输入框上方浮出可点击
2819
+ * 的 chip,点击把建议填进草稿(不自动发送)。
2820
+ *
2821
+ * 工作原理(服务端半):
2822
+ * 主通道(0.2.3 起,借鉴 dsh-cost-meter 的链尾监听模式):
2823
+ * - ctx.on('llm/stream', listener, { global: true }):llm 服务的瀑布事件,
2824
+ * 每次 ctx.llm.stream() 都会经过。监听器拿到下游流后包一层旁路观察
2825
+ * (逐 chunk 透传,不改流协议),累积 text-delta 得到助手回复;
2826
+ * options.sessionId(GenerateOptions.sessionId,dsh-reference types.ts)
2827
+ * 关联会话,options.messages 里最后一个真实用户消息作为本轮输入。
2828
+ * { global: true } 让监听器收到隔离 llm 作用域(记忆插件等)的流。
2829
+ * AsyncLocalStorage 深度标记排除两类重复捕获:包装路由插件在自己
2830
+ * stream() 体内再发起的上游调用(pull 下游时打标记),以及本插件
2831
+ * 自己发起的建议生成调用(发起时打标记)。
2832
+ * 回落通道:
2833
+ * - 客户端回合落定后 POST /generate {sessionId};服务端优先用 llm/stream
2834
+ * 实时捕获的缓存(10 分钟内),未命中才解压 session.v4.jsonl.zstd 直读
2835
+ * (日志驱动,0.2.0 引入,对新架构进程分裂免疫)
2836
+ * 旧架构事件监听(agent/pre-step / assistant-stream / status)保留:
2837
+ * 收不到事件的宿主上它们是死代码,旧版本 dsh 上仍可用。
2838
+ *
2839
+ * 已核对的 dsh API(deepseek-ai/deepseek-harness,对照 dsh-reference):
2840
+ * - ctx.on('llm/stream', (options, next) => AsyncIterable, { global: true })
2841
+ * 监听器契约:必须调用 next() 推进瀑布并返回(包装的)流;嵌套/排除
2842
+ * 时原样返回 next() 的下游
2843
+ * - GenerateOptions.sessionId?: Branded<'SessionId'>(packages/llm/llm/src/types.ts)
2844
+ * - ctx.on('agent/status', ({ agent, status }) => ...) // idle ⇄ running
2845
+ * - ctx.on('agent/pre-step', async ({ agent, messages }, next) => PreStepDecision)
2846
+ * - ctx.on('agent/assistant-stream', ({ agent, frame }) => ...)
2847
+ * frame = { type: 'start' | 'chunk' | 'end', chunk?: StreamChunk, ... }
2848
+ * 文本块 chunk = { type: 'text-delta', text }
2849
+ * - ctx.llm.stream({ provider, model, messages, maxTokens, signal })
2850
+ * → AsyncIterable<{ type: 'text-delta', text } | { type: 'finish', reason }>
2851
+ * - agent.options.provider / agent.options.model:会话当前的路由
2852
+ * - ctx.get('agentDefaultModel')?.currentSelection?.() → { provider, model }
2853
+ * - webServer.register({ kind: 'prefix', path, handler }, label)
2854
+ */
2855
+
2856
+
2857
+
2858
+
2859
+
2860
+
2861
+
2862
+
2863
+ const name = 'followup'
2864
+
2865
+ const DIAG_LOG = join(homedir(), '.dsh', 'followup-debug.log')
2866
+ const SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
2867
+ const PROFILES_ROOT = join(homedir(), '.dsh', 'profiles')
2868
+ const SUGGEST_FILE = join(homedir(), '.dsh', 'followup-suggestions.json')
2869
+
2870
+ /** 每环节一行落盘:事件是否到、文本抓没抓到、生成成没成,一次复现即可定位断点。 */
2871
+ const report = async (line) => {
2872
+ try {
2873
+ await mkdir(dirname(DIAG_LOG), { recursive: true })
2874
+ await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
2875
+ } catch { /* ignore */ }
2876
+ }
2877
+
2878
+ /* ------------------------------------------------------------------ */
2879
+ /* 会话日志直读(新架构进程分裂后 agent/* 事件到不了插件进程, */
2880
+ /* 0.2.0 起改为日志驱动:解压 session.v4.jsonl.zstd 取最后一轮文本) */
2881
+ /* ------------------------------------------------------------------ */
2882
+
2883
+ /** 多帧 zstd(追加写每帧独立):按魔数切段逐帧解压,坏帧跳过。 */
2884
+ async function decompressLog(file) {
2885
+ const zlib = await import('node:zlib')
2886
+ if (typeof zlib.zstdDecompressSync !== 'function') {
2887
+ throw new Error('node zlib 无 zstd 支持(需 Node ≥23.8)')
2888
+ }
2889
+ const buf = await readFile(file)
2890
+ const MAGIC = 0x28
2891
+ const idxs = [0]
2892
+ for (let i = 4; i <= buf.length - 4; i += 1) {
2893
+ if (buf[i] === MAGIC && buf[i + 1] === 0xB5 && buf[i + 2] === 0x2F && buf[i + 3] === 0xFD) idxs.push(i)
2894
+ }
2895
+ let out = ''
2896
+ for (let k = 0; k < idxs.length; k += 1) {
2897
+ try {
2898
+ out += zlib.zstdDecompressSync(buf.subarray(idxs[k], idxs[k + 1] ?? buf.length)).toString('utf8')
2899
+ } catch { /* 截断/坏帧跳过 */ }
2900
+ }
2901
+ return out
2902
+ }
2903
+
2904
+ /** 找会话日志文件:目录名可能是裸 uuid 或 session-<uuid>。 */
2905
+ async function findSessionFile(sessionId) {
2906
+ const bare = String(sessionId).replace(/^session-/, '')
2907
+ let workspaces = []
2908
+ try { workspaces = await readdir(SESSIONS_ROOT, { withFileTypes: true }) } catch { return null }
2909
+ for (const ws of workspaces) {
2910
+ if (!ws.isDirectory()) continue
2911
+ for (const name of [sessionId, `session-${bare}`, bare]) {
2912
+ const file = join(SESSIONS_ROOT, ws.name, name, 'session.v4.jsonl.zstd')
2913
+ try { await readFile(file); return file } catch { /* 尝试下一个 */ }
2914
+ }
2915
+ }
2916
+ return null
2917
+ }
2918
+
2919
+ /** 从持久日志提取最后一轮:最后一个真实用户消息 + 其后最后的助手文本。 */
2920
+ async function lastTurnFromLog(sessionId) {
2921
+ const file = await findSessionFile(sessionId)
2922
+ if (!file) return null
2923
+ const text = await decompressLog(file)
2924
+ const events = []
2925
+ for (const line of text.split('\n')) {
2926
+ if (line.length === 0) continue
2927
+ try { events.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
2928
+ }
2929
+ const isRealUser = (e) => e?.type === 'user/message'
2930
+ && e.data?.source?.kind === 'user'
2931
+ && Array.isArray(e.data.content) && e.data.content.length === 1
2932
+ && e.data.content[0]?.type === 'text' && typeof e.data.content[0].text === 'string'
2933
+ const isAssistant = (e) => e?.type === 'assistant/message'
2934
+ let assistantText = null
2935
+ let userText = null
2936
+ for (let i = events.length - 1; i >= 0; i -= 1) {
2937
+ const e = events[i]
2938
+ if (assistantText === null && isAssistant(e)) {
2939
+ const blocks = e.data?.message?.content
2940
+ if (Array.isArray(blocks)) {
2941
+ const joined = blocks.filter((b) => b?.type === 'text' && typeof b.text === 'string')
2942
+ .map((b) => b.text).join('')
2943
+ if (joined.trim().length > 0) assistantText = joined
2944
+ }
2945
+ continue
2946
+ }
2947
+ if (assistantText !== null && isRealUser(e)) {
2948
+ userText = e.data.content[0].text
2949
+ break
2950
+ }
2951
+ }
2952
+ if (userText === null || assistantText === null) return null
2953
+ return { userText: userText.trim(), assistantText: assistantText.trim() }
2954
+ }
2955
+
2956
+ /** 从 profile 用户补丁层读 agent-default-model 的 provider/model(兜底路由)。 */
2957
+ async function patchDefaultRoute() {
2958
+ let profiles = []
2959
+ try { profiles = await readdir(PROFILES_ROOT, { withFileTypes: true }) } catch { return null }
2960
+ for (const p of profiles) {
2961
+ if (!p.isDirectory()) continue
2962
+ try {
2963
+ const raw = await readFile(join(PROFILES_ROOT, p.name, 'cordis.patch.yml'), 'utf8')
2964
+ const m = raw.match(/id:\s*agent-default-model[\s\S]{0,300}?provider:\s*(\S+)[\s\S]{0,120}?model:\s*(\S+)/)
2965
+ if (m) return { provider: m[1].replace(/^['"]|['"]$/g, ''), model: m[2].replace(/^['"]|['"]$/g, '') }
2966
+ } catch { /* 下一个 profile */ }
2967
+ }
2968
+ return null
2969
+ }
2970
+
2971
+ const moduleInject = ['llm']
2972
+
2973
+ const Config = Schema.object({
2974
+ /** 总开关:关闭后不监听任何事件,也不注册 web 端点。 */
2975
+ enabled: Schema.boolean().default(true).description('是否启用智能跟进推荐'),
2976
+ /** 留空则跟随会话当前模型(agent.options)。 */
2977
+ provider: Schema.string().description('生成建议使用的 provider(留空跟随会话)'),
2978
+ /** 建议配一个快模型:这条调用在每轮回复结束后立刻发生。 */
2979
+ model: Schema.string().description('生成建议使用的模型(留空跟随会话,建议快模型)'),
2980
+ /** 单次生成超时;超时即放弃本轮建议。 */
2981
+ timeoutMs: Schema.number().default(12000).description('生成超时(毫秒)'),
2982
+ /** 回合落定后等多久再生成:缓冲多步回合的中间 idle。 */
2983
+ idleDelayMs: Schema.number().default(1200).description('回合结束后的缓冲时间(毫秒)'),
2984
+ /** 生成几条建议(最多 3)。 */
2985
+ maxSuggestions: Schema.number().default(2).description('最多生成几条建议(1-2)'),
2986
+ /** 上下文裁剪:超出截断,控制这条调用的 token 开销。 */
2987
+ userChars: Schema.number().default(400).description('用户消息最多带多少字符'),
2988
+ assistantChars: Schema.number().default(1200).description('助手回复最多带多少字符'),
2989
+ /** 助手回复短于此不生成:「好的」「完成」这类没有可跟进的实质内容。 */
2990
+ minAssistantChars: Schema.number().default(30).description('助手回复最小长度(字符)'),
2991
+ })
2992
+
2993
+ /* ------------------------------------------------------------------ */
2994
+ /* 提示词与解析 */
2995
+ /* ------------------------------------------------------------------ */
2996
+
2997
+ function clampTail(text, limit) {
2998
+ const clean = typeof text === 'string' ? text.trim() : ''
2999
+ if (clean.length <= limit) return clean
3000
+ return `…${clean.slice(-limit)}`
3001
+ }
3002
+
3003
+ function buildPrompt(userText, assistantText, count, config) {
3004
+ return [
3005
+ `You generate follow-up suggestions for an AI chat session (DeepSeek Harness). Based on the exchange below, propose ${count} short follow-up prompts the user would most plausibly send next.`,
3006
+ '',
3007
+ 'Rules:',
3008
+ '- Continue the conversation: refine, extend, verify or apply the assistant\'s last answer. Never generic filler like "还有吗" / "继续" / "tell me more".',
3009
+ '- One imperative sentence each: 8~24 Chinese characters or 6~16 English words. No trailing period, no quotes, no numbering, no emoji.',
3010
+ '- They must be sendable as-is: self-contained requests, not fragments.',
3011
+ '- Write in the same language as the exchange.',
3012
+ '- Output STRICT JSON only: an array of strings, nothing else. Example: ["第一条建议","第二条建议"]',
3013
+ '',
3014
+ '[User]',
3015
+ clampTail(userText, config.userChars),
3016
+ '',
3017
+ '[Assistant]',
3018
+ clampTail(assistantText, config.assistantChars),
3019
+ ].join('\n')
3020
+ }
3021
+
3022
+ /** 从模型输出里抠 JSON 数组;抠不出就返回空数组,宁缺毋滥。 */
3023
+ function parseSuggestions(text, count) {
3024
+ const start = text.indexOf('[')
3025
+ const end = text.lastIndexOf(']')
3026
+ if (start < 0 || end <= start) return []
3027
+ let parsed
3028
+ try {
3029
+ parsed = JSON.parse(text.slice(start, end + 1))
3030
+ } catch {
3031
+ return []
3032
+ }
3033
+ if (!Array.isArray(parsed)) return []
3034
+ return parsed
3035
+ .filter((item) => typeof item === 'string')
3036
+ .map((item) => item.trim().replace(/^[-*•\d.)、\s]+/, '').slice(0, 60))
3037
+ .filter((item) => item.length >= 2)
3038
+ .slice(0, count)
3039
+ }
3040
+
3041
+ /* ------------------------------------------------------------------ */
3042
+ /* 插件入口 */
3043
+ /* ------------------------------------------------------------------ */
3044
+
3045
+ function apply(ctx, config) {
3046
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
3047
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
3048
+ const live = { ...config }
3049
+ if (!live.enabled) return
3050
+
3051
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
3052
+ const SETTINGS_KEYS = ['maxSuggestions', 'idleDelayMs', 'timeoutMs', 'minAssistantChars', 'userChars', 'assistantChars']
3053
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-followup', 'settings.json')
3054
+ try {
3055
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
3056
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
3057
+ } catch { /* 无存档:全部跟随配置 */ }
3058
+ let settingsTimer = null
3059
+ const persistSettings = () => {
3060
+ if (settingsTimer) return
3061
+ settingsTimer = setTimeout(() => {
3062
+ settingsTimer = null
3063
+ try {
3064
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
3065
+ const out = {}
3066
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
3067
+ const tmp = `${SETTINGS_PATH}.tmp`
3068
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
3069
+ renameSync(tmp, SETTINGS_PATH)
3070
+ } catch { /* ignore */ }
3071
+ }, 400)
3072
+ }
3073
+
3074
+ void report(`server boot v0.3.3 enabled=true minAssistantChars=${live.minAssistantChars} idleDelayMs=${live.idleDelayMs} route=${live.provider || ''}/${live.model || ''}(follow-session)`)
3075
+
3076
+ /** 每个 agent 的对话尾部累积:userText 来自 pre-step,assistantText 来自流帧。 */
3077
+ const buffers = new WeakMap()
3078
+ const seenFrameTypes = new Set()
3079
+ let bufCounter = 0
3080
+ const agentConstructorName = (agent) => (agent === undefined || agent === null
3081
+ ? 'none'
3082
+ : `${agent.constructor?.name ?? typeof agent}`)
3083
+ const bufferOf = (agent) => {
3084
+ let buf = buffers.get(agent)
3085
+ if (buf === undefined) {
3086
+ buf = { id: ++bufCounter, userText: '', assistantText: '', controller: null }
3087
+ buffers.set(agent, buf)
3088
+ void report(`buffer new #${buf.id} for agent=${agentConstructorName(agent)}`)
3089
+ }
3090
+ return buf
3091
+ }
3092
+
3093
+ /** 供 web 端拉取的最近一份建议;seq 递增,客户端凭它去重。 */
3094
+ let latest = { seq: 0, suggestions: [], at: 0 }
3095
+ /** 最近一次生成的上下文,供「换一批」重生成。 */
3096
+ let lastContext = null
3097
+ let seqCounter = 0
3098
+ let pendingTimer = 0
3099
+
3100
+ /* -- 建议持久化(0.2.4):重启后打开会话还能拿到最近一轮的建议 ----- */
3101
+ /** sessionId → { seq, suggestions, at },boot 时读回,成功生成时写入。 */
3102
+ let persisted = {}
3103
+ try {
3104
+ persisted = JSON.parse(readFileSync(SUGGEST_FILE, 'utf8'))
3105
+ if (!persisted || typeof persisted !== 'object') persisted = {}
3106
+ for (const entry of Object.values(persisted)) {
3107
+ if (Number.isInteger(entry?.seq) && entry.seq > seqCounter) seqCounter = entry.seq
3108
+ }
3109
+ say('info', `restored ${Object.keys(persisted).length} persisted suggestion set(s)`)
3110
+ } catch { /* 首次或损坏:空表开始 */ }
3111
+
3112
+ function persistSuggestions(sessionId, seq, suggestions) {
3113
+ if (!sessionId || !Array.isArray(suggestions) || suggestions.length === 0) return
3114
+ persisted[sessionId] = { seq, suggestions, at: Date.now() }
3115
+ const keys = Object.keys(persisted)
3116
+ if (keys.length > 24) {
3117
+ let oldest = null
3118
+ let oldestAt = Infinity
3119
+ for (const key of keys) {
3120
+ if (persisted[key].at < oldestAt) { oldestAt = persisted[key].at; oldest = key }
3121
+ }
3122
+ if (oldest !== null) delete persisted[oldest]
3123
+ }
3124
+ void writeFile(SUGGEST_FILE, `${JSON.stringify(persisted)}\n`, 'utf8')
3125
+ .catch(() => { /* 持久化失败不影响主流程 */ })
3126
+ }
3127
+
3128
+ /** Trae 逻辑:建议属于「每轮响应」——本轮没有新建议就清掉该会话的旧建议。 */
3129
+ function clearSuggestions(sessionId) {
3130
+ if (!sessionId || !(sessionId in persisted)) return
3131
+ delete persisted[sessionId]
3132
+ void writeFile(SUGGEST_FILE, `${JSON.stringify(persisted)}\n`, 'utf8')
3133
+ .catch(() => { /* ignore */ })
3134
+ }
3135
+
3136
+ const say = (level, message) => {
3137
+ try {
3138
+ console[level === 'error' ? 'error' : 'info'](`[dsh-followup] ${message}`)
3139
+ } catch { /* ignore */ }
3140
+ }
3141
+
3142
+ function singleTextOf(message) {
3143
+ if (!message || message.role !== 'user') return null
3144
+ // dsh 内部还有 goal / session-reference / notice 等来源的 user 消息,
3145
+ // 不是用户在对话框里输入的内容,不进上下文。
3146
+ if (message.source?.kind !== 'user') return null
3147
+ if (!Array.isArray(message.content) || message.content.length !== 1) return null
3148
+ const block = message.content[0]
3149
+ return block && block.type === 'text' && typeof block.text === 'string' ? block.text : null
3150
+ }
3151
+
3152
+ /** llm/stream 捕获用:请求 messages 里最后一个真实用户文本。 */
3153
+ function singleTextOfLastUser(messages) {
3154
+ if (!Array.isArray(messages)) return null
3155
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
3156
+ const text = singleTextOf(messages[i])
3157
+ if (text !== null) return text
3158
+ }
3159
+ return null
3160
+ }
3161
+
3162
+ function defaultRoute() {
3163
+ const defaults = ctx.get('agentDefaultModel')
3164
+ const selection = defaults?.currentSelection?.()
3165
+ return selection?.provider && selection?.model
3166
+ ? { provider: selection.provider, model: selection.model }
3167
+ : null
3168
+ }
3169
+
3170
+ function configRoute() {
3171
+ return live.provider && live.model ? { provider: live.provider, model: live.model } : null
3172
+ }
3173
+
3174
+ function resolveRoute(agent) {
3175
+ const provider = agent?.options?.provider
3176
+ const model = agent?.options?.model
3177
+ return configRoute()
3178
+ ?? (provider && model ? { provider, model } : null)
3179
+ ?? defaultRoute()
3180
+ }
3181
+
3182
+ function cancelPending(buf, reason) {
3183
+ if (buf.controller) {
3184
+ try { buf.controller.abort(new Error(reason)) } catch { /* ignore */ }
3185
+ buf.controller = null
3186
+ }
3187
+ }
3188
+
3189
+ /* -- 事件监听:拼出对话尾部(旧架构回落通道) ---------------------- */
3190
+ /* 新架构进程分裂下这些 agent/* 事件到不了插件进程(0.1.x 实证零事件),
3191
+ 保留是为了旧版本 dsh 仍然可用;新架构走上面的 llm/stream 捕获 + 日志回落。 */
3192
+
3193
+ ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
3194
+ const buf = bufferOf(agent)
3195
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
3196
+ const text = singleTextOf(messages[i])
3197
+ if (text !== null) { buf.userText = text; break }
3198
+ }
3199
+ buf.assistantText = ''
3200
+ // 用户已经开始说下一句:上一份没生成的建议直接作废。
3201
+ cancelPending(buf, 'superseded by next user message')
3202
+ void report(`pre-step buf#${buf.id} agent=${agentConstructorName(agent)} msgs=${messages.length} userLen=${(buf.userText ?? '').length} userHead=${JSON.stringify((buf.userText ?? '').slice(0, 40))}`)
3203
+ return next()
3204
+ })
3205
+
3206
+ ctx.on('agent/assistant-stream', ({ agent, frame }) => {
3207
+ const buf = bufferOf(agent)
3208
+ if (frame?.type && !seenFrameTypes.has(frame.type)) {
3209
+ seenFrameTypes.add(frame.type)
3210
+ void report(`stream frame-type first-seen: ${frame.type} agent=${agentConstructorName(agent)}`)
3211
+ }
3212
+ if (frame?.type === 'start') { buf.assistantText = ''; return }
3213
+ const chunk = frame?.type === 'chunk' ? frame.chunk : null
3214
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
3215
+ buf.assistantText += chunk.text
3216
+ // 超长回复只保留尾部,控制内存。
3217
+ if (buf.assistantText.length > 20000) buf.assistantText = buf.assistantText.slice(-10000)
3218
+ return
3219
+ }
3220
+ if (frame?.type === 'end') {
3221
+ void report(`stream end buf#${buf.id} agent=${agentConstructorName(agent)} assistantLen=${(buf.assistantText ?? '').length}`)
3222
+ }
3223
+ })
3224
+
3225
+ ctx.on('agent/status', ({ agent, status }) => {
3226
+ const buf = bufferOf(agent)
3227
+ if (status !== 'idle') {
3228
+ // 回合重新运转(工具链下一步):撤销缓冲中的生成计划。
3229
+ cancelPending(buf, 'agent running again')
3230
+ if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = 0 }
3231
+ return
3232
+ }
3233
+ const userText = (buf.userText ?? '').trim()
3234
+ const assistantText = (buf.assistantText ?? '').trim()
3235
+ if (!userText || assistantText.length < live.minAssistantChars) {
3236
+ void report(`idle skip buf#${buf.id} agent=${agentConstructorName(agent)} userLen=${userText.length} assistantLen=${assistantText.length} (need user>0 & assistant>=${live.minAssistantChars})`)
3237
+ return
3238
+ }
3239
+ // 缓冲片刻:多步回合的步骤之间也会闪现 idle,期间回到 running 就作废。
3240
+ if (pendingTimer) clearTimeout(pendingTimer)
3241
+ pendingTimer = setTimeout(() => {
3242
+ pendingTimer = 0
3243
+ void generateFor(agent, buf, userText, assistantText)
3244
+ }, Math.max(200, live.idleDelayMs))
3245
+ })
3246
+
3247
+ /* -- 主通道:llm/stream 瀑布旁路捕获(0.2.3,借鉴 dsh-cost-meter) -- */
3248
+
3249
+ /** 深度标记:存在即「本插件已知会、不需要再捕获」的 llm 流。 */
3250
+ const llmDepth = new AsyncLocalStorage()
3251
+
3252
+ /** 实时捕获的最近一轮:sessionId → { userText, assistantText, at }。 */
3253
+ const turnCache = new Map()
3254
+ const TURN_CACHE_MAX = 24
3255
+ const TURN_CACHE_TTL_MS = 10 * 60_000
3256
+
3257
+ function cachedTurnOf(sessionId) {
3258
+ const hit = turnCache.get(sessionId)
3259
+ if (!hit) return null
3260
+ if (Date.now() - hit.at > TURN_CACHE_TTL_MS) { turnCache.delete(sessionId); return null }
3261
+ return hit
3262
+ }
3263
+
3264
+ function rememberTurn(sessionId, userText, assistantText) {
3265
+ if (!sessionId || !userText || !assistantText || assistantText.trim().length === 0) return
3266
+ const prev = turnCache.get(sessionId)
3267
+ // 同一轮的多步工具链 / 重试续流 userText 相同:助手文本追加累积,
3268
+ // 让「最终答案」落在缓存尾部(生成时 clampTail 取尾部正好命中)。
3269
+ const merged = prev && prev.userText === userText
3270
+ ? `${prev.assistantText}\n\n${assistantText}`
3271
+ : assistantText
3272
+ turnCache.set(sessionId, { userText, assistantText: merged.slice(-20000), at: Date.now() })
3273
+ if (turnCache.size > TURN_CACHE_MAX) {
3274
+ let oldestKey = null
3275
+ let oldestAt = Infinity
3276
+ for (const [key, value] of turnCache) {
3277
+ if (value.at < oldestAt) { oldestAt = value.at; oldestKey = key }
3278
+ }
3279
+ if (oldestKey !== null) turnCache.delete(oldestKey)
3280
+ }
3281
+ }
3282
+
3283
+ // llm/stream 捕获是否活着是 0.2.3 的关键真机问题(新架构进程分裂下
3284
+ // 事件归属未实证),首帧与每次入缓存都留痕,一次对话即可从日志判定。
3285
+ let llmStreamArmed = false
3286
+
3287
+ ctx.on('llm/stream', (options, next) => {
3288
+ // 自己发起的建议生成调用 / 下游包装路由的嵌套调用:原样透传。
3289
+ if (llmDepth.getStore() !== undefined) return next()
3290
+ const downstream = next()
3291
+ if (!llmStreamArmed) {
3292
+ llmStreamArmed = true
3293
+ void report('llm/stream first fire: capture channel is ALIVE in this process')
3294
+ }
3295
+ const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
3296
+ const userText = (singleTextOfLastUser(options?.messages) ?? '').trim()
3297
+ let assistantText = ''
3298
+ let downstreamIter = null
3299
+ let completed = false
3300
+ return (async function* followupObserve() {
3301
+ try {
3302
+ downstreamIter = downstream[Symbol.asyncIterator]()
3303
+ for (;;) {
3304
+ // 深度标记内拉取:包装路由适配器在消费上下文里再发起的
3305
+ // ctx.llm.stream() 继承标记,内层监听器判定为已处理、透传。
3306
+ const result = await llmDepth.run(1, () => downstreamIter.next())
3307
+ if (result.done) break
3308
+ const chunk = result.value
3309
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
3310
+ assistantText += chunk.text
3311
+ if (assistantText.length > 20000) assistantText = assistantText.slice(-10000)
3312
+ }
3313
+ yield chunk
3314
+ }
3315
+ completed = true
3316
+ } finally {
3317
+ // 消费方提前中断(用户改下一句、错误终止)也要收尾;流失败时
3318
+ // assistantText 不完整,通常过不了 minAssistantChars,无害。
3319
+ if (!completed) {
3320
+ try { await downstreamIter?.return?.() } catch { /* ignore */ }
3321
+ }
3322
+ try {
3323
+ rememberTurn(sessionId, userText, assistantText)
3324
+ void report(`llm/stream captured session=${sessionId ? sessionId.slice(0, 50) : '(none)'} userLen=${userText.length} assistantLen=${assistantText.length} ${!completed ? '(interrupted)' : ''}`)
3325
+ } catch { /* 缓存失败绝不影响主对话流 */ }
3326
+ }
3327
+ })()
3328
+ }, { global: true })
3329
+
3330
+ /* -- 生成 --------------------------------------------------------- */
3331
+
3332
+ async function runGeneration(llm, route, userText, assistantText, signal) {
3333
+ const count = Math.min(2, Math.max(1, Math.round(live.maxSuggestions)))
3334
+ // 深度标记内发起:本次生成调用经过 llm/stream 瀑布时,监听器读到
3335
+ // 标记即透传——建议生成的 prompt 不会被误缓存为对话轮。
3336
+ // reasoningEffort low + maxTokens 2000:真机 04:50 实测 profile 默认
3337
+ // high 推理把 600 上限全部烧光(raw len=0 → 无建议渲染)。
3338
+ // 给 dsh-cost 打来源标记:建议生成的费用单独归类,不混进对话。
3339
+ globalThis.__dshPluginCall = { source: 'followup', at: Date.now() }
3340
+
3341
+ const iterator = llmDepth.run(1, () => llm.stream({
3342
+ provider: route.provider,
3343
+ model: route.model,
3344
+ reasoningEffort: 'low',
3345
+ messages: [{ role: 'user', content: [{ type: 'text', text: buildPrompt(userText, assistantText, count, config) }] }],
3346
+ maxTokens: 2000,
3347
+ signal,
3348
+ })[Symbol.asyncIterator]())
3349
+ let text = ''
3350
+ try {
3351
+ for (;;) {
3352
+ const { done, value } = await iterator.next()
3353
+ if (done) break
3354
+ if (value?.type === 'text-delta' && typeof value.text === 'string') text += value.text
3355
+ else if (value?.type === 'finish' && value.reason && (value.reason.kind === 'error' || value.reason.kind === 'aborted')) {
3356
+ throw new Error(`followup llm ${value.reason.kind}`)
3357
+ }
3358
+ }
3359
+ } catch (err) {
3360
+ // 中断底层流,失败后不在后台继续烧 token。
3361
+ try {
3362
+ const closing = iterator.return?.()
3363
+ if (closing && typeof closing.catch === 'function') closing.catch(() => {})
3364
+ } catch { /* ignore */ }
3365
+ throw err
3366
+ }
3367
+ void report(`raw output len=${text.length}: ${JSON.stringify(text.slice(0, 300))}`)
3368
+ return parseSuggestions(text, count)
3369
+ }
3370
+
3371
+ async function generateFor(agent, buf, userText, assistantText) {
3372
+ const route = resolveRoute(agent)
3373
+ if (!route) {
3374
+ say('info', 'no model route available, skip')
3375
+ void report('generate skip: no model route (config unset, agent.options empty, agentDefaultModel missing)')
3376
+ return
3377
+ }
3378
+ const controller = new AbortController()
3379
+ buf.controller = controller
3380
+ const timer = setTimeout(() => controller.abort(new Error('followup timeout')), live.timeoutMs)
3381
+ // 根作用域没声明 llm inject 时属性访问直接抛(真机 07:41 实证);
3382
+ // 捕住降级——cache/日志路径不受影响。
3383
+ let eventLlm = null
3384
+ try { eventLlm = ctx.llm } catch { eventLlm = null }
3385
+ if (eventLlm === null) {
3386
+ void report('event-path skip: llm not injectable in this scope (cache path covers it)')
3387
+ return
3388
+ }
3389
+ try {
3390
+ void report(`generate start route=${route.provider}/${route.model} userLen=${userText.length} assistantLen=${assistantText.length}`)
3391
+ const suggestions = await runGeneration(eventLlm, route, userText, assistantText, controller.signal)
3392
+ if (suggestions.length) {
3393
+ lastContext = { route, userText, assistantText }
3394
+ latest = { seq: ++seqCounter, suggestions, at: Date.now() }
3395
+ let eventSessionId = ''
3396
+ try { eventSessionId = agent?.session?.id ?? '' } catch { eventSessionId = '' }
3397
+ persistSuggestions(eventSessionId, latest.seq, suggestions)
3398
+ say('info', `turn ${latest.seq}: ${suggestions.length} suggestions`)
3399
+ void report(`generate ok seq=${latest.seq} count=${suggestions.length} ${JSON.stringify(suggestions)}`)
3400
+ } else {
3401
+ void report('generate empty: model returned no parseable suggestions')
3402
+ }
3403
+ } catch (err) {
3404
+ if (!/aborted|superseded|timeout/i.test(String(err?.message ?? err))) {
3405
+ say('error', `generate failed: ${err?.message ?? err}`)
3406
+ void report(`generate failed: ${String(err?.message ?? err).slice(0, 300)}`)
3407
+ } else {
3408
+ void report(`generate aborted: ${String(err?.message ?? err).slice(0, 120)}`)
3409
+ }
3410
+ } finally {
3411
+ clearTimeout(timer)
3412
+ if (buf.controller === controller) buf.controller = null
3413
+ }
3414
+ }
3415
+
3416
+ /* -- web 端点 ------------------------------------------------------ */
3417
+
3418
+ // 可选注入:CLI / TUI 等没有 webServer 的宿主照常加载,只是没有 chips。
3419
+ // llm 必须在此作用域声明——cordis 的属性访问按作用域校验 inject,
3420
+ // 根作用域的静态 inject 不传导进嵌套作用域(真机实测报
3421
+ // cannot get property "llm" without inject)。
3422
+ ctx.inject(['webServer', 'llm'], (webCtx) => {
3423
+ webCtx.effect(() => webCtx.webServer.register({
3424
+ kind: 'prefix',
3425
+ path: '/api/dsh-followup',
3426
+ handler: async (req, res) => {
3427
+ const send = (status, body) => {
3428
+ res.statusCode = status
3429
+ res.setHeader('content-type', 'application/json')
3430
+ res.end(JSON.stringify(body))
3431
+ }
3432
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-followup/, '')
3433
+ if (path === '/settings' && req.method === 'GET') {
3434
+ send(200, { ...live })
3435
+ return
3436
+ }
3437
+ // 运行时改参数:白名单字段,provider/model 不在其中(改了要重建路由)。
3438
+ if (path === '/settings' && req.method === 'POST') {
3439
+ let raw = ''
3440
+ for await (const chunk of req) raw += chunk
3441
+ let body
3442
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
3443
+ const numeric = { maxSuggestions: [1, 3], idleDelayMs: [200, 10000], timeoutMs: [1000, 120000], minAssistantChars: [0, 500], userChars: [100, 4000], assistantChars: [100, 8000] }
3444
+ for (const [key, [min, max]] of Object.entries(numeric)) {
3445
+ if (body[key] === undefined) continue
3446
+ const value = Number(body[key])
3447
+ if (!Number.isFinite(value)) continue
3448
+ live[key] = Math.min(max, Math.max(min, Math.round(value)))
3449
+ }
3450
+ persistSettings()
3451
+ send(200, { ...live })
3452
+ return
3453
+ }
3454
+
3455
+
3456
+ // 客户端在每次「生成中 → 空闲」后拉取;seq 相同就静默忽略。
3457
+ // Trae 逻辑:建议随会话持久化、无时间过期,只被下一轮替换或清除。
3458
+ if (path === '/latest' && req.method === 'GET') {
3459
+ const qs = new URL(req.url ?? '', 'http://local').searchParams
3460
+ const wantSession = qs.get('sessionId') ?? ''
3461
+ const entry = wantSession ? persisted[wantSession] : undefined
3462
+ if (entry && Array.isArray(entry.suggestions) && entry.suggestions.length > 0) {
3463
+ send(200, entry)
3464
+ return
3465
+ }
3466
+ send(200, latest)
3467
+ return
3468
+ }
3469
+
3470
+ /**
3471
+ * 生成建议(0.2.3 双通道):优先用 llm/stream 实时捕获的对话轮
3472
+ * (零文件 IO、无解析时延),未命中才回落日志驱动(0.2.0 引入,
3473
+ * 解压 session.v4.jsonl.zstd 直读)。
3474
+ */
3475
+ if ((path === '/generate' || path === '/refresh') && req.method === 'POST') {
3476
+ let body = ''
3477
+ for await (const chunk of req) body += chunk
3478
+ let sessionId = ''
3479
+ try { sessionId = JSON.parse(body).sessionId ?? '' } catch { sessionId = '' }
3480
+ if (!sessionId) { send(400, { error: 'missing sessionId' }); return }
3481
+ const cached = cachedTurnOf(sessionId)
3482
+ const turn = cached
3483
+ ? { userText: cached.userText, assistantText: cached.assistantText }
3484
+ : await lastTurnFromLog(sessionId)
3485
+ if (!turn) {
3486
+ void report(`generate(${cached ? 'cache' : 'log'}) ${path}: no usable turn in session ${String(sessionId).slice(0, 50)}`)
3487
+ // Trae 逻辑:建议属于每轮响应——本轮拿不到可跟进内容即清掉旧建议
3488
+ clearSuggestions(String(sessionId))
3489
+ send(409, { error: 'no-turn' })
3490
+ return
3491
+ }
3492
+ const route = configRoute() ?? defaultRoute() ?? await patchDefaultRoute()
3493
+ if (!route) {
3494
+ void report(`generate(${cached ? 'cache' : 'log'}): no route (config/agentDefaultModel/patch all missing)`)
3495
+ send(503, { error: 'no-route' })
3496
+ return
3497
+ }
3498
+ const source = cached ? 'cache' : 'log'
3499
+ const controller = new AbortController()
3500
+ const timer = setTimeout(() => controller.abort(new Error('followup timeout')), live.timeoutMs)
3501
+ try {
3502
+ void report(`generate(${source}) start route=${route.provider}/${route.model} userLen=${turn.userText.length} assistantLen=${turn.assistantText.length}`)
3503
+ const suggestions = await runGeneration(webCtx.llm, route, turn.userText, turn.assistantText, controller.signal)
3504
+ if (!suggestions.length) {
3505
+ void report(`generate(${source}) empty`)
3506
+ // 同上:本轮生成失败/为空,旧建议不再属于最新一轮
3507
+ clearSuggestions(String(sessionId))
3508
+ send(502, { error: 'generate-failed' })
3509
+ return
3510
+ }
3511
+ lastContext = { route, ...turn }
3512
+ latest = { seq: ++seqCounter, suggestions, at: Date.now() }
3513
+ persistSuggestions(String(sessionId), latest.seq, suggestions)
3514
+ void report(`generate(${source}) ok seq=${latest.seq} ${JSON.stringify(suggestions)}`)
3515
+ send(200, latest)
3516
+ } catch (err) {
3517
+ void report(`generate(${source}) failed: ${String(err?.message ?? err).slice(0, 200)}`)
3518
+ send(502, { error: 'generate-failed' })
3519
+ } finally {
3520
+ clearTimeout(timer)
3521
+ }
3522
+ return
3523
+ }
3524
+
3525
+ send(404, { error: 'not-found' })
3526
+ },
3527
+ }), 'dsh-followup: /api/dsh-followup/*')
3528
+ })
3529
+ }
3530
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
3531
+ })()
3532
+
3533
+ /* ==== module: voice(原 dsh-siri-voice) ==== */
3534
+ const voiceModule = (() => {
3535
+ /* 由 scripts/build-suite.mjs 从 dsh-siri-voice/index.js 机械生成;勿手改,重跑脚本再生成。 */
3536
+ /**
3537
+ * dsh-siri-voice — 服务端半(无逻辑)。
3538
+ *
3539
+ * 这个插件只做 web 客户端的视觉层,但 dsh 的客户端模块扫描是从 Host 的
3540
+ * loader entries 出发的,所以包仍然需要一个可被加载的服务端入口:
3541
+ * 声明 `dsh.bundle.patch` 把一行插件插进 profile,`dsh.client.platform = 'web'`
3542
+ * 让 web 宿主把 client.js 挂到页面里。
3543
+ */
3544
+
3545
+ const name = 'siri-voice'
3546
+
3547
+ /** 不注册任何服务;所有效果都在客户端。 */
3548
+ function apply() {}
3549
+
3550
+
3551
+ const Config = Schema.object({ enabled: Schema.boolean().default(true).description('启用') })
3552
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
3553
+ })()
3554
+
3555
+ /* ==== module: compaction(原 dsh-compaction-control) ==== */
3556
+ const compactionModule = (() => {
3557
+ /* 由 scripts/build-suite.mjs 从 dsh-compaction-control/index.js 机械生成;勿手改,重跑脚本再生成。 */
3558
+ /**
3559
+ * dsh-compaction-control — 上下文压缩控制中心(DeepSeek Harness / dsh)
3560
+ *
3561
+ * 三件事:
3562
+ * 1. 手动压缩:把官方 compaction 服务的 ctx.compaction.compactNow(agent, signal)
3563
+ * 暴露成 HTTP 路由,客户端面板一键触发(等价 /compact,免打字)。
3564
+ * 2. 调节自动压缩:把 {auto, thresholdRatio, retainRatio} 写进每个挂载了
3565
+ * 本插件的 profile 的用户补丁层(~/.dsh/profiles/<name>/cordis.patch.yml,
3566
+ * id 定向覆盖 compaction-basic 的 config;applyEntryPatches 后写者胜)。
3567
+ * 补丁层是 initProfile 官方创建的用户文件,插件管理器不会回收它;
3568
+ * 但 loader 只在启动时读取——保存后需重启 dsh 生效。
3569
+ * 3. 状态源:~/.dsh/compaction-control.json 是唯一事实源(含 hideBuiltIn
3570
+ * 等 UI 偏好),补丁文件由它派生,可随时重生成。
3571
+ *
3572
+ * 已核对的 dsh API(dsh-reference 源码对照):
3573
+ * - ctx.agents.get(sessionId) → Agent(core/agent AgentRegistry,实现
3574
+ * runMaintenance,满足 compaction 的 ManualCompactAgentContext)
3575
+ * - ctx.compaction.compactNow(agent, signal) → CompactionResult |
3576
+ * null;错误带 code: busy/cancelled/changed/summary/commit/persistence
3577
+ * - BasicCompactionConfig 键集(compaction-basic/src/config.ts):
3578
+ * auto/thresholdRatio/retainRatio/headroomTokens/maxTokens/
3579
+ * summarizationProvider/summarizationModel/compactionRetries/
3580
+ * maxOverflowRetries/modelPolicies —— 只写前三个,其余走默认
3581
+ * - applyEntryPatches(vendor/include):{id, name, config} 直接整体
3582
+ * 替换目标条目的 config;未知键会让 compaction-basic 加载即抛错,
3583
+ * 所以这里的 config 永远是完整合法子集
3584
+ */
3585
+
3586
+
3587
+
3588
+
3589
+
3590
+
3591
+
3592
+
3593
+ const require = createRequire(import.meta.url)
3594
+
3595
+ const name = 'compaction-control'
3596
+
3597
+ const DSH_DIR = join(homedir(), '.dsh')
3598
+ const PROFILES_DIR = join(DSH_DIR, 'profiles')
3599
+ const STATE_FILE = join(DSH_DIR, 'compaction-control.json')
3600
+ const DIAG_LOG = join(DSH_DIR, 'compaction-control-debug.log')
3601
+
3602
+ /** 与 compaction-basic 的默认对齐(thresholdRatio 0.8 / retainRatio 0.16 / auto)。 */
3603
+ const DEFAULT_STATE = { auto: true, thresholdRatio: 0.8, retainRatio: 0.16, hideBuiltIn: true, checkpoint: true }
3604
+
3605
+ const MANAGED_BEGIN = '# BEGIN dsh-compaction-control (managed)'
3606
+ const MANAGED_END = '# END dsh-compaction-control'
3607
+
3608
+ /** 补丁文件语法校验用;运行时没带 yaml 就跳过校验(写入逻辑本身保守)。 */
3609
+ let Yaml = null
3610
+ try {
3611
+ const mod = require('yaml')
3612
+ if (typeof mod?.parse === 'function') Yaml = mod
3613
+ } catch { Yaml = null }
3614
+
3615
+ const Config = Schema.object({
3616
+ /** 关闭后客户端不再注册圆环与面板(压缩行为恢复 compaction-basic 默认)。 */
3617
+ enabled: Schema.boolean().default(true).description('是否启用上下文压缩控制'),
3618
+ })
3619
+
3620
+ /** 校验并归一化一份状态;不合法直接抛中文错误给面板显示。 */
3621
+ function sanitizeState(input) {
3622
+ const ratio = (value, fallback, lo, hi, label) => {
3623
+ const n = Number(value ?? fallback)
3624
+ if (!Number.isFinite(n) || n < lo || n > hi) {
3625
+ throw new Error(`${label} 需在 ${lo}~${hi} 之间`)
3626
+ }
3627
+ return Math.round(n * 1000) / 1000
3628
+ }
3629
+ const state = {
3630
+ auto: input?.auto === undefined ? DEFAULT_STATE.auto : Boolean(input.auto),
3631
+ thresholdRatio: ratio(input?.thresholdRatio, DEFAULT_STATE.thresholdRatio, 0.3, 0.99, '触发阈值'),
3632
+ retainRatio: ratio(input?.retainRatio, DEFAULT_STATE.retainRatio, 0.02, 0.5, '保留比例'),
3633
+ hideBuiltIn: input?.hideBuiltIn === undefined ? DEFAULT_STATE.hideBuiltIn : Boolean(input.hideBuiltIn),
3634
+ checkpoint: input?.checkpoint === undefined ? DEFAULT_STATE.checkpoint : Boolean(input.checkpoint),
3635
+ }
3636
+ // compaction-basic 加载期硬约束:retainRatio >= thresholdRatio 直接抛错。
3637
+ if (state.retainRatio >= state.thresholdRatio) {
3638
+ throw new Error(`保留比例 (${state.retainRatio}) 必须小于触发阈值 (${state.thresholdRatio})`)
3639
+ }
3640
+ return state
3641
+ }
3642
+
3643
+ async function readState() {
3644
+ try {
3645
+ return sanitizeState(JSON.parse(await readFile(STATE_FILE, 'utf8')))
3646
+ } catch {
3647
+ return { ...DEFAULT_STATE }
3648
+ }
3649
+ }
3650
+
3651
+ /** 列出挂载了本插件的 profile 名(manifest 里出现包名即算)。 */
3652
+ async function listMountedProfiles() {
3653
+ let entries = []
3654
+ try { entries = await readdir(PROFILES_DIR, { withFileTypes: true }) } catch { return [] }
3655
+ const mounted = []
3656
+ for (const entry of entries) {
3657
+ if (!entry.isDirectory()) continue
3658
+ try {
3659
+ const raw = await readFile(join(PROFILES_DIR, entry.name, 'package.json'), 'utf8')
3660
+ if (raw.includes('dsh-compaction-control')) mounted.push(entry.name)
3661
+ } catch { /* 无 manifest 的目录不是 profile */ }
3662
+ }
3663
+ return mounted.sort()
3664
+ }
3665
+
3666
+ function managedBlock(state) {
3667
+ return [
3668
+ MANAGED_BEGIN,
3669
+ '- id: compaction-basic',
3670
+ " name: '@deepseek-ai/dsh-compaction-basic'",
3671
+ ' config:',
3672
+ ` auto: ${state.auto}`,
3673
+ ` thresholdRatio: ${state.thresholdRatio}`,
3674
+ ` retainRatio: ${state.retainRatio}`,
3675
+ MANAGED_END,
3676
+ ].join('\n')
3677
+ }
3678
+
3679
+ /** 纯文本手术:由现有内容与目标状态算出下一版补丁文件内容(不落盘)。 */
3680
+ function composePatch(raw, state) {
3681
+ const begin = raw.indexOf(MANAGED_BEGIN)
3682
+ const end = raw.indexOf(MANAGED_END)
3683
+ if (begin !== -1 && end !== -1 && end > begin) {
3684
+ return raw.slice(0, begin) + managedBlock(state) + raw.slice(end + MANAGED_END.length)
3685
+ }
3686
+ const stripped = raw
3687
+ .split('\n')
3688
+ .filter((line) => line.trim() !== '' && !line.trim().startsWith('#'))
3689
+ .join('')
3690
+ .trim()
3691
+ if (stripped === '[]') {
3692
+ return raw.replace('[]', managedBlock(state))
3693
+ }
3694
+ if (stripped === '') {
3695
+ return `${managedBlock(state)}\n`
3696
+ }
3697
+ const base = raw.replace(/\s+$/, '')
3698
+ return `${base}\n${managedBlock(state)}\n`
3699
+ }
3700
+
3701
+ /** 移除受管块后的剩余内容;没有受管块时返回 null(无需写回)。 */
3702
+ function stripManaged(raw) {
3703
+ const begin = raw.indexOf(MANAGED_BEGIN)
3704
+ const end = raw.indexOf(MANAGED_END)
3705
+ if (begin === -1 || end === -1 || end < begin) return null
3706
+ return raw.slice(0, begin) + raw.slice(end + MANAGED_END.length)
3707
+ }
3708
+
3709
+ /** 官方 initProfile 的用户补丁层模板(移除后文件为空时回填)。 */
3710
+ const PATCH_TEMPLATE = '# Your patch layer for this dsh profile, applied after every bundle layer:\n'
3711
+ + '# a top-level YAML array of loader patch entries (id-targeted config\n'
3712
+ + '# overrides, disables, and insert lists; `!!js` expressions allowed).\n'
3713
+ + '[]\n'
3714
+
3715
+ /** 恢复默认:从 profile 补丁层移除受管块,让 compaction-basic 回落自身默认。 */
3716
+ async function clearProfilePatch(profileName, report) {
3717
+ const file = join(PROFILES_DIR, profileName, 'cordis.patch.yml')
3718
+ let raw = ''
3719
+ try { raw = await readFile(file, 'utf8') } catch { return }
3720
+ const strippedManaged = stripManaged(raw)
3721
+ if (strippedManaged === null) return
3722
+ const stripped = strippedManaged
3723
+ .split('\n')
3724
+ .filter((line) => line.trim() !== '' && !line.trim().startsWith('#'))
3725
+ .join('')
3726
+ .trim()
3727
+ const next = stripped === ''
3728
+ ? PATCH_TEMPLATE
3729
+ : `${strippedManaged.replace(/\s+$/, '')}\n`
3730
+ if (Yaml) {
3731
+ try {
3732
+ const parsed = Yaml.parse(next)
3733
+ if (!Array.isArray(parsed)) throw new Error('not an array')
3734
+ } catch (error) {
3735
+ throw new Error(`${profileName}/cordis.patch.yml 语法校验失败,未写入:${String(error?.message ?? error)}`)
3736
+ }
3737
+ } else {
3738
+ report(`yaml lib unavailable, rewrote ${profileName}/cordis.patch.yml without validation`)
3739
+ }
3740
+ await writeFile(file, next, 'utf8')
3741
+ return file
3742
+ }
3743
+
3744
+ /**
3745
+ * 把受管块写入一个 profile 的 cordis.patch.yml。
3746
+ * 纯文本手术:只动 BEGIN/END 标记之间的内容,用户自己写的其他条目原样保留;
3747
+ * 全新文件(官方模板是注释 + `[]`)用受管块替换 `[]`。写前用 yaml 库校验
3748
+ * (如果运行时带了这个库),校验失败不落盘。
3749
+ */
3750
+ async function syncProfilePatch(profileName, state, report) {
3751
+ const file = join(PROFILES_DIR, profileName, 'cordis.patch.yml')
3752
+ let raw = ''
3753
+ try { raw = await readFile(file, 'utf8') } catch { raw = '' }
3754
+ const next = composePatch(raw, state)
3755
+
3756
+ if (Yaml) {
3757
+ try {
3758
+ const parsed = Yaml.parse(next)
3759
+ if (!Array.isArray(parsed)) throw new Error('not an array')
3760
+ } catch (error) {
3761
+ throw new Error(`${profileName}/cordis.patch.yml 语法校验失败,未写入:${String(error?.message ?? error)}`)
3762
+ }
3763
+ } else {
3764
+ report(`yaml lib unavailable, wrote ${profileName}/cordis.patch.yml without validation`)
3765
+ }
3766
+
3767
+ await writeFile(file, next, 'utf8')
3768
+ return file
3769
+ }
3770
+
3771
+ /** 把状态同步进所有挂载 profile;返回写入的 profile 名单。 */
3772
+ async function syncAllProfiles(state, report) {
3773
+ const profiles = await listMountedProfiles()
3774
+ const written = []
3775
+ for (const profileName of profiles) {
3776
+ await syncProfilePatch(profileName, state, report)
3777
+ written.push(profileName)
3778
+ }
3779
+ return written
3780
+ }
3781
+
3782
+ function apply(ctx, config) {
3783
+ const say = (message) => {
3784
+ try { console.info(`[dsh-compaction-control] ${message}`) } catch { /* ignore */ }
3785
+ }
3786
+ const report = async (line) => {
3787
+ try {
3788
+ await mkdir(dirname(DIAG_LOG), { recursive: true })
3789
+ await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
3790
+ } catch { /* ignore */ }
3791
+ }
3792
+
3793
+ say(`server boot, enabled=${config.enabled !== false}`)
3794
+ void report(`server boot enabled=${config.enabled !== false}`)
3795
+
3796
+ // 压缩前自动落档:客户端圆环在占用率上穿「阈值-5%」时布防,本进程在
3797
+ // 该会话下一次模型调用前往消息尾追加落档指令——agent 用自己的文件工具
3798
+ // 把项目状态写进工作区 checkpoint.md,随后 compaction-basic 照常压缩。
3799
+ // 只注入一次(注入即解除布防;占用回落后客户端重新布防)。
3800
+ const armedSessions = new Set()
3801
+ const CHECKPOINT_MARKER = 'dsh-auto-checkpoint'
3802
+ const CHECKPOINT_TEXT = [
3803
+ '【自动落档】上下文即将压缩。请先把当前任务状态写入工作区文件 `checkpoint.md`(覆盖旧内容):',
3804
+ '任务目标、已完成事项、待办事项、关键决定与约束、涉及的重要文件路径。',
3805
+ '写完落档后,再回答上一条消息的问题。(dsh-auto-checkpoint)',
3806
+ ].join('\n')
3807
+
3808
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
3809
+ const decision = await next()
3810
+ try {
3811
+ if (!decision || decision.kind !== 'enter' || !Array.isArray(decision.messages)) return decision
3812
+ const sessionId = String(agent?.sessionId ?? '')
3813
+ if (!sessionId || !armedSessions.has(sessionId)) return decision
3814
+ const state = await readState()
3815
+ if (state.checkpoint === false) { armedSessions.delete(sessionId); return decision }
3816
+ const tail = decision.messages.slice(-3).map((m) => JSON.stringify(m?.content ?? '')).join('')
3817
+ armedSessions.delete(sessionId)
3818
+ if (tail.includes(CHECKPOINT_MARKER)) return decision
3819
+ const message = createUserMessage({ content: [{ type: 'text', text: CHECKPOINT_TEXT }] })
3820
+ await report(`checkpoint injected session=${sessionId} msgs=${decision.messages.length}`)
3821
+ return { ...decision, messages: [...decision.messages, message] }
3822
+ } catch (error) {
3823
+ await report(`checkpoint pre-step failed: ${String(error?.message ?? error).slice(0, 200)}`)
3824
+ return decision
3825
+ }
3826
+ })
3827
+
3828
+ // compaction/agents 属于可选依赖:base bundle 没带它们时路由仍注册,
3829
+ // /compact 返回 503,面板其余功能(状态展示、参数保存)不受影响。
3830
+ let runtime = null
3831
+
3832
+ ctx.inject(['webServer'], (webCtx) => {
3833
+ webCtx.effect(() => webCtx.webServer.register({
3834
+ kind: 'prefix',
3835
+ path: '/api/dsh-compaction-control',
3836
+ handler: async (req, res) => {
3837
+ const send = (status, body) => {
3838
+ res.statusCode = status
3839
+ res.setHeader('content-type', 'application/json')
3840
+ res.end(JSON.stringify(body))
3841
+ }
3842
+ const readBody = async () => {
3843
+ let raw = ''
3844
+ for await (const chunk of req) raw += chunk
3845
+ try { return raw ? JSON.parse(raw) : {} } catch { return null }
3846
+ }
3847
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-compaction-control/, '')
3848
+
3849
+ if (path === '/enabled' && req.method === 'GET') {
3850
+ send(200, { enabled: config.enabled !== false, name })
3851
+ return
3852
+ }
3853
+
3854
+ if (path === '/diag' && req.method === 'POST') {
3855
+ await report(String((await readBody())?.line ?? '').slice(0, 2000))
3856
+ send(200, { ok: true })
3857
+ return
3858
+ }
3859
+
3860
+ // 压缩前落档布防:客户端占用率上穿阈值-5% 时 arm=true(升沿只发一次),
3861
+ // 占用回落后 arm=false。服务端只记标记,注入发生在下一次 pre-step。
3862
+ if (path === '/checkpoint-arm' && req.method === 'POST') {
3863
+ const body = await readBody()
3864
+ const sessionId = String(body?.sessionId ?? '')
3865
+ if (!sessionId) { send(400, { error: '缺少 sessionId' }); return }
3866
+ const arm = body?.arm === true
3867
+ const state = await readState()
3868
+ if (arm && state.checkpoint === false) { send(200, { ok: true, armed: false, reason: 'checkpoint-disabled' }); return }
3869
+ if (arm) armedSessions.add(sessionId)
3870
+ else armedSessions.delete(sessionId)
3871
+ await report(`checkpoint ${arm ? 'armed' : 'disarmed'} session=${sessionId}`)
3872
+ send(200, { ok: true, armed })
3873
+ return
3874
+ }
3875
+
3876
+ if (path === '/state' && req.method === 'GET') {
3877
+ try {
3878
+ const [state, profiles] = await Promise.all([readState(), listMountedProfiles()])
3879
+ send(200, { ok: true, state, defaults: DEFAULT_STATE, profiles, version: '0.1.0' })
3880
+ } catch (error) {
3881
+ send(500, { error: String(error?.message ?? error) })
3882
+ }
3883
+ return
3884
+ }
3885
+
3886
+ if (path === '/state' && req.method === 'POST') {
3887
+ const body = await readBody()
3888
+ if (body === null) { send(400, { error: '请求体不是合法 JSON' }); return }
3889
+ try {
3890
+ const state = sanitizeState(body)
3891
+ await mkdir(DSH_DIR, { recursive: true })
3892
+ await writeFile(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
3893
+ const profiles = await syncAllProfiles(state, report)
3894
+ await report(`state saved ${JSON.stringify(state)} profiles=${profiles.join(',') || 'none'}`)
3895
+ send(200, { ok: true, state, profiles, restartRequired: true })
3896
+ } catch (error) {
3897
+ send(400, { error: String(error?.message ?? error) })
3898
+ }
3899
+ return
3900
+ }
3901
+
3902
+ if (path === '/reset' && req.method === 'POST') {
3903
+ try {
3904
+ const state = { ...DEFAULT_STATE }
3905
+ await mkdir(DSH_DIR, { recursive: true })
3906
+ await writeFile(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
3907
+ const profiles = await listMountedProfiles()
3908
+ for (const profileName of profiles) await clearProfilePatch(profileName, report)
3909
+ await report(`state reset to defaults, managed block removed from: ${profiles.join(',') || 'none'}`)
3910
+ send(200, { ok: true, state, profiles, restartRequired: true })
3911
+ } catch (error) {
3912
+ send(400, { error: String(error?.message ?? error) })
3913
+ }
3914
+ return
3915
+ }
3916
+
3917
+ if (path === '/compact' && req.method === 'POST') {
3918
+ if (!runtime) { send(503, { error: '压缩服务未就绪(本 profile 未挂载 compaction/agents)' }); return }
3919
+ const body = await readBody()
3920
+ const sessionId = body?.sessionId
3921
+ if (!sessionId) { send(400, { error: '缺少 sessionId' }); return }
3922
+ const agent = runtime.agents.get(sessionId)
3923
+ if (!agent) { send(409, { error: '未找到该会话的 agent' }); return }
3924
+ const controller = new AbortController()
3925
+ try {
3926
+ const result = await runtime.compaction.compactNow(agent, controller.signal)
3927
+ if (result === null) {
3928
+ send(200, { ok: true, empty: true, message: '暂无可压缩的历史' })
3929
+ } else {
3930
+ await report(`manual compact ok: ${result.shadowedSeqs.length} items ~${result.shadowedTokenCount} tokens`)
3931
+ send(200, {
3932
+ ok: true,
3933
+ shadowed: result.shadowedSeqs.length,
3934
+ tokens: result.shadowedTokenCount,
3935
+ })
3936
+ }
3937
+ } catch (error) {
3938
+ const code = typeof error?.code === 'string' ? error.code.toLowerCase() : ''
3939
+ const messages = {
3940
+ busy: '会话正忙(运行中或已有压缩任务),请在空闲时重试',
3941
+ cancelled: '压缩已取消',
3942
+ changed: '待压缩历史在提交前发生了变化,本次已放弃并记录',
3943
+ summary: '未能生成有效摘要,本次已记录',
3944
+ commit: '压缩未干净完成,请检查会话状态后再试',
3945
+ persistence: '压缩完成,但会话保存失败',
3946
+ }
3947
+ await report(`manual compact failed: code=${code || 'unknown'} ${String(error?.message ?? error).slice(0, 300)}`)
3948
+ if (messages[code]) { send(409, { error: messages[code], code }); return }
3949
+ send(500, { error: String(error?.message ?? error) })
3950
+ }
3951
+ return
3952
+ }
3953
+
3954
+ send(404, { error: 'not-found' })
3955
+ },
3956
+ }), 'dsh-compaction-control: /api/dsh-compaction-control/*')
3957
+
3958
+ webCtx.inject(['compaction', 'agents'], (deep) => {
3959
+ runtime = { compaction: deep.compaction, agents: deep.agents }
3960
+ say('compaction + agents ready')
3961
+ void report('runtime ready (compaction + agents)')
3962
+ return () => { runtime = null }
3963
+ })
3964
+ })
3965
+ }
3966
+
3967
+
3968
+ /** 仅供离线自测(scripts/ 与 CI 可直接 import),运行时不使用。 */
3969
+ const __internals = { sanitizeState, managedBlock, composePatch, stripManaged, PATCH_TEMPLATE, DEFAULT_STATE }
3970
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
3971
+ })()
3972
+
3973
+ /* ==== module: prompts(手写) ==== */
3974
+ const promptsModule = (() => {
3975
+ /**
3976
+ * dsh-superinput · prompts 模块(提示词模板库)
3977
+ *
3978
+ * prompts 是手动保存的常用模板(划选正文可一键存入)。
3979
+ * 支持 {{变量}} 占位符,插入时由前端弹窗填值;服务端只存原文,不做替换。
3980
+ *
3981
+ * 存储 ~/.dsh/storages/dsh-superinput/prompts.json(结构 { items: [...] })。
3982
+ */
3983
+
3984
+
3985
+
3986
+
3987
+
3988
+
3989
+
3990
+ const moduleInject = []
3991
+
3992
+ const STORE_DIR = join(homedir(), '.dsh', 'storages', 'dsh-superinput')
3993
+ const STORE_PATH = join(STORE_DIR, 'prompts.json')
3994
+ const CLIENT_DIAG = join(homedir(), '.dsh', 'superinput-debug.log')
3995
+
3996
+ /* 划选上下文:客户端把「选段 + 评论」暂存到这里,下一轮 pre-step 一次性
3997
+ * 注入成一条 sourced UserMessage,随后立即删除(只影响紧接着的一次发送)。 */
3998
+ const CONTEXT_MAX_ITEMS = 8
3999
+ const CONTEXT_TEXT_MAX = 4000
4000
+ const CONTEXT_COMMENT_MAX = 600
4001
+ const CONTEXT_TTL_MS = 10 * 60_000
4002
+ const pendingContext = new Map()
4003
+
4004
+ function renderSelectionContext(items) {
4005
+ const lines = [
4006
+ '以下是我从对话中选中的上下文,按顺序附带我的评论。回答时请优先参考这些内容,并针对评论中的问题作答。',
4007
+ ]
4008
+ items.forEach((item, index) => {
4009
+ lines.push(`【选段 ${index + 1}】\n${item.text}${item.comment ? `\n我的评论:${item.comment}` : ''}`)
4010
+ })
4011
+ return lines.join('\n\n')
4012
+ }
4013
+
4014
+ const Config = Schema.object({
4015
+ enabled: Schema.boolean().default(true).description('启用提示词模板库'),
4016
+ maxItems: Schema.number().default(500).description('最多保存多少条模板(收藏不计入上限)'),
4017
+ })
4018
+
4019
+ function createStore(maxItems) {
4020
+ let state = { items: [] }
4021
+ let dirty = false
4022
+ let timer = null
4023
+
4024
+ const load = () => {
4025
+ try { state = JSON.parse(readFileSync(STORE_PATH, 'utf8')) } catch { /* 首次或损坏:空库 */ }
4026
+ if (!state || !Array.isArray(state.items)) state = { items: [] }
4027
+ }
4028
+
4029
+ const flush = () => {
4030
+ if (!dirty) return
4031
+ try {
4032
+ mkdirSync(STORE_DIR, { recursive: true })
4033
+ const tmp = `${STORE_PATH}.${process.pid}.tmp`
4034
+ writeFileSync(tmp, JSON.stringify(state), 'utf8')
4035
+ renameSync(tmp, STORE_PATH)
4036
+ dirty = false
4037
+ } catch (error) {
4038
+ console.error('[dsh-superinput] prompts flush failed:', error?.message ?? error)
4039
+ }
4040
+ }
4041
+
4042
+ const schedule = () => {
4043
+ dirty = true
4044
+ if (timer) return
4045
+ timer = setTimeout(() => { timer = null; flush() }, 500)
4046
+ timer.unref?.()
4047
+ }
4048
+
4049
+ return {
4050
+ load,
4051
+ dispose() { if (timer) { clearTimeout(timer); timer = null } flush() },
4052
+ list() { return state.items },
4053
+
4054
+ /** 新建或更新。title 为空时用正文前 40 字兜底,避免列表里出现无标题条目。 */
4055
+ save({ id, title, body, tags }) {
4056
+ const cleanBody = String(body ?? '').replace(/\0/g, '')
4057
+ if (!cleanBody.trim()) return { ok: false, reason: 'empty-body' }
4058
+ const cleanTitle = String(title ?? '').trim().slice(0, 80) || cleanBody.trim().slice(0, 40)
4059
+ const cleanTags = Array.isArray(tags)
4060
+ ? tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 8)
4061
+ : []
4062
+
4063
+ if (id) {
4064
+ const item = state.items.find((entry) => entry.id === id)
4065
+ if (!item) return { ok: false, reason: 'not-found' }
4066
+ item.title = cleanTitle
4067
+ item.body = cleanBody
4068
+ item.tags = cleanTags
4069
+ item.at = Date.now()
4070
+ schedule()
4071
+ return { ok: true, id, created: false }
4072
+ }
4073
+
4074
+ const newId = randomUUID()
4075
+ state.items.unshift({
4076
+ id: newId,
4077
+ title: cleanTitle,
4078
+ body: cleanBody,
4079
+ tags: cleanTags,
4080
+ at: Date.now(),
4081
+ pinned: false,
4082
+ })
4083
+ // 收藏条目不占上限
4084
+ const overflow = state.items.filter((item) => !item.pinned).length - maxItems
4085
+ if (overflow > 0) {
4086
+ const drop = new Set(state.items.filter((item) => !item.pinned).slice(-overflow).map((item) => item.id))
4087
+ state.items = state.items.filter((item) => !drop.has(item.id))
4088
+ }
4089
+ schedule()
4090
+ return { ok: true, id: newId, created: true }
4091
+ },
4092
+
4093
+ remove(id) {
4094
+ const before = state.items.length
4095
+ state.items = state.items.filter((item) => item.id !== id)
4096
+ if (state.items.length !== before) schedule()
4097
+ return state.items.length !== before
4098
+ },
4099
+
4100
+ pin(id, pinned) {
4101
+ const item = state.items.find((entry) => entry.id === id)
4102
+ if (!item) return false
4103
+ item.pinned = !!pinned
4104
+ schedule()
4105
+ return true
4106
+ },
4107
+
4108
+ /** 使用计数:面板点插入时调用,列表按最近使用排序。 */
4109
+ use(id) {
4110
+ const item = state.items.find((entry) => entry.id === id)
4111
+ if (!item) return false
4112
+ item.usedAt = Date.now()
4113
+ item.uses = (item.uses ?? 0) + 1
4114
+ schedule()
4115
+ return true
4116
+ },
4117
+
4118
+ /** 是否注册为 / 斜杠命令(客户端按此标记同步 commandUi)。 */
4119
+ slash(id, on) {
4120
+ const item = state.items.find((entry) => entry.id === id)
4121
+ if (!item) return false
4122
+ item.slash = !!on
4123
+ schedule()
4124
+ return true
4125
+ },
4126
+
4127
+ /**
4128
+ * 导入:按 id 合并;同 id 冲突保留 at(更新时间)较新的一方。
4129
+ * id 不在库中则原 id 收编(跨机重复导入可去重),缺 id 才新分配。
4130
+ */
4131
+ import(items) {
4132
+ let added = 0
4133
+ let updated = 0
4134
+ for (const raw of items.slice(0, 5000)) {
4135
+ const body = String(raw?.body ?? '').replace(/\0/g, '')
4136
+ if (!body.trim()) continue
4137
+ const incoming = {
4138
+ title: String(raw?.title ?? '').trim().slice(0, 80) || body.trim().slice(0, 40),
4139
+ body,
4140
+ tags: Array.isArray(raw?.tags)
4141
+ ? raw.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 8)
4142
+ : [],
4143
+ at: Number.isFinite(raw?.at) ? raw.at : Date.now(),
4144
+ pinned: raw?.pinned === true,
4145
+ slash: raw?.slash === true,
4146
+ usedAt: Number.isFinite(raw?.usedAt) ? raw.usedAt : undefined,
4147
+ uses: Number.isFinite(raw?.uses) ? Math.max(0, Math.round(raw.uses)) : 0,
4148
+ }
4149
+ const existing = raw?.id ? state.items.find((entry) => entry.id === raw.id) : null
4150
+ if (existing) {
4151
+ if (incoming.at > (existing.at ?? 0)) {
4152
+ Object.assign(existing, incoming)
4153
+ updated++
4154
+ }
4155
+ continue
4156
+ }
4157
+ state.items.unshift({ id: raw?.id ? String(raw.id).slice(0, 64) : randomUUID(), ...incoming })
4158
+ added++
4159
+ }
4160
+ const overflow = state.items.filter((item) => !item.pinned).length - maxItems
4161
+ if (overflow > 0) {
4162
+ const drop = new Set(state.items.filter((item) => !item.pinned).slice(-overflow).map((item) => item.id))
4163
+ state.items = state.items.filter((item) => !drop.has(item.id))
4164
+ }
4165
+ if (added + updated > 0) schedule()
4166
+ return { added, updated }
4167
+ },
4168
+ }
4169
+ }
4170
+
4171
+ function apply(ctx, config) {
4172
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
4173
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。回放必须在
4174
+ // createStore 之前,重启后上次的 maxItems 才对裁剪生效。
4175
+ const live = { ...config }
4176
+ if (!live.enabled) return
4177
+
4178
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
4179
+ const SETTINGS_KEYS = ['maxItems']
4180
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-superinput', 'prompts.settings.json')
4181
+ try {
4182
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
4183
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
4184
+ } catch { /* 无存档:全部跟随配置 */ }
4185
+ let settingsTimer = null
4186
+ const persistSettings = () => {
4187
+ if (settingsTimer) return
4188
+ settingsTimer = setTimeout(() => {
4189
+ settingsTimer = null
4190
+ try {
4191
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
4192
+ const out = {}
4193
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
4194
+ const tmp = `${SETTINGS_PATH}.tmp`
4195
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
4196
+ renameSync(tmp, SETTINGS_PATH)
4197
+ } catch { /* ignore */ }
4198
+ }, 400)
4199
+ }
4200
+
4201
+ const store = createStore(Math.max(20, Math.round(live.maxItems ?? 500)))
4202
+ store.load()
4203
+ ctx.effect(() => store.dispose(), 'dsh-superinput: prompts store')
4204
+
4205
+ // 划选上下文注入:挂在 pre-step 最前面,先让下游(含提示词增强)处理完,
4206
+ // 再把上下文作为一条独立的 sourced 用户消息追加到本轮送模消息末尾。
4207
+ ctx.on('agent/pre-step', async ({ agent, step, signal }, next) => {
4208
+ const decision = await next()
4209
+ if (decision.kind === 'reject' || signal?.aborted) return decision
4210
+ const rawSession = agent?.session
4211
+ const sessionId = typeof rawSession === 'string'
4212
+ ? rawSession
4213
+ : rawSession?.id === undefined ? '' : String(rawSession.id)
4214
+ const pending = sessionId ? pendingContext.get(sessionId) : undefined
4215
+ if (step !== 1 || !pending) return decision
4216
+ pendingContext.delete(sessionId)
4217
+ if (pending.items.length === 0 || Date.now() - pending.at > CONTEXT_TTL_MS) return decision
4218
+ const text = renderSelectionContext(pending.items)
4219
+ try {
4220
+ appendFileSync(CLIENT_DIAG, `${new Date().toISOString()} context injected session=${sessionId.slice(0, 50)} items=${pending.items.length}\n`, 'utf8')
4221
+ } catch { /* 日志失败不影响注入 */ }
4222
+ return {
4223
+ ...decision,
4224
+ messages: [...decision.messages, createUserMessage({
4225
+ content: [{ type: 'text', text }],
4226
+ source: {
4227
+ kind: 'dsh-superinput-selection',
4228
+ form: 'snapshot',
4229
+ sections: [{ name: 'selection-context', text }],
4230
+ },
4231
+ })],
4232
+ }
4233
+ }, { prepend: true })
4234
+
4235
+ ctx.inject(['webServer'], (webCtx) => {
4236
+ webCtx.effect(() => webCtx.webServer.register({
4237
+ kind: 'prefix',
4238
+ path: '/api/dsh-superinput/prompts',
4239
+ handler: async (req, res) => {
4240
+ const send = (status, body) => {
4241
+ res.statusCode = status
4242
+ res.setHeader('content-type', 'application/json')
4243
+ res.end(JSON.stringify(body))
4244
+ }
4245
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-superinput\/prompts/, '')
4246
+ const readBody = async () => {
4247
+ let body = ''
4248
+ for await (const chunk of req) body += chunk
4249
+ try { return JSON.parse(body) } catch { return {} }
4250
+ }
4251
+
4252
+ if (path === '/settings' && req.method === 'GET') {
4253
+ send(200, { ...live })
4254
+ return
4255
+ }
4256
+ // 运行时改参数:只接受白名单字段,其余忽略(provider/model 这类
4257
+ // 改了要重建路由,运行时改无效,不如不给入口)。
4258
+ if (path === '/settings' && req.method === 'POST') {
4259
+ let raw = ''
4260
+ for await (const chunk of req) raw += chunk
4261
+ let body
4262
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
4263
+ if (body.maxItems !== undefined) { const v = Number(body.maxItems); if (Number.isFinite(v)) live.maxItems = Math.min(5000, Math.max(20, Math.round(v))) }
4264
+ persistSettings()
4265
+ send(200, { ...live })
4266
+ return
4267
+ }
4268
+ // 套件客户端统一诊断通道(0.4.0 起从 snippets 迁来):落
4269
+ // ~/.dsh/superinput-debug.log,与服务端 trace 同文件便于一次排查。
4270
+ if (path === '/diag' && req.method === 'POST') {
4271
+ let raw = ''
4272
+ for await (const chunk of req) raw += chunk
4273
+ try {
4274
+ let message = raw
4275
+ try { message = JSON.parse(raw).message ?? raw } catch { /* 纯文本也收 */ }
4276
+ mkdirSync(dirname(CLIENT_DIAG), { recursive: true })
4277
+ appendFileSync(CLIENT_DIAG, `${new Date().toISOString()} ${String(message).slice(0, 500)}\n`, 'utf8')
4278
+ } catch { /* 落盘失败不影响应答 */ }
4279
+ send(200, { ok: true })
4280
+ return
4281
+ }
4282
+ // 划选上下文暂存:items 为空表示清除;真正的注入在 agent/pre-step。
4283
+ if (path === '/context' && req.method === 'POST') {
4284
+ const body = await readBody()
4285
+ const sessionId = String(body.sessionId ?? '').trim().slice(0, 200)
4286
+ if (!sessionId) { send(400, { error: 'missing-session' }); return }
4287
+ const items = Array.isArray(body.items)
4288
+ ? body.items.slice(0, CONTEXT_MAX_ITEMS).map((item) => ({
4289
+ text: String(item?.text ?? '').replace(/\0/g, '').trim().slice(0, CONTEXT_TEXT_MAX),
4290
+ comment: String(item?.comment ?? '').replace(/\0/g, '').trim().slice(0, CONTEXT_COMMENT_MAX),
4291
+ })).filter((item) => item.text.length > 0)
4292
+ : []
4293
+ if (items.length === 0) pendingContext.delete(sessionId)
4294
+ else pendingContext.set(sessionId, { items, at: Date.now() })
4295
+ send(200, { ok: true, count: items.length })
4296
+ return
4297
+ }
4298
+ if (path === '/list' && req.method === 'GET') {
4299
+ send(200, { items: store.list() })
4300
+ return
4301
+ }
4302
+ if (path === '/save' && req.method === 'POST') {
4303
+ send(200, store.save(await readBody()))
4304
+ return
4305
+ }
4306
+ if (path === '/remove' && req.method === 'POST') {
4307
+ const body = await readBody()
4308
+ send(200, { ok: store.remove(String(body.id ?? '')) })
4309
+ return
4310
+ }
4311
+ if (path === '/pin' && req.method === 'POST') {
4312
+ const body = await readBody()
4313
+ send(200, { ok: store.pin(String(body.id ?? ''), body.pinned === true) })
4314
+ return
4315
+ }
4316
+ if (path === '/use' && req.method === 'POST') {
4317
+ const body = await readBody()
4318
+ send(200, { ok: store.use(String(body.id ?? '')) })
4319
+ return
4320
+ }
4321
+ if (path === '/slash' && req.method === 'POST') {
4322
+ const body = await readBody()
4323
+ send(200, { ok: store.slash(String(body.id ?? ''), body.slash === true) })
4324
+ return
4325
+ }
4326
+ if (path === '/export' && req.method === 'GET') {
4327
+ send(200, { version: 1, exportedAt: Date.now(), items: store.list() })
4328
+ return
4329
+ }
4330
+ // 兼容两种载荷:导出文件原样({items:[...]})和裸数组。
4331
+ if (path === '/import' && req.method === 'POST') {
4332
+ const body = await readBody()
4333
+ const items = Array.isArray(body) ? body : Array.isArray(body?.items) ? body.items : null
4334
+ if (!items) { send(400, { error: 'invalid-payload' }); return }
4335
+ send(200, { ok: true, ...store.import(items) })
4336
+ return
4337
+ }
4338
+ send(404, { error: 'not-found' })
4339
+ },
4340
+ }), 'dsh-superinput: prompts endpoints')
4341
+ })
4342
+ }
4343
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
4344
+ })()
4345
+
4346
+ /* ==== module: hub(手写) ==== */
4347
+ const hubModule = (() => {
4348
+ /**
4349
+ * dsh-superinput · hub 模块(设置总览)
4350
+ *
4351
+ * 把各模块的**最终生效配置**聚合成一张表,供设置页展示。
4352
+ *
4353
+ * 为什么不做写入:dsh 的插件 Config 在启动时由宿主读取,运行时改不了。
4354
+ * 各模块自己的 /settings 端点改的是内存 live 副本 + storages 持久化,
4355
+ * 重启后由模块自己回放。这里只回答「现在是什么状态」。
4356
+ *
4357
+ * 数据来源两层:apply 时聚合层塞进来的 snapshot(defaults 与宿主配置合并),
4358
+ * 叠加各模块落盘的显式改动(<storages>/dsh-superinput/<mod>.settings.json)——
4359
+ * 只吐快照会在重启后与实际运行值不符(快照不知道回放)。
4360
+ */
4361
+
4362
+
4363
+
4364
+
4365
+
4366
+ const moduleInject = []
4367
+
4368
+ const Config = Schema.object({
4369
+ enabled: Schema.boolean().default(true).description('在设置页显示「输入栏」总览'),
4370
+ })
4371
+
4372
+ /** 各模块持久化文件名(与各模块 SETTINGS_PATH 保持一致)。 */
4373
+ const PERSISTED_FILES = [
4374
+ ['cost', 'cost.settings.json'],
4375
+ ['enhancer', 'enhancer.settings.json'],
4376
+ ['followup', 'followup.settings.json'],
4377
+ ['picker', 'picker.settings.json'],
4378
+ ['prompts', 'prompts.settings.json'],
4379
+ ]
4380
+
4381
+ function loadPersisted() {
4382
+ const persisted = {}
4383
+ const dir = join(homedir(), '.dsh', 'storages', 'dsh-superinput')
4384
+ for (const [mod, file] of PERSISTED_FILES) {
4385
+ try {
4386
+ const saved = JSON.parse(readFileSync(join(dir, file), 'utf8'))
4387
+ if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) persisted[mod] = saved
4388
+ } catch { /* 模块无存档:跟随配置 */ }
4389
+ }
4390
+ return persisted
4391
+ }
4392
+
4393
+ function apply(ctx, config) {
4394
+ if (!config.enabled) return
4395
+ const snapshot = config.snapshot ?? {}
4396
+
4397
+ ctx.inject(['webServer'], (webCtx) => {
4398
+ webCtx.effect(() => webCtx.webServer.register({
4399
+ kind: 'prefix',
4400
+ path: '/api/dsh-superinput/hub',
4401
+ handler: async (req, res) => {
4402
+ const send = (status, body) => {
4403
+ res.statusCode = status
4404
+ res.setHeader('content-type', 'application/json')
4405
+ res.end(JSON.stringify(body))
4406
+ }
4407
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-superinput\/hub/, '')
4408
+ if (path === '/state' && req.method === 'GET') {
4409
+ const persisted = loadPersisted()
4410
+ const modules = {}
4411
+ for (const [mod, cfg] of Object.entries(snapshot)) modules[mod] = { ...cfg, ...(persisted[mod] ?? {}) }
4412
+ for (const [mod, saved] of Object.entries(persisted)) {
4413
+ if (!modules[mod]) modules[mod] = { ...saved }
4414
+ else modules[mod].persistedKeys = Object.keys(saved)
4415
+ }
4416
+ send(200, { modules })
4417
+ return
4418
+ }
4419
+ send(404, { error: 'not-found' })
4420
+ },
4421
+ }), 'dsh-superinput: hub endpoint')
4422
+ })
4423
+ }
4424
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
4425
+ })()
4426
+
4427
+ const MODULES = [
4428
+ ['cost', costModule],
4429
+ ['picker', pickerModule],
4430
+ ['enhancer', enhancerModule],
4431
+ ['followup', followupModule],
4432
+ ['voice', voiceModule],
4433
+ ['prompts', promptsModule],
4434
+ ['compaction', compactionModule],
4435
+ ['hub', hubModule],
4436
+ ]
4437
+
4438
+ export const name = 'superinput'
4439
+
4440
+ export const inject = [...new Set(MODULES.flatMap(([, mod]) => mod.inject ?? []))]
4441
+
4442
+ export const Config = Schema.object({
4443
+ cost: costModule.Config,
4444
+ picker: pickerModule.Config,
4445
+ enhancer: enhancerModule.Config,
4446
+ followup: followupModule.Config,
4447
+ voice: voiceModule.Config,
4448
+ prompts: promptsModule.Config,
4449
+ compaction: compactionModule.Config,
4450
+ hub: hubModule.Config,
4451
+ })
4452
+
4453
+ /**
4454
+ * 嵌套默认值兜底:宿主对插件 row config 的解析深度未知——套件层自己跑
4455
+ * 一遍每个模块的 Config({}) 拿完整默认,与宿主传入的 slice 合并。
4456
+ */
4457
+ const DEFAULTS = new Map()
4458
+ for (const [key, mod] of MODULES) {
4459
+ try { DEFAULTS.set(key, mod.Config ? mod.Config({}) : {}) } catch { DEFAULTS.set(key, {}) }
4460
+ }
4461
+
4462
+ export function apply(ctx, config) {
4463
+ const keys = Object.keys(config ?? {})
4464
+ console.info(`[dsh-superinput] server apply: host config keys=${keys.length ? keys.join(',') : '(none)'} — merging schema defaults`)
4465
+ // 先算出每个模块的最终生效配置,hub 用它做总览(自己拿不到别人的 config)。
4466
+ const merged = {}
4467
+ for (const [key] of MODULES) {
4468
+ merged[key] = { ...(DEFAULTS.get(key) ?? {}), ...(config?.[key] ?? {}) }
4469
+ }
4470
+ for (const [key, mod] of MODULES) {
4471
+ try {
4472
+ const own = merged[key]
4473
+ mod.apply(ctx, key === 'hub' ? { ...own, snapshot: merged } : own)
4474
+ trace(`module "${key}" applied OK`)
4475
+ } catch (error) {
4476
+ console.error(`[dsh-superinput] server module "${key}" failed:`, error)
4477
+ trace(`module "${key}" FAILED: ${String(error?.stack ?? error).slice(0, 600)}`)
4478
+ }
4479
+ }
4480
+ }
4481
+
4482
+ export default { name, Config, apply }