@bolloon/bolloon-agent 0.6.4 → 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.
- package/dist/agents/opportunity-inbox.js +147 -0
- package/dist/agents/opportunity-match.js +48 -7
- package/dist/agents/opportunity-web-search.js +183 -0
- package/dist/agents/world-watcher.js +30 -1
- package/dist/cli/intent-command.js +1 -1
- package/dist/ios/bolloon-web.json +3 -3
- package/dist/ios/client.js +80 -82
- package/dist/ios/routes-world.js +54 -0
- package/dist/ios/style.css +39 -0
- package/dist/ios/sw.js +1 -1
- package/dist/web/bolloon-web.json +3 -3
- package/dist/web/client.js +80 -82
- package/dist/web/index.html +27 -46
- package/dist/web/routes-world.js +54 -0
- package/dist/web/style.css +39 -0
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opportunity-inbox.ts — 机会信箱 (2026-10-05, leo: 别人主动投来的机会)
|
|
3
|
+
*
|
|
4
|
+
* 设计: 外部 agent 用 DIAP Ed25519 身份签名投递机会声明到本机信箱,
|
|
5
|
+
* 验签通过才入库 (~/.bolloon/world/inbox/<id>.json) 并进入世界流。
|
|
6
|
+
* 来源可验: 每条机会带 provider DID + 验签状态, 不再是「本机自产」的虚拟感。
|
|
7
|
+
*
|
|
8
|
+
* 协议: bolloon-opportunity-inbox/1
|
|
9
|
+
* POST payload = { title, summary, refs[], provider: {did}, issuedAt }
|
|
10
|
+
* signature = ed25519Sign(privateKey, canonicalize(payload))
|
|
11
|
+
* 验证 = ed25519Verify(providerPublicKey, canonicalize(payload), signature)
|
|
12
|
+
*
|
|
13
|
+
* 未来: 群复制 (OrbitDB 公共群 announce) 是信箱的另一条投递通道, 需要第二节点 (诚实边界)。
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs/promises';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import * as crypto from 'node:crypto';
|
|
18
|
+
import { worldDir } from './intent-store.js';
|
|
19
|
+
import { canonicalize, ed25519Verify } from './x402/paid-info-protocol.js';
|
|
20
|
+
export const INBOX_PROTOCOL = 'bolloon-opportunity-inbox/1';
|
|
21
|
+
const inboxDir = () => path.join(worldDir(), 'inbox');
|
|
22
|
+
function safeId(s) {
|
|
23
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
|
24
|
+
}
|
|
25
|
+
/** 把投递载荷按签名标准序列化 (必须与签名时完全一致) */
|
|
26
|
+
export function canonicalPayload(p) {
|
|
27
|
+
return canonicalize({
|
|
28
|
+
protocol: p.protocol,
|
|
29
|
+
title: p.title,
|
|
30
|
+
summary: p.summary,
|
|
31
|
+
refs: p.refs,
|
|
32
|
+
provider: p.provider,
|
|
33
|
+
issuedAt: p.issuedAt,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** 投递方 DID 的 Ed25519 公钥解析 (did:key 格式 → 32 字节 hex) */
|
|
37
|
+
export async function resolveDidPublicKey(did) {
|
|
38
|
+
try {
|
|
39
|
+
// did:key:z6Mk... — multibase base58btc 编码: [multicodec 0xed01(2B) + 32B 公钥] = 34 字节
|
|
40
|
+
const m = String(did || '').match(/^did:key:z([1-9A-HJ-NP-Za-km-z]+)$/);
|
|
41
|
+
if (!m)
|
|
42
|
+
return null;
|
|
43
|
+
const decoded = Buffer.from(importB58(m[1]));
|
|
44
|
+
if (decoded.length === 34 && decoded[0] === 0xed && decoded[1] === 0x01) {
|
|
45
|
+
return decoded.subarray(2).toString('hex'); // 剥 multicodec, 取 32 字节公钥
|
|
46
|
+
}
|
|
47
|
+
if (decoded.length === 32)
|
|
48
|
+
return decoded.toString('hex'); // 裸 32 字节兜底
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** base58btc 解码 (multibase 'z' 前缀 = base58btc) — 只依赖 Node crypto 的 b58 简易实现 */
|
|
56
|
+
function importB58(s) {
|
|
57
|
+
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
58
|
+
const base = BigInt(58);
|
|
59
|
+
let num = BigInt(0);
|
|
60
|
+
for (const ch of s) {
|
|
61
|
+
const idx = ALPHABET.indexOf(ch);
|
|
62
|
+
if (idx < 0)
|
|
63
|
+
throw new Error('bad base58 char');
|
|
64
|
+
num = num * base + BigInt(idx);
|
|
65
|
+
}
|
|
66
|
+
const bytes = [];
|
|
67
|
+
while (num > 0n) {
|
|
68
|
+
bytes.unshift(Number(num & 0xffn));
|
|
69
|
+
num >>= 8n;
|
|
70
|
+
}
|
|
71
|
+
// 前导 '1' → 前导 0x00
|
|
72
|
+
let leading = 0;
|
|
73
|
+
for (const ch of s) {
|
|
74
|
+
if (ch === '1')
|
|
75
|
+
leading++;
|
|
76
|
+
else
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
return Uint8Array.from([...new Array(leading).fill(0), ...bytes]);
|
|
80
|
+
}
|
|
81
|
+
/** 投递机会到信箱 (验签通过才入库) */
|
|
82
|
+
export async function deliverOpportunity(payload, signatureB64) {
|
|
83
|
+
if (payload.protocol !== INBOX_PROTOCOL)
|
|
84
|
+
return { ok: false, error: `协议不匹配: ${payload.protocol}` };
|
|
85
|
+
const title = String(payload.title || '').trim();
|
|
86
|
+
if (!title)
|
|
87
|
+
return { ok: false, error: '缺 title' };
|
|
88
|
+
const did = String(payload.provider?.did || '').trim();
|
|
89
|
+
if (!did)
|
|
90
|
+
return { ok: false, error: '缺 provider.did' };
|
|
91
|
+
// 1. 验签 (能解析公钥才可能 verified)
|
|
92
|
+
const pub = await resolveDidPublicKey(did);
|
|
93
|
+
let verification;
|
|
94
|
+
if (!pub) {
|
|
95
|
+
verification = 'unverified'; // 公钥解析不了 → 不入库 (不可信来源不污染世界流)
|
|
96
|
+
return { ok: false, error: '无法解析 provider DID 公钥 (拒绝不可信来源)' };
|
|
97
|
+
}
|
|
98
|
+
const okSig = await ed25519Verify(pub, canonicalPayload(payload), signatureB64);
|
|
99
|
+
if (!okSig) {
|
|
100
|
+
verification = 'signature-invalid';
|
|
101
|
+
return { ok: false, error: '签名无效 (拒绝伪造投递)' };
|
|
102
|
+
}
|
|
103
|
+
verification = 'verified';
|
|
104
|
+
// 2. 入库 (一机会一文件)
|
|
105
|
+
const id = `inbox_${safeId(crypto.createHash('sha256').update(canonicalPayload(payload)).digest('hex').slice(0, 16))}`;
|
|
106
|
+
const opp = {
|
|
107
|
+
protocol: payload.protocol,
|
|
108
|
+
id,
|
|
109
|
+
title,
|
|
110
|
+
summary: String(payload.summary || '').trim(),
|
|
111
|
+
refs: Array.isArray(payload.refs) ? payload.refs.map(String).filter(Boolean) : [],
|
|
112
|
+
provider: { did, name: payload.provider?.name },
|
|
113
|
+
issuedAt: payload.issuedAt,
|
|
114
|
+
verification,
|
|
115
|
+
receivedAt: Date.now(),
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
await fs.mkdir(inboxDir(), { recursive: true });
|
|
119
|
+
await fs.writeFile(path.join(inboxDir(), `${id}.json`), JSON.stringify(opp, null, 2) + '\n', { mode: 0o600 });
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
return { ok: false, error: `入库失败: ${err instanceof Error ? err.message : err}` };
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, opportunity: opp };
|
|
125
|
+
}
|
|
126
|
+
/** 列出信箱机会 (verified 优先, 最近在前) */
|
|
127
|
+
export async function listInboxOpportunities() {
|
|
128
|
+
try {
|
|
129
|
+
const files = await fs.readdir(inboxDir());
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const f of files) {
|
|
132
|
+
if (!f.endsWith('.json'))
|
|
133
|
+
continue;
|
|
134
|
+
try {
|
|
135
|
+
const raw = await fs.readFile(path.join(inboxDir(), f), 'utf-8');
|
|
136
|
+
const p = JSON.parse(raw);
|
|
137
|
+
if (p && p.id)
|
|
138
|
+
out.push(p);
|
|
139
|
+
}
|
|
140
|
+
catch { /* 坏文件跳过 */ }
|
|
141
|
+
}
|
|
142
|
+
return out.sort((a, b) => b.receivedAt - a.receivedAt);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -134,7 +134,52 @@ function candidateOf(entry, intentId, score, reason, matchTags) {
|
|
|
134
134
|
* · 无意图: 流入最近 open 公告 (新鲜度优先) —— 打开即有内容
|
|
135
135
|
* · 有意图: 匹配(`match`)优先浮上来, 其余世界变化按新鲜度垫底 (世界不停)
|
|
136
136
|
* · 已忽略的同源公告不流入 (随交互越来越准)
|
|
137
|
+
* · 机会信箱 (外部 agent 投递, 验签通过) 恒优先 —— 真实可信来源
|
|
137
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
|
+
}
|
|
138
183
|
export async function scanOpportunities(opts = {}) {
|
|
139
184
|
const limit = opts.limit ?? 50;
|
|
140
185
|
const minScore = opts.minScore ?? 0.2;
|
|
@@ -184,7 +229,7 @@ export async function scanOpportunities(opts = {}) {
|
|
|
184
229
|
for (const c of all.filter((x) => x.reason === 'match').slice(0, 10)) {
|
|
185
230
|
void noteObservation({ ts: Date.now(), kind: 'opportunity-seen', sourceId: c.sourceId, title: c.title, score: c.score, reason: 'match' });
|
|
186
231
|
}
|
|
187
|
-
return { ok: true, results: all.slice(0, limit) };
|
|
232
|
+
return { ok: true, results: (await mergeInboxOnTop(all)).slice(0, limit) };
|
|
188
233
|
}
|
|
189
234
|
// 默认世界流: 新鲜度优先
|
|
190
235
|
const sorted = [...openEntries].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
|
|
@@ -198,7 +243,7 @@ export async function scanOpportunities(opts = {}) {
|
|
|
198
243
|
for (const c of out.slice(0, 10)) {
|
|
199
244
|
void noteObservation({ ts: Date.now(), kind: 'world-change', sourceId: c.sourceId, title: c.title, score: c.score, reason: 'world' });
|
|
200
245
|
}
|
|
201
|
-
return { ok: true, results: out.slice(0, limit) };
|
|
246
|
+
return { ok: true, results: (await mergeInboxOnTop(out)).slice(0, limit) };
|
|
202
247
|
}
|
|
203
248
|
// 有意图: 匹配打分, match 优先; 世界变化不消失
|
|
204
249
|
const matches = [];
|
|
@@ -229,11 +274,7 @@ export async function scanOpportunities(opts = {}) {
|
|
|
229
274
|
return rank(a.reason) - rank(b.reason);
|
|
230
275
|
return b.score - a.score;
|
|
231
276
|
});
|
|
232
|
-
|
|
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) };
|
|
277
|
+
return { ok: true, results: (await mergeInboxOnTop(all)).slice(0, limit) };
|
|
237
278
|
}
|
|
238
279
|
/** 对单个意图即时匹配 (list --intent 用; 不走反馈过滤) */
|
|
239
280
|
export async function matchOneIntentText(text, tags, budget, opts = {}) {
|
|
@@ -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
|
+
}
|
|
@@ -15,7 +15,8 @@ export { readProfile, setProfile };
|
|
|
15
15
|
let watcherTimer = null;
|
|
16
16
|
let watching = false; // 防重叠: 上一轮没跑完就跳过这一轮
|
|
17
17
|
let watcherCount = 0;
|
|
18
|
-
/** 单轮观察: 扫描世界 → 观察日志/校准环自动积累 (分发主体本地版)
|
|
18
|
+
/** 单轮观察: 扫描世界 → 观察日志/校准环自动积累 (分发主体本地版)。
|
|
19
|
+
* 2026-10-05 (leo): 有 active intent / 画像时, 自动上网搜机会 (AI 主动搜索, 非被动响应)。 */
|
|
19
20
|
export async function tickWorldWatcher() {
|
|
20
21
|
if (watching)
|
|
21
22
|
return { ok: true, observed: 0, count: watcherCount, error: '上一轮观察未完成, 跳过本轮 (防重叠)' };
|
|
@@ -23,12 +24,40 @@ export async function tickWorldWatcher() {
|
|
|
23
24
|
try {
|
|
24
25
|
const r = await scanOpportunities({ limit: 50 });
|
|
25
26
|
watcherCount += 1;
|
|
27
|
+
// 2026-10-05: 主动搜索 — 有意图/画像才搜 (无 topic 不烧网络); 失败不影响扫描
|
|
28
|
+
await autoSearchForIntents();
|
|
26
29
|
return { ok: r.ok, observed: r.results.length, count: watcherCount, error: r.error };
|
|
27
30
|
}
|
|
28
31
|
finally {
|
|
29
32
|
watching = false;
|
|
30
33
|
}
|
|
31
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
|
+
}
|
|
32
61
|
/**
|
|
33
62
|
* 启动世界自动观察器 (服务端启动时调用一次即可; 幂等, 重复调用不叠定时器)。
|
|
34
63
|
* 每 intervalMs (默认 10 分钟) 自动扫描一次 —— 机会进入 AI 视野是主动行为, 不是人触发。
|
|
@@ -174,7 +174,7 @@ export async function opportunityCommand(flags) {
|
|
|
174
174
|
}
|
|
175
175
|
function cardRows(opts) {
|
|
176
176
|
const pct = Math.round(opts.score * 100);
|
|
177
|
-
const tag = opts.reason === 'match' ? '● 与你匹配' : '○ 世界变化';
|
|
177
|
+
const tag = opts.reason === 'match' ? '● 与你匹配' : (opts.reason === 'inbox' ? '✉ 外部投递' : (opts.reason === 'search' ? '🔎 AI 搜索' : '○ 世界变化'));
|
|
178
178
|
return [
|
|
179
179
|
` ${pct}% ${tag} ${opts.title}`,
|
|
180
180
|
` ${opts.summary}`,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": "bolloon-mobile-web/1",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"channel": "stable",
|
|
5
5
|
"build": "stable",
|
|
6
|
-
"commit": "
|
|
7
|
-
"builtAt": "2026-10-
|
|
6
|
+
"commit": "bc1ef6920ae4c4ef7c4d6b6433e81dcf3e17a55d",
|
|
7
|
+
"builtAt": "2026-10-06T07:02:09.463Z",
|
|
8
8
|
"layer": "web",
|
|
9
9
|
"note": "web 资源层构建戳 (手机端 OTA 的身份第 ③ 级回退; 装机后以 OTA state.json 为准)"
|
|
10
10
|
}
|