principles-disciple 1.5.4 → 1.7.0

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 (88) hide show
  1. package/dist/commands/context.d.ts +5 -0
  2. package/dist/commands/context.js +312 -0
  3. package/dist/commands/evolution-status.d.ts +4 -0
  4. package/dist/commands/evolution-status.js +138 -0
  5. package/dist/commands/export.d.ts +2 -0
  6. package/dist/commands/export.js +45 -0
  7. package/dist/commands/focus.d.ts +14 -0
  8. package/dist/commands/focus.js +582 -0
  9. package/dist/commands/pain.js +143 -6
  10. package/dist/commands/principle-rollback.d.ts +4 -0
  11. package/dist/commands/principle-rollback.js +22 -0
  12. package/dist/commands/rollback.d.ts +19 -0
  13. package/dist/commands/rollback.js +119 -0
  14. package/dist/commands/samples.d.ts +2 -0
  15. package/dist/commands/samples.js +55 -0
  16. package/dist/core/config.d.ts +37 -0
  17. package/dist/core/config.js +47 -0
  18. package/dist/core/control-ui-db.d.ts +68 -0
  19. package/dist/core/control-ui-db.js +274 -0
  20. package/dist/core/detection-funnel.d.ts +1 -1
  21. package/dist/core/detection-funnel.js +4 -0
  22. package/dist/core/dictionary.d.ts +2 -0
  23. package/dist/core/dictionary.js +13 -0
  24. package/dist/core/event-log.d.ts +22 -1
  25. package/dist/core/event-log.js +319 -0
  26. package/dist/core/evolution-engine.d.ts +5 -5
  27. package/dist/core/evolution-engine.js +18 -18
  28. package/dist/core/evolution-migration.d.ts +5 -0
  29. package/dist/core/evolution-migration.js +65 -0
  30. package/dist/core/evolution-reducer.d.ts +69 -0
  31. package/dist/core/evolution-reducer.js +369 -0
  32. package/dist/core/evolution-types.d.ts +103 -0
  33. package/dist/core/focus-history.d.ts +65 -0
  34. package/dist/core/focus-history.js +266 -0
  35. package/dist/core/init.js +30 -7
  36. package/dist/core/migration.js +0 -2
  37. package/dist/core/path-resolver.d.ts +3 -0
  38. package/dist/core/path-resolver.js +90 -31
  39. package/dist/core/paths.d.ts +7 -8
  40. package/dist/core/paths.js +48 -40
  41. package/dist/core/profile.js +1 -1
  42. package/dist/core/session-tracker.d.ts +4 -0
  43. package/dist/core/session-tracker.js +15 -0
  44. package/dist/core/thinking-models.d.ts +38 -0
  45. package/dist/core/thinking-models.js +170 -0
  46. package/dist/core/trajectory.d.ts +184 -0
  47. package/dist/core/trajectory.js +817 -0
  48. package/dist/core/trust-engine.d.ts +2 -0
  49. package/dist/core/trust-engine.js +30 -4
  50. package/dist/core/workspace-context.d.ts +13 -0
  51. package/dist/core/workspace-context.js +50 -7
  52. package/dist/hooks/gate.js +301 -30
  53. package/dist/hooks/llm.d.ts +8 -0
  54. package/dist/hooks/llm.js +347 -69
  55. package/dist/hooks/message-sanitize.d.ts +3 -0
  56. package/dist/hooks/message-sanitize.js +37 -0
  57. package/dist/hooks/pain.js +105 -5
  58. package/dist/hooks/prompt.d.ts +20 -11
  59. package/dist/hooks/prompt.js +558 -158
  60. package/dist/hooks/subagent.d.ts +9 -2
  61. package/dist/hooks/subagent.js +40 -3
  62. package/dist/http/principles-console-route.d.ts +2 -0
  63. package/dist/http/principles-console-route.js +257 -0
  64. package/dist/i18n/commands.js +48 -20
  65. package/dist/index.js +264 -8
  66. package/dist/service/control-ui-query-service.d.ts +217 -0
  67. package/dist/service/control-ui-query-service.js +537 -0
  68. package/dist/service/empathy-observer-manager.d.ts +42 -0
  69. package/dist/service/empathy-observer-manager.js +147 -0
  70. package/dist/service/evolution-worker.d.ts +10 -0
  71. package/dist/service/evolution-worker.js +156 -24
  72. package/dist/service/trajectory-service.d.ts +2 -0
  73. package/dist/service/trajectory-service.js +15 -0
  74. package/dist/tools/agent-spawn.d.ts +27 -6
  75. package/dist/tools/agent-spawn.js +339 -87
  76. package/dist/tools/deep-reflect.d.ts +27 -7
  77. package/dist/tools/deep-reflect.js +282 -113
  78. package/dist/types/event-types.d.ts +84 -2
  79. package/dist/types/event-types.js +33 -0
  80. package/dist/types.d.ts +52 -0
  81. package/dist/types.js +24 -1
  82. package/openclaw.plugin.json +43 -11
  83. package/package.json +16 -6
  84. package/templates/langs/zh/core/HEARTBEAT.md +28 -4
  85. package/templates/langs/zh/skills/pd-daily/SKILL.md +97 -13
  86. package/templates/pain_settings.json +54 -2
  87. package/templates/workspace/.principles/PROFILE.json +2 -0
  88. package/templates/workspace/okr/CURRENT_FOCUS.md +57 -0
package/dist/hooks/llm.js CHANGED
@@ -1,9 +1,180 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { trackLlmOutput, recordThinkingCheckpoint } from '../core/session-tracker.js';
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';
10
+ const empathyDedupState = new Map();
11
+ const empathyRateState = new Map();
12
+ function clamp(value, min, max) {
13
+ return Math.max(min, Math.min(max, value));
14
+ }
15
+ function normalizeSeverity(input) {
16
+ const normalized = (input || '').toLowerCase();
17
+ if (normalized === 'severe' || normalized === 'high')
18
+ return 'severe';
19
+ if (normalized === 'moderate' || normalized === 'medium')
20
+ return 'moderate';
21
+ return 'mild';
22
+ }
23
+ function parseConfidence(raw) {
24
+ const parsed = Number(raw);
25
+ if (!Number.isFinite(parsed))
26
+ return 1;
27
+ return clamp(parsed, 0, 1);
28
+ }
29
+ function parseTrustedLegacyTag(text) {
30
+ return text.match(/^\s*\[EMOTIONAL_DAMAGE_DETECTED(?::(mild|moderate|severe))?\]\s*$/i);
31
+ }
32
+ /**
33
+ * 检测标签是否是被用户诱导/引用输出的(回显),而非 LLM 主动输出的情绪信号
34
+ */
35
+ function isEchoedTag(text, tagMatch) {
36
+ const tagIndex = tagMatch.index ?? 0;
37
+ const before = text.substring(Math.max(0, tagIndex - 100), tagIndex).toLowerCase();
38
+ // 1. 检查是否在引号内(用户引用)
39
+ const quotesBefore = (before.match(/["'\u300c\u300d\u201c\u201d`]/g) || []).length;
40
+ if (quotesBefore % 2 === 1)
41
+ return true;
42
+ // 2. Strong patterns: 用户指令关键词(任意位置匹配)
43
+ const strongPatterns = [
44
+ /用户(说|让|要求|让我输出)/,
45
+ /user\s+(said|asked|told|wants)\s+me\s+to\s+(output|write|say)/,
46
+ /请(输出|包含|显示).*\[emotional/,
47
+ /please\s+(output|include).*\[emotional/,
48
+ /你让我输出/,
49
+ ];
50
+ for (const pattern of strongPatterns) {
51
+ if (pattern.test(before))
52
+ return true;
53
+ }
54
+ // 3. Weak patterns: 仅在标签 15 字符内触发
55
+ const weakPatterns = [
56
+ { pattern: /echo/, window: 15 },
57
+ { pattern: /copy/, window: 15 },
58
+ { pattern: /复述/, window: 15 },
59
+ ];
60
+ for (const { pattern, window } of weakPatterns) {
61
+ const nearTag = text.substring(Math.max(0, tagIndex - window), tagIndex).toLowerCase();
62
+ if (pattern.test(nearTag))
63
+ return true;
64
+ }
65
+ // 4. 检查是否在代码块内
66
+ const codeBlocksBefore = (before.match(/```/g) || []).length;
67
+ if (codeBlocksBefore % 2 === 1)
68
+ return true;
69
+ return false;
70
+ }
71
+ export function extractEmpathySignal(text) {
72
+ if (!text || typeof text !== 'string') {
73
+ return { detected: false, severity: 'mild', confidence: 1 };
74
+ }
75
+ const xmlMatch = text.match(/<empathy\s+([^>]*)\/?>(?:<\/empathy>)?/i);
76
+ if (xmlMatch?.[1]) {
77
+ const attrs = xmlMatch[1];
78
+ const signal = attrs.match(/signal\s*=\s*"([^"]+)"/i)?.[1]?.toLowerCase();
79
+ if (signal === 'damage' || signal === 'pain' || signal === 'frustration') {
80
+ const severity = normalizeSeverity(attrs.match(/severity\s*=\s*"([^"]+)"/i)?.[1]);
81
+ const confidence = parseConfidence(attrs.match(/confidence\s*=\s*"([^"]+)"/i)?.[1]);
82
+ const reason = attrs.match(/reason\s*=\s*"([^"]+)"/i)?.[1];
83
+ return { detected: true, severity, confidence, reason, mode: 'structured' };
84
+ }
85
+ }
86
+ const jsonMatch = text.match(/"empathy"\s*:\s*\{[\s\S]*?\}/i);
87
+ if (jsonMatch) {
88
+ const jsonText = `{${jsonMatch[0]}}`;
89
+ try {
90
+ const parsed = JSON.parse(jsonText);
91
+ if (parsed.empathy?.damageDetected === true) {
92
+ return {
93
+ detected: true,
94
+ severity: normalizeSeverity(parsed.empathy.severity),
95
+ confidence: clamp(Number(parsed.empathy.confidence ?? 1), 0, 1),
96
+ reason: parsed.empathy.reason,
97
+ mode: 'structured'
98
+ };
99
+ }
100
+ }
101
+ catch {
102
+ // ignore malformed snippet
103
+ }
104
+ }
105
+ const tagMatch = parseTrustedLegacyTag(text);
106
+ if (tagMatch) {
107
+ if (isEchoedTag(text, tagMatch)) {
108
+ return { detected: false, severity: 'mild', confidence: 1 };
109
+ }
110
+ return {
111
+ detected: true,
112
+ severity: normalizeSeverity(tagMatch[1]),
113
+ confidence: 1,
114
+ mode: 'legacy_tag'
115
+ };
116
+ }
117
+ return { detected: false, severity: 'mild', confidence: 1 };
118
+ }
119
+ function mapSeverityToPenalty(severity, config) {
120
+ const mild = Number(config.get('empathy_engine.penalties.mild') ?? 10);
121
+ const moderate = Number(config.get('empathy_engine.penalties.moderate') ?? 25);
122
+ const severe = Number(config.get('empathy_engine.penalties.severe') ?? 40);
123
+ if (severity === 'severe')
124
+ return severe;
125
+ if (severity === 'moderate')
126
+ return moderate;
127
+ return mild;
128
+ }
129
+ function dedupeKey(sessionId, runId, signal) {
130
+ return `${sessionId}:${runId}:${signal.severity}:${(signal.reason || '').slice(0, 80)}`;
131
+ }
132
+ function shouldDedupe(sessionId, runId, signal, windowMs) {
133
+ const key = dedupeKey(sessionId, runId, signal);
134
+ const now = Date.now();
135
+ const last = empathyDedupState.get(key);
136
+ if (typeof last === 'number' && now - last <= windowMs) {
137
+ return true;
138
+ }
139
+ empathyDedupState.set(key, now);
140
+ return false;
141
+ }
142
+ function resolveCalibrationFactor(event, config) {
143
+ const table = config.get('empathy_engine.model_calibration');
144
+ if (!table || typeof table !== 'object')
145
+ return 1;
146
+ const modelKey = `${event.provider}/${event.model}`;
147
+ const factor = Number(table[modelKey] ?? 1);
148
+ if (!Number.isFinite(factor))
149
+ return 1;
150
+ return clamp(factor, 0.1, 3);
151
+ }
152
+ function applyRateLimit(sessionId, runId, score, config) {
153
+ const maxPerTurn = Number(config.get('empathy_engine.rate_limit.max_per_turn') ?? 40);
154
+ const maxPerHour = Number(config.get('empathy_engine.rate_limit.max_per_hour') ?? 120);
155
+ const now = Date.now();
156
+ const prev = empathyRateState.get(sessionId) ?? {
157
+ turnScore: 0,
158
+ hourScore: 0,
159
+ hourWindowStart: now,
160
+ lastRunId: runId,
161
+ };
162
+ if (prev.lastRunId !== runId) {
163
+ prev.turnScore = 0;
164
+ prev.lastRunId = runId;
165
+ }
166
+ if (now - prev.hourWindowStart >= 60 * 60 * 1000) {
167
+ prev.hourScore = 0;
168
+ prev.hourWindowStart = now;
169
+ }
170
+ const byTurn = Math.max(0, maxPerTurn - prev.turnScore);
171
+ const byHour = Math.max(0, maxPerHour - prev.hourScore);
172
+ const allowed = Math.max(0, Math.min(score, byTurn, byHour));
173
+ prev.turnScore += allowed;
174
+ prev.hourScore += allowed;
175
+ empathyRateState.set(sessionId, prev);
176
+ return allowed;
177
+ }
7
178
  export function handleLlmOutput(event, ctx) {
8
179
  if (!ctx.workspaceDir || !ctx.sessionId)
9
180
  return;
@@ -16,6 +187,25 @@ export function handleLlmOutput(event, ctx) {
16
187
  if (!event.assistantTexts || event.assistantTexts.length === 0)
17
188
  return;
18
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
+ }
19
209
  // ── Track B: Semantic Pain Detection (V1.3.0 Funnel) ──
20
210
  const detectionService = DetectionService.get(wctx.stateDir);
21
211
  const detection = detectionService.detect(text);
@@ -34,6 +224,84 @@ export function handleLlmOutput(event, ctx) {
34
224
  let matchedReason = detection.detected
35
225
  ? `Agent triggered pain detection (Source: ${detection.source}${detection.ruleId ? `, Rule: ${detection.ruleId}` : ''})`
36
226
  : '';
227
+ // empathy sub-pipeline (enabled by default)
228
+ const empathyEnabled = config.get('empathy_engine.enabled');
229
+ if (empathyEnabled !== false) {
230
+ if (signal.detected) {
231
+ const dedupeWindow = Number(config.get('empathy_engine.dedupe_window_ms') ?? 60000);
232
+ const deduped = shouldDedupe(ctx.sessionId, event.runId, signal, dedupeWindow);
233
+ if (!deduped) {
234
+ const baseScore = mapSeverityToPenalty(signal.severity, config);
235
+ const weightedScore = Math.round(baseScore * signal.confidence);
236
+ const calibrationFactor = resolveCalibrationFactor(event, config);
237
+ const calibratedScore = Math.round(weightedScore * calibrationFactor);
238
+ const boundedScore = applyRateLimit(ctx.sessionId, event.runId, calibratedScore, config);
239
+ if (boundedScore > 0) {
240
+ trackFriction(ctx.sessionId, boundedScore, `user_empathy_${signal.severity}`, ctx.workspaceDir);
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
+ }
255
+ // Generate unique event ID for rollback support
256
+ const eventId = `emp_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
257
+ eventLog.recordPainSignal(ctx.sessionId, {
258
+ score: boundedScore,
259
+ source: 'user_empathy',
260
+ reason: signal.reason || 'Assistant self-reported user emotional distress.',
261
+ isRisky: false,
262
+ origin: 'assistant_self_report',
263
+ severity: signal.severity,
264
+ confidence: signal.confidence,
265
+ detection_mode: signal.mode,
266
+ deduped: false,
267
+ trigger_text_excerpt: text.substring(0, 120),
268
+ raw_score: weightedScore,
269
+ calibrated_score: calibratedScore,
270
+ eventId,
271
+ });
272
+ }
273
+ }
274
+ else {
275
+ eventLog.recordPainSignal(ctx.sessionId, {
276
+ score: 0,
277
+ source: 'user_empathy',
278
+ reason: signal.reason || 'Deduped empathy signal.',
279
+ isRisky: false,
280
+ origin: 'assistant_self_report',
281
+ severity: signal.severity,
282
+ confidence: signal.confidence,
283
+ detection_mode: signal.mode,
284
+ deduped: true,
285
+ trigger_text_excerpt: text.substring(0, 120),
286
+ raw_score: Math.round(mapSeverityToPenalty(signal.severity, config) * signal.confidence),
287
+ calibrated_score: Math.round(mapSeverityToPenalty(signal.severity, config) * signal.confidence * resolveCalibrationFactor(event, config))
288
+ });
289
+ }
290
+ }
291
+ }
292
+ // ═══ Natural Language Rollback Detection ═══
293
+ // Detect [EMPATHY_ROLLBACK_REQUEST] tag and trigger rollback
294
+ const rollbackMatch = text.match(/^\s*\[EMPATHY_ROLLBACK_REQUEST\]\s*$/m);
295
+ if (rollbackMatch) {
296
+ const eventId = eventLog.getLastEmpathyEventId(ctx.sessionId);
297
+ if (eventId) {
298
+ const rolledBackScore = eventLog.rollbackEmpathyEvent(eventId, ctx.sessionId, 'Natural language rollback request detected', 'natural_language');
299
+ if (rolledBackScore > 0) {
300
+ // Reset GFI after successful rollback
301
+ resetFriction(ctx.sessionId);
302
+ }
303
+ }
304
+ }
37
305
  // 3. Paralysis Check (from session state tracker)
38
306
  const stuckThreshold = config.get('thresholds.stuck_loops_trigger') || 3;
39
307
  const inputThreshold = config.get('thresholds.cognitive_paralysis_input') || 4000;
@@ -64,56 +332,18 @@ export function handleLlmOutput(event, ctx) {
64
332
  });
65
333
  }
66
334
  // ═══ Thinking OS: Mental Model Usage Tracking ═══
67
- trackThinkingModelUsage(text, wctx, ctx.sessionId);
335
+ trackThinkingModelUsage({
336
+ text,
337
+ wctx,
338
+ sessionId: ctx.sessionId,
339
+ runId: event.runId,
340
+ assistantTurnId,
341
+ createdAt,
342
+ logger: ctx.logger,
343
+ });
68
344
  }
69
- const THINKING_MODEL_SIGNALS = {
70
- 'T-01': [
71
- /let me (first )?(understand|map|outline|survey|review the (structure|architecture|dependencies))/i,
72
- /让我先(梳理|了解|画出|理解|查看)(一下)?(结构|架构|依赖|全貌)/,
73
- ],
74
- 'T-02': [
75
- /(type|test|contract|schema|interface) (constraint|requirement|check|validation)/i,
76
- /we (must|need to) (respect|follow|adhere to) the/i,
77
- /(必须|需要).*?(遵守|符合|满足).*?(类型|测试|契约|接口|规范)/,
78
- ],
79
- 'T-03': [
80
- /based on (the |this )?(evidence|logs?|output|error|stack trace|test result)/i,
81
- /let me (check|verify|confirm|read|look at) (the |)(actual|source|code|file|log)/i,
82
- /根据(日志|证据|输出|报错|堆栈|测试结果)/,
83
- ],
84
- 'T-04': [
85
- /this (is|would be) (irreversible|destructive|permanent|not easily undone)/i,
86
- /(reversible|can be undone|safely roll back)/i,
87
- /(不可逆|破坏性|永久的|无法回滚|可以回滚|安全地撤销)/,
88
- ],
89
- 'T-05': [
90
- /we (must|should) (not|never|avoid|prevent|ensure we don't)/i,
91
- /(critical|important) (not to|that we don't|to avoid)/i,
92
- /(绝不能|必须避免|不可以|禁止|确保不会)/,
93
- ],
94
- 'T-06': [
95
- /(simpl(er|est|ify)|minimal|straightforward|lean) (approach|solution|fix|implementation)/i,
96
- /(simple is better|keep it simple|no need to over)/i,
97
- /(最简(单|洁)|精简|没有必要(过度|额外))/,
98
- ],
99
- 'T-07': [
100
- /(minimal|smallest|narrowest|least) (change|diff|modification|impact)/i,
101
- /only (change|modify|touch|edit) (the |what)/i,
102
- /(最小(改动|变更|修改)|只(改|动|修))/,
103
- ],
104
- 'T-08': [
105
- /this (error|failure|issue) (tells us|indicates|signals|suggests|means)/i,
106
- /let me (stop|pause|step back|reconsider|rethink)/i,
107
- /这个(错误|失败|问题)(告诉我们|表明|说明|意味)/,
108
- /让我(停下|暂停|退一步|重新(考虑|思考|审视))/,
109
- ],
110
- 'T-09': [
111
- /(break|split|decompose|divide) (this |the task |it )?(into|down)/i,
112
- /(step 1|first,? (we|i|let's)|phase 1)/i,
113
- /(拆分|分解|分步|分阶段|第一步)/,
114
- ],
115
- };
116
- function trackThinkingModelUsage(text, wctx, sessionId) {
345
+ function trackThinkingModelUsage(args) {
346
+ const { text, wctx, sessionId, runId, assistantTurnId, createdAt, logger } = args;
117
347
  const logPath = wctx.resolve('THINKING_OS_USAGE');
118
348
  const logDir = path.dirname(logPath);
119
349
  if (!fs.existsSync(logDir))
@@ -127,27 +357,75 @@ function trackThinkingModelUsage(text, wctx, sessionId) {
127
357
  console.error(`[PD:LLM] Failed to parse thinking OS usage log: ${String(e)}`);
128
358
  }
129
359
  }
130
- let anyMatch = false;
131
- for (const [modelId, patterns] of Object.entries(THINKING_MODEL_SIGNALS)) {
132
- for (const pattern of patterns) {
133
- if (pattern.test(text)) {
134
- usageLog[modelId] = (usageLog[modelId] || 0) + 1;
135
- anyMatch = true;
136
- break;
137
- }
138
- }
360
+ const matches = detectThinkingModelMatches(text);
361
+ for (const match of matches) {
362
+ usageLog[match.modelId] = (usageLog[match.modelId] || 0) + 1;
139
363
  }
140
364
  usageLog['_total_turns'] = (usageLog['_total_turns'] || 0) + 1;
141
- if (anyMatch) {
142
- // Record thinking checkpoint for gate enforcement
143
- if (sessionId) {
144
- recordThinkingCheckpoint(sessionId, wctx.workspaceDir);
145
- }
146
- try {
147
- fs.writeFileSync(logPath, JSON.stringify(usageLog, null, 2), 'utf8');
148
- }
149
- catch (e) {
150
- console.error(`[PD:LLM] Failed to write thinking OS usage log: ${String(e)}`);
365
+ try {
366
+ fs.writeFileSync(logPath, JSON.stringify(usageLog, null, 2), 'utf8');
367
+ }
368
+ catch (e) {
369
+ console.error(`[PD:LLM] Failed to write thinking OS usage log: ${String(e)}`);
370
+ }
371
+ if (matches.length === 0) {
372
+ return;
373
+ }
374
+ if (sessionId) {
375
+ recordThinkingCheckpoint(sessionId, wctx.workspaceDir);
376
+ }
377
+ if (!sessionId || !assistantTurnId) {
378
+ return;
379
+ }
380
+ const uiDb = new ControlUiDatabase({ workspaceDir: wctx.workspaceDir });
381
+ try {
382
+ const recentContext = uiDb.getRecentThinkingContext(sessionId, createdAt);
383
+ const toolContext = recentContext.toolCalls.map((call) => ({
384
+ toolName: call.toolName,
385
+ outcome: call.outcome,
386
+ errorType: call.errorType,
387
+ }));
388
+ const painContext = recentContext.painEvents.map((event) => ({
389
+ source: event.source,
390
+ score: event.score,
391
+ }));
392
+ const principleContext = recentContext.principleEvents.map((event) => ({
393
+ principleId: event.principleId,
394
+ eventType: event.eventType,
395
+ }));
396
+ const triggerExcerpt = text.length > 280 ? `${text.slice(0, 277)}...` : text;
397
+ for (const match of matches) {
398
+ const scenarios = deriveThinkingScenarios(match.modelId, {
399
+ recentToolCalls: toolContext,
400
+ recentPainEvents: painContext,
401
+ recentGateBlocks: recentContext.gateBlocks.map((block) => ({
402
+ toolName: block.toolName,
403
+ reason: block.reason,
404
+ })),
405
+ recentUserCorrections: recentContext.userCorrections.map((correction) => ({
406
+ correctionCue: correction.correctionCue,
407
+ })),
408
+ recentPrincipleEvents: principleContext,
409
+ });
410
+ uiDb.recordThinkingModelEvent({
411
+ sessionId,
412
+ runId,
413
+ assistantTurnId,
414
+ modelId: match.modelId,
415
+ matchedPattern: match.matchedPattern,
416
+ scenarioJson: scenarios,
417
+ toolContextJson: toolContext,
418
+ painContextJson: painContext,
419
+ principleContextJson: principleContext,
420
+ triggerExcerpt,
421
+ createdAt,
422
+ });
151
423
  }
152
424
  }
425
+ catch (error) {
426
+ logger?.warn?.(`[PD:LLM] Failed to persist thinking model events: ${String(error)}`);
427
+ }
428
+ finally {
429
+ uiDb.dispose();
430
+ }
153
431
  }
@@ -0,0 +1,3 @@
1
+ import type { PluginHookBeforeMessageWriteEvent, PluginHookBeforeMessageWriteResult } from '../openclaw-sdk.js';
2
+ export declare function sanitizeAssistantText(text: string): string;
3
+ export declare function handleBeforeMessageWrite(event: PluginHookBeforeMessageWriteEvent): PluginHookBeforeMessageWriteResult | void;
@@ -0,0 +1,37 @@
1
+ const INTERNAL_TAG_PATTERNS = [
2
+ /\[EMOTIONAL_DAMAGE_DETECTED(?::(?:mild|moderate|severe))?\]/gi,
3
+ /\[EMPATHY_ROLLBACK_REQUEST\]/gi,
4
+ /<empathy\s+[^>]*\/?>(?:<\/empathy>)?/gi,
5
+ ];
6
+ export function sanitizeAssistantText(text) {
7
+ let result = text;
8
+ for (const pattern of INTERNAL_TAG_PATTERNS) {
9
+ result = result.replace(pattern, '');
10
+ }
11
+ return result
12
+ .replace(/[ \t]+\n/g, '\n')
13
+ .replace(/\n{3,}/g, '\n\n')
14
+ .trim();
15
+ }
16
+ export function handleBeforeMessageWrite(event) {
17
+ const msg = event.message;
18
+ if (!msg || msg.role !== 'assistant')
19
+ return;
20
+ if (typeof msg.content === 'string') {
21
+ const sanitized = sanitizeAssistantText(msg.content);
22
+ if (sanitized !== msg.content) {
23
+ return { message: { ...msg, content: sanitized } };
24
+ }
25
+ return;
26
+ }
27
+ if (Array.isArray(msg.content)) {
28
+ const next = msg.content.map((part) => {
29
+ if (part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string') {
30
+ return { ...part, text: sanitizeAssistantText(part.text) };
31
+ }
32
+ return part;
33
+ });
34
+ return { message: { ...msg, content: next } };
35
+ }
36
+ return;
37
+ }
@@ -2,11 +2,25 @@ import * as fs from 'fs';
2
2
  import { isRisky, normalizePath } from '../utils/io.js';
3
3
  import { normalizeProfile } from '../core/profile.js';
4
4
  import { computePainScore, writePainFlag } from '../core/pain.js';
5
- import { trackFriction, resetFriction } from '../core/session-tracker.js';
5
+ import { getSession, trackFriction, resetFriction, getInjectedProbationIds, clearInjectedProbationIds } from '../core/session-tracker.js';
6
6
  import { denoiseError, computeHash } from '../utils/hashing.js';
7
7
  import { SystemLogger } from '../core/system-logger.js';
8
8
  import { WorkspaceContext } from '../core/workspace-context.js';
9
9
  const WRITE_TOOLS = ['write', 'edit', 'apply_patch', 'write_file', 'edit_file', 'replace'];
10
+ function shouldAttributePrincipleToTool(principle, toolName) {
11
+ return principle.contextTags.includes(toolName) || principle.trigger.includes(toolName);
12
+ }
13
+ function emitPainDetectedEvent(wctx, event) {
14
+ try {
15
+ wctx.evolutionReducer.emitSync(event);
16
+ }
17
+ catch (e) {
18
+ SystemLogger.log(wctx.workspaceDir, 'EVOLUTION_EMIT_WARN', `Failed to emit evolution event: ${String(e)}`);
19
+ }
20
+ }
21
+ function createPainId(sessionId) {
22
+ return `pain_${Date.now()}_${computeHash(sessionId).slice(0, 8)}`;
23
+ }
10
24
  export function handleAfterToolCall(event, ctx, api) {
11
25
  const effectiveWorkspaceDir = ctx.workspaceDir || api?.workspaceDir || api?.resolvePath?.('.');
12
26
  if (!effectiveWorkspaceDir) {
@@ -17,6 +31,8 @@ export function handleAfterToolCall(event, ctx, api) {
17
31
  const eventLog = wctx.eventLog;
18
32
  const trust = wctx.trust;
19
33
  const sessionId = ctx.sessionId || 'unknown';
34
+ const sessionState = ctx.sessionId ? getSession(ctx.sessionId) : undefined;
35
+ const gfiBefore = sessionState?.currentGfi ?? 0;
20
36
  const params = event.params;
21
37
  // ── Track A: Empirical Friction (GFI) ──
22
38
  // 0. Special Case: Manual Pain Intervention
@@ -30,6 +46,25 @@ export function handleAfterToolCall(event, ctx, api) {
30
46
  reason: `User intervention: ${reason}`,
31
47
  isRisky: true
32
48
  });
49
+ wctx.trajectory?.recordPainEvent?.({
50
+ sessionId,
51
+ source: 'manual',
52
+ score: 100,
53
+ reason: `User intervention: ${reason}`,
54
+ origin: 'user_manual',
55
+ });
56
+ emitPainDetectedEvent(wctx, {
57
+ ts: new Date().toISOString(),
58
+ type: 'pain_detected',
59
+ data: {
60
+ painId: createPainId(sessionId),
61
+ painType: 'user_frustration',
62
+ source: event.toolName,
63
+ reason: `User intervention: ${reason}`,
64
+ score: 100,
65
+ sessionId,
66
+ },
67
+ });
33
68
  return;
34
69
  }
35
70
  // 1. Determine if this was a failure
@@ -52,13 +87,16 @@ export function handleAfterToolCall(event, ctx, api) {
52
87
  try {
53
88
  profile = normalizeProfile(JSON.parse(fs.readFileSync(profilePath, 'utf8')));
54
89
  }
55
- catch (_e) { }
90
+ catch (e) {
91
+ SystemLogger.log(effectiveWorkspaceDir, 'PROFILE_PARSE_WARN', `Failed to parse PROFILE.json: ${String(e)}`);
92
+ }
56
93
  }
57
94
  const isRisk = isRisky(relPath, profile.risk_paths);
58
95
  trust.recordFailure(isRisk ? 'risky' : 'tool', {
59
96
  sessionId,
60
97
  api,
61
- toolName: event.toolName // 👈 NEW: Pass toolName for classification
98
+ toolName: event.toolName,
99
+ error: event.error // Pass error for timeout detection
62
100
  });
63
101
  // Record tool call failure event
64
102
  eventLog.recordToolCall(sessionId, {
@@ -70,16 +108,56 @@ export function handleAfterToolCall(event, ctx, api) {
70
108
  consecutiveErrors: updatedState.consecutiveErrors,
71
109
  exitCode,
72
110
  });
111
+ wctx.trajectory?.recordToolCall?.({
112
+ sessionId,
113
+ toolName: event.toolName,
114
+ outcome: 'failure',
115
+ durationMs: event.durationMs,
116
+ exitCode,
117
+ errorType,
118
+ errorMessage: event.error ? String(event.error) : undefined,
119
+ gfiBefore,
120
+ gfiAfter: updatedState.currentGfi,
121
+ paramsJson: event.params,
122
+ });
123
+ const injectedProbationIds = getInjectedProbationIds(sessionId, effectiveWorkspaceDir);
124
+ for (const id of injectedProbationIds) {
125
+ const principle = wctx.evolutionReducer.getPrincipleById(id);
126
+ const shouldAttribute = !!principle && shouldAttributePrincipleToTool(principle, event.toolName);
127
+ if (shouldAttribute) {
128
+ wctx.evolutionReducer.recordProbationFeedback(id, false);
129
+ }
130
+ }
131
+ clearInjectedProbationIds(sessionId, effectiveWorkspaceDir);
73
132
  }
74
133
  else {
75
134
  // ── SUCCESS BRANCH ──
76
- resetFriction(sessionId, effectiveWorkspaceDir);
135
+ const resetState = resetFriction(sessionId, effectiveWorkspaceDir);
77
136
  // 👈 Record success to reset failure streak and earn minor trust (if constructive)
78
137
  trust.recordSuccess('tool_success', {
79
138
  sessionId,
80
139
  api,
81
140
  toolName: event.toolName // 👈 NEW: Pass toolName for classification
82
141
  });
142
+ const injectedProbationIds = getInjectedProbationIds(sessionId, effectiveWorkspaceDir);
143
+ for (const id of injectedProbationIds) {
144
+ const principle = wctx.evolutionReducer.getPrincipleById(id);
145
+ const shouldAttribute = !!principle && shouldAttributePrincipleToTool(principle, event.toolName);
146
+ if (shouldAttribute) {
147
+ wctx.evolutionReducer.recordProbationFeedback(id, true);
148
+ }
149
+ }
150
+ clearInjectedProbationIds(sessionId, effectiveWorkspaceDir);
151
+ wctx.trajectory?.recordToolCall?.({
152
+ sessionId,
153
+ toolName: event.toolName,
154
+ outcome: 'success',
155
+ durationMs: event.durationMs,
156
+ exitCode,
157
+ gfiBefore,
158
+ gfiAfter: resetState.currentGfi,
159
+ paramsJson: event.params,
160
+ });
83
161
  if (WRITE_TOOLS.includes(event.toolName)) {
84
162
  const filePath = params.file_path || params.path || params.file;
85
163
  eventLog.recordToolCall(sessionId, {
@@ -128,7 +206,9 @@ export function handleAfterToolCall(event, ctx, api) {
128
206
  try {
129
207
  profile = normalizeProfile(JSON.parse(fs.readFileSync(profilePath, 'utf8')));
130
208
  }
131
- catch (_e) { }
209
+ catch (e) {
210
+ SystemLogger.log(effectiveWorkspaceDir, 'PROFILE_PARSE_WARN', `Failed to parse PROFILE.json: ${String(e)}`);
211
+ }
132
212
  }
133
213
  const isRisk = isRisky(relPath, profile.risk_paths);
134
214
  const painScore = computePainScore(1, false, false, isRisk ? 20 : 0, effectiveWorkspaceDir);
@@ -146,6 +226,26 @@ export function handleAfterToolCall(event, ctx, api) {
146
226
  reason: `Tool ${event.toolName} failed on ${relPath}`,
147
227
  isRisky: isRisk,
148
228
  });
229
+ wctx.trajectory?.recordPainEvent?.({
230
+ sessionId,
231
+ source: 'tool_failure',
232
+ score: painScore,
233
+ reason: `Tool ${event.toolName} failed on ${relPath}`,
234
+ severity: painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild',
235
+ origin: 'system_infer',
236
+ });
237
+ emitPainDetectedEvent(wctx, {
238
+ ts: new Date().toISOString(),
239
+ type: 'pain_detected',
240
+ data: {
241
+ painId: createPainId(sessionId),
242
+ painType: 'tool_failure',
243
+ source: event.toolName,
244
+ reason: `Tool ${event.toolName} failed on ${relPath}`,
245
+ score: painScore,
246
+ sessionId,
247
+ },
248
+ });
149
249
  }
150
250
  function extractErrorType(error) {
151
251
  if (!error)