claude-usage-limits 1.7.0 → 1.8.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +76 -12
- package/bin/cli.js +2 -0
- package/commands/session.md +13 -0
- package/hooks/hooks.json +23 -1
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +29 -1
- package/skills/usage-limits/references/how-it-works.md +15 -0
- package/skills/usage-limits/references/tactics.md +1 -1
- package/skills/usage-limits/scripts/brief.js +153 -10
- package/skills/usage-limits/scripts/pulse.js +6 -4
- package/skills/usage-limits/scripts/sessionend.js +47 -0
- package/skills/usage-limits/scripts/stop.js +55 -0
- package/skills/usage-limits/scripts/tally.js +378 -0
- package/skills/usage-limits/scripts/usage.js +496 -66
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// The end-of-reply tally.
|
|
5
|
+
//
|
|
6
|
+
// Runs as the Stop hook, after Claude has finished a reply, and puts one line
|
|
7
|
+
// in front of the user saying what that reply cost and what the session has
|
|
8
|
+
// cost so far. It costs the model nothing: the line goes to the person, not
|
|
9
|
+
// into the context, and the numbers come from bytes of the transcript that
|
|
10
|
+
// have already been written.
|
|
11
|
+
//
|
|
12
|
+
// It must never exit with code 2. On this event that would stop Claude from
|
|
13
|
+
// stopping.
|
|
14
|
+
|
|
15
|
+
const usage = require('./usage.js');
|
|
16
|
+
const host = require('./host.js');
|
|
17
|
+
const tally = require('./tally.js');
|
|
18
|
+
|
|
19
|
+
async function run(now, hookInput) {
|
|
20
|
+
if (String(process.env.USAGE_LIMITS_TALLY || '').toLowerCase() === 'off') return '';
|
|
21
|
+
usage.setHost(host.detect(process.argv.slice(2), process.env));
|
|
22
|
+
|
|
23
|
+
const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
|
|
24
|
+
const transcript = hookInput && hookInput.transcript_path ? hookInput.transcript_path : null;
|
|
25
|
+
if (!sessionId || !transcript) return '';
|
|
26
|
+
|
|
27
|
+
const all = tally.readState();
|
|
28
|
+
const { session, delta, created } = tally.update(all, sessionId, transcript, now, {
|
|
29
|
+
cwd: hookInput.cwd || null,
|
|
30
|
+
});
|
|
31
|
+
tally.writeState(tally.trim(all));
|
|
32
|
+
|
|
33
|
+
// The first time a session is seen, everything read is history rather than
|
|
34
|
+
// the reply that just finished, so only the total is shown.
|
|
35
|
+
return JSON.stringify({
|
|
36
|
+
systemMessage: tally.formatTally(session, created ? null : delta, tally.pricing(now)),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (require.main === module) {
|
|
41
|
+
tally
|
|
42
|
+
.readHookInput()
|
|
43
|
+
.then((input) => run(Date.now(), input))
|
|
44
|
+
.then(
|
|
45
|
+
(text) => {
|
|
46
|
+
if (text) process.stdout.write(text + '\n');
|
|
47
|
+
process.exit(0);
|
|
48
|
+
},
|
|
49
|
+
() => {
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { run };
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// What one session has spent, kept as it goes.
|
|
4
|
+
//
|
|
5
|
+
// The report answers "how much is left". This answers the other question,
|
|
6
|
+
// "how much did that cost", at the moment it is most useful: right after a
|
|
7
|
+
// reply lands, and when the session closes. It is fed by the Stop and
|
|
8
|
+
// SessionEnd hooks, which run after every reply, so it has to be cheap in the
|
|
9
|
+
// common case. It is: each run reads only the bytes of the transcript that
|
|
10
|
+
// were written since the last one, and keeps the running totals on disk.
|
|
11
|
+
//
|
|
12
|
+
// A session's spend includes its subagents. Their transcripts are written
|
|
13
|
+
// beside the session's own, under <session id>/subagents/, and they are the
|
|
14
|
+
// same budget whichever file they landed in.
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const usage = require('./usage.js');
|
|
21
|
+
|
|
22
|
+
const KEEP_SESSIONS = 50;
|
|
23
|
+
|
|
24
|
+
// Message ids remembered across sessions. A forked session copies the history
|
|
25
|
+
// it came from into a new file under a new id, and without these it would be
|
|
26
|
+
// billed for turns it never made.
|
|
27
|
+
const KEEP_IDS = 3000;
|
|
28
|
+
|
|
29
|
+
const IDS_KEY = '_ids';
|
|
30
|
+
|
|
31
|
+
function configDir() {
|
|
32
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stateFile() {
|
|
36
|
+
const dir = usage.isCodex() ? require('./codex.js').homeDir() : configDir();
|
|
37
|
+
return path.join(dir, 'usage-limits-sessions.json');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readState() {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
|
|
43
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeState(all) {
|
|
50
|
+
try {
|
|
51
|
+
const file = stateFile();
|
|
52
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
53
|
+
fs.writeFileSync(file, JSON.stringify(all), 'utf8');
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// Losing the total costs one re-read of the transcript. Failing the hook
|
|
56
|
+
// that runs after every reply is not worth avoiding that.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isSession(value) {
|
|
61
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function emptySession(project, cwd) {
|
|
65
|
+
return {
|
|
66
|
+
project: project || null,
|
|
67
|
+
cwd: cwd || null,
|
|
68
|
+
firstAt: null,
|
|
69
|
+
lastAt: null,
|
|
70
|
+
updatedAt: null,
|
|
71
|
+
endedAt: null,
|
|
72
|
+
reason: null,
|
|
73
|
+
prompts: 0,
|
|
74
|
+
turns: 0,
|
|
75
|
+
subagentTurns: 0,
|
|
76
|
+
tokens: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 },
|
|
77
|
+
cost: 0,
|
|
78
|
+
models: {},
|
|
79
|
+
context: null,
|
|
80
|
+
cursors: {},
|
|
81
|
+
lastReply: null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The complete lines written to a file since `from`, and where the next read
|
|
86
|
+
// should start. A line still being written is left for next time, which is
|
|
87
|
+
// why the cursor stops at the last newline rather than the end of the file.
|
|
88
|
+
function readNewLines(file, from) {
|
|
89
|
+
let size;
|
|
90
|
+
try {
|
|
91
|
+
size = fs.statSync(file).size;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return { lines: [], next: Number.isFinite(from) && from > 0 ? from : 0, reset: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let start = Number.isFinite(from) && from > 0 ? from : 0;
|
|
97
|
+
let reset = false;
|
|
98
|
+
// Shorter than where we left off means the file was replaced. Read it again
|
|
99
|
+
// from the top; the message ids stop anything being counted twice.
|
|
100
|
+
if (size < start) {
|
|
101
|
+
start = 0;
|
|
102
|
+
reset = true;
|
|
103
|
+
}
|
|
104
|
+
if (size === start) return { lines: [], next: start, reset };
|
|
105
|
+
|
|
106
|
+
const buffer = Buffer.alloc(size - start);
|
|
107
|
+
const fd = fs.openSync(file, 'r');
|
|
108
|
+
try {
|
|
109
|
+
fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
110
|
+
} finally {
|
|
111
|
+
fs.closeSync(fd);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const lastNewline = buffer.lastIndexOf(0x0a);
|
|
115
|
+
if (lastNewline === -1) return { lines: [], next: start, reset };
|
|
116
|
+
|
|
117
|
+
const lines = buffer.subarray(0, lastNewline).toString('utf8').split('\n');
|
|
118
|
+
return {
|
|
119
|
+
lines: lines.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)),
|
|
120
|
+
next: start + lastNewline + 1,
|
|
121
|
+
reset,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Where Claude Code writes the transcripts of the subagents a session spawned.
|
|
126
|
+
function subagentFiles(transcriptPath, sessionId) {
|
|
127
|
+
const dir = path.join(path.dirname(transcriptPath), sessionId, 'subagents');
|
|
128
|
+
let names;
|
|
129
|
+
try {
|
|
130
|
+
names = fs.readdirSync(dir);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
return names
|
|
135
|
+
.filter((name) => name.endsWith('.jsonl'))
|
|
136
|
+
.sort()
|
|
137
|
+
.map((name) => path.join(dir, name));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function apply(session, event, sidechain, delta) {
|
|
141
|
+
if (sidechain) {
|
|
142
|
+
session.subagentTurns += 1;
|
|
143
|
+
delta.subagentTurns += 1;
|
|
144
|
+
} else {
|
|
145
|
+
session.turns += 1;
|
|
146
|
+
delta.turns += 1;
|
|
147
|
+
}
|
|
148
|
+
session.cost += event.cost;
|
|
149
|
+
delta.cost += event.cost;
|
|
150
|
+
delta.tokens += event.tokens;
|
|
151
|
+
|
|
152
|
+
const parts = event.parts || {};
|
|
153
|
+
session.tokens.input += parts.input || 0;
|
|
154
|
+
session.tokens.cacheWrite += parts.cacheWrite || 0;
|
|
155
|
+
session.tokens.cacheRead += parts.cacheRead || 0;
|
|
156
|
+
session.tokens.output += parts.output || 0;
|
|
157
|
+
session.tokens.reasoning += parts.reasoning || 0;
|
|
158
|
+
|
|
159
|
+
const id = event.model || 'unknown';
|
|
160
|
+
if (!session.models[id]) session.models[id] = { turns: 0, cost: 0 };
|
|
161
|
+
session.models[id].turns += 1;
|
|
162
|
+
session.models[id].cost += event.cost;
|
|
163
|
+
|
|
164
|
+
// The context that matters is the main thread's own: that is what every
|
|
165
|
+
// later call in this session re-reads.
|
|
166
|
+
if (!sidechain && Number.isFinite(event.context)) session.context = event.context;
|
|
167
|
+
|
|
168
|
+
if (session.firstAt === null || event.at < session.firstAt) session.firstAt = event.at;
|
|
169
|
+
if (session.lastAt === null || event.at > session.lastAt) session.lastAt = event.at;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Brings one session's totals up to date from its transcript and returns both
|
|
173
|
+
// the totals and what this call added, which is what the reply just finished
|
|
174
|
+
// cost. Mutates `all`, which the caller writes back.
|
|
175
|
+
function update(all, sessionId, transcriptPath, now, options) {
|
|
176
|
+
const opts = options || {};
|
|
177
|
+
const project = path.basename(path.dirname(transcriptPath));
|
|
178
|
+
|
|
179
|
+
let session = all[sessionId];
|
|
180
|
+
// A session seen for the first time may already have a long transcript
|
|
181
|
+
// behind it, so what this call reads is "so far", not one reply.
|
|
182
|
+
const created = !isSession(session);
|
|
183
|
+
if (created) {
|
|
184
|
+
session = emptySession(project, opts.cwd);
|
|
185
|
+
all[sessionId] = session;
|
|
186
|
+
}
|
|
187
|
+
if (!session.project) session.project = project;
|
|
188
|
+
if (opts.cwd) session.cwd = opts.cwd;
|
|
189
|
+
if (!session.cursors || typeof session.cursors !== 'object') session.cursors = {};
|
|
190
|
+
|
|
191
|
+
const seen = new Set(Array.isArray(all[IDS_KEY]) ? all[IDS_KEY] : []);
|
|
192
|
+
const delta = { turns: 0, subagentTurns: 0, tokens: 0, cost: 0 };
|
|
193
|
+
|
|
194
|
+
const files = [{ file: transcriptPath, sidechain: false }].concat(
|
|
195
|
+
subagentFiles(transcriptPath, sessionId).map((file) => ({ file, sidechain: true }))
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
for (const entry of files) {
|
|
199
|
+
const key = path.resolve(entry.file);
|
|
200
|
+
const read = readNewLines(entry.file, session.cursors[key]);
|
|
201
|
+
for (const line of read.lines) {
|
|
202
|
+
// The prompt that started a subagent was written by Claude, not typed.
|
|
203
|
+
if (!entry.sidechain && usage.promptFrom(line)) {
|
|
204
|
+
session.prompts += 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const event = usage.eventFrom(line, seen, project);
|
|
208
|
+
if (!event || event.rejected) continue;
|
|
209
|
+
apply(session, event, entry.sidechain || Boolean(event.sidechain), delta);
|
|
210
|
+
}
|
|
211
|
+
session.cursors[key] = read.next;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
all[IDS_KEY] = [...seen].slice(-KEEP_IDS);
|
|
215
|
+
session.updatedAt = now;
|
|
216
|
+
// Only a reply that spent something replaces the last one, or a quiet stop
|
|
217
|
+
// would report the previous reply as free. The first read of a session is
|
|
218
|
+
// not a reply either.
|
|
219
|
+
if (!created && (delta.turns || delta.subagentTurns)) {
|
|
220
|
+
session.lastReply = {
|
|
221
|
+
turns: delta.turns,
|
|
222
|
+
subagentTurns: delta.subagentTurns,
|
|
223
|
+
tokens: delta.tokens,
|
|
224
|
+
cost: delta.cost,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return { session, delta, created };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Newest sessions first, and the remembered ids carried across.
|
|
231
|
+
function trim(all) {
|
|
232
|
+
const kept = {};
|
|
233
|
+
if (Array.isArray(all[IDS_KEY])) kept[IDS_KEY] = all[IDS_KEY];
|
|
234
|
+
const keys = Object.keys(all || {}).filter((key) => key !== IDS_KEY && isSession(all[key]));
|
|
235
|
+
keys.sort((a, b) => (all[b].lastAt || 0) - (all[a].lastAt || 0));
|
|
236
|
+
for (const key of keys.slice(0, KEEP_SESSIONS)) kept[key] = all[key];
|
|
237
|
+
return kept;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Every session on record, newest last activity first.
|
|
241
|
+
function sessions(all) {
|
|
242
|
+
return Object.keys(all || {})
|
|
243
|
+
.filter((key) => key !== IDS_KEY && isSession(all[key]))
|
|
244
|
+
.map((key) => Object.assign({ sessionId: key }, all[key]))
|
|
245
|
+
.sort((a, b) => (b.lastAt || 0) - (a.lastAt || 0));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// What a point of the binding short window costs on this plan, so a session's
|
|
249
|
+
// spend can be said in the unit the limit is measured in. Absent until the
|
|
250
|
+
// report has learned it.
|
|
251
|
+
function pricing(now) {
|
|
252
|
+
try {
|
|
253
|
+
const base = usage.collect(now);
|
|
254
|
+
const learned = usage.calibrationForPlan(usage.readCalibration(), base.planId).learned;
|
|
255
|
+
const five = learned && learned.five_hour;
|
|
256
|
+
if (five && Number.isFinite(five.usdPerPercent) && five.usdPerPercent > 0) {
|
|
257
|
+
return { usdPerPercent: five.usdPerPercent, label: '5-hour' };
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
// Fall through: the tally reads fine without a price per point.
|
|
261
|
+
}
|
|
262
|
+
return {};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Two places, not the report's three-below-a-dollar: this is a total someone
|
|
266
|
+
// reads after every reply, not a per-turn price. Looked up at call time, not
|
|
267
|
+
// load time, because usage.js requires this module back.
|
|
268
|
+
function money(value) {
|
|
269
|
+
return usage.formatMoney(value);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function count(value, noun) {
|
|
273
|
+
const n = value || 0;
|
|
274
|
+
return n + ' ' + noun + (n === 1 ? '' : 's');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function turnsPhrase(turns, subagentTurns) {
|
|
278
|
+
return count(turns, 'turn') + (subagentTurns > 0 ? ' (+' + subagentTurns + ' subagent)' : '');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function totalTokens(tokens) {
|
|
282
|
+
const t = tokens || {};
|
|
283
|
+
return (t.input || 0) + (t.cacheWrite || 0) + (t.cacheRead || 0) + (t.output || 0);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// One line, tokens first because that is the question being answered. Every
|
|
287
|
+
// clause whose number is not known is left out rather than shown as a dash.
|
|
288
|
+
function formatTally(session, delta, options) {
|
|
289
|
+
const opts = options || {};
|
|
290
|
+
const change = delta || {};
|
|
291
|
+
const parts = [];
|
|
292
|
+
|
|
293
|
+
if ((change.turns || 0) + (change.subagentTurns || 0) > 0) {
|
|
294
|
+
parts.push(
|
|
295
|
+
'this reply: ' + turnsPhrase(change.turns, change.subagentTurns) + ', ' +
|
|
296
|
+
usage.formatTokens(change.tokens || 0) + ' tokens, ' + money(change.cost || 0) + '.'
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const tokens = session.tokens || {};
|
|
301
|
+
let line =
|
|
302
|
+
'This session: ' + count(session.prompts, 'prompt') + ', ' +
|
|
303
|
+
turnsPhrase(session.turns, session.subagentTurns) + ', ' +
|
|
304
|
+
usage.formatTokens(totalTokens(tokens)) + ' tokens (' +
|
|
305
|
+
usage.formatTokens(tokens.cacheRead || 0) + ' cache read, ' +
|
|
306
|
+
usage.formatTokens(tokens.output || 0) + ' output), about ' + money(session.cost || 0);
|
|
307
|
+
if (Number.isFinite(opts.usdPerPercent) && opts.usdPerPercent > 0) {
|
|
308
|
+
const points = (session.cost || 0) / opts.usdPerPercent;
|
|
309
|
+
if (points >= 1) {
|
|
310
|
+
line += ', roughly ' + Math.round(points) + ' points of the ' + (opts.label || '5-hour') + ' window';
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
parts.push(line + '.');
|
|
314
|
+
|
|
315
|
+
if (Number.isFinite(session.context) && session.context > 0) {
|
|
316
|
+
parts.push('Context is now about ' + usage.formatTokens(session.context) + ' tokens.');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return '[usage-limits] ' + parts.join(' ');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function formatClosed(session, now) {
|
|
323
|
+
const end = Number.isFinite(session.endedAt) ? session.endedAt : now;
|
|
324
|
+
const ran = Number.isFinite(session.firstAt) ? usage.formatDuration(Math.max(0, end - session.firstAt)) : null;
|
|
325
|
+
return (
|
|
326
|
+
'[usage-limits] session closed' + (ran ? ' after ' + ran : '') + ': ' +
|
|
327
|
+
count(session.prompts, 'prompt') + ', ' + turnsPhrase(session.turns, session.subagentTurns) + ', ' +
|
|
328
|
+
usage.formatTokens(totalTokens(session.tokens)) + ' tokens, about ' + money(session.cost || 0) + '.'
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The hook is handed JSON on stdin. Shared by the two hooks that feed this.
|
|
333
|
+
function readHookInput() {
|
|
334
|
+
return new Promise((resolve) => {
|
|
335
|
+
if (process.stdin.isTTY) return resolve(null);
|
|
336
|
+
let raw = '';
|
|
337
|
+
let settled = false;
|
|
338
|
+
const done = () => {
|
|
339
|
+
if (settled) return;
|
|
340
|
+
settled = true;
|
|
341
|
+
try {
|
|
342
|
+
resolve(raw ? JSON.parse(raw) : null);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
resolve(null);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
// Never hang a hook waiting for input that is not coming.
|
|
348
|
+
const timer = setTimeout(done, 500);
|
|
349
|
+
if (timer.unref) timer.unref();
|
|
350
|
+
process.stdin.setEncoding('utf8');
|
|
351
|
+
process.stdin.on('data', (chunk) => {
|
|
352
|
+
raw += chunk;
|
|
353
|
+
});
|
|
354
|
+
process.stdin.on('end', done);
|
|
355
|
+
process.stdin.on('error', done);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = {
|
|
360
|
+
KEEP_SESSIONS,
|
|
361
|
+
KEEP_IDS,
|
|
362
|
+
IDS_KEY,
|
|
363
|
+
stateFile,
|
|
364
|
+
readState,
|
|
365
|
+
writeState,
|
|
366
|
+
emptySession,
|
|
367
|
+
readNewLines,
|
|
368
|
+
subagentFiles,
|
|
369
|
+
update,
|
|
370
|
+
trim,
|
|
371
|
+
sessions,
|
|
372
|
+
pricing,
|
|
373
|
+
money,
|
|
374
|
+
totalTokens,
|
|
375
|
+
formatTally,
|
|
376
|
+
formatClosed,
|
|
377
|
+
readHookInput,
|
|
378
|
+
};
|