@leo000001/opencode-quota-sidebar 1.0.3 → 1.3.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/README.md +5 -2
- package/dist/cost.js +5 -2
- package/dist/providers/third_party/rightcode.js +12 -11
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +63 -9
- package/dist/storage_parse.js +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/usage.d.ts +1 -0
- package/dist/usage.js +9 -1
- package/dist/usage_service.js +47 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@ Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to i
|
|
|
17
17
|
}
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
Note for OpenCode `>=1.2.15`: TUI settings (`theme`/`keybinds`/`tui`) moved to `tui.json`, but plugin loading still stays in `opencode.json` (`plugin: []`).
|
|
21
|
+
|
|
20
22
|
## Development (build from source)
|
|
21
23
|
|
|
22
24
|
```bash
|
|
@@ -105,6 +107,7 @@ memory on startup. Chunk files remain on disk for historical range scans.
|
|
|
105
107
|
|
|
106
108
|
- Node.js: >= 18 (for `fetch` + `AbortController`)
|
|
107
109
|
- OpenCode: plugin SDK `@opencode-ai/plugin` ^1.2.10
|
|
110
|
+
- OpenCode config split: if you are on `>=1.2.15`, keep this plugin in `opencode.json` and keep TUI-only keys in `tui.json`.
|
|
108
111
|
|
|
109
112
|
## Optional commands
|
|
110
113
|
|
|
@@ -177,8 +180,8 @@ Notes:
|
|
|
177
180
|
- `sidebar.childrenMaxDepth` limits how many levels of nested subagents are traversed (default: `6`, clamped 1–32).
|
|
178
181
|
- `sidebar.childrenMaxSessions` caps the total number of descendant sessions aggregated (default: `128`, clamped 0–2000).
|
|
179
182
|
- `sidebar.childrenConcurrency` controls parallel fetches for descendant session messages (default: `5`, clamped 1–10).
|
|
180
|
-
- `output`
|
|
181
|
-
- API cost
|
|
183
|
+
- `output` includes reasoning tokens (`output = tokens.output + tokens.reasoning`). Reasoning is not rendered as a separate line.
|
|
184
|
+
- API cost bills reasoning tokens at the output rate (same as completion tokens).
|
|
182
185
|
- `quota.providers` is the extensible per-adapter switch map.
|
|
183
186
|
- If API Cost is `$0.00`, it usually means the model/provider has no pricing mapping in OpenCode at the moment, so equivalent API cost cannot be estimated.
|
|
184
187
|
|
package/dist/cost.js
CHANGED
|
@@ -65,9 +65,12 @@ export function guessModelCostDivisor(rates) {
|
|
|
65
65
|
: MODEL_COST_DIVISOR_PER_TOKEN;
|
|
66
66
|
}
|
|
67
67
|
export function calcEquivalentApiCostForMessage(message, rates) {
|
|
68
|
+
// For providers that expose reasoning tokens separately, they are still
|
|
69
|
+
// billed as output/completion tokens (same unit price). Our UI also merges
|
|
70
|
+
// reasoning into the single Output statistic, so API cost should match that.
|
|
71
|
+
const billedOutput = message.tokens.output + message.tokens.reasoning;
|
|
68
72
|
const rawCost = message.tokens.input * rates.input +
|
|
69
|
-
|
|
70
|
-
message.tokens.output * rates.output +
|
|
73
|
+
billedOutput * rates.output +
|
|
71
74
|
message.tokens.cache.read * rates.cacheRead +
|
|
72
75
|
message.tokens.cache.write * rates.cacheWrite;
|
|
73
76
|
const divisor = guessModelCostDivisor(rates);
|
|
@@ -159,18 +159,19 @@ async function fetchRightCodeQuota(ctx) {
|
|
|
159
159
|
const dailyTotal = matched.reduce((sum, subscription) => sum + subscription.dailyTotal, 0);
|
|
160
160
|
const dailyRemaining = matched.reduce((sum, subscription) => sum + subscription.dailyRemaining, 0);
|
|
161
161
|
const dailyPercent = dailyTotal > 0 ? (dailyRemaining / dailyTotal) * 100 : undefined;
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
const parsedExpiries = matched
|
|
163
|
+
.map((subscription) => subscription.expiresAt)
|
|
164
|
+
.filter((iso) => typeof iso === 'string' && !!iso)
|
|
165
|
+
.map((iso) => ({ iso, ts: Date.parse(iso) }))
|
|
166
|
+
.filter((item) => !Number.isNaN(item.ts));
|
|
167
|
+
const uniqueExpiryTimestamps = new Set(parsedExpiries.map((e) => e.ts));
|
|
168
|
+
const hasMultipleExpiries = uniqueExpiryTimestamps.size > 1;
|
|
169
|
+
const expiry = parsedExpiries.reduce((acc, item) => {
|
|
168
170
|
if (!acc)
|
|
169
|
-
return
|
|
171
|
+
return item.iso;
|
|
170
172
|
const existing = Date.parse(acc);
|
|
171
|
-
if (Number.isNaN(existing) ||
|
|
172
|
-
return
|
|
173
|
-
}
|
|
173
|
+
if (Number.isNaN(existing) || item.ts < existing)
|
|
174
|
+
return item.iso;
|
|
174
175
|
return acc;
|
|
175
176
|
}, undefined);
|
|
176
177
|
const windows = [
|
|
@@ -179,7 +180,7 @@ async function fetchRightCodeQuota(ctx) {
|
|
|
179
180
|
showPercent: false,
|
|
180
181
|
remainingPercent: dailyPercent,
|
|
181
182
|
resetAt: expiry,
|
|
182
|
-
resetLabel: 'Exp',
|
|
183
|
+
resetLabel: hasMultipleExpiries ? 'Exp+' : 'Exp',
|
|
183
184
|
},
|
|
184
185
|
];
|
|
185
186
|
const names = matched.map((subscription) => subscription.name).join(', ');
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dateKeyFromTimestamp, normalizeTimestampMs } from './storage_dates.js';
|
|
2
2
|
import { authFilePath, resolveOpencodeDataDir, stateFilePath } from './storage_paths.js';
|
|
3
|
-
import type { QuotaSidebarConfig, QuotaSidebarState, SessionState } from './types.js';
|
|
3
|
+
import type { CachedSessionUsage, IncrementalCursor, QuotaSidebarConfig, QuotaSidebarState, SessionState } from './types.js';
|
|
4
4
|
export { authFilePath, dateKeyFromTimestamp, normalizeTimestampMs, resolveOpencodeDataDir, stateFilePath, };
|
|
5
5
|
export declare const defaultConfig: QuotaSidebarConfig;
|
|
6
6
|
export declare function defaultState(): QuotaSidebarState;
|
|
@@ -32,3 +32,10 @@ export declare function scanSessionsByCreatedRange(statePath: string, startAt: n
|
|
|
32
32
|
}[]>;
|
|
33
33
|
/** Best-effort: remove a session entry from its day chunk (if present). */
|
|
34
34
|
export declare function deleteSessionFromDayChunk(statePath: string, sessionID: string, dateKey: string): Promise<boolean>;
|
|
35
|
+
/** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */
|
|
36
|
+
export declare function updateSessionsInDayChunks(statePath: string, updates: Array<{
|
|
37
|
+
sessionID: string;
|
|
38
|
+
dateKey: string;
|
|
39
|
+
usage: CachedSessionUsage;
|
|
40
|
+
cursor: IncrementalCursor | undefined;
|
|
41
|
+
}>): Promise<number>;
|
package/dist/storage.js
CHANGED
|
@@ -161,6 +161,21 @@ export async function loadState(statePath) {
|
|
|
161
161
|
}
|
|
162
162
|
// ─── State saving ────────────────────────────────────────────────────────────
|
|
163
163
|
const MAX_QUOTA_CACHE_AGE_MS = 24 * 60 * 60 * 1000;
|
|
164
|
+
const dayChunkWriteLocks = new Map();
|
|
165
|
+
async function withDayChunkWriteLock(dateKey, fn) {
|
|
166
|
+
const previous = dayChunkWriteLocks.get(dateKey) || Promise.resolve();
|
|
167
|
+
const run = previous.then(() => fn());
|
|
168
|
+
const release = run.then(() => undefined, () => undefined);
|
|
169
|
+
dayChunkWriteLocks.set(dateKey, release);
|
|
170
|
+
try {
|
|
171
|
+
return await run;
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
if (dayChunkWriteLocks.get(dateKey) === release) {
|
|
175
|
+
dayChunkWriteLocks.delete(dateKey);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
164
179
|
function pruneState(state) {
|
|
165
180
|
const now = Date.now();
|
|
166
181
|
for (const [key, value] of Object.entries(state.quotaCache)) {
|
|
@@ -214,7 +229,7 @@ export async function saveState(statePath, state, options) {
|
|
|
214
229
|
if (!Object.prototype.hasOwnProperty.call(sessionsByDate, dateKey)) {
|
|
215
230
|
return undefined;
|
|
216
231
|
}
|
|
217
|
-
return (async () => {
|
|
232
|
+
return withDayChunkWriteLock(dateKey, async () => {
|
|
218
233
|
const memorySessions = sessionsByDate[dateKey] || {};
|
|
219
234
|
const next = writeAll
|
|
220
235
|
? memorySessions
|
|
@@ -223,7 +238,7 @@ export async function saveState(statePath, state, options) {
|
|
|
223
238
|
...memorySessions,
|
|
224
239
|
};
|
|
225
240
|
await writeDayChunk(rootPath, dateKey, next);
|
|
226
|
-
})
|
|
241
|
+
});
|
|
227
242
|
})
|
|
228
243
|
.filter((promise) => Boolean(promise)));
|
|
229
244
|
}
|
|
@@ -316,11 +331,50 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
316
331
|
/** Best-effort: remove a session entry from its day chunk (if present). */
|
|
317
332
|
export async function deleteSessionFromDayChunk(statePath, sessionID, dateKey) {
|
|
318
333
|
const rootPath = chunkRootPathFromStateFile(statePath);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
334
|
+
return withDayChunkWriteLock(dateKey, async () => {
|
|
335
|
+
const sessions = await readDayChunk(rootPath, dateKey);
|
|
336
|
+
if (!Object.prototype.hasOwnProperty.call(sessions, sessionID))
|
|
337
|
+
return false;
|
|
338
|
+
const next = { ...sessions };
|
|
339
|
+
delete next[sessionID];
|
|
340
|
+
await writeDayChunk(rootPath, dateKey, next);
|
|
341
|
+
return true;
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */
|
|
345
|
+
export async function updateSessionsInDayChunks(statePath, updates) {
|
|
346
|
+
if (updates.length === 0)
|
|
347
|
+
return 0;
|
|
348
|
+
const rootPath = chunkRootPathFromStateFile(statePath);
|
|
349
|
+
const byDate = updates.reduce((acc, item) => {
|
|
350
|
+
const bucket = acc[item.dateKey] || [];
|
|
351
|
+
bucket.push(item);
|
|
352
|
+
acc[item.dateKey] = bucket;
|
|
353
|
+
return acc;
|
|
354
|
+
}, {});
|
|
355
|
+
let written = 0;
|
|
356
|
+
const WRITE_CONCURRENCY = 5;
|
|
357
|
+
await mapConcurrent(Object.entries(byDate), WRITE_CONCURRENCY, async ([dateKey, dateUpdates]) => {
|
|
358
|
+
await withDayChunkWriteLock(dateKey, async () => {
|
|
359
|
+
const sessions = await readDayChunk(rootPath, dateKey);
|
|
360
|
+
const next = { ...sessions };
|
|
361
|
+
let changed = false;
|
|
362
|
+
for (const item of dateUpdates) {
|
|
363
|
+
const existing = next[item.sessionID];
|
|
364
|
+
if (!existing)
|
|
365
|
+
continue;
|
|
366
|
+
next[item.sessionID] = {
|
|
367
|
+
...existing,
|
|
368
|
+
usage: item.usage,
|
|
369
|
+
cursor: item.cursor,
|
|
370
|
+
};
|
|
371
|
+
changed = true;
|
|
372
|
+
}
|
|
373
|
+
if (!changed)
|
|
374
|
+
return;
|
|
375
|
+
await writeDayChunk(rootPath, dateKey, next);
|
|
376
|
+
written++;
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
return written;
|
|
326
380
|
}
|
package/dist/storage_parse.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type CachedProviderUsage = {
|
|
|
49
49
|
assistantMessages: number;
|
|
50
50
|
};
|
|
51
51
|
export type CachedSessionUsage = {
|
|
52
|
+
/** Billing aggregation cache version for cost/apiCost refresh migrations. */
|
|
53
|
+
billingVersion?: number;
|
|
52
54
|
input: number;
|
|
53
55
|
output: number;
|
|
54
56
|
reasoning: number;
|
package/dist/usage.d.ts
CHANGED
package/dist/usage.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export const USAGE_BILLING_CACHE_VERSION = 1;
|
|
1
2
|
export function emptyUsageSummary() {
|
|
2
3
|
return {
|
|
3
4
|
input: 0,
|
|
@@ -189,10 +190,16 @@ export function summarizeMessagesIncremental(entries, existingUsage, cursor, for
|
|
|
189
190
|
time: nextCursor.lastMessageTime ?? cursorTime,
|
|
190
191
|
};
|
|
191
192
|
if (newerThan(candidate, current)) {
|
|
193
|
+
const idsAtCursorTime = new Set(nextCursor.lastMessageIdsAtTime ||
|
|
194
|
+
cursor.lastMessageIdsAtTime ||
|
|
195
|
+
(current.id ? [current.id] : []));
|
|
196
|
+
idsAtCursorTime.add(msg.id);
|
|
192
197
|
nextCursor = {
|
|
193
198
|
lastMessageId: msg.id,
|
|
194
199
|
lastMessageTime: msg.time.completed,
|
|
195
|
-
lastMessageIdsAtTime:
|
|
200
|
+
lastMessageIdsAtTime: candidate.time > current.time
|
|
201
|
+
? [msg.id]
|
|
202
|
+
: Array.from(idsAtCursorTime).sort(),
|
|
196
203
|
};
|
|
197
204
|
}
|
|
198
205
|
else if (nextCursor.lastMessageTime === msg.time.completed) {
|
|
@@ -298,6 +305,7 @@ export function toCachedSessionUsage(summary) {
|
|
|
298
305
|
return acc;
|
|
299
306
|
}, {});
|
|
300
307
|
return {
|
|
308
|
+
billingVersion: USAGE_BILLING_CACHE_VERSION,
|
|
301
309
|
input: summary.input,
|
|
302
310
|
output: summary.output,
|
|
303
311
|
// Always 0 after merge into output; kept for serialization shape.
|
package/dist/usage_service.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { TtlValueCache } from './cache.js';
|
|
2
2
|
import { calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
|
|
3
|
-
import { dateKeyFromTimestamp, scanSessionsByCreatedRange } from './storage.js';
|
|
3
|
+
import { dateKeyFromTimestamp, scanSessionsByCreatedRange, updateSessionsInDayChunks, } from './storage.js';
|
|
4
4
|
import { periodStart } from './period.js';
|
|
5
5
|
import { debug, isRecord, mapConcurrent, swallow } from './helpers.js';
|
|
6
|
-
import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, } from './usage.js';
|
|
6
|
+
import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
|
|
7
7
|
export function createUsageService(deps) {
|
|
8
8
|
const forceRescanSessions = new Set();
|
|
9
9
|
const dirtyGeneration = new Map();
|
|
@@ -189,6 +189,11 @@ export function createUsageService(deps) {
|
|
|
189
189
|
deps.state.sessionDateMap[sessionID] = dateKey;
|
|
190
190
|
deps.persistence.markDirty(dateKey);
|
|
191
191
|
};
|
|
192
|
+
const isUsageBillingCurrent = (cached) => {
|
|
193
|
+
if (!cached)
|
|
194
|
+
return false;
|
|
195
|
+
return cached.billingVersion === USAGE_BILLING_CACHE_VERSION;
|
|
196
|
+
};
|
|
192
197
|
const summarizeSessionUsage = async (sessionID, generationAtStart) => {
|
|
193
198
|
const entries = await loadSessionEntries(sessionID);
|
|
194
199
|
const sessionState = deps.state.sessions[sessionID];
|
|
@@ -206,9 +211,14 @@ export function createUsageService(deps) {
|
|
|
206
211
|
return { usage: empty, persist: false };
|
|
207
212
|
}
|
|
208
213
|
const modelCostMap = await getModelCostMap();
|
|
209
|
-
const
|
|
214
|
+
const staleBillingCache = Boolean(sessionState?.usage) &&
|
|
215
|
+
!isUsageBillingCurrent(sessionState?.usage);
|
|
216
|
+
const forceRescan = forceRescanSessions.has(sessionID) || staleBillingCache;
|
|
210
217
|
if (forceRescan)
|
|
211
218
|
forceRescanSessions.delete(sessionID);
|
|
219
|
+
if (staleBillingCache) {
|
|
220
|
+
debug(`usage cache billing refresh for session ${sessionID}`);
|
|
221
|
+
}
|
|
212
222
|
const { usage, cursor } = summarizeMessagesIncremental(entries, sessionState?.usage, sessionState?.cursor, forceRescan, {
|
|
213
223
|
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
214
224
|
});
|
|
@@ -268,7 +278,7 @@ export function createUsageService(deps) {
|
|
|
268
278
|
const needsFetch = [];
|
|
269
279
|
for (const childID of descendantIDs) {
|
|
270
280
|
const cached = deps.state.sessions[childID]?.usage;
|
|
271
|
-
if (cached && !isDirty(childID)) {
|
|
281
|
+
if (cached && !isDirty(childID) && isUsageBillingCurrent(cached)) {
|
|
272
282
|
mergeUsage(merged, fromCachedSessionUsage(cached, 1));
|
|
273
283
|
}
|
|
274
284
|
else {
|
|
@@ -309,7 +319,9 @@ export function createUsageService(deps) {
|
|
|
309
319
|
return SUBSCRIPTION_API_COST_PROVIDERS.has(canonical);
|
|
310
320
|
});
|
|
311
321
|
};
|
|
312
|
-
const
|
|
322
|
+
const shouldRecomputeUsageCache = (cached) => {
|
|
323
|
+
if (!isUsageBillingCurrent(cached))
|
|
324
|
+
return true;
|
|
313
325
|
if (!hasPricing)
|
|
314
326
|
return false;
|
|
315
327
|
if (cached.assistantMessages <= 0)
|
|
@@ -325,7 +337,7 @@ export function createUsageService(deps) {
|
|
|
325
337
|
const needsFetch = [];
|
|
326
338
|
for (const session of sessions) {
|
|
327
339
|
if (session.state.usage) {
|
|
328
|
-
if (
|
|
340
|
+
if (shouldRecomputeUsageCache(session.state.usage)) {
|
|
329
341
|
needsFetch.push(session);
|
|
330
342
|
}
|
|
331
343
|
else {
|
|
@@ -343,35 +355,58 @@ export function createUsageService(deps) {
|
|
|
343
355
|
if (session.state.usage) {
|
|
344
356
|
return {
|
|
345
357
|
sessionID: session.sessionID,
|
|
358
|
+
dateKey: session.dateKey,
|
|
346
359
|
computed: fromCachedSessionUsage(session.state.usage, 1),
|
|
347
360
|
persist: false,
|
|
361
|
+
cursor: session.state.cursor,
|
|
348
362
|
};
|
|
349
363
|
}
|
|
350
364
|
const empty = emptyUsageSummary();
|
|
351
365
|
empty.sessionCount = 1;
|
|
352
366
|
return {
|
|
353
367
|
sessionID: session.sessionID,
|
|
368
|
+
dateKey: session.dateKey,
|
|
354
369
|
computed: empty,
|
|
355
370
|
persist: false,
|
|
371
|
+
cursor: undefined,
|
|
356
372
|
};
|
|
357
373
|
}
|
|
358
|
-
const { usage: computed } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
|
|
374
|
+
const { usage: computed, cursor } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
|
|
359
375
|
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
360
376
|
});
|
|
361
|
-
return {
|
|
377
|
+
return {
|
|
378
|
+
sessionID: session.sessionID,
|
|
379
|
+
dateKey: session.dateKey,
|
|
380
|
+
computed,
|
|
381
|
+
persist: true,
|
|
382
|
+
cursor,
|
|
383
|
+
};
|
|
362
384
|
});
|
|
363
385
|
let dirty = false;
|
|
364
|
-
|
|
386
|
+
const diskOnlyUpdates = [];
|
|
387
|
+
for (const { sessionID, dateKey, computed, persist, cursor } of fetched) {
|
|
365
388
|
mergeUsage(usage, { ...computed, sessionCount: 0 });
|
|
366
389
|
const memoryState = deps.state.sessions[sessionID];
|
|
367
390
|
if (persist && memoryState) {
|
|
368
391
|
memoryState.usage = toCachedSessionUsage(computed);
|
|
369
|
-
|
|
392
|
+
memoryState.cursor = cursor;
|
|
393
|
+
const resolvedDateKey = deps.state.sessionDateMap[sessionID] ||
|
|
370
394
|
dateKeyFromTimestamp(memoryState.createdAt);
|
|
371
|
-
deps.state.sessionDateMap[sessionID] =
|
|
372
|
-
deps.persistence.markDirty(
|
|
395
|
+
deps.state.sessionDateMap[sessionID] = resolvedDateKey;
|
|
396
|
+
deps.persistence.markDirty(resolvedDateKey);
|
|
373
397
|
dirty = true;
|
|
374
398
|
}
|
|
399
|
+
else if (persist) {
|
|
400
|
+
diskOnlyUpdates.push({
|
|
401
|
+
sessionID,
|
|
402
|
+
dateKey,
|
|
403
|
+
usage: toCachedSessionUsage(computed),
|
|
404
|
+
cursor,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (diskOnlyUpdates.length > 0) {
|
|
409
|
+
await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch(swallow('updateSessionsInDayChunks'));
|
|
375
410
|
}
|
|
376
411
|
if (dirty)
|
|
377
412
|
deps.persistence.scheduleSave();
|