skillrepo 1.9.0 → 1.10.1
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/package.json
CHANGED
|
@@ -222,6 +222,83 @@ export function sendTelemetry(config, payload) {
|
|
|
222
222
|
}).catch(() => { /* telemetry errors are non-critical */ });
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Activation telemetry for rules-delivered matched skills
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Read the shared activation dedup state (same file as pretool-activation hook).
|
|
231
|
+
* This prevents double-counting: if rules_match fires first, pretool_hook
|
|
232
|
+
* won't re-report the same skill in the same session, and vice versa.
|
|
233
|
+
*/
|
|
234
|
+
export function readActivationState(sessionId) {
|
|
235
|
+
const hash = createHash("sha256").update(sessionId || "default").digest("hex").slice(0, 16);
|
|
236
|
+
const statePath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
return { path: statePath, state: JSON.parse(readFileSync(statePath, "utf-8")) };
|
|
240
|
+
} catch {
|
|
241
|
+
return { path: statePath, state: { reported: {} } };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Filter matches to exclude skills already reported as activated in this session.
|
|
247
|
+
*/
|
|
248
|
+
export function deduplicateActivations(matches, sessionState) {
|
|
249
|
+
return matches.filter(m => {
|
|
250
|
+
const key = `${m.skill.owner}/${m.skill.name}`;
|
|
251
|
+
return !sessionState.reported[key];
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Mark skills as reported in activation state.
|
|
257
|
+
*/
|
|
258
|
+
export function updateActivationState(statePath, state, reportedMatches) {
|
|
259
|
+
for (const m of reportedMatches) {
|
|
260
|
+
const key = `${m.skill.owner}/${m.skill.name}`;
|
|
261
|
+
state.reported[key] = new Date().toISOString();
|
|
262
|
+
}
|
|
263
|
+
try { writeFileSync(statePath, JSON.stringify(state), "utf-8"); }
|
|
264
|
+
catch { /* non-critical */ }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Build activation telemetry payload for rules-delivered matched skills.
|
|
269
|
+
*/
|
|
270
|
+
export function buildActivationPayload(matches, sessionInfo) {
|
|
271
|
+
const events = matches.slice(0, MAX_EVENTS_PER_BATCH).map(m => ({
|
|
272
|
+
skillOwner: m.skill.owner,
|
|
273
|
+
skillName: m.skill.name,
|
|
274
|
+
skillVersion: m.skill.version ?? "",
|
|
275
|
+
activatedAt: new Date().toISOString(),
|
|
276
|
+
ide: sessionInfo.ide || "claude-code",
|
|
277
|
+
sessionHash: sessionInfo.sessionHash,
|
|
278
|
+
userId: sessionInfo.userId || undefined,
|
|
279
|
+
source: "rules_match",
|
|
280
|
+
toolPattern: undefined,
|
|
281
|
+
}));
|
|
282
|
+
|
|
283
|
+
return { events };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Fire-and-forget POST to activation telemetry endpoint.
|
|
288
|
+
*/
|
|
289
|
+
export function sendActivationTelemetry(config, payload) {
|
|
290
|
+
const url = `${config.serverUrl}/api/v1/telemetry/activation`;
|
|
291
|
+
|
|
292
|
+
fetch(url, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: {
|
|
295
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
296
|
+
"Content-Type": "application/json",
|
|
297
|
+
},
|
|
298
|
+
body: JSON.stringify(payload),
|
|
299
|
+
}).catch(() => { /* telemetry errors are non-critical */ });
|
|
300
|
+
}
|
|
301
|
+
|
|
225
302
|
// ---------------------------------------------------------------------------
|
|
226
303
|
// Main entry point
|
|
227
304
|
// ---------------------------------------------------------------------------
|
|
@@ -281,6 +358,27 @@ export async function main(input) {
|
|
|
281
358
|
|
|
282
359
|
sendTelemetry(config, payload); // fire-and-forget — do NOT await
|
|
283
360
|
|
|
361
|
+
// ── Send activation telemetry for rules-delivered matches ───
|
|
362
|
+
// For skills delivered via .claude/rules/, the match IS the activation
|
|
363
|
+
// signal — the skill was in context and the prompt was relevant.
|
|
364
|
+
// Uses the shared activation dedup state so pretool_hook won't
|
|
365
|
+
// double-report the same skill in this session.
|
|
366
|
+
const rulesMatches = newMatches.filter(m => m.skill.isRulesDelivered);
|
|
367
|
+
if (rulesMatches.length > 0) {
|
|
368
|
+
const { path: actStatePath, state: actState } = readActivationState(sessionId);
|
|
369
|
+
const newActivations = deduplicateActivations(rulesMatches, actState);
|
|
370
|
+
|
|
371
|
+
if (newActivations.length > 0) {
|
|
372
|
+
const actPayload = buildActivationPayload(newActivations, {
|
|
373
|
+
ide: "claude-code",
|
|
374
|
+
sessionHash,
|
|
375
|
+
userId: config.userId,
|
|
376
|
+
});
|
|
377
|
+
sendActivationTelemetry(config, actPayload); // fire-and-forget
|
|
378
|
+
updateActivationState(actStatePath, actState, newActivations);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
284
382
|
// ── Update session state ────────────────────────────────────
|
|
285
383
|
updateSessionState(statePath, state, newMatches);
|
|
286
384
|
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
import { describe, it } from "node:test";
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import {
|
|
10
|
-
mkdtempSync, rmSync, readFileSync,
|
|
10
|
+
mkdtempSync, rmSync, readFileSync, writeFileSync,
|
|
11
11
|
} from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
|
-
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
15
|
import {
|
|
16
16
|
matchSkills,
|
|
17
17
|
readSessionState,
|
|
@@ -19,6 +19,11 @@ import {
|
|
|
19
19
|
updateSessionState,
|
|
20
20
|
buildTelemetryPayload,
|
|
21
21
|
sendTelemetry,
|
|
22
|
+
readActivationState,
|
|
23
|
+
deduplicateActivations,
|
|
24
|
+
updateActivationState,
|
|
25
|
+
buildActivationPayload,
|
|
26
|
+
sendActivationTelemetry,
|
|
22
27
|
main,
|
|
23
28
|
} from "../../hooks/skillrepo-prompt-match.mjs";
|
|
24
29
|
|
|
@@ -336,6 +341,145 @@ describe("sendTelemetry", () => {
|
|
|
336
341
|
});
|
|
337
342
|
});
|
|
338
343
|
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// buildActivationPayload — rules-match activation telemetry
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
describe("buildActivationPayload", () => {
|
|
349
|
+
it("produces correct payload shape with source rules_match", () => {
|
|
350
|
+
const matches = [
|
|
351
|
+
{ skill: makeSkill({ owner: "alice", name: "deploy-flow", version: "2.0.0", isRulesDelivered: true }), score: 5 },
|
|
352
|
+
];
|
|
353
|
+
const sessionInfo = { ide: "claude-code", sessionHash: "abc123", userId: "user-42" };
|
|
354
|
+
|
|
355
|
+
const payload = buildActivationPayload(matches, sessionInfo);
|
|
356
|
+
|
|
357
|
+
assert.equal(payload.events.length, 1);
|
|
358
|
+
const event = payload.events[0];
|
|
359
|
+
assert.equal(event.skillOwner, "alice");
|
|
360
|
+
assert.equal(event.skillName, "deploy-flow");
|
|
361
|
+
assert.equal(event.skillVersion, "2.0.0");
|
|
362
|
+
assert.equal(event.ide, "claude-code");
|
|
363
|
+
assert.equal(event.sessionHash, "abc123");
|
|
364
|
+
assert.equal(event.userId, "user-42");
|
|
365
|
+
assert.equal(event.source, "rules_match");
|
|
366
|
+
assert.equal(event.toolPattern, undefined);
|
|
367
|
+
assert.ok(event.activatedAt); // ISO 8601 string
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("caps events at 50 per batch", () => {
|
|
371
|
+
const matches = Array.from({ length: 60 }, (_, i) => ({
|
|
372
|
+
skill: makeSkill({ owner: "o", name: `s-${i}` }),
|
|
373
|
+
score: 1,
|
|
374
|
+
}));
|
|
375
|
+
const payload = buildActivationPayload(matches, { ide: "claude-code", sessionHash: "x" });
|
|
376
|
+
assert.equal(payload.events.length, 50);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("handles missing optional fields gracefully", () => {
|
|
380
|
+
const matches = [
|
|
381
|
+
{ skill: makeSkill({ version: null, isRulesDelivered: undefined }), score: 1 },
|
|
382
|
+
];
|
|
383
|
+
const payload = buildActivationPayload(matches, {
|
|
384
|
+
ide: "claude-code",
|
|
385
|
+
sessionHash: "hash",
|
|
386
|
+
userId: null,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const event = payload.events[0];
|
|
390
|
+
assert.equal(event.skillVersion, "");
|
|
391
|
+
assert.equal(event.userId, undefined); // null → undefined via || operator
|
|
392
|
+
assert.equal(event.source, "rules_match");
|
|
393
|
+
assert.equal(event.toolPattern, undefined);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// sendActivationTelemetry — error resilience
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
describe("sendActivationTelemetry", () => {
|
|
402
|
+
it("does not throw on network failure", async () => {
|
|
403
|
+
const config = { apiKey: "sk_live_test", serverUrl: "http://127.0.0.1:1" };
|
|
404
|
+
const payload = { events: [{ skillOwner: "o", skillName: "s", activatedAt: new Date().toISOString(), ide: "test", sessionHash: "x", source: "rules_match" }] };
|
|
405
|
+
|
|
406
|
+
// Should resolve without throwing
|
|
407
|
+
await sendActivationTelemetry(config, payload);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// readActivationState / deduplicateActivations / updateActivationState
|
|
413
|
+
// — shared dedup state between rules_match and pretool_hook
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
describe("activation dedup state", () => {
|
|
417
|
+
it("returns empty state when file does not exist", () => {
|
|
418
|
+
const { state } = readActivationState("nonexistent-session-id-" + Date.now());
|
|
419
|
+
assert.deepEqual(state, { reported: {} });
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("reads activation state from shared tmpdir file", () => {
|
|
423
|
+
const sessionId = "dedup-read-test-" + Date.now();
|
|
424
|
+
const hash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
|
|
425
|
+
const statePath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
|
|
426
|
+
|
|
427
|
+
// Pre-populate state file
|
|
428
|
+
writeFileSync(statePath, JSON.stringify({ reported: { "alice/deploy": "2025-01-01T00:00:00Z" } }));
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const { state } = readActivationState(sessionId);
|
|
432
|
+
assert.equal(state.reported["alice/deploy"], "2025-01-01T00:00:00Z");
|
|
433
|
+
} finally {
|
|
434
|
+
rmSync(statePath, { force: true });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("filters out already-reported skills", () => {
|
|
439
|
+
const matches = [
|
|
440
|
+
{ skill: makeSkill({ owner: "alice", name: "deploy" }), score: 5 },
|
|
441
|
+
{ skill: makeSkill({ owner: "bob", name: "review" }), score: 3 },
|
|
442
|
+
];
|
|
443
|
+
const sessionState = { reported: { "alice/deploy": "2025-01-01T00:00:00Z" } };
|
|
444
|
+
|
|
445
|
+
const filtered = deduplicateActivations(matches, sessionState);
|
|
446
|
+
|
|
447
|
+
assert.equal(filtered.length, 1);
|
|
448
|
+
assert.equal(filtered[0].skill.owner, "bob");
|
|
449
|
+
assert.equal(filtered[0].skill.name, "review");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("persists reported skills to state file", () => {
|
|
453
|
+
const sessionId = "dedup-write-test-" + Date.now();
|
|
454
|
+
const hash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
|
|
455
|
+
const statePath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
|
|
456
|
+
const state = { reported: {} };
|
|
457
|
+
|
|
458
|
+
const matches = [
|
|
459
|
+
{ skill: makeSkill({ owner: "alice", name: "deploy" }), score: 5 },
|
|
460
|
+
];
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
updateActivationState(statePath, state, matches);
|
|
464
|
+
|
|
465
|
+
const persisted = JSON.parse(readFileSync(statePath, "utf-8"));
|
|
466
|
+
assert.ok(persisted.reported["alice/deploy"]);
|
|
467
|
+
} finally {
|
|
468
|
+
rmSync(statePath, { force: true });
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("uses same state file path format as pretool-activation hook", () => {
|
|
473
|
+
const sessionId = "cross-hook-dedup-test";
|
|
474
|
+
const hash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
|
|
475
|
+
const expectedPath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
|
|
476
|
+
|
|
477
|
+
const { path } = readActivationState(sessionId);
|
|
478
|
+
|
|
479
|
+
assert.equal(path, expectedPath);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
339
483
|
// ---------------------------------------------------------------------------
|
|
340
484
|
// main() integration via subprocess — avoids test runner interference
|
|
341
485
|
// ---------------------------------------------------------------------------
|