@bolloon/bolloon-agent 0.1.24 → 0.1.25

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.
@@ -623,6 +623,11 @@ async function selectChannel(channelId, targetSessionId = null) {
623
623
  currentChannelId = channelId;
624
624
  reconnectAttempts.set(channelId, 0);
625
625
 
626
+ // v3: 盾牌弹窗打开时, 切 channel 要刷列表 (tab 标题 + 已绑/未绑 分组)
627
+ if (typeof judgmentsModal !== 'undefined' && judgmentsModal && judgmentsModal.classList.contains('active')) {
628
+ if (typeof lastJudgmentsCache !== 'undefined') renderJudgments(lastJudgmentsCache);
629
+ }
630
+
626
631
  // 找到当前频道和 session
627
632
  const channel = channels.find(c => c.id === channelId);
628
633
  if (channel) {
@@ -1996,6 +2001,18 @@ let judgmentsLoaded = false;
1996
2001
  function showJudgmentsModal() {
1997
2002
  if (judgmentsModal) judgmentsModal.classList.add('active');
1998
2003
  if (!judgmentsLoaded) loadJudgments();
2004
+ else renderJudgments(lastJudgmentsCache); // 打开时按当前 channel / tab 重渲
2005
+ }
2006
+
2007
+ function switchJudgmentTab(tab) {
2008
+ currentJudgmentTab = tab;
2009
+ document.querySelectorAll('.judgment-tab').forEach(btn => {
2010
+ const active = btn.dataset.tab === tab;
2011
+ btn.classList.toggle('active', active);
2012
+ btn.style.borderBottomColor = active ? '#2563eb' : 'transparent';
2013
+ btn.style.color = active ? '#2563eb' : '#6b7280';
2014
+ });
2015
+ renderJudgments(lastJudgmentsCache);
1999
2016
  }
2000
2017
 
2001
2018
  function hideJudgmentsModal() {
@@ -2008,16 +2025,83 @@ function escapeHtml(s) {
2008
2025
  }[c]));
2009
2026
  }
2010
2027
 
2028
+ let currentJudgmentTab = 'channel'; // 'channel' | 'global'
2029
+ let lastJudgmentsCache = []; // 最近一次 loadJudgments 拿到的原始列表, 切 tab / 切 channel 时复用
2030
+
2031
+ /**
2032
+ * v3 重做: 渲染判断力列表 (受 tab + 当前 channel 影响)
2033
+ * tab = 'channel': 拆为"已绑定" + "未绑定"两组, 每条带 + / × 按钮
2034
+ * tab = 'global': 全部 judgment 列表, 无 + / × 按钮
2035
+ * 如果没选 channel, 'channel' tab 自动显示提示 + 全部 judgment
2036
+ */
2011
2037
  function renderJudgments(items) {
2012
2038
  if (!judgmentsList) return;
2013
- if (!items || items.length === 0) {
2039
+ const all = items || [];
2040
+ const titleEl = document.getElementById('judgments-list-title');
2041
+ const chNameEl = document.getElementById('judgments-tab-channel-name');
2042
+ const currentCh = currentChannelId
2043
+ ? channels.find(c => c.id === currentChannelId)
2044
+ : null;
2045
+
2046
+ if (chNameEl) {
2047
+ chNameEl.textContent = currentCh ? `(${currentCh.name})` : '(未选)';
2048
+ }
2049
+
2050
+ if (all.length === 0) {
2014
2051
  judgmentsList.innerHTML = '<div class="task-empty">还没有判断, 在上面记录第一条吧</div>';
2052
+ if (titleEl) titleEl.textContent = '本 channel 的判断力';
2053
+ return;
2054
+ }
2055
+
2056
+ if (currentJudgmentTab === 'global') {
2057
+ // 全局 tab: 全部 judgment, 简单列表
2058
+ if (titleEl) titleEl.textContent = `全局判断力 (${all.length} 条)`;
2059
+ judgmentsList.innerHTML = renderJudgmentItems(all, { showBindToggle: false });
2015
2060
  return;
2016
2061
  }
2017
- const html = items.map(j => {
2062
+
2063
+ // channel tab: 必须有 channel
2064
+ if (!currentCh) {
2065
+ if (titleEl) titleEl.textContent = '本 channel 的判断力';
2066
+ judgmentsList.innerHTML = `
2067
+ <div style="padding:24px 12px;text-align:center;color:#6b7280;font-size:13px;">
2068
+ 请先在左侧选中一个 channel,<br>然后这里会显示已绑定和可加入的判断力。
2069
+ </div>
2070
+ `;
2071
+ return;
2072
+ }
2073
+
2074
+ const boundIds = new Set(
2075
+ Array.isArray(currentCh.bound_judgment_ids) ? currentCh.bound_judgment_ids : []
2076
+ );
2077
+ const bound = all.filter(j => boundIds.has(j.id));
2078
+ const unbound = all.filter(j => !boundIds.has(j.id));
2079
+
2080
+ if (titleEl) titleEl.textContent = `${currentCh.name} 的判断力 (已绑 ${bound.length} / 共 ${all.length})`;
2081
+
2082
+ let html = '';
2083
+ if (bound.length > 0) {
2084
+ html += `<div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;padding:8px 4px 4px;">已绑定 (${bound.length})</div>`;
2085
+ html += renderJudgmentItems(bound, { showBindToggle: true, isBound: true });
2086
+ }
2087
+ if (unbound.length > 0) {
2088
+ html += `<div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;padding:14px 4px 4px;">未绑定 (${unbound.length})</div>`;
2089
+ html += renderJudgmentItems(unbound, { showBindToggle: true, isBound: false });
2090
+ }
2091
+ judgmentsList.innerHTML = html;
2092
+ }
2093
+
2094
+ function renderJudgmentItems(items, opts) {
2095
+ const { showBindToggle, isBound } = opts || {};
2096
+ return items.map(j => {
2018
2097
  const reason = (j.reasons && j.reasons[0]) ? escapeHtml(j.reasons[0]) : '';
2019
2098
  const domain = (j.context && j.context.domain) ? escapeHtml(j.context.domain) : 'general';
2020
2099
  const stakes = (j.context && j.context.stakes) ? escapeHtml(j.context.stakes) : 'medium';
2100
+ const bindBtn = showBindToggle
2101
+ ? isBound
2102
+ ? `<button class="judgment-toggle-btn" data-id="${escapeHtml(j.id)}" data-action="unbind" title="从当前 channel 移除" style="background:none;border:1px solid #fca5a5;color:#b91c1c;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">× 移除</button>`
2103
+ : `<button class="judgment-toggle-btn" data-id="${escapeHtml(j.id)}" data-action="bind" title="加进当前 channel" style="background:none;border:1px solid #6b7280;color:#6b7280;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">+ 加入</button>`
2104
+ : '';
2021
2105
  return `
2022
2106
  <div class="task-item completed judgment-row"
2023
2107
  data-judgment-id="${escapeHtml(j.id)}"
@@ -2037,6 +2121,7 @@ function renderJudgments(items) {
2037
2121
  <div class="task-item-meta" style="color:#999;font-size:11px;margin-top:4px;display:flex;justify-content:space-between;align-items:center;">
2038
2122
  <span>${domain} · ${escapeHtml(j.timestamp)} · ${escapeHtml(j.id)}</span>
2039
2123
  <span style="display:flex;gap:4px;">
2124
+ ${bindBtn}
2040
2125
  <button class="judgment-edit-btn" data-id="${escapeHtml(j.id)}" title="编辑判断" style="background:none;border:1px solid #d1d5db;color:#374151;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">编辑</button>
2041
2126
  <button class="judgment-del-btn" data-id="${escapeHtml(j.id)}" title="删除判断" style="background:none;border:1px solid #fca5a5;color:#b91c1c;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">删除</button>
2042
2127
  </span>
@@ -2044,7 +2129,6 @@ function renderJudgments(items) {
2044
2129
  </div>
2045
2130
  `;
2046
2131
  }).join('');
2047
- judgmentsList.innerHTML = html;
2048
2132
  }
2049
2133
 
2050
2134
  async function loadJudgments() {
@@ -2053,7 +2137,8 @@ async function loadJudgments() {
2053
2137
  const res = await fetch('/api/judgments');
2054
2138
  if (!res.ok) throw new Error('HTTP ' + res.status);
2055
2139
  const data = await res.json();
2056
- renderJudgments(data.judgments);
2140
+ lastJudgmentsCache = data.judgments || [];
2141
+ renderJudgments(lastJudgmentsCache);
2057
2142
  if (judgmentsBadge) {
2058
2143
  if (data.count > 0) {
2059
2144
  judgmentsBadge.textContent = data.count;
@@ -2068,11 +2153,46 @@ async function loadJudgments() {
2068
2153
  }
2069
2154
  }
2070
2155
 
2156
+ /** 把 judgment id 加进 / 移出当前 channel.bound_judgment_ids, 然后刷新两边 UI */
2157
+ async function toggleChannelJudgment(judgmentId, action) {
2158
+ if (!currentChannelId) {
2159
+ showJudgmentError('请先选中一个 channel');
2160
+ return;
2161
+ }
2162
+ const ch = channels.find(c => c.id === currentChannelId);
2163
+ if (!ch) return;
2164
+ const set = new Set(Array.isArray(ch.bound_judgment_ids) ? ch.bound_judgment_ids : []);
2165
+ if (action === 'bind') set.add(judgmentId);
2166
+ else set.delete(judgmentId);
2167
+ const next = Array.from(set);
2168
+ try {
2169
+ const res = await fetch(`/channels/${currentChannelId}`, {
2170
+ method: 'PATCH',
2171
+ headers: { 'Content-Type': 'application/json' },
2172
+ body: JSON.stringify({ bound_judgment_ids: next })
2173
+ });
2174
+ if (!res.ok) {
2175
+ const err = await res.json().catch(() => ({}));
2176
+ throw new Error(err.error || `HTTP ${res.status}`);
2177
+ }
2178
+ const updated = await res.json();
2179
+ const idx = channels.findIndex(c => c.id === currentChannelId);
2180
+ if (idx >= 0) channels[idx] = updated;
2181
+ // 弹窗开着就刷新, 关着就跳过
2182
+ if (judgmentsModal && judgmentsModal.classList.contains('active')) {
2183
+ renderJudgments(lastJudgmentsCache);
2184
+ }
2185
+ } catch (err) {
2186
+ showJudgmentError('绑定失败: ' + err.message);
2187
+ }
2188
+ }
2189
+
2071
2190
  // 列表内编辑/删除 + 拖拽 — 事件委托
2072
2191
  if (judgmentsList) {
2073
2192
  judgmentsList.addEventListener('click', async (e) => {
2074
2193
  const editBtn = e.target.closest && e.target.closest('.judgment-edit-btn');
2075
2194
  const delBtn = e.target.closest && e.target.closest('.judgment-del-btn');
2195
+ const toggleBtn = e.target.closest && e.target.closest('.judgment-toggle-btn');
2076
2196
  if (editBtn) {
2077
2197
  const id = editBtn.getAttribute('data-id');
2078
2198
  await editJudgment(id);
@@ -2087,9 +2207,18 @@ if (judgmentsList) {
2087
2207
  } catch (err) {
2088
2208
  showJudgmentError('删除失败: ' + err.message);
2089
2209
  }
2210
+ } else if (toggleBtn) {
2211
+ const id = toggleBtn.getAttribute('data-id');
2212
+ const action = toggleBtn.getAttribute('data-action');
2213
+ await toggleChannelJudgment(id, action);
2090
2214
  }
2091
2215
  });
2092
2216
 
2217
+ // tab 切换
2218
+ document.querySelectorAll('.judgment-tab').forEach(btn => {
2219
+ btn.addEventListener('click', () => switchJudgmentTab(btn.dataset.tab));
2220
+ });
2221
+
2093
2222
  // 拖拽: 每条 judgment 是 drag source, dataTransfer 装 decision text
2094
2223
  judgmentsList.addEventListener('dragstart', (e) => {
2095
2224
  const row = e.target.closest && e.target.closest('.judgment-row');
@@ -2831,7 +2960,10 @@ if (agentAddConfirmBtn) {
2831
2960
  const res = await fetch(`/channels/${channelId}`, {
2832
2961
  method: 'PATCH',
2833
2962
  headers: { 'Content-Type': 'application/json' },
2834
- body: JSON.stringify({ walletAddress: walletAddress || null, autoInvokeTools })
2963
+ body: JSON.stringify({
2964
+ walletAddress: walletAddress || null,
2965
+ autoInvokeTools
2966
+ })
2835
2967
  });
2836
2968
  if (!res.ok) throw new Error('update failed');
2837
2969
  const updated = await res.json();
@@ -2846,4 +2978,3 @@ if (agentAddConfirmBtn) {
2846
2978
  }
2847
2979
  });
2848
2980
  }
2849
-
@@ -236,8 +236,19 @@
236
236
  <input type="file" id="judgment-import-file" accept=".json,.yaml,.yml,.md,.txt,.html,.htm" style="display:none">
237
237
  </div>
238
238
  <div id="judgment-error" class="form-info" style="display:none;color:#b91c1c;"></div>
239
- <h3 style="margin-top:24px;font-size:14px;font-weight:600;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
240
- <span>已记录的判断</span>
239
+ <!-- v3 重做: tab 切换 — 默认显示当前 channel 的 judgment 上下文, 切换后才是全部 -->
240
+ <div id="judgments-tabs" style="display:flex;border-bottom:1px solid #e5e7eb;margin-top:20px;">
241
+ <button class="judgment-tab active" data-tab="channel"
242
+ style="flex:1;padding:10px 12px;background:none;border:none;border-bottom:2px solid #2563eb;color:#2563eb;font-size:13px;font-weight:600;cursor:pointer;">
243
+ 本 channel <span id="judgments-tab-channel-name" style="font-weight:normal;color:#6b7280;"></span>
244
+ </button>
245
+ <button class="judgment-tab" data-tab="global"
246
+ style="flex:1;padding:10px 12px;background:none;border:none;border-bottom:2px solid transparent;color:#6b7280;font-size:13px;font-weight:600;cursor:pointer;">
247
+ 全局
248
+ </button>
249
+ </div>
250
+ <h3 style="margin-top:16px;font-size:14px;font-weight:600;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
251
+ <span id="judgments-list-title">本 channel 的判断力</span>
241
252
  <span style="display:flex;align-items:center;gap:6px;margin-left:auto;font-size:12px;font-weight:normal;">
242
253
  <label style="display:flex;align-items:center;gap:4px;cursor:pointer;color:#6b7280;">
243
254
  <input type="checkbox" id="judgment-select-all" style="cursor:pointer;"> 全选
@@ -256,6 +256,54 @@ async function executeTask(task, channelId) {
256
256
  let sseClients = new Set();
257
257
  let channelSessions = new Map(); // key: channelId
258
258
  let sessionMessages = new Map(); // key: channelId + sessionId
259
+ /**
260
+ * v3 重做: 构造 channel 的两路 judgment prompt 片段
261
+ * 路 1: 用户在盾牌里手动绑定的 judgment (channel.bound_judgment_ids)
262
+ * 路 2: 全局 judgment 列表 (供 LLM 在主调用中按需挑选, 写入回复)
263
+ * 返回 "" 表示完全没数据; 否则返回完整 "[系统上下文] ..." 块 (含尾部换行)
264
+ * 失败非致命 — 任何异常都返回空串, 保证 LLM 调用不被阻塞
265
+ */
266
+ async function buildJudgmentHint(channel, channelIdForLog) {
267
+ try {
268
+ const { loadAllJudgments, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
269
+ await initializeValueStore();
270
+ const allJudgments = await loadAllJudgments();
271
+ if (allJudgments.length === 0)
272
+ return '';
273
+ const boundIds = new Set(channel && Array.isArray(channel.bound_judgment_ids) ? channel.bound_judgment_ids : []);
274
+ const bound = allJudgments.filter(j => j.id !== undefined && boundIds.has(j.id));
275
+ const others = allJudgments.filter(j => j.id !== undefined && !boundIds.has(j.id));
276
+ let hint = '';
277
+ // 路 1: 用户手动绑定的 judgment — 硬约束, 必须遵循
278
+ if (bound.length > 0) {
279
+ hint += `[系统上下文] 此 channel 用户绑定了 ${bound.length} 条判断力, 必须严格遵循:\n`;
280
+ for (const j of bound) {
281
+ const decision = (j.decision || '').toString().slice(0, 200);
282
+ const reasonList = Array.isArray(j.reasons) ? j.reasons : [];
283
+ const reasonText = reasonList.length > 0
284
+ ? ` (理由: ${reasonList.join('; ').slice(0, 100)})`
285
+ : '';
286
+ hint += `- ${decision}${reasonText}\n`;
287
+ }
288
+ hint += '\n';
289
+ }
290
+ // 路 2: 全局 judgment 候选池 — 软参考, LLM 自己挑
291
+ if (others.length > 0) {
292
+ hint += `[系统上下文] 候选判断力 (用户未明确绑定, 你可以按相关性自主选择参考):\n`;
293
+ for (const j of others) {
294
+ const decision = (j.decision || '').toString().slice(0, 120);
295
+ hint += `- [id=${j.id}] ${decision}\n`;
296
+ }
297
+ hint += `\n[系统上下文] 如果你的回复参考了某条候选判断力, 请在回复中自然提及 "我参考了你的判断: <decision 简述>" 即可, 无需复述 id.\n\n`;
298
+ }
299
+ console.log(`[v3] channel ${channelIdForLog} 注入: 绑定 ${bound.length} 条, 候选 ${others.length} 条`);
300
+ return hint;
301
+ }
302
+ catch (err) {
303
+ console.error(`[v3] 加载判断力失败 (非致命):`, err.message);
304
+ return '';
305
+ }
306
+ }
259
307
  async function getAgentForChannel(channelId, channelDid, channelName, channelDidDoc) {
260
308
  // 获取当前 channel 的 currentSessionId
261
309
  const channels = await loadChannels();
@@ -468,6 +516,8 @@ export async function createWebServer(port = 3000, options = {}) {
468
516
  // (line ~638) 与这里外层的 const channel 形成 shadowing 让 TS 误报"使用前未声明"
469
517
  const boundWalletAddress = channel?.walletAddress;
470
518
  const autoToolsEnabled = channel?.autoInvokeTools !== false; // 默认开启
519
+ // 捕获外层 channel 到独立变量, 避免被 try 块内 (line 740+) 的 const channel 遮蔽
520
+ const channelForJudgment = channel;
471
521
  try {
472
522
  const agent = await getAgentForChannel(channelId, realChannelDid, realChannelName, realChannelDidDoc);
473
523
  let fullResponse = '';
@@ -503,6 +553,11 @@ export async function createWebServer(port = 3000, options = {}) {
503
553
  else {
504
554
  contextHint += `[系统上下文] 自动工具调用已关闭: 每次执行工具前必须先与用户确认。\n`;
505
555
  }
556
+ // v3: 注入 channel 绑定的判断力 (judgment_ids)
557
+ // 这是 v3 的核心 — channel 跑 LLM 时, 它的判断力 = 绑定的 judgment 列表
558
+ const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
559
+ if (judgmentHint)
560
+ contextHint += judgmentHint;
506
561
  if (contextHint)
507
562
  contextHint += '\n';
508
563
  fullResponse = await agent.promptStream(contextHint + text, streamCallback);
@@ -661,8 +716,8 @@ export async function createWebServer(port = 3000, options = {}) {
661
716
  });
662
717
  app.post('/channels', async (req, res) => {
663
718
  try {
664
- const { name, agentId, walletAddress, autoInvokeTools } = req.body;
665
- console.log(`[创建频道] 收到请求: name=${name}, agentId=${agentId}, wallet=${walletAddress ? 'yes' : 'no'}`);
719
+ const { name, agentId, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
720
+ console.log(`[创建频道] 收到请求: name=${name}, agentId=${agentId}, wallet=${walletAddress ? 'yes' : 'no'}, boundJudgments=${Array.isArray(bound_judgment_ids) ? bound_judgment_ids.length : 0}`);
666
721
  if (!name || !agentId) {
667
722
  return res.status(400).json({ error: 'name and agentId required' });
668
723
  }
@@ -670,6 +725,10 @@ export async function createWebServer(port = 3000, options = {}) {
670
725
  const id = `ch_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
671
726
  // 校验钱包地址格式 (粗校验: 0x + 40 hex / Solana base58 / Sui 0x+64)
672
727
  const validWallet = isValidWalletAddress(walletAddress);
728
+ // 过滤 bound_judgment_ids: 只保留 string
729
+ const safeBoundIds = Array.isArray(bound_judgment_ids)
730
+ ? bound_judgment_ids.filter((x) => typeof x === 'string' && x.length > 0)
731
+ : [];
673
732
  // 先创建频道(不阻塞等待 DID 生成)
674
733
  const channel = {
675
734
  id,
@@ -681,6 +740,7 @@ export async function createWebServer(port = 3000, options = {}) {
681
740
  walletAddress: validWallet || undefined,
682
741
  walletRegisteredAt: validWallet ? new Date().toISOString() : undefined,
683
742
  autoInvokeTools: autoInvokeTools !== false, // 默认 true
743
+ bound_judgment_ids: safeBoundIds,
684
744
  sessions: [{
685
745
  id: `sess_${Date.now()}`,
686
746
  createdAt: new Date().toISOString(),
@@ -839,7 +899,7 @@ export async function createWebServer(port = 3000, options = {}) {
839
899
  app.patch('/channels/:channelId', async (req, res) => {
840
900
  try {
841
901
  const { channelId } = req.params;
842
- const { name, walletAddress, autoInvokeTools } = req.body;
902
+ const { name, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
843
903
  const channels = await loadChannels();
844
904
  const channel = channels.find(c => c.id === channelId);
845
905
  if (!channel) {
@@ -866,6 +926,19 @@ export async function createWebServer(port = 3000, options = {}) {
866
926
  if (typeof autoInvokeTools === 'boolean') {
867
927
  channel.autoInvokeTools = autoInvokeTools;
868
928
  }
929
+ // bound_judgment_ids: 允许数组(替换)/null(清空)/undefined(不改)
930
+ if (bound_judgment_ids !== undefined) {
931
+ if (bound_judgment_ids === null) {
932
+ channel.bound_judgment_ids = [];
933
+ }
934
+ else if (Array.isArray(bound_judgment_ids)) {
935
+ channel.bound_judgment_ids = bound_judgment_ids.filter((x) => typeof x === 'string' && x.length > 0);
936
+ }
937
+ else {
938
+ return res.status(400).json({ error: 'bound_judgment_ids must be array or null' });
939
+ }
940
+ console.log(`[Channel ${channelId}] 绑定判断力: ${channel.bound_judgment_ids.length} 条`);
941
+ }
869
942
  channel.updatedAt = new Date().toISOString();
870
943
  await saveChannels(channels);
871
944
  res.json(channel);
@@ -1027,8 +1100,9 @@ export async function createWebServer(port = 3000, options = {}) {
1027
1100
  broadcast({ type: 'error', content: event.content }, channelId);
1028
1101
  }
1029
1102
  };
1030
- // 重新生成时只发送用户消息
1031
- fullResponse = await agent.promptStream(userMessage, streamCallback);
1103
+ // 重新生成时只发送用户消息 (v3: 同时注入 channel 绑定的判断力)
1104
+ const regenHint = await buildJudgmentHint(channel, channelId);
1105
+ fullResponse = await agent.promptStream(regenHint + userMessage, streamCallback);
1032
1106
  broadcast({ type: 'ai', content: fullResponse }, channelId);
1033
1107
  // 更新 session
1034
1108
  const existingSession = await loadSession(channelId, currentSessionId);
@@ -2140,7 +2214,8 @@ export async function createWebServer(port = 3000, options = {}) {
2140
2214
  try {
2141
2215
  const { createHealthMonitor, createWatchdog } = await import('../heartbeat/index.js');
2142
2216
  healthMonitor = createHealthMonitor();
2143
- watchdog = createWatchdog();
2217
+ // 把 watchdog 静默阈值拉到 30 分钟, 避免开发期 / 用户空闲时被误杀
2218
+ watchdog = createWatchdog({ silentThresholdMs: 30 * 60 * 1000 });
2144
2219
  console.log('[24h] Heartbeat modules loaded');
2145
2220
  }
2146
2221
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli.js",
package/src/web/client.js CHANGED
@@ -623,6 +623,11 @@ async function selectChannel(channelId, targetSessionId = null) {
623
623
  currentChannelId = channelId;
624
624
  reconnectAttempts.set(channelId, 0);
625
625
 
626
+ // v3: 盾牌弹窗打开时, 切 channel 要刷列表 (tab 标题 + 已绑/未绑 分组)
627
+ if (typeof judgmentsModal !== 'undefined' && judgmentsModal && judgmentsModal.classList.contains('active')) {
628
+ if (typeof lastJudgmentsCache !== 'undefined') renderJudgments(lastJudgmentsCache);
629
+ }
630
+
626
631
  // 找到当前频道和 session
627
632
  const channel = channels.find(c => c.id === channelId);
628
633
  if (channel) {
@@ -1996,6 +2001,18 @@ let judgmentsLoaded = false;
1996
2001
  function showJudgmentsModal() {
1997
2002
  if (judgmentsModal) judgmentsModal.classList.add('active');
1998
2003
  if (!judgmentsLoaded) loadJudgments();
2004
+ else renderJudgments(lastJudgmentsCache); // 打开时按当前 channel / tab 重渲
2005
+ }
2006
+
2007
+ function switchJudgmentTab(tab) {
2008
+ currentJudgmentTab = tab;
2009
+ document.querySelectorAll('.judgment-tab').forEach(btn => {
2010
+ const active = btn.dataset.tab === tab;
2011
+ btn.classList.toggle('active', active);
2012
+ btn.style.borderBottomColor = active ? '#2563eb' : 'transparent';
2013
+ btn.style.color = active ? '#2563eb' : '#6b7280';
2014
+ });
2015
+ renderJudgments(lastJudgmentsCache);
1999
2016
  }
2000
2017
 
2001
2018
  function hideJudgmentsModal() {
@@ -2008,16 +2025,83 @@ function escapeHtml(s) {
2008
2025
  }[c]));
2009
2026
  }
2010
2027
 
2028
+ let currentJudgmentTab = 'channel'; // 'channel' | 'global'
2029
+ let lastJudgmentsCache = []; // 最近一次 loadJudgments 拿到的原始列表, 切 tab / 切 channel 时复用
2030
+
2031
+ /**
2032
+ * v3 重做: 渲染判断力列表 (受 tab + 当前 channel 影响)
2033
+ * tab = 'channel': 拆为"已绑定" + "未绑定"两组, 每条带 + / × 按钮
2034
+ * tab = 'global': 全部 judgment 列表, 无 + / × 按钮
2035
+ * 如果没选 channel, 'channel' tab 自动显示提示 + 全部 judgment
2036
+ */
2011
2037
  function renderJudgments(items) {
2012
2038
  if (!judgmentsList) return;
2013
- if (!items || items.length === 0) {
2039
+ const all = items || [];
2040
+ const titleEl = document.getElementById('judgments-list-title');
2041
+ const chNameEl = document.getElementById('judgments-tab-channel-name');
2042
+ const currentCh = currentChannelId
2043
+ ? channels.find(c => c.id === currentChannelId)
2044
+ : null;
2045
+
2046
+ if (chNameEl) {
2047
+ chNameEl.textContent = currentCh ? `(${currentCh.name})` : '(未选)';
2048
+ }
2049
+
2050
+ if (all.length === 0) {
2014
2051
  judgmentsList.innerHTML = '<div class="task-empty">还没有判断, 在上面记录第一条吧</div>';
2052
+ if (titleEl) titleEl.textContent = '本 channel 的判断力';
2053
+ return;
2054
+ }
2055
+
2056
+ if (currentJudgmentTab === 'global') {
2057
+ // 全局 tab: 全部 judgment, 简单列表
2058
+ if (titleEl) titleEl.textContent = `全局判断力 (${all.length} 条)`;
2059
+ judgmentsList.innerHTML = renderJudgmentItems(all, { showBindToggle: false });
2015
2060
  return;
2016
2061
  }
2017
- const html = items.map(j => {
2062
+
2063
+ // channel tab: 必须有 channel
2064
+ if (!currentCh) {
2065
+ if (titleEl) titleEl.textContent = '本 channel 的判断力';
2066
+ judgmentsList.innerHTML = `
2067
+ <div style="padding:24px 12px;text-align:center;color:#6b7280;font-size:13px;">
2068
+ 请先在左侧选中一个 channel,<br>然后这里会显示已绑定和可加入的判断力。
2069
+ </div>
2070
+ `;
2071
+ return;
2072
+ }
2073
+
2074
+ const boundIds = new Set(
2075
+ Array.isArray(currentCh.bound_judgment_ids) ? currentCh.bound_judgment_ids : []
2076
+ );
2077
+ const bound = all.filter(j => boundIds.has(j.id));
2078
+ const unbound = all.filter(j => !boundIds.has(j.id));
2079
+
2080
+ if (titleEl) titleEl.textContent = `${currentCh.name} 的判断力 (已绑 ${bound.length} / 共 ${all.length})`;
2081
+
2082
+ let html = '';
2083
+ if (bound.length > 0) {
2084
+ html += `<div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;padding:8px 4px 4px;">已绑定 (${bound.length})</div>`;
2085
+ html += renderJudgmentItems(bound, { showBindToggle: true, isBound: true });
2086
+ }
2087
+ if (unbound.length > 0) {
2088
+ html += `<div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;padding:14px 4px 4px;">未绑定 (${unbound.length})</div>`;
2089
+ html += renderJudgmentItems(unbound, { showBindToggle: true, isBound: false });
2090
+ }
2091
+ judgmentsList.innerHTML = html;
2092
+ }
2093
+
2094
+ function renderJudgmentItems(items, opts) {
2095
+ const { showBindToggle, isBound } = opts || {};
2096
+ return items.map(j => {
2018
2097
  const reason = (j.reasons && j.reasons[0]) ? escapeHtml(j.reasons[0]) : '';
2019
2098
  const domain = (j.context && j.context.domain) ? escapeHtml(j.context.domain) : 'general';
2020
2099
  const stakes = (j.context && j.context.stakes) ? escapeHtml(j.context.stakes) : 'medium';
2100
+ const bindBtn = showBindToggle
2101
+ ? isBound
2102
+ ? `<button class="judgment-toggle-btn" data-id="${escapeHtml(j.id)}" data-action="unbind" title="从当前 channel 移除" style="background:none;border:1px solid #fca5a5;color:#b91c1c;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">× 移除</button>`
2103
+ : `<button class="judgment-toggle-btn" data-id="${escapeHtml(j.id)}" data-action="bind" title="加进当前 channel" style="background:none;border:1px solid #6b7280;color:#6b7280;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">+ 加入</button>`
2104
+ : '';
2021
2105
  return `
2022
2106
  <div class="task-item completed judgment-row"
2023
2107
  data-judgment-id="${escapeHtml(j.id)}"
@@ -2037,6 +2121,7 @@ function renderJudgments(items) {
2037
2121
  <div class="task-item-meta" style="color:#999;font-size:11px;margin-top:4px;display:flex;justify-content:space-between;align-items:center;">
2038
2122
  <span>${domain} · ${escapeHtml(j.timestamp)} · ${escapeHtml(j.id)}</span>
2039
2123
  <span style="display:flex;gap:4px;">
2124
+ ${bindBtn}
2040
2125
  <button class="judgment-edit-btn" data-id="${escapeHtml(j.id)}" title="编辑判断" style="background:none;border:1px solid #d1d5db;color:#374151;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">编辑</button>
2041
2126
  <button class="judgment-del-btn" data-id="${escapeHtml(j.id)}" title="删除判断" style="background:none;border:1px solid #fca5a5;color:#b91c1c;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">删除</button>
2042
2127
  </span>
@@ -2044,7 +2129,6 @@ function renderJudgments(items) {
2044
2129
  </div>
2045
2130
  `;
2046
2131
  }).join('');
2047
- judgmentsList.innerHTML = html;
2048
2132
  }
2049
2133
 
2050
2134
  async function loadJudgments() {
@@ -2053,7 +2137,8 @@ async function loadJudgments() {
2053
2137
  const res = await fetch('/api/judgments');
2054
2138
  if (!res.ok) throw new Error('HTTP ' + res.status);
2055
2139
  const data = await res.json();
2056
- renderJudgments(data.judgments);
2140
+ lastJudgmentsCache = data.judgments || [];
2141
+ renderJudgments(lastJudgmentsCache);
2057
2142
  if (judgmentsBadge) {
2058
2143
  if (data.count > 0) {
2059
2144
  judgmentsBadge.textContent = data.count;
@@ -2068,11 +2153,46 @@ async function loadJudgments() {
2068
2153
  }
2069
2154
  }
2070
2155
 
2156
+ /** 把 judgment id 加进 / 移出当前 channel.bound_judgment_ids, 然后刷新两边 UI */
2157
+ async function toggleChannelJudgment(judgmentId, action) {
2158
+ if (!currentChannelId) {
2159
+ showJudgmentError('请先选中一个 channel');
2160
+ return;
2161
+ }
2162
+ const ch = channels.find(c => c.id === currentChannelId);
2163
+ if (!ch) return;
2164
+ const set = new Set(Array.isArray(ch.bound_judgment_ids) ? ch.bound_judgment_ids : []);
2165
+ if (action === 'bind') set.add(judgmentId);
2166
+ else set.delete(judgmentId);
2167
+ const next = Array.from(set);
2168
+ try {
2169
+ const res = await fetch(`/channels/${currentChannelId}`, {
2170
+ method: 'PATCH',
2171
+ headers: { 'Content-Type': 'application/json' },
2172
+ body: JSON.stringify({ bound_judgment_ids: next })
2173
+ });
2174
+ if (!res.ok) {
2175
+ const err = await res.json().catch(() => ({}));
2176
+ throw new Error(err.error || `HTTP ${res.status}`);
2177
+ }
2178
+ const updated = await res.json();
2179
+ const idx = channels.findIndex(c => c.id === currentChannelId);
2180
+ if (idx >= 0) channels[idx] = updated;
2181
+ // 弹窗开着就刷新, 关着就跳过
2182
+ if (judgmentsModal && judgmentsModal.classList.contains('active')) {
2183
+ renderJudgments(lastJudgmentsCache);
2184
+ }
2185
+ } catch (err) {
2186
+ showJudgmentError('绑定失败: ' + err.message);
2187
+ }
2188
+ }
2189
+
2071
2190
  // 列表内编辑/删除 + 拖拽 — 事件委托
2072
2191
  if (judgmentsList) {
2073
2192
  judgmentsList.addEventListener('click', async (e) => {
2074
2193
  const editBtn = e.target.closest && e.target.closest('.judgment-edit-btn');
2075
2194
  const delBtn = e.target.closest && e.target.closest('.judgment-del-btn');
2195
+ const toggleBtn = e.target.closest && e.target.closest('.judgment-toggle-btn');
2076
2196
  if (editBtn) {
2077
2197
  const id = editBtn.getAttribute('data-id');
2078
2198
  await editJudgment(id);
@@ -2087,9 +2207,18 @@ if (judgmentsList) {
2087
2207
  } catch (err) {
2088
2208
  showJudgmentError('删除失败: ' + err.message);
2089
2209
  }
2210
+ } else if (toggleBtn) {
2211
+ const id = toggleBtn.getAttribute('data-id');
2212
+ const action = toggleBtn.getAttribute('data-action');
2213
+ await toggleChannelJudgment(id, action);
2090
2214
  }
2091
2215
  });
2092
2216
 
2217
+ // tab 切换
2218
+ document.querySelectorAll('.judgment-tab').forEach(btn => {
2219
+ btn.addEventListener('click', () => switchJudgmentTab(btn.dataset.tab));
2220
+ });
2221
+
2093
2222
  // 拖拽: 每条 judgment 是 drag source, dataTransfer 装 decision text
2094
2223
  judgmentsList.addEventListener('dragstart', (e) => {
2095
2224
  const row = e.target.closest && e.target.closest('.judgment-row');
@@ -2831,7 +2960,10 @@ if (agentAddConfirmBtn) {
2831
2960
  const res = await fetch(`/channels/${channelId}`, {
2832
2961
  method: 'PATCH',
2833
2962
  headers: { 'Content-Type': 'application/json' },
2834
- body: JSON.stringify({ walletAddress: walletAddress || null, autoInvokeTools })
2963
+ body: JSON.stringify({
2964
+ walletAddress: walletAddress || null,
2965
+ autoInvokeTools
2966
+ })
2835
2967
  });
2836
2968
  if (!res.ok) throw new Error('update failed');
2837
2969
  const updated = await res.json();
@@ -2846,4 +2978,3 @@ if (agentAddConfirmBtn) {
2846
2978
  }
2847
2979
  });
2848
2980
  }
2849
-
@@ -236,8 +236,19 @@
236
236
  <input type="file" id="judgment-import-file" accept=".json,.yaml,.yml,.md,.txt,.html,.htm" style="display:none">
237
237
  </div>
238
238
  <div id="judgment-error" class="form-info" style="display:none;color:#b91c1c;"></div>
239
- <h3 style="margin-top:24px;font-size:14px;font-weight:600;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
240
- <span>已记录的判断</span>
239
+ <!-- v3 重做: tab 切换 — 默认显示当前 channel 的 judgment 上下文, 切换后才是全部 -->
240
+ <div id="judgments-tabs" style="display:flex;border-bottom:1px solid #e5e7eb;margin-top:20px;">
241
+ <button class="judgment-tab active" data-tab="channel"
242
+ style="flex:1;padding:10px 12px;background:none;border:none;border-bottom:2px solid #2563eb;color:#2563eb;font-size:13px;font-weight:600;cursor:pointer;">
243
+ 本 channel <span id="judgments-tab-channel-name" style="font-weight:normal;color:#6b7280;"></span>
244
+ </button>
245
+ <button class="judgment-tab" data-tab="global"
246
+ style="flex:1;padding:10px 12px;background:none;border:none;border-bottom:2px solid transparent;color:#6b7280;font-size:13px;font-weight:600;cursor:pointer;">
247
+ 全局
248
+ </button>
249
+ </div>
250
+ <h3 style="margin-top:16px;font-size:14px;font-weight:600;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
251
+ <span id="judgments-list-title">本 channel 的判断力</span>
241
252
  <span style="display:flex;align-items:center;gap:6px;margin-left:auto;font-size:12px;font-weight:normal;">
242
253
  <label style="display:flex;align-items:center;gap:4px;cursor:pointer;color:#6b7280;">
243
254
  <input type="checkbox" id="judgment-select-all" style="cursor:pointer;"> 全选
package/src/web/server.ts CHANGED
@@ -72,6 +72,8 @@ interface Channel {
72
72
  updatedAt: string;
73
73
  currentSessionId?: string;
74
74
  sessions?: SessionSummary[];
75
+ /** 用户在盾牌里手动绑定的判断力 (LLM 跑 channel 时会注入). 默认 []. */
76
+ bound_judgment_ids?: string[];
75
77
  }
76
78
 
77
79
  interface SessionSummary {
@@ -383,6 +385,67 @@ let sseClients: Set<SSEClient> = new Set();
383
385
  let channelSessions: Map<string, AgentSession> = new Map(); // key: channelId
384
386
  let sessionMessages: Map<string, any[]> = new Map(); // key: channelId + sessionId
385
387
 
388
+ /**
389
+ * v3 重做: 构造 channel 的两路 judgment prompt 片段
390
+ * 路 1: 用户在盾牌里手动绑定的 judgment (channel.bound_judgment_ids)
391
+ * 路 2: 全局 judgment 列表 (供 LLM 在主调用中按需挑选, 写入回复)
392
+ * 返回 "" 表示完全没数据; 否则返回完整 "[系统上下文] ..." 块 (含尾部换行)
393
+ * 失败非致命 — 任何异常都返回空串, 保证 LLM 调用不被阻塞
394
+ */
395
+ async function buildJudgmentHint(
396
+ channel: Channel | undefined | null,
397
+ channelIdForLog: string
398
+ ): Promise<string> {
399
+ try {
400
+ const { loadAllJudgments, initializeValueStore } = await import(
401
+ '../pi-ecosystem-judgment/human-value-store.js'
402
+ );
403
+ await initializeValueStore();
404
+ const allJudgments = await loadAllJudgments();
405
+ if (allJudgments.length === 0) return '';
406
+
407
+ const boundIds = new Set(
408
+ channel && Array.isArray(channel.bound_judgment_ids) ? channel.bound_judgment_ids : []
409
+ );
410
+ const bound = allJudgments.filter(j => j.id !== undefined && boundIds.has(j.id));
411
+ const others = allJudgments.filter(j => j.id !== undefined && !boundIds.has(j.id));
412
+
413
+ let hint = '';
414
+
415
+ // 路 1: 用户手动绑定的 judgment — 硬约束, 必须遵循
416
+ if (bound.length > 0) {
417
+ hint += `[系统上下文] 此 channel 用户绑定了 ${bound.length} 条判断力, 必须严格遵循:\n`;
418
+ for (const j of bound) {
419
+ const decision = (j.decision || '').toString().slice(0, 200);
420
+ const reasonList = Array.isArray(j.reasons) ? j.reasons : [];
421
+ const reasonText = reasonList.length > 0
422
+ ? ` (理由: ${reasonList.join('; ').slice(0, 100)})`
423
+ : '';
424
+ hint += `- ${decision}${reasonText}\n`;
425
+ }
426
+ hint += '\n';
427
+ }
428
+
429
+ // 路 2: 全局 judgment 候选池 — 软参考, LLM 自己挑
430
+ if (others.length > 0) {
431
+ hint += `[系统上下文] 候选判断力 (用户未明确绑定, 你可以按相关性自主选择参考):\n`;
432
+ for (const j of others) {
433
+ const decision = (j.decision || '').toString().slice(0, 120);
434
+ hint += `- [id=${j.id}] ${decision}\n`;
435
+ }
436
+ hint += `\n[系统上下文] 如果你的回复参考了某条候选判断力, 请在回复中自然提及 "我参考了你的判断: <decision 简述>" 即可, 无需复述 id.\n\n`;
437
+ }
438
+
439
+ console.log(
440
+ `[v3] channel ${channelIdForLog} 注入: 绑定 ${bound.length} 条, 候选 ${others.length} 条`
441
+ );
442
+ return hint;
443
+ } catch (err) {
444
+ console.error(`[v3] 加载判断力失败 (非致命):`, (err as Error).message);
445
+ return '';
446
+ }
447
+ }
448
+
386
449
  async function getAgentForChannel(
387
450
  channelId: string,
388
451
  channelDid?: string,
@@ -640,6 +703,8 @@ export async function createWebServer(port: number = 3000, options: CreateWebSer
640
703
  // (line ~638) 与这里外层的 const channel 形成 shadowing 让 TS 误报"使用前未声明"
641
704
  const boundWalletAddress = channel?.walletAddress;
642
705
  const autoToolsEnabled = channel?.autoInvokeTools !== false; // 默认开启
706
+ // 捕获外层 channel 到独立变量, 避免被 try 块内 (line 740+) 的 const channel 遮蔽
707
+ const channelForJudgment = channel;
643
708
 
644
709
  try {
645
710
  const agent = await getAgentForChannel(channelId, realChannelDid, realChannelName, realChannelDidDoc);
@@ -675,6 +740,12 @@ export async function createWebServer(port: number = 3000, options: CreateWebSer
675
740
  } else {
676
741
  contextHint += `[系统上下文] 自动工具调用已关闭: 每次执行工具前必须先与用户确认。\n`;
677
742
  }
743
+
744
+ // v3: 注入 channel 绑定的判断力 (judgment_ids)
745
+ // 这是 v3 的核心 — channel 跑 LLM 时, 它的判断力 = 绑定的 judgment 列表
746
+ const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
747
+ if (judgmentHint) contextHint += judgmentHint;
748
+
678
749
  if (contextHint) contextHint += '\n';
679
750
  fullResponse = await agent.promptStream(contextHint + text, streamCallback);
680
751
 
@@ -835,8 +906,8 @@ app.get('/channels', async (_req, res) => {
835
906
 
836
907
  app.post('/channels', async (req, res) => {
837
908
  try {
838
- const { name, agentId, walletAddress, autoInvokeTools } = req.body;
839
- console.log(`[创建频道] 收到请求: name=${name}, agentId=${agentId}, wallet=${walletAddress ? 'yes' : 'no'}`);
909
+ const { name, agentId, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
910
+ console.log(`[创建频道] 收到请求: name=${name}, agentId=${agentId}, wallet=${walletAddress ? 'yes' : 'no'}, boundJudgments=${Array.isArray(bound_judgment_ids) ? bound_judgment_ids.length : 0}`);
840
911
  if (!name || !agentId) {
841
912
  return res.status(400).json({ error: 'name and agentId required' });
842
913
  }
@@ -846,6 +917,11 @@ app.get('/channels', async (_req, res) => {
846
917
  // 校验钱包地址格式 (粗校验: 0x + 40 hex / Solana base58 / Sui 0x+64)
847
918
  const validWallet = isValidWalletAddress(walletAddress);
848
919
 
920
+ // 过滤 bound_judgment_ids: 只保留 string
921
+ const safeBoundIds = Array.isArray(bound_judgment_ids)
922
+ ? bound_judgment_ids.filter((x: unknown) => typeof x === 'string' && (x as string).length > 0)
923
+ : [];
924
+
849
925
  // 先创建频道(不阻塞等待 DID 生成)
850
926
  const channel: Channel = {
851
927
  id,
@@ -857,6 +933,7 @@ app.get('/channels', async (_req, res) => {
857
933
  walletAddress: validWallet || undefined,
858
934
  walletRegisteredAt: validWallet ? new Date().toISOString() : undefined,
859
935
  autoInvokeTools: autoInvokeTools !== false, // 默认 true
936
+ bound_judgment_ids: safeBoundIds,
860
937
  sessions: [{
861
938
  id: `sess_${Date.now()}`,
862
939
  createdAt: new Date().toISOString(),
@@ -1035,7 +1112,7 @@ app.get('/channels', async (_req, res) => {
1035
1112
  app.patch('/channels/:channelId', async (req, res) => {
1036
1113
  try {
1037
1114
  const { channelId } = req.params;
1038
- const { name, walletAddress, autoInvokeTools } = req.body;
1115
+ const { name, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
1039
1116
  const channels = await loadChannels();
1040
1117
  const channel = channels.find(c => c.id === channelId);
1041
1118
  if (!channel) {
@@ -1061,6 +1138,19 @@ app.get('/channels', async (_req, res) => {
1061
1138
  if (typeof autoInvokeTools === 'boolean') {
1062
1139
  channel.autoInvokeTools = autoInvokeTools;
1063
1140
  }
1141
+ // bound_judgment_ids: 允许数组(替换)/null(清空)/undefined(不改)
1142
+ if (bound_judgment_ids !== undefined) {
1143
+ if (bound_judgment_ids === null) {
1144
+ channel.bound_judgment_ids = [];
1145
+ } else if (Array.isArray(bound_judgment_ids)) {
1146
+ channel.bound_judgment_ids = bound_judgment_ids.filter(
1147
+ (x: unknown) => typeof x === 'string' && (x as string).length > 0
1148
+ );
1149
+ } else {
1150
+ return res.status(400).json({ error: 'bound_judgment_ids must be array or null' });
1151
+ }
1152
+ console.log(`[Channel ${channelId}] 绑定判断力: ${channel.bound_judgment_ids.length} 条`);
1153
+ }
1064
1154
  channel.updatedAt = new Date().toISOString();
1065
1155
  await saveChannels(channels);
1066
1156
  res.json(channel);
@@ -1225,8 +1315,9 @@ app.get('/channels', async (_req, res) => {
1225
1315
  }
1226
1316
  };
1227
1317
 
1228
- // 重新生成时只发送用户消息
1229
- fullResponse = await agent.promptStream(userMessage, streamCallback);
1318
+ // 重新生成时只发送用户消息 (v3: 同时注入 channel 绑定的判断力)
1319
+ const regenHint = await buildJudgmentHint(channel, channelId);
1320
+ fullResponse = await agent.promptStream(regenHint + userMessage, streamCallback);
1230
1321
 
1231
1322
  broadcast({ type: 'ai', content: fullResponse }, channelId);
1232
1323
 
@@ -2451,7 +2542,8 @@ app.get('/channels', async (_req, res) => {
2451
2542
  try {
2452
2543
  const { createHealthMonitor, createWatchdog } = await import('../heartbeat/index.js');
2453
2544
  healthMonitor = createHealthMonitor();
2454
- watchdog = createWatchdog();
2545
+ // 把 watchdog 静默阈值拉到 30 分钟, 避免开发期 / 用户空闲时被误杀
2546
+ watchdog = createWatchdog({ silentThresholdMs: 30 * 60 * 1000 });
2455
2547
 
2456
2548
  console.log('[24h] Heartbeat modules loaded');
2457
2549
  } catch (err) {