@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 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,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -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 = 60_000; // auto-fetch every 60s
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
- /** Cached usage data */
33
- let cache: ClaudeUsage = {};
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
- /** Fetch with retry logic */
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
- cache = data;
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 rate-limit / cost events into the cache so the REST endpoint
160
- * always returns the freshest data even when the OAuth Usage API is unreachable.
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
- rateLimitType?: string,
164
- utilization?: number,
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
- cache.totalCostUsd = (cache.totalCostUsd ?? 0) + costUsd;
226
+ inMemoryCostUsd += costUsd;
187
227
  }
188
228
  }
189
229
 
190
- /** Force immediate refresh (e.g. after a chat completes) */
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 cache;
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 = 3;
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
  // ---------------------------------------------------------------------------