dsh-skill-hub 0.3.6 → 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 (122) hide show
  1. package/README.md +40 -50
  2. package/README.zh.md +38 -48
  3. package/lib/client.js +2569 -1646
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +988 -400
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -2
  7. package/lib/types/client/api.d.ts +15 -5
  8. package/lib/types/client/helpers.d.ts +10 -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 +147 -111
  16. package/lib/types/client/panel/DisabledRow.d.ts +2 -0
  17. package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
  18. package/lib/types/client/panel/MarketView.d.ts +1 -0
  19. package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
  20. package/lib/types/client/panel/SkillDetailView.d.ts +5 -1
  21. package/lib/types/client/panel/dialogs.d.ts +19 -0
  22. package/lib/types/client/panel/hooks/shared.d.ts +72 -0
  23. package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
  24. package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
  25. package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
  26. package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
  27. package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
  28. package/lib/types/client/panel/market-format.d.ts +6 -0
  29. package/lib/types/client/panel/useSkillHub.d.ts +54 -71
  30. package/lib/types/client/settings-card.d.ts +20 -17
  31. package/lib/types/client/settings-form.d.ts +6 -1
  32. package/lib/types/protocol/api.d.ts +38 -0
  33. package/lib/types/protocol/catalog.d.ts +155 -0
  34. package/lib/types/protocol/config.d.ts +113 -0
  35. package/lib/types/protocol/groups.d.ts +95 -0
  36. package/lib/types/protocol/market.d.ts +94 -0
  37. package/lib/types/protocol/repo.d.ts +107 -0
  38. package/lib/types/protocol/sources.d.ts +139 -0
  39. package/lib/types/protocol/stats.d.ts +46 -0
  40. package/lib/types/protocol.d.ts +13 -720
  41. package/lib/types/repo/github-client.d.ts +42 -0
  42. package/lib/types/repo.d.ts +9 -22
  43. package/lib/types/routes/catalog.d.ts +7 -0
  44. package/lib/types/routes/config.d.ts +7 -0
  45. package/lib/types/routes/groups.d.ts +7 -0
  46. package/lib/types/routes/helpers.d.ts +145 -0
  47. package/lib/types/routes/market.d.ts +8 -0
  48. package/lib/types/routes/repo-import.d.ts +7 -0
  49. package/lib/types/routes/route-state.d.ts +55 -0
  50. package/lib/types/routes/sources.d.ts +8 -0
  51. package/lib/types/routes.d.ts +5 -34
  52. package/lib/types/skillfs.d.ts +13 -0
  53. package/lib/types/stats.d.ts +3 -2
  54. package/lib/types/store/errors.d.ts +10 -0
  55. package/lib/types/store/migrate.d.ts +44 -0
  56. package/lib/types/store/paths.d.ts +32 -0
  57. package/lib/types/store/store.d.ts +133 -0
  58. package/lib/types/store.d.ts +7 -146
  59. package/package.json +30 -28
  60. package/src/client/SkillHubSettingsCard.tsx +17 -5
  61. package/src/client/api.ts +108 -184
  62. package/src/client/helpers.ts +50 -1
  63. package/src/client/locales/common.ts +102 -0
  64. package/src/client/locales/detail.ts +30 -0
  65. package/src/client/locales/market.ts +144 -0
  66. package/src/client/locales/settings.ts +80 -0
  67. package/src/client/locales/skills.ts +128 -0
  68. package/src/client/locales/sources.ts +68 -0
  69. package/src/client/locales.ts +26 -452
  70. package/src/client/panel/DisabledRow.tsx +22 -7
  71. package/src/client/panel/MarketSourceRow.tsx +108 -0
  72. package/src/client/panel/MarketView.tsx +12 -246
  73. package/src/client/panel/RepoScanCard.tsx +171 -0
  74. package/src/client/panel/ScenesView.tsx +1 -1
  75. package/src/client/panel/SkillDetailView.tsx +35 -11
  76. package/src/client/panel/SkillHubPanel.tsx +42 -4
  77. package/src/client/panel/SkillRow.tsx +5 -2
  78. package/src/client/panel/SourcesView.tsx +2 -17
  79. package/src/client/panel/dialogs.tsx +61 -0
  80. package/src/client/panel/hooks/shared.ts +81 -0
  81. package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
  82. package/src/client/panel/hooks/useGroupFlow.ts +240 -0
  83. package/src/client/panel/hooks/useMarketFlow.ts +421 -0
  84. package/src/client/panel/hooks/useMetaFlow.ts +58 -0
  85. package/src/client/panel/hooks/useSourceFlow.ts +193 -0
  86. package/src/client/panel/market-format.ts +22 -0
  87. package/src/client/panel/panel.module.css +1 -8
  88. package/src/client/panel/useSkillHub.ts +142 -948
  89. package/src/client/settings-card.tsx +51 -42
  90. package/src/client/settings-form.ts +19 -5
  91. package/src/index.ts +14 -4
  92. package/src/protocol/api.ts +38 -0
  93. package/src/protocol/catalog.ts +168 -0
  94. package/src/protocol/config.ts +145 -0
  95. package/src/protocol/groups.ts +106 -0
  96. package/src/protocol/market.ts +100 -0
  97. package/src/protocol/repo.ts +104 -0
  98. package/src/protocol/sources.ts +146 -0
  99. package/src/protocol/stats.ts +42 -0
  100. package/src/protocol.ts +83 -775
  101. package/src/repo/github-client.ts +131 -0
  102. package/src/repo.test.ts +24 -1
  103. package/src/repo.ts +69 -147
  104. package/src/routes/catalog.ts +309 -0
  105. package/src/routes/config.ts +119 -0
  106. package/src/routes/groups.ts +133 -0
  107. package/src/routes/helpers.ts +489 -0
  108. package/src/routes/market.ts +255 -0
  109. package/src/routes/repo-import.ts +267 -0
  110. package/src/routes/route-state.ts +89 -0
  111. package/src/routes/sources.ts +318 -0
  112. package/src/routes.test.ts +42 -0
  113. package/src/routes.ts +21 -1528
  114. package/src/skillfs.ts +188 -3
  115. package/src/stats.test.ts +58 -3
  116. package/src/stats.ts +45 -16
  117. package/src/store/errors.ts +14 -0
  118. package/src/store/migrate.ts +272 -0
  119. package/src/store/paths.ts +43 -0
  120. package/src/store/store.ts +532 -0
  121. package/src/store.test.ts +18 -0
  122. package/src/store.ts +7 -737
package/src/skillfs.ts CHANGED
@@ -171,10 +171,21 @@ export function parseFrontmatter(text: string): { value: FrontmatterValue } | {
171
171
  const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n([\s\S]*))?$/.exec(text)
172
172
  if (match === null) return { error: 'missing YAML frontmatter (--- block)' }
173
173
  let data: unknown
174
+ let rawFrontmatter = match[1]
174
175
  try {
175
- data = load(match[1])
176
+ data = load(rawFrontmatter)
176
177
  } catch (error) {
177
- return { error: 'invalid YAML frontmatter: ' + (error instanceof Error ? error.message : String(error)) }
178
+ const repaired = repairFrontmatterScalarFields(rawFrontmatter)
179
+ if (repaired !== null) {
180
+ try {
181
+ data = load(repaired)
182
+ rawFrontmatter = repaired
183
+ } catch {
184
+ return { error: 'invalid YAML frontmatter: ' + (error instanceof Error ? error.message : String(error)) }
185
+ }
186
+ } else {
187
+ return { error: 'invalid YAML frontmatter: ' + (error instanceof Error ? error.message : String(error)) }
188
+ }
178
189
  }
179
190
  if (typeof data !== 'object' || data === null || Array.isArray(data)) {
180
191
  return { error: 'frontmatter must be a YAML mapping' }
@@ -200,6 +211,125 @@ export function parseFrontmatter(text: string): { value: FrontmatterValue } | {
200
211
  return { value: { name, description, ...(whenToUse !== undefined ? { whenToUse } : {}), invocation, content: (match[2] ?? '').trim() } }
201
212
  }
202
213
 
214
+ /**
215
+ * Repair bare scalar fields that contain an unquoted colon (e.g. `description: Build for AWS: ECS`)
216
+ * or an invalid flow-like scalar (`@`, `` ` ``, `[`, `{`). Mirrors codex `repair_frontmatter_scalar_fields`:
217
+ * line-oriented, only touches lines where quoting makes the YAML valid, and preserves block scalars.
218
+ * Returns the repaired frontmatter string when at least one line was quoted, otherwise null.
219
+ */
220
+ function repairFrontmatterScalarFields(frontmatter: string): string | null {
221
+ let changed = false
222
+ let blockScalarIndent: number | null = null
223
+ const repairedLines: string[] = []
224
+ for (const line of frontmatter.split('\n')) {
225
+ const indent = line.search(/[^ ]/)
226
+ const effectiveIndent = indent === -1 ? line.length : indent
227
+ if (blockScalarIndent !== null) {
228
+ if (line.trim() === '' || effectiveIndent > blockScalarIndent) {
229
+ repairedLines.push(line)
230
+ continue
231
+ }
232
+ blockScalarIndent = null
233
+ }
234
+ const colonIndex = line.indexOf(':')
235
+ if (colonIndex === -1) {
236
+ repairedLines.push(line)
237
+ continue
238
+ }
239
+ const key = line.slice(0, colonIndex)
240
+ const value = line.slice(colonIndex + 1)
241
+ if (key.trim() === '' || value.length === 0 || !/^\s/.test(value)) {
242
+ repairedLines.push(line)
243
+ continue
244
+ }
245
+ const trimmedStart = value.trimStart()
246
+ const leadingWhitespace = value.slice(0, value.length - trimmedStart.length)
247
+ let scalar = trimmedStart
248
+ let comment = ''
249
+ // Split trailing `# comment` only when `#` is preceded by whitespace and
250
+ // followed by whitespace/end (YAML comment rule). This preserves `#1` as content.
251
+ for (let idx = 0; idx < trimmedStart.length; idx += 1) {
252
+ if (trimmedStart[idx] === '#') {
253
+ const prev = idx === 0 ? ' ' : trimmedStart[idx - 1]
254
+ const next = idx + 1 < trimmedStart.length ? trimmedStart[idx + 1] : ' '
255
+ if (/\s/.test(prev) && /\s/.test(next)) {
256
+ const commentStart = trimmedStart.slice(0, idx).trimEnd().length
257
+ scalar = trimmedStart.slice(0, commentStart)
258
+ comment = trimmedStart.slice(commentStart)
259
+ break
260
+ }
261
+ }
262
+ }
263
+ scalar = scalar.trimEnd()
264
+ if (scalar === '') {
265
+ repairedLines.push(line)
266
+ continue
267
+ }
268
+ const firstChar = scalar[0]
269
+ if (firstChar === '|' || firstChar === '>') {
270
+ blockScalarIndent = effectiveIndent
271
+ repairedLines.push(line)
272
+ continue
273
+ }
274
+ if (firstChar === "'" || firstChar === '"') {
275
+ repairedLines.push(line)
276
+ continue
277
+ }
278
+ let hasColonSeparator = false
279
+ for (let i = 0; i < scalar.length - 1; i += 1) {
280
+ if (scalar[i] === ':' && /\s/.test(scalar[i + 1])) {
281
+ hasColonSeparator = true
282
+ break
283
+ }
284
+ }
285
+ let invalidFlowLike = false
286
+ if ((firstChar === '[' || firstChar === '{' || firstChar === '@' || firstChar === '`')) {
287
+ try {
288
+ load(scalar)
289
+ } catch {
290
+ invalidFlowLike = true
291
+ }
292
+ }
293
+ if (!hasColonSeparator && !invalidFlowLike) {
294
+ repairedLines.push(line)
295
+ continue
296
+ }
297
+ const quotedScalar = "'" + scalar.replace(/'/g, "''") + "'"
298
+ repairedLines.push(key + ':' + leadingWhitespace + quotedScalar + comment)
299
+ changed = true
300
+ }
301
+ return changed ? repairedLines.join('\n') : null
302
+ }
303
+
304
+ /** Try to repair a SKILL.md's frontmatter in place (unquoted colon etc.). Returns the new file text or null when not fixable. */
305
+ export function repairFrontmatterFileText(text: string): string | null {
306
+ const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n([\s\S]*))?$/.exec(text)
307
+ if (match === null) return null
308
+ const repaired = repairFrontmatterScalarFields(match[1])
309
+ if (repaired === null) return null
310
+ try {
311
+ load(repaired)
312
+ } catch {
313
+ return null
314
+ }
315
+ const body = match[2] ?? ''
316
+ return '---\n' + repaired + '\n---' + (body !== '' ? '\n' + body : '')
317
+ }
318
+
319
+ /** Repair one file on disk when its frontmatter is auto-fixable. Returns the new text. */
320
+ export async function fixDiagnosticFile(path: string, home = dshHome()): Promise<string> {
321
+ const root = rootOfPath(path, home)
322
+ if (root === undefined) throw new TypeError('not a hub writable skill path: ' + path)
323
+ const text = await readFile(path, 'utf8')
324
+ const repairedText = repairFrontmatterFileText(text)
325
+ if (repairedText === null) throw new TypeError('diagnostic is not auto-fixable: ' + path)
326
+ // Validate the repaired text parses as a legal skill (prevents writing a still-broken file).
327
+ const parsed = parseFrontmatter(repairedText)
328
+ if ('error' in parsed) throw new TypeError('repaired frontmatter still invalid: ' + parsed.error)
329
+ await writeFile(path, repairedText, 'utf8')
330
+ return path
331
+ }
332
+
203
333
  /** Official boolean grammar: true/false, 1/0, and the common string spellings. */
204
334
  function frontmatterBoolean(data: Record<string, unknown>, key: string): boolean | undefined {
205
335
  if (!Object.hasOwn(data, key)) return undefined
@@ -262,6 +392,55 @@ export function listSkillEntries(root: WritableRoot, home = dshHome()): Promise<
262
392
  return scanRoot(rootPath(root, home))
263
393
  }
264
394
 
395
+ /** Read UI metadata from `agents/openai.yaml` beside a directory skill (mirrors codex SkillInterface). */
396
+ export async function readSkillInterface(directory: string): Promise<{ displayName?: string; shortDescription?: string; brandColor?: string; iconSmall?: string; iconLarge?: string; defaultPrompt?: string } | undefined> {
397
+ const yamlPath = join(directory, 'agents', 'openai.yaml')
398
+ let text: string
399
+ try {
400
+ text = await readFile(yamlPath, 'utf8')
401
+ } catch {
402
+ return undefined
403
+ }
404
+ let data: unknown
405
+ try {
406
+ data = load(text)
407
+ } catch {
408
+ return undefined
409
+ }
410
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined
411
+ const record = data as Record<string, unknown>
412
+ const iface = (record.interface ?? record) as Record<string, unknown>
413
+ // codex stores interface under top-level `interface` key, but some templates put display_name at top level; support both.
414
+ const ifaceObj = typeof record.interface === 'object' && record.interface !== null && !Array.isArray(record.interface) ? record.interface as Record<string, unknown> : iface
415
+ const cleanStr = (value: unknown, maxLen: number): string | undefined => {
416
+ if (typeof value !== 'string') return undefined
417
+ const cleaned = value.split(/\s+/).join(' ').trim()
418
+ if (cleaned === '' || cleaned.length > maxLen) return undefined
419
+ return cleaned
420
+ }
421
+ const hexColor = (value: unknown): string | undefined => {
422
+ if (typeof value !== 'string') return undefined
423
+ const trimmed = value.trim()
424
+ return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : undefined
425
+ }
426
+ const iconPath = (value: unknown): string | undefined => {
427
+ if (typeof value !== 'string' || value.trim() === '') return undefined
428
+ const trimmed = value.trim().replace(/\\/g, '/')
429
+ if (trimmed.startsWith('/') || trimmed.includes('..')) return undefined
430
+ if (!trimmed.startsWith('assets/')) return undefined
431
+ return trimmed
432
+ }
433
+ const displayName = cleanStr(ifaceObj.display_name, 64)
434
+ const shortDescription = cleanStr(ifaceObj.short_description, 1024)
435
+ const brandColor = hexColor(ifaceObj.brand_color)
436
+ const defaultPrompt = cleanStr(ifaceObj.default_prompt, 1024)
437
+ const iconSmall = iconPath(ifaceObj.icon_small)
438
+ const iconLarge = iconPath(ifaceObj.icon_large)
439
+ const hasAny = displayName !== undefined || shortDescription !== undefined || brandColor !== undefined || iconSmall !== undefined || iconLarge !== undefined || defaultPrompt !== undefined
440
+ if (!hasAny) return undefined
441
+ return { ...(displayName !== undefined ? { displayName } : {}), ...(shortDescription !== undefined ? { shortDescription } : {}), ...(brandColor !== undefined ? { brandColor } : {}), ...(iconSmall !== undefined ? { iconSmall } : {}), ...(iconLarge !== undefined ? { iconLarge } : {}), ...(defaultPrompt !== undefined ? { defaultPrompt } : {}) }
442
+ }
443
+
265
444
  /**
266
445
  * Walk up from cwd (max 32 levels) for a project marker (.dsh or .git);
267
446
  * falls back to cwd itself. The provider roots project skills here.
@@ -298,9 +477,15 @@ export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Pro
298
477
  }
299
478
  const parsed = parseFrontmatter(text)
300
479
  if ('error' in parsed) {
301
- diagnostics.push({ path: entry.path, root, reason: parsed.error })
480
+ const fixable = repairFrontmatterFileText(text) !== null
481
+ diagnostics.push({ path: entry.path, root, reason: parsed.error, ...(fixable ? { fixable: true } : {}) })
302
482
  continue
303
483
  }
484
+ // File was auto-repaired in memory (e.g. unquoted `:`); surface a fixable hint so the user can persist it.
485
+ const repaired = repairFrontmatterFileText(text)
486
+ if (repaired !== null) {
487
+ diagnostics.push({ path: entry.path, root, reason: 'frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)', fixable: true })
488
+ }
304
489
  const { value } = parsed
305
490
  // The provider registers a skill by its discovery path (directory name or
306
491
  // flat file name), so a frontmatter name that diverges from the path makes
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
+ }