principles-disciple 1.6.0 → 1.7.1

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 (75) hide show
  1. package/dist/commands/context.js +7 -3
  2. package/dist/commands/evolution-status.d.ts +4 -0
  3. package/dist/commands/evolution-status.js +134 -0
  4. package/dist/commands/export.d.ts +2 -0
  5. package/dist/commands/export.js +45 -0
  6. package/dist/commands/focus.js +9 -6
  7. package/dist/commands/pain.js +8 -0
  8. package/dist/commands/principle-rollback.d.ts +4 -0
  9. package/dist/commands/principle-rollback.js +22 -0
  10. package/dist/commands/rollback.js +9 -3
  11. package/dist/commands/samples.d.ts +2 -0
  12. package/dist/commands/samples.js +55 -0
  13. package/dist/commands/trust.js +64 -81
  14. package/dist/core/config.d.ts +5 -0
  15. package/dist/core/control-ui-db.d.ts +68 -0
  16. package/dist/core/control-ui-db.js +274 -0
  17. package/dist/core/detection-funnel.d.ts +1 -1
  18. package/dist/core/detection-funnel.js +4 -0
  19. package/dist/core/dictionary.d.ts +2 -0
  20. package/dist/core/dictionary.js +13 -0
  21. package/dist/core/event-log.d.ts +7 -1
  22. package/dist/core/event-log.js +10 -0
  23. package/dist/core/evolution-engine.d.ts +5 -5
  24. package/dist/core/evolution-engine.js +18 -18
  25. package/dist/core/evolution-migration.d.ts +5 -0
  26. package/dist/core/evolution-migration.js +65 -0
  27. package/dist/core/evolution-reducer.d.ts +69 -0
  28. package/dist/core/evolution-reducer.js +369 -0
  29. package/dist/core/evolution-types.d.ts +103 -0
  30. package/dist/core/path-resolver.js +75 -36
  31. package/dist/core/paths.d.ts +7 -8
  32. package/dist/core/paths.js +48 -40
  33. package/dist/core/profile.js +1 -1
  34. package/dist/core/session-tracker.d.ts +14 -2
  35. package/dist/core/session-tracker.js +75 -9
  36. package/dist/core/thinking-models.d.ts +38 -0
  37. package/dist/core/thinking-models.js +170 -0
  38. package/dist/core/trajectory.d.ts +184 -0
  39. package/dist/core/trajectory.js +817 -0
  40. package/dist/core/trust-engine.d.ts +6 -0
  41. package/dist/core/trust-engine.js +50 -29
  42. package/dist/core/workspace-context.d.ts +13 -0
  43. package/dist/core/workspace-context.js +50 -7
  44. package/dist/hooks/gate.js +171 -87
  45. package/dist/hooks/llm.js +119 -71
  46. package/dist/hooks/pain.js +105 -5
  47. package/dist/hooks/prompt.d.ts +11 -14
  48. package/dist/hooks/prompt.js +283 -57
  49. package/dist/hooks/subagent.js +69 -28
  50. package/dist/hooks/trajectory-collector.d.ts +32 -0
  51. package/dist/hooks/trajectory-collector.js +256 -0
  52. package/dist/http/principles-console-route.d.ts +2 -0
  53. package/dist/http/principles-console-route.js +257 -0
  54. package/dist/i18n/commands.js +16 -0
  55. package/dist/index.js +105 -4
  56. package/dist/service/control-ui-query-service.d.ts +217 -0
  57. package/dist/service/control-ui-query-service.js +537 -0
  58. package/dist/service/empathy-observer-manager.d.ts +2 -0
  59. package/dist/service/empathy-observer-manager.js +43 -1
  60. package/dist/service/evolution-worker.d.ts +27 -0
  61. package/dist/service/evolution-worker.js +256 -41
  62. package/dist/service/runtime-summary-service.d.ts +79 -0
  63. package/dist/service/runtime-summary-service.js +319 -0
  64. package/dist/service/trajectory-service.d.ts +2 -0
  65. package/dist/service/trajectory-service.js +15 -0
  66. package/dist/tools/agent-spawn.d.ts +27 -6
  67. package/dist/tools/agent-spawn.js +339 -87
  68. package/dist/tools/deep-reflect.d.ts +27 -7
  69. package/dist/tools/deep-reflect.js +210 -121
  70. package/dist/types/event-types.d.ts +10 -2
  71. package/dist/types.d.ts +10 -0
  72. package/dist/types.js +5 -0
  73. package/openclaw.plugin.json +43 -11
  74. package/package.json +14 -4
  75. package/templates/langs/zh/skills/pd-daily/SKILL.md +97 -13
@@ -7,6 +7,7 @@ import { trackBlock, hasRecentThinking, getSession } from '../core/session-track
7
7
  import { assessRiskLevel, estimateLineChanges } from '../core/risk-calculator.js';
8
8
  import { WorkspaceContext } from '../core/workspace-context.js';
9
9
  import { checkEvolutionGate } from '../core/evolution-engine.js';
10
+ import { EventLogService } from '../core/event-log.js';
10
11
  // ═══ GFI Gate Tool Tiers ═══
11
12
  // TIER 0: 只读工具 - 永不拦截
12
13
  const READ_ONLY_TOOLS = new Set([
@@ -19,7 +20,7 @@ const READ_ONLY_TOOLS = new Set([
19
20
  'deep_reflect',
20
21
  ]);
21
22
  // TIER 1: 低风险修改 - GFI >= low_risk_block 时拦截
22
- // 注意:pd_spawn_agent、sessions_spawn、task 是 Agent 派生工具,不应被 GFI Gate 拦截
23
+ // 注意:pd_run_worker、sessions_spawn、task 是 Agent 派生工具,不应被 GFI Gate 拦截
23
24
  // 它们属于 AGENT_TOOLS,在早期过滤后直接放行
24
25
  const LOW_RISK_WRITE_TOOLS = new Set([
25
26
  'write', 'write_file',
@@ -36,32 +37,69 @@ const BASH_TOOLS_SET = new Set([
36
37
  /**
37
38
  * 分析 Bash 命令风险等级
38
39
  */
39
- function analyzeBashCommand(command, safePatterns, dangerousPatterns) {
40
- const normalizedCmd = command.trim().toLowerCase();
41
- // 1. 优先检查危险命令
42
- for (const pattern of dangerousPatterns) {
43
- try {
44
- if (new RegExp(pattern, 'i').test(normalizedCmd)) {
40
+ function analyzeBashCommand(command, safePatterns, dangerousPatterns, logger) {
41
+ let normalizedCmd = command.trim().toLowerCase();
42
+ // P2 fix: Unicode de-obfuscation — convert Cyrillic/Unicode lookalikes to ASCII equivalents
43
+ // Common Cyrillic lookalikes that could bypass detection: аеорсух (Cyrillic) aeopcyx (Latin)
44
+ const CYRILLIC_TO_LATIN = {
45
+ 'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c', 'у': 'y', 'х': 'x',
46
+ 'А': 'a', 'Е': 'e', 'О': 'o', 'Р': 'p', 'С': 'c', 'У': 'y', 'Х': 'x',
47
+ // Additional confusable chars
48
+ 'і': 'i', 'ј': 'j', 'ѕ': 's', 'ԁ': 'd', 'ɡ': 'g', 'һ': 'h', 'ⅰ': 'i',
49
+ 'ƚ': 'l', 'м': 'm', 'п': 'n', 'ѵ': 'v', 'ѡ': 'w', 'ᴦ': 'r', 'ꜱ': 's',
50
+ };
51
+ normalizedCmd = normalizedCmd.replace(/[а-яА-Яіјѕԁɡһⅰƚмпеꜱѵѡᴦꜱ]/g, m => CYRILLIC_TO_LATIN[m] ?? m);
52
+ // P2 fix: Tokenize command chain before pattern matching to catch `cmd1 && cmd2` bypasses
53
+ // Only split on statement separators (; && ||), NOT on pipe (|) which is part of the command
54
+ const tokens = normalizedCmd
55
+ .split(/\s*(?:;|&&|\|\|)\s*/)
56
+ .map(t => t.trim())
57
+ .filter(t => t.length > 0);
58
+ // If no tokens (e.g., pure pipe-only), use the original
59
+ const segments = tokens.length > 0 ? tokens : [normalizedCmd];
60
+ // P2 fix: Also strip outer $() and backticks from each segment
61
+ const cleanSegments = segments.map(seg => {
62
+ let s = seg;
63
+ // Strip leading $() or ${} or backtick-wrapped commands
64
+ s = s.replace(/^\$\([^)]+\)$/, '').replace(/^\$\{[^}]+\}$/, '').replace(/^`([^`]+)`$/, '$1');
65
+ return s.trim();
66
+ }).filter(s => s.length > 0);
67
+ // 1. Check dangerous patterns against each segment
68
+ for (const seg of cleanSegments) {
69
+ for (const pattern of dangerousPatterns) {
70
+ try {
71
+ if (new RegExp(pattern, 'i').test(seg)) {
72
+ return 'dangerous';
73
+ }
74
+ }
75
+ catch (error) {
76
+ logger?.warn?.(`[PD_GATE] Invalid dangerous bash regex "${pattern}": ${String(error)}. Failing closed.`);
45
77
  return 'dangerous';
78
+ // Fail-closed: 无效的危险模式正则视为匹配危险命令
46
79
  }
47
80
  }
48
- catch {
49
- // 忽略无效正则
50
- }
51
81
  }
52
- // 2. 检查安全命令
53
- for (const pattern of safePatterns) {
54
- try {
55
- if (new RegExp(pattern, 'i').test(normalizedCmd)) {
56
- return 'safe';
82
+ // 2. Check safe patterns (only if ALL segments are safe)
83
+ for (const seg of cleanSegments) {
84
+ let isSafe = false;
85
+ for (const pattern of safePatterns) {
86
+ try {
87
+ if (new RegExp(pattern, 'i').test(seg)) {
88
+ isSafe = true;
89
+ break;
90
+ }
91
+ }
92
+ catch (error) {
93
+ logger?.warn?.(`[PD_GATE] Invalid safe bash regex "${pattern}": ${String(error)}. Ignoring safe override.`);
57
94
  }
58
95
  }
59
- catch {
60
- // 忽略无效正则
96
+ if (!isSafe) {
97
+ // Not all segments are safe → treat as normal
98
+ return 'normal';
61
99
  }
62
100
  }
63
- // 3. 默认为普通命令
64
- return 'normal';
101
+ // All segments are safe
102
+ return 'safe';
65
103
  }
66
104
  /**
67
105
  * 计算动态 GFI 阈值
@@ -82,7 +120,7 @@ export function handleBeforeToolCall(event, ctx) {
82
120
  // 1. Identify tool type
83
121
  const WRITE_TOOLS = ['write', 'edit', 'apply_patch', 'write_file', 'replace', 'insert', 'patch', 'edit_file', 'delete_file', 'move_file'];
84
122
  const BASH_TOOLS = ['bash', 'run_shell_command', 'exec', 'execute', 'shell', 'cmd'];
85
- const AGENT_TOOLS = ['pd_spawn_agent', 'sessions_spawn'];
123
+ const AGENT_TOOLS = ['pd_run_worker', 'sessions_spawn'];
86
124
  const isBash = BASH_TOOLS.includes(event.toolName);
87
125
  const isWriteTool = WRITE_TOOLS.includes(event.toolName);
88
126
  const isAgentTool = AGENT_TOOLS.includes(event.toolName);
@@ -115,7 +153,7 @@ export function handleBeforeToolCall(event, ctx) {
115
153
  thinking_checkpoint: {
116
154
  enabled: false, // Default OFF
117
155
  window_ms: 5 * 60 * 1000,
118
- high_risk_tools: ['run_shell_command', 'delete_file', 'move_file', 'pd_spawn_agent'],
156
+ high_risk_tools: ['run_shell_command', 'delete_file', 'move_file', 'pd_run_worker'],
119
157
  }
120
158
  };
121
159
  if (fs.existsSync(profilePath)) {
@@ -151,7 +189,7 @@ export function handleBeforeToolCall(event, ctx) {
151
189
  // TIER 3: Bash 命令 - 根据内容判断
152
190
  if (BASH_TOOLS_SET.has(event.toolName)) {
153
191
  const command = String(event.params.command || event.params.args || '');
154
- const bashRisk = analyzeBashCommand(command, gfiGateConfig?.bash_safe_patterns || [], gfiGateConfig?.bash_dangerous_patterns || []);
192
+ const bashRisk = analyzeBashCommand(command, gfiGateConfig?.bash_safe_patterns || [], gfiGateConfig?.bash_dangerous_patterns || [], logger);
155
193
  if (bashRisk === 'dangerous') {
156
194
  // 危险命令 - 直接拦截
157
195
  logger?.warn?.(`[PD:GFI_GATE] Dangerous bash command blocked: ${command.substring(0, 50)}...`);
@@ -272,6 +310,26 @@ GFI: ${currentGfi}/100
272
310
  };
273
311
  }
274
312
  }
313
+ // AGENT_TOOLS: Block subagent spawn when GFI is critically high (P0 fix: prevent privilege escalation via spawned subagents)
314
+ if (isAgentTool) {
315
+ const AGENT_SPAWN_GFI_THRESHOLD = 90;
316
+ if (currentGfi >= AGENT_SPAWN_GFI_THRESHOLD) {
317
+ logger?.warn?.(`[PD:GFI_GATE] Agent tool "${event.toolName}" blocked by GFI: ${currentGfi} >= ${AGENT_SPAWN_GFI_THRESHOLD}`);
318
+ return {
319
+ block: true,
320
+ blockReason: `[GFI Gate] 疲劳指数过高,禁止派生子智能体。
321
+
322
+ GFI: ${currentGfi}/100
323
+ 阈值: ${AGENT_SPAWN_GFI_THRESHOLD} (Stage ${wctx.trust.getStage()})
324
+
325
+ 原因: 高疲劳状态下派生子智能体会放大错误风险。
326
+
327
+ 解决方案:
328
+ 1. 执行 /pd-status reset 清零疲劳值
329
+ 2. 简化任务后重试`,
330
+ };
331
+ }
332
+ }
275
333
  }
276
334
  // Merge pluginConfig (OpenClaw UI settings)
277
335
  const configRiskPaths = ctx.pluginConfig?.riskPaths ?? [];
@@ -314,71 +372,17 @@ GFI: ${currentGfi}/100
314
372
  };
315
373
  const riskLevel = assessRiskLevel(relPath, { toolName: event.toolName, params: event.params }, profile.risk_paths);
316
374
  const lineChanges = estimateLineChanges({ toolName: event.toolName, params: event.params });
375
+ const planApprovals = profile.progressive_gate?.plan_approvals;
376
+ const canUsePlanApproval = Boolean(stage === 1 &&
377
+ planApprovals?.enabled &&
378
+ getPlanStatus(ctx.workspaceDir) === 'READY' &&
379
+ planApprovals.allowed_operations?.includes(event.toolName) &&
380
+ matchesAnyPattern(relPath, planApprovals.allowed_patterns || []) &&
381
+ ((planApprovals.max_lines_override ?? -1) === -1 || lineChanges <= (planApprovals.max_lines_override ?? -1)));
317
382
  logger.info(`[PD_GATE] Trust: ${trustScore} (Stage ${stage}), Risk: ${riskLevel}, Path: ${relPath}`);
318
- // Stage 1 (Bankruptcy): Block ALL writes to risk paths, and all medium+ writes
319
- if (stage === 1) {
320
- if (risky || riskLevel !== 'LOW') {
321
- // Check if PLAN whitelist is enabled
322
- if (profile.progressive_gate?.plan_approvals?.enabled) {
323
- const planApprovals = profile.progressive_gate.plan_approvals;
324
- const planStatus = getPlanStatus(ctx.workspaceDir);
325
- // Must have READY plan
326
- if (planStatus === 'READY') {
327
- // Check operation type
328
- if (planApprovals.allowed_operations?.includes(event.toolName)) {
329
- // Check path pattern
330
- if (matchesAnyPattern(relPath, planApprovals.allowed_patterns || [])) {
331
- // Check line limit (if configured)
332
- const maxLines = planApprovals.max_lines_override ?? -1;
333
- if (maxLines === -1 || lineChanges <= maxLines) {
334
- // Record PLAN approval event
335
- wctx.eventLog.recordPlanApproval(ctx.sessionId, {
336
- toolName: event.toolName,
337
- filePath: relPath,
338
- pattern: relPath,
339
- planStatus
340
- });
341
- logger.info(`[PD_GATE] Stage 1 PLAN approval: ${relPath}`);
342
- return; // Allow the operation
343
- }
344
- }
345
- }
346
- }
347
- }
348
- // Block if not approved by whitelist
349
- return block(relPath, `Trust score too low (${trustScore}). Stage 1 agents cannot modify risk paths or perform non-trivial edits.`, wctx, event.toolName);
350
- }
351
- }
352
- // Stage 2 (Editor): Block writes to risk paths. Block large changes.
353
- if (stage === 2) {
354
- if (risky) {
355
- return block(relPath, `Stage 2 agents are not authorized to modify risk paths.`, wctx, event.toolName);
356
- }
357
- const stage2Limit = trustSettings.limits?.stage_2_max_lines ?? 50;
358
- if (lineChanges > stage2Limit) {
359
- return block(relPath, `Modification too large (${lineChanges} lines) for Stage 2. Max allowed is ${stage2Limit}.`, wctx, event.toolName);
360
- }
361
- }
362
- // Stage 3 (Developer): Allow normal writes. Require READY plan for risk paths.
363
- if (stage === 3) {
364
- if (risky) {
365
- const planStatus = getPlanStatus(ctx.workspaceDir);
366
- if (planStatus !== 'READY') {
367
- return block(relPath, `No READY plan found. Stage 3 requires a plan for risk path modifications.`, wctx, event.toolName);
368
- }
369
- }
370
- const stage3Limit = trustSettings.limits?.stage_3_max_lines ?? 300;
371
- if (lineChanges > stage3Limit) {
372
- return block(relPath, `Modification too large (${lineChanges} lines) for Stage 3. Max allowed is ${stage3Limit}.`, wctx, event.toolName);
373
- }
374
- }
375
- // Stage 4 (Architect): Full bypass
376
- if (stage === 4) {
377
- logger.info(`[PD_GATE] Trusted Architect bypass for ${relPath}`);
378
- return;
379
- }
380
383
  // ── EP SIMULATION MODE (M6验证) ──
381
384
  // 记录EP系统的模拟决策,但不生效(仅用于对比分析)
385
+ // BUGFIX #90: 移到所有Stage检查之前,确保所有Stage都触发EP simulation记录
382
386
  try {
383
387
  const epDecision = checkEvolutionGate(ctx.workspaceDir, {
384
388
  toolName: event.toolName,
@@ -416,13 +420,81 @@ GFI: ${currentGfi}/100
416
420
  const errMsg = err instanceof Error ? err.message : String(err);
417
421
  logger.warn(`[PD_EP_SIM] Simulation failed: ${errMsg}, continuing with Trust Engine`);
418
422
  }
423
+ // Stage 1 (Bankruptcy): Block ALL writes to risk paths, and all medium+ writes
424
+ if (stage === 1) {
425
+ if (canUsePlanApproval) {
426
+ const planStatus = 'READY';
427
+ wctx.eventLog.recordPlanApproval(ctx.sessionId, {
428
+ toolName: event.toolName,
429
+ filePath: relPath,
430
+ pattern: relPath,
431
+ planStatus
432
+ });
433
+ wctx.trajectory?.recordGateBlock?.({
434
+ sessionId: ctx.sessionId,
435
+ toolName: event.toolName,
436
+ filePath: relPath,
437
+ reason: 'plan_approval',
438
+ planStatus,
439
+ });
440
+ logger.info(`[PD_GATE] Stage 1 PLAN approval: ${relPath}`);
441
+ return;
442
+ }
443
+ if (risky || riskLevel !== 'LOW') {
444
+ // Block if not approved by whitelist
445
+ return block(relPath, `Trust score too low (${trustScore}). Stage 1 agents cannot modify risk paths or perform non-trivial edits.`, wctx, event.toolName, ctx.sessionId);
446
+ }
447
+ }
448
+ // Stage 2 (Editor): Block writes to risk paths. Block large changes.
449
+ if (stage === 2) {
450
+ if (risky) {
451
+ return block(relPath, `Stage 2 agents are not authorized to modify risk paths.`, wctx, event.toolName, ctx.sessionId);
452
+ }
453
+ const stage2Limit = trustSettings.limits?.stage_2_max_lines ?? 50;
454
+ if (lineChanges > stage2Limit) {
455
+ return block(relPath, `Modification too large (${lineChanges} lines) for Stage 2. Max allowed is ${stage2Limit}.`, wctx, event.toolName, ctx.sessionId);
456
+ }
457
+ }
458
+ // Stage 3 (Developer): Allow normal writes. Require READY plan for risk paths.
459
+ if (stage === 3) {
460
+ if (risky) {
461
+ const planStatus = getPlanStatus(ctx.workspaceDir);
462
+ if (planStatus !== 'READY') {
463
+ return block(relPath, `No READY plan found. Stage 3 requires a plan for risk path modifications.`, wctx, event.toolName, ctx.sessionId);
464
+ }
465
+ }
466
+ const stage3Limit = trustSettings.limits?.stage_3_max_lines ?? 300;
467
+ if (lineChanges > stage3Limit) {
468
+ return block(relPath, `Modification too large (${lineChanges} lines) for Stage 3. Max allowed is ${stage3Limit}.`, wctx, event.toolName, ctx.sessionId);
469
+ }
470
+ }
471
+ // Stage 4 (Architect): Full bypass
472
+ if (stage === 4) {
473
+ logger.info(`[PD_GATE] Trusted Architect bypass for ${relPath}`);
474
+ // Audit log for Stage 4 bypass (security traceability)
475
+ try {
476
+ const stateDir = wctx.resolve('STATE_DIR');
477
+ const eventLog = EventLogService.get(stateDir);
478
+ eventLog.recordGateBypass(ctx.sessionId, {
479
+ toolName: event.toolName,
480
+ filePath: relPath,
481
+ bypassType: 'stage4_architect',
482
+ trustScore,
483
+ trustStage: stage,
484
+ });
485
+ }
486
+ catch (auditErr) {
487
+ logger?.warn?.(`[PD_GATE] Failed to record Stage 4 bypass audit: ${String(auditErr)}`);
488
+ }
489
+ return;
490
+ }
419
491
  }
420
492
  else {
421
493
  // FALLBACK: Legacy Gate Logic
422
494
  if (risky && profile.gate?.require_plan_for_risk_paths) {
423
495
  const planStatus = getPlanStatus(ctx.workspaceDir);
424
496
  if (planStatus !== 'READY') {
425
- return block(relPath, `No READY plan found in PLAN.md.`, wctx, event.toolName);
497
+ return block(relPath, `No READY plan found in PLAN.md.`, wctx, event.toolName, ctx.sessionId);
426
498
  }
427
499
  }
428
500
  }
@@ -443,10 +515,22 @@ GFI: ${currentGfi}/100
443
515
  }
444
516
  }
445
517
  }
446
- function block(filePath, reason, wctx, toolName) {
518
+ function block(filePath, reason, wctx, toolName, sessionId) {
447
519
  const logger = console;
448
520
  logger.error(`[PD_GATE] BLOCKED: ${filePath}. Reason: ${reason}`);
449
- trackBlock(wctx.workspaceDir);
521
+ if (sessionId) {
522
+ trackBlock(sessionId);
523
+ }
524
+ try {
525
+ wctx.eventLog.recordGateBlock(sessionId, {
526
+ toolName,
527
+ filePath,
528
+ reason,
529
+ });
530
+ }
531
+ catch (error) {
532
+ logger.warn(`[PD_GATE] Failed to record gate block event: ${String(error)}`);
533
+ }
450
534
  return {
451
535
  block: true,
452
536
  blockReason: `[Principles Disciple] Security Gate Blocked this action.\nFile: ${filePath}\nReason: ${reason}\n\nHint: You may need a READY plan or a higher trust score to perform this action.`,
package/dist/hooks/llm.js CHANGED
@@ -2,8 +2,11 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { trackFriction, trackLlmOutput, recordThinkingCheckpoint, resetFriction } from '../core/session-tracker.js';
4
4
  import { writePainFlag } from '../core/pain.js';
5
+ import { ControlUiDatabase } from '../core/control-ui-db.js';
5
6
  import { DetectionService } from '../core/detection-service.js';
7
+ import { detectThinkingModelMatches, deriveThinkingScenarios } from '../core/thinking-models.js';
6
8
  import { WorkspaceContext } from '../core/workspace-context.js';
9
+ import { sanitizeAssistantText } from './message-sanitize.js';
7
10
  const empathyDedupState = new Map();
8
11
  const empathyRateState = new Map();
9
12
  function clamp(value, min, max) {
@@ -184,6 +187,25 @@ export function handleLlmOutput(event, ctx) {
184
187
  if (!event.assistantTexts || event.assistantTexts.length === 0)
185
188
  return;
186
189
  const text = event.assistantTexts.join('\n');
190
+ const signal = extractEmpathySignal(text);
191
+ const createdAt = new Date().toISOString();
192
+ let assistantTurnId = null;
193
+ try {
194
+ assistantTurnId = wctx.trajectory?.recordAssistantTurn?.({
195
+ sessionId: ctx.sessionId,
196
+ runId: event.runId,
197
+ provider: event.provider,
198
+ model: event.model,
199
+ rawText: text,
200
+ sanitizedText: sanitizeAssistantText(text),
201
+ usageJson: event.usage || {},
202
+ empathySignalJson: signal,
203
+ createdAt,
204
+ });
205
+ }
206
+ catch (error) {
207
+ ctx.logger?.warn?.(`[PD:LLM] Failed to persist assistant turn to trajectory: ${String(error)}`);
208
+ }
187
209
  // ── Track B: Semantic Pain Detection (V1.3.0 Funnel) ──
188
210
  const detectionService = DetectionService.get(wctx.stateDir);
189
211
  const detection = detectionService.detect(text);
@@ -205,7 +227,6 @@ export function handleLlmOutput(event, ctx) {
205
227
  // empathy sub-pipeline (enabled by default)
206
228
  const empathyEnabled = config.get('empathy_engine.enabled');
207
229
  if (empathyEnabled !== false) {
208
- const signal = extractEmpathySignal(text);
209
230
  if (signal.detected) {
210
231
  const dedupeWindow = Number(config.get('empathy_engine.dedupe_window_ms') ?? 60000);
211
232
  const deduped = shouldDedupe(ctx.sessionId, event.runId, signal, dedupeWindow);
@@ -216,7 +237,21 @@ export function handleLlmOutput(event, ctx) {
216
237
  const calibratedScore = Math.round(weightedScore * calibrationFactor);
217
238
  const boundedScore = applyRateLimit(ctx.sessionId, event.runId, calibratedScore, config);
218
239
  if (boundedScore > 0) {
219
- trackFriction(ctx.sessionId, boundedScore, `user_empathy_${signal.severity}`, ctx.workspaceDir);
240
+ trackFriction(ctx.sessionId, boundedScore, `user_empathy_${signal.severity}`, ctx.workspaceDir, { source: 'user_empathy' });
241
+ try {
242
+ wctx.trajectory?.recordPainEvent?.({
243
+ sessionId: ctx.sessionId,
244
+ source: 'user_empathy',
245
+ score: boundedScore,
246
+ reason: signal.reason || 'Assistant self-reported user emotional distress.',
247
+ severity: signal.severity,
248
+ origin: 'assistant_self_report',
249
+ confidence: signal.confidence,
250
+ });
251
+ }
252
+ catch (error) {
253
+ ctx.logger?.warn?.(`[PD:LLM] Failed to persist empathy pain event to trajectory: ${String(error)}`);
254
+ }
220
255
  // Generate unique event ID for rollback support
221
256
  const eventId = `emp_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
222
257
  eventLog.recordPainSignal(ctx.sessionId, {
@@ -263,7 +298,10 @@ export function handleLlmOutput(event, ctx) {
263
298
  const rolledBackScore = eventLog.rollbackEmpathyEvent(eventId, ctx.sessionId, 'Natural language rollback request detected', 'natural_language');
264
299
  if (rolledBackScore > 0) {
265
300
  // Reset GFI after successful rollback
266
- resetFriction(ctx.sessionId);
301
+ resetFriction(ctx.sessionId, ctx.workspaceDir, {
302
+ source: 'user_empathy',
303
+ amount: rolledBackScore,
304
+ });
267
305
  }
268
306
  }
269
307
  }
@@ -297,56 +335,18 @@ export function handleLlmOutput(event, ctx) {
297
335
  });
298
336
  }
299
337
  // ═══ Thinking OS: Mental Model Usage Tracking ═══
300
- trackThinkingModelUsage(text, wctx, ctx.sessionId);
338
+ trackThinkingModelUsage({
339
+ text,
340
+ wctx,
341
+ sessionId: ctx.sessionId,
342
+ runId: event.runId,
343
+ assistantTurnId,
344
+ createdAt,
345
+ logger: ctx.logger,
346
+ });
301
347
  }
302
- const THINKING_MODEL_SIGNALS = {
303
- 'T-01': [
304
- /let me (first )?(understand|map|outline|survey|review the (structure|architecture|dependencies))/i,
305
- /让我先(梳理|了解|画出|理解|查看)(一下)?(结构|架构|依赖|全貌)/,
306
- ],
307
- 'T-02': [
308
- /(type|test|contract|schema|interface) (constraint|requirement|check|validation)/i,
309
- /we (must|need to) (respect|follow|adhere to) the/i,
310
- /(必须|需要).*?(遵守|符合|满足).*?(类型|测试|契约|接口|规范)/,
311
- ],
312
- 'T-03': [
313
- /based on (the |this )?(evidence|logs?|output|error|stack trace|test result)/i,
314
- /let me (check|verify|confirm|read|look at) (the |)(actual|source|code|file|log)/i,
315
- /根据(日志|证据|输出|报错|堆栈|测试结果)/,
316
- ],
317
- 'T-04': [
318
- /this (is|would be) (irreversible|destructive|permanent|not easily undone)/i,
319
- /(reversible|can be undone|safely roll back)/i,
320
- /(不可逆|破坏性|永久的|无法回滚|可以回滚|安全地撤销)/,
321
- ],
322
- 'T-05': [
323
- /we (must|should) (not|never|avoid|prevent|ensure we don't)/i,
324
- /(critical|important) (not to|that we don't|to avoid)/i,
325
- /(绝不能|必须避免|不可以|禁止|确保不会)/,
326
- ],
327
- 'T-06': [
328
- /(simpl(er|est|ify)|minimal|straightforward|lean) (approach|solution|fix|implementation)/i,
329
- /(simple is better|keep it simple|no need to over)/i,
330
- /(最简(单|洁)|精简|没有必要(过度|额外))/,
331
- ],
332
- 'T-07': [
333
- /(minimal|smallest|narrowest|least) (change|diff|modification|impact)/i,
334
- /only (change|modify|touch|edit) (the |what)/i,
335
- /(最小(改动|变更|修改)|只(改|动|修))/,
336
- ],
337
- 'T-08': [
338
- /this (error|failure|issue) (tells us|indicates|signals|suggests|means)/i,
339
- /let me (stop|pause|step back|reconsider|rethink)/i,
340
- /这个(错误|失败|问题)(告诉我们|表明|说明|意味)/,
341
- /让我(停下|暂停|退一步|重新(考虑|思考|审视))/,
342
- ],
343
- 'T-09': [
344
- /(break|split|decompose|divide) (this |the task |it )?(into|down)/i,
345
- /(step 1|first,? (we|i|let's)|phase 1)/i,
346
- /(拆分|分解|分步|分阶段|第一步)/,
347
- ],
348
- };
349
- function trackThinkingModelUsage(text, wctx, sessionId) {
348
+ function trackThinkingModelUsage(args) {
349
+ const { text, wctx, sessionId, runId, assistantTurnId, createdAt, logger } = args;
350
350
  const logPath = wctx.resolve('THINKING_OS_USAGE');
351
351
  const logDir = path.dirname(logPath);
352
352
  if (!fs.existsSync(logDir))
@@ -360,27 +360,75 @@ function trackThinkingModelUsage(text, wctx, sessionId) {
360
360
  console.error(`[PD:LLM] Failed to parse thinking OS usage log: ${String(e)}`);
361
361
  }
362
362
  }
363
- let anyMatch = false;
364
- for (const [modelId, patterns] of Object.entries(THINKING_MODEL_SIGNALS)) {
365
- for (const pattern of patterns) {
366
- if (pattern.test(text)) {
367
- usageLog[modelId] = (usageLog[modelId] || 0) + 1;
368
- anyMatch = true;
369
- break;
370
- }
371
- }
363
+ const matches = detectThinkingModelMatches(text);
364
+ for (const match of matches) {
365
+ usageLog[match.modelId] = (usageLog[match.modelId] || 0) + 1;
372
366
  }
373
367
  usageLog['_total_turns'] = (usageLog['_total_turns'] || 0) + 1;
374
- if (anyMatch) {
375
- // Record thinking checkpoint for gate enforcement
376
- if (sessionId) {
377
- recordThinkingCheckpoint(sessionId, wctx.workspaceDir);
378
- }
379
- try {
380
- fs.writeFileSync(logPath, JSON.stringify(usageLog, null, 2), 'utf8');
381
- }
382
- catch (e) {
383
- console.error(`[PD:LLM] Failed to write thinking OS usage log: ${String(e)}`);
368
+ try {
369
+ fs.writeFileSync(logPath, JSON.stringify(usageLog, null, 2), 'utf8');
370
+ }
371
+ catch (e) {
372
+ console.error(`[PD:LLM] Failed to write thinking OS usage log: ${String(e)}`);
373
+ }
374
+ if (matches.length === 0) {
375
+ return;
376
+ }
377
+ if (sessionId) {
378
+ recordThinkingCheckpoint(sessionId, wctx.workspaceDir);
379
+ }
380
+ if (!sessionId || !assistantTurnId) {
381
+ return;
382
+ }
383
+ const uiDb = new ControlUiDatabase({ workspaceDir: wctx.workspaceDir });
384
+ try {
385
+ const recentContext = uiDb.getRecentThinkingContext(sessionId, createdAt);
386
+ const toolContext = recentContext.toolCalls.map((call) => ({
387
+ toolName: call.toolName,
388
+ outcome: call.outcome,
389
+ errorType: call.errorType,
390
+ }));
391
+ const painContext = recentContext.painEvents.map((event) => ({
392
+ source: event.source,
393
+ score: event.score,
394
+ }));
395
+ const principleContext = recentContext.principleEvents.map((event) => ({
396
+ principleId: event.principleId,
397
+ eventType: event.eventType,
398
+ }));
399
+ const triggerExcerpt = text.length > 280 ? `${text.slice(0, 277)}...` : text;
400
+ for (const match of matches) {
401
+ const scenarios = deriveThinkingScenarios(match.modelId, {
402
+ recentToolCalls: toolContext,
403
+ recentPainEvents: painContext,
404
+ recentGateBlocks: recentContext.gateBlocks.map((block) => ({
405
+ toolName: block.toolName,
406
+ reason: block.reason,
407
+ })),
408
+ recentUserCorrections: recentContext.userCorrections.map((correction) => ({
409
+ correctionCue: correction.correctionCue,
410
+ })),
411
+ recentPrincipleEvents: principleContext,
412
+ });
413
+ uiDb.recordThinkingModelEvent({
414
+ sessionId,
415
+ runId,
416
+ assistantTurnId,
417
+ modelId: match.modelId,
418
+ matchedPattern: match.matchedPattern,
419
+ scenarioJson: scenarios,
420
+ toolContextJson: toolContext,
421
+ painContextJson: painContext,
422
+ principleContextJson: principleContext,
423
+ triggerExcerpt,
424
+ createdAt,
425
+ });
384
426
  }
385
427
  }
428
+ catch (error) {
429
+ logger?.warn?.(`[PD:LLM] Failed to persist thinking model events: ${String(error)}`);
430
+ }
431
+ finally {
432
+ uiDb.dispose();
433
+ }
386
434
  }