chatccc 0.2.178 → 0.2.179
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 +4 -4
- package/config.sample.json +3 -1
- package/package.json +1 -1
- package/src/__tests__/card-plain-text.test.ts +1 -1
- package/src/__tests__/config-reload.test.ts +29 -22
- package/src/__tests__/feishu-avatar.test.ts +219 -129
- package/src/__tests__/orchestrator.test.ts +163 -93
- package/src/cards.ts +6 -6
- package/src/config.ts +31 -2
- package/src/cursor-usage.ts +128 -0
- package/src/feishu-api.ts +65 -9
- package/src/index.ts +1 -1
- package/src/orchestrator.ts +217 -131
- package/src/web-ui.ts +95 -11
|
@@ -6,8 +6,9 @@ import { join } from "node:path";
|
|
|
6
6
|
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
7
7
|
import type { SessionInfo, ToolAdapter } from "../adapters/adapter-interface.ts";
|
|
8
8
|
|
|
9
|
-
const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped"; finalReply: string }>();
|
|
10
|
-
const mockGetCodexUsageSummary = vi.hoisted(() => vi.fn());
|
|
9
|
+
const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped"; finalReply: string }>();
|
|
10
|
+
const mockGetCodexUsageSummary = vi.hoisted(() => vi.fn());
|
|
11
|
+
const mockGetCursorUsageSummary = vi.hoisted(() => vi.fn());
|
|
11
12
|
|
|
12
13
|
vi.mock("../im-skills.ts", () => ({
|
|
13
14
|
buildImSkillsPrompt: async () => "",
|
|
@@ -15,7 +16,7 @@ vi.mock("../im-skills.ts", () => ({
|
|
|
15
16
|
exportSkillSubDocs: async () => {},
|
|
16
17
|
}));
|
|
17
18
|
|
|
18
|
-
vi.mock("../stream-state.ts", () => ({
|
|
19
|
+
vi.mock("../stream-state.ts", () => ({
|
|
19
20
|
readStreamState: async (sessionId: string) => {
|
|
20
21
|
const state = mockStreamStates.get(sessionId);
|
|
21
22
|
if (!state) return null;
|
|
@@ -50,14 +51,18 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
50
51
|
cwd,
|
|
51
52
|
tool,
|
|
52
53
|
}),
|
|
53
|
-
fixStaleStreamStates: async () => {},
|
|
54
|
-
}));
|
|
55
|
-
|
|
56
|
-
vi.mock("../feishu-platform.ts", () => ({
|
|
57
|
-
getCodexUsageSummary: mockGetCodexUsageSummary,
|
|
58
|
-
getTenantAccessToken: vi.fn(async () => "tenant-token"),
|
|
59
|
-
sendPostMessage: vi.fn(async () => true),
|
|
60
|
-
}));
|
|
54
|
+
fixStaleStreamStates: async () => {},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
vi.mock("../feishu-platform.ts", () => ({
|
|
58
|
+
getCodexUsageSummary: mockGetCodexUsageSummary,
|
|
59
|
+
getTenantAccessToken: vi.fn(async () => "tenant-token"),
|
|
60
|
+
sendPostMessage: vi.fn(async () => true),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
vi.mock("../cursor-usage.ts", () => ({
|
|
64
|
+
getCursorUsageSummary: mockGetCursorUsageSummary,
|
|
65
|
+
}));
|
|
61
66
|
|
|
62
67
|
import { handleCommand } from "../orchestrator.ts";
|
|
63
68
|
import {
|
|
@@ -120,14 +125,40 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
120
125
|
_setSessionToolsFileForTest(join(tempDir, "sessions.json"));
|
|
121
126
|
resetState();
|
|
122
127
|
resetBindingState();
|
|
123
|
-
mockStreamStates.clear();
|
|
124
|
-
mockGetCodexUsageSummary.mockReset();
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
mockStreamStates.clear();
|
|
129
|
+
mockGetCodexUsageSummary.mockReset();
|
|
130
|
+
mockGetCursorUsageSummary.mockReset();
|
|
131
|
+
mockGetCodexUsageSummary.mockResolvedValue({
|
|
132
|
+
fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
133
|
+
weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
134
|
+
});
|
|
135
|
+
mockGetCursorUsageSummary.mockResolvedValue({
|
|
136
|
+
billingCycleStart: "1779357999000",
|
|
137
|
+
billingCycleEnd: "1782036399000",
|
|
138
|
+
planUsage: {
|
|
139
|
+
totalSpend: 8159,
|
|
140
|
+
includedSpend: 2000,
|
|
141
|
+
bonusSpend: 6159,
|
|
142
|
+
limit: 2000,
|
|
143
|
+
remainingBonus: false,
|
|
144
|
+
autoPercentUsed: 0,
|
|
145
|
+
apiPercentUsed: 100,
|
|
146
|
+
totalPercentUsed: 100,
|
|
147
|
+
},
|
|
148
|
+
spendLimitUsage: {
|
|
149
|
+
pooledLimit: 48950000,
|
|
150
|
+
pooledUsed: 31808224,
|
|
151
|
+
pooledRemaining: 17141776,
|
|
152
|
+
individualUsed: 101252,
|
|
153
|
+
limitType: "team",
|
|
154
|
+
},
|
|
155
|
+
displayThreshold: 200,
|
|
156
|
+
enabled: true,
|
|
157
|
+
displayMessage: "You've hit your usage limit",
|
|
158
|
+
autoBucketModels: ["default"],
|
|
159
|
+
});
|
|
160
|
+
_setAdapterForToolForTest("claude", mockAdapter());
|
|
161
|
+
});
|
|
131
162
|
|
|
132
163
|
afterEach(async () => {
|
|
133
164
|
resetState();
|
|
@@ -249,7 +280,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
249
280
|
expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
|
|
250
281
|
});
|
|
251
282
|
|
|
252
|
-
it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
|
|
283
|
+
it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
|
|
253
284
|
const platform = mockPlatform("feishu");
|
|
254
285
|
await recordSessionRegistry({
|
|
255
286
|
chatId: "feishu-p2p",
|
|
@@ -264,76 +295,115 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
264
295
|
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
265
296
|
expect(platform.sendRawCard).toHaveBeenCalled();
|
|
266
297
|
const registry = await loadSessionRegistryForBinding();
|
|
267
|
-
expect(registry["feishu-p2p"]).toBeUndefined();
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("handles /usage without creating a new Feishu group", async () => {
|
|
271
|
-
const platform = mockPlatform("feishu");
|
|
272
|
-
mockGetCodexUsageSummary.mockResolvedValue({
|
|
273
|
-
fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
|
|
274
|
-
weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
278
|
-
|
|
279
|
-
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
280
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
281
|
-
"feishu-p2p",
|
|
282
|
-
"Codex Usage",
|
|
283
|
-
expect.stringContaining("**5h:** 已用 37%,剩余 63%,重置:"),
|
|
284
|
-
"blue",
|
|
285
|
-
);
|
|
286
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
287
|
-
"feishu-p2p",
|
|
288
|
-
"Codex Usage",
|
|
289
|
-
expect.stringContaining("约 2小时52分钟后"),
|
|
290
|
-
"blue",
|
|
291
|
-
);
|
|
292
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
293
|
-
"feishu-p2p",
|
|
294
|
-
"Codex Usage",
|
|
295
|
-
expect.stringContaining("[███████░░░░░░░░░░░░░]"),
|
|
296
|
-
"blue",
|
|
297
|
-
);
|
|
298
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
299
|
-
"feishu-p2p",
|
|
300
|
-
"Codex Usage",
|
|
301
|
-
expect.stringContaining("**周:** 已用 12%,剩余 88%,重置:"),
|
|
302
|
-
"blue",
|
|
303
|
-
);
|
|
304
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
305
|
-
"feishu-p2p",
|
|
306
|
-
"Codex Usage",
|
|
307
|
-
expect.stringContaining("约 3天18小时17分钟后"),
|
|
308
|
-
"blue",
|
|
309
|
-
);
|
|
310
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
311
|
-
"feishu-p2p",
|
|
312
|
-
"Codex Usage",
|
|
313
|
-
expect.stringContaining("[██░░░░░░░░░░░░░░░░░░]"),
|
|
314
|
-
"blue",
|
|
315
|
-
);
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it("
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
298
|
+
expect(registry["feishu-p2p"]).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("handles /usage without creating a new Feishu group", async () => {
|
|
302
|
+
const platform = mockPlatform("feishu");
|
|
303
|
+
mockGetCodexUsageSummary.mockResolvedValue({
|
|
304
|
+
fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
|
|
305
|
+
weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
309
|
+
|
|
310
|
+
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
311
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
312
|
+
"feishu-p2p",
|
|
313
|
+
"Codex Usage",
|
|
314
|
+
expect.stringContaining("**5h:** 已用 37%,剩余 63%,重置:"),
|
|
315
|
+
"blue",
|
|
316
|
+
);
|
|
317
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
318
|
+
"feishu-p2p",
|
|
319
|
+
"Codex Usage",
|
|
320
|
+
expect.stringContaining("约 2小时52分钟后"),
|
|
321
|
+
"blue",
|
|
322
|
+
);
|
|
323
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
324
|
+
"feishu-p2p",
|
|
325
|
+
"Codex Usage",
|
|
326
|
+
expect.stringContaining("[███████░░░░░░░░░░░░░]"),
|
|
327
|
+
"blue",
|
|
328
|
+
);
|
|
329
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
330
|
+
"feishu-p2p",
|
|
331
|
+
"Codex Usage",
|
|
332
|
+
expect.stringContaining("**周:** 已用 12%,剩余 88%,重置:"),
|
|
333
|
+
"blue",
|
|
334
|
+
);
|
|
335
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
336
|
+
"feishu-p2p",
|
|
337
|
+
"Codex Usage",
|
|
338
|
+
expect.stringContaining("约 3天18小时17分钟后"),
|
|
339
|
+
"blue",
|
|
340
|
+
);
|
|
341
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
342
|
+
"feishu-p2p",
|
|
343
|
+
"Codex Usage",
|
|
344
|
+
expect.stringContaining("[██░░░░░░░░░░░░░░░░░░]"),
|
|
345
|
+
"blue",
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("handles /usage as Cursor usage in Cursor chats", async () => {
|
|
350
|
+
const platform = mockPlatform("feishu");
|
|
351
|
+
await recordSessionRegistry({
|
|
352
|
+
chatId: "cursor-chat",
|
|
353
|
+
sessionId: "sid-cursor",
|
|
354
|
+
tool: "cursor",
|
|
355
|
+
chatName: "cursor-session",
|
|
356
|
+
running: false,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
await handleCommand(platform, "/usage", "cursor-chat", "ou-user", Date.now(), "group");
|
|
360
|
+
|
|
361
|
+
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
362
|
+
expect(mockGetCodexUsageSummary).not.toHaveBeenCalled();
|
|
363
|
+
expect(mockGetCursorUsageSummary).toHaveBeenCalled();
|
|
364
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
365
|
+
"cursor-chat",
|
|
366
|
+
"Cursor Usage",
|
|
367
|
+
expect.stringContaining("Individual used: $1012.52"),
|
|
368
|
+
"blue",
|
|
369
|
+
);
|
|
370
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
371
|
+
"cursor-chat",
|
|
372
|
+
"Cursor Usage",
|
|
373
|
+
expect.stringContaining("Pool remaining: $171417.76"),
|
|
374
|
+
"blue",
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("advertises /usage in new Codex and Cursor session ready messages", async () => {
|
|
379
|
+
const codexPlatform = mockPlatform("feishu");
|
|
380
|
+
_setAdapterForToolForTest("codex", mockAdapter("sid-codex"));
|
|
381
|
+
|
|
382
|
+
await handleCommand(codexPlatform, "/new codex", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
383
|
+
|
|
384
|
+
expect(codexPlatform.sendCard).toHaveBeenCalledWith(
|
|
385
|
+
"feishu-group",
|
|
386
|
+
"Codex Session Ready",
|
|
387
|
+
expect.stringContaining("发送 **/usage** 查看 Codex 5h 和周用量。"),
|
|
388
|
+
"green",
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const cursorPlatform = mockPlatform("feishu");
|
|
392
|
+
_setAdapterForToolForTest("cursor", mockAdapter("sid-cursor"));
|
|
393
|
+
|
|
394
|
+
await handleCommand(cursorPlatform, "/new cursor", "feishu-p2p-cursor", "ou-user", Date.now(), "p2p");
|
|
395
|
+
|
|
396
|
+
const cursorReadyCall = vi.mocked(cursorPlatform.sendCard).mock.calls.find(
|
|
397
|
+
([chatId, title]) => chatId === "feishu-group" && title === "Cursor Session Ready",
|
|
398
|
+
);
|
|
399
|
+
expect(cursorReadyCall?.[2]).toContain("/usage");
|
|
400
|
+
|
|
401
|
+
const claudePlatform = mockPlatform("feishu");
|
|
402
|
+
await handleCommand(claudePlatform, "/new claude", "feishu-p2p-2", "ou-user", Date.now(), "p2p");
|
|
403
|
+
|
|
404
|
+
const claudeReadyCall = vi.mocked(claudePlatform.sendCard).mock.calls.find(
|
|
405
|
+
([chatId, title]) => chatId === "feishu-group" && title === "Claude Code Session Ready",
|
|
406
|
+
);
|
|
407
|
+
expect(claudeReadyCall?.[2]).not.toContain("/usage");
|
|
408
|
+
});
|
|
409
|
+
});
|
package/src/cards.ts
CHANGED
|
@@ -133,11 +133,11 @@ export function buildHelpCard(
|
|
|
133
133
|
"发送 **/new cursor** 创建新 Cursor 会话",
|
|
134
134
|
"发送 **/new codex** 创建新 Codex 会话",
|
|
135
135
|
"发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
|
|
136
|
-
"发送 **/plan** 以规划模式提问(只读,不执行写操作)",
|
|
137
|
-
"发送 **/ask** 以问答模式提问(只读,不执行写操作)",
|
|
138
|
-
"发送 **/usage** 查看 Codex 5h 和周用量",
|
|
139
|
-
"发送 **/restart** 重启 ChatCCC 进程",
|
|
140
|
-
"发送 **/
|
|
136
|
+
"发送 **/plan** 以规划模式提问(只读,不执行写操作)",
|
|
137
|
+
"发送 **/ask** 以问答模式提问(只读,不执行写操作)",
|
|
138
|
+
"发送 **/usage** 查看 Codex 5h 和周用量",
|
|
139
|
+
"发送 **/restart** 重启 ChatCCC 进程",
|
|
140
|
+
"发送 **/update** 更新并重启(仅 npm 全局安装可用)",
|
|
141
141
|
].join("\n");
|
|
142
142
|
return JSON.stringify({
|
|
143
143
|
config: { wide_screen_mode: true },
|
|
@@ -151,7 +151,7 @@ export function buildHelpCard(
|
|
|
151
151
|
{ text: "新建 Cursor 会话(/new cursor)", value: JSON.stringify({ cmd: "new cursor" }), type: "primary" },
|
|
152
152
|
{ text: "新建 Codex 会话(/new codex)", value: JSON.stringify({ cmd: "new codex" }), type: "primary" },
|
|
153
153
|
{ text: "重启 ChatCCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
|
|
154
|
-
{ text: "更新并重启(/
|
|
154
|
+
{ text: "更新并重启(/update)", value: JSON.stringify({ cmd: "update" }), type: "danger" },
|
|
155
155
|
{ text: "切换工作路径(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
|
|
156
156
|
]),
|
|
157
157
|
],
|
package/src/config.ts
CHANGED
|
@@ -81,6 +81,8 @@ export interface CursorConfig {
|
|
|
81
81
|
/** Cursor Agent CLI 可执行文件绝对路径;留空时由运行时按 LocalAppData / PATH 兜底 */
|
|
82
82
|
path: string;
|
|
83
83
|
model: string;
|
|
84
|
+
avatarBatteryMode: CursorAvatarBatteryMode;
|
|
85
|
+
onDemandMonthlyBudget: number;
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
export interface CodexConfig {
|
|
@@ -125,6 +127,7 @@ export interface AppConfig {
|
|
|
125
127
|
|
|
126
128
|
export type AgentTool = "claude" | "cursor" | "codex";
|
|
127
129
|
export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
|
|
130
|
+
export type CursorAvatarBatteryMode = "apiPercent" | "onDemandUse";
|
|
128
131
|
|
|
129
132
|
/** 获取指定 agent 配置中所有模型相关的值(最多 100 个,去重) */
|
|
130
133
|
export function getAllModelsForTool(tool: AgentTool, cfg: AppConfig = config): string[] {
|
|
@@ -326,6 +329,15 @@ function normalizePlatformType(raw: string): "feishu" | "lark" {
|
|
|
326
329
|
return "feishu";
|
|
327
330
|
}
|
|
328
331
|
|
|
332
|
+
function normalizeCursorAvatarBatteryMode(raw: unknown): CursorAvatarBatteryMode {
|
|
333
|
+
return raw === "onDemandUse" ? "onDemandUse" : "apiPercent";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function normalizeCursorOnDemandMonthlyBudget(raw: unknown): number {
|
|
337
|
+
const value = typeof raw === "string" ? Number(raw.trim()) : Number(raw);
|
|
338
|
+
return Number.isFinite(value) && value > 0 ? value : 1000;
|
|
339
|
+
}
|
|
340
|
+
|
|
329
341
|
function loadConfig(): AppConfig {
|
|
330
342
|
const defaults: AppConfig = {
|
|
331
343
|
feishu: { appId: "", appSecret: "" },
|
|
@@ -334,7 +346,14 @@ function loadConfig(): AppConfig {
|
|
|
334
346
|
gitTimeoutSeconds: 180,
|
|
335
347
|
allowInterrupt: false,
|
|
336
348
|
claude: { enabled: false, defaultAgent: true, model: "", subagentModel: "", effort: "", apiKey: "", baseUrl: "", maxTurn: 0 },
|
|
337
|
-
cursor: {
|
|
349
|
+
cursor: {
|
|
350
|
+
enabled: false,
|
|
351
|
+
defaultAgent: false,
|
|
352
|
+
path: "",
|
|
353
|
+
model: "claude-opus-4-7-max",
|
|
354
|
+
avatarBatteryMode: "apiPercent",
|
|
355
|
+
onDemandMonthlyBudget: 1000,
|
|
356
|
+
},
|
|
338
357
|
codex: { enabled: false, defaultAgent: false, path: "", model: "", effort: "" },
|
|
339
358
|
};
|
|
340
359
|
|
|
@@ -377,7 +396,15 @@ function loadConfig(): AppConfig {
|
|
|
377
396
|
|
|
378
397
|
let parsed: Partial<AppConfig> & {
|
|
379
398
|
claude?: Partial<ClaudeConfig> & { enabled?: unknown };
|
|
380
|
-
cursor?: {
|
|
399
|
+
cursor?: {
|
|
400
|
+
enabled?: unknown;
|
|
401
|
+
defaultAgent?: unknown;
|
|
402
|
+
path?: unknown;
|
|
403
|
+
command?: unknown;
|
|
404
|
+
model?: unknown;
|
|
405
|
+
avatarBatteryMode?: unknown;
|
|
406
|
+
onDemandMonthlyBudget?: unknown;
|
|
407
|
+
};
|
|
381
408
|
codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; effort?: unknown };
|
|
382
409
|
};
|
|
383
410
|
try {
|
|
@@ -491,6 +518,8 @@ function loadConfig(): AppConfig {
|
|
|
491
518
|
defaultAgent: defaultTool === "cursor",
|
|
492
519
|
path: readToolCliPath(cursorRaw, { label: "cursor", onLegacyField }),
|
|
493
520
|
model: normalizeOptionalConfigField(cursorRaw.model, { label: "cursor.model", fallback: "claude-opus-4-7-max" }),
|
|
521
|
+
avatarBatteryMode: normalizeCursorAvatarBatteryMode(cursorRaw.avatarBatteryMode),
|
|
522
|
+
onDemandMonthlyBudget: normalizeCursorOnDemandMonthlyBudget(cursorRaw.onDemandMonthlyBudget),
|
|
494
523
|
},
|
|
495
524
|
codex: {
|
|
496
525
|
enabled: codexEnabled,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
|
|
8
|
+
const CURSOR_USAGE_URL = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage";
|
|
9
|
+
|
|
10
|
+
export type CursorUsageSummary = {
|
|
11
|
+
billingCycleStart?: string;
|
|
12
|
+
billingCycleEnd?: string;
|
|
13
|
+
planUsage?: {
|
|
14
|
+
totalSpend?: number;
|
|
15
|
+
includedSpend?: number;
|
|
16
|
+
bonusSpend?: number;
|
|
17
|
+
limit?: number;
|
|
18
|
+
remainingBonus?: boolean;
|
|
19
|
+
bonusTooltip?: string;
|
|
20
|
+
autoPercentUsed?: number;
|
|
21
|
+
apiPercentUsed?: number;
|
|
22
|
+
totalPercentUsed?: number;
|
|
23
|
+
};
|
|
24
|
+
spendLimitUsage?: {
|
|
25
|
+
totalSpend?: number;
|
|
26
|
+
pooledLimit?: number;
|
|
27
|
+
pooledUsed?: number;
|
|
28
|
+
pooledRemaining?: number;
|
|
29
|
+
individualUsed?: number;
|
|
30
|
+
limitType?: string;
|
|
31
|
+
};
|
|
32
|
+
displayThreshold?: number;
|
|
33
|
+
enabled?: boolean;
|
|
34
|
+
displayMessage?: string;
|
|
35
|
+
autoModelSelectedDisplayMessage?: string;
|
|
36
|
+
namedModelSelectedDisplayMessage?: string;
|
|
37
|
+
autoBucketModels?: string[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type SqliteStatement = {
|
|
41
|
+
get(value?: unknown): unknown;
|
|
42
|
+
all(value?: unknown): unknown[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type SqliteDatabase = {
|
|
46
|
+
prepare(sql: string): SqliteStatement;
|
|
47
|
+
close(): void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function getCursorDbPath(): string {
|
|
51
|
+
if (process.platform === "win32") {
|
|
52
|
+
return join(process.env.APPDATA || "", "Cursor", "User", "globalStorage", "state.vscdb");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (process.platform === "darwin") {
|
|
56
|
+
return join(homedir(), "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return join(homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function openSqliteDatabase(dbPath: string): SqliteDatabase {
|
|
63
|
+
try {
|
|
64
|
+
const { DatabaseSync } = require("node:sqlite") as {
|
|
65
|
+
DatabaseSync?: new (path: string, opts: { readOnly: boolean }) => SqliteDatabase;
|
|
66
|
+
};
|
|
67
|
+
if (DatabaseSync) return new DatabaseSync(dbPath, { readOnly: true });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const code = (error as { code?: string }).code;
|
|
70
|
+
if (code !== "ERR_UNKNOWN_BUILTIN_MODULE" && code !== "MODULE_NOT_FOUND") {
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const Database = require("better-sqlite3") as new (path: string, opts: { readonly: boolean }) => SqliteDatabase;
|
|
77
|
+
return new Database(dbPath, { readonly: true });
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error("node:sqlite is unavailable and better-sqlite3 is not installed");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseStorageValue(value: unknown): unknown {
|
|
84
|
+
if (typeof value !== "string") return value;
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(value);
|
|
87
|
+
} catch {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getCursorAccessToken(dbPath = getCursorDbPath()): string {
|
|
93
|
+
if (!existsSync(dbPath)) {
|
|
94
|
+
throw new Error(`Cursor database not found: ${dbPath}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const db = openSqliteDatabase(dbPath);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get("cursorAuth/accessToken") as
|
|
101
|
+
| { value?: unknown }
|
|
102
|
+
| undefined;
|
|
103
|
+
const token = parseStorageValue(row?.value);
|
|
104
|
+
if (typeof token === "string" && token.length > 0) return token;
|
|
105
|
+
throw new Error("Cursor access token not found");
|
|
106
|
+
} finally {
|
|
107
|
+
db.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function getCursorUsageSummary(): Promise<CursorUsageSummary> {
|
|
112
|
+
const token = getCursorAccessToken();
|
|
113
|
+
const response = await fetch(CURSOR_USAGE_URL, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
Authorization: `Bearer ${token}`,
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
"Connect-Protocol-Version": "1",
|
|
119
|
+
},
|
|
120
|
+
body: "{}",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`Cursor usage API returned ${response.status} ${response.statusText}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (await response.json()) as CursorUsageSummary;
|
|
128
|
+
}
|
package/src/feishu-api.ts
CHANGED
|
@@ -17,7 +17,9 @@ import {
|
|
|
17
17
|
ts,
|
|
18
18
|
resolveDefaultAgentTool,
|
|
19
19
|
toolDisplayName,
|
|
20
|
+
config,
|
|
20
21
|
} from "./config.ts";
|
|
22
|
+
import { getCursorUsageSummary, type CursorUsageSummary } from "./cursor-usage.ts";
|
|
21
23
|
import { applyPrivacy } from "./privacy.ts";
|
|
22
24
|
|
|
23
25
|
// ---------------------------------------------------------------------------
|
|
@@ -316,6 +318,7 @@ const AVATAR_SIZE = 256;
|
|
|
316
318
|
const AVATAR_BADGE_SIZE = 92;
|
|
317
319
|
const AVATAR_BADGE_MARGIN = 10;
|
|
318
320
|
const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-ring-gray-consumed-v13";
|
|
321
|
+
const CURSOR_AVATAR_USAGE_STYLE_VERSION = "usage-battery-v1";
|
|
319
322
|
|
|
320
323
|
export interface CodexUsageBalance {
|
|
321
324
|
usedPercent: number;
|
|
@@ -344,7 +347,12 @@ function avatarCombinationPath(tool: string, status: string): string {
|
|
|
344
347
|
return resolvePath(AVATAR_COMBINATIONS_DIR, `avatar_${normalizeAvatarTool(tool)}_${normalizeAvatarStatus(status)}.png`);
|
|
345
348
|
}
|
|
346
349
|
|
|
347
|
-
function avatarCacheKey(
|
|
350
|
+
function avatarCacheKey(
|
|
351
|
+
tool: string,
|
|
352
|
+
status: string,
|
|
353
|
+
codexUsage: CodexUsageSummary | null = null,
|
|
354
|
+
cursorBatteryPercent: number | null = null,
|
|
355
|
+
): string {
|
|
348
356
|
const normalizedTool = normalizeAvatarTool(tool);
|
|
349
357
|
const normalizedStatus = normalizeAvatarStatus(status);
|
|
350
358
|
if (normalizedTool === "codex") {
|
|
@@ -352,6 +360,11 @@ function avatarCacheKey(tool: string, status: string, codexUsage: CodexUsageSumm
|
|
|
352
360
|
? `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:week-battery:${codexUsage.weekly?.remainingPercent}:5h-ring:${codexUsage.fiveHour.remainingPercent}`
|
|
353
361
|
: `${normalizedTool}:${normalizedStatus}:plain`;
|
|
354
362
|
}
|
|
363
|
+
if (normalizedTool === "cursor") {
|
|
364
|
+
return cursorBatteryPercent !== null
|
|
365
|
+
? `${normalizedTool}:${normalizedStatus}:${CURSOR_AVATAR_USAGE_STYLE_VERSION}:battery:${cursorBatteryPercent}`
|
|
366
|
+
: `${normalizedTool}:${normalizedStatus}:plain`;
|
|
367
|
+
}
|
|
355
368
|
return `${normalizedTool}:${normalizedStatus}`;
|
|
356
369
|
}
|
|
357
370
|
|
|
@@ -460,6 +473,28 @@ async function fetchCodexAvatarUsage(): Promise<CodexUsageSummary | null> {
|
|
|
460
473
|
}
|
|
461
474
|
}
|
|
462
475
|
|
|
476
|
+
function cursorAvatarBatteryPercentFromUsage(summary: CursorUsageSummary): number {
|
|
477
|
+
if (config.cursor.avatarBatteryMode === "onDemandUse") {
|
|
478
|
+
const usedCents = Number(summary.spendLimitUsage?.individualUsed);
|
|
479
|
+
if (!Number.isFinite(usedCents)) throw new Error("missing spendLimitUsage.individualUsed");
|
|
480
|
+
const budget = config.cursor.onDemandMonthlyBudget > 0 ? config.cursor.onDemandMonthlyBudget : 1000;
|
|
481
|
+
return clampPercent(100 - ((usedCents / 100) / budget) * 100);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const apiPercentUsed = Number(summary.planUsage?.apiPercentUsed);
|
|
485
|
+
if (!Number.isFinite(apiPercentUsed)) throw new Error("missing planUsage.apiPercentUsed");
|
|
486
|
+
return clampPercent(100 - apiPercentUsed);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function fetchCursorAvatarBatteryPercent(): Promise<number | null> {
|
|
490
|
+
try {
|
|
491
|
+
return cursorAvatarBatteryPercentFromUsage(await getCursorUsageSummary());
|
|
492
|
+
} catch (err) {
|
|
493
|
+
console.warn(`[${ts()}] [AVATAR] Cursor usage unavailable, using plain avatar: ${(err as Error).message}`);
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
463
498
|
function codexUsagePalette(remainingPercent: number): { start: string; end: string; glow: string } {
|
|
464
499
|
if (remainingPercent <= 25) return { start: "#ef4444", end: "#fb923c", glow: "#fed7aa" };
|
|
465
500
|
if (remainingPercent <= 60) return { start: "#eab308", end: "#facc15", glow: "#fef3c7" };
|
|
@@ -573,20 +608,32 @@ async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOption
|
|
|
573
608
|
};
|
|
574
609
|
}
|
|
575
610
|
|
|
576
|
-
async function renderAvatar(
|
|
611
|
+
async function renderAvatar(
|
|
612
|
+
tool: string,
|
|
613
|
+
status: string,
|
|
614
|
+
codexUsage: CodexUsageSummary | null = null,
|
|
615
|
+
cursorBatteryPercent: number | null = null,
|
|
616
|
+
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
|
577
617
|
const normalizedTool = normalizeAvatarTool(tool);
|
|
578
618
|
const normalizedStatus = normalizeAvatarStatus(status);
|
|
579
619
|
const composites: sharp.OverlayOptions[] = [];
|
|
580
620
|
|
|
581
|
-
const
|
|
582
|
-
const
|
|
621
|
+
const codexWeeklyUsage = normalizedTool === "codex" ? codexUsage?.weekly : null;
|
|
622
|
+
const useDynamicCodexAvatar = codexUsage && codexWeeklyUsage;
|
|
623
|
+
const useDynamicCursorAvatar = normalizedTool === "cursor" && cursorBatteryPercent !== null;
|
|
624
|
+
const basePath = useDynamicCodexAvatar || useDynamicCursorAvatar
|
|
583
625
|
? AVATAR_SOURCES[normalizedStatus]
|
|
584
626
|
: avatarCombinationPath(normalizedTool, normalizedStatus);
|
|
585
627
|
|
|
586
628
|
if (useDynamicCodexAvatar) {
|
|
587
629
|
composites.push(
|
|
588
630
|
{ input: buildCodexUsageRingSvg(codexUsage.fiveHour.remainingPercent), left: 0, top: 0 },
|
|
589
|
-
{ input: buildCodexUsageBatterySvg(
|
|
631
|
+
{ input: buildCodexUsageBatterySvg(codexWeeklyUsage.remainingPercent), left: 0, top: 0 },
|
|
632
|
+
await buildAgentBadgeOverlay(normalizedTool),
|
|
633
|
+
);
|
|
634
|
+
} else if (useDynamicCursorAvatar) {
|
|
635
|
+
composites.push(
|
|
636
|
+
{ input: buildCodexUsageBatterySvg(cursorBatteryPercent), left: 0, top: 0 },
|
|
590
637
|
await buildAgentBadgeOverlay(normalizedTool),
|
|
591
638
|
);
|
|
592
639
|
}
|
|
@@ -608,12 +655,20 @@ async function renderAvatar(tool: string, status: string, codexUsage: CodexUsage
|
|
|
608
655
|
contentType: "image/jpeg",
|
|
609
656
|
filename: codexUsage?.weekly
|
|
610
657
|
? `avatar_${normalizedTool}_${normalizedStatus}_week_${codexUsage.weekly.remainingPercent}_5h_${codexUsage.fiveHour.remainingPercent}.jpg`
|
|
658
|
+
: cursorBatteryPercent !== null
|
|
659
|
+
? `avatar_${normalizedTool}_${normalizedStatus}_battery_${cursorBatteryPercent}.jpg`
|
|
611
660
|
: `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
|
|
612
661
|
};
|
|
613
662
|
}
|
|
614
663
|
|
|
615
|
-
async function uploadImage(
|
|
616
|
-
|
|
664
|
+
async function uploadImage(
|
|
665
|
+
token: string,
|
|
666
|
+
tool: string,
|
|
667
|
+
status: string,
|
|
668
|
+
codexUsage: CodexUsageSummary | null = null,
|
|
669
|
+
cursorBatteryPercent: number | null = null,
|
|
670
|
+
): Promise<string> {
|
|
671
|
+
const image = await renderAvatar(tool, status, codexUsage, cursorBatteryPercent);
|
|
617
672
|
const blob = new Blob([new Uint8Array(image.buffer)], { type: image.contentType });
|
|
618
673
|
const form = new FormData();
|
|
619
674
|
form.append("image_type", "avatar");
|
|
@@ -639,10 +694,11 @@ async function getOrUploadAvatarKey(token: string, tool: string, status: string)
|
|
|
639
694
|
const normalizedTool = normalizeAvatarTool(tool);
|
|
640
695
|
const normalizedStatus = normalizeAvatarStatus(status);
|
|
641
696
|
const codexUsage = normalizedTool === "codex" ? await fetchCodexAvatarUsage() : null;
|
|
642
|
-
const
|
|
697
|
+
const cursorBatteryPercent = normalizedTool === "cursor" ? await fetchCursorAvatarBatteryPercent() : null;
|
|
698
|
+
const keyName = avatarCacheKey(normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent);
|
|
643
699
|
const cached = avatarKeyCache.get(keyName);
|
|
644
700
|
if (cached) return cached;
|
|
645
|
-
const key = await uploadImage(token, normalizedTool, normalizedStatus, codexUsage);
|
|
701
|
+
const key = await uploadImage(token, normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent);
|
|
646
702
|
avatarKeyCache.set(keyName, key);
|
|
647
703
|
await persistAvatarKeyCache().catch((err) => {
|
|
648
704
|
console.error(`[${ts()}] [AVATAR] persist cache FAIL: ${(err as Error).message}`);
|