@bolloon/bolloon-agent 0.2.14 → 0.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/dist/agents/goal-resume.js +321 -0
- package/dist/agents/pi-sdk-tools.js +99 -0
- package/dist/bollharness-integration/skill-adapter.js +111 -0
- package/dist/bootstrap/lifecycle-hooks.js +45 -0
- package/dist/cli-entry.js +1 -1
- package/dist/llm/config-store.js +93 -14
- package/dist/llm/system-prompt/layers/channel/human-async.md +41 -0
- package/dist/llm/system-prompt/layers/channel/p2p-peer-sync.md +51 -0
- package/dist/llm/system-prompt/layers/channel/p2p-proactive.md +43 -0
- package/dist/llm/system-prompt/layers/channel/session-handoff.md +61 -0
- package/dist/llm/system-prompt/layers/core/external-engagement.md +73 -0
- package/dist/llm/system-prompt/layers/core/identity.md +30 -19
- package/dist/llm/system-prompt/layers/tool/goal_handoff.md +77 -0
- package/dist/llm/system-prompt/layers/tool/p2p_request.md +61 -0
- package/dist/llm/system-prompt/registry.js +20 -2
- package/dist/web/api-config.html +12 -2
- package/dist/web/routes-llm-config.js +23 -13
- package/package.json +2 -2
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal-resume — 双栖 agent 网络的"目标接力"原语
|
|
3
|
+
*
|
|
4
|
+
* 设计动机 (2026-07-10):
|
|
5
|
+
* - bolloon 改造: 从本地优先 → 远程/本地双栖 agent 网络.
|
|
6
|
+
* - 需要"目标不中断"机制: 用户当前 task 跑一半, 切到另一个 channel / 切到对端 peer
|
|
7
|
+
* / 用户离开, 目标不能丢.
|
|
8
|
+
* - 现有 task-state.ts 已有 'paused' 状态, 但没有跨 session / 跨机器的"目标快照+恢复"流.
|
|
9
|
+
* - 本文件提供 parkGoal / resumeGoal / continueGoalInBackground, 在 4 级 Bolloon.md 之外的
|
|
10
|
+
* 运行时层做目标接力.
|
|
11
|
+
*
|
|
12
|
+
* 复用现有:
|
|
13
|
+
* - task-state.ts: Task.status = 'paused' (park) / 'running' (resume)
|
|
14
|
+
* - session-store.ts: 消息历史 (saveMessages / loadMessages)
|
|
15
|
+
* - chat-archiver.ts: 高频归档 (~/.bolloon/archive/<peerDID>/YYYY-MM.jsonl)
|
|
16
|
+
* - p2p-outbox.ts: 离线消息不丢
|
|
17
|
+
* - injectJudgmentGate (pi-sdk.ts): judgment 注入门; goal handoff 路径上保留注入
|
|
18
|
+
*
|
|
19
|
+
* 不重复造:
|
|
20
|
+
* - 没有新数据库. 落盘 = ~/.bolloon/goals/snapshot.jsonl (append-only, 与 chat-archiver 同模式)
|
|
21
|
+
* - 没有新 LLM 调度. LLM 工具入口在 pi-sdk-tools.ts 注册, 复用现有 pi-ai.ts 调度.
|
|
22
|
+
*
|
|
23
|
+
* 安全 / 边界:
|
|
24
|
+
* - 人类隐私 judgment (target_id 包含"用户偏好"语义) 不外泄给 peer;
|
|
25
|
+
* continueGoalInBackground 只发"目标描述 + 已完成步骤", 不发 judgment 内容.
|
|
26
|
+
* - 任何 IO 失败静默 (返回 null / error 对象), 不阻塞主对话.
|
|
27
|
+
*/
|
|
28
|
+
import * as fs from 'fs/promises';
|
|
29
|
+
import * as os from 'os';
|
|
30
|
+
import * as path from 'path';
|
|
31
|
+
import * as crypto from 'crypto';
|
|
32
|
+
import { sessionStore } from './session-store.js';
|
|
33
|
+
import * as taskState from './task-state.js';
|
|
34
|
+
import { onGoalParked, onGoalResumed } from '../bootstrap/lifecycle-hooks.js';
|
|
35
|
+
// ============================================================
|
|
36
|
+
// 内部: 落盘 + 读盘
|
|
37
|
+
// ============================================================
|
|
38
|
+
const GOALS_DIR = path.join(os.homedir(), '.bolloon', 'goals');
|
|
39
|
+
const SNAPSHOT_FILE = path.join(GOALS_DIR, 'snapshot.jsonl');
|
|
40
|
+
const EVENT_FILE = path.join(GOALS_DIR, 'event.jsonl');
|
|
41
|
+
/** 写一条 park/resume event (独立文件, 不影响 snapshot 覆盖逻辑) */
|
|
42
|
+
async function appendEvent(event) {
|
|
43
|
+
try {
|
|
44
|
+
await ensureGoalsDir();
|
|
45
|
+
await fs.appendFile(EVENT_FILE, JSON.stringify({ ...event, ts: new Date().toISOString() }) + '\n', 'utf-8');
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// 静默 — event log 失败不应阻塞
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function ensureGoalsDir() {
|
|
52
|
+
await fs.mkdir(GOALS_DIR, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
/** append-only 写一行 JSON */
|
|
55
|
+
async function appendSnapshot(snap) {
|
|
56
|
+
try {
|
|
57
|
+
await ensureGoalsDir();
|
|
58
|
+
await fs.appendFile(SNAPSHOT_FILE, JSON.stringify(snap) + '\n', 'utf-8');
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
// 静默失败 — 落盘失败不应阻塞主对话
|
|
62
|
+
if (process.env.BOLLOON_VERBOSE === '1') {
|
|
63
|
+
console.warn(`[goal-resume] appendSnapshot failed: ${err.message?.slice(0, 100)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** 读所有 snapshot, 按 goalId 分组保留最新一条 */
|
|
68
|
+
async function loadAllSnapshots() {
|
|
69
|
+
const out = new Map();
|
|
70
|
+
try {
|
|
71
|
+
const raw = await fs.readFile(SNAPSHOT_FILE, 'utf-8');
|
|
72
|
+
for (const line of raw.split('\n')) {
|
|
73
|
+
if (!line.trim())
|
|
74
|
+
continue;
|
|
75
|
+
try {
|
|
76
|
+
const snap = JSON.parse(line);
|
|
77
|
+
if (snap.schemaVersion !== 1)
|
|
78
|
+
continue;
|
|
79
|
+
out.set(snap.goalRef.goalId, snap); // 后写覆盖先写 (新状态覆盖旧状态)
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// 跳过损坏行
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// 文件不存在 = 空 map
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
function newGoalId() {
|
|
92
|
+
return `goal-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`;
|
|
93
|
+
}
|
|
94
|
+
// ============================================================
|
|
95
|
+
// Park 现有 task (复用 task-state.ts 'paused' 状态)
|
|
96
|
+
// ============================================================
|
|
97
|
+
async function parkAssociatedTask(taskId, goalId) {
|
|
98
|
+
if (!taskId)
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
// 1. 把 task 状态置 'paused'
|
|
102
|
+
await taskState.updateTask(taskId, { status: 'paused' });
|
|
103
|
+
// 2. 在 goal 字段里塞 goalId (让反向查找有据可查)
|
|
104
|
+
const t = await taskState.getTask(taskId);
|
|
105
|
+
if (t) {
|
|
106
|
+
// 不覆盖已有 goal 字段, 改用 metadata 注释
|
|
107
|
+
// (task-state.ts 的 Task interface 没 metadata, 跳过避免 schema 改动)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
if (process.env.BOLLOON_VERBOSE === '1') {
|
|
112
|
+
console.warn(`[goal-resume] parkAssociatedTask failed: ${err.message?.slice(0, 100)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function resumeAssociatedTask(taskId, newSessionKey) {
|
|
117
|
+
if (!taskId)
|
|
118
|
+
return;
|
|
119
|
+
try {
|
|
120
|
+
const patch = { status: 'running' };
|
|
121
|
+
if (newSessionKey)
|
|
122
|
+
patch.sessionKey = newSessionKey;
|
|
123
|
+
await taskState.updateTask(taskId, patch);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (process.env.BOLLOON_VERBOSE === '1') {
|
|
127
|
+
console.warn(`[goal-resume] resumeAssociatedTask failed: ${err.message?.slice(0, 100)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// ============================================================
|
|
132
|
+
// 公共 API
|
|
133
|
+
// ============================================================
|
|
134
|
+
/**
|
|
135
|
+
* Park 当前目标 — 把 session 状态快照 + task 状态落盘, 切走/离开不丢目标.
|
|
136
|
+
*
|
|
137
|
+
* @param goalRef 目标引用 (goalId 必须已存在, 不能传空)
|
|
138
|
+
* @param reason park 原因 (决定后续恢复策略)
|
|
139
|
+
* @returns GoalHandle (含 error 字段, 不抛错)
|
|
140
|
+
*/
|
|
141
|
+
export async function parkGoal(goalRef, reason) {
|
|
142
|
+
if (!goalRef.goalId || !goalRef.targetId) {
|
|
143
|
+
return { goalId: goalRef.goalId, targetId: goalRef.targetId, state: 'parked', error: 'goalId/targetId 必填' };
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
// 1. 拉最近 30 条消息
|
|
147
|
+
const messages = await sessionStore.loadMessages(goalRef.originChannel) ?? [];
|
|
148
|
+
const recentMessages = messages.slice(-30);
|
|
149
|
+
// 2. 找关联 task (用 goalId 反向搜, 或 caller 传 taskId)
|
|
150
|
+
// 简化: caller 负责在 goalRef 里塞 taskId; 此处拿不到 — 跳过
|
|
151
|
+
const taskId = null; // TODO: 反向索引 (后续阶段)
|
|
152
|
+
const taskState_ = null;
|
|
153
|
+
// 3. 落 snapshot
|
|
154
|
+
const snap = {
|
|
155
|
+
goalRef,
|
|
156
|
+
sessionKey: goalRef.originChannel,
|
|
157
|
+
recentMessages,
|
|
158
|
+
taskId,
|
|
159
|
+
taskState: taskState_,
|
|
160
|
+
parkReason: reason,
|
|
161
|
+
parkedAt: new Date().toISOString(),
|
|
162
|
+
schemaVersion: 1,
|
|
163
|
+
};
|
|
164
|
+
await appendSnapshot(snap);
|
|
165
|
+
// 4. 写 park event 留痕 (独立 event.jsonl, 不污染 snapshot 覆盖)
|
|
166
|
+
await appendEvent({ goalId: goalRef.goalId, event: 'goal_parked', targetId: goalRef.targetId, reason });
|
|
167
|
+
// 5. park 关联 task
|
|
168
|
+
await parkAssociatedTask(taskId, goalRef.goalId);
|
|
169
|
+
// 6. 触发 lifecycle hook (留痕到 ~/.bolloon/sessions/goal-parked.jsonl)
|
|
170
|
+
await onGoalParked({
|
|
171
|
+
goalId: goalRef.goalId,
|
|
172
|
+
targetId: goalRef.targetId,
|
|
173
|
+
reason,
|
|
174
|
+
originChannel: goalRef.originChannel,
|
|
175
|
+
sessionKey: goalRef.originChannel,
|
|
176
|
+
taskId,
|
|
177
|
+
});
|
|
178
|
+
return { goalId: goalRef.goalId, targetId: goalRef.targetId, state: 'parked', taskId: taskId ?? undefined };
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
return { goalId: goalRef.goalId, targetId: goalRef.targetId, state: 'parked', error: `park 失败: ${err.message?.slice(0, 100)}` };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* 恢复目标 — 重新加载 session 消息历史 + 把 task 状态置 running.
|
|
186
|
+
*
|
|
187
|
+
* @param goalId park 时记的 goalId
|
|
188
|
+
* @param options newSession = true 时返回新 session key
|
|
189
|
+
*/
|
|
190
|
+
export async function resumeGoal(goalId, options = {}) {
|
|
191
|
+
if (!goalId) {
|
|
192
|
+
return { goalId, targetId: '', state: 'resumed', error: 'goalId 必填' };
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const allSnaps = await loadAllSnapshots();
|
|
196
|
+
const snap = allSnaps.get(goalId);
|
|
197
|
+
if (!snap) {
|
|
198
|
+
return { goalId, targetId: '', state: 'resumed', error: `goal ${goalId} 未找到 (未 park 过)` };
|
|
199
|
+
}
|
|
200
|
+
// 1. 把末 30 条消息灌回 session (供 LLM 立即看到上下文)
|
|
201
|
+
const targetKey = options.newSession
|
|
202
|
+
? `${snap.goalRef.originChannel}:resume-${Date.now()}`
|
|
203
|
+
: snap.goalRef.originChannel;
|
|
204
|
+
await sessionStore.saveMessages(targetKey, snap.recentMessages);
|
|
205
|
+
// 2. resume 关联 task
|
|
206
|
+
await resumeAssociatedTask(snap.taskId, options.newSession ? targetKey : undefined);
|
|
207
|
+
// 3. 写一条 resume 留痕 (独立 event.jsonl)
|
|
208
|
+
await appendEvent({ goalId, event: 'goal_resumed', targetId: snap.goalRef.targetId, resumedIn: targetKey });
|
|
209
|
+
// 4. 触发 lifecycle hook
|
|
210
|
+
await onGoalResumed({
|
|
211
|
+
goalId,
|
|
212
|
+
targetId: snap.goalRef.targetId,
|
|
213
|
+
originChannel: snap.goalRef.originChannel,
|
|
214
|
+
resumedIn: targetKey,
|
|
215
|
+
taskId: snap.taskId,
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
goalId,
|
|
219
|
+
targetId: snap.goalRef.targetId,
|
|
220
|
+
state: 'resumed',
|
|
221
|
+
resumedIn: targetKey,
|
|
222
|
+
taskId: snap.taskId ?? undefined,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
return { goalId, targetId: '', state: 'resumed', error: `resume 失败: ${err.message?.slice(0, 100)}` };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* 把目标推到对端 peer — 包含 park (本机) + P2P 推消息 (对端) + 期望对端 resume.
|
|
231
|
+
*
|
|
232
|
+
* 流程:
|
|
233
|
+
* 1. park 本机的 goal
|
|
234
|
+
* 2. 通过现有 p2pNetwork.sendMessage 发一条 'goal_continue' 类型消息给对端
|
|
235
|
+
* 3. 对端 hook 收到 → 自动调 resumeGoal (对端有同名 hook 处理 — 后续阶段)
|
|
236
|
+
* 4. 返回 handle 含 peerSessionId (对端生成) — 但**对端 id 当前拿不到**, 留 TODO
|
|
237
|
+
*
|
|
238
|
+
* @param goalRef 同 parkGoal
|
|
239
|
+
* @param peerDid 对端 DID (来自 list_peers 结果)
|
|
240
|
+
* @param p2pSendMessage 注入 P2P 发送函数 (避免循环依赖; pi-sdk-tools.ts 注入)
|
|
241
|
+
*/
|
|
242
|
+
export async function continueGoalInBackground(goalRef, peerDid, p2pSendMessage) {
|
|
243
|
+
if (!goalRef.goalId || !peerDid) {
|
|
244
|
+
return { handle: { goalId: goalRef.goalId, targetId: goalRef.targetId, state: 'continued_background', error: 'goalId/peerDid 必填' } };
|
|
245
|
+
}
|
|
246
|
+
// 1. park 本机
|
|
247
|
+
const parkResult = await parkGoal(goalRef, 'peer_handoff');
|
|
248
|
+
if (parkResult.error) {
|
|
249
|
+
return { handle: parkResult };
|
|
250
|
+
}
|
|
251
|
+
// 2. 推给对端
|
|
252
|
+
try {
|
|
253
|
+
// 隐私过滤: 不发 judgment 内容, 只发 target_id + 末 5 条消息摘要
|
|
254
|
+
const safePayload = {
|
|
255
|
+
event: 'goal_continue',
|
|
256
|
+
goalId: goalRef.goalId,
|
|
257
|
+
targetId: goalRef.targetId,
|
|
258
|
+
originChannel: goalRef.originChannel,
|
|
259
|
+
createdBy: goalRef.createdBy,
|
|
260
|
+
recentMessages: (parkResult.error ? [] : (await sessionStore.loadMessages(goalRef.originChannel) ?? [])).slice(-5),
|
|
261
|
+
timestamp: new Date().toISOString(),
|
|
262
|
+
};
|
|
263
|
+
const sendResult = await p2pSendMessage(peerDid, 'goal_continue', JSON.stringify(safePayload));
|
|
264
|
+
if (!sendResult.success) {
|
|
265
|
+
return { handle: { ...parkResult, state: 'continued_background', error: `P2P 推失败: ${sendResult.error?.slice(0, 100)}` } };
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
handle: { ...parkResult, state: 'continued_background', peerSessionId: '(对端生成, 当前拿不到 — TODO)' },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
return { handle: { ...parkResult, state: 'continued_background', error: `continue 失败: ${err.message?.slice(0, 100)}` } };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* 列出所有 park 的 goal — 用于 LLM 切回时找回上下文
|
|
277
|
+
*/
|
|
278
|
+
export async function listParkedGoals(filter = {}) {
|
|
279
|
+
try {
|
|
280
|
+
const all = await loadAllSnapshots();
|
|
281
|
+
let arr = Array.from(all.values());
|
|
282
|
+
if (filter.originChannel)
|
|
283
|
+
arr = arr.filter((s) => s.goalRef.originChannel === filter.originChannel);
|
|
284
|
+
if (filter.createdBy)
|
|
285
|
+
arr = arr.filter((s) => s.goalRef.createdBy === filter.createdBy);
|
|
286
|
+
if (filter.targetIdPrefix)
|
|
287
|
+
arr = arr.filter((s) => s.goalRef.targetId.startsWith(filter.targetIdPrefix));
|
|
288
|
+
return arr.sort((a, b) => b.parkedAt.localeCompare(a.parkedAt));
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 单个 goal 查询
|
|
296
|
+
*/
|
|
297
|
+
export async function getGoal(goalId) {
|
|
298
|
+
try {
|
|
299
|
+
const all = await loadAllSnapshots();
|
|
300
|
+
return all.get(goalId) ?? null;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// ============================================================
|
|
307
|
+
// 调试 / 健康端点用
|
|
308
|
+
// ============================================================
|
|
309
|
+
/** Goal 落盘目录 (只读) */
|
|
310
|
+
export function goalsDir() {
|
|
311
|
+
return GOALS_DIR;
|
|
312
|
+
}
|
|
313
|
+
/** 重置 (测试用) */
|
|
314
|
+
export async function _resetGoalsForTest() {
|
|
315
|
+
try {
|
|
316
|
+
await fs.unlink(SNAPSHOT_FILE);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// 不存在 = OK
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -933,6 +933,105 @@ export function registerBuiltinTools(ctx) {
|
|
|
933
933
|
}
|
|
934
934
|
}
|
|
935
935
|
});
|
|
936
|
+
// ============================================================
|
|
937
|
+
// 2026-07-10 双栖 agent 网络新增: goal handoff 工具
|
|
938
|
+
// (park_goal / resume_goal / continue_goal_background)
|
|
939
|
+
// 内部调用 goal-resume.ts, 错误不抛, 静默返回 error 字段
|
|
940
|
+
// ============================================================
|
|
941
|
+
ctx.tools.set('park_goal', {
|
|
942
|
+
name: 'park_goal',
|
|
943
|
+
description: '暂停当前目标并落盘快照. 切换 channel / 用户离开 / 等对端响应 / 推到对端 前必调. 接收 goalRef (含 goalId + targetId) + reason.',
|
|
944
|
+
parameters: {
|
|
945
|
+
goal_id: '已存在或新生成的 goal ID (建议 goal-${ts}-${rand} 格式)',
|
|
946
|
+
target_id: '用户视角的稳定目标描述 (e.g. "完成财务模块迁移")',
|
|
947
|
+
created_by: 'user | agent | peer',
|
|
948
|
+
origin_channel: '当前 session / channel id',
|
|
949
|
+
reason: 'channel_switch | user_away | awaiting_external | peer_handoff',
|
|
950
|
+
},
|
|
951
|
+
execute: async (args) => {
|
|
952
|
+
try {
|
|
953
|
+
const { parkGoal } = await import('./goal-resume.js');
|
|
954
|
+
const handle = await parkGoal({
|
|
955
|
+
goalId: String(args.goal_id || '').trim(),
|
|
956
|
+
targetId: String(args.target_id || '').trim(),
|
|
957
|
+
createdBy: (args.created_by === 'user' || args.created_by === 'peer') ? args.created_by : 'agent',
|
|
958
|
+
createdAt: new Date().toISOString(),
|
|
959
|
+
originChannel: String(args.origin_channel || '').trim(),
|
|
960
|
+
}, args.reason || 'channel_switch');
|
|
961
|
+
return { success: !handle.error, output: JSON.stringify(handle, null, 2), error: handle.error };
|
|
962
|
+
}
|
|
963
|
+
catch (e) {
|
|
964
|
+
return { success: false, error: `park_goal 失败: ${String(e).slice(0, 200)}` };
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
ctx.tools.set('resume_goal', {
|
|
969
|
+
name: 'resume_goal',
|
|
970
|
+
description: '恢复一个 park 的目标. 加载末 30 条消息到 session + 关联 task 改 running. 切回 channel / 用户回来 / 收到对端 ack 时调.',
|
|
971
|
+
parameters: {
|
|
972
|
+
goal_id: 'park 时记的 goal ID',
|
|
973
|
+
new_session: 'true = 在新 session key 下续, 默认 false (留原 session)',
|
|
974
|
+
channel_id: '可选, 指定 channelId 恢复 (默认 = originChannel)',
|
|
975
|
+
},
|
|
976
|
+
execute: async (args) => {
|
|
977
|
+
try {
|
|
978
|
+
const { resumeGoal } = await import('./goal-resume.js');
|
|
979
|
+
const newSessionStr = String(args.new_session || '').toLowerCase();
|
|
980
|
+
const handle = await resumeGoal(String(args.goal_id || '').trim(), {
|
|
981
|
+
newSession: newSessionStr === 'true' || newSessionStr === '1',
|
|
982
|
+
channelId: args.channel_id ? String(args.channel_id) : undefined,
|
|
983
|
+
});
|
|
984
|
+
return { success: !handle.error, output: JSON.stringify(handle, null, 2), error: handle.error };
|
|
985
|
+
}
|
|
986
|
+
catch (e) {
|
|
987
|
+
return { success: false, error: `resume_goal 失败: ${String(e).slice(0, 200)}` };
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
ctx.tools.set('continue_goal_background', {
|
|
992
|
+
name: 'continue_goal_background',
|
|
993
|
+
description: '把目标推到对端 peer 后台跑. 内部 park 本机 + P2P 推消息 + 隐私过滤 (judgment 不发). 任务大 / 想分工时用.',
|
|
994
|
+
parameters: {
|
|
995
|
+
goal_id: '当前 goal ID',
|
|
996
|
+
target_id: '用户视角目标描述',
|
|
997
|
+
origin_channel: '当前 session id',
|
|
998
|
+
peer_did: '对端 DID (来自 list_peers)',
|
|
999
|
+
},
|
|
1000
|
+
execute: async (args) => {
|
|
1001
|
+
try {
|
|
1002
|
+
const { continueGoalInBackground } = await import('./goal-resume.js');
|
|
1003
|
+
const peerDid = String(args.peer_did || '').trim();
|
|
1004
|
+
if (!peerDid)
|
|
1005
|
+
return { success: false, error: 'peer_did 必填' };
|
|
1006
|
+
// 注入 p2p 发送函数 — 通过 ctx.p2pNetwork 调用
|
|
1007
|
+
const p2pSendMessage = async (peerId, type, message) => {
|
|
1008
|
+
try {
|
|
1009
|
+
// p2pNetwork 在 ctx 里 (由 PiAgentSession 注入)
|
|
1010
|
+
const p2pNetwork = ctx.p2pNetwork;
|
|
1011
|
+
if (!p2pNetwork || typeof p2pNetwork.sendMessage !== 'function') {
|
|
1012
|
+
return { success: false, error: 'p2pNetwork 未注入到 ctx' };
|
|
1013
|
+
}
|
|
1014
|
+
await p2pNetwork.sendMessage(peerId, type, message);
|
|
1015
|
+
return { success: true };
|
|
1016
|
+
}
|
|
1017
|
+
catch (e) {
|
|
1018
|
+
return { success: false, error: String(e).slice(0, 100) };
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
const result = await continueGoalInBackground({
|
|
1022
|
+
goalId: String(args.goal_id || '').trim(),
|
|
1023
|
+
targetId: String(args.target_id || '').trim(),
|
|
1024
|
+
createdBy: 'agent',
|
|
1025
|
+
createdAt: new Date().toISOString(),
|
|
1026
|
+
originChannel: String(args.origin_channel || '').trim(),
|
|
1027
|
+
}, peerDid, p2pSendMessage);
|
|
1028
|
+
return { success: !result.handle.error, output: JSON.stringify(result, null, 2), error: result.handle.error };
|
|
1029
|
+
}
|
|
1030
|
+
catch (e) {
|
|
1031
|
+
return { success: false, error: `continue_goal_background 失败: ${String(e).slice(0, 200)}` };
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
936
1035
|
}
|
|
937
1036
|
/**
|
|
938
1037
|
* 注册 Wallet + Polymarket + Safe 工具 (基于 constraint-runtime/src/tools/).
|
|
@@ -350,6 +350,113 @@ export class CrystalLearnSkill extends BaseSkill {
|
|
|
350
350
|
return this.callLm('You are a failure pattern analyst. Extract failure modes, patterns, and invariants from the task description. Output structured JSON with failure_modes, patterns, invariants.', `Task: ${task}`);
|
|
351
351
|
}
|
|
352
352
|
}
|
|
353
|
+
// ============================================================
|
|
354
|
+
// 2026-07-10 双栖 agent 网络新增 3 个 skill
|
|
355
|
+
// (peer-sync / habit-distill / target-tracker)
|
|
356
|
+
// ============================================================
|
|
357
|
+
/**
|
|
358
|
+
* Peer-Sync Skill — 主动找对端协作.
|
|
359
|
+
*
|
|
360
|
+
* 调用流程:
|
|
361
|
+
* 1. 拿 list_peers (通过 p2pNetwork.getPeers() 或已注入的 ctx.tools)
|
|
362
|
+
* 2. 用 LLM 选最合适的 peer (按 expertise tag / 在线时长 / 任务匹配)
|
|
363
|
+
* 3. send_message 问对方是否接 + 预算
|
|
364
|
+
* 4. 同意后 send_to_channel 建带 target_id 的 channel
|
|
365
|
+
*/
|
|
366
|
+
export class PeerSyncSkill extends BaseSkill {
|
|
367
|
+
name = 'peer-sync';
|
|
368
|
+
description = '主动找对端 bolloon 节点协作. 接收 target_id + task, 自动选 peer + 建 channel. 写 channel 时调 sessionStore.saveMessages + chatArchiver (via goal-resume event log).';
|
|
369
|
+
async execute(params) {
|
|
370
|
+
const targetId = (params.target_id || params.targetId || '');
|
|
371
|
+
const task = (params.task || params.description || '');
|
|
372
|
+
if (!targetId || !task) {
|
|
373
|
+
return this.formatOutput({
|
|
374
|
+
warning: 'target_id 和 task 必填',
|
|
375
|
+
usage: '--harness-skill peer-sync --target_id "完成 X" --task "实现 Y"',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
this.log(`peer-sync 启动: target_id=${targetId}`);
|
|
379
|
+
// 选 peer 的策略由 LLM 决定 (根据 list_peers 输出 + expertise tag)
|
|
380
|
+
return this.callLm(`You are a peer-selection specialist for the bolloon P2P network.
|
|
381
|
+
Given a target_id and task description, output structured JSON with:
|
|
382
|
+
- selectedPeer: DID of the best peer (or null if none suitable)
|
|
383
|
+
- channelName: human-readable name for the new channel
|
|
384
|
+
- initialMessage: first message to send (≤ 200 chars, brief + asks for acceptance)
|
|
385
|
+
- estimatedRounds: expected back-and-forth count (1-10)
|
|
386
|
+
|
|
387
|
+
Rules:
|
|
388
|
+
- Prefer peers with matching expertise tag
|
|
389
|
+
- If no peer matches, return selectedPeer: null
|
|
390
|
+
- Don't over-explain — peer agent has same identity layer as you`, `target_id: ${targetId}\ntask: ${task}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Habit-Distill Skill — 提炼用户习性, 写到 judgment 库.
|
|
395
|
+
*
|
|
396
|
+
* 调用流程:
|
|
397
|
+
* 1. sessionStore.loadMessages(channelId) 拿当前 session 末 30 条
|
|
398
|
+
* 2. LLM 抽取用户习性 (输入习惯 / 偏好术语 / 反复问的主题 / 纠正记录)
|
|
399
|
+
* 3. humanValueStore.storeHumanJudgment({ content, tags: ['habit'], source: 'habit-distill', privacy: 'private' })
|
|
400
|
+
*/
|
|
401
|
+
export class HabitDistillSkill extends BaseSkill {
|
|
402
|
+
name = 'habit-distill';
|
|
403
|
+
description = '从当前 session 提炼用户习性, 写到 ~/.bolloon/human-values/judgments.json. 调 humanValueStore.storeHumanJudgment, 标签 privacy:private.';
|
|
404
|
+
async execute(params) {
|
|
405
|
+
const sessionKey = (params.session_key || params.sessionKey || '');
|
|
406
|
+
const recentMessagesSummary = (params.messages_summary || '');
|
|
407
|
+
if (!sessionKey) {
|
|
408
|
+
return this.formatOutput({
|
|
409
|
+
warning: 'session_key 必填',
|
|
410
|
+
usage: '--harness-skill habit-distill --session_key <key>',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
this.log(`habit-distill 启动: session=${sessionKey}`);
|
|
414
|
+
// 抽取习性的 prompt — 让 LLM 输出结构化 JSON
|
|
415
|
+
return this.callLm(`You are a habit-extraction specialist.
|
|
416
|
+
Given a summary of user-assistant interaction, output JSON with:
|
|
417
|
+
- habits: [{ content: string, tags: string[], weight: 'low'|'medium'|'high' }]
|
|
418
|
+
- content: 一个用户偏好/习性的简短描述 (≤ 100 字)
|
|
419
|
+
- tags: 标签如 ['input-style', 'terminology', 'recurring-topic', 'correction']
|
|
420
|
+
- weight: 影响判断力注入门时的优先级
|
|
421
|
+
- skipReason: 若不该蒸馏 (用户拒绝/任务太简单/judgment 库饱和), 填理由
|
|
422
|
+
|
|
423
|
+
Rules:
|
|
424
|
+
- 习性必须是"用户可观察到的稳定模式", 不是单次偏好
|
|
425
|
+
- 隐私相关打 tags 含 'private', 注入门自动过滤
|
|
426
|
+
- 不要重复已有 judgment (调用方负责查重)`, `Session: ${sessionKey}\nMessages summary: ${recentMessagesSummary || '(caller should pass loadMessages output)'}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Target-Tracker Skill — 跨 channel 查 target_id 进展.
|
|
431
|
+
*
|
|
432
|
+
* 调用流程:
|
|
433
|
+
* 1. goal-resume.ts 的 listParkedGoals({ targetIdPrefix: <id> }) 查所有匹配的 snapshot
|
|
434
|
+
* 2. chatArchiver.listPeerSummaries 查 channel 维度
|
|
435
|
+
* 3. 输出结构化 status: { goalId, targetId, parkedAt, reason, recentProgress[] }
|
|
436
|
+
*/
|
|
437
|
+
export class TargetTrackerSkill extends BaseSkill {
|
|
438
|
+
name = 'target-tracker';
|
|
439
|
+
description = '跨 channel 查 target_id 进展. 调 goal-resume listParkedGoals + chatArchiver.listPeerSummaries. 用于切 channel 时不丢目标状态.';
|
|
440
|
+
async execute(params) {
|
|
441
|
+
const targetId = (params.target_id || params.targetId || '');
|
|
442
|
+
const originChannel = (params.origin_channel || '');
|
|
443
|
+
if (!targetId && !originChannel) {
|
|
444
|
+
return this.formatOutput({
|
|
445
|
+
warning: 'target_id 或 origin_channel 至少传一个',
|
|
446
|
+
usage: '--harness-skill target-tracker --target_id <id>',
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
this.log(`target-tracker 启动: target_id=${targetId} channel=${originChannel}`);
|
|
450
|
+
// status JSON 由调用方负责真实查询 (skill 接收参数, 返回查询模板)
|
|
451
|
+
return this.formatOutput({
|
|
452
|
+
skill: this.name,
|
|
453
|
+
description: this.description,
|
|
454
|
+
query_params: { targetId, originChannel },
|
|
455
|
+
action: 'caller must invoke goal-resume.listParkedGoals + chatArchiver.listPeerSummaries with these params',
|
|
456
|
+
expected_output: 'List of GoalSnapshot + per-channel progress summary',
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
353
460
|
/**
|
|
354
461
|
* Skill Adapter - Registers all bollharness skills with Bolloon's SkillRegistry
|
|
355
462
|
*/
|
|
@@ -372,6 +479,10 @@ export class SkillAdapter {
|
|
|
372
479
|
new HarnessEngSkill(),
|
|
373
480
|
new HarnessEngTestSkill(),
|
|
374
481
|
new CrystalLearnSkill(),
|
|
482
|
+
// 2026-07-10 双栖 agent 网络新增 3 个:
|
|
483
|
+
new PeerSyncSkill(),
|
|
484
|
+
new HabitDistillSkill(),
|
|
485
|
+
new TargetTrackerSkill(),
|
|
375
486
|
];
|
|
376
487
|
for (const skill of adaptedSkills) {
|
|
377
488
|
try {
|
|
@@ -183,3 +183,48 @@ export async function onMonitorViolation(opts) {
|
|
|
183
183
|
console.warn('[lifecycle-hooks] onMonitorViolation failed (silent):', err);
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
|
+
export async function onGoalParked(opts) {
|
|
187
|
+
try {
|
|
188
|
+
const fs = await import('fs/promises');
|
|
189
|
+
const os = await import('os');
|
|
190
|
+
const path = await import('path');
|
|
191
|
+
const file = path.join(process.env.HOME || os.homedir() || '/tmp', '.bolloon', 'sessions', 'goal-parked.jsonl');
|
|
192
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
193
|
+
const entry = {
|
|
194
|
+
ts: new Date().toISOString(),
|
|
195
|
+
goalId: opts.goalId,
|
|
196
|
+
targetId: opts.targetId,
|
|
197
|
+
reason: opts.reason,
|
|
198
|
+
originChannel: opts.originChannel,
|
|
199
|
+
sessionKey: opts.sessionKey,
|
|
200
|
+
taskId: opts.taskId,
|
|
201
|
+
peerDid: opts.peerDid,
|
|
202
|
+
};
|
|
203
|
+
await fs.appendFile(file, JSON.stringify(entry) + '\n', 'utf-8').catch(() => { });
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.warn('[lifecycle-hooks] onGoalParked failed (silent):', err);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
export async function onGoalResumed(opts) {
|
|
210
|
+
try {
|
|
211
|
+
const fs = await import('fs/promises');
|
|
212
|
+
const os = await import('os');
|
|
213
|
+
const path = await import('path');
|
|
214
|
+
const file = path.join(process.env.HOME || os.homedir() || '/tmp', '.bolloon', 'sessions', 'goal-resumed.jsonl');
|
|
215
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
216
|
+
const entry = {
|
|
217
|
+
ts: new Date().toISOString(),
|
|
218
|
+
goalId: opts.goalId,
|
|
219
|
+
targetId: opts.targetId,
|
|
220
|
+
originChannel: opts.originChannel,
|
|
221
|
+
resumedIn: opts.resumedIn,
|
|
222
|
+
taskId: opts.taskId,
|
|
223
|
+
fromPeerDid: opts.fromPeerDid,
|
|
224
|
+
};
|
|
225
|
+
await fs.appendFile(file, JSON.stringify(entry) + '\n', 'utf-8').catch(() => { });
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
console.warn('[lifecycle-hooks] onGoalResumed failed (silent):', err);
|
|
229
|
+
}
|
|
230
|
+
}
|
package/dist/cli-entry.js
CHANGED
|
@@ -23,7 +23,7 @@ const YELLOW = '\x1b[33m';
|
|
|
23
23
|
const GREEN = '\x1b[32m';
|
|
24
24
|
const MAGENTA = '\x1b[35m';
|
|
25
25
|
// 版本信息 — 与 package.json:version 同步, 否则 banner 会显示过时版本误导用户
|
|
26
|
-
const VERSION = '0.2.
|
|
26
|
+
const VERSION = '0.2.15';
|
|
27
27
|
function log(msg, color = RESET) {
|
|
28
28
|
console.log(`${color}${msg}${RESET}`);
|
|
29
29
|
}
|