chatccc 0.2.52 → 0.2.53

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 (39) hide show
  1. package/README.md +95 -256
  2. package/bin/chatccc.mjs +23 -23
  3. package/demo/ilink_echo_probe.ts +222 -222
  4. package/im-skills/feishu-skill/download-video.mjs +162 -162
  5. package/im-skills/feishu-skill/receive-send-file.md +66 -66
  6. package/im-skills/feishu-skill/receive-send-image.md +27 -27
  7. package/im-skills/feishu-skill/send-file.mjs +50 -50
  8. package/im-skills/feishu-skill/send-image.mjs +50 -50
  9. package/im-skills/feishu-skill/skill.md +10 -10
  10. package/package.json +59 -59
  11. package/src/__tests__/agent-image-rpc.test.ts +33 -33
  12. package/src/__tests__/agent-rpc-body.test.ts +41 -41
  13. package/src/__tests__/card-plain-text.test.ts +42 -42
  14. package/src/__tests__/codex-adapter.test.ts +304 -304
  15. package/src/__tests__/config-reload.test.ts +26 -26
  16. package/src/__tests__/config-sample.test.ts +19 -19
  17. package/src/__tests__/crash-logging.test.ts +62 -62
  18. package/src/__tests__/fixtures/codex_simple_text.jsonl +4 -4
  19. package/src/__tests__/fixtures/codex_with_tool.jsonl +6 -6
  20. package/src/__tests__/im-skills.test.ts +46 -46
  21. package/src/__tests__/session.test.ts +57 -2
  22. package/src/__tests__/sim-agent.test.ts +173 -173
  23. package/src/__tests__/sim-platform.test.ts +76 -76
  24. package/src/__tests__/sim-store.test.ts +213 -213
  25. package/src/__tests__/stream-state.test.ts +134 -134
  26. package/src/__tests__/wechat-platform.test.ts +57 -32
  27. package/src/adapters/codex-adapter.ts +294 -294
  28. package/src/adapters/codex-session-meta-store.ts +130 -130
  29. package/src/agent-file-rpc.ts +156 -156
  30. package/src/agent-image-rpc.ts +152 -152
  31. package/src/agent-rpc-body.ts +48 -48
  32. package/src/card-plain-text.ts +108 -108
  33. package/src/im-skills.ts +109 -109
  34. package/src/platform-adapter.ts +60 -60
  35. package/src/session-chat-binding.ts +6 -1
  36. package/src/session.ts +40 -35
  37. package/src/sim-agent.ts +167 -167
  38. package/src/trace.ts +50 -50
  39. package/src/wechat-platform.ts +1 -1
package/src/im-skills.ts CHANGED
@@ -1,109 +1,109 @@
1
- import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
2
- import { join, dirname } from "node:path";
3
-
4
- import { PROJECT_ROOT } from "./config.ts";
5
-
6
- export const DEFAULT_IM_SKILLS_DIR = join(PROJECT_ROOT, "im-skills");
7
-
8
- export interface ImSkillVariableMap {
9
- [key: string]: string | undefined;
10
- }
11
-
12
- export interface BuildImSkillsPromptInput {
13
- skillsDir?: string;
14
- variables: ImSkillVariableMap;
15
- }
16
-
17
- interface ImSkillDoc {
18
- name: string;
19
- content: string;
20
- }
21
-
22
- function renderTemplate(template: string, variables: ImSkillVariableMap): string {
23
- return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, key: string) => {
24
- return variables[key] ?? "";
25
- });
26
- }
27
-
28
- async function loadSkillDocs(skillsDir: string): Promise<ImSkillDoc[]> {
29
- let entries;
30
- try {
31
- entries = await readdir(skillsDir, { withFileTypes: true });
32
- } catch {
33
- return [];
34
- }
35
-
36
- const docs = await Promise.all(
37
- entries
38
- .filter((entry) => entry.isDirectory())
39
- .sort((a, b) => a.name.localeCompare(b.name))
40
- .map(async (entry): Promise<ImSkillDoc | null> => {
41
- const skillPath = join(skillsDir, entry.name, "skill.md");
42
- try {
43
- const content = await readFile(skillPath, "utf-8");
44
- return { name: entry.name, content };
45
- } catch {
46
- return null;
47
- }
48
- }),
49
- );
50
- return docs.filter((doc): doc is ImSkillDoc => doc !== null);
51
- }
52
-
53
- export async function buildImSkillsPrompt(input: BuildImSkillsPromptInput): Promise<string> {
54
- const skillsDir = input.skillsDir ?? DEFAULT_IM_SKILLS_DIR;
55
- const docs = await loadSkillDocs(skillsDir);
56
- return docs
57
- .map((doc) => {
58
- const rendered = renderTemplate(doc.content, input.variables).trim();
59
- return [
60
- `[ChatCCC IM skill: ${doc.name}]`,
61
- rendered,
62
- `[/ChatCCC IM skill: ${doc.name}]`,
63
- ].join("\n");
64
- })
65
- .join("\n\n");
66
- }
67
-
68
- /**
69
- * 渲染技能目录下的子文档(skill.md 除外)并写入 outputDir。
70
- * 返回写入的文件路径列表。通常在会话初始化时调用,写入的文档供 Agent 按需读取。
71
- */
72
- export async function exportSkillSubDocs(
73
- input: BuildImSkillsPromptInput,
74
- outputDir: string,
75
- ): Promise<string[]> {
76
- const skillsDir = input.skillsDir ?? DEFAULT_IM_SKILLS_DIR;
77
- const files: string[] = [];
78
-
79
- let entries;
80
- try {
81
- entries = await readdir(skillsDir, { withFileTypes: true });
82
- } catch {
83
- return files;
84
- }
85
-
86
- for (const entry of entries) {
87
- if (!entry.isDirectory()) continue;
88
- const skillPath = join(skillsDir, entry.name);
89
-
90
- let subFiles;
91
- try {
92
- subFiles = await readdir(skillPath);
93
- } catch {
94
- continue;
95
- }
96
-
97
- for (const sf of subFiles) {
98
- if (sf === "skill.md" || !sf.endsWith(".md")) continue;
99
- const content = await readFile(join(skillPath, sf), "utf-8");
100
- const rendered = renderTemplate(content, input.variables);
101
- const outPath = join(outputDir, entry.name, sf);
102
- await mkdir(dirname(outPath), { recursive: true });
103
- await writeFile(outPath, rendered, "utf-8");
104
- files.push(outPath);
105
- }
106
- }
107
-
108
- return files;
109
- }
1
+ import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
2
+ import { join, dirname } from "node:path";
3
+
4
+ import { PROJECT_ROOT } from "./config.ts";
5
+
6
+ export const DEFAULT_IM_SKILLS_DIR = join(PROJECT_ROOT, "im-skills");
7
+
8
+ export interface ImSkillVariableMap {
9
+ [key: string]: string | undefined;
10
+ }
11
+
12
+ export interface BuildImSkillsPromptInput {
13
+ skillsDir?: string;
14
+ variables: ImSkillVariableMap;
15
+ }
16
+
17
+ interface ImSkillDoc {
18
+ name: string;
19
+ content: string;
20
+ }
21
+
22
+ function renderTemplate(template: string, variables: ImSkillVariableMap): string {
23
+ return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, key: string) => {
24
+ return variables[key] ?? "";
25
+ });
26
+ }
27
+
28
+ async function loadSkillDocs(skillsDir: string): Promise<ImSkillDoc[]> {
29
+ let entries;
30
+ try {
31
+ entries = await readdir(skillsDir, { withFileTypes: true });
32
+ } catch {
33
+ return [];
34
+ }
35
+
36
+ const docs = await Promise.all(
37
+ entries
38
+ .filter((entry) => entry.isDirectory())
39
+ .sort((a, b) => a.name.localeCompare(b.name))
40
+ .map(async (entry): Promise<ImSkillDoc | null> => {
41
+ const skillPath = join(skillsDir, entry.name, "skill.md");
42
+ try {
43
+ const content = await readFile(skillPath, "utf-8");
44
+ return { name: entry.name, content };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }),
49
+ );
50
+ return docs.filter((doc): doc is ImSkillDoc => doc !== null);
51
+ }
52
+
53
+ export async function buildImSkillsPrompt(input: BuildImSkillsPromptInput): Promise<string> {
54
+ const skillsDir = input.skillsDir ?? DEFAULT_IM_SKILLS_DIR;
55
+ const docs = await loadSkillDocs(skillsDir);
56
+ return docs
57
+ .map((doc) => {
58
+ const rendered = renderTemplate(doc.content, input.variables).trim();
59
+ return [
60
+ `[ChatCCC IM skill: ${doc.name}]`,
61
+ rendered,
62
+ `[/ChatCCC IM skill: ${doc.name}]`,
63
+ ].join("\n");
64
+ })
65
+ .join("\n\n");
66
+ }
67
+
68
+ /**
69
+ * 渲染技能目录下的子文档(skill.md 除外)并写入 outputDir。
70
+ * 返回写入的文件路径列表。通常在会话初始化时调用,写入的文档供 Agent 按需读取。
71
+ */
72
+ export async function exportSkillSubDocs(
73
+ input: BuildImSkillsPromptInput,
74
+ outputDir: string,
75
+ ): Promise<string[]> {
76
+ const skillsDir = input.skillsDir ?? DEFAULT_IM_SKILLS_DIR;
77
+ const files: string[] = [];
78
+
79
+ let entries;
80
+ try {
81
+ entries = await readdir(skillsDir, { withFileTypes: true });
82
+ } catch {
83
+ return files;
84
+ }
85
+
86
+ for (const entry of entries) {
87
+ if (!entry.isDirectory()) continue;
88
+ const skillPath = join(skillsDir, entry.name);
89
+
90
+ let subFiles;
91
+ try {
92
+ subFiles = await readdir(skillPath);
93
+ } catch {
94
+ continue;
95
+ }
96
+
97
+ for (const sf of subFiles) {
98
+ if (sf === "skill.md" || !sf.endsWith(".md")) continue;
99
+ const content = await readFile(join(skillPath, sf), "utf-8");
100
+ const rendered = renderTemplate(content, input.variables);
101
+ const outPath = join(outputDir, entry.name, sf);
102
+ await mkdir(dirname(outPath), { recursive: true });
103
+ await writeFile(outPath, rendered, "utf-8");
104
+ files.push(outPath);
105
+ }
106
+ }
107
+
108
+ return files;
109
+ }
@@ -1,61 +1,61 @@
1
- /**
2
- * platform-adapter.ts — IM 平台适配器接口
3
- *
4
- * 独立于 orchestrator.ts,避免 orchestrator ↔ session 循环依赖。
5
- * 每个方法内部自行管理认证,消费者不感知 token 等认证细节。
6
- */
7
-
8
- export interface PlatformAdapter {
9
- /** 平台标识,用于区分不同平台的行为(如 wechat、feishu 等) */
10
- kind?: string;
11
-
12
- /** 发送纯文本回复 */
13
- sendText(chatId: string, text: string): Promise<boolean>;
14
-
15
- /** 发送卡片回复(标题 + 内容 + 颜色模板) */
16
- sendCard(
17
- chatId: string,
18
- title: string,
19
- content: string,
20
- template: string,
21
- ): Promise<boolean>;
22
-
23
- /** 发送原始卡片 JSON */
24
- sendRawCard(chatId: string, cardJson: string): Promise<boolean>;
25
-
26
- /** 创建群聊,返回新群 ID */
27
- createGroup(name: string, userIds: string[]): Promise<string>;
28
-
29
- /** 更新群名称和描述 */
30
- updateChatInfo(
31
- chatId: string,
32
- name: string,
33
- description: string,
34
- ): Promise<void>;
35
-
36
- /** 获取群信息 */
37
- getChatInfo(chatId: string): Promise<{ name: string; description: string }>;
38
-
39
- /** 解散群聊 */
40
- disbandChat(chatId: string): Promise<void>;
41
-
42
- /** 设置群头像 */
43
- setChatAvatar(chatId: string, tool: string, status: string): Promise<void>;
44
-
45
- /** 从群描述中提取 session 信息 */
46
- extractSessionInfo(
47
- description: string,
48
- ): { sessionId: string; tool: string } | null;
49
-
50
- // ---- 进度展示(display loop 使用) ----
51
- // 不同平台能力不同:飞书用 CardKit 实时卡片,微信降级为纯文本。
52
-
53
- /** 创建进度展示实体,返回唯一标识;不支持进度展示的平台返回 null */
54
- cardCreate(cardJson: string): Promise<string | null>;
55
-
56
- /** 将进度展示发送到指定会话,返回 message_id */
57
- cardSend(chatId: string, cardId: string): Promise<string>;
58
-
59
- /** 更新已发送的进度展示,sequence 保证有序 */
60
- cardUpdate(cardId: string, cardJson: string, sequence: number): Promise<void>;
1
+ /**
2
+ * platform-adapter.ts — IM 平台适配器接口
3
+ *
4
+ * 独立于 orchestrator.ts,避免 orchestrator ↔ session 循环依赖。
5
+ * 每个方法内部自行管理认证,消费者不感知 token 等认证细节。
6
+ */
7
+
8
+ export interface PlatformAdapter {
9
+ /** 平台标识,用于区分不同平台的行为(如 wechat、feishu 等) */
10
+ kind?: string;
11
+
12
+ /** 发送纯文本回复 */
13
+ sendText(chatId: string, text: string): Promise<boolean>;
14
+
15
+ /** 发送卡片回复(标题 + 内容 + 颜色模板) */
16
+ sendCard(
17
+ chatId: string,
18
+ title: string,
19
+ content: string,
20
+ template: string,
21
+ ): Promise<boolean>;
22
+
23
+ /** 发送原始卡片 JSON */
24
+ sendRawCard(chatId: string, cardJson: string): Promise<boolean>;
25
+
26
+ /** 创建群聊,返回新群 ID */
27
+ createGroup(name: string, userIds: string[]): Promise<string>;
28
+
29
+ /** 更新群名称和描述 */
30
+ updateChatInfo(
31
+ chatId: string,
32
+ name: string,
33
+ description: string,
34
+ ): Promise<void>;
35
+
36
+ /** 获取群信息 */
37
+ getChatInfo(chatId: string): Promise<{ name: string; description: string }>;
38
+
39
+ /** 解散群聊 */
40
+ disbandChat(chatId: string): Promise<void>;
41
+
42
+ /** 设置群头像 */
43
+ setChatAvatar(chatId: string, tool: string, status: string): Promise<void>;
44
+
45
+ /** 从群描述中提取 session 信息 */
46
+ extractSessionInfo(
47
+ description: string,
48
+ ): { sessionId: string; tool: string } | null;
49
+
50
+ // ---- 进度展示(display loop 使用) ----
51
+ // 不同平台能力不同:飞书用 CardKit 实时卡片,微信降级为纯文本。
52
+
53
+ /** 创建进度展示实体,返回唯一标识;不支持进度展示的平台返回 null */
54
+ cardCreate(cardJson: string): Promise<string | null>;
55
+
56
+ /** 将进度展示发送到指定会话,返回 message_id */
57
+ cardSend(chatId: string, cardId: string): Promise<string>;
58
+
59
+ /** 更新已发送的进度展示,sequence 保证有序 */
60
+ cardUpdate(cardId: string, cardJson: string, sequence: number): Promise<void>;
61
61
  }
@@ -110,7 +110,12 @@ export interface DisplayCardState {
110
110
  lastSentContent: string;
111
111
  streamErrorNotified: boolean;
112
112
  /** 卡片轮转时记录的基线,轮转后只展示增量 */
113
- rotationBaseline?: string;
113
+ rotationAccLen?: number;
114
+ rotationFinalReply?: string;
115
+ /** WeChat delta: 上次发送时 accumulatedContent 的长度 */
116
+ lastSentAccLen?: number;
117
+ /** WeChat delta: 上次发送时的 finalReply */
118
+ lastSentFinalReply?: string;
114
119
  }
115
120
 
116
121
  export const displayCards = new Map<string, DisplayCardState>();
package/src/session.ts CHANGED
@@ -917,14 +917,17 @@ export function ensureDisplayLoop(sessionId: string): void {
917
917
  if (isTerminal) {
918
918
  if (isWechat) {
919
919
  // WeChat: 没有卡片需要终结,用 delta 逻辑发剩余内容,避免重发已推送的部分
920
- const rawFull = state.accumulatedContent + state.finalReply;
921
- const prevRaw = display?.lastSentContent ?? "";
922
- let remaining: string;
923
- if (prevRaw && rawFull.startsWith(prevRaw)) {
924
- remaining = rawFull.slice(prevRaw.length).trim();
920
+ // 分开追踪 accumulatedContent finalReply,而非拼接后对比
921
+ const prevAccLen = display?.lastSentAccLen ?? 0;
922
+ const prevFinalReply = display?.lastSentFinalReply ?? "";
923
+ const accDelta = state.accumulatedContent.slice(prevAccLen);
924
+ let replyDelta: string;
925
+ if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
926
+ replyDelta = state.finalReply.slice(prevFinalReply.length);
925
927
  } else {
926
- remaining = rawFull.trim();
928
+ replyDelta = state.finalReply;
927
929
  }
930
+ const remaining = (accDelta + replyDelta).trim();
928
931
  const tail = "━━━ 回答结束 ━━━";
929
932
  const finalMsg = remaining ? remaining + "\n" + tail : tail;
930
933
  await p.sendText(chatId, finalMsg).catch(() => {});
@@ -966,21 +969,29 @@ export function ensureDisplayLoop(sessionId: string): void {
966
969
  const d = displayCards.get(chatId);
967
970
  if (!d || d.cardBusy) return;
968
971
 
969
- const rawFull = state.accumulatedContent + state.finalReply;
970
- const prevRaw = d.lastSentContent;
971
- let delta: string;
972
- if (prevRaw && rawFull.startsWith(prevRaw)) {
973
- delta = rawFull.slice(prevRaw.length).trim();
972
+ // 分开追踪 accumulatedContent finalReply 的已发送位置。
973
+ // 如果只用 rawFull.startsWith(prevRaw),当新的 tool_use/tool_result
974
+ // 插入到 accumulatedContent 中间时,rawFull 不再以 prevRaw 开头,
975
+ // 会回退到发送完整内容 产生大量重复。
976
+ const prevAccLen = d.lastSentAccLen ?? 0;
977
+ const prevFinalReply = d.lastSentFinalReply ?? "";
978
+ const accDelta = state.accumulatedContent.slice(prevAccLen);
979
+ let replyDelta: string;
980
+ if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
981
+ replyDelta = state.finalReply.slice(prevFinalReply.length);
974
982
  } else {
975
- delta = rawFull.trim();
983
+ replyDelta = state.finalReply;
976
984
  }
985
+ const delta = (accDelta + replyDelta).trim();
977
986
  if (!delta) return;
978
987
 
979
988
  d.cardBusy = true;
980
989
  try {
981
990
  const ok = await p.sendText(chatId, delta);
982
991
  if (ok) {
983
- d.lastSentContent = rawFull;
992
+ d.lastSentAccLen = state.accumulatedContent.length;
993
+ d.lastSentFinalReply = state.finalReply;
994
+ d.lastSentContent = delta;
984
995
  } else {
985
996
  // 发送失败(限流等),不更新光标,下次合并重试
986
997
  return;
@@ -1026,8 +1037,10 @@ export function ensureDisplayLoop(sessionId: string): void {
1026
1037
  display.cardId = newCardId;
1027
1038
  display.sequence = 1;
1028
1039
  display.cardCreatedAt = Date.now();
1029
- // 记录基线:新卡片只展示轮转后新增的内容,不重复旧卡已有内容
1030
- display.rotationBaseline = state.accumulatedContent + state.finalReply;
1040
+ // 记录基线:分开追踪 accumulatedContent 长度和 finalReply,
1041
+ // 避免 tool 内容中间插入时前缀匹配失败 → 回退发完整内容
1042
+ display.rotationAccLen = state.accumulatedContent.length;
1043
+ display.rotationFinalReply = state.finalReply;
1031
1044
  display.lastSentContent = "";
1032
1045
  display.streamErrorNotified = false;
1033
1046
  }
@@ -1039,27 +1052,19 @@ export function ensureDisplayLoop(sessionId: string): void {
1039
1052
  return;
1040
1053
  }
1041
1054
 
1042
- // 轮转后:只展示增量(从 baseline 之后的新内容)
1043
- if (display.rotationBaseline) {
1044
- const fullRaw = state.accumulatedContent + state.finalReply;
1045
- if (!fullRaw.startsWith(display.rotationBaseline)) {
1046
- // baseline 失效(极端情况),回退到完整内容
1047
- display.rotationBaseline = undefined;
1048
- dotCount = (dotCount % 9) + 1;
1049
- const fallback = fullRaw + "\n" + "。".repeat(dotCount);
1050
- display.lastSentContent = fallback;
1051
- const fbCard = buildProgressCard(truncateContent(fallback), { showStop: true, headerTitle: "生成中..." });
1052
- display.cardBusy = true;
1053
- try {
1054
- await p.cardUpdate(display.cardId, fbCard, display.sequence + 1);
1055
- display.sequence++;
1056
- } finally {
1057
- display.cardBusy = false;
1058
- }
1059
- return;
1055
+ // 轮转后:分开追踪 accumulatedContent 和 finalReply 增量,
1056
+ // 避免 tool 内容插入中间时前缀匹配失败
1057
+ if (display.rotationAccLen !== undefined) {
1058
+ const accDelta = state.accumulatedContent.slice(display.rotationAccLen);
1059
+ const rotReply = display.rotationFinalReply ?? "";
1060
+ let replyDelta: string;
1061
+ if (rotReply && state.finalReply.startsWith(rotReply)) {
1062
+ replyDelta = state.finalReply.slice(rotReply.length);
1063
+ } else {
1064
+ replyDelta = state.finalReply;
1060
1065
  }
1061
- const delta = fullRaw.slice(display.rotationBaseline.length).trim();
1062
- if (delta === display.lastSentContent) return;
1066
+ const delta = (accDelta + replyDelta).trim();
1067
+ if (!delta || delta === display.lastSentContent) return;
1063
1068
  display.lastSentContent = delta;
1064
1069
  const deltaCard = buildProgressCard(truncateContent(delta) || "处理中...", { showStop: true, headerTitle: "生成中..." });
1065
1070
  display.cardBusy = true;