dsh-skill-hub 0.2.2 → 0.2.5

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.
Files changed (38) hide show
  1. package/README.md +10 -14
  2. package/README.zh.md +9 -12
  3. package/lib/client.js +493 -177
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +213 -36
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +4 -6
  7. package/lib/types/client/icons.d.ts +25 -0
  8. package/lib/types/client/locales.d.ts +7 -3
  9. package/lib/types/client/panel/format.d.ts +10 -0
  10. package/lib/types/client/settings-card.d.ts +18 -0
  11. package/lib/types/client/settings-form.d.ts +9 -0
  12. package/lib/types/client/slash-dots.d.ts +57 -0
  13. package/lib/types/index.d.ts +4 -0
  14. package/lib/types/protocol.d.ts +42 -1
  15. package/lib/types/stats.d.ts +53 -4
  16. package/lib/types/store.d.ts +7 -2
  17. package/package.json +68 -26
  18. package/src/client/SkillHubSettingsCard.tsx +35 -10
  19. package/src/client/icons.tsx +61 -0
  20. package/src/client/index.tsx +13 -1
  21. package/src/client/locales.ts +14 -6
  22. package/src/client/panel/SkillDetailView.tsx +6 -2
  23. package/src/client/panel/SkillHubPanel.tsx +2 -2
  24. package/src/client/panel/SkillRow.tsx +8 -3
  25. package/src/client/panel/format.ts +11 -0
  26. package/src/client/panel/panel.module.css +1 -0
  27. package/src/client/settings-card.tsx +49 -1
  28. package/src/client/settings-form.ts +21 -0
  29. package/src/client/slash-dots.test.ts +145 -0
  30. package/src/client/slash-dots.tsx +190 -0
  31. package/src/index.ts +34 -3
  32. package/src/protocol.ts +47 -1
  33. package/src/routes.test.ts +3 -1
  34. package/src/routes.ts +2 -2
  35. package/src/stats.test.ts +273 -2
  36. package/src/stats.ts +166 -23
  37. package/src/store.test.ts +32 -0
  38. package/src/store.ts +58 -3
@@ -127,6 +127,8 @@ describe('skill-hub routes', () => {
127
127
  expect(body.skills[1].writable).toBe(false)
128
128
  expect(body.diagnostics).toHaveLength(1)
129
129
  expect(body.diagnostics[0].reason).toBe('missing YAML frontmatter (--- block)')
130
+ // 面板标题旁的插件版本徽标数据源:随 catalog 附带当前安装版本。
131
+ expect(body.pluginVersion).toMatch(/^\d+\.\d+\.\d+$/)
130
132
  })
131
133
 
132
134
  it('forwards the cwd query to the registry snapshot', async () => {
@@ -394,7 +396,7 @@ describe('skill-hub routes', () => {
394
396
  expect(res.status).toBe(200)
395
397
  const body = res.json() as ConfigResponse
396
398
  expect(body.ok).toBe(true)
397
- expect(body.config).toEqual({ enabled: false, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true })
399
+ expect(body.config).toEqual({ enabled: false, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true, statsWindowDays: 14, statsScanMinutes: 5 })
398
400
  expect(body.saved).toEqual({ enabled: false })
399
401
  })
400
402
 
package/src/routes.ts CHANGED
@@ -85,7 +85,7 @@ import {
85
85
  resolveHubConfig,
86
86
  } from './protocol.ts'
87
87
  import { clearTrash, createSkill, disableSkill, enableSkill, restoreSkill, rootOfPath, rootPath, scanDiagnostics, trashSkill } from './skillfs.ts'
88
- import { checkLatestRelease } from './update.ts'
88
+ import { checkLatestRelease, CURRENT_VERSION } from './update.ts'
89
89
  import { dshHome, StoreError, type SkillHubStore } from './store.ts'
90
90
  import type { SkillStatsReader } from './stats.ts'
91
91
 
@@ -410,7 +410,7 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
410
410
  ...(await scanDiagnostics('user-dsh', home)),
411
411
  ...(await scanDiagnostics('user-agents', home)),
412
412
  ]
413
- return { ok: true, complete, skills, disabled, diagnostics }
413
+ return { ok: true, pluginVersion: CURRENT_VERSION, complete, skills, disabled, diagnostics }
414
414
  }
415
415
 
416
416
  /** Map a loaded definition onto the wire shape. */
package/src/stats.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { countSkillInvocations, createSkillStatsReader, readSkillStats, type SessionQueryLike } from './stats.ts'
3
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
2
+ import { countSkillInvocations, createSkillStatsReader, readSkillStats, STATS_FREEZE_AFTER_MS, type SessionQueryLike } from './stats.ts'
3
+ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
4
4
 
5
5
  /** Minimal user/message event carrying a skill-invocation source. */
6
6
  function invocationEvent(name: string, seq = 1, time = 0): SessionEvent {
@@ -166,3 +166,274 @@ describe('createSkillStatsReader', () => {
166
166
  expect(reads).toBe(2)
167
167
  })
168
168
  })
169
+
170
+ // ------------------------------------------------ 增量扫描与自适应 TTL
171
+
172
+ const NOW = 1_000_000_000_000
173
+ const DAY = 24 * 60 * 60 * 1000
174
+
175
+ /** One fake corpus record (createdAt omitted → never freezes). */
176
+ function record(id: string, createdAt?: number): { header: { id: SessionId; createdAt?: number } } {
177
+ return { header: { id: id as never, ...(createdAt !== undefined ? { createdAt } : {}) } }
178
+ }
179
+
180
+ /** Fake session-query that records which sessions were actually read. */
181
+ function fakeQuery(
182
+ records: ReturnType<typeof record>[],
183
+ eventsById: Record<string, SessionEvent[] | 'corrupt'>,
184
+ reads: string[],
185
+ ): SessionQueryLike {
186
+ return {
187
+ listSessions: async () => records,
188
+ readSession: async (id) => {
189
+ reads.push(String(id))
190
+ const events = eventsById[String(id)]
191
+ if (events === 'corrupt') throw new Error('corrupt')
192
+ return { events: events ?? [] }
193
+ },
194
+ }
195
+ }
196
+
197
+ /** Let the background scan's promise chain settle. */
198
+ async function flush(): Promise<void> {
199
+ await new Promise((resolve) => setTimeout(resolve, 20))
200
+ }
201
+
202
+ describe('frozen-bucket incremental scans', () => {
203
+ it('skips frozen sessions and merges checkpoint totals with the recent window', async () => {
204
+ const reads: string[] = []
205
+ const query = fakeQuery(
206
+ [record('frozen-skill', NOW - 30 * DAY), record('fresh', NOW - 1 * DAY)],
207
+ {
208
+ 'frozen-skill': [invocationEvent('oldskill', 1, NOW - 20 * DAY)],
209
+ fresh: [invocationEvent('tdd', 1), invocationEvent('tdd', 2)],
210
+ },
211
+ reads,
212
+ )
213
+ let checkpoints = 0
214
+ const reader = createSkillStatsReader(query, 60_000, {
215
+ now: () => NOW,
216
+ checkpoint: {
217
+ windowDays: 0,
218
+ frozenBefore: NOW - 10 * DAY,
219
+ frozenSessions: { 'frozen-skill': { createdAt: NOW - 30 * DAY, counts: { oldskill: { count: 5, lastUsed: NOW - 20 * DAY } } } },
220
+ lastFullReconcile: NOW - 3_600_000, // 1h ago → incremental path
221
+ },
222
+ onCheckpoint: () => { checkpoints += 1 },
223
+ })
224
+ expect(await reader()).toEqual([]) // 首次调用不等待扫描
225
+ await flush()
226
+ expect(await reader()).toEqual([
227
+ { name: 'oldskill', count: 5, lastUsed: NOW - 20 * DAY },
228
+ { name: 'tdd', count: 2, lastUsed: 0 },
229
+ ])
230
+ // 冻结会话没有被重读;增量扫描不改检查点 → 不触发持久化回调。
231
+ expect(reads).toEqual(['fresh'])
232
+ expect(checkpoints).toBe(0)
233
+ })
234
+
235
+ it('runs a full reconciliation when due: rebuilds the frozen bucket and advances the watermark', async () => {
236
+ const reads: string[] = []
237
+ const query = fakeQuery(
238
+ [record('ancient', NOW - 30 * DAY), record('recent', NOW - 1 * DAY)],
239
+ {
240
+ ancient: [invocationEvent('a', 1, 5)],
241
+ recent: [invocationEvent('b', 1), invocationEvent('b', 2)],
242
+ },
243
+ reads,
244
+ )
245
+ const saved: Array<Record<string, unknown>> = []
246
+ const reader = createSkillStatsReader(query, 60_000, {
247
+ now: () => NOW,
248
+ // lastFullReconcile = 0 → 对账到期(全量路径)
249
+ checkpoint: { windowDays: 0, frozenBefore: 0, frozenSessions: {}, lastFullReconcile: 0 },
250
+ onCheckpoint: (cp) => { saved.push(cp as unknown as Record<string, unknown>) },
251
+ })
252
+ expect(await reader()).toEqual([])
253
+ await flush()
254
+ expect(await reader()).toEqual([
255
+ { name: 'a', count: 1, lastUsed: 5 },
256
+ { name: 'b', count: 2, lastUsed: 0 },
257
+ ])
258
+ expect(reads).toEqual(['ancient', 'recent']) // 全量:两个都读
259
+ expect(saved).toHaveLength(1)
260
+ expect(saved[0]).toEqual({
261
+ windowDays: 0,
262
+ frozenBefore: NOW - STATS_FREEZE_AFTER_MS,
263
+ // 全历史模式(windowDays=0):冻结会话进缓存且仍计入总数。
264
+ frozenSessions: { ancient: { createdAt: NOW - 30 * DAY, counts: { a: { count: 1, lastUsed: 5 } } } },
265
+ lastFullReconcile: NOW,
266
+ })
267
+ })
268
+
269
+ it('re-reads sessions without createdAt on incremental scans (never freezes)', async () => {
270
+ const reads: string[] = []
271
+ const query = fakeQuery(
272
+ [record('no-stamp'), record('fresh', NOW - 1 * DAY)],
273
+ { 'no-stamp': [invocationEvent('x', 1)], fresh: [invocationEvent('y', 1)] },
274
+ reads,
275
+ )
276
+ const reader = createSkillStatsReader(query, 60_000, {
277
+ now: () => NOW,
278
+ checkpoint: {
279
+ windowDays: 0,
280
+ frozenBefore: NOW - 10 * DAY,
281
+ frozenSessions: {},
282
+ lastFullReconcile: NOW - 3_600_000,
283
+ },
284
+ })
285
+ expect(await reader()).toEqual([])
286
+ await flush()
287
+ expect(await reader().then((s) => s.map((stat) => stat.name))).toEqual(['x', 'y'])
288
+ expect(reads).toEqual(['no-stamp', 'fresh'])
289
+ })
290
+
291
+ it('skips unreadable recent sessions on incremental scans without failing', async () => {
292
+ const reads: string[] = []
293
+ const query = fakeQuery(
294
+ [record('bad', NOW - 2 * DAY), record('good', NOW - 1 * DAY)],
295
+ { bad: 'corrupt', good: [invocationEvent('tdd', 1)] },
296
+ reads,
297
+ )
298
+ const reader = createSkillStatsReader(query, 60_000, {
299
+ now: () => NOW,
300
+ checkpoint: {
301
+ windowDays: 0,
302
+ frozenBefore: NOW - 10 * DAY,
303
+ frozenSessions: { cached: { createdAt: NOW - 30 * DAY, counts: { oldskill: { count: 3, lastUsed: 0 } } } },
304
+ lastFullReconcile: NOW - 3_600_000,
305
+ },
306
+ })
307
+ expect(await reader()).toEqual([])
308
+ await flush()
309
+ expect(await reader()).toEqual([
310
+ { name: 'oldskill', count: 3, lastUsed: 0 },
311
+ { name: 'tdd', count: 1, lastUsed: 0 },
312
+ ])
313
+ })
314
+ })
315
+
316
+ describe('adaptive rescan TTL', () => {
317
+ it('extends the effective TTL to three times the measured scan duration', async () => {
318
+ let clock = 0
319
+ let scans = 0
320
+ const query: SessionQueryLike = {
321
+ listSessions: async () => [{ header: { id: 'a' as never } }, { header: { id: 'b' as never } }],
322
+ readSession: async () => {
323
+ clock += 60_000 // 每个会话耗时 60s → 扫描总耗时 120s
324
+ scans += 1
325
+ return { events: [invocationEvent('tdd')] }
326
+ },
327
+ }
328
+ const reader = createSkillStatsReader(query, 300_000, { now: () => clock })
329
+ expect(await reader()).toEqual([])
330
+ await flush() // 扫描完成:cachedAt=120_000,lastScanDuration=120s
331
+ expect(scans).toBe(2)
332
+
333
+ // 自适应 TTL = max(300s, 3×120s) = 360s。固定 TTL 在 310s 时就该重扫了,
334
+ // 这里必须仍然命中缓存 —— 证明自适应生效。
335
+ clock += 310_000
336
+ await reader()
337
+ expect(scans).toBe(2)
338
+
339
+ clock += 60_000 // 距上次缓存 370s ≥ 360s → 触发后台重扫
340
+ await reader()
341
+ await flush()
342
+ expect(scans).toBe(4)
343
+ })
344
+ })
345
+
346
+ describe('rolling stats window (configurable days)', () => {
347
+ it('counts only sessions inside the window when windowDays > 0', async () => {
348
+ const reads: string[] = []
349
+ const query = fakeQuery(
350
+ [record('old', NOW - 10 * DAY), record('fresh', NOW - 1 * DAY)],
351
+ {
352
+ old: [invocationEvent('oldskill', 1)],
353
+ fresh: [invocationEvent('tdd', 1)],
354
+ },
355
+ reads,
356
+ )
357
+ const reader = createSkillStatsReader(query, 60_000, {
358
+ now: () => NOW,
359
+ windowDays: () => 7, // 只统计最近 7 天
360
+ })
361
+ expect(await reader()).toEqual([])
362
+ await flush()
363
+ // 10 天前的会话超出窗口:不计入,也不进缓存。
364
+ expect(await reader()).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
365
+ })
366
+
367
+ it('keeps full history when the window is 0 (default)', async () => {
368
+ const query = fakeQuery(
369
+ [record('old', NOW - 400 * DAY), record('fresh', NOW - 1 * DAY)],
370
+ {
371
+ old: [invocationEvent('oldskill', 1)],
372
+ fresh: [invocationEvent('tdd', 1)],
373
+ },
374
+ [],
375
+ )
376
+ const reader = createSkillStatsReader(query, 60_000, { now: () => NOW })
377
+ expect(await reader()).toEqual([])
378
+ await flush()
379
+ expect(await reader().then((s) => s.map((stat) => stat.name))).toEqual(['oldskill', 'tdd'])
380
+ })
381
+
382
+ it('forces a full reconciliation when the configured window changes', async () => {
383
+ let window = 0
384
+ const reads: string[] = []
385
+ const query = fakeQuery(
386
+ [record('ancient', NOW - 30 * DAY), record('mid', NOW - 20 * DAY), record('fresh', NOW - 1 * DAY)],
387
+ {
388
+ ancient: [invocationEvent('a', 1)],
389
+ mid: [invocationEvent('b', 1)],
390
+ fresh: [invocationEvent('c', 1)],
391
+ },
392
+ reads,
393
+ )
394
+ let clock = NOW
395
+ const reader = createSkillStatsReader(query, 3_600_000, {
396
+ now: () => clock,
397
+ windowDays: () => window,
398
+ checkpoint: { windowDays: 0, frozenBefore: NOW - STATS_FREEZE_AFTER_MS, frozenSessions: {}, lastFullReconcile: NOW - 1000 },
399
+ })
400
+ // 全历史首轮:增量水位(NOW-14d)之后的只有 fresh;mid/ancient 被冻结跳过。
401
+ expect(await reader()).toEqual([])
402
+ await flush()
403
+ expect(await reader()).toEqual([{ name: 'c', count: 1, lastUsed: 0 }])
404
+ const afterFirst = reads.length
405
+
406
+ // 窗口切到 7 天:检查点记录的 windowDays 不一致 → 强制全量对账,
407
+ // 老会话也会被重读一次,随后合计只含窗内的 c。(推进时钟使 TTL 过期)
408
+ window = 7
409
+ clock += 2 * 3_600_000
410
+ expect(await reader()).toEqual([{ name: 'c', count: 1, lastUsed: 0 }]) // 先回 stale 缓存并触发重扫
411
+ await flush()
412
+ await reader()
413
+ expect(reads.length).toBeGreaterThan(afterFirst)
414
+ const names = (await reader()).map((stat) => stat.name)
415
+ expect(names).not.toContain('a')
416
+ expect(names).not.toContain('b')
417
+ expect(names).toContain('c')
418
+ })
419
+
420
+ it('filters and prunes over-window cache entries on incremental reads', async () => {
421
+ // 防御行为:检查点里残留了超出当前窗口的缓存条目时(例如窗口曾收窄),
422
+ // 增量读取直接把它过滤掉并顺手清除,而不是计入总数。
423
+ const query = fakeQuery([], {}, [])
424
+ const checkpoint = {
425
+ windowDays: 7,
426
+ frozenBefore: NOW - 7 * DAY,
427
+ frozenSessions: {
428
+ 'stale-entry': { createdAt: NOW - 10 * DAY, counts: { oldskill: { count: 9, lastUsed: NOW - 10 * DAY } } },
429
+ },
430
+ lastFullReconcile: NOW - 1000, // 对账未到期 → 增量路径
431
+ }
432
+ const reader = createSkillStatsReader(query, 3_600_000, { now: () => NOW, checkpoint, windowDays: () => 7 })
433
+ expect(await reader()).toEqual([])
434
+ await flush()
435
+ const stats = await reader()
436
+ expect(stats).toEqual([])
437
+ expect(checkpoint.frozenSessions['stale-entry']).toBeUndefined() // 已被懒清理
438
+ })
439
+ })
package/src/stats.ts CHANGED
@@ -12,16 +12,44 @@
12
12
  *
13
13
  * Counting is per-skill-name, not per-source: a name may resolve to different
14
14
  * files across projects, but the model-facing identity is the kebab-case name.
15
+ *
16
+ * Scaling (per-session checkpoint + incremental scans): a full scan
17
+ * decompresses every session log, which grows linearly with total history.
18
+ * Sessions older than the effective watermark are therefore treated as
19
+ * finalized — their per-session counts live in the checkpoint (persisted by
20
+ * the host via the sidecar) and are skipped on incremental scans; only the
21
+ * recent window is re-read. A daily full reconciliation rebuilds the cache
22
+ * and advances the watermark, so a resumed old session is eventually
23
+ * re-counted. On top of that, the reader's TTL adapts to the measured scan
24
+ * duration (STATS_TTL_SCAN_FACTOR), so a heavy scan also lowers its own
25
+ * frequency.
26
+ *
27
+ * Rolling window (statsWindowDays > 0): totals only include sessions created
28
+ * within the last N days. The watermark then equals the window edge, so
29
+ * sessions outside the window are neither re-read nor counted, and the
30
+ * reconciliation prunes their cache entries. Changing the configured window
31
+ * forces one full reconciliation immediately (the checkpoint records the
32
+ * window it was built for), so the new semantics take effect on the next scan
33
+ * instead of up to a day later.
15
34
  */
16
35
 
17
36
  import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
18
37
  // Type-only: pulls the 'skill-invocation' MessageSourceMap augmentation.
19
38
  import type {} from '@deepseek-ai/dsh-skill'
20
- import type { SkillStat } from './protocol.ts'
39
+ import type { SkillStat, SkillStatsCheckpoint } from './protocol.ts'
40
+
41
+ /** Fallback freeze horizon when no rolling window is configured (14 days). */
42
+ export const STATS_FREEZE_AFTER_MS = 14 * 24 * 60 * 60 * 1000
43
+ /** Cadence of the full reconciliation that rebuilds the checkpoint (24 h). */
44
+ export const STATS_FULL_RECONCILE_MS = 24 * 60 * 60 * 1000
45
+ /** Adaptive TTL factor: effective TTL ≥ this multiple of the last scan duration. */
46
+ export const STATS_TTL_SCAN_FACTOR = 3
47
+
48
+ const DAY_MS = 24 * 60 * 60 * 1000
21
49
 
22
50
  /** Narrow structural view of the session-query service (kept loose for tests). */
23
51
  export interface SessionQueryLike {
24
- listSessions(signal?: AbortSignal): Promise<Array<{ header: { id: SessionId } }>>
52
+ listSessions(signal?: AbortSignal): Promise<Array<{ header: { id: SessionId; createdAt?: number } }>>
25
53
  readSession(id: SessionId): Promise<{ events: SessionEvent[] }>
26
54
  }
27
55
 
@@ -65,34 +93,134 @@ export function countSkillInvocations(events: readonly SessionEvent[]): Map<stri
65
93
  return stats
66
94
  }
67
95
 
68
- /** Scan every session and total per-skill counts, keeping the latest lastUsed. */
69
- export async function readSkillStats(query: SessionQueryLike): Promise<SkillStat[]> {
70
- const totals = new Map<string, InvocationStat>()
96
+ /** Aggregated totals bucket keyed by skill name. */
97
+ type Totals = Record<string, InvocationStat>
98
+
99
+ function isFrozen(record: { header: { createdAt?: number } }, watermark: number): boolean {
100
+ const created = record.header.createdAt
101
+ // Missing or non-positive timestamps never freeze: the session stays on the
102
+ // re-read path, which is merely slower — never wrong.
103
+ return typeof created === 'number' && created > 0 && created < watermark
104
+ }
105
+
106
+ function mergeInto(totals: Totals, counted: Map<string, InvocationStat>): void {
107
+ for (const [name, stat] of counted) {
108
+ const total = totals[name]
109
+ if (total === undefined) totals[name] = { ...stat }
110
+ else {
111
+ total.count += stat.count
112
+ if (stat.lastUsed > total.lastUsed) total.lastUsed = stat.lastUsed
113
+ }
114
+ }
115
+ }
116
+
117
+ function toSorted(totals: Totals): SkillStat[] {
118
+ return Object.entries(totals)
119
+ .map(([name, stat]) => ({ name, count: stat.count, lastUsed: stat.lastUsed }))
120
+ .sort((x, y) => x.name.localeCompare(y.name))
121
+ }
122
+
123
+ /** Effective freeze watermark for the configured rolling window (0 = all history → freeze horizon). */
124
+ function watermarkFor(windowDays: number, nowMs: number): number {
125
+ return windowDays > 0 ? nowMs - windowDays * DAY_MS : nowMs - STATS_FREEZE_AFTER_MS
126
+ }
127
+
128
+ /**
129
+ * Whether a session's usage counts toward the configured window. With no
130
+ * window (0) everything counts — full history; with a window, only sessions
131
+ * created inside it do. Distinct from the freeze watermark, which is purely a
132
+ * re-read optimization.
133
+ */
134
+ function inWindow(createdAt: number | undefined, windowDays: number, nowMs: number): boolean {
135
+ if (windowDays <= 0) return true
136
+ return typeof createdAt === 'number' && createdAt > 0 && createdAt >= nowMs - windowDays * DAY_MS
137
+ }
138
+
139
+ /**
140
+ * One pass over the corpus. Runs either a full reconciliation (rebuilds the
141
+ * per-session cache and advances the watermark — mutates the checkpoint) or a
142
+ * cheap incremental scan (re-reads everything at or after the watermark and
143
+ * merges over the cached sessions — leaves the checkpoint untouched). Totals
144
+ * always apply the CURRENT window filter over the cached sessions, so a
145
+ * window shrink takes effect immediately even before the next reconciliation.
146
+ */
147
+ async function scan(query: SessionQueryLike, checkpoint: SkillStatsCheckpoint, nowMs: number, windowDays: number): Promise<{ stats: SkillStat[]; mutated: boolean }> {
71
148
  const sessions = await query.listSessions()
149
+ const cutoff = watermarkFor(windowDays, nowMs)
150
+ const dueFullScan = nowMs - checkpoint.lastFullReconcile >= STATS_FULL_RECONCILE_MS
151
+ || checkpoint.windowDays !== windowDays
152
+
153
+ if (dueFullScan) {
154
+ const cache: SkillStatsCheckpoint['frozenSessions'] = {}
155
+ const totals: Totals = {}
156
+ for (const record of sessions) {
157
+ let counted: Map<string, InvocationStat>
158
+ try {
159
+ counted = countSkillInvocations((await query.readSession(record.header.id)).events)
160
+ } catch {
161
+ continue // unreadable sessions are skipped, never fatal
162
+ }
163
+ const created = record.header.createdAt
164
+ if (isFrozen(record, cutoff) && counted.size > 0 && typeof created === 'number') {
165
+ cache[record.header.id as unknown as string] = { createdAt: created, counts: Object.fromEntries(counted) }
166
+ }
167
+ if (inWindow(created, windowDays, nowMs)) mergeInto(totals, counted)
168
+ }
169
+ checkpoint.frozenSessions = cache
170
+ checkpoint.frozenBefore = cutoff
171
+ checkpoint.windowDays = windowDays
172
+ checkpoint.lastFullReconcile = nowMs
173
+ return { stats: toSorted(totals), mutated: true }
174
+ }
175
+
176
+ const recent: Totals = {}
72
177
  for (const record of sessions) {
73
- let snapshot: { events: SessionEvent[] }
178
+ if (isFrozen(record, checkpoint.frozenBefore)) continue
74
179
  try {
75
- snapshot = await query.readSession(record.header.id)
180
+ mergeInto(recent, countSkillInvocations((await query.readSession(record.header.id)).events))
76
181
  } catch {
77
182
  continue // unreadable sessions are skipped, never fatal
78
183
  }
79
- for (const [name, stat] of countSkillInvocations(snapshot.events)) {
80
- const total = totals.get(name)
81
- if (total === undefined) totals.set(name, { ...stat })
82
- else {
83
- total.count += stat.count
84
- if (stat.lastUsed > total.lastUsed) total.lastUsed = stat.lastUsed
85
- }
184
+ }
185
+ const totals: Totals = {}
186
+ for (const [id, entry] of Object.entries(checkpoint.frozenSessions)) {
187
+ if (!inWindow(entry.createdAt, windowDays, nowMs)) {
188
+ delete checkpoint.frozenSessions[id] // lazily prune entries outside the window
189
+ continue
86
190
  }
191
+ mergeInto(totals, new Map(Object.entries(entry.counts)))
87
192
  }
88
- return [...totals.entries()]
89
- .map(([name, stat]) => ({ name, count: stat.count, lastUsed: stat.lastUsed }))
90
- .sort((a, b) => a.name.localeCompare(b.name))
193
+ mergeInto(totals, new Map(Object.entries(recent)))
194
+ return { stats: toSorted(totals), mutated: false }
195
+ }
196
+
197
+ /**
198
+ * Full-corpus totals in one shot (no checkpoint reuse). Kept as the
199
+ * reference implementation for tests and one-off callers.
200
+ */
201
+ export async function readSkillStats(query: SessionQueryLike, windowDays = 0): Promise<SkillStat[]> {
202
+ const checkpoint: SkillStatsCheckpoint = { windowDays, frozenBefore: 0, frozenSessions: {}, lastFullReconcile: 0 }
203
+ return (await scan(query, checkpoint, Date.now(), windowDays)).stats
91
204
  }
92
205
 
93
206
  /** A memoized stats reader (the panel polls, but logs change slowly). */
94
207
  export type SkillStatsReader = () => Promise<SkillStat[]>
95
208
 
209
+ /** Optional wiring for {@link createSkillStatsReader}. */
210
+ export interface SkillStatsReaderOptions {
211
+ /** Checkpoint restored from the sidecar; absent means "start from zero". */
212
+ checkpoint?: SkillStatsCheckpoint
213
+ /** Injectable clock (epoch ms); defaults to Date.now. Tests drive time with it. */
214
+ now?: () => number
215
+ /** Base rescan interval in ms; a getter reads the live config each check. */
216
+ ttlMs?: number | (() => number)
217
+ /** Rolling window in days; a getter reads the live config each scan. 0 = all history. */
218
+ windowDays?: () => number
219
+ /** Called after a full reconciliation mutated the checkpoint (never after an
220
+ * incremental scan) so the host can persist it to the sidecar. */
221
+ onCheckpoint?: (checkpoint: SkillStatsCheckpoint) => void
222
+ }
223
+
96
224
  /**
97
225
  * Wrap a query in a stale-while-revalidate cache: responses never wait for a
98
226
  * full session-log scan. While the TTL is fresh the cached totals are
@@ -100,21 +228,36 @@ export type SkillStatsReader = () => Promise<SkillStat[]>
100
228
  * single background rescan refreshes them — the panel's next poll picks the
101
229
  * fresh numbers. A full scan decompresses every session log and can take
102
230
  * seconds, so it must never sit on the request path.
231
+ *
232
+ * Two scaling mechanisms keep this sane as history grows:
233
+ * - the rescan is incremental (per-session checkpoint, see module doc);
234
+ * - the effective TTL adapts to the measured scan duration, so a heavier
235
+ * corpus automatically lowers the rescan cadence instead of burning CPU
236
+ * on every poll interval.
103
237
  */
104
- export function createSkillStatsReader(query: SessionQueryLike, ttlMs = 300_000): SkillStatsReader {
238
+ export function createSkillStatsReader(query: SessionQueryLike, ttlMs: number | (() => number) = 300_000, options: SkillStatsReaderOptions = {}): SkillStatsReader {
239
+ const checkpoint: SkillStatsCheckpoint = options.checkpoint ?? { windowDays: 0, frozenBefore: 0, frozenSessions: {}, lastFullReconcile: 0 }
240
+ const now = options.now ?? (() => Date.now())
105
241
  let cached: SkillStat[] | undefined
106
242
  let cachedAt = 0
107
243
  let refreshing: Promise<void> | null = null
244
+ let lastScanDurationMs = 0
245
+
108
246
  return async () => {
109
- const now = Date.now()
110
- if (cached !== undefined && now - cachedAt < ttlMs) return cached
247
+ const startedAt = now()
248
+ const base = typeof ttlMs === 'function' ? ttlMs() : ttlMs
249
+ const ttl = Math.max(base, lastScanDurationMs * STATS_TTL_SCAN_FACTOR)
250
+ if (cached !== undefined && startedAt - cachedAt < ttl) return cached
111
251
  // Expired (or first call): hand back the stale totals (empty on first
112
252
  // call) and kick off one background rescan.
113
253
  if (refreshing === null) {
114
- refreshing = readSkillStats(query)
115
- .then((stats) => {
254
+ const windowDays = options.windowDays?.() ?? 0
255
+ refreshing = scan(query, checkpoint, startedAt, windowDays)
256
+ .then(({ stats, mutated }) => {
116
257
  cached = stats
117
- cachedAt = Date.now()
258
+ cachedAt = now()
259
+ lastScanDurationMs = Math.max(0, cachedAt - startedAt)
260
+ if (mutated) options.onCheckpoint?.({ ...checkpoint, frozenSessions: { ...checkpoint.frozenSessions } })
118
261
  })
119
262
  .catch(() => { /* keep the previous totals on scan failure */ })
120
263
  .finally(() => { refreshing = null })
package/src/store.test.ts CHANGED
@@ -250,4 +250,36 @@ describe('SkillHubStore', () => {
250
250
  expect(await fresh.listDisabled()).toEqual([])
251
251
  })
252
252
 
253
+ it('migrates a v3 file (no skillStats) and persists a checkpoint round-trip at v4', async () => {
254
+ // v3 旧文件:无 skillStats 字段 → 装载后检查点为空,写盘时版本升到当前。
255
+ await writeFile(file, JSON.stringify({
256
+ version: 3,
257
+ disabled: [],
258
+ tags: [{ id: 't1', name: '通用', skillNames: [], default: true }],
259
+ }), 'utf8')
260
+ expect(await store.getSkillStatsState()).toBeUndefined()
261
+ const checkpoint = {
262
+ windowDays: 0,
263
+ frozenBefore: 1000,
264
+ frozenSessions: { 'session-a': { createdAt: 900, counts: { tdd: { count: 7, lastUsed: 900 }, 'code-review': { count: 2, lastUsed: 800 } } } },
265
+ lastFullReconcile: 1234,
266
+ }
267
+ await store.saveSkillStatsState(checkpoint)
268
+ // 新实例读回一致(含深拷贝的 frozenTotals)。
269
+ const reloaded = new SkillHubStore(file)
270
+ expect(await reloaded.getSkillStatsState()).toEqual(checkpoint)
271
+ const raw = JSON.parse(await readFile(file, 'utf8')) as { version: number; skillStats?: unknown }
272
+ expect(raw.version).toBe(4)
273
+ expect(raw.skillStats).toEqual(checkpoint)
274
+ })
275
+
276
+ it('drops a corrupt skillStats bucket instead of trusting bad counts', async () => {
277
+ await writeFile(file, JSON.stringify({
278
+ version: 4,
279
+ disabled: [],
280
+ skillStats: { windowDays: 'x', frozenBefore: true, lastFullReconcile: false, frozenSessions: { bad: { createdAt: 'y', counts: [] } } },
281
+ }), 'utf8')
282
+ expect(await store.getSkillStatsState()).toBeUndefined()
283
+ })
284
+
253
285
  })