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/CHANGELOG.md +265 -0
- package/README.md +18 -7
- package/client.js +2356 -251
- package/modules/compaction.js +413 -0
- package/modules/cost.js +571 -3
- package/modules/prompts.client.js +1068 -57
- package/modules/prompts.js +65 -0
- package/package.json +2 -3
- package/scripts/build-suite.mjs +14 -4
- package/scripts/smoke.mjs +54 -3
- package/server.js +1057 -3
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.
|
|
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
|
-
|
|
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
|
|