@moxxy/plugin-usage-stats 0.26.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/LICENSE +21 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +150 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/index.test.ts +457 -0
- package/src/index.ts +169 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moxxy (moxxy.ai)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Plugin } from '@moxxy/sdk';
|
|
2
|
+
export interface BuildUsageStatsPluginOptions {
|
|
3
|
+
/** Override the on-disk aggregate path. Tests inject a tmp file here. */
|
|
4
|
+
readonly statsPath?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Records cross-session token usage. On shutdown it folds THIS run's
|
|
8
|
+
* `provider_response` events by `<provider>/<model>` and merges the delta into
|
|
9
|
+
* `~/.moxxy/usage.json` — a forward-going aggregate the `/usage` panel renders
|
|
10
|
+
* and `/usage clear` resets.
|
|
11
|
+
*
|
|
12
|
+
* Resume-safe by construction: restored events are seeded into the log WITHOUT
|
|
13
|
+
* firing subscribers, and `onInit` runs after seeding. `onInit` records the
|
|
14
|
+
* highest restored event seq, and `onShutdown` folds only events with a
|
|
15
|
+
* strictly greater seq — the live suffix of this run. A session's usage is
|
|
16
|
+
* therefore counted exactly once — when it closes — never re-counted on resume,
|
|
17
|
+
* and the seq boundary stays correct even on a rebased mirror (baseSeq > 0).
|
|
18
|
+
*
|
|
19
|
+
* `/new`-safe: `Session.reset()` calls `log.clear()`, which restarts the
|
|
20
|
+
* authoritative seq stream at 0 WITHOUT re-firing `onInit`. Without reacting to
|
|
21
|
+
* that, the post-`/new` events (seqs 0,1,2…) would all fall at or below the
|
|
22
|
+
* pre-`/new` boundary and be silently dropped. We subscribe to the log's
|
|
23
|
+
* `onClear` and re-baseline the cursor to `null` so the post-`/new` suffix is
|
|
24
|
+
* folded from the new base.
|
|
25
|
+
*
|
|
26
|
+
* Flush-on-close means a hard kill (SIGKILL) drops the in-progress session's
|
|
27
|
+
* delta; that's the accepted trade-off for a single, contention-free write.
|
|
28
|
+
*
|
|
29
|
+
* Best-effort, never load-blocking: both hooks are internally fault-tolerant.
|
|
30
|
+
* A hostile or half-implemented reader (one whose `ofType`/`onClear` throws)
|
|
31
|
+
* degrades — init falls back to the fold-whole-suffix default and shutdown
|
|
32
|
+
* records nothing for the run — instead of rejecting the hook. This holds even
|
|
33
|
+
* when the hooks are invoked directly (outside the host's lifecycle dispatcher).
|
|
34
|
+
*/
|
|
35
|
+
export declare function buildUsageStatsPlugin(opts?: BuildUsageStatsPluginOptions): Plugin;
|
|
36
|
+
export default buildUsageStatsPlugin;
|
|
37
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { definePlugin, summarizeTokensByModel, } from '@moxxy/sdk';
|
|
2
|
+
import { mergeUsageStats } from '@moxxy/core';
|
|
3
|
+
/**
|
|
4
|
+
* Records cross-session token usage. On shutdown it folds THIS run's
|
|
5
|
+
* `provider_response` events by `<provider>/<model>` and merges the delta into
|
|
6
|
+
* `~/.moxxy/usage.json` — a forward-going aggregate the `/usage` panel renders
|
|
7
|
+
* and `/usage clear` resets.
|
|
8
|
+
*
|
|
9
|
+
* Resume-safe by construction: restored events are seeded into the log WITHOUT
|
|
10
|
+
* firing subscribers, and `onInit` runs after seeding. `onInit` records the
|
|
11
|
+
* highest restored event seq, and `onShutdown` folds only events with a
|
|
12
|
+
* strictly greater seq — the live suffix of this run. A session's usage is
|
|
13
|
+
* therefore counted exactly once — when it closes — never re-counted on resume,
|
|
14
|
+
* and the seq boundary stays correct even on a rebased mirror (baseSeq > 0).
|
|
15
|
+
*
|
|
16
|
+
* `/new`-safe: `Session.reset()` calls `log.clear()`, which restarts the
|
|
17
|
+
* authoritative seq stream at 0 WITHOUT re-firing `onInit`. Without reacting to
|
|
18
|
+
* that, the post-`/new` events (seqs 0,1,2…) would all fall at or below the
|
|
19
|
+
* pre-`/new` boundary and be silently dropped. We subscribe to the log's
|
|
20
|
+
* `onClear` and re-baseline the cursor to `null` so the post-`/new` suffix is
|
|
21
|
+
* folded from the new base.
|
|
22
|
+
*
|
|
23
|
+
* Flush-on-close means a hard kill (SIGKILL) drops the in-progress session's
|
|
24
|
+
* delta; that's the accepted trade-off for a single, contention-free write.
|
|
25
|
+
*
|
|
26
|
+
* Best-effort, never load-blocking: both hooks are internally fault-tolerant.
|
|
27
|
+
* A hostile or half-implemented reader (one whose `ofType`/`onClear` throws)
|
|
28
|
+
* degrades — init falls back to the fold-whole-suffix default and shutdown
|
|
29
|
+
* records nothing for the run — instead of rejecting the hook. This holds even
|
|
30
|
+
* when the hooks are invoked directly (outside the host's lifecycle dispatcher).
|
|
31
|
+
*/
|
|
32
|
+
export function buildUsageStatsPlugin(opts = {}) {
|
|
33
|
+
// Boundary tracked by event SEQ, not by index/length. `log.length` is a count
|
|
34
|
+
// of held events while `log.slice(from)` is SEQ-addressed; the two only
|
|
35
|
+
// coincide on a base-0 log. On a rebased mirror (baseSeq > 0, e.g. partial
|
|
36
|
+
// replay), a length-as-seq cursor would clamp to the base and re-fold the
|
|
37
|
+
// entire restored prefix, double-counting usage. Capturing the highest seq
|
|
38
|
+
// restored at init and folding only events with a strictly greater seq is
|
|
39
|
+
// base-independent. `null` (no onInit, or a post-`/new` re-baseline) folds the
|
|
40
|
+
// whole log — the documented suffix-from-base default.
|
|
41
|
+
//
|
|
42
|
+
// Keyed by sessionId so one shared plugin instance stays correct across
|
|
43
|
+
// multiple sessions (the runner/thin-client model hosts many in one process);
|
|
44
|
+
// a single scalar would let one session's onInit clobber another's cursor.
|
|
45
|
+
// The map self-cleans in onShutdown, so it cannot grow unbounded.
|
|
46
|
+
const cursors = new Map();
|
|
47
|
+
// Per-session `onClear` unsubscribers, so we tear the listener down on
|
|
48
|
+
// shutdown rather than leaking one per init.
|
|
49
|
+
const clearUnsubs = new Map();
|
|
50
|
+
return definePlugin({
|
|
51
|
+
name: '@moxxy/plugin-usage-stats',
|
|
52
|
+
hooks: {
|
|
53
|
+
onInit(ctx) {
|
|
54
|
+
// Usage stats are a best-effort, non-load-blocking layer: a hostile or
|
|
55
|
+
// half-implemented reader (e.g. one whose `ofType`/`onClear` throws)
|
|
56
|
+
// must NEVER take down init.
|
|
57
|
+
//
|
|
58
|
+
// The two steps are isolated so a failure in one can't poison the other.
|
|
59
|
+
// The boundary is the load-bearing correctness value: if `onClear`
|
|
60
|
+
// registration throws AFTER a valid boundary was computed, we must KEEP
|
|
61
|
+
// that boundary — discarding it (resetting to `null`) would re-fold the
|
|
62
|
+
// whole restored prefix on shutdown and DOUBLE-COUNT the resumed
|
|
63
|
+
// session's usage into the lifetime aggregate. A failed boundary
|
|
64
|
+
// computation, by contrast, falls back to `null` (the documented
|
|
65
|
+
// fold-whole-suffix default) so the run is still counted, just from base.
|
|
66
|
+
try {
|
|
67
|
+
cursors.set(ctx.sessionId, boundarySeq(ctx.log));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
cursors.set(ctx.sessionId, null);
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const extras = ctx.log;
|
|
74
|
+
if (typeof extras.onClear === 'function') {
|
|
75
|
+
// Replace any stale listener from a prior init of the same session id.
|
|
76
|
+
clearUnsubs.get(ctx.sessionId)?.();
|
|
77
|
+
const unsub = extras.onClear(() => {
|
|
78
|
+
// `clear()` reset the seq stream to base 0; fold the post-wipe
|
|
79
|
+
// suffix from the start rather than against the now-stale boundary.
|
|
80
|
+
cursors.set(ctx.sessionId, null);
|
|
81
|
+
});
|
|
82
|
+
clearUnsubs.set(ctx.sessionId, unsub);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// No `/new` re-baseline wired for this run — the boundary captured
|
|
87
|
+
// above still stands. A session that never clears is unaffected; one
|
|
88
|
+
// that does would over-count the post-`/new` suffix, the lesser evil
|
|
89
|
+
// versus crashing init or dropping the boundary.
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
async onShutdown(ctx) {
|
|
93
|
+
// Tear down the per-session state first and unconditionally, so a later
|
|
94
|
+
// throw in the fold/merge can't strand the `onClear` listener or leave a
|
|
95
|
+
// cursor in the map (which would otherwise grow unbounded under a hosting
|
|
96
|
+
// model that reuses one instance across many sessions).
|
|
97
|
+
try {
|
|
98
|
+
clearUnsubs.get(ctx.sessionId)?.();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// An unsubscribe that throws must not block the merge below.
|
|
102
|
+
}
|
|
103
|
+
clearUnsubs.delete(ctx.sessionId);
|
|
104
|
+
const initMaxSeq = cursors.has(ctx.sessionId) ? cursors.get(ctx.sessionId) : null;
|
|
105
|
+
cursors.delete(ctx.sessionId);
|
|
106
|
+
// Best-effort: a throwing reader or a failed fold degrades to "record
|
|
107
|
+
// 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.
|
|
110
|
+
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
|
+
const responses = ctx.log.ofType('provider_response');
|
|
115
|
+
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);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Drop this run's usage rather than crash shutdown — explicitly the
|
|
123
|
+
// accepted trade-off for an optional, contention-free aggregate.
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Highest held seq — the resume boundary — without copying the log. Prefers the
|
|
131
|
+
* 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.
|
|
137
|
+
*/
|
|
138
|
+
function boundarySeq(log) {
|
|
139
|
+
if (log.length === 0)
|
|
140
|
+
return null;
|
|
141
|
+
if (typeof log.baseSeq === 'number')
|
|
142
|
+
return log.baseSeq + log.length - 1;
|
|
143
|
+
let max = null;
|
|
144
|
+
for (const e of log.ofType('provider_response'))
|
|
145
|
+
if (max === null || e.seq > max)
|
|
146
|
+
max = e.seq;
|
|
147
|
+
return max;
|
|
148
|
+
}
|
|
149
|
+
export default buildUsageStatsPlugin;
|
|
150
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moxxy/plugin-usage-stats",
|
|
3
|
+
"version": "0.26.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"moxxy",
|
|
7
|
+
"agent",
|
|
8
|
+
"usage",
|
|
9
|
+
"tokens",
|
|
10
|
+
"stats"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://moxxy.ai",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/moxxy-ai/moxxy/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/moxxy-ai/moxxy.git",
|
|
19
|
+
"directory": "packages/plugin-usage-stats"
|
|
20
|
+
},
|
|
21
|
+
"author": "Michal Makowski <michal.makowski97@gmail.com>",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"moxxy": {
|
|
40
|
+
"plugin": {
|
|
41
|
+
"entry": "./dist/index.js",
|
|
42
|
+
"kind": "hooks"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@moxxy/core": "0.26.0",
|
|
47
|
+
"@moxxy/sdk": "0.26.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.10.0",
|
|
51
|
+
"typescript": "^5.7.3",
|
|
52
|
+
"vitest": "^2.1.8",
|
|
53
|
+
"@moxxy/tsconfig": "0.0.0",
|
|
54
|
+
"@moxxy/vitest-preset": "0.0.0"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsc -p tsconfig.json",
|
|
58
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"clean": "rm -rf dist .turbo"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
asEventId,
|
|
7
|
+
asSessionId,
|
|
8
|
+
asTurnId,
|
|
9
|
+
type AppContext,
|
|
10
|
+
type EventLogReader,
|
|
11
|
+
type MoxxyEvent,
|
|
12
|
+
} from '@moxxy/sdk';
|
|
13
|
+
import { loadUsageStats } from '@moxxy/core';
|
|
14
|
+
import { buildUsageStatsPlugin } from './index.js';
|
|
15
|
+
|
|
16
|
+
const sid = asSessionId('s1');
|
|
17
|
+
const tid = asTurnId('t1');
|
|
18
|
+
|
|
19
|
+
function resp(seq: number, model: string, inputTokens: number): MoxxyEvent {
|
|
20
|
+
return {
|
|
21
|
+
id: asEventId(`e${seq}`),
|
|
22
|
+
seq,
|
|
23
|
+
ts: seq,
|
|
24
|
+
sessionId: sid,
|
|
25
|
+
turnId: tid,
|
|
26
|
+
source: 'system',
|
|
27
|
+
type: 'provider_response',
|
|
28
|
+
provider: 'anthropic',
|
|
29
|
+
model,
|
|
30
|
+
inputTokens,
|
|
31
|
+
outputTokens: 1,
|
|
32
|
+
} as MoxxyEvent;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
|
|
36
|
+
// Faithfully model the real EventLog: `length` is a COUNT of held events,
|
|
37
|
+
// while `slice(from)`/`at(seq)` are SEQ-addressed (translated through the
|
|
38
|
+
// log's base). On a rebased mirror the held events' seqs start above 0.
|
|
39
|
+
const base = events.length > 0 ? events[0]!.seq : 0;
|
|
40
|
+
return {
|
|
41
|
+
length: events.length,
|
|
42
|
+
at: (seq: number) => events[seq - base],
|
|
43
|
+
slice: (from = base, to = base + events.length) =>
|
|
44
|
+
events.slice(Math.max(0, from - base), Math.max(0, to - base)),
|
|
45
|
+
ofType: ((type: string) => events.filter((e) => e.type === type)) as EventLogReader['ofType'],
|
|
46
|
+
byTurn: (turnId) => events.filter((e) => e.turnId === turnId),
|
|
47
|
+
toJSON: () => events,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ctxFor(events: ReadonlyArray<MoxxyEvent>): AppContext {
|
|
52
|
+
return { sessionId: sid, cwd: '/tmp', log: reader(events), env: {} };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A stateful fake that mirrors the real `EventLog`'s clear semantics: holding
|
|
57
|
+
* events with a base seq, exposing `baseSeq` + `onClear`, and on `clear()`
|
|
58
|
+
* emptying the events AND resetting `base` to 0 while firing clear listeners —
|
|
59
|
+
* exactly the `/new` (`Session.reset()`) lifecycle that restarts the seq stream.
|
|
60
|
+
*/
|
|
61
|
+
class FakeLog {
|
|
62
|
+
private events: MoxxyEvent[];
|
|
63
|
+
private base: number;
|
|
64
|
+
private readonly clearListeners = new Set<() => void>();
|
|
65
|
+
constructor(seed: ReadonlyArray<MoxxyEvent> = []) {
|
|
66
|
+
this.events = [...seed];
|
|
67
|
+
this.base = seed.length > 0 ? seed[0]!.seq : 0;
|
|
68
|
+
}
|
|
69
|
+
get length() {
|
|
70
|
+
return this.events.length;
|
|
71
|
+
}
|
|
72
|
+
get baseSeq() {
|
|
73
|
+
return this.base;
|
|
74
|
+
}
|
|
75
|
+
at(seq: number) {
|
|
76
|
+
return this.events[seq - this.base];
|
|
77
|
+
}
|
|
78
|
+
slice(from = this.base, to = this.base + this.events.length) {
|
|
79
|
+
return this.events.slice(Math.max(0, from - this.base), Math.max(0, to - this.base));
|
|
80
|
+
}
|
|
81
|
+
ofType(type: string) {
|
|
82
|
+
return this.events.filter((e) => e.type === type);
|
|
83
|
+
}
|
|
84
|
+
byTurn(turnId: unknown) {
|
|
85
|
+
return this.events.filter((e) => e.turnId === turnId);
|
|
86
|
+
}
|
|
87
|
+
toJSON() {
|
|
88
|
+
return this.events;
|
|
89
|
+
}
|
|
90
|
+
onClear(fn: () => void) {
|
|
91
|
+
this.clearListeners.add(fn);
|
|
92
|
+
return () => this.clearListeners.delete(fn);
|
|
93
|
+
}
|
|
94
|
+
/** Test-only: how many clear listeners are currently registered. Lets a test
|
|
95
|
+
* assert the plugin actually tears its `onClear` subscription down on shutdown
|
|
96
|
+
* (no leaked listener under a one-instance-many-sessions host). */
|
|
97
|
+
get clearListenerCount() {
|
|
98
|
+
return this.clearListeners.size;
|
|
99
|
+
}
|
|
100
|
+
/** Append more live events after `onInit` has captured the boundary. */
|
|
101
|
+
push(...more: MoxxyEvent[]) {
|
|
102
|
+
this.events.push(...more);
|
|
103
|
+
}
|
|
104
|
+
/** `/new` wipe: empty events, reset base to 0, fire clear listeners. */
|
|
105
|
+
clear() {
|
|
106
|
+
this.events = [];
|
|
107
|
+
this.base = 0;
|
|
108
|
+
for (const fn of [...this.clearListeners]) fn();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ctxForLog(log: FakeLog, sessionId = sid): AppContext {
|
|
113
|
+
return { sessionId, cwd: '/tmp', log: log as unknown as EventLogReader, env: {} };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let tmpDir: string;
|
|
117
|
+
let statsPath: string;
|
|
118
|
+
|
|
119
|
+
beforeEach(async () => {
|
|
120
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-usage-plugin-'));
|
|
121
|
+
statsPath = path.join(tmpDir, 'usage.json');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterEach(async () => {
|
|
125
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('usage-stats plugin', () => {
|
|
129
|
+
it('folds the whole session for a fresh (non-resumed) run', async () => {
|
|
130
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
131
|
+
const events = [resp(0, 'opus', 100), resp(1, 'opus', 50)];
|
|
132
|
+
|
|
133
|
+
await plugin.hooks!.onInit!(ctxFor([])); // boots with empty log
|
|
134
|
+
await plugin.hooks!.onShutdown!(ctxFor(events));
|
|
135
|
+
|
|
136
|
+
const file = await loadUsageStats(statsPath);
|
|
137
|
+
expect(file.models['anthropic/opus']!.calls).toBe(2);
|
|
138
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(150);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('skips restored events on resume and folds only the live suffix', async () => {
|
|
142
|
+
const restored = [resp(0, 'opus', 1000), resp(1, 'opus', 2000)];
|
|
143
|
+
const liveSuffix = [resp(2, 'opus', 30)];
|
|
144
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
145
|
+
|
|
146
|
+
// onInit fires after restored events are already seeded into the log.
|
|
147
|
+
await plugin.hooks!.onInit!(ctxFor(restored));
|
|
148
|
+
// onShutdown sees restored + live.
|
|
149
|
+
await plugin.hooks!.onShutdown!(ctxFor([...restored, ...liveSuffix]));
|
|
150
|
+
|
|
151
|
+
const file = await loadUsageStats(statsPath);
|
|
152
|
+
// Only the single live call (30 tokens) is counted — not the 3000 restored.
|
|
153
|
+
expect(file.models['anthropic/opus']!.calls).toBe(1);
|
|
154
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(30);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('writes nothing when no live events were produced', async () => {
|
|
158
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
159
|
+
await plugin.hooks!.onInit!(ctxFor([resp(0, 'opus', 100)]));
|
|
160
|
+
await plugin.hooks!.onShutdown!(ctxFor([resp(0, 'opus', 100)]));
|
|
161
|
+
// No new events past the cursor → file never created.
|
|
162
|
+
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('counts only live events on a rebased mirror (baseSeq > 0), not the restored prefix', async () => {
|
|
166
|
+
// A partial-replay mirror primes the log with restored events at seqs that
|
|
167
|
+
// start above 0. A length-as-seq cursor would clamp to the base and re-fold
|
|
168
|
+
// the entire restored prefix, double-counting. Tracking the boundary by seq
|
|
169
|
+
// is base-independent.
|
|
170
|
+
const restored = [resp(100, 'opus', 1000), resp(101, 'opus', 2000), resp(102, 'opus', 4000)];
|
|
171
|
+
const liveSuffix = [resp(103, 'opus', 30)];
|
|
172
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
173
|
+
|
|
174
|
+
await plugin.hooks!.onInit!(ctxFor(restored));
|
|
175
|
+
await plugin.hooks!.onShutdown!(ctxFor([...restored, ...liveSuffix]));
|
|
176
|
+
|
|
177
|
+
const file = await loadUsageStats(statsPath);
|
|
178
|
+
// Only the single live call (30 tokens) — never the 7000 restored tokens.
|
|
179
|
+
expect(file.models['anthropic/opus']!.calls).toBe(1);
|
|
180
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(30);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('counts the post-/new (log clear/reset) suffix that restarts at seq 0', async () => {
|
|
184
|
+
// Regression: `Session.reset()` (the `/new` flow) calls `log.clear()`, which
|
|
185
|
+
// empties the events AND resets base to 0 — restarting the authoritative seq
|
|
186
|
+
// stream WITHOUT re-firing onInit. A boundary captured at init (e.g. 102 from
|
|
187
|
+
// a restored prefix) would then drop every post-/new event (seqs 0,1,2…),
|
|
188
|
+
// silently losing the whole session's usage. The plugin must re-baseline on
|
|
189
|
+
// clear and fold the post-wipe suffix.
|
|
190
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
191
|
+
const log = new FakeLog([resp(100, 'opus', 1000), resp(101, 'opus', 2000)]);
|
|
192
|
+
|
|
193
|
+
await plugin.hooks!.onInit!(ctxForLog(log)); // boundary captured at seq 101
|
|
194
|
+
log.clear(); // /new — events emptied, base reset to 0, onClear fires
|
|
195
|
+
log.push(resp(0, 'opus', 40), resp(1, 'opus', 60)); // post-/new live events
|
|
196
|
+
|
|
197
|
+
await plugin.hooks!.onShutdown!(ctxForLog(log));
|
|
198
|
+
|
|
199
|
+
const file = await loadUsageStats(statsPath);
|
|
200
|
+
// Both post-/new calls counted (100 tokens) — not dropped as <= old boundary.
|
|
201
|
+
expect(file.models['anthropic/opus']!.calls).toBe(2);
|
|
202
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(100);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('isolates the cursor per session so concurrent sessions on one instance never clobber each other', async () => {
|
|
206
|
+
// One shared plugin instance, two interleaved session lifecycles. A single
|
|
207
|
+
// scalar cursor would let session B's onInit overwrite session A's boundary;
|
|
208
|
+
// keying by sessionId keeps both correct.
|
|
209
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
210
|
+
const sidA = asSessionId('A');
|
|
211
|
+
const sidB = asSessionId('B');
|
|
212
|
+
const logA = new FakeLog([resp(0, 'opus', 1000)]); // A restored 1 event (seq 0)
|
|
213
|
+
const logB = new FakeLog([]); // B is a fresh run
|
|
214
|
+
|
|
215
|
+
await plugin.hooks!.onInit!(ctxForLog(logA, sidA)); // A boundary = 0
|
|
216
|
+
await plugin.hooks!.onInit!(ctxForLog(logB, sidB)); // B boundary = null (would clobber A's scalar)
|
|
217
|
+
|
|
218
|
+
logA.push(resp(1, 'opus', 5)); // A's single live call
|
|
219
|
+
logB.push(resp(0, 'opus', 7)); // B's single live call
|
|
220
|
+
|
|
221
|
+
await plugin.hooks!.onShutdown!(ctxForLog(logA, sidA));
|
|
222
|
+
await plugin.hooks!.onShutdown!(ctxForLog(logB, sidB));
|
|
223
|
+
|
|
224
|
+
const file = await loadUsageStats(statsPath);
|
|
225
|
+
// A counts only its live call (5), B counts its only call (7) → 2 calls, 12.
|
|
226
|
+
expect(file.models['anthropic/opus']!.calls).toBe(2);
|
|
227
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(12);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('folds the whole log when onShutdown fires without a preceding onInit (cursor defaults to null)', async () => {
|
|
231
|
+
// Documents the deliberate null-cursor default: the "counted exactly once on
|
|
232
|
+
// resume" guarantee hinges on onInit running first to capture the restored
|
|
233
|
+
// prefix length. On an abnormal lifecycle (onShutdown with no onInit) the
|
|
234
|
+
// cursor is absent → treated as null → the entire log is folded — accepted
|
|
235
|
+
// behavior, asserted here so a future guard change is a conscious one.
|
|
236
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
237
|
+
const events = [resp(0, 'opus', 100), resp(1, 'opus', 50)];
|
|
238
|
+
|
|
239
|
+
await plugin.hooks!.onShutdown!(ctxFor(events)); // no onInit called
|
|
240
|
+
|
|
241
|
+
const file = await loadUsageStats(statsPath);
|
|
242
|
+
expect(file.models['anthropic/opus']!.calls).toBe(2);
|
|
243
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(150);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('onShutdown degrades to a no-op (resolves, writes nothing) when the reader throws', async () => {
|
|
247
|
+
// Worst case: a hostile/half-implemented reader whose `ofType` throws on the
|
|
248
|
+
// 5s-timeboxed shutdown path. The plugin must swallow it — usage stats are an
|
|
249
|
+
// optional, best-effort layer — and resolve instead of rejecting the hook
|
|
250
|
+
// (which the host would log as a spurious failure). No file is written.
|
|
251
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
252
|
+
const hostile = {
|
|
253
|
+
length: 1,
|
|
254
|
+
at: () => undefined,
|
|
255
|
+
slice: () => [],
|
|
256
|
+
ofType: () => {
|
|
257
|
+
throw new Error('reader exploded');
|
|
258
|
+
},
|
|
259
|
+
byTurn: () => [],
|
|
260
|
+
toJSON: () => [],
|
|
261
|
+
} as unknown as EventLogReader;
|
|
262
|
+
const ctx = { sessionId: sid, cwd: '/tmp', log: hostile, env: {} } as AppContext;
|
|
263
|
+
|
|
264
|
+
await expect(plugin.hooks!.onShutdown!(ctx)).resolves.toBeUndefined();
|
|
265
|
+
// Nothing folded → file never created.
|
|
266
|
+
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('onInit degrades gracefully when boundarySeq/onClear throw, still counting the run', async () => {
|
|
270
|
+
// A reader that throws from `ofType` (the boundarySeq fallback) AND from
|
|
271
|
+
// `onClear` at init time must not crash init. The cursor falls back to null
|
|
272
|
+
// (fold-whole-suffix default), so a subsequent shutdown with a WORKING reader
|
|
273
|
+
// still records the run rather than silently losing it.
|
|
274
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
275
|
+
const throwingInit = {
|
|
276
|
+
length: 3, // non-empty → boundarySeq won't early-return; no baseSeq → hits ofType
|
|
277
|
+
at: () => undefined,
|
|
278
|
+
slice: () => [],
|
|
279
|
+
ofType: () => {
|
|
280
|
+
throw new Error('ofType exploded at init');
|
|
281
|
+
},
|
|
282
|
+
byTurn: () => [],
|
|
283
|
+
toJSON: () => [],
|
|
284
|
+
onClear: () => {
|
|
285
|
+
throw new Error('onClear exploded at init');
|
|
286
|
+
},
|
|
287
|
+
} as unknown as EventLogReader;
|
|
288
|
+
const initCtx = { sessionId: sid, cwd: '/tmp', log: throwingInit, env: {} } as AppContext;
|
|
289
|
+
|
|
290
|
+
// onInit is synchronous; it must not throw despite ofType + onClear both
|
|
291
|
+
// throwing. (`Promise.resolve` tolerates the sync void return.)
|
|
292
|
+
expect(() => plugin.hooks!.onInit!(initCtx)).not.toThrow();
|
|
293
|
+
|
|
294
|
+
// Now shut down with a healthy reader: null cursor folds the whole log.
|
|
295
|
+
await plugin.hooks!.onShutdown!(ctxFor([resp(0, 'opus', 10), resp(1, 'opus', 20)]));
|
|
296
|
+
|
|
297
|
+
const file = await loadUsageStats(statsPath);
|
|
298
|
+
expect(file.models['anthropic/opus']!.calls).toBe(2);
|
|
299
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(30);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('onShutdown tears down the onClear listener even when the fold/merge path throws (no leak)', async () => {
|
|
303
|
+
// The unsubscribe + map deletes must run before (and regardless of) the
|
|
304
|
+
// fold/merge, so a throwing reader can't strand the onClear listener or leave
|
|
305
|
+
// the cursor map growing unbounded under a one-instance-many-sessions host.
|
|
306
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
307
|
+
let unsubscribed = false;
|
|
308
|
+
let throwFromOfType = false;
|
|
309
|
+
const log = {
|
|
310
|
+
length: 0,
|
|
311
|
+
at: () => undefined,
|
|
312
|
+
slice: () => [],
|
|
313
|
+
ofType: () => {
|
|
314
|
+
if (throwFromOfType) throw new Error('shutdown reader exploded');
|
|
315
|
+
return [];
|
|
316
|
+
},
|
|
317
|
+
byTurn: () => [],
|
|
318
|
+
toJSON: () => [],
|
|
319
|
+
onClear: () => () => {
|
|
320
|
+
unsubscribed = true;
|
|
321
|
+
},
|
|
322
|
+
} as unknown as EventLogReader;
|
|
323
|
+
const ctx = { sessionId: sid, cwd: '/tmp', log, env: {} } as AppContext;
|
|
324
|
+
|
|
325
|
+
await plugin.hooks!.onInit!(ctx); // registers the clear listener (ofType ok: empty log path)
|
|
326
|
+
throwFromOfType = true; // now the fold path will throw on shutdown
|
|
327
|
+
await expect(plugin.hooks!.onShutdown!(ctx)).resolves.toBeUndefined();
|
|
328
|
+
// Listener was unsubscribed despite the fold throwing → no leak.
|
|
329
|
+
expect(unsubscribed).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('degrades to a no-op when the reader returns a non-array from ofType (not just when it throws)', async () => {
|
|
333
|
+
// Worst case beyond a throwing reader: a half-implemented/hostile reader
|
|
334
|
+
// whose `ofType` returns garbage (a number, null) instead of an array. The
|
|
335
|
+
// plugin does `responses.filter(...)`, which raises a TypeError on a
|
|
336
|
+
// non-array — it must be swallowed by the same best-effort guard, resolving
|
|
337
|
+
// the hook and writing nothing rather than crashing the timeboxed shutdown.
|
|
338
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
339
|
+
for (const bogus of [42, null, 'nope', { length: 1 }] as unknown[]) {
|
|
340
|
+
const garbageReader = {
|
|
341
|
+
length: 1,
|
|
342
|
+
at: () => undefined,
|
|
343
|
+
slice: () => [],
|
|
344
|
+
ofType: () => bogus,
|
|
345
|
+
byTurn: () => [],
|
|
346
|
+
toJSON: () => [],
|
|
347
|
+
} as unknown as EventLogReader;
|
|
348
|
+
const ctx = { sessionId: sid, cwd: '/tmp', log: garbageReader, env: {} } as AppContext;
|
|
349
|
+
await expect(plugin.hooks!.onShutdown!(ctx)).resolves.toBeUndefined();
|
|
350
|
+
}
|
|
351
|
+
// No call ever produced a fold-able array → file never created.
|
|
352
|
+
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('onShutdown for a session that never ran onInit folds the whole log and leaves no leaked state', async () => {
|
|
356
|
+
// Abnormal lifecycle on a SHARED instance: onShutdown arrives for a session
|
|
357
|
+
// id the plugin never saw onInit for (absent cursor → treated as null →
|
|
358
|
+
// whole-log fold). It must not throw on the map.delete of an absent key, and
|
|
359
|
+
// a SECOND shutdown for the same id must behave identically (idempotent,
|
|
360
|
+
// never resurrecting a stale cursor) — proving the map self-cleans even on
|
|
361
|
+
// the no-onInit path so it can't grow unbounded under a many-sessions host.
|
|
362
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
363
|
+
const events = [resp(0, 'opus', 100)];
|
|
364
|
+
|
|
365
|
+
await expect(plugin.hooks!.onShutdown!(ctxFor(events))).resolves.toBeUndefined();
|
|
366
|
+
// A repeat shutdown re-folds the same whole log (cursor still absent → null),
|
|
367
|
+
// so it must not throw; the aggregate doubles, which is the documented
|
|
368
|
+
// whole-log-fold default, asserted so a future guard change is deliberate.
|
|
369
|
+
await expect(plugin.hooks!.onShutdown!(ctxFor(events))).resolves.toBeUndefined();
|
|
370
|
+
|
|
371
|
+
const file = await loadUsageStats(statsPath);
|
|
372
|
+
expect(file.models['anthropic/opus']!.calls).toBe(2);
|
|
373
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(200);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('a re-init of the same session id replaces (does not stack) the onClear listener', async () => {
|
|
377
|
+
// Worst case under a host that re-dispatches onInit for the same session id
|
|
378
|
+
// without an intervening onShutdown (e.g. a buggy/abnormal lifecycle, or a
|
|
379
|
+
// resume that re-inits). Each init must REPLACE the prior onClear listener,
|
|
380
|
+
// not add a second one — otherwise listeners would accumulate (a leak) and a
|
|
381
|
+
// single clear() would fire stale closures. We assert the real FakeLog only
|
|
382
|
+
// ever holds one listener across repeated inits, and exactly zero after
|
|
383
|
+
// shutdown.
|
|
384
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
385
|
+
const log = new FakeLog([resp(0, 'opus', 10)]);
|
|
386
|
+
|
|
387
|
+
await plugin.hooks!.onInit!(ctxForLog(log));
|
|
388
|
+
await plugin.hooks!.onInit!(ctxForLog(log));
|
|
389
|
+
await plugin.hooks!.onInit!(ctxForLog(log));
|
|
390
|
+
expect(log.clearListenerCount).toBe(1); // replaced each time, never stacked
|
|
391
|
+
|
|
392
|
+
await plugin.hooks!.onShutdown!(ctxForLog(log));
|
|
393
|
+
expect(log.clearListenerCount).toBe(0); // fully torn down
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('preserves a valid resume boundary when onClear registration throws (no double-count)', async () => {
|
|
397
|
+
// Regression: `boundarySeq` succeeds (a clean restored prefix, seqs 100..102)
|
|
398
|
+
// but the reader's `onClear` registration throws. The boundary is the
|
|
399
|
+
// load-bearing correctness value — it must SURVIVE the onClear failure.
|
|
400
|
+
// Discarding it (resetting the cursor to null) would re-fold the entire
|
|
401
|
+
// restored prefix on shutdown and DOUBLE-COUNT 7000 restored tokens into the
|
|
402
|
+
// lifetime aggregate. Only the single live call (30) may be counted.
|
|
403
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
404
|
+
const restored = [resp(100, 'opus', 1000), resp(101, 'opus', 2000), resp(102, 'opus', 4000)];
|
|
405
|
+
const onClearThrows = {
|
|
406
|
+
length: restored.length,
|
|
407
|
+
baseSeq: 100, // boundarySeq takes the O(1) path → 100 + 3 - 1 = 102
|
|
408
|
+
at: () => undefined,
|
|
409
|
+
slice: () => [],
|
|
410
|
+
ofType: ((type: string) =>
|
|
411
|
+
type === 'provider_response' ? restored : []) as EventLogReader['ofType'],
|
|
412
|
+
byTurn: () => [],
|
|
413
|
+
toJSON: () => restored,
|
|
414
|
+
onClear: () => {
|
|
415
|
+
throw new Error('onClear registration exploded');
|
|
416
|
+
},
|
|
417
|
+
} as unknown as EventLogReader;
|
|
418
|
+
const initCtx = { sessionId: sid, cwd: '/tmp', log: onClearThrows, env: {} } as AppContext;
|
|
419
|
+
|
|
420
|
+
expect(() => plugin.hooks!.onInit!(initCtx)).not.toThrow();
|
|
421
|
+
// Shut down with restored prefix + one live call. The preserved boundary (102)
|
|
422
|
+
// must exclude every restored response and include only the live one.
|
|
423
|
+
await plugin.hooks!.onShutdown!(ctxFor([...restored, resp(103, 'opus', 30)]));
|
|
424
|
+
|
|
425
|
+
const file = await loadUsageStats(statsPath);
|
|
426
|
+
expect(file.models['anthropic/opus']!.calls).toBe(1);
|
|
427
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(30); // not 7030
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('actually tears down the real onClear listener on shutdown (no leaked subscription)', async () => {
|
|
431
|
+
// The prior "no leak" test used a hand-rolled fake whose unsubscribe just
|
|
432
|
+
// flipped a flag; it could not prove the real subscription was removed. Here
|
|
433
|
+
// the stateful FakeLog tracks live listeners, so we assert the count returns
|
|
434
|
+
// to zero after shutdown — and that a post-shutdown clear() can't resurrect a
|
|
435
|
+
// cursor entry by firing a stale listener.
|
|
436
|
+
const plugin = buildUsageStatsPlugin({ statsPath });
|
|
437
|
+
const log = new FakeLog([resp(0, 'opus', 100)]);
|
|
438
|
+
|
|
439
|
+
await plugin.hooks!.onInit!(ctxForLog(log));
|
|
440
|
+
expect(log.clearListenerCount).toBe(1); // listener registered
|
|
441
|
+
|
|
442
|
+
log.push(resp(1, 'opus', 50));
|
|
443
|
+
await plugin.hooks!.onShutdown!(ctxForLog(log));
|
|
444
|
+
expect(log.clearListenerCount).toBe(0); // torn down — no leak
|
|
445
|
+
|
|
446
|
+
// A clear() now fires no plugin listener; a second shutdown sees an absent
|
|
447
|
+
// cursor (null) and folds the (post-clear, empty) log → still no crash.
|
|
448
|
+
log.clear();
|
|
449
|
+
await expect(plugin.hooks!.onShutdown!(ctxForLog(log))).resolves.toBeUndefined();
|
|
450
|
+
|
|
451
|
+
const file = await loadUsageStats(statsPath);
|
|
452
|
+
// Only the first run's live call (50) — the restored 100 was excluded, and
|
|
453
|
+
// the post-shutdown clear added nothing.
|
|
454
|
+
expect(file.models['anthropic/opus']!.calls).toBe(1);
|
|
455
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(50);
|
|
456
|
+
});
|
|
457
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
definePlugin,
|
|
3
|
+
summarizeTokensByModel,
|
|
4
|
+
type EventLogReader,
|
|
5
|
+
type Plugin,
|
|
6
|
+
type SessionId,
|
|
7
|
+
} from '@moxxy/sdk';
|
|
8
|
+
import { mergeUsageStats } from '@moxxy/core';
|
|
9
|
+
|
|
10
|
+
export interface BuildUsageStatsPluginOptions {
|
|
11
|
+
/** Override the on-disk aggregate path. Tests inject a tmp file here. */
|
|
12
|
+
readonly statsPath?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Optional capabilities the concrete `EventLog` exposes beyond the
|
|
17
|
+
* `EventLogReader` contract. `ctx.log` is the live `EventLog` at runtime
|
|
18
|
+
* (`Session.appContext()` hands us `log.asReader()`, which returns the log
|
|
19
|
+
* itself), so we duck-type these. A reader that lacks them (e.g. a test fake)
|
|
20
|
+
* just degrades to the contract-only path — never crashes.
|
|
21
|
+
*/
|
|
22
|
+
interface EventLogExtras {
|
|
23
|
+
/** Seq of the first held event; lets us compute the boundary in O(1). */
|
|
24
|
+
readonly baseSeq?: number;
|
|
25
|
+
/** Fires after `clear()`/`/new` empties the log and restarts seqs at 0. */
|
|
26
|
+
onClear?(fn: () => void): () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Records cross-session token usage. On shutdown it folds THIS run's
|
|
31
|
+
* `provider_response` events by `<provider>/<model>` and merges the delta into
|
|
32
|
+
* `~/.moxxy/usage.json` — a forward-going aggregate the `/usage` panel renders
|
|
33
|
+
* and `/usage clear` resets.
|
|
34
|
+
*
|
|
35
|
+
* Resume-safe by construction: restored events are seeded into the log WITHOUT
|
|
36
|
+
* firing subscribers, and `onInit` runs after seeding. `onInit` records the
|
|
37
|
+
* highest restored event seq, and `onShutdown` folds only events with a
|
|
38
|
+
* strictly greater seq — the live suffix of this run. A session's usage is
|
|
39
|
+
* therefore counted exactly once — when it closes — never re-counted on resume,
|
|
40
|
+
* and the seq boundary stays correct even on a rebased mirror (baseSeq > 0).
|
|
41
|
+
*
|
|
42
|
+
* `/new`-safe: `Session.reset()` calls `log.clear()`, which restarts the
|
|
43
|
+
* authoritative seq stream at 0 WITHOUT re-firing `onInit`. Without reacting to
|
|
44
|
+
* that, the post-`/new` events (seqs 0,1,2…) would all fall at or below the
|
|
45
|
+
* pre-`/new` boundary and be silently dropped. We subscribe to the log's
|
|
46
|
+
* `onClear` and re-baseline the cursor to `null` so the post-`/new` suffix is
|
|
47
|
+
* folded from the new base.
|
|
48
|
+
*
|
|
49
|
+
* Flush-on-close means a hard kill (SIGKILL) drops the in-progress session's
|
|
50
|
+
* delta; that's the accepted trade-off for a single, contention-free write.
|
|
51
|
+
*
|
|
52
|
+
* Best-effort, never load-blocking: both hooks are internally fault-tolerant.
|
|
53
|
+
* A hostile or half-implemented reader (one whose `ofType`/`onClear` throws)
|
|
54
|
+
* degrades — init falls back to the fold-whole-suffix default and shutdown
|
|
55
|
+
* records nothing for the run — instead of rejecting the hook. This holds even
|
|
56
|
+
* when the hooks are invoked directly (outside the host's lifecycle dispatcher).
|
|
57
|
+
*/
|
|
58
|
+
export function buildUsageStatsPlugin(opts: BuildUsageStatsPluginOptions = {}): Plugin {
|
|
59
|
+
// Boundary tracked by event SEQ, not by index/length. `log.length` is a count
|
|
60
|
+
// of held events while `log.slice(from)` is SEQ-addressed; the two only
|
|
61
|
+
// coincide on a base-0 log. On a rebased mirror (baseSeq > 0, e.g. partial
|
|
62
|
+
// replay), a length-as-seq cursor would clamp to the base and re-fold the
|
|
63
|
+
// entire restored prefix, double-counting usage. Capturing the highest seq
|
|
64
|
+
// restored at init and folding only events with a strictly greater seq is
|
|
65
|
+
// base-independent. `null` (no onInit, or a post-`/new` re-baseline) folds the
|
|
66
|
+
// whole log — the documented suffix-from-base default.
|
|
67
|
+
//
|
|
68
|
+
// Keyed by sessionId so one shared plugin instance stays correct across
|
|
69
|
+
// multiple sessions (the runner/thin-client model hosts many in one process);
|
|
70
|
+
// a single scalar would let one session's onInit clobber another's cursor.
|
|
71
|
+
// The map self-cleans in onShutdown, so it cannot grow unbounded.
|
|
72
|
+
const cursors = new Map<SessionId, number | null>();
|
|
73
|
+
// Per-session `onClear` unsubscribers, so we tear the listener down on
|
|
74
|
+
// shutdown rather than leaking one per init.
|
|
75
|
+
const clearUnsubs = new Map<SessionId, () => void>();
|
|
76
|
+
return definePlugin({
|
|
77
|
+
name: '@moxxy/plugin-usage-stats',
|
|
78
|
+
hooks: {
|
|
79
|
+
onInit(ctx) {
|
|
80
|
+
// Usage stats are a best-effort, non-load-blocking layer: a hostile or
|
|
81
|
+
// half-implemented reader (e.g. one whose `ofType`/`onClear` throws)
|
|
82
|
+
// must NEVER take down init.
|
|
83
|
+
//
|
|
84
|
+
// The two steps are isolated so a failure in one can't poison the other.
|
|
85
|
+
// The boundary is the load-bearing correctness value: if `onClear`
|
|
86
|
+
// registration throws AFTER a valid boundary was computed, we must KEEP
|
|
87
|
+
// that boundary — discarding it (resetting to `null`) would re-fold the
|
|
88
|
+
// whole restored prefix on shutdown and DOUBLE-COUNT the resumed
|
|
89
|
+
// session's usage into the lifetime aggregate. A failed boundary
|
|
90
|
+
// computation, by contrast, falls back to `null` (the documented
|
|
91
|
+
// fold-whole-suffix default) so the run is still counted, just from base.
|
|
92
|
+
try {
|
|
93
|
+
cursors.set(ctx.sessionId, boundarySeq(ctx.log));
|
|
94
|
+
} catch {
|
|
95
|
+
cursors.set(ctx.sessionId, null);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const extras = ctx.log as EventLogReader & EventLogExtras;
|
|
99
|
+
if (typeof extras.onClear === 'function') {
|
|
100
|
+
// Replace any stale listener from a prior init of the same session id.
|
|
101
|
+
clearUnsubs.get(ctx.sessionId)?.();
|
|
102
|
+
const unsub = extras.onClear(() => {
|
|
103
|
+
// `clear()` reset the seq stream to base 0; fold the post-wipe
|
|
104
|
+
// suffix from the start rather than against the now-stale boundary.
|
|
105
|
+
cursors.set(ctx.sessionId, null);
|
|
106
|
+
});
|
|
107
|
+
clearUnsubs.set(ctx.sessionId, unsub);
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// No `/new` re-baseline wired for this run — the boundary captured
|
|
111
|
+
// above still stands. A session that never clears is unaffected; one
|
|
112
|
+
// that does would over-count the post-`/new` suffix, the lesser evil
|
|
113
|
+
// versus crashing init or dropping the boundary.
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
async onShutdown(ctx) {
|
|
117
|
+
// Tear down the per-session state first and unconditionally, so a later
|
|
118
|
+
// throw in the fold/merge can't strand the `onClear` listener or leave a
|
|
119
|
+
// cursor in the map (which would otherwise grow unbounded under a hosting
|
|
120
|
+
// model that reuses one instance across many sessions).
|
|
121
|
+
try {
|
|
122
|
+
clearUnsubs.get(ctx.sessionId)?.();
|
|
123
|
+
} catch {
|
|
124
|
+
// An unsubscribe that throws must not block the merge below.
|
|
125
|
+
}
|
|
126
|
+
clearUnsubs.delete(ctx.sessionId);
|
|
127
|
+
const initMaxSeq = cursors.has(ctx.sessionId) ? cursors.get(ctx.sessionId)! : null;
|
|
128
|
+
cursors.delete(ctx.sessionId);
|
|
129
|
+
// Best-effort: a throwing reader or a failed fold degrades to "record
|
|
130
|
+
// 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.
|
|
133
|
+
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
|
+
const responses = ctx.log.ofType('provider_response');
|
|
138
|
+
const live =
|
|
139
|
+
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);
|
|
143
|
+
} catch {
|
|
144
|
+
// Drop this run's usage rather than crash shutdown — explicitly the
|
|
145
|
+
// accepted trade-off for an optional, contention-free aggregate.
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Highest held seq — the resume boundary — without copying the log. Prefers the
|
|
154
|
+
* 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.
|
|
160
|
+
*/
|
|
161
|
+
function boundarySeq(log: EventLogReader & EventLogExtras): number | null {
|
|
162
|
+
if (log.length === 0) return null;
|
|
163
|
+
if (typeof log.baseSeq === 'number') return log.baseSeq + log.length - 1;
|
|
164
|
+
let max: number | null = null;
|
|
165
|
+
for (const e of log.ofType('provider_response')) if (max === null || e.seq > max) max = e.seq;
|
|
166
|
+
return max;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export default buildUsageStatsPlugin;
|