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/client.js +153 -9
- package/lib/client.js.map +1 -1
- package/lib/index.js +215 -36
- package/lib/types/client/SkillHubSettingsCard.d.ts +3 -1
- package/lib/types/client/locales.d.ts +5 -1
- package/lib/types/client/settings-card.d.ts +20 -0
- package/lib/types/client/settings-form.d.ts +9 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/protocol.d.ts +44 -1
- package/lib/types/stats.d.ts +53 -4
- package/lib/types/store.d.ts +7 -2
- package/package.json +1 -1
- package/src/client/SkillHubSettingsCard.tsx +43 -4
- package/src/client/locales.ts +9 -1
- package/src/client/panel/SkillHubPanel.tsx +1 -1
- package/src/client/panel/panel.module.css +1 -0
- package/src/client/settings-card.module.css +1 -0
- package/src/client/settings-card.tsx +51 -1
- package/src/client/settings-form.ts +21 -0
- package/src/index.ts +34 -3
- package/src/protocol.ts +49 -1
- package/src/routes.test.ts +4 -1
- package/src/routes.ts +4 -4
- 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/types/stats.d.ts
CHANGED
|
@@ -12,14 +12,40 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Counting is per-skill-name, not per-source: a name may resolve to different
|
|
14
14
|
* files across projects, but the model-facing identity is the kebab-case name.
|
|
15
|
+
*
|
|
16
|
+
* Scaling (per-session checkpoint + incremental scans): a full scan
|
|
17
|
+
* decompresses every session log, which grows linearly with total history.
|
|
18
|
+
* Sessions older than the effective watermark are therefore treated as
|
|
19
|
+
* finalized — their per-session counts live in the checkpoint (persisted by
|
|
20
|
+
* the host via the sidecar) and are skipped on incremental scans; only the
|
|
21
|
+
* recent window is re-read. A daily full reconciliation rebuilds the cache
|
|
22
|
+
* and advances the watermark, so a resumed old session is eventually
|
|
23
|
+
* re-counted. On top of that, the reader's TTL adapts to the measured scan
|
|
24
|
+
* duration (STATS_TTL_SCAN_FACTOR), so a heavy scan also lowers its own
|
|
25
|
+
* frequency.
|
|
26
|
+
*
|
|
27
|
+
* Rolling window (statsWindowDays > 0): totals only include sessions created
|
|
28
|
+
* within the last N days. The watermark then equals the window edge, so
|
|
29
|
+
* sessions outside the window are neither re-read nor counted, and the
|
|
30
|
+
* reconciliation prunes their cache entries. Changing the configured window
|
|
31
|
+
* forces one full reconciliation immediately (the checkpoint records the
|
|
32
|
+
* window it was built for), so the new semantics take effect on the next scan
|
|
33
|
+
* instead of up to a day later.
|
|
15
34
|
*/
|
|
16
35
|
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
|
|
17
|
-
import type { SkillStat } from './protocol.ts';
|
|
36
|
+
import type { SkillStat, SkillStatsCheckpoint } from './protocol.ts';
|
|
37
|
+
/** Fallback freeze horizon when no rolling window is configured (14 days). */
|
|
38
|
+
export declare const STATS_FREEZE_AFTER_MS: number;
|
|
39
|
+
/** Cadence of the full reconciliation that rebuilds the checkpoint (24 h). */
|
|
40
|
+
export declare const STATS_FULL_RECONCILE_MS: number;
|
|
41
|
+
/** Adaptive TTL factor: effective TTL ≥ this multiple of the last scan duration. */
|
|
42
|
+
export declare const STATS_TTL_SCAN_FACTOR = 3;
|
|
18
43
|
/** Narrow structural view of the session-query service (kept loose for tests). */
|
|
19
44
|
export interface SessionQueryLike {
|
|
20
45
|
listSessions(signal?: AbortSignal): Promise<Array<{
|
|
21
46
|
header: {
|
|
22
47
|
id: SessionId;
|
|
48
|
+
createdAt?: number;
|
|
23
49
|
};
|
|
24
50
|
}>>;
|
|
25
51
|
readSession(id: SessionId): Promise<{
|
|
@@ -34,10 +60,27 @@ export interface InvocationStat {
|
|
|
34
60
|
}
|
|
35
61
|
/** Collect per-skill invocation counts and last-used times from one session. */
|
|
36
62
|
export declare function countSkillInvocations(events: readonly SessionEvent[]): Map<string, InvocationStat>;
|
|
37
|
-
/**
|
|
38
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Full-corpus totals in one shot (no checkpoint reuse). Kept as the
|
|
65
|
+
* reference implementation for tests and one-off callers.
|
|
66
|
+
*/
|
|
67
|
+
export declare function readSkillStats(query: SessionQueryLike, windowDays?: number): Promise<SkillStat[]>;
|
|
39
68
|
/** A memoized stats reader (the panel polls, but logs change slowly). */
|
|
40
69
|
export type SkillStatsReader = () => Promise<SkillStat[]>;
|
|
70
|
+
/** Optional wiring for {@link createSkillStatsReader}. */
|
|
71
|
+
export interface SkillStatsReaderOptions {
|
|
72
|
+
/** Checkpoint restored from the sidecar; absent means "start from zero". */
|
|
73
|
+
checkpoint?: SkillStatsCheckpoint;
|
|
74
|
+
/** Injectable clock (epoch ms); defaults to Date.now. Tests drive time with it. */
|
|
75
|
+
now?: () => number;
|
|
76
|
+
/** Base rescan interval in ms; a getter reads the live config each check. */
|
|
77
|
+
ttlMs?: number | (() => number);
|
|
78
|
+
/** Rolling window in days; a getter reads the live config each scan. 0 = all history. */
|
|
79
|
+
windowDays?: () => number;
|
|
80
|
+
/** Called after a full reconciliation mutated the checkpoint (never after an
|
|
81
|
+
* incremental scan) so the host can persist it to the sidecar. */
|
|
82
|
+
onCheckpoint?: (checkpoint: SkillStatsCheckpoint) => void;
|
|
83
|
+
}
|
|
41
84
|
/**
|
|
42
85
|
* Wrap a query in a stale-while-revalidate cache: responses never wait for a
|
|
43
86
|
* full session-log scan. While the TTL is fresh the cached totals are
|
|
@@ -45,5 +88,11 @@ export type SkillStatsReader = () => Promise<SkillStat[]>;
|
|
|
45
88
|
* single background rescan refreshes them — the panel's next poll picks the
|
|
46
89
|
* fresh numbers. A full scan decompresses every session log and can take
|
|
47
90
|
* seconds, so it must never sit on the request path.
|
|
91
|
+
*
|
|
92
|
+
* Two scaling mechanisms keep this sane as history grows:
|
|
93
|
+
* - the rescan is incremental (per-session checkpoint, see module doc);
|
|
94
|
+
* - the effective TTL adapts to the measured scan duration, so a heavier
|
|
95
|
+
* corpus automatically lowers the rescan cadence instead of burning CPU
|
|
96
|
+
* on every poll interval.
|
|
48
97
|
*/
|
|
49
|
-
export declare function createSkillStatsReader(query: SessionQueryLike, ttlMs?: number): SkillStatsReader;
|
|
98
|
+
export declare function createSkillStatsReader(query: SessionQueryLike, ttlMs?: number | (() => number), options?: SkillStatsReaderOptions): SkillStatsReader;
|
package/lib/types/store.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* State file: $DSH_HOME/dsh-skill-hub.json — a small JSON document written
|
|
12
12
|
* atomically (tmp file + rename).
|
|
13
13
|
*/
|
|
14
|
-
import type { DisabledSkill, HubConfig, MarketSourceRecord, RepoRoot, SkillTag, SourceRecord, TrashEntry } from './protocol.ts';
|
|
14
|
+
import type { DisabledSkill, HubConfig, MarketSourceRecord, RepoRoot, SkillStatsCheckpoint, SkillTag, SourceRecord, TrashEntry } from './protocol.ts';
|
|
15
15
|
/** 默认场景名(系统预置的兜底场景,新技能自动归入)。 */
|
|
16
16
|
export declare const DEFAULT_SCENE_NAME = "\u901A\u7528";
|
|
17
17
|
/** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
|
|
@@ -19,7 +19,7 @@ export declare function dshHome(): string;
|
|
|
19
19
|
/** Resolve the sidecar state path (injectable in tests). */
|
|
20
20
|
export declare function statePath(home?: string): string;
|
|
21
21
|
/** Current sidecar schema version. Bump on breaking shape changes and add a migration below. */
|
|
22
|
-
export declare const STORE_VERSION =
|
|
22
|
+
export declare const STORE_VERSION = 4;
|
|
23
23
|
/**
|
|
24
24
|
* Business-rule failure the routes layer can map onto a 4xx status instead
|
|
25
25
|
* of a blanket 500: user input is invalid (validation → 400), the target
|
|
@@ -39,6 +39,7 @@ export declare class SkillHubStore {
|
|
|
39
39
|
private sourcesByRepo;
|
|
40
40
|
private marketSources;
|
|
41
41
|
private trashByName;
|
|
42
|
+
private skillStats;
|
|
42
43
|
private loaded;
|
|
43
44
|
/** Serializes persist runs: concurrent mutators must not let an earlier
|
|
44
45
|
* snapshot overwrite a later one (rename is atomic, ordering is not). */
|
|
@@ -138,5 +139,9 @@ export declare class SkillHubStore {
|
|
|
138
139
|
addTrash(entry: TrashEntry): Promise<void>;
|
|
139
140
|
/** Remove a trash record (after restore). */
|
|
140
141
|
removeTrash(name: string): Promise<void>;
|
|
142
|
+
/** The persisted usage-statistics checkpoint (undefined until first saved). */
|
|
143
|
+
getSkillStatsState(): Promise<SkillStatsCheckpoint | undefined>;
|
|
144
|
+
/** Persist a usage-statistics checkpoint (written at most ~once a day, on full reconciliations). */
|
|
145
|
+
saveSkillStatsState(state: SkillStatsCheckpoint): Promise<void>;
|
|
141
146
|
private persist;
|
|
142
147
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-hub",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* namespace, so the plugin shows up in Settings → 插件 on dsh rc.7+.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type
|
|
9
|
+
import { useEffect, useState, type ReactElement } from 'react'
|
|
10
10
|
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
11
11
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { SkillHubApi } from './api.ts'
|
|
13
|
+
import { ColorField, NumberField, PluginSettingsCard, SwitchField } from './settings-card.tsx'
|
|
14
|
+
import { booleanField, CardForm, colorField, numberField, type CardShell, type FieldState, type FormScope } from './settings-form.ts'
|
|
14
15
|
// Single source for the TS-side dot defaults (the panel CSS mirrors these).
|
|
15
16
|
import { DEFAULT_DOT_MODEL_COLOR, DEFAULT_DOT_USER_COLOR } from './panel/format.ts'
|
|
16
17
|
// Re-export for any consumer that imported them from the card before the move.
|
|
@@ -25,6 +26,8 @@ export interface SkillHubSettingsState extends CardShell {
|
|
|
25
26
|
showUseCount: FieldState
|
|
26
27
|
showUseTime: FieldState
|
|
27
28
|
showGroupSummary: FieldState
|
|
29
|
+
statsWindowDays: FieldState
|
|
30
|
+
statsScanMinutes: FieldState
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/** The business face the card's slot registration injects. */
|
|
@@ -50,7 +53,7 @@ export class SkillHubSettingsCardController {
|
|
|
50
53
|
|
|
51
54
|
/** @param scope - the hub settings scope the card edits (FormScope-compatible). */
|
|
52
55
|
constructor(scope: FormScope) {
|
|
53
|
-
this.form = new CardForm(scope, [booleanField('enabled'), booleanField('announceToAgent'), colorField('dotModelColor'), colorField('dotUserColor'), booleanField('showUseCount'), booleanField('showUseTime'), booleanField('showGroupSummary')])
|
|
56
|
+
this.form = new CardForm(scope, [booleanField('enabled'), booleanField('announceToAgent'), colorField('dotModelColor'), colorField('dotUserColor'), booleanField('showUseCount'), booleanField('showUseTime'), booleanField('showGroupSummary'), numberField('statsWindowDays', { min: 0, max: 3650 }), numberField('statsScanMinutes', { min: 1, max: 1440 })])
|
|
54
57
|
this.store = this.form.bind(() => this.projection())
|
|
55
58
|
}
|
|
56
59
|
|
|
@@ -64,6 +67,8 @@ export class SkillHubSettingsCardController {
|
|
|
64
67
|
showUseCount: this.form.field('showUseCount'),
|
|
65
68
|
showUseTime: this.form.field('showUseTime'),
|
|
66
69
|
showGroupSummary: this.form.field('showGroupSummary'),
|
|
70
|
+
statsWindowDays: this.form.field('statsWindowDays'),
|
|
71
|
+
statsScanMinutes: this.form.field('statsScanMinutes'),
|
|
67
72
|
}
|
|
68
73
|
}
|
|
69
74
|
|
|
@@ -87,6 +92,15 @@ export class SkillHubSettingsCardController {
|
|
|
87
92
|
export function SkillHubSettingsCard(props: SkillHubSettingsCardProps): ReactElement {
|
|
88
93
|
const { t } = props
|
|
89
94
|
const state = props.useSkillHubSettingsCard((snapshot) => snapshot)
|
|
95
|
+
// 插件自身版本:挂载时从 config 路由取一次(与面板标题徽标同源),失败静默不显示。
|
|
96
|
+
const [version, setVersion] = useState<string | undefined>(undefined)
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
let cancelled = false
|
|
99
|
+
void new SkillHubApi().config()
|
|
100
|
+
.then((res) => { if (!cancelled && res.ok) setVersion(res.pluginVersion) })
|
|
101
|
+
.catch(() => { /* 版本徽标是装饰性的,失败不提示 */ })
|
|
102
|
+
return () => { cancelled = true }
|
|
103
|
+
}, [])
|
|
90
104
|
const disabled = !state.writable
|
|
91
105
|
const fieldProps = {
|
|
92
106
|
overriddenLabel: t('settings.overridden'),
|
|
@@ -98,6 +112,7 @@ export function SkillHubSettingsCard(props: SkillHubSettingsCardProps): ReactEle
|
|
|
98
112
|
t={t}
|
|
99
113
|
titleKey='settings.title'
|
|
100
114
|
descriptionKey='settings.description'
|
|
115
|
+
version={version}
|
|
101
116
|
state={state}
|
|
102
117
|
onSave={props.save}
|
|
103
118
|
onDiscard={props.discard}
|
|
@@ -164,6 +179,30 @@ export function SkillHubSettingsCard(props: SkillHubSettingsCardProps): ReactEle
|
|
|
164
179
|
onEdit={(text) => { props.edit('showGroupSummary', text) }}
|
|
165
180
|
onReset={() => { props.resetField('showGroupSummary') }}
|
|
166
181
|
/>
|
|
182
|
+
<NumberField
|
|
183
|
+
id='skill-hub-stats-window-days'
|
|
184
|
+
label={t('settings.statsWindowDays')}
|
|
185
|
+
hint={t('settings.statsWindowDaysHint')}
|
|
186
|
+
inheritLabel={t('settings.inherit')}
|
|
187
|
+
{...fieldProps}
|
|
188
|
+
text={state.statsWindowDays.text}
|
|
189
|
+
overridden={state.statsWindowDays.overridden}
|
|
190
|
+
invalid={state.statsWindowDays.invalid}
|
|
191
|
+
onEdit={(text) => { props.edit('statsWindowDays', text) }}
|
|
192
|
+
onReset={() => { props.resetField('statsWindowDays') }}
|
|
193
|
+
/>
|
|
194
|
+
<NumberField
|
|
195
|
+
id='skill-hub-stats-scan-minutes'
|
|
196
|
+
label={t('settings.statsScanMinutes')}
|
|
197
|
+
hint={t('settings.statsScanMinutesHint')}
|
|
198
|
+
inheritLabel={t('settings.inherit')}
|
|
199
|
+
{...fieldProps}
|
|
200
|
+
text={state.statsScanMinutes.text}
|
|
201
|
+
overridden={state.statsScanMinutes.overridden}
|
|
202
|
+
invalid={state.statsScanMinutes.invalid}
|
|
203
|
+
onEdit={(text) => { props.edit('statsScanMinutes', text) }}
|
|
204
|
+
onReset={() => { props.resetField('statsScanMinutes') }}
|
|
205
|
+
/>
|
|
167
206
|
</PluginSettingsCard>
|
|
168
207
|
)
|
|
169
208
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -181,7 +181,7 @@ export const zh = {
|
|
|
181
181
|
'form.success': '已创建:',
|
|
182
182
|
'form.error': '创建失败:',
|
|
183
183
|
'err.dismiss': '知道了',
|
|
184
|
-
'settings.title': '
|
|
184
|
+
'settings.title': '技能中枢',
|
|
185
185
|
'settings.description': '管理 dsh 本地技能目录:浏览、分组、来源同步与新建。',
|
|
186
186
|
'settings.enabled': '启用插件',
|
|
187
187
|
'settings.enabledHint': '关闭后技能中枢的路由、入口与公告全部下线。',
|
|
@@ -197,6 +197,10 @@ export const zh = {
|
|
|
197
197
|
'settings.showUseTimeHint': '在技能名行右侧显示相对时间(如「3 天前」)。',
|
|
198
198
|
'settings.showGroupSummary': '显示分组汇总',
|
|
199
199
|
'settings.showGroupSummaryHint': '在分组标题后汇总调用次数与最近调用时间。',
|
|
200
|
+
'settings.statsWindowDays': '统计窗口(天)',
|
|
201
|
+
'settings.statsWindowDaysHint': '只统计最近 N 天的使用次数,默认 14 天;0 = 全部历史。改动立即生效。',
|
|
202
|
+
'settings.statsScanMinutes': '自动统计间隔(分钟)',
|
|
203
|
+
'settings.statsScanMinutesHint': '后台扫描会话日志的间隔,最小 1 分钟;扫描耗时会自动拉长间隔。',
|
|
200
204
|
'settings.inherit': '继承',
|
|
201
205
|
'settings.on': '开',
|
|
202
206
|
'settings.off': '关',
|
|
@@ -406,6 +410,10 @@ export const en: Record<HubKey, string> = {
|
|
|
406
410
|
'settings.showUseTimeHint': 'Show relative last-used time on the name row (e.g. "3 d ago").',
|
|
407
411
|
'settings.showGroupSummary': 'Show group summaries',
|
|
408
412
|
'settings.showGroupSummaryHint': 'Summarize invocation count and last-used time after group titles.',
|
|
413
|
+
'settings.statsWindowDays': 'Stats window (days)',
|
|
414
|
+
'settings.statsWindowDaysHint': 'Count only usage within the last N days (default 14); 0 = full history. Changes apply immediately.',
|
|
415
|
+
'settings.statsScanMinutes': 'Auto stats interval (minutes)',
|
|
416
|
+
'settings.statsScanMinutesHint': 'How often the background session-log scan runs (min 1); heavy scans automatically stretch it.',
|
|
409
417
|
'settings.inherit': 'Inherit',
|
|
410
418
|
'settings.on': 'On',
|
|
411
419
|
'settings.off': 'Off',
|
|
@@ -119,7 +119,7 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
|
|
|
119
119
|
return (
|
|
120
120
|
<div className={css.panel}>
|
|
121
121
|
<div className={css.header}>
|
|
122
|
-
<h2 className={css.title}><IconSkillOutline16 size={16} className={css.titleIcon} /> {tt('panel.title')}</h2>
|
|
122
|
+
<h2 className={css.title}><IconSkillOutline16 size={16} className={css.titleIcon} /> {tt('panel.title')}{catalog !== null ? <span className={css.pluginVersion}>v{catalog.pluginVersion}</span> : null}</h2>
|
|
123
123
|
{catalog !== null
|
|
124
124
|
? <span className={css.headerCount}>
|
|
125
125
|
{tt('panel.count', { count: catalog.skills.length + catalog.disabled.length })}
|
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
|
|
121
121
|
/* Reduced header: title + quiet count on one line, controls on the next. */
|
|
122
122
|
.headerCount { font-size: 12px; opacity: .5; margin-left: 2px; white-space: nowrap; }
|
|
123
|
+
.pluginVersion { font-size: 12px; font-weight: 500; opacity: .45; margin-left: 6px; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
123
124
|
.subbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
124
125
|
.legendToggle { flex: none; width: 22px; height: 22px; border-radius: 50%; border: none; background: rgba(128,128,128,.14); color: inherit; font-size: 12px; line-height: 1; cursor: pointer; opacity: .55; transition: opacity .15s ease, background .15s ease; }
|
|
125
126
|
.legendToggle:hover { opacity: 1; }
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
.headText { flex-direction: column; flex: 1; gap: 4px; min-width: 0; display: flex; }
|
|
9
9
|
.name { color: var(--dsw-alias-label-primary); font-size: 15px; font-weight: 600; line-height: 1.4; }
|
|
10
10
|
.description { color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 1.5; }
|
|
11
|
+
.versionTag { font-size: 12px; font-weight: 500; opacity: .45; margin-left: 6px; white-space: nowrap; font-variant-numeric: tabular-nums; vertical-align: 1px; }
|
|
11
12
|
.pending { white-space: nowrap; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary); border-radius: 999px; flex: none; padding: 1px 8px; font-size: 11px; font-weight: 500; line-height: 17px; }
|
|
12
13
|
.chevron, .chevronOpen { color: var(--dsw-alias-label-tertiary); flex: none; transition: transform .16s; }
|
|
13
14
|
.chevronOpen { transform: rotate(180deg); }
|
|
@@ -20,6 +20,8 @@ export interface PluginSettingsCardProps {
|
|
|
20
20
|
titleKey: HubKey
|
|
21
21
|
/** Locale key of the card description. */
|
|
22
22
|
descriptionKey: HubKey
|
|
23
|
+
/** 插件自身版本(可选):显示在标题旁的小徽标;缺省不渲染。 */
|
|
24
|
+
version?: string
|
|
23
25
|
/** The form shell state. */
|
|
24
26
|
state: CardShell
|
|
25
27
|
onSave: () => void
|
|
@@ -48,7 +50,7 @@ export function PluginSettingsCard(props: PluginSettingsCardProps): ReactElement
|
|
|
48
50
|
onClick={() => { setOpen(!open) }}
|
|
49
51
|
>
|
|
50
52
|
<span className={css.headText}>
|
|
51
|
-
<span className={css.name}>{title}</span>
|
|
53
|
+
<span className={css.name}>{title}{props.version !== undefined ? <span className={css.versionTag}>v{props.version}</span> : null}</span>
|
|
52
54
|
<span className={css.description}>{props.t(props.descriptionKey)}</span>
|
|
53
55
|
</span>
|
|
54
56
|
{state.dirty ? <span className={css.pending}>{props.t('settings.unsaved')}</span> : null}
|
|
@@ -236,3 +238,51 @@ export function ColorField(props: ColorFieldProps): ReactElement {
|
|
|
236
238
|
</div>
|
|
237
239
|
)
|
|
238
240
|
}
|
|
241
|
+
|
|
242
|
+
/** One staged numeric field: a numeric draft text input with inherit/reset semantics. */
|
|
243
|
+
export interface NumberFieldProps {
|
|
244
|
+
id: string
|
|
245
|
+
label: string
|
|
246
|
+
hint: string
|
|
247
|
+
/** Placeholder shown while the field inherits its default. */
|
|
248
|
+
inheritLabel: string
|
|
249
|
+
overriddenLabel: string
|
|
250
|
+
resetLabel: string
|
|
251
|
+
disabled: boolean
|
|
252
|
+
/** Effective number when overridden; empty string means inherit. */
|
|
253
|
+
text: string
|
|
254
|
+
overridden: boolean
|
|
255
|
+
invalid?: boolean
|
|
256
|
+
onEdit: (text: string) => void
|
|
257
|
+
onReset: () => void
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function NumberField(props: NumberFieldProps): ReactElement {
|
|
261
|
+
return (
|
|
262
|
+
<div className={css.field}>
|
|
263
|
+
<div className={css.head}>
|
|
264
|
+
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
|
265
|
+
{props.overridden ? (
|
|
266
|
+
<span className={css.badges}>
|
|
267
|
+
<span className={css.badge}>{props.overriddenLabel}</span>
|
|
268
|
+
<button type='button' className={css.reset} disabled={props.disabled} onClick={props.onReset}>
|
|
269
|
+
{props.resetLabel}
|
|
270
|
+
</button>
|
|
271
|
+
</span>
|
|
272
|
+
) : null}
|
|
273
|
+
</div>
|
|
274
|
+
<input
|
|
275
|
+
id={props.id}
|
|
276
|
+
type='text'
|
|
277
|
+
inputMode='numeric'
|
|
278
|
+
className={css.input}
|
|
279
|
+
value={props.text}
|
|
280
|
+
disabled={props.disabled}
|
|
281
|
+
placeholder={props.inheritLabel}
|
|
282
|
+
aria-invalid={props.invalid === true}
|
|
283
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
284
|
+
/>
|
|
285
|
+
<p className={css.hint}>{props.hint}</p>
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
@@ -49,6 +49,27 @@ export function colorField(field: string): FieldSpec {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A numeric field with range clamping. Integer by default: a fractional draft
|
|
54
|
+
* is truncated so "5.5 分钟" cannot sneak past an integer-only schema.
|
|
55
|
+
*/
|
|
56
|
+
export function numberField(field: string, options: { min?: number; max?: number; integer?: boolean } = {}): FieldSpec {
|
|
57
|
+
const min = options.min ?? Number.NEGATIVE_INFINITY
|
|
58
|
+
const max = options.max ?? Number.POSITIVE_INFINITY
|
|
59
|
+
const integer = options.integer ?? true
|
|
60
|
+
return {
|
|
61
|
+
field,
|
|
62
|
+
format: (value) => (typeof value === 'number' && Number.isFinite(value) ? String(value) : ''),
|
|
63
|
+
parse: (text) => {
|
|
64
|
+
const trimmed = text.trim()
|
|
65
|
+
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return undefined
|
|
66
|
+
const parsed = integer ? Math.trunc(Number(trimmed)) : Number(trimmed)
|
|
67
|
+
if (!Number.isFinite(parsed) || parsed < min || parsed > max) return undefined
|
|
68
|
+
return { kind: 'set', value: parsed }
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
/** Card-level state the chrome renders. */
|
|
53
74
|
export interface CardShell {
|
|
54
75
|
available: boolean
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,10 @@ export interface Config {
|
|
|
40
40
|
showUseTime?: boolean
|
|
41
41
|
/** Show group-header usage summaries (count + last used). Default true. */
|
|
42
42
|
showGroupSummary?: boolean
|
|
43
|
+
/** 统计滚动窗口天数:只统计最近 N 天的使用;0 = 全部历史。默认 0。 */
|
|
44
|
+
statsWindowDays?: number
|
|
45
|
+
/** 自动统计扫描间隔(分钟,最小 1)。默认 5。 */
|
|
46
|
+
statsScanMinutes?: number
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
export const Config: z<Config> = z.object({
|
|
@@ -48,6 +52,8 @@ export const Config: z<Config> = z.object({
|
|
|
48
52
|
showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount),
|
|
49
53
|
showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
|
|
50
54
|
showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
|
|
55
|
+
statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
|
|
56
|
+
statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes),
|
|
51
57
|
})
|
|
52
58
|
|
|
53
59
|
/**
|
|
@@ -68,6 +74,8 @@ export const HubSettingsSchema: z<HubSettingsValue> = z.object({
|
|
|
68
74
|
showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
|
|
69
75
|
dotModelColor: z.string().pattern(HEX_COLOR_RE),
|
|
70
76
|
dotUserColor: z.string().pattern(HEX_COLOR_RE),
|
|
77
|
+
statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
|
|
78
|
+
statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes),
|
|
71
79
|
})
|
|
72
80
|
|
|
73
81
|
/** Order of the announcement section within the tool-guidance band. */
|
|
@@ -126,7 +134,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
126
134
|
// surfaces once the namespace commits.
|
|
127
135
|
const updateConfig = async (patch: Partial<HubConfig>): Promise<HubConfig> => {
|
|
128
136
|
const user: Record<string, unknown> = { ...saved() }
|
|
129
|
-
for (const [key, value] of Object.entries(patch) as Array<[keyof HubConfig, boolean | string | undefined]>) {
|
|
137
|
+
for (const [key, value] of Object.entries(patch) as Array<[keyof HubConfig, boolean | string | number | undefined]>) {
|
|
130
138
|
if (value === undefined) delete user[key]
|
|
131
139
|
else user[key] = value
|
|
132
140
|
}
|
|
@@ -222,7 +230,30 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
222
230
|
// stats wiring — no sessionQuery service ever mounted means none of this
|
|
223
231
|
// runs).
|
|
224
232
|
ctx.inject(['sessionQuery'], (sctx) => {
|
|
225
|
-
|
|
226
|
-
|
|
233
|
+
// 恢复 sidecar 里的增量扫描检查点后再建 reader(异步、不阻塞注入回调):
|
|
234
|
+
// 重启后无需重新解压全部历史日志;检查点只在全量对账后落盘(约每天一次,
|
|
235
|
+
// 或统计窗口配置变化后的下一次扫描)。扫描间隔与滚动窗口都从设置命名空间
|
|
236
|
+
// 实时读取——卡片里改完即生效,无需重启。
|
|
237
|
+
void (async () => {
|
|
238
|
+
const saved = await store.getSkillStatsState().catch(() => undefined)
|
|
239
|
+
const scanMinutes = (): number => {
|
|
240
|
+
const value = current().statsScanMinutes
|
|
241
|
+
return typeof value === 'number' && value >= 1 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsScanMinutes
|
|
242
|
+
}
|
|
243
|
+
const windowDays = (): number => {
|
|
244
|
+
const value = current().statsWindowDays
|
|
245
|
+
return typeof value === 'number' && value >= 0 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsWindowDays
|
|
246
|
+
}
|
|
247
|
+
stats = createSkillStatsReader(sctx.sessionQuery, () => scanMinutes() * 60_000, {
|
|
248
|
+
checkpoint: saved,
|
|
249
|
+
windowDays,
|
|
250
|
+
onCheckpoint: (next) => {
|
|
251
|
+
void store.saveSkillStatsState(next).catch((error) => {
|
|
252
|
+
ctx.logger.warn('[dsh-skill-hub] persisting skill-stats checkpoint failed', error)
|
|
253
|
+
})
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
sync()
|
|
257
|
+
})()
|
|
227
258
|
})
|
|
228
259
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -88,6 +88,8 @@ export interface DiagnosticEntry {
|
|
|
88
88
|
/** GET /api/skill-hub/catalog */
|
|
89
89
|
export interface CatalogResponse {
|
|
90
90
|
ok: true
|
|
91
|
+
/** 已安装插件自身的版本号(package.json version),面板标题旁显示。 */
|
|
92
|
+
pluginVersion: string
|
|
91
93
|
/** Whether discovery completed within a stable catalog revision. */
|
|
92
94
|
complete: boolean
|
|
93
95
|
/** Sorted winning summaries of every enabled skill (all roots + providers). */
|
|
@@ -196,6 +198,25 @@ export interface StatsResponse {
|
|
|
196
198
|
stats: SkillStat[]
|
|
197
199
|
}
|
|
198
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Persisted incremental-scan checkpoint for the usage statistics (sidecar
|
|
203
|
+
* `skillStats` field). Sessions created before `frozenBefore` are treated as
|
|
204
|
+
* finalized: their per-session counts live in `frozenSessions` (only sessions
|
|
205
|
+
* with at least one invocation are kept) and they are not re-read on
|
|
206
|
+
* incremental scans. A daily full reconciliation rebuilds the cache and
|
|
207
|
+
* advances the watermark, so a resumed old session is eventually re-counted.
|
|
208
|
+
*/
|
|
209
|
+
export interface SkillStatsCheckpoint {
|
|
210
|
+
/** The rolling-window configuration this checkpoint was built for (0 = all history). */
|
|
211
|
+
windowDays: number
|
|
212
|
+
/** Watermark: every session with header.createdAt < this value is frozen. */
|
|
213
|
+
frozenBefore: number
|
|
214
|
+
/** Per-session counts of finalized sessions, keyed by session id. */
|
|
215
|
+
frozenSessions: Record<string, { createdAt: number; counts: Record<string, { count: number; lastUsed: number }> }>
|
|
216
|
+
/** Epoch ms of the last full reconciliation (drives the daily cadence). */
|
|
217
|
+
lastFullReconcile: number
|
|
218
|
+
}
|
|
219
|
+
|
|
199
220
|
/** JSON error body shared by every route. */
|
|
200
221
|
export interface ErrorResponse {
|
|
201
222
|
error: string
|
|
@@ -217,6 +238,10 @@ export interface HubConfig {
|
|
|
217
238
|
showUseTime?: boolean
|
|
218
239
|
/** Show group-header usage summaries (count + last used). Default true. */
|
|
219
240
|
showGroupSummary?: boolean
|
|
241
|
+
/** 统计滚动窗口天数:只统计最近 N 天的使用;0 = 全部历史。默认 0。 */
|
|
242
|
+
statsWindowDays?: number
|
|
243
|
+
/** 自动统计扫描间隔(分钟,最小 1)。默认 5。 */
|
|
244
|
+
statsScanMinutes?: number
|
|
220
245
|
}
|
|
221
246
|
|
|
222
247
|
/**
|
|
@@ -235,6 +260,10 @@ export type HubSettingsValue = {
|
|
|
235
260
|
dotModelColor?: string
|
|
236
261
|
/** User-invocable dot color (#rrggbb); absent means the panel default. */
|
|
237
262
|
dotUserColor?: string
|
|
263
|
+
/** 统计滚动窗口天数(0 = 全部历史)。 */
|
|
264
|
+
statsWindowDays?: number
|
|
265
|
+
/** 自动统计扫描间隔(分钟)。 */
|
|
266
|
+
statsScanMinutes?: number
|
|
238
267
|
}
|
|
239
268
|
|
|
240
269
|
/**
|
|
@@ -248,34 +277,49 @@ export const HUB_CONFIG_DEFAULTS = {
|
|
|
248
277
|
showUseCount: true,
|
|
249
278
|
showUseTime: true,
|
|
250
279
|
showGroupSummary: true,
|
|
280
|
+
statsWindowDays: 14,
|
|
281
|
+
statsScanMinutes: 5,
|
|
251
282
|
} as const
|
|
252
283
|
|
|
253
284
|
/**
|
|
254
285
|
* Resolve the effective hub config: saved sidecar overrides win over the
|
|
255
286
|
* cordis composition entry (the web card owns runtime config), missing
|
|
256
287
|
* booleans fall back to HUB_CONFIG_DEFAULTS, and dot colors pass through
|
|
257
|
-
* (saved first, then base) only when set.
|
|
288
|
+
* (saved first, then base) only when set. Numeric stats knobs are clamped to
|
|
289
|
+
* their sane ranges (window ≥ 0, scan interval ≥ 1 minute).
|
|
258
290
|
*/
|
|
259
291
|
export function resolveHubConfig(saved: Partial<HubConfig>, base: Partial<HubConfig> = {}): HubConfig {
|
|
260
292
|
const dotModelColor = saved.dotModelColor !== undefined ? saved.dotModelColor : base.dotModelColor
|
|
261
293
|
const dotUserColor = saved.dotUserColor !== undefined ? saved.dotUserColor : base.dotUserColor
|
|
294
|
+
const windowDays = clampNumber(saved.statsWindowDays ?? base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays
|
|
295
|
+
const scanMinutes = clampNumber(saved.statsScanMinutes ?? base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes
|
|
262
296
|
return {
|
|
263
297
|
enabled: saved.enabled ?? base.enabled ?? HUB_CONFIG_DEFAULTS.enabled,
|
|
264
298
|
announceToAgent: saved.announceToAgent ?? base.announceToAgent ?? HUB_CONFIG_DEFAULTS.announceToAgent,
|
|
265
299
|
showUseCount: saved.showUseCount ?? base.showUseCount ?? HUB_CONFIG_DEFAULTS.showUseCount,
|
|
266
300
|
showUseTime: saved.showUseTime ?? base.showUseTime ?? HUB_CONFIG_DEFAULTS.showUseTime,
|
|
267
301
|
showGroupSummary: saved.showGroupSummary ?? base.showGroupSummary ?? HUB_CONFIG_DEFAULTS.showGroupSummary,
|
|
302
|
+
statsWindowDays: windowDays,
|
|
303
|
+
statsScanMinutes: scanMinutes,
|
|
268
304
|
...(dotModelColor !== undefined ? { dotModelColor } : {}),
|
|
269
305
|
...(dotUserColor !== undefined ? { dotUserColor } : {}),
|
|
270
306
|
}
|
|
271
307
|
}
|
|
272
308
|
|
|
309
|
+
/** Clamp a numeric override into a valid value; undefined/invalid stays undefined. */
|
|
310
|
+
function clampNumber(value: unknown, min: number): number | undefined {
|
|
311
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < min) return undefined
|
|
312
|
+
return Math.floor(value)
|
|
313
|
+
}
|
|
314
|
+
|
|
273
315
|
/** HEX color validation shared by host routes and the settings card. */
|
|
274
316
|
export const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i
|
|
275
317
|
|
|
276
318
|
/** GET /api/skill-hub/config */
|
|
277
319
|
export interface ConfigResponse {
|
|
278
320
|
ok: true
|
|
321
|
+
/** 已安装插件自身的版本号(package.json version),设置卡标题旁显示。 */
|
|
322
|
+
pluginVersion: string
|
|
279
323
|
/** Effective configuration (saved overrides merged over the defaults). */
|
|
280
324
|
config: HubConfig
|
|
281
325
|
/** Raw user overrides persisted in the sidecar (absent fields inherit defaults). */
|
|
@@ -298,6 +342,10 @@ export interface ConfigRequest {
|
|
|
298
342
|
dotModelColor?: string | null
|
|
299
343
|
/** Set the dot color; null clears the saved override so it re-inherits the default. */
|
|
300
344
|
dotUserColor?: string | null
|
|
345
|
+
/** 统计滚动窗口天数(0 = 全部历史);null 清除覆盖回默认。 */
|
|
346
|
+
statsWindowDays?: number | null
|
|
347
|
+
/** 自动统计扫描间隔(分钟);null 清除覆盖回默认。 */
|
|
348
|
+
statsScanMinutes?: number | null
|
|
301
349
|
}
|
|
302
350
|
|
|
303
351
|
/** One market source: a tracked upstream repo plus its pinned version. */
|
package/src/routes.test.ts
CHANGED
|
@@ -127,6 +127,8 @@ describe('skill-hub routes', () => {
|
|
|
127
127
|
expect(body.skills[1].writable).toBe(false)
|
|
128
128
|
expect(body.diagnostics).toHaveLength(1)
|
|
129
129
|
expect(body.diagnostics[0].reason).toBe('missing YAML frontmatter (--- block)')
|
|
130
|
+
// 面板标题旁的插件版本徽标数据源:随 catalog 附带当前安装版本。
|
|
131
|
+
expect(body.pluginVersion).toMatch(/^\d+\.\d+\.\d+$/)
|
|
130
132
|
})
|
|
131
133
|
|
|
132
134
|
it('forwards the cwd query to the registry snapshot', async () => {
|
|
@@ -394,7 +396,8 @@ describe('skill-hub routes', () => {
|
|
|
394
396
|
expect(res.status).toBe(200)
|
|
395
397
|
const body = res.json() as ConfigResponse
|
|
396
398
|
expect(body.ok).toBe(true)
|
|
397
|
-
expect(body.
|
|
399
|
+
expect(body.pluginVersion).toMatch(/^\d+\.\d+\.\d+$/)
|
|
400
|
+
expect(body.config).toEqual({ enabled: false, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true, statsWindowDays: 14, statsScanMinutes: 5 })
|
|
398
401
|
expect(body.saved).toEqual({ enabled: false })
|
|
399
402
|
})
|
|
400
403
|
|
package/src/routes.ts
CHANGED
|
@@ -85,7 +85,7 @@ import {
|
|
|
85
85
|
resolveHubConfig,
|
|
86
86
|
} from './protocol.ts'
|
|
87
87
|
import { clearTrash, createSkill, disableSkill, enableSkill, restoreSkill, rootOfPath, rootPath, scanDiagnostics, trashSkill } from './skillfs.ts'
|
|
88
|
-
import { checkLatestRelease } from './update.ts'
|
|
88
|
+
import { checkLatestRelease, CURRENT_VERSION } from './update.ts'
|
|
89
89
|
import { dshHome, StoreError, type SkillHubStore } from './store.ts'
|
|
90
90
|
import type { SkillStatsReader } from './stats.ts'
|
|
91
91
|
|
|
@@ -410,7 +410,7 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
|
|
|
410
410
|
...(await scanDiagnostics('user-dsh', home)),
|
|
411
411
|
...(await scanDiagnostics('user-agents', home)),
|
|
412
412
|
]
|
|
413
|
-
return { ok: true, complete, skills, disabled, diagnostics }
|
|
413
|
+
return { ok: true, pluginVersion: CURRENT_VERSION, complete, skills, disabled, diagnostics }
|
|
414
414
|
}
|
|
415
415
|
|
|
416
416
|
/** Map a loaded definition onto the wire shape. */
|
|
@@ -719,7 +719,7 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
|
719
719
|
skipGate: true,
|
|
720
720
|
handler: async ({ req, res, body }) => {
|
|
721
721
|
if (req.method === 'GET') {
|
|
722
|
-
writeJson(res, 200, { ok: true, config: configOf(deps), saved: savedOf(deps) } satisfies ConfigResponse)
|
|
722
|
+
writeJson(res, 200, { ok: true, pluginVersion: CURRENT_VERSION, config: configOf(deps), saved: savedOf(deps) } satisfies ConfigResponse)
|
|
723
723
|
return
|
|
724
724
|
}
|
|
725
725
|
const raw = body
|
|
@@ -759,7 +759,7 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
|
759
759
|
} else {
|
|
760
760
|
config = await deps.updateConfig(patch)
|
|
761
761
|
}
|
|
762
|
-
writeJson(res, 200, { ok: true, config, saved: savedOf(deps) } satisfies ConfigResponse)
|
|
762
|
+
writeJson(res, 200, { ok: true, pluginVersion: CURRENT_VERSION, config, saved: savedOf(deps) } satisfies ConfigResponse)
|
|
763
763
|
},
|
|
764
764
|
}),
|
|
765
765
|
// --------------------------------------------------------------- market
|