@bolloon/bolloon-agent 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,11 +29,22 @@ function getMainEntry() {
29
29
  const binPath = require.main.filename; // /tmp/node_modules/.bin/bolloon
30
30
  const binDir = path.dirname(binPath); // /tmp/node_modules/.bin
31
31
  const packageDir = path.dirname(binDir); // /tmp/node_modules/@bolloon/bolloon-agent
32
- const distIndex = path.join(packageDir, "dist", "index.js");
33
- if (fs.existsSync(distIndex)) {
34
- return distIndex;
32
+ // 优先用 package.json 'main' 字段 (v0.2.3+ 指向 dist/cli-entry.js).
33
+ // 真实 npm 安装用户拿到的是编译后的 dist, 不该再依赖 tsx.
34
+ const pkgJsonPath = path.join(packageDir, "package.json");
35
+ if (fs.existsSync(pkgJsonPath)) {
36
+ try {
37
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
38
+ if (pkg.main) {
39
+ const mainPath = path.join(packageDir, pkg.main);
40
+ if (fs.existsSync(mainPath)) return mainPath;
41
+ }
42
+ } catch {}
35
43
  }
36
- // fallback to src (for development)
44
+ // fallback: 兼容老版本 (dist/index.js 是之前默认)
45
+ const distIndex = path.join(packageDir, "dist", "index.js");
46
+ if (fs.existsSync(distIndex)) return distIndex;
47
+ // 最后才 fallback 到 src (开发环境)
37
48
  return path.join(packageDir, "src", "index.ts");
38
49
  }
39
50
 
@@ -34,6 +34,7 @@ import { ReactHarness } from '../security/react-harness.js';
34
34
  import { parseToolCall as parseToolCallImpl, isFinalResponse as isFinalResponseImpl, extractFinalAnswer as extractFinalAnswerImpl } from './parse-tool-call.js';
35
35
  import { sessionStore as defaultSessionStore } from './session-store.js';
36
36
  import { ToolRegistry } from './tool-registry.js';
37
+ import { decideMaxIterations, decideContextOverflow, shouldCompactBeforeIteration } from './react-loop.js';
37
38
  const SHARED_SESSION_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'sessions');
38
39
  const PERSONA_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'persona.json');
39
40
  export class PiSessionManager {
@@ -2187,10 +2188,12 @@ ${this.getToolDefinitions()}
2187
2188
  while (iteration < this.MAX_REACT_ITERATIONS) {
2188
2189
  iteration++;
2189
2190
  // 停止条件 1: max turns (fail-safe 10000, 正常任务永远跑不到)
2190
- if (iteration >= this.MAX_REACT_ITERATIONS) {
2191
+ // 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideMaxIterations 纯函数
2192
+ const maxIterDecision = decideMaxIterations(iteration, this.MAX_REACT_ITERATIONS);
2193
+ if (maxIterDecision.shouldExit) {
2191
2194
  console.warn(`[PiAgent] 达到最大循环数 ${this.MAX_REACT_ITERATIONS}, 强制终止 (fail-safe)`);
2192
2195
  onStream?.({ type: 'error', content: `⏹️ 达到最大循环数 (${this.MAX_REACT_ITERATIONS}, fail-safe)`, tool: 'loop' });
2193
- finalResponse = finalResponse || '(本轮 ReAct 循环达到最大步数, 强制结束)';
2196
+ finalResponse = finalResponse || maxIterDecision.finalAnswer;
2194
2197
  break;
2195
2198
  }
2196
2199
  // 停止条件 2: signal.aborted (显式 abort / 用户中断)
@@ -2217,9 +2220,10 @@ ${this.getToolDefinitions()}
2217
2220
  }
2218
2221
  // 2026-06-16 新增: loop 内自动压缩 — token 超 80% 阈值时跑一次
2219
2222
  // compact 失败走 C 路径: 不强行 break, 让现有 60K 阈值兜底 (后面有检查)
2223
+ // 2026-07-01 (v0.2.4 子任务 1): 触发判定走 shouldCompactBeforeIteration 纯函数
2220
2224
  const compactThreshold = this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD * this.LOOP_COMPACT_RATIO;
2221
2225
  const estimatedTokensBefore = this.estimateHistoryTokens();
2222
- if (estimatedTokensBefore > compactThreshold) {
2226
+ if (shouldCompactBeforeIteration(estimatedTokensBefore, compactThreshold)) {
2223
2227
  const tokensBeforeCompact = estimatedTokensBefore;
2224
2228
  console.log(`[PiAgent] loop 入口 token ${tokensBeforeCompact} > ${compactThreshold}, 触发自动压缩`);
2225
2229
  onStream?.({ type: 'status', content: `🗜️ loop 自动压缩 (token ${tokensBeforeCompact} > ${compactThreshold})`, tool: 'compactor' });
@@ -2232,11 +2236,13 @@ ${this.getToolDefinitions()}
2232
2236
  }
2233
2237
  }
2234
2238
  // 停止条件 3: context overflow (compact 后还超, 强制终止)
2239
+ // 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideContextOverflow 纯函数
2235
2240
  const estimatedTokens = this.estimateHistoryTokens();
2236
- if (estimatedTokens > this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD) {
2241
+ const overflowDecision = decideContextOverflow(estimatedTokens, this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD);
2242
+ if (overflowDecision.shouldExit) {
2237
2243
  console.warn(`[PiAgent] context overflow (${estimatedTokens} tokens > ${this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD})`);
2238
2244
  onStream?.({ type: 'error', content: `⏹️ 上下文溢出 (${estimatedTokens} tokens, 阈值 ${this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD})`, tool: 'loop' });
2239
- finalResponse = finalResponse || '(本轮 ReAct 循环因上下文溢出终止)';
2245
+ finalResponse = finalResponse || overflowDecision.finalAnswer;
2240
2246
  break;
2241
2247
  }
2242
2248
  // 调试日志:显示每次循环开始
@@ -78,6 +78,40 @@ export function extractFinalText(reply, marker = '<final gen>') {
78
78
  export function shouldForceExit(totalErrors, maxTotalErrors) {
79
79
  return totalErrors >= maxTotalErrors;
80
80
  }
81
+ /**
82
+ * 2026-07-01 (v0.2.4 子任务 1): 迭代上限判定 — 纯函数.
83
+ * 之前 runReActLoop 在循环顶部硬编码 `if (iteration >= MAX_REACT_ITERATIONS)`,
84
+ * 抽出后 claude code 可独立单测 + 替代.
85
+ */
86
+ export function decideMaxIterations(iteration, maxIterations) {
87
+ return {
88
+ shouldExit: iteration >= maxIterations,
89
+ finalAnswer: iteration >= maxIterations
90
+ ? '(本轮 ReAct 循环达到最大步数, 强制结束)'
91
+ : '',
92
+ };
93
+ }
94
+ /**
95
+ * 2026-07-01 (v0.2.4 子任务 1): context overflow 判定 — 纯函数.
96
+ * 之前 runReActLoop 紧接 decideMaxIterations 之后硬编码 token 阈值检查.
97
+ * 抽出后行为一致 + 可测.
98
+ */
99
+ export function decideContextOverflow(estimatedTokens, threshold) {
100
+ return {
101
+ shouldExit: estimatedTokens > threshold,
102
+ finalAnswer: estimatedTokens > threshold
103
+ ? `(本轮 ReAct 循环因上下文溢出终止)`
104
+ : '',
105
+ };
106
+ }
107
+ /**
108
+ * 2026-07-01 (v0.2.4 子任务 1): compact 触发判定 — 纯函数.
109
+ * 之前 runReActLoop 入口处: estimatedTokens > compactThreshold → 跑 maybeAutoCompact.
110
+ * 抽出纯函数后, claude code 能 dry-run "token X 时该 compact 吗".
111
+ */
112
+ export function shouldCompactBeforeIteration(estimatedTokens, compactThreshold) {
113
+ return estimatedTokens > compactThreshold;
114
+ }
81
115
  /**
82
116
  * 同一工具连续失败 N 次, 提示 LLM 不要再用同一个工具 (force final).
83
117
  */
package/dist/cli-entry.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { spawn } from 'child_process';
14
14
  import * as path from 'path';
15
15
  import * as fs from 'fs';
16
+ import { fileURLToPath } from 'url';
16
17
  const isWindows = process.platform === 'win32';
17
18
  // ANSI 颜色
18
19
  const RESET = '\x1b[0m';
@@ -66,8 +67,11 @@ ${BOLD}环境变量:${RESET}
66
67
  `);
67
68
  }
68
69
  function getDistDir() {
69
- // 对于打包后的应用,__dirname dist 目录
70
- return __dirname;
70
+ // 2026-07-01: ESM 模块无 __dirname, import.meta.url + path.dirname 拿当前文件所在目录.
71
+ // 对于打包后的应用, 这是 dist 目录; 对于 tsx 跑 src/index.ts 时, 是 src/ 目录
72
+ // (后续 getMainScript 会优先用 dist/cli-entry.js, 不依赖这里的返回值)
73
+ const __filename_esm = fileURLToPath(import.meta.url);
74
+ return path.dirname(__filename_esm);
71
75
  }
72
76
  function getMainScript() {
73
77
  const distDir = getDistDir();
@@ -0,0 +1,103 @@
1
+ /**
2
+ * input-validator — web 接口输入验证 (v0.2.5)
3
+ *
4
+ * 2026-07-01: 抽到独立模块, claude code / 前端都能 import 验证.
5
+ * - 后端用: POST /api/validate-input 调 validate()
6
+ * - 前端用: 实时 UI 反馈 (发送前预校验, 不阻塞)
7
+ *
8
+ * 验证规则 (bolloon web 当前业务):
9
+ * - text 非空 (trim 后)
10
+ * - text 长度 <= MAX_TEXT_LENGTH
11
+ * - text 不包含 NUL / 不可打印控制字符
12
+ * - channelId 非空
13
+ * - channelName (新建频道) 长度 <= MAX_NAME_LENGTH
14
+ * - 不含 SQL/XSS payload 触发 (sanitize-only: 不是阻止, 是 warn)
15
+ *
16
+ * 设计: 纯函数 + 返回结构化 ValidationResult.
17
+ * { ok: true, reason?, severity? }
18
+ * 严重等级: 'info' | 'warn' | 'block'
19
+ */
20
+ export const MAX_TEXT_LENGTH = 16_000; // 大约 4K tokens, 单条 user 消息够用
21
+ export const MAX_NAME_LENGTH = 200; // channel name 上限
22
+ export const MAX_AGENT_ID_LENGTH = 200;
23
+ /** 校验单条 /message 请求 (text + channelId) */
24
+ export function validateMessageInput(input) {
25
+ // 1. channelId
26
+ if (typeof input.channelId !== 'string' || input.channelId.trim() === '') {
27
+ return { ok: false, severity: 'block', reason: 'channelId 必填' };
28
+ }
29
+ if (input.channelId.length > 256) {
30
+ return { ok: false, severity: 'block', reason: `channelId 长度超过 256 字符 (${input.channelId.length})` };
31
+ }
32
+ // 2. text
33
+ if (typeof input.text !== 'string') {
34
+ return { ok: false, severity: 'block', reason: 'text 必填且必须是字符串' };
35
+ }
36
+ const trimmed = input.text.trim();
37
+ if (trimmed === '') {
38
+ return { ok: false, severity: 'block', reason: 'text 不能为空 (trim 后)' };
39
+ }
40
+ if (trimmed.length > MAX_TEXT_LENGTH) {
41
+ return {
42
+ ok: false,
43
+ severity: 'block',
44
+ reason: `text 长度 ${trimmed.length} 超过最大 ${MAX_TEXT_LENGTH} 字符`,
45
+ };
46
+ }
47
+ // 3. 控制字符检测 — 拒绝 NUL + 不可打印 char (< 0x20 除了 \n \r \t)
48
+ // eslint-disable-next-line no-control-regex
49
+ const controlCharRe = /[\x00-\x08\x0B-\x0C\x0E-\x1F]/g;
50
+ const controlMatches = trimmed.match(controlCharRe);
51
+ if (controlMatches && controlMatches.length > 0) {
52
+ return {
53
+ ok: false,
54
+ severity: 'block',
55
+ reason: `text 含 ${controlMatches.length} 个不可打印控制字符`,
56
+ };
57
+ }
58
+ // 4. 通用安全 check — 大段 HTML/JS 注入 (sanitize 是另一层, 这里只 warn)
59
+ const htmlRe = /<\/?[a-z][\s\S]*>/i;
60
+ if (htmlRe.test(trimmed) && trimmed.length > 500) {
61
+ return {
62
+ ok: true,
63
+ severity: 'warn',
64
+ reason: 'text 包含 HTML 标签 (>500 字符), 确认发送前预览',
65
+ cleaned: trimmed,
66
+ };
67
+ }
68
+ return { ok: true, severity: 'info', cleaned: trimmed };
69
+ }
70
+ /** 校验 channel 创建输入 */
71
+ export function validateChannelInput(input) {
72
+ if (typeof input.name !== 'string' || input.name.trim() === '') {
73
+ return { ok: false, severity: 'block', reason: 'name 必填' };
74
+ }
75
+ const trimmedName = input.name.trim();
76
+ if (trimmedName.length > MAX_NAME_LENGTH) {
77
+ return {
78
+ ok: false,
79
+ severity: 'block',
80
+ reason: `name 长度 ${trimmedName.length} 超过最大 ${MAX_NAME_LENGTH}`,
81
+ };
82
+ }
83
+ if (typeof input.agentId === 'string' && input.agentId.length > MAX_AGENT_ID_LENGTH) {
84
+ return {
85
+ ok: false,
86
+ severity: 'block',
87
+ reason: `agentId 长度 ${input.agentId.length} 超过最大 ${MAX_AGENT_ID_LENGTH}`,
88
+ };
89
+ }
90
+ return { ok: true, severity: 'info', cleaned: trimmedName };
91
+ }
92
+ const BOOT_TIME = Date.now();
93
+ export function healthCheck(version) {
94
+ return {
95
+ ok: true,
96
+ version,
97
+ uptime_sec: Math.floor((Date.now() - BOOT_TIME) / 1000),
98
+ validators_loaded: [
99
+ 'validateMessageInput',
100
+ 'validateChannelInput',
101
+ ],
102
+ };
103
+ }
@@ -6,6 +6,32 @@ import * as fs from 'fs/promises';
6
6
  import * as fsSync from 'fs';
7
7
  import * as path from 'path';
8
8
  import * as os from 'os';
9
+ import { validateMessageInput, validateChannelInput, healthCheck, } from './input-validator.js';
10
+ // 读自身 package.json 拿 version (health endpoint 用)
11
+ // 路径: src/web/server.ts → ../../package.json (编译后 dist/web/server.js)
12
+ let cachedVersion = null;
13
+ function getPackageVersion() {
14
+ if (cachedVersion)
15
+ return cachedVersion;
16
+ try {
17
+ const here = fileURLToPath(import.meta.url);
18
+ const hereDir = path.dirname(here);
19
+ // 试 ../package.json (相对 dist/web/) 或 ../../package.json (相对 src/web/)
20
+ let raw = null;
21
+ for (const rel of ['../package.json', '../../package.json']) {
22
+ try {
23
+ raw = fsSync.readFileSync(path.join(hereDir, rel), 'utf-8');
24
+ break;
25
+ }
26
+ catch { }
27
+ }
28
+ cachedVersion = raw ? (JSON.parse(raw).version ?? '0.0.0') : 'unknown';
29
+ }
30
+ catch {
31
+ cachedVersion = 'unknown';
32
+ }
33
+ return cachedVersion;
34
+ }
9
35
  // 2026-06-17: 终端静默 — server.ts 高频 spam (LLM 流式每个 token 一次 broadcast,
10
36
  // 每次 P2P 收发 [v3]/[v3-meta]/[v3-cross]/[v3-friend] 各一次) 全部走 console.log proxy,
11
37
  // 默认 (BOLLOON_VERBOSE != '1') 完全不打;VERBOSE=1 时恢复.
@@ -1410,6 +1436,36 @@ export async function createWebServer(port = 3000, options = {}) {
1410
1436
  }
1411
1437
  app.get('/', serveStaticHtml('index.html', 'index.html not found; please run `npm run build:web`', 'index'));
1412
1438
  app.get('/api-config', serveStaticHtml('api-config.html', 'api-config.html not found; please run `npm run build:web`', 'api-config'));
1439
+ // 2026-07-01 (v0.2.5): 输入验证 + 健康检查 API
1440
+ // - POST /api/validate-input: 前端发送前预校验, 避免后端 reject
1441
+ // - GET /api/health: liveness probe + validators 清单
1442
+ // 这些端点不依赖 LLM / P2P, 永远可以响应.
1443
+ app.post('/api/validate-input', async (req, res) => {
1444
+ try {
1445
+ const body = req.body ?? {};
1446
+ const kind = body.kind || 'message';
1447
+ let result;
1448
+ if (kind === 'channel') {
1449
+ result = validateChannelInput({ name: body.name, agentId: body.agentId });
1450
+ }
1451
+ else {
1452
+ result = validateMessageInput({ text: body.text, channelId: body.channelId });
1453
+ }
1454
+ res.json({
1455
+ ok: result.ok,
1456
+ severity: result.severity ?? (result.ok ? 'info' : 'block'),
1457
+ reason: result.reason,
1458
+ cleaned: result.cleaned,
1459
+ kind,
1460
+ });
1461
+ }
1462
+ catch (e) {
1463
+ res.status(400).json({ ok: false, severity: 'block', reason: e?.message ?? 'parse error' });
1464
+ }
1465
+ });
1466
+ app.get('/api/health', (_req, res) => {
1467
+ res.json(healthCheck(getPackageVersion()));
1468
+ });
1413
1469
  // 全局兜底: 任何 next(err) 走到这里, 给出结构化 4xx/5xx 而不是默认 HTML
1414
1470
  app.use((err, req, res, _next) => {
1415
1471
  console.error('[server] unhandled error on', req.method, req.path, '-', err?.message || err);
@@ -1480,8 +1536,11 @@ export async function createWebServer(port = 3000, options = {}) {
1480
1536
  runState.running = true;
1481
1537
  runState.abortController = new AbortController();
1482
1538
  broadcastQueueUpdate(channelId);
1539
+ // 2026-07-01 (v0.2.5): hoist sessionKey 到 try 外, 让 finally 块的 saveCurrentSession 能用
1540
+ const sessionKey = `${channelId}:${currentSessionId}`;
1541
+ let agent = null;
1483
1542
  try {
1484
- const agent = await getAgentForChannel(channelId, realChannelDid, realChannelName, realChannelDidDoc);
1543
+ agent = await getAgentForChannel(channelId, realChannelDid, realChannelName, realChannelDidDoc);
1485
1544
  let fullResponse = '';
1486
1545
  // P0.5: 注入门回传的 usedIds, 落 session message metadata, UI 可查
1487
1546
  let usedJudgmentIds = [];
@@ -1816,6 +1875,18 @@ export async function createWebServer(port = 3000, options = {}) {
1816
1875
  runState.running = false;
1817
1876
  runState.abortController = null;
1818
1877
  broadcastQueueUpdate(channelId);
1878
+ // 2026-07-01 (v0.2.5): 持久化当前 messageHistory — 让 web 用户跨刷新保留对话.
1879
+ // saveCurrentSession 失败静默, 不阻塞 channel 状态清理.
1880
+ // saveCurrentSession 内部走 SessionStore (默认 ~/.bolloon/sessions/cache/<sessionKey>.json).
1881
+ // agent 可能在 try 抛错后仍为 null (getAgentForChannel 失败), null-guard 跳过 save.
1882
+ if (agent) {
1883
+ try {
1884
+ await agent.saveCurrentSession(sessionKey);
1885
+ }
1886
+ catch (saveErr) {
1887
+ console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr?.message?.slice(0, 100)}`);
1888
+ }
1889
+ }
1819
1890
  }
1820
1891
  });
1821
1892
  // ---------- 频道元数据后台修复队列 ----------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",