@bolloon/bolloon-agent 0.1.34 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.auto-evolve-calls +1 -0
  2. package/.last-auto-evolve-baseline +1 -0
  3. package/Bolloon.md +103 -0
  4. package/dist/agents/pi-sdk.js +264 -12
  5. package/dist/bootstrap/bootstrap.js +114 -0
  6. package/dist/bootstrap/context-collector.js +296 -0
  7. package/dist/bootstrap/lifecycle-hooks.js +109 -0
  8. package/dist/bootstrap/project-context.js +151 -0
  9. package/dist/index.js +11 -0
  10. package/dist/llm/pi-ai.js +31 -21
  11. package/dist/pi-ecosystem-judgment/adaptive-scan.js +231 -0
  12. package/dist/pi-ecosystem-judgment/causal-judge.js +449 -0
  13. package/dist/pi-ecosystem-judgment/detect-hook.js +168 -0
  14. package/dist/pi-ecosystem-judgment/distill-prompt.js +226 -0
  15. package/dist/pi-ecosystem-judgment/evolve-judgment.js +170 -0
  16. package/dist/pi-ecosystem-judgment/human-value-pipeline.js +21 -0
  17. package/dist/pi-ecosystem-judgment/human-value-store.js +283 -22
  18. package/dist/pi-ecosystem-judgment/injection-gate.js +166 -0
  19. package/dist/pi-ecosystem-judgment/monitor-gate.js +188 -0
  20. package/dist/security/builtin-guards.js +124 -0
  21. package/dist/security/context-router-tool.js +106 -0
  22. package/dist/security/react-harness.js +143 -0
  23. package/dist/security/tool-gate.js +235 -0
  24. package/dist/utils/auto-evolve-policy.js +117 -0
  25. package/dist/utils/clamp.js +7 -0
  26. package/dist/utils/double.js +6 -0
  27. package/dist/web/client.js +668 -204
  28. package/dist/web/index.html +24 -4
  29. package/dist/web/server.js +531 -10
  30. package/lefthook.yml +29 -0
  31. package/package.json +3 -2
  32. package/scripts/auto-evolve-loop.ts +376 -0
  33. package/scripts/auto-evolve-oneshot.sh +155 -0
  34. package/scripts/auto-evolve-snapshot.sh +136 -0
  35. package/scripts/detect-schema-changes.sh +48 -0
  36. package/scripts/diff-reviewer.ts +159 -0
  37. package/scripts/weekly-report.ts +364 -0
  38. package/src/agents/pi-sdk.ts +293 -15
  39. package/src/bootstrap/bootstrap.ts +132 -0
  40. package/src/bootstrap/context-collector.ts +342 -0
  41. package/src/bootstrap/lifecycle-hooks.ts +176 -0
  42. package/src/bootstrap/project-context.ts +163 -0
  43. package/src/index.ts +11 -0
  44. package/src/llm/pi-ai.ts +33 -22
  45. package/src/security/builtin-guards.ts +162 -0
  46. package/src/security/context-router-tool.ts +122 -0
  47. package/src/security/react-harness.ts +177 -0
  48. package/src/security/tool-gate.ts +294 -0
  49. package/src/utils/auto-evolve-policy.ts +138 -0
  50. package/src/utils/clamp.ts +5 -0
  51. package/src/web/client.js +668 -204
  52. package/src/web/index.html +24 -4
  53. package/src/web/server.ts +596 -10
  54. package/staging/auto-evolve/clean-001/.review-verdict +9 -0
  55. package/staging/auto-evolve/clean-001/clean-001.patch +14 -0
  56. package/staging/auto-evolve/e2e-001/.patch-id +1 -0
  57. package/staging/auto-evolve/e2e-001/.review-verdict +12 -0
  58. package/staging/auto-evolve/e2e-001/e2e-001.patch +11 -0
  59. package/staging/auto-evolve/test-bad/.review-verdict +12 -0
  60. package/staging/auto-evolve/test-bad/test-bad.patch +11 -0
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import * as fs from 'fs/promises';
17
17
  import * as path from 'path';
18
+ import { createHash } from 'crypto';
18
19
  const VALUE_STORE_DIR = path.join(process.env.HOME || '/tmp', '.bolloon', 'human-values');
19
20
  const JUDGMENTS_FILE = path.join(VALUE_STORE_DIR, 'judgments.json');
20
21
  // In-memory cache
@@ -49,14 +50,64 @@ export async function initializeValueStore() {
49
50
  throw error;
50
51
  }
51
52
  }
53
+ // ============================================================
54
+ // 阶段 2: Causal-judge 4 字段 migration
55
+ // ============================================================
56
+ /** TTL 默认 90 天 (用户可在 createUI 显式覆盖) */
57
+ export const DEFAULT_TTL_DAYS = 90;
58
+ /** 已 migration 标记 (避免每次 loadAllJudgments 都重写盘) */
59
+ let migratedOnce = false;
60
+ /**
61
+ * 给老数据补 4 字段默认值 (in-place).
62
+ * - expiresAt: createdAt + 90 天
63
+ * - appliesTo: undefined (适用所有)
64
+ * - conflictWith: []
65
+ * - causalChain: undefined
66
+ *
67
+ * 返回 true 表示这次实际有 migration (写盘时持久化), false 表示无变化
68
+ */
69
+ export function migrateJudgmentInPlace(j) {
70
+ let changed = false;
71
+ if (j.expiresAt === undefined) {
72
+ const base = new Date(j.timestamp || Date.now());
73
+ base.setDate(base.getDate() + DEFAULT_TTL_DAYS);
74
+ j.expiresAt = base.toISOString();
75
+ changed = true;
76
+ }
77
+ if (j.conflictWith === undefined) {
78
+ j.conflictWith = [];
79
+ changed = true;
80
+ }
81
+ // appliesTo / causalChain 留空不补 (语义性, 由 causal-judge 自动填)
82
+ return changed;
83
+ }
84
+ /**
85
+ * 不可变版本: 返回新对象, 不 mutate 原 j. 供测试/谨慎场景用.
86
+ */
87
+ export function migrateJudgmentImmutable(j) {
88
+ const next = { ...j };
89
+ migrateJudgmentInPlace(next);
90
+ return next;
91
+ }
52
92
  /**
53
93
  * 存储人类判断
54
94
  */
55
95
  export async function storeHumanJudgment(judgment) {
96
+ const now = new Date().toISOString();
97
+ // 阶段 2: 4 字段默认值补全 (新 judgment)
98
+ if (!judgment.expiresAt) {
99
+ const expireDate = new Date();
100
+ expireDate.setDate(expireDate.getDate() + DEFAULT_TTL_DAYS);
101
+ judgment.expiresAt = expireDate.toISOString();
102
+ }
103
+ if (!Array.isArray(judgment.conflictWith)) {
104
+ judgment.conflictWith = [];
105
+ }
106
+ // 留空: appliesTo / causalChain 由后续 causal-judge 自动填
56
107
  const fullJudgment = {
57
108
  ...judgment,
58
109
  id: generateId(),
59
- timestamp: new Date().toISOString()
110
+ timestamp: now
60
111
  };
61
112
  // 加载现有判断
62
113
  const judgments = await loadAllJudgments();
@@ -148,6 +199,10 @@ export async function updateJudgment(id, patch) {
148
199
  ...(patch.values_derived !== undefined ? { values_derived: patch.values_derived } : {}),
149
200
  ...(patch.context !== undefined ? { context: { ...cur.context, ...patch.context } } : {}),
150
201
  ...(patch.outcome !== undefined ? { outcome: { ...cur.outcome, ...patch.outcome } } : {}),
202
+ ...(patch.status !== undefined ? { status: patch.status } : {}),
203
+ ...(patch.supersededBy !== undefined ? { supersededBy: patch.supersededBy } : {}),
204
+ ...(patch.evolutionReason !== undefined ? { evolutionReason: patch.evolutionReason } : {}),
205
+ ...(patch.evolvedAt !== undefined ? { evolvedAt: patch.evolvedAt } : {}),
151
206
  };
152
207
  judgments[idx] = next;
153
208
  await saveJudgments(judgments);
@@ -155,6 +210,96 @@ export async function updateJudgment(id, patch) {
155
210
  valueProfileCache.clear();
156
211
  return next;
157
212
  }
213
+ /**
214
+ * 按 status 过滤查询 (用于判断力库的 active/superseded tab)
215
+ * status='all' 或不传 → 返回所有
216
+ */
217
+ export async function listJudgmentsByStatus(status) {
218
+ const judgments = await loadAllJudgments();
219
+ if (!status || status === 'all')
220
+ return judgments;
221
+ return judgments.filter((j) => (j.status ?? 'active') === status);
222
+ }
223
+ /**
224
+ * 内容 hash 用于去重窗口 (24h 滑窗内撞 hash 视为重复)
225
+ * 归一化: 去标点 + 折叠空白 + lowercase
226
+ * 64-bit 截断: 1 万条库碰撞率 < 1e-13, 够用
227
+ */
228
+ export function hashDecision(decision) {
229
+ const normalized = decision
230
+ .toLowerCase()
231
+ .replace(/[\s,.,。、!?!?""''()()::;;\-—_]/g, '')
232
+ .trim();
233
+ return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
234
+ }
235
+ /**
236
+ * 在 withinMs 时间内查找与 decision hash 相同的判断力
237
+ * - 默认扫全库 (100 条库 < 1ms, 不建索引)
238
+ * - 可按 channelId 隔离 (判断力的 context.domain 是 'channel:xxx' 格式)
239
+ * - 当前实现只做精确 hash 匹配, 不做相似度评分; 撞 hash 即视为重复
240
+ */
241
+ export async function findRecentSimilarDecisions(decision, withinMs, options) {
242
+ const judgments = await loadAllJudgments();
243
+ const targetHash = hashDecision(decision);
244
+ const cutoff = Date.now() - withinMs;
245
+ const statusFilter = options?.status ?? 'all';
246
+ const wantChannel = options?.channelId ? `channel:${options.channelId}` : null;
247
+ const out = [];
248
+ for (const j of judgments) {
249
+ if (statusFilter !== 'all' && (j.status ?? 'active') !== statusFilter)
250
+ continue;
251
+ if (new Date(j.timestamp).getTime() < cutoff)
252
+ continue;
253
+ if (wantChannel) {
254
+ const jChannel = j.context?.domain;
255
+ if (jChannel !== wantChannel)
256
+ continue;
257
+ }
258
+ if (hashDecision(j.decision) === targetHash) {
259
+ out.push({ id: j.id, decision: j.decision, timestamp: j.timestamp, similarity: 1.0 });
260
+ }
261
+ }
262
+ return out;
263
+ }
264
+ /**
265
+ * 批量更新 (用于演化对齐后批量标 superseded)
266
+ * 写完文件 + 清缓存
267
+ * 返回成功更新的条数
268
+ */
269
+ export async function batchUpdateJudgments(updates) {
270
+ const judgments = await loadAllJudgments();
271
+ const notFound = [];
272
+ let updated = 0;
273
+ const next = judgments.map((cur) => {
274
+ const u = updates.find((x) => x.id === cur.id);
275
+ if (!u)
276
+ return cur;
277
+ updated++;
278
+ return { ...cur, ...u.patch };
279
+ });
280
+ for (const u of updates) {
281
+ if (!judgments.some((j) => j.id === u.id))
282
+ notFound.push(u.id);
283
+ }
284
+ if (updated > 0) {
285
+ await saveJudgments(next);
286
+ valueProfileCache.clear();
287
+ }
288
+ return { updated, notFound };
289
+ }
290
+ /**
291
+ * 更新单条 judgment (给"标记 rejected"等用)
292
+ * id 不变; 不能改 id, timestamp
293
+ * 允许改的字段: decision, decision_type, reasons, values_derived, context, outcome, status, supersededBy, evolutionReason, evolvedAt
294
+ */
295
+ export async function updateJudgmentStatus(id, status, extra) {
296
+ return updateJudgment(id, {
297
+ status,
298
+ ...(extra?.supersededBy !== undefined ? { supersededBy: extra.supersededBy } : {}),
299
+ ...(extra?.evolutionReason !== undefined ? { evolutionReason: extra.evolutionReason } : {}),
300
+ ...(extra ? { evolvedAt: new Date().toISOString() } : {}),
301
+ });
302
+ }
158
303
  /**
159
304
  * 删除一个判断.
160
305
  */
@@ -176,12 +321,42 @@ export async function loadAllJudgments() {
176
321
  await initializeValueStore();
177
322
  }
178
323
  if (judgmentCache.length > 0) {
324
+ // 早返回路径也要确保每条都有 status (防御性, 防止 saveJudgments 路径异常)
325
+ const needs = judgmentCache.some((j) => j.status === undefined);
326
+ if (needs) {
327
+ judgmentCache = judgmentCache.map((j) => j.status === undefined ? { ...j, status: 'active' } : j);
328
+ }
329
+ // 阶段 2: 4 字段 migration (in-place, 仅一次)
330
+ if (!migratedOnce) {
331
+ let anyChanged = false;
332
+ judgmentCache.forEach((j) => {
333
+ if (migrateJudgmentInPlace(j))
334
+ anyChanged = true;
335
+ });
336
+ if (anyChanged) {
337
+ await saveJudgments(judgmentCache).catch(() => { });
338
+ }
339
+ migratedOnce = true;
340
+ }
179
341
  return [...judgmentCache];
180
342
  }
181
343
  try {
182
344
  const content = await fs.readFile(JUDGMENTS_FILE, 'utf-8');
183
- judgmentCache = JSON.parse(content);
184
- return judgmentCache;
345
+ const parsed = JSON.parse(content);
346
+ judgmentCache = parsed.map((j) => (j.status === undefined ? { ...j, status: 'active' } : j));
347
+ // 阶段 2: 4 字段 migration (in-place, 仅一次)
348
+ if (!migratedOnce) {
349
+ let anyChanged = false;
350
+ judgmentCache.forEach((j) => {
351
+ if (migrateJudgmentInPlace(j))
352
+ anyChanged = true;
353
+ });
354
+ if (anyChanged) {
355
+ await saveJudgments(judgmentCache).catch(() => { });
356
+ }
357
+ migratedOnce = true;
358
+ }
359
+ return [...judgmentCache];
185
360
  }
186
361
  catch {
187
362
  judgmentCache = [];
@@ -190,49 +365,127 @@ export async function loadAllJudgments() {
190
365
  }
191
366
  /**
192
367
  * 获取相关价值观
193
- * context 拆分为关键词,任意一个匹配即可
368
+ * 两路召回 (P2 升级):
369
+ * 1. 关键词匹配 (精确, 高权重)
370
+ * 2. bigram 软相似度 (措辞改写也能命中, 低权重)
371
+ * - 关键词完全匹配 → weight 不衰减
372
+ * - 软相似 > 0.4 → weight * 0.7 (留作辅助, 不冲掉精确命中)
194
373
  */
195
- export async function getRelevantValues(context, domain) {
374
+ export async function getRelevantValues(context, domain, currentTool) {
196
375
  const judgments = await loadAllJudgments();
197
376
  const keywords = context.split(/[\s,,、]+/).filter(k => k.length >= 2);
198
377
  const contextLower = context.toLowerCase();
199
- const relevant = judgments.filter(j => {
378
+ const relevant = [];
379
+ for (const j of judgments) {
200
380
  if (domain && j.context.domain !== domain)
201
- return false;
381
+ continue;
382
+ // 阶段 2: appliesTo 路由 — 不匹配当前 tool 类别的 judgment 直接跳过
383
+ // appliesTo 为空 / undefined = 适用所有 (默认)
384
+ if (currentTool && Array.isArray(j.appliesTo) && j.appliesTo.length > 0) {
385
+ if (!j.appliesTo.includes(currentTool))
386
+ continue;
387
+ }
388
+ let matched = false;
389
+ let soft = 0;
202
390
  if (keywords.length === 0) {
203
- return j.decision.toLowerCase().includes(contextLower) ||
204
- j.reasons.some(r => r.toLowerCase().includes(contextLower));
391
+ if (j.decision.toLowerCase().includes(contextLower) ||
392
+ j.reasons.some(r => r.toLowerCase().includes(contextLower)) ||
393
+ j.values_derived.some(v => v.value.toLowerCase().includes(contextLower))) {
394
+ matched = true;
395
+ }
205
396
  }
206
- const decisionLower = j.decision.toLowerCase();
207
- const reasonsLower = j.reasons.map(r => r.toLowerCase());
208
- return keywords.some(kw => {
209
- const kwLower = kw.toLowerCase();
210
- return decisionLower.includes(kwLower) ||
211
- reasonsLower.some(r => r.includes(kwLower));
212
- });
213
- });
397
+ else {
398
+ const decisionLower = j.decision.toLowerCase();
399
+ const reasonsLower = j.reasons.map(r => r.toLowerCase());
400
+ // values_derived[i].value 也是索引一部分 (如 'security-first', 'privacy-first')
401
+ const valueTokens = j.values_derived.map(v => v.value.toLowerCase());
402
+ for (const kw of keywords) {
403
+ const kwLower = kw.toLowerCase();
404
+ if (decisionLower.includes(kwLower) ||
405
+ reasonsLower.some(r => r.includes(kwLower)) ||
406
+ valueTokens.some(vt => vt.includes(kwLower))) {
407
+ matched = true;
408
+ }
409
+ else {
410
+ // P2: 软相似度召回 — 关键词没命中, 但 bigram 相似度阈值
411
+ // - 长句 (>8 字符): 阈值 0.4 (合理召回, 不易误触)
412
+ // - 短句 (≤8 字符): 阈值 0.15 (bigram 模式天然偏低, 放宽避免漏召)
413
+ const threshold = kw.length > 8 ? 0.4 : 0.15;
414
+ const simA = softBigramSimilarity(kw, j.decision);
415
+ const simReason = Math.max(0, ...j.reasons.map(r => softBigramSimilarity(kw, r)));
416
+ const simValue = Math.max(0, ...valueTokens.map(vt => softBigramSimilarity(kw, vt)));
417
+ const best = Math.max(simA, simReason, simValue);
418
+ if (best > threshold)
419
+ soft = Math.max(soft, best);
420
+ }
421
+ }
422
+ }
423
+ if (matched)
424
+ relevant.push({ j, softWeight: 1.0 });
425
+ else if (soft > 0)
426
+ relevant.push({ j, softWeight: soft });
427
+ }
214
428
  const valueMap = new Map();
215
- for (const j of relevant) {
429
+ for (const { j, softWeight } of relevant) {
216
430
  for (const v of j.values_derived) {
217
431
  const key = `${v.category}:${v.value}`;
218
432
  const existing = valueMap.get(key);
219
433
  if (existing) {
220
434
  existing.count++;
221
435
  existing.tag.weight = Math.min(1, existing.tag.weight + 0.1);
436
+ existing.softFactor = Math.max(existing.softFactor, softWeight);
222
437
  }
223
438
  else {
224
- valueMap.set(key, { tag: { ...v }, count: 1 });
439
+ valueMap.set(key, {
440
+ tag: { ...v },
441
+ count: 1,
442
+ softFactor: softWeight,
443
+ });
225
444
  }
226
445
  }
227
446
  }
228
447
  return Array.from(valueMap.values())
229
- .map(({ tag, count }) => ({
448
+ .map(({ tag, count, softFactor }) => ({
230
449
  ...tag,
231
- weight: tag.weight * Math.min(1, count / 3)
450
+ // 软命中: 额外乘 0.7 衰减, 避免冲掉精确命中的排序
451
+ weight: tag.weight * Math.min(1, count / 3) * (softFactor >= 0.999 ? 1.0 : 0.7),
232
452
  }))
233
453
  .sort((a, b) => b.weight - a.weight)
234
454
  .slice(0, 10);
235
455
  }
456
+ /**
457
+ * P2 软相似度: 用于"关键词未命中但措辞相近"的兜底召回
458
+ * - 与 evolve-judgment.ts 的 jaccardSimilarity 算法一致 (避免循环依赖, inline 一份)
459
+ * - 短句 (<8 字符) 走 bigram, 长句走单字 set
460
+ */
461
+ function softBigramSimilarity(a, b) {
462
+ if (!a || !b)
463
+ return 0;
464
+ const normalize = (s) => s.toLowerCase().replace(/[\s,.,。、!?!?""''()()::;;\-—_]/g, '').trim();
465
+ const textA = normalize(a);
466
+ const textB = normalize(b);
467
+ if (textA.length === 0 || textB.length === 0)
468
+ return 0;
469
+ const grams = (s) => {
470
+ if (s.length < 8) {
471
+ const out = new Set();
472
+ for (let i = 0; i < s.length - 1; i++)
473
+ out.add(s.slice(i, i + 2));
474
+ for (const c of s)
475
+ out.add(c);
476
+ return out;
477
+ }
478
+ return new Set(s);
479
+ };
480
+ const setA = grams(textA);
481
+ const setB = grams(textB);
482
+ let inter = 0;
483
+ for (const c of setA)
484
+ if (setB.has(c))
485
+ inter++;
486
+ const union = setA.size + setB.size - inter;
487
+ return union === 0 ? 0 : inter / union;
488
+ }
236
489
  /**
237
490
  * 获取价值画像
238
491
  */
@@ -277,7 +530,15 @@ async function saveJudgments(judgments) {
277
530
  await fs.mkdir(VALUE_STORE_DIR, { recursive: true });
278
531
  await fs.writeFile(JUDGMENTS_FILE, JSON.stringify(judgments, null, 2), 'utf-8');
279
532
  // 让 loadAllJudgments 下次重新读盘, 避免缓存与磁盘脱节
280
- judgmentCache = judgments;
533
+ // 同时在写盘时也补 status 默认值, 防止 loadAllJudgments 走早返回路径时绕过迁移
534
+ // 关键: 用浅拷贝 + spread 避免 mutate 入参 (storeHumanJudgment 返回的 fullJudgment 也会被改)
535
+ judgmentCache = judgments.map((j) => {
536
+ const next = { ...j };
537
+ if (next.status === undefined)
538
+ next.status = 'active';
539
+ migrateJudgmentInPlace(next);
540
+ return next;
541
+ });
281
542
  }
282
543
  function buildValueProfile(agentId, judgments) {
283
544
  const profile = {
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Judgment Injection Gate — LLM 调用前的"门"
3
+ *
4
+ * 作用: 在每次主对话 LLM chat() 调起前, 自动从判断力库检索 Top N
5
+ * 相关原则, 拼到 system prompt 尾部.
6
+ *
7
+ * 不做的事 (留作下个迭代):
8
+ * - 不拦 channel rename / 其他旁路 LLM 调用
9
+ * - 不在 AI 回复后做"违反检测" (那是 P3 持续监控门)
10
+ * - 不做 embedding 检索 (P2 替换 getRelevantValues 即可)
11
+ *
12
+ * 性能: 单次调用 < 5ms (关键词匹配 + 全表扫 100 条库)
13
+ * 失败: 静默 fallback (空字符串), 主对话不阻塞
14
+ */
15
+ import * as fs from 'fs/promises';
16
+ import * as os from 'os';
17
+ import { getRelevantValues, loadAllJudgments, } from './human-value-store.js';
18
+ export const DEFAULT_INJECTION_CONFIG = {
19
+ topN: 3,
20
+ mode: 'standard',
21
+ skip: false,
22
+ };
23
+ /**
24
+ * 注入门主函数: 给定用户输入, 返回要追加到 system prompt 的文本 + 用到的判断力 id
25
+ *
26
+ * 静默: 任意步骤失败返回空字符串, 不 throw (主对话不阻塞)
27
+ */
28
+ export async function injectJudgmentGate(userInput, ctx = {}, options = {}) {
29
+ const cfg = { ...DEFAULT_INJECTION_CONFIG, ...options };
30
+ if (cfg.skip || !userInput || userInput.trim().length === 0) {
31
+ return { systemAddition: '', usedIds: [], matchedCount: 0 };
32
+ }
33
+ try {
34
+ // 1. 拉相关价值观 (已带 weight 排序, Top 10)
35
+ const values = await getRelevantValues(userInput, ctx.domain);
36
+ if (values.length === 0) {
37
+ return { systemAddition: '', usedIds: [], matchedCount: 0 };
38
+ }
39
+ // 2. 选 Top N (按 weight desc)
40
+ const top = values.slice(0, cfg.topN);
41
+ // 3. 从顶层权重出发, 反查对应的 judgment id (供回溯记录)
42
+ // 同一 category+value 可能来自多条 judgment, 取最近一条 active
43
+ const usedIds = await resolveJudgmentIds(top);
44
+ // 4. 拼注入文本
45
+ const systemAddition = formatInjection(top, cfg.mode, usedIds.length);
46
+ return {
47
+ systemAddition,
48
+ usedIds,
49
+ matchedCount: values.length,
50
+ };
51
+ }
52
+ catch (err) {
53
+ console.warn('[injection-gate] failed (silent fallback):', err);
54
+ return { systemAddition: '', usedIds: [], matchedCount: 0 };
55
+ }
56
+ }
57
+ /**
58
+ * 反查 judgment id: 给定 [category, value], 在 active 库中找最近一条
59
+ * decision 包含该 value 描述的
60
+ */
61
+ async function resolveJudgmentIds(values) {
62
+ if (values.length === 0)
63
+ return [];
64
+ try {
65
+ const all = await loadAllJudgments();
66
+ const active = all.filter((j) => (j.status ?? 'active') === 'active');
67
+ const ids = [];
68
+ const seen = new Set();
69
+ for (const v of values) {
70
+ // 找拥有 [category=v.category, value=v.value] 的最近一条 active judgment
71
+ // 不依赖 decision 文本包含 v.value (那是弱约束)
72
+ const hit = active.find((j) => j.values_derived.some((vd) => vd.category === v.category && vd.value === v.value));
73
+ if (hit && !seen.has(hit.id)) {
74
+ ids.push(hit.id);
75
+ seen.add(hit.id);
76
+ }
77
+ }
78
+ return ids;
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ }
84
+ function formatInjection(values, mode, resolvedCount) {
85
+ if (values.length === 0)
86
+ return '';
87
+ const header = mode === 'concise'
88
+ ? '\n# 用户判断力原则 (自动注入, 按相关度)\n'
89
+ : '\n# 用户的判断力原则 (自动注入, 按相关度排序)\n- 适用时主动遵守; 冲突时在回复中说明\n';
90
+ const lines = values.map((v, i) => {
91
+ if (mode === 'concise') {
92
+ return `${i + 1}. [${v.category}] ${v.value}`;
93
+ }
94
+ return `${i + 1}. [${v.category} · weight=${v.weight.toFixed(2)}] ${v.value}`;
95
+ });
96
+ const footer = resolvedCount > 0
97
+ ? `\n# (本轮注入了 ${resolvedCount} 条具体判断力, 已记录以便回溯)\n`
98
+ : '\n';
99
+ return header + lines.join('\n') + footer;
100
+ }
101
+ // ============================================================
102
+ // 使用记录 (回溯): AI 实际"用了"哪些判断力
103
+ // ============================================================
104
+ const USAGE_LOG = (os.homedir() || '/tmp') + '/.bolloon/human-values/usage.jsonl';
105
+ export async function recordJudgmentUsage(usedIds, meta) {
106
+ if (usedIds.length === 0)
107
+ return;
108
+ try {
109
+ const entry = {
110
+ ts: new Date().toISOString(),
111
+ channelId: meta.channelId ?? null,
112
+ userInputPreview: (meta.userInput ?? '').substring(0, 80),
113
+ usedIds,
114
+ };
115
+ await fs.appendFile(USAGE_LOG, JSON.stringify(entry) + '\n', 'utf-8');
116
+ }
117
+ catch (err) {
118
+ console.warn('[injection-gate] recordJudgmentUsage failed:', err);
119
+ }
120
+ }
121
+ /**
122
+ * 给定 channelId, 取最近 N 条 usage 记录 (UI 显示用)
123
+ */
124
+ export async function getRecentUsage(channelId, limit = 20) {
125
+ try {
126
+ const content = await fs.readFile(USAGE_LOG, 'utf-8');
127
+ const lines = content.trim().split('\n').filter(Boolean);
128
+ const parsed = lines
129
+ .map((l) => {
130
+ try {
131
+ return JSON.parse(l);
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ })
137
+ .filter(Boolean);
138
+ const filtered = channelId
139
+ ? parsed.filter((p) => p.channelId === channelId)
140
+ : parsed;
141
+ return filtered.slice(-limit).reverse();
142
+ }
143
+ catch {
144
+ return [];
145
+ }
146
+ }
147
+ // ============================================================
148
+ // 便捷包装: 调 LLM + 注入门 + 记录回溯 (调用方接入用)
149
+ // ============================================================
150
+ /**
151
+ * 一步完成: 注入门 → 调 LLM → 记录 usage
152
+ * 调用方传入 LLM 实例 (duck-typed: { chat(message, systemPrompt) })
153
+ *
154
+ * - 默认 topN=3, mode='standard' (P1 防 prompt 膨胀已内置)
155
+ * - 任意步骤失败静默, 返回原 systemPrompt + 原始 chat 结果
156
+ */
157
+ export async function chatWithJudgmentGate(llm, userInput, baseSystemPrompt, ctx = {}, options = {}) {
158
+ const gate = await injectJudgmentGate(userInput, ctx, options);
159
+ const systemPrompt = baseSystemPrompt + gate.systemAddition;
160
+ const reply = await llm.chat(userInput, systemPrompt);
161
+ // 异步记录使用 (不等)
162
+ if (gate.usedIds.length > 0) {
163
+ recordJudgmentUsage(gate.usedIds, { channelId: ctx.channelId, userInput }).catch((err) => console.warn('[injection-gate] recordJudgmentUsage async failed:', err));
164
+ }
165
+ return { reply: reply.reply, usedIds: gate.usedIds, matchedCount: gate.matchedCount };
166
+ }