@bolloon/bolloon-agent 0.4.29 → 0.4.30

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 (47) hide show
  1. package/dist/agents/contacts/chain.js +579 -0
  2. package/dist/agents/contacts/cli.js +158 -0
  3. package/dist/agents/contacts/consent.js +124 -0
  4. package/dist/agents/contacts/grant-payload.js +45 -0
  5. package/dist/agents/contacts/grants.js +451 -0
  6. package/dist/agents/contacts/policy.js +264 -0
  7. package/dist/agents/contacts/preview-types.js +6 -0
  8. package/dist/agents/contacts/providers.js +341 -0
  9. package/dist/agents/contacts/store.js +326 -0
  10. package/dist/agents/contacts/tools.js +154 -0
  11. package/dist/agents/contacts/types.js +148 -0
  12. package/dist/agents/pi-sdk.js +10 -0
  13. package/dist/electron-build/agents/contacts/chain.js +619 -0
  14. package/dist/electron-build/agents/contacts/chain.js.map +1 -0
  15. package/dist/electron-build/agents/contacts/consent.js +163 -0
  16. package/dist/electron-build/agents/contacts/consent.js.map +1 -0
  17. package/dist/electron-build/agents/contacts/grant-payload.js +52 -0
  18. package/dist/electron-build/agents/contacts/grant-payload.js.map +1 -0
  19. package/dist/electron-build/agents/contacts/grants.js +500 -0
  20. package/dist/electron-build/agents/contacts/grants.js.map +1 -0
  21. package/dist/electron-build/agents/contacts/policy.js +271 -0
  22. package/dist/electron-build/agents/contacts/policy.js.map +1 -0
  23. package/dist/electron-build/agents/contacts/preview-types.js +8 -0
  24. package/dist/electron-build/agents/contacts/preview-types.js.map +1 -0
  25. package/dist/electron-build/agents/contacts/providers.js +384 -0
  26. package/dist/electron-build/agents/contacts/providers.js.map +1 -0
  27. package/dist/electron-build/agents/contacts/store.js +370 -0
  28. package/dist/electron-build/agents/contacts/store.js.map +1 -0
  29. package/dist/electron-build/agents/contacts/tools.js +159 -0
  30. package/dist/electron-build/agents/contacts/tools.js.map +1 -0
  31. package/dist/electron-build/agents/contacts/types.js +166 -0
  32. package/dist/electron-build/agents/contacts/types.js.map +1 -0
  33. package/dist/electron-build/agents/external-events.js.map +1 -1
  34. package/dist/electron-build/agents/goal-store.js.map +1 -1
  35. package/dist/electron-build/agents/pi-sdk.js +10 -0
  36. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  37. package/dist/electron-build/security/tool-gate.js +4 -0
  38. package/dist/electron-build/security/tool-gate.js.map +1 -1
  39. package/dist/index.js +30 -0
  40. package/dist/security/tool-gate.js +4 -0
  41. package/dist/web/mobile-contacts.js +329 -0
  42. package/dist/web/mobile-core.js +481 -15
  43. package/dist/web/mobile.html +32 -0
  44. package/dist/web/mobile.js +139 -0
  45. package/dist/web/routes-contacts.js +420 -0
  46. package/dist/web/server.js +5 -0
  47. package/package.json +1 -1
@@ -0,0 +1,326 @@
1
+ /**
2
+ * contacts/store.ts — 联系方式事实的唯一落盘层 (2026-09-19, Phase 1/4)
3
+ *
4
+ * 目录: ~/.bolloon/contacts/
5
+ * social-identity.json SocialIdentity (DID 主身份 + 已验证 contactId 列表)
6
+ * contacts.json VerifiedContact[] (含 normalizedValue, 只在本层读写)
7
+ * secrets.json Secret Store, **0600**; 记录里只引用 secretRef
8
+ * sent.json SendRecord[] (requestId 去重 + 回信关联)
9
+ * ledger.jsonl 只追加的通信台账 (脱敏, 可回放)
10
+ * outbox/ 本地投递通道 (provider=local-sink) 的真落盘目录
11
+ *
12
+ * 纪律:
13
+ * - 所有写都是 tmp + rename (原子); secrets.json 额外 chmod 0600。
14
+ * - 读失败**不吞成空**: 解析损坏时抛/标记, 由调用方决定, 不许静默当成"没有联系方式"。
15
+ * - 任何对外返回值都先过 `maskContact()` —— 明文只在本层内部流转。
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as fsp from 'fs/promises';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { displayFor, newIdentityId, } from './types.js';
22
+ const home = () => process.env.HOME || os.homedir() || '/tmp';
23
+ export function contactsDir(homeDir) {
24
+ return path.join(homeDir || home(), '.bolloon', 'contacts');
25
+ }
26
+ const f = (dir, name) => path.join(dir, name);
27
+ // ── 原子写 + 权限 ───────────────────────────────────────────────────────────
28
+ async function writeJsonAtomic(file, data, mode) {
29
+ // 目录可能还没建 (例如首个动作就是写 secret) → 先建, 否则 rename 必 ENOENT
30
+ await fsp.mkdir(path.dirname(file), { recursive: true });
31
+ const tmp = `${file}.tmp-${process.pid}-${Date.now().toString(36)}`;
32
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
33
+ if (mode) {
34
+ try {
35
+ await fsp.chmod(tmp, mode);
36
+ }
37
+ catch { /* 平台不支持就算了, 但要如实记 */ }
38
+ }
39
+ await fsp.rename(tmp, file);
40
+ if (mode) {
41
+ try {
42
+ await fsp.chmod(file, mode);
43
+ }
44
+ catch { /* 同上 */ }
45
+ }
46
+ }
47
+ async function readJson(file) {
48
+ try {
49
+ return JSON.parse(await fsp.readFile(file, 'utf8'));
50
+ }
51
+ catch (err) {
52
+ if (err?.code === 'ENOENT')
53
+ return null;
54
+ // 损坏的文件绝不静默当空: 抛出去, 让上层报 degraded
55
+ throw new Error(`联系方式存储损坏 (${file}): ${err?.message || err}`);
56
+ }
57
+ }
58
+ /** 任何离开 store 的联系方式都要走这里 (明文 → 脱敏) */
59
+ export function maskContact(c) {
60
+ const { normalizedValue: _drop, consentScope: _scope, secretRef: _ref, ...rest } = c;
61
+ return { ...rest, displayValue: c.displayValue || displayFor(c.kind, c.normalizedValue) };
62
+ }
63
+ export class SecretStore {
64
+ dir;
65
+ constructor(dir) { this.dir = dir; }
66
+ file() { return f(this.dir, 'secrets.json'); }
67
+ async list() {
68
+ const all = (await readJson(this.file())) || [];
69
+ return all.map(({ value: _v, ...rest }) => rest);
70
+ }
71
+ /** 真值只在这里出现; 调用方只能是 provider 适配器 */
72
+ async get(ref) {
73
+ if (!ref)
74
+ return null;
75
+ const all = (await readJson(this.file())) || [];
76
+ return all.find((s) => s.ref === ref) || null;
77
+ }
78
+ async put(ref, provider, value) {
79
+ const all = (await readJson(this.file())) || [];
80
+ const i = all.findIndex((s) => s.ref === ref);
81
+ const entry = { ref, provider, value, createdAt: all[i]?.createdAt || new Date().toISOString(), lastVerifiedAt: all[i]?.lastVerifiedAt };
82
+ if (i >= 0)
83
+ all[i] = entry;
84
+ else
85
+ all.push(entry);
86
+ await writeJsonAtomic(this.file(), all, 0o600);
87
+ }
88
+ async remove(ref) {
89
+ const all = (await readJson(this.file())) || [];
90
+ await writeJsonAtomic(this.file(), all.filter((s) => s.ref !== ref), 0o600);
91
+ }
92
+ async has(ref) { return !!(await this.get(ref)); }
93
+ }
94
+ // ── 主 store ────────────────────────────────────────────────────────────────
95
+ export class ContactsStore {
96
+ dir;
97
+ secrets;
98
+ constructor(homeDir) {
99
+ this.dir = contactsDir(homeDir);
100
+ this.secrets = new SecretStore(this.dir);
101
+ }
102
+ async ensureDirs() {
103
+ await fsp.mkdir(this.dir, { recursive: true });
104
+ await fsp.mkdir(f(this.dir, 'outbox'), { recursive: true });
105
+ }
106
+ // ── 身份 ──────────────────────────────────────────────────────────────────
107
+ async getIdentity(ownerDid) {
108
+ const ids = (await readJson(f(this.dir, 'social-identity.json'))) || [];
109
+ return ids.find((i) => i.ownerDid === ownerDid) || null;
110
+ }
111
+ async ensureIdentity(ownerDid, displayName = '') {
112
+ await this.ensureDirs();
113
+ const file = f(this.dir, 'social-identity.json');
114
+ const ids = (await readJson(file)) || [];
115
+ let id = ids.find((i) => i.ownerDid === ownerDid);
116
+ if (!id) {
117
+ id = {
118
+ identityId: newIdentityId(),
119
+ ownerDid,
120
+ displayName,
121
+ verifiedContacts: [],
122
+ privacyPolicy: {
123
+ plaintextVisibleToAgent: false,
124
+ batchAllowed: false,
125
+ note: '明文联系方式不进 prompt/Run/Git; 外部只看到 已验证/可联系/不可联系/需要重新授权',
126
+ },
127
+ createdAt: new Date().toISOString(),
128
+ updatedAt: new Date().toISOString(),
129
+ };
130
+ ids.push(id);
131
+ }
132
+ await writeJsonAtomic(file, ids);
133
+ return id;
134
+ }
135
+ // ── 联系方式 ──────────────────────────────────────────────────────────────
136
+ async listContacts(opts = {}) {
137
+ const all = (await readJson(f(this.dir, 'contacts.json'))) || [];
138
+ return opts.identityId ? all.filter((c) => c.identityId === opts.identityId) : all;
139
+ }
140
+ async getContact(contactId) {
141
+ return (await this.listContacts()).find((c) => c.contactId === contactId) || null;
142
+ }
143
+ /** 内部用 —— 拿明文值 (只有 provider / 回信校验该调) */
144
+ async getNormalized(contactId) {
145
+ const c = await this.getContact(contactId);
146
+ return c ? c.normalizedValue : null;
147
+ }
148
+ async putContact(c) {
149
+ await this.ensureDirs();
150
+ const file = f(this.dir, 'contacts.json');
151
+ const all = (await readJson(file)) || [];
152
+ const i = all.findIndex((x) => x.contactId === c.contactId);
153
+ c.updatedAt = new Date().toISOString();
154
+ if (i >= 0)
155
+ all[i] = c;
156
+ else
157
+ all.push(c);
158
+ // 2026-09-19: contacts.json 里存 normalizedValue (真发信要用它), 所以按 0600 落盘 ——
159
+ // 明文联系方式不该有"随便能被别人读"的副本; Run/Goal/ledger 里永远只有脱敏值。
160
+ await writeJsonAtomic(file, all, 0o600);
161
+ // 身份里的 verifiedContacts 跟着走
162
+ const sf = f(this.dir, 'social-identity.json');
163
+ const ids = (await readJson(sf)) || [];
164
+ const idIdx = ids.findIndex((x) => x.identityId === c.identityId);
165
+ if (idIdx >= 0) {
166
+ const list = new Set(ids[idIdx].verifiedContacts);
167
+ if (c.verificationStatus === 'verified' && !c.revokedAt)
168
+ list.add(c.contactId);
169
+ else
170
+ list.delete(c.contactId);
171
+ ids[idIdx].verifiedContacts = [...list];
172
+ ids[idIdx].updatedAt = new Date().toISOString();
173
+ await writeJsonAtomic(sf, ids);
174
+ }
175
+ return c;
176
+ }
177
+ /** 按 contactId → DID → 明确名称/别名 匹配 (禁止模糊姓名自动发送) */
178
+ async resolve(opts) {
179
+ const all = (await this.listContacts({ identityId: opts.identityId })).filter((c) => !c.revokedAt);
180
+ if (opts.contactId) {
181
+ const hit = all.find((c) => c.contactId === opts.contactId);
182
+ return { contact: hit || null, by: hit ? 'contactId' : 'none' };
183
+ }
184
+ if (opts.did) {
185
+ const hits = all.filter((c) => c.peerDid && c.peerDid === opts.did);
186
+ if (hits.length === 1)
187
+ return { contact: hits[0], by: 'did' };
188
+ if (hits.length > 1)
189
+ return { contact: null, by: 'none', ambiguous: true };
190
+ }
191
+ if (opts.name) {
192
+ const n = opts.name.trim().toLowerCase();
193
+ const hits = all.filter((c) => (c.aliases || []).some((a) => a.trim().toLowerCase() === n));
194
+ if (hits.length === 1)
195
+ return { contact: hits[0], by: 'name' };
196
+ if (hits.length > 1)
197
+ return { contact: null, by: 'none', ambiguous: true };
198
+ }
199
+ return { contact: null, by: 'none' };
200
+ }
201
+ // ── 发送台账 (去重 + 回信关联) ─────────────────────────────────────────────
202
+ async listSends() {
203
+ return (await readJson(f(this.dir, 'sent.json'))) || [];
204
+ }
205
+ /** 同一 requestId 已发过 → 返回旧记录 (调用方必须拒绝重复发送) */
206
+ async findSend(requestId) {
207
+ return (await this.listSends()).find((s) => s.requestId === requestId) || null;
208
+ }
209
+ async findSendByThread(threadToken) {
210
+ return (await this.listSends()).find((s) => s.threadToken === threadToken) || null;
211
+ }
212
+ async recordSend(rec) {
213
+ await this.ensureDirs();
214
+ const file = f(this.dir, 'sent.json');
215
+ const all = await this.listSends();
216
+ all.push(rec);
217
+ await writeJsonAtomic(file, all.slice(-500));
218
+ }
219
+ async patchSend(requestId, patch) {
220
+ const file = f(this.dir, 'sent.json');
221
+ const all = await this.listSends();
222
+ const i = all.findIndex((s) => s.requestId === requestId);
223
+ if (i < 0)
224
+ return;
225
+ all[i] = { ...all[i], ...patch };
226
+ await writeJsonAtomic(file, all.slice(-500));
227
+ }
228
+ /** 当天该 contact 的发送条数 (速率限制真值) */
229
+ async countSentToday(contactId, now = Date.now()) {
230
+ const day = new Date(now).toISOString().slice(0, 10);
231
+ return (await this.listSends()).filter((s) => s.contactId === contactId && s.sentAt.slice(0, 10) === day && s.status === 'sent').length;
232
+ }
233
+ async countSentForGoal(goalId) {
234
+ return (await this.listSends()).filter((s) => s.goalId === goalId && s.status === 'sent').length;
235
+ }
236
+ async markUsed(contactId) {
237
+ const c = await this.getContact(contactId);
238
+ if (!c)
239
+ return;
240
+ c.lastUsedAt = new Date().toISOString();
241
+ await this.putContact(c);
242
+ }
243
+ // ── 待批准正文 (用户内容, 0600, 发完即删) ───────────────────────────────────
244
+ /** 待批准时把真实正文暂存起来 —— consents.json 里只放脱敏预览 */
245
+ async putPendingBody(requestId, body, subject, exec) {
246
+ const file = path.join(this.dir, 'pending', `${requestId}.json`);
247
+ // 2026-09-19: 执行参数 (是否等回复 / 等多久) 必须和正文一起留下来 ——
248
+ // 否则"批准后执行"会退回默认 48h, 把用户/任务指定的等待窗口丢掉 (真跑抓到过)。
249
+ await writeJsonAtomic(file, {
250
+ requestId, body, subject, replyExpected: exec?.replyExpected, replyWindowMs: exec?.replyWindowMs,
251
+ createdAt: new Date().toISOString(),
252
+ }, 0o600);
253
+ }
254
+ async getPendingBody(requestId) {
255
+ try {
256
+ const raw = JSON.parse(await fsp.readFile(path.join(this.dir, 'pending', `${requestId}.json`), 'utf8'));
257
+ return { body: String(raw?.body || ''), subject: raw?.subject, replyExpected: raw?.replyExpected, replyWindowMs: raw?.replyWindowMs };
258
+ }
259
+ catch {
260
+ return null;
261
+ }
262
+ }
263
+ async clearPendingBody(requestId) {
264
+ try {
265
+ await fsp.unlink(path.join(this.dir, 'pending', `${requestId}.json`));
266
+ }
267
+ catch { /* 没有就算了 */ }
268
+ }
269
+ // ── 台账 (只追加) ─────────────────────────────────────────────────────────
270
+ evidenceRefFor(contactId, activity, ts) {
271
+ return `contact:${contactId}@${ts}#${activity}`;
272
+ }
273
+ async appendLedger(entry) {
274
+ await this.ensureDirs();
275
+ const full = {
276
+ ...entry,
277
+ detail: String(entry.detail || '').slice(0, 300),
278
+ evidenceRef: entry.evidenceRef || this.evidenceRefFor(entry.contactId || '-', entry.activity, entry.ts),
279
+ };
280
+ await fsp.appendFile(f(this.dir, 'ledger.jsonl'), JSON.stringify(full) + '\n', 'utf8');
281
+ return full;
282
+ }
283
+ async readLedger(opts = {}) {
284
+ try {
285
+ const raw = await fsp.readFile(f(this.dir, 'ledger.jsonl'), 'utf8');
286
+ let rows = raw.split('\n').filter(Boolean).map((l) => { try {
287
+ return JSON.parse(l);
288
+ }
289
+ catch {
290
+ return null;
291
+ } }).filter(Boolean);
292
+ if (opts.contactId)
293
+ rows = rows.filter((r) => r.contactId === opts.contactId);
294
+ if (opts.goalId)
295
+ rows = rows.filter((r) => r.goalId === opts.goalId);
296
+ return opts.limit ? rows.slice(-opts.limit) : rows;
297
+ }
298
+ catch (err) {
299
+ if (err?.code === 'ENOENT')
300
+ return [];
301
+ throw err;
302
+ }
303
+ }
304
+ }
305
+ /** 单例 (默认 HOME); 测试要隔离时直接 new ContactsStore(tmpHome) */
306
+ let _default = null;
307
+ export function contactsStore(homeDir) {
308
+ if (homeDir)
309
+ return new ContactsStore(homeDir);
310
+ if (!_default)
311
+ _default = new ContactsStore();
312
+ return _default;
313
+ }
314
+ export function resetContactsStore() { _default = null; }
315
+ /** 文件权限自检 (doctor 用): secrets.json 必须 0600 */
316
+ export async function secretsFileMode(homeDir) {
317
+ const p = f(contactsDir(homeDir), 'secrets.json');
318
+ try {
319
+ const st = fs.statSync(p);
320
+ const mode = (st.mode & 0o777).toString(8);
321
+ return { path: p, mode, ok: mode === '600' };
322
+ }
323
+ catch {
324
+ return { path: p, mode: null, ok: true }; // 还没有 secret = 不算问题
325
+ }
326
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * contacts/tools.ts — 暴露给 Agent 的联系工具 (2026-09-19, Phase 8)
3
+ *
4
+ * 设计铁律:
5
+ * - Agent **拿不到**手机号/邮箱原文, 只能拿 `contactId` + 脱敏 displayValue。
6
+ * - Agent **不能**指定任意收件人地址 —— 只能指定已授权 contactId (或已在别名表里的名字)。
7
+ * - 每个工具都经 policy 判定, 返回里带 blockKind / requiresApproval, 不吞掉拒绝原因。
8
+ *
9
+ * 六个工具: contact.list_authorized / preview / request_consent / send / await_reply / revoke
10
+ */
11
+ export const CONTACT_TOOL_NAMES = [
12
+ 'contact.list_authorized',
13
+ 'contact.preview',
14
+ 'contact.request_consent',
15
+ 'contact.send',
16
+ 'contact.await_reply',
17
+ 'contact.revoke',
18
+ ];
19
+ function text(v) { return String(v ?? '').trim(); }
20
+ export function registerContactTools(ctx, chain) {
21
+ const registered = [];
22
+ ctx.tools.set('contact.list_authorized', {
23
+ name: 'contact.list_authorized',
24
+ description: '列出已授权可联系的联系方式 (只返回脱敏值: 联系人不给完整手机号/邮箱)。需要联系某人先调这个; 拿到的 contactId 才能用于发送。',
25
+ parameters: {},
26
+ execute: async () => {
27
+ const list = await chain.listAuthorized();
28
+ return {
29
+ success: true,
30
+ contacts: list,
31
+ channels: chain.channels(),
32
+ note: list.length ? '只能对这里列出的 contactId 发送; 未验证/已撤销的一律发不出去' : '还没有任何已绑定的联系方式 —— 需要用户在 CLI/桌面/手机上先绑定并验证',
33
+ };
34
+ },
35
+ });
36
+ ctx.tools.set('contact.preview', {
37
+ name: 'contact.preview',
38
+ description: '生成一份可审计的发送预览 (联系人/通道/收件人脱敏/任务/权限/是否需要人工批准), 不发送任何东西。',
39
+ parameters: {
40
+ contactId: '联系人 id (必填, 来自 contact.list_authorized)',
41
+ body: '正文 (必填)',
42
+ subject: '主题 (可选, 邮箱用)',
43
+ goalId: '关联的长期任务 id (可选但强烈建议; 未绑定的任务联系人会被拒绝)',
44
+ },
45
+ execute: async (args) => {
46
+ const contactId = text(args.contactId);
47
+ if (!contactId)
48
+ return { success: false, error: 'contactId 必填 (不允许按姓名直接发送)' };
49
+ const { decision, text: rendered } = await chain.preview({
50
+ contactId, body: text(args.body), subject: text(args.subject) || undefined, goalId: text(args.goalId) || undefined,
51
+ });
52
+ return {
53
+ success: true,
54
+ preview: rendered,
55
+ allowed: decision.allowed,
56
+ blockKind: decision.blockKind,
57
+ reason: decision.reason,
58
+ requiresApproval: decision.requiresApproval,
59
+ reallySent: decision.reallySent,
60
+ };
61
+ },
62
+ });
63
+ const sendLike = (name, description) => async (args) => {
64
+ const contactId = text(args.contactId);
65
+ if (!contactId)
66
+ return { success: false, error: 'contactId 必填 (不允许按姓名直接发送; 用 contact.list_authorized 拿 id)' };
67
+ const res = await chain.send({
68
+ contactId,
69
+ goalId: text(args.goalId) || undefined,
70
+ runId: text(args.runId) || undefined,
71
+ taskKind: text(args.taskKind) || undefined,
72
+ subject: text(args.subject) || undefined,
73
+ body: text(args.body),
74
+ requestId: text(args.requestId) || undefined,
75
+ replyExpected: args.replyExpected !== false,
76
+ });
77
+ const ok = res.status === 'sent' || res.status === 'awaiting_reply';
78
+ return {
79
+ success: ok,
80
+ status: res.status,
81
+ reason: res.reason,
82
+ blockKind: res.blockKind,
83
+ requestId: res.requestId,
84
+ consentId: res.consentId,
85
+ preview: res.preview,
86
+ evidenceRef: res.evidenceRef,
87
+ goalStatus: res.goalStatus,
88
+ // 让 Agent 说人话: 被拒/等批准时必须如实转述, 不许假装已发送
89
+ note: res.status === 'awaiting_approval'
90
+ ? '已生成待批准请求: 需要用户在 CLI/桌面/手机点批准后才会真正发送 (首次联系/敏感内容/策略要求时必然如此)'
91
+ : res.status === 'denied' ? `被策略拒绝 (${res.blockKind}): ${res.reason}` : undefined,
92
+ };
93
+ };
94
+ const sendParams = {
95
+ contactId: '联系人 id (必填)',
96
+ body: '正文 (必填)',
97
+ subject: '主题 (可选)',
98
+ goalId: '长期任务 id (任务绑定联系人必须给)',
99
+ runId: '当前 Run id (可选; 通信证据会写进这个 Run)',
100
+ requestId: '幂等 id (可选; 同一个 requestId 绝不会重复发送)',
101
+ replyExpected: '是否等待回复 (默认 true; goalId 存在时会绑定外部等待)',
102
+ };
103
+ ctx.tools.set('contact.send', {
104
+ name: 'contact.send',
105
+ description: '联系一个人 (手机号/邮箱): 经策略判定 —— 首次联系/敏感内容需要用户批准, 批量永远禁止, 未验证/已撤销一律拒绝。发送成功后若等待回复, 任务会进入 awaiting_external。',
106
+ parameters: sendParams,
107
+ execute: sendLike('contact.send', ''),
108
+ });
109
+ ctx.tools.set('contact.request_consent', {
110
+ name: 'contact.request_consent',
111
+ description: '就某次联系向用户请求授权 (不发送): 生成待批准请求 + 可审计预览, 用户批准后才发送。',
112
+ parameters: sendParams,
113
+ execute: sendLike('contact.request_consent', ''),
114
+ });
115
+ ctx.tools.set('contact.await_reply', {
116
+ name: 'contact.await_reply',
117
+ description: '查看某次联系是否在等待回复 / 是否已收到回复 / 是否超时 (只读)。',
118
+ parameters: { requestId: '发送时拿到的 requestId (必填)' },
119
+ execute: async (args) => {
120
+ const requestId = text(args.requestId);
121
+ if (!requestId)
122
+ return { success: false, error: 'requestId 必填' };
123
+ const rec = await chain.store.findSend(requestId);
124
+ if (!rec)
125
+ return { success: false, error: `没有 requestId=${requestId} 的发送记录` };
126
+ const ledger = await chain.store.readLedger({ limit: 200 });
127
+ const mine = ledger.filter((l) => l.detail.includes(requestId) || (l.contactId === rec.contactId && l.goalId === rec.goalId));
128
+ return {
129
+ success: true,
130
+ status: rec.status,
131
+ goalId: rec.goalId,
132
+ sentAt: rec.sentAt,
133
+ replied: mine.some((l) => l.activity === 'contact.reply_received'),
134
+ timedOut: mine.some((l) => l.activity === 'contact.wait_expired'),
135
+ provider: rec.provider,
136
+ evidence: mine.slice(-5).map((l) => ({ activity: l.activity, ts: l.ts, detail: l.detail, evidenceRef: l.evidenceRef })),
137
+ };
138
+ },
139
+ });
140
+ ctx.tools.set('contact.revoke', {
141
+ name: 'contact.revoke',
142
+ description: '撤销某个联系方式的授权 (用户级动作; 撤销后历史证据保留但后续调用一律拒绝)。',
143
+ parameters: { contactId: '联系人 id (必填)', reason: '撤销原因 (可选)' },
144
+ execute: async (args) => {
145
+ const contactId = text(args.contactId);
146
+ if (!contactId)
147
+ return { success: false, error: 'contactId 必填' };
148
+ const r = await chain.revoke({ contactId, by: 'agent_request', reason: text(args.reason) || undefined });
149
+ return { success: r.ok, error: r.error, affectedGoals: r.affectedGoals, evidenceRef: r.evidenceRef };
150
+ },
151
+ });
152
+ registered.push(...CONTACT_TOOL_NAMES);
153
+ return registered;
154
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * contacts/types.ts — 社交身份模型 (2026-09-19, Phase 0/1)
3
+ *
4
+ * 核心定义 (leo 冻结):
5
+ * Bolloon 的社交身份 = **DID 主身份 + 已验证联系方式 + 联系能力 Skill + 调用权限 + 可恢复任务证据**,
6
+ * 不是"公开手机号/邮箱"。外部只看到四类状态: 已验证 / 可联系 / 不可联系 / 需要重新授权。
7
+ *
8
+ * 硬规则 (贯穿本目录所有文件):
9
+ * 1. 明文手机号/邮箱**不进**普通 Run step、不进 prompt、不进 Git; 只有 displayValue(脱敏) 可以。
10
+ * 2. 密钥 (API key / OAuth refresh / SMTP 密码) 只存 Secret Store, 记录里只留 secretRef。
11
+ * 3. Goal/Run 只保存 contactId / provider / 动作 / 状态 / 证据引用。
12
+ * 4. 未验证的联系方式**任何情况下**不能被用于真实发送。
13
+ * 5. 撤销后历史证据保留, 但后续调用一律拒绝。
14
+ */
15
+ export const DEFAULT_LIMITS = { dailyMax: 5, perTaskMax: 3, requireApprovalEachTime: false };
16
+ // ── 规范化 ──────────────────────────────────────────────────────────────────
17
+ const CC = {
18
+ CN: '86', US: '1', CA: '1', JP: '81', GB: '44', DE: '49', FR: '33', IN: '91', AU: '61', SG: '65', KR: '82', RU: '7',
19
+ };
20
+ export function normalizePhone(input, defaultRegion = '') {
21
+ const raw = String(input || '').trim();
22
+ if (!raw)
23
+ return { ok: false, error: 'empty' };
24
+ const plus = raw.startsWith('+');
25
+ const digits = raw.replace(/[^\d]/g, '');
26
+ if (digits.length < 4)
27
+ return { ok: false, error: 'too_short' };
28
+ let e164;
29
+ if (plus) {
30
+ e164 = `+${digits}`;
31
+ }
32
+ else {
33
+ const cc = CC[(defaultRegion || '').toUpperCase()];
34
+ if (!cc)
35
+ return { ok: false, error: 'region_required' };
36
+ const local = digits.replace(/^0+/, '');
37
+ e164 = `+${cc}${local}`;
38
+ }
39
+ const numDigits = e164.length - 1;
40
+ if (numDigits < 8 || numDigits > 15)
41
+ return { ok: false, error: 'invalid_length' };
42
+ return { ok: true, value: e164 };
43
+ }
44
+ export function normalizeEmail(input) {
45
+ const raw = String(input || '').trim().toLowerCase();
46
+ if (!raw)
47
+ return { ok: false, error: 'empty' };
48
+ if (/\s/.test(raw))
49
+ return { ok: false, error: 'contains_space' };
50
+ const m = raw.match(/^([^@]+)@([^@]+\.[^@.]+)$/);
51
+ if (!m)
52
+ return { ok: false, error: 'invalid_format' };
53
+ if (m[1].length > 64)
54
+ return { ok: false, error: 'local_part_too_long' };
55
+ return { ok: true, value: `${m[1]}@${m[2]}` };
56
+ }
57
+ /** 脱敏展示值 —— 唯一允许出现在 prompt / evidence / Web UI 的形态 */
58
+ export function maskPhone(e164) {
59
+ const digits = e164.replace(/[^\d]/g, '');
60
+ if (digits.length <= 6)
61
+ return `+${'*'.repeat(Math.max(0, digits.length - 2))}${digits.slice(-2)}`;
62
+ return `+${digits.slice(0, 2)}******${digits.slice(-4)}`;
63
+ }
64
+ export function maskEmail(email) {
65
+ const [local, domain] = String(email).split('@');
66
+ if (!domain)
67
+ return '***';
68
+ const head = local.slice(0, 1);
69
+ return `${head}${'*'.repeat(Math.max(1, Math.min(6, local.length - 1)))}@${domain}`;
70
+ }
71
+ export function displayFor(kind, normalized) {
72
+ return kind === 'phone' ? maskPhone(normalized) : maskEmail(normalized);
73
+ }
74
+ /** 内部状态 → 外部四类状态 (Phase 0: 外部只看到这四个) */
75
+ export function externalStatusOf(c) {
76
+ if (c.revokedAt || c.verificationStatus === 'revoked')
77
+ return 'reauth_required';
78
+ if (c.verificationStatus === 'blocked' || c.verificationStatus === 'expired')
79
+ return 'not_contactable';
80
+ if (c.verificationStatus === 'verified') {
81
+ return c.capabilities.includes('send') && c.policy !== 'draft_only' ? 'contactable' : 'verified';
82
+ }
83
+ return 'not_contactable';
84
+ }
85
+ export function scanSensitive(text) {
86
+ const out = [];
87
+ const s = String(text || '');
88
+ const push = (kind, hint) => {
89
+ if (!out.some((f) => f.kind === kind && f.hint === hint))
90
+ out.push({ kind, hint });
91
+ };
92
+ // 卡号: 13-19 位连续数字 (允许空格/横线分隔)
93
+ for (const m of s.matchAll(/\b(?:\d[ -]?){13,19}\b/g)) {
94
+ const digits = m[0].replace(/[^\d]/g, '');
95
+ if (digits.length >= 13 && digits.length <= 19)
96
+ push('card_number', `${digits.slice(0, 4)}…${digits.slice(-4)}`);
97
+ }
98
+ // 中国大陆身份证: 17 位 + 数字/X
99
+ for (const m of s.matchAll(/\b\d{17}[\dXx]\b/g))
100
+ push('id_number_cn', `${m[0].slice(0, 4)}…${m[0].slice(-2)}`);
101
+ // API key / token
102
+ for (const m of s.matchAll(/\b(sk-[A-Za-z0-9]{12,}|AKIA[0-9A-Z]{12,}|ghp_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,})\b/g)) {
103
+ push('api_key', `${m[0].slice(0, 6)}…`);
104
+ }
105
+ if (/(密码|password|passwd|pwd)\s*[::=]\s*\S+/i.test(s))
106
+ push('password', '疑似密码明文');
107
+ // 一次性验证码: 只在出现"验证码/otp/code"上下文时才算
108
+ if (/(验证码|动态码|one[-\s]?time|otp)\D{0,8}\d{4,8}/i.test(s))
109
+ push('otp_code', '疑似一次性验证码');
110
+ // 银行账号: 10-17 位且上下文含"账号/account"
111
+ if (/(账号|账户|account|iban)\D{0,6}\d{10,17}/i.test(s))
112
+ push('bank_account', '疑似银行账号');
113
+ return out;
114
+ }
115
+ /**
116
+ * 永不由联系方式授权放行的内容类别 (2026-09-19, Phase 6):
117
+ * 凭证 / 资金指令 / 合同承诺 —— 即使 full_contact_access 也拒绝, 它们属于另一类高风险能力。
118
+ */
119
+ export function scanForbidden(text) {
120
+ const s = String(text || '');
121
+ const out = new Set();
122
+ if (/\b(sk-[A-Za-z0-9]{12,}|AKIA[0-9A-Z]{12,}|ghp_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,})\b/.test(s))
123
+ out.add('api_key');
124
+ if (/(密码|password|passwd|pwd)\s*[::=]\s*\S+/i.test(s))
125
+ out.add('password');
126
+ if (/(账号|账户|account|iban)\D{0,6}\d{10,17}/i.test(s))
127
+ out.add('bank_account');
128
+ if (/(转账|打款|汇款|付款指令|支付授权|remit|wire transfer|pay\s+to|send\s+funds)/i.test(s))
129
+ out.add('payment_instruction');
130
+ if (/(签署合同|签订合同|签合同|同意采购|承诺下单|sign\s+(the\s+)?(contract|agreement)|binding\s+commitment)/i.test(s))
131
+ out.add('contract_commitment');
132
+ return [...out];
133
+ }
134
+ /** 发送前把正文里明显的口令类内容打码 (预览与证据都走这个) */
135
+ export function redactForEvidence(text) {
136
+ let s = String(text || '');
137
+ s = s.replace(/\b\d[ -]?(?:\d[ -]?){12,18}\d\b/g, (m) => `${m.replace(/[^\d]/g, '').slice(0, 4)}…<卡号已打码>`);
138
+ s = s.replace(/\b(sk-|AKIA|ghp_|npm_)[A-Za-z0-9_-]{8,}\b/g, '<密钥已打码>');
139
+ s = s.replace(/\b\d{17}[\dXx]\b/g, '<身份证已打码>');
140
+ return s;
141
+ }
142
+ // ── id 生成 ─────────────────────────────────────────────────────────────────
143
+ const rid = (n = 8) => Math.random().toString(36).slice(2, 2 + n);
144
+ export const newContactId = (kind) => `ct-${kind}-${Date.now().toString(36)}-${rid(4)}`;
145
+ export const newIdentityId = () => `sid-${Date.now().toString(36)}-${rid(4)}`;
146
+ export const newRequestId = () => `creq-${Date.now().toString(36)}-${rid(6)}`;
147
+ export const newThreadToken = () => `thr-${rid(20)}`;
148
+ export const newEventId = () => `cev-${Date.now().toString(36)}-${rid(6)}`;
@@ -45,6 +45,8 @@ export { TOOL_DEFINITIONS, } from './pi-sdk-types.js';
45
45
  export { PiSessionManager } from './pi-sdk-session-manager.js';
46
46
  import { PiSessionManager } from './pi-sdk-session-manager.js';
47
47
  import { registerBuiltinTools, registerWalletTools, setupInboxListener, IdempotencyCache, } from './pi-sdk-tools.js';
48
+ import { registerContactTools } from './contacts/tools.js';
49
+ import { ContactChain } from './contacts/chain.js';
48
50
  export { createAgentSession, getAgentSession, resetAgentSession, runSelfImproveLoop, } from './pi-sdk-session-factory.js';
49
51
  // Judgment 注入门 (P0): 在主对话 LLM 调起前自动拼入 Top 3 判断力
50
52
  // 失败静默, 不阻塞主对话
@@ -575,6 +577,14 @@ export class PiAgentSession {
575
577
  },
576
578
  };
577
579
  registerBuiltinTools(toolCtx);
580
+ // 2026-09-19: 联系方式工具 (手机/邮箱) —— Agent 拿不到明文, 只能给 contactId;
581
+ // 所有调用都要过 contacts/policy (首次联系/敏感内容需人工批准, 批量永远禁止)。
582
+ try {
583
+ registerContactTools(toolCtx, new ContactChain({ ownerDid: this.identity?.did || 'did:bolln:local' }));
584
+ }
585
+ catch (err) {
586
+ console.warn('[contacts] 注册联系工具失败 (非致命, 该能力不可用):', err?.message || err);
587
+ }
578
588
  registerWalletTools(toolCtx);
579
589
  setupInboxListener(toolCtx);
580
590
  // 镜像到 ToolRegistry (alias resolve 用)