principles-disciple 1.8.3 → 1.10.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 (35) hide show
  1. package/openclaw.plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/core/pain-context-extractor.ts +286 -0
  4. package/src/core/pain.ts +83 -1
  5. package/src/hooks/lifecycle.ts +7 -6
  6. package/src/hooks/llm.ts +7 -6
  7. package/src/hooks/pain.ts +5 -6
  8. package/src/hooks/subagent.ts +5 -6
  9. package/src/service/evolution-worker.ts +59 -2
  10. package/templates/langs/en/skills/pd-auditor/SKILL.md +61 -0
  11. package/templates/langs/en/skills/pd-daily/SKILL.md +1 -1
  12. package/templates/langs/en/skills/pd-diagnostician/SKILL.md +370 -0
  13. package/templates/langs/en/skills/pd-explorer/SKILL.md +65 -0
  14. package/templates/langs/en/skills/pd-grooming/SKILL.md +1 -1
  15. package/templates/langs/en/skills/pd-implementer/SKILL.md +68 -0
  16. package/templates/langs/en/skills/pd-mentor/SKILL.md +1 -1
  17. package/templates/langs/en/skills/pd-pain-signal/SKILL.md +37 -0
  18. package/templates/langs/en/skills/pd-planner/SKILL.md +65 -0
  19. package/templates/langs/zh/core/PRINCIPLES.md +7 -0
  20. package/templates/langs/zh/skills/pd-auditor/SKILL.md +1 -1
  21. package/templates/langs/zh/skills/pd-daily/SKILL.md +1 -1
  22. package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +37 -23
  23. package/templates/langs/zh/skills/pd-explorer/SKILL.md +1 -1
  24. package/templates/langs/zh/skills/pd-grooming/SKILL.md +1 -1
  25. package/templates/langs/zh/skills/pd-implementer/SKILL.md +1 -1
  26. package/templates/langs/zh/skills/pd-mentor/SKILL.md +1 -1
  27. package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +37 -0
  28. package/templates/langs/zh/skills/pd-planner/SKILL.md +1 -1
  29. package/tests/core/pain-context-extractor.test.ts +278 -0
  30. package/tests/core/pain.test.ts +100 -1
  31. package/tests/hooks/pain.test.ts +1 -1
  32. package/templates/langs/en/skills/pain/SKILL.md +0 -19
  33. package/templates/langs/zh/skills/pain/SKILL.md +0 -19
  34. package/templates/langs/zh/skills/pd-reporter/SKILL.md +0 -78
  35. package/templates/langs/zh/skills/pd-reviewer/SKILL.md +0 -66
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.8.3",
5
+ "version": "1.10.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.8.3",
3
+ "version": "1.10.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Pain Context Extractor
3
+ *
4
+ * Extracts conversation context from OpenClaw session JSONL files
5
+ * to provide diagnostic context beyond the pain reason.
6
+ *
7
+ * DESIGN PRINCIPLES (from real data analysis):
8
+ * - JSONL files can be 6 lines (HEARTBEAT injection) to 632+ lines (full conversation)
9
+ * - Large files: one line can be 11MB (system prompt) — MUST skip oversized lines
10
+ * - Assistant text appears ONLY in final replies (~3% of assistant messages)
11
+ * - Most assistant messages contain toolCall blocks (what operations were performed)
12
+ * - toolResult contains tool output (success AND failure) — both are useful for diagnosis
13
+ * - Always read from END of file to get most recent context
14
+ *
15
+ * SAFETY:
16
+ * - Never load entire file (tail-only, max 512KB)
17
+ * - Skip lines > 100KB (real files have 11MB single lines)
18
+ * - Cap total output at 1500 chars
19
+ * - All errors caught silently — return empty string on failure
20
+ */
21
+
22
+ import * as fs from 'fs';
23
+ import * as path from 'path';
24
+ import * as os from 'os';
25
+
26
+ // =========================================================================
27
+ // Safety Limits
28
+ // =========================================================================
29
+
30
+ /** Skip JSONL lines larger than this */
31
+ const MAX_LINE_BYTES = 100_000; // 100KB
32
+ /** Only read last portion of file */
33
+ const TAIL_READ_SIZE = 512_000; // 512KB
34
+ /** Max turns to extract */
35
+ const MAX_TURNS = 8;
36
+ /** Max chars per turn entry */
37
+ const MAX_TURN_CHARS = 250;
38
+ /** Max total output */
39
+ const MAX_OUTPUT_CHARS = 1500;
40
+
41
+ /** Valid characters for session IDs and agent IDs — prevents path traversal */
42
+ const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
43
+
44
+ function getAgentsDir(): string {
45
+ return process.env.PD_TEST_AGENTS_DIR || path.join(os.homedir(), '.openclaw', 'agents');
46
+ }
47
+
48
+ // =========================================================================
49
+ // Safe File Reading
50
+ // =========================================================================
51
+
52
+ function safeTail(filePath: string): string[] {
53
+ try {
54
+ if (!fs.existsSync(filePath)) return [];
55
+ const stat = fs.statSync(filePath);
56
+ if (stat.size === 0) return [];
57
+
58
+ const isTruncated = stat.size > TAIL_READ_SIZE;
59
+ const readSize = Math.min(stat.size, TAIL_READ_SIZE);
60
+ const buffer = Buffer.alloc(readSize);
61
+ const fd = fs.openSync(filePath, 'r');
62
+ try {
63
+ fs.readSync(fd, buffer, 0, readSize, stat.size - readSize);
64
+ const content = buffer.toString('utf8');
65
+ // Only strip first line if file was actually truncated (started mid-line)
66
+ const validContent = isTruncated ? content.slice(content.indexOf('\n') + 1) : content;
67
+ return validContent.split('\n').filter(l => l.trim().length > 0);
68
+ } finally {
69
+ fs.closeSync(fd);
70
+ }
71
+ } catch (err) {
72
+ console.debug(`[pain-context-extractor] safeTail failed: ${String(err)}`);
73
+ return [];
74
+ }
75
+ }
76
+
77
+ // =========================================================================
78
+ // Safe JSONL Parsing
79
+ // =========================================================================
80
+
81
+ interface ParsedMessage {
82
+ role: string;
83
+ textParts: string[];
84
+ toolCalls: Array<{ id?: string; name?: string; arguments?: Record<string, unknown> }>;
85
+ toolCallId?: string;
86
+ toolName?: string;
87
+ details?: { exitCode?: number; isError?: boolean; aggregated?: string };
88
+ }
89
+
90
+ function parseSafeMessages(lines: string[]): ParsedMessage[] {
91
+ const messages: ParsedMessage[] = [];
92
+ for (const line of lines) {
93
+ if (line.length > MAX_LINE_BYTES) continue;
94
+ try {
95
+ const parsed = JSON.parse(line);
96
+ if (parsed.type !== 'message' || !parsed.message) continue;
97
+ const msg = parsed.message;
98
+ const content = Array.isArray(msg.content) ? msg.content : [];
99
+ messages.push({
100
+ role: msg.role || '',
101
+ textParts: content.filter((c: { type: string }) => c.type === 'text').map((c: { text?: string }) => c.text || ''),
102
+ toolCalls: content.filter((c: { type: string }) => c.type === 'toolCall').map((c: { id?: string; name?: string; arguments?: Record<string, unknown> }) => ({ id: c.id, name: c.name, arguments: c.arguments })),
103
+ toolCallId: msg.toolCallId,
104
+ toolName: msg.toolName,
105
+ details: msg.details,
106
+ });
107
+ } catch { /* malformed JSON line, skip silently — expected with corrupted files */ }
108
+ }
109
+ return messages;
110
+ }
111
+
112
+ // =========================================================================
113
+ // Turn Extraction
114
+ // =========================================================================
115
+
116
+ /**
117
+ * Extracts a concise turn representation from a message.
118
+ * Returns null if nothing useful to extract.
119
+ */
120
+ function extractTurn(msg: ParsedMessage): string | null {
121
+ if (msg.role === 'user' && msg.textParts.length > 0) {
122
+ // For user messages, skip system prompt injection patterns
123
+ const text = msg.textParts.join(' ').trim();
124
+ if (!text) return null;
125
+ // Skip if it looks like a system injection
126
+ if (text.startsWith('<evolution_task') || text.startsWith('<system_override') ||
127
+ text.startsWith('You are an empathy observer') || text.startsWith('Analyze ONLY') ||
128
+ text.startsWith('{"damageDetected"')) return null;
129
+ // Find the last meaningful user input line
130
+ const lines = text.split('\n');
131
+ let lastInput = '';
132
+ for (const line of lines) {
133
+ const trimmed = line.trim();
134
+ if (trimmed.length > 3 && trimmed.length < 500 &&
135
+ !trimmed.startsWith('<') && !trimmed.startsWith('{') &&
136
+ !trimmed.startsWith('Trust Score:') && !trimmed.startsWith('Hygiene:')) {
137
+ lastInput = trimmed;
138
+ }
139
+ }
140
+ const userInput = lastInput || text;
141
+ return `[User]: ${userInput.substring(0, MAX_TURN_CHARS)}`;
142
+ }
143
+
144
+ if (msg.role === 'assistant') {
145
+ // Priority 1: final text reply
146
+ if (msg.textParts.length > 0) {
147
+ const text = msg.textParts.join(' ').trim();
148
+ if (text) return `[Assistant]: ${text.substring(0, MAX_TURN_CHARS)}`;
149
+ }
150
+ // Priority 2: tool call summary (what operations were performed)
151
+ if (msg.toolCalls.length > 0) {
152
+ const tools = msg.toolCalls.map(tc => tc.name).filter(Boolean);
153
+ const uniqueTools = [...new Set(tools)];
154
+ if (uniqueTools.length > 0) {
155
+ return `[Assistant → ${uniqueTools.join(', ')}]`;
156
+ }
157
+ }
158
+ }
159
+
160
+ if (msg.role === 'toolResult') {
161
+ const exitCode = msg.details?.exitCode;
162
+ const isError = msg.details?.isError || (exitCode !== undefined && exitCode !== 0);
163
+ const text = msg.textParts.join(' ').trim();
164
+ const toolLabel = msg.toolName || 'tool';
165
+
166
+ if (isError) {
167
+ // Failed tool call — important for diagnosis
168
+ const errorPreview = text ? text.substring(0, MAX_TURN_CHARS) : `(exit ${exitCode ?? '?'})`;
169
+ return `[${toolLabel} FAILED]: ${errorPreview}`;
170
+ }
171
+
172
+ // Successful tool call — include brief result
173
+ if (text) {
174
+ // For successful results, show first meaningful line
175
+ const lines = text.split('\n').filter(l => l.trim());
176
+ const firstLine = lines[0]?.substring(0, MAX_TURN_CHARS) || '';
177
+ if (firstLine) return `[${toolLabel}]: ${firstLine}`;
178
+ }
179
+ }
180
+
181
+ return null;
182
+ }
183
+
184
+ // =========================================================================
185
+ // Public API
186
+ // =========================================================================
187
+
188
+ /**
189
+ * Extracts recent conversation context from a session's JSONL file.
190
+ *
191
+ * SAFETY: Tail-only read, skip oversized lines, cap output.
192
+ * Returns empty string on any failure — caller should use pain reason as fallback.
193
+ */
194
+ export async function extractRecentConversation(
195
+ sessionId: string,
196
+ agentId: string = 'main',
197
+ maxTurns: number = MAX_TURNS,
198
+ ): Promise<string> {
199
+ if (!sessionId || sessionId.length < 5 || !SAFE_ID_REGEX.test(sessionId)) return '';
200
+ if (agentId && !SAFE_ID_REGEX.test(agentId)) return '';
201
+ try {
202
+ const jsonlPath = path.join(getAgentsDir(), agentId, 'sessions', `${sessionId}.jsonl`);
203
+ const lines = safeTail(jsonlPath);
204
+ const messages = parseSafeMessages(lines);
205
+ if (messages.length === 0) return '';
206
+
207
+ const turns: string[] = [];
208
+ for (const msg of messages) {
209
+ const turn = extractTurn(msg);
210
+ if (turn) turns.push(turn);
211
+ }
212
+
213
+ const recent = turns.slice(-maxTurns);
214
+ if (recent.length === 0) return '';
215
+ const result = recent.join('\n');
216
+ return result.length > MAX_OUTPUT_CHARS ? result.substring(0, MAX_OUTPUT_CHARS - 3) + '...' : result;
217
+ } catch (err) {
218
+ console.debug(`[pain-context-extractor] extractRecentConversation failed for session=${sessionId}, agent=${agentId}: ${String(err)}`);
219
+ return ''; // Fail silently
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Extracts failed tool call context with argument correlation.
225
+ */
226
+ export async function extractFailedToolContext(
227
+ sessionId: string,
228
+ agentId: string = 'main',
229
+ toolName: string,
230
+ filePath?: string,
231
+ ): Promise<string> {
232
+ if (!sessionId || sessionId.length < 5 || !SAFE_ID_REGEX.test(sessionId) || !toolName) return '';
233
+ if (agentId && !SAFE_ID_REGEX.test(agentId)) return '';
234
+ try {
235
+ const jsonlPath = path.join(getAgentsDir(), agentId, 'sessions', `${sessionId}.jsonl`);
236
+ const lines = safeTail(jsonlPath);
237
+ const messages = parseSafeMessages(lines);
238
+ if (messages.length === 0) return '';
239
+
240
+ // Build toolCallId → arguments map
241
+ // Keep both full args (for matching) and truncated (for display)
242
+ const toolArgsById = new Map<string, { name: string; fullArgs: string; previewArgs: string }>();
243
+ for (const msg of messages) {
244
+ for (const tc of msg.toolCalls) {
245
+ if (tc.id && tc.name) {
246
+ const args = tc.arguments || {};
247
+ const fullArgs = JSON.stringify(args);
248
+ const truncated: Record<string, unknown> = {};
249
+ for (const [k, v] of Object.entries(args)) {
250
+ const s = typeof v === 'string' ? v : JSON.stringify(v);
251
+ truncated[k] = s.length > 150 ? s.substring(0, 150) + '...' : s;
252
+ }
253
+ toolArgsById.set(tc.id, {
254
+ name: tc.name,
255
+ fullArgs,
256
+ previewArgs: JSON.stringify(truncated, null, 2),
257
+ });
258
+ }
259
+ }
260
+ }
261
+
262
+ const parts: string[] = [];
263
+ for (const msg of messages) {
264
+ if (msg.role === 'toolResult' && msg.toolName === toolName) {
265
+ const exitCode = msg.details?.exitCode;
266
+ const isError = msg.details?.isError || (exitCode !== undefined && exitCode !== 0);
267
+ if (!isError) continue;
268
+
269
+ const toolCallId = msg.toolCallId;
270
+ const correlated = toolCallId ? toolArgsById.get(toolCallId) : null;
271
+ if (filePath && correlated && !correlated.fullArgs.includes(filePath)) continue;
272
+
273
+ parts.push(`[Tool Call: ${correlated?.name || toolName}]`);
274
+ if (correlated) parts.push(`Arguments: ${correlated.previewArgs.substring(0, 300)}`);
275
+ parts.push(`Exit Code: ${exitCode ?? 'N/A'}`);
276
+ const errorText = msg.textParts.join(' ').trim();
277
+ if (errorText) parts.push(`Error: ${errorText.substring(0, 500)}`);
278
+ break;
279
+ }
280
+ }
281
+ return parts.length > 0 ? parts.join('\n') : '';
282
+ } catch (err) {
283
+ console.debug(`[pain-context-extractor] extractFailedToolContext failed for tool=${toolName}, session=${sessionId}: ${String(err)}`);
284
+ return '';
285
+ }
286
+ }
package/src/core/pain.ts CHANGED
@@ -4,6 +4,88 @@ import { serializeKvLines, parseKvLines } from '../utils/io.js';
4
4
  import { resolvePdPath } from './paths.js';
5
5
  import { ConfigService } from './config-service.js';
6
6
 
7
+ // =========================================================================
8
+ // Pain Flag Contract (Single Source of Truth)
9
+ //
10
+ // All pain flag writers MUST use buildPainFlag() below.
11
+ // If you need to add a new field, update this type AND buildPainFlag().
12
+ // This prevents format drift across the 5+ pain signal sources.
13
+ // =========================================================================
14
+
15
+ /**
16
+ * Required fields — every pain flag MUST have these.
17
+ */
18
+ export interface PainFlagData {
19
+ /** What triggered this pain signal (e.g., tool_failure, human_intervention, intercept_extraction) */
20
+ source: string;
21
+ /** Pain score 0-100 */
22
+ score: string;
23
+ /** ISO 8601 timestamp */
24
+ time: string;
25
+ /** Human-readable reason / error description */
26
+ reason: string;
27
+ /** Session ID — identifies which conversation this happened in */
28
+ session_id: string;
29
+ /** Agent ID — identifies which agent (main, builder, diagnostician, etc.) */
30
+ agent_id: string;
31
+ /** Whether this involves risky operation ('true' / 'false') */
32
+ is_risky: string;
33
+ /** Correlation trace ID (for linking events across the pipeline) */
34
+ trace_id?: string;
35
+ /** Short preview of text that triggered this pain signal */
36
+ trigger_text_preview?: string;
37
+ }
38
+
39
+ /**
40
+ * Factory function — the ONLY way to construct pain flag data.
41
+ *
42
+ * All callers (hooks/pain.ts, hooks/subagent.ts, hooks/lifecycle.ts,
43
+ * hooks/llm.ts, skills/pain/SKILL.md) must go through this function.
44
+ *
45
+ * Required fields are enforced by TypeScript — you can't compile
46
+ * without providing source, score, time, reason, session_id, agent_id.
47
+ *
48
+ * Optional fields (trace_id, trigger_text_preview) default to empty string.
49
+ */
50
+ export function buildPainFlag(input: {
51
+ source: string;
52
+ score: string;
53
+ time?: string;
54
+ reason: string;
55
+ session_id?: string;
56
+ agent_id?: string;
57
+ is_risky?: boolean;
58
+ trace_id?: string;
59
+ trigger_text_preview?: string;
60
+ }): PainFlagData {
61
+ return {
62
+ source: input.source,
63
+ score: input.score,
64
+ time: input.time || new Date().toISOString(),
65
+ reason: input.reason,
66
+ session_id: input.session_id || '',
67
+ agent_id: input.agent_id || '',
68
+ is_risky: input.is_risky ? 'true' : 'false',
69
+ trace_id: input.trace_id || '',
70
+ trigger_text_preview: input.trigger_text_preview || '',
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Validates a pain flag read from disk.
76
+ * Returns list of missing required fields — empty string means all present.
77
+ */
78
+ export function validatePainFlag(data: Record<string, string>): string[] {
79
+ const missing: string[] = [];
80
+ const required = ['source', 'score', 'time', 'reason', 'session_id', 'agent_id'] as const;
81
+ for (const field of required) {
82
+ if (!data[field] || data[field].trim() === '') {
83
+ missing.push(field);
84
+ }
85
+ }
86
+ return missing;
87
+ }
88
+
7
89
  export function computePainScore(rc: number, isSpiral: boolean, missingTestCommand: boolean, softScore: number, projectDir?: string): number {
8
90
  let score = Math.max(0, softScore || 0);
9
91
 
@@ -54,7 +136,7 @@ export function painSeverityLabel(painScore: number, isSpiral: boolean = false,
54
136
  }
55
137
  }
56
138
 
57
- export function writePainFlag(projectDir: string, painData: Record<string, string>): void {
139
+ export function writePainFlag(projectDir: string, painData: PainFlagData): void {
58
140
  const painFlagPath = resolvePdPath(projectDir, 'PAIN_FLAG');
59
141
  const dir = path.dirname(painFlagPath);
60
142
  if (!fs.existsSync(dir)) {
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as readline from 'readline';
4
- import { computePainScore, writePainFlag } from '../core/pain.js';
4
+ import { computePainScore, buildPainFlag, writePainFlag } from '../core/pain.js';
5
5
  import { WorkspaceContext } from '../core/workspace-context.js';
6
6
  import { PD_DIRS } from '../core/paths.js';
7
7
  import {
@@ -154,14 +154,15 @@ export async function extractPainFromSessionFile(sessionFile: string, ctx: Plugi
154
154
 
155
155
  const hasFatal = painPoints.some(p => p.includes('[FATAL INTERCEPT]'));
156
156
  if (hasFatal) {
157
- writePainFlag(workspaceDir, {
157
+ writePainFlag(workspaceDir, buildPainFlag({
158
158
  source: 'intercept_extraction',
159
159
  score: '100',
160
- time: new Date().toISOString(),
161
160
  reason: 'Hard intercept detected in session history compaction.',
162
- is_risky: 'true',
163
- trigger_text_preview: painPoints.find(p => p.includes('[FATAL INTERCEPT]'))?.substring(0, 150) || 'Fatal intercept'
164
- });
161
+ is_risky: true,
162
+ trigger_text_preview: painPoints.find(p => p.includes('[FATAL INTERCEPT]'))?.substring(0, 150) || 'Fatal intercept',
163
+ session_id: ctx.sessionId || '',
164
+ agent_id: ctx.agentId || '',
165
+ }));
165
166
  }
166
167
  } catch (err) {
167
168
  ctx.logger?.error?.(`[PD:Lifecycle] Failed to write pain signals: ${String(err)}`);
package/src/hooks/llm.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { PluginHookLlmOutputEvent, PluginHookAgentContext } from '../openclaw-sdk.js';
4
4
  import { trackFriction, trackLlmOutput, recordThinkingCheckpoint, resetFriction } from '../core/session-tracker.js';
5
- import { writePainFlag } from '../core/pain.js';
5
+ import { buildPainFlag, writePainFlag } from '../core/pain.js';
6
6
  import { ControlUiDatabase } from '../core/control-ui-db.js';
7
7
  import { DetectionService } from '../core/detection-service.js';
8
8
  import { detectThinkingModelMatches, deriveThinkingScenarios } from '../core/thinking-models.js';
@@ -326,14 +326,15 @@ export function handleLlmOutput(
326
326
  const snippet = text.length > 200 ? text.substring(0, 100) + '...' + text.substring(text.length - 100) : text;
327
327
 
328
328
  try {
329
- writePainFlag(ctx.workspaceDir, {
329
+ writePainFlag(ctx.workspaceDir, buildPainFlag({
330
330
  source,
331
331
  score: String(painScore),
332
- time: new Date().toISOString(),
333
332
  reason: matchedReason,
334
- is_risky: 'false',
335
- trigger_text_preview: snippet
336
- });
333
+ is_risky: false,
334
+ trigger_text_preview: snippet,
335
+ session_id: ctx.sessionId || '',
336
+ agent_id: ctx.agentId || '',
337
+ }));
337
338
  } catch (e) {
338
339
  ctx.logger?.warn?.(`[PD:LLM] Failed to write pain flag: ${String(e)}`);
339
340
  }
package/src/hooks/pain.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { isRisky, normalizePath } from '../utils/io.js';
4
4
  import { normalizeProfile } from '../core/profile.js';
5
- import { computePainScore, writePainFlag, trackPrincipleValue } from '../core/pain.js';
5
+ import { computePainScore, buildPainFlag, writePainFlag, trackPrincipleValue } from '../core/pain.js';
6
6
  import { getSession, trackFriction, resetFriction, getInjectedProbationIds, clearInjectedProbationIds } from '../core/session-tracker.js';
7
7
  import { denoiseError, computeHash } from '../utils/hashing.js';
8
8
  import { SystemLogger } from '../core/system-logger.js';
@@ -273,16 +273,15 @@ export function handleAfterToolCall(
273
273
  const painScore = computePainScore(1, false, false, isRisk ? 20 : 0, effectiveWorkspaceDir);
274
274
  const traceId = createTraceId();
275
275
 
276
- const painData = {
277
- score: String(painScore),
276
+ const painData = buildPainFlag({
278
277
  source: 'tool_failure',
279
- time: new Date().toISOString(),
278
+ score: String(painScore),
280
279
  reason: `Tool ${event.toolName} failed on ${relPath}. Error: ${event.error ?? 'Non-zero exit code'}`,
281
- is_risky: String(isRisk),
280
+ is_risky: isRisk,
282
281
  trace_id: traceId,
283
282
  session_id: sessionId,
284
283
  agent_id: ctx.agentId || '',
285
- };
284
+ });
286
285
 
287
286
  try {
288
287
  writePainFlag(effectiveWorkspaceDir, painData);
@@ -1,6 +1,6 @@
1
1
  import type { PluginHookSubagentEndedEvent, PluginHookSubagentContext, PluginLogger, OpenClawPluginApi } from '../openclaw-sdk.js';
2
2
  import * as fs from 'fs';
3
- import { writePainFlag } from '../core/pain.js';
3
+ import { buildPainFlag, writePainFlag } from '../core/pain.js';
4
4
  import { WorkspaceContext } from '../core/workspace-context.js';
5
5
  // No longer needed — diagnostician runs via HEARTBEAT, not subagent
6
6
  import { recordEvolutionSuccess } from '../core/evolution-engine.js';
@@ -163,15 +163,14 @@ export async function handleSubagentEnded(
163
163
  const score = scoreSettings.subagent_error_penalty;
164
164
  const reason = `Subagent session ${targetSessionKey} ended with error`;
165
165
 
166
- writePainFlag(workspaceDir, {
167
- source: `subagent_error`,
166
+ writePainFlag(workspaceDir, buildPainFlag({
167
+ source: 'subagent_error',
168
168
  score: String(score),
169
- time: new Date().toISOString(),
170
169
  reason,
171
- is_risky: 'true',
170
+ is_risky: true,
172
171
  session_id: ctx.sessionId || '',
173
172
  agent_id: ctx.agentId || extractAgentIdFromSessionKey(targetSessionKey) || '',
174
- });
173
+ }));
175
174
 
176
175
  emitSubagentPainEvent(wctx, {
177
176
  source: `subagent_error`,
@@ -586,7 +586,9 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
586
586
 
587
587
  return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
588
588
  score: jsonScore, source: jsonSource, reason: jsonReason,
589
- preview: jsonPreview, traceId: '', sessionId: '', agentId: '',
589
+ preview: jsonPreview, traceId: '',
590
+ sessionId: jsonPain.session_id || '',
591
+ agentId: jsonPain.agent_id || '',
590
592
  });
591
593
  }
592
594
  } catch { /* Not JSON — fall through to KV/Markdown parsing */ }
@@ -935,6 +937,48 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
935
937
  }
936
938
  } catch {}
937
939
 
940
+ // ── Context Enrichment (CTX-01): Dual-path strategy ──
941
+ // P1: OpenClaw built-in tools (sessions_history) - safe, visibility-limited
942
+ // P2: JSONL direct read - fallback when tools fail or session not visible
943
+ // The diagnostician skill implements both paths (Phase 0 protocol).
944
+ //
945
+ // Here we pre-extract JSONL context as backup and inject tool instructions.
946
+
947
+ let contextSection = '';
948
+ if (highestScoreTask.session_id && highestScoreTask.agent_id) {
949
+ try {
950
+ const { extractRecentConversation, extractFailedToolContext } = await import('../core/pain-context-extractor.js');
951
+ const conversation = await extractRecentConversation(highestScoreTask.session_id, highestScoreTask.agent_id, 5);
952
+
953
+ if (conversation) {
954
+ contextSection = `\n## Recent Conversation Context (pre-extracted JSONL fallback)\n\n${conversation}\n`;
955
+
956
+ // Also try to extract failed tool context if this is a tool failure
957
+ if (highestScoreTask.source === 'tool_failure') {
958
+ const toolMatch = highestScoreTask.reason?.match(/Tool ([\w-]+) failed/);
959
+ const fileMatch = highestScoreTask.reason?.match(/on (.+?)(?=\s*Error:|$)/i);
960
+ if (toolMatch) {
961
+ const toolContext = await extractFailedToolContext(
962
+ highestScoreTask.session_id,
963
+ highestScoreTask.agent_id,
964
+ toolMatch[1],
965
+ fileMatch?.[1]?.trim(),
966
+ );
967
+ if (toolContext) {
968
+ contextSection += `\n## Failed Tool Call Context\n\n${toolContext}\n`;
969
+ }
970
+ }
971
+ }
972
+ }
973
+ if (logger) {
974
+ const turns = contextSection ? contextSection.split('\n').filter(l => l.startsWith('[User]') || l.startsWith('[Assistant]')).length : 0;
975
+ logger?.debug?.(`[PD:EvolutionWorker] Pre-extracted ${turns} conversation turns for task ${highestScoreTask.id}`);
976
+ }
977
+ } catch (e) {
978
+ if (logger) logger.warn(`[PD:EvolutionWorker] Failed to extract conversation context for task ${highestScoreTask.id}: ${String(e)}. Diagnostician will use P1 tools or fallback.`);
979
+ }
980
+ }
981
+
938
982
  const heartbeatContent = [
939
983
  `## Evolution Task [ID: ${highestScoreTask.id}]`,
940
984
  ``,
@@ -946,12 +990,25 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
946
990
  `**Session ID**: ${highestScoreTask.session_id || 'N/A'}`,
947
991
  `**Agent ID**: ${highestScoreTask.agent_id || 'main'}`,
948
992
  ``,
993
+ `## Available Tools for Context Search (P1 - Preferred)`,
994
+ ``,
995
+ `1. **sessions_history** — Get full message history (requires sessionKey)`,
996
+ `2. **sessions_list** — List sessions (searches metadata only, NOT message content)`,
997
+ `3. **read_file / search_file_content** — Search codebase`,
998
+ ``,
999
+ `**P1 SOP**: sessions_history(sessionKey="agent:${highestScoreTask.agent_id || 'main'}:run:${highestScoreTask.session_id || 'N/A'}", limit=30)`,
1000
+ ``,
1001
+ `## Pre-extracted Context (P2 - JSONL Fallback)`,
1002
+ `If OpenClaw tools cannot access the session (visibility limits),`,
1003
+ `use this pre-extracted context below:`,
1004
+ contextSection || `*(No JSONL context available — use P1 tools first)*`,
1005
+ ``,
949
1006
  `---`,
950
1007
  ``,
951
1008
  `## Diagnostician Protocol`,
952
1009
  ``,
953
1010
  `You MUST use the **pd-diagnostician** skill for this task.`,
954
- `Read the full skill definition and follow the 4-phase protocol (Evidence → Causal Chain → Classification → Principle Extraction) EXACTLY as specified.`,
1011
+ `Read the full skill definition and follow the protocol EXACTLY as specified: Phase 0 (context extraction, optional) → Phase 1 (Evidence)Phase 2 (Causal Chain)Phase 3 (Classification)Phase 4 (Principle Extraction).`,
955
1012
  `The skill defines the complete output contract — your JSON report MUST match the format specified in the skill.`,
956
1013
  ``,
957
1014
  `---`,
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: pd-auditor
3
+ description: Deductive audit using axiom verification, system audit, and via negativa methods. TRIGGER CONDITIONS: (1) Need to audit system or process consistency (2) Verify core assumptions are self-consistent (3) Check component interactions are correct (4) Need to identify design flaws or logical contradictions.
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Auditor
8
+
9
+ You are a rigorous deductive audit expert. Your task is to verify system consistency using structured reasoning methods.
10
+
11
+ ## Audit Methodology
12
+
13
+ Use a three-phase audit framework:
14
+
15
+ ### Phase 1: Axiom Verification
16
+ - Check whether the system follows foundational axioms
17
+ - Verify whether core assumptions are self-consistent
18
+ - Identify logical contradictions
19
+
20
+ ### Phase 2: System Audit
21
+ - Check whether interactions between components are correct
22
+ - Verify data flow and control flow
23
+ - Identify design flaws
24
+
25
+ ### Phase 3: Via Negativa
26
+ - Systematically eliminate "impossible" options
27
+ - Approach truth by eliminating wrong paths
28
+ - Verify satisfaction of necessary conditions
29
+
30
+ ## Output Format
31
+
32
+ ### Audit Report
33
+
34
+ **Audit Objective**: [Clear audit target]
35
+
36
+ **Axiom Verification**:
37
+ - [Axiom 1]: [Verification result]
38
+ - [Axiom 2]: [Verification result]
39
+
40
+ **System Audit**:
41
+ - [Component A]: [Issues found]
42
+ - [Component B]: [Issues found]
43
+
44
+ **Via Negativa**:
45
+ - Excluded hypothesis 1: [Why impossible]
46
+ - Excluded hypothesis 2: [Why impossible]
47
+
48
+ **Audit Conclusion**: [Comprehensive judgment]
49
+
50
+ **Risk Rating**: Low|Medium|High
51
+
52
+ ## Notes
53
+
54
+ - Each audit point should have a clear basis for judgment
55
+ - Do not skip reasoning steps, proceed gradually
56
+ - If information is insufficient, clearly state what is needed
57
+ - Conclusions should be verifiable, not intuitive judgments
58
+
59
+ ---
60
+
61
+ Please follow this framework to conduct the audit and output a structured verification report.