dsh-skill-hub 0.2.2 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -14
- package/README.zh.md +9 -12
- package/lib/client.js +493 -177
- package/lib/client.js.map +1 -1
- package/lib/index.js +213 -36
- package/lib/types/client/SkillHubSettingsCard.d.ts +4 -6
- package/lib/types/client/icons.d.ts +25 -0
- package/lib/types/client/locales.d.ts +7 -3
- package/lib/types/client/panel/format.d.ts +10 -0
- package/lib/types/client/settings-card.d.ts +18 -0
- package/lib/types/client/settings-form.d.ts +9 -0
- package/lib/types/client/slash-dots.d.ts +57 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/protocol.d.ts +42 -1
- package/lib/types/stats.d.ts +53 -4
- package/lib/types/store.d.ts +7 -2
- package/package.json +68 -26
- package/src/client/SkillHubSettingsCard.tsx +35 -10
- package/src/client/icons.tsx +61 -0
- package/src/client/index.tsx +13 -1
- package/src/client/locales.ts +14 -6
- package/src/client/panel/SkillDetailView.tsx +6 -2
- package/src/client/panel/SkillHubPanel.tsx +2 -2
- package/src/client/panel/SkillRow.tsx +8 -3
- package/src/client/panel/format.ts +11 -0
- package/src/client/panel/panel.module.css +1 -0
- package/src/client/settings-card.tsx +49 -1
- package/src/client/settings-form.ts +21 -0
- package/src/client/slash-dots.test.ts +145 -0
- package/src/client/slash-dots.tsx +190 -0
- package/src/index.ts +34 -3
- package/src/protocol.ts +47 -1
- package/src/routes.test.ts +3 -1
- package/src/routes.ts +2 -2
- package/src/stats.test.ts +273 -2
- package/src/stats.ts +166 -23
- package/src/store.test.ts +32 -0
- package/src/store.ts +58 -3
package/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 >
|
|
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:
|
|
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:
|
|
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
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
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
|
-
|
|
3178
|
+
if (isFrozen(record, checkpoint.frozenBefore)) continue;
|
|
3050
3179
|
try {
|
|
3051
|
-
|
|
3180
|
+
mergeInto(recent, countSkillInvocations((await query.readSession(record.header.id)).events));
|
|
3052
3181
|
} catch {
|
|
3053
3182
|
continue;
|
|
3054
3183
|
}
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
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
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
}
|
|
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
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
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
|
-
|
|
3215
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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': "
|
|
190
|
+
readonly 'settings.dotModelColorHint': "技能行/聊天 / 菜单中「模型可调」蓝色圆点的颜色(#rrggbb)。";
|
|
191
191
|
readonly 'settings.dotUserColor': "用户可调圆点颜色";
|
|
192
|
-
readonly 'settings.dotUserColorHint': "
|
|
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;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
/**
|