dsh-skill-hub 0.3.9 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -264,6 +264,30 @@ function hydrateMigratedState(migrated) {
264
264
  });
265
265
  }
266
266
  }
267
+ /**
268
+ * Validate the cold-path revision bucket: corrupt entries degrade to a
269
+ * re-read (the revision simply misses), never to bad counts.
270
+ */
271
+ function cleanColdRevisions(raw) {
272
+ if (raw === null || typeof raw !== "object") return {};
273
+ const revisions = {};
274
+ for (const [id, entry] of Object.entries(raw)) {
275
+ if (entry === null || typeof entry !== "object") continue;
276
+ const { rev, createdAt, counts } = entry;
277
+ if (typeof rev !== "string" || typeof createdAt !== "number" || counts === null || typeof counts !== "object") continue;
278
+ const clean = {};
279
+ for (const [name, stat] of Object.entries(counts)) if (stat !== null && typeof stat === "object" && typeof stat.count === "number" && typeof stat.lastUsed === "number") clean[name] = {
280
+ count: stat.count,
281
+ lastUsed: stat.lastUsed
282
+ };
283
+ revisions[id] = {
284
+ rev,
285
+ createdAt,
286
+ counts: clean
287
+ };
288
+ }
289
+ return { coldRevisions: revisions };
290
+ }
267
291
  let skillStats = void 0;
268
292
  const savedStats = migrated.skillStats;
269
293
  if (savedStats !== null && typeof savedStats === "object" && typeof savedStats.frozenBefore === "number" && typeof savedStats.lastFullReconcile === "number" && typeof savedStats.windowDays === "number" && typeof savedStats.frozenSessions === "object" && savedStats.frozenSessions !== null) {
@@ -291,7 +315,8 @@ function hydrateMigratedState(migrated) {
291
315
  frozenBefore: savedStats.frozenBefore,
292
316
  frozenSessions: sessions,
293
317
  lastFullReconcile: savedStats.lastFullReconcile,
294
- ...lastTotals !== void 0 ? { lastTotals } : {}
318
+ ...lastTotals !== void 0 ? { lastTotals } : {},
319
+ ...cleanColdRevisions(savedStats.coldRevisions)
295
320
  };
296
321
  }
297
322
  let marketStats = void 0;
@@ -757,7 +782,8 @@ var SkillHubStore = class {
757
782
  return this.skillStats !== void 0 ? {
758
783
  ...this.skillStats,
759
784
  frozenSessions: { ...this.skillStats.frozenSessions },
760
- ...this.skillStats.lastTotals !== void 0 ? { lastTotals: [...this.skillStats.lastTotals] } : {}
785
+ ...this.skillStats.lastTotals !== void 0 ? { lastTotals: [...this.skillStats.lastTotals] } : {},
786
+ ...this.skillStats.coldRevisions !== void 0 ? { coldRevisions: { ...this.skillStats.coldRevisions } } : {}
761
787
  } : void 0;
762
788
  }
763
789
  /** Persist a usage-statistics checkpoint (after every completed scan; cadence follows the scan TTL). */
@@ -768,7 +794,8 @@ var SkillHubStore = class {
768
794
  frozenBefore: state.frozenBefore,
769
795
  frozenSessions: { ...state.frozenSessions },
770
796
  lastFullReconcile: state.lastFullReconcile,
771
- ...state.lastTotals !== void 0 ? { lastTotals: [...state.lastTotals] } : {}
797
+ ...state.lastTotals !== void 0 ? { lastTotals: [...state.lastTotals] } : {},
798
+ ...state.coldRevisions !== void 0 ? { coldRevisions: { ...state.coldRevisions } } : {}
772
799
  };
773
800
  await this.persist();
774
801
  }
@@ -2783,10 +2810,21 @@ function catalogRoutes(deps) {
2783
2810
  });
2784
2811
  return;
2785
2812
  }
2813
+ const display = configOf(deps);
2814
+ if (display.showUseCount === false && display.showUseTime === false && display.showGroupSummary === false) {
2815
+ writeJson(res, 200, {
2816
+ ok: true,
2817
+ available: false,
2818
+ stats: []
2819
+ });
2820
+ return;
2821
+ }
2822
+ const stats = await deps.stats();
2786
2823
  writeJson(res, 200, {
2787
2824
  ok: true,
2788
2825
  available: true,
2789
- stats: await deps.stats()
2826
+ ...deps.stats.source !== void 0 ? { source: deps.stats.source } : {},
2827
+ stats
2790
2828
  });
2791
2829
  }
2792
2830
  }
@@ -3981,6 +4019,55 @@ function makeRoutes(deps) {
3981
4019
  /** Fallback freeze horizon when no rolling window is configured (14 days). */
3982
4020
  const STATS_FREEZE_AFTER_MS = 336 * 60 * 60 * 1e3;
3983
4021
  const DAY_MS = 1440 * 60 * 1e3;
4022
+ /**
4023
+ * Adapt a host persistence service to {@link SessionPersistenceLike}.
4024
+ * Shape-checked only (no I/O): returns undefined when the value is not a
4025
+ * usable seam, so callers fall back to the query path. The adapter re-resolves
4026
+ * nothing and holds no state — a replaced host service surfaces as ordinary
4027
+ * per-call failures, which scans already tolerate.
4028
+ */
4029
+ function asPersistenceSeam(service) {
4030
+ if (service === null || typeof service !== "object") return void 0;
4031
+ const { list, open } = service;
4032
+ if (typeof list !== "function" || typeof open !== "function") return void 0;
4033
+ const listFn = list.bind(service);
4034
+ const openFn = open.bind(service);
4035
+ return {
4036
+ list: async () => {
4037
+ const rows = await listFn();
4038
+ if (!Array.isArray(rows)) throw new Error("persistence list shape mismatch");
4039
+ return rows.map((row) => {
4040
+ const record = row;
4041
+ const id = record.header?.id;
4042
+ if (typeof id !== "string" && typeof id !== "number") throw new Error("persistence snapshot shape mismatch");
4043
+ const created = record.header?.createdAt;
4044
+ return {
4045
+ header: {
4046
+ id,
4047
+ ...typeof created === "number" ? { createdAt: created } : {}
4048
+ },
4049
+ revision: String(record.revision)
4050
+ };
4051
+ });
4052
+ },
4053
+ open: async (id, access) => {
4054
+ const raw = await openFn(id, access);
4055
+ if (raw === null || typeof raw !== "object") throw new Error("persistence handle shape mismatch");
4056
+ const { read, close } = raw;
4057
+ if (typeof read !== "function" || typeof close !== "function") throw new Error("persistence handle shape mismatch");
4058
+ const readFn = read.bind(raw);
4059
+ const closeFn = close.bind(raw);
4060
+ return {
4061
+ read: async () => {
4062
+ const events = (await readFn(0, void 0))?.events;
4063
+ if (!Array.isArray(events)) throw new Error("persistence read shape mismatch");
4064
+ return { events };
4065
+ },
4066
+ close: () => closeFn()
4067
+ };
4068
+ }
4069
+ };
4070
+ }
3984
4071
  /** Collect per-skill invocation counts and last-used times from one session. */
3985
4072
  function countSkillInvocations(events) {
3986
4073
  const stats = /* @__PURE__ */ new Map();
@@ -4029,8 +4116,11 @@ async function mapConcurrent(items, limit, worker) {
4029
4116
  await Promise.all(runners);
4030
4117
  return results;
4031
4118
  }
4032
- /** Max parallel session-log reads per scan (read-only; merge is order-independent). */
4033
- const SCAN_READ_CONCURRENCY = 6;
4119
+ /** Max parallel session-log reads per scan. Reads on the query path restore +
4120
+ * replay-validate a full Session each (structuredClone × 2 + deepFreeze), so
4121
+ * this stays 1: at most one restored log is resident and a heavy corpus costs
4122
+ * wall time, never a memory spike. The cold path reads sequentially anyway. */
4123
+ const SCAN_READ_CONCURRENCY = 1;
4034
4124
  function mergeInto(totals, counted) {
4035
4125
  for (const [name, stat] of counted) {
4036
4126
  const total = totals[name];
@@ -4087,8 +4177,9 @@ async function scan(query, checkpoint, nowMs, windowDays) {
4087
4177
  const counted = countedList[index];
4088
4178
  if (counted === void 0) return;
4089
4179
  const created = record.header.createdAt;
4090
- if (isFrozen(record, cutoff) && counted.size > 0 && typeof created === "number") cache[record.header.id] = {
4091
- createdAt: created,
4180
+ const id = record.header.id;
4181
+ if (counted.size > 0 && (isFrozen(record, cutoff) || !(typeof created === "number" && created > 0))) cache[id] = {
4182
+ createdAt: typeof created === "number" ? created : 0,
4092
4183
  counts: Object.fromEntries(counted)
4093
4184
  };
4094
4185
  if (inWindow(created, windowDays, nowMs)) mergeInto(totals, counted);
@@ -4103,7 +4194,8 @@ async function scan(query, checkpoint, nowMs, windowDays) {
4103
4194
  };
4104
4195
  }
4105
4196
  const recent = {};
4106
- const recentList = await mapConcurrent(sessions.filter((record) => !isFrozen(record, checkpoint.frozenBefore)), SCAN_READ_CONCURRENCY, async (record) => {
4197
+ const frozenIds = checkpoint.frozenSessions;
4198
+ const recentList = await mapConcurrent(sessions.filter((record) => !Object.prototype.hasOwnProperty.call(frozenIds, record.header.id) && !isFrozen(record, checkpoint.frozenBefore)), SCAN_READ_CONCURRENCY, async (record) => {
4107
4199
  try {
4108
4200
  return countSkillInvocations((await query.readSession(record.header.id)).events);
4109
4201
  } catch {
@@ -4116,7 +4208,7 @@ async function scan(query, checkpoint, nowMs, windowDays) {
4116
4208
  }
4117
4209
  const totals = {};
4118
4210
  for (const [id, entry] of Object.entries(checkpoint.frozenSessions)) {
4119
- if (!inWindow(entry.createdAt, windowDays, nowMs)) {
4211
+ if (entry.createdAt > 0 && !inWindow(entry.createdAt, windowDays, nowMs)) {
4120
4212
  delete checkpoint.frozenSessions[id];
4121
4213
  continue;
4122
4214
  }
@@ -4129,6 +4221,69 @@ async function scan(query, checkpoint, nowMs, windowDays) {
4129
4221
  };
4130
4222
  }
4131
4223
  /**
4224
+ * One pass over the corpus through the persistence seam. Reads are strictly
4225
+ * sequential and each handle is closed before the next opens, so at most one
4226
+ * raw log is resident. A session is re-read only when its revision token
4227
+ * changed since the checkpoint; the rolling window is applied from the
4228
+ * listing headers, so out-of-window logs are never even opened. Live sessions
4229
+ * (in-memory, possibly newer than their persisted revision) are read through
4230
+ * `readLive` and never enter the revision cache.
4231
+ *
4232
+ * A `list()` failure rejects — the reader keeps the previous totals, and the
4233
+ * next poll retries. Per-session failures are skipped, never fatal.
4234
+ */
4235
+ async function scanCold(persistence, checkpoint, nowMs, windowDays, liveIds = /* @__PURE__ */ new Set(), readLive = async () => void 0) {
4236
+ const revisions = checkpoint.coldRevisions ?? (checkpoint.coldRevisions = {});
4237
+ const totals = {};
4238
+ const snapshots = await persistence.list();
4239
+ const seen = /* @__PURE__ */ new Set();
4240
+ for (const snapshot of snapshots) {
4241
+ const id = snapshot.header.id;
4242
+ seen.add(id);
4243
+ if (liveIds.has(id)) continue;
4244
+ const created = snapshot.header.createdAt;
4245
+ if (!inWindow(created, windowDays, nowMs)) {
4246
+ delete revisions[id];
4247
+ continue;
4248
+ }
4249
+ const rev = snapshot.revision;
4250
+ const cached = revisions[id];
4251
+ if (cached !== void 0 && cached.rev === rev) {
4252
+ mergeInto(totals, new Map(Object.entries(cached.counts)));
4253
+ continue;
4254
+ }
4255
+ let counted;
4256
+ try {
4257
+ const handle = await persistence.open(snapshot.header.id, "read");
4258
+ try {
4259
+ counted = countSkillInvocations((await handle.read()).events);
4260
+ } finally {
4261
+ await handle.close();
4262
+ }
4263
+ } catch {
4264
+ continue;
4265
+ }
4266
+ revisions[id] = {
4267
+ rev,
4268
+ createdAt: typeof created === "number" ? created : 0,
4269
+ counts: Object.fromEntries(counted)
4270
+ };
4271
+ mergeInto(totals, counted);
4272
+ }
4273
+ for (const id of Object.keys(revisions)) if (!seen.has(id)) delete revisions[id];
4274
+ for (const id of liveIds) {
4275
+ let events;
4276
+ try {
4277
+ events = await readLive(id);
4278
+ } catch {
4279
+ continue;
4280
+ }
4281
+ if (events === void 0) continue;
4282
+ mergeInto(totals, countSkillInvocations(events));
4283
+ }
4284
+ return toSorted(totals);
4285
+ }
4286
+ /**
4132
4287
  * Wrap a query in a stale-while-revalidate cache: responses never wait for a
4133
4288
  * full session-log scan. While the TTL is fresh the cached totals are
4134
4289
  * returned; after expiry the stale totals are returned immediately and a
@@ -4154,21 +4309,30 @@ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
4154
4309
  let cachedAt = 0;
4155
4310
  let refreshing = null;
4156
4311
  let lastScanDurationMs = 0;
4157
- return async () => {
4312
+ const reader = async () => {
4158
4313
  const startedAt = now();
4159
4314
  const base = typeof ttlMs === "function" ? ttlMs() : ttlMs;
4160
4315
  const ttl = Math.max(base, lastScanDurationMs * 3);
4161
4316
  if (cached !== void 0 && startedAt - cachedAt < ttl) return cached;
4162
4317
  if (refreshing === null) {
4163
4318
  const windowDays = options.windowDays?.() ?? 0;
4164
- refreshing = scan(query, checkpoint, startedAt, windowDays).then(({ stats }) => {
4319
+ refreshing = (options.persistence !== void 0 ? (async () => {
4320
+ let liveIds = /* @__PURE__ */ new Set();
4321
+ if (options.listLiveIds !== void 0) try {
4322
+ liveIds = new Set((await options.listLiveIds()).map((id) => String(id)));
4323
+ } catch {}
4324
+ return scanCold(options.persistence, checkpoint, startedAt, windowDays, liveIds, async (id) => {
4325
+ return (await options.readLiveSession?.(id))?.events;
4326
+ });
4327
+ })() : scan(query, checkpoint, startedAt, windowDays).then(({ stats }) => stats)).then((stats) => {
4165
4328
  cached = stats;
4166
4329
  cachedAt = now();
4167
4330
  lastScanDurationMs = Math.max(0, cachedAt - startedAt);
4168
4331
  checkpoint.lastTotals = stats;
4169
4332
  options.onCheckpoint?.({
4170
4333
  ...checkpoint,
4171
- frozenSessions: { ...checkpoint.frozenSessions }
4334
+ frozenSessions: { ...checkpoint.frozenSessions },
4335
+ ...checkpoint.coldRevisions !== void 0 ? { coldRevisions: { ...checkpoint.coldRevisions } } : {}
4172
4336
  });
4173
4337
  }).catch(() => {}).finally(() => {
4174
4338
  refreshing = null;
@@ -4176,6 +4340,8 @@ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
4176
4340
  }
4177
4341
  return cached ?? [];
4178
4342
  };
4343
+ reader.source = options.persistence !== void 0 ? "cold" : "query";
4344
+ return reader;
4179
4345
  }
4180
4346
  //#endregion
4181
4347
  //#region src/index.ts
@@ -4313,9 +4479,17 @@ function apply(ctx, config) {
4313
4479
  ctx.logger.warn("[dsh-skill-hub] sidecar config migration into the settings namespace failed", error);
4314
4480
  }
4315
4481
  })();
4316
- ctx.inject(["sessionQuery"], (sctx) => {
4482
+ let statsQuery;
4483
+ let statsPersistence;
4484
+ let statsGeneration = 0;
4485
+ const wireStats = () => {
4486
+ const query = statsQuery;
4487
+ if (query === void 0) return;
4488
+ const generation = ++statsGeneration;
4489
+ const cold = statsPersistence;
4317
4490
  (async () => {
4318
4491
  const saved = await store.getSkillStatsState().catch(() => void 0);
4492
+ if (generation !== statsGeneration) return;
4319
4493
  const scanMinutes = () => {
4320
4494
  const value = current().statsScanMinutes;
4321
4495
  return typeof value === "number" && value >= 1 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsScanMinutes;
@@ -4324,19 +4498,59 @@ function apply(ctx, config) {
4324
4498
  const value = current().statsWindowDays;
4325
4499
  return typeof value === "number" && value >= 0 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsWindowDays;
4326
4500
  };
4327
- const reader = createSkillStatsReader(sctx.sessionQuery, () => scanMinutes() * 6e4, {
4501
+ const reader = createSkillStatsReader(query, () => scanMinutes() * 6e4, {
4328
4502
  checkpoint: saved,
4329
4503
  windowDays,
4504
+ ...cold === void 0 ? {} : {
4505
+ persistence: cold,
4506
+ listLiveIds: async () => {
4507
+ try {
4508
+ return (await query.listSessions()).filter((record) => record.live === true).map((record) => record.header.id);
4509
+ } catch {
4510
+ return [];
4511
+ }
4512
+ },
4513
+ readLiveSession: async (id) => {
4514
+ try {
4515
+ return { events: (await query.readSession(id)).events };
4516
+ } catch {
4517
+ return;
4518
+ }
4519
+ }
4520
+ },
4330
4521
  onCheckpoint: (next) => {
4331
4522
  store.saveSkillStatsState(next).catch((error) => {
4332
4523
  ctx.logger.warn("[dsh-skill-hub] persisting skill-stats checkpoint failed", error);
4333
4524
  });
4334
4525
  }
4335
4526
  });
4527
+ if (generation !== statsGeneration) return;
4528
+ ctx.logger.info(`[dsh-skill-hub] stats seam: ${cold === void 0 ? "session-query (fallback)" : "session-persistence (cold)"}`);
4336
4529
  stats = reader;
4337
4530
  sync();
4338
- reader();
4339
4531
  })();
4532
+ };
4533
+ ctx.inject(["sessionQuery"], (sctx) => {
4534
+ statsQuery = sctx.sessionQuery;
4535
+ wireStats();
4536
+ sctx.inject(["sessionPersistence"], (pctx) => {
4537
+ (async () => {
4538
+ const raw = pctx.sessionPersistence;
4539
+ const seam = asPersistenceSeam(raw);
4540
+ if (seam === void 0) {
4541
+ ctx.logger.warn("[dsh-skill-hub] sessionPersistence shape mismatch, staying on query fallback");
4542
+ return;
4543
+ }
4544
+ try {
4545
+ await seam.list();
4546
+ } catch (error) {
4547
+ ctx.logger.warn("[dsh-skill-hub] persistence seam probe failed, staying on query fallback", error);
4548
+ return;
4549
+ }
4550
+ statsPersistence = seam;
4551
+ wireStats();
4552
+ })();
4553
+ });
4340
4554
  });
4341
4555
  }
4342
4556
  //#endregion
@@ -14,14 +14,24 @@ export interface StatsResponse {
14
14
  available: boolean;
15
15
  /** Sorted per-skill invocation counts. */
16
16
  stats: SkillStat[];
17
+ /** Which read path produced the counts (debug aid for memory-issue follow-ups). */
18
+ source?: 'cold' | 'query';
17
19
  }
18
20
  /**
19
21
  * Persisted incremental-scan checkpoint for the usage statistics (sidecar
20
- * `skillStats` field). Sessions created before `frozenBefore` are treated as
21
- * finalized: their per-session counts live in `frozenSessions` (only sessions
22
- * with at least one invocation are kept) and they are not re-read on
23
- * incremental scans. A daily full reconciliation rebuilds the cache and
24
- * advances the watermark, so a resumed old session is eventually re-counted.
22
+ * `skillStats` field). Two buckets cover the two read paths:
23
+ *
24
+ * - `coldRevisions` (preferred): per-session counts keyed by the persistence
25
+ * seam's opaque revision token. A session is re-read only when its revision
26
+ * changes, so resumed old sessions are picked up exactly and the daily full
27
+ * reconciliation is unnecessary on this path.
28
+ * - `frozenSessions` + `frozenBefore` + `lastFullReconcile` (fallback): the
29
+ * legacy time-watermark scheme used when only the heavier `sessionQuery`
30
+ * seam is available. Sessions older than the effective watermark are treated
31
+ * as finalized: their per-session counts live in `frozenSessions` (only
32
+ * sessions with at least one invocation are kept) and they are not re-read
33
+ * on incremental scans. A daily full reconciliation rebuilds the cache and
34
+ * advances the watermark, so a resumed old session is eventually re-counted.
25
35
  */
26
36
  export interface SkillStatsCheckpoint {
27
37
  /** The rolling-window configuration this checkpoint was built for (0 = all history). */
@@ -43,4 +53,22 @@ export interface SkillStatsCheckpoint {
43
53
  * start so a restart still shows numbers while the background rescan runs.
44
54
  */
45
55
  lastTotals?: SkillStat[];
56
+ /**
57
+ * Per-session counts read through the persistence seam, keyed by session id
58
+ * and stamped with the revision they were read at. Absent until the first
59
+ * cold-path scan completes; the query fallback path ignores it.
60
+ */
61
+ coldRevisions?: Record<string, ColdRevisionEntry>;
62
+ }
63
+ /** One cold-path cache entry: what a session's log contained at a revision. */
64
+ export interface ColdRevisionEntry {
65
+ /** The persistence seam's opaque revision token, stringified. */
66
+ rev: string;
67
+ /** header.createdAt at read time (0 when the header carried none). */
68
+ createdAt: number;
69
+ /** Per-skill counts (empty when the session invoked no skill). */
70
+ counts: Record<string, {
71
+ count: number;
72
+ lastUsed: number;
73
+ }>;
46
74
  }
@@ -13,16 +13,27 @@
13
13
  * Counting is per-skill-name, not per-source: a name may resolve to different
14
14
  * files across projects, but the model-facing identity is the kebab-case name.
15
15
  *
16
- * Scaling (per-session checkpoint + incremental scans): a full scan
17
- * decompresses every session log, which grows linearly with total history.
18
- * Sessions older than the effective watermark are therefore treated as
19
- * finalizedtheir per-session counts live in the checkpoint (persisted by
20
- * the host via the sidecar) and are skipped on incremental scans; only the
21
- * recent window is re-read. A daily full reconciliation rebuilds the cache
22
- * and advances the watermark, so a resumed old session is eventually
23
- * re-counted. On top of that, the reader's TTL adapts to the measured scan
24
- * duration (STATS_TTL_SCAN_FACTOR), so a heavy scan also lowers its own
25
- * frequency.
16
+ * Two read paths, cheapest first:
17
+ *
18
+ * 1. Cold path (preferred): the host's `sessionPersistence` seam serves raw
19
+ * stored logsdecompress + parse only, no Session restore, no
20
+ * structuredClone-per-event, no deepFreeze. `list()` also reports an opaque
21
+ * per-session revision token, so the checkpoint re-reads exactly the
22
+ * sessions whose revision changed (plus live sessions via the query seam).
23
+ * No time watermark, no daily full reconciliation.
24
+ * 2. Query fallback: `sessionQuery.readSession` restores + replay-validates a
25
+ * full Session per call (structuredClone × 2 + deepFreeze). Reads are
26
+ * strictly sequential (concurrency 1) so at most one restored log is
27
+ * resident, and a per-session checkpoint + incremental scans keeps the
28
+ * repeat cost to the recent window: a full scan decompresses every session
29
+ * log, which grows linearly with total history. Sessions older than the
30
+ * effective watermark are therefore treated as finalized — their
31
+ * per-session counts live in the checkpoint (persisted by the host via the
32
+ * sidecar) and are skipped on incremental scans; only the recent window is
33
+ * re-read. A daily full reconciliation rebuilds the cache and advances the
34
+ * watermark, so a resumed old session is eventually re-counted. On top of
35
+ * that, the reader's TTL adapts to the measured scan duration
36
+ * (STATS_TTL_SCAN_FACTOR), so a heavy scan also lowers its own frequency.
26
37
  *
27
38
  * Rolling window (statsWindowDays > 0): totals only include sessions created
28
39
  * within the last N days. The watermark then equals the window edge, so
@@ -52,6 +63,41 @@ export interface SessionQueryLike {
52
63
  events: SessionEvent[];
53
64
  }>;
54
65
  }
66
+ /**
67
+ * Narrow structural view of the host's session-persistence seam (kept loose
68
+ * so no runtime import of host packages is needed — the owner adapts the real
69
+ * service via {@link asPersistenceSeam}). Raw log reads here cost decompress +
70
+ * parse only: no Session restore, no per-event clone, no deep-freeze.
71
+ */
72
+ export interface ColdSessionSnapshot {
73
+ header: {
74
+ id: SessionId;
75
+ createdAt?: number;
76
+ };
77
+ /** Opaque per-session change token, kept as its string form. */
78
+ revision: string;
79
+ }
80
+ /** One read handle over a stored session log (always closed after counting). */
81
+ export interface ColdSessionHandle {
82
+ read(): Promise<{
83
+ events: SessionEvent[];
84
+ }>;
85
+ close(): Promise<void> | void;
86
+ }
87
+ /** Minimal persistence surface the cold scan needs. */
88
+ export interface SessionPersistenceLike {
89
+ /** Lightweight listing: headers + revision tokens, no event logs. */
90
+ list(): Promise<readonly ColdSessionSnapshot[]>;
91
+ open(id: SessionId, access: 'read'): Promise<ColdSessionHandle>;
92
+ }
93
+ /**
94
+ * Adapt a host persistence service to {@link SessionPersistenceLike}.
95
+ * Shape-checked only (no I/O): returns undefined when the value is not a
96
+ * usable seam, so callers fall back to the query path. The adapter re-resolves
97
+ * nothing and holds no state — a replaced host service surfaces as ordinary
98
+ * per-call failures, which scans already tolerate.
99
+ */
100
+ export declare function asPersistenceSeam(service: unknown): SessionPersistenceLike | undefined;
55
101
  /** Per-skill invocation stats in one session's event log. */
56
102
  export interface InvocationStat {
57
103
  count: number;
@@ -60,13 +106,34 @@ export interface InvocationStat {
60
106
  }
61
107
  /** Collect per-skill invocation counts and last-used times from one session. */
62
108
  export declare function countSkillInvocations(events: readonly SessionEvent[]): Map<string, InvocationStat>;
109
+ /**
110
+ * One pass over the corpus through the persistence seam. Reads are strictly
111
+ * sequential and each handle is closed before the next opens, so at most one
112
+ * raw log is resident. A session is re-read only when its revision token
113
+ * changed since the checkpoint; the rolling window is applied from the
114
+ * listing headers, so out-of-window logs are never even opened. Live sessions
115
+ * (in-memory, possibly newer than their persisted revision) are read through
116
+ * `readLive` and never enter the revision cache.
117
+ *
118
+ * A `list()` failure rejects — the reader keeps the previous totals, and the
119
+ * next poll retries. Per-session failures are skipped, never fatal.
120
+ */
121
+ export declare function scanCold(persistence: SessionPersistenceLike, checkpoint: SkillStatsCheckpoint, nowMs: number, windowDays: number, liveIds?: ReadonlySet<string>, readLive?: (id: SessionId) => Promise<readonly SessionEvent[] | undefined>): Promise<SkillStat[]>;
63
122
  /**
64
123
  * Full-corpus totals in one shot (no checkpoint reuse). Kept as the
65
124
  * reference implementation for tests and one-off callers.
66
125
  */
67
126
  export declare function readSkillStats(query: SessionQueryLike, windowDays?: number): Promise<SkillStat[]>;
127
+ /**
128
+ * Full-corpus totals in one shot through the persistence seam (no checkpoint
129
+ * reuse). Reference implementation for tests and one-off callers.
130
+ */
131
+ export declare function readColdSkillStats(persistence: SessionPersistenceLike, windowDays?: number): Promise<SkillStat[]>;
68
132
  /** A memoized stats reader (the panel polls, but logs change slowly). */
69
- export type SkillStatsReader = () => Promise<SkillStat[]>;
133
+ export type SkillStatsReader = (() => Promise<SkillStat[]>) & {
134
+ /** Which read path this reader scans with (set at wiring; absent on test doubles). */
135
+ source?: 'cold' | 'query';
136
+ };
70
137
  /** Optional wiring for {@link createSkillStatsReader}. */
71
138
  export interface SkillStatsReaderOptions {
72
139
  /** Checkpoint restored from the sidecar; absent means "start from zero". */
@@ -81,6 +148,14 @@ export interface SkillStatsReaderOptions {
81
148
  * persist the checkpoint including the fresh totals. Cadence follows the
82
149
  * scan TTL (minutes, not days) — the payload is tiny and writes are atomic. */
83
150
  onCheckpoint?: (checkpoint: SkillStatsCheckpoint) => void;
151
+ /** Preferred cheap seam: raw stored-log reads keyed by revision token. */
152
+ persistence?: SessionPersistenceLike;
153
+ /** Live session ids (usually 0-2) read through the query seam, not the cache. */
154
+ listLiveIds?: () => Promise<readonly SessionId[]>;
155
+ /** Full current log of one live session; undefined skips it for this scan. */
156
+ readLiveSession?: (id: SessionId) => Promise<{
157
+ events: SessionEvent[];
158
+ } | undefined>;
84
159
  }
85
160
  /**
86
161
  * Wrap a query in a stale-while-revalidate cache: responses never wait for a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-hub",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
5
5
  "type": "module",
6
6
  "engines": {