principles-disciple 1.107.0 → 1.109.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 +2 -2
- package/src/core/init.ts +3 -1
- package/src/core/workspace-dir-validation.ts +3 -3
- package/src/service/evolution-worker.ts +1 -1
- package/templates/langs/en/skills/pd-runtime-v2/SKILL.md +1 -1
- package/templates/langs/zh/skills/pd-runtime-v2/SKILL.md +1 -1
- package/tests/core-anti-growth.test.ts +0 -13
- package/tests/hooks/prompt-characterization.test.ts +1 -11
- package/tests/hooks/prompt-diet.test.ts +3 -11
- package/tests/hooks/prompt-size-guard.test.ts +0 -10
- package/tests/hooks/runtime-v2-prompt-activation.test.ts +0 -10
- package/tests/index.test.ts +1 -1
- package/tests/runtime-v2-discovery-guard.test.ts +1 -2
- package/vitest.config.ts +2 -3
- package/vitest.unit.config.ts +12 -0
- package/src/core/evolution-hook.ts +0 -74
- package/src/core/file-storage-adapter.ts +0 -203
- package/src/core/merge-gate-audit.ts +0 -314
- package/src/core/pain-context-extractor.ts +0 -306
- package/src/core/pain-lifecycle.ts +0 -38
- package/src/core/pain-signal-adapter.ts +0 -42
- package/src/core/pain-signal.ts +0 -22
- package/src/core/principle-injector.ts +0 -84
- package/src/core/principle-tree-migration.ts +0 -196
- package/src/core/storage-adapter.ts +0 -65
- package/src/core/telemetry-event.ts +0 -109
- package/src/core/training-program.ts +0 -632
- package/src/core/workspace-dir-service.ts +0 -119
- package/src/hooks/lifecycle-routing.ts +0 -125
- package/src/service/event-log-auditor.ts +0 -284
- package/src/service/evolution-queue-lock.ts +0 -47
- package/src/service/failure-classifier.ts +0 -79
- package/src/service/internalization-trigger-adapter.ts +0 -302
- package/src/service/monitoring-query-service.ts +0 -67
- package/src/service/subagent-workflow/index.ts +0 -17
- package/src/tools/critique-prompt.ts +0 -1
- package/src/tools/model-index.ts +0 -1
- package/src/types/event-payload.ts +0 -16
- package/src/utils/glob-match.ts +0 -50
- package/src/utils/nlp.ts +0 -25
- package/src/utils/plugin-logger.ts +0 -97
- package/src/utils/subagent-probe.ts +0 -81
- package/tests/core/evolution-hook.test.ts +0 -123
- package/tests/core/file-storage-adapter.test.ts +0 -285
- package/tests/core/merge-gate-audit.test.ts +0 -117
- package/tests/core/pain-context-extractor.test.ts +0 -279
- package/tests/core/pain-lifecycle.test.ts +0 -38
- package/tests/core/pain-signal-adapter.test.ts +0 -116
- package/tests/core/pain-signal.test.ts +0 -190
- package/tests/core/principle-injector.test.ts +0 -90
- package/tests/core/principle-tree-migration.test.ts +0 -77
- package/tests/core/storage-conformance.test.ts +0 -429
- package/tests/core/telemetry-event.test.ts +0 -119
- package/tests/core/training-program.test.ts +0 -472
- package/tests/core/workspace-dir-service.test.ts +0 -68
- package/tests/core/workspace-dir-validation.test.ts +0 -143
- package/tests/integration/internalization-trigger-guard.test.ts +0 -69
- package/tests/integration/pain-lifecycle-e2e.test.ts +0 -75
- package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +0 -209
- package/tests/service/failure-classifier.test.ts +0 -171
- package/tests/service/internalization-trigger-adapter.test.ts +0 -251
- package/tests/service/monitoring-query-service.test.ts +0 -67
- package/tests/utils/nlp.test.ts +0 -35
- package/tests/utils/plugin-logger.test.ts +0 -156
- package/tests/utils/subagent-probe.test.ts +0 -79
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
import { getImplementationAssetRoot } from './code-implementation-storage.js';
|
|
4
|
-
import { resolvePdPath } from './paths.js';
|
|
5
|
-
import type { ReplayReport } from './replay-engine.js';
|
|
6
|
-
|
|
7
|
-
export type MergeGateAuditStatus = 'pass' | 'block' | 'defer';
|
|
8
|
-
|
|
9
|
-
export interface MergeGateAuditCheck {
|
|
10
|
-
id: string;
|
|
11
|
-
status: MergeGateAuditStatus;
|
|
12
|
-
summary: string;
|
|
13
|
-
details?: Record<string, unknown>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface MergeGateAuditReport {
|
|
17
|
-
overallStatus: MergeGateAuditStatus;
|
|
18
|
-
generatedAt: string;
|
|
19
|
-
workspaceDir: string;
|
|
20
|
-
stateDir: string;
|
|
21
|
-
checks: MergeGateAuditCheck[];
|
|
22
|
-
counts: {
|
|
23
|
-
pass: number;
|
|
24
|
-
block: number;
|
|
25
|
-
defer: number;
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function computeOverallStatus(checks: MergeGateAuditCheck[]): MergeGateAuditStatus {
|
|
30
|
-
if (checks.some((check) => check.status === 'block')) {
|
|
31
|
-
return 'block';
|
|
32
|
-
}
|
|
33
|
-
if (checks.some((check) => check.status === 'defer')) {
|
|
34
|
-
return 'defer';
|
|
35
|
-
}
|
|
36
|
-
return 'pass';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function countStatuses(checks: MergeGateAuditCheck[]): MergeGateAuditReport['counts'] {
|
|
40
|
-
const counts = { pass: 0, block: 0, defer: 0 };
|
|
41
|
-
for (const check of checks) {
|
|
42
|
-
counts[check.status] += 1;
|
|
43
|
-
}
|
|
44
|
-
return counts;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function auditPainFlagPathContract(workspaceDir: string): MergeGateAuditCheck {
|
|
48
|
-
const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
|
|
49
|
-
const expectedPath = path.join(path.resolve(workspaceDir), '.state', '.pain_flag');
|
|
50
|
-
const normalizedPainFlagPath = path.normalize(painFlagPath);
|
|
51
|
-
const normalizedExpectedPath = path.normalize(expectedPath);
|
|
52
|
-
|
|
53
|
-
if (normalizedPainFlagPath !== normalizedExpectedPath) {
|
|
54
|
-
return {
|
|
55
|
-
id: 'pain_flag_path_contract',
|
|
56
|
-
status: 'block',
|
|
57
|
-
summary: 'Canonical pain flag path does not resolve under workspace/.state/.pain_flag.',
|
|
58
|
-
details: {
|
|
59
|
-
resolvedPath: normalizedPainFlagPath,
|
|
60
|
-
expectedPath: normalizedExpectedPath,
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
id: 'pain_flag_path_contract',
|
|
67
|
-
status: 'pass',
|
|
68
|
-
summary: 'Canonical pain flag path resolves to workspace/.state/.pain_flag.',
|
|
69
|
-
details: {
|
|
70
|
-
resolvedPath: normalizedPainFlagPath,
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function auditQueuePathContract(workspaceDir: string): MergeGateAuditCheck {
|
|
76
|
-
const queuePath = resolvePdPath(workspaceDir, 'EVOLUTION_QUEUE');
|
|
77
|
-
const expectedPath = path.join(path.resolve(workspaceDir), '.state', 'evolution_queue.json');
|
|
78
|
-
const normalizedQueuePath = path.normalize(queuePath);
|
|
79
|
-
const normalizedExpectedPath = path.normalize(expectedPath);
|
|
80
|
-
|
|
81
|
-
if (normalizedQueuePath !== normalizedExpectedPath) {
|
|
82
|
-
return {
|
|
83
|
-
id: 'queue_path_contract',
|
|
84
|
-
status: 'block',
|
|
85
|
-
summary: 'Canonical evolution queue path does not resolve under workspace/.state/evolution_queue.json.',
|
|
86
|
-
details: {
|
|
87
|
-
resolvedPath: normalizedQueuePath,
|
|
88
|
-
expectedPath: normalizedExpectedPath,
|
|
89
|
-
},
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
id: 'queue_path_contract',
|
|
95
|
-
status: 'pass',
|
|
96
|
-
summary: 'Canonical evolution queue path resolves to workspace/.state/evolution_queue.json.',
|
|
97
|
-
details: {
|
|
98
|
-
resolvedPath: normalizedQueuePath,
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function isReplayReportShape(value: unknown): value is ReplayReport {
|
|
104
|
-
if (!value || typeof value !== 'object') {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const report = value as Partial<ReplayReport>;
|
|
109
|
-
return (
|
|
110
|
-
typeof report.overallDecision === 'string' &&
|
|
111
|
-
typeof report.generatedAt === 'string' &&
|
|
112
|
-
typeof report.implementationId === 'string' &&
|
|
113
|
-
report.evidenceSummary !== undefined &&
|
|
114
|
-
Array.isArray(report.blockers)
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function collectReplayReportPaths(stateDir: string): string[] {
|
|
119
|
-
const implementationsRoot = path.join(stateDir, 'principles', 'implementations');
|
|
120
|
-
if (!fs.existsSync(implementationsRoot)) return [];
|
|
121
|
-
|
|
122
|
-
const implementationIds = fs
|
|
123
|
-
.readdirSync(implementationsRoot, { withFileTypes: true })
|
|
124
|
-
.filter((entry) => entry.isDirectory())
|
|
125
|
-
.map((entry) => entry.name);
|
|
126
|
-
|
|
127
|
-
const paths: string[] = [];
|
|
128
|
-
for (const id of implementationIds) {
|
|
129
|
-
const replaysDir = path.join(getImplementationAssetRoot(stateDir, id), 'replays');
|
|
130
|
-
if (!fs.existsSync(replaysDir)) continue;
|
|
131
|
-
|
|
132
|
-
const files = fs
|
|
133
|
-
.readdirSync(replaysDir, { withFileTypes: true })
|
|
134
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
135
|
-
.map((entry) => path.join(replaysDir, entry.name));
|
|
136
|
-
paths.push(...files);
|
|
137
|
-
}
|
|
138
|
-
return paths;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
type ReplayValidationCategory =
|
|
142
|
-
| 'io_error'
|
|
143
|
-
| 'malformed'
|
|
144
|
-
| 'missing_evidence_summary'
|
|
145
|
-
| 'unsupported_pass'
|
|
146
|
-
| 'empty_needs_review'
|
|
147
|
-
| 'valid';
|
|
148
|
-
|
|
149
|
-
function hasValidEvidenceSummary(parsed: unknown): boolean {
|
|
150
|
-
if (!parsed || typeof parsed !== 'object') return false;
|
|
151
|
-
const report = parsed as Partial<ReplayReport>;
|
|
152
|
-
const summary = report.evidenceSummary;
|
|
153
|
-
if (!summary) return false;
|
|
154
|
-
if (typeof (summary as Partial<ReplayReport['evidenceSummary']>).evidenceStatus !== 'string') {
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
157
|
-
return typeof (summary as Partial<ReplayReport['evidenceSummary']>).totalSamples === 'number';
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function validateSingleReplayReport(reportPath: string): ReplayValidationCategory {
|
|
161
|
-
let rawContent: string;
|
|
162
|
-
try {
|
|
163
|
-
rawContent = fs.readFileSync(reportPath, 'utf-8');
|
|
164
|
-
} catch {
|
|
165
|
-
return 'io_error';
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
let parsed: unknown;
|
|
169
|
-
try {
|
|
170
|
-
parsed = JSON.parse(rawContent);
|
|
171
|
-
} catch {
|
|
172
|
-
return 'malformed';
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (!isReplayReportShape(parsed)) {
|
|
176
|
-
return 'malformed';
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (!hasValidEvidenceSummary(parsed)) {
|
|
180
|
-
return 'missing_evidence_summary';
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const evidenceSummary = parsed.evidenceSummary;
|
|
184
|
-
if (parsed.overallDecision === 'pass' && evidenceSummary.totalSamples === 0) {
|
|
185
|
-
return 'unsupported_pass';
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (parsed.overallDecision === 'needs-review' && evidenceSummary.totalSamples === 0) {
|
|
189
|
-
return 'empty_needs_review';
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return 'valid';
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
interface ReplayValidationResults {
|
|
196
|
-
ioErrorReports: string[];
|
|
197
|
-
malformedReports: string[];
|
|
198
|
-
missingEvidenceSummary: string[];
|
|
199
|
-
unsupportedPassingReports: string[];
|
|
200
|
-
emptyEvidenceNeedsReview: string[];
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function categorizeReplayReports(reportPaths: string[]): ReplayValidationResults {
|
|
204
|
-
const results: ReplayValidationResults = {
|
|
205
|
-
ioErrorReports: [],
|
|
206
|
-
malformedReports: [],
|
|
207
|
-
missingEvidenceSummary: [],
|
|
208
|
-
unsupportedPassingReports: [],
|
|
209
|
-
emptyEvidenceNeedsReview: [],
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
for (const reportPath of reportPaths) {
|
|
213
|
-
const category = validateSingleReplayReport(reportPath);
|
|
214
|
-
switch (category) {
|
|
215
|
-
case 'io_error':
|
|
216
|
-
results.ioErrorReports.push(reportPath);
|
|
217
|
-
break;
|
|
218
|
-
case 'malformed':
|
|
219
|
-
results.malformedReports.push(reportPath);
|
|
220
|
-
break;
|
|
221
|
-
case 'missing_evidence_summary':
|
|
222
|
-
results.missingEvidenceSummary.push(reportPath);
|
|
223
|
-
break;
|
|
224
|
-
case 'unsupported_pass':
|
|
225
|
-
results.unsupportedPassingReports.push(reportPath);
|
|
226
|
-
break;
|
|
227
|
-
case 'empty_needs_review':
|
|
228
|
-
results.emptyEvidenceNeedsReview.push(reportPath);
|
|
229
|
-
break;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return results;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function hasValidationFailures(results: ReplayValidationResults): boolean {
|
|
237
|
-
return (
|
|
238
|
-
results.malformedReports.length > 0 ||
|
|
239
|
-
results.ioErrorReports.length > 0 ||
|
|
240
|
-
results.missingEvidenceSummary.length > 0 ||
|
|
241
|
-
results.unsupportedPassingReports.length > 0 ||
|
|
242
|
-
results.emptyEvidenceNeedsReview.length > 0
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function auditReplayEvidenceIntegrity(stateDir: string): MergeGateAuditCheck {
|
|
247
|
-
const replayReportPaths = collectReplayReportPaths(stateDir);
|
|
248
|
-
|
|
249
|
-
if (replayReportPaths.length === 0) {
|
|
250
|
-
return {
|
|
251
|
-
id: 'replay_evidence_integrity',
|
|
252
|
-
status: 'defer',
|
|
253
|
-
summary: 'No replay reports found. Replay evidence integrity cannot be verified yet.',
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const results = categorizeReplayReports(replayReportPaths);
|
|
258
|
-
|
|
259
|
-
if (hasValidationFailures(results)) {
|
|
260
|
-
return {
|
|
261
|
-
id: 'replay_evidence_integrity',
|
|
262
|
-
status: 'block',
|
|
263
|
-
summary: 'Replay reports contain malformed payloads, I/O errors, empty-evidence passes, or zero-evidence needs-review verdicts.',
|
|
264
|
-
details: {
|
|
265
|
-
reportCount: replayReportPaths.length,
|
|
266
|
-
...results,
|
|
267
|
-
},
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
return {
|
|
272
|
-
id: 'replay_evidence_integrity',
|
|
273
|
-
status: 'pass',
|
|
274
|
-
summary: 'Replay reports include evidence summaries and no empty-evidence unsafe verdicts.',
|
|
275
|
-
details: {
|
|
276
|
-
reportCount: replayReportPaths.length,
|
|
277
|
-
},
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
export function runMergeGateAudit(workspaceDir: string, stateDir: string): MergeGateAuditReport {
|
|
282
|
-
const checks: MergeGateAuditCheck[] = [
|
|
283
|
-
auditPainFlagPathContract(workspaceDir),
|
|
284
|
-
auditQueuePathContract(workspaceDir),
|
|
285
|
-
auditReplayEvidenceIntegrity(stateDir),
|
|
286
|
-
];
|
|
287
|
-
|
|
288
|
-
return {
|
|
289
|
-
overallStatus: computeOverallStatus(checks),
|
|
290
|
-
generatedAt: new Date().toISOString(),
|
|
291
|
-
workspaceDir: path.resolve(workspaceDir),
|
|
292
|
-
stateDir: path.resolve(stateDir),
|
|
293
|
-
checks,
|
|
294
|
-
counts: countStatuses(checks),
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
export function formatMergeGateAuditReport(report: MergeGateAuditReport): string {
|
|
299
|
-
const lines: string[] = [
|
|
300
|
-
'=== Merge Gate Audit ===',
|
|
301
|
-
`Overall Status: ${report.overallStatus.toUpperCase()}`,
|
|
302
|
-
`Generated At: ${report.generatedAt}`,
|
|
303
|
-
`Workspace: ${report.workspaceDir}`,
|
|
304
|
-
`State Dir: ${report.stateDir}`,
|
|
305
|
-
`Counts: pass=${report.counts.pass}, block=${report.counts.block}, defer=${report.counts.defer}`,
|
|
306
|
-
'',
|
|
307
|
-
];
|
|
308
|
-
|
|
309
|
-
for (const check of report.checks) {
|
|
310
|
-
lines.push(`[${check.status.toUpperCase()}] ${check.id}: ${check.summary}`);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
return `${lines.join('\n')}\n`;
|
|
314
|
-
}
|
|
@@ -1,306 +0,0 @@
|
|
|
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 2000 chars
|
|
19
|
-
* - All errors caught silently — return empty string on failure
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import type * as fs from 'fs';
|
|
23
|
-
import * as fsPromises from 'fs/promises';
|
|
24
|
-
import * as path from 'path';
|
|
25
|
-
import * as os from 'os';
|
|
26
|
-
|
|
27
|
-
// =========================================================================
|
|
28
|
-
// Safety Limits
|
|
29
|
-
// =========================================================================
|
|
30
|
-
|
|
31
|
-
/** Skip JSONL lines larger than this */
|
|
32
|
-
const MAX_LINE_BYTES = 100_000; // 100KB
|
|
33
|
-
/** Only read last portion of file */
|
|
34
|
-
const TAIL_READ_SIZE = 512_000; // 512KB
|
|
35
|
-
/** Max turns to extract */
|
|
36
|
-
const MAX_TURNS = 8;
|
|
37
|
-
/** Max chars per turn entry */
|
|
38
|
-
const MAX_TURN_CHARS = 400;
|
|
39
|
-
/** Max total output */
|
|
40
|
-
const MAX_OUTPUT_CHARS = 2000;
|
|
41
|
-
|
|
42
|
-
/** Valid characters for session IDs and agent IDs — prevents path traversal */
|
|
43
|
-
const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
44
|
-
|
|
45
|
-
function getAgentsDir(): string {
|
|
46
|
-
return process.env.PD_TEST_AGENTS_DIR || path.join(os.homedir(), '.openclaw', 'agents');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// =========================================================================
|
|
50
|
-
// Safe File Reading (Async)
|
|
51
|
-
// =========================================================================
|
|
52
|
-
|
|
53
|
-
async function safeTail(filePath: string): Promise<string[]> {
|
|
54
|
-
try {
|
|
55
|
-
// Check existence and stats asynchronously
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
let stat: fs.Stats;
|
|
59
|
-
try {
|
|
60
|
-
stat = await fsPromises.stat(filePath);
|
|
61
|
-
} catch {
|
|
62
|
-
return []; // File doesn't exist or can't be accessed
|
|
63
|
-
}
|
|
64
|
-
if (stat.size === 0) return [];
|
|
65
|
-
|
|
66
|
-
const isTruncated = stat.size > TAIL_READ_SIZE;
|
|
67
|
-
const readSize = Math.min(stat.size, TAIL_READ_SIZE);
|
|
68
|
-
const buffer = Buffer.alloc(readSize);
|
|
69
|
-
|
|
70
|
-
// Use async file read
|
|
71
|
-
const fileHandle = await fsPromises.open(filePath, 'r');
|
|
72
|
-
try {
|
|
73
|
-
await fileHandle.read(buffer, 0, readSize, stat.size - readSize);
|
|
74
|
-
await fileHandle.close();
|
|
75
|
-
const content = buffer.toString('utf8');
|
|
76
|
-
// Only strip first line if file was actually truncated (started mid-line)
|
|
77
|
-
const validContent = isTruncated ? content.slice(content.indexOf('\n') + 1) : content;
|
|
78
|
-
return validContent.split('\n').filter(l => l.trim().length > 0);
|
|
79
|
-
} catch (err) {
|
|
80
|
-
// Ensure file handle is closed even on error
|
|
81
|
-
try { await fileHandle.close(); } catch { /* ignore close error */ }
|
|
82
|
-
throw err;
|
|
83
|
-
}
|
|
84
|
-
} catch (err) {
|
|
85
|
-
console.debug(`[pain-context-extractor] safeTail failed: ${String(err)}`);
|
|
86
|
-
return [];
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// =========================================================================
|
|
91
|
-
// Safe JSONL Parsing
|
|
92
|
-
// =========================================================================
|
|
93
|
-
|
|
94
|
-
interface ParsedMessage {
|
|
95
|
-
role: string;
|
|
96
|
-
textParts: string[];
|
|
97
|
-
toolCalls: { id?: string; name?: string; arguments?: Record<string, unknown> }[];
|
|
98
|
-
toolCallId?: string;
|
|
99
|
-
toolName?: string;
|
|
100
|
-
details?: { exitCode?: number; isError?: boolean; aggregated?: string };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function parseSafeMessages(lines: string[]): ParsedMessage[] {
|
|
104
|
-
const messages: ParsedMessage[] = [];
|
|
105
|
-
for (const line of lines) {
|
|
106
|
-
if (line.length > MAX_LINE_BYTES) continue;
|
|
107
|
-
try {
|
|
108
|
-
const parsed = JSON.parse(line);
|
|
109
|
-
if (parsed.type !== 'message' || !parsed.message) continue;
|
|
110
|
-
const msg = parsed.message;
|
|
111
|
-
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
112
|
-
messages.push({
|
|
113
|
-
role: msg.role || '',
|
|
114
|
-
textParts: content.filter((c: { type: string }) => c.type === 'text').map((c: { text?: string }) => c.text || ''),
|
|
115
|
-
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 })),
|
|
116
|
-
toolCallId: msg.toolCallId,
|
|
117
|
-
toolName: msg.toolName,
|
|
118
|
-
details: msg.details,
|
|
119
|
-
});
|
|
120
|
-
} catch { /* malformed JSON line, skip silently — expected with corrupted files */ }
|
|
121
|
-
}
|
|
122
|
-
return messages;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// =========================================================================
|
|
126
|
-
// Turn Extraction
|
|
127
|
-
// =========================================================================
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Extracts a concise turn representation from a message.
|
|
131
|
-
* Returns null if nothing useful to extract.
|
|
132
|
-
*/
|
|
133
|
-
function extractTurn(msg: ParsedMessage): string | null {
|
|
134
|
-
if (msg.role === 'user' && msg.textParts.length > 0) {
|
|
135
|
-
// For user messages, skip system prompt injection patterns
|
|
136
|
-
const text = msg.textParts.join(' ').trim();
|
|
137
|
-
if (!text) return null;
|
|
138
|
-
// Skip if it looks like a system injection
|
|
139
|
-
if (text.startsWith('<evolution_task') || text.startsWith('<system_override') ||
|
|
140
|
-
text.startsWith('You are an empathy observer') || text.startsWith('Analyze ONLY') ||
|
|
141
|
-
text.startsWith('{"damageDetected"')) return null;
|
|
142
|
-
// Find the last meaningful user input line
|
|
143
|
-
const lines = text.split('\n');
|
|
144
|
-
let lastInput = '';
|
|
145
|
-
for (const line of lines) {
|
|
146
|
-
const trimmed = line.trim();
|
|
147
|
-
if (trimmed.length > 3 && trimmed.length < 500 &&
|
|
148
|
-
!trimmed.startsWith('<') && !trimmed.startsWith('{') &&
|
|
149
|
-
!trimmed.startsWith('Trust Score:') && !trimmed.startsWith('Hygiene:')) {
|
|
150
|
-
lastInput = trimmed;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
const userInput = lastInput || text;
|
|
154
|
-
return `[User]: ${userInput.substring(0, MAX_TURN_CHARS)}`;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (msg.role === 'assistant') {
|
|
158
|
-
const parts: string[] = [];
|
|
159
|
-
|
|
160
|
-
// Priority 1: final text reply (if present)
|
|
161
|
-
if (msg.textParts.length > 0) {
|
|
162
|
-
const text = msg.textParts.join(' ').trim();
|
|
163
|
-
if (text) parts.push(`[Assistant]: ${text.substring(0, MAX_TURN_CHARS)}`);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Priority 2: tool call summary (always include if present, for context)
|
|
167
|
-
if (msg.toolCalls.length > 0) {
|
|
168
|
-
const tools = msg.toolCalls.map(tc => tc.name).filter(Boolean);
|
|
169
|
-
const uniqueTools = [...new Set(tools)];
|
|
170
|
-
if (uniqueTools.length > 0) {
|
|
171
|
-
parts.push(`[Assistant → ${uniqueTools.join(', ')}]`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return parts.length > 0 ? parts.join(' ') : null;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (msg.role === 'toolResult') {
|
|
179
|
-
const exitCode = msg.details?.exitCode;
|
|
180
|
-
const isError = msg.details?.isError || (exitCode !== undefined && exitCode !== 0);
|
|
181
|
-
const text = msg.textParts.join(' ').trim();
|
|
182
|
-
const toolLabel = msg.toolName || 'tool';
|
|
183
|
-
|
|
184
|
-
if (isError) {
|
|
185
|
-
// Failed tool call — important for diagnosis
|
|
186
|
-
const errorPreview = text ? text.substring(0, MAX_TURN_CHARS) : `(exit ${exitCode ?? '?'})`;
|
|
187
|
-
return `[${toolLabel} FAILED]: ${errorPreview}`;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Successful tool call — include brief result
|
|
191
|
-
if (text) {
|
|
192
|
-
// For successful results, show first meaningful line
|
|
193
|
-
const lines = text.split('\n').filter(l => l.trim());
|
|
194
|
-
const firstLine = lines[0]?.substring(0, MAX_TURN_CHARS) || '';
|
|
195
|
-
if (firstLine) return `[${toolLabel}]: ${firstLine}`;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return null;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// =========================================================================
|
|
203
|
-
// Public API
|
|
204
|
-
// =========================================================================
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Extracts recent conversation context from a session's JSONL file.
|
|
208
|
-
*
|
|
209
|
-
* SAFETY: Tail-only read, skip oversized lines, cap output.
|
|
210
|
-
* Returns empty string on any failure — caller should use pain reason as fallback.
|
|
211
|
-
*/
|
|
212
|
-
|
|
213
|
-
export async function extractRecentConversation(
|
|
214
|
-
sessionId: string,
|
|
215
|
-
agentId = 'main',
|
|
216
|
-
maxTurns: number = MAX_TURNS,
|
|
217
|
-
): Promise<string> {
|
|
218
|
-
if (!sessionId || sessionId.length < 5 || !SAFE_ID_REGEX.test(sessionId)) return '';
|
|
219
|
-
if (agentId && !SAFE_ID_REGEX.test(agentId)) return '';
|
|
220
|
-
try {
|
|
221
|
-
const jsonlPath = path.join(getAgentsDir(), agentId, 'sessions', `${sessionId}.jsonl`);
|
|
222
|
-
const lines = await safeTail(jsonlPath);
|
|
223
|
-
const messages = parseSafeMessages(lines);
|
|
224
|
-
if (messages.length === 0) return '';
|
|
225
|
-
|
|
226
|
-
const turns: string[] = [];
|
|
227
|
-
for (const msg of messages) {
|
|
228
|
-
const turn = extractTurn(msg);
|
|
229
|
-
if (turn) turns.push(turn);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const recent = turns.slice(-maxTurns);
|
|
233
|
-
if (recent.length === 0) return '';
|
|
234
|
-
const result = recent.join('\n');
|
|
235
|
-
return result.length > MAX_OUTPUT_CHARS ? result.substring(0, MAX_OUTPUT_CHARS - 3) + '...' : result;
|
|
236
|
-
} catch (err) {
|
|
237
|
-
console.debug(`[pain-context-extractor] extractRecentConversation failed for session=${sessionId}, agent=${agentId}: ${String(err)}`);
|
|
238
|
-
return ''; // Fail silently
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Extracts failed tool call context with argument correlation.
|
|
244
|
-
*/
|
|
245
|
-
|
|
246
|
-
export async function extractFailedToolContext(
|
|
247
|
-
sessionId: string,
|
|
248
|
-
agentId: string,
|
|
249
|
-
toolName: string,
|
|
250
|
-
filePath?: string,
|
|
251
|
-
): Promise<string> {
|
|
252
|
-
if (!sessionId || sessionId.length < 5 || !SAFE_ID_REGEX.test(sessionId) || !toolName) return '';
|
|
253
|
-
if (agentId && !SAFE_ID_REGEX.test(agentId)) return '';
|
|
254
|
-
try {
|
|
255
|
-
const jsonlPath = path.join(getAgentsDir(), agentId, 'sessions', `${sessionId}.jsonl`);
|
|
256
|
-
const lines = await safeTail(jsonlPath);
|
|
257
|
-
const messages = parseSafeMessages(lines);
|
|
258
|
-
if (messages.length === 0) return '';
|
|
259
|
-
|
|
260
|
-
// Build toolCallId → arguments map
|
|
261
|
-
// Keep both full args (for matching) and truncated (for display)
|
|
262
|
-
const toolArgsById = new Map<string, { name: string; fullArgs: string; previewArgs: string }>();
|
|
263
|
-
for (const msg of messages) {
|
|
264
|
-
for (const tc of msg.toolCalls) {
|
|
265
|
-
if (tc.id && tc.name) {
|
|
266
|
-
const args = tc.arguments || {};
|
|
267
|
-
const fullArgs = JSON.stringify(args);
|
|
268
|
-
const truncated: Record<string, unknown> = {};
|
|
269
|
-
for (const [k, v] of Object.entries(args)) {
|
|
270
|
-
const s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
271
|
-
truncated[k] = s.length > 150 ? s.substring(0, 150) + '...' : s;
|
|
272
|
-
}
|
|
273
|
-
toolArgsById.set(tc.id, {
|
|
274
|
-
name: tc.name,
|
|
275
|
-
fullArgs,
|
|
276
|
-
previewArgs: JSON.stringify(truncated, null, 2),
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const parts: string[] = [];
|
|
283
|
-
for (const msg of messages) {
|
|
284
|
-
if (msg.role === 'toolResult' && msg.toolName === toolName) {
|
|
285
|
-
const exitCode = msg.details?.exitCode;
|
|
286
|
-
const isError = msg.details?.isError || (exitCode !== undefined && exitCode !== 0);
|
|
287
|
-
if (!isError) continue;
|
|
288
|
-
|
|
289
|
-
const {toolCallId} = msg;
|
|
290
|
-
const correlated = toolCallId ? toolArgsById.get(toolCallId) : null;
|
|
291
|
-
if (filePath && correlated && !correlated.fullArgs.includes(filePath)) continue;
|
|
292
|
-
|
|
293
|
-
parts.push(`[Tool Call: ${correlated?.name || toolName}]`);
|
|
294
|
-
if (correlated) parts.push(`Arguments: ${correlated.previewArgs.substring(0, 300)}`);
|
|
295
|
-
parts.push(`Exit Code: ${exitCode ?? 'N/A'}`);
|
|
296
|
-
const errorText = msg.textParts.join(' ').trim();
|
|
297
|
-
if (errorText) parts.push(`Error: ${errorText.substring(0, 500)}`);
|
|
298
|
-
break;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
return parts.length > 0 ? parts.join('\n') : '';
|
|
302
|
-
} catch (err) {
|
|
303
|
-
console.debug(`[pain-context-extractor] extractFailedToolContext failed for tool=${toolName}, session=${sessionId}: ${String(err)}`);
|
|
304
|
-
return '';
|
|
305
|
-
}
|
|
306
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
|
-
import { resolvePdPath } from './paths.js';
|
|
3
|
-
|
|
4
|
-
export const PAIN_FLAG_FILENAME = '.pain_flag';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Removes the .pain_flag file from the workspace's .state directory.
|
|
8
|
-
* Called when a pain signal task completes (success, timeout, duplicate, or invalid)
|
|
9
|
-
* to prevent stale flags from triggering repeated processing.
|
|
10
|
-
*
|
|
11
|
-
* Optionally verifies the file content before deleting to prevent accidentally removing
|
|
12
|
-
* a concurrent new signal that was written between checkPainFlag reading the file and
|
|
13
|
-
* this deletion call (TOCTOU race).
|
|
14
|
-
*
|
|
15
|
-
* @param workspaceDir - Workspace directory
|
|
16
|
-
* @param expectedPainEventId - If provided, only deletes the file if its pain_event_id matches.
|
|
17
|
-
* This prevents deleting a newly written signal during a race window.
|
|
18
|
-
*/
|
|
19
|
-
export function clearPainFlag(workspaceDir: string, expectedPainEventId?: number | string): void {
|
|
20
|
-
const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
|
|
21
|
-
try {
|
|
22
|
-
// Guard against TOCTOU race: if expectedPainEventId is provided,
|
|
23
|
-
// re-read the file and verify the pain_event_id matches before deleting.
|
|
24
|
-
// This prevents accidentally removing a new signal written between
|
|
25
|
-
// checkPainFlag reading the flag and this deletion.
|
|
26
|
-
if (expectedPainEventId !== undefined) {
|
|
27
|
-
const content = fs.readFileSync(painFlagPath, 'utf8');
|
|
28
|
-
const idMatch = content.includes(`pain_event_id: ${expectedPainEventId}`);
|
|
29
|
-
if (!idMatch) {
|
|
30
|
-
// File was rewritten with a different signal — do not delete.
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
fs.unlinkSync(painFlagPath);
|
|
35
|
-
} catch {
|
|
36
|
-
// Best-effort cleanup — ENOENT means already gone, other errors are ignored.
|
|
37
|
-
}
|
|
38
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PainSignalAdapter interface for the Evolution SDK.
|
|
3
|
-
*
|
|
4
|
-
* This interface decouples the evolution engine from specific AI agent
|
|
5
|
-
* frameworks (OpenClaw, Claude Code, etc.). All modules that need to
|
|
6
|
-
* capture pain signals from tool failures should depend on this interface
|
|
7
|
-
* rather than importing framework-specific event types directly.
|
|
8
|
-
*
|
|
9
|
-
* The interface uses a generic type parameter for the raw framework event,
|
|
10
|
-
* so each framework implementation provides its own concrete type.
|
|
11
|
-
*/
|
|
12
|
-
import type { PainSignal } from './pain-signal.js';
|
|
13
|
-
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
// PainSignalAdapter Interface
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Framework-agnostic adapter for capturing pain signals.
|
|
20
|
-
*
|
|
21
|
-
* @typeParam TRawEvent - The framework-specific event type
|
|
22
|
-
* (e.g., PluginHookAfterToolCallEvent for OpenClaw)
|
|
23
|
-
*/
|
|
24
|
-
export interface PainSignalAdapter<TRawEvent> {
|
|
25
|
-
/**
|
|
26
|
-
* Translate a framework-specific event into a universal PainSignal.
|
|
27
|
-
*
|
|
28
|
-
* Returns null when the event does not produce a pain signal (e.g., the
|
|
29
|
-
* event type is not a failure, or the event lacks required fields).
|
|
30
|
-
*
|
|
31
|
-
* This method performs pure translation only. Trigger decision logic
|
|
32
|
-
* (e.g., GFI threshold checks, tool name filtering) stays in the
|
|
33
|
-
* framework-side hook logic. Per D-02, capture() only translates.
|
|
34
|
-
*
|
|
35
|
-
* Translation failures (malformed events, missing required fields)
|
|
36
|
-
* return null rather than throwing. This keeps the adapter resilient.
|
|
37
|
-
*
|
|
38
|
-
* @param rawEvent - The framework-specific event to translate
|
|
39
|
-
* @returns A valid PainSignal, or null if the event does not produce one
|
|
40
|
-
*/
|
|
41
|
-
capture(rawEvent: TRawEvent): PainSignal | null;
|
|
42
|
-
}
|