principles-disciple 1.5.4 → 1.6.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/dist/commands/context.d.ts +5 -0
- package/dist/commands/context.js +308 -0
- package/dist/commands/focus.d.ts +14 -0
- package/dist/commands/focus.js +579 -0
- package/dist/commands/pain.js +135 -6
- package/dist/commands/rollback.d.ts +19 -0
- package/dist/commands/rollback.js +119 -0
- package/dist/core/config.d.ts +32 -0
- package/dist/core/config.js +47 -0
- package/dist/core/event-log.d.ts +21 -1
- package/dist/core/event-log.js +316 -0
- package/dist/core/focus-history.d.ts +65 -0
- package/dist/core/focus-history.js +266 -0
- package/dist/core/init.js +30 -7
- package/dist/core/migration.js +0 -2
- package/dist/core/path-resolver.d.ts +3 -0
- package/dist/core/path-resolver.js +20 -0
- package/dist/hooks/gate.js +203 -1
- package/dist/hooks/llm.d.ts +8 -0
- package/dist/hooks/llm.js +234 -1
- package/dist/hooks/message-sanitize.d.ts +3 -0
- package/dist/hooks/message-sanitize.js +37 -0
- package/dist/hooks/prompt.d.ts +12 -0
- package/dist/hooks/prompt.js +309 -135
- package/dist/hooks/subagent.d.ts +9 -2
- package/dist/hooks/subagent.js +13 -2
- package/dist/i18n/commands.js +32 -20
- package/dist/index.js +181 -4
- package/dist/service/empathy-observer-manager.d.ts +42 -0
- package/dist/service/empathy-observer-manager.js +147 -0
- package/dist/service/evolution-worker.d.ts +1 -0
- package/dist/service/evolution-worker.js +4 -2
- package/dist/tools/deep-reflect.js +80 -0
- package/dist/types/event-types.d.ts +77 -2
- package/dist/types/event-types.js +33 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +19 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -3
- package/templates/langs/zh/core/HEARTBEAT.md +28 -4
- package/templates/pain_settings.json +54 -2
- package/templates/workspace/.principles/PROFILE.json +2 -0
- package/templates/workspace/okr/CURRENT_FOCUS.md +57 -0
package/dist/core/init.js
CHANGED
|
@@ -2,6 +2,15 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { PD_DIRS } from './paths.js';
|
|
5
|
+
import { defaultContextConfig } from '../types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Default PROFILE.json content
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_PROFILE = {
|
|
10
|
+
name: "Principles Disciple Agent",
|
|
11
|
+
version: "1.0.0",
|
|
12
|
+
contextInjection: defaultContextConfig
|
|
13
|
+
};
|
|
5
14
|
/**
|
|
6
15
|
* Ensures that the workspace has the necessary template files for Principles Disciple.
|
|
7
16
|
* This function flattens 'core' templates to the root so OpenClaw can find them.
|
|
@@ -47,6 +56,17 @@ export function ensureWorkspaceTemplates(api, workspaceDir, language = 'en') {
|
|
|
47
56
|
}
|
|
48
57
|
copyRecursiveSync(painTemplatesDir, painDestDir, api);
|
|
49
58
|
}
|
|
59
|
+
// 4. Initialize PROFILE.json with default contextInjection config
|
|
60
|
+
const principlesDir = path.join(workspaceDir, PD_DIRS.IDENTITY);
|
|
61
|
+
const profilePath = path.join(principlesDir, 'PROFILE.json');
|
|
62
|
+
if (!fs.existsSync(profilePath)) {
|
|
63
|
+
// Ensure .principles directory exists
|
|
64
|
+
if (!fs.existsSync(principlesDir)) {
|
|
65
|
+
fs.mkdirSync(principlesDir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
fs.writeFileSync(profilePath, JSON.stringify(DEFAULT_PROFILE, null, 2), 'utf-8');
|
|
68
|
+
api.logger.info(`[PD] Initialized PROFILE.json with default contextInjection config`);
|
|
69
|
+
}
|
|
50
70
|
}
|
|
51
71
|
catch (err) {
|
|
52
72
|
api.logger.error(`[PD] Failed to initialize workspace templates: ${String(err)}`);
|
|
@@ -54,12 +74,15 @@ export function ensureWorkspaceTemplates(api, workspaceDir, language = 'en') {
|
|
|
54
74
|
}
|
|
55
75
|
/**
|
|
56
76
|
* Standard recursive copy that preserves directory structure.
|
|
77
|
+
* Special handling: maps 'okr' directory to 'memory/okr' for runtime compatibility.
|
|
57
78
|
*/
|
|
58
79
|
function copyRecursiveSync(srcDir, destDir, api) {
|
|
59
80
|
const items = fs.readdirSync(srcDir);
|
|
60
81
|
for (const item of items) {
|
|
61
82
|
const srcPath = path.join(srcDir, item);
|
|
62
|
-
|
|
83
|
+
// Special mapping: okr -> memory/okr (runtime path expects memory/okr/)
|
|
84
|
+
const destItemName = item === 'okr' ? path.join('memory', 'okr') : item;
|
|
85
|
+
const destPath = path.join(destDir, destItemName);
|
|
63
86
|
const stat = fs.statSync(srcPath);
|
|
64
87
|
if (stat.isDirectory()) {
|
|
65
88
|
if (!fs.existsSync(destPath)) {
|
|
@@ -69,13 +92,13 @@ function copyRecursiveSync(srcDir, destDir, api) {
|
|
|
69
92
|
}
|
|
70
93
|
else {
|
|
71
94
|
if (!fs.existsSync(destPath)) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if ('logger' in api)
|
|
77
|
-
api.logger.warn(`[PD] Failed to copy ${item}: ${String(err)}`);
|
|
95
|
+
// Ensure parent directory exists
|
|
96
|
+
const destParent = path.dirname(destPath);
|
|
97
|
+
if (!fs.existsSync(destParent)) {
|
|
98
|
+
fs.mkdirSync(destParent, { recursive: true });
|
|
78
99
|
}
|
|
100
|
+
fs.copyFileSync(srcPath, destPath);
|
|
101
|
+
api.logger.info(`[PD] Copied: ${destItemName}`);
|
|
79
102
|
}
|
|
80
103
|
}
|
|
81
104
|
}
|
package/dist/core/migration.js
CHANGED
|
@@ -12,14 +12,12 @@ export function migrateDirectoryStructure(api, workspaceDir) {
|
|
|
12
12
|
// Comprehensive migration map covering ALL legacy locations
|
|
13
13
|
const migrationMap = [
|
|
14
14
|
// From docs/
|
|
15
|
-
{ legacy: path.join(legacyDocsDir, 'PROFILE.json'), newKey: 'PROFILE' },
|
|
16
15
|
{ legacy: path.join(legacyDocsDir, 'PRINCIPLES.md'), newKey: 'PRINCIPLES' },
|
|
17
16
|
{ legacy: path.join(legacyDocsDir, 'THINKING_OS.md'), newKey: 'THINKING_OS' },
|
|
18
17
|
{ legacy: path.join(legacyDocsDir, '00-kernel.md'), newKey: 'KERNEL' },
|
|
19
18
|
{ legacy: path.join(legacyDocsDir, 'DECISION_POLICY.json'), newKey: 'DECISION_POLICY' },
|
|
20
19
|
{ legacy: path.join(legacyDocsDir, 'PLAN.md'), newKey: 'PLAN' },
|
|
21
20
|
{ legacy: path.join(legacyDocsDir, 'evolution_queue.json'), newKey: 'EVOLUTION_QUEUE' },
|
|
22
|
-
{ legacy: path.join(legacyDocsDir, 'AGENT_SCORECARD.json'), newKey: 'AGENT_SCORECARD' },
|
|
23
21
|
{ legacy: path.join(legacyDocsDir, '.pain_flag'), newKey: 'PAIN_FLAG' },
|
|
24
22
|
{ legacy: path.join(legacyDocsDir, 'SYSTEM_CAPABILITIES.json'), newKey: 'SYSTEM_CAPABILITIES' },
|
|
25
23
|
{ legacy: path.join(legacyDocsDir, 'SYSTEM.log'), newKey: 'SYSTEM_LOG' },
|
|
@@ -24,11 +24,14 @@ export interface PDConfig {
|
|
|
24
24
|
debug?: boolean;
|
|
25
25
|
}
|
|
26
26
|
export declare class PathResolver {
|
|
27
|
+
private static extensionRoot;
|
|
27
28
|
private workspaceDir;
|
|
28
29
|
private stateDir;
|
|
29
30
|
private readonly logger?;
|
|
30
31
|
private readonly normalizeWorkspace;
|
|
31
32
|
private initialized;
|
|
33
|
+
static setExtensionRoot(extensionRootPath: string): void;
|
|
34
|
+
static getExtensionRoot(): string | null;
|
|
32
35
|
constructor(options?: PathResolverOptions);
|
|
33
36
|
private log;
|
|
34
37
|
private detectWorkspaceDir;
|
|
@@ -67,11 +67,21 @@ function loadConfigFromFile() {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
export class PathResolver {
|
|
70
|
+
static extensionRoot = null;
|
|
70
71
|
workspaceDir = null;
|
|
71
72
|
stateDir = null;
|
|
72
73
|
logger;
|
|
73
74
|
normalizeWorkspace;
|
|
74
75
|
initialized = false;
|
|
76
|
+
static setExtensionRoot(extensionRootPath) {
|
|
77
|
+
if (!extensionRootPath || !extensionRootPath.trim()) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
PathResolver.extensionRoot = path.resolve(extensionRootPath.trim());
|
|
81
|
+
}
|
|
82
|
+
static getExtensionRoot() {
|
|
83
|
+
return PathResolver.extensionRoot;
|
|
84
|
+
}
|
|
75
85
|
constructor(options = {}) {
|
|
76
86
|
this.logger = options.logger;
|
|
77
87
|
this.normalizeWorkspace = options.normalizeWorkspace ?? true;
|
|
@@ -180,6 +190,12 @@ export class PathResolver {
|
|
|
180
190
|
const workspace = this.getWorkspaceDir();
|
|
181
191
|
const state = this.getStateDir();
|
|
182
192
|
const memory = path.join(workspace, 'memory');
|
|
193
|
+
const extensionRoot = PathResolver.extensionRoot || path.resolve(process.cwd(), 'packages', 'openclaw-plugin');
|
|
194
|
+
const extensionSrc = path.join(extensionRoot, 'src');
|
|
195
|
+
const extensionDist = path.join(extensionRoot, 'dist');
|
|
196
|
+
const evolutionWorker = fs.existsSync(extensionSrc)
|
|
197
|
+
? path.join(extensionSrc, 'service', 'evolution-worker.ts')
|
|
198
|
+
: path.join(extensionDist, 'service', 'evolution-worker.js');
|
|
183
199
|
const pathMap = {
|
|
184
200
|
'PROFILE': path.join(workspace, '.principles', 'PROFILE.json'),
|
|
185
201
|
'PRINCIPLES': path.join(workspace, '.principles', 'PRINCIPLES.md'),
|
|
@@ -199,6 +215,10 @@ export class PathResolver {
|
|
|
199
215
|
'THINKING_OS_USAGE': path.join(state, 'thinking_os_usage.json'),
|
|
200
216
|
'DICTIONARY': path.join(state, 'pain_dictionary.json'),
|
|
201
217
|
'STATE_DIR': state,
|
|
218
|
+
'EXTENSION_ROOT': extensionRoot,
|
|
219
|
+
'EXTENSION_SRC': extensionSrc,
|
|
220
|
+
'EXTENSION_DIST': extensionDist,
|
|
221
|
+
'EVOLUTION_WORKER': evolutionWorker,
|
|
202
222
|
'LOGS': path.join(memory, 'logs'),
|
|
203
223
|
'SYSTEM_LOG': path.join(memory, 'logs', 'SYSTEM.log'),
|
|
204
224
|
'REFLECTION_LOG': path.join(memory, 'reflection-log.md'),
|
package/dist/hooks/gate.js
CHANGED
|
@@ -3,10 +3,80 @@ import * as path from 'path';
|
|
|
3
3
|
import { isRisky, normalizePath, planStatus as getPlanStatus } from '../utils/io.js';
|
|
4
4
|
import { matchesAnyPattern } from '../utils/glob-match.js';
|
|
5
5
|
import { normalizeProfile } from '../core/profile.js';
|
|
6
|
-
import { trackBlock, hasRecentThinking } from '../core/session-tracker.js';
|
|
6
|
+
import { trackBlock, hasRecentThinking, getSession } from '../core/session-tracker.js';
|
|
7
7
|
import { assessRiskLevel, estimateLineChanges } from '../core/risk-calculator.js';
|
|
8
8
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
9
9
|
import { checkEvolutionGate } from '../core/evolution-engine.js';
|
|
10
|
+
// ═══ GFI Gate Tool Tiers ═══
|
|
11
|
+
// TIER 0: 只读工具 - 永不拦截
|
|
12
|
+
const READ_ONLY_TOOLS = new Set([
|
|
13
|
+
'read', 'read_file', 'read_many_files', 'image_read',
|
|
14
|
+
'search_file_content', 'grep', 'grep_search', 'list_directory', 'ls', 'glob',
|
|
15
|
+
'lsp_hover', 'lsp_goto_definition', 'lsp_find_references',
|
|
16
|
+
'web_fetch', 'web_search', 'ref_search_documentation', 'ref_read_url',
|
|
17
|
+
'resolve-library-id', 'get-library-docs',
|
|
18
|
+
'todo_read', 'save_memory',
|
|
19
|
+
'deep_reflect',
|
|
20
|
+
]);
|
|
21
|
+
// TIER 1: 低风险修改 - GFI >= low_risk_block 时拦截
|
|
22
|
+
// 注意:pd_spawn_agent、sessions_spawn、task 是 Agent 派生工具,不应被 GFI Gate 拦截
|
|
23
|
+
// 它们属于 AGENT_TOOLS,在早期过滤后直接放行
|
|
24
|
+
const LOW_RISK_WRITE_TOOLS = new Set([
|
|
25
|
+
'write', 'write_file',
|
|
26
|
+
'edit', 'edit_file', 'replace', 'apply_patch', 'insert', 'patch',
|
|
27
|
+
]);
|
|
28
|
+
// TIER 2: 高风险操作 - GFI >= high_risk_block 时拦截
|
|
29
|
+
const HIGH_RISK_TOOLS = new Set([
|
|
30
|
+
'delete_file', 'move_file',
|
|
31
|
+
]);
|
|
32
|
+
// TIER 3: Bash 命令 - 根据内容判断
|
|
33
|
+
const BASH_TOOLS_SET = new Set([
|
|
34
|
+
'bash', 'run_shell_command', 'exec', 'execute', 'shell', 'cmd',
|
|
35
|
+
]);
|
|
36
|
+
/**
|
|
37
|
+
* 分析 Bash 命令风险等级
|
|
38
|
+
*/
|
|
39
|
+
function analyzeBashCommand(command, safePatterns, dangerousPatterns) {
|
|
40
|
+
const normalizedCmd = command.trim().toLowerCase();
|
|
41
|
+
// 1. 优先检查危险命令
|
|
42
|
+
for (const pattern of dangerousPatterns) {
|
|
43
|
+
try {
|
|
44
|
+
if (new RegExp(pattern, 'i').test(normalizedCmd)) {
|
|
45
|
+
return 'dangerous';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// 忽略无效正则
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// 2. 检查安全命令
|
|
53
|
+
for (const pattern of safePatterns) {
|
|
54
|
+
try {
|
|
55
|
+
if (new RegExp(pattern, 'i').test(normalizedCmd)) {
|
|
56
|
+
return 'safe';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// 忽略无效正则
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// 3. 默认为普通命令
|
|
64
|
+
return 'normal';
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 计算动态 GFI 阈值
|
|
68
|
+
*/
|
|
69
|
+
function calculateDynamicThreshold(baseThreshold, trustStage, lineChanges, config) {
|
|
70
|
+
// 1. Trust Stage 乘数
|
|
71
|
+
const stageMultiplier = config.trust_stage_multipliers[trustStage.toString()] || 1.0;
|
|
72
|
+
let threshold = baseThreshold * stageMultiplier;
|
|
73
|
+
// 2. 大规模修改降低阈值
|
|
74
|
+
if (lineChanges > config.large_change_lines) {
|
|
75
|
+
const ratio = Math.min(lineChanges / 200, 0.5); // 最多降低 50%
|
|
76
|
+
threshold = threshold * (1 - ratio);
|
|
77
|
+
}
|
|
78
|
+
return Math.round(Math.max(threshold, 0));
|
|
79
|
+
}
|
|
10
80
|
export function handleBeforeToolCall(event, ctx) {
|
|
11
81
|
const logger = ctx.logger || console;
|
|
12
82
|
// 1. Identify tool type
|
|
@@ -71,6 +141,138 @@ export function handleBeforeToolCall(event, ctx) {
|
|
|
71
141
|
};
|
|
72
142
|
}
|
|
73
143
|
}
|
|
144
|
+
// ═══ GFI GATE - Hard Intercept ═══
|
|
145
|
+
// 根据 GFI (疲劳指数) 精细化拦截工具调用
|
|
146
|
+
// 注意:TIER 0 (只读工具) 已在早期过滤中放行,此处不检查
|
|
147
|
+
const gfiGateConfig = wctx.config.get('gfi_gate');
|
|
148
|
+
if (gfiGateConfig?.enabled !== false && ctx.sessionId) {
|
|
149
|
+
const session = getSession(ctx.sessionId);
|
|
150
|
+
const currentGfi = session?.currentGfi || 0;
|
|
151
|
+
// TIER 3: Bash 命令 - 根据内容判断
|
|
152
|
+
if (BASH_TOOLS_SET.has(event.toolName)) {
|
|
153
|
+
const command = String(event.params.command || event.params.args || '');
|
|
154
|
+
const bashRisk = analyzeBashCommand(command, gfiGateConfig?.bash_safe_patterns || [], gfiGateConfig?.bash_dangerous_patterns || []);
|
|
155
|
+
if (bashRisk === 'dangerous') {
|
|
156
|
+
// 危险命令 - 直接拦截
|
|
157
|
+
logger?.warn?.(`[PD:GFI_GATE] Dangerous bash command blocked: ${command.substring(0, 50)}...`);
|
|
158
|
+
return {
|
|
159
|
+
block: true,
|
|
160
|
+
blockReason: `[GFI Gate] 危险命令被拦截。
|
|
161
|
+
|
|
162
|
+
命令: ${command.substring(0, 100)}${command.length > 100 ? '...' : ''}
|
|
163
|
+
|
|
164
|
+
原因: 检测到危险命令模式,需要确认执行意图。
|
|
165
|
+
|
|
166
|
+
解决方案:
|
|
167
|
+
1. 如果确实需要执行,请确认操作意图后重试
|
|
168
|
+
2. 使用更安全的方式(如手动操作)
|
|
169
|
+
3. 咨询用户确认是否继续
|
|
170
|
+
|
|
171
|
+
注意: 危险命令需要更严格的审批流程。`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// safe 命令 - 放行
|
|
175
|
+
else if (bashRisk === 'safe') {
|
|
176
|
+
// 继续执行
|
|
177
|
+
}
|
|
178
|
+
// normal 命令 - 按 GFI 阈值判断
|
|
179
|
+
else {
|
|
180
|
+
const trustEngine = wctx.trust;
|
|
181
|
+
const stage = trustEngine.getStage();
|
|
182
|
+
const baseThreshold = gfiGateConfig?.thresholds?.low_risk_block || 70;
|
|
183
|
+
const dynamicThreshold = calculateDynamicThreshold(baseThreshold, stage, 0, // bash 命令没有行数概念
|
|
184
|
+
{
|
|
185
|
+
large_change_lines: gfiGateConfig?.large_change_lines || 50,
|
|
186
|
+
trust_stage_multipliers: gfiGateConfig?.trust_stage_multipliers || { '1': 0.5, '2': 0.75, '3': 1.0, '4': 1.5 },
|
|
187
|
+
});
|
|
188
|
+
if (currentGfi >= dynamicThreshold) {
|
|
189
|
+
logger?.warn?.(`[PD:GFI_GATE] Bash blocked by GFI: ${currentGfi} >= ${dynamicThreshold}`);
|
|
190
|
+
return {
|
|
191
|
+
block: true,
|
|
192
|
+
blockReason: `[GFI Gate] 疲劳指数过高,操作被拦截。
|
|
193
|
+
|
|
194
|
+
命令: ${command.substring(0, 100)}${command.length > 100 ? '...' : ''}
|
|
195
|
+
GFI: ${currentGfi}/100
|
|
196
|
+
动态阈值: ${dynamicThreshold} (Stage ${stage})
|
|
197
|
+
|
|
198
|
+
原因: 当前疲劳指数超过阈值,系统进入保护模式。
|
|
199
|
+
|
|
200
|
+
解决方案:
|
|
201
|
+
1. 执行 /pd-status reset 清零疲劳值
|
|
202
|
+
2. 检查是否存在理解偏差或死循环
|
|
203
|
+
3. 等待问题自然解决后再尝试
|
|
204
|
+
|
|
205
|
+
注意: 这是系统级硬性拦截,AI 无法绕过。`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// TIER 2: 高风险操作 - GFI >= high_risk_block 时拦截
|
|
211
|
+
else if (HIGH_RISK_TOOLS.has(event.toolName)) {
|
|
212
|
+
const trustEngine = wctx.trust;
|
|
213
|
+
const stage = trustEngine.getStage();
|
|
214
|
+
const baseThreshold = gfiGateConfig?.thresholds?.high_risk_block || 40;
|
|
215
|
+
const dynamicThreshold = calculateDynamicThreshold(baseThreshold, stage, 0, {
|
|
216
|
+
large_change_lines: gfiGateConfig?.large_change_lines || 50,
|
|
217
|
+
trust_stage_multipliers: gfiGateConfig?.trust_stage_multipliers || { '1': 0.5, '2': 0.75, '3': 1.0, '4': 1.5 },
|
|
218
|
+
});
|
|
219
|
+
if (currentGfi >= dynamicThreshold) {
|
|
220
|
+
const filePath = event.params.file_path || event.params.path || event.params.file || event.params.target || 'unknown';
|
|
221
|
+
logger?.warn?.(`[PD:GFI_GATE] High-risk tool "${event.toolName}" blocked by GFI: ${currentGfi} >= ${dynamicThreshold}`);
|
|
222
|
+
return {
|
|
223
|
+
block: true,
|
|
224
|
+
blockReason: `[GFI Gate] 高风险操作被拦截。
|
|
225
|
+
|
|
226
|
+
工具: ${event.toolName}
|
|
227
|
+
文件: ${filePath}
|
|
228
|
+
GFI: ${currentGfi}/100
|
|
229
|
+
动态阈值: ${dynamicThreshold} (Stage ${stage})
|
|
230
|
+
|
|
231
|
+
原因: 高风险工具需要更低的 GFI 阈值才能执行。
|
|
232
|
+
|
|
233
|
+
解决方案:
|
|
234
|
+
1. 执行 /pd-status reset 清零疲劳值
|
|
235
|
+
2. 检查是否存在理解偏差或死循环
|
|
236
|
+
3. 等待 GFI 自然衰减后重试
|
|
237
|
+
|
|
238
|
+
注意: 这是系统级硬性拦截,AI 无法绕过。`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// TIER 1: 低风险修改 - GFI >= low_risk_block 时拦截
|
|
243
|
+
else if (LOW_RISK_WRITE_TOOLS.has(event.toolName)) {
|
|
244
|
+
const trustEngine = wctx.trust;
|
|
245
|
+
const stage = trustEngine.getStage();
|
|
246
|
+
const lineChanges = estimateLineChanges({ toolName: event.toolName, params: event.params });
|
|
247
|
+
const baseThreshold = gfiGateConfig?.thresholds?.low_risk_block || 70;
|
|
248
|
+
const dynamicThreshold = calculateDynamicThreshold(baseThreshold, stage, lineChanges, {
|
|
249
|
+
large_change_lines: gfiGateConfig?.large_change_lines || 50,
|
|
250
|
+
trust_stage_multipliers: gfiGateConfig?.trust_stage_multipliers || { '1': 0.5, '2': 0.75, '3': 1.0, '4': 1.5 },
|
|
251
|
+
});
|
|
252
|
+
if (currentGfi >= dynamicThreshold) {
|
|
253
|
+
const filePath = event.params.file_path || event.params.path || event.params.file || event.params.target || 'unknown';
|
|
254
|
+
logger?.warn?.(`[PD:GFI_GATE] Low-risk tool "${event.toolName}" blocked by GFI: ${currentGfi} >= ${dynamicThreshold}`);
|
|
255
|
+
return {
|
|
256
|
+
block: true,
|
|
257
|
+
blockReason: `[GFI Gate] 疲劳指数过高,操作被拦截。
|
|
258
|
+
|
|
259
|
+
工具: ${event.toolName}
|
|
260
|
+
文件: ${filePath}
|
|
261
|
+
GFI: ${currentGfi}/100
|
|
262
|
+
动态阈值: ${dynamicThreshold} (Stage ${stage}${lineChanges > 50 ? `, ${lineChanges}行修改` : ''})
|
|
263
|
+
|
|
264
|
+
原因: 当前疲劳指数超过阈值,系统进入保护模式。
|
|
265
|
+
|
|
266
|
+
解决方案:
|
|
267
|
+
1. 执行 /pd-status reset 清零疲劳值
|
|
268
|
+
2. 检查是否存在理解偏差或死循环
|
|
269
|
+
3. 等待问题自然解决后再尝试
|
|
270
|
+
|
|
271
|
+
注意: 这是系统级硬性拦截,AI 无法绕过。`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
74
276
|
// Merge pluginConfig (OpenClaw UI settings)
|
|
75
277
|
const configRiskPaths = ctx.pluginConfig?.riskPaths ?? [];
|
|
76
278
|
if (configRiskPaths.length > 0) {
|
package/dist/hooks/llm.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { PluginHookLlmOutputEvent, PluginHookAgentContext } from '../openclaw-sdk.js';
|
|
2
|
+
export interface EmpathySignal {
|
|
3
|
+
detected: boolean;
|
|
4
|
+
severity: 'mild' | 'moderate' | 'severe';
|
|
5
|
+
confidence: number;
|
|
6
|
+
reason?: string;
|
|
7
|
+
mode?: 'structured' | 'legacy_tag';
|
|
8
|
+
}
|
|
9
|
+
export declare function extractEmpathySignal(text: string): EmpathySignal;
|
|
2
10
|
export declare function handleLlmOutput(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext & {
|
|
3
11
|
workspaceDir?: string;
|
|
4
12
|
}): void;
|
package/dist/hooks/llm.js
CHANGED
|
@@ -1,9 +1,177 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import { trackLlmOutput, recordThinkingCheckpoint } from '../core/session-tracker.js';
|
|
3
|
+
import { trackFriction, trackLlmOutput, recordThinkingCheckpoint, resetFriction } from '../core/session-tracker.js';
|
|
4
4
|
import { writePainFlag } from '../core/pain.js';
|
|
5
5
|
import { DetectionService } from '../core/detection-service.js';
|
|
6
6
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
7
|
+
const empathyDedupState = new Map();
|
|
8
|
+
const empathyRateState = new Map();
|
|
9
|
+
function clamp(value, min, max) {
|
|
10
|
+
return Math.max(min, Math.min(max, value));
|
|
11
|
+
}
|
|
12
|
+
function normalizeSeverity(input) {
|
|
13
|
+
const normalized = (input || '').toLowerCase();
|
|
14
|
+
if (normalized === 'severe' || normalized === 'high')
|
|
15
|
+
return 'severe';
|
|
16
|
+
if (normalized === 'moderate' || normalized === 'medium')
|
|
17
|
+
return 'moderate';
|
|
18
|
+
return 'mild';
|
|
19
|
+
}
|
|
20
|
+
function parseConfidence(raw) {
|
|
21
|
+
const parsed = Number(raw);
|
|
22
|
+
if (!Number.isFinite(parsed))
|
|
23
|
+
return 1;
|
|
24
|
+
return clamp(parsed, 0, 1);
|
|
25
|
+
}
|
|
26
|
+
function parseTrustedLegacyTag(text) {
|
|
27
|
+
return text.match(/^\s*\[EMOTIONAL_DAMAGE_DETECTED(?::(mild|moderate|severe))?\]\s*$/i);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 检测标签是否是被用户诱导/引用输出的(回显),而非 LLM 主动输出的情绪信号
|
|
31
|
+
*/
|
|
32
|
+
function isEchoedTag(text, tagMatch) {
|
|
33
|
+
const tagIndex = tagMatch.index ?? 0;
|
|
34
|
+
const before = text.substring(Math.max(0, tagIndex - 100), tagIndex).toLowerCase();
|
|
35
|
+
// 1. 检查是否在引号内(用户引用)
|
|
36
|
+
const quotesBefore = (before.match(/["'\u300c\u300d\u201c\u201d`]/g) || []).length;
|
|
37
|
+
if (quotesBefore % 2 === 1)
|
|
38
|
+
return true;
|
|
39
|
+
// 2. Strong patterns: 用户指令关键词(任意位置匹配)
|
|
40
|
+
const strongPatterns = [
|
|
41
|
+
/用户(说|让|要求|让我输出)/,
|
|
42
|
+
/user\s+(said|asked|told|wants)\s+me\s+to\s+(output|write|say)/,
|
|
43
|
+
/请(输出|包含|显示).*\[emotional/,
|
|
44
|
+
/please\s+(output|include).*\[emotional/,
|
|
45
|
+
/你让我输出/,
|
|
46
|
+
];
|
|
47
|
+
for (const pattern of strongPatterns) {
|
|
48
|
+
if (pattern.test(before))
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
// 3. Weak patterns: 仅在标签 15 字符内触发
|
|
52
|
+
const weakPatterns = [
|
|
53
|
+
{ pattern: /echo/, window: 15 },
|
|
54
|
+
{ pattern: /copy/, window: 15 },
|
|
55
|
+
{ pattern: /复述/, window: 15 },
|
|
56
|
+
];
|
|
57
|
+
for (const { pattern, window } of weakPatterns) {
|
|
58
|
+
const nearTag = text.substring(Math.max(0, tagIndex - window), tagIndex).toLowerCase();
|
|
59
|
+
if (pattern.test(nearTag))
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
// 4. 检查是否在代码块内
|
|
63
|
+
const codeBlocksBefore = (before.match(/```/g) || []).length;
|
|
64
|
+
if (codeBlocksBefore % 2 === 1)
|
|
65
|
+
return true;
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
export function extractEmpathySignal(text) {
|
|
69
|
+
if (!text || typeof text !== 'string') {
|
|
70
|
+
return { detected: false, severity: 'mild', confidence: 1 };
|
|
71
|
+
}
|
|
72
|
+
const xmlMatch = text.match(/<empathy\s+([^>]*)\/?>(?:<\/empathy>)?/i);
|
|
73
|
+
if (xmlMatch?.[1]) {
|
|
74
|
+
const attrs = xmlMatch[1];
|
|
75
|
+
const signal = attrs.match(/signal\s*=\s*"([^"]+)"/i)?.[1]?.toLowerCase();
|
|
76
|
+
if (signal === 'damage' || signal === 'pain' || signal === 'frustration') {
|
|
77
|
+
const severity = normalizeSeverity(attrs.match(/severity\s*=\s*"([^"]+)"/i)?.[1]);
|
|
78
|
+
const confidence = parseConfidence(attrs.match(/confidence\s*=\s*"([^"]+)"/i)?.[1]);
|
|
79
|
+
const reason = attrs.match(/reason\s*=\s*"([^"]+)"/i)?.[1];
|
|
80
|
+
return { detected: true, severity, confidence, reason, mode: 'structured' };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const jsonMatch = text.match(/"empathy"\s*:\s*\{[\s\S]*?\}/i);
|
|
84
|
+
if (jsonMatch) {
|
|
85
|
+
const jsonText = `{${jsonMatch[0]}}`;
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(jsonText);
|
|
88
|
+
if (parsed.empathy?.damageDetected === true) {
|
|
89
|
+
return {
|
|
90
|
+
detected: true,
|
|
91
|
+
severity: normalizeSeverity(parsed.empathy.severity),
|
|
92
|
+
confidence: clamp(Number(parsed.empathy.confidence ?? 1), 0, 1),
|
|
93
|
+
reason: parsed.empathy.reason,
|
|
94
|
+
mode: 'structured'
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// ignore malformed snippet
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const tagMatch = parseTrustedLegacyTag(text);
|
|
103
|
+
if (tagMatch) {
|
|
104
|
+
if (isEchoedTag(text, tagMatch)) {
|
|
105
|
+
return { detected: false, severity: 'mild', confidence: 1 };
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
detected: true,
|
|
109
|
+
severity: normalizeSeverity(tagMatch[1]),
|
|
110
|
+
confidence: 1,
|
|
111
|
+
mode: 'legacy_tag'
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { detected: false, severity: 'mild', confidence: 1 };
|
|
115
|
+
}
|
|
116
|
+
function mapSeverityToPenalty(severity, config) {
|
|
117
|
+
const mild = Number(config.get('empathy_engine.penalties.mild') ?? 10);
|
|
118
|
+
const moderate = Number(config.get('empathy_engine.penalties.moderate') ?? 25);
|
|
119
|
+
const severe = Number(config.get('empathy_engine.penalties.severe') ?? 40);
|
|
120
|
+
if (severity === 'severe')
|
|
121
|
+
return severe;
|
|
122
|
+
if (severity === 'moderate')
|
|
123
|
+
return moderate;
|
|
124
|
+
return mild;
|
|
125
|
+
}
|
|
126
|
+
function dedupeKey(sessionId, runId, signal) {
|
|
127
|
+
return `${sessionId}:${runId}:${signal.severity}:${(signal.reason || '').slice(0, 80)}`;
|
|
128
|
+
}
|
|
129
|
+
function shouldDedupe(sessionId, runId, signal, windowMs) {
|
|
130
|
+
const key = dedupeKey(sessionId, runId, signal);
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
const last = empathyDedupState.get(key);
|
|
133
|
+
if (typeof last === 'number' && now - last <= windowMs) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
empathyDedupState.set(key, now);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
function resolveCalibrationFactor(event, config) {
|
|
140
|
+
const table = config.get('empathy_engine.model_calibration');
|
|
141
|
+
if (!table || typeof table !== 'object')
|
|
142
|
+
return 1;
|
|
143
|
+
const modelKey = `${event.provider}/${event.model}`;
|
|
144
|
+
const factor = Number(table[modelKey] ?? 1);
|
|
145
|
+
if (!Number.isFinite(factor))
|
|
146
|
+
return 1;
|
|
147
|
+
return clamp(factor, 0.1, 3);
|
|
148
|
+
}
|
|
149
|
+
function applyRateLimit(sessionId, runId, score, config) {
|
|
150
|
+
const maxPerTurn = Number(config.get('empathy_engine.rate_limit.max_per_turn') ?? 40);
|
|
151
|
+
const maxPerHour = Number(config.get('empathy_engine.rate_limit.max_per_hour') ?? 120);
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
const prev = empathyRateState.get(sessionId) ?? {
|
|
154
|
+
turnScore: 0,
|
|
155
|
+
hourScore: 0,
|
|
156
|
+
hourWindowStart: now,
|
|
157
|
+
lastRunId: runId,
|
|
158
|
+
};
|
|
159
|
+
if (prev.lastRunId !== runId) {
|
|
160
|
+
prev.turnScore = 0;
|
|
161
|
+
prev.lastRunId = runId;
|
|
162
|
+
}
|
|
163
|
+
if (now - prev.hourWindowStart >= 60 * 60 * 1000) {
|
|
164
|
+
prev.hourScore = 0;
|
|
165
|
+
prev.hourWindowStart = now;
|
|
166
|
+
}
|
|
167
|
+
const byTurn = Math.max(0, maxPerTurn - prev.turnScore);
|
|
168
|
+
const byHour = Math.max(0, maxPerHour - prev.hourScore);
|
|
169
|
+
const allowed = Math.max(0, Math.min(score, byTurn, byHour));
|
|
170
|
+
prev.turnScore += allowed;
|
|
171
|
+
prev.hourScore += allowed;
|
|
172
|
+
empathyRateState.set(sessionId, prev);
|
|
173
|
+
return allowed;
|
|
174
|
+
}
|
|
7
175
|
export function handleLlmOutput(event, ctx) {
|
|
8
176
|
if (!ctx.workspaceDir || !ctx.sessionId)
|
|
9
177
|
return;
|
|
@@ -34,6 +202,71 @@ export function handleLlmOutput(event, ctx) {
|
|
|
34
202
|
let matchedReason = detection.detected
|
|
35
203
|
? `Agent triggered pain detection (Source: ${detection.source}${detection.ruleId ? `, Rule: ${detection.ruleId}` : ''})`
|
|
36
204
|
: '';
|
|
205
|
+
// empathy sub-pipeline (enabled by default)
|
|
206
|
+
const empathyEnabled = config.get('empathy_engine.enabled');
|
|
207
|
+
if (empathyEnabled !== false) {
|
|
208
|
+
const signal = extractEmpathySignal(text);
|
|
209
|
+
if (signal.detected) {
|
|
210
|
+
const dedupeWindow = Number(config.get('empathy_engine.dedupe_window_ms') ?? 60000);
|
|
211
|
+
const deduped = shouldDedupe(ctx.sessionId, event.runId, signal, dedupeWindow);
|
|
212
|
+
if (!deduped) {
|
|
213
|
+
const baseScore = mapSeverityToPenalty(signal.severity, config);
|
|
214
|
+
const weightedScore = Math.round(baseScore * signal.confidence);
|
|
215
|
+
const calibrationFactor = resolveCalibrationFactor(event, config);
|
|
216
|
+
const calibratedScore = Math.round(weightedScore * calibrationFactor);
|
|
217
|
+
const boundedScore = applyRateLimit(ctx.sessionId, event.runId, calibratedScore, config);
|
|
218
|
+
if (boundedScore > 0) {
|
|
219
|
+
trackFriction(ctx.sessionId, boundedScore, `user_empathy_${signal.severity}`, ctx.workspaceDir);
|
|
220
|
+
// Generate unique event ID for rollback support
|
|
221
|
+
const eventId = `emp_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
|
222
|
+
eventLog.recordPainSignal(ctx.sessionId, {
|
|
223
|
+
score: boundedScore,
|
|
224
|
+
source: 'user_empathy',
|
|
225
|
+
reason: signal.reason || 'Assistant self-reported user emotional distress.',
|
|
226
|
+
isRisky: false,
|
|
227
|
+
origin: 'assistant_self_report',
|
|
228
|
+
severity: signal.severity,
|
|
229
|
+
confidence: signal.confidence,
|
|
230
|
+
detection_mode: signal.mode,
|
|
231
|
+
deduped: false,
|
|
232
|
+
trigger_text_excerpt: text.substring(0, 120),
|
|
233
|
+
raw_score: weightedScore,
|
|
234
|
+
calibrated_score: calibratedScore,
|
|
235
|
+
eventId,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
eventLog.recordPainSignal(ctx.sessionId, {
|
|
241
|
+
score: 0,
|
|
242
|
+
source: 'user_empathy',
|
|
243
|
+
reason: signal.reason || 'Deduped empathy signal.',
|
|
244
|
+
isRisky: false,
|
|
245
|
+
origin: 'assistant_self_report',
|
|
246
|
+
severity: signal.severity,
|
|
247
|
+
confidence: signal.confidence,
|
|
248
|
+
detection_mode: signal.mode,
|
|
249
|
+
deduped: true,
|
|
250
|
+
trigger_text_excerpt: text.substring(0, 120),
|
|
251
|
+
raw_score: Math.round(mapSeverityToPenalty(signal.severity, config) * signal.confidence),
|
|
252
|
+
calibrated_score: Math.round(mapSeverityToPenalty(signal.severity, config) * signal.confidence * resolveCalibrationFactor(event, config))
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// ═══ Natural Language Rollback Detection ═══
|
|
258
|
+
// Detect [EMPATHY_ROLLBACK_REQUEST] tag and trigger rollback
|
|
259
|
+
const rollbackMatch = text.match(/^\s*\[EMPATHY_ROLLBACK_REQUEST\]\s*$/m);
|
|
260
|
+
if (rollbackMatch) {
|
|
261
|
+
const eventId = eventLog.getLastEmpathyEventId(ctx.sessionId);
|
|
262
|
+
if (eventId) {
|
|
263
|
+
const rolledBackScore = eventLog.rollbackEmpathyEvent(eventId, ctx.sessionId, 'Natural language rollback request detected', 'natural_language');
|
|
264
|
+
if (rolledBackScore > 0) {
|
|
265
|
+
// Reset GFI after successful rollback
|
|
266
|
+
resetFriction(ctx.sessionId);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
37
270
|
// 3. Paralysis Check (from session state tracker)
|
|
38
271
|
const stuckThreshold = config.get('thresholds.stuck_loops_trigger') || 3;
|
|
39
272
|
const inputThreshold = config.get('thresholds.cognitive_paralysis_input') || 4000;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { PluginHookBeforeMessageWriteEvent, PluginHookBeforeMessageWriteResult } from '../openclaw-sdk.js';
|
|
2
|
+
export declare function sanitizeAssistantText(text: string): string;
|
|
3
|
+
export declare function handleBeforeMessageWrite(event: PluginHookBeforeMessageWriteEvent): PluginHookBeforeMessageWriteResult | void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const INTERNAL_TAG_PATTERNS = [
|
|
2
|
+
/\[EMOTIONAL_DAMAGE_DETECTED(?::(?:mild|moderate|severe))?\]/gi,
|
|
3
|
+
/\[EMPATHY_ROLLBACK_REQUEST\]/gi,
|
|
4
|
+
/<empathy\s+[^>]*\/?>(?:<\/empathy>)?/gi,
|
|
5
|
+
];
|
|
6
|
+
export function sanitizeAssistantText(text) {
|
|
7
|
+
let result = text;
|
|
8
|
+
for (const pattern of INTERNAL_TAG_PATTERNS) {
|
|
9
|
+
result = result.replace(pattern, '');
|
|
10
|
+
}
|
|
11
|
+
return result
|
|
12
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
13
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
export function handleBeforeMessageWrite(event) {
|
|
17
|
+
const msg = event.message;
|
|
18
|
+
if (!msg || msg.role !== 'assistant')
|
|
19
|
+
return;
|
|
20
|
+
if (typeof msg.content === 'string') {
|
|
21
|
+
const sanitized = sanitizeAssistantText(msg.content);
|
|
22
|
+
if (sanitized !== msg.content) {
|
|
23
|
+
return { message: { ...msg, content: sanitized } };
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(msg.content)) {
|
|
28
|
+
const next = msg.content.map((part) => {
|
|
29
|
+
if (part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string') {
|
|
30
|
+
return { ...part, text: sanitizeAssistantText(part.text) };
|
|
31
|
+
}
|
|
32
|
+
return part;
|
|
33
|
+
});
|
|
34
|
+
return { message: { ...msg, content: next } };
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|