@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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* judgeness · visibility.ts
|
|
3
|
+
*
|
|
4
|
+
* 三道授权闸的核心实现:
|
|
5
|
+
* 闸 1: id-visibility scrubber 出站前按 audience 字段过滤
|
|
6
|
+
* 闸 2: channel-allowlist gate joinPeer / joinTopic 前的白名单
|
|
7
|
+
* 闸 3: human-override handler 任何写入由人类 override 优先
|
|
8
|
+
*
|
|
9
|
+
* 复用了现有 sanitizeChannelForPeer 模式 (src/web/server-v3-p2p.ts:54) 的思路:
|
|
10
|
+
* 不引额外依赖; 默认 fail-closed (闸 1/2); 闸 3 fail-人类优先.
|
|
11
|
+
*/
|
|
12
|
+
import { loadVisibility, isPubkeyAllowed } from './store.js';
|
|
13
|
+
/** 三态映射:
|
|
14
|
+
* - locked + agent 写入 → 拒
|
|
15
|
+
* - locked + human 写入 → 允许
|
|
16
|
+
* - open + 任意 → 允许 (但要过闸 2 allowlist)
|
|
17
|
+
* - human-only + agent 写入 → 拒
|
|
18
|
+
* - visibility.yaml.humanOverride=true → 完全优先于 agent openState
|
|
19
|
+
*/
|
|
20
|
+
export function resolveGate3(desc, ctx, visFile) {
|
|
21
|
+
// 闸 3 先看 visibility.yaml.humanOverride (强制)
|
|
22
|
+
const visCard = visFile.cards.find((c) => c.descriptionId === desc.descriptionId);
|
|
23
|
+
const visChan = ctx.channelTopic
|
|
24
|
+
? visFile.channels.find((c) => c.channelId === ctx.channelTopic)
|
|
25
|
+
: undefined;
|
|
26
|
+
const humanOverride = visCard?.humanOverride ?? visChan?.humanOverride ?? false;
|
|
27
|
+
const effectiveOpenState = visCard?.openState ?? visChan?.openState ?? desc.openState;
|
|
28
|
+
// humanOverride=true 时, agent 永不能写, 即使 openState=open
|
|
29
|
+
if (humanOverride && ctx.role !== 'human') {
|
|
30
|
+
return { allow: false, reason: 'humanOverride=true and writer is not human' };
|
|
31
|
+
}
|
|
32
|
+
// human-only 状态: 仅 human
|
|
33
|
+
if (effectiveOpenState === 'human-only' && ctx.role !== 'human') {
|
|
34
|
+
return { allow: false, reason: 'openState=human-only rejects agent' };
|
|
35
|
+
}
|
|
36
|
+
// locked 状态: agent 不能自动 share / publish (但可写入本地 draft)
|
|
37
|
+
if (effectiveOpenState === 'locked' && ctx.role === 'agent') {
|
|
38
|
+
return { allow: false, reason: 'openState=locked rejects agent auto-write' };
|
|
39
|
+
}
|
|
40
|
+
return { allow: true, reason: 'ok' };
|
|
41
|
+
}
|
|
42
|
+
/** 给 audience 一份 description 的可见版本. */
|
|
43
|
+
export async function scrubForAudience(desc, audience) {
|
|
44
|
+
const vis = await loadVisibility();
|
|
45
|
+
const visCard = vis.cards.find((c) => c.descriptionId === desc.descriptionId);
|
|
46
|
+
const visChan = audience.channelTopic
|
|
47
|
+
? vis.channels.find((c) => c.channelId === audience.channelTopic)
|
|
48
|
+
: undefined;
|
|
49
|
+
const effectiveVis = visCard?.visibility ?? visChan?.visibility ?? desc.visibility;
|
|
50
|
+
const base = {
|
|
51
|
+
descriptionId: desc.descriptionId,
|
|
52
|
+
judgmentRef: desc.judgmentRef,
|
|
53
|
+
visibility: effectiveVis,
|
|
54
|
+
openState: visCard?.openState ?? visChan?.openState ?? desc.openState,
|
|
55
|
+
};
|
|
56
|
+
// private 仅 self 可见
|
|
57
|
+
if (effectiveVis === 'private' && audience.pubkey !== '__self__') {
|
|
58
|
+
return base;
|
|
59
|
+
}
|
|
60
|
+
// peers 仅已 join 的 peer (这里简化为: 任何非 self 都视为 peer-by-default)
|
|
61
|
+
if (effectiveVis === 'peers' && audience.pubkey === '__self__') {
|
|
62
|
+
return { ...base, facets: desc.facets, basis: desc.basis, scope: desc.scope, by: desc.by, createdAt: desc.createdAt };
|
|
63
|
+
}
|
|
64
|
+
// allowlist
|
|
65
|
+
if (effectiveVis === 'allowlist') {
|
|
66
|
+
if (audience.pubkey === '__self__') {
|
|
67
|
+
return { ...base, facets: desc.facets, basis: desc.basis, scope: desc.scope, by: desc.by, createdAt: desc.createdAt };
|
|
68
|
+
}
|
|
69
|
+
const allowed = await isPubkeyAllowed(audience.pubkey);
|
|
70
|
+
if (!allowed)
|
|
71
|
+
return base;
|
|
72
|
+
}
|
|
73
|
+
// public
|
|
74
|
+
return {
|
|
75
|
+
...base,
|
|
76
|
+
facets: desc.facets,
|
|
77
|
+
basis: desc.basis,
|
|
78
|
+
scope: desc.scope,
|
|
79
|
+
by: desc.by,
|
|
80
|
+
createdAt: desc.createdAt,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** 批量. 顺序: scrub → 过滤 private (self-only). */
|
|
84
|
+
export async function scrubListForAudience(descs, audience) {
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const d of descs) {
|
|
87
|
+
const s = await scrubForAudience(d, audience);
|
|
88
|
+
out.push(s);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// 闸 2 — allowlist gate (joinPeer / joinTopic 前)
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
/** resolveGate2: true = 允许 join. */
|
|
96
|
+
export async function resolveGate2(audiencePubkey, targetChannel, visFile) {
|
|
97
|
+
// 自我永远放行
|
|
98
|
+
if (audiencePubkey === '__self__')
|
|
99
|
+
return { allow: true, reason: 'self' };
|
|
100
|
+
const f = visFile ?? (await loadVisibility());
|
|
101
|
+
const chan = f.channels.find((c) => c.channelId === targetChannel);
|
|
102
|
+
if (!chan) {
|
|
103
|
+
// channel 没登记 = 默认 allowlist 模式 (闸 2 fail-closed)
|
|
104
|
+
const allowed = await isPubkeyAllowed(audiencePubkey);
|
|
105
|
+
return allowed
|
|
106
|
+
? { allow: true, reason: 'default allowlist: pk in list' }
|
|
107
|
+
: { allow: false, reason: 'default allowlist: pk not in list' };
|
|
108
|
+
}
|
|
109
|
+
if (chan.visibility === 'public')
|
|
110
|
+
return { allow: true, reason: 'channel=public' };
|
|
111
|
+
if (chan.visibility === 'private')
|
|
112
|
+
return { allow: false, reason: 'channel=private' };
|
|
113
|
+
// allowlist / peers 都要求在白名单
|
|
114
|
+
const allowed = await isPubkeyAllowed(audiencePubkey);
|
|
115
|
+
return allowed
|
|
116
|
+
? { allow: true, reason: 'allowlist match' }
|
|
117
|
+
: { allow: false, reason: 'allowlist miss' };
|
|
118
|
+
}
|
package/dist/llm/config-store.js
CHANGED
|
@@ -65,7 +65,9 @@ export const DEFAULT_PROVIDER_CONFIGS = {
|
|
|
65
65
|
enabled: false,
|
|
66
66
|
apiKey: '',
|
|
67
67
|
baseUrl: 'https://api.deepseek.com/v1',
|
|
68
|
-
|
|
68
|
+
// 2026-07-17: deepseek-chat (V3) 已不在官方 model list, 迁到 V4 系列 — deepseek-v4-flash
|
|
69
|
+
// 1M context, 支持 tool calls, 默认 thinking mode (官方 https://api-docs.deepseek.com/quick_start/pricing)
|
|
70
|
+
model: 'deepseek-v4-flash',
|
|
69
71
|
temperature: 0.7,
|
|
70
72
|
maxTokens: 4096,
|
|
71
73
|
requiresApiKey: true
|
|
@@ -134,7 +136,8 @@ export const PROVIDER_INFO = {
|
|
|
134
136
|
requiresApiKey: true,
|
|
135
137
|
models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2', 'MiniMax-M2.1-highspeed', 'MiniMax-M2.7-highspeed']
|
|
136
138
|
},
|
|
137
|
-
|
|
139
|
+
// 2026-07-17: V3 系列 (deepseek-chat / deepseek-reasoner) 官方已下线, 改 V4
|
|
140
|
+
deepseek: { name: 'DeepSeek', description: '深度求索大模型 (V4)', requiresApiKey: true, models: ['deepseek-v4-flash', 'deepseek-v4-pro'] },
|
|
138
141
|
kimi: { name: 'Kimi (月之暗面)', description: 'Moonshot 长上下文模型', requiresApiKey: true, models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'] },
|
|
139
142
|
glm: { name: 'GLM (智谱)', description: '智谱 ChatGLM 系列模型', requiresApiKey: true, models: ['glm-4-flash', 'glm-4', 'glm-4-plus', 'glm-4-air', 'glm-4-airx'] },
|
|
140
143
|
qwen: { name: 'Qwen (通义千问)', description: '阿里云通义千问系列', requiresApiKey: true, models: ['qwen-plus', 'qwen-max', 'qwen-turbo', 'qwen-long'] },
|
package/dist/llm/pi-ai.js
CHANGED
|
@@ -68,7 +68,7 @@ export class PiAIModel {
|
|
|
68
68
|
* LLM 看不到 tool 调用的真实结果,导致 CLI loop 卡死.
|
|
69
69
|
* 现在 messages 数组版本保留 role 语义, LLM 能正确看到工具返回.
|
|
70
70
|
*/
|
|
71
|
-
async chat(messageOrMessages, contextOrSystemPrompt, signal) {
|
|
71
|
+
async chat(messageOrMessages, contextOrSystemPrompt, signal, tools) {
|
|
72
72
|
const systemPrompt = await this.buildSystemPromptAsync(contextOrSystemPrompt);
|
|
73
73
|
let messages;
|
|
74
74
|
if (Array.isArray(messageOrMessages)) {
|
|
@@ -93,6 +93,7 @@ export class PiAIModel {
|
|
|
93
93
|
temperature: 0.8,
|
|
94
94
|
maxTokens: 16384, // 2026-06-17: 提到 16384 — agent 注入 16K+ system prompt + 8K tool defs 时, 8K 撞上限返回空 content (见 memory: bolloon-llm-empty-large-prompt)
|
|
95
95
|
signal,
|
|
96
|
+
tools, // pass through for native tool calling
|
|
96
97
|
});
|
|
97
98
|
return { reply: response.reply, toolCalls: response.toolCalls };
|
|
98
99
|
}
|
|
@@ -166,9 +167,10 @@ export class PiAIModel {
|
|
|
166
167
|
console.warn('[pi-ai] systemPrepend 失败:', err?.message?.slice(0, 100));
|
|
167
168
|
}
|
|
168
169
|
}
|
|
170
|
+
let openaiTools;
|
|
169
171
|
if (tools && tools.length > 0) {
|
|
170
172
|
try {
|
|
171
|
-
const { getToolManifest, formatForPrompt } = await import('./tool-manifest/index.js');
|
|
173
|
+
const { getToolManifest, formatForPrompt, formatForOpenAI } = await import('./tool-manifest/index.js');
|
|
172
174
|
const manifests = tools
|
|
173
175
|
.map((id) => getToolManifest(id))
|
|
174
176
|
.filter((m) => m !== undefined);
|
|
@@ -178,6 +180,8 @@ export class PiAIModel {
|
|
|
178
180
|
{ role: 'system', content: toolPrompt },
|
|
179
181
|
...messages,
|
|
180
182
|
];
|
|
183
|
+
// Bug 3: 从 manifests 生成原生 OpenAI tools 格式
|
|
184
|
+
openaiTools = formatForOpenAI(manifests);
|
|
181
185
|
}
|
|
182
186
|
}
|
|
183
187
|
catch (err) {
|
|
@@ -192,7 +196,7 @@ export class PiAIModel {
|
|
|
192
196
|
case 'glm':
|
|
193
197
|
case 'qwen':
|
|
194
198
|
case 'mimo':
|
|
195
|
-
return this.callOpenAI(finalMessages, temperature, maxTokens, signal);
|
|
199
|
+
return this.callOpenAI(finalMessages, temperature, maxTokens, signal, openaiTools);
|
|
196
200
|
case 'anthropic':
|
|
197
201
|
return this.callAnthropic(finalMessages, temperature, maxTokens, signal);
|
|
198
202
|
case 'ollama':
|
|
@@ -259,7 +263,8 @@ export class PiAIModel {
|
|
|
259
263
|
// The 3.x line ships as `-flash` only — there is no `gemini-3.x-pro`.
|
|
260
264
|
gemini: this.config.model || 'gemini-2.5-pro',
|
|
261
265
|
minimax: this.config.model || process.env.MINIMAX_MODEL || 'MiniMax-M3',
|
|
262
|
-
|
|
266
|
+
// 2026-07-17: deepseek-chat (V3) 官方已下线, 迁 deepseek-v4-flash
|
|
267
|
+
deepseek: this.config.model || process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
|
|
263
268
|
kimi: this.config.model || process.env.KIMI_MODEL || process.env.MOONSHOT_MODEL || 'moonshot-v1-8k',
|
|
264
269
|
glm: this.config.model || process.env.GLM_MODEL || process.env.ZHIPU_MODEL || 'glm-4-flash',
|
|
265
270
|
qwen: this.config.model || process.env.QWEN_MODEL || process.env.DASHSCOPE_MODEL || 'qwen-plus',
|
|
@@ -269,7 +274,7 @@ export class PiAIModel {
|
|
|
269
274
|
};
|
|
270
275
|
return modelMap[this.provider];
|
|
271
276
|
}
|
|
272
|
-
async callOpenAI(messages, temperature, maxTokens, signal) {
|
|
277
|
+
async callOpenAI(messages, temperature, maxTokens, signal, tools) {
|
|
273
278
|
const apiKey = this.getApiKey();
|
|
274
279
|
if (!apiKey) {
|
|
275
280
|
throw new Error('OPENAI_API_KEY not set');
|
|
@@ -280,14 +285,12 @@ export class PiAIModel {
|
|
|
280
285
|
temperature,
|
|
281
286
|
max_tokens: maxTokens
|
|
282
287
|
};
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
// 现在外层加 2 次重试 + 退避, 让 90%+ 的一次调用不出现错误
|
|
288
|
+
// Bug 3: 传入原生 tools 参数 + tool_choice auto, LLM 返回结构化 tool_calls
|
|
289
|
+
if (tools && tools.length > 0) {
|
|
290
|
+
requestBody.tools = tools;
|
|
291
|
+
requestBody.tool_choice = 'auto';
|
|
292
|
+
}
|
|
289
293
|
let lastFinishReason = '';
|
|
290
|
-
// 2026-07-06: 加分阶段 instrumentation — 让"9.8s 大头是哪段"可定位
|
|
291
294
|
const _t0 = Date.now();
|
|
292
295
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
293
296
|
const _tFetch = Date.now();
|
|
@@ -313,24 +316,23 @@ export class PiAIModel {
|
|
|
313
316
|
const content = choice?.message?.content || '';
|
|
314
317
|
const toolCalls = choice?.message?.tool_calls;
|
|
315
318
|
lastFinishReason = choice?.finish_reason || '';
|
|
316
|
-
|
|
319
|
+
// Bug 7: tool_calls 存在时不走重试 — LLM 选工具时 content 空是合法的
|
|
320
|
+
if (content || (toolCalls && toolCalls.length > 0)) {
|
|
317
321
|
if (lastFinishReason === 'length') {
|
|
318
322
|
console.warn(`[pi-ai] hit max_tokens ceiling (model=${this.mapModel()}, max_tokens=${maxTokens}) — caller should trim prompt or raise cap`);
|
|
319
323
|
}
|
|
320
|
-
// 2026-07-06: 日志打 fetch/network/parse 三段 + prompt 体积, 以后 LLM 调用慢直接看这里定位
|
|
321
324
|
const _tAfter = Date.now();
|
|
322
325
|
const promptBytes = JSON.stringify(messages).length;
|
|
323
|
-
console.log(`[pi-ai timing] total=${_tAfter - _t0}ms attempt=${attempt + 1} fetch=${_tResp - _tFetch}ms parse=${_tParse - _tResp}ms reply=${content.length}B model=${this.mapModel()} prompt=${promptBytes}B`);
|
|
326
|
+
console.log(`[pi-ai timing] total=${_tAfter - _t0}ms attempt=${attempt + 1} fetch=${_tResp - _tFetch}ms parse=${_tParse - _tResp}ms reply=${content.length}B toolCalls=${toolCalls?.length ?? 0} model=${this.mapModel()} prompt=${promptBytes}B`);
|
|
324
327
|
return { reply: content, toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined };
|
|
325
328
|
}
|
|
326
|
-
// 空 content: 200 但 content="" → minimax 上游偶发, 退避后重试
|
|
327
329
|
console.warn(`[pi-ai] attempt ${attempt + 1}/3: 空 content (finish_reason=${lastFinishReason}), 退避 1.5s 重试`);
|
|
328
330
|
const _tSleep = Date.now();
|
|
329
331
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
330
332
|
console.log(`[pi-ai timing] attempt=${attempt + 1} empty; backoff=${Date.now() - _tSleep}ms; total=${Date.now() - _t0}ms so far`);
|
|
331
333
|
}
|
|
332
334
|
console.warn(`[pi-ai] 3 次重试都返回空 content (finish_reason=${lastFinishReason})`);
|
|
333
|
-
return { reply: '' };
|
|
335
|
+
return { reply: '' };
|
|
334
336
|
}
|
|
335
337
|
async callAnthropic(messages, temperature, maxTokens, signal) {
|
|
336
338
|
const apiKey = this.getApiKey();
|
|
@@ -600,7 +602,8 @@ function detectModel(provider) {
|
|
|
600
602
|
openrouter: 'anthropic/claude-sonnet-4.5',
|
|
601
603
|
gemini: 'gemini-2.5-pro',
|
|
602
604
|
minimax: 'MiniMax-M3',
|
|
603
|
-
|
|
605
|
+
// 2026-07-17: V3 官方下线, 迁 V4
|
|
606
|
+
deepseek: 'deepseek-v4-flash',
|
|
604
607
|
kimi: 'moonshot-v1-8k',
|
|
605
608
|
glm: 'glm-4-flash',
|
|
606
609
|
qwen: 'qwen-plus',
|
|
@@ -48,6 +48,65 @@ export function getToolsByLayer(layerId) {
|
|
|
48
48
|
*
|
|
49
49
|
* 不包含: 完整 parameters schema (那是 PiAI 客户端在调用时读)
|
|
50
50
|
*/
|
|
51
|
+
/**
|
|
52
|
+
* 把 ToolManifest 转成 OpenAI function calling 格式 (tools 数组)
|
|
53
|
+
* 用于 native tool_choice: "auto" 模式, 让 LLM 选择调用.
|
|
54
|
+
*
|
|
55
|
+
* 注意: 递归处理嵌套参数 (type='object' 的 properties / type='array' 的 items)
|
|
56
|
+
*/
|
|
57
|
+
export function formatForOpenAI(tools) {
|
|
58
|
+
const list = tools ?? ALL;
|
|
59
|
+
return list.map((t) => {
|
|
60
|
+
const properties = {};
|
|
61
|
+
const required = [];
|
|
62
|
+
for (const p of t.parameters) {
|
|
63
|
+
properties[p.name] = convertParameter(p);
|
|
64
|
+
if (p.required)
|
|
65
|
+
required.push(p.name);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
type: 'function',
|
|
69
|
+
function: {
|
|
70
|
+
name: t.id,
|
|
71
|
+
description: t.oneLine,
|
|
72
|
+
parameters: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties,
|
|
75
|
+
required,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function convertParameter(p) {
|
|
82
|
+
const schema = { type: p.type === 'enum' ? 'string' : p.type };
|
|
83
|
+
if (p.description)
|
|
84
|
+
schema.description = p.description;
|
|
85
|
+
if (p.enumValues)
|
|
86
|
+
schema.enum = p.enumValues;
|
|
87
|
+
if (p.default !== undefined)
|
|
88
|
+
schema.default = p.default;
|
|
89
|
+
if (p.minimum !== undefined)
|
|
90
|
+
schema.minimum = p.minimum;
|
|
91
|
+
if (p.maximum !== undefined)
|
|
92
|
+
schema.maximum = p.maximum;
|
|
93
|
+
if (p.format)
|
|
94
|
+
schema.format = p.format;
|
|
95
|
+
if (p.type === 'object' && p.properties) {
|
|
96
|
+
schema.properties = {};
|
|
97
|
+
for (const sub of p.properties) {
|
|
98
|
+
schema.properties[sub.name] = convertParameter(sub);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (p.type === 'array' && p.items) {
|
|
102
|
+
schema.items = convertParameter(p.items);
|
|
103
|
+
if (p.minItems !== undefined)
|
|
104
|
+
schema.minItems = p.minItems;
|
|
105
|
+
if (p.maxItems !== undefined)
|
|
106
|
+
schema.maxItems = p.maxItems;
|
|
107
|
+
}
|
|
108
|
+
return schema;
|
|
109
|
+
}
|
|
51
110
|
export function formatForPrompt(tools) {
|
|
52
111
|
const list = tools ?? ALL;
|
|
53
112
|
const lines = [
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 历史 session.messages 清理脚本 (2026-07-15 Bug 4 修复配套)
|
|
3
|
+
* 一次性扫描 ~/.bolloon/sessions/cache/ 下所有 session 文件, 相邻去重 (同 type+content),
|
|
4
|
+
* 写回原文件. 老数据有此 bug (client PATCH + server /message 各 push 一份 user msg)
|
|
5
|
+
* 会导致重启后"每个 user 气泡显示 2 个".
|
|
6
|
+
*
|
|
7
|
+
* 跑法:
|
|
8
|
+
* npx tsx src/scripts/dedup-session-messages.ts [--dry-run] [--only channelId]
|
|
9
|
+
*/
|
|
10
|
+
import { promises as fs } from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
const SESSIONS_DIR = path.join(process.env.HOME || '/tmp', '.bolloon', 'sessions', 'cache');
|
|
13
|
+
async function main() {
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const dryRun = args.includes('--dry-run');
|
|
16
|
+
const onlyId = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
|
|
17
|
+
let files;
|
|
18
|
+
try {
|
|
19
|
+
files = await fs.readdir(SESSIONS_DIR);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
console.log(`无法读 ${SESSIONS_DIR}: ${e.message}`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
console.log(`扫描 ${SESSIONS_DIR}/ (${files.length} 文件), dry-run=${dryRun}${onlyId ? `, only=${onlyId}` : ''}`);
|
|
26
|
+
let totalScanned = 0;
|
|
27
|
+
let totalFixed = 0;
|
|
28
|
+
let totalDupes = 0;
|
|
29
|
+
for (const f of files) {
|
|
30
|
+
if (!f.endsWith('.json'))
|
|
31
|
+
continue;
|
|
32
|
+
if (onlyId && !f.startsWith(onlyId))
|
|
33
|
+
continue;
|
|
34
|
+
const fp = path.join(SESSIONS_DIR, f);
|
|
35
|
+
totalScanned++;
|
|
36
|
+
try {
|
|
37
|
+
const raw = await fs.readFile(fp, 'utf8');
|
|
38
|
+
const session = JSON.parse(raw);
|
|
39
|
+
const msgs = Array.isArray(session.messages) ? session.messages : [];
|
|
40
|
+
if (msgs.length === 0)
|
|
41
|
+
continue;
|
|
42
|
+
let lastType = null;
|
|
43
|
+
let lastContent = null;
|
|
44
|
+
const deduped = msgs.filter((m) => {
|
|
45
|
+
const same = lastType === m.type && lastContent === m.content;
|
|
46
|
+
lastType = m.type;
|
|
47
|
+
lastContent = m.content;
|
|
48
|
+
return !same;
|
|
49
|
+
});
|
|
50
|
+
const dupes = msgs.length - deduped.length;
|
|
51
|
+
if (dupes === 0)
|
|
52
|
+
continue;
|
|
53
|
+
totalFixed++;
|
|
54
|
+
totalDupes += dupes;
|
|
55
|
+
console.log(` ${f}: ${msgs.length} → ${deduped.length} (去重 ${dupes} 条)`);
|
|
56
|
+
if (!dryRun) {
|
|
57
|
+
session.messages = deduped;
|
|
58
|
+
session.lastUpdated = new Date().toISOString();
|
|
59
|
+
await fs.writeFile(fp, JSON.stringify(session, null, 2));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
console.warn(` ${f} 解析失败: ${e.message?.slice(0, 100)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
console.log(`\n扫描 ${totalScanned} 文件, 修复 ${totalFixed} 个, 共去重 ${totalDupes} 条${dryRun ? ' (dry-run, 未写入)' : ''}`);
|
|
67
|
+
}
|
|
68
|
+
main().catch(e => { console.error('ERR:', e); process.exit(1); });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client-hearth.ts — judgeness 前端模块 (占位实现)
|
|
3
|
+
*
|
|
4
|
+
* 防御期: 接 routes-hearth.ts, 渲染最小骨架 UI (My Hearth / Discover / Visit).
|
|
5
|
+
* 真实样式与组件待相持期接入 bolloon 现有 ui 设计 (HIG 等参考).
|
|
6
|
+
*
|
|
7
|
+
* 这个文件会被 esbuild 打包进 dist/web/client.js (与 src/web/client.ts 一致链路).
|
|
8
|
+
*
|
|
9
|
+
* 部署约定: 本文件 import 的 url 都是 '/api/hearth/*', 与 server.ts 端口 54188 对齐.
|
|
10
|
+
*/
|
|
11
|
+
// 默认接受 JSON-LD (agent 头等公民); 人类用 ?view=human 切
|
|
12
|
+
async function fetchHearth(path, opts = {}) {
|
|
13
|
+
const res = await fetch(path, { headers: { Accept: opts.json ? 'application/ld+json' : '*/*' } });
|
|
14
|
+
if (!res.ok)
|
|
15
|
+
throw new Error(`hearth fetch ${path} -> ${res.status}`);
|
|
16
|
+
return await res.json();
|
|
17
|
+
}
|
|
18
|
+
// 三视图 stub: 由主 client.ts 路由到 #/hearth / #/hearth/discover / #/hearth/visit/<pk>
|
|
19
|
+
export async function renderMyHearth(root) {
|
|
20
|
+
root.innerHTML = '<h1>My Hearth</h1><p>Loading…</p>';
|
|
21
|
+
try {
|
|
22
|
+
const data = await fetchHearth('/api/hearth');
|
|
23
|
+
root.innerHTML = `
|
|
24
|
+
<h1>My Hearth</h1>
|
|
25
|
+
<dl>
|
|
26
|
+
<dt>Service</dt><dd>${data.service} v${data.version}</dd>
|
|
27
|
+
<dt>Root</dt><dd><code>${data.rootPath}</code></dd>
|
|
28
|
+
<dt>Description count</dt><dd>${data.descriptionCount}</dd>
|
|
29
|
+
<dt>Visibility channels</dt><dd>${data.visibilityChannels}</dd>
|
|
30
|
+
<dt>Allowlist count</dt><dd>${data.allowlistCount}</dd>
|
|
31
|
+
<dt>Defense mode</dt><dd>${data.defenseMode ? 'ON (write APIs disabled)' : 'OFF (反攻期)'}</dd>
|
|
32
|
+
</dl>
|
|
33
|
+
<p>视图: <a href="#/hearth/discover">Discover</a> · <a href="#/hearth/visit/__self__">Visit self</a></p>`;
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
root.innerHTML = `<h1>My Hearth</h1><p style="color:red">Error: ${e.message}</p>`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function renderDiscover(root) {
|
|
40
|
+
root.innerHTML = '<h1>Discover</h1><p>Searching…</p>';
|
|
41
|
+
try {
|
|
42
|
+
const data = await fetchHearth('/api/hearth/discover');
|
|
43
|
+
// data 可能是 JSON-LD @graph 或 list
|
|
44
|
+
const items = Array.isArray(data) ? data : (data['@graph'] ?? []);
|
|
45
|
+
const cards = items.slice(0, 50).map((it) => {
|
|
46
|
+
const id = it['@id'] ?? it.descriptionId ?? '?';
|
|
47
|
+
const vis = it.visibility ?? '?';
|
|
48
|
+
const state = it.openState ?? '?';
|
|
49
|
+
return `<li><code>${id}</code> · <span>${vis}</span> · <span>${state}</span></li>`;
|
|
50
|
+
}).join('');
|
|
51
|
+
root.innerHTML = `<h1>Discover</h1><ul>${cards || '<li>(no results)</li>'}</ul>`;
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
root.innerHTML = `<h1>Discover</h1><p style="color:red">Error: ${e.message}</p>`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function renderVisit(root, _pubkey) {
|
|
58
|
+
root.innerHTML = `<h1>Visit</h1><p>Visit 视图待相持期实现 (plan §C2).</p>`;
|
|
59
|
+
}
|
|
60
|
+
// 注册 router hook — 主 client.ts 在启动时调用
|
|
61
|
+
export function registerHearthRoutes(router, getRoot) {
|
|
62
|
+
router.on('/hearth', () => void renderMyHearth(getRoot()));
|
|
63
|
+
router.on('/hearth/discover', () => void renderDiscover(getRoot()));
|
|
64
|
+
router.on('/hearth/visit/:pk', (_p, params) => {
|
|
65
|
+
void renderVisit(getRoot(), params['pk'] ?? '__self__');
|
|
66
|
+
});
|
|
67
|
+
}
|