principles-disciple 1.6.0 → 1.7.1
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.js +7 -3
- package/dist/commands/evolution-status.d.ts +4 -0
- package/dist/commands/evolution-status.js +134 -0
- package/dist/commands/export.d.ts +2 -0
- package/dist/commands/export.js +45 -0
- package/dist/commands/focus.js +9 -6
- package/dist/commands/pain.js +8 -0
- package/dist/commands/principle-rollback.d.ts +4 -0
- package/dist/commands/principle-rollback.js +22 -0
- package/dist/commands/rollback.js +9 -3
- package/dist/commands/samples.d.ts +2 -0
- package/dist/commands/samples.js +55 -0
- package/dist/commands/trust.js +64 -81
- package/dist/core/config.d.ts +5 -0
- package/dist/core/control-ui-db.d.ts +68 -0
- package/dist/core/control-ui-db.js +274 -0
- package/dist/core/detection-funnel.d.ts +1 -1
- package/dist/core/detection-funnel.js +4 -0
- package/dist/core/dictionary.d.ts +2 -0
- package/dist/core/dictionary.js +13 -0
- package/dist/core/event-log.d.ts +7 -1
- package/dist/core/event-log.js +10 -0
- package/dist/core/evolution-engine.d.ts +5 -5
- package/dist/core/evolution-engine.js +18 -18
- package/dist/core/evolution-migration.d.ts +5 -0
- package/dist/core/evolution-migration.js +65 -0
- package/dist/core/evolution-reducer.d.ts +69 -0
- package/dist/core/evolution-reducer.js +369 -0
- package/dist/core/evolution-types.d.ts +103 -0
- package/dist/core/path-resolver.js +75 -36
- package/dist/core/paths.d.ts +7 -8
- package/dist/core/paths.js +48 -40
- package/dist/core/profile.js +1 -1
- package/dist/core/session-tracker.d.ts +14 -2
- package/dist/core/session-tracker.js +75 -9
- package/dist/core/thinking-models.d.ts +38 -0
- package/dist/core/thinking-models.js +170 -0
- package/dist/core/trajectory.d.ts +184 -0
- package/dist/core/trajectory.js +817 -0
- package/dist/core/trust-engine.d.ts +6 -0
- package/dist/core/trust-engine.js +50 -29
- package/dist/core/workspace-context.d.ts +13 -0
- package/dist/core/workspace-context.js +50 -7
- package/dist/hooks/gate.js +171 -87
- package/dist/hooks/llm.js +119 -71
- package/dist/hooks/pain.js +105 -5
- package/dist/hooks/prompt.d.ts +11 -14
- package/dist/hooks/prompt.js +283 -57
- package/dist/hooks/subagent.js +69 -28
- package/dist/hooks/trajectory-collector.d.ts +32 -0
- package/dist/hooks/trajectory-collector.js +256 -0
- package/dist/http/principles-console-route.d.ts +2 -0
- package/dist/http/principles-console-route.js +257 -0
- package/dist/i18n/commands.js +16 -0
- package/dist/index.js +105 -4
- package/dist/service/control-ui-query-service.d.ts +217 -0
- package/dist/service/control-ui-query-service.js +537 -0
- package/dist/service/empathy-observer-manager.d.ts +2 -0
- package/dist/service/empathy-observer-manager.js +43 -1
- package/dist/service/evolution-worker.d.ts +27 -0
- package/dist/service/evolution-worker.js +256 -41
- package/dist/service/runtime-summary-service.d.ts +79 -0
- package/dist/service/runtime-summary-service.js +319 -0
- package/dist/service/trajectory-service.d.ts +2 -0
- package/dist/service/trajectory-service.js +15 -0
- package/dist/tools/agent-spawn.d.ts +27 -6
- package/dist/tools/agent-spawn.js +339 -87
- package/dist/tools/deep-reflect.d.ts +27 -7
- package/dist/tools/deep-reflect.js +210 -121
- package/dist/types/event-types.d.ts +10 -2
- package/dist/types.d.ts +10 -0
- package/dist/types.js +5 -0
- package/openclaw.plugin.json +43 -11
- package/package.json +14 -4
- package/templates/langs/zh/skills/pd-daily/SKILL.md +97 -13
package/dist/hooks/subagent.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { writePainFlag } from '../core/pain.js';
|
|
2
2
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
3
3
|
import { empathyObserverManager } from '../service/empathy-observer-manager.js';
|
|
4
|
+
import { acquireQueueLock, EVOLUTION_QUEUE_LOCK_SUFFIX } from '../service/evolution-worker.js';
|
|
4
5
|
import * as fs from 'fs';
|
|
6
|
+
function emitSubagentPainEvent(wctx, payload) {
|
|
7
|
+
try {
|
|
8
|
+
wctx.evolutionReducer.emitSync({
|
|
9
|
+
ts: new Date().toISOString(),
|
|
10
|
+
type: 'pain_detected',
|
|
11
|
+
data: {
|
|
12
|
+
painId: `pain_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
|
|
13
|
+
painType: 'subagent_error',
|
|
14
|
+
source: payload.source,
|
|
15
|
+
reason: payload.reason,
|
|
16
|
+
score: payload.score,
|
|
17
|
+
sessionId: payload.sessionId,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
console.warn(`[PD:Subagent] failed to emit evolution event: ${String(e)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
5
25
|
export async function handleSubagentEnded(event, ctx) {
|
|
6
26
|
const { outcome, targetSessionKey } = event;
|
|
7
27
|
const workspaceDir = ctx.workspaceDir;
|
|
@@ -18,13 +38,20 @@ export async function handleSubagentEnded(event, ctx) {
|
|
|
18
38
|
if (outcome === 'error' || outcome === 'timeout') {
|
|
19
39
|
const scoreSettings = config.get('scores');
|
|
20
40
|
const score = outcome === 'error' ? scoreSettings.subagent_error_penalty : scoreSettings.subagent_timeout_penalty;
|
|
41
|
+
const reason = `Subagent session ${targetSessionKey} ended with outcome: ${outcome}`;
|
|
21
42
|
writePainFlag(workspaceDir, {
|
|
22
43
|
source: `subagent_${outcome}`,
|
|
23
44
|
score: String(score),
|
|
24
45
|
time: new Date().toISOString(),
|
|
25
|
-
reason
|
|
46
|
+
reason,
|
|
26
47
|
is_risky: 'true'
|
|
27
48
|
});
|
|
49
|
+
emitSubagentPainEvent(wctx, {
|
|
50
|
+
source: `subagent_${outcome}`,
|
|
51
|
+
reason,
|
|
52
|
+
score,
|
|
53
|
+
sessionId: ctx.sessionId,
|
|
54
|
+
});
|
|
28
55
|
}
|
|
29
56
|
// 2. Loop Closure: Clean up evolution queue if any subagent finished successfully
|
|
30
57
|
if (outcome === 'ok' || outcome === 'deleted') {
|
|
@@ -35,38 +62,49 @@ export async function handleSubagentEnded(event, ctx) {
|
|
|
35
62
|
}, true);
|
|
36
63
|
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
37
64
|
if (fs.existsSync(queuePath)) {
|
|
65
|
+
const lockPath = queuePath + EVOLUTION_QUEUE_LOCK_SUFFIX;
|
|
66
|
+
const releaseLock = acquireQueueLock(lockPath, console);
|
|
67
|
+
if (!releaseLock) {
|
|
68
|
+
console.warn('[PD:Subagent] Failed to acquire queue lock, skipping queue update');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
38
71
|
try {
|
|
39
72
|
const queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
40
73
|
let changed = false;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
const resolveTaskTime = (task) => {
|
|
75
|
+
const raw = task?.enqueued_at || task?.timestamp;
|
|
76
|
+
const ts = new Date(raw).getTime();
|
|
77
|
+
return Number.isFinite(ts) ? ts : Number.MAX_SAFE_INTEGER;
|
|
78
|
+
};
|
|
79
|
+
// Resolve the queue entry by its position, not by id. Historical pain
|
|
80
|
+
// records may legitimately share the same id in legacy data.
|
|
81
|
+
let oldestTaskIndex = -1;
|
|
82
|
+
let oldestTaskTime = Number.MAX_SAFE_INTEGER;
|
|
83
|
+
queue.forEach((task, index) => {
|
|
84
|
+
if (task?.status !== 'in_progress')
|
|
85
|
+
return;
|
|
86
|
+
const taskTime = resolveTaskTime(task);
|
|
87
|
+
if (taskTime < oldestTaskTime) {
|
|
88
|
+
oldestTaskTime = taskTime;
|
|
89
|
+
oldestTaskIndex = index;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
if (oldestTaskIndex !== -1) {
|
|
93
|
+
queue[oldestTaskIndex].status = 'completed';
|
|
94
|
+
queue[oldestTaskIndex].completed_at = new Date().toISOString();
|
|
95
|
+
changed = true;
|
|
96
|
+
// Clean up the .pain_flag if it was queued, to reset the environment
|
|
97
|
+
const painFlagPath = wctx.resolve('PAIN_FLAG');
|
|
98
|
+
if (fs.existsSync(painFlagPath)) {
|
|
99
|
+
try {
|
|
100
|
+
const painData = fs.readFileSync(painFlagPath, 'utf8');
|
|
101
|
+
if (painData.includes('status: queued')) {
|
|
102
|
+
fs.unlinkSync(painFlagPath);
|
|
68
103
|
}
|
|
69
104
|
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
console.error(`[PD:Subagent] Failed to cleanup pain flag: ${String(e)}`);
|
|
107
|
+
}
|
|
70
108
|
}
|
|
71
109
|
}
|
|
72
110
|
if (changed) {
|
|
@@ -76,6 +114,9 @@ export async function handleSubagentEnded(event, ctx) {
|
|
|
76
114
|
catch (e) {
|
|
77
115
|
console.error(`[PD:Subagent] Failed to update evolution queue: ${String(e)}`);
|
|
78
116
|
}
|
|
117
|
+
finally {
|
|
118
|
+
releaseLock();
|
|
119
|
+
}
|
|
79
120
|
}
|
|
80
121
|
}
|
|
81
122
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trajectory Collector - 行为进化引擎 Phase 0 数据收集
|
|
3
|
+
*
|
|
4
|
+
* 收集工具调用和 LLM 输出到 memory/trajectories/ 目录
|
|
5
|
+
* 用于分析工具使用模式、识别原则应用案例、评估行为质量
|
|
6
|
+
*/
|
|
7
|
+
import type { PluginHookAfterToolCallEvent, PluginHookToolContext, PluginHookLlmOutputEvent, PluginHookAgentContext, PluginHookBeforeMessageWriteEvent } from '../openclaw-sdk.js';
|
|
8
|
+
/**
|
|
9
|
+
* 工具调用完成后的处理
|
|
10
|
+
* 记录:工具名、参数、结果、错误、执行时间
|
|
11
|
+
*/
|
|
12
|
+
export declare function handleAfterToolCall(event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext & {
|
|
13
|
+
workspaceDir?: string;
|
|
14
|
+
}): void;
|
|
15
|
+
/**
|
|
16
|
+
* LLM 输出处理
|
|
17
|
+
* 记录:provider、model、输出长度、token 使用量
|
|
18
|
+
*/
|
|
19
|
+
export declare function handleLlmOutput(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext & {
|
|
20
|
+
workspaceDir?: string;
|
|
21
|
+
}): void;
|
|
22
|
+
/**
|
|
23
|
+
* 消息写入前的处理
|
|
24
|
+
* 记录:用户/助手消息内容
|
|
25
|
+
*/
|
|
26
|
+
export declare function handleBeforeMessageWrite(event: PluginHookBeforeMessageWriteEvent, ctx: PluginHookAgentContext & {
|
|
27
|
+
workspaceDir?: string;
|
|
28
|
+
}): void;
|
|
29
|
+
/**
|
|
30
|
+
* 轨迹汇总统计(供 cron 任务调用)
|
|
31
|
+
*/
|
|
32
|
+
export declare function computeTrajectoryStats(workspaceDir: string): object;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trajectory Collector - 行为进化引擎 Phase 0 数据收集
|
|
3
|
+
*
|
|
4
|
+
* 收集工具调用和 LLM 输出到 memory/trajectories/ 目录
|
|
5
|
+
* 用于分析工具使用模式、识别原则应用案例、评估行为质量
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
const TRAJECTORY_DIR = 'memory/trajectories/';
|
|
10
|
+
// 敏感字段匹配正则
|
|
11
|
+
const SENSITIVE_KEY_PATTERN = /password|token|authorization|secret|api[_-]?key|credential|cookie|session/i;
|
|
12
|
+
// 最大字符串长度
|
|
13
|
+
const MAX_STRING_LENGTH = 1000;
|
|
14
|
+
const MAX_RESULT_LENGTH = 500;
|
|
15
|
+
/**
|
|
16
|
+
* 递归脱敏处理:遍历对象/数组,移除敏感字段值
|
|
17
|
+
*/
|
|
18
|
+
function scrubSensitive(obj, depth = 0) {
|
|
19
|
+
// 防止无限递归
|
|
20
|
+
if (depth > 10)
|
|
21
|
+
return '[MAX_DEPTH]';
|
|
22
|
+
// 处理 null/undefined
|
|
23
|
+
if (obj == null)
|
|
24
|
+
return obj;
|
|
25
|
+
// 处理基本类型
|
|
26
|
+
if (typeof obj !== 'object') {
|
|
27
|
+
if (typeof obj === 'string' && obj.length > MAX_STRING_LENGTH) {
|
|
28
|
+
return obj.slice(0, MAX_STRING_LENGTH) + '...[truncated]';
|
|
29
|
+
}
|
|
30
|
+
return obj;
|
|
31
|
+
}
|
|
32
|
+
// 处理数组
|
|
33
|
+
if (Array.isArray(obj)) {
|
|
34
|
+
return obj.map(item => scrubSensitive(item, depth + 1));
|
|
35
|
+
}
|
|
36
|
+
// 处理对象
|
|
37
|
+
const result = {};
|
|
38
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
39
|
+
if (SENSITIVE_KEY_PATTERN.test(key)) {
|
|
40
|
+
result[key] = '[REDACTED]';
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
result[key] = scrubSensitive(value, depth + 1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 异步写入队列 - 确保有序、非阻塞写入
|
|
50
|
+
*/
|
|
51
|
+
class AsyncWriteQueue {
|
|
52
|
+
queue = [];
|
|
53
|
+
processing = false;
|
|
54
|
+
async enqueue(task) {
|
|
55
|
+
this.queue.push(task);
|
|
56
|
+
if (!this.processing) {
|
|
57
|
+
this.processNext();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async processNext() {
|
|
61
|
+
if (this.queue.length === 0) {
|
|
62
|
+
this.processing = false;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.processing = true;
|
|
66
|
+
const task = this.queue.shift();
|
|
67
|
+
try {
|
|
68
|
+
await task();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Silently fail - trajectory collection should not block main functionality
|
|
72
|
+
}
|
|
73
|
+
// 处理下一个任务
|
|
74
|
+
this.processNext();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// 全局写入队列实例
|
|
78
|
+
const writeQueue = new AsyncWriteQueue();
|
|
79
|
+
// 目录缓存(避免重复检查)
|
|
80
|
+
const dirCache = new Map();
|
|
81
|
+
/**
|
|
82
|
+
* 确保轨迹目录存在(异步)
|
|
83
|
+
*/
|
|
84
|
+
async function ensureTrajectoryDirAsync(workspaceDir) {
|
|
85
|
+
const dir = path.join(workspaceDir, TRAJECTORY_DIR);
|
|
86
|
+
if (dirCache.get(dir)) {
|
|
87
|
+
return dir;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
91
|
+
dirCache.set(dir, true);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// 目录可能已存在,忽略错误
|
|
95
|
+
dirCache.set(dir, true);
|
|
96
|
+
}
|
|
97
|
+
return dir;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 获取今日轨迹文件名
|
|
101
|
+
*/
|
|
102
|
+
function getTodayFilename() {
|
|
103
|
+
const now = new Date();
|
|
104
|
+
const year = now.getUTCFullYear();
|
|
105
|
+
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
106
|
+
return `${year}-${month}-${String(now.getUTCDate()).padStart(2, '0')}.jsonl`;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 写入轨迹记录(JSON Lines 格式)- 异步版本
|
|
110
|
+
*/
|
|
111
|
+
function writeTrajectoryRecord(workspaceDir, record) {
|
|
112
|
+
const line = JSON.stringify(record) + '\n';
|
|
113
|
+
writeQueue.enqueue(async () => {
|
|
114
|
+
const dir = await ensureTrajectoryDirAsync(workspaceDir);
|
|
115
|
+
const filepath = path.join(dir, getTodayFilename());
|
|
116
|
+
await fs.promises.appendFile(filepath, line, 'utf8');
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 工具调用完成后的处理
|
|
121
|
+
* 记录:工具名、参数、结果、错误、执行时间
|
|
122
|
+
*/
|
|
123
|
+
export function handleAfterToolCall(event, ctx) {
|
|
124
|
+
const workspaceDir = ctx.workspaceDir;
|
|
125
|
+
if (!workspaceDir)
|
|
126
|
+
return;
|
|
127
|
+
// 递归脱敏处理所有字段
|
|
128
|
+
const sanitizedParams = scrubSensitive(event.params);
|
|
129
|
+
const sanitizedResult = event.result == null
|
|
130
|
+
? null
|
|
131
|
+
: String(scrubSensitive(event.result)).slice(0, MAX_RESULT_LENGTH);
|
|
132
|
+
const sanitizedError = event.error == null
|
|
133
|
+
? null
|
|
134
|
+
: String(scrubSensitive(event.error));
|
|
135
|
+
writeTrajectoryRecord(workspaceDir, {
|
|
136
|
+
type: 'tool_call',
|
|
137
|
+
timestamp: new Date().toISOString(),
|
|
138
|
+
sessionId: ctx.sessionId || 'unknown',
|
|
139
|
+
toolName: event.toolName,
|
|
140
|
+
params: sanitizedParams,
|
|
141
|
+
result: sanitizedResult,
|
|
142
|
+
error: sanitizedError,
|
|
143
|
+
durationMs: event.durationMs,
|
|
144
|
+
success: !event.error,
|
|
145
|
+
runId: event.runId || null,
|
|
146
|
+
toolCallId: event.toolCallId || null
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* LLM 输出处理
|
|
151
|
+
* 记录:provider、model、输出长度、token 使用量
|
|
152
|
+
*/
|
|
153
|
+
export function handleLlmOutput(event, ctx) {
|
|
154
|
+
const workspaceDir = ctx.workspaceDir;
|
|
155
|
+
if (!workspaceDir)
|
|
156
|
+
return;
|
|
157
|
+
const totalTextLength = event.assistantTexts?.reduce((sum, text) => sum + (text?.length || 0), 0) || 0;
|
|
158
|
+
writeTrajectoryRecord(workspaceDir, {
|
|
159
|
+
type: 'llm_output',
|
|
160
|
+
timestamp: new Date().toISOString(),
|
|
161
|
+
sessionId: ctx.sessionId || 'unknown',
|
|
162
|
+
provider: event.provider,
|
|
163
|
+
model: event.model,
|
|
164
|
+
textLength: totalTextLength,
|
|
165
|
+
outputCount: event.assistantTexts?.length || 0,
|
|
166
|
+
usage: event.usage ? scrubSensitive(event.usage) : null
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* 消息写入前的处理
|
|
171
|
+
* 记录:用户/助手消息内容
|
|
172
|
+
*/
|
|
173
|
+
export function handleBeforeMessageWrite(event, ctx) {
|
|
174
|
+
const workspaceDir = ctx.workspaceDir;
|
|
175
|
+
if (!workspaceDir)
|
|
176
|
+
return;
|
|
177
|
+
const msg = event.message;
|
|
178
|
+
if (!msg || !msg.role)
|
|
179
|
+
return;
|
|
180
|
+
// 只记录 user 和 assistant 消息
|
|
181
|
+
if (msg.role !== 'user' && msg.role !== 'assistant')
|
|
182
|
+
return;
|
|
183
|
+
// 提取文本内容
|
|
184
|
+
let content = '';
|
|
185
|
+
if (typeof msg.content === 'string') {
|
|
186
|
+
content = msg.content;
|
|
187
|
+
}
|
|
188
|
+
else if (Array.isArray(msg.content)) {
|
|
189
|
+
content = msg.content
|
|
190
|
+
.filter((part) => part?.type === 'text')
|
|
191
|
+
.map((part) => part.text)
|
|
192
|
+
.join('\n');
|
|
193
|
+
}
|
|
194
|
+
// 脱敏处理内容预览
|
|
195
|
+
const sanitizedPreview = scrubSensitive(content.slice(0, 200));
|
|
196
|
+
writeTrajectoryRecord(workspaceDir, {
|
|
197
|
+
type: 'message',
|
|
198
|
+
timestamp: new Date().toISOString(),
|
|
199
|
+
sessionId: event.sessionKey || 'unknown',
|
|
200
|
+
role: msg.role,
|
|
201
|
+
contentLength: content.length,
|
|
202
|
+
contentPreview: typeof sanitizedPreview === 'string' ? sanitizedPreview : '[sanitized]',
|
|
203
|
+
agentId: event.agentId || null
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 脱敏处理:移除敏感参数(保留旧函数签名以兼容)
|
|
208
|
+
* @deprecated 使用 scrubSensitive 替代
|
|
209
|
+
*/
|
|
210
|
+
function sanitizeParams(params) {
|
|
211
|
+
return scrubSensitive(params);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* 轨迹汇总统计(供 cron 任务调用)
|
|
215
|
+
*/
|
|
216
|
+
export function computeTrajectoryStats(workspaceDir) {
|
|
217
|
+
const dir = path.join(workspaceDir, TRAJECTORY_DIR);
|
|
218
|
+
const todayFile = path.join(dir, getTodayFilename());
|
|
219
|
+
if (!fs.existsSync(todayFile)) {
|
|
220
|
+
return { date: getTodayFilename(), totalRecords: 0, toolCalls: 0, llmOutputs: 0, messages: 0 };
|
|
221
|
+
}
|
|
222
|
+
const content = fs.readFileSync(todayFile, 'utf8');
|
|
223
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
224
|
+
const toolCalls = lines.filter(line => {
|
|
225
|
+
try {
|
|
226
|
+
return JSON.parse(line).type === 'tool_call';
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}).length;
|
|
232
|
+
const llmOutputs = lines.filter(line => {
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(line).type === 'llm_output';
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}).length;
|
|
240
|
+
const messages = lines.filter(line => {
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(line).type === 'message';
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}).length;
|
|
248
|
+
return {
|
|
249
|
+
date: getTodayFilename(),
|
|
250
|
+
totalRecords: lines.length,
|
|
251
|
+
toolCalls,
|
|
252
|
+
llmOutputs,
|
|
253
|
+
messages,
|
|
254
|
+
generatedAt: new Date().toISOString()
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { ControlUiQueryService } from '../service/control-ui-query-service.js';
|
|
4
|
+
const ROUTE_PREFIX = '/plugins/principles';
|
|
5
|
+
const API_PREFIX = `${ROUTE_PREFIX}/api`;
|
|
6
|
+
const ASSETS_PREFIX = `${ROUTE_PREFIX}/assets`;
|
|
7
|
+
function json(res, statusCode, payload) {
|
|
8
|
+
const body = JSON.stringify(payload, null, 2);
|
|
9
|
+
res.statusCode = statusCode;
|
|
10
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
11
|
+
res.end(body);
|
|
12
|
+
}
|
|
13
|
+
function text(res, statusCode, body) {
|
|
14
|
+
res.statusCode = statusCode;
|
|
15
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
16
|
+
res.end(body);
|
|
17
|
+
}
|
|
18
|
+
function contentTypeFor(filePath) {
|
|
19
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
20
|
+
switch (ext) {
|
|
21
|
+
case '.html':
|
|
22
|
+
return 'text/html; charset=utf-8';
|
|
23
|
+
case '.css':
|
|
24
|
+
return 'text/css; charset=utf-8';
|
|
25
|
+
case '.js':
|
|
26
|
+
return 'application/javascript; charset=utf-8';
|
|
27
|
+
case '.json':
|
|
28
|
+
return 'application/json; charset=utf-8';
|
|
29
|
+
case '.svg':
|
|
30
|
+
return 'image/svg+xml';
|
|
31
|
+
default:
|
|
32
|
+
return 'application/octet-stream';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function readJsonBody(req) {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
for await (const chunk of req) {
|
|
38
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
39
|
+
}
|
|
40
|
+
if (chunks.length === 0)
|
|
41
|
+
return {};
|
|
42
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(body);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error('invalid_json');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function safeStaticPath(rootDir, requestPath) {
|
|
51
|
+
const relative = requestPath.startsWith(ASSETS_PREFIX)
|
|
52
|
+
? requestPath.slice(ASSETS_PREFIX.length).replace(/^\/+/, '')
|
|
53
|
+
: '';
|
|
54
|
+
const normalized = path.normalize(relative);
|
|
55
|
+
const webRoot = path.join(rootDir, 'dist', 'web');
|
|
56
|
+
const assetsRoot = path.join(webRoot, 'assets');
|
|
57
|
+
const target = path.join(assetsRoot, normalized);
|
|
58
|
+
const relativeTarget = path.relative(assetsRoot, target);
|
|
59
|
+
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return target;
|
|
63
|
+
}
|
|
64
|
+
function serveFile(res, filePath) {
|
|
65
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
res.statusCode = 200;
|
|
69
|
+
res.setHeader('Content-Type', contentTypeFor(filePath));
|
|
70
|
+
const stream = fs.createReadStream(filePath);
|
|
71
|
+
stream.on('error', () => {
|
|
72
|
+
res.statusCode = 500;
|
|
73
|
+
res.end('Internal Server Error');
|
|
74
|
+
});
|
|
75
|
+
stream.pipe(res);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
function createService(api) {
|
|
79
|
+
const workspaceDir = api.resolvePath('.');
|
|
80
|
+
return new ControlUiQueryService(workspaceDir);
|
|
81
|
+
}
|
|
82
|
+
function handleApiRoute(api, pathname, req, res) {
|
|
83
|
+
const service = createService(api);
|
|
84
|
+
const url = new URL(req.url || pathname, 'http://127.0.0.1');
|
|
85
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
86
|
+
const done = (fn) => {
|
|
87
|
+
try {
|
|
88
|
+
const payload = fn();
|
|
89
|
+
json(res, 200, payload);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
api.logger.warn(`[PD:ControlUI] API request failed for ${pathname}: ${String(error)}`);
|
|
94
|
+
json(res, 500, { error: 'internal_error', message: String(error) });
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
service.dispose();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
if (pathname === `${API_PREFIX}/overview` && method === 'GET') {
|
|
102
|
+
return done(() => service.getOverview());
|
|
103
|
+
}
|
|
104
|
+
if (pathname === `${API_PREFIX}/samples` && method === 'GET') {
|
|
105
|
+
return done(() => service.listSamples({
|
|
106
|
+
status: url.searchParams.get('status') ?? undefined,
|
|
107
|
+
qualityMin: url.searchParams.has('qualityMin') ? Number(url.searchParams.get('qualityMin')) : undefined,
|
|
108
|
+
dateFrom: url.searchParams.get('dateFrom') ?? undefined,
|
|
109
|
+
dateTo: url.searchParams.get('dateTo') ?? undefined,
|
|
110
|
+
failureMode: url.searchParams.get('failureMode') ?? undefined,
|
|
111
|
+
page: url.searchParams.has('page') ? Number(url.searchParams.get('page')) : undefined,
|
|
112
|
+
pageSize: url.searchParams.has('pageSize') ? Number(url.searchParams.get('pageSize')) : undefined,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
const sampleDetailMatch = pathname.match(/^\/plugins\/principles\/api\/samples\/([^/]+)$/);
|
|
116
|
+
if (sampleDetailMatch && method === 'GET') {
|
|
117
|
+
try {
|
|
118
|
+
const detail = service.getSampleDetail(decodeURIComponent(sampleDetailMatch[1]));
|
|
119
|
+
if (!detail) {
|
|
120
|
+
json(res, 404, { error: 'not_found', message: 'Sample not found.' });
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
json(res, 200, detail);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
api.logger.warn(`[PD:ControlUI] API request failed for ${pathname}: ${String(error)}`);
|
|
128
|
+
json(res, 500, { error: 'internal_error', message: String(error) });
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
service.dispose();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const sampleReviewMatch = pathname.match(/^\/plugins\/principles\/api\/samples\/([^/]+)\/review$/);
|
|
136
|
+
if (sampleReviewMatch && method === 'POST') {
|
|
137
|
+
return (async () => {
|
|
138
|
+
try {
|
|
139
|
+
const body = await readJsonBody(req);
|
|
140
|
+
const decision = body.decision === 'approved' || body.decision === 'rejected'
|
|
141
|
+
? body.decision
|
|
142
|
+
: null;
|
|
143
|
+
if (!decision) {
|
|
144
|
+
json(res, 400, { error: 'bad_request', message: 'decision must be approved or rejected' });
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
const record = service.reviewSample(decodeURIComponent(sampleReviewMatch[1]), decision, typeof body.note === 'string' ? body.note : undefined);
|
|
148
|
+
json(res, 200, record);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error instanceof Error && error.message === 'invalid_json') {
|
|
153
|
+
json(res, 400, { error: 'bad_request', message: 'Request body must be valid JSON.' });
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
api.logger.warn(`[PD:ControlUI] Review request failed for ${pathname}: ${String(error)}`);
|
|
157
|
+
json(res, 500, { error: 'internal_error', message: String(error) });
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
service.dispose();
|
|
162
|
+
}
|
|
163
|
+
})();
|
|
164
|
+
}
|
|
165
|
+
if (pathname === `${API_PREFIX}/thinking` && method === 'GET') {
|
|
166
|
+
return done(() => service.getThinkingOverview());
|
|
167
|
+
}
|
|
168
|
+
const thinkingDetailMatch = pathname.match(/^\/plugins\/principles\/api\/thinking\/models\/([^/]+)$/);
|
|
169
|
+
if (thinkingDetailMatch && method === 'GET') {
|
|
170
|
+
try {
|
|
171
|
+
const detail = service.getThinkingModelDetail(decodeURIComponent(thinkingDetailMatch[1]));
|
|
172
|
+
if (!detail) {
|
|
173
|
+
json(res, 404, { error: 'not_found', message: 'Thinking model not found.' });
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
json(res, 200, detail);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
api.logger.warn(`[PD:ControlUI] API request failed for ${pathname}: ${String(error)}`);
|
|
181
|
+
json(res, 500, { error: 'internal_error', message: String(error) });
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
service.dispose();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (pathname === `${API_PREFIX}/export/corrections` && method === 'GET') {
|
|
189
|
+
try {
|
|
190
|
+
const mode = url.searchParams.get('mode') === 'redacted' ? 'redacted' : 'raw';
|
|
191
|
+
const result = service.exportCorrections(mode);
|
|
192
|
+
if (!fs.existsSync(result.filePath)) {
|
|
193
|
+
json(res, 404, { error: 'not_found', message: 'Export file not found.' });
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
res.statusCode = 200;
|
|
197
|
+
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
198
|
+
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(result.filePath)}"`);
|
|
199
|
+
const stream = fs.createReadStream(result.filePath);
|
|
200
|
+
stream.on('error', () => {
|
|
201
|
+
res.statusCode = 500;
|
|
202
|
+
res.end('Internal Server Error');
|
|
203
|
+
});
|
|
204
|
+
stream.pipe(res);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
api.logger.warn(`[PD:ControlUI] Export request failed for ${pathname}: ${String(error)}`);
|
|
209
|
+
json(res, 500, { error: 'internal_error', message: String(error) });
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
service.dispose();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
service.dispose();
|
|
217
|
+
json(res, 404, { error: 'not_found', message: 'Unknown Principles Console API route.' });
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
export function createPrinciplesConsoleRoute(api) {
|
|
221
|
+
return {
|
|
222
|
+
path: ROUTE_PREFIX,
|
|
223
|
+
auth: 'gateway',
|
|
224
|
+
match: 'prefix',
|
|
225
|
+
async handler(req, res) {
|
|
226
|
+
const url = new URL(req.url || ROUTE_PREFIX, 'http://127.0.0.1');
|
|
227
|
+
const pathname = url.pathname;
|
|
228
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
229
|
+
if (!pathname.startsWith(ROUTE_PREFIX)) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
if (pathname.startsWith(API_PREFIX)) {
|
|
233
|
+
return handleApiRoute(api, pathname, req, res);
|
|
234
|
+
}
|
|
235
|
+
if (pathname.startsWith(ASSETS_PREFIX)) {
|
|
236
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
237
|
+
text(res, 405, 'Method Not Allowed');
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
const assetPath = safeStaticPath(api.rootDir, pathname);
|
|
241
|
+
if (!assetPath || !serveFile(res, assetPath)) {
|
|
242
|
+
text(res, 404, 'Asset Not Found');
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
247
|
+
text(res, 405, 'Method Not Allowed');
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
const indexPath = path.join(api.rootDir, 'dist', 'web', 'index.html');
|
|
251
|
+
if (!serveFile(res, indexPath)) {
|
|
252
|
+
text(res, 503, 'Principles Console UI is not built yet.');
|
|
253
|
+
}
|
|
254
|
+
return true;
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|