chatccc 0.2.50 → 0.2.52

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/sim-agent.ts CHANGED
@@ -71,6 +71,45 @@ export function createSimAgent(userId: string): SimAgent {
71
71
  // 确保用户有至少一个会话(bot 私聊自动创建)
72
72
  simStore.createP2pChat(userId);
73
73
 
74
+ function onEvent(event: "message", handler: MessageEventCallback): void;
75
+ function onEvent(event: "invited_to_group", handler: InvitedToGroupCallback): void;
76
+ function onEvent(
77
+ event: "message" | "invited_to_group",
78
+ handler: MessageEventCallback | InvitedToGroupCallback,
79
+ ): void {
80
+ if (event === "message") {
81
+ const messageHandler = handler as MessageEventCallback;
82
+ const filteredHandler = (payload: { chatId: string; message: SimMessage }) => {
83
+ // 只推送给该用户所在群的消息
84
+ const chat = simStore.getChat(payload.chatId);
85
+ if (chat && chat.memberIds.includes(userId)) {
86
+ messageHandler(payload.chatId, payload.message);
87
+ }
88
+ };
89
+ // 保存映射以便 off 能移除
90
+ (handler as any).__filtered = filteredHandler;
91
+ simStore.on("message", filteredHandler);
92
+ } else {
93
+ const invitedHandler = handler as InvitedToGroupCallback;
94
+ const filteredHandler = (payload: { chatId: string; userId: string }) => {
95
+ if (payload.userId === userId) {
96
+ invitedHandler(payload.chatId, payload.userId);
97
+ }
98
+ };
99
+ (handler as any).__filtered = filteredHandler;
100
+ simStore.on("member_added", filteredHandler);
101
+ }
102
+ }
103
+
104
+ function offEvent(event: string, handler: (...args: any[]) => void): void {
105
+ const filtered = (handler as any).__filtered;
106
+ if (event === "message" && filtered) {
107
+ simStore.off("message", filtered);
108
+ } else if (event === "invited_to_group" && filtered) {
109
+ simStore.off("member_added", filtered);
110
+ }
111
+ }
112
+
74
113
  const agent: SimAgent = {
75
114
  userId,
76
115
  account,
@@ -101,37 +140,9 @@ export function createSimAgent(userId: string): SimAgent {
101
140
  }));
102
141
  },
103
142
 
104
- on(event, handler) {
105
- if (event === "message") {
106
- const filteredHandler = (payload: { chatId: string; message: SimMessage }) => {
107
- // 只推送给该用户所在群的消息
108
- const chat = simStore.getChat(payload.chatId);
109
- if (chat && chat.memberIds.includes(userId)) {
110
- handler(payload.chatId, payload.message);
111
- }
112
- };
113
- // 保存映射以便 off 能移除
114
- (handler as any).__filtered = filteredHandler;
115
- simStore.on("message", filteredHandler);
116
- } else if (event === "invited_to_group") {
117
- const filteredHandler = (payload: { chatId: string; userId: string }) => {
118
- if (payload.userId === userId) {
119
- handler(payload.chatId, payload.userId);
120
- }
121
- };
122
- (handler as any).__filtered = filteredHandler;
123
- simStore.on("member_added", filteredHandler);
124
- }
125
- },
143
+ on: onEvent,
126
144
 
127
- off(event, handler) {
128
- const filtered = (handler as any).__filtered;
129
- if (event === "message" && filtered) {
130
- simStore.off("message", filtered);
131
- } else if (event === "invited_to_group" && filtered) {
132
- simStore.off("member_added", filtered);
133
- }
134
- },
145
+ off: offEvent,
135
146
 
136
147
  waitForReply(chatId, timeoutMs = 30000) {
137
148
  return new Promise((resolve) => {
@@ -153,4 +164,4 @@ export function createSimAgent(userId: string): SimAgent {
153
164
  };
154
165
 
155
166
  return agent;
156
- }
167
+ }
@@ -1,94 +1,141 @@
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
- // stream-state.json — 每个 session 的流式输出持久化文件
8
- // ---------------------------------------------------------------------------
9
-
10
- export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
11
-
12
- export interface StreamState {
13
- sessionId: string;
14
- status: "running" | "done" | "stopped" | "error";
15
- accumulatedContent: string;
16
- finalReply: string;
17
- chunkCount: number;
18
- turnCount: number;
19
- contextTokens: number;
20
- updatedAt: number;
21
- cwd: string;
22
- tool: string;
23
- }
24
-
25
- function getStreamStatePath(sessionId: string): string {
26
- return join(STREAMS_DIR, `${sessionId}.json`);
27
- }
28
-
29
- export async function readStreamState(sessionId: string): Promise<StreamState | null> {
30
- try {
31
- const raw = await readFile(getStreamStatePath(sessionId), "utf-8");
32
- const parsed = JSON.parse(raw) as StreamState;
33
- if (parsed && typeof parsed.sessionId === "string") return parsed;
34
- return null;
35
- } catch {
36
- return null;
37
- }
38
- }
39
-
40
- export async function writeStreamState(state: StreamState): Promise<void> {
41
- const filePath = getStreamStatePath(state.sessionId);
42
- try {
43
- await mkdir(dirname(filePath), { recursive: true });
44
- // 先写临时文件再 rename,保证原子性
45
- const tmpPath = filePath + ".tmp";
46
- await writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
47
- await writeFile(filePath, JSON.stringify(state, null, 2), "utf-8");
48
- } catch (err) {
49
- console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
50
- }
51
- }
52
-
53
- export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
54
- return {
55
- sessionId,
56
- status: "running",
57
- accumulatedContent: "",
58
- finalReply: "",
59
- chunkCount: 0,
60
- turnCount,
61
- contextTokens: 0,
62
- updatedAt: Date.now(),
63
- cwd,
64
- tool,
65
- };
66
- }
67
-
68
- /** 进程重启时修正虚假的 "running" 状态 */
69
- export async function fixStaleStreamStates(): Promise<void> {
70
- // 启动时将残留的 running 标记为 error
71
- // 实际 agent 进程已不存在,下次 prompt 时会自然覆盖
72
- try {
73
- const { readdir } = await import("node:fs/promises");
74
- let entries: string[];
75
- try {
76
- entries = await readdir(STREAMS_DIR);
77
- } catch {
78
- return;
79
- }
80
- for (const entry of entries) {
81
- if (!entry.endsWith(".json")) continue;
82
- const state = await readStreamState(entry.replace(".json", ""));
83
- if (state && state.status === "running") {
84
- state.status = "error";
85
- state.accumulatedContent += "\n\n⚠️ 进程重启,流式输出中断。";
86
- state.updatedAt = Date.now();
87
- await writeStreamState(state);
88
- console.log(`[${ts()}] [STREAM-STATE] marked stale running as error: ${state.sessionId}`);
89
- }
90
- }
91
- } catch (err) {
92
- console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
93
- }
1
+ import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import { USER_DATA_DIR, ts } from "./config.ts";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // stream-state.json — 每个 session 的流式输出持久化文件
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
11
+
12
+ export interface StreamState {
13
+ sessionId: string;
14
+ status: "running" | "done" | "stopped" | "error";
15
+ accumulatedContent: string;
16
+ finalReply: string;
17
+ chunkCount: number;
18
+ turnCount: number;
19
+ contextTokens: number;
20
+ updatedAt: number;
21
+ cwd: string;
22
+ tool: string;
23
+ }
24
+
25
+ function getStreamStatePath(sessionId: string): string {
26
+ return join(STREAMS_DIR, `${sessionId}.json`);
27
+ }
28
+
29
+ export async function readStreamState(sessionId: string): Promise<StreamState | null> {
30
+ try {
31
+ const raw = await readFile(getStreamStatePath(sessionId), "utf-8");
32
+ const parsed = JSON.parse(raw) as StreamState;
33
+ if (parsed && typeof parsed.sessionId === "string") return parsed;
34
+ return null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ // rename 的可注入实现——仅供测试模拟"Windows EPERM 降级"路径。
41
+ // 生产代码使用 node:fs/promises.rename。
42
+ let renameImpl: typeof rename = rename;
43
+
44
+ /**
45
+ * 仅供单测:注入自定义 rename 实现以验证降级分支。
46
+ * 调用方负责测试结束后用 _resetRenameForTest 还原。
47
+ */
48
+ export function _setRenameForTest(impl: typeof rename): void {
49
+ renameImpl = impl;
50
+ }
51
+
52
+ export function _resetRenameForTest(): void {
53
+ renameImpl = rename;
54
+ }
55
+
56
+ /**
57
+ * 把 StreamState 持久化到磁盘——尽量原子地写。
58
+ *
59
+ * 为什么需要原子写:display loop 每 3 秒读一次 stream-state.json,而
60
+ * runAgentSession 每 2 秒写一次。如果用 `writeFile(filePath, ...)` 直接
61
+ * 覆盖,reader 在写入过程中读会拿到半截 JSON,JSON.parse 报错——虽然
62
+ * readStreamState 包了 try/catch 返回 null 不会崩,但相当于"丢一帧"。
63
+ *
64
+ * 实现:
65
+ * 1) 先把内容写到 `<filePath>.tmp`(reader 不会读 .tmp)
66
+ * 2) 再用 `rename` 把 .tmp 替换成正式文件——`rename` 在 POSIX 上是真原子
67
+ * 操作,reader 任何时刻读到的都是某个完整的旧/新版本
68
+ *
69
+ * Windows 兼容性:Windows `rename` 偶尔会因为目标文件正被 reader 打开
70
+ * EPERM/EBUSY(实测罕见,但非零概率)。降级方案:直接 writeFile 覆盖
71
+ * 真文件,这一帧可能被 reader 读到半截,但 readStreamState 的 try/catch 兜底
72
+ * 会让 loop 跳过这一帧,2 秒后下次 write 大概率成功——比写盘失败丢数据好。
73
+ */
74
+ export async function writeStreamState(state: StreamState): Promise<void> {
75
+ const filePath = getStreamStatePath(state.sessionId);
76
+ const payload = JSON.stringify(state, null, 2);
77
+ try {
78
+ await mkdir(dirname(filePath), { recursive: true });
79
+ const tmpPath = filePath + ".tmp";
80
+ await writeFile(tmpPath, payload, "utf-8");
81
+ try {
82
+ await renameImpl(tmpPath, filePath);
83
+ } catch (renameErr) {
84
+ // Windows 偶发 EPERM/EBUSY:rename 失败时降级为直接覆盖写。
85
+ // 此次写入失去原子性(reader 可能读到半截 JSON,但 readStreamState
86
+ // try/catch 会兜底返回 null,只是丢一帧 display 更新)。
87
+ console.warn(
88
+ `[${ts()}] [STREAM-STATE] rename failed for ${state.sessionId}, ` +
89
+ `fallback to overwrite: ${(renameErr as Error).message}`,
90
+ );
91
+ await writeFile(filePath, payload, "utf-8");
92
+ // 尽力清理 .tmp,失败也无所谓——下次 write 会覆盖它
93
+ await unlink(tmpPath).catch(() => {});
94
+ }
95
+ } catch (err) {
96
+ console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
97
+ }
98
+ }
99
+
100
+ export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
101
+ return {
102
+ sessionId,
103
+ status: "running",
104
+ accumulatedContent: "",
105
+ finalReply: "",
106
+ chunkCount: 0,
107
+ turnCount,
108
+ contextTokens: 0,
109
+ updatedAt: Date.now(),
110
+ cwd,
111
+ tool,
112
+ };
113
+ }
114
+
115
+ /** 进程重启时修正虚假的 "running" 状态 */
116
+ export async function fixStaleStreamStates(): Promise<void> {
117
+ // 启动时将残留的 running 标记为 error
118
+ // 实际 agent 进程已不存在,下次 prompt 时会自然覆盖
119
+ try {
120
+ const { readdir } = await import("node:fs/promises");
121
+ let entries: string[];
122
+ try {
123
+ entries = await readdir(STREAMS_DIR);
124
+ } catch {
125
+ return;
126
+ }
127
+ for (const entry of entries) {
128
+ if (!entry.endsWith(".json")) continue;
129
+ const state = await readStreamState(entry.replace(".json", ""));
130
+ if (state && state.status === "running") {
131
+ state.status = "error";
132
+ state.accumulatedContent += "\n\n⚠️ 进程重启,流式输出中断。";
133
+ state.updatedAt = Date.now();
134
+ await writeStreamState(state);
135
+ console.log(`[${ts()}] [STREAM-STATE] marked stale running as error: ${state.sessionId}`);
136
+ }
137
+ }
138
+ } catch (err) {
139
+ console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
140
+ }
94
141
  }