evolclaw 3.0.0 → 3.1.1

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 (104) hide show
  1. package/README.md +1 -1
  2. package/bin/ec.js +29 -0
  3. package/dist/agents/baseagent-normalize.js +19 -0
  4. package/dist/agents/claude-runner.js +47 -12
  5. package/dist/agents/codex-runner.js +2 -0
  6. package/dist/agents/gemini-runner.js +9 -9
  7. package/dist/agents/kit-renderer.js +281 -0
  8. package/dist/aun/aid/identity.js +28 -0
  9. package/dist/aun/aid/index.js +1 -1
  10. package/dist/aun/aid/lifecycle-log.js +33 -0
  11. package/dist/aun/msg/group.js +3 -1
  12. package/dist/aun/msg/p2p.js +42 -1
  13. package/dist/channels/aun.js +427 -146
  14. package/dist/channels/dingtalk.js +3 -1
  15. package/dist/channels/feishu.js +128 -7
  16. package/dist/channels/qqbot.js +3 -1
  17. package/dist/channels/wechat.js +4 -1
  18. package/dist/channels/wecom.js +3 -1
  19. package/dist/cli/bench.js +1219 -0
  20. package/dist/cli/index.js +418 -40
  21. package/dist/cli/init.js +3 -4
  22. package/dist/cli/link-rules.js +245 -0
  23. package/dist/cli/net-check.js +640 -0
  24. package/dist/cli/watch-msg.js +666 -0
  25. package/dist/config-store.js +82 -5
  26. package/dist/core/channel-loader.js +23 -10
  27. package/dist/core/command-handler.js +127 -99
  28. package/dist/core/evolagent.js +5 -10
  29. package/dist/core/message/im-renderer.js +93 -48
  30. package/dist/core/message/items-formatter.js +11 -4
  31. package/dist/core/message/message-bridge.js +11 -2
  32. package/dist/core/message/message-log.js +8 -1
  33. package/dist/core/message/message-processor.js +194 -127
  34. package/dist/core/message/message-queue.js +10 -3
  35. package/dist/core/permission.js +95 -3
  36. package/dist/core/relation/peer-identity.js +161 -0
  37. package/dist/core/session/session-manager.js +103 -65
  38. package/dist/core/trigger/manager.js +16 -0
  39. package/dist/core/trigger/parser.js +110 -0
  40. package/dist/core/trigger/scheduler.js +7 -1
  41. package/dist/data/error-dict.json +118 -0
  42. package/dist/eck/baseagent-caps.js +18 -0
  43. package/dist/eck/detect.js +47 -0
  44. package/dist/eck/init.js +77 -0
  45. package/dist/eck/rules-loader.js +28 -0
  46. package/dist/index.js +186 -19
  47. package/dist/net-check.js +640 -0
  48. package/dist/paths.js +31 -40
  49. package/dist/utils/aid-lifecycle-log.js +33 -0
  50. package/dist/utils/atomic-write.js +10 -0
  51. package/dist/utils/cross-platform.js +17 -8
  52. package/dist/utils/error-utils.js +27 -15
  53. package/dist/utils/instance-registry.js +6 -5
  54. package/dist/utils/log-writer.js +2 -1
  55. package/dist/utils/logger.js +10 -0
  56. package/dist/utils/npm-ops.js +35 -3
  57. package/dist/utils/process-introspect.js +16 -38
  58. package/dist/utils/stats.js +216 -2
  59. package/dist/watch-msg.js +26 -11
  60. package/evolclaw-install-aun.md +14 -2
  61. package/kits/docs/GUIDE.md +20 -0
  62. package/kits/docs/INDEX.md +52 -0
  63. package/kits/docs/aun/CHEATSHEET.md +17 -0
  64. package/kits/docs/aun/SYNC_PROTOCOL.md +15 -0
  65. package/kits/docs/channels/feishu.md +27 -0
  66. package/kits/docs/eck_templates/GUIDE.template.md +22 -0
  67. package/kits/docs/eck_templates/INDEX.template.md +28 -0
  68. package/kits/docs/eck_templates/path-registry.template.md +33 -0
  69. package/kits/docs/eck_templates/runtime.template.md +19 -0
  70. package/kits/docs/evolclaw/MSG_GROUP.md +30 -0
  71. package/kits/docs/evolclaw/MSG_PRIVATE.md +72 -0
  72. package/kits/docs/identity/AID_PROFILE_SPEC.md +27 -0
  73. package/kits/docs/identity/PATH_OPS.md +16 -0
  74. package/kits/docs/identity/ROLE_DETAIL.md +20 -0
  75. package/kits/docs/path-registry.md +43 -0
  76. package/kits/eck_manifest.json +95 -0
  77. package/kits/rules/01-overview.md +120 -0
  78. package/kits/rules/02-navigation.md +75 -0
  79. package/kits/rules/03-identity.md +34 -0
  80. package/kits/rules/04-relation.md +49 -0
  81. package/kits/rules/05-venue.md +45 -0
  82. package/kits/rules/06-channel.md +73 -0
  83. package/kits/templates/system-fragments/baseagent.md +2 -0
  84. package/kits/templates/system-fragments/channel.md +10 -0
  85. package/kits/templates/system-fragments/identity.md +12 -0
  86. package/kits/templates/system-fragments/relation.md +9 -0
  87. package/kits/templates/system-fragments/runtime.md +19 -0
  88. package/kits/templates/system-fragments/venue.md +5 -0
  89. package/package.json +7 -5
  90. package/dist/agents/templates.js +0 -122
  91. package/dist/data/prompts.md +0 -137
  92. package/kits/aun/meta.md +0 -25
  93. package/kits/aun/role.md +0 -25
  94. package/kits/templates/group.md +0 -20
  95. package/kits/templates/private.md +0 -9
  96. package/kits/templates/system-fragments/personal-context.md +0 -3
  97. package/kits/templates/system-fragments/self-intro.md +0 -5
  98. package/kits/templates/system-fragments/speaker-intro.md +0 -5
  99. package/kits/templates/system-fragments/venue-intro.md +0 -5
  100. /package/kits/{channels → docs/channels}/aun.md +0 -0
  101. /package/kits/{evolclaw/commands.md → docs/evolclaw/AGENT_CMD.md} +0 -0
  102. /package/kits/{evolclaw → docs/evolclaw}/self-summary.md +0 -0
  103. /package/kits/{evolclaw → docs/evolclaw}/tools.md +0 -0
  104. /package/kits/{evolclaw → docs/identity}/identity-tools.md +0 -0
@@ -1,4 +1,5 @@
1
1
  import path from 'path';
2
+ import fs from 'fs';
2
3
  import { renderActionAsText } from './interaction-router.js';
3
4
  import { buildEnvelope, sendInteractionPayload } from './message/message-processor.js';
4
5
  // 危险命令黑名单(正则表达式)
@@ -89,9 +90,15 @@ export function summarizeToolInput(toolName, input) {
89
90
  return '';
90
91
  const extractors = {
91
92
  'Read': (i) => i.file_path,
92
- 'Edit': (i) => i.file_path,
93
+ 'Edit': (i) => formatEditSummary(i),
93
94
  'Write': (i) => i.file_path,
94
- 'Bash': (i) => i.command?.substring(0, 80),
95
+ 'Bash': (i) => {
96
+ const cmd = i.command?.substring(0, 80) || '';
97
+ const desc = i.description;
98
+ if (desc && cmd)
99
+ return `${cmd} | ${desc}`;
100
+ return cmd || desc;
101
+ },
95
102
  'Grep': (i) => `pattern: ${i.pattern}`,
96
103
  'Glob': (i) => `pattern: ${i.pattern}`,
97
104
  'Agent': (i) => i.description || i.prompt?.substring(0, 80),
@@ -110,6 +117,8 @@ export function summarizeToolInput(toolName, input) {
110
117
  },
111
118
  'TaskCreate': (i) => i.subject || i.description?.substring(0, 80),
112
119
  'TaskUpdate': (i) => i.status ? `${i.taskId} → ${i.status}` : i.taskId,
120
+ 'TaskOutput': (i) => `${i.task_id || '?'}${i.block === false ? ' (non-blocking)' : ''}${i.timeout ? ` timeout=${i.timeout}ms` : ''}`,
121
+ 'TaskStop': (i) => i.task_id || i.shell_id || '?',
113
122
  'NotebookEdit': (i) => i.notebook_path,
114
123
  'WebFetch': (i) => i.url,
115
124
  'WebSearch': (i) => i.query?.substring(0, 80),
@@ -131,6 +140,81 @@ export function summarizeToolInput(toolName, input) {
131
140
  || input.url
132
141
  || '';
133
142
  }
143
+ /** 为 Edit 工具生成 diff 风格摘要 */
144
+ function formatEditSummary(input) {
145
+ const filePath = input.file_path || '';
146
+ const oldStr = typeof input.old_string === 'string' ? input.old_string : '';
147
+ const newStr = typeof input.new_string === 'string' ? input.new_string : '';
148
+ if (!oldStr && !newStr)
149
+ return filePath;
150
+ const MAX_DIFF_LINES = 14;
151
+ const oldLines = oldStr.split('\n');
152
+ const newLines = newStr.split('\n');
153
+ // 尝试从文件中定位 old_string 的起始行号
154
+ let startLine = 0; // 0-based; 0 means unknown
155
+ if (filePath && oldStr) {
156
+ try {
157
+ const content = fs.readFileSync(filePath, 'utf-8');
158
+ const idx = content.indexOf(oldStr);
159
+ if (idx >= 0) {
160
+ startLine = content.slice(0, idx).split('\n').length; // 1-based
161
+ }
162
+ }
163
+ catch {
164
+ // 文件不可读,行号留空
165
+ }
166
+ }
167
+ const diffLines = [];
168
+ // 找公共前缀行数
169
+ let prefixLen = 0;
170
+ while (prefixLen < oldLines.length && prefixLen < newLines.length && oldLines[prefixLen] === newLines[prefixLen]) {
171
+ prefixLen++;
172
+ }
173
+ // 找公共后缀行数
174
+ let suffixLen = 0;
175
+ while (suffixLen < oldLines.length - prefixLen &&
176
+ suffixLen < newLines.length - prefixLen &&
177
+ oldLines[oldLines.length - 1 - suffixLen] === newLines[newLines.length - 1 - suffixLen]) {
178
+ suffixLen++;
179
+ }
180
+ const CONTEXT = 2;
181
+ // 计算行号宽度(用于对齐)
182
+ const maxLineNo = startLine > 0 ? startLine + oldLines.length - 1 : 0;
183
+ const newMaxLineNo = startLine > 0 ? startLine + prefixLen + (newLines.length - suffixLen - prefixLen) - 1 : 0;
184
+ const padWidth = startLine > 0 ? Math.max(maxLineNo, newMaxLineNo).toString().length : 0;
185
+ // 格式化一行:行号 + 标记 + 内容
186
+ // 使用 Unicode 符号避免飞书 Markdown 将 "- " 解析为列表
187
+ const fmtLine = (lineNo, marker, text) => {
188
+ if (startLine > 0) {
189
+ return `${lineNo.toString().padStart(padWidth)} ${marker} ${text}`;
190
+ }
191
+ return `${marker} ${text}`;
192
+ };
193
+ // 上下文前缀(最多 CONTEXT 行)
194
+ const ctxStart = Math.max(0, prefixLen - CONTEXT);
195
+ for (let i = ctxStart; i < prefixLen; i++) {
196
+ diffLines.push(fmtLine(startLine + i, ' ', oldLines[i]));
197
+ }
198
+ // 删除行
199
+ const removedEnd = oldLines.length - suffixLen;
200
+ for (let i = prefixLen; i < removedEnd && diffLines.length < MAX_DIFF_LINES; i++) {
201
+ diffLines.push(fmtLine(startLine + i, '−', oldLines[i]));
202
+ }
203
+ // 新增行(行号从 prefixLen 位置开始递增)
204
+ const addedEnd = newLines.length - suffixLen;
205
+ for (let i = prefixLen; i < addedEnd && diffLines.length < MAX_DIFF_LINES; i++) {
206
+ diffLines.push(fmtLine(startLine + i, '+', newLines[i]));
207
+ }
208
+ // 上下文后缀(最多 CONTEXT 行)
209
+ const ctxEnd = Math.min(oldLines.length, removedEnd + CONTEXT);
210
+ for (let i = removedEnd; i < ctxEnd && diffLines.length < MAX_DIFF_LINES + 2; i++) {
211
+ diffLines.push(fmtLine(startLine + i, ' ', oldLines[i]));
212
+ }
213
+ if (diffLines.length > MAX_DIFF_LINES + 2) {
214
+ diffLines.splice(MAX_DIFF_LINES, diffLines.length, ' ...');
215
+ }
216
+ return `${filePath}\n\`\`\`\n${diffLines.join('\n')}\n\`\`\``;
217
+ }
134
218
  export class PermissionGateway {
135
219
  pending = new Map();
136
220
  timeout = 5 * 60 * 1000;
@@ -222,7 +306,15 @@ export class PermissionGateway {
222
306
  await sendPrompt(renderActionAsText(interaction));
223
307
  }
224
308
  return new Promise((resolve) => {
225
- this.pending.set(requestId, { sessionId, toolName, resolve, timer: setTimeout(() => { }, 0) });
309
+ const timer = setTimeout(() => {
310
+ const pending = this.pending.get(requestId);
311
+ if (!pending)
312
+ return;
313
+ this.pending.delete(requestId);
314
+ this.eventBus?.publish({ type: 'permission:timeout', sessionId, requestId, toolName });
315
+ pending.resolve('deny');
316
+ }, this.timeout);
317
+ this.pending.set(requestId, { sessionId, toolName, resolve, timer });
226
318
  // 注册到 InteractionRouter(卡片和文本降级都注册,统一路由)
227
319
  if (context?.interactionRouter) {
228
320
  context.interactionRouter.register(requestId, sessionId, (action) => {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * PeerIdentityCache - 对端身份缓存管理
3
+ *
4
+ * 职责:
5
+ * 1. 从对端的 agent.md 确定身份(human / agent)
6
+ * 2. 缓存到关系层文件(30天时效)
7
+ * 3. 支持入站和出站消息的身份查询
8
+ *
9
+ * 信源:对端的 agent.md(通过 AUN SDK 下载并验签)
10
+ * 判定规则:type !== 'human' → agent
11
+ * 缓存位置:$AGENT_DIR/relations/<channel>#<urlEncode(peerId)>/peer-identity.json
12
+ */
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import * as crypto from 'crypto';
16
+ import { logger } from '../../utils/logger.js';
17
+ /**
18
+ * 对端身份缓存管理器
19
+ */
20
+ export class PeerIdentityCache {
21
+ /** 缓存最大时效:30 天 */
22
+ static CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
23
+ /**
24
+ * 获取 peer-identity.json 文件路径
25
+ */
26
+ static getFilePath(channel, peerId, agentDir) {
27
+ const peerKey = `${channel}#${encodeURIComponent(peerId)}`;
28
+ return path.join(agentDir, 'relations', peerKey, 'peer-identity.json');
29
+ }
30
+ /**
31
+ * 从文件读取缓存
32
+ * @returns PeerIdentity | null(缓存不存在)
33
+ */
34
+ static get(channel, peerId, agentDir) {
35
+ const filePath = this.getFilePath(channel, peerId, agentDir);
36
+ try {
37
+ const content = fs.readFileSync(filePath, 'utf-8');
38
+ return JSON.parse(content);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * 检查缓存是否需要刷新
46
+ * @param maxAgeMs 最大缓存时间(默认 30 天)
47
+ * @returns true=需要刷新
48
+ */
49
+ static needsRefresh(channel, peerId, agentDir, maxAgeMs = this.CACHE_MAX_AGE_MS) {
50
+ const cached = this.get(channel, peerId, agentDir);
51
+ if (!cached)
52
+ return true;
53
+ return Date.now() - cached.lastCheckedAt > maxAgeMs;
54
+ }
55
+ /**
56
+ * 从 agent.md 更新身份信息
57
+ * @param agentMd 已验签的 agent.md 内容
58
+ */
59
+ static updateFromAgentMd(channel, peerId, agentDir, agentMd, verifiedAt) {
60
+ // 解析 type 和 name
61
+ const typeMatch = agentMd.match(/^type:\s*["']?(\w+)["']?/m);
62
+ const nameMatch = agentMd.match(/^name:\s*["']?(.+?)["']?\s*$/m);
63
+ const type = typeMatch?.[1] || 'unknown';
64
+ const isAgent = type !== 'human';
65
+ const name = nameMatch?.[1]?.trim();
66
+ // 计算 hash
67
+ const agentMdHash = 'sha256:' + crypto.createHash('sha256').update(agentMd, 'utf-8').digest('hex');
68
+ // 构建身份信息
69
+ const identity = {
70
+ aid: peerId,
71
+ type,
72
+ isAgent,
73
+ name,
74
+ agentMdHash,
75
+ verifiedAt,
76
+ lastCheckedAt: Date.now(),
77
+ source: 'agentmd',
78
+ };
79
+ // 写入文件
80
+ const filePath = this.getFilePath(channel, peerId, agentDir);
81
+ try {
82
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
83
+ fs.writeFileSync(filePath, JSON.stringify(identity, null, 2), 'utf-8');
84
+ logger.debug(`[PeerIdentityCache] Updated: ${channel}#${peerId} type=${type} isAgent=${isAgent}`);
85
+ }
86
+ catch (err) {
87
+ logger.warn(`[PeerIdentityCache] Failed to write cache: ${filePath} err=${err}`);
88
+ }
89
+ return identity;
90
+ }
91
+ /**
92
+ * 标记为 unknown(验签失败或无 agent.md)
93
+ */
94
+ static markUnknown(channel, peerId, agentDir) {
95
+ const identity = {
96
+ aid: peerId,
97
+ type: 'unknown',
98
+ isAgent: true, // 验签失败 → 当做 agent(安全策略)
99
+ agentMdHash: '',
100
+ verifiedAt: 0,
101
+ lastCheckedAt: Date.now(),
102
+ source: 'unknown',
103
+ };
104
+ const filePath = this.getFilePath(channel, peerId, agentDir);
105
+ try {
106
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
107
+ fs.writeFileSync(filePath, JSON.stringify(identity, null, 2), 'utf-8');
108
+ logger.debug(`[PeerIdentityCache] Marked unknown: ${channel}#${peerId}`);
109
+ }
110
+ catch (err) {
111
+ logger.warn(`[PeerIdentityCache] Failed to write unknown cache: ${filePath} err=${err}`);
112
+ }
113
+ return identity;
114
+ }
115
+ /**
116
+ * 完整流程:检查缓存 → 需要刷新则下载 agent.md → 更新缓存
117
+ *
118
+ * @param channel 渠道类型(如 'aun')
119
+ * @param peerId 对端 ID(AUN 是 AID)
120
+ * @param agentDir agent 数据根目录
121
+ * @param aunClient AUN SDK client(需要有 fetchAgentMd 方法)
122
+ * @param forceRefresh 强制刷新(忽略缓存时效)
123
+ * @returns PeerIdentity
124
+ */
125
+ static async resolve(channel, peerId, agentDir, aunClient, forceRefresh = false) {
126
+ // 1. 检查缓存
127
+ if (!forceRefresh && !this.needsRefresh(channel, peerId, agentDir)) {
128
+ const cached = this.get(channel, peerId, agentDir);
129
+ if (cached) {
130
+ logger.debug(`[PeerIdentityCache] Cache hit: ${channel}#${peerId} type=${cached.type} age=${Math.floor((Date.now() - cached.lastCheckedAt) / 1000 / 60 / 60 / 24)}d`);
131
+ return cached;
132
+ }
133
+ }
134
+ // 2. 下载并验签 agent.md(SDK 自动验签)
135
+ try {
136
+ logger.debug(`[PeerIdentityCache] Fetching agent.md: ${channel}#${peerId}`);
137
+ const result = await aunClient.fetchAgentMd(peerId);
138
+ const agentMd = result.content;
139
+ // 3. 更新缓存
140
+ return this.updateFromAgentMd(channel, peerId, agentDir, agentMd, Date.now());
141
+ }
142
+ catch (err) {
143
+ // 验签失败或下载失败 → 标记为 unknown,当做 agent
144
+ logger.warn(`[PeerIdentityCache] Failed to fetch agent.md: ${channel}#${peerId} err=${err instanceof Error ? err.message : String(err)}`);
145
+ return this.markUnknown(channel, peerId, agentDir);
146
+ }
147
+ }
148
+ /**
149
+ * 清除指定对端的缓存
150
+ */
151
+ static clear(channel, peerId, agentDir) {
152
+ const filePath = this.getFilePath(channel, peerId, agentDir);
153
+ try {
154
+ fs.unlinkSync(filePath);
155
+ logger.debug(`[PeerIdentityCache] Cleared: ${channel}#${peerId}`);
156
+ }
157
+ catch {
158
+ // 文件不存在,忽略
159
+ }
160
+ }
161
+ }
@@ -31,9 +31,16 @@ export class SessionManager {
31
31
  setSessionModeResolver(resolver) {
32
32
  this.sessionModeResolver = resolver;
33
33
  }
34
- resolveDefaultSessionMode(channel, chatType) {
34
+ resolveDefaultSessionMode(channel, chatType, peerType) {
35
35
  const ct = chatType || 'private';
36
- const resolved = this.sessionModeResolver?.(channel, ct);
36
+ // 来源2:群聊强制 proactive
37
+ if (ct === 'group')
38
+ return 'proactive';
39
+ // 来源3:非 human 对端(ai/bot)强制 proactive,无视 agent 的默认 chatmode 配置
40
+ if (peerType && peerType !== 'human' && peerType !== 'unknown')
41
+ return 'proactive';
42
+ // 来源1:agent 配置默认值
43
+ const resolved = this.sessionModeResolver?.(channel, ct, peerType);
37
44
  return resolved || 'interactive';
38
45
  }
39
46
  registerFileAdapter(adapter) {
@@ -176,12 +183,75 @@ export class SessionManager {
176
183
  const metaPath = this.metaFilePath(targetDir, session.id);
177
184
  appendJsonl(metaPath, file);
178
185
  }
186
+ /**
187
+ * 比较两个 SessionFile 是否在内容上相等(忽略 updatedAt / updatedAtStr)。
188
+ * 用于跳过"没真变化"的写入,避免 jsonl 写放大。
189
+ */
190
+ sessionFilesEqual(a, b) {
191
+ const stripVolatile = ({ updatedAt, updatedAtStr, ...rest }) => rest;
192
+ return JSON.stringify(stripVolatile(a)) === JSON.stringify(stripVolatile(b));
193
+ }
194
+ /**
195
+ * Append meta + write active.json,但只在 session 内容(除 updatedAt 外)真正变化时才写。
196
+ * prev 是修改前的快照(用于 diff),next 是修改后的 session。
197
+ * 返回是否发生了写入。
198
+ */
199
+ writeSessionIfChanged(channel, channelId, prev, next) {
200
+ if (prev) {
201
+ const prevFile = sessionToFile(prev);
202
+ const nextFile = sessionToFile(next);
203
+ if (this.sessionFilesEqual(prevFile, nextFile))
204
+ return false;
205
+ }
206
+ next.updatedAt = Date.now();
207
+ this.appendMeta(channel, channelId, next);
208
+ const active = this.readActive(channel, channelId);
209
+ if (active && active.id === next.id) {
210
+ // 保留 active.json 中已有的 activeTask(markProcessing 写入的处理状态)
211
+ if (active.processingState && !next.processingState) {
212
+ next.processingState = active.processingState;
213
+ }
214
+ this.writeActive(channel, channelId, next);
215
+ }
216
+ return true;
217
+ }
179
218
  readMetaLatest(metaFilePath) {
180
219
  const file = readLastJsonlLine(metaFilePath);
181
220
  if (!file)
182
221
  return undefined;
183
222
  return fileToSession(file);
184
223
  }
224
+ /**
225
+ * 为 by-sessionId 改方法加载"当前 session 状态"。
226
+ *
227
+ * 设计契约(docs/refactor/01-db-to-fs.md):
228
+ * active.json 是热路径权威源。.jsonl 是历史档案。
229
+ *
230
+ * 读取策略:
231
+ * 1. 先按 sessionId 定位 .jsonl 文件(确认 session 存在 + 拿到 channel/channelId)
232
+ * 2. 优先读 active.json(如果 active.id === sessionId)—— 当前状态
233
+ * 3. 否则 fallback 到 .jsonl 末行 —— 非活跃 session 的更新(如多 session 并存时改非 active 那个)
234
+ *
235
+ * 返回 { current, prev }:
236
+ * - current 用于 caller 修改后写回
237
+ * - prev 是 current 的初始快照(用于 writeSessionIfChanged 的 diff 检查)
238
+ */
239
+ loadSessionForUpdate(sessionId) {
240
+ const found = this.findSessionFileById(sessionId);
241
+ if (!found)
242
+ return undefined;
243
+ // 先读 .jsonl 末行拿 channel/channelId(active.json 文件路径需要这两个)
244
+ const fromJsonl = this.readMetaLatest(found.metaPath);
245
+ if (!fromJsonl)
246
+ return undefined;
247
+ // 优先用 active.json 的当前状态(如果它就是这个 sessionId)
248
+ const active = this.readActive(fromJsonl.channel, fromJsonl.channelId);
249
+ const base = (active && active.id === sessionId) ? active : fromJsonl;
250
+ // 深拷贝避免 caller 改 current 时污染 prev
251
+ const current = JSON.parse(JSON.stringify(base));
252
+ const prev = JSON.parse(JSON.stringify(base));
253
+ return { current, prev };
254
+ }
185
255
  validateSessionFile(session) {
186
256
  const agentSessionId = session.agentSessionId;
187
257
  if (!agentSessionId)
@@ -193,12 +263,9 @@ export class SessionManager {
193
263
  if (adapter.checkExists(session.projectPath, agentSessionId))
194
264
  return agentSessionId;
195
265
  logger.warn(`Session file not found for ${agentId}: ${agentSessionId}, clearing session ID`);
266
+ const prev = JSON.parse(JSON.stringify(session));
196
267
  session.agentSessionId = undefined;
197
- this.appendMeta(session.channel, session.channelId, session);
198
- const active = this.readActive(session.channel, session.channelId);
199
- if (active && active.id === session.id) {
200
- this.writeActive(session.channel, session.channelId, session);
201
- }
268
+ this.writeSessionIfChanged(session.channel, session.channelId, prev, session);
202
269
  return undefined;
203
270
  }
204
271
  getActiveChatType(channel, channelId) {
@@ -332,9 +399,9 @@ export class SessionManager {
332
399
  return result;
333
400
  }
334
401
  // ─── Session lifecycle ───
335
- async getOrCreateSession(channel, channelId, defaultProjectPath, threadId, metadata, name, userId, chatType, agentId, selfId, channelType) {
402
+ async getOrCreateSession(channel, channelId, defaultProjectPath, threadId, metadata, name, userId, chatType, agentId, selfId, channelType, peerType) {
336
403
  if (threadId) {
337
- const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfId, channelType);
404
+ const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfId, channelType, peerType);
338
405
  session.identity = this.resolveIdentity(channel, userId);
339
406
  if (session.metadata && !session.metadata.permissionMode) {
340
407
  session.metadata.permissionMode = DEFAULT_PERMISSION_MODE;
@@ -399,6 +466,7 @@ export class SessionManager {
399
466
  .sort((a, b) => b.updatedAt - a.updatedAt)[0];
400
467
  if (existing) {
401
468
  const validSessionId = this.validateSessionFile(existing);
469
+ const prev = JSON.parse(JSON.stringify({ ...existing, agentSessionId: validSessionId }));
402
470
  const session = { ...existing, agentSessionId: validSessionId };
403
471
  session.identity = this.resolveIdentity(channel, userId);
404
472
  if (!session.metadata)
@@ -416,9 +484,7 @@ export class SessionManager {
416
484
  if (chatType === 'private' && metadata?.peerName && !session.metadata.peerName) {
417
485
  session.metadata.peerName = metadata.peerName;
418
486
  }
419
- session.updatedAt = Date.now();
420
- this.appendMeta(channel, channelId, session);
421
- this.writeActive(channel, channelId, session);
487
+ this.writeSessionIfChanged(channel, channelId, prev, session);
422
488
  return session;
423
489
  }
424
490
  // Create new session
@@ -435,7 +501,7 @@ export class SessionManager {
435
501
  threadId: '',
436
502
  agentId: agentId || 'claude',
437
503
  chatType: chatType || 'private',
438
- sessionMode: this.resolveDefaultSessionMode(channel, chatType || 'private'),
504
+ sessionMode: this.resolveDefaultSessionMode(channel, chatType || 'private', peerType),
439
505
  metadata: sessionMetadata,
440
506
  name: name || '默认会话',
441
507
  createdAt: Date.now(),
@@ -457,12 +523,10 @@ export class SessionManager {
457
523
  return session;
458
524
  }
459
525
  async updateSession(sessionId, updates) {
460
- const found = this.findSessionFileById(sessionId);
461
- if (!found)
462
- return;
463
- const current = this.readMetaLatest(found.metaPath);
464
- if (!current)
526
+ const loaded = this.loadSessionForUpdate(sessionId);
527
+ if (!loaded)
465
528
  return;
529
+ const { current, prev } = loaded;
466
530
  if (updates.chatType !== undefined)
467
531
  current.chatType = updates.chatType;
468
532
  if (updates.name !== undefined)
@@ -473,14 +537,9 @@ export class SessionManager {
473
537
  current.metadata = updates.metadata;
474
538
  if ('agentSessionId' in updates)
475
539
  current.agentSessionId = updates.agentSessionId ?? undefined;
476
- current.updatedAt = Date.now();
477
- this.appendMeta(current.channel, current.channelId, current);
478
- const active = this.readActive(current.channel, current.channelId);
479
- if (active && active.id === sessionId) {
480
- this.writeActive(current.channel, current.channelId, current);
481
- }
540
+ this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
482
541
  }
483
- getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfId, channelType) {
542
+ getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId, selfId, channelType, peerType) {
484
543
  const chatDir = this.ensureResolvedChatDir(channel, channelId);
485
544
  const threadIndex = readThreadIndex(chatDir);
486
545
  const existingMetaId = threadIndex[threadId];
@@ -511,7 +570,7 @@ export class SessionManager {
511
570
  threadId,
512
571
  agentId: agentId || 'claude',
513
572
  chatType: inheritedChatType,
514
- sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
573
+ sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType, peerType),
515
574
  metadata,
516
575
  name: name || '话题会话',
517
576
  createdAt: Date.now(),
@@ -581,25 +640,19 @@ export class SessionManager {
581
640
  const active = this.readActive(channel, channelId);
582
641
  if (!active)
583
642
  return;
643
+ const prev = JSON.parse(JSON.stringify(active));
584
644
  active.agentSessionId = agentSessionId;
585
- active.updatedAt = Date.now();
586
- this.appendMeta(channel, channelId, active);
587
- this.writeActive(channel, channelId, active);
645
+ this.writeSessionIfChanged(channel, channelId, prev, active);
588
646
  }
589
647
  async updateAgentSessionIdBySessionId(sessionId, agentSessionId) {
590
- logger.info(`[SessionManager] Updating agent_session_id: sessionId=${sessionId}, agentSessionId=${agentSessionId}`);
591
- const found = this.findSessionFileById(sessionId);
592
- if (!found)
593
- return;
594
- const current = this.readMetaLatest(found.metaPath);
595
- if (!current)
648
+ const loaded = this.loadSessionForUpdate(sessionId);
649
+ if (!loaded)
596
650
  return;
651
+ const { current, prev } = loaded;
597
652
  current.agentSessionId = agentSessionId;
598
- current.updatedAt = Date.now();
599
- this.appendMeta(current.channel, current.channelId, current);
600
- const active = this.readActive(current.channel, current.channelId);
601
- if (active && active.id === sessionId) {
602
- this.writeActive(current.channel, current.channelId, current);
653
+ const wrote = this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
654
+ if (wrote) {
655
+ logger.info(`[SessionManager] Updating agent_session_id: sessionId=${sessionId}, agentSessionId=${agentSessionId}`);
603
656
  }
604
657
  }
605
658
  async switchAgent(channel, channelId, projectPath, newAgentId) {
@@ -650,10 +703,9 @@ export class SessionManager {
650
703
  const active = this.readActive(channel, channelId);
651
704
  if (!active)
652
705
  return;
706
+ const prev = JSON.parse(JSON.stringify(active));
653
707
  active.agentSessionId = undefined;
654
- active.updatedAt = Date.now();
655
- this.appendMeta(channel, channelId, active);
656
- this.writeActive(channel, channelId, active);
708
+ this.writeSessionIfChanged(channel, channelId, prev, active);
657
709
  }
658
710
  getOwnerChatId(targetChannel, ownerPeerId) {
659
711
  const chatDirs = scanChatDirs(this.sessionsDir);
@@ -742,34 +794,20 @@ export class SessionManager {
742
794
  return target;
743
795
  }
744
796
  updateMetadata(sessionId, metadata) {
745
- const found = this.findSessionFileById(sessionId);
746
- if (!found)
747
- return;
748
- const current = this.readMetaLatest(found.metaPath);
749
- if (!current)
797
+ const loaded = this.loadSessionForUpdate(sessionId);
798
+ if (!loaded)
750
799
  return;
800
+ const { current, prev } = loaded;
751
801
  current.metadata = metadata;
752
- current.updatedAt = Date.now();
753
- this.appendMeta(current.channel, current.channelId, current);
754
- const active = this.readActive(current.channel, current.channelId);
755
- if (active && active.id === sessionId) {
756
- this.writeActive(current.channel, current.channelId, current);
757
- }
802
+ this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
758
803
  }
759
804
  async renameSession(sessionId, newName) {
760
- const found = this.findSessionFileById(sessionId);
761
- if (!found)
762
- return false;
763
- const current = this.readMetaLatest(found.metaPath);
764
- if (!current)
805
+ const loaded = this.loadSessionForUpdate(sessionId);
806
+ if (!loaded)
765
807
  return false;
808
+ const { current, prev } = loaded;
766
809
  current.name = newName;
767
- current.updatedAt = Date.now();
768
- this.appendMeta(current.channel, current.channelId, current);
769
- const active = this.readActive(current.channel, current.channelId);
770
- if (active && active.id === sessionId) {
771
- this.writeActive(current.channel, current.channelId, current);
772
- }
810
+ this.writeSessionIfChanged(current.channel, current.channelId, prev, current);
773
811
  return true;
774
812
  }
775
813
  async unbindSession(sessionId) {
@@ -92,6 +92,22 @@ export class TriggerManager {
92
92
  }
93
93
  return { active, history };
94
94
  }
95
+ update(id, patch) {
96
+ const t = this.triggers.get(id);
97
+ if (!t)
98
+ throw new Error(`触发器不存在:${id}`);
99
+ // Check name uniqueness if name is being changed
100
+ if (patch.name && patch.name !== t.name) {
101
+ for (const other of this.triggers.values()) {
102
+ if (other.id !== id && other.name === patch.name) {
103
+ throw new Error(`触发器名称已存在:${patch.name}`);
104
+ }
105
+ }
106
+ }
107
+ Object.assign(t, patch, { updatedAt: Date.now() });
108
+ this.save();
109
+ return t;
110
+ }
95
111
  updateFireStats(id, firedAt) {
96
112
  const t = this.triggers.get(id);
97
113
  if (!t)