chatccc 0.2.198 → 0.2.200

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 (49) hide show
  1. package/README.md +1 -1
  2. package/agent-prompts/claude_specific.md +45 -45
  3. package/agent-prompts/codex_specific.md +2 -2
  4. package/im-skills/feishu-skill/receive-send-file.md +63 -63
  5. package/im-skills/feishu-skill/receive-send-image.md +24 -24
  6. package/im-skills/feishu-skill/skill.md +3 -3
  7. package/im-skills/wechat-file-skill/receive-send-file.md +38 -38
  8. package/im-skills/wechat-file-skill/send-file.mjs +83 -83
  9. package/im-skills/wechat-file-skill/skill.md +10 -10
  10. package/im-skills/wechat-image-skill/skill.md +10 -10
  11. package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
  12. package/im-skills/wechat-video-skill/send-video.mjs +79 -79
  13. package/im-skills/wechat-video-skill/skill.md +10 -10
  14. package/package.json +1 -1
  15. package/scripts/postinstall-sharp-check.mjs +58 -58
  16. package/src/__tests__/agent-delegate-task-rpc.test.ts +165 -165
  17. package/src/__tests__/card-plain-text.test.ts +5 -5
  18. package/src/__tests__/cardkit.test.ts +60 -60
  19. package/src/__tests__/cards.test.ts +1 -1
  20. package/src/__tests__/claude-adapter.test.ts +592 -592
  21. package/src/__tests__/codex-reset-actions.test.ts +146 -146
  22. package/src/__tests__/cursor-adapter.test.ts +66 -7
  23. package/src/__tests__/feishu-api.test.ts +60 -60
  24. package/src/__tests__/feishu-avatar.test.ts +47 -5
  25. package/src/__tests__/feishu-platform.test.ts +22 -22
  26. package/src/__tests__/format-message.test.ts +47 -47
  27. package/src/__tests__/orchestrator.test.ts +27 -2
  28. package/src/__tests__/privacy.test.ts +198 -198
  29. package/src/__tests__/shared-prefix.test.ts +36 -36
  30. package/src/__tests__/stream-state.test.ts +42 -42
  31. package/src/adapters/claude-session-meta-store.ts +120 -120
  32. package/src/adapters/cursor-adapter.ts +39 -6
  33. package/src/adapters/resource-monitor.ts +140 -140
  34. package/src/agent-delegate-task-rpc.ts +153 -153
  35. package/src/agent-delegate-task.ts +81 -81
  36. package/src/agent-stop-stuck.ts +129 -129
  37. package/src/cards.ts +1 -1
  38. package/src/codex-reset-actions.ts +184 -184
  39. package/src/feishu-api.ts +41 -18
  40. package/src/feishu-platform.ts +20 -20
  41. package/src/format-message.ts +293 -293
  42. package/src/litellm-proxy.ts +374 -374
  43. package/src/orchestrator.ts +4 -4
  44. package/src/privacy.ts +118 -118
  45. package/src/session-chat-binding.ts +6 -6
  46. package/src/session-name.ts +8 -8
  47. package/src/shared-prefix.ts +29 -29
  48. package/src/sim-platform.ts +20 -20
  49. package/src/turn-cards.ts +117 -117
@@ -135,7 +135,7 @@ function formatCodexUsageSummary(usage: CodexUsageSummary, chatGptSubscription:
135
135
  return `(约 ${parts.join("")}后)`;
136
136
  };
137
137
 
138
- const formatResetTime = (balance: CodexUsageSummary["fiveHour"]) => {
138
+ const formatResetTime = (balance: NonNullable<CodexUsageSummary["fiveHour"]>) => {
139
139
  if (balance.resetAtEpochSeconds === null) return "暂无数据";
140
140
  const date = new Date(balance.resetAtEpochSeconds * 1000);
141
141
  const pad = (value: number) => String(value).padStart(2, "0");
@@ -263,8 +263,8 @@ function formatCodexUsageSummary(usage: CodexUsageSummary, chatGptSubscription:
263
263
  formatChatGptSubscriptionFailure(),
264
264
  formatResetCredits(),
265
265
  "",
266
- formatWindow("5h", usage.fiveHour),
267
- formatWindow("", usage.weekly),
266
+ usage.fiveHour ? formatWindow("5h", usage.fiveHour) : "",
267
+ usage.weekly ? formatWindow("7天", usage.weekly) : "",
268
268
  ].filter((line, index, arr) => line !== "" || (index > 0 && arr[index - 1] !== "")).join("\n");
269
269
  }
270
270
 
@@ -325,7 +325,7 @@ function formatCursorUsageSummary(usage: CursorUsageSummary): string {
325
325
  }
326
326
 
327
327
  function usageHelpLine(tool: string): string {
328
- if (tool === "codex") return "\n发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡。";
328
+ if (tool === "codex") return "\n发送 **/usage** 查看 Codex 实际存在的 5h/7天用量窗口,以及查询/使用主动重置卡。";
329
329
  if (tool === "cursor") return "\n发送 **/usage** 查看 Cursor 用量。";
330
330
  return "";
331
331
  }
package/src/privacy.ts CHANGED
@@ -1,118 +1,118 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { USER_DATA_DIR } from "./config.ts";
4
-
5
- interface PrivacyRules {
6
- [key: string]: string;
7
- }
8
-
9
- interface PrivacyConfig {
10
- enabled: boolean;
11
- rules: PrivacyRules;
12
- }
13
-
14
- let config: PrivacyConfig | null = null;
15
- let loadedStamp: string | null | undefined;
16
-
17
- function privacyFilePath(): string {
18
- return join(USER_DATA_DIR, "privacy.json");
19
- }
20
-
21
- function privacyFileStamp(): string | null {
22
- const filePath = privacyFilePath();
23
- if (!existsSync(filePath)) return null;
24
- try {
25
- const s = statSync(filePath);
26
- return `${s.mtimeMs}:${s.size}`;
27
- } catch {
28
- return null;
29
- }
30
- }
31
-
32
- function sanitizeRules(raw: Record<string, unknown>): PrivacyRules {
33
- const result: PrivacyRules = {};
34
- for (const [from, to] of Object.entries(raw)) {
35
- if (!from) {
36
- console.error("[PRIVACY] privacy.json rule key cannot be empty, skipped");
37
- continue;
38
- }
39
- if (typeof to !== "string") {
40
- console.error(`[PRIVACY] privacy.json value must be a string, skipped "${from}"`);
41
- continue;
42
- }
43
- result[from] = to;
44
- }
45
- return result;
46
- }
47
-
48
- function normalizeConfig(parsed: Record<string, unknown>): PrivacyConfig {
49
- const hasNewSchema = Object.prototype.hasOwnProperty.call(parsed, "enabled") ||
50
- Object.prototype.hasOwnProperty.call(parsed, "rules");
51
-
52
- if (!hasNewSchema) {
53
- return { enabled: true, rules: sanitizeRules(parsed) };
54
- }
55
-
56
- const enabledRaw = parsed.enabled;
57
- const enabled = enabledRaw === undefined ? true : enabledRaw !== false;
58
- if (enabledRaw !== undefined && typeof enabledRaw !== "boolean") {
59
- console.error("[PRIVACY] privacy.json enabled must be a boolean, using true");
60
- }
61
-
62
- const rulesRaw = parsed.rules;
63
- if (rulesRaw === undefined) return { enabled, rules: {} };
64
- if (typeof rulesRaw !== "object" || rulesRaw === null || Array.isArray(rulesRaw)) {
65
- console.error("[PRIVACY] privacy.json rules must be an object");
66
- return { enabled, rules: {} };
67
- }
68
- return { enabled, rules: sanitizeRules(rulesRaw as Record<string, unknown>) };
69
- }
70
-
71
- function loadConfig(): PrivacyConfig {
72
- const filePath = privacyFilePath();
73
- if (!existsSync(filePath)) {
74
- return { enabled: true, rules: {} };
75
- }
76
- try {
77
- const raw = readFileSync(filePath, "utf-8").replace(/^\uFEFF/, "");
78
- const parsed = JSON.parse(raw);
79
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
80
- console.error("[PRIVACY] privacy.json must be an object");
81
- return { enabled: true, rules: {} };
82
- }
83
- return normalizeConfig(parsed as Record<string, unknown>);
84
- } catch (err) {
85
- console.error(`[PRIVACY] failed to read privacy.json: ${(err as Error).message}`);
86
- return { enabled: true, rules: {} };
87
- }
88
- }
89
-
90
- export function getPrivacyConfig(): PrivacyConfig {
91
- const stamp = privacyFileStamp();
92
- if (!config || stamp !== loadedStamp) {
93
- config = loadConfig();
94
- loadedStamp = stamp;
95
- }
96
- return config;
97
- }
98
-
99
- export function getPrivacyRules(): PrivacyRules {
100
- return getPrivacyConfig().rules;
101
- }
102
-
103
- export function reloadPrivacyRules(): void {
104
- config = null;
105
- loadedStamp = undefined;
106
- }
107
-
108
- export function applyPrivacy(text: string): string {
109
- const { enabled, rules } = getPrivacyConfig();
110
- if (!enabled || Object.keys(rules).length === 0 || !text) return text;
111
-
112
- let result = text;
113
- for (const [from, to] of Object.entries(rules)) {
114
- // Use split+join instead of regex replacement so rule keys stay literal.
115
- result = result.split(from).join(to);
116
- }
117
- return result;
118
- }
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { USER_DATA_DIR } from "./config.ts";
4
+
5
+ interface PrivacyRules {
6
+ [key: string]: string;
7
+ }
8
+
9
+ interface PrivacyConfig {
10
+ enabled: boolean;
11
+ rules: PrivacyRules;
12
+ }
13
+
14
+ let config: PrivacyConfig | null = null;
15
+ let loadedStamp: string | null | undefined;
16
+
17
+ function privacyFilePath(): string {
18
+ return join(USER_DATA_DIR, "privacy.json");
19
+ }
20
+
21
+ function privacyFileStamp(): string | null {
22
+ const filePath = privacyFilePath();
23
+ if (!existsSync(filePath)) return null;
24
+ try {
25
+ const s = statSync(filePath);
26
+ return `${s.mtimeMs}:${s.size}`;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function sanitizeRules(raw: Record<string, unknown>): PrivacyRules {
33
+ const result: PrivacyRules = {};
34
+ for (const [from, to] of Object.entries(raw)) {
35
+ if (!from) {
36
+ console.error("[PRIVACY] privacy.json rule key cannot be empty, skipped");
37
+ continue;
38
+ }
39
+ if (typeof to !== "string") {
40
+ console.error(`[PRIVACY] privacy.json value must be a string, skipped "${from}"`);
41
+ continue;
42
+ }
43
+ result[from] = to;
44
+ }
45
+ return result;
46
+ }
47
+
48
+ function normalizeConfig(parsed: Record<string, unknown>): PrivacyConfig {
49
+ const hasNewSchema = Object.prototype.hasOwnProperty.call(parsed, "enabled") ||
50
+ Object.prototype.hasOwnProperty.call(parsed, "rules");
51
+
52
+ if (!hasNewSchema) {
53
+ return { enabled: true, rules: sanitizeRules(parsed) };
54
+ }
55
+
56
+ const enabledRaw = parsed.enabled;
57
+ const enabled = enabledRaw === undefined ? true : enabledRaw !== false;
58
+ if (enabledRaw !== undefined && typeof enabledRaw !== "boolean") {
59
+ console.error("[PRIVACY] privacy.json enabled must be a boolean, using true");
60
+ }
61
+
62
+ const rulesRaw = parsed.rules;
63
+ if (rulesRaw === undefined) return { enabled, rules: {} };
64
+ if (typeof rulesRaw !== "object" || rulesRaw === null || Array.isArray(rulesRaw)) {
65
+ console.error("[PRIVACY] privacy.json rules must be an object");
66
+ return { enabled, rules: {} };
67
+ }
68
+ return { enabled, rules: sanitizeRules(rulesRaw as Record<string, unknown>) };
69
+ }
70
+
71
+ function loadConfig(): PrivacyConfig {
72
+ const filePath = privacyFilePath();
73
+ if (!existsSync(filePath)) {
74
+ return { enabled: true, rules: {} };
75
+ }
76
+ try {
77
+ const raw = readFileSync(filePath, "utf-8").replace(/^\uFEFF/, "");
78
+ const parsed = JSON.parse(raw);
79
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
80
+ console.error("[PRIVACY] privacy.json must be an object");
81
+ return { enabled: true, rules: {} };
82
+ }
83
+ return normalizeConfig(parsed as Record<string, unknown>);
84
+ } catch (err) {
85
+ console.error(`[PRIVACY] failed to read privacy.json: ${(err as Error).message}`);
86
+ return { enabled: true, rules: {} };
87
+ }
88
+ }
89
+
90
+ export function getPrivacyConfig(): PrivacyConfig {
91
+ const stamp = privacyFileStamp();
92
+ if (!config || stamp !== loadedStamp) {
93
+ config = loadConfig();
94
+ loadedStamp = stamp;
95
+ }
96
+ return config;
97
+ }
98
+
99
+ export function getPrivacyRules(): PrivacyRules {
100
+ return getPrivacyConfig().rules;
101
+ }
102
+
103
+ export function reloadPrivacyRules(): void {
104
+ config = null;
105
+ loadedStamp = undefined;
106
+ }
107
+
108
+ export function applyPrivacy(text: string): string {
109
+ const { enabled, rules } = getPrivacyConfig();
110
+ if (!enabled || Object.keys(rules).length === 0 || !text) return text;
111
+
112
+ let result = text;
113
+ for (const [from, to] of Object.entries(rules)) {
114
+ // Use split+join instead of regex replacement so rule keys stay literal.
115
+ result = result.split(from).join(to);
116
+ }
117
+ return result;
118
+ }
@@ -71,11 +71,11 @@ export interface ActivePrompt {
71
71
  /** Set when the watchdog detects that the CLI process disappeared before stream finalization. */
72
72
  abnormalExit?: boolean;
73
73
  abnormalExitNotified?: boolean;
74
- /** Set when the resource monitor detects CPU + memory unchanged for 3 minutes. */
75
- resourceStuck?: boolean;
76
- /** Adapter-provided callback to close the underlying SDK session / subprocess.
77
- * Called by stop-stuck-loop before controller.abort() to terminate the CLI
78
- * process immediately, rather than waiting for the async generator to unblock. */
74
+ /** Set when the resource monitor detects CPU + memory unchanged for 3 minutes. */
75
+ resourceStuck?: boolean;
76
+ /** Adapter-provided callback to close the underlying SDK session / subprocess.
77
+ * Called by stop-stuck-loop before controller.abort() to terminate the CLI
78
+ * process immediately, rather than waiting for the async generator to unblock. */
79
79
  closeSession?: () => void;
80
80
  }
81
81
 
@@ -228,4 +228,4 @@ export function resetBindingState(): void {
228
228
  clearInterval(unifiedDisplayLoopHandle);
229
229
  unifiedDisplayLoopHandle = null;
230
230
  }
231
- }
231
+ }
@@ -1,8 +1,8 @@
1
- export function cwdDisplayName(cwd: string): string {
2
- const trimmed = cwd.trim().replace(/[\\/]+$/, "");
3
- return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed || "cwd";
4
- }
5
-
6
- export function sessionChatName(left: string, cwd: string): string {
7
- return `${left}-${cwdDisplayName(cwd)}`;
8
- }
1
+ export function cwdDisplayName(cwd: string): string {
2
+ const trimmed = cwd.trim().replace(/[\\/]+$/, "");
3
+ return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed || "cwd";
4
+ }
5
+
6
+ export function sessionChatName(left: string, cwd: string): string {
7
+ return `${left}-${cwdDisplayName(cwd)}`;
8
+ }
@@ -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
  }