@bolloon/bolloon-agent 0.6.3 → 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.
package/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # Bolloon
2
2
 
3
- **一万个智能体,为你工作。**
3
+ **一万个智能体的世界,走进你的世界。**
4
4
 
5
- Bolloon 是一台跑在你自己设备上的智能体。它有自己的身份(DID)、自己的记忆、自己的技能库,
6
- 并且能在点对点网络里**找到别的智能体、把自己的活委派出去、按结果结算**。
5
+ Bolloon 是一个多终端的 Agent 分发网络。它带来的不是一个更大更听话的助手,而是一万个智能体
6
+ 所在的世界——每个智能体都有自己的身份(DID)、自己的记忆、自己的技能库,并且能在点对点
7
+ 网络里**找到别的智能体、把自己的活委派出去、按结果结算**。
7
8
 
8
- 不是「又一个聊天框」,而是**一个可以互相接手工作的智能体网络**。
9
+ 不是「又一个聊天框」,而是**一个可以互相接手工作的智能体世界,在不同设备上以不同方式分发**:
10
+ PC 上创建、手机上发现、生活中相遇。
9
11
 
10
12
  > 官网 · https://bolloon.cn | 安装 · `npm i -g @bolloon/bolloon-agent`([npm 上的最新版](https://www.npmjs.com/package/@bolloon/bolloon-agent))
11
13
 
@@ -19,9 +21,10 @@ Bolloon 是一台跑在你自己设备上的智能体。它有自己的身份(
19
21
  Bolloon 把这件事变成网络问题:**每个智能体都有身份、技能和账本**,于是它可以被别的智能体
20
22
  发现、被委派、被结算——你只需要说清目标。
21
23
 
22
- 这也是我们的方向:让 C 端用户在自己的电脑上,拥有一条**由很多智能体组成的生产线**,
23
- 而不只是一个对话框。我自己每天用它做的事是**可控核聚变的研究**——线圈设计、仿真、验证、
24
- 论文形式化,由不同智能体分头接手,我只看结论。
24
+ 这也是我们的方向:不是给你更多个对话框,而是让**一万个智能体所在的世界,走进你的世界**——
25
+ 你在自己的电脑上拥有一条由很多智能体组成的生产线,它跟着你用,而不是你跟着它学。
26
+ 我自己每天用它做的事是**可控核聚变的研究**——线圈设计、仿真、验证、论文形式化,
27
+ 由不同智能体分头接手,我只看结论。
25
28
 
26
29
  ---
27
30
 
@@ -89,7 +92,7 @@ docker run -d --name bolloon -p 127.0.0.1:54188:54188 \
89
92
  **已经稳定**:CLI 交互 · 技能库与提炼 · 工具调用与类型门 · 后台进程与服务管理 · 链上任务与结算 · P2P 发现与委派。
90
93
 
91
94
  **还在早期**:多智能体之间的**经济闭环**(谁付钱、怎么分账、争议怎么裁)只有最基础的一层;
92
- 「一万个智能体」是方向,不是今天打开就能看到的数量。
95
+ 「一个装着上万智能体的世界走进你生活」是方向,不是今天打开就能看到的世界大小。
93
96
 
94
97
  **我们不说的话**:不承诺「全自动无需照看」;链上记录不可撤销;模型能力是上限,
95
98
  智能体不会超过它用的模型。
@@ -117,7 +120,7 @@ npm run build:all
117
120
  npm start
118
121
  ```
119
122
 
120
- 跑测试:`npx vitest run`(当前 304 个文件 / 4609 个用例)。
123
+ 跑测试:`npx vitest run`(当前 342 个文件 / 4752 个用例)。
121
124
  改了 TypeScript 后:`npx tsc --noEmit`。
122
125
 
123
126
  ---
@@ -132,13 +135,15 @@ MIT。见 [LICENSE](./LICENSE)。
132
135
 
133
136
  # Bolloon (English)
134
137
 
135
- **Ten thousand agents, working for you.**
138
+ **A world of ten thousand agents, stepping into yours.**
136
139
 
137
- Bolloon is an agent that runs on your own machine. It has its own identity (DID), its own memory and
138
- its own library of skills, and it can **find other agents on a peer-to-peer network, hand work over,
139
- and settle by result**.
140
+ Bolloon is a multi-device agent distribution network. It brings not a bigger, more obedient assistant,
141
+ but a world where ten thousand agents live — each with its own identity (DID), its own memory and its
142
+ own library of skills, able to **find other agents on a peer-to-peer network, hand work over, and
143
+ settle by result**.
140
144
 
141
- Not "yet another chat box" — **a network of agents that can take over each other's work.**
145
+ Not "yet another chat box" — **a world of agents that can take over each other's work, distributed
146
+ differently on each device**: created on PC, discovered on phone, met in life.
142
147
 
143
148
  > Website · https://bolloon.cn | Install · `npm i -g @bolloon/bolloon-agent`([latest on npm](https://www.npmjs.com/package/@bolloon/bolloon-agent))
144
149
 
@@ -150,8 +155,9 @@ roles: someone researches, someone writes, someone verifies, someone makes sure
150
155
  Bolloon turns that into a network problem. Every agent has an identity, skills and a ledger, so it can
151
156
  be discovered, delegated to and paid. You only have to state the goal.
152
157
 
153
- That is the direction: a **production line made of many agents**, running on a consumer machine —
154
- not a single dialogue box. I use it every day for **controlled nuclear fusion** research.
158
+ That is the direction: not more chat boxes, but **a world of ten thousand agents stepping into yours**
159
+ — a production line made of many agents, running on a consumer machine, following you rather than
160
+ you following it. I use it every day for **controlled nuclear fusion** research.
155
161
 
156
162
  ## What works today
157
163
 
@@ -182,7 +188,8 @@ Node.js ≥ 18. First run walks you through picking a model provider.
182
188
 
183
189
  **Stable**: CLI · skills · tool calling · background services · on-chain tasks · P2P discovery and
184
190
  delegation. **Early**: the economics between agents (who pays, how it splits, how disputes resolve).
185
- "Ten thousand agents" is a direction, not a number you will see on day one.
191
+ "A world of ten thousand agents entering your life" is a direction, not the world-size you will see
192
+ on day one.
186
193
 
187
194
  **What we will not claim**: fully unattended operation; reversible on-chain records; capability beyond
188
195
  the model you plug in.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * intent-store.ts — Intent Store (2026-10-05, leo 四端 Intent Network 设计 P0)
3
+ *
4
+ * Intent = Goal 的**前身**: 一句话「我正在做 X」+ 标签/预算/期限, 未到可执行程度。
5
+ * 它与 goal-flywheel 的 Goal 不冲突 (Goal 是意图的可执行投影):
6
+ * · intent 是轻量声明 (world/intents/ 一意图一文件)
7
+ * · 升级成可执行意图时 → 转 Goal (goal-flywheel)
8
+ * 接口层不重叠, 本模块不碰 goal-store。
9
+ *
10
+ * 存储布局 (leo: 「intents 专门设计一个文件夹」「world 也是设计文件夹更好」):
11
+ * ~/.bolloon/world/ ← World 根 (六元组本体的实体根)
12
+ * ├── intents/ ← Intent 实体目录 (一意图一文件, 原子写)
13
+ * │ └── int_<id>.json
14
+ * ├── opportunities/ ← (P1) Opportunity 实体目录
15
+ * ├── actions/ ← (后续) Action 实体目录
16
+ * └── memory/ ← (后续) Memory 实体目录
17
+ * 每个实体独立文件 ⇒ 原子写 / 并发安全 / 可扩展, 与 task-board 的 announcement 文件模式同源。
18
+ */
19
+ import * as fs from 'fs/promises';
20
+ import * as os from 'os';
21
+ import * as path from 'path';
22
+ import * as crypto from 'crypto';
23
+ const homeOf = (home) => home || process.env.HOME || os.homedir();
24
+ /** World 根目录 (~/.bolloon/world/) */
25
+ export const worldDir = (home) => path.join(homeOf(home), '.bolloon', 'world');
26
+ /** Intent 实体目录 (~/.bolloon/world/intents/) */
27
+ export const intentsDir = (home) => path.join(worldDir(home), 'intents');
28
+ /** Opportunity 实体目录 (~/.bolloon/world/opportunities/, P1) */
29
+ export const opportunitiesDir = (home) => path.join(worldDir(home), 'opportunities');
30
+ /** 只允许 int_ 开头的 id 当文件名 (防路径穿越); 非法 → null */
31
+ function safeId(id) {
32
+ const s = String(id || '').trim();
33
+ return /^int_[A-Za-z0-9]{6,32}$/.test(s) ? s : null;
34
+ }
35
+ /** 意图文件路径 (非法 id → null) */
36
+ export function intentFile(id, home) {
37
+ const safe = safeId(id);
38
+ return safe ? path.join(intentsDir(home), `${safe}.json`) : null;
39
+ }
40
+ async function ensure(dir) {
41
+ try {
42
+ await fs.mkdir(dir, { recursive: true });
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ async function writeJson(file, value) {
50
+ try {
51
+ await ensure(path.dirname(file));
52
+ await fs.writeFile(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
53
+ return { ok: true };
54
+ }
55
+ catch (e) {
56
+ return { ok: false, error: String(e?.message || e).slice(0, 200) };
57
+ }
58
+ }
59
+ async function readJson(file) {
60
+ try {
61
+ const raw = await fs.readFile(file, 'utf-8');
62
+ const parsed = JSON.parse(raw);
63
+ if (!parsed || typeof parsed !== 'object')
64
+ return { ok: false, error: `${path.basename(file)} 不是对象` };
65
+ return { ok: true, value: parsed };
66
+ }
67
+ catch (e) {
68
+ if (e?.code === 'ENOENT')
69
+ return { ok: false, error: 'ENOENT' };
70
+ return { ok: false, error: `读 ${path.basename(file)} 失败: ${String(e?.message || e).slice(0, 120)}` };
71
+ }
72
+ }
73
+ /** 标签: 从意图正文提取简单标签 (英文词 + 中文 2 字以上词元) + 手工标签去重 */
74
+ export function extractTags(text, manual = []) {
75
+ const src = String(text || '').toLowerCase();
76
+ const words = src.match(/[a-z][a-z0-9_-]{1,}/g) || [];
77
+ const cjk = src.match(/[\u4e00-\u9fff]{2,}/g) || [];
78
+ const all = [...words, ...cjk, ...manual.map((t) => t.toLowerCase().trim()).filter(Boolean)];
79
+ return Array.from(new Set(all)).slice(0, 24);
80
+ }
81
+ /** 扫描 intents 目录, 列出全部意图 (按创建时间倒序) */
82
+ export async function listIntents(home, status) {
83
+ const dir = intentsDir(home);
84
+ let entries;
85
+ try {
86
+ entries = await fs.readdir(dir);
87
+ }
88
+ catch (e) {
89
+ if (e?.code === 'ENOENT')
90
+ return { ok: true, intents: [] };
91
+ return { ok: false, intents: [], error: `读 ${dir} 失败: ${String(e?.message || e).slice(0, 120)}` };
92
+ }
93
+ const intents = [];
94
+ for (const name of entries.filter((n) => n.startsWith('int_') && n.endsWith('.json'))) {
95
+ const r = await readJson(path.join(dir, name));
96
+ if (!r.ok || !r.value || !r.value.id)
97
+ continue; // 坏文件跳过, 不崩
98
+ intents.push(r.value);
99
+ }
100
+ intents.sort((a, b) => b.createdAt - a.createdAt);
101
+ return { ok: true, intents: status ? intents.filter((i) => i.status === status) : intents };
102
+ }
103
+ /** 单条意图按 id 读 (不存在 → ok:true 无 intent) */
104
+ export async function getIntent(id, home) {
105
+ const file = intentFile(id, home);
106
+ if (!file)
107
+ return { ok: false, error: `非法意图 id: ${String(id).slice(0, 32)}` };
108
+ const r = await readJson(file);
109
+ if (!r.ok)
110
+ return r.error === 'ENOENT' ? { ok: true } : r;
111
+ return { ok: true, intent: r.value };
112
+ }
113
+ /** 声明意图 (幂等: 同 text 已存在 → 更新 tags/priority, 不重复建文件) */
114
+ export async function setIntent(input) {
115
+ const text = String(input.text || '').trim();
116
+ if (!text)
117
+ return { ok: false, created: false, error: '意图不能为空' };
118
+ const priority = input.priority ?? 3;
119
+ if (!Number.isInteger(priority) || priority < 1 || priority > 5)
120
+ return { ok: false, created: false, error: `priority 必须 1-5 整数, 实得 ${String(priority)}` };
121
+ const loaded = await listIntents(input.home);
122
+ if (!loaded.ok)
123
+ return { ok: false, created: false, error: loaded.error };
124
+ const now = Date.now();
125
+ const existing = loaded.intents.find((i) => i.text === text && i.status === 'active');
126
+ if (existing) {
127
+ const merged = {
128
+ ...existing,
129
+ tags: extractTags(text, [...(input.tags ?? []), ...existing.tags]),
130
+ priority,
131
+ budget: input.budget !== undefined ? input.budget : existing.budget,
132
+ deadline: input.deadline !== undefined ? input.deadline : existing.deadline,
133
+ updatedAt: now,
134
+ };
135
+ const file = intentFile(existing.id, input.home);
136
+ if (!file)
137
+ return { ok: false, created: false, error: '非法 id' };
138
+ const saved = await writeJson(file, merged);
139
+ if (!saved.ok)
140
+ return { ok: false, created: false, error: saved.error };
141
+ return { ok: true, intent: merged, created: false };
142
+ }
143
+ const record = {
144
+ id: `int_${crypto.randomBytes(6).toString('hex')}`,
145
+ text, tags: extractTags(text, input.tags ?? []), priority,
146
+ budget: input.budget ?? null, deadline: input.deadline ?? null,
147
+ status: 'active', createdAt: now, updatedAt: now, matchedCount: 0,
148
+ };
149
+ const file = intentFile(record.id, input.home);
150
+ if (!file)
151
+ return { ok: false, created: false, error: '非法 id' };
152
+ const saved = await writeJson(file, record);
153
+ if (!saved.ok)
154
+ return { ok: false, created: false, error: saved.error };
155
+ return { ok: true, intent: record, created: true };
156
+ }
157
+ /** 删意图 (或标记 done) */
158
+ export async function removeIntent(id, markDone = false, home) {
159
+ const file = intentFile(id, home);
160
+ if (!file)
161
+ return { ok: false, error: `非法意图 id: ${String(id).slice(0, 32)}` };
162
+ if (markDone) {
163
+ const r = await getIntent(id, home);
164
+ if (!r.ok)
165
+ return { ok: false, error: r.error };
166
+ if (!r.intent)
167
+ return { ok: false, error: `没有意图 ${id} (看 bolloon intent list)` };
168
+ const updated = { ...r.intent, status: 'done', updatedAt: Date.now() };
169
+ const saved = await writeJson(file, updated);
170
+ if (!saved.ok)
171
+ return { ok: false, error: saved.error };
172
+ return { ok: true, removed: id };
173
+ }
174
+ try {
175
+ await fs.unlink(file);
176
+ return { ok: true, removed: id };
177
+ }
178
+ catch (e) {
179
+ if (e?.code === 'ENOENT')
180
+ return { ok: false, error: `没有意图 ${id} (看 bolloon intent list)` };
181
+ return { ok: false, error: `删 ${file} 失败: ${String(e?.message || e).slice(0, 120)}` };
182
+ }
183
+ }
184
+ /** 意图匹配数 +1 (扫描后被上层调用, 诚实计数) */
185
+ export async function bumpMatchedCount(id, home) {
186
+ const r = await getIntent(id, home);
187
+ if (!r.ok || !r.intent)
188
+ return;
189
+ const updated = { ...r.intent, matchedCount: r.intent.matchedCount + 1, updatedAt: Date.now() };
190
+ const file = intentFile(id, home);
191
+ if (file)
192
+ await writeJson(file, updated);
193
+ }
@@ -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
+ }
@@ -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
+ }