dsh-skill-hub 0.3.7 → 0.3.8

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 (109) hide show
  1. package/README.md +40 -50
  2. package/README.zh.md +38 -48
  3. package/lib/client.js +2209 -1820
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +653 -419
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -0
  7. package/lib/types/client/api.d.ts +12 -6
  8. package/lib/types/client/helpers.d.ts +0 -1
  9. package/lib/types/client/locales/common.d.ts +52 -0
  10. package/lib/types/client/locales/detail.d.ts +16 -0
  11. package/lib/types/client/locales/market.d.ts +73 -0
  12. package/lib/types/client/locales/settings.d.ts +41 -0
  13. package/lib/types/client/locales/skills.d.ts +65 -0
  14. package/lib/types/client/locales/sources.d.ts +35 -0
  15. package/lib/types/client/locales.d.ts +124 -118
  16. package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
  17. package/lib/types/client/panel/MarketView.d.ts +1 -0
  18. package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
  19. package/lib/types/client/panel/hooks/shared.d.ts +72 -0
  20. package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
  21. package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
  22. package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
  23. package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
  24. package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
  25. package/lib/types/client/panel/market-format.d.ts +6 -0
  26. package/lib/types/client/panel/useSkillHub.d.ts +45 -73
  27. package/lib/types/client/settings-card.d.ts +20 -0
  28. package/lib/types/client/settings-form.d.ts +6 -0
  29. package/lib/types/protocol/api.d.ts +38 -0
  30. package/lib/types/protocol/catalog.d.ts +155 -0
  31. package/lib/types/protocol/config.d.ts +113 -0
  32. package/lib/types/protocol/groups.d.ts +95 -0
  33. package/lib/types/protocol/market.d.ts +94 -0
  34. package/lib/types/protocol/repo.d.ts +107 -0
  35. package/lib/types/protocol/sources.d.ts +139 -0
  36. package/lib/types/protocol/stats.d.ts +46 -0
  37. package/lib/types/protocol.d.ts +13 -743
  38. package/lib/types/repo/github-client.d.ts +42 -0
  39. package/lib/types/repo.d.ts +6 -17
  40. package/lib/types/routes/catalog.d.ts +7 -0
  41. package/lib/types/routes/config.d.ts +7 -0
  42. package/lib/types/routes/groups.d.ts +7 -0
  43. package/lib/types/routes/helpers.d.ts +145 -0
  44. package/lib/types/routes/market.d.ts +8 -0
  45. package/lib/types/routes/repo-import.d.ts +7 -0
  46. package/lib/types/routes/route-state.d.ts +55 -0
  47. package/lib/types/routes/sources.d.ts +8 -0
  48. package/lib/types/routes.d.ts +5 -34
  49. package/lib/types/stats.d.ts +3 -2
  50. package/lib/types/store/errors.d.ts +10 -0
  51. package/lib/types/store/migrate.d.ts +44 -0
  52. package/lib/types/store/paths.d.ts +32 -0
  53. package/lib/types/store/store.d.ts +133 -0
  54. package/lib/types/store.d.ts +7 -146
  55. package/package.json +28 -27
  56. package/src/client/SkillHubSettingsCard.tsx +17 -3
  57. package/src/client/api.ts +101 -195
  58. package/src/client/helpers.ts +10 -1
  59. package/src/client/locales/common.ts +102 -0
  60. package/src/client/locales/detail.ts +30 -0
  61. package/src/client/locales/market.ts +144 -0
  62. package/src/client/locales/settings.ts +80 -0
  63. package/src/client/locales/skills.ts +128 -0
  64. package/src/client/locales/sources.ts +68 -0
  65. package/src/client/locales.ts +26 -512
  66. package/src/client/panel/MarketSourceRow.tsx +108 -0
  67. package/src/client/panel/MarketView.tsx +12 -253
  68. package/src/client/panel/RepoScanCard.tsx +171 -0
  69. package/src/client/panel/hooks/shared.ts +81 -0
  70. package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
  71. package/src/client/panel/hooks/useGroupFlow.ts +240 -0
  72. package/src/client/panel/hooks/useMarketFlow.ts +421 -0
  73. package/src/client/panel/hooks/useMetaFlow.ts +58 -0
  74. package/src/client/panel/hooks/useSourceFlow.ts +193 -0
  75. package/src/client/panel/market-format.ts +22 -0
  76. package/src/client/panel/useSkillHub.ts +141 -1032
  77. package/src/client/settings-card.tsx +51 -0
  78. package/src/client/settings-form.ts +18 -0
  79. package/src/index.ts +14 -4
  80. package/src/protocol/api.ts +38 -0
  81. package/src/protocol/catalog.ts +168 -0
  82. package/src/protocol/config.ts +145 -0
  83. package/src/protocol/groups.ts +106 -0
  84. package/src/protocol/market.ts +100 -0
  85. package/src/protocol/repo.ts +104 -0
  86. package/src/protocol/sources.ts +146 -0
  87. package/src/protocol/stats.ts +42 -0
  88. package/src/protocol.ts +83 -809
  89. package/src/repo/github-client.ts +131 -0
  90. package/src/repo.test.ts +24 -1
  91. package/src/repo.ts +50 -170
  92. package/src/routes/catalog.ts +309 -0
  93. package/src/routes/config.ts +119 -0
  94. package/src/routes/groups.ts +133 -0
  95. package/src/routes/helpers.ts +489 -0
  96. package/src/routes/market.ts +255 -0
  97. package/src/routes/repo-import.ts +267 -0
  98. package/src/routes/route-state.ts +89 -0
  99. package/src/routes/sources.ts +318 -0
  100. package/src/routes.test.ts +20 -0
  101. package/src/routes.ts +21 -1699
  102. package/src/stats.test.ts +58 -3
  103. package/src/stats.ts +45 -16
  104. package/src/store/errors.ts +14 -0
  105. package/src/store/migrate.ts +272 -0
  106. package/src/store/paths.ts +43 -0
  107. package/src/store/store.ts +532 -0
  108. package/src/store.test.ts +18 -0
  109. package/src/store.ts +7 -737
package/src/stats.test.ts CHANGED
@@ -199,6 +199,29 @@ async function flush(): Promise<void> {
199
199
  await new Promise((resolve) => setTimeout(resolve, 20))
200
200
  }
201
201
 
202
+ describe('scan read concurrency', () => {
203
+ it('reads session logs in parallel, not one by one', async () => {
204
+ const ids = Array.from({ length: 8 }, (_, i) => 's' + i)
205
+ let inFlight = 0
206
+ let peak = 0
207
+ const query: SessionQueryLike = {
208
+ listSessions: async () => ids.map((id) => record(id)),
209
+ readSession: async (id) => {
210
+ inFlight += 1
211
+ peak = Math.max(peak, inFlight)
212
+ await new Promise((resolve) => setTimeout(resolve, 5))
213
+ inFlight -= 1
214
+ return { events: [invocationEvent(String(id))] }
215
+ },
216
+ }
217
+ // No checkpoint → full path, all 8 sessions read.
218
+ const stats = await readSkillStats(query)
219
+ expect(stats).toHaveLength(8)
220
+ expect(peak).toBeGreaterThan(1)
221
+ expect(peak).toBeLessThanOrEqual(6)
222
+ })
223
+ })
224
+
202
225
  describe('frozen-bucket incremental scans', () => {
203
226
  it('skips frozen sessions and merges checkpoint totals with the recent window', async () => {
204
227
  const reads: string[] = []
@@ -221,15 +244,15 @@ describe('frozen-bucket incremental scans', () => {
221
244
  },
222
245
  onCheckpoint: () => { checkpoints += 1 },
223
246
  })
224
- expect(await reader()).toEqual([]) // 首次调用不等待扫描
247
+ expect(await reader()).toEqual([]) // 首次调用不等待扫描(无上次总数可端)
225
248
  await flush()
226
249
  expect(await reader()).toEqual([
227
250
  { name: 'oldskill', count: 5, lastUsed: NOW - 20 * DAY },
228
251
  { name: 'tdd', count: 2, lastUsed: 0 },
229
252
  ])
230
- // 冻结会话没有被重读;增量扫描不改检查点 → 不触发持久化回调。
253
+ // 冻结会话没有被重读;增量扫描也落盘(含新总数)→ 回调恰好一次。
231
254
  expect(reads).toEqual(['fresh'])
232
- expect(checkpoints).toBe(0)
255
+ expect(checkpoints).toBe(1)
233
256
  })
234
257
 
235
258
  it('runs a full reconciliation when due: rebuilds the frozen bucket and advances the watermark', async () => {
@@ -263,9 +286,41 @@ describe('frozen-bucket incremental scans', () => {
263
286
  // 全历史模式(windowDays=0):冻结会话进缓存且仍计入总数。
264
287
  frozenSessions: { ancient: { createdAt: NOW - 30 * DAY, counts: { a: { count: 1, lastUsed: 5 } } } },
265
288
  lastFullReconcile: NOW,
289
+ lastTotals: [
290
+ { name: 'a', count: 1, lastUsed: 5 },
291
+ { name: 'b', count: 2, lastUsed: 0 },
292
+ ],
266
293
  })
267
294
  })
268
295
 
296
+ it('serves persisted lastTotals instantly on cold start, then merges the rescan', async () => {
297
+ const reads: string[] = []
298
+ const query = fakeQuery(
299
+ [record('fresh', NOW - 1 * DAY)],
300
+ { fresh: [invocationEvent('tdd', 1)] },
301
+ reads,
302
+ )
303
+ const saved: Array<Record<string, unknown>> = []
304
+ const reader = createSkillStatsReader(query, 60_000, {
305
+ now: () => NOW,
306
+ checkpoint: {
307
+ windowDays: 0,
308
+ frozenBefore: 0,
309
+ frozenSessions: {},
310
+ lastFullReconcile: NOW, // 对账刚做过 → 增量路径
311
+ lastTotals: [{ name: 'oldskill', count: 9, lastUsed: 7 }],
312
+ },
313
+ onCheckpoint: (cp) => { saved.push(cp as unknown as Record<string, unknown>) },
314
+ })
315
+ // 冷启动直接端上次总数,同时后台重扫;重扫后以新鲜值为准(旧数不复活)。
316
+ expect(await reader()).toEqual([{ name: 'oldskill', count: 9, lastUsed: 7 }])
317
+ await flush()
318
+ expect(await reader()).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
319
+ expect(reads).toEqual(['fresh'])
320
+ expect(saved).toHaveLength(1)
321
+ expect((saved[0] as { lastTotals: unknown }).lastTotals).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
322
+ })
323
+
269
324
  it('re-reads sessions without createdAt on incremental scans (never freezes)', async () => {
270
325
  const reads: string[] = []
271
326
  const query = fakeQuery(
package/src/stats.ts CHANGED
@@ -103,6 +103,24 @@ function isFrozen(record: { header: { createdAt?: number } }, watermark: number)
103
103
  return typeof created === 'number' && created > 0 && created < watermark
104
104
  }
105
105
 
106
+ /** Bounded-concurrency map: session-log reads are independent decompressions, so wall time scales ~1/limit. Merge stays sequential in index order. */
107
+ async function mapConcurrent<T, R>(items: readonly T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]> {
108
+ const results = new Array<R>(items.length)
109
+ let cursor = 0
110
+ const runners = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
111
+ while (cursor < items.length) {
112
+ const index = cursor
113
+ cursor += 1
114
+ results[index] = await worker(items[index], index)
115
+ }
116
+ })
117
+ await Promise.all(runners)
118
+ return results
119
+ }
120
+
121
+ /** Max parallel session-log reads per scan (read-only; merge is order-independent). */
122
+ const SCAN_READ_CONCURRENCY = 6
123
+
106
124
  function mergeInto(totals: Totals, counted: Map<string, InvocationStat>): void {
107
125
  for (const [name, stat] of counted) {
108
126
  const total = totals[name]
@@ -153,19 +171,22 @@ async function scan(query: SessionQueryLike, checkpoint: SkillStatsCheckpoint, n
153
171
  if (dueFullScan) {
154
172
  const cache: SkillStatsCheckpoint['frozenSessions'] = {}
155
173
  const totals: Totals = {}
156
- for (const record of sessions) {
157
- let counted: Map<string, InvocationStat>
174
+ const countedList = await mapConcurrent(sessions, SCAN_READ_CONCURRENCY, async (record) => {
158
175
  try {
159
- counted = countSkillInvocations((await query.readSession(record.header.id)).events)
176
+ return countSkillInvocations((await query.readSession(record.header.id)).events)
160
177
  } catch {
161
- continue // unreadable sessions are skipped, never fatal
178
+ return undefined // unreadable sessions are skipped, never fatal
162
179
  }
180
+ })
181
+ sessions.forEach((record, index) => {
182
+ const counted = countedList[index]
183
+ if (counted === undefined) return
163
184
  const created = record.header.createdAt
164
185
  if (isFrozen(record, cutoff) && counted.size > 0 && typeof created === 'number') {
165
186
  cache[record.header.id as unknown as string] = { createdAt: created, counts: Object.fromEntries(counted) }
166
187
  }
167
188
  if (inWindow(created, windowDays, nowMs)) mergeInto(totals, counted)
168
- }
189
+ })
169
190
  checkpoint.frozenSessions = cache
170
191
  checkpoint.frozenBefore = cutoff
171
192
  checkpoint.windowDays = windowDays
@@ -174,13 +195,17 @@ async function scan(query: SessionQueryLike, checkpoint: SkillStatsCheckpoint, n
174
195
  }
175
196
 
176
197
  const recent: Totals = {}
177
- for (const record of sessions) {
178
- if (isFrozen(record, checkpoint.frozenBefore)) continue
198
+ const unfrozen = sessions.filter((record) => !isFrozen(record, checkpoint.frozenBefore))
199
+ const recentList = await mapConcurrent(unfrozen, SCAN_READ_CONCURRENCY, async (record) => {
179
200
  try {
180
- mergeInto(recent, countSkillInvocations((await query.readSession(record.header.id)).events))
201
+ return countSkillInvocations((await query.readSession(record.header.id)).events)
181
202
  } catch {
182
- continue // unreadable sessions are skipped, never fatal
203
+ return undefined // unreadable sessions are skipped, never fatal
183
204
  }
205
+ })
206
+ for (const counted of recentList) {
207
+ if (counted === undefined) continue
208
+ mergeInto(recent, counted)
184
209
  }
185
210
  const totals: Totals = {}
186
211
  for (const [id, entry] of Object.entries(checkpoint.frozenSessions)) {
@@ -216,8 +241,9 @@ export interface SkillStatsReaderOptions {
216
241
  ttlMs?: number | (() => number)
217
242
  /** Rolling window in days; a getter reads the live config each scan. 0 = all history. */
218
243
  windowDays?: () => number
219
- /** Called after a full reconciliation mutated the checkpoint (never after an
220
- * incremental scan) so the host can persist it to the sidecar. */
244
+ /** Called after every completed scan (full or incremental) so the host can
245
+ * persist the checkpoint including the fresh totals. Cadence follows the
246
+ * scan TTL (minutes, not days) — the payload is tiny and writes are atomic. */
221
247
  onCheckpoint?: (checkpoint: SkillStatsCheckpoint) => void
222
248
  }
223
249
 
@@ -238,7 +264,9 @@ export interface SkillStatsReaderOptions {
238
264
  export function createSkillStatsReader(query: SessionQueryLike, ttlMs: number | (() => number) = 300_000, options: SkillStatsReaderOptions = {}): SkillStatsReader {
239
265
  const checkpoint: SkillStatsCheckpoint = options.checkpoint ?? { windowDays: 0, frozenBefore: 0, frozenSessions: {}, lastFullReconcile: 0 }
240
266
  const now = options.now ?? (() => Date.now())
241
- let cached: SkillStat[] | undefined
267
+ // Cold start serves the last persisted totals instantly (may be a window
268
+ // behind); cachedAt stays 0 so the first call still kicks a background rescan.
269
+ let cached: SkillStat[] | undefined = checkpoint.lastTotals !== undefined ? [...checkpoint.lastTotals] : undefined
242
270
  let cachedAt = 0
243
271
  let refreshing: Promise<void> | null = null
244
272
  let lastScanDurationMs = 0
@@ -248,16 +276,17 @@ export function createSkillStatsReader(query: SessionQueryLike, ttlMs: number |
248
276
  const base = typeof ttlMs === 'function' ? ttlMs() : ttlMs
249
277
  const ttl = Math.max(base, lastScanDurationMs * STATS_TTL_SCAN_FACTOR)
250
278
  if (cached !== undefined && startedAt - cachedAt < ttl) return cached
251
- // Expired (or first call): hand back the stale totals (empty on first
252
- // call) and kick off one background rescan.
279
+ // Expired (or first call): hand back the stale totals (empty on a truly
280
+ // fresh start) and kick off one background rescan.
253
281
  if (refreshing === null) {
254
282
  const windowDays = options.windowDays?.() ?? 0
255
283
  refreshing = scan(query, checkpoint, startedAt, windowDays)
256
- .then(({ stats, mutated }) => {
284
+ .then(({ stats }) => {
257
285
  cached = stats
258
286
  cachedAt = now()
259
287
  lastScanDurationMs = Math.max(0, cachedAt - startedAt)
260
- if (mutated) options.onCheckpoint?.({ ...checkpoint, frozenSessions: { ...checkpoint.frozenSessions } })
288
+ checkpoint.lastTotals = stats
289
+ options.onCheckpoint?.({ ...checkpoint, frozenSessions: { ...checkpoint.frozenSessions } })
261
290
  })
262
291
  .catch(() => { /* keep the previous totals on scan failure */ })
263
292
  .finally(() => { refreshing = null })
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Business-rule failure the routes layer can map onto a 4xx status instead
3
+ * of a blanket 500: user input is invalid (validation → 400), the target
4
+ * does not exist (not-found → 404), or the operation conflicts with an
5
+ * invariant (conflict → 409).
6
+ */
7
+ export class StoreError extends Error {
8
+ readonly kind: 'validation' | 'not-found' | 'conflict'
9
+ constructor(kind: StoreError['kind'], message: string) {
10
+ super(message)
11
+ this.name = 'StoreError'
12
+ this.kind = kind
13
+ }
14
+ }
@@ -0,0 +1,272 @@
1
+ import type { DisabledSkill, HubConfig, MarketSourceRecord, MarketStatsSnapshot, SkillStatsCheckpoint, SkillTag, SourceRecord, TrashEntry } from '../protocol.ts'
2
+ import { STORE_VERSION } from './paths.ts'
3
+
4
+ /** Normalized raw sidecar document after schema migration (fields still unvalidated). */
5
+ export interface MigratedStore {
6
+ version: number
7
+ disabled: unknown
8
+ config?: unknown
9
+ tags?: unknown
10
+ sources?: unknown
11
+ marketSources?: unknown
12
+ trash?: unknown
13
+ skillStats?: unknown
14
+ marketStats?: unknown
15
+ collectionOrder?: unknown
16
+ sourceGroupOrder?: unknown
17
+ }
18
+
19
+ /**
20
+ * Normalize an arbitrary parsed sidecar document to the current schema.
21
+ * v1 to v2: scenes are dropped (the one-click role merged into group
22
+ * switches), and origins are migrated into source records (repo = origin
23
+ * value, commit snapshot empty — the first update check backfills it).
24
+ * v2 to v3: market sources become records ({ repo, ref?, commitSha? })
25
+ * instead of bare repo slugs, so a source can pin a release/branch version.
26
+ * Returns null when the file claims a newer schema than this plugin
27
+ * understands, so the caller starts empty instead of risking data loss.
28
+ */
29
+ export function migrateStore(parsed: unknown): MigratedStore | null {
30
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null
31
+ const record = parsed as Record<string, unknown>
32
+ const version = typeof record.version === 'number' ? record.version : 0
33
+ if (version > STORE_VERSION) return null
34
+ const disabled = Array.isArray(record.disabled) ? record.disabled : []
35
+ const config = typeof record.config === 'object' && record.config !== null && !Array.isArray(record.config) ? record.config : undefined
36
+ const tags = Array.isArray(record.tags) ? record.tags : undefined
37
+ const trash = Array.isArray(record.trash) ? record.trash : undefined
38
+ // v2 stored bare slugs; v3 stores records. Normalize both shapes here.
39
+ const marketSources = Array.isArray(record.marketSources)
40
+ ? record.marketSources.map((entry) => typeof entry === 'string'
41
+ ? { repo: entry }
42
+ : entry)
43
+ : undefined
44
+
45
+ // v2 stores structured source records; v1 only had a skillName to
46
+ // collection string map (origins). Migrate each origin into a SourceRecord
47
+ // whose repo is the collection string itself and whose commit snapshot is
48
+ // empty.
49
+ let sources: unknown
50
+ if (Array.isArray(record.sources)) {
51
+ sources = record.sources
52
+ } else if (version < 2 && typeof record.origins === 'object' && record.origins !== null && !Array.isArray(record.origins)) {
53
+ const byOrigin = new Map<string, string[]>()
54
+ for (const [name, origin] of Object.entries(record.origins as Record<string, unknown>)) {
55
+ if (typeof origin !== 'string' || origin === '') continue
56
+ const list = byOrigin.get(origin)
57
+ if (list === undefined) byOrigin.set(origin, [name])
58
+ else list.push(name)
59
+ }
60
+ sources = [...byOrigin.entries()].map(([repo, skillNames]) => ({
61
+ repo,
62
+ root: 'skills',
63
+ commitSha: '',
64
+ skills: skillNames.sort((a, b) => a.localeCompare(b)),
65
+ }))
66
+ }
67
+ return {
68
+ version: STORE_VERSION,
69
+ disabled,
70
+ ...(config !== undefined ? { config } : {}),
71
+ ...(tags !== undefined ? { tags } : {}),
72
+ ...(sources !== undefined ? { sources } : {}),
73
+ ...(marketSources !== undefined ? { marketSources } : {}),
74
+ ...(trash !== undefined ? { trash } : {}),
75
+ // v4 (skillStats) is a pure addition — older files simply lack the field,
76
+ // and the loader validates its shape, so pass it through untouched.
77
+ // marketStats follows the same pattern (validated on load below).
78
+ ...(record.skillStats !== undefined ? { skillStats: record.skillStats } : {}),
79
+ ...(record.marketStats !== undefined ? { marketStats: record.marketStats } : {}),
80
+ ...(Array.isArray(record.collectionOrder) ? { collectionOrder: record.collectionOrder } : {}),
81
+ ...(Array.isArray(record.sourceGroupOrder) ? { sourceGroupOrder: record.sourceGroupOrder } : {}),
82
+ }
83
+ }
84
+
85
+ /** Validated in-memory state hydrated from a migrated document. */
86
+ export interface HydratedState {
87
+ entries: Map<string, DisabledSkill>
88
+ config: Partial<HubConfig>
89
+ tagsById: Map<string, SkillTag>
90
+ sourcesByRepo: Map<string, SourceRecord>
91
+ marketSources: MarketSourceRecord[]
92
+ trashByName: Map<string, TrashEntry>
93
+ skillStats: SkillStatsCheckpoint | undefined
94
+ marketStats: MarketStatsSnapshot | undefined
95
+ collectionOrder: string[]
96
+ sourceGroupOrder: string[]
97
+ }
98
+
99
+ /** 小优化:统一的非空字符串数组清洗(去空、去重可选由调用方处理)。 */
100
+ function cleanStringList(value: unknown): string[] {
101
+ if (!Array.isArray(value)) return []
102
+ return value.filter((n): n is string => typeof n === 'string' && n !== '')
103
+ }
104
+
105
+ /**
106
+ * 把迁移后的原始文档校验为内存状态:坏条目丢弃、坏桶降级为空,
107
+ * 绝不因一条脏数据丢掉整个 sidecar(与原 ensureLoaded 内联逻辑一致)。
108
+ */
109
+ export function hydrateMigratedState(migrated: MigratedStore): HydratedState {
110
+ const entries = new Map<string, DisabledSkill>()
111
+ if (Array.isArray(migrated.disabled)) {
112
+ for (const entry of migrated.disabled) {
113
+ if (typeof entry?.name === 'string' && typeof entry?.path === 'string') {
114
+ entries.set(entry.name, entry)
115
+ }
116
+ }
117
+ }
118
+
119
+ const config: Partial<HubConfig> = {}
120
+ const saved = migrated.config as { enabled?: unknown; announceToAgent?: unknown; dotModelColor?: unknown; dotUserColor?: unknown; showUseCount?: unknown; showUseTime?: unknown; showGroupSummary?: unknown } | undefined
121
+ if (typeof saved === 'object' && saved !== null) {
122
+ if (typeof saved.enabled === 'boolean') config.enabled = saved.enabled
123
+ if (typeof saved.announceToAgent === 'boolean') config.announceToAgent = saved.announceToAgent
124
+ if (typeof saved.dotModelColor === 'string' && saved.dotModelColor !== '') config.dotModelColor = saved.dotModelColor
125
+ if (typeof saved.dotUserColor === 'string' && saved.dotUserColor !== '') config.dotUserColor = saved.dotUserColor
126
+ if (typeof saved.showUseCount === 'boolean') config.showUseCount = saved.showUseCount
127
+ if (typeof saved.showUseTime === 'boolean') config.showUseTime = saved.showUseTime
128
+ if (typeof saved.showGroupSummary === 'boolean') config.showGroupSummary = saved.showGroupSummary
129
+ }
130
+
131
+ const tagsById = new Map<string, SkillTag>()
132
+ if (Array.isArray(migrated.tags)) {
133
+ for (const entry of migrated.tags as unknown[]) {
134
+ const tag = entry as { id?: unknown; name?: unknown; skillNames?: unknown; default?: unknown } | null
135
+ if (tag !== null && typeof tag === 'object' && typeof tag.id === 'string' && typeof tag.name === 'string' && Array.isArray(tag.skillNames)) {
136
+ tagsById.set(tag.id, {
137
+ id: tag.id,
138
+ name: tag.name,
139
+ skillNames: tag.skillNames.filter((n): n is string => typeof n === 'string'),
140
+ ...(tag.default === true ? { default: true } : {}),
141
+ })
142
+ }
143
+ }
144
+ }
145
+
146
+ const sourcesByRepo = new Map<string, SourceRecord>()
147
+ if (Array.isArray(migrated.sources)) {
148
+ for (const entry of migrated.sources as unknown[]) {
149
+ const source = entry as { repo?: unknown; ref?: unknown; root?: unknown; commitSha?: unknown; skills?: unknown; manifest?: unknown } | null
150
+ if (source !== null && typeof source === 'object' && typeof source.repo === 'string' && source.repo !== '' && Array.isArray(source.skills)) {
151
+ const manifest = source.manifest as Record<string, unknown> | undefined
152
+ const rawRoot = typeof source.root === 'string' && source.root !== '' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : 'skills'
153
+ sourcesByRepo.set(source.repo, {
154
+ repo: source.repo,
155
+ ...(typeof source.ref === 'string' && source.ref !== '' ? { ref: source.ref } : {}),
156
+ root: rawRoot,
157
+ commitSha: typeof source.commitSha === 'string' ? source.commitSha : '',
158
+ skills: source.skills.filter((n): n is string => typeof n === 'string'),
159
+ ...(manifest !== null && typeof manifest === 'object' && !Array.isArray(manifest)
160
+ ? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === 'number')) as Record<string, number> }
161
+ : {}),
162
+ })
163
+ }
164
+ }
165
+ }
166
+
167
+ const marketSources: MarketSourceRecord[] = []
168
+ if (Array.isArray(migrated.marketSources)) {
169
+ for (const entry of migrated.marketSources as unknown[]) {
170
+ const item = entry as { repo?: unknown; ref?: unknown; commitSha?: unknown } | null
171
+ if (item !== null && typeof item === 'object' && typeof item.repo === 'string' && item.repo !== '' && !marketSources.some((s) => s.repo === item.repo)) {
172
+ marketSources.push({
173
+ repo: item.repo,
174
+ ...(typeof item.ref === 'string' && item.ref !== '' ? { ref: item.ref } : {}),
175
+ ...(typeof item.commitSha === 'string' && item.commitSha !== '' ? { commitSha: item.commitSha } : {}),
176
+ })
177
+ }
178
+ }
179
+ }
180
+
181
+ const trashByName = new Map<string, TrashEntry>()
182
+ if (Array.isArray(migrated.trash)) {
183
+ for (const entry of migrated.trash as unknown[]) {
184
+ const item = entry as { name?: unknown; path?: unknown; movedAt?: unknown; sourcePath?: unknown } | null
185
+ if (item !== null && typeof item === 'object' && typeof item.name === 'string' && typeof item.path === 'string') {
186
+ const origin = (item as Record<string, unknown>).origin as { repo?: unknown; root?: unknown; ref?: unknown; commitSha?: unknown } | undefined
187
+ const tagIds = (item as Record<string, unknown>).tagIds
188
+ trashByName.set(item.name, {
189
+ name: item.name,
190
+ path: item.path,
191
+ movedAt: typeof item.movedAt === 'number' ? item.movedAt : 0,
192
+ ...(typeof item.sourcePath === 'string' && item.sourcePath !== '' ? { sourcePath: item.sourcePath } : {}),
193
+ ...(origin !== null && typeof origin === 'object' && typeof origin.repo === 'string' && origin.repo !== '' && typeof origin.root === 'string' && origin.root !== '' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(origin.root)
194
+ ? {
195
+ origin: {
196
+ repo: origin.repo,
197
+ root: origin.root,
198
+ ...(typeof origin.ref === 'string' && origin.ref !== '' ? { ref: origin.ref } : {}),
199
+ commitSha: typeof origin.commitSha === 'string' ? origin.commitSha : '',
200
+ },
201
+ }
202
+ : {}),
203
+ ...(Array.isArray(tagIds) ? { tagIds: tagIds.filter((id): id is string => typeof id === 'string') } : {}),
204
+ })
205
+ }
206
+ }
207
+ }
208
+
209
+ let skillStats: SkillStatsCheckpoint | undefined = undefined
210
+ const savedStats = migrated.skillStats as Partial<SkillStatsCheckpoint> | null | undefined
211
+ if (savedStats !== null && typeof savedStats === 'object'
212
+ && typeof savedStats.frozenBefore === 'number' && typeof savedStats.lastFullReconcile === 'number'
213
+ && typeof savedStats.windowDays === 'number'
214
+ && typeof savedStats.frozenSessions === 'object' && savedStats.frozenSessions !== null) {
215
+ // Validate entry shapes too: a corrupt bucket degrades to a fresh
216
+ // checkpoint (one extra full reconciliation), never to bad counts.
217
+ const sessions: SkillStatsCheckpoint['frozenSessions'] = {}
218
+ for (const [id, entry] of Object.entries(savedStats.frozenSessions)) {
219
+ if (entry === null || typeof entry !== 'object' || typeof entry.createdAt !== 'number'
220
+ || typeof entry.counts !== 'object' || entry.counts === null) continue
221
+ const counts: Record<string, { count: number; lastUsed: number }> = {}
222
+ for (const [name, stat] of Object.entries(entry.counts)) {
223
+ if (stat !== null && typeof stat === 'object'
224
+ && typeof (stat as { count?: unknown }).count === 'number'
225
+ && typeof (stat as { lastUsed?: unknown }).lastUsed === 'number') {
226
+ counts[name] = { count: (stat as { count: number }).count, lastUsed: (stat as { lastUsed: number }).lastUsed }
227
+ }
228
+ }
229
+ sessions[id] = { createdAt: entry.createdAt, counts }
230
+ }
231
+ const rawTotals = (savedStats as { lastTotals?: unknown }).lastTotals
232
+ const lastTotals = Array.isArray(rawTotals)
233
+ ? rawTotals.filter((entry): entry is { name: string; count: number; lastUsed?: number } =>
234
+ entry !== null && typeof entry === 'object'
235
+ && typeof (entry as { name?: unknown }).name === 'string'
236
+ && typeof (entry as { count?: unknown }).count === 'number').map((entry) => ({
237
+ name: (entry as { name: string }).name,
238
+ count: (entry as { count: number }).count,
239
+ ...(typeof (entry as { lastUsed?: unknown }).lastUsed === 'number' ? { lastUsed: (entry as { lastUsed: number }).lastUsed } : {}),
240
+ }))
241
+ : undefined
242
+ skillStats = {
243
+ windowDays: savedStats.windowDays,
244
+ frozenBefore: savedStats.frozenBefore,
245
+ frozenSessions: sessions,
246
+ lastFullReconcile: savedStats.lastFullReconcile,
247
+ ...(lastTotals !== undefined ? { lastTotals } : {}),
248
+ }
249
+ }
250
+
251
+ let marketStats: MarketStatsSnapshot | undefined = undefined
252
+ const savedMarketStats = migrated.marketStats as Partial<MarketStatsSnapshot> | null | undefined
253
+ if (savedMarketStats !== null && typeof savedMarketStats === 'object'
254
+ && typeof savedMarketStats.fetchedAt === 'number'
255
+ && typeof savedMarketStats.stats === 'object' && savedMarketStats.stats !== null) {
256
+ // A corrupt bucket degrades to no snapshot (one cold fetch), never to bad numbers.
257
+ const stats: MarketStatsSnapshot['stats'] = {}
258
+ for (const [repo, entry] of Object.entries(savedMarketStats.stats)) {
259
+ if (entry !== null && typeof entry === 'object'
260
+ && typeof (entry as { stars?: unknown }).stars === 'number'
261
+ && typeof (entry as { downloads?: unknown }).downloads === 'number') {
262
+ stats[repo] = { stars: (entry as { stars: number }).stars, downloads: (entry as { downloads: number }).downloads }
263
+ }
264
+ }
265
+ marketStats = { fetchedAt: savedMarketStats.fetchedAt, stats }
266
+ }
267
+
268
+ const collectionOrder = cleanStringList(migrated.collectionOrder)
269
+ const sourceGroupOrder = cleanStringList(migrated.sourceGroupOrder)
270
+
271
+ return { entries, config, tagsById, sourcesByRepo, marketSources, trashByName, skillStats, marketStats, collectionOrder, sourceGroupOrder }
272
+ }
@@ -0,0 +1,43 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+ import type { DisabledSkill, HubConfig, MarketSourceRecord, MarketStatsSnapshot, SkillStatsCheckpoint, SkillTag, SourceRecord, TrashEntry } from '../protocol.ts'
4
+
5
+ /** 默认场景名(系统预置的兜底场景,新技能自动归入)。 */
6
+ export const DEFAULT_SCENE_NAME = '通用'
7
+
8
+ /** Wire shape persisted on disk. */
9
+ export interface StoreFile {
10
+ version: number
11
+ disabled: DisabledSkill[]
12
+ /** Runtime configuration edited from the web settings card (hub-owned, not settings-service). */
13
+ config?: Partial<HubConfig>
14
+ /** User-defined tag groups (pure organization; skill files untouched). */
15
+ tags?: SkillTag[]
16
+ /** Upstream source tracking records (repo + commit snapshot). */
17
+ sources?: SourceRecord[]
18
+ /** User-added market sources (owner/repo slugs + optional pinned ref). */
19
+ marketSources?: MarketSourceRecord[]
20
+ /** Trashed skills (removed after upstream deletion, restorable). */
21
+ trash?: TrashEntry[]
22
+ /** Usage-statistics incremental-scan checkpoint (frozen watermark + totals). */
23
+ skillStats?: SkillStatsCheckpoint
24
+ /** Market-stats snapshot (stars/downloads per repo, hourly TTL). */
25
+ marketStats?: MarketStatsSnapshot
26
+ /** Drag-reorder: collection name order for 来源分组 */
27
+ collectionOrder?: string[]
28
+ /** Drag-reorder: 来源顶层分组整体顺序(project / col:xxx / uncategorized-source) */
29
+ sourceGroupOrder?: string[]
30
+ }
31
+
32
+ /** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
33
+ export function dshHome(): string {
34
+ return process.env.DSH_HOME ?? join(homedir(), '.dsh')
35
+ }
36
+
37
+ /** Resolve the sidecar state path (injectable in tests). */
38
+ export function statePath(home = dshHome()): string {
39
+ return join(home, 'dsh-skill-hub.json')
40
+ }
41
+
42
+ /** Current sidecar schema version. Bump on breaking shape changes and add a migration below. */
43
+ export const STORE_VERSION = 4