dsh-spendingframe 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +218 -2
- package/client/client.js +523 -0
- package/cordis.patch.yml +4 -0
- package/lib/index.js +165 -0
- package/lib/pricing.js +184 -0
- package/lib/session-logs.js +197 -0
- package/lib/summary.js +209 -0
- package/package.json +58 -4
- package/tests/balance-probe.mjs +132 -0
- package/tests/client-harness-test.mjs +378 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSHBalance 宿主半(包名 dsh-spendingframe):把「高峰/空闲 + 会话花费 + 今日花费」暴露成一个只读 HTTP 路由。
|
|
3
|
+
*
|
|
4
|
+
* 客户端半(client/client.js)只负责显示:它每 5 分钟拉一次
|
|
5
|
+
* `GET /dsh-spendingframe/summary?session=<会话 id>`,因此金额只在宿主算一次,
|
|
6
|
+
* 浏览器不需要接触会话日志,也不需要自己维护价目表。
|
|
7
|
+
*
|
|
8
|
+
* 路由注册在裸 webServer 上(exact 匹配优先于宿主自己的 `/api` 前缀),
|
|
9
|
+
* 所以自己判断来源:只接受回环地址(或宿主声明的可信 authority)与同源页面,
|
|
10
|
+
* 拒绝 DNS rebinding 页面与跨站请求。接口是只读的,不写入任何状态。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { buildSummary } from './summary.js'
|
|
14
|
+
|
|
15
|
+
export const name = 'dsh-spendingframe'
|
|
16
|
+
|
|
17
|
+
const ROUTE = '/dsh-spendingframe/summary'
|
|
18
|
+
|
|
19
|
+
export function apply(ctx) {
|
|
20
|
+
// webServer 只在网页宿主里存在;没有它的组合(headless / acp 等)安静地不挂载。
|
|
21
|
+
ctx.inject(['webServer'], (hostCtx) => {
|
|
22
|
+
hostCtx.effect(() => {
|
|
23
|
+
const dispose = hostCtx.webServer.register({
|
|
24
|
+
kind: 'exact',
|
|
25
|
+
path: ROUTE,
|
|
26
|
+
handler: (request, response) => { void handle(hostCtx, request, response) },
|
|
27
|
+
})
|
|
28
|
+
return () => { dispose() }
|
|
29
|
+
}, 'dsh-spendingframe: /dsh-spendingframe/summary')
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function handle(ctx, request, response) {
|
|
34
|
+
if (refuseUntrusted(ctx, request, response)) return
|
|
35
|
+
|
|
36
|
+
const method = request.method ?? 'GET'
|
|
37
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
38
|
+
sendJson(response, 405, { ok: false, error: '只支持 GET' })
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let sessionId = ''
|
|
43
|
+
try {
|
|
44
|
+
sessionId = new URL(request.url ?? '/', 'http://dsh.local').searchParams.get('session') ?? ''
|
|
45
|
+
} catch {
|
|
46
|
+
sessionId = ''
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
sendJson(response, 200, buildSummary({ sessionId }))
|
|
51
|
+
} catch (error) {
|
|
52
|
+
sendJson(response, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 来源闸门。
|
|
58
|
+
*
|
|
59
|
+
* 首选宿主自己的登录门禁(Host Connection 的 `requestRejection`):它同时做
|
|
60
|
+
* Host/Origin 围栏与浏览器 cookie 校验,网页里的请求本来就带着它签发的 cookie
|
|
61
|
+
* (宿主自己的 `/api` 也靠这一套),所以面板能读到数据,而本机其它未登录客户端
|
|
62
|
+
* 会被 401 拒绝。
|
|
63
|
+
*
|
|
64
|
+
* 该服务在宿主启动早期可能还没就绪(dsh-market 就因为启动时取不到它而永久退化成
|
|
65
|
+
* 无门禁),所以这里在**请求时**才查一次;确实没有这个服务时才退回到自己实现的
|
|
66
|
+
* authority 围栏:只接受回环地址、宿主声明的可信 authority,以及同源页面。
|
|
67
|
+
*/
|
|
68
|
+
function refuseUntrusted(ctx, request, response) {
|
|
69
|
+
const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
|
|
70
|
+
const gate = connection?.requestRejection
|
|
71
|
+
if (typeof gate === 'function') {
|
|
72
|
+
let status
|
|
73
|
+
try {
|
|
74
|
+
status = gate.call(connection, request)
|
|
75
|
+
} catch {
|
|
76
|
+
status = 403 // 门禁抛错不等于放行
|
|
77
|
+
}
|
|
78
|
+
if (typeof status === 'number') {
|
|
79
|
+
sendJson(response, status, {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: status === 401 ? '需要先登录 DSH:请重新打开 dsh web 启动时打印的网址' : '请求来源不受信任,已拒绝',
|
|
82
|
+
})
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
return refuseByAuthority(ctx, request, response)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 没有登录门禁时的兜底:回环地址 / 声明 authority / 同源页面。
|
|
92
|
+
*
|
|
93
|
+
* 要点是 `Host` 是页面无法伪造的头,所以 DNS rebinding(Origin 与 Host 都被换成
|
|
94
|
+
* 攻击者的域名)会在这里被拒;桌面端转发代理会剥掉 host/origin,因此“头不存在”
|
|
95
|
+
* 按放行处理——这与 dsh-market 的 sameOrigin 是同一套判断。
|
|
96
|
+
*/
|
|
97
|
+
function refuseByAuthority(ctx, request, response) {
|
|
98
|
+
const host = request.headers.host
|
|
99
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') {
|
|
100
|
+
sendJson(response, 403, { ok: false, error: '跨站请求已拒绝' })
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
if (typeof host === 'string' && host !== '' && !loopbackAuthority(host) && !trustedAuthority(host, trustedHosts(ctx))) {
|
|
104
|
+
sendJson(response, 403, { ok: false, error: '请求来源不受信任,已拒绝' })
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
const origin = request.headers.origin
|
|
108
|
+
if (origin !== undefined) {
|
|
109
|
+
let sameHost = false
|
|
110
|
+
try {
|
|
111
|
+
sameHost = host === undefined || new URL(origin).host === host
|
|
112
|
+
} catch {
|
|
113
|
+
sameHost = false // 存在但无法解析(含 Origin: null)不算同源
|
|
114
|
+
}
|
|
115
|
+
if (!sameHost) {
|
|
116
|
+
sendJson(response, 403, { ok: false, error: '跨站请求已拒绝' })
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 宿主声明的额外 authority(`dsh web --trusted-host <name>`);服务缺席时为空。 */
|
|
124
|
+
function trustedHosts(ctx) {
|
|
125
|
+
const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
|
|
126
|
+
const declared = connection?.trustedHosts
|
|
127
|
+
return Array.isArray(declared) ? declared.filter((entry) => typeof entry === 'string') : []
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** `127.0.0.1` / `localhost` / `[::1]`(含端口)视为回环。 */
|
|
131
|
+
function loopbackAuthority(host) {
|
|
132
|
+
const lower = host.toLowerCase()
|
|
133
|
+
const name = lower.startsWith('[') ? lower.slice(0, lower.indexOf(']') + 1) : lower.split(':')[0]
|
|
134
|
+
return name === '127.0.0.1' || name === 'localhost' || name === '[::1]'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `host` 是否命中某条声明:带端口按完整 authority 比较,不带端口只比主机名。 */
|
|
138
|
+
function trustedAuthority(host, declared) {
|
|
139
|
+
const actual = parseAuthority(host)
|
|
140
|
+
if (actual === null) return false
|
|
141
|
+
return declared.some((entry) => {
|
|
142
|
+
const expected = parseAuthority(entry)
|
|
143
|
+
if (expected === null) return false
|
|
144
|
+
return expected.port === '' ? expected.hostname === actual.hostname : expected.host === actual.host
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 解析 `host[:port]`;必须原样往返,避免把 `evil.com/path` 当成合法声明。 */
|
|
149
|
+
function parseAuthority(value) {
|
|
150
|
+
try {
|
|
151
|
+
const url = new URL(`http://${value}`)
|
|
152
|
+
const canonical = url.port === '' ? url.hostname : `${url.hostname}:${url.port}`
|
|
153
|
+
return canonical === value.toLowerCase() ? url : null
|
|
154
|
+
} catch {
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function sendJson(response, status, payload) {
|
|
160
|
+
response.writeHead(status, {
|
|
161
|
+
'cache-control': 'no-store',
|
|
162
|
+
'content-type': 'application/json; charset=utf-8',
|
|
163
|
+
})
|
|
164
|
+
response.end(JSON.stringify(payload))
|
|
165
|
+
}
|
package/lib/pricing.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek API 分时定价与人民币费用计算。
|
|
3
|
+
*
|
|
4
|
+
* 高峰时段:北京时间周一至周五(不含中国法定节假日)09:00–12:00、14:00–18:00。
|
|
5
|
+
* 其余时段(含周末与中国法定节假日全天)为空闲时段。
|
|
6
|
+
*
|
|
7
|
+
* 这里全是纯函数,不依赖 DSH 运行时,便于单独测试。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 北京时间相对 UTC 的固定偏移(中国不使用夏令时)。 */
|
|
11
|
+
const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
/** 高峰时段区间,单位:北京时间当天第几分钟。 */
|
|
14
|
+
const PEAK_WINDOWS = [
|
|
15
|
+
[9 * 60, 12 * 60],
|
|
16
|
+
[14 * 60, 18 * 60],
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 中国法定节假日(国务院办公厅通知)。
|
|
21
|
+
*
|
|
22
|
+
* 只列放假日期:调休上班的周末按规则仍属空闲时段,无需单独处理。
|
|
23
|
+
* 未知年份只按“工作日 + 时段”判定,不会把普通工作日误判成节假日。
|
|
24
|
+
*
|
|
25
|
+
* 数据来源:
|
|
26
|
+
* 2024 — 国办发明电〔2023〕7 号
|
|
27
|
+
* 2025 — 国办发明电〔2024〕12 号
|
|
28
|
+
* 2026 — 国办发明电〔2025〕7 号
|
|
29
|
+
*/
|
|
30
|
+
const HOLIDAY_RANGES = [
|
|
31
|
+
// 2024
|
|
32
|
+
['2024-01-01', '2024-01-01'], // 元旦
|
|
33
|
+
['2024-02-10', '2024-02-17'], // 春节
|
|
34
|
+
['2024-04-04', '2024-04-06'], // 清明节
|
|
35
|
+
['2024-05-01', '2024-05-05'], // 劳动节
|
|
36
|
+
['2024-06-08', '2024-06-10'], // 端午节
|
|
37
|
+
['2024-09-15', '2024-09-17'], // 中秋节
|
|
38
|
+
['2024-10-01', '2024-10-07'], // 国庆节
|
|
39
|
+
// 2025
|
|
40
|
+
['2025-01-01', '2025-01-01'], // 元旦
|
|
41
|
+
['2025-01-28', '2025-02-04'], // 春节
|
|
42
|
+
['2025-04-04', '2025-04-06'], // 清明节
|
|
43
|
+
['2025-05-01', '2025-05-05'], // 劳动节
|
|
44
|
+
['2025-05-31', '2025-06-02'], // 端午节
|
|
45
|
+
['2025-10-01', '2025-10-08'], // 国庆节、中秋节
|
|
46
|
+
// 2026
|
|
47
|
+
['2026-01-01', '2026-01-03'], // 元旦
|
|
48
|
+
['2026-02-15', '2026-02-23'], // 春节
|
|
49
|
+
['2026-04-04', '2026-04-06'], // 清明节
|
|
50
|
+
['2026-05-01', '2026-05-05'], // 劳动节
|
|
51
|
+
['2026-06-19', '2026-06-21'], // 端午节
|
|
52
|
+
['2026-09-25', '2026-09-27'], // 中秋节
|
|
53
|
+
['2026-10-01', '2026-10-07'], // 国庆节
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/** 展开成 `YYYY-MM-DD` 集合,按北京时间的日期字符串直接查表。 */
|
|
57
|
+
function expandHolidays(ranges) {
|
|
58
|
+
const days = new Set()
|
|
59
|
+
for (const [from, to] of ranges) {
|
|
60
|
+
const cursor = new Date(`${from}T00:00:00Z`)
|
|
61
|
+
const end = new Date(`${to}T00:00:00Z`)
|
|
62
|
+
while (cursor.getTime() <= end.getTime()) {
|
|
63
|
+
days.add(cursor.toISOString().slice(0, 10))
|
|
64
|
+
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return days
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const HOLIDAYS = expandHolidays(HOLIDAY_RANGES)
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 把时间戳拆成北京时间字段。
|
|
74
|
+
*
|
|
75
|
+
* 用固定 +8 偏移后再读 UTC 字段:不依赖宿主机的本地时区设置,
|
|
76
|
+
* 结果在任何机器上都一致。
|
|
77
|
+
*/
|
|
78
|
+
export function beijingParts(timeMs) {
|
|
79
|
+
const shifted = new Date((Number.isFinite(timeMs) ? timeMs : Date.now()) + BEIJING_OFFSET_MS)
|
|
80
|
+
return {
|
|
81
|
+
ymd: shifted.toISOString().slice(0, 10),
|
|
82
|
+
weekday: shifted.getUTCDay(), // 0 = 周日
|
|
83
|
+
minutes: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 该日期是否为(北京时间的)中国法定节假日。 */
|
|
88
|
+
export function isHoliday(ymd) {
|
|
89
|
+
return HOLIDAYS.has(ymd)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 某个时刻是否处于高峰时段。 */
|
|
93
|
+
export function isPeak(timeMs) {
|
|
94
|
+
const { ymd, weekday, minutes } = beijingParts(timeMs)
|
|
95
|
+
if (weekday === 0 || weekday === 6) return false
|
|
96
|
+
if (HOLIDAYS.has(ymd)) return false
|
|
97
|
+
return PEAK_WINDOWS.some(([from, to]) => minutes >= from && minutes < to)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 距离下一次高峰/空闲切换的毫秒数(用于提示,不参与计费)。 */
|
|
101
|
+
export function msUntilNextChange(timeMs) {
|
|
102
|
+
const now = Number.isFinite(timeMs) ? timeMs : Date.now()
|
|
103
|
+
const current = isPeak(now)
|
|
104
|
+
// 北京时间的整点候选:每天的 00:00、09:00、12:00、14:00、18:00。
|
|
105
|
+
for (let day = 0; day <= 9; day++) {
|
|
106
|
+
const dayStart = beijingDayStart(now) + day * 24 * 60 * 60 * 1000
|
|
107
|
+
for (const minutes of [0, 9 * 60, 12 * 60, 14 * 60, 18 * 60]) {
|
|
108
|
+
const candidate = dayStart + minutes * 60 * 1000
|
|
109
|
+
if (candidate <= now) continue
|
|
110
|
+
if (isPeak(candidate) !== current) return candidate - now
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 北京时间当天 00:00 的时间戳。 */
|
|
117
|
+
export function beijingDayStart(timeMs) {
|
|
118
|
+
const now = Number.isFinite(timeMs) ? timeMs : Date.now()
|
|
119
|
+
const shifted = new Date(now + BEIJING_OFFSET_MS)
|
|
120
|
+
const midnight = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate())
|
|
121
|
+
return midnight - BEIJING_OFFSET_MS
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 官方单价,单位:元 / 百万 tokens。
|
|
126
|
+
*
|
|
127
|
+
* 每个模型给出缓存命中输入、缓存未命中输入与输出三档;
|
|
128
|
+
* 每档再分空闲(offPeak)与高峰(peak)。
|
|
129
|
+
*/
|
|
130
|
+
export const PRICES = {
|
|
131
|
+
'deepseek-flash': {
|
|
132
|
+
hit: { offPeak: 0.02, peak: 0.04 },
|
|
133
|
+
miss: { offPeak: 1, peak: 2 },
|
|
134
|
+
out: { offPeak: 4, peak: 8 },
|
|
135
|
+
},
|
|
136
|
+
'deepseek-v4-pro': {
|
|
137
|
+
hit: { offPeak: 0.15, peak: 0.3 },
|
|
138
|
+
miss: { offPeak: 4.5, peak: 9 },
|
|
139
|
+
out: { offPeak: 13.5, peak: 27 },
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 选择模型价目表。
|
|
145
|
+
*
|
|
146
|
+
* 精确命中优先;名称里带 `pro` / `flash` 的变体按对应档位计价;
|
|
147
|
+
* 其余未知模型退回 flash 价目,并由调用方在响应里说明,避免静默出错。
|
|
148
|
+
*/
|
|
149
|
+
export function priceTableFor(modelId) {
|
|
150
|
+
const id = String(modelId ?? '').trim().toLowerCase()
|
|
151
|
+
if (Object.hasOwn(PRICES, id)) return { table: PRICES[id], assumed: false }
|
|
152
|
+
if (id.includes('pro')) return { table: PRICES['deepseek-v4-pro'], assumed: true }
|
|
153
|
+
if (id.includes('flash')) return { table: PRICES['deepseek-flash'], assumed: true }
|
|
154
|
+
return { table: PRICES['deepseek-flash'], assumed: true }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 一次调用的费用(元)。
|
|
159
|
+
*
|
|
160
|
+
* @param usage - `inputTokens` 为缓存未命中的输入 tokens,`cacheReadTokens` 为命中,
|
|
161
|
+
* 这是 DSH 会话日志里 `assistant/message.data.usage` 的实际含义。
|
|
162
|
+
* @param table - {@link priceTableFor} 选出的价目表。
|
|
163
|
+
* @param peak - 这次调用发生的时刻是否处于高峰时段。
|
|
164
|
+
*/
|
|
165
|
+
export function costOfUsage(usage, table, peak) {
|
|
166
|
+
if (usage === null || typeof usage !== 'object') return 0
|
|
167
|
+
const tier = peak ? 'peak' : 'offPeak'
|
|
168
|
+
const hit = numberOrZero(usage.cacheReadTokens)
|
|
169
|
+
const miss = numberOrZero(usage.inputTokens)
|
|
170
|
+
const out = numberOrZero(usage.outputTokens)
|
|
171
|
+
return (hit * table.hit[tier] + miss * table.miss[tier] + out * table.out[tier]) / 1e6
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 仅接受有限非负数,日志里的脏字段按 0 计。 */
|
|
175
|
+
function numberOrZero(value) {
|
|
176
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 当前时段的中文名,用于提示文案。 */
|
|
180
|
+
export function windowLabel(peak) {
|
|
181
|
+
return peak ? '高峰时段' : '空闲时段'
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export { PEAK_WINDOWS, HOLIDAYS }
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 读取 DSH 会话日志里的 token 用量。
|
|
3
|
+
*
|
|
4
|
+
* 会话日志位于 `<DSH_HOME>/sessions/<工作区>/<会话 id>/session.v4.jsonl.zstd`:
|
|
5
|
+
* 追加写入的 zstd 多帧文件,每帧一到多行 JSON 记录。带用量的是
|
|
6
|
+
* `assistant/message`,其 `data.usage` 为
|
|
7
|
+
* `{ inputTokens(缓存未命中), cacheReadTokens(缓存命中), outputTokens, ... }`,
|
|
8
|
+
* 模型在 `data.message.source.model`。
|
|
9
|
+
*
|
|
10
|
+
* 这里只做两件事:把文件读成用量记录,并缓存解析结果(按 mtime + size 失效),
|
|
11
|
+
* 这样每 5 分钟一次的轮询不会反复解压没变过的历史会话。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { join, resolve } from 'node:path'
|
|
17
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
18
|
+
|
|
19
|
+
const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
|
|
20
|
+
const LOG_SUFFIX = '.jsonl.zstd'
|
|
21
|
+
const LOG_PREFIX = 'session.v'
|
|
22
|
+
|
|
23
|
+
/** 解析 DSH home:显式配置 > `DSH_HOME` > `~/.dsh`。 */
|
|
24
|
+
export function resolveDshHome(configured, env = process.env) {
|
|
25
|
+
const fromEnv = env?.DSH_HOME
|
|
26
|
+
const selected = typeof configured === 'string' && configured.trim() !== ''
|
|
27
|
+
? configured.trim()
|
|
28
|
+
: typeof fromEnv === 'string' && fromEnv.trim() !== ''
|
|
29
|
+
? fromEnv.trim()
|
|
30
|
+
: join(homedir(), '.dsh')
|
|
31
|
+
const expanded = selected === '~' ? homedir() : selected.startsWith('~/') || selected.startsWith('~\\') ? join(homedir(), selected.slice(2)) : selected
|
|
32
|
+
return resolve(expanded)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 列出全部会话日志文件。
|
|
37
|
+
*
|
|
38
|
+
* 目录层级由 DSH 决定,这里只认形状:任意两层目录下的 `session.v*.jsonl.zstd`。
|
|
39
|
+
* 找不到(例如全新安装还没有会话)不是错误,返回空数组。
|
|
40
|
+
*/
|
|
41
|
+
export function listSessionLogs(home) {
|
|
42
|
+
const root = join(home, 'sessions')
|
|
43
|
+
const found = []
|
|
44
|
+
let workspaces = []
|
|
45
|
+
try {
|
|
46
|
+
workspaces = readdirSync(root, { withFileTypes: true })
|
|
47
|
+
} catch {
|
|
48
|
+
return found
|
|
49
|
+
}
|
|
50
|
+
for (const workspace of workspaces) {
|
|
51
|
+
if (!workspace.isDirectory()) continue
|
|
52
|
+
const workspaceDir = join(root, workspace.name)
|
|
53
|
+
let sessions = []
|
|
54
|
+
try {
|
|
55
|
+
sessions = readdirSync(workspaceDir, { withFileTypes: true })
|
|
56
|
+
} catch {
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
for (const session of sessions) {
|
|
60
|
+
if (!session.isDirectory()) continue
|
|
61
|
+
const sessionDir = join(workspaceDir, session.name)
|
|
62
|
+
let entries = []
|
|
63
|
+
try {
|
|
64
|
+
entries = readdirSync(sessionDir, { withFileTypes: true })
|
|
65
|
+
} catch {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (!entry.isFile() || !entry.name.startsWith(LOG_PREFIX) || !entry.name.endsWith(LOG_SUFFIX)) continue
|
|
70
|
+
found.push({ path: join(sessionDir, entry.name), sessionId: session.name })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return found
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 把追加写入的多帧 zstd 文件解成一整段文本;写坏的尾部帧直接放弃。 */
|
|
78
|
+
export function decodeLogFile(path) {
|
|
79
|
+
const buffer = readFileSync(path)
|
|
80
|
+
const offsets = []
|
|
81
|
+
for (let i = 0; i + 4 <= buffer.length; i++) {
|
|
82
|
+
if (buffer[i] === ZSTD_MAGIC[0] && buffer[i + 1] === ZSTD_MAGIC[1] && buffer[i + 2] === ZSTD_MAGIC[2] && buffer[i + 3] === ZSTD_MAGIC[3]) {
|
|
83
|
+
offsets.push(i)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const parts = []
|
|
87
|
+
for (let i = 0; i < offsets.length; i++) {
|
|
88
|
+
const end = i + 1 < offsets.length ? offsets[i + 1] : buffer.length
|
|
89
|
+
try {
|
|
90
|
+
parts.push(zstdDecompressSync(buffer.subarray(offsets[i], end)))
|
|
91
|
+
} catch {
|
|
92
|
+
// 最后一个帧可能正在被写入,或历史的坏帧:跳过而不是丢掉整个会话。
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return Buffer.concat(parts).toString('utf8')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 解析一个会话日志中的用量记录。
|
|
100
|
+
*
|
|
101
|
+
* 返回 `{ createdAt, parentSession, origin, delegationDepth, records }`:
|
|
102
|
+
* 会话头(`type: "session"`)里的血缘字段用来把子智能体会话挂回父会话,
|
|
103
|
+
* `createdAt` 用来排除可能的继承记录。
|
|
104
|
+
*
|
|
105
|
+
* 子智能体会话的头部长这样(本机实测):
|
|
106
|
+
* {"type":"session","id":"150f49a5-…","parentSession":"session-79fa1914-…",
|
|
107
|
+
* "origin":"subagent","delegationDepth":1,"createdAt":…}
|
|
108
|
+
* 普通会话没有 `parentSession`,`delegationDepth` 为 0;分支(fork)出来的会话
|
|
109
|
+
* 同样是 0,因此只有 `origin === "subagent"` 或 `delegationDepth > 0` 才算子智能体。
|
|
110
|
+
*/
|
|
111
|
+
export function parseSessionLog(path) {
|
|
112
|
+
const text = decodeLogFile(path)
|
|
113
|
+
let header = null
|
|
114
|
+
const records = []
|
|
115
|
+
for (const line of text.split('\n')) {
|
|
116
|
+
if (line.trim() === '') continue
|
|
117
|
+
let record
|
|
118
|
+
try {
|
|
119
|
+
record = JSON.parse(line)
|
|
120
|
+
} catch {
|
|
121
|
+
continue // 追加写入时被截断的最后一行
|
|
122
|
+
}
|
|
123
|
+
if (record.type === 'session') {
|
|
124
|
+
if (header === null) header = record
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (record.type !== 'assistant/message') continue
|
|
128
|
+
const usage = record.data?.usage
|
|
129
|
+
if (usage === null || typeof usage !== 'object') continue
|
|
130
|
+
records.push({
|
|
131
|
+
time: typeof record.time === 'number' ? record.time : null,
|
|
132
|
+
model: typeof record.data?.message?.source?.model === 'string' ? record.data.message.source.model : '',
|
|
133
|
+
usage,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
createdAt: typeof header?.createdAt === 'number' ? header.createdAt : null,
|
|
138
|
+
parentSession: typeof header?.parentSession === 'string' && header.parentSession !== '' ? header.parentSession : null,
|
|
139
|
+
origin: typeof header?.origin === 'string' ? header.origin : null,
|
|
140
|
+
delegationDepth: typeof header?.delegationDepth === 'number' ? header.delegationDepth : 0,
|
|
141
|
+
records,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 已解析会话的缓存,键为文件路径。 */
|
|
146
|
+
const cache = new Map()
|
|
147
|
+
const MAX_CACHED_SESSIONS = 400
|
|
148
|
+
|
|
149
|
+
/** 读取(必要时重新解析)一个会话,命中缓存时不做任何解压。 */
|
|
150
|
+
export function readSessionLog(file) {
|
|
151
|
+
const stat = statSync(file.path)
|
|
152
|
+
const cached = cache.get(file.path)
|
|
153
|
+
if (cached !== undefined && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
|
|
154
|
+
return cached.session
|
|
155
|
+
}
|
|
156
|
+
const parsed = parseSessionLog(file.path)
|
|
157
|
+
const session = {
|
|
158
|
+
sessionId: file.sessionId,
|
|
159
|
+
path: file.path,
|
|
160
|
+
createdAt: parsed.createdAt,
|
|
161
|
+
parentSession: parsed.parentSession,
|
|
162
|
+
origin: parsed.origin,
|
|
163
|
+
delegationDepth: parsed.delegationDepth,
|
|
164
|
+
records: parsed.records,
|
|
165
|
+
}
|
|
166
|
+
cache.set(file.path, { size: stat.size, mtimeMs: stat.mtimeMs, session })
|
|
167
|
+
if (cache.size > MAX_CACHED_SESSIONS) {
|
|
168
|
+
// Map 保持插入顺序:丢掉最早写入的条目,避免长期运行时无上限增长。
|
|
169
|
+
const oldest = cache.keys().next()
|
|
170
|
+
if (!oldest.done) cache.delete(oldest.value)
|
|
171
|
+
}
|
|
172
|
+
return session
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 读出所有会话的用量记录。
|
|
177
|
+
*
|
|
178
|
+
* 单个会话失败只记录在 `errors` 里,不影响其它会话与整体响应。
|
|
179
|
+
*/
|
|
180
|
+
export function collectSessions(home) {
|
|
181
|
+
const files = listSessionLogs(home)
|
|
182
|
+
const sessions = []
|
|
183
|
+
const errors = []
|
|
184
|
+
for (const file of files) {
|
|
185
|
+
try {
|
|
186
|
+
sessions.push(readSessionLog(file))
|
|
187
|
+
} catch (error) {
|
|
188
|
+
errors.push({ path: file.path, message: error instanceof Error ? error.message : String(error) })
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { sessions, errors, fileCount: files.length }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 清空缓存(测试与诊断用)。 */
|
|
195
|
+
export function clearSessionCache() {
|
|
196
|
+
cache.clear()
|
|
197
|
+
}
|