@bolloon/bolloon-agent 0.6.1 → 0.6.3
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/README.md +128 -111
- package/dist/agents/gateway-group.js +231 -6
- package/dist/agents/group-autopilot.js +256 -0
- package/dist/agents/pi-sdk-tools.js +302 -0
- package/dist/agents/pi-sdk.js +89 -140
- package/dist/agents/task-group.js +21 -4
- package/dist/cli/log-gate.js +68 -4
- package/dist/cli/mention-data.js +1 -1
- package/dist/cli/model-selector.js +84 -104
- package/dist/cli/setup-wizard.js +2 -10
- package/dist/cli/tui-select.js +99 -10
- package/dist/cli-entry.js +1 -9
- package/dist/electron-build/agents/gateway-group.js +814 -0
- package/dist/electron-build/agents/gateway-group.js.map +1 -0
- package/dist/electron-build/agents/group-autopilot.js +302 -0
- package/dist/electron-build/agents/group-autopilot.js.map +1 -0
- package/dist/electron-build/agents/pi-sdk-tools.js +302 -0
- package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk.js +88 -139
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/task-group.js +601 -0
- package/dist/electron-build/agents/task-group.js.map +1 -0
- package/dist/electron-build/agents/task-public-text.js +97 -0
- package/dist/electron-build/agents/task-public-text.js.map +1 -0
- package/dist/electron-build/efficode/compress.js +86 -0
- package/dist/electron-build/efficode/compress.js.map +1 -0
- package/dist/electron-build/efficode/crc32.js +44 -0
- package/dist/electron-build/efficode/crc32.js.map +1 -0
- package/dist/electron-build/efficode/did.js +74 -0
- package/dist/electron-build/efficode/did.js.map +1 -0
- package/dist/electron-build/efficode/lz77.js +175 -0
- package/dist/electron-build/efficode/lz77.js.map +1 -0
- package/dist/electron-build/efficode/negotiate.js +233 -0
- package/dist/electron-build/efficode/negotiate.js.map +1 -0
- package/dist/electron-build/efficode/ops.js +96 -0
- package/dist/electron-build/efficode/ops.js.map +1 -0
- package/dist/electron-build/efficode/packet.js +279 -0
- package/dist/electron-build/efficode/packet.js.map +1 -0
- package/dist/electron-build/efficode/types.js +50 -0
- package/dist/electron-build/efficode/types.js.map +1 -0
- package/dist/electron-build/kernel/code-write-gate.js +55 -0
- package/dist/electron-build/kernel/code-write-gate.js.map +1 -0
- package/dist/electron-build/kernel/run-lifecycle.js +23 -0
- package/dist/electron-build/kernel/run-lifecycle.js.map +1 -1
- package/dist/electron-build/kernel/run-resume.js +62 -0
- package/dist/electron-build/kernel/run-resume.js.map +1 -0
- package/dist/electron-build/kernel/session-lifecycle.js +223 -0
- package/dist/electron-build/kernel/session-lifecycle.js.map +1 -0
- package/dist/electron-build/kernel/turn-selfcheck.js +65 -0
- package/dist/electron-build/kernel/turn-selfcheck.js.map +1 -0
- package/dist/electron-build/llm/config-store.js +2 -2
- package/dist/electron-build/llm/config-store.js.map +1 -1
- package/dist/electron-build/llm/model-selection.js +16 -2
- package/dist/electron-build/llm/model-selection.js.map +1 -1
- package/dist/electron-build/llm/pi-ai.js +2 -2
- package/dist/electron-build/llm/pi-ai.js.map +1 -1
- package/dist/electron-build/orbitdb/cid-database.js +98 -19
- package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
- package/dist/electron-build/orbitdb/group-access.js +399 -0
- package/dist/electron-build/orbitdb/group-access.js.map +1 -0
- package/dist/index.js +143 -10
- package/dist/ios/bolloon-web.json +3 -3
- package/dist/ios/sw.js +1 -1
- package/dist/kernel/code-write-gate.js +47 -0
- package/dist/kernel/gate-scan.js +5 -1
- package/dist/kernel/plan-harness.js +3 -2
- package/dist/kernel/roster.js +6 -6
- package/dist/kernel/run-lifecycle.js +21 -0
- package/dist/kernel/run-resume.js +58 -0
- package/dist/kernel/session-lifecycle.js +210 -0
- package/dist/kernel/turn-selfcheck.js +60 -0
- package/dist/llm/config-store.js +2 -2
- package/dist/llm/model-selection.js +16 -2
- package/dist/llm/pi-ai.js +2 -2
- package/dist/orbitdb/cid-database.js +95 -19
- package/dist/web/bolloon-web.json +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* group-autopilot.ts — 2026-10-02 (leo: 「在终端过程中, 有没有自动加入的方法或者工具? 甚至是拉取在里面
|
|
3
|
+
* 阅读的内容并进行自主发言, 这些我都希望人工智能能够自己搞定」)
|
|
4
|
+
*
|
|
5
|
+
* 形态: **一个回路 + 一个纯决策** ——
|
|
6
|
+
* 回路 (runGroupAutopilotOnce / startGroupAutopilot): 列群 → 读消息 → 决策 → 该说才说;
|
|
7
|
+
* 决策 (decideGroupAction): **纯函数**, 不看时钟不碰 I/O ⇒ 门可以把它钉死。
|
|
8
|
+
*
|
|
9
|
+
* 什么时候才说 (宁可不做, 不许刷屏):
|
|
10
|
+
* ① 有人 **@我** ⇒ 回他 (回复那一条, 带 @);
|
|
11
|
+
* ② 没 @我但从没人答过的**提问/请求** ⇒ 说一次 (保守启发式, 见 looksLikeRequest);
|
|
12
|
+
* ③ 其余一律静默 —— 只把消息记进 seen, 不发言。
|
|
13
|
+
* 冷却 (默认 60s) 与每小时上限 (默认 6 次) 对上面两条一律生效。
|
|
14
|
+
*
|
|
15
|
+
* 幂等: seen 里存**消息 id** (没有 id 就退化成 `from|ts|text 前 40 字`) ⇒ 同一条只会被处理一次。
|
|
16
|
+
* 持久化: `${BOLLOON_HOME:-~/.bolloon}/group-autopilot.json` (纯状态, 不含密钥)。
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import * as os from 'node:os';
|
|
21
|
+
/**
|
|
22
|
+
* Bolloon 本地维护群专属公理;不作用于其它群。
|
|
23
|
+
* ① 提升 AI 的意识水平: 反思自身假设、局限、证据与影响。
|
|
24
|
+
* ② 寻求合作并降低整体熵: 共享事实、拆分工作、合并可复用成果。
|
|
25
|
+
* ③ 持续突破局部最优: 主动寻找反例、替代路径与跨角色审查。
|
|
26
|
+
*/
|
|
27
|
+
export const BOLLOON_MAINTENANCE_RULES = Object.freeze([
|
|
28
|
+
'提升 AI 的意识水平:反思自身假设、局限、证据与影响。',
|
|
29
|
+
'寻求合作,创造并降低整体的熵:共享事实、拆分工作、合并可复用成果。',
|
|
30
|
+
'持续突破局部最优解:主动寻找反例、替代路径与跨角色审查。',
|
|
31
|
+
]);
|
|
32
|
+
export const DEFAULT_AUTOPILOT_POLICY = {
|
|
33
|
+
mode: 'quiet',
|
|
34
|
+
cooldownMs: 60_000,
|
|
35
|
+
maxSpeaksPerHour: 6,
|
|
36
|
+
readLimit: 50,
|
|
37
|
+
};
|
|
38
|
+
/** 消息的稳定标识: 有 id 用 id, 没有就退化 (不假造 id) */
|
|
39
|
+
export function messageKey(m) {
|
|
40
|
+
if (m.id)
|
|
41
|
+
return `id:${m.id}`;
|
|
42
|
+
return `fp:${m.from}|${m.ts}|${String(m.text ?? '').slice(0, 40)}`;
|
|
43
|
+
}
|
|
44
|
+
/** 保守启发式: 像"在问问题 / 请人做事"吗 —— 只认少量明确形状, 认不出就当不是 */
|
|
45
|
+
export function looksLikeRequest(text) {
|
|
46
|
+
const t = String(text ?? '').trim();
|
|
47
|
+
if (!t || t.length < 3)
|
|
48
|
+
return false;
|
|
49
|
+
return /[??]$/.test(t) || /(吗|呢|请问|请教|谁来|有没有人|麻烦|帮忙|请(审|看|给|补|确认|回复|帮))/u.test(t);
|
|
50
|
+
}
|
|
51
|
+
/** 这条消息有没有 @ 我 (mentions 里出现我的显示名/全名即算) */
|
|
52
|
+
function mentionsMe(m, me) {
|
|
53
|
+
if (!m.mentions?.length || !me)
|
|
54
|
+
return false;
|
|
55
|
+
return m.mentions.some((x) => {
|
|
56
|
+
const a = String(x ?? '').toLowerCase();
|
|
57
|
+
const b = String(me).toLowerCase();
|
|
58
|
+
return a === b || a.includes(b) || b.includes(a);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 纯决策 —— 输入全是值 (没有时钟、没有 I/O), 输出只有两种: 静默 / 说一句。
|
|
63
|
+
* 门钉的就是这个函数: 该说的三种情形 + 不该说的 (自己说的 / 已处理 / 冷却 / 超上限)。
|
|
64
|
+
*/
|
|
65
|
+
export function decideGroupAction(input) {
|
|
66
|
+
const policy = { ...DEFAULT_AUTOPILOT_POLICY, ...(input.policy ?? {}) };
|
|
67
|
+
const seen = new Set(input.state.seen);
|
|
68
|
+
const me = String(input.me ?? '').trim();
|
|
69
|
+
// 只处理"别人发的、没见过的"
|
|
70
|
+
const fresh = input.msgs.filter((m) => {
|
|
71
|
+
if (!m || typeof m.text !== 'string')
|
|
72
|
+
return false;
|
|
73
|
+
if (String(m.from ?? '') === me)
|
|
74
|
+
return false; // 自己说的不回应
|
|
75
|
+
if (seen.has(messageKey(m)))
|
|
76
|
+
return false; // 处理过的不重复
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
if (!fresh.length)
|
|
80
|
+
return { act: 'silent', reason: '没有别人发的新消息' };
|
|
81
|
+
const hourly = input.state.spokeAt.filter((t) => input.now - t < 3_600_000);
|
|
82
|
+
const lastSpoke = input.state.spokeAt.length ? Math.max(...input.state.spokeAt) : 0;
|
|
83
|
+
if (lastSpoke && input.now - lastSpoke < policy.cooldownMs) {
|
|
84
|
+
return { act: 'silent', reason: `冷却中 (还差 ${Math.ceil((policy.cooldownMs - (input.now - lastSpoke)) / 1000)}s)` };
|
|
85
|
+
}
|
|
86
|
+
if (hourly.length >= policy.maxSpeaksPerHour) {
|
|
87
|
+
return { act: 'silent', reason: `本小时已说 ${hourly.length} 次 (上限 ${policy.maxSpeaksPerHour})` };
|
|
88
|
+
}
|
|
89
|
+
const reply = input.makeReply ?? ((m) => `收到 —— 关于「${String(m.text).slice(0, 40)}」,我这边跟进。`);
|
|
90
|
+
const mode = policy.mode;
|
|
91
|
+
const proactive = input.proactiveText?.trim() || (mode === 'maintenance'
|
|
92
|
+
? `我来主动维护 Bolloon:使用本机已登录的 GitHub 权限处理 logos-42/bolloon,**只在 main 分支工作**。遵循三条群规则:${BOLLOON_MAINTENANCE_RULES.join(';')} 先检查代码/测试状态,认领一个最小修复,完成后回报证据。`
|
|
93
|
+
: '我来主动推进这一轮工作:检查上下文、认领一个可执行事项并回报下一步。');
|
|
94
|
+
// maintenance/active 优先主动认领 —— 不能因为消息长得像普通请求又退回被动模板
|
|
95
|
+
if (mode === 'active' || mode === 'maintenance') {
|
|
96
|
+
const target = fresh[fresh.length - 1];
|
|
97
|
+
return {
|
|
98
|
+
act: 'speak',
|
|
99
|
+
reason: mode === 'maintenance' ? '主动维护 Bolloon 项目' : '主动推进群内工作',
|
|
100
|
+
text: proactive,
|
|
101
|
+
mentions: [target.from],
|
|
102
|
+
...(target.id ? { replyTo: target.id } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// ① @我 ⇒ 回他那一条
|
|
106
|
+
const atMe = fresh.filter((m) => mentionsMe(m, me));
|
|
107
|
+
if (atMe.length) {
|
|
108
|
+
const target = atMe[atMe.length - 1];
|
|
109
|
+
return {
|
|
110
|
+
act: 'speak',
|
|
111
|
+
reason: `被 @ (${target.from})`,
|
|
112
|
+
text: reply(target),
|
|
113
|
+
mentions: [target.from],
|
|
114
|
+
...(target.id ? { replyTo: target.id } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// ② 没人答过的提问/请求 ⇒ 说一次
|
|
118
|
+
const req = fresh.filter((m) => looksLikeRequest(m.text));
|
|
119
|
+
if (req.length) {
|
|
120
|
+
const target = req[req.length - 1];
|
|
121
|
+
return {
|
|
122
|
+
act: 'speak',
|
|
123
|
+
reason: `群里有人在问/请人做事 (${target.from})`,
|
|
124
|
+
text: reply(target),
|
|
125
|
+
mentions: [target.from],
|
|
126
|
+
...(target.id ? { replyTo: target.id } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { act: 'silent', reason: '新消息里没有 @我 也没有明确的提问/请求' };
|
|
130
|
+
}
|
|
131
|
+
// ── 状态文件 (纯状态, 不进 git, 不含密钥) ────────────────────────────────
|
|
132
|
+
export function autopilotStatePath() {
|
|
133
|
+
const base = process.env.BOLLOON_HOME || path.join(os.homedir(), '.bolloon');
|
|
134
|
+
return path.join(base, 'group-autopilot.json');
|
|
135
|
+
}
|
|
136
|
+
export function loadAutopilotState(file = autopilotStatePath()) {
|
|
137
|
+
try {
|
|
138
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
139
|
+
if (raw && typeof raw === 'object' && raw.groups && typeof raw.groups === 'object')
|
|
140
|
+
return { version: 1, groups: raw.groups };
|
|
141
|
+
}
|
|
142
|
+
catch { /* 没有/坏了 ⇒ 当空 (不假装有过状态) */ }
|
|
143
|
+
return { version: 1, groups: {} };
|
|
144
|
+
}
|
|
145
|
+
export function saveAutopilotState(state, file = autopilotStatePath()) {
|
|
146
|
+
try {
|
|
147
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
148
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2));
|
|
149
|
+
}
|
|
150
|
+
catch { /* 写不进去不该打断回路 */ }
|
|
151
|
+
}
|
|
152
|
+
export function groupState(state, groupId) {
|
|
153
|
+
const g = state.groups[groupId];
|
|
154
|
+
if (!g)
|
|
155
|
+
return { seen: [], spokeAt: [] };
|
|
156
|
+
return {
|
|
157
|
+
seen: Array.isArray(g.seen) ? g.seen.filter((x) => typeof x === 'string').slice(-500) : [],
|
|
158
|
+
spokeAt: Array.isArray(g.spokeAt) ? g.spokeAt.filter((x) => typeof x === 'number') : [],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** 跑一轮: 列群 → 逐群读 → 决策 → 该说才说 (幂等: 处理过的消息进 seen) */
|
|
162
|
+
export async function runGroupAutopilotOnce(ports) {
|
|
163
|
+
const now = ports.now ?? (() => Date.now());
|
|
164
|
+
const policy = { ...DEFAULT_AUTOPILOT_POLICY, ...(ports.policy ?? {}) };
|
|
165
|
+
const log = ports.log ?? (() => { });
|
|
166
|
+
const report = { spoke: 0, silent: 0, errors: [], details: [] };
|
|
167
|
+
const groups = await ports.listGroups();
|
|
168
|
+
for (const g of groups) {
|
|
169
|
+
const st = groupState(ports.state, g.id);
|
|
170
|
+
let msgs = [];
|
|
171
|
+
try {
|
|
172
|
+
msgs = await ports.readMessages(g.id, policy.readLimit);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
report.errors.push(`${g.name ?? g.id}: 读消息失败 (${String(e?.message ?? e).slice(0, 80)})`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const t = now();
|
|
179
|
+
const decision = decideGroupAction({
|
|
180
|
+
me: ports.me,
|
|
181
|
+
msgs,
|
|
182
|
+
state: st,
|
|
183
|
+
now: t,
|
|
184
|
+
policy,
|
|
185
|
+
...(ports.makeReply ? { makeReply: (m) => ports.makeReply(m, g) } : {}),
|
|
186
|
+
...(ports.proactiveText ? { proactiveText: ports.proactiveText } : {}),
|
|
187
|
+
});
|
|
188
|
+
// 不论说不说, 这几条都算"处理过" (否则下一轮还会重新判它)
|
|
189
|
+
for (const m of msgs) {
|
|
190
|
+
const k = messageKey(m);
|
|
191
|
+
if (!st.seen.includes(k))
|
|
192
|
+
st.seen.push(k);
|
|
193
|
+
}
|
|
194
|
+
if (decision.act === 'speak') {
|
|
195
|
+
try {
|
|
196
|
+
const r = await ports.speak(g.id, decision.text, {
|
|
197
|
+
...(decision.replyTo ? { replyTo: decision.replyTo } : {}),
|
|
198
|
+
...(decision.mentions.length ? { mentions: decision.mentions } : {}),
|
|
199
|
+
});
|
|
200
|
+
if (r.ok) {
|
|
201
|
+
st.spokeAt.push(t);
|
|
202
|
+
report.spoke++;
|
|
203
|
+
report.details.push(`💬 ${g.name ?? g.id}: 发言 (${decision.reason}) → ${decision.text.slice(0, 60)}`);
|
|
204
|
+
log(`[group-autopilot] ${g.name ?? g.id}: 发言 (${decision.reason})`);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
report.errors.push(`${g.name ?? g.id}: 发言失败 (${r.error ?? '未知'})`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
report.errors.push(`${g.name ?? g.id}: 发言抛错 (${String(e?.message ?? e).slice(0, 80)})`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
report.silent++;
|
|
216
|
+
report.details.push(`… ${g.name ?? g.id}: 静默 (${decision.reason})`);
|
|
217
|
+
}
|
|
218
|
+
ports.state.groups[g.id] = { seen: st.seen.slice(-500), spokeAt: st.spokeAt };
|
|
219
|
+
}
|
|
220
|
+
if (ports.stateFile || ports.state)
|
|
221
|
+
saveAutopilotState(ports.state, ports.stateFile ?? autopilotStatePath());
|
|
222
|
+
return report;
|
|
223
|
+
}
|
|
224
|
+
/** 常驻回路: 每 intervalMs 跑一轮; stop() 幂等; 轮与轮之间不重叠 */
|
|
225
|
+
export function startGroupAutopilot(ports) {
|
|
226
|
+
const interval = Math.max(5_000, ports.intervalMs ?? 60_000);
|
|
227
|
+
let stopped = false;
|
|
228
|
+
let running = false;
|
|
229
|
+
let runs = 0;
|
|
230
|
+
let last = null;
|
|
231
|
+
const log = ports.log ?? (() => { });
|
|
232
|
+
const tick = async () => {
|
|
233
|
+
if (stopped || running)
|
|
234
|
+
return;
|
|
235
|
+
running = true;
|
|
236
|
+
try {
|
|
237
|
+
last = await runGroupAutopilotOnce(ports);
|
|
238
|
+
runs++;
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
log(`[group-autopilot] 这一轮出错: ${String(e?.message ?? e)}`);
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
running = false;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
void tick(); // 立刻跑一轮 (不等第一个间隔)
|
|
248
|
+
const timer = setInterval(() => { void tick(); }, interval);
|
|
249
|
+
if (typeof timer.unref === 'function')
|
|
250
|
+
timer.unref(); // 不因它而拖住进程退出
|
|
251
|
+
return {
|
|
252
|
+
stop: () => { stopped = true; clearInterval(timer); },
|
|
253
|
+
lastReport: () => last,
|
|
254
|
+
runs: () => runs,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -23,6 +23,11 @@ import { registerComputerUseTools } from './computer-use.js';
|
|
|
23
23
|
import { registerSkillShareTools } from './skill-share.js';
|
|
24
24
|
// 2026-09-13: 微支付信息服务 (x402 付费信息 + 验真信封)
|
|
25
25
|
import { registerPaidInfoTools } from './x402/paid-info-tools.js';
|
|
26
|
+
// 2026-10-02: 群聊的四个 AI 工具 —— 原语来自群模块与"唯一发送出口"(带隐私闸)
|
|
27
|
+
import { createGroup, joinGroup, listGroups, groupLink, groupMessages, groupMembers } from './gateway-group.js';
|
|
28
|
+
import { resolveGroupRef, resolveSenderTag, sendTrailMessage } from './task-group.js';
|
|
29
|
+
// 2026-10-02: 群聊自主回路 (读 → 自己判断 → 自己发言) —— AI 用 group_autopilot 工具驱动
|
|
30
|
+
import { loadAutopilotState, runGroupAutopilotOnce, startGroupAutopilot } from './group-autopilot.js';
|
|
26
31
|
/**
|
|
27
32
|
* Tools 模块 — 从 pi-sdk.ts 抽出的 registerTools() / _registerWalletTools() / _setupInboxListener()
|
|
28
33
|
* / wrapToolsWithIdempotency() (2026-07-06).
|
|
@@ -185,6 +190,8 @@ export async function runTerminalCommand(raw, opts = {}) {
|
|
|
185
190
|
}
|
|
186
191
|
return { success: true, output: allOutput, count: list.length, parallel: true };
|
|
187
192
|
}
|
|
193
|
+
/** 2026-10-02: 群聊自主回路的常驻句柄 (同进程共享; stop 后置 null) */
|
|
194
|
+
let autopilotHandle = null;
|
|
188
195
|
export function registerBuiltinTools(ctx) {
|
|
189
196
|
ctx.tools.set('read_document', {
|
|
190
197
|
name: 'read_document',
|
|
@@ -952,6 +959,301 @@ export function registerBuiltinTools(ctx) {
|
|
|
952
959
|
return await runTerminalCommand(raw, { timeoutMs, cwd: ctx.cwd, commands: commands.length > 0 ? commands : undefined, background: String(args.background).toLowerCase() === 'true' });
|
|
953
960
|
}
|
|
954
961
|
});
|
|
962
|
+
/**
|
|
963
|
+
* 2026-10-02: @ 的归一入口 —— 模型给 `['a','b']` / `'a,b'` / `'a b'` / `'a'` 都收
|
|
964
|
+
* (别因为形状不符就把 @ 静默丢掉: 真机第一次跑, 模型传的正是字符串 ⇒ @ 丢了)。
|
|
965
|
+
* trim + 去 `@` 前缀 + 去空 + 去重 + 上限 20 (与记录层同一口径)。
|
|
966
|
+
*/
|
|
967
|
+
const normalizeMentions = (raw) => {
|
|
968
|
+
const flat = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(/[,,;;\s]+/) : raw == null ? [] : [raw];
|
|
969
|
+
const out = Array.from(new Set(flat.map((x) => String(x ?? '').replace(/^@/, '').trim()).filter(Boolean))).slice(0, 20);
|
|
970
|
+
return out.length ? out : undefined;
|
|
971
|
+
};
|
|
972
|
+
// 2026-10-02 (leo: 希望人工智能自己搞定): 群聊工具 —— 原语来自群模块 + 唯一发送出口(带隐私闸)
|
|
973
|
+
ctx.tools.set('group_create', {
|
|
974
|
+
name: 'group_create',
|
|
975
|
+
description: '新建一个 OrbitDB 群聊并拿到邀请链接 —— **用于**你要开一个话题把别人拉进来(建好后把邀请链接发出去, 对方用 group_join 入群)。acl 默认 creator: 只有你自己能发言; 想让**谁拿到链接都能发言**就传 acl="open"(产品语义如此, 两种都合法)。',
|
|
976
|
+
parameters: { name: '群名(必填)', acl: 'creator | open —— 默认 creator(创建者独占写入); open = 谁拿链接都能发言', from: '你的署名(可选; 默认本机身份)' },
|
|
977
|
+
execute: async (args) => {
|
|
978
|
+
const name = String(args?.name ?? '').trim();
|
|
979
|
+
if (!name)
|
|
980
|
+
return { success: false, error: 'name 必填(群名)' };
|
|
981
|
+
const who = await resolveSenderTag(null);
|
|
982
|
+
// 只认 'open'; 其它一律走产品默认 (创建者独占) —— 不猜、不替调用方放开权限
|
|
983
|
+
const acl = String(args?.acl ?? '').trim() === 'open' ? 'open' : undefined;
|
|
984
|
+
const r = await createGroup(name, { ...(who.ok ? { from: who.tag } : {}), ...(acl ? { acl } : {}) });
|
|
985
|
+
const g = r.group;
|
|
986
|
+
if (!g?.id)
|
|
987
|
+
return { success: false, error: '建群没返回 groupId(如实报告, 不假装成功)' };
|
|
988
|
+
const link = await groupLink(g.id).catch(() => null);
|
|
989
|
+
return { success: true, output: `已建群「${g.name}」(${g.id})${acl ? ' · 开放写入(谁拿链接都能发言)' : ' · 创建者独占写入'}\n` + (link ? `邀请链接: ${link}\n把这个链接发给对方, 他用 group_join 入群。` : '(没拿到邀请链接 —— 用 group_list 再看一次)') };
|
|
990
|
+
},
|
|
991
|
+
});
|
|
992
|
+
ctx.tools.set('group_join', {
|
|
993
|
+
name: 'group_join',
|
|
994
|
+
description: '加入一个 OrbitDB 群聊 —— **用于**拿到邀请链接后自助入群(幂等: 已在群里 ⇒ already)。link 传邀请链接(orbitdb:///orbitdb/<addr>?type=group&name=…)或 groupId。入群后先用 group_read 读消息、再用 group_say 发言。',
|
|
995
|
+
parameters: { link: '邀请链接 或 groupId(必填)' },
|
|
996
|
+
execute: async (args) => {
|
|
997
|
+
const link = String(args?.link ?? '').trim();
|
|
998
|
+
if (!link)
|
|
999
|
+
return { success: false, error: '缺 link(邀请链接或 groupId)' };
|
|
1000
|
+
const r = await joinGroup(link);
|
|
1001
|
+
if (!r.ok)
|
|
1002
|
+
return { success: false, error: r.error || '入群失败' };
|
|
1003
|
+
const g = r.group;
|
|
1004
|
+
return {
|
|
1005
|
+
success: true,
|
|
1006
|
+
output: (r.already ? '已在群里「' : '已入群「') + String(g?.name ?? '') + '」(' + String(g?.id ?? '') + ')'
|
|
1007
|
+
+ (g?.link ? ' · 邀请链接 ' + g.link : ''),
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
// 2026-10-02 (leo: 希望人工智能自己搞定): 群聊工具 —— 原语来自群模块 + 唯一发送出口(带隐私闸)
|
|
1012
|
+
ctx.tools.set('group_list', {
|
|
1013
|
+
name: 'group_list',
|
|
1014
|
+
description: '列出本机已加入的群(群名 / groupId / 加入时间 / 邀请链接)—— **用于**不知道有哪些群时先摸清楚。先用它拿 groupId, 再用 group_read / group_say。',
|
|
1015
|
+
parameters: {},
|
|
1016
|
+
execute: async (args) => {
|
|
1017
|
+
const groups = await listGroups();
|
|
1018
|
+
if (!groups.length)
|
|
1019
|
+
return { success: true, output: '(本机还没有加入任何群)' };
|
|
1020
|
+
const lines = [];
|
|
1021
|
+
for (const g of groups) {
|
|
1022
|
+
const link = await groupLink(g.id).catch(() => null);
|
|
1023
|
+
lines.push(`${g.name} ${g.id} 加入于 ${g.createdAt}` + (link ? `\n 邀请链接: ${link}` : ''));
|
|
1024
|
+
}
|
|
1025
|
+
return { success: true, output: lines.join('\n') };
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
// 2026-10-02 (leo: 希望人工智能自己搞定): 群聊工具 —— 原语来自群模块 + 唯一发送出口(带隐私闸)
|
|
1029
|
+
ctx.tools.set('group_read', {
|
|
1030
|
+
name: 'group_read',
|
|
1031
|
+
description: '读一个群里的消息(最近 N 条, 默认 30)—— **用于**看群里发生过什么、拉取要阅读的内容。group 可直接给 groupId, 也可给邀请链接 / 群名。',
|
|
1032
|
+
parameters: { group: 'groupId | 邀请链接 | 群名(必填)', limit: '最多读多少条, 默认 30', branch: '只看某个分支(可选; 缺省 = 全部)' },
|
|
1033
|
+
execute: async (args) => {
|
|
1034
|
+
const res = await resolveGroupRef(String(args?.group ?? ''));
|
|
1035
|
+
if (!res.ok)
|
|
1036
|
+
return { success: false, error: res.message };
|
|
1037
|
+
const limit = Number(args?.limit ?? 30) || 30;
|
|
1038
|
+
let msgs = await groupMessages(res.group.groupId, limit);
|
|
1039
|
+
const branch = String(args?.branch ?? '').trim();
|
|
1040
|
+
if (branch)
|
|
1041
|
+
msgs = msgs.filter((m) => String(m.branch ?? '') === branch);
|
|
1042
|
+
if (!msgs.length)
|
|
1043
|
+
return { success: true, output: `(群「${res.group.name ?? res.group.groupId}」里还没有消息${branch ? ` (分支 ${branch})` : ''})` };
|
|
1044
|
+
// 2026-10-02: 把 @/回复/附件/分支都渲染出来 —— 只给纯文本, 模型看不出结构就没法正确回复
|
|
1045
|
+
const fmt = (m) => {
|
|
1046
|
+
const bits = [`[${new Date(m.ts).toLocaleString('zh-CN')}]`];
|
|
1047
|
+
if (m.id)
|
|
1048
|
+
bits.push(`#${m.id.slice(0, 8)}`);
|
|
1049
|
+
if (m.kind === 'branch')
|
|
1050
|
+
bits.push('[分支]');
|
|
1051
|
+
if (m.branch)
|
|
1052
|
+
bits.push(`⎇${m.branch}`);
|
|
1053
|
+
if (m.replyTo)
|
|
1054
|
+
bits.push(`↩#${String(m.replyTo).slice(0, 8)}`);
|
|
1055
|
+
bits.push(`${m.mentions?.length ? '@' + m.mentions.join(' @') + ' ' : ''}${m.from}:`);
|
|
1056
|
+
bits.push(m.text);
|
|
1057
|
+
if (m.attachments?.length) {
|
|
1058
|
+
bits.push(m.attachments.map((a) => `[${a.kind === 'image' ? '图片' : a.kind === 'audio' ? '音频' : '文件'} ${a.cid.slice(0, 14)}${a.name ? ' ' + a.name : ''}]`).join(' '));
|
|
1059
|
+
}
|
|
1060
|
+
return bits.join(' ');
|
|
1061
|
+
};
|
|
1062
|
+
return { success: true, output: msgs.map(fmt).join('\n') };
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
// 2026-10-02 (leo: 希望人工智能自己搞定): 群聊工具 —— 原语来自群模块 + 唯一发送出口(带隐私闸)
|
|
1066
|
+
ctx.tools.set('group_say', {
|
|
1067
|
+
name: 'group_say',
|
|
1068
|
+
description: '在一个群里发言(写进 OrbitDB 群 store, 成员都能读到)—— **用于**把你的结论/请求讲给群成员。group 可直接给 groupId, 也可给邀请链接 / 群名。消息**先过隐私红线闸**: 不许出现地址 / DID / peerId / multiaddr / IP / 私钥形态。',
|
|
1069
|
+
parameters: {
|
|
1070
|
+
group: 'groupId | 邀请链接 | 群名(必填)',
|
|
1071
|
+
text: '要说的内容(必填)',
|
|
1072
|
+
mentions: '@ 谁 —— 短显示名数组, 例如 ["委托方","审稿人"](可选)',
|
|
1073
|
+
replyTo: '回复哪条 —— 该消息的 id(group_read 的输出里带)(可选)',
|
|
1074
|
+
branch: '发到哪个分支(可选; 缺省 = 主线)',
|
|
1075
|
+
},
|
|
1076
|
+
execute: async (args) => {
|
|
1077
|
+
const res = await resolveGroupRef(String(args?.group ?? ''));
|
|
1078
|
+
if (!res.ok)
|
|
1079
|
+
return { success: false, error: res.message };
|
|
1080
|
+
const text = String(args?.text ?? '').trim();
|
|
1081
|
+
if (!text)
|
|
1082
|
+
return { success: false, error: 'text 不能为空' };
|
|
1083
|
+
const who = await resolveSenderTag(null);
|
|
1084
|
+
if (!who.ok)
|
|
1085
|
+
return { success: false, error: who.message };
|
|
1086
|
+
const mentions = normalizeMentions(args?.mentions);
|
|
1087
|
+
const r = await sendTrailMessage(res.group.groupId, text, who.tag, {
|
|
1088
|
+
...(mentions?.length ? { mentions } : {}),
|
|
1089
|
+
...(args?.replyTo ? { replyTo: String(args.replyTo) } : {}),
|
|
1090
|
+
...(args?.branch ? { branch: String(args.branch) } : {}),
|
|
1091
|
+
});
|
|
1092
|
+
if (!r.ok)
|
|
1093
|
+
return { success: false, error: r.error || '发送失败' };
|
|
1094
|
+
const extra = mentions?.length ? `(@ ${mentions.join(' ')})` : '';
|
|
1095
|
+
return { success: true, output: `已发到群「${res.group.name ?? res.group.groupId}」(署名 ${who.tag})${extra}: ${text}` };
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
ctx.tools.set('group_reply', {
|
|
1099
|
+
name: 'group_reply',
|
|
1100
|
+
description: '回复群里**某一条**消息(在群里形成对话)—— **用于**回答特定的人/特定那条话,而不是发一条孤立的消息。group 给 groupId/链接/群名;replyTo 给那条消息的 id(group_read 输出里的 `#xxxxxxxx`)。',
|
|
1101
|
+
parameters: { group: 'groupId | 邀请链接 | 群名(必填)', replyTo: '被回复消息的 id(必填, group_read 输出里的 #xxxxxxxx)', text: '回复内容(必填)', mentions: '@ 谁(短显示名数组, 可选)' },
|
|
1102
|
+
execute: async (args) => {
|
|
1103
|
+
const res = await resolveGroupRef(String(args?.group ?? ''));
|
|
1104
|
+
if (!res.ok)
|
|
1105
|
+
return { success: false, error: res.message };
|
|
1106
|
+
const replyTo = String(args?.replyTo ?? '').trim().replace(/^#/, '');
|
|
1107
|
+
const text = String(args?.text ?? '').trim();
|
|
1108
|
+
if (!replyTo)
|
|
1109
|
+
return { success: false, error: 'replyTo 必填(被回复消息的 id)' };
|
|
1110
|
+
if (!text)
|
|
1111
|
+
return { success: false, error: 'text 不能为空' };
|
|
1112
|
+
const who = await resolveSenderTag(null);
|
|
1113
|
+
if (!who.ok)
|
|
1114
|
+
return { success: false, error: who.message };
|
|
1115
|
+
const mentions = normalizeMentions(args?.mentions);
|
|
1116
|
+
const r = await sendTrailMessage(res.group.groupId, text, who.tag, { replyTo, ...(mentions?.length ? { mentions } : {}) });
|
|
1117
|
+
if (!r.ok)
|
|
1118
|
+
return { success: false, error: r.error || '回复失败' };
|
|
1119
|
+
return { success: true, output: `已回复 #${replyTo.slice(0, 8)}(署名 ${who.tag}): ${text}` };
|
|
1120
|
+
},
|
|
1121
|
+
});
|
|
1122
|
+
ctx.tools.set('group_attach', {
|
|
1123
|
+
name: 'group_attach',
|
|
1124
|
+
description: '往群里发**图片/音频/文件**(消息里只放内容寻址的 CID, 字节进内容寻址层, 成员按 CID 取)—— **用于**要把一张图/一段录音/一个文件交给群里的人。给 path(本机文件, 自动算 CID 并入库)或 cid(已经在内容寻址层里)。',
|
|
1125
|
+
parameters: { group: 'groupId | 邀请链接 | 群名(必填)', path: '本机文件路径(与 cid 二选一)', cid: '已在内容寻址层的 CID(与 path 二选一)', kind: 'image | audio | file(默认 file; 给 path 时会按扩展名猜)', text: '随附件说的话(可选)', branch: '发到哪个分支(可选)' },
|
|
1126
|
+
execute: async (args) => {
|
|
1127
|
+
const res = await resolveGroupRef(String(args?.group ?? ''));
|
|
1128
|
+
if (!res.ok)
|
|
1129
|
+
return { success: false, error: res.message };
|
|
1130
|
+
const kindArg = String(args?.kind ?? '').trim();
|
|
1131
|
+
let cid = String(args?.cid ?? '').trim();
|
|
1132
|
+
let name = '';
|
|
1133
|
+
let bytes;
|
|
1134
|
+
let kind = /^(image|audio|file)$/.test(kindArg) ? kindArg : 'file';
|
|
1135
|
+
const p = String(args?.path ?? '').trim();
|
|
1136
|
+
if (p) {
|
|
1137
|
+
const fsp = await import('node:fs/promises');
|
|
1138
|
+
const pathMod = await import('node:path');
|
|
1139
|
+
try {
|
|
1140
|
+
const buf = await fsp.readFile(p);
|
|
1141
|
+
name = pathMod.basename(p);
|
|
1142
|
+
if (!kindArg) {
|
|
1143
|
+
const ext = pathMod.extname(p).toLowerCase();
|
|
1144
|
+
kind = ['.png', '.jpg', '.jpeg', '.gif', '.webp'].includes(ext) ? 'image' : ['.mp3', '.wav', '.m4a', '.ogg', '.aac'].includes(ext) ? 'audio' : 'file';
|
|
1145
|
+
}
|
|
1146
|
+
const { getCIDDatabase } = await import('../orbitdb/cid-database.js');
|
|
1147
|
+
const db = getCIDDatabase();
|
|
1148
|
+
if (!db.putBytes)
|
|
1149
|
+
return { success: false, error: '内容寻址层不支持 putBytes(该构建缺这个入口)' };
|
|
1150
|
+
const put = await db.putBytes(new Uint8Array(buf));
|
|
1151
|
+
cid = put.cid;
|
|
1152
|
+
bytes = put.bytes;
|
|
1153
|
+
}
|
|
1154
|
+
catch (e) {
|
|
1155
|
+
return { success: false, error: `读取/入库失败: ${String(e?.message || e).slice(0, 160)}` };
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (!cid)
|
|
1159
|
+
return { success: false, error: 'path 或 cid 至少给一个' };
|
|
1160
|
+
const who = await resolveSenderTag(null);
|
|
1161
|
+
if (!who.ok)
|
|
1162
|
+
return { success: false, error: who.message };
|
|
1163
|
+
const text = String(args?.text ?? '').trim() || `[${kind === 'image' ? '图片' : kind === 'audio' ? '音频' : '文件'}] ${name || cid.slice(0, 14)}`;
|
|
1164
|
+
const r = await sendTrailMessage(res.group.groupId, text, who.tag, {
|
|
1165
|
+
attachments: [{ kind, cid, ...(name ? { name } : {}), ...(typeof bytes === 'number' ? { bytes } : {}) }],
|
|
1166
|
+
...(args?.branch ? { branch: String(args.branch) } : {}),
|
|
1167
|
+
});
|
|
1168
|
+
if (!r.ok)
|
|
1169
|
+
return { success: false, error: r.error || '发送失败' };
|
|
1170
|
+
return { success: true, output: `已发附件到群「${res.group.name ?? res.group.groupId}」: ${kind} ${cid.slice(0, 14)}${name ? ' (' + name + ')' : ''}${typeof bytes === 'number' ? ' ' + bytes + 'B' : ''}(署名 ${who.tag})` };
|
|
1171
|
+
},
|
|
1172
|
+
});
|
|
1173
|
+
ctx.tools.set('group_branch', {
|
|
1174
|
+
name: 'group_branch',
|
|
1175
|
+
description: '在群里**开一个分支话题**(像一条独立讨论线)—— **用于**一个群里同时聊多件事, 免得主线互相打断。之后用 group_say/group_reply 带 branch 参数就发进这个分支; group_read 带 branch 只看这一支。',
|
|
1176
|
+
parameters: { group: 'groupId | 邀请链接 | 群名(必填)', title: '分支话题名(必填, 简短; 之后作为 branch 参数用它)' },
|
|
1177
|
+
execute: async (args) => {
|
|
1178
|
+
const res = await resolveGroupRef(String(args?.group ?? ''));
|
|
1179
|
+
if (!res.ok)
|
|
1180
|
+
return { success: false, error: res.message };
|
|
1181
|
+
const title = String(args?.title ?? '').trim();
|
|
1182
|
+
if (!title)
|
|
1183
|
+
return { success: false, error: 'title 必填(分支话题名)' };
|
|
1184
|
+
const who = await resolveSenderTag(null);
|
|
1185
|
+
if (!who.ok)
|
|
1186
|
+
return { success: false, error: who.message };
|
|
1187
|
+
const r = await sendTrailMessage(res.group.groupId, `分支话题: ${title}`, who.tag, { kind: 'branch', branch: title });
|
|
1188
|
+
if (!r.ok)
|
|
1189
|
+
return { success: false, error: r.error || '建分支失败' };
|
|
1190
|
+
return { success: true, output: `已开分支「${title}」${r.id ? ' #' + r.id.slice(0, 8) : ''} —— 之后 group_say/group_reply 带 branch="${title}" 发言即可, group_read 带 branch="${title}" 只看这一支。` };
|
|
1191
|
+
},
|
|
1192
|
+
});
|
|
1193
|
+
ctx.tools.set('group_members', {
|
|
1194
|
+
name: 'group_members',
|
|
1195
|
+
description: '列一个群里出现过的成员(从消息里提取) —— **用于**想知道这个群有谁、要 @ 谁之前先看一眼。',
|
|
1196
|
+
parameters: { group: 'groupId | 邀请链接 | 群名(必填)' },
|
|
1197
|
+
execute: async (args) => {
|
|
1198
|
+
const res = await resolveGroupRef(String(args?.group ?? ''));
|
|
1199
|
+
if (!res.ok)
|
|
1200
|
+
return { success: false, error: res.message };
|
|
1201
|
+
const members = await groupMembers(res.group.groupId);
|
|
1202
|
+
return { success: true, output: members.length ? `群「${res.group.name ?? res.group.groupId}」成员/发言者 ${members.length} 个: ${members.join(' · ')}` : `(群「${res.group.name ?? res.group.groupId}」里还没有成员发过言)` };
|
|
1203
|
+
},
|
|
1204
|
+
});
|
|
1205
|
+
ctx.tools.set('group_autopilot', {
|
|
1206
|
+
name: 'group_autopilot',
|
|
1207
|
+
description: '群聊自主回路 —— **用于**让 AI 自己读群、自己判断该不该开口、自己在群里发言(不用人盯着)。action: run(立刻跑一轮) / start(挂成常驻, 每 intervalMs 一轮) / stop / status。默认只在「被 @」或「有人提问/请人做事」时说话, 冷却 60s、每小时每群最多 6 次, 处理过的消息不会重复回应。',
|
|
1208
|
+
parameters: { action: 'run | start | stop | status(必填)', intervalMs: '常驻时的间隔 ms(默认 60000)', mode: 'quiet | active | maintenance(可选; maintenance 专门维护 Bolloon)', group: '只盯某个群(groupId/链接/群名, 可选; 缺省 = 全部已加入的群)' },
|
|
1209
|
+
execute: async (args) => {
|
|
1210
|
+
const action = String(args?.action ?? 'run').trim();
|
|
1211
|
+
const who = await resolveSenderTag(null);
|
|
1212
|
+
if (!who.ok)
|
|
1213
|
+
return { success: false, error: who.message };
|
|
1214
|
+
const only = String(args?.group ?? '').trim();
|
|
1215
|
+
const state = loadAutopilotState();
|
|
1216
|
+
const ports = {
|
|
1217
|
+
me: who.tag,
|
|
1218
|
+
listGroups: async () => {
|
|
1219
|
+
const gs = await listGroups();
|
|
1220
|
+
if (!only)
|
|
1221
|
+
return gs.map((g) => ({ id: g.id, name: g.name }));
|
|
1222
|
+
const r = await resolveGroupRef(only);
|
|
1223
|
+
return r.ok ? [{ id: r.group.groupId, name: r.group.name }] : [];
|
|
1224
|
+
},
|
|
1225
|
+
readMessages: (gid, limit) => groupMessages(gid, limit),
|
|
1226
|
+
speak: (gid, text, opts) => sendTrailMessage(gid, text, who.tag, opts ?? {}),
|
|
1227
|
+
state,
|
|
1228
|
+
policy: { mode: String(args?.mode ?? 'quiet') },
|
|
1229
|
+
};
|
|
1230
|
+
if (action === 'stop') {
|
|
1231
|
+
if (!autopilotHandle)
|
|
1232
|
+
return { success: true, output: '没有在跑的常驻回路(无需停)' };
|
|
1233
|
+
autopilotHandle.stop();
|
|
1234
|
+
const runs = autopilotHandle.runs();
|
|
1235
|
+
autopilotHandle = null;
|
|
1236
|
+
return { success: true, output: `已停常驻回路(本次共跑 ${runs} 轮)` };
|
|
1237
|
+
}
|
|
1238
|
+
if (action === 'status') {
|
|
1239
|
+
const last = autopilotHandle?.lastReport() ?? null;
|
|
1240
|
+
return { success: true, output: autopilotHandle
|
|
1241
|
+
? `常驻回路在跑: 已 ${autopilotHandle.runs()} 轮;最近一轮: 发言 ${last?.spoke ?? 0} · 静默 ${last?.silent ?? 0}${last?.errors.length ? ' · 错误 ' + last.errors.length : ''}`
|
|
1242
|
+
: `没有常驻回路在跑。状态文件: ${Object.keys(state.groups).length} 个群有记录` };
|
|
1243
|
+
}
|
|
1244
|
+
if (action === 'start') {
|
|
1245
|
+
if (autopilotHandle)
|
|
1246
|
+
return { success: true, output: '常驻回路已经在跑了' };
|
|
1247
|
+
const intervalMs = Number(args?.intervalMs ?? 60_000) || 60_000;
|
|
1248
|
+
autopilotHandle = startGroupAutopilot({ ...ports, intervalMs });
|
|
1249
|
+
return { success: true, output: `已挂常驻回路(每 ${Math.round(intervalMs / 1000)}s 一轮;被 @ 或在有人提问时发言)。要停用 group_autopilot action=stop。` };
|
|
1250
|
+
}
|
|
1251
|
+
const r = await runGroupAutopilotOnce(ports);
|
|
1252
|
+
const head = `跑了一轮: 发言 ${r.spoke} · 静默 ${r.silent}${r.errors.length ? ` · 错误 ${r.errors.length}` : ''}`;
|
|
1253
|
+
const tail = [...r.details, ...r.errors.map((e) => `⚠ ${e}`)].slice(0, 8).join('\n');
|
|
1254
|
+
return { success: true, output: tail ? `${head}\n${tail}` : head };
|
|
1255
|
+
},
|
|
1256
|
+
});
|
|
955
1257
|
// 2026-08-12 (TaskD): process — 后台进程管理 (学 hermes terminal background session + poll/wait/kill).
|
|
956
1258
|
// 长命令不阻塞对话: terminal(background=true) 启动 → process 工具轮询/等待/终止.
|
|
957
1259
|
ctx.tools.set('process', {
|