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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
4
4
  "description": "Set up SkillRepo in any IDE — one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  // ---------------------------------------------------------------------------