dsh-superinput 0.7.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js CHANGED
@@ -8,6 +8,7 @@ import Schema from '@deepseek-ai/schemastery'
8
8
  import { AsyncLocalStorage } from 'node:async_hooks'
9
9
  import { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
10
10
  import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, utimesSync, writeSync, writeFileSync, appendFileSync } from 'node:fs'
11
+ import { createRequire } from 'node:module'
11
12
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
12
13
  import { defineTool } from '@deepseek-ai/dsh-tools'
13
14
  import { dirname, join } from 'node:path'
@@ -721,6 +722,465 @@ const moduleInject = []
721
722
  /** 官方余额查询:host 白名单(安全约束——只允许官方域名,内网/环回不可能出现)。 */
722
723
  const BALANCE_HOST = 'api.deepseek.com'
723
724
 
725
+ /* ------------------------------------------------------------------ */
726
+ /* Coding Plan 订阅配额(对标 dsh-cost-meter lib/coding-plans.js 的 */
727
+ /* Bearer 子集:端点/解析/降级链照抄上游,全部官方域名硬编码白名单) */
728
+ /* ------------------------------------------------------------------ */
729
+
730
+ /** 0-100 已用百分比(保留 1 位)。 */
731
+ const clampPct = (p) => Math.max(0, Math.min(100, Math.round(p * 10) / 10))
732
+
733
+ /** 归一化百分比:0-1 视为小数,>=1 视为已是百分数;非法 → null。 */
734
+ function normalizePercent(value) {
735
+ const n = Number(value)
736
+ if (!Number.isFinite(n) || n < 0) return null
737
+ return clampPct(n <= 1 ? n * 100 : n)
738
+ }
739
+
740
+ /** 归一化重置时刻:unix 秒 / 毫秒 / ISO 字符串 → ISO 字符串;非法 → ''。 */
741
+ function normalizeResetAt(value) {
742
+ if (typeof value === 'string' && value.length > 0) {
743
+ const ms = Date.parse(value)
744
+ if (Number.isFinite(ms)) return new Date(ms).toISOString()
745
+ const asNum = Number(value)
746
+ if (Number.isFinite(asNum) && asNum > 0) return new Date(asNum > 1e12 ? asNum : asNum * 1000).toISOString()
747
+ return ''
748
+ }
749
+ const n = Number(value)
750
+ if (!Number.isFinite(n) || n <= 0) return ''
751
+ return new Date(n > 1e12 ? n : n * 1000).toISOString()
752
+ }
753
+
754
+ /** 组装文本窗口(余额等无百分比的量):text 空 → null。 */
755
+ const textWindowOf = (text) => {
756
+ const s = String(text ?? '').trim()
757
+ return s.length > 0 ? { resetsAt: '', text: s } : null
758
+ }
759
+
760
+ /** 单窗口:utilization(0-100 或 0-1)+ resets(unix 秒/毫秒/ISO)。 */
761
+ function windowOf(utilization, resetsAt) {
762
+ const percent = normalizePercent(utilization)
763
+ return percent === null ? null : { percent, resetsAt: normalizeResetAt(resetsAt) }
764
+ }
765
+
766
+ /** Anthropic:{ five_hour: { utilization, resets_at }, seven_day: {...}, ... };子配额窗口丢弃。 */
767
+ function parseAnthropicUsage(data) {
768
+ if (data === null || typeof data !== 'object') return null
769
+ const windows = {}
770
+ for (const [name, raw] of Object.entries(data)) {
771
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
772
+ if (/^(five[_ -]?hour|seven[_ -]?day)[_ -]/i.test(name)) continue
773
+ if (name === 'iguana_necktie') continue
774
+ const win = windowOf(raw.utilization ?? raw.used_percentage, raw.resets_at ?? raw.reset_at)
775
+ if (win !== null) windows[name] = win
776
+ }
777
+ return Object.keys(windows).length > 0 ? windows : null
778
+ }
779
+
780
+ /** Z.ai / 智谱 GLM:monitor 端点 limits 数组(unit 3=小时档/6=周档)→ plans 数组 → 扁平窗口。 */
781
+ function parseZaiUsage(data) {
782
+ if (data === null || typeof data !== 'object') return null
783
+ const windows = {}
784
+ const limits = data.data !== null && typeof data.data === 'object' && Array.isArray(data.data.limits)
785
+ ? data.data.limits : null
786
+ if (limits !== null) {
787
+ const monWindows = {}
788
+ const rest = []
789
+ for (const limit of limits) {
790
+ if (limit === null || typeof limit !== 'object' || (limit.type !== 'TOKENS_LIMIT' && limit.type !== 'CREDIT_LIMIT')) continue
791
+ const pct = limit.percentage !== undefined ? clampPct(Number(limit.percentage))
792
+ : Number.isFinite(Number(limit.usage)) && Number(limit.usage) > 0 && Number.isFinite(Number(limit.currentValue))
793
+ ? clampPct((Number(limit.currentValue) / Number(limit.usage)) * 100)
794
+ : null
795
+ if (pct === null) continue
796
+ const resetsAt = normalizeResetAt(limit.nextResetTime)
797
+ const unit = Number(limit.unit)
798
+ if (unit === 3 && monWindows.fiveHour === undefined) monWindows.fiveHour = { percent: pct, resetsAt }
799
+ else if (unit === 6 && monWindows.weekly === undefined) monWindows.weekly = { percent: pct, resetsAt }
800
+ else if (!Number.isFinite(unit)) rest.push({ pct, resetsAt, resetMs: Number(limit.nextResetTime) })
801
+ }
802
+ if (rest.length > 0) {
803
+ rest.sort((a, b) => {
804
+ const av = Number.isFinite(a.resetMs) && a.resetMs > 0 ? a.resetMs : 0
805
+ const bv = Number.isFinite(b.resetMs) && b.resetMs > 0 ? b.resetMs : 0
806
+ return av - bv
807
+ })
808
+ for (const item of rest) {
809
+ if (monWindows.fiveHour === undefined) monWindows.fiveHour = { percent: item.pct, resetsAt: item.resetsAt }
810
+ else if (monWindows.weekly === undefined) monWindows.weekly = { percent: item.pct, resetsAt: item.resetsAt }
811
+ }
812
+ }
813
+ if (Object.keys(monWindows).length > 0) return monWindows
814
+ }
815
+ if (Array.isArray(data.plans)) {
816
+ for (const plan of data.plans) {
817
+ if (plan === null || typeof plan !== 'object') continue
818
+ const total = Number(plan.total_units)
819
+ const used = Number(plan.used_units)
820
+ const pct = Number.isFinite(total) && total > 0 && Number.isFinite(used)
821
+ ? clampPct((used / total) * 100)
822
+ : normalizePercent(plan.utilization ?? plan.percent ?? plan.used_percentage)
823
+ if (pct === null) continue
824
+ const spanMs = Number(plan.period_end) * 1000 - Date.now()
825
+ const key = Number.isFinite(spanMs) && spanMs > 24 * 3600_000 ? 'weekly' : 'fiveHour'
826
+ windows[key] = { percent: pct, resetsAt: normalizeResetAt(plan.period_end) }
827
+ }
828
+ }
829
+ for (const [name, raw] of Object.entries(data)) {
830
+ if (name === 'plans' || raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
831
+ const win = windowOf(raw.utilization ?? raw.percent ?? raw.used_percentage, raw.resets_at ?? raw.reset_at ?? raw.resetsAt)
832
+ if (win !== null) windows[name] = win
833
+ }
834
+ return Object.keys(windows).length > 0 ? windows : null
835
+ }
836
+
837
+ /** MiniMax 单条记录的剩余百分比(优先 *_remaining_percent,total 常为 0)。 */
838
+ function remainingPercentOf(row, remainPctKey, totalKey, usedKey, remainKey) {
839
+ if (row === null || typeof row !== 'object') return null
840
+ const numeric = (key) => (row[key] == null || row[key] === '' ? NaN : Number(row[key]))
841
+ const rp = numeric(remainPctKey)
842
+ if (Number.isFinite(rp)) return Math.min(100, Math.max(0, rp <= 1 ? rp * 100 : rp))
843
+ const total = numeric(totalKey)
844
+ const used = numeric(usedKey)
845
+ const remain = numeric(remainKey)
846
+ if (Number.isFinite(total) && total > 0) {
847
+ if (Number.isFinite(remain)) return (remain / total) * 100
848
+ if (Number.isFinite(used)) return ((total - used) / total) * 100
849
+ }
850
+ return null
851
+ }
852
+
853
+ /** MiniMax 单条记录 → 5h/7d 窗口;status=3 为不限量,保留显式 ∞。 */
854
+ function windowsFromMiniMaxRecord(row) {
855
+ if (row === null || typeof row !== 'object') return {}
856
+ const windows = {}
857
+ if (Number(row.current_interval_status) === 3) {
858
+ windows['5h'] = { unlimited: true, text: '∞', resetsAt: '' }
859
+ } else {
860
+ const remain = remainingPercentOf(row, 'current_interval_remaining_percent', 'current_interval_total_count', 'current_interval_usage_count', 'current_interval_remain_count')
861
+ if (remain !== null) {
862
+ windows['5h'] = { percent: clampPct(100 - remain), resetsAt: normalizeResetAt(row.end_time ?? row.reset_time ?? row.next_reset_time) }
863
+ }
864
+ }
865
+ if (Number(row.current_weekly_status) === 3) {
866
+ windows['7d'] = { unlimited: true, text: '∞', resetsAt: '' }
867
+ } else {
868
+ const remain = remainingPercentOf(row, 'current_weekly_remaining_percent', 'current_weekly_total_count', 'current_weekly_usage_count', 'current_weekly_remain_count')
869
+ if (remain !== null) {
870
+ windows['7d'] = { percent: clampPct(100 - remain), resetsAt: normalizeResetAt(row.weekly_end_time) }
871
+ }
872
+ }
873
+ return windows
874
+ }
875
+
876
+ /** 选 MiniMax 通用额度行:general → MiniMax-M* → 第一条可解析行。 */
877
+ function pickMiniMaxModelRow(rows) {
878
+ const list = rows.filter((row) => row !== null && typeof row === 'object' && !/^(video|speech|audio|image)(?:$|[-_])/i.test(String(row.model_name ?? '')))
879
+ const byName = (name) => list.find((row) => String(row.model_name ?? '').toLowerCase() === name)
880
+ return byName('general')
881
+ ?? list.find((row) => /^minimax-m/i.test(String(row.model_name ?? '')))
882
+ ?? list.find((row) => Object.keys(windowsFromMiniMaxRecord(row)).length > 0)
883
+ ?? list[0]
884
+ ?? null
885
+ }
886
+
887
+ /** MiniMax:token_plan/remains(现行 model_remains)→ 平铺 → 旧数组形态。 */
888
+ function parseMiniMaxRemains(data) {
889
+ if (data === null || typeof data !== 'object') return null
890
+ const payload = data.data !== null && typeof data.data === 'object' && !Array.isArray(data.data) ? data.data : data
891
+ const pickArray = (...keys) => {
892
+ for (const key of keys) {
893
+ if (Array.isArray(data?.[key])) return data[key]
894
+ if (Array.isArray(data?.data?.[key])) return data.data[key]
895
+ }
896
+ return null
897
+ }
898
+ const modelRows = pickArray('model_remains')
899
+ if (modelRows !== null) {
900
+ const fromRow = windowsFromMiniMaxRecord(pickMiniMaxModelRow(modelRows))
901
+ if (Object.keys(fromRow).length > 0) return fromRow
902
+ // 旧计数制:无 remaining_percent、靠 total>0 汇总。
903
+ let total = 0
904
+ let used = 0
905
+ let found = false
906
+ for (const item of modelRows) {
907
+ if (item === null || typeof item !== 'object') continue
908
+ const t = Number(item.current_interval_total_count ?? item.total)
909
+ const u = Number(item.current_interval_usage_count ?? item.used)
910
+ if (!Number.isFinite(t) || t <= 0) continue
911
+ found = true
912
+ total += t
913
+ used += Number.isFinite(u) ? u : 0
914
+ }
915
+ if (found && total > 0) return { current: { percent: clampPct((used / total) * 100), resetsAt: '' } }
916
+ }
917
+ const flat = windowsFromMiniMaxRecord(payload)
918
+ if (Object.keys(flat).length > 0) return flat
919
+ const windows = {}
920
+ const planRows = pickArray('token_plan_remains', 'plan_remains', 'remains', 'windows')
921
+ if (planRows !== null) {
922
+ planRows.forEach((row, index) => {
923
+ if (row === null || typeof row !== 'object') return
924
+ const total = Number(row.current_interval_total_count ?? row.total_count ?? row.total ?? row.limit)
925
+ const used = Number(row.current_interval_usage_count ?? row.used_count ?? row.usage_count ?? row.used)
926
+ const remain = Number(row.current_interval_remain_count ?? row.remain_count ?? row.remain ?? row.remaining)
927
+ let pct = null
928
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) pct = (used / total) * 100
929
+ else if (Number.isFinite(total) && total > 0 && Number.isFinite(remain)) pct = ((total - remain) / total) * 100
930
+ else pct = normalizePercent(row.utilization ?? row.percent ?? row.used_percentage)
931
+ if (pct === null) return
932
+ const labelRaw = row.interval ?? row.interval_type ?? row.window_type ?? row.type ?? row.name
933
+ const label = typeof labelRaw === 'string' && labelRaw.length > 0 ? labelRaw : `window${index + 1}`
934
+ windows[label] = { percent: clampPct(pct), resetsAt: normalizeResetAt(row.reset_time ?? row.resets_at ?? row.next_reset_time ?? row.reset_at) }
935
+ })
936
+ }
937
+ return Object.keys(windows).length > 0 ? windows : null
938
+ }
939
+
940
+ /** Kimi Code 订阅配额(api.kimi.com/coding/v1/usages,Kimi CLI 同款接口)。 */
941
+ function parseKimiCodingUsage(data) {
942
+ if (data === null || typeof data !== 'object') return null
943
+ const payload = data.data && typeof data.data === 'object' ? data.data : data
944
+ const quota = payload.quota && typeof payload.quota === 'object' ? payload.quota : payload
945
+ const windows = {}
946
+ const numeric = (value) => (value == null || typeof value === 'boolean' || String(value).trim() === '' ? NaN : Number(value))
947
+ const counter = (row) => {
948
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return null
949
+ const total = numeric(row.limit)
950
+ const used = numeric(row.used)
951
+ const remaining = numeric(row.remaining)
952
+ const pct = total > 0 && Number.isFinite(total)
953
+ ? Number.isFinite(used) && used >= 0 ? (used / total) * 100
954
+ : Number.isFinite(remaining) && remaining >= 0 ? ((total - remaining) / total) * 100 : NaN
955
+ : NaN
956
+ return Number.isFinite(pct) ? { percent: clampPct(pct), resetsAt: normalizeResetAt(row.resetTime ?? row.reset_time ?? row.resetAt ?? row.resetsAt) } : null
957
+ }
958
+ const usages = quota.usages ?? {}
959
+ const shapes = [['5h', ['limit_5h', 'limit5h']], ['weekly', ['limit_7d', 'limit7d']], ['monthly', ['limit_month_total', 'limitMonthTotal']]]
960
+ for (const [name, aliases] of shapes) {
961
+ const raw = aliases.map((key) => usages[key]).find((row) => row && typeof row === 'object' && !Array.isArray(row))
962
+ if (!raw) continue
963
+ const counted = counter(raw)
964
+ const ratio = numeric(raw.usedRatio ?? raw.used_ratio)
965
+ if (counted) windows[name] = counted
966
+ else if (Number.isFinite(ratio) && ratio >= 0 && ratio <= 1) {
967
+ windows[name] = { percent: clampPct(ratio * 100), resetsAt: normalizeResetAt(raw.resetTime ?? raw.reset_time ?? raw.resetAt) }
968
+ }
969
+ }
970
+ const usage = counter(quota.usage)
971
+ if (usage && (!Object.keys(usages).length || usages.limit_7d || usages.limit7d)) windows.weekly = usage
972
+ const limits = Array.isArray(quota.limits) ? quota.limits : []
973
+ limits.forEach((row, index) => {
974
+ const parsed = counter(row?.detail)
975
+ if (!parsed) return
976
+ const duration = numeric(row.window?.duration)
977
+ const unitRaw = String(row.window?.timeUnit ?? '').toLowerCase().replace(/^time_unit_/, '')
978
+ const unit = unitRaw.startsWith('hour') ? 'h' : unitRaw.startsWith('day') ? 'd'
979
+ : unitRaw.startsWith('week') ? 'w' : unitRaw.startsWith('minute') ? 'm'
980
+ : unitRaw.startsWith('month') ? 'mo' : unitRaw.slice(0, 4)
981
+ const name = unit === 'm' && duration === 300 ? '5h'
982
+ : Number.isFinite(duration) && duration > 0 && unit.length > 0 ? `${duration}${unit}` : `window${index + 1}`
983
+ windows[name] = parsed
984
+ })
985
+ return Object.keys(windows).length > 0 ? windows : null
986
+ }
987
+
988
+ /** Kimi / Moonshot PAYG 余额(人民币分;<100 视为已是元)。 */
989
+ function parseKimiBalance(data) {
990
+ if (data === null || typeof data !== 'object') return null
991
+ const raw = data.available_balance ?? data.balance ?? data.cash_balance ?? data.data?.available_balance
992
+ const n = Number(raw)
993
+ if (!Number.isFinite(n) || n < 0) return null
994
+ const cny = n >= 100 ? n / 100 : n
995
+ const win = textWindowOf(`¥${(Math.round(cny * 100) / 100).toFixed(2)}`)
996
+ return win === null ? null : { balance: win }
997
+ }
998
+
999
+ /** OpenRouter:{ data: { total_credits, total_usage } }(美元)→ 已用%。 */
1000
+ function parseOpenRouterCredits(data) {
1001
+ if (data === null || typeof data !== 'object') return null
1002
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
1003
+ const total = Number(d.total_credits ?? d.credits)
1004
+ const used = Number(d.total_usage ?? d.usage)
1005
+ if (!Number.isFinite(total) || total <= 0 || !Number.isFinite(used)) return null
1006
+ return { credits: { percent: clampPct((used / total) * 100), resetsAt: normalizeResetAt(d.resets_at ?? d.next_reset_time) } }
1007
+ }
1008
+
1009
+ /** SiliconFlow:balance/amount/remain(人民币)→ 文本窗口。 */
1010
+ function parseSiliconFlowInfo(data) {
1011
+ if (data === null || typeof data !== 'object') return null
1012
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
1013
+ const n = Number(d.balance ?? d.amount ?? d.remain ?? d.remaining)
1014
+ if (!Number.isFinite(n) || n < 0) return null
1015
+ const win = textWindowOf(`¥${(Math.round(n * 100) / 100).toFixed(2)}`)
1016
+ return win === null ? null : { balance: win }
1017
+ }
1018
+
1019
+ /** CommandCode:windowLimits(used/cap)+ 月度 Credits 余额文本。 */
1020
+ function parseCommandCodeCredits(data) {
1021
+ if (data === null || typeof data !== 'object') return null
1022
+ const windows = {}
1023
+ const limits = data.windowLimits
1024
+ if (limits !== null && typeof limits === 'object' && !Array.isArray(limits)) {
1025
+ for (const [name, raw] of Object.entries(limits)) {
1026
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
1027
+ const used = Number(raw.used)
1028
+ const cap = Number(raw.cap)
1029
+ if (!Number.isFinite(used) || !Number.isFinite(cap) || cap <= 0 || used < 0) continue
1030
+ windows[name] = { percent: clampPct((used / cap) * 100), resetsAt: normalizeResetAt(raw.resetAt) }
1031
+ }
1032
+ }
1033
+ const monthly = Number(data.credits !== null && typeof data.credits === 'object' ? data.credits.monthlyCredits : undefined)
1034
+ if (Number.isFinite(monthly) && monthly >= 0) {
1035
+ const win = textWindowOf(`$${(Math.round(monthly * 100) / 100).toFixed(2)}`)
1036
+ if (win !== null) windows.monthly = win
1037
+ }
1038
+ return Object.keys(windows).length > 0 ? windows : null
1039
+ }
1040
+
1041
+ /** Cline Pass:limits[].percentUsed 已是 0-100 已用百分比,不走 0-1 猜测。 */
1042
+ function parseClinePassUsage(data) {
1043
+ if (data === null || typeof data !== 'object') return null
1044
+ const limits = data.data !== null && typeof data.data === 'object' ? data.data.limits : undefined
1045
+ if (!Array.isArray(limits)) return null
1046
+ const names = new Map([['five_hour', 'fiveHour'], ['weekly', 'weekly'], ['monthly', 'monthly']])
1047
+ const windows = {}
1048
+ for (const entry of limits) {
1049
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
1050
+ const name = names.get(entry.type)
1051
+ const raw = entry.percentUsed
1052
+ const percent = typeof raw === 'number'
1053
+ ? raw
1054
+ : typeof raw === 'string' && /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(raw.trim()) ? Number(raw) : Number.NaN
1055
+ if (name === undefined || !Number.isFinite(percent) || percent < 0) continue
1056
+ windows[name] = { percent: clampPct(percent), resetsAt: normalizeResetAt(entry.resetsAt) }
1057
+ }
1058
+ return Object.keys(windows).length > 0 ? windows : null
1059
+ }
1060
+
1061
+ const CODING_PLAN_PARSERS = {
1062
+ anthropic: parseAnthropicUsage,
1063
+ zai: parseZaiUsage,
1064
+ minimax: parseMiniMaxRemains,
1065
+ openrouter: parseOpenRouterCredits,
1066
+ siliconflow: parseSiliconFlowInfo,
1067
+ commandcode: parseCommandCodeCredits,
1068
+ clinepass: parseClinePassUsage,
1069
+ }
1070
+
1071
+ /**
1072
+ * 官方端点白名单(硬编码,配置不可改;MiniMax 是唯一例外——允许显式 HTTPS
1073
+ * origin 覆盖,且经 normalizeMiniMaxOrigin 校验为公网域名)。顺序即降级链:
1074
+ * 认证失败(401/403)与解析成功立即返回,其余错误换下一端点。
1075
+ */
1076
+ const CODING_PLAN_ENDPOINTS = {
1077
+ anthropic: ['https://api.anthropic.com/api/oauth/usage'],
1078
+ zai: [
1079
+ 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
1080
+ 'https://api.z.ai/api/monitor/usage/quota/limit',
1081
+ 'https://api.z.ai/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
1082
+ 'https://open.bigmodel.cn/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
1083
+ ],
1084
+ minimax: [
1085
+ 'https://www.minimax.cn/v1/token_plan/remains',
1086
+ 'https://www.minimax.io/v1/token_plan/remains',
1087
+ 'https://www.minimaxi.com/v1/token_plan/remains',
1088
+ ],
1089
+ kimi: [
1090
+ 'https://api.kimi.com/coding/v1/usages',
1091
+ 'https://api.kimi.com/coding/v1/usage',
1092
+ 'https://api.moonshot.cn/v1/users/me/balance',
1093
+ ],
1094
+ openrouter: ['https://openrouter.ai/api/v1/credits'],
1095
+ siliconflow: ['https://api.siliconflow.cn/v1/user/info'],
1096
+ commandcode: ['https://api.commandcode.ai/alpha/billing/credits'],
1097
+ clinepass: ['https://api.cline.bot/api/v1/users/me/plan/usage-limits'],
1098
+ }
1099
+
1100
+ const CODING_PLAN_IDS = Object.keys(CODING_PLAN_ENDPOINTS)
1101
+
1102
+ /**
1103
+ * MiniMax 自定源归一化:只接受 HTTPS origin(无路径/凭据/空白),并拒绝
1104
+ * 环回/内网/链路本地/裸 IP —— SSRF 硬化比上游更严(上游只查 HTTPS origin)。
1105
+ */
1106
+ function normalizeMiniMaxOrigin(value) {
1107
+ if (value === undefined || value === null || value === '') return ''
1108
+ if (typeof value !== 'string' || value.length > 2048) return null
1109
+ const text = value.trim()
1110
+ if (!text) return ''
1111
+ if (!/^https:\/\/[^/?#]+\/?$/i.test(text) || /[\s\\@?#]/.test(text)) return null
1112
+ let url
1113
+ try { url = new URL(text) } catch { return null }
1114
+ const host = url.hostname.toLowerCase()
1115
+ if (!host || url.username || url.password || url.pathname !== '/') return null
1116
+ if (host === '::1' || host.endsWith('.local') || host.endsWith('.internal')) return null
1117
+ if (/^(localhost|0\.|10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return null
1118
+ if (/^\[?[0-9a-f:]+\]?$/i.test(host) && host.includes(':')) return null
1119
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null
1120
+ return url.origin
1121
+ }
1122
+
1123
+ /** 单端点抓取:Bearer 鉴权;Kimi 订阅端点须带官方 CLI 的 UA(上游实测约束)。 */
1124
+ async function fetchPlanJson(url, key, { kimiCoding = false, redirect } = {}) {
1125
+ const headers = {
1126
+ authorization: `Bearer ${key.trim()}`,
1127
+ accept: 'application/json',
1128
+ 'user-agent': 'dsh-cost (DeepSeek Harness plugin)',
1129
+ }
1130
+ if (kimiCoding) headers['user-agent'] = 'KimiCLI/1.6'
1131
+ return fetch(url, { headers, redirect, signal: AbortSignal.timeout(15_000) })
1132
+ }
1133
+
1134
+ /** 响应体有界 JSON(256KiB 上限,防异常端点拖内存)。 */
1135
+ async function readJsonBounded(res, cap = 262_144) {
1136
+ const text = await res.text()
1137
+ if (text.length > cap) throw new Error('response too large')
1138
+ return JSON.parse(text)
1139
+ }
1140
+
1141
+ /**
1142
+ * 查询单家 Coding Plan 额度:按白名单端点顺序尝试。401/403 与解析成功立即
1143
+ * 定论,其余错误换下一端点;zai/minimax 双域 Key 不互通、kimi 订阅端点 401
1144
+ * 视为「非订阅 Key」继续降级到 PAYG 余额端点(上游 issue #42/#53 同语义)。
1145
+ * 全部错误均为 soft(未订阅/Key 无效属预期场景,面板中性提示)。
1146
+ */
1147
+ async function queryCodingPlan(provider, key, origin) {
1148
+ const urls = provider === 'minimax'
1149
+ ? (() => {
1150
+ const base = normalizeMiniMaxOrigin(origin)
1151
+ if (base === null) throw Object.assign(new Error('minimax: invalid HTTPS origin'), { soft: true })
1152
+ return base ? [`${base}/v1/token_plan/remains`, `${base}/v1/api/openplatform/coding_plan/remains`] : CODING_PLAN_ENDPOINTS.minimax
1153
+ })()
1154
+ : CODING_PLAN_ENDPOINTS[provider]
1155
+ let lastError = 'HTTP error'
1156
+ for (const url of urls) {
1157
+ const kimiCoding = provider === 'kimi' && new URL(url).hostname === 'api.kimi.com'
1158
+ let res
1159
+ try {
1160
+ res = await fetchPlanJson(url, key, {
1161
+ kimiCoding,
1162
+ redirect: provider === 'minimax' ? 'error' : provider === 'zai' ? 'manual' : undefined,
1163
+ })
1164
+ } catch (error) {
1165
+ lastError = String(error?.message ?? error).slice(0, 120)
1166
+ continue
1167
+ }
1168
+ if (res.status === 401 || res.status === 403) {
1169
+ lastError = `HTTP ${res.status}`
1170
+ if (provider === 'zai' || provider === 'minimax' || kimiCoding) continue
1171
+ throw Object.assign(new Error(lastError), { soft: true })
1172
+ }
1173
+ if (!res.ok) { lastError = `HTTP ${res.status}`; continue }
1174
+ let data = null
1175
+ try { data = await readJsonBounded(res) } catch { lastError = 'invalid JSON response'; continue }
1176
+ const windows = (kimiCoding ? parseKimiCodingUsage(data) : CODING_PLAN_PARSERS[provider](data))
1177
+ if (windows) return { windows, endpoint: url }
1178
+ lastError = 'no usage windows in response'
1179
+ }
1180
+ throw Object.assign(new Error(lastError), { soft: true })
1181
+ }
1182
+
1183
+
724
1184
  const Config = Schema.object({
725
1185
  enabled: Schema.boolean().default(true).description('是否启用费用统计'),
726
1186
  /** 展示汇率(USD → CNY);金额账本恒存美元,展示时换算。 */
@@ -738,6 +1198,8 @@ const Config = Schema.object({
738
1198
  peakAlertNotify: Schema.boolean().default(false).description('同时发送系统通知'),
739
1199
  /** DeepSeek 开放平台 API Key(选填):配置后费用弹卡显示官方账户余额。 */
740
1200
  apiKey: Schema.string().default('').description('DeepSeek 开放平台 API Key(查官方余额用,可选)'),
1201
+ /** Coding Plan 订阅配额:{ 厂商: { key, origin? } },Key 在费用设置页配置、不回显。 */
1202
+ codingPlans: Schema.object({}).default({}).description('Coding Plan 订阅配额(在费用设置页配置)'),
741
1203
  })
742
1204
 
743
1205
  function apply(ctx, config) {
@@ -749,7 +1211,7 @@ function apply(ctx, config) {
749
1211
  if (!live.enabled) return
750
1212
 
751
1213
  // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
752
- const SETTINGS_KEYS = ['budgetEnabled', 'budgetAmount', 'budgetPeriod', 'peakAlertEnabled', 'peakAlertNotify', 'peakAlertLeadMinutes', 'exchangeRate', 'apiKey']
1214
+ const SETTINGS_KEYS = ['budgetEnabled', 'budgetAmount', 'budgetPeriod', 'peakAlertEnabled', 'peakAlertNotify', 'peakAlertLeadMinutes', 'exchangeRate', 'apiKey', 'codingPlans']
753
1215
  const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-cost', 'settings.json')
754
1216
  try {
755
1217
  const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
@@ -771,9 +1233,38 @@ function apply(ctx, config) {
771
1233
  }, 400)
772
1234
  }
773
1235
 
1236
+ /* -- Coding Plan 配额缓存与查询编排 -------------------------------- */
1237
+
1238
+ const PLAN_TTL_MS = 600_000
1239
+ const planCache = new Map() // provider → { at, result }
1240
+ const planKeyOf = (id) => {
1241
+ const entry = live.codingPlans?.[id]
1242
+ const key = typeof entry?.key === 'string' ? entry.key.trim() : ''
1243
+ return key.length > 0 && key.length <= 4096 ? key : ''
1244
+ }
1245
+ async function refreshCodingPlan(id, force = false) {
1246
+ const key = planKeyOf(id)
1247
+ if (!key) return { hasKey: false }
1248
+ const cached = planCache.get(id)
1249
+ if (!force && cached && Date.now() - cached.at < PLAN_TTL_MS) return cached.result
1250
+ let result
1251
+ try {
1252
+ const { windows } = await queryCodingPlan(id, key, live.codingPlans?.[id]?.origin)
1253
+ result = { hasKey: true, windows, at: Date.now() }
1254
+ void report(`coding plan ${id} refreshed: ${Object.keys(windows).join(',')}`)
1255
+ } catch (error) {
1256
+ result = { hasKey: true, error: String(error?.message ?? error).slice(0, 160), at: Date.now() }
1257
+ void report(`coding plan ${id} refresh failed: ${result.error}`)
1258
+ }
1259
+ planCache.set(id, { at: Date.now(), result })
1260
+ return result
1261
+ }
1262
+
774
1263
  const lockPath = join(STORE_DIR, 'ledger.lock')
1264
+ // 存储目录可能不存在(全新安装):锁文件 openSync(wx) 要求父目录已建。
1265
+ try { mkdirSync(STORE_DIR, { recursive: true }) } catch { /* ignore */ }
775
1266
  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'}`)
1267
+ void report(`server boot v0.10.0 pid=${process.pid} ledgerOwner=${lock.owner} retention=${live.retentionDays} rate=${live.exchangeRate} budget=${live.budgetEnabled ? `${live.budgetAmount}/${live.budgetPeriod}` : 'off'} backfill=${live.backfillEnabled && lock.owner ? 'on' : 'off'} plans=${CODING_PLAN_IDS.filter((id) => planKeyOf(id)).length}`)
777
1268
 
778
1269
  const ledger = createLedger(live.retentionDays)
779
1270
  if (lock.owner) ledger.load()
@@ -1156,7 +1647,7 @@ function apply(ctx, config) {
1156
1647
  const daysLeft = daysInMonth - nowDate.getDate()
1157
1648
  const projected = monthCost + avgDaily * daysLeft
1158
1649
 
1159
- return json({
1650
+ send(200, {
1160
1651
  monthCost,
1161
1652
  monthCalls,
1162
1653
  avgDaily,
@@ -1168,6 +1659,7 @@ function apply(ctx, config) {
1168
1659
  basis: n >= 3 ? 'recent' : 'insufficient',
1169
1660
  recent,
1170
1661
  })
1662
+ return
1171
1663
  }
1172
1664
 
1173
1665
  if (path === '/calls' && req.method === 'GET') {
@@ -1342,6 +1834,83 @@ function apply(ctx, config) {
1342
1834
  return
1343
1835
  }
1344
1836
 
1837
+ // 热力图:近 N 天逐日 { cost, calls, tokens }(GitHub 贡献图样式)。
1838
+ if (path === '/heatmap' && req.method === 'GET') {
1839
+ const url = new URL(req.url ?? '/api/dsh-cost/heatmap', 'http://localhost')
1840
+ const days = Math.min(365, Math.max(28, Number(url.searchParams.get('days') ?? 182)))
1841
+ const snap = await ledgerSnapshotFresh()
1842
+ const since = localDayKey(Date.now() - days * 86400000)
1843
+ const rows = []
1844
+ for (const key of Object.keys(snap.days).sort()) {
1845
+ if (key < since) continue
1846
+ const day = snap.days[key]
1847
+ rows.push({
1848
+ date: key,
1849
+ cost: day.cost ?? 0,
1850
+ calls: day.calls ?? 0,
1851
+ tokens: (day.input ?? 0) + (day.output ?? 0) + (day.cacheRead ?? 0) + (day.cacheWrite ?? 0) + (day.reasoning ?? 0),
1852
+ })
1853
+ }
1854
+ send(200, { days: rows })
1855
+ return
1856
+ }
1857
+
1858
+ // Coding Plan 配额:只下发窗口/错误/时间戳,Key 永不出服务端。
1859
+ const planState = () => {
1860
+ const providers = {}
1861
+ for (const id of CODING_PLAN_IDS) providers[id] = { hasKey: planKeyOf(id).length > 0 }
1862
+ for (const id of CODING_PLAN_IDS) {
1863
+ if (!providers[id].hasKey) continue
1864
+ const cached = planCache.get(id)
1865
+ if (cached) providers[id] = { ...providers[id], ...cached.result, stale: Date.now() - cached.at >= PLAN_TTL_MS }
1866
+ }
1867
+ return providers
1868
+ }
1869
+ if (path === '/coding-plans' && req.method === 'GET') {
1870
+ // 已配置且缓存过期的后台补刷:GET 不阻塞,下一轮轮询取到新值。
1871
+ for (const id of CODING_PLAN_IDS) {
1872
+ if (!planKeyOf(id)) continue
1873
+ const cached = planCache.get(id)
1874
+ if (!cached || Date.now() - cached.at >= PLAN_TTL_MS) void refreshCodingPlan(id)
1875
+ }
1876
+ send(200, { providers: planState() })
1877
+ return
1878
+ }
1879
+ if (path === '/coding-plans/refresh' && req.method === 'POST') {
1880
+ let raw = ''
1881
+ for await (const chunk of req) raw += chunk
1882
+ let body = {}
1883
+ try { body = raw ? JSON.parse(raw) : {} } catch { send(400, { error: 'invalid-json' }); return }
1884
+ const only = typeof body.provider === 'string' ? body.provider : ''
1885
+ const ids = only ? [only] : CODING_PLAN_IDS.filter((id) => planKeyOf(id))
1886
+ for (const id of ids) await refreshCodingPlan(id, true)
1887
+ send(200, { ok: true, providers: planState() })
1888
+ return
1889
+ }
1890
+ if (path === '/coding-plans/config' && req.method === 'POST') {
1891
+ let raw = ''
1892
+ for await (const chunk of req) raw += chunk
1893
+ let body
1894
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
1895
+ const id = typeof body.provider === 'string' ? body.provider : ''
1896
+ if (!CODING_PLAN_IDS.includes(id)) { send(400, { error: 'unknown-provider' }); return }
1897
+ const key = typeof body.key === 'string' ? body.key.trim().slice(0, 4096) : ''
1898
+ const merged = { ...(live.codingPlans ?? {}) }
1899
+ if (key) {
1900
+ const origin = normalizeMiniMaxOrigin(typeof body.origin === 'string' ? body.origin : undefined)
1901
+ if (origin === null) { send(400, { error: 'invalid-origin' }); return }
1902
+ merged[id] = origin ? { key, origin } : { key }
1903
+ } else {
1904
+ delete merged[id] // 空 key = 清除配置
1905
+ }
1906
+ live.codingPlans = merged
1907
+ planCache.delete(id)
1908
+ persistSettings()
1909
+ void report(`coding plan config updated: ${id} ${key ? 'set' : 'cleared'}`)
1910
+ send(200, { ok: true })
1911
+ return
1912
+ }
1913
+
1345
1914
  send(404, { error: 'not-found' })
1346
1915
  },
1347
1916
  }), 'dsh-cost: /api/dsh-cost/*')
@@ -2983,6 +3552,424 @@ const Config = Schema.object({ enabled: Schema.boolean().default(true).descripti
2983
3552
  return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
2984
3553
  })()
2985
3554
 
3555
+ /* ==== module: compaction(原 dsh-compaction-control) ==== */
3556
+ const compactionModule = (() => {
3557
+ /* 由 scripts/build-suite.mjs 从 dsh-compaction-control/index.js 机械生成;勿手改,重跑脚本再生成。 */
3558
+ /**
3559
+ * dsh-compaction-control — 上下文压缩控制中心(DeepSeek Harness / dsh)
3560
+ *
3561
+ * 三件事:
3562
+ * 1. 手动压缩:把官方 compaction 服务的 ctx.compaction.compactNow(agent, signal)
3563
+ * 暴露成 HTTP 路由,客户端面板一键触发(等价 /compact,免打字)。
3564
+ * 2. 调节自动压缩:把 {auto, thresholdRatio, retainRatio} 写进每个挂载了
3565
+ * 本插件的 profile 的用户补丁层(~/.dsh/profiles/<name>/cordis.patch.yml,
3566
+ * id 定向覆盖 compaction-basic 的 config;applyEntryPatches 后写者胜)。
3567
+ * 补丁层是 initProfile 官方创建的用户文件,插件管理器不会回收它;
3568
+ * 但 loader 只在启动时读取——保存后需重启 dsh 生效。
3569
+ * 3. 状态源:~/.dsh/compaction-control.json 是唯一事实源(含 hideBuiltIn
3570
+ * 等 UI 偏好),补丁文件由它派生,可随时重生成。
3571
+ *
3572
+ * 已核对的 dsh API(dsh-reference 源码对照):
3573
+ * - ctx.agents.get(sessionId) → Agent(core/agent AgentRegistry,实现
3574
+ * runMaintenance,满足 compaction 的 ManualCompactAgentContext)
3575
+ * - ctx.compaction.compactNow(agent, signal) → CompactionResult |
3576
+ * null;错误带 code: busy/cancelled/changed/summary/commit/persistence
3577
+ * - BasicCompactionConfig 键集(compaction-basic/src/config.ts):
3578
+ * auto/thresholdRatio/retainRatio/headroomTokens/maxTokens/
3579
+ * summarizationProvider/summarizationModel/compactionRetries/
3580
+ * maxOverflowRetries/modelPolicies —— 只写前三个,其余走默认
3581
+ * - applyEntryPatches(vendor/include):{id, name, config} 直接整体
3582
+ * 替换目标条目的 config;未知键会让 compaction-basic 加载即抛错,
3583
+ * 所以这里的 config 永远是完整合法子集
3584
+ */
3585
+
3586
+
3587
+
3588
+
3589
+
3590
+
3591
+
3592
+
3593
+ const require = createRequire(import.meta.url)
3594
+
3595
+ const name = 'compaction-control'
3596
+
3597
+ const DSH_DIR = join(homedir(), '.dsh')
3598
+ const PROFILES_DIR = join(DSH_DIR, 'profiles')
3599
+ const STATE_FILE = join(DSH_DIR, 'compaction-control.json')
3600
+ const DIAG_LOG = join(DSH_DIR, 'compaction-control-debug.log')
3601
+
3602
+ /** 与 compaction-basic 的默认对齐(thresholdRatio 0.8 / retainRatio 0.16 / auto)。 */
3603
+ const DEFAULT_STATE = { auto: true, thresholdRatio: 0.8, retainRatio: 0.16, hideBuiltIn: true, checkpoint: true }
3604
+
3605
+ const MANAGED_BEGIN = '# BEGIN dsh-compaction-control (managed)'
3606
+ const MANAGED_END = '# END dsh-compaction-control'
3607
+
3608
+ /** 补丁文件语法校验用;运行时没带 yaml 就跳过校验(写入逻辑本身保守)。 */
3609
+ let Yaml = null
3610
+ try {
3611
+ const mod = require('yaml')
3612
+ if (typeof mod?.parse === 'function') Yaml = mod
3613
+ } catch { Yaml = null }
3614
+
3615
+ const Config = Schema.object({
3616
+ /** 关闭后客户端不再注册圆环与面板(压缩行为恢复 compaction-basic 默认)。 */
3617
+ enabled: Schema.boolean().default(true).description('是否启用上下文压缩控制'),
3618
+ })
3619
+
3620
+ /** 校验并归一化一份状态;不合法直接抛中文错误给面板显示。 */
3621
+ function sanitizeState(input) {
3622
+ const ratio = (value, fallback, lo, hi, label) => {
3623
+ const n = Number(value ?? fallback)
3624
+ if (!Number.isFinite(n) || n < lo || n > hi) {
3625
+ throw new Error(`${label} 需在 ${lo}~${hi} 之间`)
3626
+ }
3627
+ return Math.round(n * 1000) / 1000
3628
+ }
3629
+ const state = {
3630
+ auto: input?.auto === undefined ? DEFAULT_STATE.auto : Boolean(input.auto),
3631
+ thresholdRatio: ratio(input?.thresholdRatio, DEFAULT_STATE.thresholdRatio, 0.3, 0.99, '触发阈值'),
3632
+ retainRatio: ratio(input?.retainRatio, DEFAULT_STATE.retainRatio, 0.02, 0.5, '保留比例'),
3633
+ hideBuiltIn: input?.hideBuiltIn === undefined ? DEFAULT_STATE.hideBuiltIn : Boolean(input.hideBuiltIn),
3634
+ checkpoint: input?.checkpoint === undefined ? DEFAULT_STATE.checkpoint : Boolean(input.checkpoint),
3635
+ }
3636
+ // compaction-basic 加载期硬约束:retainRatio >= thresholdRatio 直接抛错。
3637
+ if (state.retainRatio >= state.thresholdRatio) {
3638
+ throw new Error(`保留比例 (${state.retainRatio}) 必须小于触发阈值 (${state.thresholdRatio})`)
3639
+ }
3640
+ return state
3641
+ }
3642
+
3643
+ async function readState() {
3644
+ try {
3645
+ return sanitizeState(JSON.parse(await readFile(STATE_FILE, 'utf8')))
3646
+ } catch {
3647
+ return { ...DEFAULT_STATE }
3648
+ }
3649
+ }
3650
+
3651
+ /** 列出挂载了本插件的 profile 名(manifest 里出现包名即算)。 */
3652
+ async function listMountedProfiles() {
3653
+ let entries = []
3654
+ try { entries = await readdir(PROFILES_DIR, { withFileTypes: true }) } catch { return [] }
3655
+ const mounted = []
3656
+ for (const entry of entries) {
3657
+ if (!entry.isDirectory()) continue
3658
+ try {
3659
+ const raw = await readFile(join(PROFILES_DIR, entry.name, 'package.json'), 'utf8')
3660
+ if (raw.includes('dsh-compaction-control')) mounted.push(entry.name)
3661
+ } catch { /* 无 manifest 的目录不是 profile */ }
3662
+ }
3663
+ return mounted.sort()
3664
+ }
3665
+
3666
+ function managedBlock(state) {
3667
+ return [
3668
+ MANAGED_BEGIN,
3669
+ '- id: compaction-basic',
3670
+ " name: '@deepseek-ai/dsh-compaction-basic'",
3671
+ ' config:',
3672
+ ` auto: ${state.auto}`,
3673
+ ` thresholdRatio: ${state.thresholdRatio}`,
3674
+ ` retainRatio: ${state.retainRatio}`,
3675
+ MANAGED_END,
3676
+ ].join('\n')
3677
+ }
3678
+
3679
+ /** 纯文本手术:由现有内容与目标状态算出下一版补丁文件内容(不落盘)。 */
3680
+ function composePatch(raw, state) {
3681
+ const begin = raw.indexOf(MANAGED_BEGIN)
3682
+ const end = raw.indexOf(MANAGED_END)
3683
+ if (begin !== -1 && end !== -1 && end > begin) {
3684
+ return raw.slice(0, begin) + managedBlock(state) + raw.slice(end + MANAGED_END.length)
3685
+ }
3686
+ const stripped = raw
3687
+ .split('\n')
3688
+ .filter((line) => line.trim() !== '' && !line.trim().startsWith('#'))
3689
+ .join('')
3690
+ .trim()
3691
+ if (stripped === '[]') {
3692
+ return raw.replace('[]', managedBlock(state))
3693
+ }
3694
+ if (stripped === '') {
3695
+ return `${managedBlock(state)}\n`
3696
+ }
3697
+ const base = raw.replace(/\s+$/, '')
3698
+ return `${base}\n${managedBlock(state)}\n`
3699
+ }
3700
+
3701
+ /** 移除受管块后的剩余内容;没有受管块时返回 null(无需写回)。 */
3702
+ function stripManaged(raw) {
3703
+ const begin = raw.indexOf(MANAGED_BEGIN)
3704
+ const end = raw.indexOf(MANAGED_END)
3705
+ if (begin === -1 || end === -1 || end < begin) return null
3706
+ return raw.slice(0, begin) + raw.slice(end + MANAGED_END.length)
3707
+ }
3708
+
3709
+ /** 官方 initProfile 的用户补丁层模板(移除后文件为空时回填)。 */
3710
+ const PATCH_TEMPLATE = '# Your patch layer for this dsh profile, applied after every bundle layer:\n'
3711
+ + '# a top-level YAML array of loader patch entries (id-targeted config\n'
3712
+ + '# overrides, disables, and insert lists; `!!js` expressions allowed).\n'
3713
+ + '[]\n'
3714
+
3715
+ /** 恢复默认:从 profile 补丁层移除受管块,让 compaction-basic 回落自身默认。 */
3716
+ async function clearProfilePatch(profileName, report) {
3717
+ const file = join(PROFILES_DIR, profileName, 'cordis.patch.yml')
3718
+ let raw = ''
3719
+ try { raw = await readFile(file, 'utf8') } catch { return }
3720
+ const strippedManaged = stripManaged(raw)
3721
+ if (strippedManaged === null) return
3722
+ const stripped = strippedManaged
3723
+ .split('\n')
3724
+ .filter((line) => line.trim() !== '' && !line.trim().startsWith('#'))
3725
+ .join('')
3726
+ .trim()
3727
+ const next = stripped === ''
3728
+ ? PATCH_TEMPLATE
3729
+ : `${strippedManaged.replace(/\s+$/, '')}\n`
3730
+ if (Yaml) {
3731
+ try {
3732
+ const parsed = Yaml.parse(next)
3733
+ if (!Array.isArray(parsed)) throw new Error('not an array')
3734
+ } catch (error) {
3735
+ throw new Error(`${profileName}/cordis.patch.yml 语法校验失败,未写入:${String(error?.message ?? error)}`)
3736
+ }
3737
+ } else {
3738
+ report(`yaml lib unavailable, rewrote ${profileName}/cordis.patch.yml without validation`)
3739
+ }
3740
+ await writeFile(file, next, 'utf8')
3741
+ return file
3742
+ }
3743
+
3744
+ /**
3745
+ * 把受管块写入一个 profile 的 cordis.patch.yml。
3746
+ * 纯文本手术:只动 BEGIN/END 标记之间的内容,用户自己写的其他条目原样保留;
3747
+ * 全新文件(官方模板是注释 + `[]`)用受管块替换 `[]`。写前用 yaml 库校验
3748
+ * (如果运行时带了这个库),校验失败不落盘。
3749
+ */
3750
+ async function syncProfilePatch(profileName, state, report) {
3751
+ const file = join(PROFILES_DIR, profileName, 'cordis.patch.yml')
3752
+ let raw = ''
3753
+ try { raw = await readFile(file, 'utf8') } catch { raw = '' }
3754
+ const next = composePatch(raw, state)
3755
+
3756
+ if (Yaml) {
3757
+ try {
3758
+ const parsed = Yaml.parse(next)
3759
+ if (!Array.isArray(parsed)) throw new Error('not an array')
3760
+ } catch (error) {
3761
+ throw new Error(`${profileName}/cordis.patch.yml 语法校验失败,未写入:${String(error?.message ?? error)}`)
3762
+ }
3763
+ } else {
3764
+ report(`yaml lib unavailable, wrote ${profileName}/cordis.patch.yml without validation`)
3765
+ }
3766
+
3767
+ await writeFile(file, next, 'utf8')
3768
+ return file
3769
+ }
3770
+
3771
+ /** 把状态同步进所有挂载 profile;返回写入的 profile 名单。 */
3772
+ async function syncAllProfiles(state, report) {
3773
+ const profiles = await listMountedProfiles()
3774
+ const written = []
3775
+ for (const profileName of profiles) {
3776
+ await syncProfilePatch(profileName, state, report)
3777
+ written.push(profileName)
3778
+ }
3779
+ return written
3780
+ }
3781
+
3782
+ function apply(ctx, config) {
3783
+ const say = (message) => {
3784
+ try { console.info(`[dsh-compaction-control] ${message}`) } catch { /* ignore */ }
3785
+ }
3786
+ const report = async (line) => {
3787
+ try {
3788
+ await mkdir(dirname(DIAG_LOG), { recursive: true })
3789
+ await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
3790
+ } catch { /* ignore */ }
3791
+ }
3792
+
3793
+ say(`server boot, enabled=${config.enabled !== false}`)
3794
+ void report(`server boot enabled=${config.enabled !== false}`)
3795
+
3796
+ // 压缩前自动落档:客户端圆环在占用率上穿「阈值-5%」时布防,本进程在
3797
+ // 该会话下一次模型调用前往消息尾追加落档指令——agent 用自己的文件工具
3798
+ // 把项目状态写进工作区 checkpoint.md,随后 compaction-basic 照常压缩。
3799
+ // 只注入一次(注入即解除布防;占用回落后客户端重新布防)。
3800
+ const armedSessions = new Set()
3801
+ const CHECKPOINT_MARKER = 'dsh-auto-checkpoint'
3802
+ const CHECKPOINT_TEXT = [
3803
+ '【自动落档】上下文即将压缩。请先把当前任务状态写入工作区文件 `checkpoint.md`(覆盖旧内容):',
3804
+ '任务目标、已完成事项、待办事项、关键决定与约束、涉及的重要文件路径。',
3805
+ '写完落档后,再回答上一条消息的问题。(dsh-auto-checkpoint)',
3806
+ ].join('\n')
3807
+
3808
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
3809
+ const decision = await next()
3810
+ try {
3811
+ if (!decision || decision.kind !== 'enter' || !Array.isArray(decision.messages)) return decision
3812
+ const sessionId = String(agent?.sessionId ?? '')
3813
+ if (!sessionId || !armedSessions.has(sessionId)) return decision
3814
+ const state = await readState()
3815
+ if (state.checkpoint === false) { armedSessions.delete(sessionId); return decision }
3816
+ const tail = decision.messages.slice(-3).map((m) => JSON.stringify(m?.content ?? '')).join('')
3817
+ armedSessions.delete(sessionId)
3818
+ if (tail.includes(CHECKPOINT_MARKER)) return decision
3819
+ const message = createUserMessage({ content: [{ type: 'text', text: CHECKPOINT_TEXT }] })
3820
+ await report(`checkpoint injected session=${sessionId} msgs=${decision.messages.length}`)
3821
+ return { ...decision, messages: [...decision.messages, message] }
3822
+ } catch (error) {
3823
+ await report(`checkpoint pre-step failed: ${String(error?.message ?? error).slice(0, 200)}`)
3824
+ return decision
3825
+ }
3826
+ })
3827
+
3828
+ // compaction/agents 属于可选依赖:base bundle 没带它们时路由仍注册,
3829
+ // /compact 返回 503,面板其余功能(状态展示、参数保存)不受影响。
3830
+ let runtime = null
3831
+
3832
+ ctx.inject(['webServer'], (webCtx) => {
3833
+ webCtx.effect(() => webCtx.webServer.register({
3834
+ kind: 'prefix',
3835
+ path: '/api/dsh-compaction-control',
3836
+ handler: async (req, res) => {
3837
+ const send = (status, body) => {
3838
+ res.statusCode = status
3839
+ res.setHeader('content-type', 'application/json')
3840
+ res.end(JSON.stringify(body))
3841
+ }
3842
+ const readBody = async () => {
3843
+ let raw = ''
3844
+ for await (const chunk of req) raw += chunk
3845
+ try { return raw ? JSON.parse(raw) : {} } catch { return null }
3846
+ }
3847
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-compaction-control/, '')
3848
+
3849
+ if (path === '/enabled' && req.method === 'GET') {
3850
+ send(200, { enabled: config.enabled !== false, name })
3851
+ return
3852
+ }
3853
+
3854
+ if (path === '/diag' && req.method === 'POST') {
3855
+ await report(String((await readBody())?.line ?? '').slice(0, 2000))
3856
+ send(200, { ok: true })
3857
+ return
3858
+ }
3859
+
3860
+ // 压缩前落档布防:客户端占用率上穿阈值-5% 时 arm=true(升沿只发一次),
3861
+ // 占用回落后 arm=false。服务端只记标记,注入发生在下一次 pre-step。
3862
+ if (path === '/checkpoint-arm' && req.method === 'POST') {
3863
+ const body = await readBody()
3864
+ const sessionId = String(body?.sessionId ?? '')
3865
+ if (!sessionId) { send(400, { error: '缺少 sessionId' }); return }
3866
+ const arm = body?.arm === true
3867
+ const state = await readState()
3868
+ if (arm && state.checkpoint === false) { send(200, { ok: true, armed: false, reason: 'checkpoint-disabled' }); return }
3869
+ if (arm) armedSessions.add(sessionId)
3870
+ else armedSessions.delete(sessionId)
3871
+ await report(`checkpoint ${arm ? 'armed' : 'disarmed'} session=${sessionId}`)
3872
+ send(200, { ok: true, armed })
3873
+ return
3874
+ }
3875
+
3876
+ if (path === '/state' && req.method === 'GET') {
3877
+ try {
3878
+ const [state, profiles] = await Promise.all([readState(), listMountedProfiles()])
3879
+ send(200, { ok: true, state, defaults: DEFAULT_STATE, profiles, version: '0.1.0' })
3880
+ } catch (error) {
3881
+ send(500, { error: String(error?.message ?? error) })
3882
+ }
3883
+ return
3884
+ }
3885
+
3886
+ if (path === '/state' && req.method === 'POST') {
3887
+ const body = await readBody()
3888
+ if (body === null) { send(400, { error: '请求体不是合法 JSON' }); return }
3889
+ try {
3890
+ const state = sanitizeState(body)
3891
+ await mkdir(DSH_DIR, { recursive: true })
3892
+ await writeFile(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
3893
+ const profiles = await syncAllProfiles(state, report)
3894
+ await report(`state saved ${JSON.stringify(state)} profiles=${profiles.join(',') || 'none'}`)
3895
+ send(200, { ok: true, state, profiles, restartRequired: true })
3896
+ } catch (error) {
3897
+ send(400, { error: String(error?.message ?? error) })
3898
+ }
3899
+ return
3900
+ }
3901
+
3902
+ if (path === '/reset' && req.method === 'POST') {
3903
+ try {
3904
+ const state = { ...DEFAULT_STATE }
3905
+ await mkdir(DSH_DIR, { recursive: true })
3906
+ await writeFile(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
3907
+ const profiles = await listMountedProfiles()
3908
+ for (const profileName of profiles) await clearProfilePatch(profileName, report)
3909
+ await report(`state reset to defaults, managed block removed from: ${profiles.join(',') || 'none'}`)
3910
+ send(200, { ok: true, state, profiles, restartRequired: true })
3911
+ } catch (error) {
3912
+ send(400, { error: String(error?.message ?? error) })
3913
+ }
3914
+ return
3915
+ }
3916
+
3917
+ if (path === '/compact' && req.method === 'POST') {
3918
+ if (!runtime) { send(503, { error: '压缩服务未就绪(本 profile 未挂载 compaction/agents)' }); return }
3919
+ const body = await readBody()
3920
+ const sessionId = body?.sessionId
3921
+ if (!sessionId) { send(400, { error: '缺少 sessionId' }); return }
3922
+ const agent = runtime.agents.get(sessionId)
3923
+ if (!agent) { send(409, { error: '未找到该会话的 agent' }); return }
3924
+ const controller = new AbortController()
3925
+ try {
3926
+ const result = await runtime.compaction.compactNow(agent, controller.signal)
3927
+ if (result === null) {
3928
+ send(200, { ok: true, empty: true, message: '暂无可压缩的历史' })
3929
+ } else {
3930
+ await report(`manual compact ok: ${result.shadowedSeqs.length} items ~${result.shadowedTokenCount} tokens`)
3931
+ send(200, {
3932
+ ok: true,
3933
+ shadowed: result.shadowedSeqs.length,
3934
+ tokens: result.shadowedTokenCount,
3935
+ })
3936
+ }
3937
+ } catch (error) {
3938
+ const code = typeof error?.code === 'string' ? error.code.toLowerCase() : ''
3939
+ const messages = {
3940
+ busy: '会话正忙(运行中或已有压缩任务),请在空闲时重试',
3941
+ cancelled: '压缩已取消',
3942
+ changed: '待压缩历史在提交前发生了变化,本次已放弃并记录',
3943
+ summary: '未能生成有效摘要,本次已记录',
3944
+ commit: '压缩未干净完成,请检查会话状态后再试',
3945
+ persistence: '压缩完成,但会话保存失败',
3946
+ }
3947
+ await report(`manual compact failed: code=${code || 'unknown'} ${String(error?.message ?? error).slice(0, 300)}`)
3948
+ if (messages[code]) { send(409, { error: messages[code], code }); return }
3949
+ send(500, { error: String(error?.message ?? error) })
3950
+ }
3951
+ return
3952
+ }
3953
+
3954
+ send(404, { error: 'not-found' })
3955
+ },
3956
+ }), 'dsh-compaction-control: /api/dsh-compaction-control/*')
3957
+
3958
+ webCtx.inject(['compaction', 'agents'], (deep) => {
3959
+ runtime = { compaction: deep.compaction, agents: deep.agents }
3960
+ say('compaction + agents ready')
3961
+ void report('runtime ready (compaction + agents)')
3962
+ return () => { runtime = null }
3963
+ })
3964
+ })
3965
+ }
3966
+
3967
+
3968
+ /** 仅供离线自测(scripts/ 与 CI 可直接 import),运行时不使用。 */
3969
+ const __internals = { sanitizeState, managedBlock, composePatch, stripManaged, PATCH_TEMPLATE, DEFAULT_STATE }
3970
+ return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
3971
+ })()
3972
+
2986
3973
  /* ==== module: prompts(手写) ==== */
2987
3974
  const promptsModule = (() => {
2988
3975
  /**
@@ -2999,12 +3986,31 @@ const promptsModule = (() => {
2999
3986
 
3000
3987
 
3001
3988
 
3989
+
3002
3990
  const moduleInject = []
3003
3991
 
3004
3992
  const STORE_DIR = join(homedir(), '.dsh', 'storages', 'dsh-superinput')
3005
3993
  const STORE_PATH = join(STORE_DIR, 'prompts.json')
3006
3994
  const CLIENT_DIAG = join(homedir(), '.dsh', 'superinput-debug.log')
3007
3995
 
3996
+ /* 划选上下文:客户端把「选段 + 评论」暂存到这里,下一轮 pre-step 一次性
3997
+ * 注入成一条 sourced UserMessage,随后立即删除(只影响紧接着的一次发送)。 */
3998
+ const CONTEXT_MAX_ITEMS = 8
3999
+ const CONTEXT_TEXT_MAX = 4000
4000
+ const CONTEXT_COMMENT_MAX = 600
4001
+ const CONTEXT_TTL_MS = 10 * 60_000
4002
+ const pendingContext = new Map()
4003
+
4004
+ function renderSelectionContext(items) {
4005
+ const lines = [
4006
+ '以下是我从对话中选中的上下文,按顺序附带我的评论。回答时请优先参考这些内容,并针对评论中的问题作答。',
4007
+ ]
4008
+ items.forEach((item, index) => {
4009
+ lines.push(`【选段 ${index + 1}】\n${item.text}${item.comment ? `\n我的评论:${item.comment}` : ''}`)
4010
+ })
4011
+ return lines.join('\n\n')
4012
+ }
4013
+
3008
4014
  const Config = Schema.object({
3009
4015
  enabled: Schema.boolean().default(true).description('启用提示词模板库'),
3010
4016
  maxItems: Schema.number().default(500).description('最多保存多少条模板(收藏不计入上限)'),
@@ -3196,6 +4202,36 @@ function apply(ctx, config) {
3196
4202
  store.load()
3197
4203
  ctx.effect(() => store.dispose(), 'dsh-superinput: prompts store')
3198
4204
 
4205
+ // 划选上下文注入:挂在 pre-step 最前面,先让下游(含提示词增强)处理完,
4206
+ // 再把上下文作为一条独立的 sourced 用户消息追加到本轮送模消息末尾。
4207
+ ctx.on('agent/pre-step', async ({ agent, step, signal }, next) => {
4208
+ const decision = await next()
4209
+ if (decision.kind === 'reject' || signal?.aborted) return decision
4210
+ const rawSession = agent?.session
4211
+ const sessionId = typeof rawSession === 'string'
4212
+ ? rawSession
4213
+ : rawSession?.id === undefined ? '' : String(rawSession.id)
4214
+ const pending = sessionId ? pendingContext.get(sessionId) : undefined
4215
+ if (step !== 1 || !pending) return decision
4216
+ pendingContext.delete(sessionId)
4217
+ if (pending.items.length === 0 || Date.now() - pending.at > CONTEXT_TTL_MS) return decision
4218
+ const text = renderSelectionContext(pending.items)
4219
+ try {
4220
+ appendFileSync(CLIENT_DIAG, `${new Date().toISOString()} context injected session=${sessionId.slice(0, 50)} items=${pending.items.length}\n`, 'utf8')
4221
+ } catch { /* 日志失败不影响注入 */ }
4222
+ return {
4223
+ ...decision,
4224
+ messages: [...decision.messages, createUserMessage({
4225
+ content: [{ type: 'text', text }],
4226
+ source: {
4227
+ kind: 'dsh-superinput-selection',
4228
+ form: 'snapshot',
4229
+ sections: [{ name: 'selection-context', text }],
4230
+ },
4231
+ })],
4232
+ }
4233
+ }, { prepend: true })
4234
+
3199
4235
  ctx.inject(['webServer'], (webCtx) => {
3200
4236
  webCtx.effect(() => webCtx.webServer.register({
3201
4237
  kind: 'prefix',
@@ -3243,6 +4279,22 @@ function apply(ctx, config) {
3243
4279
  send(200, { ok: true })
3244
4280
  return
3245
4281
  }
4282
+ // 划选上下文暂存:items 为空表示清除;真正的注入在 agent/pre-step。
4283
+ if (path === '/context' && req.method === 'POST') {
4284
+ const body = await readBody()
4285
+ const sessionId = String(body.sessionId ?? '').trim().slice(0, 200)
4286
+ if (!sessionId) { send(400, { error: 'missing-session' }); return }
4287
+ const items = Array.isArray(body.items)
4288
+ ? body.items.slice(0, CONTEXT_MAX_ITEMS).map((item) => ({
4289
+ text: String(item?.text ?? '').replace(/\0/g, '').trim().slice(0, CONTEXT_TEXT_MAX),
4290
+ comment: String(item?.comment ?? '').replace(/\0/g, '').trim().slice(0, CONTEXT_COMMENT_MAX),
4291
+ })).filter((item) => item.text.length > 0)
4292
+ : []
4293
+ if (items.length === 0) pendingContext.delete(sessionId)
4294
+ else pendingContext.set(sessionId, { items, at: Date.now() })
4295
+ send(200, { ok: true, count: items.length })
4296
+ return
4297
+ }
3246
4298
  if (path === '/list' && req.method === 'GET') {
3247
4299
  send(200, { items: store.list() })
3248
4300
  return
@@ -3379,6 +4431,7 @@ const MODULES = [
3379
4431
  ['followup', followupModule],
3380
4432
  ['voice', voiceModule],
3381
4433
  ['prompts', promptsModule],
4434
+ ['compaction', compactionModule],
3382
4435
  ['hub', hubModule],
3383
4436
  ]
3384
4437
 
@@ -3393,6 +4446,7 @@ export const Config = Schema.object({
3393
4446
  followup: followupModule.Config,
3394
4447
  voice: voiceModule.Config,
3395
4448
  prompts: promptsModule.Config,
4449
+ compaction: compactionModule.Config,
3396
4450
  hub: hubModule.Config,
3397
4451
  })
3398
4452