@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 +26 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -22
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/index.test.ts +114 -2
- package/src/index.ts +111 -20
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,MAAM,
|
|
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).
|
|
109
|
-
//
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 —
|
|
123
|
-
//
|
|
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 `
|
|
133
|
-
* reader doesn't expose `baseSeq`. `null` for an empty log.
|
|
134
|
-
*
|
|
135
|
-
* boundary at the max restored
|
|
136
|
-
*
|
|
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
|
|
145
|
-
|
|
146
|
-
max
|
|
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":"AAAA,OAAO,EACL,YAAY,EACZ,sBAAsB,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,sBAAsB,GAMvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,eAAe,EAAE,eAAe,EAAwB,MAAM,aAAa,CAAC;AAuBrF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,MAAM,UAAU,qBAAqB,CAAC,OAAqC,EAAE;IAC3E,8EAA8E;IAC9E,wEAAwE;IACxE,2EAA2E;IAC3E,0EAA0E;IAC1E,2EAA2E;IAC3E,0EAA0E;IAC1E,+EAA+E;IAC/E,uDAAuD;IACvD,EAAE;IACF,wEAAwE;IACxE,8EAA8E;IAC9E,2EAA2E;IAC3E,kEAAkE;IAClE,MAAM,OAAO,GAAG,IAAI,GAAG,EAA4B,CAAC;IACpD,uEAAuE;IACvE,6CAA6C;IAC7C,MAAM,WAAW,GAAG,IAAI,GAAG,EAAyB,CAAC;IACrD,OAAO,YAAY,CAAC;QAClB,IAAI,EAAE,2BAA2B;QACjC,KAAK,EAAE;YACL,MAAM,CAAC,GAAG;gBACR,uEAAuE;gBACvE,qEAAqE;gBACrE,6BAA6B;gBAC7B,EAAE;gBACF,yEAAyE;gBACzE,mEAAmE;gBACnE,wEAAwE;gBACxE,wEAAwE;gBACxE,iEAAiE;gBACjE,iEAAiE;gBACjE,iEAAiE;gBACjE,0EAA0E;gBAC1E,IAAI,CAAC;oBACH,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;gBACnD,CAAC;gBAAC,MAAM,CAAC;oBACP,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;gBACnC,CAAC;gBACD,IAAI,CAAC;oBACH,MAAM,MAAM,GAAG,GAAG,CAAC,GAAsC,CAAC;oBAC1D,IAAI,OAAO,MAAM,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;wBACzC,uEAAuE;wBACvE,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC;wBACnC,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE;4BAChC,+DAA+D;4BAC/D,oEAAoE;4BACpE,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;wBACnC,CAAC,CAAC,CAAC;wBACH,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;oBACxC,CAAC;gBACH,CAAC;gBAAC,MAAM,CAAC;oBACP,mEAAmE;oBACnE,qEAAqE;oBACrE,qEAAqE;oBACrE,iDAAiD;gBACnD,CAAC;YACH,CAAC;YACD,KAAK,CAAC,UAAU,CAAC,GAAG;gBAClB,wEAAwE;gBACxE,yEAAyE;gBACzE,0EAA0E;gBAC1E,wDAAwD;gBACxD,IAAI,CAAC;oBACH,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC;gBACrC,CAAC;gBAAC,MAAM,CAAC;oBACP,6DAA6D;gBAC/D,CAAC;gBACD,WAAW,CAAC,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;gBAClC,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,SAAS,CAAE,CAAC,CAAC,CAAC,IAAI,CAAC;gBACnF,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;gBAC9B,sEAAsE;gBACtE,uEAAuE;gBACvE,wEAAwE;gBACxE,uEAAuE;gBACvE,mEAAmE;gBACnE,wEAAwE;gBACxE,qDAAqD;gBAErD,uEAAuE;gBACvE,wEAAwE;gBACxE,6BAA6B;gBAC7B,IAAI,CAAC;oBACH,MAAM,SAAS,GAAG,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;oBACtD,MAAM,IAAI,GACR,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,UAAU,CAAC,CAAC;oBAChF,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;wBACpB,MAAM,KAAK,GAAG,sBAAsB,CAAC,IAAI,CAAC,CAAC;wBAC3C,MAAM,eAAe,CAAC,KAAK,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;oBAC/C,CAAC;gBACH,CAAC;gBAAC,MAAM,CAAC;oBACP,sEAAsE;oBACtE,qEAAqE;gBACvE,CAAC;gBAED,yEAAyE;gBACzE,gEAAgE;gBAChE,IAAI,CAAC;oBACH,MAAM,OAAO,GAAG,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;oBAChD,MAAM,OAAO,GAAG,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;oBAChD,MAAM,UAAU,GAAG,oBAAoB,CACrC,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,UAAU,CAAC,EACzE,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,UAAU,CAAC,CAC1E,CAAC;oBACF,sEAAsE;oBACtE,IAAI,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;wBACvC,MAAM,eAAe,CAAC,UAAU,EAAE,IAAI,CAAC,cAAc,CAAC,CAAC;oBACzD,CAAC;gBACH,CAAC;gBAAC,MAAM,CAAC;oBACP,gEAAgE;oBAChE,iDAAiD;gBACnD,CAAC;YACH,CAAC;SACF;KACF,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CAClC,OAAyC,EACzC,OAAyC;IAEzC,MAAM,GAAG,GAAG,IAAI,GAAG,EAA+E,CAAC;IACnG,MAAM,KAAK,GAAG,CAAC,IAAY,EAAE,EAAE;QAC7B,IAAI,CAAC,GAAG,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACtB,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,CAAC,GAAG,EAAE,WAAW,EAAE,CAAC,EAAE,CAAC;YACvB,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;QACnB,CAAC;QACD,OAAO,CAAC,CAAC;IACX,CAAC,CAAC;IACF,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;QACxB,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QAC1B,GAAG,CAAC,WAAW,IAAI,CAAC,CAAC;QACrB,MAAM,GAAG,GAAG,IAAI,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;QACzC,IAAI,CAAC,GAAG,CAAC,aAAa,IAAI,GAAG,GAAG,GAAG,CAAC,aAAa;YAAE,GAAG,CAAC,aAAa,GAAG,GAAG,CAAC;IAC7E,CAAC;IACD,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;QACxB,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QAC1B,MAAM,GAAG,GAAG,IAAI,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;QACzC,6EAA6E;QAC7E,IAAI,CAAC,GAAG,CAAC,SAAS,IAAI,GAAG,GAAG,GAAG,CAAC,SAAS;YAAE,GAAG,CAAC,SAAS,GAAG,GAAG,CAAC;IACjE,CAAC;IACD,MAAM,GAAG,GAAoC,EAAE,CAAC;IAChD,KAAK,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,GAAG,EAAE,CAAC;QAC5B,GAAG,CAAC,IAAI,CAAC,GAAG;YACV,WAAW,EAAE,CAAC,CAAC,WAAW;YAC1B,GAAG,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,CAAC,aAAa,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC9D,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACnD,CAAC;IACJ,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,wEAAwE;AACxE,6EAA6E;AAC7E,wEAAwE;AACxE,iFAAiF;AACjF,2EAA2E;AAC3E,MAAM,kBAAkB,GAAG,CAAC,mBAAmB,EAAE,eAAe,EAAE,eAAe,CAAU,CAAC;AAE5F;;;;;;;;;GASG;AACH,SAAS,WAAW,CAAC,GAAoC;IACvD,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAClC,IAAI,OAAO,GAAG,CAAC,OAAO,KAAK,QAAQ;QAAE,OAAO,GAAG,CAAC,OAAO,GAAG,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC;IACzE,IAAI,GAAG,GAAkB,IAAI,CAAC;IAC9B,KAAK,MAAM,IAAI,IAAI,kBAAkB,EAAE,CAAC;QACtC,KAAK,MAAM,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC;YAAE,IAAI,GAAG,KAAK,IAAI,IAAI,CAAC,CAAC,GAAG,GAAG,GAAG;gBAAE,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC;IACjF,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,eAAe,qBAAqB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moxxy/plugin-usage-stats",
|
|
3
|
-
"version": "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.
|
|
47
|
-
"@moxxy/sdk": "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/
|
|
54
|
-
"@moxxy/
|
|
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).
|
|
132
|
-
//
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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 `
|
|
156
|
-
* reader doesn't expose `baseSeq`. `null` for an empty log.
|
|
157
|
-
*
|
|
158
|
-
* boundary at the max restored
|
|
159
|
-
*
|
|
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
|
|
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
|
|