principles-disciple 1.175.0 → 1.177.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.
@@ -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
+ }