dsh-skill-hub 0.2.2 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +10 -14
  2. package/README.zh.md +9 -12
  3. package/lib/client.js +493 -177
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +213 -36
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +4 -6
  7. package/lib/types/client/icons.d.ts +25 -0
  8. package/lib/types/client/locales.d.ts +7 -3
  9. package/lib/types/client/panel/format.d.ts +10 -0
  10. package/lib/types/client/settings-card.d.ts +18 -0
  11. package/lib/types/client/settings-form.d.ts +9 -0
  12. package/lib/types/client/slash-dots.d.ts +57 -0
  13. package/lib/types/index.d.ts +4 -0
  14. package/lib/types/protocol.d.ts +42 -1
  15. package/lib/types/stats.d.ts +53 -4
  16. package/lib/types/store.d.ts +7 -2
  17. package/package.json +68 -26
  18. package/src/client/SkillHubSettingsCard.tsx +35 -10
  19. package/src/client/icons.tsx +61 -0
  20. package/src/client/index.tsx +13 -1
  21. package/src/client/locales.ts +14 -6
  22. package/src/client/panel/SkillDetailView.tsx +6 -2
  23. package/src/client/panel/SkillHubPanel.tsx +2 -2
  24. package/src/client/panel/SkillRow.tsx +8 -3
  25. package/src/client/panel/format.ts +11 -0
  26. package/src/client/panel/panel.module.css +1 -0
  27. package/src/client/settings-card.tsx +49 -1
  28. package/src/client/settings-form.ts +21 -0
  29. package/src/client/slash-dots.test.ts +145 -0
  30. package/src/client/slash-dots.tsx +190 -0
  31. package/src/index.ts +34 -3
  32. package/src/protocol.ts +47 -1
  33. package/src/routes.test.ts +3 -1
  34. package/src/routes.ts +2 -2
  35. package/src/stats.test.ts +273 -2
  36. package/src/stats.ts +166 -23
  37. package/src/store.test.ts +32 -0
  38. package/src/store.ts +58 -3
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
@@ -9,12 +9,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';
12
- /** Model-invocable dot color default. Single source for the TS side; the
13
- * panel's CSS mirrors it via --hub-model (panel.module.css). */
14
- export declare const DEFAULT_DOT_MODEL_COLOR = "#2f81f7";
15
- /** User-invocable dot color default. Single source for the TS side; the
16
- * panel's CSS mirrors it via --hub-user (panel.module.css). */
17
- export declare const DEFAULT_DOT_USER_COLOR = "#3fb950";
12
+ import { DEFAULT_DOT_MODEL_COLOR, DEFAULT_DOT_USER_COLOR } from './panel/format.ts';
13
+ export { DEFAULT_DOT_MODEL_COLOR, DEFAULT_DOT_USER_COLOR };
18
14
  /** The card's projected state. */
19
15
  export interface SkillHubSettingsState extends CardShell {
20
16
  enabled: FieldState;
@@ -24,6 +20,8 @@ export interface SkillHubSettingsState extends CardShell {
24
20
  showUseCount: FieldState;
25
21
  showUseTime: FieldState;
26
22
  showGroupSummary: FieldState;
23
+ statsWindowDays: FieldState;
24
+ statsScanMinutes: FieldState;
27
25
  }
28
26
  /** The business face the card's slot registration injects. */
29
27
  export interface SkillHubSettingsCardFace {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Vendored UI icons for dsh-skill-hub.
3
+ *
4
+ * These were originally imported from `@deepseek-ai/dsh-client-ui-primitives`.
5
+ * That package is still published for the rc.7/rc.2 SDK families, but newer dsh
6
+ * web builds no longer expose it as a standalone plugin module — they keep a
7
+ * static compatibility module instead. Vendoring the few tiny outline icons the
8
+ * hub uses makes the browser half self-contained and equally compatible with
9
+ * older and newer dsh hosts.
10
+ *
11
+ * SVG paths are copied verbatim from dsh-client-ui-primitives (MIT licensed)
12
+ * so the visuals remain pixel-identical to the dsh icon family.
13
+ */
14
+ import type { JSX } from 'react';
15
+ /** Props understood by the dsh icon family: size + optional className. */
16
+ export interface IconProps {
17
+ size?: number;
18
+ className?: string;
19
+ }
20
+ /** ic_ds_chevron_down_outline_14 */
21
+ export declare function IconChevronDownOutline14({ size, className }: IconProps): JSX.Element;
22
+ /** ic_ds_trash_outline_16 */
23
+ export declare function IconTrashOutline16({ size, className }: IconProps): JSX.Element;
24
+ /** ic_ds_skill_outline_16 */
25
+ export declare function IconSkillOutline16({ size, className }: IconProps): JSX.Element;
@@ -18,7 +18,7 @@ export declare const zh: {
18
18
  readonly 'panel.search': "搜索技能…";
19
19
  readonly 'legend.model': "模型可调 — Agent 可自动调用";
20
20
  readonly 'legend.user': "用户可调 — 仅显式点名时触发";
21
- readonly 'legend.hint': "圆点标在技能名旁;无圆点表示该技能不可被调用。";
21
+ readonly 'legend.hint': "每个技能最多一个圆点;无圆点 = 不可被调用。";
22
22
  readonly 'panel.enabled': "已启用";
23
23
  readonly 'panel.disabled': "已禁用";
24
24
  readonly 'panel.diagnostics': "发现诊断";
@@ -187,15 +187,19 @@ export declare const zh: {
187
187
  readonly 'settings.announceToAgent': "向 Agent 公告";
188
188
  readonly 'settings.announceToAgentHint': "在系统提示中加入本插件说明,用户提到技能管理时 Agent 知道如何协作。";
189
189
  readonly 'settings.dotModelColor': "模型可调圆点颜色";
190
- readonly 'settings.dotModelColorHint': "技能名旁蓝色圆点的颜色(#rrggbb)。";
190
+ readonly 'settings.dotModelColorHint': "技能行/聊天 / 菜单中「模型可调」蓝色圆点的颜色(#rrggbb)。";
191
191
  readonly 'settings.dotUserColor': "用户可调圆点颜色";
192
- readonly 'settings.dotUserColorHint': "技能名旁绿色圆点的颜色(#rrggbb)。";
192
+ readonly 'settings.dotUserColorHint': "技能行/聊天 / 菜单中「仅用户可调」绿色圆点的颜色(#rrggbb)。";
193
193
  readonly 'settings.showUseCount': "显示调用次数";
194
194
  readonly 'settings.showUseCountHint': "在技能名旁显示琥珀色调用次数。";
195
195
  readonly 'settings.showUseTime': "显示最近调用时间";
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': "关";
@@ -2,8 +2,18 @@
2
2
  * Shared display helpers for the panel views (skill rows, detail view, and
3
3
  * dialogs): dot color styling, relative/absolute time text, and commit-SHA
4
4
  * shortening. Kept in one place so the views render identically.
5
+ *
6
+ * The invocation-status dot defaults live here (not in the settings card) so
7
+ * both the panel legend and the chat `/` skill menu draw from one source and
8
+ * so this primitives-free module stays unit-testable.
5
9
  */
6
10
  import type { CSSProperties } from 'react';
11
+ /** Model-invocable dot color default. Single source for the TS side; the
12
+ * panel's CSS mirrors it via --hub-model (panel.module.css). */
13
+ export declare const DEFAULT_DOT_MODEL_COLOR = "#2f81f7";
14
+ /** User-invocable dot color default. Single source for the TS side; the
15
+ * panel's CSS mirrors it via --hub-user (panel.module.css). */
16
+ export declare const DEFAULT_DOT_USER_COLOR = "#3fb950";
7
17
  /** Dot inline style from the user-chosen color (undefined keeps the CSS default). */
8
18
  export declare function dotStyle(color: string | undefined): CSSProperties | undefined;
9
19
  /** Localized relative-time text for a Unix-ms timestamp. */
@@ -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;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Slash-menu skill dots: puts the invocation-status dot (model-callable blue /
3
+ * user-only green) in front of every skill candidate in the chat `/` menu.
4
+ *
5
+ * Mechanism (mirrors how dsh-at-file fills the menu icon slot): the candidate
6
+ * menu's rows already render an optional `icon` slot (`MenuView` renders
7
+ * `item.icon` in a 16×16 leading span when it's defined), but the core `/skill`
8
+ * source (`dsh-client-ui-skill`) returns candidates without `icon`. This module
9
+ * wraps that source's `candidates` and stamps each row with a colored dot,
10
+ * reusing the same settings (dotModelColor / dotUserColor) and the same
11
+ * `modelInvocable` classification the panel legend uses — so the chat menu and
12
+ * the Settings → 技能 panel stay in sync, and editing the color updates both.
13
+ *
14
+ * The skill source is registered by the core plugin under the `name` "skill"
15
+ * on the `/` trigger; re-registering the same name would throw, so this wraps
16
+ * the already-registered source object instead (found through the runtime
17
+ * source registry, which the frozen contract exposes only as `registerSource`
18
+ * / `sessionOf` — the lookup below is defensive: it never throws).
19
+ */
20
+ import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client';
21
+ import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client';
22
+ import type { HubSettingsValue } from '../protocol.ts';
23
+ import type { SkillHubApi } from './api.ts';
24
+ /**
25
+ * Find the core `/skill` source through the runtime registry, or undefined.
26
+ * The lookup never throws: a missing/reshaped registry just means no dots. Exported
27
+ * for unit tests; the apply path only calls it indirectly through setupSkillSlashDots.
28
+ * @param service - the ctx.inputTriggers service face.
29
+ * @returns the registered skill source, or undefined.
30
+ */
31
+ export declare function findSkillSource(service: InputTriggerServiceContract): InputTriggerSource | undefined;
32
+ /**
33
+ * Clear the modelInvocable cache. Called on connection/reset so a fresh
34
+ * catalog wins after reconnect; exported for deterministic unit tests.
35
+ */
36
+ export declare function resetModelCache(): void;
37
+ /**
38
+ * Wrap the core skill source so every menu row carries the invocation dot.
39
+ * Exported for unit tests; production wiring goes through setupSkillSlashDots.
40
+ * @param source - the registered `/skill` source.
41
+ * @param api - hub browser API for the modelInvocable lookup.
42
+ * @param scope - hub settings scope for the dot colors.
43
+ * @returns a disposer restoring the original candidates.
44
+ */
45
+ export declare function wrapSkillSource(source: InputTriggerSource, api: SkillHubApi, scope: SettingsScope<HubSettingsValue>): () => void;
46
+ /**
47
+ * Mount the slash-menu dots on the registered `/skill` source. Idempotent and
48
+ * defensive: if the core source isn't registered yet (or the registry shape
49
+ * changes), it retries briefly and then gives up silently — the chat keeps
50
+ * working, it simply shows no dots. The returned disposer restores the
51
+ * original candidates and clears the model cache.
52
+ * @param ctx - the client root context (inputTriggers + events).
53
+ * @param api - hub browser API.
54
+ * @param scope - hub settings scope for dot colors.
55
+ * @returns a cleanup function for `ctx.effect`.
56
+ */
57
+ export declare function setupSkillSlashDots(ctx: ClientContext, api: SkillHubApi, scope: SettingsScope<HubSettingsValue>): () => void;
@@ -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
  /**