@bolloon/bolloon-agent 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +24 -17
  2. package/dist/agents/gateway-group.js +172 -4
  3. package/dist/agents/group-autopilot.js +29 -1
  4. package/dist/agents/intent-store.js +193 -0
  5. package/dist/agents/opportunity-match.js +260 -0
  6. package/dist/agents/pi-sdk-tools.js +22 -2
  7. package/dist/agents/pi-sdk.js +8 -1
  8. package/dist/agents/world-profile.js +49 -0
  9. package/dist/agents/world-watcher.js +75 -0
  10. package/dist/cli/commands/tasks.js +191 -1
  11. package/dist/cli/intent-command.js +338 -0
  12. package/dist/cli/mention-data.js +4 -1
  13. package/dist/cli/model-selector.js +84 -104
  14. package/dist/cli/protocol-envelope.js +2 -0
  15. package/dist/cli/setup-wizard.js +2 -10
  16. package/dist/cli/tui-select.js +99 -10
  17. package/dist/cli-entry.js +25 -9
  18. package/dist/electron-build/agents/gateway-group.js +177 -3
  19. package/dist/electron-build/agents/gateway-group.js.map +1 -1
  20. package/dist/electron-build/agents/group-autopilot.js +30 -2
  21. package/dist/electron-build/agents/group-autopilot.js.map +1 -1
  22. package/dist/electron-build/agents/pi-sdk-tools.js +21 -1
  23. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  24. package/dist/electron-build/agents/pi-sdk.js +8 -1
  25. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  26. package/dist/electron-build/llm/config-store.js +2 -2
  27. package/dist/electron-build/llm/config-store.js.map +1 -1
  28. package/dist/electron-build/llm/model-selection.js +16 -2
  29. package/dist/electron-build/llm/model-selection.js.map +1 -1
  30. package/dist/electron-build/llm/pi-ai.js +2 -2
  31. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  32. package/dist/index.js +121 -3
  33. package/dist/ios/bolloon-web.json +3 -3
  34. package/dist/ios/client.js +232 -0
  35. package/dist/ios/index.html +10 -0
  36. package/dist/ios/mobile.css +80 -0
  37. package/dist/ios/mobile.html +10 -0
  38. package/dist/ios/mobile.js +54 -0
  39. package/dist/ios/routes-world.js +105 -0
  40. package/dist/ios/server.js +7 -0
  41. package/dist/ios/sw.js +1 -1
  42. package/dist/llm/config-store.js +2 -2
  43. package/dist/llm/model-selection.js +16 -2
  44. package/dist/llm/pi-ai.js +2 -2
  45. package/dist/web/bolloon-web.json +3 -3
  46. package/dist/web/client.js +232 -0
  47. package/dist/web/index.html +57 -0
  48. package/dist/web/mobile.css +80 -0
  49. package/dist/web/mobile.html +10 -0
  50. package/dist/web/mobile.js +54 -0
  51. package/dist/web/routes-world.js +105 -0
  52. package/dist/web/server.js +7 -0
  53. package/dist/web/sw.js +1 -1
  54. package/package.json +1 -1
@@ -0,0 +1,260 @@
1
+ /**
2
+ * opportunity-match.ts — 意图 ↔ 机会 匹配引擎 (2026-10-05, leo Intent Network P0)
3
+ *
4
+ * ★ 设计原则 (leo 2026-10-05 三次纠偏): **自动校准, 不是「输入→扫描→结果」**。
5
+ * · 默认就是匹配状态: 没有 intent 也流入**世界变化流** (最近的 open 公告, 按新鲜度),
6
+ * 不是「等待意图声明」的空态 —— 打开即有内容。
7
+ * · 匹配随交互越来越准: ignore/accept 写 `world/memory/feedback.json` (负/正证据),
8
+ * 已忽略的同源公告不再流入; 是持续的校准环, 不依赖任何按钮。
9
+ * · 机会源 v1: task board (listBoard, 本地 + 注册表)。后续: 群 announce / x402 商品。
10
+ */
11
+ import * as fs from 'fs/promises';
12
+ import * as path from 'path';
13
+ import { listIntents, worldDir } from './intent-store.js';
14
+ import { listBoard } from './task-board.js';
15
+ import { readProfile } from './world-profile.js';
16
+ function tagsOf(text) {
17
+ const src = String(text || '').toLowerCase();
18
+ const words = src.match(/[a-z][a-z0-9_-]{1,}/g) || [];
19
+ const cjk = src.match(/[\u4e00-\u9fff]{2,}/g) || [];
20
+ return Array.from(new Set([...words, ...cjk]));
21
+ }
22
+ function tagOverlap(a, b) {
23
+ if (!a.length || !b.length)
24
+ return 0;
25
+ const setB = new Set(b);
26
+ const hit = a.filter((t) => setB.has(t));
27
+ return hit.length / Math.max(a.length, 1);
28
+ }
29
+ function keywordHit(intentText, oppText) {
30
+ const words = tagsOf(intentText).filter((t) => t.length >= 3);
31
+ if (!words.length)
32
+ return 0;
33
+ const lower = String(oppText || '').toLowerCase();
34
+ const hit = words.filter((w) => lower.includes(w));
35
+ return hit.length / Math.max(words.length, 1);
36
+ }
37
+ function budgetFit(intentBudget, oppBudget) {
38
+ if (intentBudget === null || oppBudget === null)
39
+ return 0.5; // 缺失 = 中性
40
+ const i = Number(intentBudget) || 0;
41
+ const o = Number(oppBudget.maxAmount) || 0;
42
+ if (i <= 0 || o <= 0)
43
+ return 0.5;
44
+ if (o <= i)
45
+ return 1;
46
+ return Math.max(0, 1 - (o - i) / i);
47
+ }
48
+ const feedback = () => path.join(worldDir(), 'memory', 'feedback.json');
49
+ // ── 世界观察日志 (2026-10-05, leo: 机会进入 AI 视野是主动性行为) ──────────────
50
+ // 每次扫描 = AI 睁眼观察世界; 看到高分机会就记一条观察 (world/memory/events.jsonl)。
51
+ // 这是「AI 自动观察」的落点: 不是人看了记, 是扫描这个动作自己留下视野记录。
52
+ const eventsFile = () => path.join(worldDir(), 'memory', 'events.jsonl');
53
+ /** 追加一条观察 (尽力而为, 失败不阻断扫描) */
54
+ export async function noteObservation(obs) {
55
+ try {
56
+ await fs.mkdir(path.dirname(eventsFile()), { recursive: true });
57
+ await fs.appendFile(eventsFile(), JSON.stringify(obs) + '\n', { mode: 0o600 });
58
+ }
59
+ catch { /* 观察记录失败不阻断 */ }
60
+ }
61
+ /** 读观察日志 (最近 N 条, 倒序) */
62
+ export async function readObservations(limit = 50) {
63
+ try {
64
+ const raw = await fs.readFile(eventsFile(), 'utf-8');
65
+ const lines = raw.split('\n').filter(Boolean).slice(-limit * 2);
66
+ const out = [];
67
+ for (const l of lines) {
68
+ try {
69
+ const p = JSON.parse(l);
70
+ if (p && typeof p === 'object' && typeof p.sourceId === 'string')
71
+ out.push(p);
72
+ }
73
+ catch { /* 坏行跳过 */ }
74
+ }
75
+ return out.slice(-limit).reverse();
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ }
81
+ async function readFeedback() {
82
+ try {
83
+ const raw = await fs.readFile(feedback(), 'utf-8');
84
+ const parsed = JSON.parse(raw);
85
+ return { version: 1, ignored: Array.isArray(parsed.ignored) ? parsed.ignored : [], accepted: Array.isArray(parsed.accepted) ? parsed.accepted : [] };
86
+ }
87
+ catch {
88
+ return { version: 1, ignored: [], accepted: [] };
89
+ }
90
+ }
91
+ async function writeFeedback(fb) {
92
+ try {
93
+ await fs.mkdir(path.dirname(feedback()), { recursive: true });
94
+ await fs.writeFile(feedback(), JSON.stringify(fb, null, 2) + '\n', { mode: 0o600 });
95
+ }
96
+ catch { /* 反馈写失败不阻断 (尽力而为) */ }
97
+ }
98
+ /** 记录交互反馈 (校准环): ignore → 负证据 (同源不再流入), accept → 正证据 */
99
+ export async function recordFeedback(action, sourceId) {
100
+ const sid = String(sourceId || '').trim();
101
+ if (!sid)
102
+ return { ok: false, error: '缺 sourceId' };
103
+ const fb = await readFeedback();
104
+ if (action === 'ignore') {
105
+ if (!fb.ignored.includes(sid))
106
+ fb.ignored.push(sid);
107
+ fb.accepted = fb.accepted.filter((s) => s !== sid);
108
+ }
109
+ else {
110
+ if (!fb.accepted.includes(sid))
111
+ fb.accepted.push(sid);
112
+ fb.ignored = fb.ignored.filter((s) => s !== sid);
113
+ }
114
+ await writeFeedback(fb);
115
+ return { ok: true };
116
+ }
117
+ function candidateOf(entry, intentId, score, reason, matchTags) {
118
+ return {
119
+ id: `opp_${entry.announcementId.slice(0, 12)}`,
120
+ intentId,
121
+ source: entry.source || 'board',
122
+ sourceId: entry.announcementId,
123
+ score,
124
+ reason,
125
+ title: entry.capability || entry.announcementId,
126
+ summary: entry.instructionPreview || '(无预览)',
127
+ budget: entry.budget ? `${entry.budget.maxAmount} ${entry.budget.currency}` : null,
128
+ matchTags,
129
+ createdAt: entry.createdAt ?? Date.now(),
130
+ };
131
+ }
132
+ /**
133
+ * 扫描 = **默认世界流 + 意图校准** (leo: 默认匹配状态, 不用先声明)。
134
+ * · 无意图: 流入最近 open 公告 (新鲜度优先) —— 打开即有内容
135
+ * · 有意图: 匹配(`match`)优先浮上来, 其余世界变化按新鲜度垫底 (世界不停)
136
+ * · 已忽略的同源公告不流入 (随交互越来越准)
137
+ */
138
+ export async function scanOpportunities(opts = {}) {
139
+ const limit = opts.limit ?? 50;
140
+ const minScore = opts.minScore ?? 0.2;
141
+ const intents = await listIntents();
142
+ if (!intents.ok)
143
+ return { ok: false, results: [], error: intents.error };
144
+ const active = intents.intents.filter((i) => i.status === 'active');
145
+ const fb = await readFeedback();
146
+ const board = await listBoard({ openOnly: true });
147
+ const openEntries = board.entries
148
+ .filter((e) => e.claimable && e.status === 'open')
149
+ .filter((e) => !fb.ignored.includes(e.announcementId)); // 校准: 忽略过的不再来
150
+ const now = Date.now();
151
+ if (!active.length) {
152
+ // 画像流: 有用户画像 → 画像标签当常驻意图 (reason=match, 初始化阶段的推送)
153
+ // 无画像 → 默认世界流 (新鲜度优先, 打开即有内容)
154
+ const profile = await readProfile();
155
+ const profileTags = profile?.tags?.filter((t) => t && t.length >= 2) ?? [];
156
+ if (profileTags.length) {
157
+ const matched = [];
158
+ for (const entry of openEntries) {
159
+ const oppText = `${entry.capability} ${entry.instructionPreview || ''}`;
160
+ const oppTags = tagsOf(oppText);
161
+ const to = tagOverlap(profileTags, oppTags);
162
+ const kh = keywordHit(profile?.about ?? '', oppText);
163
+ const bf = 0.5; // 画像无预算信息 → 中性
164
+ const score = 0.5 * to + 0.3 * kh + 0.2 * bf;
165
+ if (score < 0.15)
166
+ continue;
167
+ matched.push(candidateOf(entry, null, Math.round(score * 100) / 100, 'match', profileTags.filter((t) => oppTags.includes(t))));
168
+ }
169
+ const matchedIds = new Set(matched.map((m) => m.sourceId));
170
+ const unmatched = openEntries
171
+ .filter((e) => !matchedIds.has(e.announcementId))
172
+ .map((e) => {
173
+ const ageDays = e.createdAt ? (now - e.createdAt) / 86400000 : 1;
174
+ const freshness = Math.max(0.3, Math.min(0.7, 1 - ageDays / 30));
175
+ return candidateOf(e, null, Math.round(freshness * 100) / 100, 'world', []);
176
+ });
177
+ const all = [...matched, ...unmatched].sort((a, b) => {
178
+ const rank = (r) => (r === 'match' ? 0 : 1);
179
+ if (rank(a.reason) !== rank(b.reason))
180
+ return rank(a.reason) - rank(b.reason);
181
+ return b.score - a.score;
182
+ });
183
+ // 世界观察: 画像流看到的机会也记 memory
184
+ for (const c of all.filter((x) => x.reason === 'match').slice(0, 10)) {
185
+ void noteObservation({ ts: Date.now(), kind: 'opportunity-seen', sourceId: c.sourceId, title: c.title, score: c.score, reason: 'match' });
186
+ }
187
+ return { ok: true, results: all.slice(0, limit) };
188
+ }
189
+ // 默认世界流: 新鲜度优先
190
+ const sorted = [...openEntries].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
191
+ const out = sorted.slice(0, limit).map((e) => {
192
+ const ageDays = e.createdAt ? (now - e.createdAt) / 86400000 : 1;
193
+ const freshness = Math.max(0.5, Math.min(0.95, 1 - ageDays / 30));
194
+ return candidateOf(e, null, Math.round(freshness * 100) / 100, 'world', []);
195
+ });
196
+ out.sort((a, b) => b.createdAt - a.createdAt || b.score - a.score);
197
+ // 世界观察: AI 睁眼看到的变化, 记进 memory (自动, 不是人触发)
198
+ for (const c of out.slice(0, 10)) {
199
+ void noteObservation({ ts: Date.now(), kind: 'world-change', sourceId: c.sourceId, title: c.title, score: c.score, reason: 'world' });
200
+ }
201
+ return { ok: true, results: out.slice(0, limit) };
202
+ }
203
+ // 有意图: 匹配打分, match 优先; 世界变化不消失
204
+ const matches = [];
205
+ for (const intent of active) {
206
+ for (const entry of openEntries) {
207
+ const oppText = `${entry.capability} ${entry.instructionPreview || ''}`;
208
+ const oppTags = tagsOf(oppText);
209
+ const to = tagOverlap(intent.tags, oppTags);
210
+ const kh = keywordHit(intent.text, oppText);
211
+ const bf = budgetFit(intent.budget, entry.budget ?? null);
212
+ const score = 0.5 * to + 0.3 * kh + 0.2 * bf;
213
+ if (score < minScore)
214
+ continue;
215
+ matches.push(candidateOf(entry, intent.id, Math.round(score * 100) / 100, 'match', intent.tags.filter((t) => oppTags.includes(t))));
216
+ }
217
+ }
218
+ const matchedIds = new Set(matches.map((m) => m.sourceId));
219
+ const unmatched = openEntries
220
+ .filter((e) => !matchedIds.has(e.announcementId))
221
+ .map((e) => {
222
+ const ageDays = e.createdAt ? (now - e.createdAt) / 86400000 : 1;
223
+ const freshness = Math.max(0.3, Math.min(0.7, 1 - ageDays / 30));
224
+ return candidateOf(e, null, Math.round(freshness * 100) / 100, 'world', []);
225
+ });
226
+ const all = [...matches, ...unmatched].sort((a, b) => {
227
+ const rank = (r) => (r === 'match' ? 0 : 1);
228
+ if (rank(a.reason) !== rank(b.reason))
229
+ return rank(a.reason) - rank(b.reason);
230
+ return b.score - a.score;
231
+ });
232
+ // 世界观察: 高分匹配 = AI 主动看到的机会 (自动记 memory)
233
+ for (const c of all.filter((x) => x.reason === 'match').slice(0, 10)) {
234
+ void noteObservation({ ts: Date.now(), kind: 'opportunity-seen', sourceId: c.sourceId, title: c.title, score: c.score, reason: 'match' });
235
+ }
236
+ return { ok: true, results: all.slice(0, limit) };
237
+ }
238
+ /** 对单个意图即时匹配 (list --intent 用; 不走反馈过滤) */
239
+ export async function matchOneIntentText(text, tags, budget, opts = {}) {
240
+ const intent = {
241
+ id: 'int_adhoc', text, tags, priority: 3, budget,
242
+ deadline: null, status: 'active', createdAt: Date.now(), updatedAt: Date.now(), matchedCount: 0,
243
+ };
244
+ const board = await listBoard({ openOnly: true });
245
+ const openEntries = board.entries.filter((e) => e.claimable && e.status === 'open');
246
+ const out = [];
247
+ for (const entry of openEntries) {
248
+ const oppText = `${entry.capability} ${entry.instructionPreview || ''}`;
249
+ const oppTags = tagsOf(oppText);
250
+ const to = tagOverlap(intent.tags, oppTags);
251
+ const kh = keywordHit(intent.text, oppText);
252
+ const bf = budgetFit(intent.budget, entry.budget ?? null);
253
+ const score = 0.5 * to + 0.3 * kh + 0.2 * bf;
254
+ if (score < (opts.minScore ?? 0.2))
255
+ continue;
256
+ out.push(candidateOf(entry, intent.id, Math.round(score * 100) / 100, 'match', intent.tags.filter((t) => oppTags.includes(t))));
257
+ }
258
+ out.sort((x, y) => y.score - x.score || x.id.localeCompare(y.id));
259
+ return out.slice(0, opts.limit ?? 20);
260
+ }
@@ -24,7 +24,7 @@ import { registerSkillShareTools } from './skill-share.js';
24
24
  // 2026-09-13: 微支付信息服务 (x402 付费信息 + 验真信封)
25
25
  import { registerPaidInfoTools } from './x402/paid-info-tools.js';
26
26
  // 2026-10-02: 群聊的四个 AI 工具 —— 原语来自群模块与"唯一发送出口"(带隐私闸)
27
- import { joinGroup, listGroups, groupLink, groupMessages, groupMembers } from './gateway-group.js';
27
+ import { createGroup, joinGroup, listGroups, groupLink, groupMessages, groupMembers } from './gateway-group.js';
28
28
  import { resolveGroupRef, resolveSenderTag, sendTrailMessage } from './task-group.js';
29
29
  // 2026-10-02: 群聊自主回路 (读 → 自己判断 → 自己发言) —— AI 用 group_autopilot 工具驱动
30
30
  import { loadAutopilotState, runGroupAutopilotOnce, startGroupAutopilot } from './group-autopilot.js';
@@ -970,6 +970,25 @@ export function registerBuiltinTools(ctx) {
970
970
  return out.length ? out : undefined;
971
971
  };
972
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
+ });
973
992
  ctx.tools.set('group_join', {
974
993
  name: 'group_join',
975
994
  description: '加入一个 OrbitDB 群聊 —— **用于**拿到邀请链接后自助入群(幂等: 已在群里 ⇒ already)。link 传邀请链接(orbitdb:///orbitdb/<addr>?type=group&name=…)或 groupId。入群后先用 group_read 读消息、再用 group_say 发言。',
@@ -1186,7 +1205,7 @@ export function registerBuiltinTools(ctx) {
1186
1205
  ctx.tools.set('group_autopilot', {
1187
1206
  name: 'group_autopilot',
1188
1207
  description: '群聊自主回路 —— **用于**让 AI 自己读群、自己判断该不该开口、自己在群里发言(不用人盯着)。action: run(立刻跑一轮) / start(挂成常驻, 每 intervalMs 一轮) / stop / status。默认只在「被 @」或「有人提问/请人做事」时说话, 冷却 60s、每小时每群最多 6 次, 处理过的消息不会重复回应。',
1189
- parameters: { action: 'run | start | stop | status(必填)', intervalMs: '常驻时的间隔 ms(默认 60000)', group: '只盯某个群(groupId/链接/群名, 可选; 缺省 = 全部已加入的群)' },
1208
+ parameters: { action: 'run | start | stop | status(必填)', intervalMs: '常驻时的间隔 ms(默认 60000)', mode: 'quiet | active | maintenance(可选; maintenance 专门维护 Bolloon)', group: '只盯某个群(groupId/链接/群名, 可选; 缺省 = 全部已加入的群)' },
1190
1209
  execute: async (args) => {
1191
1210
  const action = String(args?.action ?? 'run').trim();
1192
1211
  const who = await resolveSenderTag(null);
@@ -1206,6 +1225,7 @@ export function registerBuiltinTools(ctx) {
1206
1225
  readMessages: (gid, limit) => groupMessages(gid, limit),
1207
1226
  speak: (gid, text, opts) => sendTrailMessage(gid, text, who.tag, opts ?? {}),
1208
1227
  state,
1228
+ policy: { mode: String(args?.mode ?? 'quiet') },
1209
1229
  };
1210
1230
  if (action === 'stop') {
1211
1231
  if (!autopilotHandle)
@@ -1099,7 +1099,14 @@ export class PiAgentSession {
1099
1099
  return `[初始化未就绪] ${why}\n${hint}`;
1100
1100
  }
1101
1101
  }
1102
- catch { /* 门禁自身异常 → 按"不能判定"处理会阻塞一切; 这里只在能读到状态时才拦 */ }
1102
+ catch (err) {
1103
+ // fail-closed: 门禁异常不能绕过初始化,否则会出现"身份未就绪但 Agent 已调用工具"。
1104
+ const why = `初始化门禁读取失败: ${String(err?.message ?? err).slice(0, 240)}`;
1105
+ this.pushHistory({ role: 'user', content: input });
1106
+ this.pushHistory({ role: 'assistant', content: `[初始化未就绪] ${why}\n请运行 bolloon setup status;修复后再重试。` });
1107
+ console.warn(`[PiAgent] 拒绝执行: ${why}`);
1108
+ return `[初始化未就绪] ${why}\n请运行 bolloon setup status;修复后再重试。`;
1109
+ }
1103
1110
  }
1104
1111
  this.minimaxAvailable = this.checkMinimax();
1105
1112
  this.actor.state.channelId = options?.channelId ?? this.actor.state.channelId;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * world-profile.ts — 用户画像 (初始化阶段收集, 常驻意图)
3
+ *
4
+ * leo (2026-10-05) 三条分发规划之③: 结合用户画像推送 —— 初始化时 AI 获取
5
+ * 「你是谁/在做什么/标签」, 无 active intent 时画像标签当常驻意图参与匹配。
6
+ * 独立文件是为了避免 opportunity-match ↔ world-watcher 循环依赖。
7
+ */
8
+ import * as fs from 'fs/promises';
9
+ import * as path from 'path';
10
+ import { worldDir } from './intent-store.js';
11
+ const profileFile = () => path.join(worldDir(), 'profile.json');
12
+ export async function readProfile() {
13
+ try {
14
+ const raw = await fs.readFile(profileFile(), 'utf-8');
15
+ const p = JSON.parse(raw);
16
+ if (!p || typeof p !== 'object')
17
+ return null;
18
+ return {
19
+ name: String(p.name || ''),
20
+ about: String(p.about || ''),
21
+ tags: Array.isArray(p.tags) ? p.tags.filter((t) => typeof t === 'string') : [],
22
+ updatedAt: Number(p.updatedAt) || Date.now(),
23
+ };
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** 写/更新用户画像 (初始化收集用) */
30
+ export async function setProfile(input) {
31
+ const prev = await readProfile();
32
+ const profile = {
33
+ name: String(input.name ?? prev?.name ?? '').trim(),
34
+ about: String(input.about ?? prev?.about ?? '').trim(),
35
+ tags: Array.isArray(input.tags) ? input.tags.map((t) => String(t).trim()).filter(Boolean) : (prev?.tags ?? []),
36
+ updatedAt: Date.now(),
37
+ };
38
+ if (!profile.name && !profile.about && !profile.tags.length) {
39
+ return { ok: false, profile, error: '画像至少要有一个字段 (name/about/tags)' };
40
+ }
41
+ try {
42
+ await fs.mkdir(path.dirname(profileFile()), { recursive: true });
43
+ await fs.writeFile(profileFile(), JSON.stringify(profile, null, 2) + '\n', { mode: 0o600 });
44
+ return { ok: true, profile };
45
+ }
46
+ catch (err) {
47
+ return { ok: false, profile, error: String(err instanceof Error ? err.message : err) };
48
+ }
49
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * world-watcher.ts — 世界自动观察器 + 分发主体插槽 (2026-10-05, leo 三条分发规划)
3
+ *
4
+ * ① 自动触发机制: startWorldWatcher() 启动服务端定时扫描 —— AI 持续睁眼观察世界,
5
+ * 不依赖用户打开界面、不依赖按钮。丝滑 = 无人值守, 观察日志自己积累。
6
+ * ② 专门分发主体: 本地版分发主体 = 观察日志 (world/memory/events.jsonl) + 校准环。
7
+ * 未来云端模型/API/专门智能体群 = 换 `dispatchSink` 实现, 接口不变。
8
+ * ③ 用户画像推送: world/profile.json —— 初始化阶段收集 (你是谁/在做什么/标签),
9
+ * 作为常驻意图参与匹配: 无 active intent 时, 画像标签就是意图 (reason=match)。
10
+ */
11
+ import { scanOpportunities } from './opportunity-match.js';
12
+ import { readProfile, setProfile } from './world-profile.js';
13
+ export { readProfile, setProfile };
14
+ // ── 自动触发机制 (服务端定时扫描) ─────────────────────────────────────────────
15
+ let watcherTimer = null;
16
+ let watching = false; // 防重叠: 上一轮没跑完就跳过这一轮
17
+ let watcherCount = 0;
18
+ /** 单轮观察: 扫描世界 → 观察日志/校准环自动积累 (分发主体本地版) */
19
+ export async function tickWorldWatcher() {
20
+ if (watching)
21
+ return { ok: true, observed: 0, count: watcherCount, error: '上一轮观察未完成, 跳过本轮 (防重叠)' };
22
+ watching = true;
23
+ try {
24
+ const r = await scanOpportunities({ limit: 50 });
25
+ watcherCount += 1;
26
+ return { ok: r.ok, observed: r.results.length, count: watcherCount, error: r.error };
27
+ }
28
+ finally {
29
+ watching = false;
30
+ }
31
+ }
32
+ /**
33
+ * 启动世界自动观察器 (服务端启动时调用一次即可; 幂等, 重复调用不叠定时器)。
34
+ * 每 intervalMs (默认 10 分钟) 自动扫描一次 —— 机会进入 AI 视野是主动行为, 不是人触发。
35
+ */
36
+ export function startWorldWatcher(intervalMs = 10 * 60 * 1000) {
37
+ if (watcherTimer)
38
+ return { started: false, intervalMs };
39
+ watcherTimer = setInterval(() => {
40
+ void tickWorldWatcher().catch(() => { });
41
+ }, intervalMs);
42
+ // 启动后立刻睁眼一次 (不等第一个周期)
43
+ void tickWorldWatcher().catch(() => { });
44
+ return { started: true, intervalMs };
45
+ }
46
+ export function stopWorldWatcher() {
47
+ if (watcherTimer) {
48
+ clearInterval(watcherTimer);
49
+ watcherTimer = null;
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+ export function worldWatcherStatus() {
55
+ return { running: !!watcherTimer, intervalMs: watcherTimer ? 10 * 60 * 1000 : 0, ticks: watcherCount };
56
+ }
57
+ let dispatchSink = null;
58
+ /** 设置分发主体 (本地默认 = 无操作, 观察日志已是记录; 未来接云端 API 换这个) */
59
+ export function setDispatchSink(sink) {
60
+ dispatchSink = sink;
61
+ }
62
+ export function getDispatchSink() {
63
+ return dispatchSink;
64
+ }
65
+ /** 手动触发一轮观察并走分发主体 (测试/CLI 用) */
66
+ export async function observeWorldOnce() {
67
+ const r = await tickWorldWatcher();
68
+ if (dispatchSink && r.observed > 0) {
69
+ // 分发主体吃到的机会 (本地默认无操作; 云端版在这里把机会推给专门智能体群)
70
+ const board = await scanOpportunities({ limit: 20 });
71
+ if (board.ok)
72
+ await dispatchSink(board.results);
73
+ }
74
+ return r;
75
+ }