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.
- package/README.md +40 -50
- package/README.zh.md +38 -48
- package/lib/client.js +2209 -1820
- package/lib/client.js.map +1 -1
- package/lib/index.js +653 -419
- package/lib/types/client/SkillHubSettingsCard.d.ts +1 -0
- package/lib/types/client/api.d.ts +12 -6
- package/lib/types/client/helpers.d.ts +0 -1
- package/lib/types/client/locales/common.d.ts +52 -0
- package/lib/types/client/locales/detail.d.ts +16 -0
- package/lib/types/client/locales/market.d.ts +73 -0
- package/lib/types/client/locales/settings.d.ts +41 -0
- package/lib/types/client/locales/skills.d.ts +65 -0
- package/lib/types/client/locales/sources.d.ts +35 -0
- package/lib/types/client/locales.d.ts +124 -118
- package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
- package/lib/types/client/panel/MarketView.d.ts +1 -0
- package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
- package/lib/types/client/panel/hooks/shared.d.ts +72 -0
- package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
- package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
- package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
- package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
- package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
- package/lib/types/client/panel/market-format.d.ts +6 -0
- package/lib/types/client/panel/useSkillHub.d.ts +45 -73
- package/lib/types/client/settings-card.d.ts +20 -0
- package/lib/types/client/settings-form.d.ts +6 -0
- package/lib/types/protocol/api.d.ts +38 -0
- package/lib/types/protocol/catalog.d.ts +155 -0
- package/lib/types/protocol/config.d.ts +113 -0
- package/lib/types/protocol/groups.d.ts +95 -0
- package/lib/types/protocol/market.d.ts +94 -0
- package/lib/types/protocol/repo.d.ts +107 -0
- package/lib/types/protocol/sources.d.ts +139 -0
- package/lib/types/protocol/stats.d.ts +46 -0
- package/lib/types/protocol.d.ts +13 -743
- package/lib/types/repo/github-client.d.ts +42 -0
- package/lib/types/repo.d.ts +6 -17
- package/lib/types/routes/catalog.d.ts +7 -0
- package/lib/types/routes/config.d.ts +7 -0
- package/lib/types/routes/groups.d.ts +7 -0
- package/lib/types/routes/helpers.d.ts +145 -0
- package/lib/types/routes/market.d.ts +8 -0
- package/lib/types/routes/repo-import.d.ts +7 -0
- package/lib/types/routes/route-state.d.ts +55 -0
- package/lib/types/routes/sources.d.ts +8 -0
- package/lib/types/routes.d.ts +5 -34
- package/lib/types/stats.d.ts +3 -2
- package/lib/types/store/errors.d.ts +10 -0
- package/lib/types/store/migrate.d.ts +44 -0
- package/lib/types/store/paths.d.ts +32 -0
- package/lib/types/store/store.d.ts +133 -0
- package/lib/types/store.d.ts +7 -146
- package/package.json +28 -27
- package/src/client/SkillHubSettingsCard.tsx +17 -3
- package/src/client/api.ts +101 -195
- package/src/client/helpers.ts +10 -1
- package/src/client/locales/common.ts +102 -0
- package/src/client/locales/detail.ts +30 -0
- package/src/client/locales/market.ts +144 -0
- package/src/client/locales/settings.ts +80 -0
- package/src/client/locales/skills.ts +128 -0
- package/src/client/locales/sources.ts +68 -0
- package/src/client/locales.ts +26 -512
- package/src/client/panel/MarketSourceRow.tsx +108 -0
- package/src/client/panel/MarketView.tsx +12 -253
- package/src/client/panel/RepoScanCard.tsx +171 -0
- package/src/client/panel/hooks/shared.ts +81 -0
- package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
- package/src/client/panel/hooks/useGroupFlow.ts +240 -0
- package/src/client/panel/hooks/useMarketFlow.ts +421 -0
- package/src/client/panel/hooks/useMetaFlow.ts +58 -0
- package/src/client/panel/hooks/useSourceFlow.ts +193 -0
- package/src/client/panel/market-format.ts +22 -0
- package/src/client/panel/useSkillHub.ts +141 -1032
- package/src/client/settings-card.tsx +51 -0
- package/src/client/settings-form.ts +18 -0
- package/src/index.ts +14 -4
- package/src/protocol/api.ts +38 -0
- package/src/protocol/catalog.ts +168 -0
- package/src/protocol/config.ts +145 -0
- package/src/protocol/groups.ts +106 -0
- package/src/protocol/market.ts +100 -0
- package/src/protocol/repo.ts +104 -0
- package/src/protocol/sources.ts +146 -0
- package/src/protocol/stats.ts +42 -0
- package/src/protocol.ts +83 -809
- package/src/repo/github-client.ts +131 -0
- package/src/repo.test.ts +24 -1
- package/src/repo.ts +50 -170
- package/src/routes/catalog.ts +309 -0
- package/src/routes/config.ts +119 -0
- package/src/routes/groups.ts +133 -0
- package/src/routes/helpers.ts +489 -0
- package/src/routes/market.ts +255 -0
- package/src/routes/repo-import.ts +267 -0
- package/src/routes/route-state.ts +89 -0
- package/src/routes/sources.ts +318 -0
- package/src/routes.test.ts +20 -0
- package/src/routes.ts +21 -1699
- package/src/stats.test.ts +58 -3
- package/src/stats.ts +45 -16
- package/src/store/errors.ts +14 -0
- package/src/store/migrate.ts +272 -0
- package/src/store/paths.ts +43 -0
- package/src/store/store.ts +532 -0
- package/src/store.test.ts +18 -0
- 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(
|
|
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
|
-
|
|
157
|
-
let counted: Map<string, InvocationStat>
|
|
174
|
+
const countedList = await mapConcurrent(sessions, SCAN_READ_CONCURRENCY, async (record) => {
|
|
158
175
|
try {
|
|
159
|
-
|
|
176
|
+
return countSkillInvocations((await query.readSession(record.header.id)).events)
|
|
160
177
|
} catch {
|
|
161
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
201
|
+
return countSkillInvocations((await query.readSession(record.header.id)).events)
|
|
181
202
|
} catch {
|
|
182
|
-
|
|
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
|
|
220
|
-
*
|
|
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
|
-
|
|
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
|
|
252
|
-
//
|
|
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
|
|
284
|
+
.then(({ stats }) => {
|
|
257
285
|
cached = stats
|
|
258
286
|
cachedAt = now()
|
|
259
287
|
lastScanDurationMs = Math.max(0, cachedAt - startedAt)
|
|
260
|
-
|
|
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
|