dsh-annual-activity 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,889 @@
1
+ /**
2
+ * 全年活跃记录 (dsh-annual-activity) — host half.
3
+ *
4
+ * 纯本地的活跃度统计引擎:
5
+ *
6
+ * 1. 扫描 $DSH_HOME/sessions/<项目目录>/<会话目录>/ 下的会话日志
7
+ * (session.v3.jsonl.zstd / session.jsonl.zstd / 明文 session.jsonl;
8
+ * Node >= 22.15 原生 node:zlib zstd 解压,支持多帧拼接与损坏尾帧容错);
9
+ * 2. 逐条事件按 **本地日历日**(宿主时区,与用户看到的日期一致)分桶,聚合:
10
+ * 会话数 / 轮次(turn) / 步骤(step) / 提问数 / 工具调用 / Token(输入+输出);
11
+ * 3. 活跃等级由「轮次」主口径决定(0 / 1 / 2-5 / 6-15 / 16+),并把阈值随快照下发,
12
+ * 保证面板色块深度与口径永远一致;
13
+ * 4. Host 半区只暴露只读 HTTP 路由(/activity/pull、/activity/hello、
14
+ * POST /activity/refresh),供浏览器半区轮询渲染。
15
+ *
16
+ * 增量策略:以「stat 全部会话文件的 mtime + size」为失效依据,只重新解析变化的
17
+ * 文件;后台每 60s 保活重扫一次,所以「今天」的色块会随会话进行自动变深,
18
+ * 不需要等到第二天。
19
+ *
20
+ * 安装为 web profile 组合中的一行:
21
+ * - id: activity
22
+ * name: 'dsh-annual-activity'
23
+ */
24
+
25
+ import { createRequire } from 'node:module'
26
+ import { fileURLToPath } from 'node:url'
27
+ import path from 'node:path'
28
+ import fs from 'node:fs'
29
+ import fsp from 'node:fs/promises'
30
+ import os from 'node:os'
31
+ import http from 'node:http'
32
+ import https from 'node:https'
33
+ import { zstdDecompress } from 'node:zlib'
34
+ import { promisify } from 'node:util'
35
+ import { execFile } from 'node:child_process'
36
+
37
+ const name = 'dsh-annual-activity'
38
+ // webServer 是 web-app 层服务(等 webStartup 就绪后路由才能注册);timer 提供
39
+ // 随插件卸载自动清理的周期任务。两者都必须列入 inject,否则 apply 可能过早执行。
40
+ const inject = ['timer', 'webServer']
41
+
42
+ // 两次自动重扫之间的最小间隔(毫秒):轮询风暴下限流
43
+ const RESCAN_MIN_INTERVAL = 5000
44
+ // 后台保活重扫周期:今天的新活动即使面板关着也会被记账
45
+ const BACKGROUND_RESCAN_MS = 60 * 1000
46
+ // 负载里最多携带的会话条数(按最近活跃倒序;总量指标不受影响)
47
+ const MAX_SESSIONS_IN_PAYLOAD = 400
48
+ // npm registry 版本检查周期(启动时各查一次;离线静默降级)
49
+ const UPDATE_CHECK_INTERVAL = 6 * 3600 * 1000
50
+
51
+ /**
52
+ * 活跃等级(0-4)的唯一事实源:Host 计算并下发给浏览器。
53
+ * 主口径 = 当日「轮次」(turn):一次用户提问到 agent 收尾算一轮,最贴近
54
+ * 「今天和 DSH 干了多少活」的直觉;Token / 工具调用 / 会话数作为悬浮明细展示,
55
+ * 不参与定级(避免长上下文任务把色块一次性顶到最深)。
56
+ */
57
+ const LEVELS = [
58
+ { level: 0, label: '未活跃', min: 0, max: 0 },
59
+ { level: 1, label: '轻', min: 1, max: 1 },
60
+ { level: 2, label: '中', min: 2, max: 5 },
61
+ { level: 3, label: '高', min: 6, max: 15 },
62
+ { level: 4, label: '极高', min: 16, max: null },
63
+ ]
64
+
65
+ function levelOf(turns) {
66
+ const t = Number(turns) || 0
67
+ if (t <= 0) return 0
68
+ if (t === 1) return 1
69
+ if (t <= 5) return 2
70
+ if (t <= 15) return 3
71
+ return 4
72
+ }
73
+
74
+ function apply(ctx) {
75
+ // ---- 版本:从本包自己的 package.json 读取(唯一事实来源,避免手写漂移)----
76
+ const here = fileURLToPath(import.meta.url)
77
+ const req = createRequire(import.meta.url)
78
+ let VERSION = ''
79
+ try { VERSION = String((req(path.join(path.dirname(here), '..', 'package.json')) || {}).version || '') } catch (err) { /* ignore */ }
80
+
81
+ // ---- 目录解析:DSH_ACTIVITY_ROOT(测试注入)> $DSH_HOME/sessions > ~/.dsh/sessions ----
82
+ function resolveDshHome() {
83
+ if (process.env.DSH_HOME) return process.env.DSH_HOME
84
+ return path.join(os.homedir(), '.dsh')
85
+ }
86
+ function resolveSessionsRoot() {
87
+ if (process.env.DSH_ACTIVITY_ROOT) return process.env.DSH_ACTIVITY_ROOT
88
+ return path.join(resolveDshHome(), 'sessions')
89
+ }
90
+
91
+ // ================= 本地日期工具 =================
92
+
93
+ function pad2(n) { return n < 10 ? '0' + n : '' + n }
94
+
95
+ /** 时间戳 → 本地日历日 'YYYY-MM-DD'(宿主时区) */
96
+ function dayKey(ts) {
97
+ const d = new Date(ts)
98
+ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
99
+ }
100
+
101
+ /** 'YYYY-MM-DD' → 本地当日 00:00 的时间戳(用于算连登) */
102
+ function dayStartMs(key) {
103
+ const [y, m, d] = String(key).split('-').map(Number)
104
+ return new Date(y, (m || 1) - 1, d || 1).getTime()
105
+ }
106
+
107
+ // ================= 会话日志解析 =================
108
+
109
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
110
+ const zstdDecompressAsync = promisify(zstdDecompress)
111
+
112
+ /** 多帧 zstd 解压:逐帧解压拼接;尾部不完整/损坏帧静默跳过(等下次 mtime 变化重扫)。 */
113
+ async function decompressZstdFrames(buf) {
114
+ const starts = []
115
+ let i = 0
116
+ while (i < buf.length - 3) {
117
+ const idx = buf.indexOf(ZSTD_MAGIC, i)
118
+ if (idx < 0) break
119
+ starts.push(idx)
120
+ i = idx + 4
121
+ }
122
+ if (!starts.length) throw new Error('invalid zstd: no frame magic')
123
+ let out = ''
124
+ for (let f = 0; f < starts.length; f++) {
125
+ const end = f + 1 < starts.length ? starts[f + 1] : buf.length
126
+ try {
127
+ out += (await zstdDecompressAsync(buf.subarray(starts[f], end))).toString('utf8')
128
+ } catch (err) { /* 尾部不完整帧:跳过 */ }
129
+ }
130
+ return out
131
+ }
132
+
133
+ function numOr0(v) { return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0 }
134
+
135
+ /**
136
+ * 解析一份会话 JSONL,返回 { sessionId, cwd, createdAt, lastActive, days, totals }。
137
+ *
138
+ * 分桶口径:
139
+ * - `turn/start` / `step/start` / `user/message` / `tool/call` 按自身 `time` 落桶;
140
+ * - Token 只认「一步一条」的权威记录:v3 日志是 `assistant/message.usage`,
141
+ * 旧版日志是 `assistant/chunk(chunk.type==='usage').usage`;同一会话里两种
142
+ * 形态不会混用(版本切换),并按 turn:step 去重双保险,绝不重复计数;
143
+ * - `session` 头事件(含 createdAt/cwd)也要落桶:即使某天只创建了会话没有对话,
144
+ * 这一天仍然算「活跃」——它是真实发生过的事实。
145
+ */
146
+ function parseSessionText(text) {
147
+ let sessionId = ''
148
+ let cwd = ''
149
+ let createdAt = 0
150
+ let lastActive = 0
151
+ let parsedVersion = 0
152
+ const days = new Map() // 'YYYY-MM-DD' -> { turns, steps, userMessages, toolCalls, inputTokens, outputTokens, usageSteps }
153
+ const touchedDays = new Set() // 出现过任何事件(含仅创建会话)的本地日
154
+ const seenUsageStep = new Set() // 'turn:step' → 防止 chunk/message 双计
155
+ const totals = { turns: 0, steps: 0, userMessages: 0, toolCalls: 0, inputTokens: 0, outputTokens: 0 }
156
+
157
+ function bucket(ts) {
158
+ const key = dayKey(ts)
159
+ touchedDays.add(key)
160
+ let d = days.get(key)
161
+ if (!d) {
162
+ d = { turns: 0, steps: 0, userMessages: 0, toolCalls: 0, inputTokens: 0, outputTokens: 0, usageSteps: 0 }
163
+ days.set(key, d)
164
+ }
165
+ return d
166
+ }
167
+
168
+ const lines = text.split('\n')
169
+ for (let li = 0; li < lines.length; li++) {
170
+ const line = lines[li]
171
+ if (!line) continue
172
+ let ev
173
+ try { ev = JSON.parse(line) } catch (err) { continue }
174
+ const t = typeof ev.time === 'number' && ev.time > 0 ? ev.time : 0
175
+ if (t && t > lastActive) lastActive = t
176
+
177
+ if (ev.type === 'session') {
178
+ sessionId = String(ev.id || sessionId || '')
179
+ cwd = typeof ev.cwd === 'string' ? ev.cwd : cwd
180
+ createdAt = Number(ev.createdAt) || createdAt
181
+ parsedVersion = Number(ev.version) || parsedVersion
182
+ // 会话创建本身不增加轮次,但要让这一天出现在活跃集合里:
183
+ // 只要当天创建过会话(哪怕还没有对话),这天也算真实发生过活动。
184
+ if (createdAt) bucket(createdAt)
185
+ continue
186
+ }
187
+
188
+ if (!t) continue
189
+ const data = ev.data || {}
190
+
191
+ if (ev.type === 'turn/start') {
192
+ bucket(t).turns += 1
193
+ totals.turns += 1
194
+ continue
195
+ }
196
+ if (ev.type === 'step/start') {
197
+ bucket(t).steps += 1
198
+ totals.steps += 1
199
+ continue
200
+ }
201
+ if (ev.type === 'user/message') {
202
+ bucket(t).userMessages += 1
203
+ totals.userMessages += 1
204
+ continue
205
+ }
206
+ if (ev.type === 'tool/call') {
207
+ bucket(t).toolCalls += 1
208
+ totals.toolCalls += 1
209
+ continue
210
+ }
211
+
212
+ // Token:v3 assistant/message.usage
213
+ if (ev.type === 'assistant/message' && data.usage && typeof data.usage === 'object') {
214
+ const stepKey = data.turn + ':' + data.step
215
+ if (typeof data.turn === 'number' && typeof data.step === 'number') {
216
+ if (seenUsageStep.has(stepKey)) continue
217
+ seenUsageStep.add(stepKey)
218
+ }
219
+ const inp = numOr0(data.usage.inputTokens)
220
+ const outp = numOr0(data.usage.outputTokens)
221
+ const d = bucket(t)
222
+ d.inputTokens += inp
223
+ d.outputTokens += outp
224
+ d.usageSteps += 1
225
+ totals.inputTokens += inp
226
+ totals.outputTokens += outp
227
+ continue
228
+ }
229
+ // Token:旧版 assistant/chunk(chunk.type === 'usage')
230
+ if (ev.type === 'assistant/chunk') {
231
+ const chunk = data.chunk
232
+ if (!chunk || chunk.type !== 'usage' || !chunk.usage) continue
233
+ const stepKey = data.turn + ':' + data.step
234
+ if (typeof data.turn === 'number' && typeof data.step === 'number') {
235
+ if (seenUsageStep.has(stepKey)) continue
236
+ seenUsageStep.add(stepKey)
237
+ }
238
+ const inp = numOr0(chunk.usage.inputTokens)
239
+ const outp = numOr0(chunk.usage.outputTokens)
240
+ const d = bucket(t)
241
+ d.inputTokens += inp
242
+ d.outputTokens += outp
243
+ d.usageSteps += 1
244
+ totals.inputTokens += inp
245
+ totals.outputTokens += outp
246
+ }
247
+ }
248
+
249
+ return { sessionId, cwd, createdAt, lastActive, version: parsedVersion, days, touchedDays, totals }
250
+ }
251
+
252
+ // ================= 文件级缓存 + 扫描 =================
253
+
254
+ // filePath -> { mtimeMs, size, parsed }
255
+ const parseCache = new Map()
256
+ let snapshot = null
257
+ let snapshotJson = ''
258
+ let seq = 0
259
+ let scanning = false
260
+ let scanPromise = null
261
+ let lastScanFinishedAt = 0
262
+ let lastScanError = ''
263
+ let scanErrorCount = 0
264
+ let forceNextScan = true
265
+
266
+ async function listDirSafe(dir) {
267
+ try { return await fsp.readdir(dir) } catch (err) { return [] }
268
+ }
269
+ async function statOrNull(p) {
270
+ try { return await fsp.stat(p) } catch (err) { return null }
271
+ }
272
+
273
+ /** 定位会话日志:优先 v3 zstd → zstd → 明文 */
274
+ async function resolveSessionFile(sessPath) {
275
+ const candidates = ['session.v3.jsonl.zstd', 'session.jsonl.zstd', 'session.v3.jsonl', 'session.jsonl']
276
+ for (const c of candidates) {
277
+ const p = path.join(sessPath, c)
278
+ if (await statOrNull(p)) return p
279
+ }
280
+ return null
281
+ }
282
+
283
+ /** 读一个会话日志并解析(带 mtime + size 缓存)。 */
284
+ async function parseSessionFile(file) {
285
+ const st = await statOrNull(file)
286
+ if (!st) return null
287
+ const cached = parseCache.get(file)
288
+ if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.parsed
289
+ let text
290
+ try {
291
+ if (file.endsWith('.zstd')) text = await decompressZstdFrames(await fsp.readFile(file))
292
+ else text = await fsp.readFile(file, 'utf8')
293
+ } catch (err) {
294
+ scanErrorCount++
295
+ lastScanError = String((err && err.message) || err)
296
+ return null
297
+ }
298
+ const parsed = parseSessionText(text)
299
+ parseCache.set(file, { mtimeMs: st.mtimeMs, size: st.size, parsed })
300
+ return parsed
301
+ }
302
+
303
+ /** 会话标题富化:storages/session_projcache.json 的 title 投影(best-effort,带 mtime 缓存)。 */
304
+ let titleCache = { mtimeMs: -1, map: {} }
305
+ async function loadTitleMap() {
306
+ const file = path.join(resolveDshHome(), 'storages', 'session_projcache.json')
307
+ const st = await statOrNull(file)
308
+ if (!st) return {}
309
+ if (titleCache.mtimeMs === st.mtimeMs) return titleCache.map
310
+ const map = {}
311
+ try {
312
+ const raw = JSON.parse(await fsp.readFile(file, 'utf8'))
313
+ const tables = raw && raw.tables && raw.tables.sessions
314
+ if (tables && typeof tables === 'object') {
315
+ for (const [id, rec] of Object.entries(tables)) {
316
+ const title = rec && rec.rows && rec.rows.title && rec.rows.title.val
317
+ const cwd = rec && rec.identity && rec.identity.cwd
318
+ if ((typeof title === 'string' && title) || (typeof cwd === 'string' && cwd)) {
319
+ map[id] = { title: typeof title === 'string' ? title : '', cwd: typeof cwd === 'string' ? cwd : '' }
320
+ }
321
+ }
322
+ }
323
+ } catch (err) { /* 富化失败不影响主流程 */ }
324
+ titleCache = { mtimeMs: st.mtimeMs, map }
325
+ return map
326
+ }
327
+
328
+ function baseName(p) { return String(p || '').split(/[\\/]/).filter(Boolean).pop() || '(未知项目)' }
329
+
330
+ /** 全量扫描(增量利用 parseCache)并重建快照。 */
331
+ async function scanOnce() {
332
+ const started = Date.now()
333
+ scanErrorCount = 0
334
+ const root = resolveSessionsRoot()
335
+ const titles = await loadTitleMap()
336
+ const seenFiles = new Set()
337
+ const perSession = []
338
+
339
+ const projectDirs = await listDirSafe(root)
340
+ for (const proj of projectDirs) {
341
+ const projPath = path.join(root, proj)
342
+ const projSt = await statOrNull(projPath)
343
+ if (!projSt || !projSt.isDirectory()) continue
344
+ const sessDirs = await listDirSafe(projPath)
345
+ for (const sess of sessDirs) {
346
+ const sessPath = path.join(projPath, sess)
347
+ const sessSt = await statOrNull(sessPath)
348
+ if (!sessSt || !sessSt.isDirectory()) continue
349
+ const file = await resolveSessionFile(sessPath)
350
+ if (!file) continue
351
+ seenFiles.add(file)
352
+ const parsed = await parseSessionFile(file)
353
+ if (!parsed) continue
354
+ perSession.push({ dir: sess, file, parsed })
355
+ }
356
+ }
357
+
358
+ // 清理已消失文件的缓存条目
359
+ for (const key of Array.from(parseCache.keys())) {
360
+ if (!seenFiles.has(key)) parseCache.delete(key)
361
+ }
362
+
363
+ // ---- 聚合:按天 + 按会话 ----
364
+ const daysAgg = new Map() // day -> metrics
365
+ const sessions = []
366
+
367
+ for (const entry of perSession) {
368
+ const p = entry.parsed
369
+ const sessionId = p.sessionId || path.basename(entry.dir)
370
+ const cwd = p.cwd || ''
371
+ const enriched = titles[sessionId]
372
+ const title = (enriched && enriched.title) || ''
373
+ // 日志头缺 cwd 时用投影里的 cwd 兜底(项目维度更完整)
374
+ const projectCwd = cwd || (enriched && enriched.cwd) || ''
375
+
376
+ let tokens = 0
377
+ let turns = 0
378
+ const sessionDays = []
379
+ // 以 touchedDays 为准:即使某天只有会话创建、没有产生指标,也是活跃日
380
+ for (const key of Array.from(p.touchedDays).sort()) {
381
+ const d = p.days.get(key) || { turns: 0, steps: 0, userMessages: 0, toolCalls: 0, inputTokens: 0, outputTokens: 0 }
382
+ const inp = d.inputTokens
383
+ const outp = d.outputTokens
384
+ tokens += inp + outp
385
+ turns += d.turns
386
+ const dayTurns = d.turns
387
+ let agg = daysAgg.get(key)
388
+ if (!agg) {
389
+ agg = { day: key, sessions: [], turns: 0, steps: 0, userMessages: 0, toolCalls: 0, inputTokens: 0, outputTokens: 0 }
390
+ daysAgg.set(key, agg)
391
+ }
392
+ agg.sessions.push(sessionId)
393
+ agg.turns += dayTurns
394
+ agg.steps += d.steps
395
+ agg.userMessages += d.userMessages
396
+ agg.toolCalls += d.toolCalls
397
+ agg.inputTokens += inp
398
+ agg.outputTokens += outp
399
+ sessionDays.push({ day: key, turns: dayTurns, tokens: inp + outp })
400
+ }
401
+
402
+ sessions.push({
403
+ id: sessionId,
404
+ title,
405
+ project: baseName(projectCwd),
406
+ cwd: projectCwd,
407
+ createdAt: p.createdAt,
408
+ lastActive: p.lastActive,
409
+ turns,
410
+ tokens,
411
+ days: sessionDays.sort((a, b) => (a.day < b.day ? -1 : 1)),
412
+ })
413
+ }
414
+
415
+ // 天 → 数组(会话去重、等级计算)
416
+ const days = Array.from(daysAgg.values()).map((d) => ({
417
+ day: d.day,
418
+ activeSessions: new Set(d.sessions).size,
419
+ turns: d.turns,
420
+ steps: d.steps,
421
+ userMessages: d.userMessages,
422
+ toolCalls: d.toolCalls,
423
+ inputTokens: d.inputTokens,
424
+ outputTokens: d.outputTokens,
425
+ tokens: d.inputTokens + d.outputTokens,
426
+ level: levelOf(d.turns),
427
+ })).sort((a, b) => (a.day < b.day ? -1 : 1))
428
+
429
+ const years = Array.from(new Set(days.map((d) => Number(d.day.slice(0, 4))))).sort((a, b) => a - b)
430
+ sessions.sort((a, b) => (b.lastActive || 0) - (a.lastActive || 0))
431
+
432
+ const next = {
433
+ days,
434
+ years,
435
+ sessions: sessions.slice(0, MAX_SESSIONS_IN_PAYLOAD).map((s) => ({
436
+ id: s.id,
437
+ title: s.title,
438
+ project: s.project,
439
+ createdAt: s.createdAt,
440
+ lastActive: s.lastActive,
441
+ turns: s.turns,
442
+ tokens: s.tokens,
443
+ activeDays: s.days.length,
444
+ })),
445
+ sessionCount: sessions.length,
446
+ levels: LEVELS,
447
+ scannedAt: Date.now(),
448
+ scanMs: Date.now() - started,
449
+ errors: scanErrorCount,
450
+ }
451
+
452
+ const json = JSON.stringify(next)
453
+ // 内容指纹(排除扫描耗时/时间戳):未变则不递增 seq,客户端可据此跳过重渲染
454
+ const stable = json.replace(/"scannedAt":\d+,"scanMs":\d+/, '')
455
+ if (stable !== snapshotJson) {
456
+ snapshotJson = stable
457
+ seq += 1
458
+ }
459
+ snapshot = next
460
+ lastScanFinishedAt = Date.now()
461
+ return snapshot
462
+ }
463
+
464
+ /** 带并发去重 + 最小间隔限流的扫描入口。 */
465
+ async function ensureScan(force) {
466
+ if (scanPromise) return scanPromise
467
+ const fresh = lastScanFinishedAt > 0 && Date.now() - lastScanFinishedAt < RESCAN_MIN_INTERVAL
468
+ if (!force && !forceNextScan && fresh && snapshot) return snapshot
469
+ forceNextScan = false
470
+ scanning = true
471
+ scanPromise = scanOnce()
472
+ .catch((err) => {
473
+ lastScanError = String((err && err.message) || err)
474
+ scanErrorCount++
475
+ return snapshot
476
+ })
477
+ .finally(() => { scanning = false; scanPromise = null })
478
+ return scanPromise
479
+ }
480
+
481
+ // ================= 统计派生(活跃率 / 连登)=================
482
+
483
+ /**
484
+ * 基于「活跃日集合」计算面板统计。口径:
485
+ * - 活跃率分母:当前年份用「已过天数」(今天也算一天),历史年份用全年天数
486
+ * —— 避免 1 月的活跃率被 365 稀释;
487
+ * - 周连登:以周一为一周起点,从「本周 / 上周」向前数连续有活跃日的周数
488
+ * (上周仍有记录就算连登未断,给跨周末的工作流留出余量);
489
+ * - 最长连续:年内连续有活跃日的最长天数。
490
+ */
491
+ function statsForYear(year, daySet, todayKey, todayMs) {
492
+ const dayKeys = Array.from(daySet).filter((k) => Number(k.slice(0, 4)) === year).sort()
493
+ const activeDays = dayKeys.length
494
+ // 已过天数 =(今年第一天 00:00 → 今天 00:00 的天数)+ 1;历史年份即全年天数
495
+ const elapsed = Math.floor((dayStartMs(todayKey) - new Date(year, 0, 1).getTime()) / 86400000) + 1
496
+ const totalDays = year === Number(todayKey.slice(0, 4)) ? Math.max(1, elapsed) : daysInYear(year)
497
+ const rate = totalDays > 0 ? activeDays / totalDays : 0
498
+
499
+ // ---- 周连登 ----
500
+ const weeks = new Set()
501
+ const sortedWeeks = []
502
+ for (const k of dayKeys) {
503
+ const ms = dayStartMs(k)
504
+ const dow = (new Date(ms).getDay() + 6) % 7 // 0 = 周一
505
+ const monday = ms - dow * 86400000
506
+ if (!weeks.has(monday)) { weeks.add(monday); sortedWeeks.push(monday) }
507
+ }
508
+ let longestWeekStreak = 0
509
+ let run = 0
510
+ for (let i = 0; i < sortedWeeks.length; i++) {
511
+ run = i > 0 && sortedWeeks[i] - sortedWeeks[i - 1] === 7 * 86400000 ? run + 1 : 1
512
+ if (run > longestWeekStreak) longestWeekStreak = run
513
+ }
514
+ const todayDow = (new Date(todayMs).getDay() + 6) % 7
515
+ const thisMonday = dayStartMs(todayKey) - todayDow * 86400000
516
+ let weekStreak = 0
517
+ if (sortedWeeks.length) {
518
+ const lastWeek = sortedWeeks[sortedWeeks.length - 1]
519
+ if (lastWeek === thisMonday || lastWeek === thisMonday - 7 * 86400000) {
520
+ let cursor = lastWeek
521
+ while (weeks.has(cursor)) { weekStreak += 1; cursor -= 7 * 86400000 }
522
+ }
523
+ }
524
+
525
+ // ---- 年内最长连续活跃天数 ----
526
+ let longestDayStreak = 0
527
+ let streak = 0
528
+ let prev = null
529
+ for (const k of dayKeys) {
530
+ const ms = dayStartMs(k)
531
+ streak = prev != null && ms - prev === 86400000 ? streak + 1 : 1
532
+ if (streak > longestDayStreak) longestDayStreak = streak
533
+ prev = ms
534
+ }
535
+
536
+ return { year, activeDays, totalDays, rate, weekStreak, longestWeekStreak, longestDayStreak }
537
+ }
538
+
539
+ function daysInYear(y) {
540
+ return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 366 : 365
541
+ }
542
+
543
+ /** 生成完整负载(含派生统计 + 各年汇总)。 */
544
+ function buildPayload() {
545
+ const now = new Date()
546
+ const todayKey = dayKey(now.getTime())
547
+ const snap = snapshot || { days: [], years: [], sessions: [], sessionCount: 0, levels: LEVELS, scannedAt: 0, errors: 0 }
548
+ const daySet = new Set(snap.days.map((d) => d.day))
549
+ const years = snap.years.slice()
550
+ if (!years.includes(now.getFullYear())) years.push(now.getFullYear())
551
+ years.sort((a, b) => a - b)
552
+
553
+ const yearStats = {}
554
+ for (const y of years) yearStats[y] = statsForYear(y, daySet, todayKey, now.getTime())
555
+
556
+ const byDay = {}
557
+ for (const d of snap.days) byDay[d.day] = d
558
+
559
+ const totalTurns = snap.days.reduce((a, d) => a + d.turns, 0)
560
+ const totalTokens = snap.days.reduce((a, d) => a + d.tokens, 0)
561
+ const projects = new Set(snap.sessions.map((s) => s.project)).size
562
+
563
+ return {
564
+ version: VERSION,
565
+ seq,
566
+ today: todayKey,
567
+ currentYear: now.getFullYear(),
568
+ years,
569
+ yearStats,
570
+ byDay,
571
+ levels: LEVELS,
572
+ sessions: snap.sessions,
573
+ sessionCount: snap.sessionCount,
574
+ projects,
575
+ totalTurns,
576
+ totalTokens,
577
+ scannedAt: snap.scannedAt,
578
+ scanMs: snap.scanMs,
579
+ scanErrors: snap.errors,
580
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
581
+ }
582
+ }
583
+
584
+ // ================= 版本与在线升级 =================
585
+ // 与 dsh-whale-copilot / oh-my-dshtoken 同款:启动时 + 每 6h 查 npm registry,
586
+ // 发现新版本后可一键在本插件安装目录执行 pnpm/npm 安装,重启 dsh web 生效。
587
+ // 全程离线静默降级;升级只替换安装文件,不卸载当前插件。
588
+
589
+ function semverParts(v) {
590
+ return String(v || '').replace(/^v/i, '').split(/[.-]/).map((x) => parseInt(x, 10) || 0)
591
+ }
592
+ function semverGt(a, b) {
593
+ const pa = semverParts(a)
594
+ const pb = semverParts(b)
595
+ for (let i = 0; i < 3; i++) {
596
+ if ((pa[i] || 0) > (pb[i] || 0)) return true
597
+ if ((pa[i] || 0) < (pb[i] || 0)) return false
598
+ }
599
+ return false
600
+ }
601
+
602
+ function httpsGetJson(url, timeoutMs) {
603
+ return new Promise((resolve, reject) => {
604
+ // 按协议选模块:便于把 registry 指到本地 http 端点(测试 / 内网镜像)
605
+ const lib = String(url).startsWith('http://') ? http : https
606
+ const r = lib.get(url, { headers: { 'user-agent': name + '/' + (VERSION || '?') } }, (res) => {
607
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
608
+ res.resume()
609
+ httpsGetJson(res.headers.location, timeoutMs).then(resolve, reject)
610
+ return
611
+ }
612
+ if (res.statusCode !== 200) {
613
+ res.resume()
614
+ reject(new Error('http ' + res.statusCode))
615
+ return
616
+ }
617
+ let body = ''
618
+ res.setEncoding('utf8')
619
+ res.on('data', (c) => { body += c; if (body.length > 200000) r.destroy() })
620
+ res.on('end', () => { try { resolve(JSON.parse(body)) } catch (err) { reject(err) } })
621
+ })
622
+ r.on('error', reject)
623
+ r.setTimeout(timeoutMs || 8000, () => r.destroy(new Error('timeout')))
624
+ })
625
+ }
626
+
627
+ // registry 基址:默认 npm 官方;可用 DSH_ACTIVITY_REGISTRY 指向内网镜像(或测试端点)
628
+ const REGISTRY = () => String(process.env.DSH_ACTIVITY_REGISTRY || 'https://registry.npmjs.org').replace(/\/+$/, '')
629
+
630
+ let latestVersion = null
631
+ let updateChecked = false
632
+ let checkInFlight = null
633
+ // 关闭在线检查(离线环境 / 测试 / 只想完全禁网的场景):设了就不查 registry,
634
+ // 面板显示「未检测」,其余功能不受影响。
635
+ const UPDATE_CHECK_DISABLED = () => !!process.env.DSH_ACTIVITY_NO_UPDATE_CHECK
636
+ function checkUpdate() {
637
+ if (UPDATE_CHECK_DISABLED()) {
638
+ updateChecked = true
639
+ latestVersion = null
640
+ return Promise.resolve(null)
641
+ }
642
+ if (checkInFlight) return checkInFlight
643
+ checkInFlight = (async () => {
644
+ try {
645
+ const doc = await httpsGetJson(REGISTRY() + '/' + name + '/latest')
646
+ latestVersion = doc && typeof doc === 'object' ? String(doc.version || '') : ''
647
+ if (!latestVersion) latestVersion = null
648
+ } catch (err) {
649
+ latestVersion = null
650
+ } finally {
651
+ updateChecked = true
652
+ }
653
+ return latestVersion
654
+ })().finally(() => { checkInFlight = null })
655
+ return checkInFlight
656
+ }
657
+
658
+ // 定位 profile 根目录:从本模块所在位置向上找「package.json 含 dsh.profile」的目录
659
+ // (兼容 hoisted 与 pnpm .pnpm 虚拟存储两种布局;本包自己的 package.json 只有
660
+ // dsh.client / dsh.bundle,不会误判)
661
+ function findProfileRoot() {
662
+ let dir = path.dirname(here)
663
+ for (let i = 0; i < 8; i++) {
664
+ try {
665
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
666
+ if (pkg && pkg.dsh && pkg.dsh.profile) return dir
667
+ } catch (err) { /* 无 package.json,继续向上 */ }
668
+ const parent = path.dirname(dir)
669
+ if (parent === dir) break
670
+ dir = parent
671
+ }
672
+ return null
673
+ }
674
+
675
+ // 本包是否为「本地目录 / link / file / tarball」安装:这类安装是源码开发态,
676
+ // 直接跑 `pnpm add <npm 包>` 会把 link 换成 registry 版本,反而让本地源码改动失效。
677
+ // 因此识别出来后拒绝在线升级,并给出正确的升级路径。
678
+ function localInstallOf(profileRoot) {
679
+ try {
680
+ const pkg = JSON.parse(fs.readFileSync(path.join(profileRoot, 'package.json'), 'utf8'))
681
+ const spec = String((pkg.dependencies && pkg.dependencies[name]) || '')
682
+ if (!spec) return null
683
+ if (/^(link|file|workspace):/.test(spec) || /^\.{0,2}\//.test(spec) || /\.tgz$/i.test(spec)) return spec
684
+ return null
685
+ } catch (err) {
686
+ return null
687
+ }
688
+ }
689
+
690
+ const upgrade = { running: false, ok: null, message: '', log: '' }
691
+
692
+ function execPipe(bin, args, opts, timeoutMs) {
693
+ return new Promise((resolve, reject) => {
694
+ let child
695
+ try {
696
+ // Windows 上需要 shell: true 才能正确解析 .cmd 与 PATH 中的可执行文件
697
+ const isWin = process.platform === 'win32'
698
+ const options = Object.assign({
699
+ timeout: timeoutMs || 240000,
700
+ maxBuffer: 8 * 1024 * 1024,
701
+ shell: isWin,
702
+ windowsHide: isWin,
703
+ }, opts || {})
704
+ child = execFile(bin, args, options)
705
+ } catch (err) { reject(err); return }
706
+ let out = ''
707
+ if (child.stdout) child.stdout.on('data', (d) => { out += d })
708
+ if (child.stderr) child.stderr.on('data', (d) => { out += d })
709
+ child.on('error', (err) => reject(err))
710
+ child.on('close', (code) => {
711
+ if (code === 0) resolve({ log: out })
712
+ else reject(new Error(bin + ' 退出码 ' + code + '\n' + String(out).slice(-1500)))
713
+ })
714
+ })
715
+ }
716
+
717
+ async function runInstall(profileRoot, target) {
718
+ // 包管理器可用 DSH_ACTIVITY_PKG_MANAGER 覆盖(测试注入假 pnpm / 用 corepack 等)
719
+ const override = process.env.DSH_ACTIVITY_PKG_MANAGER
720
+ const bin = (b) => (process.platform === 'win32' ? b + '.cmd' : b)
721
+ if (override) {
722
+ return execPipe(override, ['--dir', profileRoot, 'add', name + '@' + target], {}, 240000)
723
+ }
724
+ try {
725
+ return await execPipe(bin('pnpm'), ['--dir', profileRoot, 'add', name + '@' + target], {}, 240000)
726
+ } catch (pnpmErr) {
727
+ try {
728
+ return await execPipe(bin('npm'), ['--prefix', profileRoot, 'install', name + '@' + target, '--no-audit', '--no-fund'], {}, 240000)
729
+ } catch (npmErr) {
730
+ throw new Error('pnpm 失败:' + String((pnpmErr && pnpmErr.message) || pnpmErr).slice(0, 600) +
731
+ ';npm 失败:' + String((npmErr && npmErr.message) || npmErr).slice(0, 600))
732
+ }
733
+ }
734
+ }
735
+
736
+ async function runUpgrade() {
737
+ if (upgrade.running) return upgrade
738
+ if (!latestVersion) { try { await checkUpdate() } catch (err) { /* ignore */ } }
739
+ upgrade.running = true
740
+ upgrade.ok = null
741
+ upgrade.message = ''
742
+ upgrade.log = ''
743
+ try {
744
+ const target = latestVersion
745
+ // 本地 link/file 安装:不能走 registry 升级(会把源码开发态换成发布版)
746
+ const profileRootForCheck = findProfileRoot()
747
+ const localSpec = profileRootForCheck ? localInstallOf(profileRootForCheck) : null
748
+ if (localSpec) {
749
+ upgrade.ok = 'local'
750
+ upgrade.message = '当前是本地安装(' + localSpec + '),已跳过在线升级:直接改源码即可;要发布请用 scripts/publish-plugin.sh'
751
+ } else if (!target || !VERSION || !semverGt(target, VERSION)) {
752
+ upgrade.ok = 'skip'
753
+ upgrade.message = target ? '已是最新版本(v' + VERSION + ')' : '暂无更新信息(未发布或离线)'
754
+ } else {
755
+ const profileRoot = profileRootForCheck || (() => { throw new Error('无法定位插件安装目录(profile)') })()
756
+ const result = await runInstall(profileRoot, target)
757
+ upgrade.log = String((result && result.log) || '').slice(-2000)
758
+ upgrade.ok = 'ok'
759
+ upgrade.message = '已升级到 v' + target + ',重启 dsh web 后生效'
760
+ }
761
+ } catch (err) {
762
+ upgrade.ok = 'fail'
763
+ upgrade.message = '自动升级失败:' + String((err && err.message) || err)
764
+ upgrade.log = String((err && err.stack) || upgrade.log).slice(-2000)
765
+ } finally {
766
+ upgrade.running = false
767
+ }
768
+ return upgrade
769
+ }
770
+
771
+ /** 版本/升级元信息(面板底部「设置」里展示,与活动数据分开、可独立轮询)。 */
772
+ function metaPayload() {
773
+ const profileRoot = findProfileRoot()
774
+ return {
775
+ name,
776
+ version: VERSION,
777
+ latest: latestVersion,
778
+ updateChecked,
779
+ updateAvailable: !!(latestVersion && VERSION && semverGt(latestVersion, VERSION)),
780
+ upgrade: { running: upgrade.running, ok: upgrade.ok, message: upgrade.message },
781
+ localInstall: profileRoot ? localInstallOf(profileRoot) : null,
782
+ updateDisabled: UPDATE_CHECK_DISABLED(),
783
+ }
784
+ }
785
+
786
+ // ================= HTTP 路由(只读,仅本地回环可见)=================
787
+
788
+ function sendJson(res, code, obj) {
789
+ let body = ''
790
+ try { body = JSON.stringify(obj) } catch (err) { body = '{"error":"serialize failed"}' }
791
+ try {
792
+ res.writeHead(code, {
793
+ 'content-type': 'application/json; charset=utf-8',
794
+ 'cache-control': 'no-store',
795
+ 'content-length': Buffer.byteLength(body),
796
+ })
797
+ res.end(body)
798
+ } catch (err) { /* 连接已断开 */ }
799
+ }
800
+
801
+ ctx.webServer.register({
802
+ kind: 'prefix',
803
+ path: '/activity',
804
+ handler: async (req, res) => {
805
+ let pathname = '/'
806
+ try { pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname } catch (err) { pathname = req.url || '/' }
807
+ const url = (() => { try { return new URL(req.url || '/', 'http://127.0.0.1') } catch (err) { return null } })()
808
+
809
+ if (pathname === '/activity/hello') {
810
+ sendJson(res, 200, { ok: true, name, version: VERSION })
811
+ return
812
+ }
813
+
814
+ if (pathname === '/activity/refresh') {
815
+ if (req.method !== 'POST') { sendJson(res, 405, { error: 'method not allowed' }); return }
816
+ try { await ensureScan(true) } catch (err) { /* 已降级 */ }
817
+ sendJson(res, 200, { ok: true, seq, scannedAt: lastScanFinishedAt })
818
+ return
819
+ }
820
+
821
+ if (pathname === '/activity/pull') {
822
+ if (req.method !== 'GET' && req.method !== 'HEAD') { sendJson(res, 405, { error: 'method not allowed' }); return }
823
+ const yearParam = url ? Number(url.searchParams.get('year')) : NaN
824
+ const wantYear = Number.isFinite(yearParam) && yearParam > 1970 ? yearParam : null
825
+ try {
826
+ await ensureScan(false)
827
+ const payload = buildPayload()
828
+ const y = wantYear && payload.years.includes(wantYear) ? wantYear : payload.currentYear
829
+ // 只回该年的日明细(负载有界);跨年汇总仍在 yearStats 里完整给出
830
+ const days = Object.values(payload.byDay).filter((d) => Number(d.day.slice(0, 4)) === y)
831
+ sendJson(res, 200, {
832
+ ...payload,
833
+ byDay: undefined,
834
+ year: y,
835
+ days,
836
+ stats: payload.yearStats[y] || null,
837
+ scanning,
838
+ lastError: lastScanError,
839
+ })
840
+ } catch (err) {
841
+ sendJson(res, 500, { error: String((err && err.message) || err), version: VERSION })
842
+ }
843
+ return
844
+ }
845
+
846
+ // ---- 版本 / 升级元信息 ----
847
+ if (pathname === '/activity/meta') {
848
+ if (req.method !== 'GET' && req.method !== 'HEAD') { sendJson(res, 405, { error: 'method not allowed' }); return }
849
+ sendJson(res, 200, metaPayload())
850
+ return
851
+ }
852
+
853
+ // ---- 手动检测更新(强制查一次 registry,忽略 6h 周期)----
854
+ if (pathname === '/activity/check-update') {
855
+ if (req.method !== 'POST') { sendJson(res, 405, { error: 'method not allowed' }); return }
856
+ try { await checkUpdate() } catch (err) { /* 离线静默 */ }
857
+ sendJson(res, 200, metaPayload())
858
+ return
859
+ }
860
+
861
+ // ---- 在线升级(仅 registry 安装;本地 link/file 安装会被拒绝并说明)----
862
+ if (pathname === '/activity/upgrade') {
863
+ if (req.method !== 'POST') { sendJson(res, 405, { error: 'method not allowed' }); return }
864
+ const state = await runUpgrade()
865
+ sendJson(res, 200, { ...metaPayload(), upgrade: { running: state.running, ok: state.ok, message: state.message, log: state.log } })
866
+ return
867
+ }
868
+
869
+ sendJson(res, 404, {
870
+ error: 'not found',
871
+ routes: ['/activity/pull', '/activity/hello', '/activity/meta', 'POST /activity/refresh', 'POST /activity/check-update', 'POST /activity/upgrade'],
872
+ })
873
+ },
874
+ })
875
+
876
+ // ---- 后台保活重扫:面板关着也让「今天」保持新鲜 ----
877
+ ctx.setInterval(() => { void ensureScan(false) }, BACKGROUND_RESCAN_MS)
878
+
879
+ // ---- 版本检查:启动时 + 每 6h(离线/未发布时静默降级,不阻塞任何功能)----
880
+ void checkUpdate()
881
+ ctx.setInterval(() => { void checkUpdate() }, UPDATE_CHECK_INTERVAL)
882
+
883
+ // ---- 启动即预热一次(不阻塞 apply)----
884
+ void ensureScan(true)
885
+
886
+ ctx.logger?.info?.(`[${name}] 全年活跃记录 host 就绪 · v${VERSION} · 会话根目录 ${resolveSessionsRoot()}`)
887
+ }
888
+
889
+ export { name, inject, apply }