dsh-superinput 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js ADDED
@@ -0,0 +1,3428 @@
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 { createUserMessage } from '@deepseek-ai/dsh-llm'
12
+ import { defineTool } from '@deepseek-ai/dsh-tools'
13
+ import { dirname, join } from 'node:path'
14
+ import { homedir } from 'node:os'
15
+ import { randomUUID } from 'node:crypto'
16
+
17
+ const SUITE_DIAG = pathJoin(diagHomedir(), '.dsh', 'superinput-debug.log')
18
+ const trace = (line) => {
19
+ try {
20
+ diagMkdir(diagDirname(SUITE_DIAG), { recursive: true })
21
+ diagAppend(SUITE_DIAG, `${new Date().toISOString()} ${line}\n`, 'utf8')
22
+ } catch { /* ignore */ }
23
+ }
24
+
25
+ /* ==== module: cost(原 dsh-cost) ==== */
26
+ const costModule = (() => {
27
+ /* 由 scripts/build-suite.mjs 从 dsh-cost/index.js 机械生成;勿手改,重跑脚本再生成。 */
28
+ /**
29
+ * dsh-cost — DeepSeek Harness (dsh) 会话费用统计插件
30
+ *
31
+ * 借鉴 Han-1413141/dsh-cost-meter(v1.9.39)的核心数据模式,MVP 落地:
32
+ * - 主通道 ctx.on('llm/stream', listener, { global: true }):llm 服务官方
33
+ * 瀑布事件,链尾旁路观察逐 chunk 透传,捕获 usage 块(五桶
34
+ * input/output/cacheRead/cacheWrite/reasoning,宿主的 input 与
35
+ * cacheRead/cacheWrite 是互斥桶),流结束后按 DeepSeek 官方价格入账。
36
+ * - 计费数学与峰谷规则照抄 cost-meter lib/pricing.js:现行价(2026-09-10
37
+ * 北京时间中午后)flash 谷 $0.003/$0.15/$0.60、pro 谷 $0.022/$0.66/$1.98,
38
+ * 峰时 ×2;峰窗 UTC [01,04)∪[06,10)(北京 09-12 / 14-18 点),北京周六
39
+ * 日与法定节假日全天谷价;历史流量按当时档位(rateHistory / legacyBase)。
40
+ * - 账本 ~/.dsh/storages/dsh-cost/ledger.json:按日聚合(总/按模型/按会话),
41
+ * 金额恒为美元存储(与展示币种解耦,cost-meter 同款),临时文件原子
42
+ * 重命名 + 防抖写盘。
43
+ * - HTTP 端点 GET /api/dsh-cost/state|prices 供客户端渲染。
44
+ * - 异步局部存储(ALS)深度标记防包装路由插件嵌套重复入账(cost-meter
45
+ * 同款;本插件自己从不发起 llm 调用)。
46
+ *
47
+ * 已核对的 dsh API(对照 dsh-reference 与 cost-meter 源码):
48
+ * - ctx.on('llm/stream', (options, next) => AsyncIterable, { global: true })
49
+ * 监听器契约:必须调用 next() 推进瀑布并返回(包装的)流;嵌套时原样
50
+ * 返回 next() 的下游
51
+ * - GenerateOptions:{ sessionId?, model, provider, messages, ... }
52
+ * - StreamChunk:{ type:'usage', usage:{inputTokens,outputTokens,
53
+ * cacheReadTokens,cacheWriteTokens,reasoningTokens} }(usage 位于 finish 前)
54
+ * - webServer.register({ kind:'prefix', path, handler }, label)
55
+ */
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+ const name = 'cost'
65
+
66
+ const DIAG_LOG = join(homedir(), '.dsh', 'cost-debug.log')
67
+ const STORE_DIR = join(homedir(), '.dsh', 'storages', 'dsh-cost')
68
+ const LEDGER_PATH = join(STORE_DIR, 'ledger.json')
69
+ const JOURNAL_PATH = join(STORE_DIR, 'journal.jsonl')
70
+
71
+ const report = async (line) => {
72
+ try {
73
+ await mkdir(dirname(DIAG_LOG), { recursive: true })
74
+ await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
75
+ } catch { /* ignore */ }
76
+ }
77
+
78
+ /* ------------------------------------------------------------------ */
79
+ /* 价格表(USD / 1M tokens;照抄 cost-meter lib/pricing.js 现行档) */
80
+ /* ------------------------------------------------------------------ */
81
+
82
+ /** 峰谷时代分界(2026-08-16 16:00 UTC):此前按历史基础价,无峰谷概念。 */
83
+ const LEGACY_BASE_BOUNDARY = Date.parse('2026-08-16T16:00:00Z')
84
+ /** Flash 调价边界(2026-09-10 北京时间中午 12:00 = UTC 04:00);Pro 未调价。 */
85
+ const FLASH_PRICE_EFFECTIVE_AT = Date.parse('2026-09-10T04:00:00Z')
86
+ /** 周末全天谷价生效时刻(2026-08-23 北京时间零点)。 */
87
+ const WEEKEND_OFFPEAK_EFFECTIVE_AT = Date.parse('2026-08-22T16:00:00Z')
88
+
89
+ /** 峰时段窗口(UTC 小时,半开区间 [start,end))= 北京 09-12 / 14-18 点。 */
90
+ const PEAK_WINDOWS = [{ start: 1, end: 4 }, { start: 6, end: 10 }]
91
+ /** 国务院办公厅 2026 年安排中峰谷规则生效后的剩余公众假期(北京日历日)。 */
92
+ const PEAK_HOLIDAYS = new Set([
93
+ '2026-09-25', '2026-09-26', '2026-09-27',
94
+ '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04',
95
+ '2026-10-05', '2026-10-06', '2026-10-07',
96
+ ])
97
+
98
+ const double = (tier) => Object.fromEntries(Object.entries(tier).map(([key, value]) => [key, value * 2]))
99
+
100
+ /** 档位形状:{ legacyBase, august, offPeak, peak }(后三者带峰谷两档)。 */
101
+ const deepseekFlash = {
102
+ legacyBase: { cacheHit: 0.0028, cacheMiss: 0.14, output: 0.28 },
103
+ august: { cacheHit: 0.007, cacheMiss: 0.22, output: 0.66, peak: double({ cacheHit: 0.007, cacheMiss: 0.22, output: 0.66 }) },
104
+ offPeak: { cacheHit: 0.003, cacheMiss: 0.15, output: 0.6 },
105
+ peak: double({ cacheHit: 0.003, cacheMiss: 0.15, output: 0.6 }),
106
+ }
107
+ const deepseekPro = {
108
+ legacyBase: { cacheHit: 0.003625, cacheMiss: 0.435, output: 0.87 },
109
+ august: { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98, peak: double({ cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 }) },
110
+ offPeak: { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 },
111
+ peak: double({ cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 }),
112
+ }
113
+ /** 已下架旧模型的唯一历史价(只可能出现在峰谷时代之前)。 */
114
+ const legacyOnlyModels = {
115
+ deepseekchat: { legacyBase: { cacheHit: 0.07, cacheMiss: 0.27, output: 1.1 } },
116
+ deepseekreasoner: { legacyBase: { cacheHit: 0.14, cacheMiss: 0.55, output: 2.19 } },
117
+ }
118
+
119
+ const normalizeModelId = (value) => String(value ?? '').toLowerCase().replace(/[._\-\s]/g, '')
120
+
121
+ function isPeakAt(atMs) {
122
+ // 北京日界 = UTC+8;1970-01-01 为周四(weekday 4)。
123
+ const day = Math.floor((atMs + 8 * 3600000) / 86400000)
124
+ const weekday = (day + 4) % 7
125
+ if (atMs >= WEEKEND_OFFPEAK_EFFECTIVE_AT && (weekday === 6 || weekday === 0)) return false
126
+ const key = new Date(day * 86400000).toISOString().slice(0, 10)
127
+ if (atMs >= WEEKEND_OFFPEAK_EFFECTIVE_AT && PEAK_HOLIDAYS.has(key)) return false
128
+ const hour = new Date(atMs).getUTCHours()
129
+ return PEAK_WINDOWS.some(({ start, end }) => hour >= start && hour < end)
130
+ }
131
+
132
+ /** 选档:历史时刻回落历史价(rateHistory 语义的简化实现)。 */
133
+ function tierFor(entry, atMs, peak) {
134
+ if (entry.legacyBase && atMs < LEGACY_BASE_BOUNDARY) return entry.legacyBase
135
+ if (entry.august && atMs < FLASH_PRICE_EFFECTIVE_AT) return entry.august
136
+ return (peak && entry.peak) || entry.offPeak || entry.peak || entry.august || entry.legacyBase || { cacheHit: 0, cacheMiss: 0, output: 0 }
137
+ }
138
+
139
+ /**
140
+ * 单次调用成本(USD)。数学照抄 cost-meter costOf:五桶互斥,
141
+ * cacheWrite 缺价按 cacheHit,reasoning 缺价按 0(DeepSeek 推理并入输出)。
142
+ */
143
+ function costOfBuckets(buckets, tier) {
144
+ const amount = (value) => { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : 0 }
145
+ const cost = (amount(buckets.input) * tier.cacheMiss
146
+ + amount(buckets.output) * tier.output
147
+ + amount(buckets.cacheRead) * tier.cacheHit
148
+ + amount(buckets.cacheWrite) * (tier.cacheWrite ?? tier.cacheHit)
149
+ + amount(buckets.reasoning) * (typeof tier.reasoning === 'number' ? tier.reasoning : 0)) / 1_000_000
150
+ return Number.isFinite(cost) && cost > 0 ? cost : 0
151
+ }
152
+
153
+ /* ------------------------------------------------------------------ */
154
+ /* 用量指纹去重(cost-meter issue #70/#76 同款兜底) */
155
+ /* ------------------------------------------------------------------ */
156
+
157
+ /** 桶名 → 价格键(costOfBuckets 同款映射;cacheWrite 缺价按 cacheHit)。 */
158
+ const BUCKET_PRICE_KEYS = { input: 'cacheMiss', output: 'output', cacheRead: 'cacheHit', cacheWrite: 'cacheWrite', reasoning: 'reasoning' }
159
+
160
+ /** 一次调用的分桶计价行(cost-meter costFromSamples 同形状),供回合明细。 */
161
+ function bucketRows(buckets, tier) {
162
+ const rows = []
163
+ for (const [bucket, priceKey] of Object.entries(BUCKET_PRICE_KEYS)) {
164
+ const tokens = Number(buckets[bucket]) > 0 ? Number(buckets[bucket]) : 0
165
+ if (!tokens) continue
166
+ const rate = Number(tier[priceKey] ?? (priceKey === 'cacheWrite' ? tier.cacheHit : 0))
167
+ rows.push({ bucket, tokens, rate, cost: tokens * rate / 1_000_000 })
168
+ }
169
+ return rows
170
+ }
171
+
172
+ /**
173
+ * (model, 五桶指纹) 10s 窗互斥:包装路由插件在 ALS 标记逃逸的异步上下文里
174
+ * 发起上游调用时,同一请求的两份 usage 都会到达这里——五桶逐位相同即判重,
175
+ * 无论到达顺序只记第一次。正常独立调用五桶逐位相同的概率可忽略。
176
+ */
177
+ function createUsageDeduper(windowMs = 10_000) {
178
+ const seen = new Map()
179
+ return (model, buckets, atMs) => {
180
+ const fingerprint = `${normalizeModelId(model)}|${buckets.input}|${buckets.output}|${buckets.cacheRead}|${buckets.cacheWrite}|${buckets.reasoning}`
181
+ const last = seen.get(fingerprint)
182
+ if (last !== undefined && atMs - last < windowMs) return false
183
+ seen.set(fingerprint, atMs)
184
+ if (seen.size > 512) {
185
+ const cutoff = atMs - windowMs
186
+ for (const [key, seenAt] of seen) if (seenAt < cutoff) seen.delete(key)
187
+ }
188
+ return true
189
+ }
190
+ }
191
+
192
+ /* ------------------------------------------------------------------ */
193
+ /* 调用明细 journal(JSONL 追加;rotate 保留最近 3 卷) */
194
+ /* ------------------------------------------------------------------ */
195
+
196
+ const JOURNAL_MAX_BYTES = 8 * 1024 * 1024
197
+ const JOURNAL_KEEP = 3
198
+
199
+ function createJournal() {
200
+ let lines = 0
201
+ return {
202
+ /** 明细纯记录,写失败绝不影响聚合账本。 */
203
+ async write(record) {
204
+ try {
205
+ lines += 1
206
+ if (lines % 500 === 0) {
207
+ const info = await stat(JOURNAL_PATH).catch(() => null)
208
+ if (info && info.size > JOURNAL_MAX_BYTES) {
209
+ await rename(JOURNAL_PATH, join(STORE_DIR, `journal-${Date.now()}.jsonl`)).catch(() => {})
210
+ try {
211
+ const entries = await readdir(STORE_DIR)
212
+ const archives = entries.filter((name) => /^journal-\d+\.jsonl$/.test(name)).sort()
213
+ while (archives.length > JOURNAL_KEEP) {
214
+ await unlink(join(STORE_DIR, archives.shift())).catch(() => {})
215
+ }
216
+ } catch { /* ignore */ }
217
+ }
218
+ }
219
+ await appendFile(JOURNAL_PATH, `${JSON.stringify(record)}\n`, 'utf8')
220
+ } catch { /* ignore */ }
221
+ },
222
+ /** 读当前卷的倒序明细(归档卷不参与,最近 UI 只看当下)。 */
223
+ async recent({ sessionId = '', day = '', limit = 20 } = {}) {
224
+ let text = ''
225
+ try { text = await readFile(JOURNAL_PATH, 'utf8') } catch { return [] }
226
+ const rows = []
227
+ for (const line of text.split('\n')) {
228
+ if (!line) continue
229
+ try {
230
+ const row = JSON.parse(line)
231
+ if (sessionId && row.session !== sessionId) continue
232
+ if (day && row.day !== day) continue
233
+ rows.push(row)
234
+ } catch { /* 半行跳过 */ }
235
+ }
236
+ rows.reverse()
237
+ return rows.slice(0, Math.min(200, Math.max(1, limit)))
238
+ },
239
+ /**
240
+ * 增量读取:从字节偏移起新增的完整行(字节级,避免多字节字符错位;
241
+ * 轮转后新文件比偏移小 → 从头读新卷,天然不会重放旧卷)。
242
+ * 供锁持有者把 journal 折叠进 ledger(跨进程单写者模型)。
243
+ */
244
+ async recordsAfter(offset = 0) {
245
+ try {
246
+ const info = await stat(JOURNAL_PATH)
247
+ if (info.size === offset) return { rows: [], offset }
248
+ if (info.size < offset) offset = 0
249
+ const buf = await readFile(JOURNAL_PATH)
250
+ let start = offset
251
+ if (offset > 0) {
252
+ // 偏移已在行边界(前一字节是换行)则直接用;落在行中间才跳到下一行。
253
+ if (buf[offset - 1] !== 10) {
254
+ const nl = buf.indexOf(10, offset)
255
+ if (nl < 0) return { rows: [], offset: buf.length }
256
+ start = nl + 1
257
+ }
258
+ }
259
+ const rows = []
260
+ for (const line of buf.slice(start).toString('utf8').split('\n')) {
261
+ if (!line) continue
262
+ try { rows.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
263
+ }
264
+ return { rows, offset: buf.length }
265
+ } catch { return { rows: [], offset } }
266
+ },
267
+
268
+ /**
269
+ * 回合费用:会话在 [from,to] 时间窗内的调用聚合(客户端 turnTail 槽位
270
+ * 传宿主 turn/start~turn/end 事件时间)。journal 只保当前卷——轮转掉的
271
+ * 旧回合查不到,金额对 UI 恒标「≈」估算口径。
272
+ */ async range({ sessionId = '', from = 0, to = Number.MAX_SAFE_INTEGER } = {}) {
273
+ let text = ''
274
+ try { text = await readFile(JOURNAL_PATH, 'utf8') } catch { return { found: false, cost: 0, rows: [], calls: [] } }
275
+ const calls = []
276
+ const parts = new Map()
277
+ for (const line of text.split('\n')) {
278
+ if (!line) continue
279
+ let row
280
+ try { row = JSON.parse(line) } catch { continue }
281
+ if (row.session !== sessionId || !(row.at >= from && row.at <= to)) continue
282
+ calls.push({ model: row.model, provider: row.provider, at: row.at, cost: row.cost })
283
+ for (const item of Array.isArray(row.rows) ? row.rows : []) {
284
+ const key = JSON.stringify([row.provider, row.model, item.bucket])
285
+ const part = parts.get(key) ?? { provider: row.provider, model: row.model, bucket: item.bucket, tokens: 0, rate: item.rate, cost: 0 }
286
+ part.tokens += item.tokens
287
+ part.cost += item.cost
288
+ parts.set(key, part)
289
+ }
290
+ }
291
+ calls.sort((a, b) => a.at - b.at)
292
+ const rows = [...parts.values()].sort((a, b) => b.cost - a.cost)
293
+ return { found: calls.length > 0, cost: calls.reduce((sum, call) => sum + call.cost, 0), rows, calls }
294
+ },
295
+ }
296
+ }
297
+
298
+ /* ------------------------------------------------------------------ */
299
+ /* 账本 */
300
+ /* ------------------------------------------------------------------ */
301
+
302
+ const zeroBuckets = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 })
303
+ const localDayKey = (atMs) => {
304
+ const d = new Date(atMs)
305
+ const pad = (n) => String(n).padStart(2, '0')
306
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
307
+ }
308
+
309
+ function createLedger(retentionDays) {
310
+ let state = { version: 1, days: {}, imported: {} }
311
+ let dirty = false
312
+ let flushTimer = null
313
+ /** 最近调用明细(内存,重启清空):费用弹卡按时间倒序展示。 */
314
+ const RECENT_CALLS_MAX = 200
315
+ const recentCalls = []
316
+
317
+ const load = () => {
318
+ try {
319
+ const raw = JSON.parse(readFileSync(LEDGER_PATH, 'utf8'))
320
+ if (raw && typeof raw === 'object' && raw.days && typeof raw.days === 'object') state = raw
321
+ } catch { /* 首次运行或损坏:从空账本开始 */ }
322
+ }
323
+
324
+ const flushSync = () => {
325
+ if (!dirty) return
326
+ try {
327
+ // 保留天数裁剪:超龄日直接移除(cost-meter 同口径)。
328
+ if (Number.isFinite(retentionDays) && retentionDays > 0) {
329
+ const cutoff = localDayKey(Date.now() - retentionDays * 86400000)
330
+ for (const key of Object.keys(state.days)) if (key < cutoff) delete state.days[key]
331
+ }
332
+ mkdirSync(STORE_DIR, { recursive: true })
333
+ const tmp = `${LEDGER_PATH}.${process.pid}.tmp`
334
+ writeFileSync(tmp, JSON.stringify(state), 'utf8')
335
+ renameSync(tmp, LEDGER_PATH)
336
+ dirty = false
337
+ } catch (error) {
338
+ void report(`ledger flush failed: ${String(error?.message ?? error).slice(0, 200)}`)
339
+ }
340
+ }
341
+
342
+ const scheduleFlush = () => {
343
+ dirty = true
344
+ if (flushTimer) return
345
+ flushTimer = setTimeout(() => { flushTimer = null; flushSync() }, 800)
346
+ flushTimer.unref?.()
347
+ }
348
+
349
+ const dayOf = (atMs) => {
350
+ const key = localDayKey(atMs)
351
+ let day = state.days[key]
352
+ if (!day) {
353
+ day = { ...zeroBuckets(), calls: 0, cost: 0, byModel: {}, sessions: {} }
354
+ state.days[key] = day
355
+ }
356
+ return day
357
+ }
358
+
359
+ return {
360
+ load,
361
+ flushSync,
362
+ dispose() { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null } flushSync() },
363
+ /** 回填游标:该会话日志已覆盖到的事件时刻(实时记账同样推进,幂等切割)。 */
364
+ importedAt(sessionKey) {
365
+ return state.imported?.[sessionKey] ?? 0
366
+ },
367
+ markImported(sessionKey, atMs) {
368
+ if (!state.imported) state.imported = {}
369
+ const current = state.imported[sessionKey] ?? 0
370
+ if (atMs <= current) return false
371
+ state.imported[sessionKey] = atMs
372
+ // 裁剪:只保留最近 500 个会话的游标。
373
+ const keys = Object.keys(state.imported)
374
+ if (keys.length > 500) {
375
+ for (const key of keys.slice(0, keys.length - 500)) delete state.imported[key]
376
+ }
377
+ scheduleFlush()
378
+ return true
379
+ },
380
+ /** journal 折叠进度(字节偏移,随 ledger 一起落盘 → 崩溃安全,不重不漏)。 */
381
+ journalOffset() { return state.journalOffset ?? 0 },
382
+ setJournalOffset(offset) {
383
+ if (!Number.isFinite(offset) || offset <= (state.journalOffset ?? 0)) return
384
+ state.journalOffset = offset
385
+ scheduleFlush()
386
+ },
387
+ /** 官方同步的现行价格(flash/pro 峰谷档)与汇率——覆盖内置表,历史档不动。 */
388
+ livePrices() { return state.live?.prices ?? null },
389
+ setLivePrices(tiers) {
390
+ state.live = { ...(state.live ?? {}), prices: tiers }
391
+ scheduleFlush()
392
+ },
393
+ liveExchangeRate() { return state.live?.exchangeRate ?? null },
394
+ setExchangeRate(rate, fetchedAt) {
395
+ state.live = { ...(state.live ?? {}), exchangeRate: { rate, fetchedAt } }
396
+ scheduleFlush()
397
+ },
398
+ /** 最近调用明细,时间倒序(费用弹卡用)。 */
399
+ recentCalls(limit = 50) {
400
+ return recentCalls.slice(-Math.max(1, limit)).reverse()
401
+ },
402
+ /** 累加一次模型调用的用量与费用。source 区分对话 / 增强 / 建议生成。 */
403
+ account({ sessionId, model, buckets, cost, atMs, source }) {
404
+ const day = dayOf(atMs)
405
+ const src = typeof source === 'string' && source ? source : 'chat'
406
+ const addTo = (target) => {
407
+ for (const key of Object.keys(zeroBuckets())) target[key] += buckets[key] ?? 0
408
+ target.calls += 1
409
+ target.cost += cost
410
+ }
411
+ addTo(day)
412
+ // 按来源分账,费用弹卡才能把「增强」和「对话」分开显示。
413
+ const bySource = day.bySource ?? (day.bySource = {})
414
+ const srcEntry = bySource[src] ?? (bySource[src] = { ...zeroBuckets(), calls: 0, cost: 0 })
415
+ addTo(srcEntry)
416
+ // 最近调用明细(内存环形缓冲,重启清空;历史总额仍走 days)。
417
+ recentCalls.push({
418
+ at: atMs, session: sessionId || '', model: model || '', source: src,
419
+ tokens: (buckets.input ?? 0) + (buckets.output ?? 0) + (buckets.cacheRead ?? 0) + (buckets.cacheWrite ?? 0),
420
+ input: buckets.input ?? 0, output: buckets.output ?? 0,
421
+ cacheRead: buckets.cacheRead ?? 0, cost,
422
+ })
423
+ if (recentCalls.length > RECENT_CALLS_MAX) recentCalls.splice(0, recentCalls.length - RECENT_CALLS_MAX)
424
+ if (model) {
425
+ const entry = day.byModel[model] ?? (day.byModel[model] = { ...zeroBuckets(), calls: 0, cost: 0 })
426
+ addTo(entry)
427
+ }
428
+ if (sessionId) {
429
+ const session = day.sessions[sessionId] ?? (day.sessions[sessionId] = { ...zeroBuckets(), calls: 0, cost: 0, at: 0 })
430
+ addTo(session)
431
+ const sessionBySource = session.bySource ?? (session.bySource = {})
432
+ const sessionSrc = sessionBySource[src] ?? (sessionBySource[src] = { ...zeroBuckets(), calls: 0, cost: 0 })
433
+ addTo(sessionSrc)
434
+ session.at = Math.max(session.at, atMs)
435
+ // 回填切割游标同步推进:回填只补「游标之后」的日志事件,幂等不重复。
436
+ if (!state.imported) state.imported = {}
437
+ if (atMs > (state.imported[sessionId] ?? 0)) state.imported[sessionId] = atMs
438
+ }
439
+ scheduleFlush()
440
+ },
441
+ snapshot() { return state },
442
+ }
443
+ }
444
+
445
+ /* ------------------------------------------------------------------ */
446
+ /* 历史回填(cost-meter backfill 同思路:重放会话日志的 usage 事件) */
447
+ /* ------------------------------------------------------------------ */
448
+
449
+ const SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
450
+ /** 单日志解压上限(大会话整串解压占内存,超限跳过并留痕)。 */
451
+ const BACKFILL_MAX_LOG_BYTES = 64 * 1024 * 1024
452
+ const num = (value) => { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : 0 }
453
+
454
+ /** 多帧 zstd(追加写每帧独立):按魔数切段逐帧解压,坏帧跳过。 */
455
+ async function decompressSessionLog(file) {
456
+ const info = await stat(file).catch(() => null)
457
+ if (!info || info.size > BACKFILL_MAX_LOG_BYTES) return null
458
+ if (file.endsWith('.zstd')) {
459
+ const zlib = await import('node:zlib')
460
+ if (typeof zlib.zstdDecompressSync !== 'function') return null
461
+ const buf = await readFile(file)
462
+ const idxs = [0]
463
+ for (let i = 4; i <= buf.length - 4; i += 1) {
464
+ if (buf[i] === 0x28 && buf[i + 1] === 0xB5 && buf[i + 2] === 0x2F && buf[i + 3] === 0xFD) idxs.push(i)
465
+ }
466
+ let out = ''
467
+ for (let k = 0; k < idxs.length; k += 1) {
468
+ try { out += zlib.zstdDecompressSync(buf.subarray(idxs[k], idxs[k + 1] ?? buf.length)).toString('utf8') } catch { /* 截断/坏帧跳过 */ }
469
+ }
470
+ return out
471
+ }
472
+ return readFile(file, 'utf8')
473
+ }
474
+
475
+ /** 目录内最新的会话日志(版本号最大、zstd 优先)。 */
476
+ async function pickSessionLog(dir) {
477
+ const entries = await readdir(dir).catch(() => [])
478
+ const logs = entries.filter((name) => /^session(\.v\d+)?\.jsonl(\.zstd)?$/.test(name))
479
+ if (logs.length === 0) return null
480
+ logs.sort((a, b) => {
481
+ const versionOf = (name) => Number((name.match(/\.v(\d+)\./) ?? [])[1] ?? 0)
482
+ const zstdOf = (name) => (name.endsWith('.zstd') ? 1 : 0)
483
+ return (versionOf(a) - versionOf(b)) || (zstdOf(a) - zstdOf(b))
484
+ })
485
+ return join(dir, logs[logs.length - 1])
486
+ }
487
+
488
+ /**
489
+ * 重放会话事件流 → 用量样本(cost-meter replaySessionRecords 核心子集):
490
+ * request/header 切换当前路由;usage 取自 assistant/chunk(usage 块) /
491
+ * assistant/message(data.usage) / compaction/summary,按「回合位置 + 五桶
492
+ * 指纹」去重(同一步的 chunk 与 message 双份只记一次,retry 用量不同照记);
493
+ * 只取游标之后的事件(与实时计量幂等切割)。
494
+ */
495
+ function samplesFromEvents(events, cursorAtMs) {
496
+ let provider = 'deepseek'
497
+ let model = 'default'
498
+ let sessionId = ''
499
+ const seen = new Set()
500
+ const samples = []
501
+ for (const event of events) {
502
+ if (event.type === 'request/header') {
503
+ const header = event.data?.header?.config
504
+ if (typeof header?.model === 'string' && header.model) model = header.model
505
+ if (typeof header?.provider === 'string' && header.provider) provider = header.provider
506
+ continue
507
+ }
508
+ if (event.type === 'session' && typeof event.id === 'string' && !sessionId) sessionId = event.id
509
+ let usage = null
510
+ let positionKey = ''
511
+ if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage' && event.data.chunk.usage != null) {
512
+ usage = event.data.chunk.usage
513
+ positionKey = `s:${num(event.data?.turn)}:${num(event.data?.step)}`
514
+ } else if (event.type === 'assistant/message' && event.data?.usage != null) {
515
+ usage = event.data.usage
516
+ positionKey = `s:${num(event.data?.turn)}:${num(event.data?.step)}`
517
+ const source = event.data?.message?.source
518
+ if (typeof source?.model === 'string' && source.model) model = source.model
519
+ if (typeof source?.provider === 'string' && source.provider) provider = source.provider
520
+ } else if (event.type === 'compaction/summary' && event.data?.usage != null) {
521
+ usage = event.data.usage
522
+ positionKey = `c:${event.seq ?? ''}`
523
+ } else {
524
+ continue
525
+ }
526
+ const atMs = Number(event.time)
527
+ if (!Number.isFinite(atMs) || atMs <= 0 || atMs <= cursorAtMs) continue
528
+ const buckets = {
529
+ input: num(usage.inputTokens),
530
+ output: num(usage.outputTokens),
531
+ cacheRead: num(usage.cacheReadTokens),
532
+ cacheWrite: num(usage.cacheWriteTokens),
533
+ reasoning: num(usage.reasoningTokens),
534
+ }
535
+ const fingerprint = `${positionKey}|${buckets.input}|${buckets.output}|${buckets.cacheRead}|${buckets.cacheWrite}|${buckets.reasoning}`
536
+ if (seen.has(fingerprint)) continue
537
+ seen.add(fingerprint)
538
+ samples.push({ provider, model, buckets, atMs })
539
+ }
540
+ return { sessionId, samples }
541
+ }
542
+
543
+ /**
544
+ * 扫描全部会话日志回填历史(仅锁持有者,boot 后延迟触发;游标幂等)。
545
+ * 0.7.0 起回填样本写 journal(与实时计量同源),由 ingest 统一折叠——
546
+ * 回填与实时对同一调用的重叠由 ingest 的指纹去重互斥,永不双计。
547
+ */
548
+ async function runBackfill(journal, ledger, priceEntryFor) {
549
+ const workspaces = await readdir(SESSIONS_ROOT, { withFileTypes: true }).catch(() => [])
550
+ let touchedSessions = 0
551
+ let importedCalls = 0
552
+ for (const workspace of workspaces) {
553
+ if (!workspace.isDirectory()) continue
554
+ const workspaceDir = join(SESSIONS_ROOT, workspace.name)
555
+ const sessionDirs = await readdir(workspaceDir, { withFileTypes: true }).catch(() => [])
556
+ for (const sessionDir of sessionDirs) {
557
+ if (!sessionDir.isDirectory()) continue
558
+ const logFile = await pickSessionLog(join(workspaceDir, sessionDir.name))
559
+ if (!logFile) continue
560
+ try {
561
+ const text = await decompressSessionLog(logFile)
562
+ if (text === null) continue
563
+ const events = []
564
+ for (const line of text.split('\n')) {
565
+ if (!line) continue
566
+ try { events.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
567
+ }
568
+ const probe = samplesFromEvents(events, 0)
569
+ const sessionId = probe.sessionId
570
+ const { samples } = samplesFromEvents(events, sessionId ? ledger.importedAt(sessionId) : 0)
571
+ let maxAt = 0
572
+ for (const sample of samples) {
573
+ const entry = priceEntryFor(sample.model)
574
+ if (!entry) continue
575
+ const peak = isPeakAt(sample.atMs)
576
+ const tier = tierFor(entry, sample.atMs, peak)
577
+ const cost = costOfBuckets(sample.buckets, tier)
578
+ await journal.write({
579
+ at: sample.atMs,
580
+ day: localDayKey(sample.atMs),
581
+ session: sessionId || '',
582
+ model: sample.model,
583
+ provider: sample.provider,
584
+ ...sample.buckets,
585
+ rows: bucketRows(sample.buckets, tier),
586
+ cost,
587
+ peak,
588
+ })
589
+ maxAt = Math.max(maxAt, sample.atMs)
590
+ importedCalls += 1
591
+ }
592
+ if (sessionId && maxAt > 0 && ledger.markImported(sessionId, maxAt)) touchedSessions += 1
593
+ // 让出事件循环:几百个会话的解压不能卡住宿主启动。
594
+ await new Promise((resolve) => setImmediate(resolve))
595
+ } catch (error) {
596
+ void report(`backfill ${sessionDir.name} failed: ${String(error?.message ?? error).slice(0, 160)}`)
597
+ }
598
+ }
599
+ }
600
+ if (importedCalls > 0) void report(`backfill appended ${importedCalls} journal rows across ${touchedSessions} sessions`)
601
+ else void report('backfill done: nothing new')
602
+ }
603
+
604
+ /** 下一次峰谷状态翻转时刻(48h 内逐分钟扫描;周末/节假日边界自然覆盖)。 */
605
+ function nextPeakSwitch(now) {
606
+ const current = isPeakAt(now)
607
+ const start = Math.floor(now / 60_000) * 60_000 + 60_000
608
+ for (let ms = start; ms <= now + 48 * 3_600_000; ms += 60_000) {
609
+ if (isPeakAt(ms) !== current) return { atMs: ms, isPeak: isPeakAt(ms) }
610
+ }
611
+ return null
612
+ }
613
+
614
+ /** 官方定价页(预渲染 SSR)解析 → flash/pro 峰谷八字段(USD/1M)。 */
615
+ const OFFICIAL_PRICING_URL = 'https://api-docs.deepseek.com/quick_start/pricing/'
616
+
617
+ function parseOfficialPricing(html) {
618
+ const text = html.replace(/<script[\s\S]*?<\/script>/g, ' ').replace(/<[^>]*>/g, ' ')
619
+ .replace(/&amp;/g, '&').replace(/\s+/g, ' ')
620
+ const startIdx = text.indexOf('PRICING')
621
+ const endIdx = text.indexOf('Concurrency Limit', startIdx)
622
+ if (startIdx < 0 || endIdx < 0) throw new Error('pricing section not found')
623
+ const section = text.slice(startIdx, endIdx)
624
+ const nums = [...section.matchAll(/\$([0-9.]+)/g)].map((m) => Number(m[1]))
625
+ if (nums.length < 12) throw new Error(`pricing layout changed: expected 12 rates, got ${nums.length}`)
626
+ // 页面顺序(deepseek-flash | deepseek-v4-pro 两列):
627
+ // CACHE HIT off(a,b) peak(c,d) · CACHE MISS off(e,f) peak(g,h) · OUTPUT off(i,j) peak(k,l)
628
+ const [fhOff, phOff, fhPk, phPk, fmOff, pmOff, fmPk, pmPk, foOff, poOff, foPk, poPk] = nums
629
+ const pair = (flash, pro, label) => {
630
+ if (!(flash > 0 && pro > 0)) throw new Error(`pricing layout changed: invalid ${label}`)
631
+ return { flash, pro }
632
+ }
633
+ return {
634
+ fetchedAt: Date.now(),
635
+ currency: 'USD',
636
+ cacheHit: { offPeak: pair(fhOff, phOff, 'cacheHit.offPeak'), peak: pair(fhPk, phPk, 'cacheHit.peak') },
637
+ cacheMiss: { offPeak: pair(fmOff, pmOff, 'cacheMiss.offPeak'), peak: pair(fmPk, pmPk, 'cacheMiss.peak') },
638
+ output: { offPeak: pair(foOff, poOff, 'output.offPeak'), peak: pair(foPk, poPk, 'output.peak') },
639
+ }
640
+ }
641
+
642
+ async function fetchOfficialPricing() {
643
+ const res = await fetch(OFFICIAL_PRICING_URL, { headers: { 'user-agent': 'dsh-cost' }, signal: AbortSignal.timeout(15_000) })
644
+ if (!res.ok) throw new Error(`official pricing page HTTP ${res.status}`)
645
+ return parseOfficialPricing(await res.text())
646
+ }
647
+
648
+ /** 汇率(USD→CNY),frankfurter.dev 免钥公开端点。 */
649
+ async function fetchExchangeRate() {
650
+ const res = await fetch('https://api.frankfurter.dev/v1/latest?base=USD&symbols=CNY', { signal: AbortSignal.timeout(10_000) })
651
+ if (!res.ok) throw new Error(`frankfurter HTTP ${res.status}`)
652
+ const data = await res.json()
653
+ const rate = Number(data?.rates?.CNY)
654
+ if (!(rate > 0)) throw new Error('frankfurter returned no CNY rate')
655
+ return rate
656
+ }
657
+
658
+ /** 把官方解析结果折成 tier 形状(flash/pro 各一),并校验峰=谷×2(±1%)。 */
659
+ function tiersFromOfficial(parsed) {
660
+ const build = (family) => {
661
+ const offPeak = { cacheHit: parsed.cacheHit.offPeak[family], cacheMiss: parsed.cacheMiss.offPeak[family], output: parsed.output.offPeak[family] }
662
+ const peak = { cacheHit: parsed.cacheHit.peak[family], cacheMiss: parsed.cacheMiss.peak[family], output: parsed.output.peak[family] }
663
+ for (const key of Object.keys(offPeak)) {
664
+ if (Math.abs(peak[key] - offPeak[key] * 2) > offPeak[key] * 0.011) throw new Error(`peak != 2×offPeak for ${family}.${key} (official layout changed)`)
665
+ }
666
+ return { offPeak, peak: double(offPeak) }
667
+ }
668
+ return { fetchedAt: parsed.fetchedAt, currency: parsed.currency, flash: build('flash'), pro: build('pro') }
669
+ }
670
+
671
+ /* ------------------------------------------------------------------ */
672
+ /* 跨进程单写者锁(双进程同 boot 实锤;非持有者只 append journal) */
673
+ /* ------------------------------------------------------------------ */
674
+
675
+ const LOCK_STALE_MS = 30_000
676
+
677
+ function acquireLedgerLock(path) {
678
+ const tryWrite = () => {
679
+ try {
680
+ const fd = openSync(path, 'wx')
681
+ writeSync(fd, String(process.pid))
682
+ closeSync(fd)
683
+ return true
684
+ } catch (error) {
685
+ if (error?.code === 'EEXIST') return false
686
+ throw error
687
+ }
688
+ }
689
+ if (tryWrite()) return { owner: true }
690
+ try {
691
+ const info = statSync(path)
692
+ const holder = Number(readFileSync(path, 'utf8'))
693
+ let alive = false
694
+ if (Number.isSafeInteger(holder) && holder > 0) {
695
+ try { process.kill(holder, 0); alive = true } catch (error) { alive = error?.code === 'EPERM' }
696
+ }
697
+ if (!alive || Date.now() - info.mtimeMs > LOCK_STALE_MS) {
698
+ // 持有者已死或心跳超时:抢占(万一并发抢占,后写者败走非持有者,无害)。
699
+ try { unlinkSync(path) } catch { /* ignore */ }
700
+ return { owner: tryWrite() }
701
+ }
702
+ } catch { return { owner: tryWrite() } }
703
+ return { owner: false }
704
+ }
705
+
706
+ function touchLedgerLock(path) {
707
+ try { utimesSync(path, new Date(), new Date()) } catch { /* ignore */ }
708
+ }
709
+
710
+ function releaseLedgerLock(path) {
711
+ try {
712
+ if (Number(readFileSync(path, 'utf8')) === process.pid) unlinkSync(path)
713
+ } catch { /* ignore */ }
714
+ }
715
+
716
+ /* ------------------------------------------------------------------ */
717
+ /* 插件入口 */
718
+ /* ------------------------------------------------------------------ */
719
+ const moduleInject = []
720
+
721
+ /** 官方余额查询:host 白名单(安全约束——只允许官方域名,内网/环回不可能出现)。 */
722
+ const BALANCE_HOST = 'api.deepseek.com'
723
+
724
+ const Config = Schema.object({
725
+ enabled: Schema.boolean().default(true).description('是否启用费用统计'),
726
+ /** 展示汇率(USD → CNY);金额账本恒存美元,展示时换算。 */
727
+ exchangeRate: Schema.number().default(7.2).description('展示汇率(USD→CNY)'),
728
+ /** 自定义单价覆盖:{ "模型id": { cacheHit, cacheMiss, output } }(USD/1M)。 */
729
+ customPrices: Schema.object({}).default({}).description('自定义模型单价(USD/1M tokens)'),
730
+ /** 账本保留天数。 */
731
+ retentionDays: Schema.number().default(180).description('历史保留天数'),
732
+ budgetEnabled: Schema.boolean().default(false).description('启用预算'),
733
+ budgetAmount: Schema.number().default(50).description('预算金额(CNY)'),
734
+ budgetPeriod: Schema.union(['day', 'month', 'all']).default('month').description('预算周期'),
735
+ backfillEnabled: Schema.boolean().default(true).description('启动时回填历史会话费用'),
736
+ peakAlertEnabled: Schema.boolean().default(false).description('峰谷切换前提醒'),
737
+ peakAlertLeadMinutes: Schema.number().default(2).description('峰谷切换提前量(分钟,1-30)'),
738
+ peakAlertNotify: Schema.boolean().default(false).description('同时发送系统通知'),
739
+ /** DeepSeek 开放平台 API Key(选填):配置后费用弹卡显示官方账户余额。 */
740
+ apiKey: Schema.string().default('').description('DeepSeek 开放平台 API Key(查官方余额用,可选)'),
741
+ })
742
+
743
+ function apply(ctx, config) {
744
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
745
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
746
+ const live = { ...config }
747
+ // 官方余额缓存:10 分钟内直接回缓存;换 key 即失效。
748
+ let balanceCache = null
749
+ if (!live.enabled) return
750
+
751
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
752
+ const SETTINGS_KEYS = ['budgetEnabled', 'budgetAmount', 'budgetPeriod', 'peakAlertEnabled', 'peakAlertNotify', 'peakAlertLeadMinutes', 'exchangeRate', 'apiKey']
753
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-cost', 'settings.json')
754
+ try {
755
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
756
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
757
+ } catch { /* 无存档:全部跟随配置 */ }
758
+ let settingsTimer = null
759
+ const persistSettings = () => {
760
+ if (settingsTimer) return
761
+ settingsTimer = setTimeout(() => {
762
+ settingsTimer = null
763
+ try {
764
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
765
+ const out = {}
766
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
767
+ const tmp = `${SETTINGS_PATH}.tmp`
768
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
769
+ renameSync(tmp, SETTINGS_PATH)
770
+ } catch { /* ignore */ }
771
+ }, 400)
772
+ }
773
+
774
+ const lockPath = join(STORE_DIR, 'ledger.lock')
775
+ const lock = acquireLedgerLock(lockPath)
776
+ void report(`server boot v0.8.2 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'}`)
777
+
778
+ const ledger = createLedger(live.retentionDays)
779
+ if (lock.owner) ledger.load()
780
+ ctx.effect(() => { ledger.dispose(); if (lock.owner) releaseLedgerLock(lockPath) }, 'dsh-cost: ledger flush + lock release')
781
+
782
+ const admitUsage = createUsageDeduper()
783
+ const journal = createJournal()
784
+
785
+ /**
786
+ * 锁持有者把 journal 增量折叠进 ledger(0.7.0 单写者模型):
787
+ * 两个进程都会 boot 并各听各的 llm/stream——都只 append journal(O_APPEND
788
+ * 单行跨进程安全);只有锁持有者写 ledger.json,彻底消除整文件互踩。
789
+ * 指纹去重兜底:同一事件被两进程捕获 / 回填与实时重叠,10s 窗内互斥。
790
+ */
791
+ const ingestDedup = createUsageDeduper()
792
+ let ingestedCount = 0
793
+ let ingestBusy = false
794
+ async function ingestJournal() {
795
+ if (!lock.owner || ingestBusy) return
796
+ ingestBusy = true
797
+ try {
798
+ const { rows, offset } = await journal.recordsAfter(ledger.journalOffset())
799
+ for (const row of rows) {
800
+ if (!row || !Number.isFinite(row.at) || !Number.isFinite(row.cost) || !row.model) continue
801
+ const buckets = { input: row.input ?? 0, output: row.output ?? 0, cacheRead: row.cacheRead ?? 0, cacheWrite: row.cacheWrite ?? 0, reasoning: row.reasoning ?? 0 }
802
+ if (!ingestDedup(`${row.session ?? ''}|${row.model}`, buckets, row.at)) continue
803
+ ledger.account({ sessionId: row.session, model: row.model, buckets, cost: row.cost, atMs: row.at, source: row.source })
804
+ // +60s 余量:日志事件记的是消息完成时刻,晚于实时流发起时刻;
805
+ // 游标盖住整个调用时长,防回填重放同一条。
806
+ if (row.session) ledger.markImported(row.session, row.at + 60_000)
807
+ ingestedCount += 1
808
+ }
809
+ ledger.setJournalOffset(offset)
810
+ if (rows.length > 0) touchLedgerLock(lockPath)
811
+ } catch (error) {
812
+ void report(`ingest failed: ${String(error?.message ?? error).slice(0, 160)}`)
813
+ } finally {
814
+ ingestBusy = false
815
+ }
816
+ }
817
+
818
+ if (lock.owner) {
819
+ const ingestTimer = setInterval(() => { void ingestJournal() }, 5000)
820
+ ingestTimer.unref?.()
821
+ ctx.effect(() => () => clearInterval(ingestTimer), 'dsh-cost: journal ingest timer')
822
+ }
823
+
824
+ // 历史回填:仅锁持有者执行(boot 后 3s,避开宿主启动高峰);0.7.0 起
825
+ // 改写 journal(与实时同源),由 ingest 统一折叠 + 指纹去重,天然不重。
826
+ if (live.backfillEnabled && lock.owner) {
827
+ const backfillTimer = setTimeout(() => { void runBackfill(journal, ledger, priceEntryFor).catch((error) => { void report(`backfill crashed: ${String(error?.message ?? error).slice(0, 200)}`) }) }, 3000)
828
+ backfillTimer.unref?.()
829
+ ctx.effect(() => () => clearTimeout(backfillTimer), 'dsh-cost: backfill timer')
830
+ }
831
+
832
+ // 汇率自动刷新:仅锁持有者,启动 5s 后首刷,之后每小时。失败静默(用旧值)。
833
+ if (lock.owner) {
834
+ const refreshRate = async () => {
835
+ try {
836
+ const rate = await fetchExchangeRate()
837
+ ledger.setExchangeRate(rate, Date.now())
838
+ void report(`exchange rate auto: ${rate}`)
839
+ } catch (error) {
840
+ void report(`exchange rate auto failed: ${String(error?.message ?? error).slice(0, 120)}`)
841
+ }
842
+ }
843
+ const rateStart = setTimeout(() => { void refreshRate() }, 5000)
844
+ rateStart.unref?.()
845
+ const rateTimer = setInterval(() => { void refreshRate() }, 3_600_000)
846
+ rateTimer.unref?.()
847
+ ctx.effect(() => () => { clearTimeout(rateStart); clearInterval(rateTimer) }, 'dsh-cost: exchange rate timers')
848
+ }
849
+
850
+ /** 深度标记:存在即「已知会、不需要再入账」的 llm 流(嵌套排除)。 */
851
+ const llmDepth = new AsyncLocalStorage()
852
+
853
+ /**
854
+ * 跨插件的调用来源标记。prompt-enhancer / followup 在发起自己的
855
+ * llm.stream 前写 globalThis.__dshPluginCall,这里按 5 秒窗口读回:
856
+ * 落在窗口内算它们的调用,否则一律算对话。读一次就清,避免串到下一次。
857
+ */
858
+ const CALL_SOURCE_TTL_MS = 5000
859
+
860
+ /** 各插件自己的 prompt 特征:跨进程时 globalThis 不共享,只能看内容。 */
861
+ const SOURCE_SIGNATURES = [
862
+ { source: 'followup', needle: 'You generate follow-up suggestions' },
863
+ { source: 'enhance', needle: "You are a Prompt Engineering Expert" },
864
+ ]
865
+
866
+ function callSource(options) {
867
+ // 1) 同进程时的显式标记(prompt-enhancer / followup 调用前写入)
868
+ const marker = globalThis.__dshPluginCall
869
+ if (marker && typeof marker.at === 'number' && Date.now() - marker.at < CALL_SOURCE_TTL_MS) {
870
+ const source = typeof marker.source === 'string' && marker.source ? marker.source : 'chat'
871
+ globalThis.__dshPluginCall = undefined
872
+ return source
873
+ }
874
+ // 2) 兜底:按 prompt 特征识别,插件跑在别的进程时也能分开记
875
+ try {
876
+ const messages = options?.messages
877
+ if (messages) {
878
+ const text = JSON.stringify(messages)
879
+ for (const entry of SOURCE_SIGNATURES) {
880
+ if (text.includes(entry.needle)) return entry.source
881
+ }
882
+ }
883
+ } catch { /* 序列化失败就当普通对话 */ }
884
+ return 'chat'
885
+ }
886
+
887
+ const unmatchedModels = new Set()
888
+ let streamArmed = false
889
+ let accountCount = 0
890
+
891
+ function priceEntryFor(model) {
892
+ if (model && live.customPrices && live.customPrices[model]) return { custom: true, ...live.customPrices[model] }
893
+ const norm = normalizeModelId(model)
894
+ if (legacyOnlyModels[norm]) return legacyOnlyModels[norm]
895
+ if (norm.includes('deepseek')) {
896
+ // 官方同步的现行价优先(fetch-prices 成功后覆盖内置表;历史档不变)。
897
+ const live = ledger.livePrices()
898
+ if (live && live.currency === 'USD') {
899
+ const family = norm.includes('pro') ? 'pro' : 'flash'
900
+ const t = live[family]
901
+ return { legacyBase: (norm.includes('pro') ? deepseekPro : deepseekFlash).legacyBase, august: undefined, offPeak: t.offPeak, peak: t.peak, live: true }
902
+ }
903
+ return norm.includes('pro') ? deepseekPro : deepseekFlash
904
+ }
905
+ return null
906
+ }
907
+
908
+ ctx.on('llm/stream', (options, next) => {
909
+ // 包装路由插件在嵌套上下文里再发起的上游调用:原样透传,不重复入账。
910
+ if (llmDepth.getStore() !== undefined) return next()
911
+ const downstream = next()
912
+ if (!streamArmed) {
913
+ streamArmed = true
914
+ void report(`llm/stream first fire pid=${process.pid} — capture channel ALIVE in this process`)
915
+ }
916
+ const startedAtMs = Date.now()
917
+ const model = typeof options?.model === 'string' ? options.model : ''
918
+ const provider = typeof options?.provider === 'string' ? options.provider : ''
919
+ const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
920
+ let usage = null
921
+ let downstreamIter = null
922
+ let completed = false
923
+ return (async function* costObserve() {
924
+ try {
925
+ downstreamIter = downstream[Symbol.asyncIterator]()
926
+ for (;;) {
927
+ // 深度标记内拉取:下游适配器(包装路由)在消费上下文里再发起的
928
+ // ctx.llm.stream() 继承标记,内层监听器判定为嵌套、透传。
929
+ const result = await llmDepth.run(1, () => downstreamIter.next())
930
+ if (result.done) break
931
+ const chunk = result.value
932
+ // usage 块位于 finish 之前;多块时最后一块生效(cost-meter 同款)。
933
+ if (chunk?.type === 'usage' && chunk.usage != null) usage = chunk.usage
934
+ yield chunk
935
+ }
936
+ completed = true
937
+ } finally {
938
+ // 消费方提前中断也向下游传播 return(),防上游 HTTP 流悬挂。
939
+ if (!completed) {
940
+ try { await downstreamIter?.return?.() } catch { /* ignore */ }
941
+ }
942
+ try {
943
+ if (usage) {
944
+ const buckets = {
945
+ input: usage.inputTokens ?? 0,
946
+ output: usage.outputTokens ?? 0,
947
+ cacheRead: usage.cacheReadTokens ?? 0,
948
+ cacheWrite: usage.cacheWriteTokens ?? 0,
949
+ reasoning: usage.reasoningTokens ?? 0,
950
+ }
951
+ const entry = priceEntryFor(model)
952
+ if (entry) {
953
+ // 指纹去重兜底:本进程内 ALS 逃逸/同流双 usage 互斥(10s 窗)。
954
+ if (admitUsage(model, buckets, startedAtMs)) {
955
+ const peak = isPeakAt(startedAtMs)
956
+ const tier = tierFor(entry, startedAtMs, peak)
957
+ const cost = costOfBuckets(buckets, tier)
958
+ // 0.7.0 单写者模型:所有进程只 append journal(跨进程安全),
959
+ // ledger 由锁持有者的 ingest 统一折叠。
960
+ void journal.write({
961
+ at: startedAtMs,
962
+ day: localDayKey(startedAtMs),
963
+ session: sessionId || '',
964
+ model,
965
+ provider,
966
+ ...buckets,
967
+ rows: bucketRows(buckets, tier),
968
+ cost,
969
+ peak,
970
+ source: callSource(options),
971
+ })
972
+ accountCount += 1
973
+ // 采样留痕(前 10 条全记,之后 1/20),足以判定通路与发现异常。
974
+ if (accountCount <= 10 || accountCount % 20 === 0) {
975
+ 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)}`)
976
+ }
977
+ } else {
978
+ void report(`dedup skip model=${model} buckets=${JSON.stringify(buckets)}`)
979
+ }
980
+ } else if (model && !unmatchedModels.has(model)) {
981
+ // 未内置价格的第三方模型按 default 档(flash 现行价)计费,
982
+ // 首见留痕提醒用户在 customPrices 里补充精确单价。
983
+ unmatchedModels.add(model)
984
+ void report(`unmatched model "${model}" billed at default tier; add customPrices entry for accuracy`)
985
+ }
986
+ }
987
+ } catch (error) {
988
+ void report(`account failed: ${String(error?.message ?? error).slice(0, 200)}`)
989
+ }
990
+ }
991
+ })()
992
+ }, { global: true })
993
+
994
+ /* -- HTTP 端点 ------------------------------------------------------ */
995
+
996
+ ctx.inject(['webServer'], (webCtx) => {
997
+ /** /state、/export 共用:锁持有者先折叠+落盘再读文件;非持有者直读
998
+ (持有者 5s ingest + 800ms 防抖,两个进程的 UI 数据都足够新鲜)。 */
999
+ async function ledgerSnapshotFresh() {
1000
+ if (lock.owner) {
1001
+ await ingestJournal()
1002
+ ledger.flushSync()
1003
+ }
1004
+ try { return JSON.parse(await readFile(LEDGER_PATH, 'utf8')) } catch { return { days: {} } }
1005
+ }
1006
+ webCtx.effect(() => webCtx.webServer.register({
1007
+ kind: 'prefix',
1008
+ path: '/api/dsh-cost',
1009
+ handler: async (req, res) => {
1010
+ const send = (status, body) => {
1011
+ res.statusCode = status
1012
+ res.setHeader('content-type', 'application/json')
1013
+ res.end(JSON.stringify(body))
1014
+ }
1015
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-cost/, '')
1016
+ if (path === '/settings' && req.method === 'GET') {
1017
+ send(200, {
1018
+ enabled: live.enabled,
1019
+ exchangeRate: live.exchangeRate,
1020
+ budgetEnabled: live.budgetEnabled,
1021
+ budgetAmount: live.budgetAmount,
1022
+ budgetPeriod: live.budgetPeriod,
1023
+ peakAlertEnabled: live.peakAlertEnabled,
1024
+ peakAlertLeadMinutes: live.peakAlertLeadMinutes,
1025
+ peakAlertNotify: live.peakAlertNotify,
1026
+ retentionDays: live.retentionDays,
1027
+ })
1028
+ return
1029
+ }
1030
+ // 运行时改参数:retentionDays 与 customPrices 不在白名单——前者启动时
1031
+ // 已传给 createLedger,后者是对象结构,运行时改要么无效要么要重建缓存。
1032
+ if (path === '/settings' && req.method === 'POST') {
1033
+ let raw = ''
1034
+ for await (const chunk of req) raw += chunk
1035
+ let body
1036
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
1037
+ if (body.budgetEnabled !== undefined) live.budgetEnabled = body.budgetEnabled === true
1038
+ if (body.budgetPeriod === 'day' || body.budgetPeriod === 'month') live.budgetPeriod = body.budgetPeriod
1039
+ if (body.peakAlertEnabled !== undefined) live.peakAlertEnabled = body.peakAlertEnabled === true
1040
+ if (body.peakAlertNotify !== undefined) live.peakAlertNotify = body.peakAlertNotify === true
1041
+ if (body.budgetAmount !== undefined) {
1042
+ const value = Number(body.budgetAmount)
1043
+ if (Number.isFinite(value)) live.budgetAmount = Math.min(1_000_000, Math.max(0, value))
1044
+ }
1045
+ if (body.exchangeRate !== undefined) {
1046
+ const value = Number(body.exchangeRate)
1047
+ if (Number.isFinite(value) && value > 0) live.exchangeRate = Math.min(20, Math.max(1, value))
1048
+ }
1049
+ if (body.peakAlertLeadMinutes !== undefined) {
1050
+ const value = Number(body.peakAlertLeadMinutes)
1051
+ if (Number.isFinite(value)) live.peakAlertLeadMinutes = Math.min(30, Math.max(1, Math.round(value)))
1052
+ }
1053
+ if (body.apiKey !== undefined) {
1054
+ live.apiKey = String(body.apiKey ?? '').trim()
1055
+ balanceCache = null // 换 key 立即失效缓存
1056
+ }
1057
+ persistSettings()
1058
+ send(200, { ok: true })
1059
+ return
1060
+ }
1061
+
1062
+
1063
+ if (path === '/state' && req.method === 'GET') {
1064
+ const snap = await ledgerSnapshotFresh()
1065
+ const now = Date.now()
1066
+ const todayKey = localDayKey(now)
1067
+ const monthPrefix = todayKey.slice(0, 7)
1068
+ const weekStartKey = localDayKey(now - 6 * 86400000)
1069
+ let month = null
1070
+ const total = { ...zeroBuckets(), calls: 0, cost: 0 }
1071
+ const recent = []
1072
+ const sessions = []
1073
+ for (const key of Object.keys(snap.days).sort()) {
1074
+ const day = snap.days[key]
1075
+ for (const bucketKey of Object.keys(zeroBuckets())) total[bucketKey] += day[bucketKey] ?? 0
1076
+ total.calls += day.calls ?? 0
1077
+ total.cost += day.cost ?? 0
1078
+ if (key.startsWith(monthPrefix)) month = month ? mergeDay(month, day) : summarize(day)
1079
+ if (key >= weekStartKey) {
1080
+ 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) })
1081
+ for (const [id, session] of Object.entries(day.sessions ?? {})) {
1082
+ 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 })
1083
+ }
1084
+ }
1085
+ }
1086
+ sessions.sort((a, b) => b.at - a.at)
1087
+ // 预算:以展示币种(CNY)计,used 由对应周期的账本金额换算。
1088
+ const budgetReady = live.budgetEnabled && Number(live.budgetAmount) > 0
1089
+ const budgetUsd = budgetReady
1090
+ ? (live.budgetPeriod === 'day'
1091
+ ? (snap.days[todayKey]?.cost ?? 0)
1092
+ : live.budgetPeriod === 'month'
1093
+ ? (month?.cost ?? 0)
1094
+ : total.cost)
1095
+ : 0
1096
+ send(200, {
1097
+ serverNow: now,
1098
+ display: { currency: 'CNY', rate: ledger.liveExchangeRate()?.rate ?? live.exchangeRate, rateSource: ledger.liveExchangeRate() ? 'auto' : 'config', rateFetchedAt: ledger.liveExchangeRate()?.fetchedAt ?? null },
1099
+ peak: { isPeak: isPeakAt(now), windows: PEAK_WINDOWS, nextSwitchAtMs: nextPeakSwitch(now)?.atMs ?? null, nextIsPeak: nextPeakSwitch(now)?.isPeak ?? null },
1100
+ today: todayKey in snap.days ? summarize(snap.days[todayKey]) : null,
1101
+ month,
1102
+ total: summarize(total),
1103
+ budget: budgetReady
1104
+ ? { amount: live.budgetAmount, period: live.budgetPeriod, used: budgetUsd * live.exchangeRate, usedPct: budgetUsd * live.exchangeRate / live.budgetAmount }
1105
+ : null,
1106
+ recent,
1107
+ sessions: sessions.slice(0, 20),
1108
+ unmatched: [...unmatchedModels],
1109
+ streamAlive: streamArmed,
1110
+ alerts: {
1111
+ peakEnabled: live.peakAlertEnabled === true,
1112
+ leadMinutes: Math.min(30, Math.max(1, Math.round(live.peakAlertLeadMinutes ?? 2))),
1113
+ notify: live.peakAlertNotify === true,
1114
+ },
1115
+ })
1116
+ return
1117
+ }
1118
+
1119
+ /**
1120
+ * GET /forecast —— 月底花费预测。
1121
+ *
1122
+ * 用最近 7 个**完整**历史日的日均推算,不用今天(半天数据会把均值拉低)。
1123
+ * 样本少于 3 天时标 basis=insufficient,前端据此提示「数据不足」而不是
1124
+ * 显示一个不可信的数字。
1125
+ */
1126
+ if (path === '/forecast' && req.method === 'GET') {
1127
+ const snap = await ledgerSnapshotFresh()
1128
+ const now = Date.now()
1129
+ const todayKey = localDayKey(now)
1130
+ const monthPrefix = todayKey.slice(0, 7)
1131
+
1132
+ let monthCost = 0
1133
+ let monthCalls = 0
1134
+ const recent = []
1135
+
1136
+ for (const key of Object.keys(snap.days).sort()) {
1137
+ const day = snap.days[key]
1138
+ if (key.startsWith(monthPrefix)) {
1139
+ monthCost += day.cost ?? 0
1140
+ monthCalls += day.calls ?? 0
1141
+ }
1142
+ // 只取完整历史日,今天的数据还在增长
1143
+ if (key < todayKey && key >= localDayKey(now - 7 * 86400000)) {
1144
+ recent.push({ date: key, cost: day.cost ?? 0, calls: day.calls ?? 0 })
1145
+ }
1146
+ }
1147
+
1148
+ const n = recent.length
1149
+ const sumCost = recent.reduce((acc, d) => acc + d.cost, 0)
1150
+ const sumCalls = recent.reduce((acc, d) => acc + d.calls, 0)
1151
+ const avgDaily = n > 0 ? sumCost / n : 0
1152
+ const avgCalls = n > 0 ? sumCalls / n : 0
1153
+
1154
+ const nowDate = new Date(now)
1155
+ const daysInMonth = new Date(nowDate.getFullYear(), nowDate.getMonth() + 1, 0).getDate()
1156
+ const daysLeft = daysInMonth - nowDate.getDate()
1157
+ const projected = monthCost + avgDaily * daysLeft
1158
+
1159
+ return json({
1160
+ monthCost,
1161
+ monthCalls,
1162
+ avgDaily,
1163
+ avgCalls,
1164
+ daysLeft,
1165
+ projected,
1166
+ avgPerCall: monthCalls > 0 ? monthCost / monthCalls : 0,
1167
+ sampleDays: n,
1168
+ basis: n >= 3 ? 'recent' : 'insufficient',
1169
+ recent,
1170
+ })
1171
+ }
1172
+
1173
+ if (path === '/calls' && req.method === 'GET') {
1174
+ const url = new URL(req.url ?? '/api/dsh-cost/calls', 'http://localhost')
1175
+ const sessionId = url.searchParams.get('session') ?? ''
1176
+ const day = url.searchParams.get('day') ?? ''
1177
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 20)))
1178
+ // 全量按来源汇总(对话 / 增强 / 建议生成),供状态行费用弹卡分组显示。
1179
+ const snap = await ledgerSnapshotFresh()
1180
+ const bySource = {}
1181
+ let totalCost = 0
1182
+ let totalCalls = 0
1183
+ for (const key of Object.keys(snap.days)) {
1184
+ const d = snap.days[key]
1185
+ totalCost += d.cost ?? 0
1186
+ totalCalls += d.calls ?? 0
1187
+ for (const [src, entry] of Object.entries(d.bySource ?? {})) {
1188
+ const t = bySource[src] ?? (bySource[src] = { cost: 0, calls: 0 })
1189
+ t.cost += entry.cost ?? 0
1190
+ t.calls += entry.calls ?? 0
1191
+ }
1192
+ }
1193
+ send(200, { total: { cost: totalCost, calls: totalCalls, bySource }, calls: await journal.recent({ sessionId, day, limit }) })
1194
+ return
1195
+ }
1196
+
1197
+ // 本对话累计费用:跨所有日期聚合 day.sessions[sessionId],含来源分组。
1198
+ if (path === '/session' && req.method === 'GET') {
1199
+ const url = new URL(req.url ?? '/api/dsh-cost/session', 'http://localhost')
1200
+ const sessionId = url.searchParams.get('session') ?? ''
1201
+ if (!sessionId) { send(400, { error: 'missing session' }); return }
1202
+ const snap = await ledgerSnapshotFresh()
1203
+ const total = { ...zeroBuckets(), calls: 0, cost: 0, bySource: {} }
1204
+ for (const key of Object.keys(snap.days)) {
1205
+ const entry = snap.days[key].sessions?.[sessionId]
1206
+ if (!entry) continue
1207
+ for (const bucketKey of Object.keys(zeroBuckets())) total[bucketKey] += entry[bucketKey] ?? 0
1208
+ total.calls += entry.calls ?? 0
1209
+ total.cost += entry.cost ?? 0
1210
+ for (const [src, sub] of Object.entries(entry.bySource ?? {})) {
1211
+ const target = total.bySource[src] ?? (total.bySource[src] = { cost: 0, calls: 0 })
1212
+ target.cost += sub.cost ?? 0
1213
+ target.calls += sub.calls ?? 0
1214
+ }
1215
+ }
1216
+ send(200, {
1217
+ session: sessionId,
1218
+ total,
1219
+ calls: ledger.recentCalls(100).filter((call) => call.session === sessionId),
1220
+ })
1221
+ return
1222
+ }
1223
+
1224
+ // 回合费用:turnTail 槽位传宿主 turn/start~turn/end 事件时间窗。
1225
+ if (path === '/turn' && req.method === 'GET') {
1226
+ const url = new URL(req.url ?? '/api/dsh-cost/turn', 'http://localhost')
1227
+ const sessionId = url.searchParams.get('session') ?? ''
1228
+ const from = Number(url.searchParams.get('from') ?? 0)
1229
+ const to = Number(url.searchParams.get('to') ?? Number.MAX_SAFE_INTEGER)
1230
+ if (!sessionId || !Number.isFinite(from) || !Number.isFinite(to)) { send(400, { error: 'missing session/from/to' }); return }
1231
+ send(200, await journal.range({ sessionId, from, to }))
1232
+ return
1233
+ }
1234
+
1235
+ // 客户端调试上报:锚定/装配事件落 debug log(真机取证)。
1236
+ if (path === '/client-debug' && req.method === 'POST') {
1237
+ let body = ''
1238
+ for await (const chunk of req) body += chunk
1239
+ let message = ''
1240
+ try { message = String(JSON.parse(body).message ?? '').slice(0, 300) } catch { message = '' }
1241
+ if (message) void report(`client: ${message}`)
1242
+ send(200, { ok: true })
1243
+ return
1244
+ }
1245
+
1246
+ // 导出:全量 JSON(含日/模型/会话聚合)或日汇总 CSV。
1247
+ if (path === '/export' && req.method === 'GET') {
1248
+ const url = new URL(req.url ?? '/api/dsh-cost/export', 'http://localhost')
1249
+ const snap = await ledgerSnapshotFresh()
1250
+ if ((url.searchParams.get('format') ?? 'json') === 'csv') {
1251
+ const lines = ['date,input,output,cache_read,cache_write,reasoning,calls,cost_usd']
1252
+ for (const key of Object.keys(snap.days).sort()) {
1253
+ const day = snap.days[key]
1254
+ 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(','))
1255
+ }
1256
+ res.statusCode = 200
1257
+ res.setHeader('content-type', 'text/csv; charset=utf-8')
1258
+ res.setHeader('content-disposition', 'attachment; filename="dsh-cost-days.csv"')
1259
+ res.end(lines.join('\n'))
1260
+ return
1261
+ }
1262
+ res.statusCode = 200
1263
+ res.setHeader('content-type', 'application/json')
1264
+ res.setHeader('content-disposition', 'attachment; filename="dsh-cost-ledger.json"')
1265
+ res.end(JSON.stringify({ exportedAt: Date.now(), display: { currency: 'CNY', rate: ledger.liveExchangeRate()?.rate ?? live.exchangeRate }, days: snap.days }))
1266
+ return
1267
+ }
1268
+
1269
+ if (path === '/prices' && req.method === 'GET') {
1270
+ const now = Date.now()
1271
+ const peak = isPeakAt(now)
1272
+ const live = ledger.livePrices()
1273
+ send(200, {
1274
+ currency: 'USD',
1275
+ peak,
1276
+ peakWindowsUtc: PEAK_WINDOWS,
1277
+ holidays: [...PEAK_HOLIDAYS],
1278
+ source: live ? 'official-sync' : 'built-in',
1279
+ fetchedAt: live?.fetchedAt ?? null,
1280
+ models: {
1281
+ 'deepseek-v4.1-flash / deepseek-flash': live ? tierFor({ offPeak: live.flash.offPeak, peak: live.flash.peak }, now, peak) : currentTier(deepseekFlash, now, peak),
1282
+ 'deepseek-v4-pro': live ? tierFor({ offPeak: live.pro.offPeak, peak: live.pro.peak }, now, peak) : currentTier(deepseekPro, now, peak),
1283
+ },
1284
+ default: live ? tierFor({ offPeak: live.flash.offPeak, peak: live.flash.peak }, now, peak) : currentTier(deepseekFlash, now, peak),
1285
+ })
1286
+ return
1287
+ }
1288
+
1289
+ // 官方价格同步:抓官方定价页解析,校验峰=谷×2 后覆盖现行档。
1290
+ if (path === '/fetch-prices' && req.method === 'POST') {
1291
+ try {
1292
+ const parsed = await fetchOfficialPricing()
1293
+ const tiers = tiersFromOfficial(parsed)
1294
+ ledger.setLivePrices(tiers)
1295
+ void report(`price sync ok: flash off ${JSON.stringify(tiers.flash.offPeak)} pro off ${JSON.stringify(tiers.pro.offPeak)}`)
1296
+ send(200, { ok: true, fetchedAt: tiers.fetchedAt, flash: tiers.flash, pro: tiers.pro })
1297
+ } catch (error) {
1298
+ void report(`price sync failed: ${String(error?.message ?? error).slice(0, 200)}`)
1299
+ send(502, { ok: false, error: String(error?.message ?? error).slice(0, 200) })
1300
+ }
1301
+ return
1302
+ }
1303
+
1304
+ // 官方余额(选配 apiKey):host 白名单写死官方域名,https 证书校验兜底。
1305
+ if (path === '/balance' && req.method === 'GET') {
1306
+ const key = String(live.apiKey ?? '').trim()
1307
+ if (!key) { send(200, { ok: false, reason: 'no-key' }); return }
1308
+ if (balanceCache && Date.now() - balanceCache.at < 600_000) { send(200, balanceCache.data); return }
1309
+ if (BALANCE_HOST !== 'api.deepseek.com') { send(200, { ok: false, reason: 'bad-host' }); return }
1310
+ try {
1311
+ const res = await fetch(`https://${BALANCE_HOST}/user/balance`, {
1312
+ headers: { accept: 'application/json', authorization: `Bearer ${key}`, 'user-agent': 'dsh-cost' },
1313
+ signal: AbortSignal.timeout(8000),
1314
+ })
1315
+ if (!res.ok) throw new Error(`balance ${res.status}`)
1316
+ const data = await res.json()
1317
+ const info = Array.isArray(data.balance_infos) ? data.balance_infos[0] : null
1318
+ const out = info
1319
+ ? { 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() }
1320
+ : { ok: false, reason: 'empty' }
1321
+ balanceCache = { at: Date.now(), data: out }
1322
+ send(200, out)
1323
+ } catch (error) {
1324
+ const out = { ok: false, reason: String(error?.message ?? error).slice(0, 120) }
1325
+ balanceCache = { at: Date.now(), data: out }
1326
+ send(200, out)
1327
+ }
1328
+ return
1329
+ }
1330
+
1331
+ // 汇率手动刷新(自动刷新由每小时定时器执行)。
1332
+ if (path === '/fetch-rate' && req.method === 'POST') {
1333
+ try {
1334
+ const rate = await fetchExchangeRate()
1335
+ ledger.setExchangeRate(rate, Date.now())
1336
+ void report(`exchange rate ok: ${rate}`)
1337
+ send(200, { ok: true, rate })
1338
+ } catch (error) {
1339
+ void report(`exchange rate failed: ${String(error?.message ?? error).slice(0, 200)}`)
1340
+ send(502, { ok: false, error: String(error?.message ?? error).slice(0, 200) })
1341
+ }
1342
+ return
1343
+ }
1344
+
1345
+ send(404, { error: 'not-found' })
1346
+ },
1347
+ }), 'dsh-cost: /api/dsh-cost/*')
1348
+ })
1349
+
1350
+ function summarize(row) {
1351
+ return {
1352
+ input: row.input ?? 0, output: row.output ?? 0, cacheRead: row.cacheRead ?? 0,
1353
+ cacheWrite: row.cacheWrite ?? 0, reasoning: row.reasoning ?? 0,
1354
+ calls: row.calls ?? 0, cost: row.cost ?? 0,
1355
+ }
1356
+ }
1357
+
1358
+ function mergeDay(a, b) {
1359
+ const merged = { ...zeroBuckets(), calls: 0, cost: 0 }
1360
+ for (const part of [a, b]) {
1361
+ for (const key of Object.keys(zeroBuckets())) merged[key] += part[key] ?? 0
1362
+ merged.calls += part.calls ?? 0
1363
+ merged.cost += part.cost ?? 0
1364
+ }
1365
+ return merged
1366
+ }
1367
+
1368
+ function currentTier(entry, now, peak) {
1369
+ return tierFor(entry, now, peak)
1370
+ }
1371
+ }
1372
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
1373
+ })()
1374
+
1375
+ /* ==== module: picker(原 dsh-power-picker) ==== */
1376
+ const pickerModule = (() => {
1377
+ /* 由 scripts/build-suite.mjs 从 dsh-power-picker/index.js 机械生成;勿手改,重跑脚本再生成。 */
1378
+ /**
1379
+ * dsh-power-picker — Codex 风格的模型 + 推理强度选择器(DeepSeek Harness / dsh)
1380
+ *
1381
+ * 服务端半很薄:`enabled` 开关 + 诊断落盘。所有真正的逻辑都在客户端——
1382
+ * 以更低 priority 注册 `conversation.input.model` 槽位遮蔽内置选择器,
1383
+ * 数据走官方 modelDirectories 会话模型目录。服务端唯一的职责:
1384
+ * 1. 让「关闭插件」有一个不用卸载的开关(客户端注册遮蔽前先来确认);
1385
+ * 2. 接收客户端诊断事件,落到 ~/.dsh/power-picker-debug.log,
1386
+ * 座位异常时看这份文件就能定位卡在哪一环(服务端起没起、客户端
1387
+ * 模块加载没有、inject 是否失败、快照字段长什么样)。
1388
+ *
1389
+ * 已核对的 dsh API(0.2.1-rc.3 已装运行时反推 + dsh-reference 对照):
1390
+ * - 客户端 ctx.modelDirectories.directoryFor(sessionId) → 目录
1391
+ * - 组件拿到的是 directory.store(SnapshotStore):subscribe/getSnapshot
1392
+ * - 快照:{ current: {provider, model, reasoningEffort?} | null,
1393
+ * groups: [{ id, name, models: [{ id, name, reasoning? }] }],
1394
+ * retainedEffort?, pending, ... }
1395
+ * 档位阶梯在 groups[].models[].reasoning = { defaultEffort?, efforts: [{id, name}] }
1396
+ * - directory.select({ provider, model, reasoningEffort? }) 官方切换
1397
+ * - conversation.input.model 是 single 槽位,多注册按 priority 只渲染最低者
1398
+ */
1399
+
1400
+
1401
+
1402
+
1403
+
1404
+
1405
+
1406
+ const name = 'power-picker'
1407
+
1408
+ const DIAG_LOG = join(homedir(), '.dsh', 'power-picker-debug.log')
1409
+
1410
+ const Config = Schema.object({
1411
+ /** 关闭后客户端不再注册任何入口,模型选择恢复原样(无需卸载插件)。 */
1412
+ enabled: Schema.boolean().default(true).description('是否启用 Codex 风格选择器'),
1413
+ /**
1414
+ * Codex 实测形态:入口就是输入栏右下角「选择推理强度」按钮,面板从其
1415
+ * 上方弹出。默认 true;false 时输入栏不渲染入口(无任何入口)。
1416
+ */
1417
+ composerSeat: Schema.boolean().default(true).description('输入栏保留模型入口(Codex 同位:右下角,发送键旁)'),
1418
+ })
1419
+
1420
+ function apply(ctx, config) {
1421
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
1422
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
1423
+ const live = { ...config }
1424
+
1425
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
1426
+ const SETTINGS_KEYS = ['enabled', 'composerSeat']
1427
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-power-picker', 'settings.json')
1428
+ try {
1429
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
1430
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
1431
+ } catch { /* 无存档:全部跟随配置 */ }
1432
+ let settingsTimer = null
1433
+ const persistSettings = () => {
1434
+ if (settingsTimer) return
1435
+ settingsTimer = setTimeout(() => {
1436
+ settingsTimer = null
1437
+ try {
1438
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
1439
+ const out = {}
1440
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
1441
+ const tmp = `${SETTINGS_PATH}.tmp`
1442
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
1443
+ renameSync(tmp, SETTINGS_PATH)
1444
+ } catch { /* ignore */ }
1445
+ }, 400)
1446
+ }
1447
+
1448
+ const say = (message) => {
1449
+ try { console.info(`[dsh-power-picker] ${message}`) } catch { /* ignore */ }
1450
+ }
1451
+ const report = async (line) => {
1452
+ try {
1453
+ await mkdir(dirname(DIAG_LOG), { recursive: true })
1454
+ await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
1455
+ } catch { /* ignore */ }
1456
+ }
1457
+
1458
+ say(`server boot, enabled=${live.enabled !== false}`)
1459
+ void report(`server boot enabled=${live.enabled !== false}`)
1460
+
1461
+ ctx.inject(['webServer'], (webCtx) => {
1462
+ webCtx.effect(() => webCtx.webServer.register({
1463
+ kind: 'prefix',
1464
+ path: '/api/dsh-power-picker',
1465
+ handler: async (req, res) => {
1466
+ const send = (status, body) => {
1467
+ res.statusCode = status
1468
+ res.setHeader('content-type', 'application/json')
1469
+ res.end(JSON.stringify(body))
1470
+ }
1471
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-power-picker/, '')
1472
+ if (path === '/settings' && req.method === 'POST') {
1473
+ let raw = ''
1474
+ for await (const chunk of req) raw += chunk
1475
+ let body
1476
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
1477
+ if (body.enabled !== undefined) live.enabled = body.enabled === true
1478
+ if (typeof body.composerSeat === 'string') live.composerSeat = body.composerSeat.slice(0, 40)
1479
+ persistSettings()
1480
+ send(200, { ...live })
1481
+ return
1482
+ }
1483
+
1484
+
1485
+ // 客户端注册前读取设置:enabled 总开关 + composerSeat 入口位置。
1486
+ if (path === '/settings' && req.method === 'GET') {
1487
+ send(200, {
1488
+ enabled: live.enabled !== false,
1489
+ composerSeat: live.composerSeat === true,
1490
+ name,
1491
+ })
1492
+ return
1493
+ }
1494
+
1495
+ // 诊断上报:一行一条 JSON,出问题时看 ~/.dsh/power-picker-debug.log。
1496
+ if (path === '/diag' && req.method === 'POST') {
1497
+ let raw = ''
1498
+ for await (const chunk of req) raw += chunk
1499
+ await report(raw.slice(0, 2000))
1500
+ send(200, { ok: true })
1501
+ return
1502
+ }
1503
+
1504
+ send(404, { error: 'not-found' })
1505
+ },
1506
+ }), 'dsh-power-picker: /api/dsh-power-picker/*')
1507
+ })
1508
+ }
1509
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
1510
+ })()
1511
+
1512
+ /* ==== module: enhancer(原 dsh-prompt-enhancer) ==== */
1513
+ const enhancerModule = (() => {
1514
+ /* 由 scripts/build-suite.mjs 从 dsh-prompt-enhancer/index.js 机械生成;勿手改,重跑脚本再生成。 */
1515
+ /**
1516
+ * dsh-prompt-enhancer — 对话框提示词自动增强插件(DeepSeek Harness / dsh)
1517
+ *
1518
+ * 工作原理:
1519
+ * 挂载 `agent/pre-step` 瀑布流。每一步开始前,dsh 会把「即将进入本轮的
1520
+ * 用户消息批次」交给所有 pre-step 监听器裁决(PreStepDecision)。本插件
1521
+ * 在拿到默认裁决(kind === 'enter')后,把批次里的纯文本用户消息先发给
1522
+ * 一次轻量模型调用,重写成清晰、具体、自包含的提示词,再放行进入会话——
1523
+ * 即 WorkBuddy / ChatGPT 风格的「发送前提示词增强」。
1524
+ *
1525
+ * 安全设计(fail-open):
1526
+ * 增强调用失败、超时、找不到模型路由时,一律原样放行原始消息,
1527
+ * 绝不阻塞用户的正常对话。
1528
+ *
1529
+ * 已核对的 dsh API(deepseek-ai/deepseek-harness,developer preview):
1530
+ * - PreStepDecision = { kind: 'reject' } | { kind: 'enter', messages: UserMessage[], startsRequestSeries?: true }
1531
+ * - ctx.on('agent/pre-step', async ({ agent }, next) => PreStepDecision)
1532
+ * - ctx.llm.stream({ provider, model, messages }) → AsyncIterable<StreamChunk>
1533
+ * (文本块为 { type: 'text-delta', text };终帧为 { type: 'finish', reason })
1534
+ * - createUserMessage({ content, source }) 来自 '@deepseek-ai/dsh-llm'
1535
+ * - defineTool(...) 来自 '@deepseek-ai/dsh-tools';服务名 'llm' / 'tools'
1536
+ */
1537
+
1538
+
1539
+
1540
+
1541
+
1542
+
1543
+
1544
+
1545
+
1546
+
1547
+ /** 排查日志:客户端把拦截决策写到这里,出问题时直接看文件。 */
1548
+ const DEBUG_LOG = join(homedir(), '.dsh', 'prompt-enhancer-debug.log')
1549
+
1550
+ const name = 'prompt-enhancer'
1551
+
1552
+ const moduleInject = ['llm', 'tools']
1553
+
1554
+ const Config = Schema.object({
1555
+ /** 总开关:关闭后插件完全不参与 pre-step,也不注册工具行为变化。 */
1556
+ enabled: Schema.boolean().default(true).description('是否启用自动提示词增强'),
1557
+ /** auto:每条人类消息发送前自动增强;manual:只注册 enhance_prompt 工具,不自动改写。 */
1558
+ mode: Schema.union(['auto', 'manual']).default('auto').description('auto=自动增强, manual=仅工具'),
1559
+ /** 默认增强强度;前端选择器会覆盖它。off 表示默认不增强。 */
1560
+ level: Schema.union(['off', 'low', 'high', 'max']).default('high').description('默认增强强度:off/low/high/max'),
1561
+ /**
1562
+ * pre-step 兜底增强。默认关闭:前端拦截路径已经负责增强,服务端再动一次
1563
+ * 会让「自动增强关闭」失效(用户看不到,消息却被改了)。
1564
+ * 只有当前端拦截失效、又确实想要兜底时才手动打开。
1565
+ */
1566
+ preStepAuto: Schema.boolean().default(false).description('是否在 pre-step 兜底自动增强(默认关闭,前端已负责增强)'),
1567
+ /** 留空则跟随当前会话 Agent 的 provider 路由。 */
1568
+ provider: Schema.string().description('增强调用使用的 provider(留空跟随会话)'),
1569
+ /** 留空则跟随当前会话 Agent 的 model。建议配一个快模型降低延迟。 */
1570
+ model: Schema.string().description('增强调用使用的模型(留空跟随会话,建议快模型)'),
1571
+ /** 单次增强调用的超时时间,超时即放弃增强、原样放行。 */
1572
+ timeoutMs: Schema.number().default(20000).description('增强调用超时(毫秒)'),
1573
+ /** 短于该长度(字符)的输入不做增强(如「继续」「ok」)。 */
1574
+ minPromptLength: Schema.number().default(4).description('触发增强的最小输入长度(字符)'),
1575
+ /** 超过该长度的输入视为已经是完整提示词,不再增强。 */
1576
+ maxInputChars: Schema.number().default(4000).description('超过此长度不再增强(字符)'),
1577
+ /** 增强后提示词的语言:auto=跟随原始输入。 */
1578
+ language: Schema.union(['auto', 'zh', 'en']).default('auto').description('输出语言'),
1579
+ /** 追加到内置改写规则之后的自定义风格要求(可为空)。 */
1580
+ styleHint: Schema.string().default('').description('自定义风格要求(追加到改写规则后)'),
1581
+ /** 增强时把最近一轮会话拼进改写参考:代词/指代(它、这个)能被上下文消解。 */
1582
+ useContext: Schema.boolean().default(false).description('参考最近会话上下文(输入框按钮生效)'),
1583
+ })
1584
+
1585
+ /* ------------------------------------------------------------------ */
1586
+ /* 增强强度分级 */
1587
+ /* ------------------------------------------------------------------ */
1588
+
1589
+ /**
1590
+ * 四档强度的差异不在「想得多深」,而在「改多少、补多少、要不要改结构」。
1591
+ * 每档给独立的规则、token 上限和长度上限,避免 Max 档把一句话膨胀成论文。
1592
+ */
1593
+ const LEVELS = {
1594
+ off: null,
1595
+ low: {
1596
+ label: 'Low',
1597
+ maxTokens: 900,
1598
+ maxRatio: 1.4,
1599
+ rules: [
1600
+ 'INTENSITY: LOW (minimal edit).',
1601
+ 'Change only what is necessary to remove ambiguity. Keep the original wording, tone and length as much as possible.',
1602
+ 'Do: fix vague pronouns, unclear verbs, missing objects, redundancy, obvious typos. Split run-on sentences.',
1603
+ 'Do NOT: add new sections, add headings, invent requirements, add constraints the user did not imply, expand into a spec.',
1604
+ 'Target length: at most 1.4x the original. Shorter is better.',
1605
+ 'If the input is already clear, return it almost unchanged.',
1606
+ ],
1607
+ },
1608
+ high: {
1609
+ label: 'High',
1610
+ maxTokens: 1800,
1611
+ maxRatio: 2.2,
1612
+ rules: [
1613
+ 'INTENSITY: HIGH (structured completion).',
1614
+ 'Rewrite into a clear, self-contained, directly executable request. Add the context, constraints and output expectation that a competent assistant would need.',
1615
+ 'Do: state the goal explicitly, make implicit requirements explicit, specify the expected output form, keep the user\'s intent and scope.',
1616
+ '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: ...)".',
1617
+ '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.',
1618
+ 'Do NOT: turn the request into a tutorial, a plan, or an answer. It must stay a request.',
1619
+ 'Prefer flowing paragraphs over markdown headings unless the task is clearly multi-part.',
1620
+ '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.',
1621
+ ],
1622
+ },
1623
+ max: {
1624
+ label: 'Max',
1625
+ maxTokens: 3200,
1626
+ maxRatio: 3.5,
1627
+ rules: [
1628
+ 'INTENSITY: MAX (first-principles reconstruction).',
1629
+ 'First reason about what outcome the user actually wants, then rebuild the request from that outcome rather than patching the wording.',
1630
+ '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.',
1631
+ 'Do: point out missing critical information as conditional requirements inside the prompt, never as a question back to the user.',
1632
+ 'Do NOT: invent facts. Every assumption must be labeled.',
1633
+ 'Do NOT: pad with generic prompt-engineering boilerplate ("You are a world-class expert..."), role-play scaffolding, or restated instructions.',
1634
+ 'FORMAT: plain text only, because the output lands in a plain-text composer. No "#" headings, no "**bold**", no "-" or "*" bullets, no code fences.',
1635
+ 'Structure with short labeled lines instead: 目标:/ 背景:/ 约束:/ 产出:/ 验收: (or Goal:/Context:/Constraints:/Deliverables: when writing English). One requirement per line, sub-points indented with two spaces.',
1636
+ 'Target length: 2x to 3.5x the original. Never exceed 3500 characters.',
1637
+ ],
1638
+ },
1639
+ }
1640
+
1641
+ /* ------------------------------------------------------------------ */
1642
+ /* 内置改写元提示词 */
1643
+ /* ------------------------------------------------------------------ */
1644
+
1645
+ // 基础规则来自 WorkBuddy 客户端内置的 enhance-prompt 代理(逆向自 app.asar,
1646
+ // agentName = 'enhance-prompt'),改写为通用 agent 场景,并按强度分级。
1647
+ const BASE_RULES = [
1648
+ '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.',
1649
+ '',
1650
+ 'CORE PRINCIPLES:',
1651
+ '- Preserve the user\'s original intent and scope. You are rewriting a request, not answering it.',
1652
+ '- Never fabricate specifics the user did not provide: no invented tech stack, file paths, numbers, names, or requirements.',
1653
+ '- Keep the output in the exact same language as the input. If the input mixes languages, keep the mix.',
1654
+ '- Remove redundancy, resolve ambiguity, make the ask actionable.',
1655
+ '- Do NOT explain HOW to do the task, do NOT answer the question, do NOT add a preamble like "Here is the enhanced prompt".',
1656
+ '- Do NOT request guides, tutorials, or code snippets unless the user asked for them.',
1657
+ '- Do NOT suggest specific technologies unless they appear in the input.',
1658
+ '',
1659
+ 'MISSING INFORMATION — the most important rule:',
1660
+ '- 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.',
1661
+ '- 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, ...").',
1662
+ '- Prefer filling in generally applicable constraints (runnable output, dependencies, error handling, acceptance criteria) over interrogating the user about specifics.',
1663
+ '- The rewritten prompt must be executable as-is. Whoever reads it should be able to start working without replying first.',
1664
+ '',
1665
+ 'OUTPUT: only the rewritten prompt text. No commentary, no code fences, no quotes around it.',
1666
+ ].join('\n')
1667
+
1668
+ function buildMetaPrompt(raw, config, level, contextTurn) {
1669
+ const spec = LEVELS[level] ?? LEVELS.high
1670
+ const lines = [BASE_RULES, '', spec.rules.join('\n')]
1671
+ // 参考上下文:只用于消解指代、贴合讨论,绝不据此发明新需求。
1672
+ if (config.useContext && contextTurn?.userText && contextTurn?.assistantText) {
1673
+ lines.push(
1674
+ '',
1675
+ '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:',
1676
+ '<context>',
1677
+ `User: ${contextTurn.userText.slice(-1200)}`,
1678
+ `Assistant: ${contextTurn.assistantText.slice(-1200)}`,
1679
+ '</context>',
1680
+ )
1681
+ }
1682
+ // WorkBuddy 的提示词已内置「跟随输入语言」;这两个配置只用于强制覆盖。
1683
+ if (config.language === 'zh') lines.push('', 'OVERRIDE: write the rewritten prompt in Simplified Chinese.')
1684
+ else if (config.language === 'en') lines.push('', 'OVERRIDE: write the rewritten prompt in English.')
1685
+ if (config.styleHint && config.styleHint.trim().length > 0) {
1686
+ lines.push('', `Additional style requirements from the host config: ${config.styleHint.trim()}`)
1687
+ }
1688
+ // 用标签包住原文:即使原文本身长得像指令,也不会和改写规则混在一起。
1689
+ lines.push('', 'Raw input:', '<original_prompt>', raw, '</original_prompt>')
1690
+ return lines.join('\n')
1691
+ }
1692
+
1693
+ /* ------------------------------------------------------------------ */
1694
+ /* 最近一轮会话捕获(参考上下文用;深度标记模式照搬 followup 模块) */
1695
+ /* ------------------------------------------------------------------ */
1696
+
1697
+ const llmDepth = new AsyncLocalStorage()
1698
+ const turnCache = new Map()
1699
+ const TURN_CACHE_MAX = 24
1700
+ const TURN_CACHE_TTL_MS = 10 * 60_000
1701
+
1702
+ /** options.messages 里最后一条用户消息的纯文本。 */
1703
+ function lastUserTextOf(messages) {
1704
+ if (!Array.isArray(messages)) return ''
1705
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1706
+ const m = messages[i]
1707
+ if (m?.role !== 'user') continue
1708
+ const blocks = Array.isArray(m.content) ? m.content : []
1709
+ const joined = blocks
1710
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
1711
+ .map((b) => b.text)
1712
+ .join('')
1713
+ if (joined.trim()) return joined.trim()
1714
+ }
1715
+ return ''
1716
+ }
1717
+
1718
+ function cachedTurnOf(sessionId) {
1719
+ const hit = turnCache.get(sessionId)
1720
+ if (!hit) return null
1721
+ if (Date.now() - hit.at > TURN_CACHE_TTL_MS) { turnCache.delete(sessionId); return null }
1722
+ return hit
1723
+ }
1724
+
1725
+ function rememberTurn(sessionId, userText, assistantText) {
1726
+ if (!sessionId || !userText || !assistantText || assistantText.trim().length === 0) return
1727
+ const prev = turnCache.get(sessionId)
1728
+ // 同一轮的多步工具链 / 重试续流 userText 相同:助手文本追加累积。
1729
+ const merged = prev && prev.userText === userText
1730
+ ? `${prev.assistantText}\n\n${assistantText}`
1731
+ : assistantText
1732
+ turnCache.set(sessionId, { userText, assistantText: merged.slice(-6000), at: Date.now() })
1733
+ if (turnCache.size > TURN_CACHE_MAX) {
1734
+ let oldestKey = null
1735
+ let oldestAt = Infinity
1736
+ for (const [key, value] of turnCache) {
1737
+ if (value.at < oldestAt) { oldestAt = value.at; oldestKey = key }
1738
+ }
1739
+ if (oldestKey !== null) turnCache.delete(oldestKey)
1740
+ }
1741
+ }
1742
+
1743
+ /* ------------------------------------------------------------------ */
1744
+ /* 工具函数 */
1745
+ /* ------------------------------------------------------------------ */
1746
+
1747
+ /** 提取消息里的纯文本内容;非「聊天框用户消息 / 单个文本块」返回 null(不处理)。 */
1748
+ function singleTextOf(message) {
1749
+ if (!message || message.role !== 'user') return null
1750
+ // dsh 内部还会产生 role 为 user、source.kind 为 goal / session-reference /
1751
+ // notice 等的消息;它们不是用户在对话框里输入的内容,绝不能改写。
1752
+ if (message.source?.kind !== 'user') return null
1753
+ if (!Array.isArray(message.content)) return null
1754
+ if (message.content.length !== 1) return null
1755
+ const block = message.content[0]
1756
+ return block && block.type === 'text' && typeof block.text === 'string' ? block.text : null
1757
+ }
1758
+
1759
+ /** 判断是否应该跳过增强。 */
1760
+ function shouldSkip(text, config) {
1761
+ if (!text) return true
1762
+ const trimmed = text.trim()
1763
+ if (trimmed.length === 0) return true
1764
+ // 斜杠命令(/plan、/goal 等)绝不改写
1765
+ if (trimmed.startsWith('/')) return true
1766
+ if (trimmed.length < config.minPromptLength) return true
1767
+ if (trimmed.length > config.maxInputChars) return true
1768
+ return false
1769
+ }
1770
+
1771
+ /** 去掉模型输出里常见的包裹物:代码围栏、引号、前缀说明行。 */
1772
+ function cleanModelOutput(text) {
1773
+ let out = String(text ?? '').trim()
1774
+ // 去掉成对代码围栏 ```...```(含语言标注)
1775
+ const fence = out.match(/^```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n?```$/)
1776
+ if (fence) out = fence[1].trim()
1777
+ // 去掉「增强后提示词:」「Enhanced prompt:」之类前缀行
1778
+ out = out.replace(/^(?:enhanced prompt|提示词增强|增强后的提示词|改写后(?:的提示词)?)\s*[::]\s*/i, '')
1779
+ // 去掉最外层成对引号
1780
+ const q = out.match(/^"([\s\S]*)"$|^“([\s\S]*)”$/)
1781
+ if (q) out = (q[1] ?? q[2]).trim()
1782
+ // 兜底去 markdown:输入框是纯文本,留着 # 和 ** 只会变成噪音
1783
+ out = out
1784
+ .replace(/^#{1,6}\s+/gm, '')
1785
+ .replace(/\*\*(.+?)\*\*/g, '$1')
1786
+ .replace(/^(\s*)[-*]\s+/gm, '$1· ')
1787
+ return out
1788
+ }
1789
+
1790
+ /** 调一次 LLM 做改写;任何失败都抛错,由上层兜底放行。 */
1791
+ async function rewriteWithLlm(ctx, route, raw, config, level, outerSignal, contextTurn) {
1792
+ const spec = LEVELS[level] ?? LEVELS.high
1793
+ // 超时与外部取消(用户中断 pre-step)收敛到同一个内部 signal,
1794
+ // 底层流会被及时中断,不会在后台继续烧 token。
1795
+ const controller = new AbortController()
1796
+ const forwardAbort = () => controller.abort(outerSignal.reason)
1797
+ if (outerSignal?.aborted) forwardAbort()
1798
+ else outerSignal?.addEventListener('abort', forwardAbort, { once: true })
1799
+ const timer = setTimeout(
1800
+ () => controller.abort(new Error('prompt-enhancer timeout')),
1801
+ config.timeoutMs,
1802
+ )
1803
+
1804
+ const aborted = new Promise((_, reject) => {
1805
+ const fail = () => reject(controller.signal.reason ?? new Error('prompt-enhancer aborted'))
1806
+ // 外层 signal 可能一开始就已 abort:先检查,再挂监听。
1807
+ if (controller.signal.aborted) fail()
1808
+ else controller.signal.addEventListener('abort', fail, { once: true })
1809
+ })
1810
+ // 成功路径下 aborted 不会被消费,挂空 catch 避免 unhandled rejection。
1811
+ void aborted.catch(() => {})
1812
+
1813
+ // 给 dsh-cost 的计量通道打来源标记:增强调用单独归类,费用弹卡里才能
1814
+ // 把「增强」和「对话」分开算。5 秒窗口足够覆盖一次调用的启动。
1815
+ globalThis.__dshPluginCall = { source: 'enhance', at: Date.now() }
1816
+
1817
+ const iterator = ctx.llm.stream({
1818
+ provider: route.provider,
1819
+ model: route.model,
1820
+ messages: [{ role: 'user', content: [{ type: 'text', text: buildMetaPrompt(raw, config, level, contextTurn) }] }],
1821
+ maxTokens: spec.maxTokens,
1822
+ signal: controller.signal,
1823
+ })[Symbol.asyncIterator]()
1824
+
1825
+ async function collect() {
1826
+ let text = ''
1827
+ for (;;) {
1828
+ // 深度标记内拉取:改写调用自己的流不进会话轮缓存。
1829
+ const result = await llmDepth.run(1, () => iterator.next())
1830
+ const { done, value } = result
1831
+ if (done) break
1832
+ if (value.type === 'text-delta') text += value.text
1833
+ else if (value.type === 'finish' && value.reason && (value.reason.kind === 'error' || value.reason.kind === 'aborted')) {
1834
+ throw new Error(`prompt-enhancer llm ${value.reason.kind}: ${value.reason.failure?.message ?? 'unknown'}`)
1835
+ }
1836
+ }
1837
+ return text
1838
+ }
1839
+
1840
+ try {
1841
+ const collected = await Promise.race([collect(), aborted])
1842
+ const cleaned = cleanModelOutput(collected)
1843
+ if (!cleaned) throw new Error('prompt-enhancer got empty output')
1844
+ // 失控保护:模型把一句话膨胀成小作文时判失败,回退原文而不是塞给用户。
1845
+ // 短输入天然膨胀率高(12 字补成 300 字是合理的),所以保底 600 字;
1846
+ // 长输入按倍数算,但封顶 4000 字,避免 4000 字输入算出 2 万字上限。
1847
+ const limit = Math.min(Math.max(Math.round(raw.length * spec.maxRatio * 1.6), 600), 4000)
1848
+ if (cleaned.length > limit) {
1849
+ throw new Error(`prompt-enhancer output runaway: ${cleaned.length} chars > ${limit}`)
1850
+ }
1851
+ return cleaned
1852
+ } catch (err) {
1853
+ // 中断底层流,避免失败后还在后台烧 token。
1854
+ // 不 await:底层若卡死,等待 return() 会拖住 fail-open。
1855
+ try {
1856
+ const closing = iterator.return?.()
1857
+ if (closing && typeof closing.catch === 'function') closing.catch(() => {})
1858
+ } catch { /* ignore */ }
1859
+ throw err
1860
+ } finally {
1861
+ clearTimeout(timer)
1862
+ outerSignal?.removeEventListener('abort', forwardAbort)
1863
+ }
1864
+ }
1865
+
1866
+ /* ------------------------------------------------------------------ */
1867
+ /* 插件入口 */
1868
+ /* ------------------------------------------------------------------ */
1869
+
1870
+ function apply(ctx, config) {
1871
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
1872
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
1873
+ const live = { ...config }
1874
+
1875
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。必须先回放
1876
+ // 再初始化 currentLevel,重启后上次的强度档位才生效。
1877
+ const SETTINGS_KEYS = ['level', 'mode', 'preStepAuto', 'language', 'styleHint', 'timeoutMs', 'minPromptLength', 'maxInputChars', 'useContext']
1878
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'prompt-enhancer', 'settings.json')
1879
+ try {
1880
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
1881
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
1882
+ } catch { /* 无存档:全部跟随配置 */ }
1883
+ let settingsTimer = null
1884
+ const persistSettings = () => {
1885
+ if (settingsTimer) return
1886
+ settingsTimer = setTimeout(() => {
1887
+ settingsTimer = null
1888
+ try {
1889
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
1890
+ const out = {}
1891
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
1892
+ const tmp = `${SETTINGS_PATH}.tmp`
1893
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
1894
+ renameSync(tmp, SETTINGS_PATH)
1895
+ } catch { /* ignore */ }
1896
+ }, 400)
1897
+ }
1898
+
1899
+ const log = (level, msg) => console[level === 'error' ? 'error' : 'log'](`[dsh-prompt-enhancer] ${msg}`)
1900
+
1901
+ /** 最近一次见到的会话路由,供 enhance_prompt 工具和 web 端点在没有显式配置时复用。 */
1902
+ let lastRoute = null
1903
+
1904
+ /**
1905
+ * 运行时增强强度。前端选择器通过 /api/prompt-enhancer/settings 改这里,
1906
+ * 让 pre-step 与手动按钮共用同一档位。初始值来自配置。
1907
+ */
1908
+ let currentLevel = Object.prototype.hasOwnProperty.call(LEVELS, live.level) ? live.level : 'high'
1909
+
1910
+ /**
1911
+ * 前端刚增强过的文本指纹。发送拦截路径增强完再 submit 时,pre-step 会再看到
1912
+ * 这条消息;用它去重,避免同一句话被改两遍。两分钟自动过期。
1913
+ */
1914
+ const recentEnhanced = new Map()
1915
+ const DEDUPE_TTL = 120_000
1916
+
1917
+ function fingerprint(text) {
1918
+ let h = 0
1919
+ for (let i = 0; i < text.length; i++) {
1920
+ h = (h * 31 + text.charCodeAt(i)) | 0
1921
+ }
1922
+ return `${text.length}:${h}`
1923
+ }
1924
+
1925
+ function rememberEnhanced(text) {
1926
+ const now = Date.now()
1927
+ for (const [key, ts] of recentEnhanced) {
1928
+ if (now - ts > DEDUPE_TTL) recentEnhanced.delete(key)
1929
+ }
1930
+ recentEnhanced.set(fingerprint(text), now)
1931
+ }
1932
+
1933
+ function wasJustEnhanced(text) {
1934
+ const ts = recentEnhanced.get(fingerprint(text))
1935
+ return ts !== undefined && Date.now() - ts < DEDUPE_TTL
1936
+ }
1937
+
1938
+ /**
1939
+ * 全局默认模型选择(composer 里的模型选择器改的就是它),
1940
+ * 让输入框按钮在「会话还没创建、没有 agent」时也能拿到路由。
1941
+ */
1942
+ function defaultRoute() {
1943
+ const defaults = ctx.get('agentDefaultModel')
1944
+ const selection = defaults?.currentSelection?.()
1945
+ return selection?.provider && selection?.model
1946
+ ? { provider: selection.provider, model: selection.model }
1947
+ : null
1948
+ }
1949
+
1950
+ /** 配置里显式指定的路由。 */
1951
+ function configRoute() {
1952
+ return live.provider && live.model ? { provider: live.provider, model: live.model } : null
1953
+ }
1954
+
1955
+ function resolveRoute(agent) {
1956
+ const provider = agent?.options?.provider
1957
+ const model = agent?.options?.model
1958
+ return configRoute()
1959
+ ?? (provider && model ? { provider, model } : null)
1960
+ ?? defaultRoute()
1961
+ }
1962
+
1963
+ /** web 端点没有 agent:配置 > 最近一次会话路由 > 全局默认选择。 */
1964
+ function endpointRoute() {
1965
+ return configRoute() ?? lastRoute ?? defaultRoute()
1966
+ }
1967
+
1968
+ // 会话一建立就记住路由,这样输入框按钮在「还没发过消息」时也能工作。
1969
+ ctx.on('agent/created', ({ agent }) => {
1970
+ const route = resolveRoute(agent)
1971
+ if (route) lastRoute = route
1972
+ })
1973
+
1974
+ /** 对单条消息做增强;不需要增强时原样返回同一个对象。 */
1975
+ async function enhanceOne(message, agent, signal) {
1976
+ const raw = singleTextOf(message)
1977
+ if (raw === null || shouldSkip(raw, config)) return message
1978
+ // 前端发送拦截路径已经增强过,这里放行,避免二次改写。
1979
+ if (wasJustEnhanced(raw.trim())) return message
1980
+ const level = currentLevel
1981
+ if (!level || level === 'off') return message
1982
+ const route = resolveRoute(agent) ?? lastRoute
1983
+ if (!route) return message
1984
+ if (!llmRef) return message // llm 服务尚未就绪(inject 未解析完):fail-open 放行原文
1985
+ const enhanced = await rewriteWithLlm({ llm: llmRef }, route, raw.trim(), config, level, signal)
1986
+ log('log', `enhanced[${level}] ${raw.trim().length} -> ${enhanced.length} chars`)
1987
+ // 用官方工厂重建消息:获得全新稳定 id,source 沿用原消息
1988
+ return createUserMessage({
1989
+ content: [{ type: 'text', text: enhanced }],
1990
+ source: message.source,
1991
+ })
1992
+ }
1993
+
1994
+ /** 批量增强;没有任何改动时原样返回原数组。 */
1995
+ async function enhanceBatch(messages, agent, signal) {
1996
+ const out = []
1997
+ let changed = false
1998
+ for (const message of messages) {
1999
+ let next = message
2000
+ try {
2001
+ next = await enhanceOne(message, agent, signal)
2002
+ } catch (err) {
2003
+ // fail-open:增强失败绝不阻塞对话
2004
+ log('error', `enhance failed, pass through original: ${err?.message ?? err}`)
2005
+ next = message
2006
+ }
2007
+ if (next !== message) changed = true
2008
+ out.push(next)
2009
+ }
2010
+ return changed ? out : messages
2011
+ }
2012
+
2013
+ // pre-step 自动增强用的 llm 引用:在下方嵌套 inject 作用域里捕获。
2014
+ // 套件宿主不按插件级 inject 并集给顶层作用域授权属性访问——真机 0.3.0:
2015
+ // suite 联合声明了 tools,apply 里 ctx.tools 仍报 cannot get property
2016
+ // without inject;0.3.2 同款实锤蔓延到请求期 ctx.llm(/rewrite 全挂)。
2017
+ // 凡要用的服务,必须写进所在嵌套作用域自己的 inject 数组。
2018
+ let llmRef = null
2019
+
2020
+ if (live.enabled) {
2021
+ // 最近一轮会话捕获:输入框按钮带 sessionId 来取(参考上下文开关)。
2022
+ // 自己的改写调用经 llmDepth 标记透传,不会被误当成对话轮。
2023
+ ctx.on('llm/stream', (options, next) => {
2024
+ if (llmDepth.getStore() !== undefined) return next()
2025
+ const downstream = next()
2026
+ const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
2027
+ const userText = lastUserTextOf(options?.messages)
2028
+ let assistantText = ''
2029
+ let downstreamIter = null
2030
+ let completed = false
2031
+ return (async function* peObserve() {
2032
+ try {
2033
+ downstreamIter = downstream[Symbol.asyncIterator]()
2034
+ for (;;) {
2035
+ const result = await llmDepth.run(1, () => downstreamIter.next())
2036
+ if (result.done) break
2037
+ const chunk = result.value
2038
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
2039
+ assistantText += chunk.text
2040
+ if (assistantText.length > 8000) assistantText = assistantText.slice(-4000)
2041
+ }
2042
+ yield chunk
2043
+ }
2044
+ completed = true
2045
+ } finally {
2046
+ if (!completed) {
2047
+ try { await downstreamIter?.return?.() } catch { /* ignore */ }
2048
+ }
2049
+ try { rememberTurn(sessionId, userText, assistantText) } catch { /* 缓存失败不影响主对话流 */ }
2050
+ }
2051
+ })()
2052
+ }, { global: true })
2053
+
2054
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
2055
+ const decision = await next()
2056
+ if (decision.kind !== 'enter') return decision
2057
+ const route = resolveRoute(agent)
2058
+ if (route) lastRoute = route
2059
+ if (live.mode !== 'auto') return decision
2060
+ // 前端拦截发送并自行增强时,这里不再二次增强(否则同一句话被改两遍)。
2061
+ if (!live.preStepAuto) return decision
2062
+ const messages = await enhanceBatch(decision.messages, agent, signal)
2063
+ if (messages === decision.messages) return decision
2064
+ // spread 保留未来新增的 decision 字段
2065
+ return { ...decision, messages }
2066
+ })
2067
+
2068
+ // 注册模型可调用的工具:agent 自己也能主动增强某段提示词。
2069
+ // 显式嵌套声明 ['llm','tools'](原因见上方 llmRef 注释);顺手捕获
2070
+ // llm 引用,execute 里也不能用外层 ctx(同款报错,见 0.3.2)。
2071
+ ctx.inject(['llm', 'tools'], (scope) => {
2072
+ llmRef = scope.llm
2073
+ scope.tools.register(defineTool({
2074
+ name: 'enhance_prompt',
2075
+ description: [
2076
+ 'Rewrite a rough user prompt into a clear, specific, self-contained prompt before using it.',
2077
+ 'Use it when the user asks to improve/expand their prompt, or before re-running a vague earlier request.',
2078
+ ].join(' '),
2079
+ parameters: {
2080
+ prompt: { type: 'string', required: true, description: 'The raw prompt text to enhance' },
2081
+ },
2082
+ output: {
2083
+ schema: { type: 'string' },
2084
+ render: (_args, value) => [{ type: 'text', text: value }],
2085
+ },
2086
+ async execute(args, exec) {
2087
+ const route = endpointRoute()
2088
+ if (!route) throw new Error('No model route available: set provider/model in the plugin config.')
2089
+ const level = currentLevel === 'off' ? 'high' : currentLevel
2090
+ const enhanced = await rewriteWithLlm(scope, route, String(args.prompt ?? '').trim(), config, level, exec?.signal)
2091
+ return enhanced
2092
+ },
2093
+ }))
2094
+ })
2095
+
2096
+ log('log', `loaded (mode=${live.mode}, timeout=${live.timeoutMs}ms)`)
2097
+ } else {
2098
+ log('log', 'loaded but disabled by config')
2099
+ }
2100
+
2101
+ /* ------------------------------------------------------------------ */
2102
+ /* web 端「增强提示词」端点(只在有 webServer 的宿主里注册) */
2103
+ /* ------------------------------------------------------------------ */
2104
+
2105
+ // 可选注入:CLI / TUI 等没有 webServer 的宿主照常加载,只是没有输入框按钮。
2106
+ // webServer 作用域里请求期要调 llm:必须写进这个嵌套作用域自己的 inject
2107
+ // (真机 0.3.1:/rewrite 里 rewriteWithLlm(ctx) 报 cannot get property
2108
+ // "llm" without inject——顶层并集不授权属性访问,见上方 llmRef 注释)。
2109
+ ctx.inject(['webServer', 'llm'], (webCtx) => {
2110
+ webCtx.effect(() => webCtx.webServer.register({
2111
+ kind: 'prefix',
2112
+ path: '/api/prompt-enhancer',
2113
+ handler: async (req, res) => {
2114
+ const send = (status, body) => {
2115
+ res.statusCode = status
2116
+ res.setHeader('content-type', 'application/json')
2117
+ res.end(JSON.stringify(body))
2118
+ }
2119
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/prompt-enhancer/, '')
2120
+
2121
+ // 前端初始化:读当前档位,避免刷新后选择器回落到默认值。
2122
+ if (path === '/settings' && req.method === 'GET') {
2123
+ send(200, { ...live, level: currentLevel, levels: Object.keys(LEVELS) })
2124
+ return
2125
+ }
2126
+
2127
+ // 前端改档位:写进运行时状态,让 pre-step 与按钮共用同一档。
2128
+ if (path === '/settings' && req.method === 'POST') {
2129
+ let raw = ''
2130
+ for await (const chunk of req) raw += chunk
2131
+ let body
2132
+ try {
2133
+ body = JSON.parse(raw)
2134
+ } catch {
2135
+ send(400, { error: 'invalid-json' })
2136
+ return
2137
+ }
2138
+ // level 是运行时主开关,必须合法;其余字段宽松校验后写入 live。
2139
+ if (body?.level !== undefined) {
2140
+ const next = body.level
2141
+ if (typeof next !== 'string' || !(next in LEVELS)) {
2142
+ send(400, { error: `level must be one of ${Object.keys(LEVELS).join('/')}` })
2143
+ return
2144
+ }
2145
+ currentLevel = next
2146
+ live.level = next
2147
+ log('log', `level -> ${next}`)
2148
+ }
2149
+ if (body?.mode === 'auto' || body?.mode === 'manual') live.mode = body.mode
2150
+ if (body?.preStepAuto !== undefined) live.preStepAuto = body.preStepAuto === true
2151
+ if (body?.useContext !== undefined) live.useContext = body.useContext === true
2152
+ if (body?.language === 'auto' || body?.language === 'zh' || body?.language === 'en') live.language = body.language
2153
+ if (typeof body?.styleHint === 'string') live.styleHint = body.styleHint.slice(0, 500)
2154
+ for (const [key, min, max] of [['timeoutMs', 1000, 120000], ['minPromptLength', 1, 200], ['maxInputChars', 100, 100000]]) {
2155
+ if (body?.[key] === undefined) continue
2156
+ const value = Number(body[key])
2157
+ if (Number.isFinite(value)) live[key] = Math.min(max, Math.max(min, Math.round(value)))
2158
+ }
2159
+ persistSettings()
2160
+ send(200, { ...live, level: currentLevel })
2161
+ return
2162
+ }
2163
+
2164
+ // 前端把拦截决策写进 ~/.dsh/prompt-enhancer-debug.log,排查用。
2165
+ if (path === '/debug' && req.method === 'POST') {
2166
+ let raw = ''
2167
+ for await (const chunk of req) raw += chunk
2168
+ try {
2169
+ await mkdir(dirname(DEBUG_LOG), { recursive: true })
2170
+ await appendFile(DEBUG_LOG, `${new Date().toISOString()} ${raw}\n`, 'utf8')
2171
+ } catch (err) {
2172
+ log('error', `debug log failed: ${err?.message ?? err}`)
2173
+ }
2174
+ send(200, { ok: true })
2175
+ return
2176
+ }
2177
+
2178
+ if (path !== '/rewrite') {
2179
+ send(404, { error: 'not-found' })
2180
+ return
2181
+ }
2182
+ if (req.method !== 'POST') {
2183
+ res.setHeader('allow', 'POST')
2184
+ send(405, { error: 'method-not-allowed' })
2185
+ return
2186
+ }
2187
+ let raw = ''
2188
+ for await (const chunk of req) raw += chunk
2189
+ let body
2190
+ try {
2191
+ body = JSON.parse(raw)
2192
+ } catch {
2193
+ send(400, { error: 'invalid-json' })
2194
+ return
2195
+ }
2196
+ const text = typeof body?.text === 'string' ? body.text.trim() : ''
2197
+ if (text.length === 0) {
2198
+ send(400, { error: 'text must be a non-empty string' })
2199
+ return
2200
+ }
2201
+ if (text.length > live.maxInputChars) {
2202
+ send(413, { error: `text exceeds ${live.maxInputChars} chars` })
2203
+ return
2204
+ }
2205
+ // 单次请求可以覆盖档位(发送拦截路径会带上当前选择),否则用运行时档位。
2206
+ const requested = typeof body?.level === 'string' && body.level in LEVELS ? body.level : currentLevel
2207
+ const level = requested === 'off' ? null : requested
2208
+ if (!level) {
2209
+ send(200, { text, skipped: 'level-off' })
2210
+ return
2211
+ }
2212
+ const route = endpointRoute()
2213
+ if (!route) {
2214
+ send(409, { error: 'no model route available; set provider/model in the plugin config' })
2215
+ return
2216
+ }
2217
+ // 参考上下文开关:前端带 sessionId,服务端取该会话最近一轮。
2218
+ const contextTurn = live.useContext && typeof body?.sessionId === 'string'
2219
+ ? cachedTurnOf(body.sessionId)
2220
+ : null
2221
+ try {
2222
+ const enhanced = await rewriteWithLlm(webCtx, route, text, config, level, undefined, contextTurn)
2223
+ // 记下指纹:这条文本随后经 submit 进入 pre-step 时不再二次增强。
2224
+ rememberEnhanced(enhanced)
2225
+ send(200, { text: enhanced, level })
2226
+ } catch (err) {
2227
+ log('error', `composer enhance failed: ${err?.message ?? err}`)
2228
+ // 落盘:服务端错误只进 console 等于没有(排障教训)。
2229
+ try {
2230
+ await mkdir(dirname(DEBUG_LOG), { recursive: true })
2231
+ await appendFile(DEBUG_LOG, `${new Date().toISOString()} ${JSON.stringify({ phase: 'rewrite-error', error: String(err?.message ?? err).slice(0, 200) })}\n`, 'utf8')
2232
+ } catch { /* ignore */ }
2233
+ send(502, { error: 'enhance-failed' })
2234
+ }
2235
+ },
2236
+ }), 'prompt-enhancer: /api/prompt-enhancer/*')
2237
+ })
2238
+ }
2239
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
2240
+ })()
2241
+
2242
+ /* ==== module: followup(原 dsh-followup) ==== */
2243
+ const followupModule = (() => {
2244
+ /* 由 scripts/build-suite.mjs 从 dsh-followup/index.js 机械生成;勿手改,重跑脚本再生成。 */
2245
+ /**
2246
+ * dsh-followup — 每轮对话结束后的智能跟进推荐(DeepSeek Harness / dsh)
2247
+ *
2248
+ * Trae「智能跟进推荐」的同款交互:助手回复落定后,用一次轻量模型调用
2249
+ * 生成 2-3 条短跟进建议,缓存在内存里;web 端在输入框上方浮出可点击
2250
+ * 的 chip,点击把建议填进草稿(不自动发送)。
2251
+ *
2252
+ * 工作原理(服务端半):
2253
+ * 主通道(0.2.3 起,借鉴 dsh-cost-meter 的链尾监听模式):
2254
+ * - ctx.on('llm/stream', listener, { global: true }):llm 服务的瀑布事件,
2255
+ * 每次 ctx.llm.stream() 都会经过。监听器拿到下游流后包一层旁路观察
2256
+ * (逐 chunk 透传,不改流协议),累积 text-delta 得到助手回复;
2257
+ * options.sessionId(GenerateOptions.sessionId,dsh-reference types.ts)
2258
+ * 关联会话,options.messages 里最后一个真实用户消息作为本轮输入。
2259
+ * { global: true } 让监听器收到隔离 llm 作用域(记忆插件等)的流。
2260
+ * AsyncLocalStorage 深度标记排除两类重复捕获:包装路由插件在自己
2261
+ * stream() 体内再发起的上游调用(pull 下游时打标记),以及本插件
2262
+ * 自己发起的建议生成调用(发起时打标记)。
2263
+ * 回落通道:
2264
+ * - 客户端回合落定后 POST /generate {sessionId};服务端优先用 llm/stream
2265
+ * 实时捕获的缓存(10 分钟内),未命中才解压 session.v4.jsonl.zstd 直读
2266
+ * (日志驱动,0.2.0 引入,对新架构进程分裂免疫)
2267
+ * 旧架构事件监听(agent/pre-step / assistant-stream / status)保留:
2268
+ * 收不到事件的宿主上它们是死代码,旧版本 dsh 上仍可用。
2269
+ *
2270
+ * 已核对的 dsh API(deepseek-ai/deepseek-harness,对照 dsh-reference):
2271
+ * - ctx.on('llm/stream', (options, next) => AsyncIterable, { global: true })
2272
+ * 监听器契约:必须调用 next() 推进瀑布并返回(包装的)流;嵌套/排除
2273
+ * 时原样返回 next() 的下游
2274
+ * - GenerateOptions.sessionId?: Branded<'SessionId'>(packages/llm/llm/src/types.ts)
2275
+ * - ctx.on('agent/status', ({ agent, status }) => ...) // idle ⇄ running
2276
+ * - ctx.on('agent/pre-step', async ({ agent, messages }, next) => PreStepDecision)
2277
+ * - ctx.on('agent/assistant-stream', ({ agent, frame }) => ...)
2278
+ * frame = { type: 'start' | 'chunk' | 'end', chunk?: StreamChunk, ... }
2279
+ * 文本块 chunk = { type: 'text-delta', text }
2280
+ * - ctx.llm.stream({ provider, model, messages, maxTokens, signal })
2281
+ * → AsyncIterable<{ type: 'text-delta', text } | { type: 'finish', reason }>
2282
+ * - agent.options.provider / agent.options.model:会话当前的路由
2283
+ * - ctx.get('agentDefaultModel')?.currentSelection?.() → { provider, model }
2284
+ * - webServer.register({ kind: 'prefix', path, handler }, label)
2285
+ */
2286
+
2287
+
2288
+
2289
+
2290
+
2291
+
2292
+
2293
+
2294
+ const name = 'followup'
2295
+
2296
+ const DIAG_LOG = join(homedir(), '.dsh', 'followup-debug.log')
2297
+ const SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
2298
+ const PROFILES_ROOT = join(homedir(), '.dsh', 'profiles')
2299
+ const SUGGEST_FILE = join(homedir(), '.dsh', 'followup-suggestions.json')
2300
+
2301
+ /** 每环节一行落盘:事件是否到、文本抓没抓到、生成成没成,一次复现即可定位断点。 */
2302
+ const report = async (line) => {
2303
+ try {
2304
+ await mkdir(dirname(DIAG_LOG), { recursive: true })
2305
+ await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
2306
+ } catch { /* ignore */ }
2307
+ }
2308
+
2309
+ /* ------------------------------------------------------------------ */
2310
+ /* 会话日志直读(新架构进程分裂后 agent/* 事件到不了插件进程, */
2311
+ /* 0.2.0 起改为日志驱动:解压 session.v4.jsonl.zstd 取最后一轮文本) */
2312
+ /* ------------------------------------------------------------------ */
2313
+
2314
+ /** 多帧 zstd(追加写每帧独立):按魔数切段逐帧解压,坏帧跳过。 */
2315
+ async function decompressLog(file) {
2316
+ const zlib = await import('node:zlib')
2317
+ if (typeof zlib.zstdDecompressSync !== 'function') {
2318
+ throw new Error('node zlib 无 zstd 支持(需 Node ≥23.8)')
2319
+ }
2320
+ const buf = await readFile(file)
2321
+ const MAGIC = 0x28
2322
+ const idxs = [0]
2323
+ for (let i = 4; i <= buf.length - 4; i += 1) {
2324
+ if (buf[i] === MAGIC && buf[i + 1] === 0xB5 && buf[i + 2] === 0x2F && buf[i + 3] === 0xFD) idxs.push(i)
2325
+ }
2326
+ let out = ''
2327
+ for (let k = 0; k < idxs.length; k += 1) {
2328
+ try {
2329
+ out += zlib.zstdDecompressSync(buf.subarray(idxs[k], idxs[k + 1] ?? buf.length)).toString('utf8')
2330
+ } catch { /* 截断/坏帧跳过 */ }
2331
+ }
2332
+ return out
2333
+ }
2334
+
2335
+ /** 找会话日志文件:目录名可能是裸 uuid 或 session-<uuid>。 */
2336
+ async function findSessionFile(sessionId) {
2337
+ const bare = String(sessionId).replace(/^session-/, '')
2338
+ let workspaces = []
2339
+ try { workspaces = await readdir(SESSIONS_ROOT, { withFileTypes: true }) } catch { return null }
2340
+ for (const ws of workspaces) {
2341
+ if (!ws.isDirectory()) continue
2342
+ for (const name of [sessionId, `session-${bare}`, bare]) {
2343
+ const file = join(SESSIONS_ROOT, ws.name, name, 'session.v4.jsonl.zstd')
2344
+ try { await readFile(file); return file } catch { /* 尝试下一个 */ }
2345
+ }
2346
+ }
2347
+ return null
2348
+ }
2349
+
2350
+ /** 从持久日志提取最后一轮:最后一个真实用户消息 + 其后最后的助手文本。 */
2351
+ async function lastTurnFromLog(sessionId) {
2352
+ const file = await findSessionFile(sessionId)
2353
+ if (!file) return null
2354
+ const text = await decompressLog(file)
2355
+ const events = []
2356
+ for (const line of text.split('\n')) {
2357
+ if (line.length === 0) continue
2358
+ try { events.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
2359
+ }
2360
+ const isRealUser = (e) => e?.type === 'user/message'
2361
+ && e.data?.source?.kind === 'user'
2362
+ && Array.isArray(e.data.content) && e.data.content.length === 1
2363
+ && e.data.content[0]?.type === 'text' && typeof e.data.content[0].text === 'string'
2364
+ const isAssistant = (e) => e?.type === 'assistant/message'
2365
+ let assistantText = null
2366
+ let userText = null
2367
+ for (let i = events.length - 1; i >= 0; i -= 1) {
2368
+ const e = events[i]
2369
+ if (assistantText === null && isAssistant(e)) {
2370
+ const blocks = e.data?.message?.content
2371
+ if (Array.isArray(blocks)) {
2372
+ const joined = blocks.filter((b) => b?.type === 'text' && typeof b.text === 'string')
2373
+ .map((b) => b.text).join('')
2374
+ if (joined.trim().length > 0) assistantText = joined
2375
+ }
2376
+ continue
2377
+ }
2378
+ if (assistantText !== null && isRealUser(e)) {
2379
+ userText = e.data.content[0].text
2380
+ break
2381
+ }
2382
+ }
2383
+ if (userText === null || assistantText === null) return null
2384
+ return { userText: userText.trim(), assistantText: assistantText.trim() }
2385
+ }
2386
+
2387
+ /** 从 profile 用户补丁层读 agent-default-model 的 provider/model(兜底路由)。 */
2388
+ async function patchDefaultRoute() {
2389
+ let profiles = []
2390
+ try { profiles = await readdir(PROFILES_ROOT, { withFileTypes: true }) } catch { return null }
2391
+ for (const p of profiles) {
2392
+ if (!p.isDirectory()) continue
2393
+ try {
2394
+ const raw = await readFile(join(PROFILES_ROOT, p.name, 'cordis.patch.yml'), 'utf8')
2395
+ const m = raw.match(/id:\s*agent-default-model[\s\S]{0,300}?provider:\s*(\S+)[\s\S]{0,120}?model:\s*(\S+)/)
2396
+ if (m) return { provider: m[1].replace(/^['"]|['"]$/g, ''), model: m[2].replace(/^['"]|['"]$/g, '') }
2397
+ } catch { /* 下一个 profile */ }
2398
+ }
2399
+ return null
2400
+ }
2401
+
2402
+ const moduleInject = ['llm']
2403
+
2404
+ const Config = Schema.object({
2405
+ /** 总开关:关闭后不监听任何事件,也不注册 web 端点。 */
2406
+ enabled: Schema.boolean().default(true).description('是否启用智能跟进推荐'),
2407
+ /** 留空则跟随会话当前模型(agent.options)。 */
2408
+ provider: Schema.string().description('生成建议使用的 provider(留空跟随会话)'),
2409
+ /** 建议配一个快模型:这条调用在每轮回复结束后立刻发生。 */
2410
+ model: Schema.string().description('生成建议使用的模型(留空跟随会话,建议快模型)'),
2411
+ /** 单次生成超时;超时即放弃本轮建议。 */
2412
+ timeoutMs: Schema.number().default(12000).description('生成超时(毫秒)'),
2413
+ /** 回合落定后等多久再生成:缓冲多步回合的中间 idle。 */
2414
+ idleDelayMs: Schema.number().default(1200).description('回合结束后的缓冲时间(毫秒)'),
2415
+ /** 生成几条建议(最多 3)。 */
2416
+ maxSuggestions: Schema.number().default(2).description('最多生成几条建议(1-2)'),
2417
+ /** 上下文裁剪:超出截断,控制这条调用的 token 开销。 */
2418
+ userChars: Schema.number().default(400).description('用户消息最多带多少字符'),
2419
+ assistantChars: Schema.number().default(1200).description('助手回复最多带多少字符'),
2420
+ /** 助手回复短于此不生成:「好的」「完成」这类没有可跟进的实质内容。 */
2421
+ minAssistantChars: Schema.number().default(30).description('助手回复最小长度(字符)'),
2422
+ })
2423
+
2424
+ /* ------------------------------------------------------------------ */
2425
+ /* 提示词与解析 */
2426
+ /* ------------------------------------------------------------------ */
2427
+
2428
+ function clampTail(text, limit) {
2429
+ const clean = typeof text === 'string' ? text.trim() : ''
2430
+ if (clean.length <= limit) return clean
2431
+ return `…${clean.slice(-limit)}`
2432
+ }
2433
+
2434
+ function buildPrompt(userText, assistantText, count, config) {
2435
+ return [
2436
+ `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.`,
2437
+ '',
2438
+ 'Rules:',
2439
+ '- Continue the conversation: refine, extend, verify or apply the assistant\'s last answer. Never generic filler like "还有吗" / "继续" / "tell me more".',
2440
+ '- One imperative sentence each: 8~24 Chinese characters or 6~16 English words. No trailing period, no quotes, no numbering, no emoji.',
2441
+ '- They must be sendable as-is: self-contained requests, not fragments.',
2442
+ '- Write in the same language as the exchange.',
2443
+ '- Output STRICT JSON only: an array of strings, nothing else. Example: ["第一条建议","第二条建议"]',
2444
+ '',
2445
+ '[User]',
2446
+ clampTail(userText, config.userChars),
2447
+ '',
2448
+ '[Assistant]',
2449
+ clampTail(assistantText, config.assistantChars),
2450
+ ].join('\n')
2451
+ }
2452
+
2453
+ /** 从模型输出里抠 JSON 数组;抠不出就返回空数组,宁缺毋滥。 */
2454
+ function parseSuggestions(text, count) {
2455
+ const start = text.indexOf('[')
2456
+ const end = text.lastIndexOf(']')
2457
+ if (start < 0 || end <= start) return []
2458
+ let parsed
2459
+ try {
2460
+ parsed = JSON.parse(text.slice(start, end + 1))
2461
+ } catch {
2462
+ return []
2463
+ }
2464
+ if (!Array.isArray(parsed)) return []
2465
+ return parsed
2466
+ .filter((item) => typeof item === 'string')
2467
+ .map((item) => item.trim().replace(/^[-*•\d.)、\s]+/, '').slice(0, 60))
2468
+ .filter((item) => item.length >= 2)
2469
+ .slice(0, count)
2470
+ }
2471
+
2472
+ /* ------------------------------------------------------------------ */
2473
+ /* 插件入口 */
2474
+ /* ------------------------------------------------------------------ */
2475
+
2476
+ function apply(ctx, config) {
2477
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
2478
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
2479
+ const live = { ...config }
2480
+ if (!live.enabled) return
2481
+
2482
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
2483
+ const SETTINGS_KEYS = ['maxSuggestions', 'idleDelayMs', 'timeoutMs', 'minAssistantChars', 'userChars', 'assistantChars']
2484
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-followup', 'settings.json')
2485
+ try {
2486
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
2487
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
2488
+ } catch { /* 无存档:全部跟随配置 */ }
2489
+ let settingsTimer = null
2490
+ const persistSettings = () => {
2491
+ if (settingsTimer) return
2492
+ settingsTimer = setTimeout(() => {
2493
+ settingsTimer = null
2494
+ try {
2495
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
2496
+ const out = {}
2497
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
2498
+ const tmp = `${SETTINGS_PATH}.tmp`
2499
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
2500
+ renameSync(tmp, SETTINGS_PATH)
2501
+ } catch { /* ignore */ }
2502
+ }, 400)
2503
+ }
2504
+
2505
+ void report(`server boot v0.3.3 enabled=true minAssistantChars=${live.minAssistantChars} idleDelayMs=${live.idleDelayMs} route=${live.provider || ''}/${live.model || ''}(follow-session)`)
2506
+
2507
+ /** 每个 agent 的对话尾部累积:userText 来自 pre-step,assistantText 来自流帧。 */
2508
+ const buffers = new WeakMap()
2509
+ const seenFrameTypes = new Set()
2510
+ let bufCounter = 0
2511
+ const agentConstructorName = (agent) => (agent === undefined || agent === null
2512
+ ? 'none'
2513
+ : `${agent.constructor?.name ?? typeof agent}`)
2514
+ const bufferOf = (agent) => {
2515
+ let buf = buffers.get(agent)
2516
+ if (buf === undefined) {
2517
+ buf = { id: ++bufCounter, userText: '', assistantText: '', controller: null }
2518
+ buffers.set(agent, buf)
2519
+ void report(`buffer new #${buf.id} for agent=${agentConstructorName(agent)}`)
2520
+ }
2521
+ return buf
2522
+ }
2523
+
2524
+ /** 供 web 端拉取的最近一份建议;seq 递增,客户端凭它去重。 */
2525
+ let latest = { seq: 0, suggestions: [], at: 0 }
2526
+ /** 最近一次生成的上下文,供「换一批」重生成。 */
2527
+ let lastContext = null
2528
+ let seqCounter = 0
2529
+ let pendingTimer = 0
2530
+
2531
+ /* -- 建议持久化(0.2.4):重启后打开会话还能拿到最近一轮的建议 ----- */
2532
+ /** sessionId → { seq, suggestions, at },boot 时读回,成功生成时写入。 */
2533
+ let persisted = {}
2534
+ try {
2535
+ persisted = JSON.parse(readFileSync(SUGGEST_FILE, 'utf8'))
2536
+ if (!persisted || typeof persisted !== 'object') persisted = {}
2537
+ for (const entry of Object.values(persisted)) {
2538
+ if (Number.isInteger(entry?.seq) && entry.seq > seqCounter) seqCounter = entry.seq
2539
+ }
2540
+ say('info', `restored ${Object.keys(persisted).length} persisted suggestion set(s)`)
2541
+ } catch { /* 首次或损坏:空表开始 */ }
2542
+
2543
+ function persistSuggestions(sessionId, seq, suggestions) {
2544
+ if (!sessionId || !Array.isArray(suggestions) || suggestions.length === 0) return
2545
+ persisted[sessionId] = { seq, suggestions, at: Date.now() }
2546
+ const keys = Object.keys(persisted)
2547
+ if (keys.length > 24) {
2548
+ let oldest = null
2549
+ let oldestAt = Infinity
2550
+ for (const key of keys) {
2551
+ if (persisted[key].at < oldestAt) { oldestAt = persisted[key].at; oldest = key }
2552
+ }
2553
+ if (oldest !== null) delete persisted[oldest]
2554
+ }
2555
+ void writeFile(SUGGEST_FILE, `${JSON.stringify(persisted)}\n`, 'utf8')
2556
+ .catch(() => { /* 持久化失败不影响主流程 */ })
2557
+ }
2558
+
2559
+ /** Trae 逻辑:建议属于「每轮响应」——本轮没有新建议就清掉该会话的旧建议。 */
2560
+ function clearSuggestions(sessionId) {
2561
+ if (!sessionId || !(sessionId in persisted)) return
2562
+ delete persisted[sessionId]
2563
+ void writeFile(SUGGEST_FILE, `${JSON.stringify(persisted)}\n`, 'utf8')
2564
+ .catch(() => { /* ignore */ })
2565
+ }
2566
+
2567
+ const say = (level, message) => {
2568
+ try {
2569
+ console[level === 'error' ? 'error' : 'info'](`[dsh-followup] ${message}`)
2570
+ } catch { /* ignore */ }
2571
+ }
2572
+
2573
+ function singleTextOf(message) {
2574
+ if (!message || message.role !== 'user') return null
2575
+ // dsh 内部还有 goal / session-reference / notice 等来源的 user 消息,
2576
+ // 不是用户在对话框里输入的内容,不进上下文。
2577
+ if (message.source?.kind !== 'user') return null
2578
+ if (!Array.isArray(message.content) || message.content.length !== 1) return null
2579
+ const block = message.content[0]
2580
+ return block && block.type === 'text' && typeof block.text === 'string' ? block.text : null
2581
+ }
2582
+
2583
+ /** llm/stream 捕获用:请求 messages 里最后一个真实用户文本。 */
2584
+ function singleTextOfLastUser(messages) {
2585
+ if (!Array.isArray(messages)) return null
2586
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
2587
+ const text = singleTextOf(messages[i])
2588
+ if (text !== null) return text
2589
+ }
2590
+ return null
2591
+ }
2592
+
2593
+ function defaultRoute() {
2594
+ const defaults = ctx.get('agentDefaultModel')
2595
+ const selection = defaults?.currentSelection?.()
2596
+ return selection?.provider && selection?.model
2597
+ ? { provider: selection.provider, model: selection.model }
2598
+ : null
2599
+ }
2600
+
2601
+ function configRoute() {
2602
+ return live.provider && live.model ? { provider: live.provider, model: live.model } : null
2603
+ }
2604
+
2605
+ function resolveRoute(agent) {
2606
+ const provider = agent?.options?.provider
2607
+ const model = agent?.options?.model
2608
+ return configRoute()
2609
+ ?? (provider && model ? { provider, model } : null)
2610
+ ?? defaultRoute()
2611
+ }
2612
+
2613
+ function cancelPending(buf, reason) {
2614
+ if (buf.controller) {
2615
+ try { buf.controller.abort(new Error(reason)) } catch { /* ignore */ }
2616
+ buf.controller = null
2617
+ }
2618
+ }
2619
+
2620
+ /* -- 事件监听:拼出对话尾部(旧架构回落通道) ---------------------- */
2621
+ /* 新架构进程分裂下这些 agent/* 事件到不了插件进程(0.1.x 实证零事件),
2622
+ 保留是为了旧版本 dsh 仍然可用;新架构走上面的 llm/stream 捕获 + 日志回落。 */
2623
+
2624
+ ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
2625
+ const buf = bufferOf(agent)
2626
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
2627
+ const text = singleTextOf(messages[i])
2628
+ if (text !== null) { buf.userText = text; break }
2629
+ }
2630
+ buf.assistantText = ''
2631
+ // 用户已经开始说下一句:上一份没生成的建议直接作废。
2632
+ cancelPending(buf, 'superseded by next user message')
2633
+ 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))}`)
2634
+ return next()
2635
+ })
2636
+
2637
+ ctx.on('agent/assistant-stream', ({ agent, frame }) => {
2638
+ const buf = bufferOf(agent)
2639
+ if (frame?.type && !seenFrameTypes.has(frame.type)) {
2640
+ seenFrameTypes.add(frame.type)
2641
+ void report(`stream frame-type first-seen: ${frame.type} agent=${agentConstructorName(agent)}`)
2642
+ }
2643
+ if (frame?.type === 'start') { buf.assistantText = ''; return }
2644
+ const chunk = frame?.type === 'chunk' ? frame.chunk : null
2645
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
2646
+ buf.assistantText += chunk.text
2647
+ // 超长回复只保留尾部,控制内存。
2648
+ if (buf.assistantText.length > 20000) buf.assistantText = buf.assistantText.slice(-10000)
2649
+ return
2650
+ }
2651
+ if (frame?.type === 'end') {
2652
+ void report(`stream end buf#${buf.id} agent=${agentConstructorName(agent)} assistantLen=${(buf.assistantText ?? '').length}`)
2653
+ }
2654
+ })
2655
+
2656
+ ctx.on('agent/status', ({ agent, status }) => {
2657
+ const buf = bufferOf(agent)
2658
+ if (status !== 'idle') {
2659
+ // 回合重新运转(工具链下一步):撤销缓冲中的生成计划。
2660
+ cancelPending(buf, 'agent running again')
2661
+ if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = 0 }
2662
+ return
2663
+ }
2664
+ const userText = (buf.userText ?? '').trim()
2665
+ const assistantText = (buf.assistantText ?? '').trim()
2666
+ if (!userText || assistantText.length < live.minAssistantChars) {
2667
+ void report(`idle skip buf#${buf.id} agent=${agentConstructorName(agent)} userLen=${userText.length} assistantLen=${assistantText.length} (need user>0 & assistant>=${live.minAssistantChars})`)
2668
+ return
2669
+ }
2670
+ // 缓冲片刻:多步回合的步骤之间也会闪现 idle,期间回到 running 就作废。
2671
+ if (pendingTimer) clearTimeout(pendingTimer)
2672
+ pendingTimer = setTimeout(() => {
2673
+ pendingTimer = 0
2674
+ void generateFor(agent, buf, userText, assistantText)
2675
+ }, Math.max(200, live.idleDelayMs))
2676
+ })
2677
+
2678
+ /* -- 主通道:llm/stream 瀑布旁路捕获(0.2.3,借鉴 dsh-cost-meter) -- */
2679
+
2680
+ /** 深度标记:存在即「本插件已知会、不需要再捕获」的 llm 流。 */
2681
+ const llmDepth = new AsyncLocalStorage()
2682
+
2683
+ /** 实时捕获的最近一轮:sessionId → { userText, assistantText, at }。 */
2684
+ const turnCache = new Map()
2685
+ const TURN_CACHE_MAX = 24
2686
+ const TURN_CACHE_TTL_MS = 10 * 60_000
2687
+
2688
+ function cachedTurnOf(sessionId) {
2689
+ const hit = turnCache.get(sessionId)
2690
+ if (!hit) return null
2691
+ if (Date.now() - hit.at > TURN_CACHE_TTL_MS) { turnCache.delete(sessionId); return null }
2692
+ return hit
2693
+ }
2694
+
2695
+ function rememberTurn(sessionId, userText, assistantText) {
2696
+ if (!sessionId || !userText || !assistantText || assistantText.trim().length === 0) return
2697
+ const prev = turnCache.get(sessionId)
2698
+ // 同一轮的多步工具链 / 重试续流 userText 相同:助手文本追加累积,
2699
+ // 让「最终答案」落在缓存尾部(生成时 clampTail 取尾部正好命中)。
2700
+ const merged = prev && prev.userText === userText
2701
+ ? `${prev.assistantText}\n\n${assistantText}`
2702
+ : assistantText
2703
+ turnCache.set(sessionId, { userText, assistantText: merged.slice(-20000), at: Date.now() })
2704
+ if (turnCache.size > TURN_CACHE_MAX) {
2705
+ let oldestKey = null
2706
+ let oldestAt = Infinity
2707
+ for (const [key, value] of turnCache) {
2708
+ if (value.at < oldestAt) { oldestAt = value.at; oldestKey = key }
2709
+ }
2710
+ if (oldestKey !== null) turnCache.delete(oldestKey)
2711
+ }
2712
+ }
2713
+
2714
+ // llm/stream 捕获是否活着是 0.2.3 的关键真机问题(新架构进程分裂下
2715
+ // 事件归属未实证),首帧与每次入缓存都留痕,一次对话即可从日志判定。
2716
+ let llmStreamArmed = false
2717
+
2718
+ ctx.on('llm/stream', (options, next) => {
2719
+ // 自己发起的建议生成调用 / 下游包装路由的嵌套调用:原样透传。
2720
+ if (llmDepth.getStore() !== undefined) return next()
2721
+ const downstream = next()
2722
+ if (!llmStreamArmed) {
2723
+ llmStreamArmed = true
2724
+ void report('llm/stream first fire: capture channel is ALIVE in this process')
2725
+ }
2726
+ const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
2727
+ const userText = (singleTextOfLastUser(options?.messages) ?? '').trim()
2728
+ let assistantText = ''
2729
+ let downstreamIter = null
2730
+ let completed = false
2731
+ return (async function* followupObserve() {
2732
+ try {
2733
+ downstreamIter = downstream[Symbol.asyncIterator]()
2734
+ for (;;) {
2735
+ // 深度标记内拉取:包装路由适配器在消费上下文里再发起的
2736
+ // ctx.llm.stream() 继承标记,内层监听器判定为已处理、透传。
2737
+ const result = await llmDepth.run(1, () => downstreamIter.next())
2738
+ if (result.done) break
2739
+ const chunk = result.value
2740
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
2741
+ assistantText += chunk.text
2742
+ if (assistantText.length > 20000) assistantText = assistantText.slice(-10000)
2743
+ }
2744
+ yield chunk
2745
+ }
2746
+ completed = true
2747
+ } finally {
2748
+ // 消费方提前中断(用户改下一句、错误终止)也要收尾;流失败时
2749
+ // assistantText 不完整,通常过不了 minAssistantChars,无害。
2750
+ if (!completed) {
2751
+ try { await downstreamIter?.return?.() } catch { /* ignore */ }
2752
+ }
2753
+ try {
2754
+ rememberTurn(sessionId, userText, assistantText)
2755
+ void report(`llm/stream captured session=${sessionId ? sessionId.slice(0, 50) : '(none)'} userLen=${userText.length} assistantLen=${assistantText.length} ${!completed ? '(interrupted)' : ''}`)
2756
+ } catch { /* 缓存失败绝不影响主对话流 */ }
2757
+ }
2758
+ })()
2759
+ }, { global: true })
2760
+
2761
+ /* -- 生成 --------------------------------------------------------- */
2762
+
2763
+ async function runGeneration(llm, route, userText, assistantText, signal) {
2764
+ const count = Math.min(2, Math.max(1, Math.round(live.maxSuggestions)))
2765
+ // 深度标记内发起:本次生成调用经过 llm/stream 瀑布时,监听器读到
2766
+ // 标记即透传——建议生成的 prompt 不会被误缓存为对话轮。
2767
+ // reasoningEffort low + maxTokens 2000:真机 04:50 实测 profile 默认
2768
+ // high 推理把 600 上限全部烧光(raw len=0 → 无建议渲染)。
2769
+ // 给 dsh-cost 打来源标记:建议生成的费用单独归类,不混进对话。
2770
+ globalThis.__dshPluginCall = { source: 'followup', at: Date.now() }
2771
+
2772
+ const iterator = llmDepth.run(1, () => llm.stream({
2773
+ provider: route.provider,
2774
+ model: route.model,
2775
+ reasoningEffort: 'low',
2776
+ messages: [{ role: 'user', content: [{ type: 'text', text: buildPrompt(userText, assistantText, count, config) }] }],
2777
+ maxTokens: 2000,
2778
+ signal,
2779
+ })[Symbol.asyncIterator]())
2780
+ let text = ''
2781
+ try {
2782
+ for (;;) {
2783
+ const { done, value } = await iterator.next()
2784
+ if (done) break
2785
+ if (value?.type === 'text-delta' && typeof value.text === 'string') text += value.text
2786
+ else if (value?.type === 'finish' && value.reason && (value.reason.kind === 'error' || value.reason.kind === 'aborted')) {
2787
+ throw new Error(`followup llm ${value.reason.kind}`)
2788
+ }
2789
+ }
2790
+ } catch (err) {
2791
+ // 中断底层流,失败后不在后台继续烧 token。
2792
+ try {
2793
+ const closing = iterator.return?.()
2794
+ if (closing && typeof closing.catch === 'function') closing.catch(() => {})
2795
+ } catch { /* ignore */ }
2796
+ throw err
2797
+ }
2798
+ void report(`raw output len=${text.length}: ${JSON.stringify(text.slice(0, 300))}`)
2799
+ return parseSuggestions(text, count)
2800
+ }
2801
+
2802
+ async function generateFor(agent, buf, userText, assistantText) {
2803
+ const route = resolveRoute(agent)
2804
+ if (!route) {
2805
+ say('info', 'no model route available, skip')
2806
+ void report('generate skip: no model route (config unset, agent.options empty, agentDefaultModel missing)')
2807
+ return
2808
+ }
2809
+ const controller = new AbortController()
2810
+ buf.controller = controller
2811
+ const timer = setTimeout(() => controller.abort(new Error('followup timeout')), live.timeoutMs)
2812
+ // 根作用域没声明 llm inject 时属性访问直接抛(真机 07:41 实证);
2813
+ // 捕住降级——cache/日志路径不受影响。
2814
+ let eventLlm = null
2815
+ try { eventLlm = ctx.llm } catch { eventLlm = null }
2816
+ if (eventLlm === null) {
2817
+ void report('event-path skip: llm not injectable in this scope (cache path covers it)')
2818
+ return
2819
+ }
2820
+ try {
2821
+ void report(`generate start route=${route.provider}/${route.model} userLen=${userText.length} assistantLen=${assistantText.length}`)
2822
+ const suggestions = await runGeneration(eventLlm, route, userText, assistantText, controller.signal)
2823
+ if (suggestions.length) {
2824
+ lastContext = { route, userText, assistantText }
2825
+ latest = { seq: ++seqCounter, suggestions, at: Date.now() }
2826
+ let eventSessionId = ''
2827
+ try { eventSessionId = agent?.session?.id ?? '' } catch { eventSessionId = '' }
2828
+ persistSuggestions(eventSessionId, latest.seq, suggestions)
2829
+ say('info', `turn ${latest.seq}: ${suggestions.length} suggestions`)
2830
+ void report(`generate ok seq=${latest.seq} count=${suggestions.length} ${JSON.stringify(suggestions)}`)
2831
+ } else {
2832
+ void report('generate empty: model returned no parseable suggestions')
2833
+ }
2834
+ } catch (err) {
2835
+ if (!/aborted|superseded|timeout/i.test(String(err?.message ?? err))) {
2836
+ say('error', `generate failed: ${err?.message ?? err}`)
2837
+ void report(`generate failed: ${String(err?.message ?? err).slice(0, 300)}`)
2838
+ } else {
2839
+ void report(`generate aborted: ${String(err?.message ?? err).slice(0, 120)}`)
2840
+ }
2841
+ } finally {
2842
+ clearTimeout(timer)
2843
+ if (buf.controller === controller) buf.controller = null
2844
+ }
2845
+ }
2846
+
2847
+ /* -- web 端点 ------------------------------------------------------ */
2848
+
2849
+ // 可选注入:CLI / TUI 等没有 webServer 的宿主照常加载,只是没有 chips。
2850
+ // llm 必须在此作用域声明——cordis 的属性访问按作用域校验 inject,
2851
+ // 根作用域的静态 inject 不传导进嵌套作用域(真机实测报
2852
+ // cannot get property "llm" without inject)。
2853
+ ctx.inject(['webServer', 'llm'], (webCtx) => {
2854
+ webCtx.effect(() => webCtx.webServer.register({
2855
+ kind: 'prefix',
2856
+ path: '/api/dsh-followup',
2857
+ handler: async (req, res) => {
2858
+ const send = (status, body) => {
2859
+ res.statusCode = status
2860
+ res.setHeader('content-type', 'application/json')
2861
+ res.end(JSON.stringify(body))
2862
+ }
2863
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-followup/, '')
2864
+ if (path === '/settings' && req.method === 'GET') {
2865
+ send(200, { ...live })
2866
+ return
2867
+ }
2868
+ // 运行时改参数:白名单字段,provider/model 不在其中(改了要重建路由)。
2869
+ if (path === '/settings' && req.method === 'POST') {
2870
+ let raw = ''
2871
+ for await (const chunk of req) raw += chunk
2872
+ let body
2873
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
2874
+ const numeric = { maxSuggestions: [1, 3], idleDelayMs: [200, 10000], timeoutMs: [1000, 120000], minAssistantChars: [0, 500], userChars: [100, 4000], assistantChars: [100, 8000] }
2875
+ for (const [key, [min, max]] of Object.entries(numeric)) {
2876
+ if (body[key] === undefined) continue
2877
+ const value = Number(body[key])
2878
+ if (!Number.isFinite(value)) continue
2879
+ live[key] = Math.min(max, Math.max(min, Math.round(value)))
2880
+ }
2881
+ persistSettings()
2882
+ send(200, { ...live })
2883
+ return
2884
+ }
2885
+
2886
+
2887
+ // 客户端在每次「生成中 → 空闲」后拉取;seq 相同就静默忽略。
2888
+ // Trae 逻辑:建议随会话持久化、无时间过期,只被下一轮替换或清除。
2889
+ if (path === '/latest' && req.method === 'GET') {
2890
+ const qs = new URL(req.url ?? '', 'http://local').searchParams
2891
+ const wantSession = qs.get('sessionId') ?? ''
2892
+ const entry = wantSession ? persisted[wantSession] : undefined
2893
+ if (entry && Array.isArray(entry.suggestions) && entry.suggestions.length > 0) {
2894
+ send(200, entry)
2895
+ return
2896
+ }
2897
+ send(200, latest)
2898
+ return
2899
+ }
2900
+
2901
+ /**
2902
+ * 生成建议(0.2.3 双通道):优先用 llm/stream 实时捕获的对话轮
2903
+ * (零文件 IO、无解析时延),未命中才回落日志驱动(0.2.0 引入,
2904
+ * 解压 session.v4.jsonl.zstd 直读)。
2905
+ */
2906
+ if ((path === '/generate' || path === '/refresh') && req.method === 'POST') {
2907
+ let body = ''
2908
+ for await (const chunk of req) body += chunk
2909
+ let sessionId = ''
2910
+ try { sessionId = JSON.parse(body).sessionId ?? '' } catch { sessionId = '' }
2911
+ if (!sessionId) { send(400, { error: 'missing sessionId' }); return }
2912
+ const cached = cachedTurnOf(sessionId)
2913
+ const turn = cached
2914
+ ? { userText: cached.userText, assistantText: cached.assistantText }
2915
+ : await lastTurnFromLog(sessionId)
2916
+ if (!turn) {
2917
+ void report(`generate(${cached ? 'cache' : 'log'}) ${path}: no usable turn in session ${String(sessionId).slice(0, 50)}`)
2918
+ // Trae 逻辑:建议属于每轮响应——本轮拿不到可跟进内容即清掉旧建议
2919
+ clearSuggestions(String(sessionId))
2920
+ send(409, { error: 'no-turn' })
2921
+ return
2922
+ }
2923
+ const route = configRoute() ?? defaultRoute() ?? await patchDefaultRoute()
2924
+ if (!route) {
2925
+ void report(`generate(${cached ? 'cache' : 'log'}): no route (config/agentDefaultModel/patch all missing)`)
2926
+ send(503, { error: 'no-route' })
2927
+ return
2928
+ }
2929
+ const source = cached ? 'cache' : 'log'
2930
+ const controller = new AbortController()
2931
+ const timer = setTimeout(() => controller.abort(new Error('followup timeout')), live.timeoutMs)
2932
+ try {
2933
+ void report(`generate(${source}) start route=${route.provider}/${route.model} userLen=${turn.userText.length} assistantLen=${turn.assistantText.length}`)
2934
+ const suggestions = await runGeneration(webCtx.llm, route, turn.userText, turn.assistantText, controller.signal)
2935
+ if (!suggestions.length) {
2936
+ void report(`generate(${source}) empty`)
2937
+ // 同上:本轮生成失败/为空,旧建议不再属于最新一轮
2938
+ clearSuggestions(String(sessionId))
2939
+ send(502, { error: 'generate-failed' })
2940
+ return
2941
+ }
2942
+ lastContext = { route, ...turn }
2943
+ latest = { seq: ++seqCounter, suggestions, at: Date.now() }
2944
+ persistSuggestions(String(sessionId), latest.seq, suggestions)
2945
+ void report(`generate(${source}) ok seq=${latest.seq} ${JSON.stringify(suggestions)}`)
2946
+ send(200, latest)
2947
+ } catch (err) {
2948
+ void report(`generate(${source}) failed: ${String(err?.message ?? err).slice(0, 200)}`)
2949
+ send(502, { error: 'generate-failed' })
2950
+ } finally {
2951
+ clearTimeout(timer)
2952
+ }
2953
+ return
2954
+ }
2955
+
2956
+ send(404, { error: 'not-found' })
2957
+ },
2958
+ }), 'dsh-followup: /api/dsh-followup/*')
2959
+ })
2960
+ }
2961
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
2962
+ })()
2963
+
2964
+ /* ==== module: voice(原 dsh-siri-voice) ==== */
2965
+ const voiceModule = (() => {
2966
+ /* 由 scripts/build-suite.mjs 从 dsh-siri-voice/index.js 机械生成;勿手改,重跑脚本再生成。 */
2967
+ /**
2968
+ * dsh-siri-voice — 服务端半(无逻辑)。
2969
+ *
2970
+ * 这个插件只做 web 客户端的视觉层,但 dsh 的客户端模块扫描是从 Host 的
2971
+ * loader entries 出发的,所以包仍然需要一个可被加载的服务端入口:
2972
+ * 声明 `dsh.bundle.patch` 把一行插件插进 profile,`dsh.client.platform = 'web'`
2973
+ * 让 web 宿主把 client.js 挂到页面里。
2974
+ */
2975
+
2976
+ const name = 'siri-voice'
2977
+
2978
+ /** 不注册任何服务;所有效果都在客户端。 */
2979
+ function apply() {}
2980
+
2981
+
2982
+ const Config = Schema.object({ enabled: Schema.boolean().default(true).description('启用') })
2983
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
2984
+ })()
2985
+
2986
+ /* ==== module: prompts(手写) ==== */
2987
+ const promptsModule = (() => {
2988
+ /**
2989
+ * dsh-superinput · prompts 模块(提示词模板库)
2990
+ *
2991
+ * prompts 是手动保存的常用模板(划选正文可一键存入)。
2992
+ * 支持 {{变量}} 占位符,插入时由前端弹窗填值;服务端只存原文,不做替换。
2993
+ *
2994
+ * 存储 ~/.dsh/storages/dsh-superinput/prompts.json(结构 { items: [...] })。
2995
+ */
2996
+
2997
+
2998
+
2999
+
3000
+
3001
+
3002
+ const moduleInject = []
3003
+
3004
+ const STORE_DIR = join(homedir(), '.dsh', 'storages', 'dsh-superinput')
3005
+ const STORE_PATH = join(STORE_DIR, 'prompts.json')
3006
+ const CLIENT_DIAG = join(homedir(), '.dsh', 'superinput-debug.log')
3007
+
3008
+ const Config = Schema.object({
3009
+ enabled: Schema.boolean().default(true).description('启用提示词模板库'),
3010
+ maxItems: Schema.number().default(500).description('最多保存多少条模板(收藏不计入上限)'),
3011
+ })
3012
+
3013
+ function createStore(maxItems) {
3014
+ let state = { items: [] }
3015
+ let dirty = false
3016
+ let timer = null
3017
+
3018
+ const load = () => {
3019
+ try { state = JSON.parse(readFileSync(STORE_PATH, 'utf8')) } catch { /* 首次或损坏:空库 */ }
3020
+ if (!state || !Array.isArray(state.items)) state = { items: [] }
3021
+ }
3022
+
3023
+ const flush = () => {
3024
+ if (!dirty) return
3025
+ try {
3026
+ mkdirSync(STORE_DIR, { recursive: true })
3027
+ const tmp = `${STORE_PATH}.${process.pid}.tmp`
3028
+ writeFileSync(tmp, JSON.stringify(state), 'utf8')
3029
+ renameSync(tmp, STORE_PATH)
3030
+ dirty = false
3031
+ } catch (error) {
3032
+ console.error('[dsh-superinput] prompts flush failed:', error?.message ?? error)
3033
+ }
3034
+ }
3035
+
3036
+ const schedule = () => {
3037
+ dirty = true
3038
+ if (timer) return
3039
+ timer = setTimeout(() => { timer = null; flush() }, 500)
3040
+ timer.unref?.()
3041
+ }
3042
+
3043
+ return {
3044
+ load,
3045
+ dispose() { if (timer) { clearTimeout(timer); timer = null } flush() },
3046
+ list() { return state.items },
3047
+
3048
+ /** 新建或更新。title 为空时用正文前 40 字兜底,避免列表里出现无标题条目。 */
3049
+ save({ id, title, body, tags }) {
3050
+ const cleanBody = String(body ?? '').replace(/\0/g, '')
3051
+ if (!cleanBody.trim()) return { ok: false, reason: 'empty-body' }
3052
+ const cleanTitle = String(title ?? '').trim().slice(0, 80) || cleanBody.trim().slice(0, 40)
3053
+ const cleanTags = Array.isArray(tags)
3054
+ ? tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 8)
3055
+ : []
3056
+
3057
+ if (id) {
3058
+ const item = state.items.find((entry) => entry.id === id)
3059
+ if (!item) return { ok: false, reason: 'not-found' }
3060
+ item.title = cleanTitle
3061
+ item.body = cleanBody
3062
+ item.tags = cleanTags
3063
+ item.at = Date.now()
3064
+ schedule()
3065
+ return { ok: true, id, created: false }
3066
+ }
3067
+
3068
+ const newId = randomUUID()
3069
+ state.items.unshift({
3070
+ id: newId,
3071
+ title: cleanTitle,
3072
+ body: cleanBody,
3073
+ tags: cleanTags,
3074
+ at: Date.now(),
3075
+ pinned: false,
3076
+ })
3077
+ // 收藏条目不占上限
3078
+ const overflow = state.items.filter((item) => !item.pinned).length - maxItems
3079
+ if (overflow > 0) {
3080
+ const drop = new Set(state.items.filter((item) => !item.pinned).slice(-overflow).map((item) => item.id))
3081
+ state.items = state.items.filter((item) => !drop.has(item.id))
3082
+ }
3083
+ schedule()
3084
+ return { ok: true, id: newId, created: true }
3085
+ },
3086
+
3087
+ remove(id) {
3088
+ const before = state.items.length
3089
+ state.items = state.items.filter((item) => item.id !== id)
3090
+ if (state.items.length !== before) schedule()
3091
+ return state.items.length !== before
3092
+ },
3093
+
3094
+ pin(id, pinned) {
3095
+ const item = state.items.find((entry) => entry.id === id)
3096
+ if (!item) return false
3097
+ item.pinned = !!pinned
3098
+ schedule()
3099
+ return true
3100
+ },
3101
+
3102
+ /** 使用计数:面板点插入时调用,列表按最近使用排序。 */
3103
+ use(id) {
3104
+ const item = state.items.find((entry) => entry.id === id)
3105
+ if (!item) return false
3106
+ item.usedAt = Date.now()
3107
+ item.uses = (item.uses ?? 0) + 1
3108
+ schedule()
3109
+ return true
3110
+ },
3111
+
3112
+ /** 是否注册为 / 斜杠命令(客户端按此标记同步 commandUi)。 */
3113
+ slash(id, on) {
3114
+ const item = state.items.find((entry) => entry.id === id)
3115
+ if (!item) return false
3116
+ item.slash = !!on
3117
+ schedule()
3118
+ return true
3119
+ },
3120
+
3121
+ /**
3122
+ * 导入:按 id 合并;同 id 冲突保留 at(更新时间)较新的一方。
3123
+ * id 不在库中则原 id 收编(跨机重复导入可去重),缺 id 才新分配。
3124
+ */
3125
+ import(items) {
3126
+ let added = 0
3127
+ let updated = 0
3128
+ for (const raw of items.slice(0, 5000)) {
3129
+ const body = String(raw?.body ?? '').replace(/\0/g, '')
3130
+ if (!body.trim()) continue
3131
+ const incoming = {
3132
+ title: String(raw?.title ?? '').trim().slice(0, 80) || body.trim().slice(0, 40),
3133
+ body,
3134
+ tags: Array.isArray(raw?.tags)
3135
+ ? raw.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 8)
3136
+ : [],
3137
+ at: Number.isFinite(raw?.at) ? raw.at : Date.now(),
3138
+ pinned: raw?.pinned === true,
3139
+ slash: raw?.slash === true,
3140
+ usedAt: Number.isFinite(raw?.usedAt) ? raw.usedAt : undefined,
3141
+ uses: Number.isFinite(raw?.uses) ? Math.max(0, Math.round(raw.uses)) : 0,
3142
+ }
3143
+ const existing = raw?.id ? state.items.find((entry) => entry.id === raw.id) : null
3144
+ if (existing) {
3145
+ if (incoming.at > (existing.at ?? 0)) {
3146
+ Object.assign(existing, incoming)
3147
+ updated++
3148
+ }
3149
+ continue
3150
+ }
3151
+ state.items.unshift({ id: raw?.id ? String(raw.id).slice(0, 64) : randomUUID(), ...incoming })
3152
+ added++
3153
+ }
3154
+ const overflow = state.items.filter((item) => !item.pinned).length - maxItems
3155
+ if (overflow > 0) {
3156
+ const drop = new Set(state.items.filter((item) => !item.pinned).slice(-overflow).map((item) => item.id))
3157
+ state.items = state.items.filter((item) => !drop.has(item.id))
3158
+ }
3159
+ if (added + updated > 0) schedule()
3160
+ return { added, updated }
3161
+ },
3162
+ }
3163
+ }
3164
+
3165
+ function apply(ctx, config) {
3166
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
3167
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。回放必须在
3168
+ // createStore 之前,重启后上次的 maxItems 才对裁剪生效。
3169
+ const live = { ...config }
3170
+ if (!live.enabled) return
3171
+
3172
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
3173
+ const SETTINGS_KEYS = ['maxItems']
3174
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-superinput', 'prompts.settings.json')
3175
+ try {
3176
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
3177
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
3178
+ } catch { /* 无存档:全部跟随配置 */ }
3179
+ let settingsTimer = null
3180
+ const persistSettings = () => {
3181
+ if (settingsTimer) return
3182
+ settingsTimer = setTimeout(() => {
3183
+ settingsTimer = null
3184
+ try {
3185
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
3186
+ const out = {}
3187
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
3188
+ const tmp = `${SETTINGS_PATH}.tmp`
3189
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
3190
+ renameSync(tmp, SETTINGS_PATH)
3191
+ } catch { /* ignore */ }
3192
+ }, 400)
3193
+ }
3194
+
3195
+ const store = createStore(Math.max(20, Math.round(live.maxItems ?? 500)))
3196
+ store.load()
3197
+ ctx.effect(() => store.dispose(), 'dsh-superinput: prompts store')
3198
+
3199
+ ctx.inject(['webServer'], (webCtx) => {
3200
+ webCtx.effect(() => webCtx.webServer.register({
3201
+ kind: 'prefix',
3202
+ path: '/api/dsh-superinput/prompts',
3203
+ handler: async (req, res) => {
3204
+ const send = (status, body) => {
3205
+ res.statusCode = status
3206
+ res.setHeader('content-type', 'application/json')
3207
+ res.end(JSON.stringify(body))
3208
+ }
3209
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-superinput\/prompts/, '')
3210
+ const readBody = async () => {
3211
+ let body = ''
3212
+ for await (const chunk of req) body += chunk
3213
+ try { return JSON.parse(body) } catch { return {} }
3214
+ }
3215
+
3216
+ if (path === '/settings' && req.method === 'GET') {
3217
+ send(200, { ...live })
3218
+ return
3219
+ }
3220
+ // 运行时改参数:只接受白名单字段,其余忽略(provider/model 这类
3221
+ // 改了要重建路由,运行时改无效,不如不给入口)。
3222
+ if (path === '/settings' && req.method === 'POST') {
3223
+ let raw = ''
3224
+ for await (const chunk of req) raw += chunk
3225
+ let body
3226
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
3227
+ if (body.maxItems !== undefined) { const v = Number(body.maxItems); if (Number.isFinite(v)) live.maxItems = Math.min(5000, Math.max(20, Math.round(v))) }
3228
+ persistSettings()
3229
+ send(200, { ...live })
3230
+ return
3231
+ }
3232
+ // 套件客户端统一诊断通道(0.4.0 起从 snippets 迁来):落
3233
+ // ~/.dsh/superinput-debug.log,与服务端 trace 同文件便于一次排查。
3234
+ if (path === '/diag' && req.method === 'POST') {
3235
+ let raw = ''
3236
+ for await (const chunk of req) raw += chunk
3237
+ try {
3238
+ let message = raw
3239
+ try { message = JSON.parse(raw).message ?? raw } catch { /* 纯文本也收 */ }
3240
+ mkdirSync(dirname(CLIENT_DIAG), { recursive: true })
3241
+ appendFileSync(CLIENT_DIAG, `${new Date().toISOString()} ${String(message).slice(0, 500)}\n`, 'utf8')
3242
+ } catch { /* 落盘失败不影响应答 */ }
3243
+ send(200, { ok: true })
3244
+ return
3245
+ }
3246
+ if (path === '/list' && req.method === 'GET') {
3247
+ send(200, { items: store.list() })
3248
+ return
3249
+ }
3250
+ if (path === '/save' && req.method === 'POST') {
3251
+ send(200, store.save(await readBody()))
3252
+ return
3253
+ }
3254
+ if (path === '/remove' && req.method === 'POST') {
3255
+ const body = await readBody()
3256
+ send(200, { ok: store.remove(String(body.id ?? '')) })
3257
+ return
3258
+ }
3259
+ if (path === '/pin' && req.method === 'POST') {
3260
+ const body = await readBody()
3261
+ send(200, { ok: store.pin(String(body.id ?? ''), body.pinned === true) })
3262
+ return
3263
+ }
3264
+ if (path === '/use' && req.method === 'POST') {
3265
+ const body = await readBody()
3266
+ send(200, { ok: store.use(String(body.id ?? '')) })
3267
+ return
3268
+ }
3269
+ if (path === '/slash' && req.method === 'POST') {
3270
+ const body = await readBody()
3271
+ send(200, { ok: store.slash(String(body.id ?? ''), body.slash === true) })
3272
+ return
3273
+ }
3274
+ if (path === '/export' && req.method === 'GET') {
3275
+ send(200, { version: 1, exportedAt: Date.now(), items: store.list() })
3276
+ return
3277
+ }
3278
+ // 兼容两种载荷:导出文件原样({items:[...]})和裸数组。
3279
+ if (path === '/import' && req.method === 'POST') {
3280
+ const body = await readBody()
3281
+ const items = Array.isArray(body) ? body : Array.isArray(body?.items) ? body.items : null
3282
+ if (!items) { send(400, { error: 'invalid-payload' }); return }
3283
+ send(200, { ok: true, ...store.import(items) })
3284
+ return
3285
+ }
3286
+ send(404, { error: 'not-found' })
3287
+ },
3288
+ }), 'dsh-superinput: prompts endpoints')
3289
+ })
3290
+ }
3291
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
3292
+ })()
3293
+
3294
+ /* ==== module: hub(手写) ==== */
3295
+ const hubModule = (() => {
3296
+ /**
3297
+ * dsh-superinput · hub 模块(设置总览)
3298
+ *
3299
+ * 把各模块的**最终生效配置**聚合成一张表,供设置页展示。
3300
+ *
3301
+ * 为什么不做写入:dsh 的插件 Config 在启动时由宿主读取,运行时改不了。
3302
+ * 各模块自己的 /settings 端点改的是内存 live 副本 + storages 持久化,
3303
+ * 重启后由模块自己回放。这里只回答「现在是什么状态」。
3304
+ *
3305
+ * 数据来源两层:apply 时聚合层塞进来的 snapshot(defaults 与宿主配置合并),
3306
+ * 叠加各模块落盘的显式改动(<storages>/dsh-superinput/<mod>.settings.json)——
3307
+ * 只吐快照会在重启后与实际运行值不符(快照不知道回放)。
3308
+ */
3309
+
3310
+
3311
+
3312
+
3313
+
3314
+ const moduleInject = []
3315
+
3316
+ const Config = Schema.object({
3317
+ enabled: Schema.boolean().default(true).description('在设置页显示「输入栏」总览'),
3318
+ })
3319
+
3320
+ /** 各模块持久化文件名(与各模块 SETTINGS_PATH 保持一致)。 */
3321
+ const PERSISTED_FILES = [
3322
+ ['cost', 'cost.settings.json'],
3323
+ ['enhancer', 'enhancer.settings.json'],
3324
+ ['followup', 'followup.settings.json'],
3325
+ ['picker', 'picker.settings.json'],
3326
+ ['prompts', 'prompts.settings.json'],
3327
+ ]
3328
+
3329
+ function loadPersisted() {
3330
+ const persisted = {}
3331
+ const dir = join(homedir(), '.dsh', 'storages', 'dsh-superinput')
3332
+ for (const [mod, file] of PERSISTED_FILES) {
3333
+ try {
3334
+ const saved = JSON.parse(readFileSync(join(dir, file), 'utf8'))
3335
+ if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) persisted[mod] = saved
3336
+ } catch { /* 模块无存档:跟随配置 */ }
3337
+ }
3338
+ return persisted
3339
+ }
3340
+
3341
+ function apply(ctx, config) {
3342
+ if (!config.enabled) return
3343
+ const snapshot = config.snapshot ?? {}
3344
+
3345
+ ctx.inject(['webServer'], (webCtx) => {
3346
+ webCtx.effect(() => webCtx.webServer.register({
3347
+ kind: 'prefix',
3348
+ path: '/api/dsh-superinput/hub',
3349
+ handler: async (req, res) => {
3350
+ const send = (status, body) => {
3351
+ res.statusCode = status
3352
+ res.setHeader('content-type', 'application/json')
3353
+ res.end(JSON.stringify(body))
3354
+ }
3355
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-superinput\/hub/, '')
3356
+ if (path === '/state' && req.method === 'GET') {
3357
+ const persisted = loadPersisted()
3358
+ const modules = {}
3359
+ for (const [mod, cfg] of Object.entries(snapshot)) modules[mod] = { ...cfg, ...(persisted[mod] ?? {}) }
3360
+ for (const [mod, saved] of Object.entries(persisted)) {
3361
+ if (!modules[mod]) modules[mod] = { ...saved }
3362
+ else modules[mod].persistedKeys = Object.keys(saved)
3363
+ }
3364
+ send(200, { modules })
3365
+ return
3366
+ }
3367
+ send(404, { error: 'not-found' })
3368
+ },
3369
+ }), 'dsh-superinput: hub endpoint')
3370
+ })
3371
+ }
3372
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
3373
+ })()
3374
+
3375
+ const MODULES = [
3376
+ ['cost', costModule],
3377
+ ['picker', pickerModule],
3378
+ ['enhancer', enhancerModule],
3379
+ ['followup', followupModule],
3380
+ ['voice', voiceModule],
3381
+ ['prompts', promptsModule],
3382
+ ['hub', hubModule],
3383
+ ]
3384
+
3385
+ export const name = 'superinput'
3386
+
3387
+ export const inject = [...new Set(MODULES.flatMap(([, mod]) => mod.inject ?? []))]
3388
+
3389
+ export const Config = Schema.object({
3390
+ cost: costModule.Config,
3391
+ picker: pickerModule.Config,
3392
+ enhancer: enhancerModule.Config,
3393
+ followup: followupModule.Config,
3394
+ voice: voiceModule.Config,
3395
+ prompts: promptsModule.Config,
3396
+ hub: hubModule.Config,
3397
+ })
3398
+
3399
+ /**
3400
+ * 嵌套默认值兜底:宿主对插件 row config 的解析深度未知——套件层自己跑
3401
+ * 一遍每个模块的 Config({}) 拿完整默认,与宿主传入的 slice 合并。
3402
+ */
3403
+ const DEFAULTS = new Map()
3404
+ for (const [key, mod] of MODULES) {
3405
+ try { DEFAULTS.set(key, mod.Config ? mod.Config({}) : {}) } catch { DEFAULTS.set(key, {}) }
3406
+ }
3407
+
3408
+ export function apply(ctx, config) {
3409
+ const keys = Object.keys(config ?? {})
3410
+ console.info(`[dsh-superinput] server apply: host config keys=${keys.length ? keys.join(',') : '(none)'} — merging schema defaults`)
3411
+ // 先算出每个模块的最终生效配置,hub 用它做总览(自己拿不到别人的 config)。
3412
+ const merged = {}
3413
+ for (const [key] of MODULES) {
3414
+ merged[key] = { ...(DEFAULTS.get(key) ?? {}), ...(config?.[key] ?? {}) }
3415
+ }
3416
+ for (const [key, mod] of MODULES) {
3417
+ try {
3418
+ const own = merged[key]
3419
+ mod.apply(ctx, key === 'hub' ? { ...own, snapshot: merged } : own)
3420
+ trace(`module "${key}" applied OK`)
3421
+ } catch (error) {
3422
+ console.error(`[dsh-superinput] server module "${key}" failed:`, error)
3423
+ trace(`module "${key}" FAILED: ${String(error?.stack ?? error).slice(0, 600)}`)
3424
+ }
3425
+ }
3426
+ }
3427
+
3428
+ export default { name, Config, apply }