@moxxy/plugin-usage-stats 0.26.0 → 0.28.0

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/dist/index.d.ts CHANGED
@@ -1,13 +1,26 @@
1
- import { type Plugin } from '@moxxy/sdk';
1
+ import { type Plugin, type SkillCreatedEvent, type SkillInvokedEvent } from '@moxxy/sdk';
2
+ import { type SkillUsageDelta } from '@moxxy/core';
2
3
  export interface BuildUsageStatsPluginOptions {
3
- /** Override the on-disk aggregate path. Tests inject a tmp file here. */
4
+ /** Override the on-disk token-usage aggregate path. Tests inject a tmp file here. */
4
5
  readonly statsPath?: string;
6
+ /** Override the on-disk skill-usage aggregate path. Tests inject a tmp file here. */
7
+ readonly skillUsagePath?: string;
5
8
  }
6
9
  /**
7
- * Records cross-session token usage. On shutdown it folds THIS run's
10
+ * Records cross-session token AND skill usage. On shutdown it folds THIS run's
8
11
  * `provider_response` events by `<provider>/<model>` and merges the delta into
9
12
  * `~/.moxxy/usage.json` — a forward-going aggregate the `/usage` panel renders
10
- * and `/usage clear` resets.
13
+ * and `/usage clear` resets. It ALSO folds this run's `skill_invoked` /
14
+ * `skill_created` events by skill name and merges them into
15
+ * `~/.moxxy/skills/.meta/usage.json` (surfaced in `/skills` and
16
+ * `moxxy skills list`). Both folds use the same resume/`/new` seq boundary, so
17
+ * each store counts a session's contribution exactly once.
18
+ *
19
+ * NOTE: `skill_invoked` is only emitted by the `load_skill` tool today (reason
20
+ * `'load_skill_tool'`), so the skill store counts explicit `load_skill` calls
21
+ * only — see `@moxxy/core`'s `skill-usage.ts`. The other reasons the event type
22
+ * allows (`trigger_match` / `classifier` / `manual`) have no emission site yet;
23
+ * when they land, this same fold starts counting them with no change here.
11
24
  *
12
25
  * Resume-safe by construction: restored events are seeded into the log WITHOUT
13
26
  * firing subscribers, and `onInit` runs after seeding. `onInit` records the
@@ -33,5 +46,14 @@ export interface BuildUsageStatsPluginOptions {
33
46
  * when the hooks are invoked directly (outside the host's lifecycle dispatcher).
34
47
  */
35
48
  export declare function buildUsageStatsPlugin(opts?: BuildUsageStatsPluginOptions): Plugin;
49
+ /**
50
+ * Fold a run's live `skill_invoked` / `skill_created` events into a per-skill-
51
+ * name delta for `mergeSkillUsage`. Invocations are counted and the latest
52
+ * invocation timestamp kept; a creation event records the skill's `createdAt`.
53
+ * Timestamps convert the epoch-ms event `ts` to ISO to match the store's shape.
54
+ * Skills that only appear in a creation event still get an entry (invocations 0)
55
+ * so a freshly-synthesized-but-never-invoked skill still records its birth.
56
+ */
57
+ export declare function summarizeSkillEvents(invoked: ReadonlyArray<SkillInvokedEvent>, created: ReadonlyArray<SkillCreatedEvent>): Record<string, SkillUsageDelta>;
36
58
  export default buildUsageStatsPlugin;
37
59
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,MAAM,EAEZ,MAAM,YAAY,CAAC;AAGpB,MAAM,WAAW,4BAA4B;IAC3C,yEAAyE;IACzE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAgBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,GAAE,4BAAiC,GAAG,MAAM,CA4FrF;AAmBD,eAAe,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,MAAM,EAEX,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAoC,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAErF,MAAM,WAAW,4BAA4B;IAC3C,qFAAqF;IACrF,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,qFAAqF;IACrF,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CAClC;AAgBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,GAAE,4BAAiC,GAAG,MAAM,CAmHrF;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,aAAa,CAAC,iBAAiB,CAAC,EACzC,OAAO,EAAE,aAAa,CAAC,iBAAiB,CAAC,GACxC,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CA+BjC;AA6BD,eAAe,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -1,10 +1,20 @@
1
1
  import { definePlugin, summarizeTokensByModel, } from '@moxxy/sdk';
2
- import { mergeUsageStats } from '@moxxy/core';
2
+ import { mergeSkillUsage, mergeUsageStats } from '@moxxy/core';
3
3
  /**
4
- * Records cross-session token usage. On shutdown it folds THIS run's
4
+ * Records cross-session token AND skill usage. On shutdown it folds THIS run's
5
5
  * `provider_response` events by `<provider>/<model>` and merges the delta into
6
6
  * `~/.moxxy/usage.json` — a forward-going aggregate the `/usage` panel renders
7
- * and `/usage clear` resets.
7
+ * and `/usage clear` resets. It ALSO folds this run's `skill_invoked` /
8
+ * `skill_created` events by skill name and merges them into
9
+ * `~/.moxxy/skills/.meta/usage.json` (surfaced in `/skills` and
10
+ * `moxxy skills list`). Both folds use the same resume/`/new` seq boundary, so
11
+ * each store counts a session's contribution exactly once.
12
+ *
13
+ * NOTE: `skill_invoked` is only emitted by the `load_skill` tool today (reason
14
+ * `'load_skill_tool'`), so the skill store counts explicit `load_skill` calls
15
+ * only — see `@moxxy/core`'s `skill-usage.ts`. The other reasons the event type
16
+ * allows (`trigger_match` / `classifier` / `manual`) have no emission site yet;
17
+ * when they land, this same fold starts counting them with no change here.
8
18
  *
9
19
  * Resume-safe by construction: restored events are seeded into the log WITHOUT
10
20
  * firing subscribers, and `onInit` runs after seeding. `onInit` records the
@@ -105,35 +115,102 @@ export function buildUsageStatsPlugin(opts = {}) {
105
115
  cursors.delete(ctx.sessionId);
106
116
  // Best-effort: a throwing reader or a failed fold degrades to "record
107
117
  // nothing for this run" rather than rejecting the hook (which the host
108
- // would log as a spurious failure). `mergeUsageStats` already swallows
109
- // its own write errors; this guards the read/fold side too.
118
+ // would log as a spurious failure). Each store folds in its own guarded
119
+ // block so a failure folding one family (tokens / skills) can't strand
120
+ // the other, and neither can crash the 5s-timeboxed shutdown path.
121
+ // `mergeUsageStats` / `mergeSkillUsage` already swallow their own write
122
+ // errors; these guards cover the read/fold side too.
123
+ // Only `provider_response` events carry token usage; scan that indexed
124
+ // subset (O(matches)) instead of copying + filtering the full event log
125
+ // (O(total session events)).
110
126
  try {
111
- // Only `provider_response` events carry token usage; scan that indexed
112
- // subset (O(matches)) instead of copying + filtering the full event log
113
- // (O(total session events)) on this 5s-timeboxed shutdown path.
114
127
  const responses = ctx.log.ofType('provider_response');
115
128
  const live = initMaxSeq === null ? responses : responses.filter((e) => e.seq > initMaxSeq);
116
- if (live.length === 0)
117
- return;
118
- const delta = summarizeTokensByModel(live);
119
- await mergeUsageStats(delta, opts.statsPath);
129
+ if (live.length > 0) {
130
+ const delta = summarizeTokensByModel(live);
131
+ await mergeUsageStats(delta, opts.statsPath);
132
+ }
133
+ }
134
+ catch {
135
+ // Drop this run's token usage rather than crash shutdown — explicitly
136
+ // the accepted trade-off for an optional, contention-free aggregate.
137
+ }
138
+ // Fold this run's live skill events past the SAME boundary and merge the
139
+ // per-skill-name delta into `~/.moxxy/skills/.meta/usage.json`.
140
+ try {
141
+ const invoked = ctx.log.ofType('skill_invoked');
142
+ const created = ctx.log.ofType('skill_created');
143
+ const skillDelta = summarizeSkillEvents(initMaxSeq === null ? invoked : invoked.filter((e) => e.seq > initMaxSeq), initMaxSeq === null ? created : created.filter((e) => e.seq > initMaxSeq));
144
+ // Only touch disk (even the read side) when there's something to add.
145
+ if (Object.keys(skillDelta).length > 0) {
146
+ await mergeSkillUsage(skillDelta, opts.skillUsagePath);
147
+ }
120
148
  }
121
149
  catch {
122
- // Drop this run's usage rather than crash shutdown — explicitly the
123
- // accepted trade-off for an optional, contention-free aggregate.
150
+ // Drop this run's skill usage rather than crash shutdown — same
151
+ // best-effort trade-off as the token fold above.
124
152
  }
125
153
  },
126
154
  },
127
155
  });
128
156
  }
157
+ /**
158
+ * Fold a run's live `skill_invoked` / `skill_created` events into a per-skill-
159
+ * name delta for `mergeSkillUsage`. Invocations are counted and the latest
160
+ * invocation timestamp kept; a creation event records the skill's `createdAt`.
161
+ * Timestamps convert the epoch-ms event `ts` to ISO to match the store's shape.
162
+ * Skills that only appear in a creation event still get an entry (invocations 0)
163
+ * so a freshly-synthesized-but-never-invoked skill still records its birth.
164
+ */
165
+ export function summarizeSkillEvents(invoked, created) {
166
+ const acc = new Map();
167
+ const entry = (name) => {
168
+ let e = acc.get(name);
169
+ if (!e) {
170
+ e = { invocations: 0 };
171
+ acc.set(name, e);
172
+ }
173
+ return e;
174
+ };
175
+ for (const e of invoked) {
176
+ const cur = entry(e.name);
177
+ cur.invocations += 1;
178
+ const iso = new Date(e.ts).toISOString();
179
+ if (!cur.lastInvokedAt || iso > cur.lastInvokedAt)
180
+ cur.lastInvokedAt = iso;
181
+ }
182
+ for (const e of created) {
183
+ const cur = entry(e.name);
184
+ const iso = new Date(e.ts).toISOString();
185
+ // Earliest creation wins if (improbably) a name is created twice in one run.
186
+ if (!cur.createdAt || iso < cur.createdAt)
187
+ cur.createdAt = iso;
188
+ }
189
+ const out = {};
190
+ for (const [name, e] of acc) {
191
+ out[name] = {
192
+ invocations: e.invocations,
193
+ ...(e.lastInvokedAt ? { lastInvokedAt: e.lastInvokedAt } : {}),
194
+ ...(e.createdAt ? { createdAt: e.createdAt } : {}),
195
+ };
196
+ }
197
+ return out;
198
+ }
199
+ // The event families `onShutdown` folds. The `baseSeq`-less fallback in
200
+ // `boundarySeq` must scan the union of these so the resume boundary excludes
201
+ // every restored event of ANY folded family — otherwise a session whose
202
+ // restored prefix has, e.g., skill events but no `provider_response` would get a
203
+ // too-low boundary and re-fold (double-count) those restored skill events.
204
+ const FOLDED_EVENT_TYPES = ['provider_response', 'skill_invoked', 'skill_created'];
129
205
  /**
130
206
  * Highest held seq — the resume boundary — without copying the log. Prefers the
131
207
  * O(1) `baseSeq + length - 1` identity (every held event has `seq === base +
132
- * index`); falls back to scanning the indexed `provider_response` subset when a
133
- * reader doesn't expose `baseSeq`. `null` for an empty log. Using the response
134
- * subset for the fallback is sound: `onShutdown` only folds responses, so a
135
- * boundary at the max restored response seq still excludes every restored
136
- * response and includes every live one.
208
+ * index`); falls back to scanning the indexed subsets `onShutdown` folds when a
209
+ * reader doesn't expose `baseSeq`. `null` for an empty log. Scanning the union
210
+ * of folded families (not just `provider_response`) keeps the fallback sound now
211
+ * that skills are folded too: the boundary sits at the max restored seq across
212
+ * every folded family, so each fold excludes its restored events and includes
213
+ * only its live ones.
137
214
  */
138
215
  function boundarySeq(log) {
139
216
  if (log.length === 0)
@@ -141,9 +218,11 @@ function boundarySeq(log) {
141
218
  if (typeof log.baseSeq === 'number')
142
219
  return log.baseSeq + log.length - 1;
143
220
  let max = null;
144
- for (const e of log.ofType('provider_response'))
145
- if (max === null || e.seq > max)
146
- max = e.seq;
221
+ for (const type of FOLDED_EVENT_TYPES) {
222
+ for (const e of log.ofType(type))
223
+ if (max === null || e.seq > max)
224
+ max = e.seq;
225
+ }
147
226
  return max;
148
227
  }
149
228
  export default buildUsageStatsPlugin;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxxy/plugin-usage-stats",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Records cross-session token usage. On session close it folds this run's provider_response events by provider/model and merges the delta into ~/.moxxy/usage.json (a forward-going aggregate surfaced in the /usage panel and reset by /usage clear).",
5
5
  "keywords": [
6
6
  "moxxy",
@@ -43,15 +43,15 @@
43
43
  }
44
44
  },
45
45
  "dependencies": {
46
- "@moxxy/core": "0.26.0",
47
- "@moxxy/sdk": "0.26.0"
46
+ "@moxxy/core": "0.28.0",
47
+ "@moxxy/sdk": "0.28.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^22.10.0",
51
51
  "typescript": "^5.7.3",
52
52
  "vitest": "^2.1.8",
53
- "@moxxy/tsconfig": "0.0.0",
54
- "@moxxy/vitest-preset": "0.0.0"
53
+ "@moxxy/vitest-preset": "0.0.0",
54
+ "@moxxy/tsconfig": "0.0.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsc -p tsconfig.json",
package/src/index.test.ts CHANGED
@@ -5,13 +5,14 @@ import * as path from 'node:path';
5
5
  import {
6
6
  asEventId,
7
7
  asSessionId,
8
+ asSkillId,
8
9
  asTurnId,
9
10
  type AppContext,
10
11
  type EventLogReader,
11
12
  type MoxxyEvent,
12
13
  } from '@moxxy/sdk';
13
- import { loadUsageStats } from '@moxxy/core';
14
- import { buildUsageStatsPlugin } from './index.js';
14
+ import { loadSkillUsage, loadUsageStats } from '@moxxy/core';
15
+ import { buildUsageStatsPlugin, summarizeSkillEvents } from './index.js';
15
16
 
16
17
  const sid = asSessionId('s1');
17
18
  const tid = asTurnId('t1');
@@ -32,6 +33,38 @@ function resp(seq: number, model: string, inputTokens: number): MoxxyEvent {
32
33
  } as MoxxyEvent;
33
34
  }
34
35
 
36
+ function skillInvoked(seq: number, name: string, ts: number): MoxxyEvent {
37
+ return {
38
+ id: asEventId(`e${seq}`),
39
+ seq,
40
+ ts,
41
+ sessionId: sid,
42
+ turnId: tid,
43
+ source: 'model',
44
+ type: 'skill_invoked',
45
+ skillId: asSkillId(name),
46
+ name,
47
+ reason: 'load_skill_tool',
48
+ } as MoxxyEvent;
49
+ }
50
+
51
+ function skillCreated(seq: number, name: string, ts: number): MoxxyEvent {
52
+ return {
53
+ id: asEventId(`e${seq}`),
54
+ seq,
55
+ ts,
56
+ sessionId: sid,
57
+ turnId: tid,
58
+ source: 'model',
59
+ type: 'skill_created',
60
+ skillId: asSkillId(name),
61
+ name,
62
+ path: `/tmp/${name}.md`,
63
+ scope: 'user',
64
+ originatingPrompt: `make ${name}`,
65
+ } as MoxxyEvent;
66
+ }
67
+
35
68
  function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
36
69
  // Faithfully model the real EventLog: `length` is a COUNT of held events,
37
70
  // while `slice(from)`/`at(seq)` are SEQ-addressed (translated through the
@@ -115,10 +148,12 @@ function ctxForLog(log: FakeLog, sessionId = sid): AppContext {
115
148
 
116
149
  let tmpDir: string;
117
150
  let statsPath: string;
151
+ let skillUsagePath: string;
118
152
 
119
153
  beforeEach(async () => {
120
154
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-usage-plugin-'));
121
155
  statsPath = path.join(tmpDir, 'usage.json');
156
+ skillUsagePath = path.join(tmpDir, 'skill-usage.json');
122
157
  });
123
158
 
124
159
  afterEach(async () => {
@@ -455,3 +490,80 @@ describe('usage-stats plugin', () => {
455
490
  expect(file.models['anthropic/opus']!.inputTokens).toBe(50);
456
491
  });
457
492
  });
493
+
494
+ describe('summarizeSkillEvents', () => {
495
+ it('counts invocations per name and keeps the latest invocation timestamp', () => {
496
+ const delta = summarizeSkillEvents(
497
+ [skillInvoked(0, 'deploy', 1000), skillInvoked(1, 'deploy', 3000), skillInvoked(2, 'lint', 2000)],
498
+ [],
499
+ );
500
+ expect(delta['deploy']!.invocations).toBe(2);
501
+ expect(delta['deploy']!.lastInvokedAt).toBe(new Date(3000).toISOString());
502
+ expect(delta['lint']!.invocations).toBe(1);
503
+ });
504
+
505
+ it('records createdAt for a created-but-never-invoked skill (invocations 0)', () => {
506
+ const delta = summarizeSkillEvents([], [skillCreated(0, 'fresh', 5000)]);
507
+ expect(delta['fresh']!.invocations).toBe(0);
508
+ expect(delta['fresh']!.createdAt).toBe(new Date(5000).toISOString());
509
+ });
510
+
511
+ it('is empty for no events', () => {
512
+ expect(summarizeSkillEvents([], [])).toEqual({});
513
+ });
514
+ });
515
+
516
+ describe('usage-stats plugin — skill folding', () => {
517
+ it('folds skill_invoked/skill_created into the skill usage store on shutdown', async () => {
518
+ const plugin = buildUsageStatsPlugin({ statsPath, skillUsagePath });
519
+ const events = [
520
+ skillCreated(0, 'deploy', 1000),
521
+ skillInvoked(1, 'deploy', 2000),
522
+ skillInvoked(2, 'deploy', 4000),
523
+ skillInvoked(3, 'lint', 3000),
524
+ ];
525
+
526
+ await plugin.hooks!.onInit!(ctxFor([]));
527
+ await plugin.hooks!.onShutdown!(ctxFor(events));
528
+
529
+ const file = await loadSkillUsage(skillUsagePath);
530
+ expect(file.skills['deploy']!.invocations).toBe(2);
531
+ expect(file.skills['deploy']!.createdAt).toBe(new Date(1000).toISOString());
532
+ expect(file.skills['deploy']!.lastInvokedAt).toBe(new Date(4000).toISOString());
533
+ expect(file.skills['lint']!.invocations).toBe(1);
534
+ });
535
+
536
+ it('counts only the live skill suffix on resume (skips restored skill events)', async () => {
537
+ const restored = [skillInvoked(0, 'deploy', 1000), skillInvoked(1, 'deploy', 2000)];
538
+ const live = [skillInvoked(2, 'deploy', 3000)];
539
+ const plugin = buildUsageStatsPlugin({ statsPath, skillUsagePath });
540
+
541
+ await plugin.hooks!.onInit!(ctxFor(restored));
542
+ await plugin.hooks!.onShutdown!(ctxFor([...restored, ...live]));
543
+
544
+ // Only the single live invocation is counted — not the 2 restored.
545
+ expect((await loadSkillUsage(skillUsagePath)).skills['deploy']!.invocations).toBe(1);
546
+ });
547
+
548
+ it('folds skill usage even when the run produced no provider_response (no token early-return)', async () => {
549
+ // Regression: the token fold used to `return` early when no responses were
550
+ // present, which would skip the skill fold entirely. They must be independent.
551
+ const plugin = buildUsageStatsPlugin({ statsPath, skillUsagePath });
552
+ const events = [skillInvoked(0, 'deploy', 1000)];
553
+
554
+ await plugin.hooks!.onInit!(ctxFor([]));
555
+ await plugin.hooks!.onShutdown!(ctxFor(events));
556
+
557
+ expect((await loadSkillUsage(skillUsagePath)).skills['deploy']!.invocations).toBe(1);
558
+ // No token usage was recorded (no provider_response events).
559
+ expect((await loadUsageStats(statsPath)).models).toEqual({});
560
+ });
561
+
562
+ it('writes no skill usage file when the run exercised no skills', async () => {
563
+ const plugin = buildUsageStatsPlugin({ statsPath, skillUsagePath });
564
+ await plugin.hooks!.onInit!(ctxFor([]));
565
+ await plugin.hooks!.onShutdown!(ctxFor([resp(0, 'opus', 100)]));
566
+ // Empty skill delta → merge never touches disk.
567
+ await expect(fs.access(skillUsagePath)).rejects.toThrow();
568
+ });
569
+ });
package/src/index.ts CHANGED
@@ -4,12 +4,16 @@ import {
4
4
  type EventLogReader,
5
5
  type Plugin,
6
6
  type SessionId,
7
+ type SkillCreatedEvent,
8
+ type SkillInvokedEvent,
7
9
  } from '@moxxy/sdk';
8
- import { mergeUsageStats } from '@moxxy/core';
10
+ import { mergeSkillUsage, mergeUsageStats, type SkillUsageDelta } from '@moxxy/core';
9
11
 
10
12
  export interface BuildUsageStatsPluginOptions {
11
- /** Override the on-disk aggregate path. Tests inject a tmp file here. */
13
+ /** Override the on-disk token-usage aggregate path. Tests inject a tmp file here. */
12
14
  readonly statsPath?: string;
15
+ /** Override the on-disk skill-usage aggregate path. Tests inject a tmp file here. */
16
+ readonly skillUsagePath?: string;
13
17
  }
14
18
 
15
19
  /**
@@ -27,10 +31,20 @@ interface EventLogExtras {
27
31
  }
28
32
 
29
33
  /**
30
- * Records cross-session token usage. On shutdown it folds THIS run's
34
+ * Records cross-session token AND skill usage. On shutdown it folds THIS run's
31
35
  * `provider_response` events by `<provider>/<model>` and merges the delta into
32
36
  * `~/.moxxy/usage.json` — a forward-going aggregate the `/usage` panel renders
33
- * and `/usage clear` resets.
37
+ * and `/usage clear` resets. It ALSO folds this run's `skill_invoked` /
38
+ * `skill_created` events by skill name and merges them into
39
+ * `~/.moxxy/skills/.meta/usage.json` (surfaced in `/skills` and
40
+ * `moxxy skills list`). Both folds use the same resume/`/new` seq boundary, so
41
+ * each store counts a session's contribution exactly once.
42
+ *
43
+ * NOTE: `skill_invoked` is only emitted by the `load_skill` tool today (reason
44
+ * `'load_skill_tool'`), so the skill store counts explicit `load_skill` calls
45
+ * only — see `@moxxy/core`'s `skill-usage.ts`. The other reasons the event type
46
+ * allows (`trigger_match` / `classifier` / `manual`) have no emission site yet;
47
+ * when they land, this same fold starts counting them with no change here.
34
48
  *
35
49
  * Resume-safe by construction: restored events are seeded into the log WITHOUT
36
50
  * firing subscribers, and `onInit` runs after seeding. `onInit` records the
@@ -128,41 +142,118 @@ export function buildUsageStatsPlugin(opts: BuildUsageStatsPluginOptions = {}):
128
142
  cursors.delete(ctx.sessionId);
129
143
  // Best-effort: a throwing reader or a failed fold degrades to "record
130
144
  // nothing for this run" rather than rejecting the hook (which the host
131
- // would log as a spurious failure). `mergeUsageStats` already swallows
132
- // its own write errors; this guards the read/fold side too.
145
+ // would log as a spurious failure). Each store folds in its own guarded
146
+ // block so a failure folding one family (tokens / skills) can't strand
147
+ // the other, and neither can crash the 5s-timeboxed shutdown path.
148
+ // `mergeUsageStats` / `mergeSkillUsage` already swallow their own write
149
+ // errors; these guards cover the read/fold side too.
150
+
151
+ // Only `provider_response` events carry token usage; scan that indexed
152
+ // subset (O(matches)) instead of copying + filtering the full event log
153
+ // (O(total session events)).
133
154
  try {
134
- // Only `provider_response` events carry token usage; scan that indexed
135
- // subset (O(matches)) instead of copying + filtering the full event log
136
- // (O(total session events)) on this 5s-timeboxed shutdown path.
137
155
  const responses = ctx.log.ofType('provider_response');
138
156
  const live =
139
157
  initMaxSeq === null ? responses : responses.filter((e) => e.seq > initMaxSeq);
140
- if (live.length === 0) return;
141
- const delta = summarizeTokensByModel(live);
142
- await mergeUsageStats(delta, opts.statsPath);
158
+ if (live.length > 0) {
159
+ const delta = summarizeTokensByModel(live);
160
+ await mergeUsageStats(delta, opts.statsPath);
161
+ }
143
162
  } catch {
144
- // Drop this run's usage rather than crash shutdown — explicitly the
145
- // accepted trade-off for an optional, contention-free aggregate.
163
+ // Drop this run's token usage rather than crash shutdown — explicitly
164
+ // the accepted trade-off for an optional, contention-free aggregate.
165
+ }
166
+
167
+ // Fold this run's live skill events past the SAME boundary and merge the
168
+ // per-skill-name delta into `~/.moxxy/skills/.meta/usage.json`.
169
+ try {
170
+ const invoked = ctx.log.ofType('skill_invoked');
171
+ const created = ctx.log.ofType('skill_created');
172
+ const skillDelta = summarizeSkillEvents(
173
+ initMaxSeq === null ? invoked : invoked.filter((e) => e.seq > initMaxSeq),
174
+ initMaxSeq === null ? created : created.filter((e) => e.seq > initMaxSeq),
175
+ );
176
+ // Only touch disk (even the read side) when there's something to add.
177
+ if (Object.keys(skillDelta).length > 0) {
178
+ await mergeSkillUsage(skillDelta, opts.skillUsagePath);
179
+ }
180
+ } catch {
181
+ // Drop this run's skill usage rather than crash shutdown — same
182
+ // best-effort trade-off as the token fold above.
146
183
  }
147
184
  },
148
185
  },
149
186
  });
150
187
  }
151
188
 
189
+ /**
190
+ * Fold a run's live `skill_invoked` / `skill_created` events into a per-skill-
191
+ * name delta for `mergeSkillUsage`. Invocations are counted and the latest
192
+ * invocation timestamp kept; a creation event records the skill's `createdAt`.
193
+ * Timestamps convert the epoch-ms event `ts` to ISO to match the store's shape.
194
+ * Skills that only appear in a creation event still get an entry (invocations 0)
195
+ * so a freshly-synthesized-but-never-invoked skill still records its birth.
196
+ */
197
+ export function summarizeSkillEvents(
198
+ invoked: ReadonlyArray<SkillInvokedEvent>,
199
+ created: ReadonlyArray<SkillCreatedEvent>,
200
+ ): Record<string, SkillUsageDelta> {
201
+ const acc = new Map<string, { invocations: number; lastInvokedAt?: string; createdAt?: string }>();
202
+ const entry = (name: string) => {
203
+ let e = acc.get(name);
204
+ if (!e) {
205
+ e = { invocations: 0 };
206
+ acc.set(name, e);
207
+ }
208
+ return e;
209
+ };
210
+ for (const e of invoked) {
211
+ const cur = entry(e.name);
212
+ cur.invocations += 1;
213
+ const iso = new Date(e.ts).toISOString();
214
+ if (!cur.lastInvokedAt || iso > cur.lastInvokedAt) cur.lastInvokedAt = iso;
215
+ }
216
+ for (const e of created) {
217
+ const cur = entry(e.name);
218
+ const iso = new Date(e.ts).toISOString();
219
+ // Earliest creation wins if (improbably) a name is created twice in one run.
220
+ if (!cur.createdAt || iso < cur.createdAt) cur.createdAt = iso;
221
+ }
222
+ const out: Record<string, SkillUsageDelta> = {};
223
+ for (const [name, e] of acc) {
224
+ out[name] = {
225
+ invocations: e.invocations,
226
+ ...(e.lastInvokedAt ? { lastInvokedAt: e.lastInvokedAt } : {}),
227
+ ...(e.createdAt ? { createdAt: e.createdAt } : {}),
228
+ };
229
+ }
230
+ return out;
231
+ }
232
+
233
+ // The event families `onShutdown` folds. The `baseSeq`-less fallback in
234
+ // `boundarySeq` must scan the union of these so the resume boundary excludes
235
+ // every restored event of ANY folded family — otherwise a session whose
236
+ // restored prefix has, e.g., skill events but no `provider_response` would get a
237
+ // too-low boundary and re-fold (double-count) those restored skill events.
238
+ const FOLDED_EVENT_TYPES = ['provider_response', 'skill_invoked', 'skill_created'] as const;
239
+
152
240
  /**
153
241
  * Highest held seq — the resume boundary — without copying the log. Prefers the
154
242
  * O(1) `baseSeq + length - 1` identity (every held event has `seq === base +
155
- * index`); falls back to scanning the indexed `provider_response` subset when a
156
- * reader doesn't expose `baseSeq`. `null` for an empty log. Using the response
157
- * subset for the fallback is sound: `onShutdown` only folds responses, so a
158
- * boundary at the max restored response seq still excludes every restored
159
- * response and includes every live one.
243
+ * index`); falls back to scanning the indexed subsets `onShutdown` folds when a
244
+ * reader doesn't expose `baseSeq`. `null` for an empty log. Scanning the union
245
+ * of folded families (not just `provider_response`) keeps the fallback sound now
246
+ * that skills are folded too: the boundary sits at the max restored seq across
247
+ * every folded family, so each fold excludes its restored events and includes
248
+ * only its live ones.
160
249
  */
161
250
  function boundarySeq(log: EventLogReader & EventLogExtras): number | null {
162
251
  if (log.length === 0) return null;
163
252
  if (typeof log.baseSeq === 'number') return log.baseSeq + log.length - 1;
164
253
  let max: number | null = null;
165
- for (const e of log.ofType('provider_response')) if (max === null || e.seq > max) max = e.seq;
254
+ for (const type of FOLDED_EVENT_TYPES) {
255
+ for (const e of log.ofType(type)) if (max === null || e.seq > max) max = e.seq;
256
+ }
166
257
  return max;
167
258
  }
168
259