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.
- package/README.md +10 -14
- package/README.zh.md +9 -12
- package/lib/client.js +493 -177
- package/lib/client.js.map +1 -1
- package/lib/index.js +213 -36
- package/lib/types/client/SkillHubSettingsCard.d.ts +4 -6
- package/lib/types/client/icons.d.ts +25 -0
- package/lib/types/client/locales.d.ts +7 -3
- package/lib/types/client/panel/format.d.ts +10 -0
- package/lib/types/client/settings-card.d.ts +18 -0
- package/lib/types/client/settings-form.d.ts +9 -0
- package/lib/types/client/slash-dots.d.ts +57 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/protocol.d.ts +42 -1
- package/lib/types/stats.d.ts +53 -4
- package/lib/types/store.d.ts +7 -2
- package/package.json +68 -26
- package/src/client/SkillHubSettingsCard.tsx +35 -10
- package/src/client/icons.tsx +61 -0
- package/src/client/index.tsx +13 -1
- package/src/client/locales.ts +14 -6
- package/src/client/panel/SkillDetailView.tsx +6 -2
- package/src/client/panel/SkillHubPanel.tsx +2 -2
- package/src/client/panel/SkillRow.tsx +8 -3
- package/src/client/panel/format.ts +11 -0
- package/src/client/panel/panel.module.css +1 -0
- package/src/client/settings-card.tsx +49 -1
- package/src/client/settings-form.ts +21 -0
- package/src/client/slash-dots.test.ts +145 -0
- package/src/client/slash-dots.tsx +190 -0
- package/src/index.ts +34 -3
- package/src/protocol.ts +47 -1
- package/src/routes.test.ts +3 -1
- package/src/routes.ts +2 -2
- package/src/stats.test.ts +273 -2
- package/src/stats.ts +166 -23
- package/src/store.test.ts +32 -0
- package/src/store.ts +58 -3
package/src/routes.test.ts
CHANGED
|
@@ -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
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
178
|
+
if (isFrozen(record, checkpoint.frozenBefore)) continue
|
|
74
179
|
try {
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
254
|
+
const windowDays = options.windowDays?.() ?? 0
|
|
255
|
+
refreshing = scan(query, checkpoint, startedAt, windowDays)
|
|
256
|
+
.then(({ stats, mutated }) => {
|
|
116
257
|
cached = stats
|
|
117
|
-
cachedAt =
|
|
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
|
})
|