@hienlh/ppm 0.7.4 → 0.7.5
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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/services/claude-usage.service.ts +75 -35
- package/src/services/db.service.ts +65 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.5] - 2026-03-20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Usage limit history (SQLite)**: Claude 5hr/weekly limit snapshots now persisted to `claude_limit_snapshots` table (migration v4) — inserts new row only when utilization or reset-time changes, auto-cleans records older than 7 days
|
|
7
|
+
- **Usage polling interval**: Changed from 60s to 2min; `GET /chat/usage?refresh=1` forces an immediate API fetch and waits before returning the fresh DB snapshot
|
|
8
|
+
- **FE reads from DB**: `refresh=0` reads latest snapshot directly from DB (no Anthropic API call); `refresh=1` waits for fresh fetch then reads DB
|
|
9
|
+
|
|
3
10
|
## [0.7.4] - 2026-03-20
|
|
4
11
|
|
|
5
12
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
insertLimitSnapshot,
|
|
6
|
+
getLatestLimitSnapshot,
|
|
7
|
+
cleanupOldLimitSnapshots,
|
|
8
|
+
type LimitSnapshotRow,
|
|
9
|
+
} from "./db.service.ts";
|
|
4
10
|
|
|
5
11
|
export interface LimitBucket {
|
|
6
12
|
utilization: number;
|
|
@@ -25,12 +31,12 @@ const API_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
|
25
31
|
const API_BETA = "oauth-2025-04-20";
|
|
26
32
|
const USER_AGENT = "claude-code/1.0";
|
|
27
33
|
const FETCH_TIMEOUT = 10_000; // 10s
|
|
28
|
-
const POLL_INTERVAL =
|
|
34
|
+
const POLL_INTERVAL = 120_000; // auto-fetch every 2min
|
|
29
35
|
const RETRY_DELAY = 5_000; // 5s between retries
|
|
30
36
|
const MAX_RETRIES = 3;
|
|
31
37
|
|
|
32
|
-
/**
|
|
33
|
-
let
|
|
38
|
+
/** In-memory accumulator for cost from SDK result events */
|
|
39
|
+
let inMemoryCostUsd = 0;
|
|
34
40
|
|
|
35
41
|
/** Cached OAuth token (read once from Keychain/file) */
|
|
36
42
|
let tokenCache: { token: string; timestamp: number } | null = null;
|
|
@@ -118,12 +124,69 @@ function parseApiBucket(raw: Record<string, any>, windowHours: number): LimitBuc
|
|
|
118
124
|
};
|
|
119
125
|
}
|
|
120
126
|
|
|
121
|
-
/**
|
|
127
|
+
/** Convert DB snapshot row fields back to a LimitBucket (recomputes time-relative fields) */
|
|
128
|
+
function dbBucketToLimitBucket(util: number, resetsAt: string, windowHours: number): LimitBucket {
|
|
129
|
+
const diff = resetsAt ? new Date(resetsAt).getTime() - Date.now() : 0;
|
|
130
|
+
const totalMins = diff > 0 ? Math.ceil(diff / 60_000) : 0;
|
|
131
|
+
return {
|
|
132
|
+
utilization: util,
|
|
133
|
+
resetsAt,
|
|
134
|
+
resetsInMinutes: windowHours <= 5 ? totalMins : null,
|
|
135
|
+
resetsInHours: windowHours > 5 ? Math.round((totalMins / 60) * 100) / 100 : null,
|
|
136
|
+
windowHours,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Return ClaudeUsage from the latest DB snapshot + in-memory cost */
|
|
141
|
+
export function getCachedUsage(): ClaudeUsage {
|
|
142
|
+
const row = getLatestLimitSnapshot();
|
|
143
|
+
const result: ClaudeUsage = {};
|
|
144
|
+
if (inMemoryCostUsd > 0) result.totalCostUsd = inMemoryCostUsd;
|
|
145
|
+
if (!row) return result;
|
|
146
|
+
result.lastFetchedAt = row.recorded_at;
|
|
147
|
+
if (row.five_hour_util != null) result.session = dbBucketToLimitBucket(row.five_hour_util, row.five_hour_resets_at ?? "", 5);
|
|
148
|
+
if (row.weekly_util != null) result.weekly = dbBucketToLimitBucket(row.weekly_util, row.weekly_resets_at ?? "", 168);
|
|
149
|
+
if (row.weekly_opus_util != null) result.weeklyOpus = dbBucketToLimitBucket(row.weekly_opus_util, row.weekly_opus_resets_at ?? "", 168);
|
|
150
|
+
if (row.weekly_sonnet_util != null) result.weeklySonnet = dbBucketToLimitBucket(row.weekly_sonnet_util, row.weekly_sonnet_resets_at ?? "", 168);
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Check if new API data differs from the last DB snapshot enough to warrant a new row */
|
|
155
|
+
function hasChanged(data: ClaudeUsage, last: LimitSnapshotRow | null): boolean {
|
|
156
|
+
if (!last) return true;
|
|
157
|
+
const diff = (a: number | null | undefined, b: number | null) =>
|
|
158
|
+
a != null && (b == null || Math.abs(a - b) > 0.001);
|
|
159
|
+
if (diff(data.session?.utilization, last.five_hour_util)) return true;
|
|
160
|
+
if (diff(data.weekly?.utilization, last.weekly_util)) return true;
|
|
161
|
+
// Detect window reset (resetsAt changed)
|
|
162
|
+
if (data.session?.resetsAt && data.session.resetsAt !== (last.five_hour_resets_at ?? "")) return true;
|
|
163
|
+
if (data.weekly?.resetsAt && data.weekly.resetsAt !== (last.weekly_resets_at ?? "")) return true;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Persist API data to DB if changed, then cleanup old rows */
|
|
168
|
+
function persistIfChanged(data: ClaudeUsage): void {
|
|
169
|
+
const last = getLatestLimitSnapshot();
|
|
170
|
+
if (!hasChanged(data, last)) return;
|
|
171
|
+
insertLimitSnapshot({
|
|
172
|
+
five_hour_util: data.session?.utilization ?? null,
|
|
173
|
+
five_hour_resets_at: data.session?.resetsAt ?? null,
|
|
174
|
+
weekly_util: data.weekly?.utilization ?? null,
|
|
175
|
+
weekly_resets_at: data.weekly?.resetsAt ?? null,
|
|
176
|
+
weekly_opus_util: data.weeklyOpus?.utilization ?? null,
|
|
177
|
+
weekly_opus_resets_at: data.weeklyOpus?.resetsAt ?? null,
|
|
178
|
+
weekly_sonnet_util: data.weeklySonnet?.utilization ?? null,
|
|
179
|
+
weekly_sonnet_resets_at: data.weeklySonnet?.resetsAt ?? null,
|
|
180
|
+
});
|
|
181
|
+
cleanupOldLimitSnapshots();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Fetch with retry logic, persist to DB if changed */
|
|
122
185
|
async function fetchWithRetry(): Promise<void> {
|
|
123
186
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
124
187
|
try {
|
|
125
188
|
const data = await fetchUsageFromApi();
|
|
126
|
-
|
|
189
|
+
persistIfChanged(data);
|
|
127
190
|
return;
|
|
128
191
|
} catch (e) {
|
|
129
192
|
const msg = (e as Error).message ?? "";
|
|
@@ -150,45 +213,22 @@ export function stopUsagePolling(): void {
|
|
|
150
213
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
151
214
|
}
|
|
152
215
|
|
|
153
|
-
/** Get cached usage (fast, synchronous read — FE just reads this) */
|
|
154
|
-
export function getCachedUsage(): ClaudeUsage {
|
|
155
|
-
return cache;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
216
|
/**
|
|
159
|
-
* Merge SDK
|
|
160
|
-
*
|
|
217
|
+
* Merge SDK result cost events into in-memory accumulator.
|
|
218
|
+
* Rate limit utilization from SDK events is ignored — API polling is authoritative.
|
|
161
219
|
*/
|
|
162
220
|
export function updateFromSdkEvent(
|
|
163
|
-
|
|
164
|
-
|
|
221
|
+
_rateLimitType?: string,
|
|
222
|
+
_utilization?: number,
|
|
165
223
|
costUsd?: number,
|
|
166
224
|
): void {
|
|
167
|
-
if (rateLimitType && utilization != null) {
|
|
168
|
-
if (rateLimitType === "five_hour") {
|
|
169
|
-
cache.session = {
|
|
170
|
-
...(cache.session ?? { resetsAt: "", resetsInMinutes: null, resetsInHours: null, windowHours: 5 }),
|
|
171
|
-
utilization,
|
|
172
|
-
};
|
|
173
|
-
} else if (rateLimitType.startsWith("seven_day")) {
|
|
174
|
-
const key: keyof ClaudeUsage =
|
|
175
|
-
rateLimitType === "seven_day_opus" ? "weeklyOpus"
|
|
176
|
-
: rateLimitType === "seven_day_sonnet" ? "weeklySonnet"
|
|
177
|
-
: "weekly";
|
|
178
|
-
cache[key] = {
|
|
179
|
-
...(cache[key] as LimitBucket ?? { resetsAt: "", resetsInMinutes: null, resetsInHours: null, windowHours: 168 }),
|
|
180
|
-
utilization,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
if (!cache.lastFetchedAt) cache.lastFetchedAt = new Date().toISOString();
|
|
184
|
-
}
|
|
185
225
|
if (costUsd != null) {
|
|
186
|
-
|
|
226
|
+
inMemoryCostUsd += costUsd;
|
|
187
227
|
}
|
|
188
228
|
}
|
|
189
229
|
|
|
190
|
-
/** Force immediate refresh
|
|
230
|
+
/** Force immediate refresh from Anthropic API, persist to DB, return latest */
|
|
191
231
|
export async function refreshUsageNow(): Promise<ClaudeUsage> {
|
|
192
232
|
await fetchWithRetry();
|
|
193
|
-
return
|
|
233
|
+
return getCachedUsage();
|
|
194
234
|
}
|
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { mkdirSync, existsSync } from "node:fs";
|
|
5
5
|
|
|
6
6
|
const PPM_DIR = resolve(homedir(), ".ppm");
|
|
7
|
-
const CURRENT_SCHEMA_VERSION =
|
|
7
|
+
const CURRENT_SCHEMA_VERSION = 4;
|
|
8
8
|
|
|
9
9
|
let db: Database | null = null;
|
|
10
10
|
let dbProfile: string | null = null;
|
|
@@ -167,6 +167,27 @@ function runMigrations(database: Database): void {
|
|
|
167
167
|
PRAGMA user_version = 3;
|
|
168
168
|
`);
|
|
169
169
|
}
|
|
170
|
+
|
|
171
|
+
if (current < 4) {
|
|
172
|
+
database.exec(`
|
|
173
|
+
CREATE TABLE IF NOT EXISTS claude_limit_snapshots (
|
|
174
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
175
|
+
five_hour_util REAL,
|
|
176
|
+
five_hour_resets_at TEXT,
|
|
177
|
+
weekly_util REAL,
|
|
178
|
+
weekly_resets_at TEXT,
|
|
179
|
+
weekly_opus_util REAL,
|
|
180
|
+
weekly_opus_resets_at TEXT,
|
|
181
|
+
weekly_sonnet_util REAL,
|
|
182
|
+
weekly_sonnet_resets_at TEXT,
|
|
183
|
+
recorded_at TEXT DEFAULT (datetime('now'))
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
CREATE INDEX IF NOT EXISTS idx_limit_snapshots_recorded ON claude_limit_snapshots(recorded_at);
|
|
187
|
+
|
|
188
|
+
PRAGMA user_version = 4;
|
|
189
|
+
`);
|
|
190
|
+
}
|
|
170
191
|
}
|
|
171
192
|
|
|
172
193
|
// ---------------------------------------------------------------------------
|
|
@@ -345,6 +366,49 @@ export function getDbFilePath(): string {
|
|
|
345
366
|
return getDbPath();
|
|
346
367
|
}
|
|
347
368
|
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Claude limit snapshot helpers
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
export interface LimitSnapshotRow {
|
|
374
|
+
id: number;
|
|
375
|
+
five_hour_util: number | null;
|
|
376
|
+
five_hour_resets_at: string | null;
|
|
377
|
+
weekly_util: number | null;
|
|
378
|
+
weekly_resets_at: string | null;
|
|
379
|
+
weekly_opus_util: number | null;
|
|
380
|
+
weekly_opus_resets_at: string | null;
|
|
381
|
+
weekly_sonnet_util: number | null;
|
|
382
|
+
weekly_sonnet_resets_at: string | null;
|
|
383
|
+
recorded_at: string;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function insertLimitSnapshot(data: Omit<LimitSnapshotRow, "id" | "recorded_at">): void {
|
|
387
|
+
getDb().query(
|
|
388
|
+
`INSERT INTO claude_limit_snapshots
|
|
389
|
+
(five_hour_util, five_hour_resets_at, weekly_util, weekly_resets_at,
|
|
390
|
+
weekly_opus_util, weekly_opus_resets_at, weekly_sonnet_util, weekly_sonnet_resets_at)
|
|
391
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
392
|
+
).run(
|
|
393
|
+
data.five_hour_util ?? null, data.five_hour_resets_at ?? null,
|
|
394
|
+
data.weekly_util ?? null, data.weekly_resets_at ?? null,
|
|
395
|
+
data.weekly_opus_util ?? null, data.weekly_opus_resets_at ?? null,
|
|
396
|
+
data.weekly_sonnet_util ?? null, data.weekly_sonnet_resets_at ?? null,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function getLatestLimitSnapshot(): LimitSnapshotRow | null {
|
|
401
|
+
return getDb().query(
|
|
402
|
+
"SELECT * FROM claude_limit_snapshots ORDER BY recorded_at DESC LIMIT 1",
|
|
403
|
+
).get() as LimitSnapshotRow | null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function cleanupOldLimitSnapshots(): void {
|
|
407
|
+
getDb().query(
|
|
408
|
+
"DELETE FROM claude_limit_snapshots WHERE recorded_at < datetime('now', '-7 days')",
|
|
409
|
+
).run();
|
|
410
|
+
}
|
|
411
|
+
|
|
348
412
|
// ---------------------------------------------------------------------------
|
|
349
413
|
// Connection helpers
|
|
350
414
|
// ---------------------------------------------------------------------------
|