principles-disciple 1.174.0 → 1.176.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/samples.js +25 -0
- package/dist/core/rule-host.d.ts +21 -0
- package/dist/core/rule-host.js +58 -4
- package/dist/core/signal-collector-host.d.ts +104 -0
- package/dist/core/signal-collector-host.js +314 -0
- package/dist/hooks/prompt.js +43 -503
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/commands/samples.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
2
2
|
import { normalizeCommandArgs } from '../utils/io.js';
|
|
3
3
|
import { resolvePluginCommandWorkspaceDir } from '../utils/workspace-resolver.js';
|
|
4
|
+
import { emitPainDetectedEvent } from '../hooks/pain.js';
|
|
5
|
+
import { SystemLogger } from '../core/system-logger.js';
|
|
4
6
|
function isZh(ctx) {
|
|
5
7
|
return String(ctx.config?.language || 'en').startsWith('zh');
|
|
6
8
|
}
|
|
@@ -39,6 +41,29 @@ export function handleSamplesCommand(ctx) {
|
|
|
39
41
|
: `Failed to review sample: ${sampleId}`,
|
|
40
42
|
};
|
|
41
43
|
}
|
|
44
|
+
// 修断裂④(spec §6.3): reject 时触发 pain event,接通"owner-rejected 纠正 → 诊断"桥。
|
|
45
|
+
// 之前 recordCorrectionRejectedPain 只写 DB 不触发诊断,是假桥。
|
|
46
|
+
// 这里 fire-and-forget emitPainDetectedEvent(source='correction_rejected')。
|
|
47
|
+
if (normalizedDecision === 'rejected') {
|
|
48
|
+
const painScore = Math.max(0, Math.min(100, Math.round(record.qualityScore)));
|
|
49
|
+
void emitPainDetectedEvent(wctx, {
|
|
50
|
+
ts: new Date().toISOString(),
|
|
51
|
+
type: 'pain_detected',
|
|
52
|
+
data: {
|
|
53
|
+
painId: `correction_rejected_${record.sampleId}`,
|
|
54
|
+
painType: 'user_frustration',
|
|
55
|
+
source: 'correction_rejected',
|
|
56
|
+
reason: `Owner rejected correction sample (quality ${record.qualityScore})`,
|
|
57
|
+
score: painScore,
|
|
58
|
+
sessionId: record.sessionId,
|
|
59
|
+
agentId: 'main',
|
|
60
|
+
provenance: 'openclaw_context_bound',
|
|
61
|
+
evidence: [{ sourceRef: 'correction_sample', note: record.diffExcerpt.slice(0, 200) }],
|
|
62
|
+
},
|
|
63
|
+
}, { recordObservability: true }).catch((e) => {
|
|
64
|
+
SystemLogger.log(workspaceDir, 'SIGNAL_REJECT_EMIT_FAIL', `emitPainDetectedEvent failed on reject: ${String(e)}`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
42
67
|
return {
|
|
43
68
|
text: zh
|
|
44
69
|
? `样本 ${record.sampleId} 已标记为 ${record.reviewStatus}。`
|
package/dist/core/rule-host.d.ts
CHANGED
|
@@ -45,6 +45,16 @@ export declare class RuleHost {
|
|
|
45
45
|
private activationFingerprint;
|
|
46
46
|
private cachedImplementations;
|
|
47
47
|
private sqliteConnection;
|
|
48
|
+
/**
|
|
49
|
+
* R2-RH-002: Guards the "armed but empty" warn so it fires at most once per
|
|
50
|
+
* RuleHost instance. Without this, the 0-rules path (workspaceDir missing OR
|
|
51
|
+
* zero active code_tool_hook activations) returns [] silently on every
|
|
52
|
+
* evaluation — an observability gap (rc-9-no-silent-fallback). The warn is
|
|
53
|
+
* NOT a degradation fallback (RuleHost correctly has no opinion when empty);
|
|
54
|
+
* it makes the empty-armed state visible so operators can investigate why
|
|
55
|
+
* no live rules are loaded.
|
|
56
|
+
*/
|
|
57
|
+
private emptyLoadWarnEmitted;
|
|
48
58
|
constructor(stateDir: string, logger?: RuleHostLogger, options?: RuleHostOptions);
|
|
49
59
|
/**
|
|
50
60
|
* Update the logger sink on a cached RuleHost instance.
|
|
@@ -78,6 +88,17 @@ export declare class RuleHost {
|
|
|
78
88
|
* → JOIN pi_artifacts for content_json → extract implementationCode → compile
|
|
79
89
|
*/
|
|
80
90
|
private _loadActiveCodeImplementations;
|
|
91
|
+
/**
|
|
92
|
+
* R2-RH-002: Emit the "armed but empty" warn at most once per RuleHost
|
|
93
|
+
* instance. The warn is cached via `emptyLoadWarnEmitted` so repeated
|
|
94
|
+
* evaluations (which all hit the empty path) do not spam the log.
|
|
95
|
+
*
|
|
96
|
+
* This is an observability signal, NOT a degradation fallback — RuleHost
|
|
97
|
+
* correctly returns "no opinion" when there are no active rules. The warn
|
|
98
|
+
* makes the empty state visible so operators can distinguish "RuleHost is
|
|
99
|
+
* working but has no rules" from "RuleHost is broken" (rc-9-no-silent-fallback).
|
|
100
|
+
*/
|
|
101
|
+
private _emitEmptyLoadWarn;
|
|
81
102
|
/**
|
|
82
103
|
* Load active code implementations from the activations table (code_tool_hook channel).
|
|
83
104
|
*
|
package/dist/core/rule-host.js
CHANGED
|
@@ -51,6 +51,16 @@ export class RuleHost {
|
|
|
51
51
|
activationFingerprint = null;
|
|
52
52
|
cachedImplementations = [];
|
|
53
53
|
sqliteConnection = null;
|
|
54
|
+
/**
|
|
55
|
+
* R2-RH-002: Guards the "armed but empty" warn so it fires at most once per
|
|
56
|
+
* RuleHost instance. Without this, the 0-rules path (workspaceDir missing OR
|
|
57
|
+
* zero active code_tool_hook activations) returns [] silently on every
|
|
58
|
+
* evaluation — an observability gap (rc-9-no-silent-fallback). The warn is
|
|
59
|
+
* NOT a degradation fallback (RuleHost correctly has no opinion when empty);
|
|
60
|
+
* it makes the empty-armed state visible so operators can investigate why
|
|
61
|
+
* no live rules are loaded.
|
|
62
|
+
*/
|
|
63
|
+
emptyLoadWarnEmitted = false;
|
|
54
64
|
constructor(stateDir, logger = console, options) {
|
|
55
65
|
this.stateDir = stateDir;
|
|
56
66
|
this.logger = logger;
|
|
@@ -85,6 +95,7 @@ export class RuleHost {
|
|
|
85
95
|
this.cachedImplementations = [];
|
|
86
96
|
this.activationFingerprint = null;
|
|
87
97
|
this.implementationSources.clear();
|
|
98
|
+
this.emptyLoadWarnEmitted = false;
|
|
88
99
|
}
|
|
89
100
|
evaluateDetailed(input) {
|
|
90
101
|
try {
|
|
@@ -137,16 +148,38 @@ export class RuleHost {
|
|
|
137
148
|
*/
|
|
138
149
|
_loadActiveCodeImplementations(supportsContextV2) {
|
|
139
150
|
if (!this.workspaceDir) {
|
|
151
|
+
this._emitEmptyLoadWarn('workspaceDir not configured — RuleHost cannot load active code_tool_hook rules', 'Provide workspaceDir when constructing RuleHost (required for code_tool_hook channel)');
|
|
140
152
|
return [];
|
|
141
153
|
}
|
|
142
154
|
try {
|
|
143
|
-
|
|
155
|
+
const loaded = this._loadFromActivationsTable(this.workspaceDir, supportsContextV2);
|
|
156
|
+
if (loaded.length === 0) {
|
|
157
|
+
this._emitEmptyLoadWarn('armed but empty — 0 active code_tool_hook activations loaded (RuleHost will not block or require approval)', 'If this is unexpected, run `pd runtime activation list --channel code_tool_hook` to inspect activations, or `pd runtime activation promote` to enable a live rule');
|
|
158
|
+
}
|
|
159
|
+
return loaded;
|
|
144
160
|
}
|
|
145
161
|
catch (activationError) {
|
|
146
162
|
this.logger.warn?.(`[RuleHost] Failed to load code_tool_hook activations: ${String(activationError)}`);
|
|
147
163
|
return [];
|
|
148
164
|
}
|
|
149
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* R2-RH-002: Emit the "armed but empty" warn at most once per RuleHost
|
|
168
|
+
* instance. The warn is cached via `emptyLoadWarnEmitted` so repeated
|
|
169
|
+
* evaluations (which all hit the empty path) do not spam the log.
|
|
170
|
+
*
|
|
171
|
+
* This is an observability signal, NOT a degradation fallback — RuleHost
|
|
172
|
+
* correctly returns "no opinion" when there are no active rules. The warn
|
|
173
|
+
* makes the empty state visible so operators can distinguish "RuleHost is
|
|
174
|
+
* working but has no rules" from "RuleHost is broken" (rc-9-no-silent-fallback).
|
|
175
|
+
*/
|
|
176
|
+
_emitEmptyLoadWarn(reason, nextAction) {
|
|
177
|
+
if (this.emptyLoadWarnEmitted)
|
|
178
|
+
return;
|
|
179
|
+
this.emptyLoadWarnEmitted = true;
|
|
180
|
+
this.logger.warn?.(`[RuleHost] ${reason}. ` +
|
|
181
|
+
`nextAction=${nextAction}`);
|
|
182
|
+
}
|
|
150
183
|
/**
|
|
151
184
|
* Load active code implementations from the activations table (code_tool_hook channel).
|
|
152
185
|
*
|
|
@@ -169,7 +202,7 @@ export class RuleHost {
|
|
|
169
202
|
const db = sqliteConn.getDb();
|
|
170
203
|
const rows = db.prepare(`
|
|
171
204
|
SELECT a.activation_id, a.artifact_id, a.target_ref, a.action,
|
|
172
|
-
p.content_json, p.source_rule_id
|
|
205
|
+
p.content_json, p.source_rule_id, p.source_principle_id
|
|
173
206
|
FROM activations a
|
|
174
207
|
JOIN pi_artifacts p ON a.artifact_id = p.artifact_id
|
|
175
208
|
WHERE a.channel = 'code_tool_hook' AND a.deactivated_at IS NULL
|
|
@@ -185,7 +218,7 @@ export class RuleHost {
|
|
|
185
218
|
continue;
|
|
186
219
|
const record = row;
|
|
187
220
|
fingerprintParts.push([
|
|
188
|
-
record['activation_id'], record['artifact_id'], record['target_ref'], record['action'], record['content_json'], record['source_rule_id'],
|
|
221
|
+
record['activation_id'], record['artifact_id'], record['target_ref'], record['action'], record['content_json'], record['source_rule_id'], record['source_principle_id'],
|
|
189
222
|
].map((value) => typeof value === 'string' ? value : '').join('\u0001'));
|
|
190
223
|
}
|
|
191
224
|
const fingerprint = fingerprintParts.join('\u0002');
|
|
@@ -255,6 +288,12 @@ export class RuleHost {
|
|
|
255
288
|
const artifactId = typeof r['artifact_id'] === 'string' ? r['artifact_id'] : '';
|
|
256
289
|
const contentJson = typeof r['content_json'] === 'string' ? r['content_json'] : '';
|
|
257
290
|
const sourceRuleId = typeof r['source_rule_id'] === 'string' ? r['source_rule_id'] : null;
|
|
291
|
+
// R2-RH-004: Extract source_principle_id from pi_artifacts (DB lineage field).
|
|
292
|
+
// Previously this column was never SELECTed, so principleId was always
|
|
293
|
+
// derived from meta.ruleId (a rule ID, not a principle ID) — violating
|
|
294
|
+
// rc-6-lineage-consistency. Downstream gate.ts:135/164 recorded the
|
|
295
|
+
// wrong principleId in eventLog.recordRuleEnforced().
|
|
296
|
+
const sourcePrincipleId = typeof r['source_principle_id'] === 'string' ? r['source_principle_id'] : null;
|
|
258
297
|
const action = typeof r['action'] === 'string' ? r['action'] : '';
|
|
259
298
|
if (!activationId || !artifactId || !contentJson) {
|
|
260
299
|
this.logger.warn?.(`[RuleHost] Activation row missing required fields, skipping`);
|
|
@@ -312,6 +351,15 @@ export class RuleHost {
|
|
|
312
351
|
const ruleId = typeof contentObj['ruleId'] === 'string'
|
|
313
352
|
? contentObj['ruleId']
|
|
314
353
|
: (sourceRuleId ?? artifactId);
|
|
354
|
+
// R2-RH-004: Extract principleId from artifact content_json.
|
|
355
|
+
// Previously this field was never read, so result.principleId was
|
|
356
|
+
// always set to meta.ruleId (a rule ID) — violating
|
|
357
|
+
// rc-6-lineage-consistency. Precedence: contentJson.principleId
|
|
358
|
+
// (artifact payload) → pi_artifacts.source_principle_id (DB lineage)
|
|
359
|
+
// → meta.ruleId (rule ID fallback) → ruleId (last resort).
|
|
360
|
+
const contentPrincipleId = typeof contentObj['principleId'] === 'string'
|
|
361
|
+
? contentObj['principleId']
|
|
362
|
+
: null;
|
|
315
363
|
const implId = `act-impl-${activationId}`;
|
|
316
364
|
const moduleExports = loadRuleImplementationModule(implementationCode, implId);
|
|
317
365
|
if (!moduleExports || typeof moduleExports.callEvaluate !== 'function') {
|
|
@@ -351,7 +399,13 @@ export class RuleHost {
|
|
|
351
399
|
const result = rawResult;
|
|
352
400
|
if (result.matched && (result.decision === 'block' || result.decision === 'requireApproval')) {
|
|
353
401
|
result.ruleId = ruleId;
|
|
354
|
-
|
|
402
|
+
// R2-RH-004: principleId precedence — contentJson.principleId
|
|
403
|
+
// (artifact payload) → source_principle_id (DB lineage) →
|
|
404
|
+
// meta.ruleId (rule ID fallback) → ruleId (last resort).
|
|
405
|
+
// Previously this was `meta.ruleId ?? ruleId`, which always
|
|
406
|
+
// resolved to a rule ID (never a principle ID) because
|
|
407
|
+
// isRuleHostMeta guarantees meta.ruleId is a non-empty string.
|
|
408
|
+
result.principleId = contentPrincipleId ?? sourcePrincipleId ?? meta.ruleId ?? ruleId;
|
|
355
409
|
}
|
|
356
410
|
return result;
|
|
357
411
|
},
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SignalCollectorHost — plugin I/O 外壳 (design spec §4.2)
|
|
3
|
+
*
|
|
4
|
+
* 把 core 的纯逻辑 (collectSync / mapLlmResultToOutput) 接进 openclaw 运行时。
|
|
5
|
+
*
|
|
6
|
+
* 同步路径 (prompt.ts before_prompt_build 钩子调用,绝不阻塞):
|
|
7
|
+
* - trigger 门控 (仅 user)
|
|
8
|
+
* - Stage1 关键词快扫 (零成本)
|
|
9
|
+
* - 写 user_turns (复用 recordUserTurn)
|
|
10
|
+
* - 高精度短语命中 (high) → 直接走 STRONG 分流 (同步,纯内存)
|
|
11
|
+
* - 普通歧义词 / 未命中 → 入队异步 LLM 确认 (不阻塞)
|
|
12
|
+
*
|
|
13
|
+
* 异步路径 (fire-and-forget):
|
|
14
|
+
* - Stage2 LLM 确认 (后台,不阻塞 prompt hook)
|
|
15
|
+
* - LLM 不可用 → 降级:丢弃 ambiguous 候选 (不触发 STRONG,避免误判泛滥)
|
|
16
|
+
* - 按 strength 分流:STRONG → emitPainDetectedEvent (修断裂 ③);WEAK → trackFriction 累积 GFI
|
|
17
|
+
*
|
|
18
|
+
* 降级不静默 (rc-9):所有降级路径走 SystemLogger。
|
|
19
|
+
*/
|
|
20
|
+
import { type UnifiedKeywordStore, type SignalCollectorConfig } from '@principles/core/runtime-v2';
|
|
21
|
+
import type { PluginLogger } from '../openclaw-sdk.js';
|
|
22
|
+
import type { WorkspaceContext } from './workspace-context.js';
|
|
23
|
+
export declare const DEFAULT_SIGNAL_CONFIG: SignalCollectorConfig;
|
|
24
|
+
export declare function buildDefaultKeywordStore(): UnifiedKeywordStore;
|
|
25
|
+
/**
|
|
26
|
+
* adapter 抽象 (Stage2 LLM 调用)。plugin 层可注入真实 adapter (LMStudio);
|
|
27
|
+
* 为 null / 不可用时 host 走纯关键词降级。
|
|
28
|
+
*
|
|
29
|
+
* 本文件不依赖具体的 PDRuntimeAdapter 实现,只依赖一个最小的分类函数,
|
|
30
|
+
* 以保持 host 可单测 (测试可注入 mock classifier)。
|
|
31
|
+
*/
|
|
32
|
+
export type SignalLlmClassifier = (text: string, promptTemplate: string) => Promise<{
|
|
33
|
+
is_feedback: boolean;
|
|
34
|
+
type: 'correction' | 'empathy' | 'none';
|
|
35
|
+
confidence: number;
|
|
36
|
+
reason: string;
|
|
37
|
+
} | null>;
|
|
38
|
+
export interface SignalCollectorHostOptions {
|
|
39
|
+
keywordStore?: UnifiedKeywordStore;
|
|
40
|
+
config?: SignalCollectorConfig;
|
|
41
|
+
/** Stage2 LLM 分类器。null/undefined → 降级纯关键词。 */
|
|
42
|
+
llmClassifier?: SignalLlmClassifier | null;
|
|
43
|
+
}
|
|
44
|
+
export declare class SignalCollectorHost {
|
|
45
|
+
private readonly wctx;
|
|
46
|
+
private readonly store;
|
|
47
|
+
private readonly config;
|
|
48
|
+
private readonly llmClassifier;
|
|
49
|
+
/** rate limit 状态:sessionId → STRONG 计数桶 */
|
|
50
|
+
private readonly rateLimit;
|
|
51
|
+
constructor(wctx: WorkspaceContext, options?: SignalCollectorHostOptions);
|
|
52
|
+
/**
|
|
53
|
+
* ★ 同步路径 (prompt.ts before_prompt_build 钩子里调用,绝不能阻塞)。
|
|
54
|
+
*
|
|
55
|
+
* 只做:trigger 门控 + Stage1 关键词快扫 + 写 user_turns。
|
|
56
|
+
* 高精度短语命中 (high) → 直接走 STRONG 分流 (同步,纯内存)。
|
|
57
|
+
* 普通歧义词 / 未命中 → 入队异步 LLM 确认 (不阻塞)。
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* 可选入参:lineage 字段(由调用方算好传入,host 不感知 trajectory 结构)。
|
|
61
|
+
* referencesAssistantTurnId 让诊断 evidence 能 JOIN 到前置 assistant turn。
|
|
62
|
+
*/
|
|
63
|
+
detectSync(userMessage: string, sessionId: string, trigger: string, options?: {
|
|
64
|
+
referencesAssistantTurnId?: number | null;
|
|
65
|
+
turnIndex?: number;
|
|
66
|
+
}): void;
|
|
67
|
+
/**
|
|
68
|
+
* ★ 异步路径 (fire-and-forget)。
|
|
69
|
+
*
|
|
70
|
+
* 1. Stage2 LLM 确认 (后台,不阻塞用户)
|
|
71
|
+
* 2. LLM 不可用 → 降级:丢弃 ambiguous 候选 (不触发 STRONG,避免误判泛滥)
|
|
72
|
+
* 3. 按 strength 分流:STRONG → emitPainDetectedEvent;WEAK → trackFriction 累积 GFI;none → 仅记录
|
|
73
|
+
*/
|
|
74
|
+
private detectAsyncAndRoute;
|
|
75
|
+
/**
|
|
76
|
+
* STRONG 分流 → emitPainDetectedEvent (修断裂 ③)。
|
|
77
|
+
* 带 STRONG rate limit 门控 (spec §7.2):单 session 每小时上限 strongRateLimitPerHour。
|
|
78
|
+
*/
|
|
79
|
+
private routeStrong;
|
|
80
|
+
/**
|
|
81
|
+
* WEAK 分流 → trackFriction 累积 GFI + 写 evidence (维持现状,spec §3.2)。
|
|
82
|
+
*
|
|
83
|
+
* WEAK 是 Layer 3 弱信号,用较小的摩擦分(20)累积,过 highGfi 阈值(70)才触发诊断。
|
|
84
|
+
* 不用 strongPainScore(70),因为 WEAK 单条不该直接顶满 GFI。
|
|
85
|
+
*/
|
|
86
|
+
private routeWeak;
|
|
87
|
+
/**
|
|
88
|
+
* STRONG rate limit 门控:成功消耗一个名额返回 true;超限返回 false。
|
|
89
|
+
* 窗口满一小时自动重置。
|
|
90
|
+
*/
|
|
91
|
+
private tryConsumeRateLimit;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 从 .pd/config.yaml 的 signalCollector runtimeProfile 构造 SignalLlmClassifier。
|
|
95
|
+
* 完成配置单轨化(spec §3.3 决策3):统一走 .pd/config.yaml,移除 empathy 的 workflows.yaml 双轨。
|
|
96
|
+
*
|
|
97
|
+
* 返回 null 的情况(走纯关键词降级,rc-9 不静默):
|
|
98
|
+
* - feature flag signal_collector 关闭
|
|
99
|
+
* - runtimeProfile 未配置 / apiKeyEnv 缺失
|
|
100
|
+
* - profile 是 openclaw 类型(observer 不支持)
|
|
101
|
+
*
|
|
102
|
+
* 这是 Task 11 的最后一块:让本地 LLM(LMStudio)真正参与检测。
|
|
103
|
+
*/
|
|
104
|
+
export declare function createSignalLlmClassifierFromConfig(wctx: WorkspaceContext, logger?: Pick<PluginLogger, 'info' | 'warn' | 'error' | 'debug'>): SignalLlmClassifier | null;
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SignalCollectorHost — plugin I/O 外壳 (design spec §4.2)
|
|
3
|
+
*
|
|
4
|
+
* 把 core 的纯逻辑 (collectSync / mapLlmResultToOutput) 接进 openclaw 运行时。
|
|
5
|
+
*
|
|
6
|
+
* 同步路径 (prompt.ts before_prompt_build 钩子调用,绝不阻塞):
|
|
7
|
+
* - trigger 门控 (仅 user)
|
|
8
|
+
* - Stage1 关键词快扫 (零成本)
|
|
9
|
+
* - 写 user_turns (复用 recordUserTurn)
|
|
10
|
+
* - 高精度短语命中 (high) → 直接走 STRONG 分流 (同步,纯内存)
|
|
11
|
+
* - 普通歧义词 / 未命中 → 入队异步 LLM 确认 (不阻塞)
|
|
12
|
+
*
|
|
13
|
+
* 异步路径 (fire-and-forget):
|
|
14
|
+
* - Stage2 LLM 确认 (后台,不阻塞 prompt hook)
|
|
15
|
+
* - LLM 不可用 → 降级:丢弃 ambiguous 候选 (不触发 STRONG,避免误判泛滥)
|
|
16
|
+
* - 按 strength 分流:STRONG → emitPainDetectedEvent (修断裂 ③);WEAK → trackFriction 累积 GFI
|
|
17
|
+
*
|
|
18
|
+
* 降级不静默 (rc-9):所有降级路径走 SystemLogger。
|
|
19
|
+
*/
|
|
20
|
+
import { collectSync, mapLlmResultToOutput, buildLlmPrompt, parseLlmClassification, PiAiRuntimeAdapter, } from '@principles/core/runtime-v2';
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { emitPainDetectedEvent } from '../hooks/pain.js';
|
|
23
|
+
import { trackFriction } from './session-tracker.js';
|
|
24
|
+
import { SystemLogger } from './system-logger.js';
|
|
25
|
+
import { createTraceId } from './evolution-logger.js';
|
|
26
|
+
import { resolveObserverConfig } from './pd-config-loader.js';
|
|
27
|
+
// ── 默认配置 / 内置 seed 词库 ─────────────────────────────────────────────────
|
|
28
|
+
//
|
|
29
|
+
// 注意:完整的 <stateDir>/signal_keywords.json 加载 + 旧词库合并迁移 (spec §5.1)
|
|
30
|
+
// 属于另一项工作,这里先用内置 seed 保证 host 可独立工作。host 接口预留了
|
|
31
|
+
// keywordStore / config 的注入点,plugin 层接入真实文件 store 时无需改逻辑。
|
|
32
|
+
export const DEFAULT_SIGNAL_CONFIG = {
|
|
33
|
+
enableLlmStage: true,
|
|
34
|
+
llmTimeoutMs: 30_000,
|
|
35
|
+
promptTemplate: '',
|
|
36
|
+
strongPainScore: 70,
|
|
37
|
+
strongRateLimitPerHour: 5,
|
|
38
|
+
};
|
|
39
|
+
export function buildDefaultKeywordStore() {
|
|
40
|
+
const terms = {
|
|
41
|
+
// 高精度纠正短语 (命中即判 STRONG,不走 LLM)
|
|
42
|
+
'这是错的': { term: '这是错的', category: 'correction', weight: 0.9, precision: 'high', source: 'seed' },
|
|
43
|
+
'不要自作主张': { term: '不要自作主张', category: 'correction', weight: 0.9, precision: 'high', source: 'seed' },
|
|
44
|
+
'不应该这么做': { term: '不应该这么做', category: 'correction', weight: 0.9, precision: 'high', source: 'seed' },
|
|
45
|
+
// 普通歧义词 (仅作 evidence 候选,强制过 Stage2 LLM 二次确认)
|
|
46
|
+
'不对': { term: '不对', category: 'correction', weight: 0.5, precision: 'ambiguous', source: 'seed' },
|
|
47
|
+
'错了': { term: '错了', category: 'correction', weight: 0.5, precision: 'ambiguous', source: 'seed' },
|
|
48
|
+
'搞什么': { term: '搞什么', category: 'empathy', weight: 0.5, precision: 'ambiguous', source: 'seed' },
|
|
49
|
+
};
|
|
50
|
+
return { version: 1, terms };
|
|
51
|
+
}
|
|
52
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
53
|
+
export class SignalCollectorHost {
|
|
54
|
+
wctx;
|
|
55
|
+
store;
|
|
56
|
+
config;
|
|
57
|
+
llmClassifier;
|
|
58
|
+
/** rate limit 状态:sessionId → STRONG 计数桶 */
|
|
59
|
+
rateLimit = new Map();
|
|
60
|
+
constructor(wctx, options = {}) {
|
|
61
|
+
this.wctx = wctx;
|
|
62
|
+
this.store = options.keywordStore ?? buildDefaultKeywordStore();
|
|
63
|
+
this.config = options.config ?? DEFAULT_SIGNAL_CONFIG;
|
|
64
|
+
this.llmClassifier = options.llmClassifier ?? null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* ★ 同步路径 (prompt.ts before_prompt_build 钩子里调用,绝不能阻塞)。
|
|
68
|
+
*
|
|
69
|
+
* 只做:trigger 门控 + Stage1 关键词快扫 + 写 user_turns。
|
|
70
|
+
* 高精度短语命中 (high) → 直接走 STRONG 分流 (同步,纯内存)。
|
|
71
|
+
* 普通歧义词 / 未命中 → 入队异步 LLM 确认 (不阻塞)。
|
|
72
|
+
*/
|
|
73
|
+
/**
|
|
74
|
+
* 可选入参:lineage 字段(由调用方算好传入,host 不感知 trajectory 结构)。
|
|
75
|
+
* referencesAssistantTurnId 让诊断 evidence 能 JOIN 到前置 assistant turn。
|
|
76
|
+
*/
|
|
77
|
+
detectSync(userMessage, sessionId, trigger, options) {
|
|
78
|
+
// 1. trigger 门控 (保留现有 trigger 门控,仅处理真实用户消息)
|
|
79
|
+
if (trigger !== 'user') {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// 2. Stage1 关键词快扫 (同步,零成本)。detectedAt 由 plugin 层注入(core 不取时间,CodeRabbit #11)
|
|
83
|
+
const detectedAt = new Date().toISOString();
|
|
84
|
+
const output = collectSync(userMessage, sessionId, this.store, this.config, detectedAt);
|
|
85
|
+
// 3. 写 user_turns (复用 recordUserTurn)
|
|
86
|
+
// correctionDetected 含义扩展:isSignal && strength=STRONG (spec §5.2)
|
|
87
|
+
const turnInput = {
|
|
88
|
+
sessionId,
|
|
89
|
+
// turnIndex 由调用方传入(基于 event.messages 的真实计数);未传则用时间戳递增避免冲突。
|
|
90
|
+
turnIndex: options?.turnIndex ?? Date.now(),
|
|
91
|
+
rawText: userMessage,
|
|
92
|
+
correctionDetected: output.isSignal && output.strength === 'STRONG',
|
|
93
|
+
correctionCue: output.matchedTerms.length > 0 ? output.matchedTerms.join(', ') : null,
|
|
94
|
+
referencesAssistantTurnId: options?.referencesAssistantTurnId ?? null,
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
this.wctx.trajectory?.recordUserTurn?.(turnInput);
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
SystemLogger.log(this.wctx.workspaceDir, 'SIGNAL_TRAJECTORY_FAIL', `recordUserTurn threw: ${String(e)}`);
|
|
101
|
+
}
|
|
102
|
+
// 4. 高精度短语命中 (high, STRONG) → 直接走 STRONG 分流 (同步)
|
|
103
|
+
if (output.isSignal && output.strength === 'STRONG' && output.matchedPrecision === 'high') {
|
|
104
|
+
this.routeStrong(output, sessionId);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// 5. 普通歧义词 / 未命中 → 入队异步 LLM 确认 (不阻塞 prompt hook)
|
|
108
|
+
if (output.needsLlmConfirmation) {
|
|
109
|
+
const pending = {
|
|
110
|
+
output,
|
|
111
|
+
sessionId,
|
|
112
|
+
text: userMessage,
|
|
113
|
+
traceId: createTraceId(),
|
|
114
|
+
};
|
|
115
|
+
// fire-and-forget,失败不影响用户消息处理 (spec §4.2)
|
|
116
|
+
void this.detectAsyncAndRoute(pending);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* ★ 异步路径 (fire-and-forget)。
|
|
121
|
+
*
|
|
122
|
+
* 1. Stage2 LLM 确认 (后台,不阻塞用户)
|
|
123
|
+
* 2. LLM 不可用 → 降级:丢弃 ambiguous 候选 (不触发 STRONG,避免误判泛滥)
|
|
124
|
+
* 3. 按 strength 分流:STRONG → emitPainDetectedEvent;WEAK → trackFriction 累积 GFI;none → 仅记录
|
|
125
|
+
*/
|
|
126
|
+
async detectAsyncAndRoute(pending) {
|
|
127
|
+
// 1. Stage2 LLM 确认
|
|
128
|
+
let confirmed;
|
|
129
|
+
if (this.llmClassifier && this.config.enableLlmStage) {
|
|
130
|
+
let llmResult = null;
|
|
131
|
+
try {
|
|
132
|
+
llmResult = await this.llmClassifier(pending.text, this.config.promptTemplate);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
SystemLogger.log(this.wctx.workspaceDir, 'SIGNAL_LLM_PARSE_FAIL', `LLM classifier threw: ${String(e)}`);
|
|
136
|
+
}
|
|
137
|
+
const llmDetectedAt = new Date().toISOString();
|
|
138
|
+
if (llmResult) {
|
|
139
|
+
confirmed = mapLlmResultToOutput(llmResult, pending.text, pending.sessionId, this.config, llmDetectedAt);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
// LLM 返回非法结果 → 当 none 处理 (rc-1),降级不静默
|
|
143
|
+
SystemLogger.log(this.wctx.workspaceDir, 'SIGNAL_LLM_PARSE_FAIL', 'LLM returned invalid result, treating as none');
|
|
144
|
+
confirmed = mapLlmResultToOutput({ is_feedback: false, type: 'none', confidence: 1, reason: 'LLM parse failed' }, pending.text, pending.sessionId, this.config, llmDetectedAt);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// LLM 不可用 → 降级纯关键词:丢弃 ambiguous 候选 (不触发 STRONG,避免误判泛滥)
|
|
149
|
+
// (Stage1 已对该候选标 needsLlmConfirmation=true 且 isSignal=false,这里维持不触发)
|
|
150
|
+
SystemLogger.log(this.wctx.workspaceDir, 'SIGNAL_LLM_DEGRADED', 'LLM unavailable, dropping ambiguous candidate (no STRONG trigger)');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// 3. 按 strength 分流
|
|
154
|
+
if (confirmed.isSignal && confirmed.strength === 'STRONG') {
|
|
155
|
+
this.routeStrong(confirmed, pending.sessionId);
|
|
156
|
+
}
|
|
157
|
+
else if (confirmed.isSignal && confirmed.strength === 'WEAK') {
|
|
158
|
+
this.routeWeak(confirmed, pending.sessionId);
|
|
159
|
+
}
|
|
160
|
+
// none → 仅记录,无副作用
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* STRONG 分流 → emitPainDetectedEvent (修断裂 ③)。
|
|
164
|
+
* 带 STRONG rate limit 门控 (spec §7.2):单 session 每小时上限 strongRateLimitPerHour。
|
|
165
|
+
*/
|
|
166
|
+
routeStrong(output, sessionId) {
|
|
167
|
+
if (!this.tryConsumeRateLimit(sessionId)) {
|
|
168
|
+
SystemLogger.log(this.wctx.workspaceDir, 'SIGNAL_STRONG_RATE_LIMITED', 'STRONG signal suppressed by rate limit'); // 不记录 sessionId(隐私,CodeRabbit #2)
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const reason = output.llmReason
|
|
172
|
+
|| (output.matchedTerms.length > 0
|
|
173
|
+
? `User correction detected: ${output.matchedTerms.join(', ')}`
|
|
174
|
+
: 'User correction detected');
|
|
175
|
+
// emitPainDetectedEvent 是 async,但同步路径不能 await (会阻塞 prompt hook)。
|
|
176
|
+
// 这里 fire-and-forget,失败 catch (不 throw,不阻断用户消息处理)。
|
|
177
|
+
void emitPainDetectedEvent(this.wctx, {
|
|
178
|
+
ts: new Date().toISOString(),
|
|
179
|
+
type: 'pain_detected',
|
|
180
|
+
data: {
|
|
181
|
+
painId: `correction_${createTraceId()}`, // 唯一 ID,避免同毫秒碰撞(CodeRabbit #3)
|
|
182
|
+
painType: 'user_frustration',
|
|
183
|
+
source: 'user_correction', // Layer 2 强信号 (spec §6.1)
|
|
184
|
+
reason,
|
|
185
|
+
score: this.config.strongPainScore, // STRONG_PAIN_SCORE 默认 70
|
|
186
|
+
sessionId,
|
|
187
|
+
agentId: 'main',
|
|
188
|
+
provenance: 'openclaw_context_bound',
|
|
189
|
+
evidence: [
|
|
190
|
+
{ sourceRef: 'signal_collector', note: output.evidence.excerpt },
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
}, { recordObservability: true }).catch((e) => {
|
|
194
|
+
SystemLogger.log(this.wctx.workspaceDir, 'SIGNAL_EMIT_FAIL', `emitPainDetectedEvent failed: ${String(e)}`);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* WEAK 分流 → trackFriction 累积 GFI + 写 evidence (维持现状,spec §3.2)。
|
|
199
|
+
*
|
|
200
|
+
* WEAK 是 Layer 3 弱信号,用较小的摩擦分(20)累积,过 highGfi 阈值(70)才触发诊断。
|
|
201
|
+
* 不用 strongPainScore(70),因为 WEAK 单条不该直接顶满 GFI。
|
|
202
|
+
*/
|
|
203
|
+
routeWeak(output, sessionId) {
|
|
204
|
+
// 单向摘要,不暴露 sessionId 明文(CodeRabbit #4)
|
|
205
|
+
const hash = createHash('sha256')
|
|
206
|
+
.update(`${output.evidence.detectedAt}:${sessionId}`)
|
|
207
|
+
.digest('hex')
|
|
208
|
+
.slice(0, 32);
|
|
209
|
+
trackFriction(sessionId, 20, hash, this.wctx.workspaceDir, {
|
|
210
|
+
source: 'user_empathy',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* STRONG rate limit 门控:成功消耗一个名额返回 true;超限返回 false。
|
|
215
|
+
* 窗口满一小时自动重置。
|
|
216
|
+
*/
|
|
217
|
+
tryConsumeRateLimit(sessionId) {
|
|
218
|
+
const limit = this.config.strongRateLimitPerHour;
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
let bucket = this.rateLimit.get(sessionId);
|
|
221
|
+
if (!bucket || now - bucket.windowStart >= ONE_HOUR_MS) {
|
|
222
|
+
bucket = { count: 0, windowStart: now };
|
|
223
|
+
this.rateLimit.set(sessionId, bucket);
|
|
224
|
+
}
|
|
225
|
+
if (bucket.count >= limit) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
bucket.count += 1;
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 从 .pd/config.yaml 的 signalCollector runtimeProfile 构造 SignalLlmClassifier。
|
|
234
|
+
* 完成配置单轨化(spec §3.3 决策3):统一走 .pd/config.yaml,移除 empathy 的 workflows.yaml 双轨。
|
|
235
|
+
*
|
|
236
|
+
* 返回 null 的情况(走纯关键词降级,rc-9 不静默):
|
|
237
|
+
* - feature flag signal_collector 关闭
|
|
238
|
+
* - runtimeProfile 未配置 / apiKeyEnv 缺失
|
|
239
|
+
* - profile 是 openclaw 类型(observer 不支持)
|
|
240
|
+
*
|
|
241
|
+
* 这是 Task 11 的最后一块:让本地 LLM(LMStudio)真正参与检测。
|
|
242
|
+
*/
|
|
243
|
+
export function createSignalLlmClassifierFromConfig(wctx, logger) {
|
|
244
|
+
try {
|
|
245
|
+
const cfg = resolveObserverConfig(wctx.workspaceDir, 'signal_collector', 'signalCollector', logger);
|
|
246
|
+
if (!cfg.enabled) {
|
|
247
|
+
logger?.debug?.(`[PD:Signal] LLM classifier disabled: ${cfg.reason}`);
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
if (cfg.readiness !== 'not_ready' && cfg.readiness !== 'ready') {
|
|
251
|
+
// needs_setup / disabled / config_malformed → 降级
|
|
252
|
+
logger?.debug?.(`[PD:Signal] LLM classifier not ready (${cfg.readiness}): ${cfg.reason}. ${cfg.nextAction}`);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
if (!cfg.provider || !cfg.model || !cfg.apiKeyEnv) {
|
|
256
|
+
logger?.debug?.(`[PD:Signal] LLM classifier missing provider/model/apiKeyEnv`);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const adapter = new PiAiRuntimeAdapter({
|
|
260
|
+
provider: cfg.provider,
|
|
261
|
+
model: cfg.model,
|
|
262
|
+
apiKeyEnv: cfg.apiKeyEnv,
|
|
263
|
+
timeoutMs: cfg.timeoutMs ?? 30_000,
|
|
264
|
+
baseUrl: cfg.baseUrl ?? undefined,
|
|
265
|
+
workspace: wctx.workspaceDir,
|
|
266
|
+
});
|
|
267
|
+
// 包装成 SignalLlmClassifier:内部调 startRun/pollRun/fetchOutput + buildLlmPrompt + parseLlmClassification
|
|
268
|
+
const classifier = async (text, _promptTemplate) => {
|
|
269
|
+
void _promptTemplate; // 用 core 的 buildLlmPrompt(标准化的),不用外部传入
|
|
270
|
+
const prompt = buildLlmPrompt(text);
|
|
271
|
+
try {
|
|
272
|
+
const handle = await adapter.startRun({
|
|
273
|
+
agentSpec: { agentId: 'signal-collector', schemaVersion: '1' },
|
|
274
|
+
inputPayload: { prompt },
|
|
275
|
+
contextItems: [],
|
|
276
|
+
timeoutMs: cfg.timeoutMs ?? 30_000,
|
|
277
|
+
});
|
|
278
|
+
let status = await adapter.pollRun(handle.runId);
|
|
279
|
+
const deadline = Date.now() + (cfg.timeoutMs ?? 30_000);
|
|
280
|
+
while (status.status === 'running' && Date.now() < deadline) {
|
|
281
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
282
|
+
status = await adapter.pollRun(handle.runId);
|
|
283
|
+
}
|
|
284
|
+
if (status.status !== 'succeeded') {
|
|
285
|
+
SystemLogger.log(wctx.workspaceDir, 'SIGNAL_LLM_TIMEOUT', JSON.stringify({ status: status.status }));
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const output = await adapter.fetchOutput(handle.runId);
|
|
289
|
+
const payload = output?.payload;
|
|
290
|
+
let rawOutput = '';
|
|
291
|
+
if (typeof payload === 'string') {
|
|
292
|
+
rawOutput = payload;
|
|
293
|
+
}
|
|
294
|
+
else if (typeof payload === 'object' && payload !== null && Object.hasOwn(payload, 'output')) {
|
|
295
|
+
const maybeOutput = payload.output;
|
|
296
|
+
if (typeof maybeOutput === 'string') {
|
|
297
|
+
rawOutput = maybeOutput;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const parsed = parseLlmClassification(rawOutput);
|
|
301
|
+
return parsed.valid ? parsed.value : null;
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
SystemLogger.log(wctx.workspaceDir, 'SIGNAL_LLM_FAILED', String(e).slice(0, 200));
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
return classifier;
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
logger?.warn?.(`[PD:Signal] createSignalLlmClassifierFromConfig failed: ${String(e)}`);
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
package/dist/hooks/prompt.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import { clearInjectedProbationIds, getSession, resetFriction, setInjectedProbationIds,
|
|
3
|
+
import { clearInjectedProbationIds, getSession, resetFriction, setInjectedProbationIds, decayGfi, getGfiDecayElapsed } from '../core/session-tracker.js';
|
|
4
4
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
5
5
|
import { defaultContextConfig } from '../types.js';
|
|
6
6
|
// local-worker-routing module and its routing helpers removed entirely per PRI-448.
|
|
@@ -8,25 +8,16 @@ import { defaultContextConfig } from '../types.js';
|
|
|
8
8
|
import { extractSummary, getHistoryVersions, parseWorkingMemorySection, workingMemoryToInjection, autoCompressFocus, safeReadCurrentFocus } from '../core/focus-history.js';
|
|
9
9
|
import { PathResolver } from '../core/path-resolver.js';
|
|
10
10
|
import { selectPrinciplesForInjection, DEFAULT_PRINCIPLE_BUDGET } from '../core/principle-injection.js';
|
|
11
|
-
import { getCachedMaskedPrincipleSet,
|
|
11
|
+
import { getCachedMaskedPrincipleSet, RUNTIME_V2_PRINCIPLE_BUDGET, trimToBudget, renderPrinciplesToDirectives } from '@principles/core/runtime-v2';
|
|
12
12
|
import { truncateInjectionToBudget } from '@principles/core/prompt-builder';
|
|
13
13
|
import { PromptActivationReader } from '../core/runtime-v2-prompt-activation-reader.js';
|
|
14
|
-
import { matchEmpathyKeywords, loadKeywordStore, saveKeywordStore, applyKeywordUpdates, getKeywordStoreSummary, } from '../core/empathy-keyword-matcher.js';
|
|
15
|
-
import { severityToPenalty, DEFAULT_EMPATHY_KEYWORD_CONFIG } from '../core/empathy-types.js';
|
|
16
|
-
import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
|
|
17
|
-
import { emitPainDetectedEvent, buildTrajectoryEvidence } from './pain.js';
|
|
18
|
-
import { evaluateTriggerController } from '@principles/core/runtime-v2';
|
|
19
|
-
import { isSharedCooldownActive, markSharedEpisodeAsDiagnosed } from './trigger-cooldown-tracker.js';
|
|
20
|
-
import { buildEmpathyObservation, resolveSourceKind } from './raw-observation-adapter.js';
|
|
21
|
-
import { evaluateEvidenceTriage } from './triage-adapter.js';
|
|
22
14
|
import { loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
|
|
23
15
|
import { safeReadIntentDoc, resetIntentDocCacheForTest } from '../core/intent-doc-reader.js';
|
|
24
16
|
import { resolveIntentLang } from '../core/intent-doc-reader-adapter.js';
|
|
25
17
|
import { buildIntentFrictionBlock } from '@principles/core/runtime-v2';
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import { buildAgentIdentity, buildEmpathySilenceConstraint, extractUserMessageFromPrompt, assembleHeartbeatChecklist, formatCorePrinciples, formatEvolutionPrinciples, assembleAppendSystemContext, extractPhrasesFromReason, } from './prompt-helpers.js';
|
|
18
|
+
import { escapeXml, extractMessageContent, isMinimalTrigger, } from '@principles/core/prompt-builder';
|
|
19
|
+
import { buildAgentIdentity, buildEmpathySilenceConstraint, extractUserMessageFromPrompt, assembleHeartbeatChecklist, formatCorePrinciples, formatEvolutionPrinciples, assembleAppendSystemContext, } from './prompt-helpers.js';
|
|
20
|
+
import { SignalCollectorHost, createSignalLlmClassifierFromConfig } from '../core/signal-collector-host.js';
|
|
30
21
|
// ---------------------------------------------------------------------------
|
|
31
22
|
// Static file cache — avoids re-reading rarely-changing files every message
|
|
32
23
|
// ---------------------------------------------------------------------------
|
|
@@ -85,19 +76,6 @@ function cachedReadFile(filePath, workspaceDir) {
|
|
|
85
76
|
return '';
|
|
86
77
|
}
|
|
87
78
|
}
|
|
88
|
-
/**
|
|
89
|
-
* Per-workspace empathy state. Keyed by workspaceDir to avoid cross-workspace
|
|
90
|
-
* state pollution. Previously module-level variables.
|
|
91
|
-
*/
|
|
92
|
-
const _empathyState = new Map();
|
|
93
|
-
function getEmpathyState(workspaceDir) {
|
|
94
|
-
let state = _empathyState.get(workspaceDir);
|
|
95
|
-
if (!state) {
|
|
96
|
-
state = { turnCounter: 0, keywordCache: null };
|
|
97
|
-
_empathyState.set(workspaceDir, state);
|
|
98
|
-
}
|
|
99
|
-
return state;
|
|
100
|
-
}
|
|
101
79
|
/**
|
|
102
80
|
* Reset all module-level prompt state for a workspace.
|
|
103
81
|
* Intended for test isolation — call in beforeEach().
|
|
@@ -105,14 +83,30 @@ function getEmpathyState(workspaceDir) {
|
|
|
105
83
|
export function resetPromptStateForTest(workspaceDir) {
|
|
106
84
|
if (workspaceDir) {
|
|
107
85
|
_staticFileCache.delete(workspaceDir);
|
|
108
|
-
_empathyState.delete(workspaceDir);
|
|
109
86
|
resetIntentDocCacheForTest(workspaceDir);
|
|
110
87
|
}
|
|
111
88
|
else {
|
|
112
89
|
_staticFileCache.clear();
|
|
113
|
-
_empathyState.clear();
|
|
114
90
|
resetIntentDocCacheForTest();
|
|
115
91
|
}
|
|
92
|
+
// CodeRabbit #6: 清理 host 缓存,避免 rate-limit/classifier 状态跨测试泄漏
|
|
93
|
+
_signalCollectorHosts.clear();
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* SignalCollectorHost 实例缓存(per-workspace)。
|
|
97
|
+
* 避免每条用户消息 new 一个 host;host 内部有 rate limit 状态需跨消息保留。
|
|
98
|
+
*/
|
|
99
|
+
const _signalCollectorHosts = new Map();
|
|
100
|
+
function getSignalCollectorHost(wctx, logger) {
|
|
101
|
+
let host = _signalCollectorHosts.get(wctx.workspaceDir);
|
|
102
|
+
if (!host) {
|
|
103
|
+
// 从 .pd/config.yaml 的 signalCollector runtimeProfile 构造 LLM classifier(配置单轨化)。
|
|
104
|
+
// 未配置/降级时返回 null,host 走纯关键词模式(spec §3.3 决策3)。
|
|
105
|
+
const llmClassifier = createSignalLlmClassifierFromConfig(wctx, logger);
|
|
106
|
+
host = new SignalCollectorHost(wctx, { llmClassifier });
|
|
107
|
+
_signalCollectorHosts.set(wctx.workspaceDir, host);
|
|
108
|
+
}
|
|
109
|
+
return host;
|
|
116
110
|
}
|
|
117
111
|
function parseContextInjectionConfig(value) {
|
|
118
112
|
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
@@ -204,53 +198,28 @@ export async function handleBeforePromptBuild(event, ctx) {
|
|
|
204
198
|
if (sessionId) {
|
|
205
199
|
wctx.trajectory?.recordSession?.({ sessionId });
|
|
206
200
|
}
|
|
201
|
+
// 提前计算 lineage + userTurnCount(不依赖 isAgentToAgent),供后续 detectSync 使用。
|
|
202
|
+
// detectSync 调用延后到 isAgentToAgent 解析之后(CodeRabbit #7: 避免 agent-to-agent 误判)。
|
|
203
|
+
let correctionUserText = null;
|
|
204
|
+
let correctionReferencesAssistantTurnId = null;
|
|
205
|
+
let correctionTurnIndex = 0;
|
|
207
206
|
if (sessionId && trigger === 'user' && Array.isArray(event.messages) && event.messages.length > 0) {
|
|
208
207
|
const latestUserIndex = [...event.messages]
|
|
209
208
|
.map((message, index) => ({ message, index }))
|
|
210
209
|
.reverse()
|
|
211
210
|
.find((entry) => entry.message?.role === 'user');
|
|
212
211
|
if (latestUserIndex) {
|
|
213
|
-
|
|
214
|
-
//
|
|
215
|
-
let correctionCue = null;
|
|
216
|
-
try {
|
|
217
|
-
const learner = CorrectionCueLearner.get(wctx.stateDir);
|
|
218
|
-
const matchResult = learner.match(userText);
|
|
219
|
-
if (matchResult.matched) {
|
|
220
|
-
correctionCue = matchResult.matchedTerms[0] ?? null;
|
|
221
|
-
learner.recordHits(matchResult.matchedTerms);
|
|
222
|
-
// TP for high-confidence; flush hitCount for low-confidence
|
|
223
|
-
if (correctionCue && matchResult.confidence >= 0.5) {
|
|
224
|
-
learner.recordTruePositive(correctionCue);
|
|
225
|
-
}
|
|
226
|
-
else {
|
|
227
|
-
learner.flush();
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
catch (learnerErr) {
|
|
232
|
-
// Fallback to hardcoded detection if learner fails — log for observability
|
|
233
|
-
correctionCue = coreDetectCorrectionCue(userText);
|
|
234
|
-
logger?.warn?.(`[PD:Prompt] CorrectionCueLearner.match() failed (${String(learnerErr)}), fallback=${correctionCue ? `matched="${correctionCue}"` : 'no-match'}`);
|
|
235
|
-
}
|
|
236
|
-
let referencesAssistantTurnId = null;
|
|
212
|
+
correctionUserText = extractMessageContent(latestUserIndex.message);
|
|
213
|
+
// lineage: 关联到前置 assistant turn(诊断 evidence JOIN 用)。host 不感知 trajectory 结构,由这里算好传入。
|
|
237
214
|
const hasPriorAssistant = event.messages
|
|
238
215
|
.slice(0, latestUserIndex.index)
|
|
239
216
|
.some((message) => message?.role === 'assistant');
|
|
240
217
|
if (hasPriorAssistant) {
|
|
241
218
|
const turns = wctx.trajectory?.listAssistantTurns?.(sessionId) ?? [];
|
|
242
219
|
const lastAssistant = turns[turns.length - 1];
|
|
243
|
-
|
|
220
|
+
correctionReferencesAssistantTurnId = lastAssistant?.id ?? null;
|
|
244
221
|
}
|
|
245
|
-
|
|
246
|
-
wctx.trajectory?.recordUserTurn?.({
|
|
247
|
-
sessionId,
|
|
248
|
-
turnIndex: userTurnCount,
|
|
249
|
-
rawText: userText,
|
|
250
|
-
correctionDetected: Boolean(correctionCue),
|
|
251
|
-
correctionCue,
|
|
252
|
-
referencesAssistantTurnId,
|
|
253
|
-
});
|
|
222
|
+
correctionTurnIndex = event.messages.filter((message) => message?.role === 'user').length;
|
|
254
223
|
}
|
|
255
224
|
}
|
|
256
225
|
// Load context injection configuration
|
|
@@ -281,402 +250,21 @@ export async function handleBeforePromptBuild(event, ctx) {
|
|
|
281
250
|
// Also detects empathy observer output (prevent recursion) and agent-to-agent messages.
|
|
282
251
|
const { message: latestUserMessage, isAgentToAgent } = extractUserMessageFromPrompt(event.prompt || '', sessionId);
|
|
283
252
|
const isUserInteraction = trigger === 'user' || trigger === 'api' || !trigger;
|
|
284
|
-
// Empathy Observer: keyword fast-path + optional LLM deep analysis (zero latency async dispatch)
|
|
285
|
-
const empathyEnabled = wctx.config.get('empathy_engine.enabled') !== false;
|
|
286
|
-
logger?.info?.(`[PD:Empathy] Conditions: enabled=${empathyEnabled}, isUser=${isUserInteraction}, sessionId=${!!sessionId}, api=${!!api}, !agentToAgent=${!isAgentToAgent}, workspaceDir=${!!workspaceDir}, hasMessage=${!!latestUserMessage}`);
|
|
287
253
|
// Track if we should inject behavioral constraints (will be added to appendSystemContext later)
|
|
254
|
+
// 注:原 empathy 检测门控(empathyEnabled)已移除,检测职责由 SignalCollectorHost 接管。
|
|
255
|
+
// behavioral 约束注入是独立职责,保留门控。
|
|
288
256
|
let shouldInjectBehavioralConstraints = false;
|
|
289
|
-
if (
|
|
257
|
+
if (isUserInteraction && sessionId && api && !isAgentToAgent) {
|
|
290
258
|
shouldInjectBehavioralConstraints = true;
|
|
291
|
-
// ── Empathy Hybrid Matching (keyword + subagent sampling) ──
|
|
292
|
-
// Fast keyword scan on every turn, with strategic subagent sampling
|
|
293
|
-
// for boundary cases and random discovery of new expressions.
|
|
294
|
-
if (workspaceDir && latestUserMessage) {
|
|
295
|
-
try {
|
|
296
|
-
const msgPreview = latestUserMessage.substring(0, 200).replace(/\n/g, ' ');
|
|
297
|
-
logger?.info?.(`[PD:Empathy] Processing user message: "${msgPreview}" (trigger=${trigger}, promptLen=${latestUserMessage.length})`);
|
|
298
|
-
const lang = wctx.config.get('language') || 'zh';
|
|
299
|
-
// Load keyword store once, cache per-workspace (Finding #7: avoid per-turn I/O)
|
|
300
|
-
const empathyState = getEmpathyState(workspaceDir);
|
|
301
|
-
if (!empathyState.keywordCache || empathyState.keywordCache.lang !== lang) {
|
|
302
|
-
empathyState.keywordCache = { store: loadKeywordStore(wctx.stateDir, lang), lang };
|
|
303
|
-
}
|
|
304
|
-
const keywordStore = empathyState.keywordCache.store;
|
|
305
|
-
const matchResult = matchEmpathyKeywords(latestUserMessage, keywordStore);
|
|
306
|
-
// Increment turn counter (Finding #3: session.turnCount doesn't exist)
|
|
307
|
-
empathyState.turnCounter++;
|
|
308
|
-
const turnCount = empathyState.turnCounter;
|
|
309
|
-
const painTrigger = wctx.config.get('thresholds.pain_trigger') || 40;
|
|
310
|
-
const highGfiThreshold = Math.max(wctx.config.get('severity_thresholds.high') || 70, painTrigger + 30);
|
|
311
|
-
if (matchResult.matched) {
|
|
312
|
-
const penalty = severityToPenalty(matchResult.severity, DEFAULT_EMPATHY_KEYWORD_CONFIG);
|
|
313
|
-
// trackFriction signature: (sessionId, deltaF: number, hash: string, workspaceDir?, options?)
|
|
314
|
-
trackFriction(sessionId, penalty, 'empathy_keyword_match', workspaceDir, {
|
|
315
|
-
source: 'user_empathy',
|
|
316
|
-
});
|
|
317
|
-
logger?.info?.(`[PD:Empathy] MATCH: "${matchResult.matchedTerms.join(', ')}" → severity=${matchResult.severity}, score=${matchResult.score.toFixed(2)}, penalty=${penalty}`);
|
|
318
|
-
const currentSession = getSession(sessionId);
|
|
319
|
-
const currentGfi = currentSession?.currentGfi ?? 0;
|
|
320
|
-
if (currentGfi >= highGfiThreshold) {
|
|
321
|
-
const gfiPainScore = Math.min(Math.round(currentGfi), 60);
|
|
322
|
-
logger?.info?.(`[PD:Empathy] GFI-TRIGGERED: currentGfi=${currentGfi.toFixed(1)} >= highGfi=${highGfiThreshold}, emitting pain signal (score=${gfiPainScore})`);
|
|
323
|
-
// PRI-454: Dual-gate migration. When both flags ON → Gate B (TriggerController).
|
|
324
|
-
// When either OFF → Gate A (PainDiagnosticGate, rollback).
|
|
325
|
-
const triageFlag = loadFeatureFlagFromConfig(workspaceDir, 'painEvidenceAdmission');
|
|
326
|
-
const defaultFlag = loadFeatureFlagFromConfig(workspaceDir, 'painEvidenceAdmissionDefault');
|
|
327
|
-
const useGateB = triageFlag.enabled && defaultFlag.enabled;
|
|
328
|
-
wctx.eventLog.recordPainSignal(sessionId, {
|
|
329
|
-
score: gfiPainScore,
|
|
330
|
-
source: 'user_empathy',
|
|
331
|
-
reason: `Accumulated GFI (${currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Matched: ${matchResult.matchedTerms.join(', ')}`,
|
|
332
|
-
isRisky: false,
|
|
333
|
-
origin: 'system_infer',
|
|
334
|
-
severity: gfiPainScore >= 40 ? 'moderate' : 'mild',
|
|
335
|
-
confidence: 0.5,
|
|
336
|
-
detection_mode: 'structured',
|
|
337
|
-
deduped: false,
|
|
338
|
-
trigger_text_excerpt: sanitizeForEvidence(latestUserMessage, workspaceDir).substring(0, 120),
|
|
339
|
-
raw_score: gfiPainScore,
|
|
340
|
-
calibrated_score: gfiPainScore,
|
|
341
|
-
eventId: `empathy_gfi_${Date.now()}`,
|
|
342
|
-
});
|
|
343
|
-
// PRI-453: Generate painId early and write to trajectory.db via legacy
|
|
344
|
-
// recordPainEvent so that disabling SDK observability path does not lose
|
|
345
|
-
// trajectory coverage. canonicalPainId enables dedup.
|
|
346
|
-
const gfiPainId = `empathy_gfi_${Date.now()}`;
|
|
347
|
-
wctx.trajectory?.recordPainEvent?.({
|
|
348
|
-
sessionId,
|
|
349
|
-
source: 'user_empathy',
|
|
350
|
-
score: gfiPainScore,
|
|
351
|
-
reason: `Accumulated GFI (${currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Matched: ${matchResult.matchedTerms.join(', ')}`,
|
|
352
|
-
origin: 'system_infer',
|
|
353
|
-
canonicalPainId: gfiPainId,
|
|
354
|
-
});
|
|
355
|
-
if (useGateB) {
|
|
356
|
-
// PRI-454: Gate B path — TriggerController owns admission
|
|
357
|
-
const rawObs = buildEmpathyObservation({
|
|
358
|
-
detectionSource: 'user_empathy',
|
|
359
|
-
isGfiTriggered: true,
|
|
360
|
-
sessionId,
|
|
361
|
-
});
|
|
362
|
-
const sourceKind = resolveSourceKind(rawObs);
|
|
363
|
-
const triage = evaluateEvidenceTriage(sourceKind, gfiPainScore);
|
|
364
|
-
if (triage.decision !== 'admit') {
|
|
365
|
-
logger?.info?.(`[PD:Empathy] Triage ${triage.decision}: ${triage.reason}`);
|
|
366
|
-
}
|
|
367
|
-
else {
|
|
368
|
-
const cooldownActive = isSharedCooldownActive(sourceKind, sessionId, 'empathy_gfi_threshold');
|
|
369
|
-
const triggerDecision = evaluateTriggerController({
|
|
370
|
-
triageResult: triage,
|
|
371
|
-
isOwnerManual: false,
|
|
372
|
-
isCooldownActive: cooldownActive,
|
|
373
|
-
isValid: true,
|
|
374
|
-
score: gfiPainScore,
|
|
375
|
-
sessionId,
|
|
376
|
-
});
|
|
377
|
-
if (triggerDecision.shouldCreateDiagnosticTask) {
|
|
378
|
-
markSharedEpisodeAsDiagnosed(sourceKind, sessionId, 'empathy_gfi_threshold');
|
|
379
|
-
logger?.info?.(`[PD:Empathy] Gate B approved, calling emitPainDetectedEvent...`);
|
|
380
|
-
try {
|
|
381
|
-
const evidence = buildTrajectoryEvidence(wctx, sessionId);
|
|
382
|
-
await emitPainDetectedEvent(wctx, {
|
|
383
|
-
ts: new Date().toISOString(),
|
|
384
|
-
type: 'pain_detected',
|
|
385
|
-
data: {
|
|
386
|
-
painId: gfiPainId,
|
|
387
|
-
painType: 'user_frustration',
|
|
388
|
-
source: 'user_empathy',
|
|
389
|
-
reason: `Accumulated GFI (${currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Matched: ${matchResult.matchedTerms.join(', ')}`,
|
|
390
|
-
score: gfiPainScore,
|
|
391
|
-
sessionId,
|
|
392
|
-
agentId: 'main',
|
|
393
|
-
provenance: 'openclaw_context_bound',
|
|
394
|
-
evidence,
|
|
395
|
-
},
|
|
396
|
-
}, { recordObservability: false });
|
|
397
|
-
logger?.info?.(`[PD:Empathy] emitPainDetectedEvent completed (GFI-triggered)`);
|
|
398
|
-
}
|
|
399
|
-
catch (emitErr) {
|
|
400
|
-
console.error(`[PD:Empathy] FAILED to emit GFI-triggered pain event: ${String(emitErr)}`);
|
|
401
|
-
logger?.warn?.(`[PD:Empathy] Failed to emit GFI-triggered pain event: ${String(emitErr)}`);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
else {
|
|
405
|
-
logger?.info?.(`[PD:Empathy] Gate B skipped: ${triggerDecision.reason}`);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
else {
|
|
410
|
-
// PRI-454: Gate A path (rollback when either flag is OFF)
|
|
411
|
-
const gate = evaluatePainDiagnosticGate({
|
|
412
|
-
source: 'user_empathy',
|
|
413
|
-
score: gfiPainScore,
|
|
414
|
-
currentGfi,
|
|
415
|
-
consecutiveErrors: currentSession?.consecutiveErrors ?? 0,
|
|
416
|
-
sessionId,
|
|
417
|
-
errorHash: 'empathy_gfi_threshold',
|
|
418
|
-
thresholds: {
|
|
419
|
-
painTrigger,
|
|
420
|
-
highSeverity: wctx.config.get('severity_thresholds.high') || 70,
|
|
421
|
-
semanticPain: Math.max(painTrigger, 60),
|
|
422
|
-
},
|
|
423
|
-
});
|
|
424
|
-
if (gate.shouldDiagnose) {
|
|
425
|
-
logger?.info?.(`[PD:Empathy] Gate approved, calling emitPainDetectedEvent...`);
|
|
426
|
-
try {
|
|
427
|
-
const evidence = buildTrajectoryEvidence(wctx, sessionId);
|
|
428
|
-
await emitPainDetectedEvent(wctx, {
|
|
429
|
-
ts: new Date().toISOString(),
|
|
430
|
-
type: 'pain_detected',
|
|
431
|
-
data: {
|
|
432
|
-
painId: gfiPainId,
|
|
433
|
-
painType: 'user_frustration',
|
|
434
|
-
source: 'user_empathy',
|
|
435
|
-
reason: `Accumulated GFI (${currentGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Matched: ${matchResult.matchedTerms.join(', ')}`,
|
|
436
|
-
score: gfiPainScore,
|
|
437
|
-
sessionId,
|
|
438
|
-
agentId: 'main',
|
|
439
|
-
provenance: 'openclaw_context_bound',
|
|
440
|
-
evidence,
|
|
441
|
-
},
|
|
442
|
-
}, { recordObservability: false });
|
|
443
|
-
logger?.info?.(`[PD:Empathy] emitPainDetectedEvent completed (GFI-triggered)`);
|
|
444
|
-
}
|
|
445
|
-
catch (emitErr) {
|
|
446
|
-
console.error(`[PD:Empathy] FAILED to emit GFI-triggered pain event: ${String(emitErr)}`);
|
|
447
|
-
logger?.warn?.(`[PD:Empathy] Failed to emit GFI-triggered pain event: ${String(emitErr)}`);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
else {
|
|
451
|
-
logger?.info?.(`[PD:Empathy] GFI-triggered gate rejected: ${gate.detail}`);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
else {
|
|
457
|
-
// ── Pipeline: Observer only runs when keyword matcher misses ──
|
|
458
|
-
const observer = resolveEmpathyObserver(wctx, logger);
|
|
459
|
-
if (observer) {
|
|
460
|
-
const scheduler = new AgentScheduler();
|
|
461
|
-
scheduler.register({
|
|
462
|
-
agentId: 'empathy-observer',
|
|
463
|
-
mode: 'realtime',
|
|
464
|
-
runner: observer,
|
|
465
|
-
});
|
|
466
|
-
logger?.info?.(`[PD:Empathy] Pipeline: Triggering background Empathy Observer for unmatched message: "${msgPreview}"`);
|
|
467
|
-
void scheduler.dispatch('empathy-observer', { userMessage: latestUserMessage })
|
|
468
|
-
.then(async (result) => {
|
|
469
|
-
if (result.damageDetected) {
|
|
470
|
-
logger?.info?.(`[PD:Empathy] Pipeline: Background Empathy Observer detected damage. Severity: ${result.severity}, Reason: ${result.reason}`);
|
|
471
|
-
// ── Persistence Contract ──
|
|
472
|
-
const painScore = scoreFromSeverityForSpec(result.severity, wctx);
|
|
473
|
-
trackFriction(sessionId, painScore, `observer_empathy_${result.severity}`, workspaceDir, { source: 'user_empathy' });
|
|
474
|
-
// ── Pipeline: feed back newly detected expressions into keyword store ──
|
|
475
|
-
if (result.reason) {
|
|
476
|
-
const rawLang = wctx.config.get('language');
|
|
477
|
-
const lang = rawLang === 'en' ? 'en' : 'zh';
|
|
478
|
-
const phrases = extractPhrasesFromReason(result.reason, lang);
|
|
479
|
-
if (phrases.length > 0) {
|
|
480
|
-
const updates = {};
|
|
481
|
-
for (const phrase of phrases) {
|
|
482
|
-
if (!Object.hasOwn(keywordStore.terms, phrase)) {
|
|
483
|
-
updates[phrase] = {
|
|
484
|
-
action: 'add',
|
|
485
|
-
weight: 0.4,
|
|
486
|
-
falsePositiveRate: 0.3,
|
|
487
|
-
reasoning: `Discovered by empathy observer: ${result.reason.substring(0, 100)}`,
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
if (Object.keys(updates).length > 0) {
|
|
492
|
-
const { added } = applyKeywordUpdates(keywordStore, updates);
|
|
493
|
-
if (added > 0) {
|
|
494
|
-
saveKeywordStore(wctx.stateDir, keywordStore);
|
|
495
|
-
logger?.info?.(`[PD:Empathy] Pipeline: added ${added} new terms from observer feedback`);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
const eventId = `emp_obs_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
|
501
|
-
wctx.eventLog.recordPainSignal(sessionId, {
|
|
502
|
-
score: painScore,
|
|
503
|
-
source: 'user_empathy',
|
|
504
|
-
reason: result.reason || 'Empathy observer detected likely user frustration.',
|
|
505
|
-
isRisky: false,
|
|
506
|
-
origin: 'system_infer',
|
|
507
|
-
severity: result.severity,
|
|
508
|
-
confidence: result.confidence,
|
|
509
|
-
detection_mode: 'structured',
|
|
510
|
-
deduped: false,
|
|
511
|
-
trigger_text_excerpt: sanitizeForEvidence(latestUserMessage, workspaceDir).substring(0, 120),
|
|
512
|
-
raw_score: painScore,
|
|
513
|
-
calibrated_score: painScore,
|
|
514
|
-
eventId,
|
|
515
|
-
});
|
|
516
|
-
// PRI-453: Generate painId early to pass as canonicalPainId for dedup.
|
|
517
|
-
// Declared outside try block so it's accessible to emitPainDetectedEvent
|
|
518
|
-
// later (lineage consistency: same id for trajectory + emitted event).
|
|
519
|
-
const observerPainId = `empathy_gfi_${Date.now()}`;
|
|
520
|
-
try {
|
|
521
|
-
wctx.trajectory?.recordPainEvent?.({
|
|
522
|
-
sessionId,
|
|
523
|
-
source: 'user_empathy',
|
|
524
|
-
score: painScore,
|
|
525
|
-
reason: result.reason || 'Empathy observer detected likely user frustration.',
|
|
526
|
-
severity: result.severity,
|
|
527
|
-
origin: 'system_infer',
|
|
528
|
-
confidence: result.confidence,
|
|
529
|
-
text: sanitizeForEvidence(latestUserMessage, workspaceDir),
|
|
530
|
-
canonicalPainId: observerPainId,
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
catch (error) {
|
|
534
|
-
logger?.warn?.(`[PD:Empathy] Failed to persist trajectory: ${String(error)}`);
|
|
535
|
-
}
|
|
536
|
-
// Check if GFI triggers a pain event post-LLM validation
|
|
537
|
-
const freshSession = getSession(sessionId);
|
|
538
|
-
const freshGfi = freshSession?.currentGfi ?? 0;
|
|
539
|
-
if (freshGfi >= highGfiThreshold) {
|
|
540
|
-
const freshGfiPainScore = Math.min(Math.round(freshGfi), 60);
|
|
541
|
-
// PRI-454: Dual-gate migration. When both flags ON → Gate B (TriggerController).
|
|
542
|
-
// When either OFF → Gate A (PainDiagnosticGate, rollback).
|
|
543
|
-
const triageFlag = loadFeatureFlagFromConfig(workspaceDir, 'painEvidenceAdmission');
|
|
544
|
-
const defaultFlag = loadFeatureFlagFromConfig(workspaceDir, 'painEvidenceAdmissionDefault');
|
|
545
|
-
const useGateB = triageFlag.enabled && defaultFlag.enabled;
|
|
546
|
-
if (useGateB) {
|
|
547
|
-
// PRI-454: Gate B path — TriggerController owns admission
|
|
548
|
-
const rawObs = buildEmpathyObservation({
|
|
549
|
-
detectionSource: 'user_empathy',
|
|
550
|
-
isGfiTriggered: true,
|
|
551
|
-
sessionId,
|
|
552
|
-
});
|
|
553
|
-
const sourceKind = resolveSourceKind(rawObs);
|
|
554
|
-
const triage = evaluateEvidenceTriage(sourceKind, freshGfiPainScore);
|
|
555
|
-
if (triage.decision !== 'admit') {
|
|
556
|
-
logger?.info?.(`[PD:Empathy] Pipeline observer triage ${triage.decision}: ${triage.reason}`);
|
|
557
|
-
}
|
|
558
|
-
else {
|
|
559
|
-
const cooldownActive = isSharedCooldownActive(sourceKind, sessionId, 'empathy_gfi_observer');
|
|
560
|
-
const triggerDecision = evaluateTriggerController({
|
|
561
|
-
triageResult: triage,
|
|
562
|
-
isOwnerManual: false,
|
|
563
|
-
isCooldownActive: cooldownActive,
|
|
564
|
-
isValid: true,
|
|
565
|
-
score: freshGfiPainScore,
|
|
566
|
-
sessionId,
|
|
567
|
-
});
|
|
568
|
-
if (triggerDecision.shouldCreateDiagnosticTask) {
|
|
569
|
-
markSharedEpisodeAsDiagnosed(sourceKind, sessionId, 'empathy_gfi_observer');
|
|
570
|
-
logger?.info?.(`[PD:Empathy] Pipeline Gate B approved (observer), calling emitPainDetectedEvent...`);
|
|
571
|
-
try {
|
|
572
|
-
const evidence = buildTrajectoryEvidence(wctx, sessionId);
|
|
573
|
-
// PRI-453: Reuse observerPainId for lineage consistency —
|
|
574
|
-
// the same id is used as canonicalPainId in recordPainEvent
|
|
575
|
-
// and as painId in the emitted event, so EvidenceChainConsoleModel
|
|
576
|
-
// can JOIN pain_events.canonical_pain_id = tasks.input_ref.
|
|
577
|
-
await emitPainDetectedEvent(wctx, {
|
|
578
|
-
ts: new Date().toISOString(),
|
|
579
|
-
type: 'pain_detected',
|
|
580
|
-
data: {
|
|
581
|
-
painId: observerPainId,
|
|
582
|
-
painType: 'user_frustration',
|
|
583
|
-
source: 'user_empathy',
|
|
584
|
-
reason: `Accumulated GFI (${freshGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Verified by Empathy Observer.`,
|
|
585
|
-
score: freshGfiPainScore,
|
|
586
|
-
sessionId,
|
|
587
|
-
agentId: 'main',
|
|
588
|
-
provenance: 'openclaw_context_bound',
|
|
589
|
-
evidence,
|
|
590
|
-
},
|
|
591
|
-
}, { recordObservability: false });
|
|
592
|
-
}
|
|
593
|
-
catch (emitErr) {
|
|
594
|
-
logger?.error?.(`[PD:Empathy] Pipeline FAILED to emit observer-triggered pain event: ${String(emitErr)}`);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
else {
|
|
598
|
-
logger?.info?.(`[PD:Empathy] Pipeline Gate B skipped (observer): ${triggerDecision.reason}`);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
else {
|
|
603
|
-
// PRI-454: Gate A path (rollback when either flag is OFF)
|
|
604
|
-
const gate = evaluatePainDiagnosticGate({
|
|
605
|
-
source: 'user_empathy',
|
|
606
|
-
score: freshGfiPainScore,
|
|
607
|
-
currentGfi: freshGfi,
|
|
608
|
-
consecutiveErrors: freshSession?.consecutiveErrors ?? 0,
|
|
609
|
-
sessionId,
|
|
610
|
-
errorHash: 'empathy_gfi_threshold',
|
|
611
|
-
thresholds: {
|
|
612
|
-
painTrigger,
|
|
613
|
-
highSeverity: wctx.config.get('severity_thresholds.high') || 70,
|
|
614
|
-
semanticPain: Math.max(painTrigger, 60),
|
|
615
|
-
},
|
|
616
|
-
});
|
|
617
|
-
if (gate.shouldDiagnose) {
|
|
618
|
-
logger?.info?.(`[PD:Empathy] Pipeline GFI threshold crossed after background observer. Emitting pain signal...`);
|
|
619
|
-
try {
|
|
620
|
-
const evidence = buildTrajectoryEvidence(wctx, sessionId);
|
|
621
|
-
// PRI-453: Reuse observerPainId for lineage consistency —
|
|
622
|
-
// the same id is used as canonicalPainId in recordPainEvent
|
|
623
|
-
// and as painId in the emitted event, so EvidenceChainConsoleModel
|
|
624
|
-
// can JOIN pain_events.canonical_pain_id = tasks.input_ref.
|
|
625
|
-
await emitPainDetectedEvent(wctx, {
|
|
626
|
-
ts: new Date().toISOString(),
|
|
627
|
-
type: 'pain_detected',
|
|
628
|
-
data: {
|
|
629
|
-
painId: observerPainId,
|
|
630
|
-
painType: 'user_frustration',
|
|
631
|
-
source: 'user_empathy',
|
|
632
|
-
reason: `Accumulated GFI (${freshGfi.toFixed(1)}) crossed highGfi threshold (${highGfiThreshold}). Verified by Empathy Observer.`,
|
|
633
|
-
score: freshGfiPainScore,
|
|
634
|
-
sessionId,
|
|
635
|
-
agentId: 'main',
|
|
636
|
-
provenance: 'openclaw_context_bound',
|
|
637
|
-
evidence,
|
|
638
|
-
},
|
|
639
|
-
}, { recordObservability: false });
|
|
640
|
-
}
|
|
641
|
-
catch (emitErr) {
|
|
642
|
-
logger?.error?.(`[PD:Empathy] Pipeline FAILED to emit observer-triggered pain event: ${String(emitErr)}`);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
else {
|
|
649
|
-
logger?.info?.(`[PD:Empathy] Pipeline: Background Empathy Observer did not detect any damage.`);
|
|
650
|
-
}
|
|
651
|
-
})
|
|
652
|
-
.catch((err) => {
|
|
653
|
-
logger?.warn?.(`[PD:Empathy] Pipeline: Background analysis failed or rejected: ${String(err)}`);
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
// Log unmatched messages periodically for coverage analysis
|
|
657
|
-
if (turnCount > 0 && turnCount % 50 === 0) {
|
|
658
|
-
const sampleMsg = latestUserMessage.substring(0, 80).replace(/\n/g, ' ');
|
|
659
|
-
logger?.debug?.(`[PD:Empathy] NO_MATCH: "${sampleMsg}" (turn ${turnCount}, keywords_in_store=${Object.keys(keywordStore.terms).length})`);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
// Periodic summary (every 100 turns)
|
|
663
|
-
if (turnCount > 0 && turnCount % 100 === 0) {
|
|
664
|
-
const s = getKeywordStoreSummary(keywordStore);
|
|
665
|
-
const highFP = s.highFalsePositiveTerms.slice(0, 5).map(t => `${t.term}(${t.falsePositiveRate.toFixed(2)})`).join(', ');
|
|
666
|
-
logger?.info?.(`[PD:Empathy] SUMMARY(turn=${turnCount}): terms=${s.totalTerms}, hits=${keywordStore.stats.totalHits}, zero_hit=${s.totalTerms - (s.seedTerms + s.discoveredTerms)}, high_fp=[${highFP}]`);
|
|
667
|
-
}
|
|
668
|
-
// Save keyword store on every match
|
|
669
|
-
if (matchResult.matched) {
|
|
670
|
-
saveKeywordStore(wctx.stateDir, keywordStore);
|
|
671
|
-
const { totalHits } = keywordStore.stats;
|
|
672
|
-
logger?.info?.(`[PD:Empathy] Keyword store saved after match: terms=${matchResult.matchedTerms.join(',')}, totalHits=${totalHits}`);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
catch (e) {
|
|
676
|
-
logger?.warn?.(`[PD:Empathy] ERROR: ${String(e)}`);
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
259
|
}
|
|
260
|
+
// SignalCollectorHost 统一接管 correction + empathy 检测(spec §3.3 决策1)。
|
|
261
|
+
// 在 isAgentToAgent 解析之后调用,避免 agent-to-agent 流量被误当用户纠正(CodeRabbit #7)。
|
|
262
|
+
if (correctionUserText && sessionId && trigger === 'user' && !isAgentToAgent) {
|
|
263
|
+
getSignalCollectorHost(wctx, logger).detectSync(correctionUserText, sessionId, trigger, { referencesAssistantTurnId: correctionReferencesAssistantTurnId, turnIndex: correctionTurnIndex });
|
|
264
|
+
}
|
|
265
|
+
// 注:SignalCollectorHost.detectSync 已在上文 correction 段(:259)统一调用一次,
|
|
266
|
+
// 覆盖 correction + empathy 全部检测职责(含 lineage + turnIndex)。
|
|
267
|
+
// 此处不再重复调用,避免同一消息双重写入 user_turns 和重复触发 STRONG 分流。
|
|
680
268
|
// ──── 4. Heartbeat-specific checklist (also fires for cron-triggered sessions) ────
|
|
681
269
|
if (trigger === 'heartbeat' || trigger === 'cron') {
|
|
682
270
|
// ──── 4a. GFI Time-based Decay ────
|
|
@@ -975,51 +563,3 @@ export async function handleBeforePromptBuild(event, ctx) {
|
|
|
975
563
|
appendSystemContext
|
|
976
564
|
};
|
|
977
565
|
}
|
|
978
|
-
// ── Empathy Observer Hybrid Deep Analysis Helpers (Unified SDK Migration) ──
|
|
979
|
-
function scoreFromSeverityForSpec(severity, wctx) {
|
|
980
|
-
if (severity === 'severe')
|
|
981
|
-
return Number(wctx.config.get('empathy_engine.penalties.severe') ?? 40);
|
|
982
|
-
if (severity === 'moderate')
|
|
983
|
-
return Number(wctx.config.get('empathy_engine.penalties.moderate') ?? 25);
|
|
984
|
-
return Number(wctx.config.get('empathy_engine.penalties.mild') ?? 10);
|
|
985
|
-
}
|
|
986
|
-
function resolveEmpathyObserver(wctx, logger) {
|
|
987
|
-
try {
|
|
988
|
-
const loader = new WorkflowFunnelLoader(wctx.stateDir);
|
|
989
|
-
const funnel = loader.getFunnel('pd-empathy-observer');
|
|
990
|
-
const policy = funnel?.policy;
|
|
991
|
-
if (!policy || policy.runtimeKind !== 'pi-ai') {
|
|
992
|
-
logger?.debug?.('[PD:Empathy] workflows.yaml pd-empathy-observer policy not found. Falling back to environment variables.');
|
|
993
|
-
const provider = process.env.PD_EMPATHY_PROVIDER || 'anthropic';
|
|
994
|
-
const model = process.env.PD_EMPATHY_MODEL || 'anthropic/claude-3-5-sonnet';
|
|
995
|
-
const apiKeyEnv = process.env.PD_EMPATHY_API_KEY_ENV || 'ANTHROPIC_API_KEY';
|
|
996
|
-
const baseUrl = process.env.PD_EMPATHY_BASE_URL;
|
|
997
|
-
if (!process.env[apiKeyEnv]) {
|
|
998
|
-
logger?.debug?.(`[PD:Empathy] Empathy observer API key env ${apiKeyEnv} is not set. Background analysis disabled.`);
|
|
999
|
-
return null;
|
|
1000
|
-
}
|
|
1001
|
-
const adapter = new PiAiRuntimeAdapter({
|
|
1002
|
-
provider,
|
|
1003
|
-
model,
|
|
1004
|
-
apiKeyEnv,
|
|
1005
|
-
baseUrl,
|
|
1006
|
-
workspace: wctx.workspaceDir,
|
|
1007
|
-
});
|
|
1008
|
-
return new EmpathyObserver({ runtimeAdapter: adapter });
|
|
1009
|
-
}
|
|
1010
|
-
const adapter = new PiAiRuntimeAdapter({
|
|
1011
|
-
provider: String(policy.provider),
|
|
1012
|
-
model: String(policy.model),
|
|
1013
|
-
apiKeyEnv: String(policy.apiKeyEnv),
|
|
1014
|
-
maxRetries: policy.maxRetries,
|
|
1015
|
-
timeoutMs: policy.timeoutMs ?? 30_000,
|
|
1016
|
-
baseUrl: policy.baseUrl,
|
|
1017
|
-
workspace: wctx.workspaceDir,
|
|
1018
|
-
});
|
|
1019
|
-
return new EmpathyObserver({ runtimeAdapter: adapter }, { timeoutMs: policy.timeoutMs });
|
|
1020
|
-
}
|
|
1021
|
-
catch (err) {
|
|
1022
|
-
logger?.warn?.(`[PD:Empathy] Failed to resolve EmpathyObserver: ${String(err)}`);
|
|
1023
|
-
return null;
|
|
1024
|
-
}
|
|
1025
|
-
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "principles-disciple",
|
|
3
3
|
"name": "Principles Disciple",
|
|
4
4
|
"description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.176.0",
|
|
6
6
|
"activation": {
|
|
7
7
|
"onCapabilities": [
|
|
8
8
|
"hook"
|