dsh-skill-hub 0.2.4 → 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/lib/index.js CHANGED
@@ -53,27 +53,39 @@ const HUB_CONFIG_DEFAULTS = {
53
53
  announceToAgent: true,
54
54
  showUseCount: true,
55
55
  showUseTime: true,
56
- showGroupSummary: true
56
+ showGroupSummary: true,
57
+ statsWindowDays: 14,
58
+ statsScanMinutes: 5
57
59
  };
58
60
  /**
59
61
  * Resolve the effective hub config: saved sidecar overrides win over the
60
62
  * cordis composition entry (the web card owns runtime config), missing
61
63
  * booleans fall back to HUB_CONFIG_DEFAULTS, and dot colors pass through
62
- * (saved first, then base) only when set.
64
+ * (saved first, then base) only when set. Numeric stats knobs are clamped to
65
+ * their sane ranges (window ≥ 0, scan interval ≥ 1 minute).
63
66
  */
64
67
  function resolveHubConfig(saved, base = {}) {
65
68
  const dotModelColor = saved.dotModelColor !== void 0 ? saved.dotModelColor : base.dotModelColor;
66
69
  const dotUserColor = saved.dotUserColor !== void 0 ? saved.dotUserColor : base.dotUserColor;
70
+ const windowDays = clampNumber(saved.statsWindowDays ?? base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays;
71
+ const scanMinutes = clampNumber(saved.statsScanMinutes ?? base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes;
67
72
  return {
68
73
  enabled: saved.enabled ?? base.enabled ?? HUB_CONFIG_DEFAULTS.enabled,
69
74
  announceToAgent: saved.announceToAgent ?? base.announceToAgent ?? HUB_CONFIG_DEFAULTS.announceToAgent,
70
75
  showUseCount: saved.showUseCount ?? base.showUseCount ?? HUB_CONFIG_DEFAULTS.showUseCount,
71
76
  showUseTime: saved.showUseTime ?? base.showUseTime ?? HUB_CONFIG_DEFAULTS.showUseTime,
72
77
  showGroupSummary: saved.showGroupSummary ?? base.showGroupSummary ?? HUB_CONFIG_DEFAULTS.showGroupSummary,
78
+ statsWindowDays: windowDays,
79
+ statsScanMinutes: scanMinutes,
73
80
  ...dotModelColor !== void 0 ? { dotModelColor } : {},
74
81
  ...dotUserColor !== void 0 ? { dotUserColor } : {}
75
82
  };
76
83
  }
84
+ /** Clamp a numeric override into a valid value; undefined/invalid stays undefined. */
85
+ function clampNumber(value, min) {
86
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min) return void 0;
87
+ return Math.floor(value);
88
+ }
77
89
  /** HEX color validation shared by host routes and the settings card. */
78
90
  const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
79
91
  //#endregion
@@ -129,7 +141,7 @@ function migrateStore(parsed) {
129
141
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
130
142
  const record = parsed;
131
143
  const version = typeof record.version === "number" ? record.version : 0;
132
- if (version > 3) return null;
144
+ if (version > 4) return null;
133
145
  const disabled = Array.isArray(record.disabled) ? record.disabled : [];
134
146
  const config = typeof record.config === "object" && record.config !== null && !Array.isArray(record.config) ? record.config : void 0;
135
147
  const tags = Array.isArray(record.tags) ? record.tags : void 0;
@@ -153,13 +165,14 @@ function migrateStore(parsed) {
153
165
  }));
154
166
  }
155
167
  return {
156
- version: 3,
168
+ version: 4,
157
169
  disabled,
158
170
  ...config !== void 0 ? { config } : {},
159
171
  ...tags !== void 0 ? { tags } : {},
160
172
  ...sources !== void 0 ? { sources } : {},
161
173
  ...marketSources !== void 0 ? { marketSources } : {},
162
- ...trash !== void 0 ? { trash } : {}
174
+ ...trash !== void 0 ? { trash } : {},
175
+ ...record.skillStats !== void 0 ? { skillStats: record.skillStats } : {}
163
176
  };
164
177
  }
165
178
  /** Sidecar state owner. */
@@ -171,6 +184,7 @@ var SkillHubStore = class {
171
184
  sourcesByRepo = /* @__PURE__ */ new Map();
172
185
  marketSources = [];
173
186
  trashByName = /* @__PURE__ */ new Map();
187
+ skillStats = void 0;
174
188
  loaded = false;
175
189
  /** Serializes persist runs: concurrent mutators must not let an earlier
176
190
  * snapshot overwrite a later one (rename is atomic, ordering is not). */
@@ -250,6 +264,28 @@ var SkillHubStore = class {
250
264
  });
251
265
  }
252
266
  }
267
+ const savedStats = migrated.skillStats;
268
+ if (savedStats !== null && typeof savedStats === "object" && typeof savedStats.frozenBefore === "number" && typeof savedStats.lastFullReconcile === "number" && typeof savedStats.windowDays === "number" && typeof savedStats.frozenSessions === "object" && savedStats.frozenSessions !== null) {
269
+ const sessions = {};
270
+ for (const [id, entry] of Object.entries(savedStats.frozenSessions)) {
271
+ if (entry === null || typeof entry !== "object" || typeof entry.createdAt !== "number" || typeof entry.counts !== "object" || entry.counts === null) continue;
272
+ const counts = {};
273
+ for (const [name, stat] of Object.entries(entry.counts)) if (stat !== null && typeof stat === "object" && typeof stat.count === "number" && typeof stat.lastUsed === "number") counts[name] = {
274
+ count: stat.count,
275
+ lastUsed: stat.lastUsed
276
+ };
277
+ sessions[id] = {
278
+ createdAt: entry.createdAt,
279
+ counts
280
+ };
281
+ }
282
+ this.skillStats = {
283
+ windowDays: savedStats.windowDays,
284
+ frozenBefore: savedStats.frozenBefore,
285
+ frozenSessions: sessions,
286
+ lastFullReconcile: savedStats.lastFullReconcile
287
+ };
288
+ }
253
289
  }
254
290
  } catch (error) {
255
291
  if (error.code !== "ENOENT") console.warn("[dsh-skill-hub] sidecar state unreadable, starting empty:", error instanceof Error ? error.message : error);
@@ -594,16 +630,36 @@ var SkillHubStore = class {
594
630
  if (!this.trashByName.delete(name)) return;
595
631
  await this.persist();
596
632
  }
633
+ /** The persisted usage-statistics checkpoint (undefined until first saved). */
634
+ async getSkillStatsState() {
635
+ await this.ensureLoaded();
636
+ return this.skillStats !== void 0 ? {
637
+ ...this.skillStats,
638
+ frozenSessions: { ...this.skillStats.frozenSessions }
639
+ } : void 0;
640
+ }
641
+ /** Persist a usage-statistics checkpoint (written at most ~once a day, on full reconciliations). */
642
+ async saveSkillStatsState(state) {
643
+ await this.ensureLoaded();
644
+ this.skillStats = {
645
+ windowDays: state.windowDays,
646
+ frozenBefore: state.frozenBefore,
647
+ frozenSessions: { ...state.frozenSessions },
648
+ lastFullReconcile: state.lastFullReconcile
649
+ };
650
+ await this.persist();
651
+ }
597
652
  persist() {
598
653
  const run = this.writeChain.then(async () => {
599
654
  const payload = {
600
- version: 3,
655
+ version: 4,
601
656
  disabled: [...this.entries.values()],
602
657
  config: this.config,
603
658
  ...this.tagsById.size > 0 ? { tags: [...this.tagsById.values()] } : {},
604
659
  ...this.sourcesByRepo.size > 0 ? { sources: [...this.sourcesByRepo.values()] } : {},
605
660
  ...this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {},
606
- ...this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {}
661
+ ...this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {},
662
+ ...this.skillStats !== void 0 ? { skillStats: this.skillStats } : {}
607
663
  };
608
664
  const tmp = this.file + ".tmp";
609
665
  await mkdir(dirname(this.file), { recursive: true });
@@ -1904,6 +1960,7 @@ async function buildCatalog(deps, cwd) {
1904
1960
  const diagnostics = [...await scanDiagnostics("user-dsh", home), ...await scanDiagnostics("user-agents", home)];
1905
1961
  return {
1906
1962
  ok: true,
1963
+ pluginVersion: CURRENT_VERSION,
1907
1964
  complete,
1908
1965
  skills,
1909
1966
  disabled,
@@ -3011,6 +3068,9 @@ function makeRoutes(deps) {
3011
3068
  }
3012
3069
  //#endregion
3013
3070
  //#region src/stats.ts
3071
+ /** Fallback freeze horizon when no rolling window is configured (14 days). */
3072
+ const STATS_FREEZE_AFTER_MS = 336 * 60 * 60 * 1e3;
3073
+ const DAY_MS = 1440 * 60 * 1e3;
3014
3074
  /** Collect per-skill invocation counts and last-used times from one session. */
3015
3075
  function countSkillInvocations(events) {
3016
3076
  const stats = /* @__PURE__ */ new Map();
@@ -3041,31 +3101,100 @@ function countSkillInvocations(events) {
3041
3101
  }
3042
3102
  return stats;
3043
3103
  }
3044
- /** Scan every session and total per-skill counts, keeping the latest lastUsed. */
3045
- async function readSkillStats(query) {
3046
- const totals = /* @__PURE__ */ new Map();
3104
+ function isFrozen(record, watermark) {
3105
+ const created = record.header.createdAt;
3106
+ return typeof created === "number" && created > 0 && created < watermark;
3107
+ }
3108
+ function mergeInto(totals, counted) {
3109
+ for (const [name, stat] of counted) {
3110
+ const total = totals[name];
3111
+ if (total === void 0) totals[name] = { ...stat };
3112
+ else {
3113
+ total.count += stat.count;
3114
+ if (stat.lastUsed > total.lastUsed) total.lastUsed = stat.lastUsed;
3115
+ }
3116
+ }
3117
+ }
3118
+ function toSorted(totals) {
3119
+ return Object.entries(totals).map(([name, stat]) => ({
3120
+ name,
3121
+ count: stat.count,
3122
+ lastUsed: stat.lastUsed
3123
+ })).sort((x, y) => x.name.localeCompare(y.name));
3124
+ }
3125
+ /** Effective freeze watermark for the configured rolling window (0 = all history → freeze horizon). */
3126
+ function watermarkFor(windowDays, nowMs) {
3127
+ return windowDays > 0 ? nowMs - windowDays * DAY_MS : nowMs - STATS_FREEZE_AFTER_MS;
3128
+ }
3129
+ /**
3130
+ * Whether a session's usage counts toward the configured window. With no
3131
+ * window (0) everything counts — full history; with a window, only sessions
3132
+ * created inside it do. Distinct from the freeze watermark, which is purely a
3133
+ * re-read optimization.
3134
+ */
3135
+ function inWindow(createdAt, windowDays, nowMs) {
3136
+ if (windowDays <= 0) return true;
3137
+ return typeof createdAt === "number" && createdAt > 0 && createdAt >= nowMs - windowDays * DAY_MS;
3138
+ }
3139
+ /**
3140
+ * One pass over the corpus. Runs either a full reconciliation (rebuilds the
3141
+ * per-session cache and advances the watermark — mutates the checkpoint) or a
3142
+ * cheap incremental scan (re-reads everything at or after the watermark and
3143
+ * merges over the cached sessions — leaves the checkpoint untouched). Totals
3144
+ * always apply the CURRENT window filter over the cached sessions, so a
3145
+ * window shrink takes effect immediately even before the next reconciliation.
3146
+ */
3147
+ async function scan(query, checkpoint, nowMs, windowDays) {
3047
3148
  const sessions = await query.listSessions();
3149
+ const cutoff = watermarkFor(windowDays, nowMs);
3150
+ if (nowMs - checkpoint.lastFullReconcile >= 864e5 || checkpoint.windowDays !== windowDays) {
3151
+ const cache = {};
3152
+ const totals = {};
3153
+ for (const record of sessions) {
3154
+ let counted;
3155
+ try {
3156
+ counted = countSkillInvocations((await query.readSession(record.header.id)).events);
3157
+ } catch {
3158
+ continue;
3159
+ }
3160
+ const created = record.header.createdAt;
3161
+ if (isFrozen(record, cutoff) && counted.size > 0 && typeof created === "number") cache[record.header.id] = {
3162
+ createdAt: created,
3163
+ counts: Object.fromEntries(counted)
3164
+ };
3165
+ if (inWindow(created, windowDays, nowMs)) mergeInto(totals, counted);
3166
+ }
3167
+ checkpoint.frozenSessions = cache;
3168
+ checkpoint.frozenBefore = cutoff;
3169
+ checkpoint.windowDays = windowDays;
3170
+ checkpoint.lastFullReconcile = nowMs;
3171
+ return {
3172
+ stats: toSorted(totals),
3173
+ mutated: true
3174
+ };
3175
+ }
3176
+ const recent = {};
3048
3177
  for (const record of sessions) {
3049
- let snapshot;
3178
+ if (isFrozen(record, checkpoint.frozenBefore)) continue;
3050
3179
  try {
3051
- snapshot = await query.readSession(record.header.id);
3180
+ mergeInto(recent, countSkillInvocations((await query.readSession(record.header.id)).events));
3052
3181
  } catch {
3053
3182
  continue;
3054
3183
  }
3055
- for (const [name, stat] of countSkillInvocations(snapshot.events)) {
3056
- const total = totals.get(name);
3057
- if (total === void 0) totals.set(name, { ...stat });
3058
- else {
3059
- total.count += stat.count;
3060
- if (stat.lastUsed > total.lastUsed) total.lastUsed = stat.lastUsed;
3061
- }
3184
+ }
3185
+ const totals = {};
3186
+ for (const [id, entry] of Object.entries(checkpoint.frozenSessions)) {
3187
+ if (!inWindow(entry.createdAt, windowDays, nowMs)) {
3188
+ delete checkpoint.frozenSessions[id];
3189
+ continue;
3062
3190
  }
3191
+ mergeInto(totals, new Map(Object.entries(entry.counts)));
3063
3192
  }
3064
- return [...totals.entries()].map(([name, stat]) => ({
3065
- name,
3066
- count: stat.count,
3067
- lastUsed: stat.lastUsed
3068
- })).sort((a, b) => a.name.localeCompare(b.name));
3193
+ mergeInto(totals, new Map(Object.entries(recent)));
3194
+ return {
3195
+ stats: toSorted(totals),
3196
+ mutated: false
3197
+ };
3069
3198
  }
3070
3199
  /**
3071
3200
  * Wrap a query in a stale-while-revalidate cache: responses never wait for a
@@ -3074,19 +3203,44 @@ async function readSkillStats(query) {
3074
3203
  * single background rescan refreshes them — the panel's next poll picks the
3075
3204
  * fresh numbers. A full scan decompresses every session log and can take
3076
3205
  * seconds, so it must never sit on the request path.
3206
+ *
3207
+ * Two scaling mechanisms keep this sane as history grows:
3208
+ * - the rescan is incremental (per-session checkpoint, see module doc);
3209
+ * - the effective TTL adapts to the measured scan duration, so a heavier
3210
+ * corpus automatically lowers the rescan cadence instead of burning CPU
3211
+ * on every poll interval.
3077
3212
  */
3078
- function createSkillStatsReader(query, ttlMs = 3e5) {
3213
+ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
3214
+ const checkpoint = options.checkpoint ?? {
3215
+ windowDays: 0,
3216
+ frozenBefore: 0,
3217
+ frozenSessions: {},
3218
+ lastFullReconcile: 0
3219
+ };
3220
+ const now = options.now ?? (() => Date.now());
3079
3221
  let cached;
3080
3222
  let cachedAt = 0;
3081
3223
  let refreshing = null;
3224
+ let lastScanDurationMs = 0;
3082
3225
  return async () => {
3083
- if (cached !== void 0 && Date.now() - cachedAt < ttlMs) return cached;
3084
- if (refreshing === null) refreshing = readSkillStats(query).then((stats) => {
3085
- cached = stats;
3086
- cachedAt = Date.now();
3087
- }).catch(() => {}).finally(() => {
3088
- refreshing = null;
3089
- });
3226
+ const startedAt = now();
3227
+ const base = typeof ttlMs === "function" ? ttlMs() : ttlMs;
3228
+ const ttl = Math.max(base, lastScanDurationMs * 3);
3229
+ if (cached !== void 0 && startedAt - cachedAt < ttl) return cached;
3230
+ if (refreshing === null) {
3231
+ const windowDays = options.windowDays?.() ?? 0;
3232
+ refreshing = scan(query, checkpoint, startedAt, windowDays).then(({ stats, mutated }) => {
3233
+ cached = stats;
3234
+ cachedAt = now();
3235
+ lastScanDurationMs = Math.max(0, cachedAt - startedAt);
3236
+ if (mutated) options.onCheckpoint?.({
3237
+ ...checkpoint,
3238
+ frozenSessions: { ...checkpoint.frozenSessions }
3239
+ });
3240
+ }).catch(() => {}).finally(() => {
3241
+ refreshing = null;
3242
+ });
3243
+ }
3090
3244
  return cached ?? [];
3091
3245
  };
3092
3246
  }
@@ -3106,7 +3260,9 @@ const Config = z.object({
3106
3260
  enabled: z.boolean().default(HUB_CONFIG_DEFAULTS.enabled),
3107
3261
  showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount),
3108
3262
  showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
3109
- showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary)
3263
+ showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
3264
+ statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
3265
+ statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes)
3110
3266
  });
3111
3267
  /**
3112
3268
  * Settings namespace hosting the hub's runtime config. Since dsh rc.7 the
@@ -3124,7 +3280,9 @@ const HubSettingsSchema = z.object({
3124
3280
  showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
3125
3281
  showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
3126
3282
  dotModelColor: z.string().pattern(HEX_COLOR_RE),
3127
- dotUserColor: z.string().pattern(HEX_COLOR_RE)
3283
+ dotUserColor: z.string().pattern(HEX_COLOR_RE),
3284
+ statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
3285
+ statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes)
3128
3286
  });
3129
3287
  /** Order of the announcement section within the tool-guidance band. */
3130
3288
  const SECTION_ORDER = 152;
@@ -3211,8 +3369,27 @@ function apply(ctx, config) {
3211
3369
  }
3212
3370
  })();
3213
3371
  ctx.inject(["sessionQuery"], (sctx) => {
3214
- stats = createSkillStatsReader(sctx.sessionQuery);
3215
- sync();
3372
+ (async () => {
3373
+ const saved = await store.getSkillStatsState().catch(() => void 0);
3374
+ const scanMinutes = () => {
3375
+ const value = current().statsScanMinutes;
3376
+ return typeof value === "number" && value >= 1 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsScanMinutes;
3377
+ };
3378
+ const windowDays = () => {
3379
+ const value = current().statsWindowDays;
3380
+ return typeof value === "number" && value >= 0 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsWindowDays;
3381
+ };
3382
+ stats = createSkillStatsReader(sctx.sessionQuery, () => scanMinutes() * 6e4, {
3383
+ checkpoint: saved,
3384
+ windowDays,
3385
+ onCheckpoint: (next) => {
3386
+ store.saveSkillStatsState(next).catch((error) => {
3387
+ ctx.logger.warn("[dsh-skill-hub] persisting skill-stats checkpoint failed", error);
3388
+ });
3389
+ }
3390
+ });
3391
+ sync();
3392
+ })();
3216
3393
  });
3217
3394
  }
3218
3395
  //#endregion
@@ -20,6 +20,8 @@ export interface SkillHubSettingsState extends CardShell {
20
20
  showUseCount: FieldState;
21
21
  showUseTime: FieldState;
22
22
  showGroupSummary: FieldState;
23
+ statsWindowDays: FieldState;
24
+ statsScanMinutes: FieldState;
23
25
  }
24
26
  /** The business face the card's slot registration injects. */
25
27
  export interface SkillHubSettingsCardFace {
@@ -196,6 +196,10 @@ export declare const zh: {
196
196
  readonly 'settings.showUseTimeHint': "在技能名行右侧显示相对时间(如「3 天前」)。";
197
197
  readonly 'settings.showGroupSummary': "显示分组汇总";
198
198
  readonly 'settings.showGroupSummaryHint': "在分组标题后汇总调用次数与最近调用时间。";
199
+ readonly 'settings.statsWindowDays': "统计窗口(天)";
200
+ readonly 'settings.statsWindowDaysHint': "只统计最近 N 天的使用次数,默认 14 天;0 = 全部历史。改动立即生效。";
201
+ readonly 'settings.statsScanMinutes': "自动统计间隔(分钟)";
202
+ readonly 'settings.statsScanMinutesHint': "后台扫描会话日志的间隔,最小 1 分钟;扫描耗时会自动拉长间隔。";
199
203
  readonly 'settings.inherit': "继承";
200
204
  readonly 'settings.on': "开";
201
205
  readonly 'settings.off': "关";
@@ -80,3 +80,21 @@ export interface ColorFieldProps {
80
80
  onReset: () => void;
81
81
  }
82
82
  export declare function ColorField(props: ColorFieldProps): ReactElement;
83
+ /** One staged numeric field: a numeric draft text input with inherit/reset semantics. */
84
+ export interface NumberFieldProps {
85
+ id: string;
86
+ label: string;
87
+ hint: string;
88
+ /** Placeholder shown while the field inherits its default. */
89
+ inheritLabel: string;
90
+ overriddenLabel: string;
91
+ resetLabel: string;
92
+ disabled: boolean;
93
+ /** Effective number when overridden; empty string means inherit. */
94
+ text: string;
95
+ overridden: boolean;
96
+ invalid?: boolean;
97
+ onEdit: (text: string) => void;
98
+ onReset: () => void;
99
+ }
100
+ export declare function NumberField(props: NumberFieldProps): ReactElement;
@@ -23,6 +23,15 @@ export interface FieldSpec {
23
23
  export declare function booleanField(field: string): FieldSpec;
24
24
  /** A #rrggbb color field, edited through hex draft text. */
25
25
  export declare function colorField(field: string): FieldSpec;
26
+ /**
27
+ * A numeric field with range clamping. Integer by default: a fractional draft
28
+ * is truncated so "5.5 分钟" cannot sneak past an integer-only schema.
29
+ */
30
+ export declare function numberField(field: string, options?: {
31
+ min?: number;
32
+ max?: number;
33
+ integer?: boolean;
34
+ }): FieldSpec;
26
35
  /** Card-level state the chrome renders. */
27
36
  export interface CardShell {
28
37
  available: boolean;
@@ -25,6 +25,10 @@ export interface Config {
25
25
  showUseTime?: boolean;
26
26
  /** Show group-header usage summaries (count + last used). Default true. */
27
27
  showGroupSummary?: boolean;
28
+ /** 统计滚动窗口天数:只统计最近 N 天的使用;0 = 全部历史。默认 0。 */
29
+ statsWindowDays?: number;
30
+ /** 自动统计扫描间隔(分钟,最小 1)。默认 5。 */
31
+ statsScanMinutes?: number;
28
32
  }
29
33
  export declare const Config: z<Config>;
30
34
  /**
@@ -81,6 +81,8 @@ export interface DiagnosticEntry {
81
81
  /** GET /api/skill-hub/catalog */
82
82
  export interface CatalogResponse {
83
83
  ok: true;
84
+ /** 已安装插件自身的版本号(package.json version),面板标题旁显示。 */
85
+ pluginVersion: string;
84
86
  /** Whether discovery completed within a stable catalog revision. */
85
87
  complete: boolean;
86
88
  /** Sorted winning summaries of every enabled skill (all roots + providers). */
@@ -179,6 +181,30 @@ export interface StatsResponse {
179
181
  /** Sorted per-skill invocation counts. */
180
182
  stats: SkillStat[];
181
183
  }
184
+ /**
185
+ * Persisted incremental-scan checkpoint for the usage statistics (sidecar
186
+ * `skillStats` field). Sessions created before `frozenBefore` are treated as
187
+ * finalized: their per-session counts live in `frozenSessions` (only sessions
188
+ * with at least one invocation are kept) and they are not re-read on
189
+ * incremental scans. A daily full reconciliation rebuilds the cache and
190
+ * advances the watermark, so a resumed old session is eventually re-counted.
191
+ */
192
+ export interface SkillStatsCheckpoint {
193
+ /** The rolling-window configuration this checkpoint was built for (0 = all history). */
194
+ windowDays: number;
195
+ /** Watermark: every session with header.createdAt < this value is frozen. */
196
+ frozenBefore: number;
197
+ /** Per-session counts of finalized sessions, keyed by session id. */
198
+ frozenSessions: Record<string, {
199
+ createdAt: number;
200
+ counts: Record<string, {
201
+ count: number;
202
+ lastUsed: number;
203
+ }>;
204
+ }>;
205
+ /** Epoch ms of the last full reconciliation (drives the daily cadence). */
206
+ lastFullReconcile: number;
207
+ }
182
208
  /** JSON error body shared by every route. */
183
209
  export interface ErrorResponse {
184
210
  error: string;
@@ -199,6 +225,10 @@ export interface HubConfig {
199
225
  showUseTime?: boolean;
200
226
  /** Show group-header usage summaries (count + last used). Default true. */
201
227
  showGroupSummary?: boolean;
228
+ /** 统计滚动窗口天数:只统计最近 N 天的使用;0 = 全部历史。默认 0。 */
229
+ statsWindowDays?: number;
230
+ /** 自动统计扫描间隔(分钟,最小 1)。默认 5。 */
231
+ statsScanMinutes?: number;
202
232
  }
203
233
  /**
204
234
  * The resolved shape of the hub's settings namespace (schema defaults, then
@@ -216,6 +246,10 @@ export type HubSettingsValue = {
216
246
  dotModelColor?: string;
217
247
  /** User-invocable dot color (#rrggbb); absent means the panel default. */
218
248
  dotUserColor?: string;
249
+ /** 统计滚动窗口天数(0 = 全部历史)。 */
250
+ statsWindowDays?: number;
251
+ /** 自动统计扫描间隔(分钟)。 */
252
+ statsScanMinutes?: number;
219
253
  };
220
254
  /**
221
255
  * Hub config defaults — the single source every layer reads: the cordis
@@ -228,12 +262,15 @@ export declare const HUB_CONFIG_DEFAULTS: {
228
262
  readonly showUseCount: true;
229
263
  readonly showUseTime: true;
230
264
  readonly showGroupSummary: true;
265
+ readonly statsWindowDays: 14;
266
+ readonly statsScanMinutes: 5;
231
267
  };
232
268
  /**
233
269
  * Resolve the effective hub config: saved sidecar overrides win over the
234
270
  * cordis composition entry (the web card owns runtime config), missing
235
271
  * booleans fall back to HUB_CONFIG_DEFAULTS, and dot colors pass through
236
- * (saved first, then base) only when set.
272
+ * (saved first, then base) only when set. Numeric stats knobs are clamped to
273
+ * their sane ranges (window ≥ 0, scan interval ≥ 1 minute).
237
274
  */
238
275
  export declare function resolveHubConfig(saved: Partial<HubConfig>, base?: Partial<HubConfig>): HubConfig;
239
276
  /** HEX color validation shared by host routes and the settings card. */
@@ -262,6 +299,10 @@ export interface ConfigRequest {
262
299
  dotModelColor?: string | null;
263
300
  /** Set the dot color; null clears the saved override so it re-inherits the default. */
264
301
  dotUserColor?: string | null;
302
+ /** 统计滚动窗口天数(0 = 全部历史);null 清除覆盖回默认。 */
303
+ statsWindowDays?: number | null;
304
+ /** 自动统计扫描间隔(分钟);null 清除覆盖回默认。 */
305
+ statsScanMinutes?: number | null;
265
306
  }
266
307
  /** One market source: a tracked upstream repo plus its pinned version. */
267
308
  export interface MarketSourceRecord {
@@ -12,14 +12,40 @@
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
  import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
17
- import type { SkillStat } from './protocol.ts';
36
+ import type { SkillStat, SkillStatsCheckpoint } from './protocol.ts';
37
+ /** Fallback freeze horizon when no rolling window is configured (14 days). */
38
+ export declare const STATS_FREEZE_AFTER_MS: number;
39
+ /** Cadence of the full reconciliation that rebuilds the checkpoint (24 h). */
40
+ export declare const STATS_FULL_RECONCILE_MS: number;
41
+ /** Adaptive TTL factor: effective TTL ≥ this multiple of the last scan duration. */
42
+ export declare const STATS_TTL_SCAN_FACTOR = 3;
18
43
  /** Narrow structural view of the session-query service (kept loose for tests). */
19
44
  export interface SessionQueryLike {
20
45
  listSessions(signal?: AbortSignal): Promise<Array<{
21
46
  header: {
22
47
  id: SessionId;
48
+ createdAt?: number;
23
49
  };
24
50
  }>>;
25
51
  readSession(id: SessionId): Promise<{
@@ -34,10 +60,27 @@ export interface InvocationStat {
34
60
  }
35
61
  /** Collect per-skill invocation counts and last-used times from one session. */
36
62
  export declare function countSkillInvocations(events: readonly SessionEvent[]): Map<string, InvocationStat>;
37
- /** Scan every session and total per-skill counts, keeping the latest lastUsed. */
38
- export declare function readSkillStats(query: SessionQueryLike): Promise<SkillStat[]>;
63
+ /**
64
+ * Full-corpus totals in one shot (no checkpoint reuse). Kept as the
65
+ * reference implementation for tests and one-off callers.
66
+ */
67
+ export declare function readSkillStats(query: SessionQueryLike, windowDays?: number): Promise<SkillStat[]>;
39
68
  /** A memoized stats reader (the panel polls, but logs change slowly). */
40
69
  export type SkillStatsReader = () => Promise<SkillStat[]>;
70
+ /** Optional wiring for {@link createSkillStatsReader}. */
71
+ export interface SkillStatsReaderOptions {
72
+ /** Checkpoint restored from the sidecar; absent means "start from zero". */
73
+ checkpoint?: SkillStatsCheckpoint;
74
+ /** Injectable clock (epoch ms); defaults to Date.now. Tests drive time with it. */
75
+ now?: () => number;
76
+ /** Base rescan interval in ms; a getter reads the live config each check. */
77
+ ttlMs?: number | (() => number);
78
+ /** Rolling window in days; a getter reads the live config each scan. 0 = all history. */
79
+ windowDays?: () => number;
80
+ /** Called after a full reconciliation mutated the checkpoint (never after an
81
+ * incremental scan) so the host can persist it to the sidecar. */
82
+ onCheckpoint?: (checkpoint: SkillStatsCheckpoint) => void;
83
+ }
41
84
  /**
42
85
  * Wrap a query in a stale-while-revalidate cache: responses never wait for a
43
86
  * full session-log scan. While the TTL is fresh the cached totals are
@@ -45,5 +88,11 @@ export type SkillStatsReader = () => Promise<SkillStat[]>;
45
88
  * single background rescan refreshes them — the panel's next poll picks the
46
89
  * fresh numbers. A full scan decompresses every session log and can take
47
90
  * seconds, so it must never sit on the request path.
91
+ *
92
+ * Two scaling mechanisms keep this sane as history grows:
93
+ * - the rescan is incremental (per-session checkpoint, see module doc);
94
+ * - the effective TTL adapts to the measured scan duration, so a heavier
95
+ * corpus automatically lowers the rescan cadence instead of burning CPU
96
+ * on every poll interval.
48
97
  */
49
- export declare function createSkillStatsReader(query: SessionQueryLike, ttlMs?: number): SkillStatsReader;
98
+ export declare function createSkillStatsReader(query: SessionQueryLike, ttlMs?: number | (() => number), options?: SkillStatsReaderOptions): SkillStatsReader;
@@ -11,7 +11,7 @@
11
11
  * State file: $DSH_HOME/dsh-skill-hub.json — a small JSON document written
12
12
  * atomically (tmp file + rename).
13
13
  */
14
- import type { DisabledSkill, HubConfig, MarketSourceRecord, RepoRoot, SkillTag, SourceRecord, TrashEntry } from './protocol.ts';
14
+ import type { DisabledSkill, HubConfig, MarketSourceRecord, RepoRoot, SkillStatsCheckpoint, SkillTag, SourceRecord, TrashEntry } from './protocol.ts';
15
15
  /** 默认场景名(系统预置的兜底场景,新技能自动归入)。 */
16
16
  export declare const DEFAULT_SCENE_NAME = "\u901A\u7528";
17
17
  /** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
@@ -19,7 +19,7 @@ export declare function dshHome(): string;
19
19
  /** Resolve the sidecar state path (injectable in tests). */
20
20
  export declare function statePath(home?: string): string;
21
21
  /** Current sidecar schema version. Bump on breaking shape changes and add a migration below. */
22
- export declare const STORE_VERSION = 3;
22
+ export declare const STORE_VERSION = 4;
23
23
  /**
24
24
  * Business-rule failure the routes layer can map onto a 4xx status instead
25
25
  * of a blanket 500: user input is invalid (validation → 400), the target
@@ -39,6 +39,7 @@ export declare class SkillHubStore {
39
39
  private sourcesByRepo;
40
40
  private marketSources;
41
41
  private trashByName;
42
+ private skillStats;
42
43
  private loaded;
43
44
  /** Serializes persist runs: concurrent mutators must not let an earlier
44
45
  * snapshot overwrite a later one (rename is atomic, ordering is not). */
@@ -138,5 +139,9 @@ export declare class SkillHubStore {
138
139
  addTrash(entry: TrashEntry): Promise<void>;
139
140
  /** Remove a trash record (after restore). */
140
141
  removeTrash(name: string): Promise<void>;
142
+ /** The persisted usage-statistics checkpoint (undefined until first saved). */
143
+ getSkillStatsState(): Promise<SkillStatsCheckpoint | undefined>;
144
+ /** Persist a usage-statistics checkpoint (written at most ~once a day, on full reconciliations). */
145
+ saveSkillStatsState(state: SkillStatsCheckpoint): Promise<void>;
141
146
  private persist;
142
147
  }