chatccc 0.2.187 → 0.2.189

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 (71) hide show
  1. package/agent-prompts/claude_specific.md +45 -45
  2. package/agent-prompts/codex_specific.md +2 -2
  3. package/agent-prompts/cursor_specific.md +13 -13
  4. package/config.sample.json +5 -0
  5. package/im-skills/feishu-skill/receive-send-file.md +63 -63
  6. package/im-skills/feishu-skill/receive-send-image.md +24 -24
  7. package/im-skills/feishu-skill/skill.md +3 -3
  8. package/im-skills/wechat-file-skill/receive-send-file.md +38 -38
  9. package/im-skills/wechat-file-skill/send-file.mjs +83 -83
  10. package/im-skills/wechat-file-skill/skill.md +10 -10
  11. package/im-skills/wechat-image-skill/skill.md +10 -10
  12. package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
  13. package/im-skills/wechat-video-skill/send-video.mjs +79 -79
  14. package/im-skills/wechat-video-skill/skill.md +10 -10
  15. package/package.json +1 -1
  16. package/scripts/postinstall-sharp-check.mjs +58 -58
  17. package/src/__tests__/agent-delegate-task-rpc.test.ts +165 -165
  18. package/src/__tests__/builtin-config.test.ts +33 -0
  19. package/src/__tests__/card-plain-text.test.ts +5 -5
  20. package/src/__tests__/cardkit.test.ts +60 -60
  21. package/src/__tests__/cards.test.ts +77 -77
  22. package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
  23. package/src/__tests__/chatgpt-subscription.test.ts +135 -135
  24. package/src/__tests__/chrome-devtools-guard.test.ts +165 -125
  25. package/src/__tests__/claude-adapter.test.ts +592 -592
  26. package/src/__tests__/codex-reset-actions.test.ts +146 -146
  27. package/src/__tests__/config-reload.test.ts +1 -0
  28. package/src/__tests__/config-sample.test.ts +11 -0
  29. package/src/__tests__/feishu-api.test.ts +60 -60
  30. package/src/__tests__/feishu-avatar.test.ts +180 -115
  31. package/src/__tests__/feishu-platform.test.ts +22 -22
  32. package/src/__tests__/format-message.test.ts +47 -47
  33. package/src/__tests__/orchestrator.test.ts +150 -2
  34. package/src/__tests__/privacy.test.ts +198 -198
  35. package/src/__tests__/raw-stream-log.test.ts +106 -106
  36. package/src/__tests__/session.test.ts +10 -0
  37. package/src/__tests__/shared-prefix.test.ts +36 -36
  38. package/src/__tests__/stream-state.test.ts +42 -42
  39. package/src/__tests__/web-ui.test.ts +209 -130
  40. package/src/adapters/claude-adapter.ts +566 -566
  41. package/src/adapters/claude-session-meta-store.ts +120 -120
  42. package/src/adapters/codex-adapter.ts +10 -6
  43. package/src/adapters/cursor-adapter.ts +46 -46
  44. package/src/adapters/raw-stream-log.ts +124 -124
  45. package/src/adapters/resource-monitor.ts +140 -140
  46. package/src/agent-delegate-task-rpc.ts +153 -153
  47. package/src/agent-delegate-task.ts +81 -81
  48. package/src/agent-stop-stuck.ts +129 -129
  49. package/src/builtin/cli.ts +189 -197
  50. package/src/builtin/index.ts +168 -167
  51. package/src/cards.ts +130 -89
  52. package/src/chatgpt-subscription-rpc.ts +27 -27
  53. package/src/chatgpt-subscription.ts +299 -299
  54. package/src/chrome-devtools-guard.ts +318 -242
  55. package/src/codex-reset-actions.ts +184 -184
  56. package/src/config.ts +38 -0
  57. package/src/feishu-api.ts +219 -190
  58. package/src/feishu-platform.ts +20 -20
  59. package/src/format-message.ts +293 -293
  60. package/src/index.ts +2 -2
  61. package/src/litellm-proxy.ts +374 -374
  62. package/src/orchestrator.ts +201 -9
  63. package/src/platform-adapter.ts +9 -1
  64. package/src/privacy.ts +118 -118
  65. package/src/session-chat-binding.ts +6 -6
  66. package/src/session-name.ts +8 -8
  67. package/src/session.ts +44 -16
  68. package/src/shared-prefix.ts +29 -29
  69. package/src/sim-platform.ts +20 -20
  70. package/src/turn-cards.ts +117 -117
  71. package/src/web-ui.ts +142 -24
package/src/session.ts CHANGED
@@ -4,7 +4,6 @@ import { dirname, join } from "node:path";
4
4
  import {
5
5
  CLAUDE_API_KEY,
6
6
  CLAUDE_BASE_URL,
7
- CLAUDE_EFFORT,
8
7
  CLAUDE_MAX_TURN,
9
8
  CLAUDE_MODEL,
10
9
  CLAUDE_SUBAGENT_MODEL,
@@ -17,6 +16,7 @@ import {
17
16
  config,
18
17
  fileLog,
19
18
  getDefaultCwd,
19
+ getDefaultEffortForTool,
20
20
  isAnthropicConfigEmpty,
21
21
  toolDisplayName,
22
22
  ts,
@@ -335,6 +335,9 @@ export function resetState(): void {
335
335
  }
336
336
  activePrompts.clear();
337
337
  displayCards.clear();
338
+ sessionModelOverrides.clear();
339
+ sessionEffortOverrides.clear();
340
+ adapterCache.clear();
338
341
  stopUnifiedDisplayLoop();
339
342
  console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
340
343
  }
@@ -350,6 +353,7 @@ const adapterCache = new Map<string, ToolAdapter>();
350
353
 
351
354
  // Per-session 模型覆盖(/model 命令设置,不持久化)
352
355
  const sessionModelOverrides = new Map<string, string>();
356
+ const sessionEffortOverrides = new Map<string, string>();
353
357
 
354
358
  /** 返回 session 的生效模型:优先 per-session 覆盖,其次全局配置(Claude) */
355
359
  function getModelForSession(sessionId?: string): string {
@@ -371,6 +375,17 @@ export function getEffectiveModelForTool(tool: string, sessionId?: string): stri
371
375
  return CLAUDE_MODEL;
372
376
  }
373
377
 
378
+ export function getEffectiveEffortForTool(tool: string, sessionId?: string): string {
379
+ if (sessionId) {
380
+ const override = sessionEffortOverrides.get(sessionId);
381
+ if (override) return override;
382
+ }
383
+ if (tool === "claude" || tool === "codex") {
384
+ return getDefaultEffortForTool(tool);
385
+ }
386
+ return "";
387
+ }
388
+
374
389
  /** 为指定 session 设置模型覆盖(/model <name>) */
375
390
  export function setSessionModelOverride(sessionId: string, model: string): void {
376
391
  sessionModelOverrides.set(sessionId, model);
@@ -383,9 +398,20 @@ export function clearSessionModelOverride(sessionId: string): void {
383
398
  adapterCache.clear();
384
399
  }
385
400
 
401
+ export function setSessionEffortOverride(sessionId: string, effort: string): void {
402
+ sessionEffortOverrides.set(sessionId, effort);
403
+ adapterCache.clear();
404
+ }
405
+
406
+ export function clearSessionEffortOverride(sessionId: string): void {
407
+ sessionEffortOverrides.delete(sessionId);
408
+ adapterCache.clear();
409
+ }
410
+
386
411
  export function getAdapterForTool(tool: string, sessionId?: string): ToolAdapter {
387
412
  const effectiveModel = getEffectiveModelForTool(tool, sessionId);
388
- const cacheKey = effectiveModel ? `${tool}:${effectiveModel}` : tool;
413
+ const effectiveEffort = getEffectiveEffortForTool(tool, sessionId);
414
+ const cacheKey = `${tool}:${effectiveModel || ""}:${effectiveEffort || ""}`;
389
415
  const cached = adapterCache.get(cacheKey);
390
416
  if (cached) return cached;
391
417
 
@@ -393,12 +419,12 @@ export function getAdapterForTool(tool: string, sessionId?: string): ToolAdapter
393
419
  if (tool === "cursor") {
394
420
  adapter = createCursorAdapter({ model: effectiveModel || undefined });
395
421
  } else if (tool === "codex") {
396
- adapter = createCodexAdapter({ model: effectiveModel || undefined });
422
+ adapter = createCodexAdapter({ model: effectiveModel || undefined, effort: effectiveEffort || undefined });
397
423
  } else {
398
424
  adapter = createClaudeAdapter({
399
425
  model: effectiveModel,
400
426
  subagentModel: CLAUDE_SUBAGENT_MODEL,
401
- effort: CLAUDE_EFFORT,
427
+ effort: effectiveEffort,
402
428
  apiKey: CLAUDE_API_KEY,
403
429
  baseUrl: CLAUDE_BASE_URL,
404
430
  isEmpty: isAnthropicConfigEmpty,
@@ -831,15 +857,15 @@ function formatToolConfigForLog(tool: string, sessionModel?: string, sessionId?:
831
857
  return `model=${sessionModel ?? "(由 cursor-agent 决定,init 事件后学习)"}`;
832
858
  }
833
859
  if (tool === "codex") {
834
- const m = config.codex.model;
835
- const e = config.codex.effort;
860
+ const m = getEffectiveModelForTool(tool, sessionId);
861
+ const e = getEffectiveEffortForTool(tool, sessionId);
836
862
  const modelStr = m.trim() !== "" ? m : "(由 codex config.toml 决定)";
837
863
  const effortStr = e.trim() !== ""
838
864
  ? `effort=${e}`
839
865
  : "effort=(由 codex config.toml 决定)";
840
866
  return `model=${modelStr}, ${effortStr}`;
841
867
  }
842
- return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
868
+ return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId))}`;
843
869
  }
844
870
 
845
871
  export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
@@ -955,12 +981,12 @@ export async function runAgentSession(
955
981
  const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
956
982
  const skillVariables = {
957
983
  cwd,
958
- session_id: sessionId,
959
- im_skills_cache_dir: imSkillsCacheDir,
960
- delegate_task_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/delegate-task`,
961
- send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
962
- send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
963
- send_image_script: join(feishuSkillDir, "send-image.mjs"),
984
+ session_id: sessionId,
985
+ im_skills_cache_dir: imSkillsCacheDir,
986
+ delegate_task_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/delegate-task`,
987
+ send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
988
+ send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
989
+ send_image_script: join(feishuSkillDir, "send-image.mjs"),
964
990
  send_file_script: join(feishuSkillDir, "send-file.mjs"),
965
991
  download_video_script: join(feishuSkillDir, "download-video.mjs"),
966
992
  wechat_send_image_script: join(wechatImageSkillDir, "send-image.mjs"),
@@ -1756,8 +1782,8 @@ async function resolveModelEffort(
1756
1782
  return { model, effort: null };
1757
1783
  }
1758
1784
  if (tool === "codex") {
1759
- const m = config.codex.model;
1760
- const e = config.codex.effort;
1785
+ const m = getEffectiveModelForTool(tool, sessionId);
1786
+ const e = getEffectiveEffortForTool(tool, sessionId);
1761
1787
  return {
1762
1788
  model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
1763
1789
  effort: e.trim() !== "" ? e : UNKNOWN_MODEL_PLACEHOLDER,
@@ -1765,7 +1791,7 @@ async function resolveModelEffort(
1765
1791
  }
1766
1792
  return {
1767
1793
  model: anthropicConfigDisplay(getModelForSession(sessionId)),
1768
- effort: anthropicConfigDisplay(CLAUDE_EFFORT),
1794
+ effort: anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId)),
1769
1795
  };
1770
1796
  }
1771
1797
 
@@ -1873,6 +1899,8 @@ export function _setAdapterForToolForTest(tool: string, adapter: ToolAdapter): v
1873
1899
  adapterCache.set(tool, adapter);
1874
1900
  // 同时设置当前配置模型对应的 key(getAdapterForTool 会优先 lookup 含 model 的 key)
1875
1901
  const effective = getEffectiveModelForTool(tool);
1902
+ const effort = getEffectiveEffortForTool(tool);
1903
+ adapterCache.set(`${tool}:${effective || ""}:${effort || ""}`, adapter);
1876
1904
  if (effective) adapterCache.set(`${tool}:${effective}`, adapter);
1877
1905
  }
1878
1906
 
@@ -1,29 +1,29 @@
1
- export const ABD_PREFIX = "/abd";
2
-
3
- export const ABD_APPEND_PROMPT =
4
- "请从第一性原理出发挖掘我的真实需求。你觉得我的需求合理吗?开始实现之前有什么问题要问我的?";
5
-
6
- export const ABD_HELP_LINE =
7
- `发送 **${ABD_PREFIX}** 前缀消息,将以第一性原理追问真实需求、合理性和实现前问题`;
8
-
9
- export interface SharedPrefixResult {
10
- matched: boolean;
11
- text: string;
12
- body: string;
13
- prefix?: typeof ABD_PREFIX;
14
- }
15
-
16
- export function applySharedPrefix(text: string): SharedPrefixResult {
17
- if (!text.toLowerCase().startsWith(ABD_PREFIX)) {
18
- return { matched: false, text, body: text };
19
- }
20
-
21
- const body = text.slice(ABD_PREFIX.length).replace(/^\s+/, "");
22
- const appendix = ["---", ABD_APPEND_PROMPT].join("\n");
23
- return {
24
- matched: true,
25
- prefix: ABD_PREFIX,
26
- body,
27
- text: body ? [body, appendix].join("\n\n") : appendix,
28
- };
29
- }
1
+ export const ABD_PREFIX = "/abd";
2
+
3
+ export const ABD_APPEND_PROMPT =
4
+ "请从第一性原理出发挖掘我的真实需求。你觉得我的需求合理吗?开始实现之前有什么问题要问我的?";
5
+
6
+ export const ABD_HELP_LINE =
7
+ `发送 **${ABD_PREFIX}** 前缀消息,将以第一性原理追问真实需求、合理性和实现前问题`;
8
+
9
+ export interface SharedPrefixResult {
10
+ matched: boolean;
11
+ text: string;
12
+ body: string;
13
+ prefix?: typeof ABD_PREFIX;
14
+ }
15
+
16
+ export function applySharedPrefix(text: string): SharedPrefixResult {
17
+ if (!text.toLowerCase().startsWith(ABD_PREFIX)) {
18
+ return { matched: false, text, body: text };
19
+ }
20
+
21
+ const body = text.slice(ABD_PREFIX.length).replace(/^\s+/, "");
22
+ const appendix = ["---", ABD_APPEND_PROMPT].join("\n");
23
+ return {
24
+ matched: true,
25
+ prefix: ABD_PREFIX,
26
+ body,
27
+ text: body ? [body, appendix].join("\n\n") : appendix,
28
+ };
29
+ }
@@ -123,25 +123,25 @@ export const SimulatedPlatform: FeishuPlatform = {
123
123
  },
124
124
 
125
125
  // ---- 头像 ----
126
- async setChatAvatar(_token, _chatId, _tool, _status) {
127
- // 模拟模式不需要头像
128
- },
129
-
130
- async getCodexUsageSummary() {
131
- return {
132
- fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
133
- weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
134
- rateLimitResetCreditsAvailable: null,
135
- rateLimitResetCredits: null,
136
- };
137
- },
138
-
139
- async consumeCodexRateLimitResetCredit(_redeemRequestId) {
140
- return { code: "no_credit" as const, windowsReset: 0 };
141
- },
142
-
143
- // ---- 图片下载 ----
144
- async getOrDownloadImage(_token, _messageId, fileKey) {
126
+ async setChatAvatar(_token, _chatId, _tool, _status) {
127
+ // 模拟模式不需要头像
128
+ },
129
+
130
+ async getCodexUsageSummary() {
131
+ return {
132
+ fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
133
+ weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
134
+ rateLimitResetCreditsAvailable: null,
135
+ rateLimitResetCredits: null,
136
+ };
137
+ },
138
+
139
+ async consumeCodexRateLimitResetCredit(_redeemRequestId) {
140
+ return { code: "no_credit" as const, windowsReset: 0 };
141
+ },
142
+
143
+ // ---- 图片下载 ----
144
+ async getOrDownloadImage(_token, _messageId, fileKey) {
145
145
  return join(SIM_DIR, "images", fileKey);
146
146
  },
147
147
 
@@ -174,4 +174,4 @@ export const SimulatedPlatform: FeishuPlatform = {
174
174
  };
175
175
 
176
176
  /** 模拟模式下的默认 chat_id(重新导出以保持向后兼容) */
177
- export { SIM_DEFAULT_CHAT_ID } from "./sim-store.ts";
177
+ export { SIM_DEFAULT_CHAT_ID } from "./sim-store.ts";
package/src/turn-cards.ts CHANGED
@@ -1,118 +1,118 @@
1
- import { readFile, writeFile, mkdir } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
-
4
- import { USER_DATA_DIR, ts } from "./config.ts";
5
-
6
- // ---------------------------------------------------------------------------
7
- // turn-cards.json — 每个 session 的卡片归属持久化
8
- // 记录每个 turn 有哪些卡片(轮转会产生多张),以及每张卡片的状态
9
- // ---------------------------------------------------------------------------
10
-
11
- export const TURNS_DIR = join(USER_DATA_DIR, "state", "turns");
12
-
13
- export interface TurnCardEntry {
14
- cardId: string;
15
- status: "active" | "done" | "stopped";
16
- createdAt: number;
17
- }
18
-
19
- export interface TurnCardsFile {
20
- sessionId: string;
21
- turns: { turnCount: number; cards: TurnCardEntry[] }[];
22
- }
23
-
24
- function getTurnCardsPath(sessionId: string): string {
25
- return join(TURNS_DIR, `${sessionId}.json`);
26
- }
27
-
28
- export function createEmptyTurnCards(sessionId: string): TurnCardsFile {
29
- return { sessionId, turns: [] };
30
- }
31
-
32
- export async function readTurnCards(sessionId: string): Promise<TurnCardsFile | null> {
33
- try {
34
- const raw = await readFile(getTurnCardsPath(sessionId), "utf-8");
35
- const parsed = JSON.parse(raw) as TurnCardsFile;
36
- if (parsed && typeof parsed.sessionId === "string" && Array.isArray(parsed.turns)) {
37
- return parsed;
38
- }
39
- return null;
40
- } catch {
41
- return null;
42
- }
43
- }
44
-
45
- export async function writeTurnCards(sessionId: string, data: TurnCardsFile): Promise<void> {
46
- try {
47
- const filePath = getTurnCardsPath(sessionId);
48
- await mkdir(dirname(filePath), { recursive: true });
49
- await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
50
- } catch (err) {
51
- console.error(`[${ts()}] Failed to write turn-cards for ${sessionId}: ${(err as Error).message}`);
52
- }
53
- }
54
-
55
- /** 向指定 turn 追加一张卡片并持久化。若 turn 不存在则创建。 */
56
- export async function addCardToTurn(
57
- sessionId: string,
58
- turnCount: number,
59
- cardId: string,
60
- ): Promise<void> {
61
- const data = (await readTurnCards(sessionId)) ?? createEmptyTurnCards(sessionId);
62
- let turn = data.turns.find(t => t.turnCount === turnCount);
63
- if (!turn) {
64
- turn = { turnCount, cards: [] };
65
- data.turns.push(turn);
66
- }
67
- turn.cards.push({ cardId, status: "active", createdAt: Date.now() });
68
- await writeTurnCards(sessionId, data);
69
- }
70
-
71
- /** 将指定 turn 的所有 active 卡片标记为终态并持久化。返回被标记的 cardId 列表。 */
72
- export async function finalizeTurnCards(
73
- sessionId: string,
74
- turnCount: number,
75
- finalStatus: "done" | "stopped",
76
- ): Promise<string[]> {
77
- const data = await readTurnCards(sessionId);
78
- if (!data) return [];
79
- const turn = data.turns.find(t => t.turnCount === turnCount);
80
- if (!turn) return [];
81
- const finalized: string[] = [];
82
- for (const card of turn.cards) {
83
- if (card.status === "active") {
84
- card.status = finalStatus;
85
- finalized.push(card.cardId);
86
- }
87
- }
88
- if (finalized.length > 0) {
89
- await writeTurnCards(sessionId, data);
90
- }
91
- return finalized;
92
- }
93
-
94
- /** 将指定 turn 中的一张 active 卡片标记为 done(轮转时用),并持久化。 */
95
- export async function markCardDone(
96
- sessionId: string,
97
- turnCount: number,
98
- cardId: string,
99
- ): Promise<void> {
100
- const data = await readTurnCards(sessionId);
101
- if (!data) return;
102
- const turn = data.turns.find(t => t.turnCount === turnCount);
103
- if (!turn) return;
104
- const card = turn.cards.find(c => c.cardId === cardId && c.status === "active");
105
- if (!card) return;
106
- card.status = "done";
107
- await writeTurnCards(sessionId, data);
108
- }
109
-
110
- /** 清理 session 的 turn-cards 文件 */
111
- export async function removeTurnCards(sessionId: string): Promise<void> {
112
- try {
113
- const { unlink } = await import("node:fs/promises");
114
- await unlink(getTurnCardsPath(sessionId));
115
- } catch {
116
- // 文件不存在,忽略
117
- }
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import { USER_DATA_DIR, ts } from "./config.ts";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // turn-cards.json — 每个 session 的卡片归属持久化
8
+ // 记录每个 turn 有哪些卡片(轮转会产生多张),以及每张卡片的状态
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export const TURNS_DIR = join(USER_DATA_DIR, "state", "turns");
12
+
13
+ export interface TurnCardEntry {
14
+ cardId: string;
15
+ status: "active" | "done" | "stopped";
16
+ createdAt: number;
17
+ }
18
+
19
+ export interface TurnCardsFile {
20
+ sessionId: string;
21
+ turns: { turnCount: number; cards: TurnCardEntry[] }[];
22
+ }
23
+
24
+ function getTurnCardsPath(sessionId: string): string {
25
+ return join(TURNS_DIR, `${sessionId}.json`);
26
+ }
27
+
28
+ export function createEmptyTurnCards(sessionId: string): TurnCardsFile {
29
+ return { sessionId, turns: [] };
30
+ }
31
+
32
+ export async function readTurnCards(sessionId: string): Promise<TurnCardsFile | null> {
33
+ try {
34
+ const raw = await readFile(getTurnCardsPath(sessionId), "utf-8");
35
+ const parsed = JSON.parse(raw) as TurnCardsFile;
36
+ if (parsed && typeof parsed.sessionId === "string" && Array.isArray(parsed.turns)) {
37
+ return parsed;
38
+ }
39
+ return null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export async function writeTurnCards(sessionId: string, data: TurnCardsFile): Promise<void> {
46
+ try {
47
+ const filePath = getTurnCardsPath(sessionId);
48
+ await mkdir(dirname(filePath), { recursive: true });
49
+ await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
50
+ } catch (err) {
51
+ console.error(`[${ts()}] Failed to write turn-cards for ${sessionId}: ${(err as Error).message}`);
52
+ }
53
+ }
54
+
55
+ /** 向指定 turn 追加一张卡片并持久化。若 turn 不存在则创建。 */
56
+ export async function addCardToTurn(
57
+ sessionId: string,
58
+ turnCount: number,
59
+ cardId: string,
60
+ ): Promise<void> {
61
+ const data = (await readTurnCards(sessionId)) ?? createEmptyTurnCards(sessionId);
62
+ let turn = data.turns.find(t => t.turnCount === turnCount);
63
+ if (!turn) {
64
+ turn = { turnCount, cards: [] };
65
+ data.turns.push(turn);
66
+ }
67
+ turn.cards.push({ cardId, status: "active", createdAt: Date.now() });
68
+ await writeTurnCards(sessionId, data);
69
+ }
70
+
71
+ /** 将指定 turn 的所有 active 卡片标记为终态并持久化。返回被标记的 cardId 列表。 */
72
+ export async function finalizeTurnCards(
73
+ sessionId: string,
74
+ turnCount: number,
75
+ finalStatus: "done" | "stopped",
76
+ ): Promise<string[]> {
77
+ const data = await readTurnCards(sessionId);
78
+ if (!data) return [];
79
+ const turn = data.turns.find(t => t.turnCount === turnCount);
80
+ if (!turn) return [];
81
+ const finalized: string[] = [];
82
+ for (const card of turn.cards) {
83
+ if (card.status === "active") {
84
+ card.status = finalStatus;
85
+ finalized.push(card.cardId);
86
+ }
87
+ }
88
+ if (finalized.length > 0) {
89
+ await writeTurnCards(sessionId, data);
90
+ }
91
+ return finalized;
92
+ }
93
+
94
+ /** 将指定 turn 中的一张 active 卡片标记为 done(轮转时用),并持久化。 */
95
+ export async function markCardDone(
96
+ sessionId: string,
97
+ turnCount: number,
98
+ cardId: string,
99
+ ): Promise<void> {
100
+ const data = await readTurnCards(sessionId);
101
+ if (!data) return;
102
+ const turn = data.turns.find(t => t.turnCount === turnCount);
103
+ if (!turn) return;
104
+ const card = turn.cards.find(c => c.cardId === cardId && c.status === "active");
105
+ if (!card) return;
106
+ card.status = "done";
107
+ await writeTurnCards(sessionId, data);
108
+ }
109
+
110
+ /** 清理 session 的 turn-cards 文件 */
111
+ export async function removeTurnCards(sessionId: string): Promise<void> {
112
+ try {
113
+ const { unlink } = await import("node:fs/promises");
114
+ await unlink(getTurnCardsPath(sessionId));
115
+ } catch {
116
+ // 文件不存在,忽略
117
+ }
118
118
  }