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 +230 -16
- package/lib/types/protocol/stats.d.ts +33 -5
- package/lib/types/stats.d.ts +86 -11
- package/package.json +1 -1
- package/src/index.ts +72 -11
- package/src/protocol/stats.ts +31 -5
- package/src/routes/catalog.ts +9 -1
- package/src/routes.test.ts +32 -0
- package/src/stats.test.ts +244 -6
- package/src/stats.ts +235 -24
- package/src/store/migrate.ts +25 -0
- package/src/store/store.ts +2 -0
- package/src/store.test.ts +29 -0
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:
|
|
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
|
|
4033
|
-
|
|
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
|
-
|
|
4091
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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).
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
}
|
package/lib/types/stats.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
16
|
+
* Two read paths, cheapest first:
|
|
17
|
+
*
|
|
18
|
+
* 1. Cold path (preferred): the host's `sessionPersistence` seam serves raw
|
|
19
|
+
* stored logs — decompress + 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.
|
|
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": {
|