evolclaw 3.1.11 → 3.3.0
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/CHANGELOG.md +41 -0
- package/README.md +27 -2
- package/dist/agents/{resolve.js → baseagent.js} +34 -5
- package/dist/agents/claude-runner.js +120 -27
- package/dist/agents/codex-app-server-client.js +364 -0
- package/dist/agents/codex-runner.js +1069 -141
- package/dist/agents/gemini-runner.js +2 -2
- package/dist/agents/runner-types.js +28 -0
- package/dist/aun/aid/control-aid.js +67 -0
- package/dist/aun/aid/identity.js +20 -7
- package/dist/aun/aid/store.js +2 -2
- package/dist/aun/storage/download.js +1 -1
- package/dist/aun/storage/upload.js +13 -1
- package/dist/channels/aun.js +538 -325
- package/dist/channels/dingtalk.js +77 -140
- package/dist/channels/feishu.js +98 -151
- package/dist/channels/qqbot.js +75 -138
- package/dist/channels/wechat.js +75 -136
- package/dist/channels/wecom.js +75 -138
- package/dist/cli/agent.js +44 -13
- package/dist/cli/index.js +207 -46
- package/dist/cli/init-channel.js +38 -148
- package/dist/cli/init.js +192 -85
- package/dist/cli/model.js +1 -1
- package/dist/cli/stats.js +558 -0
- package/dist/cli/version.js +87 -0
- package/dist/cli/watch-msg.js +5 -2
- package/dist/config-store.js +48 -11
- package/dist/core/channel-loader.js +84 -82
- package/dist/core/command-handler.js +754 -172
- package/dist/core/daemon-file-cache.js +216 -0
- package/dist/core/evolagent-registry.js +4 -0
- package/dist/core/evolagent.js +28 -23
- package/dist/core/interaction-router.js +8 -0
- package/dist/core/message/command-handler-agent-control.js +215 -0
- package/dist/core/message/create-status.js +67 -0
- package/dist/core/message/im-renderer.js +35 -13
- package/dist/core/message/items-formatter.js +9 -1
- package/dist/core/message/message-bridge.js +52 -22
- package/dist/core/message/message-log.js +1 -0
- package/dist/core/message/message-processor.js +336 -68
- package/dist/core/message/message-queue.js +15 -8
- package/dist/core/message/pending-hints.js +232 -0
- package/dist/core/message/response-depth.js +56 -0
- package/dist/core/model/model-catalog.js +1 -1
- package/dist/core/model/model-scope.js +40 -7
- package/dist/core/permission.js +9 -12
- package/dist/core/relation/peer-identity.js +16 -1
- package/dist/core/session/adapters/claude-session-file-adapter.js +48 -5
- package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
- package/dist/core/session/session-manager.js +27 -13
- package/dist/core/session/session-title.js +26 -0
- package/dist/core/stats/billing.js +151 -0
- package/dist/core/stats/budget.js +93 -0
- package/dist/core/stats/db.js +314 -0
- package/dist/core/stats/eck-vars.js +84 -0
- package/dist/core/stats/index.js +10 -0
- package/dist/core/stats/normalizer.js +78 -0
- package/dist/core/stats/query.js +760 -0
- package/dist/core/stats/writer.js +115 -0
- package/dist/core/trigger/manager.js +34 -0
- package/dist/core/trigger/parser.js +9 -3
- package/dist/core/trigger/scheduler.js +20 -17
- package/dist/{agents → eck}/kit-renderer.js +5 -1
- package/dist/{agents → eck}/manifest-engine.js +127 -35
- package/dist/{agents → eck}/message-renderer.js +26 -1
- package/dist/index.js +185 -8
- package/dist/ipc.js +22 -0
- package/dist/paths.js +7 -3
- package/dist/utils/cross-platform.js +23 -5
- package/dist/utils/ecweb-pair.js +20 -0
- package/dist/utils/stats.js +14 -0
- package/kits/docs/evolclaw/INDEX.md +3 -1
- package/kits/docs/evolclaw/fs-architecture.md +1215 -0
- package/kits/docs/evolclaw/fs.md +131 -0
- package/kits/docs/evolclaw/group-fs.md +209 -0
- package/kits/docs/evolclaw/stats.md +70 -0
- package/kits/docs/venues/aun-group.md +29 -6
- package/kits/docs/venues/group.md +5 -4
- package/kits/eck_manifest.json +12 -0
- package/kits/eck_message_manifest.json +30 -3
- package/kits/rules/05-venue.md +1 -1
- package/kits/templates/message-fragments/inject-default.md +2 -0
- package/kits/templates/message-fragments/item.md +1 -1
- package/kits/templates/system-fragments/response-depth.md +16 -0
- package/package.json +4 -4
- package/dist/agents/baseagent-normalize.js +0 -19
- package/dist/core/relation/peer-key.js +0 -16
- package/dist/utils/channel-helpers.js +0 -46
|
@@ -40,16 +40,22 @@ export class MessageQueue {
|
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
42
|
* 检查消息是否应该处理(去重)
|
|
43
|
+
*
|
|
44
|
+
* 去重 key = `${sessionKey}:${messageId}`,而非裸 messageId。
|
|
45
|
+
* MessageQueue 是进程级单例,被所有 evolagent 共享。AUN 群广播时同一条群消息
|
|
46
|
+
* 会投递给群里每个 evolagent,它们 messageId 相同但 session 不同,必须各处理一次。
|
|
47
|
+
* 裸 messageId 去重会让先入队的 agent 吞掉其他 agent 的消息。
|
|
43
48
|
*/
|
|
44
|
-
shouldProcess(message) {
|
|
49
|
+
shouldProcess(sessionKey, message) {
|
|
45
50
|
if (!message.messageId)
|
|
46
51
|
return true; // 无 ID 的消息不去重
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
const dedupKey = `${sessionKey}:${message.messageId}`;
|
|
53
|
+
if (this.recentMessageIds.has(dedupKey)) {
|
|
54
|
+
logger.debug(`[Queue] Duplicate message ${dedupKey}, skipping`);
|
|
49
55
|
return false;
|
|
50
56
|
}
|
|
51
|
-
this.recentMessageIds.add(
|
|
52
|
-
setTimeout(() => this.recentMessageIds.delete(
|
|
57
|
+
this.recentMessageIds.add(dedupKey);
|
|
58
|
+
setTimeout(() => this.recentMessageIds.delete(dedupKey), this.DEDUP_WINDOW);
|
|
53
59
|
return true;
|
|
54
60
|
}
|
|
55
61
|
/**
|
|
@@ -67,7 +73,7 @@ export class MessageQueue {
|
|
|
67
73
|
}
|
|
68
74
|
async enqueue(sessionKey, message, projectPath, options) {
|
|
69
75
|
// 消息去重检查
|
|
70
|
-
if (!this.shouldProcess(message)) {
|
|
76
|
+
if (!this.shouldProcess(sessionKey, message)) {
|
|
71
77
|
return Promise.resolve();
|
|
72
78
|
}
|
|
73
79
|
// 拦截器检查:AskUserQuestion 等场景的一次性消息拦截
|
|
@@ -86,8 +92,8 @@ export class MessageQueue {
|
|
|
86
92
|
if (!this.queues.has(queueKey)) {
|
|
87
93
|
this.queues.set(queueKey, []);
|
|
88
94
|
}
|
|
89
|
-
this.queues.get(queueKey)
|
|
90
|
-
|
|
95
|
+
const queue = this.queues.get(queueKey);
|
|
96
|
+
queue.push({ message, projectPath, agentName, resolve, reject });
|
|
91
97
|
if (this.processing.has(queueKey)) {
|
|
92
98
|
if (options?.interruptible !== false) {
|
|
93
99
|
// 单聊:保留中断行为
|
|
@@ -212,6 +218,7 @@ export class MessageQueue {
|
|
|
212
218
|
sameDevice: m.sameDevice, sameNetwork: m.sameNetwork, sameEgressIp: m.sameEgressIp,
|
|
213
219
|
content: m.content, timestamp: m.timestamp,
|
|
214
220
|
images: m.images && m.images.length > 0 ? m.images : undefined,
|
|
221
|
+
mentionAids: m.mentionAids && m.mentionAids.length > 0 ? m.mentionAids : undefined,
|
|
215
222
|
});
|
|
216
223
|
}
|
|
217
224
|
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// 观察者插话:待用提示(pending-hints)存储。
|
|
2
|
+
//
|
|
3
|
+
// owner 经 observer.inject 给「agent↔对端」会话预埋的提示,落盘为 append-only jsonl,
|
|
4
|
+
// 在下一条对端消息到达、渲染该轮 prompt 时一次性消费、注入渲染层。
|
|
5
|
+
// 详见 docs/observer-insert-design.md 第一部分(§1.3 文件生命周期 / §1.4 thread 作用域)。
|
|
6
|
+
//
|
|
7
|
+
// 文件:sessions/<channelType>/<selfAID>/<对端>/pending-hints.jsonl(与 messages.jsonl 同级)
|
|
8
|
+
// 每行一个事件:
|
|
9
|
+
// { action:'add', id, text, threadId, ownerAid, ts }
|
|
10
|
+
// { action:'remove', targetId?, threadId, ts } // 无 targetId = 撤该 thread 全部
|
|
11
|
+
//
|
|
12
|
+
// 生命周期 = 一个消费周期(按 (对端,thread) 维度):
|
|
13
|
+
// - add/remove 追加行(append-only,处理「加了又撤」的竞态:按时间序回放抵消)
|
|
14
|
+
// - consume(对端消息到达触发):回放算「该 thread」有效集 → 返回 → 清掉该 thread 的事件。
|
|
15
|
+
// · 若文件里其它 thread 仍有未消费提示 → 重写文件只留它们(不误删别的 thread)。
|
|
16
|
+
// · 否则(无其它 thread 残留)→ 删整个文件。
|
|
17
|
+
// - 因为 consume 一次性用掉该 thread 的全部有效提示,消费后该 thread 有效集必然归零,
|
|
18
|
+
// 未来状态不依赖消费前历史,故消费即清安全,无需 consume 事件行。
|
|
19
|
+
// - 任何让「整文件」有效集归零的操作都删文件:consume 后无残留即删;remove 把提示全撤光也删。
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { chatDirPath, appendJsonl, readAllJsonlLines } from '../session/session-fs-store.js';
|
|
23
|
+
import { logger } from '../../utils/logger.js';
|
|
24
|
+
const PENDING_HINTS_FILE = 'pending-hints.jsonl';
|
|
25
|
+
function hintsPath(sessionsDir, channelType, channelId, selfAID) {
|
|
26
|
+
return path.join(chatDirPath(sessionsDir, channelType, channelId, selfAID), PENDING_HINTS_FILE);
|
|
27
|
+
}
|
|
28
|
+
/** 归一 threadId:undefined/null → ''(主线程)。 */
|
|
29
|
+
function normThread(threadId) {
|
|
30
|
+
return threadId || '';
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 追加一条 add 事件。返回是否写盘成功(供 ack 判定)。
|
|
34
|
+
* 写盘成功后才回 ack(accepted)——accepted 真正代表"已持久保存"。
|
|
35
|
+
*/
|
|
36
|
+
export function appendHintAdd(sessionsDir, channelType, channelId, selfAID, hint) {
|
|
37
|
+
try {
|
|
38
|
+
const fp = hintsPath(sessionsDir, channelType, channelId, selfAID);
|
|
39
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
40
|
+
const ev = {
|
|
41
|
+
action: 'add', id: hint.id, text: hint.text,
|
|
42
|
+
threadId: normThread(hint.threadId), ownerAid: hint.ownerAid, ts: hint.ts,
|
|
43
|
+
};
|
|
44
|
+
appendJsonl(fp, ev);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
logger.warn(`[PendingHints] appendHintAdd failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 追加一条 remove 事件。若该操作使 (对端,thread) 有效集归零,则删除整个文件。
|
|
54
|
+
* 返回是否写盘成功(供 ack 判定)。
|
|
55
|
+
*/
|
|
56
|
+
export function appendHintRemove(sessionsDir, channelType, channelId, selfAID, rm) {
|
|
57
|
+
try {
|
|
58
|
+
const fp = hintsPath(sessionsDir, channelType, channelId, selfAID);
|
|
59
|
+
if (!fs.existsSync(fp))
|
|
60
|
+
return true; // 无文件 = 无可撤,幂等成功
|
|
61
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
62
|
+
const ev = {
|
|
63
|
+
action: 'remove', threadId: normThread(rm.threadId), ts: rm.ts,
|
|
64
|
+
...(rm.targetId ? { targetId: rm.targetId } : {}),
|
|
65
|
+
};
|
|
66
|
+
appendJsonl(fp, ev);
|
|
67
|
+
// 撤销后:若全部 thread 的有效集都归零,删文件(不堆死文件)。
|
|
68
|
+
if (!hasAnyEffective(fp))
|
|
69
|
+
deleteFile(fp);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
logger.warn(`[PendingHints] appendHintRemove failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** 回放整个文件,返回是否还存在任意 thread 的有效提示。 */
|
|
78
|
+
function hasAnyEffective(fp) {
|
|
79
|
+
const events = readAllJsonlLines(fp);
|
|
80
|
+
// 按 thread 分组回放
|
|
81
|
+
const byThread = new Map();
|
|
82
|
+
for (const ev of events) {
|
|
83
|
+
const t = normThread(ev.threadId);
|
|
84
|
+
(byThread.get(t) ?? byThread.set(t, []).get(t)).push(ev);
|
|
85
|
+
}
|
|
86
|
+
for (const [, evs] of byThread) {
|
|
87
|
+
if (replayEffective(evs).length > 0)
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/** 对单个 thread 的事件序列回放,算出有效提示集(已 add、未被 remove,按 ts 升序)。 */
|
|
93
|
+
function replayEffective(events) {
|
|
94
|
+
const sorted = events.slice().sort((a, b) => a.ts - b.ts);
|
|
95
|
+
const active = new Map(); // id → hint,保插入序
|
|
96
|
+
for (const ev of sorted) {
|
|
97
|
+
if (ev.action === 'add') {
|
|
98
|
+
active.set(ev.id, { id: ev.id, text: ev.text, ownerAid: ev.ownerAid, ts: ev.ts });
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
if (ev.targetId)
|
|
102
|
+
active.delete(ev.targetId);
|
|
103
|
+
else
|
|
104
|
+
active.clear(); // 无 targetId = 撤该 thread 全部
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return [...active.values()].sort((a, b) => a.ts - b.ts);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 消费 (对端, thread) 的有效提示:回放算「该 thread」有效集 → 返回 → 清掉该 thread。
|
|
111
|
+
*
|
|
112
|
+
* thread 隔离:只消费传入 threadId(归一后)的提示。若文件里其它 thread 仍有未消费提示,
|
|
113
|
+
* 重写文件保留它们;只有当整文件再无任何有效提示时才删除文件。
|
|
114
|
+
* 返回空数组表示该 thread 无有效提示(调用方据此决定是否注入)。
|
|
115
|
+
*/
|
|
116
|
+
export function consumeHints(sessionsDir, channelType, channelId, selfAID, threadId) {
|
|
117
|
+
const fp = hintsPath(sessionsDir, channelType, channelId, selfAID);
|
|
118
|
+
if (!fs.existsSync(fp))
|
|
119
|
+
return [];
|
|
120
|
+
let all;
|
|
121
|
+
try {
|
|
122
|
+
all = readAllJsonlLines(fp);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
logger.warn(`[PendingHints] consume read failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const wantThread = normThread(threadId);
|
|
129
|
+
const sameThread = all.filter(ev => normThread(ev.threadId) === wantThread);
|
|
130
|
+
const effective = replayEffective(sameThread);
|
|
131
|
+
// 其它 thread 仍有未消费提示?若有,重写文件只留它们;否则删整个文件。
|
|
132
|
+
const otherThreadEvents = all.filter(ev => normThread(ev.threadId) !== wantThread);
|
|
133
|
+
if (otherThreadEvents.length > 0 && hasEffectiveInEvents(otherThreadEvents)) {
|
|
134
|
+
rewriteFile(fp, otherThreadEvents);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
deleteFile(fp);
|
|
138
|
+
}
|
|
139
|
+
return effective;
|
|
140
|
+
}
|
|
141
|
+
/** events(可能跨多 thread)中是否存在任意有效提示。 */
|
|
142
|
+
function hasEffectiveInEvents(events) {
|
|
143
|
+
const byThread = new Map();
|
|
144
|
+
for (const ev of events) {
|
|
145
|
+
const t = normThread(ev.threadId);
|
|
146
|
+
(byThread.get(t) ?? byThread.set(t, []).get(t)).push(ev);
|
|
147
|
+
}
|
|
148
|
+
for (const [, evs] of byThread) {
|
|
149
|
+
if (replayEffective(evs).length > 0)
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
function rewriteFile(fp, events) {
|
|
155
|
+
try {
|
|
156
|
+
const body = events.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
157
|
+
fs.writeFileSync(fp, body);
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
logger.warn(`[PendingHints] rewriteFile failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function deleteFile(fp) {
|
|
164
|
+
try {
|
|
165
|
+
if (fs.existsSync(fp))
|
|
166
|
+
fs.unlinkSync(fp);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
logger.warn(`[PendingHints] deleteFile failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** 仅供 watch / 调试:读出当前 (对端,thread) 有效提示,不消费、不删文件。 */
|
|
173
|
+
export function peekHints(sessionsDir, channelType, channelId, selfAID, threadId) {
|
|
174
|
+
const fp = hintsPath(sessionsDir, channelType, channelId, selfAID);
|
|
175
|
+
if (!fs.existsSync(fp))
|
|
176
|
+
return [];
|
|
177
|
+
try {
|
|
178
|
+
const all = readAllJsonlLines(fp);
|
|
179
|
+
const wantThread = normThread(threadId);
|
|
180
|
+
return replayEffective(all.filter(ev => normThread(ev.threadId) === wantThread));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// ── 渲染接线纯函数(无 IO,便于单测) ──────────────────────────
|
|
187
|
+
/** 把有效提示转成 owner-hint SubMessage(排在对端真实 item 之前,走 inject 渲染模式)。 */
|
|
188
|
+
export function hintsToSubMessages(hints) {
|
|
189
|
+
return hints.map(h => ({
|
|
190
|
+
kind: 'owner-hint',
|
|
191
|
+
content: h.text,
|
|
192
|
+
ownerAid: h.ownerAid,
|
|
193
|
+
injectTime: h.ts,
|
|
194
|
+
timestamp: h.ts,
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* 渲染失败兜底:把已消费的 owner-hint(提示已从 pending 删除、不可恢复)以纯文本前缀
|
|
199
|
+
* 拼到对端原文之前,避免提示被静默丢弃。仅在 renderMessageBody 抛错或产出空时使用。
|
|
200
|
+
*/
|
|
201
|
+
export function composeHintFallback(hintItems, content) {
|
|
202
|
+
if (!hintItems || hintItems.length === 0)
|
|
203
|
+
return content;
|
|
204
|
+
const lines = hintItems.map(h => `‹owner 提示·已验证›(仅你可见,对端无感)\n${h.content}`);
|
|
205
|
+
return [...lines, content].join('\n\n');
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* 解析 observer.inject payload:鉴权(from∈owners)+ 校验(add 需 channel_id+text;remove 需 channel_id)
|
|
209
|
+
* + 归一字段。纯函数,无副作用——落盘 / ack / watch 由调用方按结果执行。
|
|
210
|
+
* @param ts 用于 add 缺省 id(`inj-<ts>`);由调用方传入便于测试确定性。
|
|
211
|
+
*/
|
|
212
|
+
export function parseInjectRequest(payload, fromAid, owners, ts) {
|
|
213
|
+
const p = (payload && typeof payload === 'object') ? payload : {};
|
|
214
|
+
const injectId = typeof p.id === 'string' ? p.id : undefined;
|
|
215
|
+
const action = p.action === 'remove' ? 'remove' : 'add';
|
|
216
|
+
if (!owners.includes(fromAid)) {
|
|
217
|
+
return { kind: 'reject', code: 'NOT_OWNER', message: '仅 owner 可插话', action, injectId };
|
|
218
|
+
}
|
|
219
|
+
const target = (p.target && typeof p.target === 'object') ? p.target : undefined;
|
|
220
|
+
const channelId = target && typeof target.channel_id === 'string' ? target.channel_id : undefined;
|
|
221
|
+
const text = typeof p.text === 'string' ? p.text : '';
|
|
222
|
+
if (!channelId || (action === 'add' && !text.trim())) {
|
|
223
|
+
return { kind: 'reject', code: 'INVALID_TARGET', message: 'target.channel_id 必填;add 还需 text', action, injectId };
|
|
224
|
+
}
|
|
225
|
+
const chatType = target?.chat_type === 'group' ? 'group' : 'private';
|
|
226
|
+
const threadId = typeof target?.thread_id === 'string' ? target.thread_id : undefined;
|
|
227
|
+
const targetId = typeof p.target_id === 'string' ? p.target_id : undefined;
|
|
228
|
+
if (action === 'remove') {
|
|
229
|
+
return { kind: 'remove', injectId, channelId, chatType, threadId, targetId };
|
|
230
|
+
}
|
|
231
|
+
return { kind: 'add', injectId, id: injectId || `inj-${ts}`, text, channelId, chatType, threadId, ownerAid: fromAid };
|
|
232
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
/**
|
|
3
|
+
* 计算消息内容的话题指纹(前 20 字符的 md5 前 8 位)。
|
|
4
|
+
*/
|
|
5
|
+
export function computeTopicHash(content) {
|
|
6
|
+
const slice = content.trim().slice(0, 20);
|
|
7
|
+
return crypto.createHash('md5').update(slice).digest('hex').slice(0, 8);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 群聊响应深度决策(纯函数,无 I/O)。
|
|
11
|
+
*
|
|
12
|
+
* 根据 dispatch 模式、消息特征(长度/是否问句/是否被@)、话题轮次综合判断。
|
|
13
|
+
* 返回 depth 枚举 + 更新后的话题追踪状态(调用方负责持久化)。
|
|
14
|
+
*/
|
|
15
|
+
export function resolveResponseDepth(input) {
|
|
16
|
+
const { chatType, content, selfAid, mentionAids, dispatch, topicRounds: prevRounds, lastTopicHash } = input;
|
|
17
|
+
// 仅群聊走深度决策;私聊一律 standard
|
|
18
|
+
if (chatType !== 'group') {
|
|
19
|
+
return { depth: 'standard', topicRounds: prevRounds, topicHash: lastTopicHash || '' };
|
|
20
|
+
}
|
|
21
|
+
const trimmed = content.trim();
|
|
22
|
+
// ── 话题追踪:更新 topicRounds ──
|
|
23
|
+
const topicHash = computeTopicHash(trimmed);
|
|
24
|
+
let topicRounds;
|
|
25
|
+
if (lastTopicHash && lastTopicHash === topicHash) {
|
|
26
|
+
topicRounds = prevRounds + 1;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
topicRounds = 1;
|
|
30
|
+
}
|
|
31
|
+
// ── 判断因子 ──
|
|
32
|
+
const isMentioned = !!(selfAid && mentionAids?.includes(selfAid));
|
|
33
|
+
const isQuestion = /[??]\s*$/.test(trimmed) || /^(what|how|why|when|where|who|which|请问|怎么|为什么|什么|如何|能不能|可以)/i.test(trimmed);
|
|
34
|
+
const isShort = trimmed.length <= 30;
|
|
35
|
+
// ── 决策逻辑 ──
|
|
36
|
+
// 被@:至少 standard;话题深入则升 deep
|
|
37
|
+
if (isMentioned) {
|
|
38
|
+
const depth = topicRounds >= 3 ? 'deep' : 'standard';
|
|
39
|
+
return { depth, topicRounds, topicHash };
|
|
40
|
+
}
|
|
41
|
+
// broadcast 模式:短消息 + 非问句 → lightweight
|
|
42
|
+
if (dispatch === 'broadcast') {
|
|
43
|
+
let depth;
|
|
44
|
+
if (topicRounds >= 3)
|
|
45
|
+
depth = 'deep';
|
|
46
|
+
else if (isShort && !isQuestion)
|
|
47
|
+
depth = 'lightweight';
|
|
48
|
+
else
|
|
49
|
+
depth = 'standard';
|
|
50
|
+
return { depth, topicRounds, topicHash };
|
|
51
|
+
}
|
|
52
|
+
// mention 模式下到达这里说明已通过 dispatch 过滤(被@才入队),
|
|
53
|
+
// 等同于 isMentioned === true 的分支。兜底 standard。
|
|
54
|
+
const depth = topicRounds >= 3 ? 'deep' : 'standard';
|
|
55
|
+
return { depth, topicRounds, topicHash };
|
|
56
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 详见 docs/model-command-design.md。
|
|
11
11
|
*/
|
|
12
12
|
import { loadDefaults, loadAgent } from '../../config-store.js';
|
|
13
|
-
import { resolveAnthropicConfig, resolveOpenaiConfig } from '../../agents/
|
|
13
|
+
import { resolveAnthropicConfig, resolveOpenaiConfig } from '../../agents/baseagent.js';
|
|
14
14
|
import { activeBaseagent } from './model-scope.js';
|
|
15
15
|
/** 取指定 baseagent 的 baseUrl + apiKey(复用现有解析链)。 */
|
|
16
16
|
function resolveCreds(self, ba) {
|
|
@@ -16,9 +16,42 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import fs from 'fs';
|
|
18
18
|
import path from 'path';
|
|
19
|
-
import { agentRelationsDir } from '../../paths.js';
|
|
19
|
+
import { agentRelationsDir, agentConfig as agentConfigPath, resolvePaths } from '../../paths.js';
|
|
20
20
|
import { loadDefaults, saveDefaultsSafe, loadAgent, saveAgent } from '../../config-store.js';
|
|
21
|
-
import { formatPeerKey, parsePeerKey } from '../relation/peer-
|
|
21
|
+
import { formatPeerKey, parsePeerKey } from '../relation/peer-identity.js';
|
|
22
|
+
import { fileCache } from '../daemon-file-cache.js';
|
|
23
|
+
// ── mtime 门控缓存(统一走 FileCache)─────────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// resolveEffectiveModel 每条消息按 关系>agent>全局 解析,原本每次都
|
|
26
|
+
// loadAgent()/loadDefaults()/读 preferences.json —— 一条消息最多读盘 5 次。
|
|
27
|
+
//
|
|
28
|
+
// model-scope 被 CLI 子进程与 daemon 共用,CLI 改文件后 daemon 无失效通知,
|
|
29
|
+
// 故靠 mtime 门控:每次只 statSync 比对 mtime,未变用缓存,变了才真正重读 +
|
|
30
|
+
// 重解析。跨进程天然正确(文件 mtime 变即感知),改配置即时生效不变;
|
|
31
|
+
// statSync 远比 read+JSON.parse 便宜。CLI 进程的 fileCache 是独立空实例、随进程
|
|
32
|
+
// 退出,等同直读最新盘值,安全。
|
|
33
|
+
//
|
|
34
|
+
// config/defaults 与 relation-prefs 三者统一走 daemon 单例 FileCache(消除原先
|
|
35
|
+
// 第二套 makeMtimeCache),读取计数一并进监控。config/defaults 的实际读盘 + 解析
|
|
36
|
+
// 仍委托原 loadAgent/loadDefaults(保留 atomicRead 崩溃恢复 + expandEnvRefs/校验),
|
|
37
|
+
// 故传 noopRead 让 FileCache 只做 mtime 门控、不重复读盘(loader 忽略 raw)。
|
|
38
|
+
/** FileCache 的 read 钩子占位:config/defaults 的真实读盘在 loader 里(loadAgent/
|
|
39
|
+
* loadDefaults,含崩溃恢复),此处返回 null 避免 FileCache 再读一遍。 */
|
|
40
|
+
const noopRead = () => null;
|
|
41
|
+
// agent config.json:mtime 门控,per-agent 分组(config:<aid>)便于 per-agent 监控视图。
|
|
42
|
+
const loadAgentCached = (self) => fileCache.get(agentConfigPath(self), () => loadAgent(self), { policy: 'mtime', group: `config:${self}`, read: noopRead });
|
|
43
|
+
// defaults.json:单文件,mtime 门控。
|
|
44
|
+
const loadDefaultsCached = () => fileCache.get(resolvePaths().defaultsConfig, () => loadDefaults(), { policy: 'mtime', group: 'config', read: noopRead });
|
|
45
|
+
// 关系级 preferences.json —— 走统一 FileCache(mtime 门控,带外改 + 不 reload)。
|
|
46
|
+
const readPrefsCached = (file) => fileCache.get(file, (raw) => (raw === null ? null : safeParsePrefs(raw)), { policy: 'mtime', group: 'relation-prefs' });
|
|
47
|
+
function safeParsePrefs(raw) {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(raw);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
22
55
|
export class ModelScopeError extends Error {
|
|
23
56
|
code;
|
|
24
57
|
constructor(code, message) {
|
|
@@ -76,11 +109,11 @@ export function determineScope(sel) {
|
|
|
76
109
|
export function activeBaseagent(self) {
|
|
77
110
|
try {
|
|
78
111
|
if (self) {
|
|
79
|
-
const cfg =
|
|
112
|
+
const cfg = loadAgentCached(self);
|
|
80
113
|
if (cfg?.active_baseagent)
|
|
81
114
|
return cfg.active_baseagent;
|
|
82
115
|
}
|
|
83
|
-
const d =
|
|
116
|
+
const d = loadDefaultsCached();
|
|
84
117
|
if (d?.active_baseagent)
|
|
85
118
|
return d.active_baseagent;
|
|
86
119
|
}
|
|
@@ -114,17 +147,17 @@ function writeJsonAtomic(file, data) {
|
|
|
114
147
|
export function readScope(scope, sel, ba) {
|
|
115
148
|
switch (scope) {
|
|
116
149
|
case 'global': {
|
|
117
|
-
const block = (
|
|
150
|
+
const block = (loadDefaultsCached()?.baseagents || {});
|
|
118
151
|
const c = block[ba] || {};
|
|
119
152
|
return { model: c.model, effort: c[effortField(ba)] };
|
|
120
153
|
}
|
|
121
154
|
case 'agent': {
|
|
122
|
-
const cfg = sel.self ?
|
|
155
|
+
const cfg = sel.self ? loadAgentCached(sel.self) : null;
|
|
123
156
|
const c = (cfg?.baseagents || {})[ba] || {};
|
|
124
157
|
return { model: c.model, effort: c[effortField(ba)] };
|
|
125
158
|
}
|
|
126
159
|
case 'relation': {
|
|
127
|
-
const p =
|
|
160
|
+
const p = readPrefsCached(relationPrefsPath(sel.self, sel.peerKey));
|
|
128
161
|
return { model: p?.model, effort: p?.effort };
|
|
129
162
|
}
|
|
130
163
|
}
|
package/dist/core/permission.js
CHANGED
|
@@ -217,7 +217,6 @@ function formatEditSummary(input) {
|
|
|
217
217
|
}
|
|
218
218
|
export class PermissionGateway {
|
|
219
219
|
pending = new Map();
|
|
220
|
-
timeout = 5 * 60 * 1000;
|
|
221
220
|
eventBus;
|
|
222
221
|
/** 始终允许的工具缓存:toolName → Set<pattern> */
|
|
223
222
|
alwaysAllow = new Map();
|
|
@@ -283,6 +282,14 @@ export class PermissionGateway {
|
|
|
283
282
|
};
|
|
284
283
|
// 尝试富交互(走统一 adapter.send 入口)
|
|
285
284
|
let interactionSent = false;
|
|
285
|
+
if (context?.flushPending) {
|
|
286
|
+
try {
|
|
287
|
+
await context.flushPending();
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// flush 失败不应阻断权限请求发送
|
|
291
|
+
}
|
|
292
|
+
}
|
|
286
293
|
if (context?.adapter && context.channelId) {
|
|
287
294
|
try {
|
|
288
295
|
const envelope = buildEnvelope({
|
|
@@ -306,15 +313,7 @@ export class PermissionGateway {
|
|
|
306
313
|
await sendPrompt(renderActionAsText(interaction));
|
|
307
314
|
}
|
|
308
315
|
return new Promise((resolve) => {
|
|
309
|
-
|
|
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 });
|
|
316
|
+
this.pending.set(requestId, { sessionId, toolName, resolve });
|
|
318
317
|
// 注册到 InteractionRouter(卡片和文本降级都注册,统一路由)
|
|
319
318
|
if (context?.interactionRouter) {
|
|
320
319
|
context.interactionRouter.register(requestId, sessionId, (action) => {
|
|
@@ -327,7 +326,6 @@ export class PermissionGateway {
|
|
|
327
326
|
const pending = this.pending.get(requestId);
|
|
328
327
|
if (!pending || pending.sessionId !== sessionId)
|
|
329
328
|
return false;
|
|
330
|
-
clearTimeout(pending.timer);
|
|
331
329
|
// 如果是 always,缓存该工具
|
|
332
330
|
if (decision === 'always') {
|
|
333
331
|
this.addAlwaysAllow(pending.toolName);
|
|
@@ -341,7 +339,6 @@ export class PermissionGateway {
|
|
|
341
339
|
cancelAll(sessionId) {
|
|
342
340
|
for (const [requestId, pending] of this.pending.entries()) {
|
|
343
341
|
if (pending.sessionId === sessionId) {
|
|
344
|
-
clearTimeout(pending.timer);
|
|
345
342
|
pending.resolve('deny');
|
|
346
343
|
this.pending.delete(requestId);
|
|
347
344
|
}
|
|
@@ -15,7 +15,22 @@ import * as path from 'path';
|
|
|
15
15
|
import * as crypto from 'crypto';
|
|
16
16
|
import { logger } from '../../utils/logger.js';
|
|
17
17
|
import { agentMdPath } from '../../paths.js';
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* peerKey: 关系层路由键,格式 `<channelType>#<urlEncode(channelId)>`。
|
|
20
|
+
* 群聊场景下 channelId = groupId,所有发言者共用同一个 peerKey。
|
|
21
|
+
*/
|
|
22
|
+
export function formatPeerKey(channelType, channelId) {
|
|
23
|
+
return `${channelType}#${encodeURIComponent(channelId)}`;
|
|
24
|
+
}
|
|
25
|
+
export function parsePeerKey(key) {
|
|
26
|
+
const idx = key.indexOf('#');
|
|
27
|
+
if (idx <= 0)
|
|
28
|
+
throw new Error(`Invalid peer key: ${key}`);
|
|
29
|
+
return {
|
|
30
|
+
channelType: key.slice(0, idx),
|
|
31
|
+
channelId: decodeURIComponent(key.slice(idx + 1)),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
19
34
|
/**
|
|
20
35
|
* 对端身份缓存管理器
|
|
21
36
|
*/
|
|
@@ -95,24 +95,67 @@ export class ClaudeSessionFileAdapter {
|
|
|
95
95
|
}
|
|
96
96
|
return null;
|
|
97
97
|
}
|
|
98
|
+
// CLI 会话白名单:只有真正由人手发起的终端会话才值得导入。
|
|
99
|
+
// 其它 entrypoint(sdk-ts=EvolClaw 自身、sdk-py=security-guidance 等插件的后台 SDK 会话)
|
|
100
|
+
// 同样把 JSONL 写进项目目录,但不是用户会话,导入它们没有意义。
|
|
101
|
+
static CLI_ENTRYPOINTS = new Set(['cli', 'sdk-cli']);
|
|
102
|
+
// entrypoint 不可变,进程内缓存 filePath→entrypoint,避免重复读文件头部
|
|
103
|
+
entrypointCache = new Map();
|
|
104
|
+
// entrypoint 字段位于首个 user 事件行(实测恒在前 4 行内,cli 会话首次出现 < 8KB)。
|
|
105
|
+
// 插件会话首行 queue-operation 可能携带巨大 diff(数百 KB),把 entrypoint 推到很后面,
|
|
106
|
+
// 因此读取上限设为 32KB:cli 会话必命中,插件会话要么命中 sdk-py、要么读不到 → 一律排除。
|
|
107
|
+
static ENTRYPOINT_SCAN_BYTES = 32 * 1024;
|
|
108
|
+
/** 读取会话文件的 entrypoint,结果缓存在实例内(entrypoint 不可变,无需失效)。 */
|
|
109
|
+
readEntrypoint(filePath) {
|
|
110
|
+
if (this.entrypointCache.has(filePath))
|
|
111
|
+
return this.entrypointCache.get(filePath);
|
|
112
|
+
let fd;
|
|
113
|
+
let result = null;
|
|
114
|
+
try {
|
|
115
|
+
fd = fs.openSync(filePath, 'r');
|
|
116
|
+
const buf = Buffer.allocUnsafe(ClaudeSessionFileAdapter.ENTRYPOINT_SCAN_BYTES);
|
|
117
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
118
|
+
const head = buf.toString('utf-8', 0, bytesRead);
|
|
119
|
+
const m = head.match(/"entrypoint":"([^"]*)"/);
|
|
120
|
+
result = m ? m[1] : null;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// keep null
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
if (fd !== undefined)
|
|
127
|
+
fs.closeSync(fd);
|
|
128
|
+
}
|
|
129
|
+
this.entrypointCache.set(filePath, result);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
98
132
|
scanCliSessions(projectPath) {
|
|
99
133
|
const homeDir = os.homedir();
|
|
100
134
|
const encodedPath = encodePath(projectPath);
|
|
101
135
|
const sessionDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
102
136
|
if (!fs.existsSync(sessionDir))
|
|
103
137
|
return [];
|
|
104
|
-
const
|
|
138
|
+
const candidates = fs.readdirSync(sessionDir)
|
|
105
139
|
.filter(f => f.endsWith('.jsonl'))
|
|
106
140
|
.filter(f => !f.startsWith('agent-'))
|
|
107
141
|
.map(f => {
|
|
108
142
|
const filePath = path.join(sessionDir, f);
|
|
109
143
|
const stat = fs.statSync(filePath);
|
|
110
|
-
return { uuid: f.replace('.jsonl', ''), mtime: stat.mtimeMs, size: stat.size };
|
|
144
|
+
return { uuid: f.replace('.jsonl', ''), filePath, mtime: stat.mtimeMs, size: stat.size };
|
|
111
145
|
})
|
|
112
146
|
.filter(f => f.size > 0)
|
|
113
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
114
|
-
|
|
115
|
-
|
|
147
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
148
|
+
// 按 mtime 降序惰性判定 entrypoint,凑够 10 个白名单会话即停,避免读取全部文件。
|
|
149
|
+
const result = [];
|
|
150
|
+
for (const f of candidates) {
|
|
151
|
+
const entrypoint = this.readEntrypoint(f.filePath);
|
|
152
|
+
if (entrypoint && ClaudeSessionFileAdapter.CLI_ENTRYPOINTS.has(entrypoint)) {
|
|
153
|
+
result.push({ uuid: f.uuid, mtime: f.mtime });
|
|
154
|
+
if (result.length >= 10)
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
116
159
|
}
|
|
117
160
|
async listSdkSessions(projectPath) {
|
|
118
161
|
try {
|
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
* 会返回空数组。
|
|
11
11
|
*/
|
|
12
12
|
import { createRequire } from 'module';
|
|
13
|
+
import { sanitizeSessionTitle } from '../session-title.js';
|
|
13
14
|
import { logger } from '../../../utils/logger.js';
|
|
14
15
|
import path from 'path';
|
|
15
16
|
import fs from 'fs';
|
|
16
17
|
import os from 'os';
|
|
17
18
|
const requireFromHere = createRequire(import.meta.url);
|
|
18
19
|
let sqliteModule; // undefined = not tried, null = unavailable
|
|
20
|
+
export const sanitizeCodexThreadTitle = sanitizeSessionTitle;
|
|
19
21
|
function loadSqlite() {
|
|
20
22
|
if (sqliteModule !== undefined)
|
|
21
23
|
return sqliteModule;
|
|
@@ -127,7 +129,7 @@ export class CodexSessionFileAdapter {
|
|
|
127
129
|
if (row) {
|
|
128
130
|
return {
|
|
129
131
|
turns: this.countTurnsFromRollout(row.rollout_path),
|
|
130
|
-
title: row.title
|
|
132
|
+
title: sanitizeCodexThreadTitle(row.title),
|
|
131
133
|
};
|
|
132
134
|
}
|
|
133
135
|
}
|
|
@@ -210,7 +212,7 @@ export class CodexSessionFileAdapter {
|
|
|
210
212
|
const rows = db.prepare('SELECT id, title FROM threads WHERE cwd = ? AND archived = 0 ORDER BY updated_at DESC').all(projectPath);
|
|
211
213
|
return rows.map(r => ({
|
|
212
214
|
sessionId: r.id,
|
|
213
|
-
title: r.title
|
|
215
|
+
title: sanitizeCodexThreadTitle(r.title),
|
|
214
216
|
}));
|
|
215
217
|
}
|
|
216
218
|
catch (error) {
|