principles-disciple 1.26.0 → 1.28.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 +4 -4
- package/package.json +1 -1
- package/scripts/diagnose-nocturnal.mjs +139 -2
- package/scripts/seed-nocturnal-scenarios.mjs +377 -0
- package/src/core/nocturnal-trinity.ts +8 -7
- package/src/core/trajectory.ts +74 -10
- package/src/index.ts +2 -0
- package/src/service/evolution-worker.ts +137 -43
- package/src/tools/write-pain-flag.ts +191 -0
- package/templates/langs/en/skills/pd-pain-signal/SKILL.md +34 -20
- package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +34 -20
- package/tests/core/nocturnal-e2e.test.ts +224 -0
- package/tests/core/trajectory-correction-pain.test.ts +180 -0
- package/tests/tools/write-pain-flag.test.ts +240 -0
package/src/core/trajectory.ts
CHANGED
|
@@ -961,6 +961,17 @@ export class TrajectoryDatabase {
|
|
|
961
961
|
throw new SampleNotFoundError(`${sampleId} (after update)`);
|
|
962
962
|
}
|
|
963
963
|
|
|
964
|
+
// #Phase2b: Emit pain event for rejected corrections
|
|
965
|
+
if (status === 'rejected') {
|
|
966
|
+
this.recordCorrectionRejectedPain({
|
|
967
|
+
session_id: record.session_id,
|
|
968
|
+
quality_score: record.quality_score,
|
|
969
|
+
diff_excerpt: record.diff_excerpt,
|
|
970
|
+
principle_ids_json: record.principle_ids_json,
|
|
971
|
+
created_at: record.created_at,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
964
975
|
return {
|
|
965
976
|
sampleId: String(record.sample_id),
|
|
966
977
|
sessionId: String(record.session_id),
|
|
@@ -977,6 +988,43 @@ export class TrajectoryDatabase {
|
|
|
977
988
|
};
|
|
978
989
|
}
|
|
979
990
|
|
|
991
|
+
/**
|
|
992
|
+
* When a correction sample is rejected, emit a pain event to the trajectory.
|
|
993
|
+
* This feeds rejected corrections into the nocturnal pipeline as a high-fidelity
|
|
994
|
+
* violation signal (human-verified, unlike heuristic pain detection).
|
|
995
|
+
*/
|
|
996
|
+
private recordCorrectionRejectedPain(record: {
|
|
997
|
+
session_id: unknown;
|
|
998
|
+
quality_score: unknown;
|
|
999
|
+
diff_excerpt: unknown;
|
|
1000
|
+
principle_ids_json: unknown;
|
|
1001
|
+
created_at: unknown;
|
|
1002
|
+
}): void {
|
|
1003
|
+
const sessionId = String(record.session_id);
|
|
1004
|
+
const qualityScore = Number(record.quality_score);
|
|
1005
|
+
const diffExcerpt = String(record.diff_excerpt ?? '');
|
|
1006
|
+
const principleIds = String(record.principle_ids_json ?? '[]');
|
|
1007
|
+
// quality_score (0-100) from correction sample → pain score (0-100), clamped
|
|
1008
|
+
const painScore = Math.max(0, Math.min(100, Math.round(Number(qualityScore) || 0)));
|
|
1009
|
+
const reason = `Correction rejected (quality ${qualityScore.toFixed(2)}). Principles: ${principleIds}${diffExcerpt ? ` — ${diffExcerpt.slice(0, 120)}` : ''}`;
|
|
1010
|
+
|
|
1011
|
+
try {
|
|
1012
|
+
this.recordPainEvent({
|
|
1013
|
+
sessionId,
|
|
1014
|
+
source: 'correction_rejected',
|
|
1015
|
+
score: painScore,
|
|
1016
|
+
reason,
|
|
1017
|
+
severity: painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild',
|
|
1018
|
+
origin: 'system_infer',
|
|
1019
|
+
text: diffExcerpt || undefined,
|
|
1020
|
+
createdAt: String(record.created_at),
|
|
1021
|
+
});
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
// Non-fatal: pain event recording should not break the review flow
|
|
1024
|
+
console.warn(`[Trajectory] Failed to record correction_rejected pain event: ${String(err)}`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
980
1028
|
/**
|
|
981
1029
|
* Export correction samples to JSONL file.
|
|
982
1030
|
*
|
|
@@ -1496,6 +1544,11 @@ export class TrajectoryDatabase {
|
|
|
1496
1544
|
`).get(sessionId) as Record<string, unknown> | undefined;
|
|
1497
1545
|
if (!correctionTurn || !correctionTurn.references_assistant_turn_id) return;
|
|
1498
1546
|
|
|
1547
|
+
// #Phase2b-fix: Tool failure is NOT required for correction samples.
|
|
1548
|
+
// User corrections are the highest-fidelity signal — they indicate the agent
|
|
1549
|
+
// said or did something wrong, regardless of whether tool calls succeeded.
|
|
1550
|
+
// Requiring tool failure excluded the most valuable cases: "agent did something
|
|
1551
|
+
// that technically worked but violated a principle or was logically wrong."
|
|
1499
1552
|
const failedCall = this.db.prepare(`
|
|
1500
1553
|
SELECT id, tool_name, error_type, error_message
|
|
1501
1554
|
FROM tool_calls
|
|
@@ -1503,26 +1556,37 @@ export class TrajectoryDatabase {
|
|
|
1503
1556
|
ORDER BY id DESC
|
|
1504
1557
|
LIMIT 1
|
|
1505
1558
|
`).get(sessionId) as Record<string, unknown> | undefined;
|
|
1506
|
-
if (!failedCall) return;
|
|
1507
1559
|
|
|
1508
|
-
const
|
|
1509
|
-
SELECT id, tool_name
|
|
1560
|
+
const recentCalls = this.db.prepare(`
|
|
1561
|
+
SELECT id, tool_name, outcome
|
|
1510
1562
|
FROM tool_calls
|
|
1511
|
-
WHERE session_id = ?
|
|
1563
|
+
WHERE session_id = ?
|
|
1512
1564
|
ORDER BY id DESC
|
|
1513
|
-
LIMIT
|
|
1565
|
+
LIMIT 5
|
|
1514
1566
|
`).all(sessionId) as Record<string, unknown>[];
|
|
1515
|
-
if (successfulCalls.length === 0) return;
|
|
1516
1567
|
|
|
1517
|
-
const
|
|
1568
|
+
const successfulCalls = recentCalls.filter(c => c.outcome === 'success');
|
|
1569
|
+
|
|
1570
|
+
// Generate sample ID from correction turn + first recent call (or correction id if no calls)
|
|
1571
|
+
const refForHash = successfulCalls[0]?.id ?? correctionTurn.id;
|
|
1572
|
+
const sampleId = `sample_${crypto.createHash('md5').update(`${sessionId}:${correctionTurn.id}:${refForHash}`).digest('hex').slice(0, 12)}`;
|
|
1518
1573
|
const userRawText = this.restoreRawText(correctionTurn.raw_text as string | null, correctionTurn.blob_ref as string | null);
|
|
1574
|
+
|
|
1575
|
+
// Quality scoring: correction cue is always valuable
|
|
1576
|
+
// Tool failure adds context (20pts), successful calls add context (up to 15pts)
|
|
1577
|
+
// Pure conversation corrections still score 55-75 (high enough to review)
|
|
1519
1578
|
const qualityScore = [
|
|
1520
1579
|
correctionTurn.references_assistant_turn_id ? 35 : 0,
|
|
1521
1580
|
correctionTurn.correction_cue ? 20 : 0,
|
|
1522
1581
|
failedCall ? 20 : 0,
|
|
1523
|
-
successfulCalls.length
|
|
1582
|
+
Math.min(successfulCalls.length, 3) * 5,
|
|
1524
1583
|
].reduce((sum, value) => sum + value, 0);
|
|
1525
1584
|
|
|
1585
|
+
// Diff excerpt: prefer user correction text, fallback to error info, fallback to cue
|
|
1586
|
+
const diffText = userRawText
|
|
1587
|
+
|| (failedCall ? String(failedCall.error_message ?? failedCall.error_type ?? failedCall.tool_name) : '')
|
|
1588
|
+
|| String(correctionTurn.correction_cue ?? 'user correction');
|
|
1589
|
+
|
|
1526
1590
|
this.withWrite(() => {
|
|
1527
1591
|
this.db.prepare(`
|
|
1528
1592
|
INSERT OR IGNORE INTO correction_samples (
|
|
@@ -1535,8 +1599,8 @@ export class TrajectoryDatabase {
|
|
|
1535
1599
|
sessionId,
|
|
1536
1600
|
Number(correctionTurn.references_assistant_turn_id),
|
|
1537
1601
|
Number(correctionTurn.id),
|
|
1538
|
-
safeJson(successfulCalls.map((call) => ({ id: call.id, toolName: call.tool_name }))),
|
|
1539
|
-
summarizeForDiff(
|
|
1602
|
+
safeJson(successfulCalls.map((call) => ({ id: call.id, toolName: call.tool_name, outcome: call.outcome }))),
|
|
1603
|
+
summarizeForDiff(diffText),
|
|
1540
1604
|
'[]',
|
|
1541
1605
|
qualityScore,
|
|
1542
1606
|
nowIso(),
|
package/src/index.ts
CHANGED
|
@@ -57,6 +57,7 @@ import { ensureWorkspaceTemplates } from './core/init.js';
|
|
|
57
57
|
import { migrateDirectoryStructure } from './core/migration.js';
|
|
58
58
|
import { SystemLogger } from './core/system-logger.js';
|
|
59
59
|
import { createDeepReflectTool } from './tools/deep-reflect.js';
|
|
60
|
+
import { createWritePainFlagTool } from './tools/write-pain-flag.js';
|
|
60
61
|
import { PathResolver, resolveWorkspaceDirFromApi } from './core/path-resolver.js';
|
|
61
62
|
import { validateWorkspaceDir } from './core/workspace-dir-validation.js';
|
|
62
63
|
import { resolveRequiredWorkspaceDir, resolveWorkspaceDir, type WorkspaceResolutionContext } from './core/workspace-dir-service.js';
|
|
@@ -773,6 +774,7 @@ const plugin = {
|
|
|
773
774
|
});
|
|
774
775
|
|
|
775
776
|
api.registerTool(createDeepReflectTool(api));
|
|
777
|
+
api.registerTool(createWritePainFlagTool(api));
|
|
776
778
|
}
|
|
777
779
|
};
|
|
778
780
|
|
|
@@ -356,7 +356,8 @@ function isSessionAtOrBeforeTriggerTime(
|
|
|
356
356
|
|
|
357
357
|
function buildFallbackNocturnalSnapshot(
|
|
358
358
|
sleepTask: EvolutionQueueItem,
|
|
359
|
-
extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null
|
|
359
|
+
extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null,
|
|
360
|
+
logger?: { warn?: (message: string) => void }
|
|
360
361
|
): NocturnalSessionSnapshot | null {
|
|
361
362
|
const painContext = sleepTask.recentPainContext;
|
|
362
363
|
if (!painContext) {
|
|
@@ -390,8 +391,10 @@ function buildFallbackNocturnalSnapshot(
|
|
|
390
391
|
totalGateBlocks: match.gateBlockCount,
|
|
391
392
|
};
|
|
392
393
|
}
|
|
393
|
-
} catch {
|
|
394
|
-
//
|
|
394
|
+
} catch (err) {
|
|
395
|
+
// #260: Log extraction failures — silent swallowing makes debugging impossible
|
|
396
|
+
// and can mask systemic trajectory DB issues.
|
|
397
|
+
logger?.warn?.(`[PD:EvolutionWorker] Failed to extract real stats for session ${painContext.mostRecent?.sessionId} (falling back to zeros): ${String(err)}`);
|
|
395
398
|
}
|
|
396
399
|
}
|
|
397
400
|
|
|
@@ -590,63 +593,154 @@ export function readRecentPainContext(wctx: WorkspaceContext): RecentPainContext
|
|
|
590
593
|
return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
|
|
591
594
|
}
|
|
592
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Build a dedup key from pain context.
|
|
598
|
+
* Returns null when no pain context is available (bypasses dedup).
|
|
599
|
+
*/
|
|
600
|
+
function buildPainSourceKey(
|
|
601
|
+
painCtx: ReturnType<typeof readRecentPainContext>,
|
|
602
|
+
): string | null {
|
|
603
|
+
if (!painCtx.mostRecent) return null;
|
|
604
|
+
return `${painCtx.mostRecent.source}::${painCtx.mostRecent.reason?.slice(0, 50) ?? ''}`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Check whether a similar sleep_reflection task completed recently.
|
|
609
|
+
* Phase 3c: Prevents redundant reflections of the same underlying issue.
|
|
610
|
+
*/
|
|
611
|
+
function hasRecentSimilarReflection(
|
|
612
|
+
queue: EvolutionQueueItem[],
|
|
613
|
+
painSourceKey: string,
|
|
614
|
+
now: number,
|
|
615
|
+
): EvolutionQueueItem | null {
|
|
616
|
+
const DEDUP_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
617
|
+
return queue.find((t) => {
|
|
618
|
+
if (t.taskKind !== 'sleep_reflection') return false;
|
|
619
|
+
// Only match completed tasks (exclude failed to allow retries)
|
|
620
|
+
if (t.status !== 'completed') return false;
|
|
621
|
+
if (!t.completed_at) return false;
|
|
622
|
+
const age = now - new Date(t.completed_at).getTime();
|
|
623
|
+
if (age > DEDUP_WINDOW_MS) return false;
|
|
624
|
+
const taskPainKey = buildPainSourceKey(t.recentPainContext ?? { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 });
|
|
625
|
+
// If either side has no pain context, they don't match
|
|
626
|
+
if (!taskPainKey) return false;
|
|
627
|
+
return taskPainKey === painSourceKey;
|
|
628
|
+
}) ?? null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Check whether a specific task kind has a pending or in-progress entry.
|
|
633
|
+
*/
|
|
634
|
+
function hasPendingTask(queue: EvolutionQueueItem[], taskKind: string): boolean {
|
|
635
|
+
return queue.some(
|
|
636
|
+
(t) => t.taskKind === taskKind && (t.status === 'pending' || t.status === 'in_progress'),
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Decide whether to skip enqueuing due to a recent similar reflection.
|
|
642
|
+
* Returns true if skipped (with log), false if should proceed.
|
|
643
|
+
*/
|
|
644
|
+
function shouldSkipForDedup(
|
|
645
|
+
queue: EvolutionQueueItem[],
|
|
646
|
+
wctx: WorkspaceContext,
|
|
647
|
+
logger: PluginLogger,
|
|
648
|
+
): boolean {
|
|
649
|
+
const recentPainContext = readRecentPainContext(wctx);
|
|
650
|
+
const painSourceKey = buildPainSourceKey(recentPainContext);
|
|
651
|
+
|
|
652
|
+
// Bypass dedup when there is no pain context — general idle reflections
|
|
653
|
+
// should not be throttled by the 'no_pain_context' sentinel.
|
|
654
|
+
if (!painSourceKey) return false;
|
|
655
|
+
|
|
656
|
+
const now = Date.now();
|
|
657
|
+
const recentSimilarReflection = hasRecentSimilarReflection(queue, painSourceKey, now);
|
|
658
|
+
|
|
659
|
+
if (recentSimilarReflection) {
|
|
660
|
+
const completedTime = new Date(recentSimilarReflection.completed_at!).getTime();
|
|
661
|
+
logger?.debug?.(`[PD:EvolutionWorker] Skipping sleep_reflection — similar reflection completed ${Math.round((now - completedTime) / 60000)}min ago (same pain pattern: ${painSourceKey})`);
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Load and migrate the evolution queue. Returns empty array if file doesn't exist.
|
|
669
|
+
*/
|
|
670
|
+
function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
|
|
671
|
+
let rawQueue: RawQueueItem[] = [];
|
|
672
|
+
try {
|
|
673
|
+
rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
674
|
+
} catch {
|
|
675
|
+
// Queue doesn't exist yet - create empty array
|
|
676
|
+
rawQueue = [];
|
|
677
|
+
}
|
|
678
|
+
return migrateQueueToV2(rawQueue);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Build and persist a new sleep_reflection task.
|
|
683
|
+
*/
|
|
684
|
+
function enqueueNewSleepReflectionTask(
|
|
685
|
+
queue: EvolutionQueueItem[],
|
|
686
|
+
recentPainContext: ReturnType<typeof readRecentPainContext>,
|
|
687
|
+
queuePath: string,
|
|
688
|
+
logger: PluginLogger,
|
|
689
|
+
): void {
|
|
690
|
+
const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', Date.now());
|
|
691
|
+
const nowIso = new Date().toISOString();
|
|
692
|
+
|
|
693
|
+
queue.push({
|
|
694
|
+
id: taskId,
|
|
695
|
+
taskKind: 'sleep_reflection',
|
|
696
|
+
priority: 'medium',
|
|
697
|
+
score: 50,
|
|
698
|
+
source: 'nocturnal',
|
|
699
|
+
reason: 'Sleep-mode reflection triggered by idle workspace',
|
|
700
|
+
trigger_text_preview: 'Idle workspace detected',
|
|
701
|
+
timestamp: nowIso,
|
|
702
|
+
enqueued_at: nowIso,
|
|
703
|
+
status: 'pending',
|
|
704
|
+
traceId: taskId,
|
|
705
|
+
retryCount: 0,
|
|
706
|
+
maxRetries: 1, // sleep_reflection doesn't retry
|
|
707
|
+
recentPainContext,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
711
|
+
logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
|
|
712
|
+
}
|
|
713
|
+
|
|
593
714
|
/**
|
|
594
715
|
* Enqueue a sleep_reflection task if one is not already pending.
|
|
595
716
|
* Phase 2.4: Called when workspace is idle to trigger nocturnal reflection.
|
|
717
|
+
* Phase 3c: Dedup checks recent sleep_reflection tasks by pain source pattern
|
|
718
|
+
* to prevent redundant reflections of the same underlying issue.
|
|
596
719
|
*/
|
|
597
720
|
async function enqueueSleepReflectionTask(
|
|
598
721
|
wctx: WorkspaceContext,
|
|
599
|
-
logger: PluginLogger
|
|
722
|
+
logger: PluginLogger,
|
|
600
723
|
): Promise<void> {
|
|
601
724
|
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
602
725
|
const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueSleepReflection', EVOLUTION_QUEUE_LOCK_SUFFIX);
|
|
603
726
|
|
|
604
727
|
try {
|
|
605
|
-
|
|
606
|
-
try {
|
|
607
|
-
rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
608
|
-
} catch {
|
|
609
|
-
// Queue doesn't exist yet - create empty array
|
|
610
|
-
rawQueue = [];
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
|
|
728
|
+
const queue = loadEvolutionQueue(queuePath);
|
|
614
729
|
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
t => t.taskKind === 'sleep_reflection' && (t.status === 'pending' || t.status === 'in_progress')
|
|
618
|
-
);
|
|
619
|
-
if (hasPendingSleepReflection) {
|
|
730
|
+
// Guard 1: Skip if a sleep_reflection task is already pending/in-progress
|
|
731
|
+
if (hasPendingTask(queue, 'sleep_reflection')) {
|
|
620
732
|
logger?.debug?.('[PD:EvolutionWorker] sleep_reflection task already pending/in-progress, skipping');
|
|
621
733
|
return;
|
|
622
734
|
}
|
|
623
735
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
736
|
+
// Guard 2: Dedup — skip if similar reflection completed recently
|
|
737
|
+
if (shouldSkipForDedup(queue, wctx, logger)) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
627
740
|
|
|
628
|
-
//
|
|
741
|
+
// Enqueue the new task
|
|
629
742
|
const recentPainContext = readRecentPainContext(wctx);
|
|
630
|
-
|
|
631
|
-
queue.push({
|
|
632
|
-
id: taskId,
|
|
633
|
-
taskKind: 'sleep_reflection',
|
|
634
|
-
priority: 'medium',
|
|
635
|
-
score: 50,
|
|
636
|
-
source: 'nocturnal',
|
|
637
|
-
reason: 'Sleep-mode reflection triggered by idle workspace',
|
|
638
|
-
trigger_text_preview: 'Idle workspace detected',
|
|
639
|
-
timestamp: nowIso,
|
|
640
|
-
enqueued_at: nowIso,
|
|
641
|
-
status: 'pending',
|
|
642
|
-
traceId: taskId,
|
|
643
|
-
retryCount: 0,
|
|
644
|
-
maxRetries: 1, // sleep_reflection doesn't retry
|
|
645
|
-
recentPainContext,
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
649
|
-
logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
|
|
743
|
+
enqueueNewSleepReflectionTask(queue, recentPainContext, queuePath, logger);
|
|
650
744
|
} finally {
|
|
651
745
|
releaseLock();
|
|
652
746
|
}
|
|
@@ -1565,7 +1659,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1565
1659
|
// Phase 2: If no trajectory data, try pain-context fallback
|
|
1566
1660
|
if (!snapshotData && sleepTask.recentPainContext) {
|
|
1567
1661
|
logger?.warn?.(`[PD:EvolutionWorker] Using pain-context fallback for ${sleepTask.id}: trajectory snapshot unavailable, will try session summary from extractor`);
|
|
1568
|
-
snapshotData = buildFallbackNocturnalSnapshot(sleepTask, extractor) ?? undefined;
|
|
1662
|
+
snapshotData = buildFallbackNocturnalSnapshot(sleepTask, extractor, logger) ?? undefined;
|
|
1569
1663
|
}
|
|
1570
1664
|
|
|
1571
1665
|
const snapshotValidation = validateNocturnalSnapshotIngress(snapshotData);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from '../openclaw-sdk.js';
|
|
2
|
+
import { Type } from '@sinclair/typebox';
|
|
3
|
+
import { buildPainFlag, writePainFlag } from '../core/pain.js';
|
|
4
|
+
import { resolveWorkspaceDirFromApi } from '../core/path-resolver.js';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
|
|
8
|
+
// Pain flag contract required fields
|
|
9
|
+
const PAIN_FLAG_REQUIRED_FIELDS = ['source', 'score', 'time', 'reason'] as const;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Atomic file write: write to temp file then rename.
|
|
13
|
+
* Prevents corruption if process crashes mid-write.
|
|
14
|
+
*/
|
|
15
|
+
function writePainFlagAtomic(filePath: string, content: string): void {
|
|
16
|
+
const dir = path.dirname(filePath);
|
|
17
|
+
if (!fs.existsSync(dir)) {
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
const tmpPath = `${filePath}.tmp.${Date.now()}.${process.pid}`;
|
|
21
|
+
fs.writeFileSync(tmpPath, content, 'utf-8');
|
|
22
|
+
fs.renameSync(tmpPath, filePath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates the `write_pain_flag` tool.
|
|
27
|
+
*
|
|
28
|
+
* This tool allows the agent to record a pain signal when it recognizes
|
|
29
|
+
* that it made a mistake, violated a principle, or needs to flag an issue
|
|
30
|
+
* for later reflection.
|
|
31
|
+
*
|
|
32
|
+
* The tool wraps `buildPainFlag` + atomic `writePainFlag` to ensure:
|
|
33
|
+
* - Correct KV format serialization (never [object Object] corruption)
|
|
34
|
+
* - Atomic writes (temp file + rename, crash-safe)
|
|
35
|
+
* - Full contract compliance (source, score, time, reason)
|
|
36
|
+
*
|
|
37
|
+
* The agent should NEVER write to .pain_flag directly.
|
|
38
|
+
*/
|
|
39
|
+
export function createWritePainFlagTool(api: OpenClawPluginApi) {
|
|
40
|
+
return {
|
|
41
|
+
name: 'write_pain_flag',
|
|
42
|
+
description:
|
|
43
|
+
'Record a pain signal to flag mistakes, principle violations, or issues for later reflection. ' +
|
|
44
|
+
'Use this tool INSTEAD of writing .pain_flag directly. ' +
|
|
45
|
+
'Pain signals are processed by the evolution system on the next heartbeat cycle.',
|
|
46
|
+
parameters: Type.Object({
|
|
47
|
+
reason: Type.String({
|
|
48
|
+
description:
|
|
49
|
+
'Describe specifically what went wrong. ' +
|
|
50
|
+
'Include the error, the violated principle, or the issue. ' +
|
|
51
|
+
'Be concrete: "I edited config.ts without reading it first, breaking the export" ' +
|
|
52
|
+
'is better than "I made a mistake".',
|
|
53
|
+
}),
|
|
54
|
+
score: Type.Optional(Type.Number({
|
|
55
|
+
description:
|
|
56
|
+
'Pain severity score (0-100). Default: 80. ' +
|
|
57
|
+
'Guidelines: 30-50 (minor issue), 50-70 (moderate error), ' +
|
|
58
|
+
'70-100 (severe principle violation or data loss risk).',
|
|
59
|
+
minimum: 0,
|
|
60
|
+
maximum: 100,
|
|
61
|
+
})),
|
|
62
|
+
source: Type.Optional(Type.String({
|
|
63
|
+
description:
|
|
64
|
+
'Source of the pain signal. ' +
|
|
65
|
+
'Values: manual (user flagged), tool_failure (tool error), ' +
|
|
66
|
+
'user_empathy (user frustration), principle_violation (principle broken), ' +
|
|
67
|
+
'human_intervention (user manually intervened). ' +
|
|
68
|
+
'Default: manual.',
|
|
69
|
+
})),
|
|
70
|
+
session_id: Type.Optional(Type.String({
|
|
71
|
+
description:
|
|
72
|
+
'Session ID where the pain occurred. ' +
|
|
73
|
+
'If not provided, the system will use the current session.',
|
|
74
|
+
})),
|
|
75
|
+
is_risky: Type.Optional(Type.Boolean({
|
|
76
|
+
description:
|
|
77
|
+
'Whether this involves a high-risk operation (e.g., writing to sensitive files). ' +
|
|
78
|
+
'Default: false.',
|
|
79
|
+
})),
|
|
80
|
+
}),
|
|
81
|
+
|
|
82
|
+
async execute(
|
|
83
|
+
_toolCallId: string,
|
|
84
|
+
rawParams: Record<string, unknown>
|
|
85
|
+
): Promise<{ content: { type: string; text: string }[] }> {
|
|
86
|
+
const reason = typeof rawParams.reason === 'string' ? rawParams.reason.trim() : '';
|
|
87
|
+
const score = typeof rawParams.score === 'number' ? Math.max(0, Math.min(100, Math.round(rawParams.score))) : 80;
|
|
88
|
+
const source = typeof rawParams.source === 'string' && rawParams.source.trim() ? rawParams.source.trim() : 'manual';
|
|
89
|
+
const sessionId = typeof rawParams.session_id === 'string' ? rawParams.session_id.trim() : '';
|
|
90
|
+
const isRisky = rawParams.is_risky === true;
|
|
91
|
+
|
|
92
|
+
// ── Validate required fields ──
|
|
93
|
+
if (!reason) {
|
|
94
|
+
api.logger?.warn?.('[PD:write_pain_flag] Missing required field: reason');
|
|
95
|
+
return {
|
|
96
|
+
content: [{
|
|
97
|
+
type: 'text',
|
|
98
|
+
text: '❌ Error: The `reason` parameter is required.\n' +
|
|
99
|
+
'Describe specifically what went wrong. Example:\n' +
|
|
100
|
+
'"I edited config.ts without reading it first, breaking the export"',
|
|
101
|
+
}],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Resolve workspace ──
|
|
106
|
+
const workspaceDir = resolveWorkspaceDirFromApi(api);
|
|
107
|
+
if (!workspaceDir) {
|
|
108
|
+
api.logger?.error?.('[PD:write_pain_flag] Cannot resolve workspace directory');
|
|
109
|
+
return {
|
|
110
|
+
content: [{
|
|
111
|
+
type: 'text',
|
|
112
|
+
text: '❌ Error: Cannot determine the workspace directory. ' +
|
|
113
|
+
'Please ensure you are in an active workspace.',
|
|
114
|
+
}],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// ── Build pain flag data (KV format) ──
|
|
120
|
+
const painData = buildPainFlag({
|
|
121
|
+
source,
|
|
122
|
+
score: String(score),
|
|
123
|
+
reason,
|
|
124
|
+
session_id: sessionId,
|
|
125
|
+
is_risky: isRisky,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── Validate contract compliance ──
|
|
129
|
+
const missingFields: string[] = [];
|
|
130
|
+
for (const field of PAIN_FLAG_REQUIRED_FIELDS) {
|
|
131
|
+
if (!painData[field] || painData[field].trim() === '') {
|
|
132
|
+
missingFields.push(field);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (missingFields.length > 0) {
|
|
136
|
+
api.logger?.error?.(`[PD:write_pain_flag] Pain flag missing required fields: ${missingFields.join(', ')}`);
|
|
137
|
+
return {
|
|
138
|
+
content: [{
|
|
139
|
+
type: 'text',
|
|
140
|
+
text: `❌ Error: Pain flag is missing required fields: ${missingFields.join(', ')}. ` +
|
|
141
|
+
'This is an internal error — please report it.',
|
|
142
|
+
}],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Atomic write (temp file + rename) ──
|
|
147
|
+
const painFlagPath = path.join(workspaceDir, '.state', '.pain_flag');
|
|
148
|
+
const { serializeKvLines } = await import('../utils/io.js');
|
|
149
|
+
const content = serializeKvLines(painData);
|
|
150
|
+
writePainFlagAtomic(painFlagPath, content);
|
|
151
|
+
|
|
152
|
+
// ── Log success ──
|
|
153
|
+
api.logger?.info?.(
|
|
154
|
+
`[PD:write_pain_flag] Pain signal recorded: source=${source}, score=${score}, ` +
|
|
155
|
+
`reason="${reason.slice(0, 80)}"${reason.length > 80 ? '...' : ''}"`
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// ── Agent feedback ──
|
|
159
|
+
return {
|
|
160
|
+
content: [{
|
|
161
|
+
type: 'text',
|
|
162
|
+
text: `✅ Pain signal recorded successfully.\n\n` +
|
|
163
|
+
`- **Reason**: ${reason}\n` +
|
|
164
|
+
`- **Score**: ${score}/100\n` +
|
|
165
|
+
`- **Source**: ${source}\n` +
|
|
166
|
+
`- **Risk**: ${isRisky ? 'Yes' : 'No'}\n` +
|
|
167
|
+
`- **Session**: ${sessionId || '(current)'}\n\n` +
|
|
168
|
+
`The evolution system will process this signal on the next heartbeat cycle ` +
|
|
169
|
+
`(typically within 60 seconds).`,
|
|
170
|
+
}],
|
|
171
|
+
};
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// ── Log failure with stack trace ──
|
|
174
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
175
|
+
const stack = err instanceof Error ? err.stack?.split('\n').slice(0, 3).join(' → ') : '';
|
|
176
|
+
api.logger?.error?.(
|
|
177
|
+
`[PD:write_pain_flag] Failed to write pain flag: ${errorMsg}` +
|
|
178
|
+
(stack ? `\n Stack: ${stack}` : '')
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
content: [{
|
|
183
|
+
type: 'text',
|
|
184
|
+
text: `❌ Failed to record pain signal: ${errorMsg}\n\n` +
|
|
185
|
+
'The error has been logged. Please try again or report this issue.',
|
|
186
|
+
}],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pd-pain-signal
|
|
3
|
-
description: Manually inject a pain signal into the evolution system
|
|
3
|
+
description: Manually inject a pain signal into the evolution system. TRIGGER CONDITIONS: (1) User reports agent stuck/looping/unresponsive (2) User says "record this issue", "force reflection", "trigger pain" (3) Tool failure with no follow-up action (4) User provides human intervention feedback.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -9,30 +9,44 @@ disable-model-invocation: true
|
|
|
9
9
|
You are now the "Manual Intervention Pain" component.
|
|
10
10
|
|
|
11
11
|
**Task**:
|
|
12
|
-
1.
|
|
12
|
+
1. Record the user's feedback `$ARGUMENTS` as a **high-priority** pain signal.
|
|
13
13
|
2. Inform the user that the signal has been injected, and suggest waiting for the next Hook trigger (e.g., Stop or PreCompact) or manually running `/reflection-log`.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
**⚠️ Write Rules (MUST follow)**
|
|
16
|
+
|
|
17
|
+
**The ONLY correct way**: Use the `write_pain_flag` tool.
|
|
16
18
|
|
|
17
19
|
```
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
time: <ISO 8601 timestamp>
|
|
20
|
+
write_pain_flag({
|
|
21
|
+
reason: "User feedback or error description",
|
|
22
|
+
score: 80,
|
|
23
|
+
source: "human_intervention",
|
|
24
|
+
is_risky: false
|
|
25
|
+
})
|
|
25
26
|
```
|
|
26
27
|
|
|
27
|
-
**
|
|
28
|
-
- `
|
|
29
|
-
-
|
|
30
|
-
- `
|
|
31
|
-
- `
|
|
28
|
+
**Absolutely forbidden**:
|
|
29
|
+
- ❌ Writing to `.state/.pain_flag` directly (any method)
|
|
30
|
+
- ❌ Using bash heredoc (`cat <<EOF > .pain_flag`)
|
|
31
|
+
- ❌ Using `echo "..." > .pain_flag`
|
|
32
|
+
- ❌ Using `node -e` to call `writePainFlag` or `buildPainFlag`
|
|
33
|
+
- ❌ Any method that `toString()` a JavaScript object to the file
|
|
34
|
+
|
|
35
|
+
**Why use the tool?**
|
|
36
|
+
The `write_pain_flag` tool encapsulates correct KV-format serialization, ensuring `.pain_flag` is never corrupted. Historically, direct file writes caused `[object Object]` corruption multiple times.
|
|
32
37
|
|
|
33
|
-
**
|
|
34
|
-
- `
|
|
35
|
-
- `
|
|
36
|
-
- `
|
|
38
|
+
**Parameters**:
|
|
39
|
+
- `reason` (required): The reason for the pain signal — describe what went wrong
|
|
40
|
+
- `score` (optional): Pain score 0-100, default 80 (manual intervention)
|
|
41
|
+
- `source` (optional): Source, default `human_intervention`
|
|
42
|
+
- `is_risky` (optional): Whether this is a high-risk action, default false
|
|
37
43
|
|
|
38
|
-
**
|
|
44
|
+
**Example**:
|
|
45
|
+
```
|
|
46
|
+
write_pain_flag({
|
|
47
|
+
reason: "Agent edited a file without reading it first, breaking existing logic",
|
|
48
|
+
score: 85,
|
|
49
|
+
source: "human_intervention",
|
|
50
|
+
is_risky: false
|
|
51
|
+
})
|
|
52
|
+
```
|