@triflux/core 10.50.1 → 10.50.2

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.
Files changed (55) hide show
  1. package/hub/index.mjs +2 -8
  2. package/hub/lib/cto-env.mjs +0 -23
  3. package/hub/paths.mjs +0 -6
  4. package/hub/pipeline/index.mjs +0 -190
  5. package/hub/pipeline/transitions.mjs +0 -10
  6. package/hub/reflexion.mjs +1 -84
  7. package/hud/cli-policy.mjs +1 -5
  8. package/hud/colors.mjs +0 -13
  9. package/hud/constants.mjs +4 -135
  10. package/hud/context-monitor.mjs +3 -64
  11. package/hud/hud-qos-status.mjs +43 -225
  12. package/hud/providers/claude.mjs +4 -16
  13. package/hud/providers/gemini.mjs +7 -698
  14. package/hud/renderers.mjs +40 -294
  15. package/hud/terminal.mjs +1 -63
  16. package/hud/utils.mjs +0 -24
  17. package/package.json +1 -2
  18. package/scripts/lib/gemini-profiles.mjs +1 -1
  19. package/scripts/lib/legacy-mcp-cleanup.mjs +14 -3
  20. package/scripts/lib/psmux-info.mjs +18 -49
  21. package/scripts/lib/skill-template.mjs +0 -26
  22. package/hub/codex-compat.mjs +0 -12
  23. package/hub/fullcycle.mjs +0 -113
  24. package/hub/gemini-adapter.mjs +0 -174
  25. package/hub/intent.mjs +0 -321
  26. package/hub/lib/mcp-response-cache.mjs +0 -239
  27. package/hub/lib/path-utils.mjs +0 -165
  28. package/hub/lib/timeline-adapter.mjs +0 -190
  29. package/hub/pipeline/gates/confidence.mjs +0 -75
  30. package/hub/pipeline/gates/consensus.mjs +0 -106
  31. package/hub/pipeline/gates/index.mjs +0 -9
  32. package/hub/pipeline/gates/selfcheck.mjs +0 -120
  33. package/hub/quality/deslop.mjs +0 -274
  34. package/hub/research.mjs +0 -155
  35. package/hub/role-control-events.mjs +0 -113
  36. package/hub/routing/complexity.mjs +0 -269
  37. package/hub/routing/index.mjs +0 -122
  38. package/hub/routing/q-learning.mjs +0 -387
  39. package/hub/token-mode.mjs +0 -228
  40. package/hud/mission-board.mjs +0 -56
  41. package/hud/providers/cto.mjs +0 -76
  42. package/scripts/lib/agent-json.mjs +0 -27
  43. package/scripts/lib/async.mjs +0 -174
  44. package/scripts/lib/cli-agy.mjs +0 -85
  45. package/scripts/lib/cli-codex.mjs +0 -62
  46. package/scripts/lib/cli-gemini.mjs +0 -37
  47. package/scripts/lib/env.mjs +0 -14
  48. package/scripts/lib/hub.mjs +0 -48
  49. package/scripts/lib/pid.mjs +0 -63
  50. package/scripts/lib/quota.mjs +0 -47
  51. package/scripts/lib/skill-state.mjs +0 -220
  52. package/scripts/lib/team.mjs +0 -75
  53. package/scripts/lib/timeout.mjs +0 -66
  54. package/scripts/lib/tmp.mjs +0 -26
  55. package/scripts/lib/toml.mjs +0 -132
@@ -1,120 +1,10 @@
1
- // ============================================================================
2
- // Gemini 쿼터 API / 세션 토큰 / RPM 트래커
3
- // ============================================================================
4
-
5
- import { spawn, spawnSync } from "node:child_process";
6
- import { existsSync, readdirSync, readFileSync } from "node:fs";
7
- import https from "node:https";
8
- import { homedir } from "node:os";
9
- import { join } from "node:path";
10
1
  import {
11
- ANTIGRAVITY_KEYCHAIN_ACCOUNT,
12
- ANTIGRAVITY_KEYCHAIN_SERVICE,
13
2
  ANTIGRAVITY_MODEL_ABBREV,
14
3
  ANTIGRAVITY_OAUTH_PATHS,
15
4
  ANTIGRAVITY_SETTINGS_PATH,
16
- GEMINI_API_TIMEOUT_MS,
17
5
  GEMINI_OAUTH_PATH,
18
- GEMINI_PROJECT_CACHE_PATH,
19
- GEMINI_QUOTA_CACHE_PATH,
20
- GEMINI_QUOTA_REFRESH_LOCK_PATH,
21
- GEMINI_QUOTA_STALE_MS,
22
- GEMINI_REFRESH_FLAG,
23
- GEMINI_RPM_TRACKER_PATH,
24
- GEMINI_RPM_WINDOW_MS,
25
- GEMINI_SESSION_CACHE_PATH,
26
- GEMINI_SESSION_REFRESH_FLAG,
27
- GEMINI_SESSION_REFRESH_LOCK_PATH,
28
- GEMINI_SESSION_STALE_MS,
29
- LEGACY_GEMINI_PROJECT_CACHE,
30
- LEGACY_GEMINI_QUOTA_CACHE,
31
- LEGACY_GEMINI_RPM_TRACKER,
32
- LEGACY_GEMINI_SESSION_CACHE,
33
- SPAWN_LOCK_TTL_MS,
34
6
  } from "../constants.mjs";
35
- import {
36
- clampPercent,
37
- createHttpsPost,
38
- decodeJwtEmail,
39
- makeHash,
40
- readJson,
41
- readJsonMigrate,
42
- writeJsonSafe,
43
- } from "../utils.mjs";
44
-
45
- const httpsPost = createHttpsPost(https, GEMINI_API_TIMEOUT_MS);
46
-
47
- // Gemini 모델별 RPM 한도 (실측 기반: Pro 25, Flash 300)
48
- export function getGeminiRpmLimit(model) {
49
- if (model?.includes("pro")) return 25;
50
- return 300; // Flash 기본
51
- }
52
-
53
- // Gemini 모델 ID → HUD 표시 라벨 (동적 매핑)
54
- export function getGeminiModelLabel(model) {
55
- if (!model) return "";
56
- // 버전 + 티어 추출: gemini-3.1-pro-preview → [3.1Pro], gemini-2.5-flash → [2.5Flash]
57
- const m = model.match(/gemini-(\d+(?:\.\d+)?)-(\w+)/);
58
- if (!m) return "";
59
- const ver = m[1];
60
- const tier = m[2].charAt(0).toUpperCase() + m[2].slice(1);
61
- return `[${ver}${tier}]`;
62
- }
63
-
64
- // remainingFraction → 사용 퍼센트 변환 (remainingAmount가 있으면 절대값도 제공)
65
- export function deriveGeminiLimits(bucket) {
66
- if (!bucket) return null;
67
- // TODO(verify): Workspace quota 응답 모양 라이브 확인 필요
68
- if (isGeminiUnlimitedBucket(bucket, bucket.remainingFraction)) {
69
- return {
70
- unlimited: true,
71
- usedPct: null,
72
- remaining: null,
73
- limit: null,
74
- resetTime: bucket.resetTime,
75
- modelId: bucket.modelId,
76
- };
77
- }
78
- if (bucket.remainingFraction == null) return null;
79
- const fraction = bucket.remainingFraction;
80
- const usedPct = clampPercent(Math.round((1 - fraction) * 100));
81
- // remainingAmount가 API에서 오면 절대값 역산 (Gemini CLI 방식)
82
- if (bucket.remainingAmount != null) {
83
- const remaining = parseInt(bucket.remainingAmount, 10);
84
- const limit = fraction > 0 ? Math.round(remaining / fraction) : 0;
85
- return {
86
- usedPct,
87
- remaining,
88
- limit,
89
- resetTime: bucket.resetTime,
90
- modelId: bucket.modelId,
91
- };
92
- }
93
- return {
94
- usedPct,
95
- remaining: null,
96
- limit: null,
97
- resetTime: bucket.resetTime,
98
- modelId: bucket.modelId,
99
- };
100
- }
101
-
102
- function isUnlimitedValue(value) {
103
- if (value === Infinity) return true;
104
- if (typeof value !== "string") return false;
105
- const normalized = value.trim().toLowerCase();
106
- return normalized === "infinity" || normalized === "unlimited";
107
- }
108
-
109
- function isGeminiUnlimitedBucket(bucket, fraction) {
110
- return (
111
- bucket?.unlimited === true ||
112
- isUnlimitedValue(fraction) ||
113
- isUnlimitedValue(bucket?.limit) ||
114
- isUnlimitedValue(bucket?.remaining) ||
115
- isUnlimitedValue(bucket?.remainingAmount)
116
- );
117
- }
7
+ import { decodeJwtEmail, readJson } from "../utils.mjs";
118
8
 
119
9
  function getCredentialEmail(credential) {
120
10
  return (
@@ -128,39 +18,29 @@ function getCredentialEmail(credential) {
128
18
  }
129
19
 
130
20
  export function getGeminiEmail() {
131
- try {
132
- const oauth = readJson(GEMINI_OAUTH_PATH, null);
133
- return getCredentialEmail(oauth);
134
- } catch {
135
- return null;
136
- }
21
+ return getCredentialEmail(readJson(GEMINI_OAUTH_PATH, null));
137
22
  }
138
23
 
139
- // GCP 프로젝트 인증이면 계정 이메일 대신 프로젝트 ID 를 보여 준다.
24
+ // GCP 프로젝트 인증이면 계정 이메일 대신 프로젝트 ID를 보여 준다.
140
25
  export function getAntigravityAccountLabel() {
141
26
  const project = readJson(ANTIGRAVITY_SETTINGS_PATH, null)?.gcp?.project;
142
27
  if (project) return project;
143
28
  for (const oauthPath of ANTIGRAVITY_OAUTH_PATHS) {
144
29
  try {
145
- const oauth = readJson(oauthPath, null);
146
- const email = getCredentialEmail(oauth);
30
+ const email = getCredentialEmail(readJson(oauthPath, null));
147
31
  if (email) return email;
148
32
  } catch {
149
- // Try the next known Antigravity credential path.
33
+ // 다음 Antigravity 인증 경로를 확인한다.
150
34
  }
151
35
  }
152
36
  return null;
153
37
  }
154
38
 
155
- // agy 1.0.1+ settings.json `model` 필드에서 현재 장착 모델 라벨을 읽는다.
156
- // 예: "Gemini 3.5 Flash (High)". 없으면 null.
157
39
  export function getAntigravityCurrentModel() {
158
- const s = readJson(ANTIGRAVITY_SETTINGS_PATH, null);
159
- return s?.model || null;
40
+ const settings = readJson(ANTIGRAVITY_SETTINGS_PATH, null);
41
+ return settings?.model || null;
160
42
  }
161
43
 
162
- // 모델 라벨 → 2자 약어 (constants의 ANTIGRAVITY_MODEL_ABBREV 매핑).
163
- // 매핑에 없으면 "<Head> ... (<Level>)" 패턴에서 자동 추출.
164
44
  export function getAntigravityModelAbbrev(label) {
165
45
  if (!label) return "??";
166
46
  const known = ANTIGRAVITY_MODEL_ABBREV[label];
@@ -173,574 +53,3 @@ export function getAntigravityModelAbbrev(label) {
173
53
  if (head) return head[1].slice(0, 2);
174
54
  return "??";
175
55
  }
176
-
177
- // 모델 라벨 → family ("gemini" | "claude" | "gpt-oss" | "unknown")
178
- // quota 풀이 family 단위로 분리되므로 slot1 데이터 채울지 결정할 때 사용.
179
- export function getAntigravityModelFamily(label) {
180
- if (!label) return "unknown";
181
- if (label.startsWith("Gemini")) return "gemini";
182
- if (label.startsWith("Claude")) return "claude";
183
- if (label.startsWith("GPT")) return "gpt-oss";
184
- return "unknown";
185
- }
186
-
187
- // Gemini family 통합 quota — 모든 bucket 중 최소 remainingFraction을 가진 bucket을 대표로 반환.
188
- // /usage 실측 (2026-05-22): 4개 모델이 같은 reset window 공유하므로 결과는 사실상 family 단일 카운터.
189
- export function deriveGeminiFamilyBucket(buckets) {
190
- if (!Array.isArray(buckets) || buckets.length === 0) return null;
191
- let chosen = null;
192
- for (const b of buckets) {
193
- if (b?.remainingFraction == null) continue;
194
- if (!chosen || b.remainingFraction < chosen.remainingFraction) {
195
- chosen = b;
196
- }
197
- }
198
- return chosen;
199
- }
200
-
201
- export function buildGeminiAuthContext(accountId) {
202
- let oauth = readJson(GEMINI_OAUTH_PATH, null);
203
- let authSource = oauth?.access_token ? "gemini-file" : "none";
204
- let expiryMissing = oauth?.access_token ? oauth.expiry_date == null : false;
205
- const fileExpired =
206
- oauth?.expiry_date != null && oauth.expiry_date < Date.now();
207
- // Preserve a valid Gemini file token; agy Keychain is only a missing/expired fallback.
208
- if (!oauth?.access_token || fileExpired) {
209
- const keychainOAuth = getAntigravityTokenFromKeychain();
210
- if (keychainOAuth?.access_token) {
211
- const {
212
- expiry_date: _dropExpiry,
213
- access_token: _dropToken,
214
- ...fileRest
215
- } = oauth || {};
216
- oauth = { ...fileRest, ...keychainOAuth };
217
- authSource = "antigravity-keychain";
218
- expiryMissing = keychainOAuth.expiry_date == null;
219
- }
220
- }
221
- const tokenSource =
222
- oauth?.refresh_token || oauth?.id_token || oauth?.access_token || "";
223
- const tokenFingerprint = tokenSource ? makeHash(tokenSource) : "none";
224
- const cacheKey = `${accountId || "gemini-main"}::${tokenFingerprint}`;
225
- return { oauth, tokenFingerprint, cacheKey, authSource, expiryMissing };
226
- }
227
-
228
- function firstPresent(...values) {
229
- for (const value of values) {
230
- if (value != null && value !== "") return value;
231
- }
232
- return null;
233
- }
234
-
235
- function normalizeExpiryDate(value) {
236
- if (value == null || value === "") return null;
237
- if (typeof value === "number" && Number.isFinite(value)) {
238
- return value < 1_000_000_000_000 ? value * 1000 : value;
239
- }
240
- if (typeof value === "string") {
241
- const numeric = Number(value);
242
- if (Number.isFinite(numeric)) return normalizeExpiryDate(numeric);
243
- const parsed = Date.parse(value);
244
- if (Number.isFinite(parsed)) return parsed;
245
- }
246
- return null;
247
- }
248
-
249
- function normalizeAntigravityCredential(parsed) {
250
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
251
- return null;
252
- }
253
- const accessToken = firstPresent(
254
- parsed.access_token,
255
- parsed.accessToken,
256
- parsed.access?.token,
257
- parsed.token,
258
- );
259
- if (!accessToken) return null;
260
- const normalized = { ...parsed, access_token: accessToken };
261
- const idToken = firstPresent(parsed.id_token, parsed.idToken);
262
- if (idToken) normalized.id_token = idToken;
263
- const refreshToken = firstPresent(parsed.refresh_token, parsed.refreshToken);
264
- if (refreshToken) normalized.refresh_token = refreshToken;
265
- const expiryDate = normalizeExpiryDate(
266
- firstPresent(
267
- parsed.expiry_date,
268
- parsed.expiryDate,
269
- parsed.expiry,
270
- parsed.expires_at,
271
- parsed.expiresAt,
272
- ),
273
- );
274
- if (expiryDate != null) normalized.expiry_date = expiryDate;
275
- return normalized;
276
- }
277
-
278
- function getAntigravityTokenFromKeychain() {
279
- // macOS Keychain only; elsewhere `security` is absent (wasted spawn) or a
280
- // different PATH binary entirely, so skip on Linux/Windows and let the
281
- // file-token fallback apply. TFX_HUD_KEYCHAIN_FORCE=1 re-enables the spawn
282
- // for cross-platform tests that inject a fake `security` via PATH.
283
- if (
284
- process.platform !== "darwin" &&
285
- process.env.TFX_HUD_KEYCHAIN_FORCE !== "1"
286
- ) {
287
- return null;
288
- }
289
- try {
290
- const result = spawnSync(
291
- "security",
292
- [
293
- "find-generic-password",
294
- "-s",
295
- ANTIGRAVITY_KEYCHAIN_SERVICE,
296
- "-a",
297
- ANTIGRAVITY_KEYCHAIN_ACCOUNT,
298
- "-w",
299
- ],
300
- { encoding: "utf8", timeout: 3000 },
301
- );
302
- if (result.status !== 0) return null;
303
- const stdout = result.stdout?.trim();
304
- if (!stdout) return null;
305
- try {
306
- return normalizeAntigravityCredential(JSON.parse(stdout));
307
- } catch {
308
- return { access_token: stdout };
309
- }
310
- } catch {
311
- return null;
312
- }
313
- }
314
-
315
- export function classifyGeminiQuotaFailure(response, authContext = {}) {
316
- if (!response) return "network";
317
- const error = response?.error || {};
318
- const code = Number(error.code ?? response.code ?? response.status);
319
- const status = String(error.status || response.status || "").toUpperCase();
320
- const message = String(
321
- error.message || error.code || response.error || response.message || "",
322
- );
323
- if (
324
- code === 401 ||
325
- code === 403 ||
326
- status === "UNAUTHENTICATED" ||
327
- status === "PERMISSION_DENIED" ||
328
- /(unauthorized|forbidden|invalid authentication|invalid credentials|oauth|token|credential|auth)/i.test(
329
- message,
330
- )
331
- ) {
332
- return "auth";
333
- }
334
- if (
335
- authContext.authSource === "antigravity-keychain" &&
336
- authContext.expiryMissing === true &&
337
- /(expired|invalid|unauthenticated|permission denied)/i.test(message)
338
- ) {
339
- return "auth";
340
- }
341
- return "api";
342
- }
343
-
344
- function formatGeminiQuotaFailure(response, authContext = {}, stage = "quota") {
345
- const error =
346
- response?.error?.message ||
347
- response?.error?.status ||
348
- response?.error?.code ||
349
- response?.error ||
350
- response?.message ||
351
- "no buckets in response";
352
- const base = String(error);
353
- if (
354
- authContext.authSource === "antigravity-keychain" &&
355
- authContext.expiryMissing === true
356
- ) {
357
- return `expiry-less Antigravity Keychain token failed bounded ${stage} freshness probe: ${base}`;
358
- }
359
- return base;
360
- }
361
-
362
- function writeGeminiQuotaErrorCache(cache, authContext, errorType, errorHint) {
363
- const sameKey = cache?.cacheKey === authContext.cacheKey;
364
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
365
- ...(sameKey ? cache || {} : {}),
366
- timestamp: sameKey && cache?.timestamp ? cache.timestamp : Date.now(),
367
- cacheKey: authContext.cacheKey,
368
- accountId: authContext.accountId || "gemini-main",
369
- tokenFingerprint: authContext.tokenFingerprint,
370
- authSource: authContext.authSource,
371
- expiryMissing: authContext.expiryMissing === true,
372
- error: true,
373
- errorType,
374
- errorHint,
375
- });
376
- }
377
-
378
- // ============================================================================
379
- // Gemini 쿼터 API 호출 (5분 캐시)
380
- // ============================================================================
381
- export async function fetchGeminiQuota(accountId, options = {}) {
382
- const authContext = options.authContext || buildGeminiAuthContext(accountId);
383
- const { oauth, tokenFingerprint, cacheKey } = authContext;
384
- authContext.accountId = accountId || "gemini-main";
385
- const forceRefresh = options.forceRefresh === true;
386
-
387
- // 1. 캐시 확인 (계정/토큰별)
388
- const cache = readJsonMigrate(
389
- GEMINI_QUOTA_CACHE_PATH,
390
- LEGACY_GEMINI_QUOTA_CACHE,
391
- null,
392
- );
393
- if (
394
- !forceRefresh &&
395
- cache?.cacheKey === cacheKey &&
396
- cache?.timestamp &&
397
- Date.now() - cache.timestamp < GEMINI_QUOTA_STALE_MS
398
- ) {
399
- return cache;
400
- }
401
-
402
- if (!oauth?.access_token) {
403
- // access_token 없음: 에러 힌트를 캐시에 기록하고 stale 캐시 반환
404
- writeGeminiQuotaErrorCache(
405
- cache,
406
- authContext,
407
- "auth",
408
- "no access_token in oauth_creds.json",
409
- );
410
- return cache;
411
- }
412
- if (oauth.expiry_date && oauth.expiry_date < Date.now()) {
413
- // OAuth 토큰 만료: 에러 힌트를 캐시에 기록 (refresh_token 갱신은 Gemini CLI 담당)
414
- writeGeminiQuotaErrorCache(
415
- cache,
416
- authContext,
417
- "auth",
418
- `token expired at ${new Date(oauth.expiry_date).toISOString()}`,
419
- );
420
- return cache;
421
- }
422
-
423
- // 3. projectId (캐시 or API)
424
- let loadCodeAssistResponse = null;
425
- const fetchProjectId = async () => {
426
- loadCodeAssistResponse = await httpsPost(
427
- "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
428
- { metadata: { pluginType: "GEMINI" } },
429
- oauth.access_token,
430
- );
431
- const id = loadCodeAssistResponse?.cloudaicompanionProject;
432
- if (id)
433
- writeJsonSafe(GEMINI_PROJECT_CACHE_PATH, {
434
- cacheKey,
435
- projectId: id,
436
- timestamp: Date.now(),
437
- });
438
- return id || null;
439
- };
440
-
441
- const projCache = readJsonMigrate(
442
- GEMINI_PROJECT_CACHE_PATH,
443
- LEGACY_GEMINI_PROJECT_CACHE,
444
- null,
445
- );
446
- let projectId =
447
- projCache?.cacheKey === cacheKey ? projCache?.projectId : null;
448
- if (!projectId) projectId = await fetchProjectId();
449
- if (!projectId) {
450
- writeGeminiQuotaErrorCache(
451
- cache,
452
- authContext,
453
- classifyGeminiQuotaFailure(loadCodeAssistResponse, authContext),
454
- formatGeminiQuotaFailure(
455
- loadCodeAssistResponse,
456
- authContext,
457
- "loadCodeAssist",
458
- ),
459
- );
460
- return cache;
461
- }
462
-
463
- // 4. retrieveUserQuota 호출
464
- let quotaRes = await httpsPost(
465
- "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
466
- { project: projectId },
467
- oauth.access_token,
468
- );
469
-
470
- // projectId 캐시가 만료/변경된 경우 1회 재시도
471
- if (!quotaRes?.buckets && projCache?.projectId) {
472
- projectId = await fetchProjectId();
473
- if (!projectId) {
474
- writeGeminiQuotaErrorCache(
475
- cache,
476
- authContext,
477
- classifyGeminiQuotaFailure(loadCodeAssistResponse, authContext),
478
- formatGeminiQuotaFailure(
479
- loadCodeAssistResponse,
480
- authContext,
481
- "loadCodeAssist",
482
- ),
483
- );
484
- return cache;
485
- }
486
- quotaRes = await httpsPost(
487
- "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
488
- { project: projectId },
489
- oauth.access_token,
490
- );
491
- }
492
-
493
- if (!quotaRes?.buckets) {
494
- // API 응답에 buckets 없음: 에러 코드 또는 응답 내용을 캐시에 기록
495
- writeGeminiQuotaErrorCache(
496
- cache,
497
- authContext,
498
- classifyGeminiQuotaFailure(quotaRes, authContext),
499
- formatGeminiQuotaFailure(quotaRes, authContext, "quota"),
500
- );
501
- return cache;
502
- }
503
-
504
- // 5. 캐시 저장
505
- const result = {
506
- timestamp: Date.now(),
507
- cacheKey,
508
- accountId: accountId || "gemini-main",
509
- tokenFingerprint,
510
- buckets: quotaRes.buckets,
511
- };
512
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, result);
513
- return result;
514
- }
515
-
516
- /**
517
- * Gemini RPM 트래커에서 최근 60초 내 요청 수를 읽는다.
518
- * @returns {{ count: number, percent: number, remainingSec: number }}
519
- */
520
- export function readGeminiRpm(model) {
521
- try {
522
- // 새 경로 → 레거시 경로 fallback
523
- const rpmPath = existsSync(GEMINI_RPM_TRACKER_PATH)
524
- ? GEMINI_RPM_TRACKER_PATH
525
- : existsSync(LEGACY_GEMINI_RPM_TRACKER)
526
- ? LEGACY_GEMINI_RPM_TRACKER
527
- : null;
528
- if (!rpmPath) return { count: 0, percent: 0, remainingSec: 60 };
529
- const raw = readFileSync(rpmPath, "utf-8");
530
- const parsed = JSON.parse(raw);
531
- const timestamps = Array.isArray(parsed.timestamps)
532
- ? parsed.timestamps
533
- : [];
534
- const now = Date.now();
535
- const recent = timestamps.filter((t) => now - t < GEMINI_RPM_WINDOW_MS);
536
- const count = recent.length;
537
- const rpmLimit = getGeminiRpmLimit(model);
538
- const percent = clampPercent(Math.round((count / rpmLimit) * 100));
539
- // 가장 오래된 엔트리가 윈도우에서 빠지기까지 남은 초 (0건이면 0s)
540
- // 5초 단위 반올림으로 HUD 깜빡임 감소
541
- const rawRemainingSec =
542
- recent.length > 0
543
- ? Math.max(
544
- 0,
545
- Math.ceil(
546
- (GEMINI_RPM_WINDOW_MS - (now - Math.min(...recent))) / 1000,
547
- ),
548
- )
549
- : 0;
550
- const remainingSec = Math.ceil(rawRemainingSec / 5) * 5;
551
- return { count, percent, remainingSec };
552
- } catch {
553
- return { count: 0, percent: 0, remainingSec: 60 };
554
- }
555
- }
556
-
557
- export function readGeminiQuotaSnapshot(accountId, authContext) {
558
- const cache = readJsonMigrate(
559
- GEMINI_QUOTA_CACHE_PATH,
560
- LEGACY_GEMINI_QUOTA_CACHE,
561
- null,
562
- );
563
- if (!cache?.buckets) {
564
- return { quota: null, shouldRefresh: true };
565
- }
566
-
567
- const cacheKey = authContext.cacheKey;
568
- const isLegacyCache = !cache.cacheKey;
569
- const keyMatched = cache.cacheKey === cacheKey;
570
- const cacheTs = Number(cache.timestamp);
571
- const ageMs = Number.isFinite(cacheTs)
572
- ? Date.now() - cacheTs
573
- : Number.MAX_SAFE_INTEGER;
574
- const isFresh = ageMs < GEMINI_QUOTA_STALE_MS;
575
-
576
- if (keyMatched) {
577
- // resetTime이 지난 버킷의 remainingFraction을 1로 보정 (stale 캐시 방지)
578
- if (Array.isArray(cache.buckets)) {
579
- const now = Date.now();
580
- const patchedBuckets = cache.buckets.map((b) =>
581
- b?.resetTime && new Date(b.resetTime).getTime() <= now
582
- ? { ...b, remainingFraction: 1 }
583
- : b,
584
- );
585
- return {
586
- quota: { ...cache, buckets: patchedBuckets },
587
- shouldRefresh: !isFresh,
588
- };
589
- }
590
- return { quota: cache, shouldRefresh: !isFresh };
591
- }
592
- if (isLegacyCache) {
593
- return { quota: cache, shouldRefresh: true };
594
- }
595
- return { quota: null, shouldRefresh: true };
596
- }
597
-
598
- export function scheduleGeminiQuotaRefresh(accountId) {
599
- const scriptPath = process.argv[1];
600
- if (!scriptPath) return;
601
-
602
- // 스폰 락: 30초 내 이미 스폰했으면 중복 방지
603
- try {
604
- if (existsSync(GEMINI_QUOTA_REFRESH_LOCK_PATH)) {
605
- const lockAge =
606
- Date.now() - readJson(GEMINI_QUOTA_REFRESH_LOCK_PATH, {}).t;
607
- if (lockAge < SPAWN_LOCK_TTL_MS) return;
608
- }
609
- writeJsonSafe(GEMINI_QUOTA_REFRESH_LOCK_PATH, { t: Date.now() });
610
- } catch {
611
- /* 락 실패 무시 — 스폰 진행 */
612
- }
613
-
614
- try {
615
- const child = spawn(
616
- process.execPath,
617
- [
618
- scriptPath,
619
- GEMINI_REFRESH_FLAG,
620
- "--account",
621
- accountId || "gemini-main",
622
- ],
623
- {
624
- detached: true,
625
- stdio: "ignore",
626
- windowsHide: true,
627
- },
628
- );
629
- child.unref();
630
- } catch (spawnErr) {
631
- // spawn 실패 시 캐시에 에러 힌트 기록 (다음 HUD 렌더에서 원인 확인 가능)
632
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
633
- timestamp: Date.now(),
634
- error: true,
635
- errorHint: String(spawnErr?.message || spawnErr),
636
- });
637
- }
638
- }
639
-
640
- export function readGeminiSessionSnapshot() {
641
- const cache = readJsonMigrate(
642
- GEMINI_SESSION_CACHE_PATH,
643
- LEGACY_GEMINI_SESSION_CACHE,
644
- null,
645
- );
646
- if (!cache?.session) {
647
- return { session: null, shouldRefresh: true };
648
- }
649
- const ts = Number(cache.timestamp);
650
- const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
651
- const isFresh = ageMs < GEMINI_SESSION_STALE_MS;
652
- return { session: cache.session, shouldRefresh: !isFresh };
653
- }
654
-
655
- export function refreshGeminiSessionCache() {
656
- const session = scanGeminiSessionTokens();
657
- if (!session) return null;
658
- writeJsonSafe(GEMINI_SESSION_CACHE_PATH, { timestamp: Date.now(), session });
659
- return session;
660
- }
661
-
662
- export function scheduleGeminiSessionRefresh() {
663
- const scriptPath = process.argv[1];
664
- if (!scriptPath) return;
665
-
666
- // 스폰 락: 30초 내 이미 스폰했으면 중복 방지
667
- try {
668
- if (existsSync(GEMINI_SESSION_REFRESH_LOCK_PATH)) {
669
- const lockAge =
670
- Date.now() - readJson(GEMINI_SESSION_REFRESH_LOCK_PATH, {}).t;
671
- if (lockAge < SPAWN_LOCK_TTL_MS) return;
672
- }
673
- writeJsonSafe(GEMINI_SESSION_REFRESH_LOCK_PATH, { t: Date.now() });
674
- } catch {
675
- /* 락 실패 무시 — 스폰 진행 */
676
- }
677
-
678
- try {
679
- const child = spawn(
680
- process.execPath,
681
- [scriptPath, GEMINI_SESSION_REFRESH_FLAG],
682
- {
683
- detached: true,
684
- stdio: "ignore",
685
- windowsHide: true,
686
- },
687
- );
688
- child.unref();
689
- } catch {
690
- /* 백그라운드 실행 실패 무시 */
691
- }
692
- }
693
-
694
- // ============================================================================
695
- // Gemini 세션 JSON에서 토큰 사용량 추출
696
- // ============================================================================
697
- export function scanGeminiSessionTokens() {
698
- const tmpDir = join(homedir(), ".gemini", "tmp");
699
- if (!existsSync(tmpDir)) return null;
700
- let best = null;
701
- let bestTime = 0;
702
- try {
703
- const dirs = readdirSync(tmpDir).filter((d) =>
704
- existsSync(join(tmpDir, d, "chats")),
705
- );
706
- for (const dir of dirs) {
707
- const chatsDir = join(tmpDir, dir, "chats");
708
- let files;
709
- try {
710
- files = readdirSync(chatsDir).filter((f) => f.endsWith(".json"));
711
- } catch {
712
- continue;
713
- }
714
- for (const file of files) {
715
- try {
716
- const data = JSON.parse(readFileSync(join(chatsDir, file), "utf-8"));
717
- const updatedAt = new Date(data.lastUpdated || 0).getTime();
718
- if (updatedAt <= bestTime) continue;
719
- let input = 0,
720
- output = 0;
721
- let model = "unknown";
722
- for (const msg of data.messages || []) {
723
- if (msg.tokens) {
724
- input += msg.tokens.input || 0;
725
- output += msg.tokens.output || 0;
726
- }
727
- if (msg.model) model = msg.model;
728
- }
729
- bestTime = updatedAt;
730
- best = {
731
- input,
732
- output,
733
- total: input + output,
734
- model,
735
- lastUpdated: data.lastUpdated,
736
- };
737
- } catch {
738
- /* 무시 */
739
- }
740
- }
741
- }
742
- } catch {
743
- /* 무시 */
744
- }
745
- return best;
746
- }