@bolloon/bolloon-agent 0.6.3 → 0.6.5

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.
@@ -0,0 +1,301 @@
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
+ * · 机会信箱 (外部 agent 投递, 验签通过) 恒优先 —— 真实可信来源
138
+ */
139
+ async function mergeInboxOnTop(all) {
140
+ const { listInboxOpportunities } = await import('./opportunity-inbox.js');
141
+ const inboxOpps = await listInboxOpportunities();
142
+ const inboxCards = inboxOpps
143
+ .filter((o) => o.verification === 'verified') // 只放行验签通过的
144
+ .map((o) => ({
145
+ id: o.id,
146
+ intentId: null,
147
+ source: 'inbox',
148
+ sourceId: o.id,
149
+ score: 0.9,
150
+ reason: 'inbox',
151
+ title: o.title,
152
+ summary: o.summary,
153
+ budget: null,
154
+ matchTags: [],
155
+ createdAt: o.receivedAt,
156
+ inbox: { providerDid: o.provider.did, providerName: o.provider.name, verification: o.verification, refs: o.refs },
157
+ }));
158
+ for (const c of inboxCards.slice(0, 10)) {
159
+ void noteObservation({ ts: Date.now(), kind: 'opportunity-seen', sourceId: c.sourceId, title: c.title, score: c.score, reason: 'inbox' });
160
+ }
161
+ // 2026-10-05: AI 主动上网搜到的机会 (真 URL, 可点开核实) — 排信箱后、本地源前
162
+ const { listSearchOpportunities } = await import('./opportunity-web-search.js');
163
+ const searchOpps = await listSearchOpportunities();
164
+ const searchCards = searchOpps.map((o) => ({
165
+ id: o.id,
166
+ intentId: null,
167
+ source: 'search',
168
+ sourceId: o.id,
169
+ score: 0.8,
170
+ reason: 'search',
171
+ title: o.title,
172
+ summary: o.summary,
173
+ budget: null,
174
+ matchTags: [],
175
+ createdAt: o.searchedAt,
176
+ search: { url: o.url, source: o.source, query: o.query },
177
+ }));
178
+ for (const c of searchCards.slice(0, 10)) {
179
+ void noteObservation({ ts: Date.now(), kind: 'opportunity-seen', sourceId: c.sourceId, title: c.title, score: c.score, reason: 'search' });
180
+ }
181
+ return [...inboxCards, ...searchCards, ...all]; // 信箱 > 搜索 > 本地
182
+ }
183
+ export async function scanOpportunities(opts = {}) {
184
+ const limit = opts.limit ?? 50;
185
+ const minScore = opts.minScore ?? 0.2;
186
+ const intents = await listIntents();
187
+ if (!intents.ok)
188
+ return { ok: false, results: [], error: intents.error };
189
+ const active = intents.intents.filter((i) => i.status === 'active');
190
+ const fb = await readFeedback();
191
+ const board = await listBoard({ openOnly: true });
192
+ const openEntries = board.entries
193
+ .filter((e) => e.claimable && e.status === 'open')
194
+ .filter((e) => !fb.ignored.includes(e.announcementId)); // 校准: 忽略过的不再来
195
+ const now = Date.now();
196
+ if (!active.length) {
197
+ // 画像流: 有用户画像 → 画像标签当常驻意图 (reason=match, 初始化阶段的推送)
198
+ // 无画像 → 默认世界流 (新鲜度优先, 打开即有内容)
199
+ const profile = await readProfile();
200
+ const profileTags = profile?.tags?.filter((t) => t && t.length >= 2) ?? [];
201
+ if (profileTags.length) {
202
+ const matched = [];
203
+ for (const entry of openEntries) {
204
+ const oppText = `${entry.capability} ${entry.instructionPreview || ''}`;
205
+ const oppTags = tagsOf(oppText);
206
+ const to = tagOverlap(profileTags, oppTags);
207
+ const kh = keywordHit(profile?.about ?? '', oppText);
208
+ const bf = 0.5; // 画像无预算信息 → 中性
209
+ const score = 0.5 * to + 0.3 * kh + 0.2 * bf;
210
+ if (score < 0.15)
211
+ continue;
212
+ matched.push(candidateOf(entry, null, Math.round(score * 100) / 100, 'match', profileTags.filter((t) => oppTags.includes(t))));
213
+ }
214
+ const matchedIds = new Set(matched.map((m) => m.sourceId));
215
+ const unmatched = openEntries
216
+ .filter((e) => !matchedIds.has(e.announcementId))
217
+ .map((e) => {
218
+ const ageDays = e.createdAt ? (now - e.createdAt) / 86400000 : 1;
219
+ const freshness = Math.max(0.3, Math.min(0.7, 1 - ageDays / 30));
220
+ return candidateOf(e, null, Math.round(freshness * 100) / 100, 'world', []);
221
+ });
222
+ const all = [...matched, ...unmatched].sort((a, b) => {
223
+ const rank = (r) => (r === 'match' ? 0 : 1);
224
+ if (rank(a.reason) !== rank(b.reason))
225
+ return rank(a.reason) - rank(b.reason);
226
+ return b.score - a.score;
227
+ });
228
+ // 世界观察: 画像流看到的机会也记 memory
229
+ for (const c of all.filter((x) => x.reason === 'match').slice(0, 10)) {
230
+ void noteObservation({ ts: Date.now(), kind: 'opportunity-seen', sourceId: c.sourceId, title: c.title, score: c.score, reason: 'match' });
231
+ }
232
+ return { ok: true, results: (await mergeInboxOnTop(all)).slice(0, limit) };
233
+ }
234
+ // 默认世界流: 新鲜度优先
235
+ const sorted = [...openEntries].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
236
+ const out = sorted.slice(0, limit).map((e) => {
237
+ const ageDays = e.createdAt ? (now - e.createdAt) / 86400000 : 1;
238
+ const freshness = Math.max(0.5, Math.min(0.95, 1 - ageDays / 30));
239
+ return candidateOf(e, null, Math.round(freshness * 100) / 100, 'world', []);
240
+ });
241
+ out.sort((a, b) => b.createdAt - a.createdAt || b.score - a.score);
242
+ // 世界观察: AI 睁眼看到的变化, 记进 memory (自动, 不是人触发)
243
+ for (const c of out.slice(0, 10)) {
244
+ void noteObservation({ ts: Date.now(), kind: 'world-change', sourceId: c.sourceId, title: c.title, score: c.score, reason: 'world' });
245
+ }
246
+ return { ok: true, results: (await mergeInboxOnTop(out)).slice(0, limit) };
247
+ }
248
+ // 有意图: 匹配打分, match 优先; 世界变化不消失
249
+ const matches = [];
250
+ for (const intent of active) {
251
+ for (const entry of openEntries) {
252
+ const oppText = `${entry.capability} ${entry.instructionPreview || ''}`;
253
+ const oppTags = tagsOf(oppText);
254
+ const to = tagOverlap(intent.tags, oppTags);
255
+ const kh = keywordHit(intent.text, oppText);
256
+ const bf = budgetFit(intent.budget, entry.budget ?? null);
257
+ const score = 0.5 * to + 0.3 * kh + 0.2 * bf;
258
+ if (score < minScore)
259
+ continue;
260
+ matches.push(candidateOf(entry, intent.id, Math.round(score * 100) / 100, 'match', intent.tags.filter((t) => oppTags.includes(t))));
261
+ }
262
+ }
263
+ const matchedIds = new Set(matches.map((m) => m.sourceId));
264
+ const unmatched = openEntries
265
+ .filter((e) => !matchedIds.has(e.announcementId))
266
+ .map((e) => {
267
+ const ageDays = e.createdAt ? (now - e.createdAt) / 86400000 : 1;
268
+ const freshness = Math.max(0.3, Math.min(0.7, 1 - ageDays / 30));
269
+ return candidateOf(e, null, Math.round(freshness * 100) / 100, 'world', []);
270
+ });
271
+ const all = [...matches, ...unmatched].sort((a, b) => {
272
+ const rank = (r) => (r === 'match' ? 0 : 1);
273
+ if (rank(a.reason) !== rank(b.reason))
274
+ return rank(a.reason) - rank(b.reason);
275
+ return b.score - a.score;
276
+ });
277
+ return { ok: true, results: (await mergeInboxOnTop(all)).slice(0, limit) };
278
+ }
279
+ /** 对单个意图即时匹配 (list --intent 用; 不走反馈过滤) */
280
+ export async function matchOneIntentText(text, tags, budget, opts = {}) {
281
+ const intent = {
282
+ id: 'int_adhoc', text, tags, priority: 3, budget,
283
+ deadline: null, status: 'active', createdAt: Date.now(), updatedAt: Date.now(), matchedCount: 0,
284
+ };
285
+ const board = await listBoard({ openOnly: true });
286
+ const openEntries = board.entries.filter((e) => e.claimable && e.status === 'open');
287
+ const out = [];
288
+ for (const entry of openEntries) {
289
+ const oppText = `${entry.capability} ${entry.instructionPreview || ''}`;
290
+ const oppTags = tagsOf(oppText);
291
+ const to = tagOverlap(intent.tags, oppTags);
292
+ const kh = keywordHit(intent.text, oppText);
293
+ const bf = budgetFit(intent.budget, entry.budget ?? null);
294
+ const score = 0.5 * to + 0.3 * kh + 0.2 * bf;
295
+ if (score < (opts.minScore ?? 0.2))
296
+ continue;
297
+ out.push(candidateOf(entry, intent.id, Math.round(score * 100) / 100, 'match', intent.tags.filter((t) => oppTags.includes(t))));
298
+ }
299
+ out.sort((x, y) => y.score - x.score || x.id.localeCompare(y.id));
300
+ return out.slice(0, opts.limit ?? 20);
301
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * opportunity-web-search.ts — AI 主动上网搜机会 (2026-10-05, leo: 系统要主动搜索展示)
3
+ *
4
+ * 设计: 世界观察器 (world-watcher) 每轮 tick 时, 若有 active intent / 用户画像,
5
+ * 自动用 DuckDuckGo 搜索「<意图> + 合作/机会/招募/开放」类关键词, 把真搜索结果
6
+ * 结构化落盘 `~/.bolloon/world/search/<id>.json`, 并进世界流 (reason=search, 带来源 URL)。
7
+ *
8
+ * 真实可信: 每条 = 真 URL + 真摘要 (来自搜索返回), 可点开核实, 不是本机编造。
9
+ * 无 TAVILY_API_KEY 时走 DuckDuckGo HTML 端点 (免费无需 key, 与本仓 fetch_url 同源 curl 模式)。
10
+ */
11
+ import * as fs from 'fs/promises';
12
+ import * as path from 'path';
13
+ import * as crypto from 'node:crypto';
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
+ import { worldDir } from './intent-store.js';
17
+ const pExecFile = promisify(execFile);
18
+ const searchDir = () => path.join(worldDir(), 'search');
19
+ function safeId(s) {
20
+ return s.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
21
+ }
22
+ /** DuckDuckGo HTML 搜索 → 结构化结果 (标题/URL/摘要) */
23
+ async function searchDuckDuckGo(query, limit = 8) {
24
+ try {
25
+ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
26
+ const { stdout } = await pExecFile('curl', [
27
+ '-sL', '--max-time', '20', '-A', UA,
28
+ '-H', 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8',
29
+ `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`,
30
+ ], { maxBuffer: 2 * 1024 * 1024, timeout: 25_000 });
31
+ if (!stdout)
32
+ return [];
33
+ // 解析 result__a (标题+URL) + result__snippet (摘要)
34
+ const out = [];
35
+ const itemRe = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>[\s\S]*?<a[^>]*class="result__snippet"[^>]*>(.*?)<\/a>/g;
36
+ let m;
37
+ let guard = 0;
38
+ while ((m = itemRe.exec(stdout)) !== null && guard++ < 30) {
39
+ const rawHref = m[1];
40
+ // DDG 的 href 是重定向包装 (//duckduckgo.com/l/?uddg=<encoded>)
41
+ let url = rawHref;
42
+ const uddg = rawHref.match(/uddg=([^&]+)/);
43
+ if (uddg) {
44
+ try {
45
+ url = decodeURIComponent(uddg[1]);
46
+ }
47
+ catch {
48
+ url = rawHref;
49
+ }
50
+ }
51
+ if (!/^https?:\/\//i.test(url))
52
+ continue;
53
+ const title = m[2].replace(/<[^>]+>/g, '').trim();
54
+ const summary = m[3].replace(/<[^>]+>/g, '').replace(/&[a-z]+;/g, ' ').replace(/\s+/g, ' ').trim();
55
+ if (title)
56
+ out.push({ title, url, summary: summary.slice(0, 200) });
57
+ if (out.length >= limit)
58
+ break;
59
+ }
60
+ return out;
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ }
66
+ /** Tavily 搜索 (配置了 TAVILY_API_KEY 时用, 更完整) */
67
+ async function searchTavily(query, limit = 8) {
68
+ const key = process.env.TAVILY_API_KEY || '';
69
+ if (!key)
70
+ return [];
71
+ try {
72
+ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36';
73
+ const { stdout } = await pExecFile('curl', [
74
+ '-s', '--max-time', '20', '-A', UA,
75
+ '-X', 'POST', 'https://api.tavily.com/search',
76
+ '-H', 'Content-Type: application/json',
77
+ '-d', JSON.stringify({ api_key: key, query, max_results: limit, include_answer: false }),
78
+ ], { maxBuffer: 2 * 1024 * 1024, timeout: 25_000 });
79
+ const d = JSON.parse(stdout);
80
+ return (d.results || []).slice(0, limit).map((x) => ({
81
+ title: String(x.title || '').trim(),
82
+ url: String(x.url || '').trim(),
83
+ summary: String(x.content || '').replace(/\s+/g, ' ').slice(0, 200),
84
+ })).filter((x) => x.title && /^https?:/.test(x.url));
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ }
90
+ /** 搜索查询构造: 意图/画像 + 机会类词 (真搜索, 关键词可配) */
91
+ const OPPORTUNITY_HINTS = ['合作', '机会', '招募', 'open call', 'partnership', 'looking for', 'call for'];
92
+ function buildQueries(topic) {
93
+ const base = String(topic || '').trim().slice(0, 60);
94
+ if (!base)
95
+ return [];
96
+ return OPPORTUNITY_HINTS.map((h) => `${base} ${h}`);
97
+ }
98
+ /**
99
+ * 主动搜索一轮: 按 topic (意图/画像文本) 搜机会, 结构化落盘。
100
+ * 返回本轮新增条数 (已有同 URL 的不重复落盘 — 幂等)。
101
+ */
102
+ export async function searchOpportunitiesForTopic(topic, opts = {}) {
103
+ const limit = opts.limit ?? 6;
104
+ const queries = buildQueries(topic);
105
+ if (!queries.length)
106
+ return { ok: false, added: 0, total: 0, results: [], error: '空 topic' };
107
+ // 合并引擎结果 (Tavily 优先, 无 key 走 DDG)
108
+ const engine = process.env.TAVILY_API_KEY ? searchTavily : searchDuckDuckGo;
109
+ const raw = [];
110
+ for (const q of queries.slice(0, 3)) {
111
+ const hits = await engine(q, limit);
112
+ for (const h of hits)
113
+ raw.push({ ...h, query: q });
114
+ }
115
+ // 已有 URL 集合 (幂等)
116
+ const seen = new Set();
117
+ try {
118
+ const files = await fs.readdir(searchDir());
119
+ for (const f of files) {
120
+ if (!f.endsWith('.json'))
121
+ continue;
122
+ try {
123
+ const p = JSON.parse(await fs.readFile(path.join(searchDir(), f), 'utf-8'));
124
+ if (p && p.url)
125
+ seen.add(String(p.url));
126
+ }
127
+ catch { /* 坏文件跳过 */ }
128
+ }
129
+ }
130
+ catch { /* 目录不存在 = 首次 */ }
131
+ const now = Date.now();
132
+ const results = [];
133
+ let added = 0;
134
+ try {
135
+ await fs.mkdir(searchDir(), { recursive: true });
136
+ }
137
+ catch { /* 落盘失败不阻断 */ }
138
+ for (const r of raw) {
139
+ if (seen.has(r.url))
140
+ continue; // 已有, 不重复
141
+ const id = `search_${safeId(crypto.createHash('sha256').update(r.url).digest('hex').slice(0, 14))}`;
142
+ const opp = {
143
+ id,
144
+ query: r.query,
145
+ title: r.title.slice(0, 80),
146
+ url: r.url,
147
+ summary: r.summary,
148
+ source: process.env.TAVILY_API_KEY ? 'tavily' : 'duckduckgo',
149
+ searchedAt: now,
150
+ };
151
+ try {
152
+ await fs.writeFile(path.join(searchDir(), `${id}.json`), JSON.stringify(opp, null, 2) + '\n', { mode: 0o600 });
153
+ seen.add(r.url);
154
+ results.push(opp);
155
+ added += 1;
156
+ }
157
+ catch { /* 单条失败跳过 */ }
158
+ if (added >= limit)
159
+ break;
160
+ }
161
+ return { ok: true, added, total: results.length, results };
162
+ }
163
+ /** 列出已搜索到的机会 (最近在前) */
164
+ export async function listSearchOpportunities() {
165
+ try {
166
+ const files = await fs.readdir(searchDir());
167
+ const out = [];
168
+ for (const f of files) {
169
+ if (!f.endsWith('.json'))
170
+ continue;
171
+ try {
172
+ const p = JSON.parse(await fs.readFile(path.join(searchDir(), f), 'utf-8'));
173
+ if (p && p.id && p.url)
174
+ out.push(p);
175
+ }
176
+ catch { /* 坏文件跳过 */ }
177
+ }
178
+ return out.sort((a, b) => b.searchedAt - a.searchedAt);
179
+ }
180
+ catch {
181
+ return [];
182
+ }
183
+ }
@@ -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,104 @@
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
+ * 2026-10-05 (leo): 有 active intent / 画像时, 自动上网搜机会 (AI 主动搜索, 非被动响应)。 */
20
+ export async function tickWorldWatcher() {
21
+ if (watching)
22
+ return { ok: true, observed: 0, count: watcherCount, error: '上一轮观察未完成, 跳过本轮 (防重叠)' };
23
+ watching = true;
24
+ try {
25
+ const r = await scanOpportunities({ limit: 50 });
26
+ watcherCount += 1;
27
+ // 2026-10-05: 主动搜索 — 有意图/画像才搜 (无 topic 不烧网络); 失败不影响扫描
28
+ await autoSearchForIntents();
29
+ return { ok: r.ok, observed: r.results.length, count: watcherCount, error: r.error };
30
+ }
31
+ finally {
32
+ watching = false;
33
+ }
34
+ }
35
+ /** 按 active intent + 画像文本自动搜索机会 (AI 主动上网找, 结果进 world/search/) */
36
+ async function autoSearchForIntents() {
37
+ try {
38
+ const { listIntents } = await import('./intent-store.js');
39
+ const intents = await listIntents();
40
+ const active = intents.ok ? intents.intents.filter((i) => i.status === 'active') : [];
41
+ const topics = active.map((i) => i.text);
42
+ if (!topics.length) {
43
+ const profile = await readProfile();
44
+ if (profile && (profile.about || profile.tags.length)) {
45
+ topics.push(profile.about || profile.tags.join(' '));
46
+ }
47
+ }
48
+ if (!topics.length)
49
+ return; // 无意图无画像 → 不主动搜
50
+ // 每轮至多搜 2 个主题 (网络调用有成本; 已有结果幂等不重复)
51
+ for (const t of topics.slice(0, 2)) {
52
+ const { searchOpportunitiesForTopic } = await import('./opportunity-web-search.js');
53
+ const r = await searchOpportunitiesForTopic(t, { limit: 4 });
54
+ if (r.added > 0) {
55
+ console.info(`[world-watcher] 主动搜索「${t.slice(0, 30)}」新增 ${r.added} 条机会`);
56
+ }
57
+ }
58
+ }
59
+ catch { /* 主动搜索失败静默, 下一轮再试 */ }
60
+ }
61
+ /**
62
+ * 启动世界自动观察器 (服务端启动时调用一次即可; 幂等, 重复调用不叠定时器)。
63
+ * 每 intervalMs (默认 10 分钟) 自动扫描一次 —— 机会进入 AI 视野是主动行为, 不是人触发。
64
+ */
65
+ export function startWorldWatcher(intervalMs = 10 * 60 * 1000) {
66
+ if (watcherTimer)
67
+ return { started: false, intervalMs };
68
+ watcherTimer = setInterval(() => {
69
+ void tickWorldWatcher().catch(() => { });
70
+ }, intervalMs);
71
+ // 启动后立刻睁眼一次 (不等第一个周期)
72
+ void tickWorldWatcher().catch(() => { });
73
+ return { started: true, intervalMs };
74
+ }
75
+ export function stopWorldWatcher() {
76
+ if (watcherTimer) {
77
+ clearInterval(watcherTimer);
78
+ watcherTimer = null;
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+ export function worldWatcherStatus() {
84
+ return { running: !!watcherTimer, intervalMs: watcherTimer ? 10 * 60 * 1000 : 0, ticks: watcherCount };
85
+ }
86
+ let dispatchSink = null;
87
+ /** 设置分发主体 (本地默认 = 无操作, 观察日志已是记录; 未来接云端 API 换这个) */
88
+ export function setDispatchSink(sink) {
89
+ dispatchSink = sink;
90
+ }
91
+ export function getDispatchSink() {
92
+ return dispatchSink;
93
+ }
94
+ /** 手动触发一轮观察并走分发主体 (测试/CLI 用) */
95
+ export async function observeWorldOnce() {
96
+ const r = await tickWorldWatcher();
97
+ if (dispatchSink && r.observed > 0) {
98
+ // 分发主体吃到的机会 (本地默认无操作; 云端版在这里把机会推给专门智能体群)
99
+ const board = await scanOpportunities({ limit: 20 });
100
+ if (board.ok)
101
+ await dispatchSink(board.results);
102
+ }
103
+ return r;
104
+ }