principles-disciple 1.8.3 → 1.9.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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/core/pain-context-extractor.ts +286 -0
- package/src/core/pain.ts +83 -1
- package/src/hooks/lifecycle.ts +7 -6
- package/src/hooks/llm.ts +7 -6
- package/src/hooks/pain.ts +5 -6
- package/src/hooks/subagent.ts +5 -6
- package/src/service/evolution-worker.ts +59 -2
- package/templates/langs/en/skills/pd-auditor/SKILL.md +61 -0
- package/templates/langs/en/skills/pd-daily/SKILL.md +1 -1
- package/templates/langs/en/skills/pd-diagnostician/SKILL.md +370 -0
- package/templates/langs/en/skills/pd-explorer/SKILL.md +65 -0
- package/templates/langs/en/skills/pd-grooming/SKILL.md +1 -1
- package/templates/langs/en/skills/pd-implementer/SKILL.md +68 -0
- package/templates/langs/en/skills/pd-mentor/SKILL.md +1 -1
- package/templates/langs/en/skills/pd-pain-signal/SKILL.md +37 -0
- package/templates/langs/en/skills/pd-planner/SKILL.md +65 -0
- package/templates/langs/zh/core/PRINCIPLES.md +7 -0
- package/templates/langs/zh/skills/pd-auditor/SKILL.md +1 -1
- package/templates/langs/zh/skills/pd-daily/SKILL.md +1 -1
- package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +37 -23
- package/templates/langs/zh/skills/pd-explorer/SKILL.md +1 -1
- package/templates/langs/zh/skills/pd-grooming/SKILL.md +1 -1
- package/templates/langs/zh/skills/pd-implementer/SKILL.md +1 -1
- package/templates/langs/zh/skills/pd-mentor/SKILL.md +1 -1
- package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +37 -0
- package/templates/langs/zh/skills/pd-planner/SKILL.md +1 -1
- package/tests/core/pain-context-extractor.test.ts +278 -0
- package/tests/core/pain.test.ts +100 -1
- package/tests/hooks/pain.test.ts +1 -1
- package/templates/langs/en/skills/pain/SKILL.md +0 -19
- package/templates/langs/zh/skills/pain/SKILL.md +0 -19
- package/templates/langs/zh/skills/pd-reporter/SKILL.md +0 -78
- package/templates/langs/zh/skills/pd-reviewer/SKILL.md +0 -66
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -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:
|
|
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)) {
|
package/src/hooks/lifecycle.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
278
|
+
score: String(painScore),
|
|
280
279
|
reason: `Tool ${event.toolName} failed on ${relPath}. Error: ${event.error ?? 'Non-zero exit code'}`,
|
|
281
|
-
is_risky:
|
|
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);
|
package/src/hooks/subagent.ts
CHANGED
|
@@ -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:
|
|
166
|
+
writePainFlag(workspaceDir, buildPainFlag({
|
|
167
|
+
source: 'subagent_error',
|
|
168
168
|
score: String(score),
|
|
169
|
-
time: new Date().toISOString(),
|
|
170
169
|
reason,
|
|
171
|
-
is_risky:
|
|
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: '',
|
|
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
|
|
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.
|