dsh-skill-hub 0.2.4 → 0.2.6

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,
@@ -2246,6 +2303,7 @@ function makeRoutes(deps) {
2246
2303
  if (req.method === "GET") {
2247
2304
  writeJson(res, 200, {
2248
2305
  ok: true,
2306
+ pluginVersion: CURRENT_VERSION,
2249
2307
  config: configOf(deps),
2250
2308
  saved: savedOf(deps)
2251
2309
  });
@@ -2302,6 +2360,7 @@ function makeRoutes(deps) {
2302
2360
  } else config = await deps.updateConfig(patch);
2303
2361
  writeJson(res, 200, {
2304
2362
  ok: true,
2363
+ pluginVersion: CURRENT_VERSION,
2305
2364
  config,
2306
2365
  saved: savedOf(deps)
2307
2366
  });
@@ -3011,6 +3070,9 @@ function makeRoutes(deps) {
3011
3070
  }
3012
3071
  //#endregion
3013
3072
  //#region src/stats.ts
3073
+ /** Fallback freeze horizon when no rolling window is configured (14 days). */
3074
+ const STATS_FREEZE_AFTER_MS = 336 * 60 * 60 * 1e3;
3075
+ const DAY_MS = 1440 * 60 * 1e3;
3014
3076
  /** Collect per-skill invocation counts and last-used times from one session. */
3015
3077
  function countSkillInvocations(events) {
3016
3078
  const stats = /* @__PURE__ */ new Map();
@@ -3041,31 +3103,100 @@ function countSkillInvocations(events) {
3041
3103
  }
3042
3104
  return stats;
3043
3105
  }
3044
- /** Scan every session and total per-skill counts, keeping the latest lastUsed. */
3045
- async function readSkillStats(query) {
3046
- const totals = /* @__PURE__ */ new Map();
3106
+ function isFrozen(record, watermark) {
3107
+ const created = record.header.createdAt;
3108
+ return typeof created === "number" && created > 0 && created < watermark;
3109
+ }
3110
+ function mergeInto(totals, counted) {
3111
+ for (const [name, stat] of counted) {
3112
+ const total = totals[name];
3113
+ if (total === void 0) totals[name] = { ...stat };
3114
+ else {
3115
+ total.count += stat.count;
3116
+ if (stat.lastUsed > total.lastUsed) total.lastUsed = stat.lastUsed;
3117
+ }
3118
+ }
3119
+ }
3120
+ function toSorted(totals) {
3121
+ return Object.entries(totals).map(([name, stat]) => ({
3122
+ name,
3123
+ count: stat.count,
3124
+ lastUsed: stat.lastUsed
3125
+ })).sort((x, y) => x.name.localeCompare(y.name));
3126
+ }
3127
+ /** Effective freeze watermark for the configured rolling window (0 = all history → freeze horizon). */
3128
+ function watermarkFor(windowDays, nowMs) {
3129
+ return windowDays > 0 ? nowMs - windowDays * DAY_MS : nowMs - STATS_FREEZE_AFTER_MS;
3130
+ }
3131
+ /**
3132
+ * Whether a session's usage counts toward the configured window. With no
3133
+ * window (0) everything counts — full history; with a window, only sessions
3134
+ * created inside it do. Distinct from the freeze watermark, which is purely a
3135
+ * re-read optimization.
3136
+ */
3137
+ function inWindow(createdAt, windowDays, nowMs) {
3138
+ if (windowDays <= 0) return true;
3139
+ return typeof createdAt === "number" && createdAt > 0 && createdAt >= nowMs - windowDays * DAY_MS;
3140
+ }
3141
+ /**
3142
+ * One pass over the corpus. Runs either a full reconciliation (rebuilds the
3143
+ * per-session cache and advances the watermark — mutates the checkpoint) or a
3144
+ * cheap incremental scan (re-reads everything at or after the watermark and
3145
+ * merges over the cached sessions — leaves the checkpoint untouched). Totals
3146
+ * always apply the CURRENT window filter over the cached sessions, so a
3147
+ * window shrink takes effect immediately even before the next reconciliation.
3148
+ */
3149
+ async function scan(query, checkpoint, nowMs, windowDays) {
3047
3150
  const sessions = await query.listSessions();
3151
+ const cutoff = watermarkFor(windowDays, nowMs);
3152
+ if (nowMs - checkpoint.lastFullReconcile >= 864e5 || checkpoint.windowDays !== windowDays) {
3153
+ const cache = {};
3154
+ const totals = {};
3155
+ for (const record of sessions) {
3156
+ let counted;
3157
+ try {
3158
+ counted = countSkillInvocations((await query.readSession(record.header.id)).events);
3159
+ } catch {
3160
+ continue;
3161
+ }
3162
+ const created = record.header.createdAt;
3163
+ if (isFrozen(record, cutoff) && counted.size > 0 && typeof created === "number") cache[record.header.id] = {
3164
+ createdAt: created,
3165
+ counts: Object.fromEntries(counted)
3166
+ };
3167
+ if (inWindow(created, windowDays, nowMs)) mergeInto(totals, counted);
3168
+ }
3169
+ checkpoint.frozenSessions = cache;
3170
+ checkpoint.frozenBefore = cutoff;
3171
+ checkpoint.windowDays = windowDays;
3172
+ checkpoint.lastFullReconcile = nowMs;
3173
+ return {
3174
+ stats: toSorted(totals),
3175
+ mutated: true
3176
+ };
3177
+ }
3178
+ const recent = {};
3048
3179
  for (const record of sessions) {
3049
- let snapshot;
3180
+ if (isFrozen(record, checkpoint.frozenBefore)) continue;
3050
3181
  try {
3051
- snapshot = await query.readSession(record.header.id);
3182
+ mergeInto(recent, countSkillInvocations((await query.readSession(record.header.id)).events));
3052
3183
  } catch {
3053
3184
  continue;
3054
3185
  }
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
- }
3186
+ }
3187
+ const totals = {};
3188
+ for (const [id, entry] of Object.entries(checkpoint.frozenSessions)) {
3189
+ if (!inWindow(entry.createdAt, windowDays, nowMs)) {
3190
+ delete checkpoint.frozenSessions[id];
3191
+ continue;
3062
3192
  }
3193
+ mergeInto(totals, new Map(Object.entries(entry.counts)));
3063
3194
  }
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));
3195
+ mergeInto(totals, new Map(Object.entries(recent)));
3196
+ return {
3197
+ stats: toSorted(totals),
3198
+ mutated: false
3199
+ };
3069
3200
  }
3070
3201
  /**
3071
3202
  * Wrap a query in a stale-while-revalidate cache: responses never wait for a
@@ -3074,19 +3205,44 @@ async function readSkillStats(query) {
3074
3205
  * single background rescan refreshes them — the panel's next poll picks the
3075
3206
  * fresh numbers. A full scan decompresses every session log and can take
3076
3207
  * seconds, so it must never sit on the request path.
3208
+ *
3209
+ * Two scaling mechanisms keep this sane as history grows:
3210
+ * - the rescan is incremental (per-session checkpoint, see module doc);
3211
+ * - the effective TTL adapts to the measured scan duration, so a heavier
3212
+ * corpus automatically lowers the rescan cadence instead of burning CPU
3213
+ * on every poll interval.
3077
3214
  */
3078
- function createSkillStatsReader(query, ttlMs = 3e5) {
3215
+ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
3216
+ const checkpoint = options.checkpoint ?? {
3217
+ windowDays: 0,
3218
+ frozenBefore: 0,
3219
+ frozenSessions: {},
3220
+ lastFullReconcile: 0
3221
+ };
3222
+ const now = options.now ?? (() => Date.now());
3079
3223
  let cached;
3080
3224
  let cachedAt = 0;
3081
3225
  let refreshing = null;
3226
+ let lastScanDurationMs = 0;
3082
3227
  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
- });
3228
+ const startedAt = now();
3229
+ const base = typeof ttlMs === "function" ? ttlMs() : ttlMs;
3230
+ const ttl = Math.max(base, lastScanDurationMs * 3);
3231
+ if (cached !== void 0 && startedAt - cachedAt < ttl) return cached;
3232
+ if (refreshing === null) {
3233
+ const windowDays = options.windowDays?.() ?? 0;
3234
+ refreshing = scan(query, checkpoint, startedAt, windowDays).then(({ stats, mutated }) => {
3235
+ cached = stats;
3236
+ cachedAt = now();
3237
+ lastScanDurationMs = Math.max(0, cachedAt - startedAt);
3238
+ if (mutated) options.onCheckpoint?.({
3239
+ ...checkpoint,
3240
+ frozenSessions: { ...checkpoint.frozenSessions }
3241
+ });
3242
+ }).catch(() => {}).finally(() => {
3243
+ refreshing = null;
3244
+ });
3245
+ }
3090
3246
  return cached ?? [];
3091
3247
  };
3092
3248
  }
@@ -3106,7 +3262,9 @@ const Config = z.object({
3106
3262
  enabled: z.boolean().default(HUB_CONFIG_DEFAULTS.enabled),
3107
3263
  showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount),
3108
3264
  showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
3109
- showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary)
3265
+ showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
3266
+ statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
3267
+ statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes)
3110
3268
  });
3111
3269
  /**
3112
3270
  * Settings namespace hosting the hub's runtime config. Since dsh rc.7 the
@@ -3124,7 +3282,9 @@ const HubSettingsSchema = z.object({
3124
3282
  showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
3125
3283
  showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
3126
3284
  dotModelColor: z.string().pattern(HEX_COLOR_RE),
3127
- dotUserColor: z.string().pattern(HEX_COLOR_RE)
3285
+ dotUserColor: z.string().pattern(HEX_COLOR_RE),
3286
+ statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
3287
+ statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes)
3128
3288
  });
3129
3289
  /** Order of the announcement section within the tool-guidance band. */
3130
3290
  const SECTION_ORDER = 152;
@@ -3211,8 +3371,27 @@ function apply(ctx, config) {
3211
3371
  }
3212
3372
  })();
3213
3373
  ctx.inject(["sessionQuery"], (sctx) => {
3214
- stats = createSkillStatsReader(sctx.sessionQuery);
3215
- sync();
3374
+ (async () => {
3375
+ const saved = await store.getSkillStatsState().catch(() => void 0);
3376
+ const scanMinutes = () => {
3377
+ const value = current().statsScanMinutes;
3378
+ return typeof value === "number" && value >= 1 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsScanMinutes;
3379
+ };
3380
+ const windowDays = () => {
3381
+ const value = current().statsWindowDays;
3382
+ return typeof value === "number" && value >= 0 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsWindowDays;
3383
+ };
3384
+ stats = createSkillStatsReader(sctx.sessionQuery, () => scanMinutes() * 6e4, {
3385
+ checkpoint: saved,
3386
+ windowDays,
3387
+ onCheckpoint: (next) => {
3388
+ store.saveSkillStatsState(next).catch((error) => {
3389
+ ctx.logger.warn("[dsh-skill-hub] persisting skill-stats checkpoint failed", error);
3390
+ });
3391
+ }
3392
+ });
3393
+ sync();
3394
+ })();
3216
3395
  });
3217
3396
  }
3218
3397
  //#endregion
@@ -5,7 +5,7 @@
5
5
  * Registered into the official `settings.plugin.item` slot keyed by that
6
6
  * namespace, so the plugin shows up in Settings → 插件 on dsh rc.7+.
7
7
  */
8
- import type { ReactElement } from 'react';
8
+ import { type ReactElement } from 'react';
9
9
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
10
10
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
11
11
  import { type CardShell, type FieldState, type FormScope } from './settings-form.ts';
@@ -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 {
@@ -180,7 +180,7 @@ export declare const zh: {
180
180
  readonly 'form.success': "已创建:";
181
181
  readonly 'form.error': "创建失败:";
182
182
  readonly 'err.dismiss': "知道了";
183
- readonly 'settings.title': "Skill Hub";
183
+ readonly 'settings.title': "技能中枢";
184
184
  readonly 'settings.description': "管理 dsh 本地技能目录:浏览、分组、来源同步与新建。";
185
185
  readonly 'settings.enabled': "启用插件";
186
186
  readonly 'settings.enabledHint': "关闭后技能中枢的路由、入口与公告全部下线。";
@@ -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': "关";
@@ -16,6 +16,8 @@ export interface PluginSettingsCardProps {
16
16
  titleKey: HubKey;
17
17
  /** Locale key of the card description. */
18
18
  descriptionKey: HubKey;
19
+ /** 插件自身版本(可选):显示在标题旁的小徽标;缺省不渲染。 */
20
+ version?: string;
19
21
  /** The form shell state. */
20
22
  state: CardShell;
21
23
  onSave: () => void;
@@ -80,3 +82,21 @@ export interface ColorFieldProps {
80
82
  onReset: () => void;
81
83
  }
82
84
  export declare function ColorField(props: ColorFieldProps): ReactElement;
85
+ /** One staged numeric field: a numeric draft text input with inherit/reset semantics. */
86
+ export interface NumberFieldProps {
87
+ id: string;
88
+ label: string;
89
+ hint: string;
90
+ /** Placeholder shown while the field inherits its default. */
91
+ inheritLabel: string;
92
+ overriddenLabel: string;
93
+ resetLabel: string;
94
+ disabled: boolean;
95
+ /** Effective number when overridden; empty string means inherit. */
96
+ text: string;
97
+ overridden: boolean;
98
+ invalid?: boolean;
99
+ onEdit: (text: string) => void;
100
+ onReset: () => void;
101
+ }
102
+ 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. */
@@ -241,6 +278,8 @@ export declare const HEX_COLOR_RE: RegExp;
241
278
  /** GET /api/skill-hub/config */
242
279
  export interface ConfigResponse {
243
280
  ok: true;
281
+ /** 已安装插件自身的版本号(package.json version),设置卡标题旁显示。 */
282
+ pluginVersion: string;
244
283
  /** Effective configuration (saved overrides merged over the defaults). */
245
284
  config: HubConfig;
246
285
  /** Raw user overrides persisted in the sidecar (absent fields inherit defaults). */
@@ -262,6 +301,10 @@ export interface ConfigRequest {
262
301
  dotModelColor?: string | null;
263
302
  /** Set the dot color; null clears the saved override so it re-inherits the default. */
264
303
  dotUserColor?: string | null;
304
+ /** 统计滚动窗口天数(0 = 全部历史);null 清除覆盖回默认。 */
305
+ statsWindowDays?: number | null;
306
+ /** 自动统计扫描间隔(分钟);null 清除覆盖回默认。 */
307
+ statsScanMinutes?: number | null;
265
308
  }
266
309
  /** One market source: a tracked upstream repo plus its pinned version. */
267
310
  export interface MarketSourceRecord {