chatccc 0.2.187 → 0.2.188

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/src/config.ts CHANGED
@@ -88,9 +88,9 @@ export interface CursorConfig {
88
88
  onDemandMonthlyBudget: number;
89
89
  }
90
90
 
91
- export interface CodexConfig {
92
- /** 是否启用 Codex Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
93
- enabled: boolean;
91
+ export interface CodexConfig {
92
+ /** 是否启用 Codex Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
93
+ enabled: boolean;
94
94
  /** 是否作为 /new 未指定工具时使用的默认 Agent */
95
95
  defaultAgent: boolean;
96
96
  /** Codex CLI 可执行文件绝对路径;留空时退回到 PATH 中的 `codex` */
@@ -98,13 +98,22 @@ export interface CodexConfig {
98
98
  model: string;
99
99
  /** /model 可切换的单个备选模型;留空则不加入候选列表 */
100
100
  alternativeModel: string;
101
- effort: string;
102
- }
103
-
104
- export interface FeishuConfig {
105
- appId: string;
106
- appSecret: string;
107
- }
101
+ effort: string;
102
+ }
103
+
104
+ export interface CccConfig {
105
+ /** DeepSeek API Key for the ChatCCC self-developed agent. */
106
+ DEEPSEEK_API_KEY: string;
107
+ /** DeepSeek-compatible API Base URL for the ChatCCC self-developed agent. */
108
+ DEEPSEEK_BASE_URL: string;
109
+ /** Model used by the ChatCCC self-developed agent. */
110
+ model: string;
111
+ }
112
+
113
+ export interface FeishuConfig {
114
+ appId: string;
115
+ appSecret: string;
116
+ }
108
117
 
109
118
  export interface PlatformConfig {
110
119
  enabled: boolean;
@@ -149,21 +158,22 @@ export interface AppConfig {
149
158
  /** 若为 false,AI 生成过程中用户发送消息不会打断,须先点「停止」再发送新消息 */
150
159
  allowInterrupt: boolean;
151
160
  rawStreamLogs: RawStreamLogsConfig;
152
- claude: ClaudeConfig;
153
- cursor: CursorConfig;
154
- codex: CodexConfig;
155
- }
161
+ claude: ClaudeConfig;
162
+ cursor: CursorConfig;
163
+ codex: CodexConfig;
164
+ ccc: CccConfig;
165
+ }
156
166
 
157
167
  export type AgentTool = "claude" | "cursor" | "codex";
158
168
  export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
159
169
  export type CursorAvatarBatteryMode = "apiPercent" | "onDemandUse";
160
170
 
161
171
  /** 获取指定 agent 配置中所有模型相关的值(最多 100 个,去重) */
162
- export function getAllModelsForTool(tool: AgentTool, cfg: AppConfig = config): string[] {
163
- const seen = new Set<string>();
164
- const collect = (v: unknown) => {
165
- if (typeof v === "string" && v.trim()) seen.add(v.trim());
166
- };
172
+ export function getAllModelsForTool(tool: AgentTool, cfg: AppConfig = config): string[] {
173
+ const seen = new Set<string>();
174
+ const collect = (v: unknown) => {
175
+ if (typeof v === "string" && v.trim()) seen.add(v.trim());
176
+ };
167
177
 
168
178
  if (tool === "claude") {
169
179
  collect(cfg.claude.model);
@@ -175,12 +185,29 @@ export function getAllModelsForTool(tool: AgentTool, cfg: AppConfig = config): s
175
185
  collect(cfg.codex.model);
176
186
  collect(cfg.codex.alternativeModel);
177
187
  }
178
-
179
- return Array.from(seen).slice(0, 100);
180
- }
181
-
182
- const CONFIG_FILE = join(USER_DATA_DIR, "config.json");
183
- const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
188
+
189
+ return Array.from(seen).slice(0, 100);
190
+ }
191
+
192
+ export const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const;
193
+ export const CODEX_EFFORT_LEVELS = ["minimal", "low", "medium", "high", "xhigh"] as const;
194
+
195
+ export function getAllEffortsForTool(tool: AgentTool): string[] {
196
+ if (tool === "claude") return [...CLAUDE_EFFORT_LEVELS];
197
+ if (tool === "codex") return [...CODEX_EFFORT_LEVELS];
198
+ return [];
199
+ }
200
+
201
+ export function getDefaultEffortForTool(tool: AgentTool, cfg: AppConfig = config): string {
202
+ if (tool === "claude") return cfg.claude.effort;
203
+ if (tool === "codex") return cfg.codex.effort;
204
+ return "";
205
+ }
206
+
207
+ const CONFIG_FILE = join(USER_DATA_DIR, "config.json");
208
+ const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
209
+ export const DEFAULT_CCC_DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1";
210
+ export const DEFAULT_CCC_MODEL = "deepseek-v4-pro[1m]";
184
211
 
185
212
  /**
186
213
  * 将旧位置(PROJECT_ROOT)的持久化数据一次性迁移到 USER_DATA_DIR。
@@ -398,8 +425,8 @@ function loadConfig(): AppConfig {
398
425
  claude: { enabled: false, maxBytesPerTurn: 50 * 1024 * 1024, retentionDays: 7, keepCompleted: false },
399
426
  cursor: { enabled: false, maxBytesPerTurn: 50 * 1024 * 1024, retentionDays: 7, keepCompleted: false },
400
427
  codex: { enabled: false, maxBytesPerTurn: 50 * 1024 * 1024, retentionDays: 7, keepCompleted: false },
401
- },
402
- claude: { enabled: false, defaultAgent: true, model: "", subagentModel: "", effort: "", apiKey: "", baseUrl: "", maxTurn: 0 },
428
+ },
429
+ claude: { enabled: false, defaultAgent: true, model: "", subagentModel: "", effort: "", apiKey: "", baseUrl: "", maxTurn: 0 },
403
430
  cursor: {
404
431
  enabled: false,
405
432
  defaultAgent: false,
@@ -408,9 +435,10 @@ function loadConfig(): AppConfig {
408
435
  alternativeModel: "",
409
436
  avatarBatteryMode: "apiPercent",
410
437
  onDemandMonthlyBudget: 1000,
411
- },
412
- codex: { enabled: false, defaultAgent: false, path: "", model: "", alternativeModel: "", effort: "" },
413
- };
438
+ },
439
+ codex: { enabled: false, defaultAgent: false, path: "", model: "", alternativeModel: "", effort: "" },
440
+ ccc: { DEEPSEEK_API_KEY: "", DEEPSEEK_BASE_URL: DEFAULT_CCC_DEEPSEEK_BASE_URL, model: DEFAULT_CCC_MODEL },
441
+ };
414
442
 
415
443
  if (!IS_TEST_ENV) {
416
444
  migrateLegacyData();
@@ -460,11 +488,12 @@ function loadConfig(): AppConfig {
460
488
  alternativeModel?: unknown;
461
489
  avatarBatteryMode?: unknown;
462
490
  onDemandMonthlyBudget?: unknown;
463
- };
464
- codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; alternativeModel?: unknown; effort?: unknown };
465
- chromeDevtools?: { enabled?: unknown; port?: unknown; chromePath?: unknown };
466
- rawStreamLogs?: unknown;
467
- };
491
+ };
492
+ codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; alternativeModel?: unknown; effort?: unknown };
493
+ ccc?: { DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
494
+ chromeDevtools?: { enabled?: unknown; port?: unknown; chromePath?: unknown };
495
+ rawStreamLogs?: unknown;
496
+ };
468
497
  try {
469
498
  parsed = JSON.parse(raw);
470
499
  } catch (err) {
@@ -474,9 +503,10 @@ function loadConfig(): AppConfig {
474
503
 
475
504
  const feishu = parsed.feishu ?? { appId: "", appSecret: "" };
476
505
  const claude = parsed.claude ?? {} as Partial<ClaudeConfig>;
477
- const cursorRaw = (parsed.cursor ?? {}) as NonNullable<typeof parsed.cursor>;
478
- const codexRaw = (parsed.codex ?? {}) as NonNullable<typeof parsed.codex>;
479
- const chromeDevtoolsRaw = (parsed.chromeDevtools ?? {}) as NonNullable<typeof parsed.chromeDevtools>;
506
+ const cursorRaw = (parsed.cursor ?? {}) as NonNullable<typeof parsed.cursor>;
507
+ const codexRaw = (parsed.codex ?? {}) as NonNullable<typeof parsed.codex>;
508
+ const cccRaw = (parsed.ccc ?? {}) as NonNullable<typeof parsed.ccc>;
509
+ const chromeDevtoolsRaw = (parsed.chromeDevtools ?? {}) as NonNullable<typeof parsed.chromeDevtools>;
480
510
  const rawStreamLogsRaw = typeof parsed.rawStreamLogs === "object" && parsed.rawStreamLogs !== null
481
511
  ? parsed.rawStreamLogs as unknown as Record<string, unknown>
482
512
  : {};
@@ -599,16 +629,24 @@ function loadConfig(): AppConfig {
599
629
  avatarBatteryMode: normalizeCursorAvatarBatteryMode(cursorRaw.avatarBatteryMode),
600
630
  onDemandMonthlyBudget: normalizeCursorOnDemandMonthlyBudget(cursorRaw.onDemandMonthlyBudget),
601
631
  },
602
- codex: {
603
- enabled: codexEnabled,
604
- defaultAgent: defaultTool === "codex",
605
- path: readToolCliPath(codexRaw, { label: "codex", onLegacyField }),
606
- model: normalizeOptionalConfigField(codexRaw.model, { label: "codex.model" }),
607
- alternativeModel: normalizeOptionalConfigField(codexRaw.alternativeModel, { label: "codex.alternativeModel" }),
608
- effort: normalizeOptionalConfigField(codexRaw.effort, { label: "codex.effort" }),
609
- },
610
- };
611
- }
632
+ codex: {
633
+ enabled: codexEnabled,
634
+ defaultAgent: defaultTool === "codex",
635
+ path: readToolCliPath(codexRaw, { label: "codex", onLegacyField }),
636
+ model: normalizeOptionalConfigField(codexRaw.model, { label: "codex.model" }),
637
+ alternativeModel: normalizeOptionalConfigField(codexRaw.alternativeModel, { label: "codex.alternativeModel" }),
638
+ effort: normalizeOptionalConfigField(codexRaw.effort, { label: "codex.effort" }),
639
+ },
640
+ ccc: {
641
+ DEEPSEEK_API_KEY: normalizeOptionalConfigField(cccRaw.DEEPSEEK_API_KEY, { label: "ccc.DEEPSEEK_API_KEY" }),
642
+ DEEPSEEK_BASE_URL: normalizeOptionalConfigField(cccRaw.DEEPSEEK_BASE_URL, {
643
+ label: "ccc.DEEPSEEK_BASE_URL",
644
+ fallback: DEFAULT_CCC_DEEPSEEK_BASE_URL,
645
+ }),
646
+ model: normalizeOptionalConfigField(cccRaw.model, { label: "ccc.model", fallback: DEFAULT_CCC_MODEL }),
647
+ },
648
+ };
649
+ }
612
650
 
613
651
  /**
614
652
  * 全局可变 config 对象。
package/src/feishu-api.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  config,
21
21
  } from "./config.ts";
22
22
  import { getCursorUsageSummary, type CursorUsageSummary } from "./cursor-usage.ts";
23
+ import type { ChatAvatarUsageHints } from "./platform-adapter.ts";
23
24
  import { applyPrivacy } from "./privacy.ts";
24
25
 
25
26
  // ---------------------------------------------------------------------------
@@ -302,10 +303,10 @@ const AVATAR_DIR = resolvePath(PROJECT_ROOT, "images", "avatars");
302
303
  const AVATAR_BADGE_DIR = resolvePath(AVATAR_DIR, "badges");
303
304
  const AVATAR_COMBINATIONS_DIR = resolvePath(AVATAR_DIR, "combinations");
304
305
  const AVATAR_KEY_CACHE_FILE = resolvePath(USER_DATA_DIR, "state", "avatar-image-keys.json");
305
- const CODEX_AUTH_FILE = resolvePath(homedir(), ".codex", "auth.json");
306
- const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
307
- const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
308
- const CODEX_RESET_CONSUME_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
306
+ const CODEX_AUTH_FILE = resolvePath(homedir(), ".codex", "auth.json");
307
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
308
+ const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
309
+ const CODEX_RESET_CONSUME_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
309
310
  const AVATAR_SOURCES: Record<string, string> = {
310
311
  new: resolvePath(AVATAR_DIR, "status_new.png"),
311
312
  busy: resolvePath(AVATAR_DIR, "status_busy.png"),
@@ -322,31 +323,31 @@ const AVATAR_BADGE_MARGIN = 10;
322
323
  const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-ring-gray-consumed-v13";
323
324
  const CURSOR_AVATAR_USAGE_STYLE_VERSION = "usage-battery-v1";
324
325
 
325
- export interface CodexUsageBalance {
326
- usedPercent: number;
327
- remainingPercent: number;
328
- resetAtEpochSeconds: number | null;
329
- resetAfterSeconds: number | null;
330
- }
331
-
332
- export interface CodexRateLimitResetCredit {
333
- grantedAt: string | null;
334
- expiresAt: string;
335
- }
336
-
337
- export interface CodexUsageSummary {
338
- fiveHour: CodexUsageBalance;
339
- weekly: CodexUsageBalance | null;
340
- rateLimitResetCreditsAvailable: number | null;
341
- rateLimitResetCredits: CodexRateLimitResetCredit[] | null;
342
- }
343
-
344
- export type CodexResetConsumeCode = "reset" | "nothing_to_reset" | "no_credit" | "already_redeemed";
345
-
346
- export interface CodexResetConsumeResult {
347
- code: CodexResetConsumeCode;
348
- windowsReset: number;
349
- }
326
+ export interface CodexUsageBalance {
327
+ usedPercent: number;
328
+ remainingPercent: number;
329
+ resetAtEpochSeconds: number | null;
330
+ resetAfterSeconds: number | null;
331
+ }
332
+
333
+ export interface CodexRateLimitResetCredit {
334
+ grantedAt: string | null;
335
+ expiresAt: string;
336
+ }
337
+
338
+ export interface CodexUsageSummary {
339
+ fiveHour: CodexUsageBalance;
340
+ weekly: CodexUsageBalance | null;
341
+ rateLimitResetCreditsAvailable: number | null;
342
+ rateLimitResetCredits: CodexRateLimitResetCredit[] | null;
343
+ }
344
+
345
+ export type CodexResetConsumeCode = "reset" | "nothing_to_reset" | "no_credit" | "already_redeemed";
346
+
347
+ export interface CodexResetConsumeResult {
348
+ code: CodexResetConsumeCode;
349
+ windowsReset: number;
350
+ }
350
351
 
351
352
  const avatarKeyCache = new Map<string, string>();
352
353
  let avatarKeyCacheLoaded = false;
@@ -427,167 +428,172 @@ function usageBalanceFromWindow(raw: Record<string, unknown>, fieldName: string)
427
428
  };
428
429
  }
429
430
 
430
- function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: string[]): CodexUsageBalance | null {
431
+ function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: string[]): CodexUsageBalance | null {
431
432
  for (const key of keys) {
432
433
  const raw = rateLimit[key];
433
434
  if (!raw || typeof raw !== "object") continue;
434
435
  if ((raw as { used_percent?: unknown }).used_percent === undefined) continue;
435
436
  return usageBalanceFromWindow(raw as Record<string, unknown>, `rate_limit.${key}`);
436
437
  }
437
- return null;
438
- }
439
-
440
- function parseRateLimitResetCredits(data: Record<string, unknown>): number | null {
441
- const raw = data.rate_limit_reset_credits;
442
- if (!raw || typeof raw !== "object") return null;
443
- const availableCount = Number((raw as { available_count?: unknown }).available_count);
444
- if (!Number.isFinite(availableCount)) return null;
445
- return Math.max(0, Math.trunc(availableCount));
446
- }
447
-
448
- interface CodexAuth {
449
- accessToken: string;
450
- accountId?: string;
451
- }
452
-
453
- async function getCodexAuth(): Promise<CodexAuth | null> {
454
- try {
455
- const raw = await readFile(CODEX_AUTH_FILE, "utf-8");
456
- const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown; account_id?: unknown } };
457
- const token = parsed.tokens?.access_token;
458
- if (typeof token !== "string" || !token.trim()) return null;
459
- const accountId = parsed.tokens?.account_id;
460
- return {
461
- accessToken: token,
462
- accountId: typeof accountId === "string" && accountId.trim() ? accountId : undefined,
463
- };
464
- } catch {
465
- return null;
466
- }
467
- }
468
-
469
- async function getCodexAccessToken(): Promise<string | null> {
470
- return (await getCodexAuth())?.accessToken ?? null;
471
- }
472
-
473
- function codexAuthHeaders(auth: CodexAuth): Record<string, string> {
474
- const headers: Record<string, string> = {
475
- Authorization: `Bearer ${auth.accessToken}`,
476
- "OpenAI-Beta": "codex-1",
477
- originator: "Codex Desktop",
478
- };
479
- if (auth.accountId) headers["ChatGPT-Account-ID"] = auth.accountId;
480
- return headers;
481
- }
482
-
483
- function parseCodexResetCreditDetails(data: Record<string, unknown>): {
484
- availableCount: number | null;
485
- availableCredits: CodexRateLimitResetCredit[];
486
- } {
487
- const availableCount = Number(data.available_count);
488
- const credits = Array.isArray(data.credits) ? data.credits : [];
489
- return {
490
- availableCount: Number.isFinite(availableCount) ? Math.max(0, Math.trunc(availableCount)) : null,
491
- availableCredits: credits.flatMap((raw) => {
492
- if (!raw || typeof raw !== "object") return [];
493
- const credit = raw as { status?: unknown; granted_at?: unknown; expires_at?: unknown };
494
- if (credit.status !== "available") return [];
495
- if (typeof credit.expires_at !== "string" || !credit.expires_at.trim()) return [];
496
- return [{
497
- grantedAt: typeof credit.granted_at === "string" && credit.granted_at.trim() ? credit.granted_at : null,
498
- expiresAt: credit.expires_at,
499
- }];
500
- }),
501
- };
502
- }
503
-
504
- async function fetchCodexRateLimitResetCredits(auth: CodexAuth): Promise<{
505
- availableCount: number | null;
506
- availableCredits: CodexRateLimitResetCredit[];
507
- } | null> {
508
- try {
509
- const resp = await fetch(CODEX_RESET_CREDITS_URL, {
510
- headers: codexAuthHeaders(auth),
511
- });
512
- const text = await resp.text();
513
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
514
- return parseCodexResetCreditDetails(JSON.parse(text) as Record<string, unknown>);
515
- } catch (err) {
516
- console.warn(`[Codex] reset credits lookup failed: ${(err as Error).message}`);
517
- return null;
518
- }
519
- }
520
-
521
- export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
522
- const auth = await getCodexAuth();
523
- if (!auth) throw new Error("missing ~/.codex/auth.json access token");
524
-
525
- const resetCreditsPromise = fetchCodexRateLimitResetCredits(auth);
526
-
527
- const resp = await fetch(CODEX_USAGE_URL, {
528
- headers: { Authorization: `Bearer ${auth.accessToken}` },
529
- });
530
- const text = await resp.text();
531
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
532
-
533
- const data = JSON.parse(text) as {
534
- rate_limit?: Record<string, unknown>;
535
- rate_limit_reset_credits?: unknown;
536
- };
537
- const rateLimit = data.rate_limit;
538
- if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
539
-
540
- const fiveHour = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
541
- if (!fiveHour) throw new Error("missing rate_limit.primary_window.used_percent");
542
- const resetCredits = await resetCreditsPromise;
543
-
544
- return {
545
- fiveHour,
546
- weekly: parseOptionalUsageWindow(rateLimit, [
547
- "secondary_window",
548
- "weekly_window",
549
- "week_window",
550
- "long_window",
551
- ]),
552
- rateLimitResetCreditsAvailable: resetCredits?.availableCount ?? parseRateLimitResetCredits(data),
553
- rateLimitResetCredits: resetCredits?.availableCredits ?? null,
554
- };
555
- }
556
-
557
- export async function consumeCodexRateLimitResetCredit(redeemRequestId: string): Promise<CodexResetConsumeResult> {
558
- if (!redeemRequestId.trim()) throw new Error("missing redeem_request_id");
559
- const token = await getCodexAccessToken();
560
- if (!token) throw new Error("missing ~/.codex/auth.json access token");
561
-
562
- const resp = await fetch(CODEX_RESET_CONSUME_URL, {
563
- method: "POST",
564
- headers: {
565
- Authorization: `Bearer ${token}`,
566
- "Content-Type": "application/json",
567
- },
568
- body: JSON.stringify({ redeem_request_id: redeemRequestId }),
569
- });
570
- const text = await resp.text();
571
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
572
-
573
- const data = JSON.parse(text) as { code?: unknown; windows_reset?: unknown };
574
- const code = data.code;
575
- if (
576
- code !== "reset"
577
- && code !== "nothing_to_reset"
578
- && code !== "no_credit"
579
- && code !== "already_redeemed"
580
- ) {
581
- throw new Error("missing or unknown reset result code");
582
- }
583
- const windowsReset = Number(data.windows_reset);
584
- return {
585
- code,
586
- windowsReset: Number.isFinite(windowsReset) ? Math.max(0, Math.trunc(windowsReset)) : 0,
587
- };
588
- }
589
-
590
- async function fetchCodexAvatarUsage(): Promise<CodexUsageSummary | null> {
438
+ return null;
439
+ }
440
+
441
+ function parseRateLimitResetCredits(data: Record<string, unknown>): number | null {
442
+ const raw = data.rate_limit_reset_credits;
443
+ if (!raw || typeof raw !== "object") return null;
444
+ const availableCount = Number((raw as { available_count?: unknown }).available_count);
445
+ if (!Number.isFinite(availableCount)) return null;
446
+ return Math.max(0, Math.trunc(availableCount));
447
+ }
448
+
449
+ interface CodexAuth {
450
+ accessToken: string;
451
+ accountId?: string;
452
+ }
453
+
454
+ async function getCodexAuth(): Promise<CodexAuth | null> {
455
+ try {
456
+ const raw = await readFile(CODEX_AUTH_FILE, "utf-8");
457
+ const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown; account_id?: unknown } };
458
+ const token = parsed.tokens?.access_token;
459
+ if (typeof token !== "string" || !token.trim()) return null;
460
+ const accountId = parsed.tokens?.account_id;
461
+ return {
462
+ accessToken: token,
463
+ accountId: typeof accountId === "string" && accountId.trim() ? accountId : undefined,
464
+ };
465
+ } catch {
466
+ return null;
467
+ }
468
+ }
469
+
470
+ async function getCodexAccessToken(): Promise<string | null> {
471
+ return (await getCodexAuth())?.accessToken ?? null;
472
+ }
473
+
474
+ function codexAuthHeaders(auth: CodexAuth): Record<string, string> {
475
+ const headers: Record<string, string> = {
476
+ Authorization: `Bearer ${auth.accessToken}`,
477
+ "OpenAI-Beta": "codex-1",
478
+ originator: "Codex Desktop",
479
+ };
480
+ if (auth.accountId) headers["ChatGPT-Account-ID"] = auth.accountId;
481
+ return headers;
482
+ }
483
+
484
+ function parseCodexResetCreditDetails(data: Record<string, unknown>): {
485
+ availableCount: number | null;
486
+ availableCredits: CodexRateLimitResetCredit[];
487
+ } {
488
+ const availableCount = Number(data.available_count);
489
+ const credits = Array.isArray(data.credits) ? data.credits : [];
490
+ return {
491
+ availableCount: Number.isFinite(availableCount) ? Math.max(0, Math.trunc(availableCount)) : null,
492
+ availableCredits: credits.flatMap((raw) => {
493
+ if (!raw || typeof raw !== "object") return [];
494
+ const credit = raw as { status?: unknown; granted_at?: unknown; expires_at?: unknown };
495
+ if (credit.status !== "available") return [];
496
+ if (typeof credit.expires_at !== "string" || !credit.expires_at.trim()) return [];
497
+ return [{
498
+ grantedAt: typeof credit.granted_at === "string" && credit.granted_at.trim() ? credit.granted_at : null,
499
+ expiresAt: credit.expires_at,
500
+ }];
501
+ }),
502
+ };
503
+ }
504
+
505
+ async function fetchCodexRateLimitResetCredits(auth: CodexAuth): Promise<{
506
+ availableCount: number | null;
507
+ availableCredits: CodexRateLimitResetCredit[];
508
+ } | null> {
509
+ try {
510
+ const resp = await fetch(CODEX_RESET_CREDITS_URL, {
511
+ headers: codexAuthHeaders(auth),
512
+ });
513
+ const text = await resp.text();
514
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
515
+ return parseCodexResetCreditDetails(JSON.parse(text) as Record<string, unknown>);
516
+ } catch (err) {
517
+ console.warn(`[Codex] reset credits lookup failed: ${(err as Error).message}`);
518
+ return null;
519
+ }
520
+ }
521
+
522
+ export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
523
+ const auth = await getCodexAuth();
524
+ if (!auth) throw new Error("missing ~/.codex/auth.json access token");
525
+
526
+ const resetCreditsPromise = fetchCodexRateLimitResetCredits(auth);
527
+
528
+ const resp = await fetch(CODEX_USAGE_URL, {
529
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
530
+ });
531
+ const text = await resp.text();
532
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
533
+
534
+ const data = JSON.parse(text) as {
535
+ rate_limit?: Record<string, unknown>;
536
+ rate_limit_reset_credits?: unknown;
537
+ };
538
+ const rateLimit = data.rate_limit;
539
+ if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
540
+
541
+ const fiveHour = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
542
+ if (!fiveHour) throw new Error("missing rate_limit.primary_window.used_percent");
543
+ const resetCredits = await resetCreditsPromise;
544
+
545
+ return {
546
+ fiveHour,
547
+ weekly: parseOptionalUsageWindow(rateLimit, [
548
+ "secondary_window",
549
+ "weekly_window",
550
+ "week_window",
551
+ "long_window",
552
+ ]),
553
+ rateLimitResetCreditsAvailable: resetCredits?.availableCount ?? parseRateLimitResetCredits(data),
554
+ rateLimitResetCredits: resetCredits?.availableCredits ?? null,
555
+ };
556
+ }
557
+
558
+ export async function consumeCodexRateLimitResetCredit(redeemRequestId: string): Promise<CodexResetConsumeResult> {
559
+ if (!redeemRequestId.trim()) throw new Error("missing redeem_request_id");
560
+ const token = await getCodexAccessToken();
561
+ if (!token) throw new Error("missing ~/.codex/auth.json access token");
562
+
563
+ const resp = await fetch(CODEX_RESET_CONSUME_URL, {
564
+ method: "POST",
565
+ headers: {
566
+ Authorization: `Bearer ${token}`,
567
+ "Content-Type": "application/json",
568
+ },
569
+ body: JSON.stringify({ redeem_request_id: redeemRequestId }),
570
+ });
571
+ const text = await resp.text();
572
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
573
+
574
+ const data = JSON.parse(text) as { code?: unknown; windows_reset?: unknown };
575
+ const code = data.code;
576
+ if (
577
+ code !== "reset"
578
+ && code !== "nothing_to_reset"
579
+ && code !== "no_credit"
580
+ && code !== "already_redeemed"
581
+ ) {
582
+ throw new Error("missing or unknown reset result code");
583
+ }
584
+ const windowsReset = Number(data.windows_reset);
585
+ return {
586
+ code,
587
+ windowsReset: Number.isFinite(windowsReset) ? Math.max(0, Math.trunc(windowsReset)) : 0,
588
+ };
589
+ }
590
+
591
+ async function resolveCodexAvatarUsage(usageHint?: CodexUsageSummary | null): Promise<CodexUsageSummary | null> {
592
+ if (usageHint !== undefined) {
593
+ if (!usageHint?.weekly) return null;
594
+ return usageHint;
595
+ }
596
+
591
597
  try {
592
598
  const summary = await getCodexUsageSummary();
593
599
  if (!summary.weekly) throw new Error("missing weekly usage window");
@@ -611,7 +617,17 @@ function cursorAvatarBatteryPercentFromUsage(summary: CursorUsageSummary): numbe
611
617
  return clampPercent(100 - apiPercentUsed);
612
618
  }
613
619
 
614
- async function fetchCursorAvatarBatteryPercent(): Promise<number | null> {
620
+ async function resolveCursorAvatarBatteryPercent(usageHint?: CursorUsageSummary | null): Promise<number | null> {
621
+ if (usageHint !== undefined) {
622
+ if (!usageHint) return null;
623
+ try {
624
+ return cursorAvatarBatteryPercentFromUsage(usageHint);
625
+ } catch (err) {
626
+ console.warn(`[${ts()}] [AVATAR] Cursor usage unavailable, using plain avatar: ${(err as Error).message}`);
627
+ return null;
628
+ }
629
+ }
630
+
615
631
  try {
616
632
  return cursorAvatarBatteryPercentFromUsage(await getCursorUsageSummary());
617
633
  } catch (err) {
@@ -814,12 +830,19 @@ async function uploadImage(
814
830
  return data.data!.image_key!;
815
831
  }
816
832
 
817
- async function getOrUploadAvatarKey(token: string, tool: string, status: string): Promise<string> {
833
+ async function getOrUploadAvatarKey(
834
+ token: string,
835
+ tool: string,
836
+ status: string,
837
+ usageHints: ChatAvatarUsageHints = {},
838
+ ): Promise<string> {
818
839
  await loadAvatarKeyCache();
819
840
  const normalizedTool = normalizeAvatarTool(tool);
820
841
  const normalizedStatus = normalizeAvatarStatus(status);
821
- const codexUsage = normalizedTool === "codex" ? await fetchCodexAvatarUsage() : null;
822
- const cursorBatteryPercent = normalizedTool === "cursor" ? await fetchCursorAvatarBatteryPercent() : null;
842
+ const codexUsage = normalizedTool === "codex" ? await resolveCodexAvatarUsage(usageHints.codexUsage) : null;
843
+ const cursorBatteryPercent = normalizedTool === "cursor"
844
+ ? await resolveCursorAvatarBatteryPercent(usageHints.cursorUsage)
845
+ : null;
823
846
  const keyName = avatarCacheKey(normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent);
824
847
  const cached = avatarKeyCache.get(keyName);
825
848
  if (cached) return cached;
@@ -832,9 +855,15 @@ async function getOrUploadAvatarKey(token: string, tool: string, status: string)
832
855
  return key;
833
856
  }
834
857
 
835
- export async function setChatAvatar(token: string, chatId: string, tool: string, status: string): Promise<void> {
858
+ export async function setChatAvatar(
859
+ token: string,
860
+ chatId: string,
861
+ tool: string,
862
+ status: string,
863
+ usageHints: ChatAvatarUsageHints = {},
864
+ ): Promise<void> {
836
865
  try {
837
- const avatarKey = await getOrUploadAvatarKey(token, tool, status);
866
+ const avatarKey = await getOrUploadAvatarKey(token, tool, status, usageHints);
838
867
  const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
839
868
  method: "PUT",
840
869
  headers: {