principles-disciple 1.92.0 → 1.94.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.
package/src/hooks/pain.ts CHANGED
@@ -1,42 +1,52 @@
1
+ /**
2
+ * Pain Hook — PRI-326 decomposed
3
+ *
4
+ * After-tool-call hook that captures tool failures and emits pain signals.
5
+ *
6
+ * Pipeline stages (delegated to after-tool-call-helpers):
7
+ * 1. classifyToolCallOutcome — determine failure/success + source
8
+ * 2. buildToolCallObservation — normalize event into observation
9
+ * 3. handleFrictionTracking — GFI, event log, trajectory recording
10
+ * 4. handleProbationFeedback — probation attribution + cleanup
11
+ * 5. evaluatePainAdmission — triage + gate evaluation
12
+ * 6. emitPainIfAdmitted — pain signal emission
13
+ *
14
+ * The manual pain path (toolName === 'pain') remains inline because it
15
+ * has a different control flow (early return, no triage, no gate on cooldown).
16
+ */
17
+
1
18
  import * as fs from 'fs';
2
- import { isRisky, normalizePath } from '../utils/io.js';
3
19
  import { normalizeProfile } from '../core/profile.js';
4
- import { computePainScore, trackPrincipleValue } from '../core/pain.js';
5
- import { getSession, trackFriction, resetFriction, getInjectedProbationIds, clearInjectedProbationIds, type SessionState } from '../core/session-tracker.js';
6
- import { denoiseError, computeHash } from '../utils/hashing.js';
20
+ import { getSession, trackFriction } from '../core/session-tracker.js';
21
+ import { computeHash } from '../utils/hashing.js';
7
22
  import { SystemLogger } from '../core/system-logger.js';
8
23
  import { WorkspaceContext } from '../core/workspace-context.js';
9
24
  import { getEvolutionLogger, createTraceId } from '../core/evolution-logger.js';
10
- import { recordEvolutionSuccess, recordEvolutionFailure } from '../core/evolution-engine.js';
11
25
  import type { EvolutionLoopEvent } from '../core/evolution-types.js';
12
26
  import type { PluginHookAfterToolCallEvent, PluginHookToolContext, OpenClawPluginApi } from '../openclaw-sdk.js';
13
27
  import { resolveWorkspaceDirForRuntimeV2 } from '../utils/workspace-resolver.js';
14
- import { PainToPrincipleService, PrincipleTreeLedgerAdapter, type PainDetectedData, type PainEvidenceEntry, MAX_EVIDENCE_ENTRIES, MAX_EVIDENCE_NOTE_CHARS } from '@principles/core/runtime-v2';
28
+ import { PainToPrincipleService, PrincipleTreeLedgerAdapter, type PainDetectedData } from '@principles/core/runtime-v2';
15
29
  import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
16
- import { sanitizeAssistantText, sanitizeForEvidence, sanitizeToolParamsForEvidence } from './message-sanitize.js';
17
30
  import { loadPdConfigForPlugin } from '../core/pd-config-loader.js';
18
31
 
19
- /**
20
- * Interface for tool parameters to avoid 'any'
21
- */
22
- interface ToolParams {
23
- file_path?: string;
24
- path?: string;
25
- file?: string;
26
- content?: string;
27
- new_string?: string;
28
- text?: string;
29
- query?: string;
30
- input?: string;
31
- arguments?: string;
32
- }
32
+ import {
33
+ classifyToolCallOutcome,
34
+ buildToolCallObservation,
35
+ handleFrictionTrackingForFailure,
36
+ handleFrictionTrackingForSuccess,
37
+ recordHygieneTracking,
38
+ handleProbationFeedback,
39
+ evaluatePainAdmissionForToolCall,
40
+ emitPainIfAdmitted,
41
+ } from './after-tool-call-helpers.js';
42
+
43
+ import { buildTrajectoryEvidence } from './trajectory-evidence.js';
44
+ export { buildTrajectoryEvidence };
33
45
 
34
- const WRITE_TOOLS = ['write', 'edit', 'apply_patch', 'write_file', 'edit_file', 'replace'];
46
+ // ── Service Factory ─────────────────────────────────────────────────────────
35
47
 
36
48
  function createPainToPrincipleService(wctx: WorkspaceContext): PainToPrincipleService {
37
49
  const ledgerAdapter = new PrincipleTreeLedgerAdapter({ stateDir: wctx.stateDir });
38
- // PRI-306: Load .pd/config.yaml and pass effectiveConfig to PainToPrincipleService
39
- // so config-driven runtime binding resolution is used.
40
50
  const configResult = loadPdConfigForPlugin(wctx.workspaceDir);
41
51
  return new PainToPrincipleService({
42
52
  workspaceDir: wctx.workspaceDir,
@@ -49,64 +59,9 @@ function createPainToPrincipleService(wctx: WorkspaceContext): PainToPrincipleSe
49
59
  });
50
60
  }
51
61
 
52
- export function buildTrajectoryEvidence(wctx: WorkspaceContext, sessionId: string): PainEvidenceEntry[] {
53
- const evidence: PainEvidenceEntry[] = [];
54
-
55
- if (!wctx.trajectory || sessionId === 'unknown') {
56
- evidence.push({
57
- sourceRef: 'owner_message:unavailable',
58
- note: `trajectory_unavailable: ${!wctx.trajectory ? 'no_trajectory_db' : 'unknown_session'}`,
59
- });
60
- return evidence.slice(0, MAX_EVIDENCE_ENTRIES);
61
- }
62
-
63
- try {
64
- const userTurns = wctx.trajectory.listUserTurnsForSession(sessionId) ?? [];
65
- const lastCorrectionTurn = [...userTurns].reverse().find(t => t.correctionDetected);
66
- if (lastCorrectionTurn) {
67
- const sanitizedOwnerMessage = sanitizeAssistantText(
68
- (lastCorrectionTurn.rawExcerpt ?? '').slice(0, MAX_EVIDENCE_NOTE_CHARS)
69
- );
70
- evidence.push({
71
- sourceRef: `owner_message:${lastCorrectionTurn.createdAt}`,
72
- note: sanitizedOwnerMessage,
73
- });
74
- }
75
- } catch (e) {
76
- evidence.push({
77
- sourceRef: 'owner_message:unavailable',
78
- note: `trajectory_user_turns_unavailable: ${String(e).slice(0, 100)}`,
79
- });
80
- }
81
-
82
- try {
83
- const assistantTurns = wctx.trajectory.listAssistantTurns(sessionId) ?? [];
84
- const recentAssistant = assistantTurns.slice(-3);
85
- for (const turn of recentAssistant) {
86
- if (evidence.length >= MAX_EVIDENCE_ENTRIES) break;
87
- const sanitizedNote = sanitizeAssistantText(
88
- (turn.sanitizedText ?? '').slice(0, MAX_EVIDENCE_NOTE_CHARS)
89
- );
90
- evidence.push({
91
- sourceRef: `agent_turn:${turn.createdAt}`,
92
- note: sanitizedNote,
93
- });
94
- }
95
- } catch (e) {
96
- if (evidence.length < MAX_EVIDENCE_ENTRIES) {
97
- evidence.push({
98
- sourceRef: 'agent_turn:unavailable',
99
- note: `trajectory_assistant_turns_unavailable: ${String(e).slice(0, 100)}`,
100
- });
101
- }
102
- }
103
-
104
- return evidence.slice(0, MAX_EVIDENCE_ENTRIES);
105
- }
62
+ // buildTrajectoryEvidence is in ./trajectory-evidence.ts (re-exported above)
106
63
 
107
- function shouldAttributePrincipleToTool(principle: { contextTags: string[]; trigger: string; }, toolName: string): boolean {
108
- return principle.contextTags.includes(toolName) || principle.trigger.includes(toolName);
109
- }
64
+ // ── Pain Event Emission ─────────────────────────────────────────────────────
110
65
 
111
66
  export async function emitPainDetectedEvent(wctx: WorkspaceContext, event: EvolutionLoopEvent): Promise<void> {
112
67
  try {
@@ -159,39 +114,51 @@ function createPainId(sessionId: string): string {
159
114
  return `pain_${Date.now()}_${computeHash(sessionId).slice(0, 8)}`;
160
115
  }
161
116
 
162
- export function classifyToolFailureSource(toolName: string | undefined, error: unknown): 'dispatch_error' | 'tool_failure' {
163
- if (!toolName || toolName.trim() === '') return 'dispatch_error';
164
- const msg = String(error ?? '');
165
- // Dropped "error:" prefix to catch "failed: unknown tool read_file" style messages.
166
- // Catches: "tool not found", "tool <name> not found", "unknown tool".
167
- // Word-boundary anchors prevent "report_tool_not_found" from matching.
168
- if (/\btool\s+(?:\S+\s+)?not\s+found\b/i.test(msg)) return 'dispatch_error';
169
- if (/\bunknown\s+tool\b/i.test(msg)) return 'dispatch_error';
170
- return 'tool_failure';
171
- }
117
+ // ── Source Classification (re-exported from helpers) ────────────────────────
118
+
119
+ export { classifyToolFailureSource } from './after-tool-call-helpers.js';
172
120
 
121
+ // ── Main Hook ───────────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Handle after_tool_call hook — decomposed into pipeline stages.
125
+ *
126
+ * Pipeline: classify → record → triage → gate → emit
127
+ *
128
+ * Manual pain (toolName === 'pain') is handled inline with early return.
129
+ */
173
130
  export function handleAfterToolCall(
174
131
  event: PluginHookAfterToolCallEvent,
175
132
  ctx: PluginHookToolContext & { workspaceDir?: string; pluginConfig?: Record<string, unknown> },
176
133
  api?: OpenClawPluginApi
177
134
  ): void {
135
+ // ── Workspace Resolution ──
178
136
  let effectiveWorkspaceDir: string;
179
137
  try {
180
138
  effectiveWorkspaceDir = resolveWorkspaceDirForRuntimeV2(ctx, api, 'after_tool_call');
181
- } catch {
139
+ } catch (error) {
140
+ SystemLogger.log(
141
+ (ctx as any).workspaceDir ?? 'unknown',
142
+ 'WORKSPACE_RESOLUTION_FAILED',
143
+ JSON.stringify({
144
+ hook: 'after_tool_call',
145
+ sessionId: ctx.sessionId ?? 'unknown',
146
+ toolName: event.toolName,
147
+ reason: 'workspace_resolution_failed',
148
+ nextAction: 'check_plugin_config_workspace_resolution',
149
+ error: String(error).slice(0, 200),
150
+ }),
151
+ );
182
152
  return;
183
153
  }
184
154
 
185
155
  const wctx = WorkspaceContext.fromHookContextExplicit({ ...ctx, workspaceDir: effectiveWorkspaceDir });
186
- const {config} = wctx;
187
- const {eventLog} = wctx;
156
+ const { config } = wctx;
188
157
  const sessionId = ctx.sessionId || 'unknown';
189
158
  const sessionState = ctx.sessionId ? getSession(ctx.sessionId) : undefined;
190
159
  const gfiBefore = sessionState?.currentGfi ?? 0;
191
- let latestFailureState: SessionState | undefined;
192
- const params = event.params as ToolParams;
193
160
 
194
- // Load profile once (with 1MB size guard) — used by both failure and legacy risky-write paths
161
+ // ── Profile Loading (once per call) ──
195
162
  const profilePath = wctx.resolve('PROFILE');
196
163
  let profile = normalizeProfile({});
197
164
  if (fs.existsSync(profilePath)) {
@@ -207,409 +174,156 @@ export function handleAfterToolCall(
207
174
  }
208
175
  }
209
176
 
210
- // ── Track A: Empirical Friction (GFI) ──
211
-
212
- // 0. Special Case: Manual Pain Intervention
177
+ // ── Manual Pain Early Return ──
213
178
  if (event.toolName === 'pain' || event.toolName === 'skill:pain') {
214
- const reason = params.input || params.arguments || 'Manual intervention';
215
- const traceId = createTraceId();
216
- trackFriction(sessionId, 100, 'manual_pain', effectiveWorkspaceDir, { source: 'manual_pain' });
217
- SystemLogger.log(effectiveWorkspaceDir, 'MANUAL_PAIN', `User manually triggered pain: ${reason}`);
218
- eventLog.recordPainSignal(sessionId, {
219
- score: 100,
220
- source: 'manual',
221
- reason: `User intervention: ${reason}`,
222
- isRisky: true
223
- });
224
- wctx.trajectory?.recordPainEvent?.({
225
- sessionId,
226
- source: 'manual',
227
- score: 100,
228
- reason: `User intervention: ${reason}`,
229
- origin: 'user_manual',
230
- text: reason, // Store the intervention reason as text
231
- });
179
+ handleManualPain(event, ctx, wctx, effectiveWorkspaceDir, sessionId);
180
+ return;
181
+ }
232
182
 
233
- // Log to EvolutionLogger
234
- const evoLogger = getEvolutionLogger(effectiveWorkspaceDir, wctx.trajectory);
235
- evoLogger.logPainDetected({
236
- traceId,
237
- source: 'manual',
238
- reason: `User intervention: ${reason}`,
239
- score: 100,
240
- toolName: event.toolName,
241
- sessionId,
242
- });
183
+ // ── Stage 1: Classify ──
184
+ const outcome = classifyToolCallOutcome(event);
243
185
 
244
- // Apply PainDiagnosticGate with cooldown to prevent duplicate diagnoses
245
- const session = getSession(sessionId);
246
- const gate = evaluatePainDiagnosticGate({
247
- source: 'manual',
248
- score: 100,
249
- currentGfi: session?.currentGfi ?? 0,
250
- sessionId,
251
- });
252
- if (!gate.shouldDiagnose) {
253
- SystemLogger.log(effectiveWorkspaceDir, 'MANUAL_PAIN_SKIPPED', `Manual pain within cooldown: ${gate.detail}`);
254
- let payload: string;
255
- try {
256
- payload = JSON.stringify({
257
- reason: gate.reason,
258
- detail: gate.detail,
259
- source: 'manual',
260
- sessionId,
261
- gfi: 0,
262
- score: 100,
263
- });
264
- } catch (e) {
265
- SystemLogger.log(effectiveWorkspaceDir, 'PAYLOAD_SERIALIZE_FAILED', String(e));
266
- payload = JSON.stringify({ reason: gate.reason, detail: '(log serialization failed)' });
267
- }
268
- SystemLogger.log(effectiveWorkspaceDir, 'PAIN_GATE_REJECTED', payload);
269
- return;
270
- }
186
+ // ── Stage 2: Build Observation ──
187
+ const observation = buildToolCallObservation(event, outcome, effectiveWorkspaceDir, profile);
271
188
 
272
- emitPainDetectedEvent(wctx, {
273
- ts: new Date().toISOString(),
274
- type: 'pain_detected',
275
- data: {
276
- painId: createPainId(sessionId),
277
- painType: 'user_frustration',
278
- source: event.toolName,
279
- reason: `User intervention: ${reason}`,
280
- score: 100,
281
- sessionId,
282
- traceId,
283
- agentId: ctx.agentId,
284
- provenance: (sessionId && sessionId !== 'unknown') ? 'openclaw_context_bound' : 'owner_reported_no_host_trace',
285
- evidence: buildTrajectoryEvidence(wctx, sessionId),
286
- },
287
- });
288
- return;
289
- }
189
+ let latestFailureState: import('../core/session-tracker.js').SessionState | undefined;
290
190
 
291
- // 1. Determine if this was a failure
292
- // Support nested details structure where OpenClaw exec tool stores exitCode in result.details.exitCode
293
- // Prefer the first *numeric* exit code: if result.exitCode is non-numeric, fall back to details.exitCode
294
- const resultObj = (event.result && typeof event.result === 'object') ? event.result as Record<string, unknown> : null;
295
- const details = resultObj?.details && typeof resultObj.details === 'object' ? resultObj.details as Record<string, unknown> : null;
296
- const topExitCode = resultObj?.exitCode;
297
- const detailExitCode = details?.exitCode;
298
- const exitCode = typeof topExitCode === 'number' ? topExitCode
299
- : typeof detailExitCode === 'number' ? detailExitCode
300
- : 0;
301
- const isFailure = !!event.error || exitCode !== 0;
302
-
303
- if (isFailure) {
304
- const failureSource = classifyToolFailureSource(event.toolName, event.error);
305
- const errorText = String(event.error ?? (typeof event.result === 'string' ? event.result : JSON.stringify(event.result)));
306
- const denoised = denoiseError(errorText);
307
- const hash = computeHash(denoised);
308
-
309
- const deltaF = config.get('scores.tool_failure_friction') || 30;
310
- const updatedState = trackFriction(sessionId, deltaF, hash, effectiveWorkspaceDir, { source: failureSource });
311
- latestFailureState = updatedState;
312
-
313
- // ── Trust Engine: Record failure ──
314
-
315
-
316
- const errorType = extractErrorType(event.error || errorText);
317
- const filePath = params.file_path || params.path || params.file;
318
- const relPath = typeof filePath === 'string' ? normalizePath(filePath, effectiveWorkspaceDir) : 'unknown';
319
-
320
- // Use profile loaded at function scope (1MB guard already applied)
321
- const isRisk = isRisky(relPath, profile.risk_paths);
322
-
323
- recordEvolutionFailure(effectiveWorkspaceDir, event.toolName, {
324
- filePath: relPath,
325
- reason: isRisk ? 'risky' : 'tool',
326
- sessionId,
327
- });
328
-
329
- // Record tool call failure event
330
- eventLog.recordToolCall(sessionId, {
331
- toolName: event.toolName,
332
- filePath: typeof filePath === 'string' ? filePath : undefined,
333
- error: event.error ? String(event.error).substring(0, 200) : undefined,
334
- errorType,
335
- gfi: updatedState.currentGfi,
336
- consecutiveErrors: updatedState.consecutiveErrors,
337
- exitCode: exitCode as number | undefined,
338
- gfiBefore,
339
- gfiAfter: updatedState.currentGfi,
340
- });
341
- wctx.trajectory?.recordToolCall?.({
342
- sessionId,
343
- toolName: event.toolName,
344
- outcome: 'failure',
345
- durationMs: event.durationMs,
346
- exitCode: exitCode as number | undefined,
347
- errorType,
348
- errorMessage: event.error ? String(event.error) : undefined,
349
- gfiBefore,
350
- gfiAfter: updatedState.currentGfi,
351
- paramsJson: sanitizeToolParamsForEvidence(event.params, effectiveWorkspaceDir),
352
- });
353
-
354
- const injectedProbationIds = getInjectedProbationIds(sessionId, effectiveWorkspaceDir);
355
- for (const id of injectedProbationIds) {
356
- const principle = wctx.evolutionReducer.getPrincipleById(id);
357
- const shouldAttribute = !!principle && shouldAttributePrincipleToTool(principle, event.toolName);
358
- if (shouldAttribute) {
359
- wctx.evolutionReducer.recordProbationFeedback(id, false);
360
- }
361
- }
362
- clearInjectedProbationIds(sessionId, effectiveWorkspaceDir);
191
+ if (outcome.isFailure) {
192
+ // ── Stage 3a: Friction + Recording (Failure) ──
193
+ latestFailureState = handleFrictionTrackingForFailure(
194
+ sessionId, event, outcome, observation, gfiBefore, effectiveWorkspaceDir, config, wctx
195
+ );
196
+
197
+ // ── Stage 4: Probation Feedback (Failure) ──
198
+ handleProbationFeedback(sessionId, event.toolName, effectiveWorkspaceDir, wctx, false);
363
199
  } else {
364
- // ── SUCCESS BRANCH ──
365
- // PRI-80: Relieve both dispatch_error and tool_failure on success.
366
- // This prevents "read file success" from wiping dispatch error signals.
367
- const session = getSession(sessionId);
368
- const toolFailureGfi = session?.gfiBySource?.tool_failure || 0;
369
- const dispatchErrorGfi = session?.gfiBySource?.dispatch_error || 0;
370
-
371
- let resetState: SessionState = session || resetFriction(sessionId, effectiveWorkspaceDir);
372
- if (toolFailureGfi > 0 || dispatchErrorGfi > 0) {
373
- // Relieve both sources proportionally (50% relief each)
374
- if (toolFailureGfi > 0) {
375
- const reliefAmount = toolFailureGfi * 0.5;
376
- resetState = resetFriction(sessionId, effectiveWorkspaceDir, {
377
- source: 'tool_failure',
378
- amount: reliefAmount,
379
- });
380
- }
381
- if (dispatchErrorGfi > 0) {
382
- const reliefAmount = dispatchErrorGfi * 0.5;
383
- resetState = resetFriction(sessionId, effectiveWorkspaceDir, {
384
- source: 'dispatch_error',
385
- amount: reliefAmount,
386
- });
387
- }
388
- }
389
-
390
- recordEvolutionSuccess(effectiveWorkspaceDir, event.toolName, {
391
- sessionId,
392
- reason: 'tool_success',
393
- });
394
-
395
- const injectedProbationIds = getInjectedProbationIds(sessionId, effectiveWorkspaceDir);
396
- for (const id of injectedProbationIds) {
397
- const principle = wctx.evolutionReducer.getPrincipleById(id);
398
- const shouldAttribute = !!principle && shouldAttributePrincipleToTool(principle, event.toolName);
399
- if (shouldAttribute) {
400
- wctx.evolutionReducer.recordProbationFeedback(id, true);
401
- }
402
- }
403
- clearInjectedProbationIds(sessionId, effectiveWorkspaceDir);
404
- wctx.trajectory?.recordToolCall?.({
405
- sessionId,
406
- toolName: event.toolName,
407
- outcome: 'success',
408
- durationMs: event.durationMs,
409
- exitCode,
410
- gfiBefore,
411
- gfiAfter: resetState.currentGfi,
412
- paramsJson: sanitizeToolParamsForEvidence(event.params, effectiveWorkspaceDir),
413
- });
414
-
415
- const filePath = params.file_path || params.path || params.file;
416
- eventLog.recordToolCall(sessionId, {
417
- toolName: event.toolName,
418
- filePath: typeof filePath === 'string' ? filePath : undefined,
419
- gfi: resetState.currentGfi,
420
- gfiBefore,
421
- gfiAfter: resetState.currentGfi,
422
- });
423
-
424
- // ── Hygiene Tracking: Record persistence actions ──
425
- const normalized = typeof filePath === 'string' ? filePath.replace(/\\/g, '/') : '';
426
- const isMemory = /(?:^|\/)memory\//.test(normalized) || normalized.endsWith('/MEMORY.md') || normalized === 'MEMORY.md';
427
- const isPlan = normalized === 'PLAN.md' || normalized.endsWith('/PLAN.md');
428
-
429
- if (isMemory || isPlan) {
430
- const content = params.content || params.new_string || '';
431
- wctx.hygiene.recordPersistence({
432
- ts: new Date().toISOString(),
433
- tool: event.toolName,
434
- path: typeof filePath === 'string' ? filePath : 'unknown',
435
- type: isMemory ? 'memory' : 'plan',
436
- contentLength: content.length,
437
- });
438
- }
200
+ // ── Stage 3b: Friction + Recording (Success) ──
201
+ handleFrictionTrackingForSuccess(
202
+ sessionId, event, outcome, observation, gfiBefore, effectiveWorkspaceDir, wctx
203
+ );
439
204
 
440
- // Special case for memory_store tool (Success only)
441
- if (event.toolName === 'memory_store') {
442
- const text = params.text || '';
443
- wctx.hygiene.recordPersistence({
444
- ts: new Date().toISOString(),
445
- tool: event.toolName,
446
- path: 'DATABASE',
447
- type: 'memory',
448
- contentLength: text.length,
449
- });
450
- }
205
+ // ── Stage 4: Probation Feedback (Success) ──
206
+ handleProbationFeedback(sessionId, event.toolName, effectiveWorkspaceDir, wctx, true);
207
+
208
+ // ── Stage 5b: Hygiene Tracking (Success only) ──
209
+ recordHygieneTracking(event, observation, wctx);
451
210
  }
452
211
 
453
- // ── Legacy/Risky Write Pain Logic (Unified WRITE_TOOLS) ──
454
- if (!WRITE_TOOLS.includes(event.toolName) || !isFailure) {
212
+ // ── Stage 6: Pain Admission ──
213
+ const admission = evaluatePainAdmissionForToolCall(
214
+ event, observation, outcome, latestFailureState, sessionState, sessionId, effectiveWorkspaceDir, config
215
+ );
216
+
217
+ if (admission.stage === 'not_applicable') {
455
218
  return;
456
219
  }
457
220
 
458
- const failureSource = classifyToolFailureSource(event.toolName, event.error);
221
+ // ── Stage 7: Emit Pain (only if admitted) ──
222
+ emitPainIfAdmitted(
223
+ wctx, event, observation, outcome, admission, sessionId, ctx.agentId, effectiveWorkspaceDir, emitPainDetectedEvent
224
+ );
225
+ }
459
226
 
460
- const filePath = params.file_path || params.path || params.file;
461
- const relPath = typeof filePath === 'string' ? normalizePath(filePath, effectiveWorkspaceDir) : 'unknown';
227
+ // ── Manual Pain Handler ─────────────────────────────────────────────────────
462
228
 
463
- const isRisk = isRisky(relPath, profile.risk_paths);
464
- const painScore = computePainScore(1, false, false, isRisk ? 20 : 0, effectiveWorkspaceDir);
229
+ /**
230
+ * Handle manual pain intervention (toolName === 'pain' or 'skill:pain').
231
+ *
232
+ * This path is separate because:
233
+ * - It always records pain at score 100
234
+ * - It uses a different GFI track (manual_pain)
235
+ * - It has its own cooldown via PainDiagnosticGate
236
+ * - It does NOT go through evidence triage
237
+ */
238
+ function handleManualPain(
239
+ event: PluginHookAfterToolCallEvent,
240
+ ctx: PluginHookToolContext & { workspaceDir?: string },
241
+ wctx: WorkspaceContext,
242
+ workspaceDir: string,
243
+ sessionId: string,
244
+ ): void {
245
+ const rawParams = event.params;
246
+ const params: { input?: string; arguments?: string } =
247
+ (rawParams && typeof rawParams === 'object' && !Array.isArray(rawParams))
248
+ ? rawParams as { input?: string; arguments?: string }
249
+ : {};
250
+ const reason = params.input || params.arguments || 'Manual intervention';
465
251
  const traceId = createTraceId();
466
- const diagnosticGate = evaluatePainDiagnosticGate({
467
- source: failureSource,
468
- score: painScore,
469
- currentGfi: (latestFailureState ?? getSession(sessionId) ?? sessionState)?.currentGfi ?? 0,
470
- consecutiveErrors: (latestFailureState ?? getSession(sessionId) ?? sessionState)?.consecutiveErrors ?? 0,
471
- isRisky: isRisk,
472
- errorHash: latestFailureState?.lastErrorHash,
473
- sessionId,
474
- thresholds: {
475
- painTrigger: config.get('thresholds.pain_trigger') || 40,
476
- highSeverity: config.get('severity_thresholds.high') || 70,
477
- repeatedFailure: config.get('thresholds.stuck_loops_trigger') || 4,
478
- },
479
- });
480
252
 
481
- if (!diagnosticGate.shouldDiagnose) {
482
- SystemLogger.log(
483
- effectiveWorkspaceDir,
484
- 'PAIN_DIAGNOSE_SKIPPED',
485
- `Tool failure recorded as friction only: ${diagnosticGate.detail}; tool=${event.toolName}; path=${relPath}`,
486
- );
487
- // Structured gate rejection event for traceability
488
- let rejectPayload: string;
489
- try {
490
- rejectPayload = JSON.stringify({
491
- reason: diagnosticGate.reason,
492
- detail: diagnosticGate.detail,
493
- source: failureSource,
494
- sessionId: sessionId,
495
- gfi: (latestFailureState ?? getSession(sessionId) ?? sessionState)?.currentGfi ?? 0,
496
- score: painScore,
497
- });
498
- } catch (e) {
499
- SystemLogger.log(effectiveWorkspaceDir, 'PAYLOAD_SERIALIZE_FAILED', String(e));
500
- rejectPayload = JSON.stringify({ reason: diagnosticGate.reason, detail: '(log serialization failed)' });
501
- }
502
- SystemLogger.log(effectiveWorkspaceDir, 'PAIN_GATE_REJECTED', rejectPayload);
503
- return;
504
- }
253
+ // Track friction at max score
254
+ trackFriction(sessionId, 100, 'manual_pain', workspaceDir, { source: 'manual_pain' });
255
+ SystemLogger.log(workspaceDir, 'MANUAL_PAIN', `User manually triggered pain: ${reason}`);
505
256
 
506
- // Record to trajectory before Runtime V2 diagnosis so the compiler can later
507
- // resolve derivedFromPainIds to the originating failed action.
508
- wctx.trajectory?.recordPainEvent({
509
- sessionId,
510
- source: failureSource,
511
- score: painScore,
512
- reason: `Tool ${event.toolName} failed on ${relPath}`,
513
- severity: painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild',
514
- origin: 'system_infer',
515
- text: sanitizeForEvidence(params.text ?? params.content, effectiveWorkspaceDir) || undefined,
257
+ wctx.eventLog.recordPainSignal(sessionId, {
258
+ score: 100,
259
+ source: 'manual',
260
+ reason: `User intervention: ${reason}`,
261
+ isRisky: true
516
262
  });
517
263
 
518
- // Pain signal emitted via emitPainDetectedEvent below — no .pain_flag file written (M8: single-path chain)
519
-
520
- // Observe: track which principles would have prevented this pain (Phase 1, observation-only)
521
- try {
522
- trackPrincipleValue(
523
- effectiveWorkspaceDir,
524
- {
525
- reason: `Tool ${event.toolName} failed on ${relPath}. Error: ${event.error ?? 'Non-zero exit code'}`,
526
- source: failureSource,
527
- score: String(painScore),
528
- },
529
- () => wctx.evolutionReducer.getActivePrinciples().map((p) => ({
530
- id: p.id,
531
- trigger: p.trigger,
532
- valueMetrics: p.valueMetrics,
533
- })),
534
- (id, metrics) => {
535
- const principle = wctx.evolutionReducer.getPrincipleById(id);
536
- if (principle) {
537
- principle.valueMetrics = metrics;
538
- // Persist to training state (best-effort, non-critical)
539
- try {
540
- wctx.principleTreeLedger.updatePrincipleValueMetrics(id, {
541
- principleId: id,
542
- painPreventedCount: metrics.painPreventedCount,
543
- lastPainPreventedAt: metrics.lastPainPreventedAt,
544
- calculatedAt: metrics.calculatedAt,
545
- avgPainSeverityPrevented: 0,
546
- totalOpportunities: 0,
547
- adheredCount: 0,
548
- violatedCount: 0,
549
- implementationCost: 0,
550
- benefitScore: 0,
551
- });
552
- } catch (e) {
553
- // Non-critical — metrics tracked in memory
554
- SystemLogger.log(effectiveWorkspaceDir, 'METRICS_UPDATE_SKIP', String(e));
555
- }
556
- }
557
- },
558
- );
559
- } catch (e) {
560
- // Observation only — never disrupt the pain pipeline
561
- SystemLogger.log(effectiveWorkspaceDir, ' PRINCIPLE_TRACK_SKIP', String(e));
562
- }
563
-
564
- eventLog.recordPainSignal(sessionId, {
565
- score: painScore,
566
- source: failureSource,
567
- reason: `Tool ${event.toolName} failed on ${relPath}`,
568
- isRisky: isRisk,
264
+ wctx.trajectory?.recordPainEvent?.({
265
+ sessionId,
266
+ source: 'manual',
267
+ score: 100,
268
+ reason: `User intervention: ${reason}`,
269
+ origin: 'user_manual',
270
+ text: reason,
569
271
  });
570
272
 
571
273
  // Log to EvolutionLogger
572
- const evoLogger = getEvolutionLogger(effectiveWorkspaceDir, wctx.trajectory);
274
+ const evoLogger = getEvolutionLogger(workspaceDir, wctx.trajectory);
573
275
  evoLogger.logPainDetected({
574
276
  traceId,
575
- source: failureSource,
576
- reason: `Tool ${event.toolName} failed on ${relPath}`,
577
- score: painScore,
277
+ source: 'manual',
278
+ reason: `User intervention: ${reason}`,
279
+ score: 100,
578
280
  toolName: event.toolName,
579
- filePath: relPath,
580
281
  sessionId,
581
282
  });
582
283
 
284
+ // Apply PainDiagnosticGate with cooldown
285
+ const session = getSession(sessionId);
286
+ const gate = evaluatePainDiagnosticGate({
287
+ source: 'manual',
288
+ score: 100,
289
+ currentGfi: session?.currentGfi ?? 0,
290
+ sessionId,
291
+ });
292
+
293
+ if (!gate.shouldDiagnose) {
294
+ SystemLogger.log(workspaceDir, 'MANUAL_PAIN_SKIPPED', `Manual pain within cooldown: ${gate.detail}`);
295
+ let payload: string;
296
+ try {
297
+ payload = JSON.stringify({
298
+ reason: gate.reason,
299
+ detail: gate.detail,
300
+ source: 'manual',
301
+ sessionId,
302
+ gfi: 0,
303
+ score: 100,
304
+ });
305
+ } catch (e) {
306
+ SystemLogger.log(workspaceDir, 'PAYLOAD_SERIALIZE_FAILED', String(e));
307
+ payload = JSON.stringify({ reason: gate.reason, detail: '(log serialization failed)' });
308
+ }
309
+ SystemLogger.log(workspaceDir, 'PAIN_GATE_REJECTED', payload);
310
+ return;
311
+ }
312
+
583
313
  emitPainDetectedEvent(wctx, {
584
314
  ts: new Date().toISOString(),
585
315
  type: 'pain_detected',
586
316
  data: {
587
317
  painId: createPainId(sessionId),
588
- painType: failureSource,
318
+ painType: 'user_frustration',
589
319
  source: event.toolName,
590
- reason: `Tool ${event.toolName} failed on ${relPath}; diagnosticGate=${diagnosticGate.reason}`,
591
- score: painScore,
320
+ reason: `User intervention: ${reason}`,
321
+ score: 100,
592
322
  sessionId,
593
323
  traceId,
594
324
  agentId: ctx.agentId,
595
- provenance: 'automatic_hook',
325
+ provenance: (sessionId && sessionId !== 'unknown') ? 'openclaw_context_bound' : 'owner_reported_no_host_trace',
596
326
  evidence: buildTrajectoryEvidence(wctx, sessionId),
597
327
  },
598
328
  });
599
329
  }
600
-
601
-
602
- function extractErrorType(error: unknown): string {
603
- if (!error) return 'Unknown';
604
- const msg = String(error);
605
- if (msg.includes('EACCES') || msg.includes('permission denied')) return 'EACCES';
606
- if (msg.includes('ENOENT') || msg.includes('no such file')) return 'ENOENT';
607
- if (msg.includes('EISDIR')) return 'EISDIR';
608
- if (msg.includes('ENOSPC')) return 'ENOSPC';
609
- if (msg.includes('SyntaxError')) return 'SyntaxError';
610
- if (msg.includes('TypeError')) return 'TypeError';
611
- if (msg.includes('ReferenceError')) return 'ReferenceError';
612
- if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) return 'Timeout';
613
- if (msg.includes('network') || msg.includes('ECONNREFUSED')) return 'Network';
614
- return 'Other';
615
- }