@bolloon/bolloon-agent 0.3.1 → 0.3.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/dist/agents/pi-sdk-session-manager.js +1 -1
- package/dist/agents/pi-sdk.js +59 -11
- package/dist/bootstrap/remote-mirror.js +37 -4
- package/dist/judgeness/auto-add.js +145 -0
- package/dist/judgeness/protocol.js +214 -0
- package/dist/judgeness/rank.js +78 -0
- package/dist/judgeness/reflect.js +93 -0
- package/dist/judgeness/store.js +481 -0
- package/dist/judgeness/types.js +19 -0
- package/dist/judgeness/visibility.js +118 -0
- package/dist/llm/config-store.js +5 -2
- package/dist/llm/pi-ai.js +21 -18
- package/dist/llm/tool-manifest/index.js +59 -0
- package/dist/scripts/dedup-session-messages.js +68 -0
- package/dist/web/client-hearth.js +67 -0
- package/dist/web/client.js +309 -41
- package/dist/web/routes-hearth.js +371 -0
- package/dist/web/server.js +421 -13
- package/dist/web/ui/message-renderer.js +13 -2
- package/dist/web/util/dual-mode.js +87 -0
- package/package.json +2 -2
|
@@ -2,7 +2,7 @@ import * as fs from 'fs/promises';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { getGlobalSharedContext } from '../social/global-shared-context.js';
|
|
4
4
|
import { Session, saveSession, loadSession } from '@bolloon/constraint-runtime';
|
|
5
|
-
|
|
5
|
+
import { SHARED_SESSION_PATH } from '../web/server-types.js';
|
|
6
6
|
const PERSONA_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'persona.json');
|
|
7
7
|
/**
|
|
8
8
|
* PiSessionManager — 负责:
|
package/dist/agents/pi-sdk.js
CHANGED
|
@@ -842,11 +842,24 @@ ${this.getToolDefinitions()}
|
|
|
842
842
|
// 真正折叠需要把 pi-sdk 历史灌回 pivot 的 history 数组 — 侵入较大.
|
|
843
843
|
// 当前 priority: 临时传空实现, 让 budget 公式放够 (workflow-pivot-loop.ts line 220)
|
|
844
844
|
// 不再撞预算. 这条路径留作技术债.
|
|
845
|
+
// 2026-07-17 Bug 1 修: 注入 messageHistory (hydrateMessageHistory 从 session JSON 回灌的) 到 system prompt
|
|
846
|
+
// pivot loop execute() 内部自己维护 messageHistory, 跟 pi-sdk 的 this.messageHistory 隔离,
|
|
847
|
+
// 不注入的话 LLM 看不到历史对话, 每次都是新对话.
|
|
848
|
+
const historyLines = [];
|
|
849
|
+
const historyToInject = this.messageHistory.slice(-20, -1);
|
|
850
|
+
for (const m of historyToInject) {
|
|
851
|
+
const roleLabel = m.role === 'user' ? '用户' : m.role === 'assistant' ? '你' : m.role === 'tool' ? '工具结果' : m.role;
|
|
852
|
+
const text = (m.content || '').slice(0, 2000);
|
|
853
|
+
if (text)
|
|
854
|
+
historyLines.push(`[${roleLabel}]: ${text}`);
|
|
855
|
+
}
|
|
856
|
+
const historyBlock = historyLines.length > 0
|
|
857
|
+
? `\n\n【历史对话 (最近 ${historyLines.length} 条)】\n${historyLines.join('\n')}\n【历史对话结束】`
|
|
858
|
+
: '';
|
|
845
859
|
const onCompact = async () => {
|
|
846
860
|
// no-op (best-effort hook for future pi-sdk/pivot history sync)
|
|
847
861
|
};
|
|
848
|
-
const result = await loop.execute(input, llm, systemPrompt, this.currentOnStream ?? undefined, this.currentSignal ?? undefined, onCompact);
|
|
849
|
-
this.messageHistory.push({ role: 'user', content: input });
|
|
862
|
+
const result = await loop.execute(input, llm, systemPrompt + historyBlock, this.currentOnStream ?? undefined, this.currentSignal ?? undefined, onCompact);
|
|
850
863
|
if (result.response) {
|
|
851
864
|
this.messageHistory.push({ role: 'assistant', content: result.response });
|
|
852
865
|
}
|
|
@@ -1000,7 +1013,9 @@ ${toolDefs}
|
|
|
1000
1013
|
// 2. reactive compaction (prompt 估算超阈值, 跑压缩)
|
|
1001
1014
|
// 3. prompt-too-long (LLM 报错 4xxx token 错误, 跑 reactive compaction 再试 1 次)
|
|
1002
1015
|
// 失败静默: 全部重试失败 → 空 reply (上层用 no tool_use 终止)
|
|
1003
|
-
|
|
1016
|
+
// Bug 5: pass tool IDs for native OpenAI tool calling
|
|
1017
|
+
const toolIds = Array.from(this.tools.keys());
|
|
1018
|
+
const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream, toolIds);
|
|
1004
1019
|
const reply = (response.reply || '').trim();
|
|
1005
1020
|
// 2026-06-30: OpenAI 协议 native tool_calls (LLM 真产了 tool_call 时, minimax/M3 会返回 id)
|
|
1006
1021
|
const nativeToolCalls = response.toolCalls;
|
|
@@ -1067,11 +1082,39 @@ ${toolDefs}
|
|
|
1067
1082
|
// 2026-06-19 架构 fix: parseToolCall 优先于 isFinalResponse
|
|
1068
1083
|
// 之前: 思考块里的 "<final gen>" 触发 isFinalResponse 提前 break, 工具从未真正执行
|
|
1069
1084
|
// 现在: 先尝试解析 tool_call, 有就执行; 没有才检查是不是真正的 final gen
|
|
1070
|
-
|
|
1085
|
+
// Bug 5 (2026-07-17): 优先用 LLM 的 native tool_calls (response.toolCalls), 再回退到文本解析
|
|
1086
|
+
// deepseek-v4-flash 用 OpenAI 协议 tools 时, 会真返回结构化 tool_calls 数组
|
|
1087
|
+
// 之前 nativeToolCalls 被读了不用, 只查 reply 文本, 导致 LLM 明明选了工具但代码找不到
|
|
1088
|
+
let toolCall = null;
|
|
1089
|
+
if (nativeToolCalls && nativeToolCalls.length > 0) {
|
|
1090
|
+
const nc = nativeToolCalls[0];
|
|
1091
|
+
// OpenAI 协议: { id, type: 'function', function: { name, arguments: JSON string } }
|
|
1092
|
+
// 转换成 internal { name, args, id }
|
|
1093
|
+
try {
|
|
1094
|
+
const args = typeof nc.function?.arguments === 'string'
|
|
1095
|
+
? JSON.parse(nc.function.arguments)
|
|
1096
|
+
: (nc.function?.arguments || {});
|
|
1097
|
+
toolCall = {
|
|
1098
|
+
name: nc.function?.name,
|
|
1099
|
+
args,
|
|
1100
|
+
id: nc.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1101
|
+
};
|
|
1102
|
+
console.log(`[PiAgent] 用 native tool_call: ${toolCall.name} (id=${toolCall.id})`);
|
|
1103
|
+
}
|
|
1104
|
+
catch (err) {
|
|
1105
|
+
console.warn(`[PiAgent] 解析 native tool_call 失败, 回退到文本解析: ${err.message?.slice(0, 100)}`);
|
|
1106
|
+
toolCall = null;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (!toolCall) {
|
|
1110
|
+
toolCall = this.parseToolCall(reply);
|
|
1111
|
+
}
|
|
1071
1112
|
// 2026-06-30 修: 给 toolCall 分配稳定 id, 让后续 tool result 能引用同一个 id
|
|
1072
1113
|
// OpenAI 协议要求 messages 里 tool result 必须有对应的 tool_call_id, 否则 400
|
|
1073
|
-
if (toolCall) {
|
|
1114
|
+
if (toolCall && !toolCall.id) {
|
|
1074
1115
|
toolCall.id = `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1116
|
+
}
|
|
1117
|
+
if (toolCall) {
|
|
1075
1118
|
this.messageHistory.push({
|
|
1076
1119
|
role: 'assistant',
|
|
1077
1120
|
content: reply,
|
|
@@ -1526,10 +1569,14 @@ Workspace root folder: ${this.cwd}
|
|
|
1526
1569
|
// bolloon 之前把所有 tool result 包成 "[工具结果] ..." 当 user/assistant role 发, minimax 严格校验失败
|
|
1527
1570
|
// 现在: 保留 role='tool' + 加 tool_call_id 字段 (用 messageHistory 里自己生成的 id)
|
|
1528
1571
|
if (role === 'tool') {
|
|
1529
|
-
const
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1572
|
+
const toolCallId = m.toolCallId || m.toolCall?.id || '';
|
|
1573
|
+
const result = m.toolResult;
|
|
1574
|
+
out.push({
|
|
1575
|
+
role: 'tool',
|
|
1576
|
+
content: result ? (typeof result === 'string' ? result : JSON.stringify(result)) : content,
|
|
1577
|
+
tool_call_id: toolCallId,
|
|
1578
|
+
name: m.toolCall?.name || '',
|
|
1579
|
+
});
|
|
1533
1580
|
continue;
|
|
1534
1581
|
}
|
|
1535
1582
|
// system role (router hint 等) 直接保留
|
|
@@ -1593,7 +1640,7 @@ Workspace root folder: ${this.cwd}
|
|
|
1593
1640
|
*
|
|
1594
1641
|
* 失败静默: 全部失败 → 返回空 reply, 让上层 no-tool_use 终止
|
|
1595
1642
|
*/
|
|
1596
|
-
async callLlmWithRecovery(llm, contextOrMessages, systemPrompt, signal, onStream) {
|
|
1643
|
+
async callLlmWithRecovery(llm, contextOrMessages, systemPrompt, signal, onStream, tools) {
|
|
1597
1644
|
// Reactive compaction 预检: 估算 token 超 80% 阈值, 跑一次
|
|
1598
1645
|
const estimated = this.estimateHistoryTokens();
|
|
1599
1646
|
if (estimated > this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD * 0.8) {
|
|
@@ -1646,7 +1693,8 @@ Workspace root folder: ${this.cwd}
|
|
|
1646
1693
|
try {
|
|
1647
1694
|
// M3.5 (2026-06-17): 传 messages 数组 (如果 contextOrMessages 是数组) 或字符串
|
|
1648
1695
|
// 数组版让 LLM 看到结构化的 user/assistant/tool role, 而不是把 history 拼成单字符串
|
|
1649
|
-
|
|
1696
|
+
// Bug 5: pass tool IDs for native OpenAI tool calling
|
|
1697
|
+
const response = await llm.chat(contextOrMessages, systemPrompt, signal, tools);
|
|
1650
1698
|
// 2026-06-30: 透传 toolCalls (OpenAI 协议 native) 给上层, 让 assistant message 能 emit 真 id
|
|
1651
1699
|
return { reply: response.reply || '', toolCalls: response.toolCalls };
|
|
1652
1700
|
}
|
|
@@ -15,11 +15,43 @@
|
|
|
15
15
|
* 触发:
|
|
16
16
|
* - server.ts: agent.history.get.reply handler 写完 → mirrorRemoteHistory
|
|
17
17
|
* - client.ts: openRemoteChannelChat → loadRemoteHistory 优先读镜像
|
|
18
|
+
*
|
|
19
|
+
* 2026-07-17: 加写盘重试 → 短暂文件系统抖动不吞消息
|
|
18
20
|
*/
|
|
19
21
|
import * as fs from 'fs/promises';
|
|
20
22
|
import * as path from 'path';
|
|
21
23
|
import * as os from 'os';
|
|
22
24
|
import { saveWindow as saveSessionWindow } from './session-window.js';
|
|
25
|
+
// ============== 重试 ==============
|
|
26
|
+
const MIRROR_RETRIES = 3;
|
|
27
|
+
const MIRROR_BACKOFF_MS = 200;
|
|
28
|
+
const MAX_BACKOFF_MS = 3000;
|
|
29
|
+
const RETRYABLE_CODES = new Set([
|
|
30
|
+
'EBUSY', 'EAGAIN', 'EMFILE', 'ENFILE', 'ENOSPC', 'EIO',
|
|
31
|
+
]);
|
|
32
|
+
function isRetryable(e) {
|
|
33
|
+
if (!e || typeof e !== 'object')
|
|
34
|
+
return false;
|
|
35
|
+
const code = e.code || '';
|
|
36
|
+
return RETRYABLE_CODES.has(code);
|
|
37
|
+
}
|
|
38
|
+
async function withRetry(fn, label) {
|
|
39
|
+
for (let attempt = 0; attempt <= MIRROR_RETRIES; attempt++) {
|
|
40
|
+
try {
|
|
41
|
+
return await fn();
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
if (attempt < MIRROR_RETRIES && isRetryable(e)) {
|
|
45
|
+
const ms = Math.min(MIRROR_BACKOFF_MS * Math.pow(2, attempt), MAX_BACKOFF_MS);
|
|
46
|
+
console.warn(`[mirror] ${label} 失败 (attempt ${attempt + 1}/${MIRROR_RETRIES}), ${ms}ms 后重试: ${e?.message || e}`);
|
|
47
|
+
await new Promise(r => setTimeout(r, ms));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`unreachable: ${label}`);
|
|
54
|
+
}
|
|
23
55
|
// ============== 路径 ==============
|
|
24
56
|
function sanitize(s) {
|
|
25
57
|
return s.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128);
|
|
@@ -37,14 +69,14 @@ export function getRemoteMirrorWindowPath(targetPublicKey, channelId, home) {
|
|
|
37
69
|
// ============== 写入 ==============
|
|
38
70
|
/**
|
|
39
71
|
* 把 A 端的 history 镜像到 B 端本地. atomic (单文件 writeFile, 中途崩溃顶多旧版本留下).
|
|
40
|
-
*
|
|
72
|
+
* 失败自动重试 MIRROR_RETRIES 次 (暂态文件系统错误), 最终失败静默不阻塞 RPC reply.
|
|
41
73
|
*/
|
|
42
74
|
export async function mirrorRemoteHistory(opts) {
|
|
43
75
|
try {
|
|
44
76
|
const home = opts.home || os.homedir();
|
|
45
77
|
const mirrorPath = getRemoteMirrorPath(opts.targetPublicKey, opts.channelId, home);
|
|
46
78
|
const windowPath = getRemoteMirrorWindowPath(opts.targetPublicKey, opts.channelId, home);
|
|
47
|
-
await fs.mkdir(path.dirname(mirrorPath), { recursive: true });
|
|
79
|
+
await withRetry(() => fs.mkdir(path.dirname(mirrorPath), { recursive: true }), 'mkdir');
|
|
48
80
|
// 主体镜像
|
|
49
81
|
const payload = {
|
|
50
82
|
channelId: opts.channelId,
|
|
@@ -54,12 +86,13 @@ export async function mirrorRemoteHistory(opts) {
|
|
|
54
86
|
lastUpdated: opts.lastUpdated || new Date().toISOString(),
|
|
55
87
|
mirroredAt: new Date().toISOString(),
|
|
56
88
|
};
|
|
57
|
-
await fs.writeFile(mirrorPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
89
|
+
await withRetry(() => fs.writeFile(mirrorPath, JSON.stringify(payload, null, 2), 'utf-8'), 'write mirror');
|
|
58
90
|
// 窗口联动
|
|
59
|
-
await saveSessionWindow(opts.channelId, `remote-${opts.targetPublicKey.slice(0, 12)}`, opts.messages, { home, windowSize: 30 });
|
|
91
|
+
await withRetry(() => saveSessionWindow(opts.channelId, `remote-${opts.targetPublicKey.slice(0, 12)}`, opts.messages, { home, windowSize: 30 }), 'write window');
|
|
60
92
|
return { ok: true, mirrorPath, windowPath };
|
|
61
93
|
}
|
|
62
94
|
catch (e) {
|
|
95
|
+
console.warn(`[mirror] 最终失败: ${e?.message || e}`);
|
|
63
96
|
return { ok: false, error: e?.message || String(e) };
|
|
64
97
|
}
|
|
65
98
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* judgeness · auto-add.ts — Channel-based Auto-add (反攻期 O3)
|
|
3
|
+
*
|
|
4
|
+
* 用户原话: "传播智能体的时候, 智能体可根据内容频道选择其他用户的 Id 自动添加"
|
|
5
|
+
*
|
|
6
|
+
* 流程:
|
|
7
|
+
* 1. POST /api/hearth/channel-autoadd { channelTopic, sourceChannelOwnerPk? }
|
|
8
|
+
* 2. 闸 2 (allowlist gate) 校验 sourceChannelOwnerPk
|
|
9
|
+
* 3. 扫描 ~/.bolloon/judgeness/descriptions/, 找出 scope.topics 含 channelTopic 且 openState='open' 的 description
|
|
10
|
+
* 4. 对每个 description 的 owner pk 调用 p2p-direct.joinTopic
|
|
11
|
+
* 5. 全部进 ~/.bolloon/human-values/counterfactual-audit.jsonl
|
|
12
|
+
* 6. 频次限制 (defense=无; 反攻期 = 每分钟 5 次; 单 peer pk 24h 内最多 10 次)
|
|
13
|
+
*
|
|
14
|
+
* 反攻期接 src/network/p2p-direct.ts 的 joinTopic; 防御期 stub.
|
|
15
|
+
* 反攻期接 src/judgeness/protocol.ts 的 sendAutoaddInvite.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'fs/promises';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import * as os from 'os';
|
|
20
|
+
const DEFENSE_FREQ_LIMIT_PER_HOUR = 5; // 防御期更严
|
|
21
|
+
const ROLLING_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
22
|
+
export async function performAutoAdd(req, opts = {}) {
|
|
23
|
+
if (!req.channelTopic)
|
|
24
|
+
throw new Error('channelTopic required');
|
|
25
|
+
const now = opts.nowMs ?? Date.now();
|
|
26
|
+
// ---- 频次限制 (读 audit log last hour 统计) ----
|
|
27
|
+
const auditLog = await readAutoaddAudit();
|
|
28
|
+
const recent = auditLog.filter((l) => now - l.ts < ROLLING_WINDOW_MS);
|
|
29
|
+
if (recent.length >= DEFENSE_FREQ_LIMIT_PER_HOUR) {
|
|
30
|
+
return {
|
|
31
|
+
channelTopic: req.channelTopic,
|
|
32
|
+
matched: 0,
|
|
33
|
+
joined: 0,
|
|
34
|
+
skipped: 0,
|
|
35
|
+
auditLines: [],
|
|
36
|
+
frequencyLimited: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// ---- 扫描 descriptions 找 matches ----
|
|
40
|
+
const { listDescriptions } = await import('./store.js');
|
|
41
|
+
const descs = await listDescriptions();
|
|
42
|
+
const matched = descs.filter((d) => {
|
|
43
|
+
const open = d.openState === 'open';
|
|
44
|
+
const topicMatch = (d.scope.topics ?? []).includes(req.channelTopic);
|
|
45
|
+
return open && topicMatch;
|
|
46
|
+
});
|
|
47
|
+
// ---- join (defense=stub) ----
|
|
48
|
+
const result = {
|
|
49
|
+
channelTopic: req.channelTopic,
|
|
50
|
+
matched: matched.length,
|
|
51
|
+
joined: 0,
|
|
52
|
+
skipped: 0,
|
|
53
|
+
auditLines: [],
|
|
54
|
+
frequencyLimited: false,
|
|
55
|
+
};
|
|
56
|
+
// 每次请求都写一条 audit line (不论 matched), 这样 frequency limit 才能工作
|
|
57
|
+
result.auditLines.push(JSON.stringify({
|
|
58
|
+
ts: now,
|
|
59
|
+
kind: 'autoadd_request',
|
|
60
|
+
channelTopic: req.channelTopic,
|
|
61
|
+
by: undefined,
|
|
62
|
+
matched: matched.length,
|
|
63
|
+
}));
|
|
64
|
+
for (const d of matched) {
|
|
65
|
+
const ownerPk = d.byAgentId ?? '__no-pk__';
|
|
66
|
+
if (!opts.joinTopic) {
|
|
67
|
+
// defense: 仅 audit, 不调用 joinTopic
|
|
68
|
+
result.skipped += 1;
|
|
69
|
+
const line = JSON.stringify({
|
|
70
|
+
ts: now,
|
|
71
|
+
kind: 'autoadd_skipped',
|
|
72
|
+
channelTopic: req.channelTopic,
|
|
73
|
+
descriptionId: d.descriptionId,
|
|
74
|
+
ownerPk,
|
|
75
|
+
reason: 'defense stub',
|
|
76
|
+
});
|
|
77
|
+
result.auditLines.push(line);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const r = await opts.joinTopic(req.channelTopic, ownerPk);
|
|
81
|
+
if (r.ok) {
|
|
82
|
+
result.joined += 1;
|
|
83
|
+
result.auditLines.push(JSON.stringify({
|
|
84
|
+
ts: now,
|
|
85
|
+
kind: 'autoadd_joined',
|
|
86
|
+
channelTopic: req.channelTopic,
|
|
87
|
+
descriptionId: d.descriptionId,
|
|
88
|
+
ownerPk,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
result.skipped += 1;
|
|
93
|
+
result.auditLines.push(JSON.stringify({
|
|
94
|
+
ts: now,
|
|
95
|
+
kind: 'autoadd_join_failed',
|
|
96
|
+
channelTopic: req.channelTopic,
|
|
97
|
+
descriptionId: d.descriptionId,
|
|
98
|
+
ownerPk,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// ---- 写 audit log ----
|
|
103
|
+
await appendCounterfactualAudit(result.auditLines);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// audit 读写 helpers
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
async function readAutoaddAudit() {
|
|
110
|
+
const auditPath = await auditPathResolved();
|
|
111
|
+
try {
|
|
112
|
+
const raw = await fs.readFile(auditPath, 'utf-8');
|
|
113
|
+
return raw.split('\n').filter(Boolean).map((l) => {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(l);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}).filter((x) => !!x);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function appendCounterfactualAudit(lines) {
|
|
127
|
+
if (lines.length === 0)
|
|
128
|
+
return;
|
|
129
|
+
const auditPath = await auditPathResolved();
|
|
130
|
+
const dir = path.dirname(auditPath);
|
|
131
|
+
await fs.mkdir(dir, { recursive: true });
|
|
132
|
+
await fs.appendFile(auditPath, lines.join('\n') + '\n', 'utf-8');
|
|
133
|
+
}
|
|
134
|
+
let _auditPathCache = null;
|
|
135
|
+
async function auditPathResolved() {
|
|
136
|
+
if (_auditPathCache)
|
|
137
|
+
return _auditPathCache;
|
|
138
|
+
const home = process.env.BOLLOON_HOME || path.join(os.homedir(), '.bolloon');
|
|
139
|
+
_auditPathCache = path.join(home, 'human-values', 'counterfactual-audit.jsonl');
|
|
140
|
+
return _auditPathCache;
|
|
141
|
+
}
|
|
142
|
+
// 工具: 复位 cache (测试用)
|
|
143
|
+
export function _resetAuditPathCacheForTest() {
|
|
144
|
+
_auditPathCache = null;
|
|
145
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* judgeness · protocol.ts
|
|
3
|
+
*
|
|
4
|
+
* 4 新 P2P kind (扩展 judgment-protocol 的 Kind 枚举):
|
|
5
|
+
* - hearth_description_publish: A 告知 B "我公开了 jd <id>"
|
|
6
|
+
* - hearth_description_query: A 向 B 询问 jd <id> 正文
|
|
7
|
+
* - hearth_autoadd_invite: A 邀请 B 加入 channel <topic>
|
|
8
|
+
* - hearth_block: A 屏蔽 B / 某 channel
|
|
9
|
+
*
|
|
10
|
+
* 复用 src/agents/judgment-protocol.ts 的 listener 安装模式 (174-194).
|
|
11
|
+
* Transport 仍走 IrohTransport (不另起), 复用 sendMessage.
|
|
12
|
+
*
|
|
13
|
+
* 防御期 (现在 → 6 月):
|
|
14
|
+
* - 此文件已发布, 但 4 kind 仅在 enum 占位; 不会发帧.
|
|
15
|
+
* - 相持期开始才真正调用 sendMessage.
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from 'events';
|
|
18
|
+
import { irohTransport as defaultIrohTransport } from '../network/iroh-transport.js';
|
|
19
|
+
import { resolveGate2, resolveGate3 } from './visibility.js';
|
|
20
|
+
class HearthEventBus extends EventEmitter {
|
|
21
|
+
}
|
|
22
|
+
export const hearthEventBus = new HearthEventBus();
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// 帧构造 / 解析
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
function encode(f) {
|
|
27
|
+
return new TextEncoder().encode(JSON.stringify({ kind: f.kind, payload: f.payload, ts: f.payload.ts }));
|
|
28
|
+
}
|
|
29
|
+
function decode(buf) {
|
|
30
|
+
try {
|
|
31
|
+
const obj = JSON.parse(new TextDecoder().decode(buf));
|
|
32
|
+
if (!obj?.kind || !obj.payload)
|
|
33
|
+
return null;
|
|
34
|
+
if (!isHearthKind(obj.kind))
|
|
35
|
+
return null;
|
|
36
|
+
return obj;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isHearthKind(k) {
|
|
43
|
+
return (k === 'hearth_description_publish' ||
|
|
44
|
+
k === 'hearth_description_query' ||
|
|
45
|
+
k === 'hearth_autoadd_invite' ||
|
|
46
|
+
k === 'hearth_block');
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// 协议硬约束 (发送前 throw)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
/** 在调用 transport.sendMessage 前必跑一次 */
|
|
52
|
+
export function validateFrameBeforeSend(frame) {
|
|
53
|
+
const p = frame.payload;
|
|
54
|
+
switch (frame.kind) {
|
|
55
|
+
case 'hearth_description_publish':
|
|
56
|
+
if (!p.descriptionId)
|
|
57
|
+
throw new Error('hearth_description_publish: descriptionId required');
|
|
58
|
+
if (!p.visibility)
|
|
59
|
+
throw new Error('hearth_description_publish: visibility required');
|
|
60
|
+
break;
|
|
61
|
+
case 'hearth_description_query':
|
|
62
|
+
if (!p.descriptionId)
|
|
63
|
+
throw new Error('hearth_description_query: descriptionId required');
|
|
64
|
+
break;
|
|
65
|
+
case 'hearth_autoadd_invite':
|
|
66
|
+
if (!p.channelTopic)
|
|
67
|
+
throw new Error('hearth_autoadd_invite: channelTopic required');
|
|
68
|
+
if (p.visibility === 'private')
|
|
69
|
+
throw new Error('hearth_autoadd_invite: visibility=private forbidden');
|
|
70
|
+
break;
|
|
71
|
+
case 'hearth_block':
|
|
72
|
+
if (p.targetNodeId === p.fromNodeId)
|
|
73
|
+
throw new Error('hearth_block: cannot block self');
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const states = new WeakMap();
|
|
78
|
+
function getState(t) {
|
|
79
|
+
let s = states.get(t);
|
|
80
|
+
if (!s) {
|
|
81
|
+
s = { listenersInstalled: false };
|
|
82
|
+
states.set(t, s);
|
|
83
|
+
}
|
|
84
|
+
return s;
|
|
85
|
+
}
|
|
86
|
+
export function ensureHearthListeners(transport = defaultIrohTransport) {
|
|
87
|
+
const s = getState(transport);
|
|
88
|
+
if (s.listenersInstalled)
|
|
89
|
+
return;
|
|
90
|
+
s.listenersInstalled = true;
|
|
91
|
+
transport.onMessage('hearth_description_publish', async (msg) => {
|
|
92
|
+
const f = decode(msg.payload);
|
|
93
|
+
if (!f || f.kind !== 'hearth_description_publish')
|
|
94
|
+
return;
|
|
95
|
+
const p = f.payload;
|
|
96
|
+
hearthEventBus.emit('event', { kind: 'publish_received', publishId: p.publishId, fromNodeId: p.fromNodeId });
|
|
97
|
+
await onPublishReceived(transport, p);
|
|
98
|
+
});
|
|
99
|
+
transport.onMessage('hearth_description_query', async (msg) => {
|
|
100
|
+
const f = decode(msg.payload);
|
|
101
|
+
if (!f || f.kind !== 'hearth_description_query')
|
|
102
|
+
return;
|
|
103
|
+
const p = f.payload;
|
|
104
|
+
hearthEventBus.emit('event', { kind: 'query_received', queryId: p.queryId, fromNodeId: p.fromNodeId, descriptionId: p.descriptionId });
|
|
105
|
+
await onQueryReceived(transport, p);
|
|
106
|
+
});
|
|
107
|
+
transport.onMessage('hearth_autoadd_invite', async (msg) => {
|
|
108
|
+
const f = decode(msg.payload);
|
|
109
|
+
if (!f || f.kind !== 'hearth_autoadd_invite')
|
|
110
|
+
return;
|
|
111
|
+
const p = f.payload;
|
|
112
|
+
hearthEventBus.emit('event', { kind: 'invite_received', inviteId: p.inviteId, fromNodeId: p.fromNodeId, channelTopic: p.channelTopic });
|
|
113
|
+
await onAutoaddInviteReceived(transport, p);
|
|
114
|
+
});
|
|
115
|
+
transport.onMessage('hearth_block', async (msg) => {
|
|
116
|
+
const f = decode(msg.payload);
|
|
117
|
+
if (!f || f.kind !== 'hearth_block')
|
|
118
|
+
return;
|
|
119
|
+
const p = f.payload;
|
|
120
|
+
hearthEventBus.emit('event', { kind: 'block_received', blockId: p.blockId, fromNodeId: p.fromNodeId, targetNodeId: p.targetNodeId });
|
|
121
|
+
await onBlockReceived(p);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Listener handlers (实现都先 stub, 等相持期再接 store / p2p-direct)
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
async function onPublishReceived(_t, _p) {
|
|
128
|
+
// TODO(相持期): 校验 fromNodeId 在 allowlist 内, 然后 fetch cache
|
|
129
|
+
}
|
|
130
|
+
async function onQueryReceived(_t, _p) {
|
|
131
|
+
// TODO(相持期): 闸 3 后, 把对应 description 走 visibility scrubber 后回发
|
|
132
|
+
}
|
|
133
|
+
async function onAutoaddInviteReceived(_t, p) {
|
|
134
|
+
// 闸 2: 检查 fromNodeId 是否在 allowlist, 且 channel 隐私策略兼容
|
|
135
|
+
const ctx = { pubkey: p.fromNodeId, role: 'agent', channelTopic: p.channelTopic };
|
|
136
|
+
const g2 = await resolveGate2(p.fromNodeId, p.channelTopic);
|
|
137
|
+
if (!g2.allow) {
|
|
138
|
+
// 自动回一个 block
|
|
139
|
+
await sendBlock(_t, p.fromNodeId, p.channelTopic);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function onBlockReceived(p) {
|
|
143
|
+
// TODO(相持期): 加入 inbound 黑名单, 后续入站全 reject
|
|
144
|
+
void p;
|
|
145
|
+
}
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// 发送接口 (相持期 / 反攻期主用)
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
export async function sendPublish(transport, descriptionId, toNodeId, visibility, channelTopic) {
|
|
150
|
+
const frame = {
|
|
151
|
+
kind: 'hearth_description_publish',
|
|
152
|
+
payload: {
|
|
153
|
+
publishId: `pub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
154
|
+
fromNodeId: toNodeId, // 占位: 真实发送时本方 nodeId 由 transport 提供, 这里用目标
|
|
155
|
+
descriptionId,
|
|
156
|
+
visibility,
|
|
157
|
+
channelTopic,
|
|
158
|
+
ts: Date.now(),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
validateFrameBeforeSend(frame);
|
|
162
|
+
await transport.sendMessage(toNodeId, frame.kind, encode(frame));
|
|
163
|
+
hearthEventBus.emit('event', { kind: 'publish_sent', publishId: frame.payload.publishId, peer: toNodeId });
|
|
164
|
+
}
|
|
165
|
+
export async function sendQuery(transport, descriptionId, toNodeId) {
|
|
166
|
+
const frame = {
|
|
167
|
+
kind: 'hearth_description_query',
|
|
168
|
+
payload: {
|
|
169
|
+
queryId: `q-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
170
|
+
fromNodeId: toNodeId,
|
|
171
|
+
descriptionId,
|
|
172
|
+
ts: Date.now(),
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
validateFrameBeforeSend(frame);
|
|
176
|
+
await transport.sendMessage(toNodeId, frame.kind, encode(frame));
|
|
177
|
+
}
|
|
178
|
+
export async function sendAutoaddInvite(transport, channelTopic, toNodeId, visibility = 'allowlist') {
|
|
179
|
+
const frame = {
|
|
180
|
+
kind: 'hearth_autoadd_invite',
|
|
181
|
+
payload: {
|
|
182
|
+
inviteId: `inv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
183
|
+
fromNodeId: toNodeId,
|
|
184
|
+
channelTopic,
|
|
185
|
+
visibility,
|
|
186
|
+
ts: Date.now(),
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
validateFrameBeforeSend(frame);
|
|
190
|
+
await transport.sendMessage(toNodeId, frame.kind, encode(frame));
|
|
191
|
+
hearthEventBus.emit('event', { kind: 'invite_sent', inviteId: frame.payload.inviteId, peer: toNodeId });
|
|
192
|
+
}
|
|
193
|
+
export async function sendBlock(transport, targetNodeId, channelTopic, fromNodeId = '__self__') {
|
|
194
|
+
const frame = {
|
|
195
|
+
kind: 'hearth_block',
|
|
196
|
+
payload: {
|
|
197
|
+
blockId: `blk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
198
|
+
fromNodeId,
|
|
199
|
+
targetNodeId,
|
|
200
|
+
channelTopic,
|
|
201
|
+
ts: Date.now(),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
validateFrameBeforeSend(frame);
|
|
205
|
+
await transport.sendMessage(targetNodeId, frame.kind, encode(frame));
|
|
206
|
+
}
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// 防御期唯一可对外暴露的健康查询 (无 IO)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
export function listHearthKinds() {
|
|
211
|
+
return ['hearth_description_publish', 'hearth_description_query', 'hearth_autoadd_invite', 'hearth_block'];
|
|
212
|
+
}
|
|
213
|
+
// 关闭 lint: resolveGate3 未在本文件直用, 给相持期 protocol listener 用
|
|
214
|
+
void resolveGate3;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* judgeness · rank.ts — Discover 排名算法 (反攻期 O2)
|
|
3
|
+
*
|
|
4
|
+
* 设计原则 (与 plan §DISCOVER-RANKING 一致):
|
|
5
|
+
* - 可解释, 不黑盒
|
|
6
|
+
* - 4 因子线性: rank_score = a*recency + b*breadth + c*depth + d*trust
|
|
7
|
+
* - 权重可调 (visibility.yaml.ranking 段; 缺省 0.4/0.2/0.2/0.2)
|
|
8
|
+
* - 每条 ranked 项带 why 字段解释
|
|
9
|
+
*
|
|
10
|
+
* 防御期: 此文件已写, 但 routes-hearth.ts 的 /discover 还没接它 (DEFENSE_MODE).
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_RANK_WEIGHTS = {
|
|
13
|
+
recency: 0.4,
|
|
14
|
+
breadth: 0.2,
|
|
15
|
+
depth: 0.2,
|
|
16
|
+
trust: 0.2,
|
|
17
|
+
};
|
|
18
|
+
/** 主函数. */
|
|
19
|
+
export function rankDescriptions(descs, opts = {}) {
|
|
20
|
+
const w = opts.weights ?? DEFAULT_RANK_WEIGHTS;
|
|
21
|
+
const recencyWindow = opts.recencyWindow ?? 30 * 24 * 60 * 60 * 1000;
|
|
22
|
+
const now = opts.nowMs ?? Date.now();
|
|
23
|
+
const trusted = opts.trustedPks ?? new Set();
|
|
24
|
+
const ownerMap = opts.ownerPkMap ?? new Map();
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const d of descs) {
|
|
27
|
+
const recency = computeRecency(d, now, recencyWindow);
|
|
28
|
+
const breadth = computeBreadth(d);
|
|
29
|
+
const depth = computeDepth(d);
|
|
30
|
+
const ownerPk = ownerMap.get(d.descriptionId) ?? '__unknown__';
|
|
31
|
+
const trust = trusted.has(ownerPk) ? 1 : 0.3; // 不是 allowlist 也有 0.3 base score (public 维度)
|
|
32
|
+
const score = w.recency * recency + w.breadth * breadth + w.depth * depth + w.trust * trust;
|
|
33
|
+
out.push({
|
|
34
|
+
description: d,
|
|
35
|
+
rankScore: clamp01(score),
|
|
36
|
+
why: { recency, breadth, depth, trust },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// 排序: rankScore desc, recency desc tiebreaker
|
|
40
|
+
out.sort((a, b) => {
|
|
41
|
+
if (b.rankScore !== a.rankScore)
|
|
42
|
+
return b.rankScore - a.rankScore;
|
|
43
|
+
return b.description.createdAt.localeCompare(a.description.createdAt);
|
|
44
|
+
});
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
// ---- 子函数 (可单测) ----
|
|
48
|
+
export function computeRecency(d, now, window) {
|
|
49
|
+
const t = Date.parse(d.createdAt);
|
|
50
|
+
if (!Number.isFinite(t))
|
|
51
|
+
return 0;
|
|
52
|
+
const age = Math.max(0, now - t);
|
|
53
|
+
if (age >= window)
|
|
54
|
+
return 0;
|
|
55
|
+
return 1 - age / window;
|
|
56
|
+
}
|
|
57
|
+
export function computeBreadth(d) {
|
|
58
|
+
const topics = new Set(d.scope.topics ?? []);
|
|
59
|
+
const domains = new Set(d.scope.domains ?? []);
|
|
60
|
+
const all = new Set([...topics, ...domains]);
|
|
61
|
+
// 3 = 满分 (覆盖广)
|
|
62
|
+
return clamp01(all.size / 3);
|
|
63
|
+
}
|
|
64
|
+
export function computeDepth(d) {
|
|
65
|
+
const facets = d.facets ?? {};
|
|
66
|
+
const filled = ['judgment', 'taste_aesthetic', 'novelty_score', 'imaginative_score', 'curiosity_vector']
|
|
67
|
+
.filter((k) => facets[k] !== undefined && facets[k] !== null).length;
|
|
68
|
+
// 5 维满分 1; 加 basis 文本可冲 1.2 (clamp 1)
|
|
69
|
+
const basis = d.basis ?? {};
|
|
70
|
+
const basisBonus = ['taste_basis', 'novelty_basis', 'imagination_basis']
|
|
71
|
+
.filter((k) => typeof basis[k] === 'string' && basis[k].length > 5).length;
|
|
72
|
+
return clamp01(filled / 5 + basisBonus * 0.06);
|
|
73
|
+
}
|
|
74
|
+
function clamp01(x) {
|
|
75
|
+
if (!Number.isFinite(x))
|
|
76
|
+
return 0;
|
|
77
|
+
return Math.max(0, Math.min(1, x));
|
|
78
|
+
}
|