principles-disciple 1.7.3 → 1.7.5

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.
Files changed (67) hide show
  1. package/dist/commands/evolution-status.js +4 -2
  2. package/dist/commands/focus.js +30 -155
  3. package/dist/constants/diagnostician.d.ts +16 -0
  4. package/dist/constants/diagnostician.js +60 -0
  5. package/dist/constants/tools.d.ts +2 -2
  6. package/dist/constants/tools.js +1 -1
  7. package/dist/core/config.d.ts +23 -0
  8. package/dist/core/config.js +26 -1
  9. package/dist/core/evolution-engine.js +1 -1
  10. package/dist/core/evolution-logger.d.ts +137 -0
  11. package/dist/core/evolution-logger.js +256 -0
  12. package/dist/core/evolution-reducer.d.ts +23 -0
  13. package/dist/core/evolution-reducer.js +73 -29
  14. package/dist/core/evolution-types.d.ts +6 -0
  15. package/dist/core/focus-history.d.ts +145 -0
  16. package/dist/core/focus-history.js +919 -0
  17. package/dist/core/init.js +24 -0
  18. package/dist/core/profile.js +1 -1
  19. package/dist/core/risk-calculator.d.ts +15 -0
  20. package/dist/core/risk-calculator.js +48 -0
  21. package/dist/core/trajectory.d.ts +73 -0
  22. package/dist/core/trajectory.js +206 -0
  23. package/dist/hooks/gate.js +130 -20
  24. package/dist/hooks/lifecycle.js +104 -0
  25. package/dist/hooks/pain.js +31 -0
  26. package/dist/hooks/prompt.js +136 -38
  27. package/dist/hooks/subagent.d.ts +1 -0
  28. package/dist/hooks/subagent.js +200 -18
  29. package/dist/http/principles-console-route.d.ts +7 -0
  30. package/dist/http/principles-console-route.js +301 -1
  31. package/dist/index.js +0 -2
  32. package/dist/service/central-database.d.ts +104 -0
  33. package/dist/service/central-database.js +648 -0
  34. package/dist/service/control-ui-query-service.d.ts +2 -0
  35. package/dist/service/control-ui-query-service.js +4 -0
  36. package/dist/service/empathy-observer-manager.d.ts +8 -0
  37. package/dist/service/empathy-observer-manager.js +40 -0
  38. package/dist/service/evolution-query-service.d.ts +155 -0
  39. package/dist/service/evolution-query-service.js +258 -0
  40. package/dist/service/evolution-worker.d.ts +4 -0
  41. package/dist/service/evolution-worker.js +185 -63
  42. package/dist/service/phase3-input-filter.d.ts +37 -0
  43. package/dist/service/phase3-input-filter.js +106 -0
  44. package/dist/service/runtime-summary-service.d.ts +15 -0
  45. package/dist/service/runtime-summary-service.js +111 -23
  46. package/dist/tools/deep-reflect.js +8 -2
  47. package/dist/utils/subagent-probe.d.ts +34 -0
  48. package/dist/utils/subagent-probe.js +81 -0
  49. package/openclaw.plugin.json +1 -1
  50. package/package.json +6 -4
  51. package/templates/langs/en/core/AGENTS.md +15 -3
  52. package/templates/langs/en/core/BOOTSTRAP.md +24 -1
  53. package/templates/langs/en/core/TOOLS.md +9 -0
  54. package/templates/langs/zh/core/AGENTS.md +15 -3
  55. package/templates/langs/zh/core/BOOTSTRAP.md +24 -1
  56. package/templates/langs/zh/core/TOOLS.md +9 -0
  57. package/templates/langs/zh/skills/pd-auditor/SKILL.md +61 -0
  58. package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +287 -0
  59. package/templates/langs/zh/skills/pd-explorer/SKILL.md +65 -0
  60. package/templates/langs/zh/skills/pd-implementer/SKILL.md +68 -0
  61. package/templates/langs/zh/skills/pd-planner/SKILL.md +65 -0
  62. package/templates/langs/zh/skills/pd-reporter/SKILL.md +78 -0
  63. package/templates/langs/zh/skills/pd-reviewer/SKILL.md +66 -0
  64. package/dist/core/agent-loader.d.ts +0 -44
  65. package/dist/core/agent-loader.js +0 -147
  66. package/dist/tools/agent-spawn.d.ts +0 -54
  67. package/dist/tools/agent-spawn.js +0 -445
@@ -6,6 +6,8 @@ import { acquireQueueLock } from '../service/evolution-worker.js';
6
6
  const COMPLETION_RETRY_DELAY_MS = 250;
7
7
  const COMPLETION_MAX_RETRIES = 3;
8
8
  const COMPLETION_RETRY_TTL_MS = 60 * 60 * 1000; // 1 hour TTL for retry entries
9
+ const TASK_OUTCOME_RETRY_DELAY_MS = 250;
10
+ const TASK_OUTCOME_MAX_RETRIES = 3;
9
11
  const DIAGNOSTICIAN_SESSION_PREFIX = 'agent:diagnostician:';
10
12
  const completionRetryCounts = new Map();
11
13
  // Cleanup expired retry entries periodically
@@ -17,7 +19,7 @@ function cleanupExpiredRetryEntries() {
17
19
  }
18
20
  }
19
21
  }
20
- function emitSubagentPainEvent(wctx, payload) {
22
+ function emitSubagentPainEvent(wctx, payload, logger) {
21
23
  try {
22
24
  wctx.evolutionReducer.emitSync({
23
25
  ts: new Date().toISOString(),
@@ -29,17 +31,25 @@ function emitSubagentPainEvent(wctx, payload) {
29
31
  reason: payload.reason,
30
32
  score: payload.score,
31
33
  sessionId: payload.sessionId,
34
+ agentId: payload.agentId,
32
35
  },
33
36
  });
34
37
  }
35
38
  catch (e) {
36
- console.warn(`[PD:Subagent] failed to emit evolution event: ${String(e)}`);
39
+ logger.warn(`[PD:Subagent] failed to emit evolution event: ${String(e)}`);
37
40
  }
38
41
  }
39
42
  function isDiagnosticianSession(targetSessionKey) {
40
43
  return typeof targetSessionKey === 'string' && targetSessionKey.startsWith(DIAGNOSTICIAN_SESSION_PREFIX);
41
44
  }
42
- function cleanupPainFlagForTask(wctx, completedTaskId, queue) {
45
+ function extractAgentIdFromSessionKey(sessionKey) {
46
+ // sessionKey format: "agent:{agentId}:{type}:{uuid}" or "agent:{agentId}:{uuid}"
47
+ if (!sessionKey)
48
+ return undefined;
49
+ const match = sessionKey.match(/^agent:([^:]+):/);
50
+ return match ? match[1] : undefined;
51
+ }
52
+ function cleanupPainFlagForTask(wctx, completedTaskId, queue, logger) {
43
53
  const painFlagPath = wctx.resolve('PAIN_FLAG');
44
54
  try {
45
55
  const painData = fs.readFileSync(painFlagPath, 'utf8');
@@ -65,7 +75,7 @@ function cleanupPainFlagForTask(wctx, completedTaskId, queue) {
65
75
  catch (e) {
66
76
  if (e.code === 'ENOENT')
67
77
  return; // File doesn't exist, nothing to clean up
68
- console.error(`[PD:Subagent] Failed to cleanup pain flag: ${String(e)}`);
78
+ logger.error(`[PD:Subagent] Failed to cleanup pain flag: ${String(e)}`);
69
79
  }
70
80
  }
71
81
  function getCompletionRetryKey(workspaceDir, targetSessionKey) {
@@ -92,34 +102,67 @@ function scheduleCompletionRetry(event, ctx, attempt) {
92
102
  });
93
103
  }, COMPLETION_RETRY_DELAY_MS);
94
104
  }
105
+ function scheduleTaskOutcomeRetry(wctx, payload, attempt, logger) {
106
+ if (attempt > TASK_OUTCOME_MAX_RETRIES) {
107
+ logger.error(`[PD:Subagent] Failed to persist task outcome after ${TASK_OUTCOME_MAX_RETRIES} retries: ${payload.taskId}`);
108
+ return;
109
+ }
110
+ setTimeout(() => {
111
+ try {
112
+ wctx.trajectory?.recordTaskOutcome?.(payload);
113
+ }
114
+ catch (error) {
115
+ logger.warn(`[PD:Subagent] Retrying task outcome persistence for ${payload.taskId}: ${String(error)}`);
116
+ scheduleTaskOutcomeRetry(wctx, payload, attempt + 1, logger);
117
+ }
118
+ }, TASK_OUTCOME_RETRY_DELAY_MS);
119
+ }
95
120
  export async function handleSubagentEnded(event, ctx) {
96
121
  const { outcome, targetSessionKey } = event;
97
122
  const workspaceDir = ctx.workspaceDir;
98
123
  if (!workspaceDir)
99
124
  return;
100
125
  const wctx = WorkspaceContext.fromHookContext(ctx);
126
+ const logger = ctx.api?.logger ?? console;
101
127
  if (targetSessionKey?.startsWith('empathy_obs:')) {
102
128
  await empathyObserverManager.reap(ctx.api, targetSessionKey, workspaceDir);
103
129
  return;
104
130
  }
105
131
  const config = wctx.config;
106
- if (outcome === 'error' || outcome === 'timeout') {
132
+ // ── Outcome-based Trust Score and Pain Signal handling ──
133
+ // OpenClaw v2026.3.23 fixes: timeout may be false positive (fast-finishing workers)
134
+ // Only penalize actual errors, not timeout/killed/reset
135
+ if (outcome === 'error') {
136
+ // Only actual errors trigger penalty
107
137
  const scoreSettings = config.get('scores');
108
- const score = outcome === 'error' ? scoreSettings.subagent_error_penalty : scoreSettings.subagent_timeout_penalty;
109
- const reason = `Subagent session ${targetSessionKey} ended with outcome: ${outcome}`;
138
+ const score = scoreSettings.subagent_error_penalty;
139
+ const reason = `Subagent session ${targetSessionKey} ended with error`;
110
140
  writePainFlag(workspaceDir, {
111
- source: `subagent_${outcome}`,
141
+ source: `subagent_error`,
112
142
  score: String(score),
113
143
  time: new Date().toISOString(),
114
144
  reason,
115
- is_risky: 'true'
145
+ is_risky: 'true',
146
+ session_id: ctx.sessionId || '',
147
+ agent_id: ctx.agentId || extractAgentIdFromSessionKey(targetSessionKey) || '',
116
148
  });
117
149
  emitSubagentPainEvent(wctx, {
118
- source: `subagent_${outcome}`,
150
+ source: `subagent_error`,
119
151
  reason,
120
152
  score,
121
153
  sessionId: ctx.sessionId,
122
- });
154
+ agentId: ctx.agentId || extractAgentIdFromSessionKey(targetSessionKey),
155
+ }, logger);
156
+ }
157
+ if (outcome === 'timeout') {
158
+ // OpenClaw v2026.3.23 fix: timeout may be false positive
159
+ // Fast-finishing workers are no longer incorrectly reported as timed out
160
+ // Do not penalize - the task may have actually succeeded
161
+ logger.warn(`[PD:Subagent] Session ${targetSessionKey} timed out - not penalizing (OpenClaw fix applied)`);
162
+ }
163
+ if (outcome === 'killed' || outcome === 'reset') {
164
+ // User-initiated termination or system reset - not an agent failure
165
+ logger.info(`[PD:Subagent] Session ${targetSessionKey} ended with ${outcome} - no penalty (user/system action)`);
123
166
  }
124
167
  if (outcome === 'ok' || outcome === 'deleted') {
125
168
  wctx.trust.recordSuccess('subagent_success', {
@@ -138,31 +181,170 @@ export async function handleSubagentEnded(event, ctx) {
138
181
  const attempt = retryEntry?.count || 0;
139
182
  let releaseLock = null;
140
183
  try {
141
- releaseLock = await acquireQueueLock(queuePath, console);
184
+ releaseLock = await acquireQueueLock(queuePath, logger);
142
185
  const queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
143
186
  let completedTaskId = null;
144
- const matchedTask = queue.find((task) => task?.status === 'in_progress'
145
- && typeof task?.assigned_session_key === 'string'
146
- && task.assigned_session_key === targetSessionKey);
187
+ // Improved matching logic: support both direct session key match and HEARTBEAT placeholder match
188
+ // This fixes task_outcomes being empty for HEARTBEAT-triggered diagnostician runs
189
+ const matchedTask = queue.find((task) => {
190
+ if (task?.status !== 'in_progress')
191
+ return false;
192
+ const taskSessionKey = task?.assigned_session_key;
193
+ // 1. Exact match: direct session key assignment
194
+ if (typeof taskSessionKey === 'string' && taskSessionKey === targetSessionKey) {
195
+ return true;
196
+ }
197
+ // 2. HEARTBEAT placeholder match: for diagnostician sessions
198
+ // Tasks started via HEARTBEAT have placeholder like "heartbeat:diagnostician:{taskId}"
199
+ if (isDiagnosticianSession(targetSessionKey)) {
200
+ // Match tasks with HEARTBEAT placeholder
201
+ if (typeof taskSessionKey === 'string' && taskSessionKey.startsWith('heartbeat:diagnostician')) {
202
+ return true;
203
+ }
204
+ // Backward compatibility: match tasks with no assigned_session_key (legacy behavior)
205
+ // Only match tasks started within 2 hours to avoid stale task matching
206
+ if (taskSessionKey === undefined || taskSessionKey === null) {
207
+ const taskStartedAt = task?.started_at ? new Date(task.started_at).getTime() : 0;
208
+ const taskAge = taskStartedAt > 0 ? Date.now() - taskStartedAt : Infinity;
209
+ const LEGACY_FALLBACK_MAX_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
210
+ if (taskAge < LEGACY_FALLBACK_MAX_AGE_MS) {
211
+ return true;
212
+ }
213
+ }
214
+ }
215
+ return false;
216
+ });
147
217
  if (matchedTask) {
218
+ // Enhanced observability: log match type for debugging
219
+ const matchType = matchedTask.assigned_session_key === targetSessionKey
220
+ ? 'exact'
221
+ : matchedTask.assigned_session_key?.startsWith('heartbeat:diagnostician')
222
+ ? 'heartbeat_placeholder'
223
+ : 'legacy_fallback';
224
+ logger.info(`[PD:Subagent] Matched session ${targetSessionKey} to task ${matchedTask.id} (match_type: ${matchType})`);
148
225
  matchedTask.status = 'completed';
149
226
  matchedTask.completed_at = new Date().toISOString();
150
227
  delete matchedTask.assigned_session_key;
151
228
  completedTaskId = matchedTask.id;
152
229
  }
153
230
  else {
154
- console.warn(`[PD:Subagent] No in-progress evolution task matched subagent session ${targetSessionKey}`);
231
+ logger.warn(`[PD:Subagent] No in-progress evolution task matched subagent session ${targetSessionKey}`);
155
232
  }
233
+ let taskOutcomePayload = null;
156
234
  if (completedTaskId) {
157
235
  fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
158
- cleanupPainFlagForTask(wctx, completedTaskId, queue);
236
+ cleanupPainFlagForTask(wctx, completedTaskId, queue, logger);
237
+ taskOutcomePayload = {
238
+ sessionId: targetSessionKey,
239
+ taskId: completedTaskId,
240
+ outcome,
241
+ summary: `Diagnostician session ${targetSessionKey} completed evolution task ${completedTaskId}.`,
242
+ };
243
+ }
244
+ if (taskOutcomePayload) {
245
+ try {
246
+ wctx.trajectory?.recordTaskOutcome?.(taskOutcomePayload);
247
+ }
248
+ catch (error) {
249
+ logger.warn(`[PD:Subagent] Failed to persist task outcome for ${taskOutcomePayload.taskId}: ${String(error)}`);
250
+ scheduleTaskOutcomeRetry(wctx, taskOutcomePayload, 1, logger);
251
+ }
252
+ }
253
+ // Read diagnostician output and create principle with generalized pattern
254
+ if (completedTaskId && ctx.api?.runtime?.subagent) {
255
+ try {
256
+ const messages = await ctx.api?.runtime?.subagent?.getSessionMessages?.({
257
+ sessionKey: targetSessionKey,
258
+ limit: 50
259
+ });
260
+ const assistantText = extractAssistantText(messages);
261
+ const report = parseDiagnosticianReport(assistantText);
262
+ if (report?.principle) {
263
+ const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
264
+ painId: matchedTask?.id || completedTaskId,
265
+ painType: 'tool_failure', // Default, could be extracted from task
266
+ triggerPattern: report.principle.trigger_pattern,
267
+ action: report.principle.action,
268
+ source: matchedTask?.source || 'diagnostician'
269
+ });
270
+ if (principleId) {
271
+ logger.warn(`[PD:Subagent] Created principle ${principleId} from diagnostician analysis for task ${completedTaskId}`);
272
+ }
273
+ }
274
+ }
275
+ catch (e) {
276
+ logger.warn(`[PD:Subagent] Failed to read diagnostician output: ${String(e)}`);
277
+ }
159
278
  }
160
279
  }
161
280
  catch (e) {
162
- console.error(`[PD:Subagent] Failed to update evolution queue: ${String(e)}`);
281
+ logger.error(`[PD:Subagent] Failed to update evolution queue: ${String(e)}`);
163
282
  scheduleCompletionRetry(event, ctx, attempt);
164
283
  }
165
284
  finally {
166
285
  releaseLock?.();
167
286
  }
168
287
  }
288
+ /**
289
+ * Extract text content from assistant messages
290
+ */
291
+ function extractAssistantText(messages) {
292
+ if (!messages || !Array.isArray(messages))
293
+ return '';
294
+ const texts = [];
295
+ for (const msg of messages) {
296
+ if (msg?.role !== 'assistant')
297
+ continue;
298
+ const content = msg?.content;
299
+ if (Array.isArray(content)) {
300
+ for (const block of content) {
301
+ if (block?.type === 'text' && typeof block.text === 'string') {
302
+ texts.push(block.text);
303
+ }
304
+ }
305
+ }
306
+ else if (typeof content === 'string') {
307
+ texts.push(content);
308
+ }
309
+ }
310
+ return texts.join('\n');
311
+ }
312
+ /**
313
+ * Parse diagnostician JSON report from text
314
+ */
315
+ function parseDiagnosticianReport(text) {
316
+ // Try to find JSON in markdown code block
317
+ const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/);
318
+ if (jsonMatch) {
319
+ try {
320
+ const parsed = JSON.parse(jsonMatch[1]);
321
+ // Support both direct principle and nested phases.principle_extraction structure
322
+ if (parsed?.principle) {
323
+ return { principle: parsed.principle };
324
+ }
325
+ if (parsed?.phases?.principle_extraction?.principle) {
326
+ return { principle: parsed.phases.principle_extraction.principle };
327
+ }
328
+ }
329
+ catch {
330
+ // Fall through to return null
331
+ }
332
+ }
333
+ // Try to find raw JSON object
334
+ const objectMatch = text.match(/\{[\s\S]*"principle"[\s\S]*\}/);
335
+ if (objectMatch) {
336
+ try {
337
+ const parsed = JSON.parse(objectMatch[0]);
338
+ if (parsed?.principle) {
339
+ return { principle: parsed.principle };
340
+ }
341
+ if (parsed?.phases?.principle_extraction?.principle) {
342
+ return { principle: parsed.phases.principle_extraction.principle };
343
+ }
344
+ }
345
+ catch {
346
+ // Fall through to return null
347
+ }
348
+ }
349
+ return null;
350
+ }
@@ -1,2 +1,9 @@
1
1
  import type { OpenClawPluginApi, OpenClawPluginHttpRouteParams } from '../openclaw-sdk.js';
2
+ /**
3
+ * Create routes for Principles Console.
4
+ * Returns an array of routes:
5
+ * 1. Static files route (no auth required for HTML/CSS/JS)
6
+ * 2. API route (gateway auth required)
7
+ */
8
+ export declare function createPrinciplesConsoleRoutes(api: OpenClawPluginApi): OpenClawPluginHttpRouteParams[];
2
9
  export declare function createPrinciplesConsoleRoute(api: OpenClawPluginApi): OpenClawPluginHttpRouteParams;
@@ -1,6 +1,9 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { ControlUiQueryService } from '../service/control-ui-query-service.js';
4
+ import { getEvolutionQueryService } from '../service/evolution-query-service.js';
5
+ import { TrajectoryRegistry } from '../core/trajectory.js';
6
+ import { getCentralDatabase } from '../service/central-database.js';
4
7
  const ROUTE_PREFIX = '/plugins/principles';
5
8
  const API_PREFIX = `${ROUTE_PREFIX}/api`;
6
9
  const ASSETS_PREFIX = `${ROUTE_PREFIX}/assets`;
@@ -80,6 +83,11 @@ function createService(api) {
80
83
  return new ControlUiQueryService(workspaceDir);
81
84
  }
82
85
  function handleApiRoute(api, pathname, req, res) {
86
+ // Check authentication for API routes
87
+ if (!validateGatewayAuth(req)) {
88
+ json(res, 401, { error: 'unauthorized', message: 'Valid Gateway token required.' });
89
+ return true;
90
+ }
83
91
  const service = createService(api);
84
92
  const url = new URL(req.url || pathname, 'http://127.0.0.1');
85
93
  const method = (req.method || 'GET').toUpperCase();
@@ -101,6 +109,153 @@ function handleApiRoute(api, pathname, req, res) {
101
109
  if (pathname === `${API_PREFIX}/overview` && method === 'GET') {
102
110
  return done(() => service.getOverview());
103
111
  }
112
+ if (pathname === `${API_PREFIX}/central/overview` && method === 'GET') {
113
+ return done(() => {
114
+ const centralDb = getCentralDatabase();
115
+ const stats = centralDb.getOverviewStats();
116
+ const trend = centralDb.getDailyTrend(14);
117
+ const regressions = centralDb.getTopRegressions(5);
118
+ const thinkingStats = centralDb.getThinkingModelStats();
119
+ const workspaces = centralDb.getWorkspaces();
120
+ return {
121
+ workspaceDir: 'central',
122
+ generatedAt: new Date().toISOString(),
123
+ dataFreshness: workspaces.length > 0 ? (workspaces[0].lastSync ?? null) : null,
124
+ dataSource: 'central_aggregated_db',
125
+ runtimeControlPlaneSource: 'all_workspaces',
126
+ summary: {
127
+ repeatErrorRate: stats.totalToolCalls > 0
128
+ ? stats.totalFailures / stats.totalToolCalls
129
+ : 0,
130
+ userCorrectionRate: stats.totalToolCalls > 0
131
+ ? stats.totalCorrections / stats.totalToolCalls
132
+ : 0,
133
+ pendingSamples: stats.pendingSamples,
134
+ approvedSamples: stats.approvedSamples,
135
+ thinkingCoverageRate: stats.totalToolCalls > 0
136
+ ? stats.totalThinkingEvents / stats.totalToolCalls
137
+ : 0,
138
+ painEvents: stats.totalPainEvents,
139
+ principleEventCount: 0,
140
+ gateBlocks: 0,
141
+ taskOutcomes: 0,
142
+ },
143
+ dailyTrend: trend,
144
+ topRegressions: regressions,
145
+ sampleQueue: {
146
+ counters: {
147
+ pending: stats.pendingSamples,
148
+ approved: stats.approvedSamples,
149
+ rejected: stats.rejectedSamples,
150
+ },
151
+ preview: [],
152
+ },
153
+ thinkingSummary: {
154
+ activeModels: thinkingStats.activeModels,
155
+ dormantModels: thinkingStats.totalModels - thinkingStats.activeModels,
156
+ effectiveModels: thinkingStats.models.filter(m => m.coverageRate > 0.1).length,
157
+ coverageRate: stats.totalToolCalls > 0
158
+ ? stats.totalThinkingEvents / stats.totalToolCalls
159
+ : 0,
160
+ },
161
+ centralInfo: {
162
+ workspaceCount: stats.workspaceCount,
163
+ enabledWorkspaceCount: stats.enabledWorkspaceCount,
164
+ workspaces: stats.workspaceNames,
165
+ enabledWorkspaces: stats.enabledWorkspaceNames,
166
+ },
167
+ };
168
+ });
169
+ }
170
+ if (pathname === `${API_PREFIX}/central/sync` && method === 'POST') {
171
+ return done(() => {
172
+ const centralDb = getCentralDatabase();
173
+ const results = centralDb.syncEnabled();
174
+ const summary = {};
175
+ results.forEach((count, name) => {
176
+ summary[name] = count;
177
+ });
178
+ return { synced: summary, timestamp: new Date().toISOString() };
179
+ });
180
+ }
181
+ if (pathname === `${API_PREFIX}/central/workspaces` && method === 'GET') {
182
+ return done(() => {
183
+ const centralDb = getCentralDatabase();
184
+ const configs = centralDb.getWorkspaceConfigs();
185
+ const workspaces = centralDb.getWorkspaces();
186
+ return {
187
+ configs,
188
+ workspaces: workspaces.map(ws => ({
189
+ name: ws.name,
190
+ path: ws.path,
191
+ lastSync: ws.lastSync,
192
+ config: configs.find(c => c.workspaceName === ws.name) ?? null,
193
+ })),
194
+ };
195
+ });
196
+ }
197
+ const workspaceConfigMatch = pathname.match(/^\/plugins\/principles\/api\/central\/workspaces\/([^/]+)$/);
198
+ if (workspaceConfigMatch && method === 'GET') {
199
+ return done(() => {
200
+ const centralDb = getCentralDatabase();
201
+ const workspaceName = decodeURIComponent(workspaceConfigMatch[1]);
202
+ const configs = centralDb.getWorkspaceConfigs();
203
+ const config = configs.find(c => c.workspaceName === workspaceName);
204
+ return config ?? { workspaceName, enabled: true, displayName: workspaceName, syncEnabled: true };
205
+ });
206
+ }
207
+ if (workspaceConfigMatch && method === 'PATCH') {
208
+ return (async () => {
209
+ try {
210
+ const body = await readJsonBody(req);
211
+ const centralDb = getCentralDatabase();
212
+ const workspaceName = decodeURIComponent(workspaceConfigMatch[1]);
213
+ centralDb.updateWorkspaceConfig(workspaceName, {
214
+ enabled: body.enabled,
215
+ displayName: body.displayName,
216
+ syncEnabled: body.syncEnabled,
217
+ });
218
+ const configs = centralDb.getWorkspaceConfigs();
219
+ json(res, 200, configs.find(c => c.workspaceName === workspaceName));
220
+ return true;
221
+ }
222
+ catch (error) {
223
+ if (error instanceof Error && error.message === 'invalid_json') {
224
+ json(res, 400, { error: 'bad_request', message: 'Request body must be valid JSON.' });
225
+ return true;
226
+ }
227
+ api.logger.warn(`[PD:ControlUI] Workspace config update failed: ${String(error)}`);
228
+ json(res, 500, { error: 'internal_error', message: String(error) });
229
+ return true;
230
+ }
231
+ })();
232
+ }
233
+ if (pathname === `${API_PREFIX}/central/workspaces` && method === 'POST') {
234
+ return (async () => {
235
+ try {
236
+ const body = await readJsonBody(req);
237
+ const name = typeof body.name === 'string' ? body.name : '';
238
+ const workspacePath = typeof body.path === 'string' ? body.path : '';
239
+ if (!name || !workspacePath) {
240
+ json(res, 400, { error: 'bad_request', message: 'name and path are required.' });
241
+ return true;
242
+ }
243
+ const centralDb = getCentralDatabase();
244
+ centralDb.addCustomWorkspace(name, workspacePath);
245
+ json(res, 201, { success: true, workspace: name });
246
+ return true;
247
+ }
248
+ catch (error) {
249
+ if (error instanceof Error && error.message === 'invalid_json') {
250
+ json(res, 400, { error: 'bad_request', message: 'Request body must be valid JSON.' });
251
+ return true;
252
+ }
253
+ api.logger.warn(`[PD:ControlUI] Add workspace failed: ${String(error)}`);
254
+ json(res, 500, { error: 'internal_error', message: String(error) });
255
+ return true;
256
+ }
257
+ })();
258
+ }
104
259
  if (pathname === `${API_PREFIX}/samples` && method === 'GET') {
105
260
  return done(() => service.listSamples({
106
261
  status: url.searchParams.get('status') ?? undefined,
@@ -185,6 +340,62 @@ function handleApiRoute(api, pathname, req, res) {
185
340
  service.dispose();
186
341
  }
187
342
  }
343
+ // === Evolution API ===
344
+ const evolutionService = () => {
345
+ const workspaceDir = api.resolvePath('.');
346
+ const trajectory = TrajectoryRegistry.get(workspaceDir);
347
+ return getEvolutionQueryService(trajectory);
348
+ };
349
+ if (pathname === `${API_PREFIX}/evolution/tasks` && method === 'GET') {
350
+ return done(() => {
351
+ const evoService = evolutionService();
352
+ return evoService.getTasks({
353
+ status: url.searchParams.get('status') ?? undefined,
354
+ dateFrom: url.searchParams.get('dateFrom') ?? undefined,
355
+ dateTo: url.searchParams.get('dateTo') ?? undefined,
356
+ page: url.searchParams.has('page') ? Number(url.searchParams.get('page')) : undefined,
357
+ pageSize: url.searchParams.has('pageSize') ? Number(url.searchParams.get('pageSize')) : undefined,
358
+ });
359
+ });
360
+ }
361
+ if (pathname === `${API_PREFIX}/evolution/events` && method === 'GET') {
362
+ return done(() => {
363
+ const evoService = evolutionService();
364
+ return evoService.getEvents({
365
+ traceId: url.searchParams.get('traceId') ?? undefined,
366
+ stage: url.searchParams.get('stage') ?? undefined,
367
+ limit: url.searchParams.has('limit') ? Number(url.searchParams.get('limit')) : undefined,
368
+ offset: url.searchParams.has('offset') ? Number(url.searchParams.get('offset')) : undefined,
369
+ });
370
+ });
371
+ }
372
+ if (pathname === `${API_PREFIX}/evolution/stats` && method === 'GET') {
373
+ return done(() => {
374
+ const evoService = evolutionService();
375
+ return evoService.getStats();
376
+ });
377
+ }
378
+ const evolutionTraceMatch = pathname.match(/^\/plugins\/principles\/api\/evolution\/trace\/([^/]+)$/);
379
+ if (evolutionTraceMatch && method === 'GET') {
380
+ const evoService = evolutionService();
381
+ try {
382
+ const trace = evoService.getTrace(decodeURIComponent(evolutionTraceMatch[1]));
383
+ if (!trace) {
384
+ json(res, 404, { error: 'not_found', message: 'Evolution trace not found.' });
385
+ return true;
386
+ }
387
+ json(res, 200, trace);
388
+ return true;
389
+ }
390
+ catch (error) {
391
+ api.logger.warn(`[PD:ControlUI] Evolution trace request failed for ${pathname}: ${String(error)}`);
392
+ json(res, 500, { error: 'internal_error', message: String(error) });
393
+ return true;
394
+ }
395
+ finally {
396
+ evoService.dispose();
397
+ }
398
+ }
188
399
  if (pathname === `${API_PREFIX}/export/corrections` && method === 'GET') {
189
400
  try {
190
401
  const mode = url.searchParams.get('mode') === 'redacted' ? 'redacted' : 'raw';
@@ -217,10 +428,93 @@ function handleApiRoute(api, pathname, req, res) {
217
428
  json(res, 404, { error: 'not_found', message: 'Unknown Principles Console API route.' });
218
429
  return true;
219
430
  }
431
+ function getGatewayToken() {
432
+ try {
433
+ const configPath = path.join(process.env.HOME || '', '.openclaw', 'openclaw.json');
434
+ if (!fs.existsSync(configPath))
435
+ return null;
436
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
437
+ return config?.gateway?.auth?.token || null;
438
+ }
439
+ catch {
440
+ return null;
441
+ }
442
+ }
443
+ function validateGatewayAuth(req) {
444
+ const gatewayToken = getGatewayToken();
445
+ if (!gatewayToken) {
446
+ // No token configured, allow all requests
447
+ return true;
448
+ }
449
+ const authHeader = req.headers?.['authorization'] || '';
450
+ const tokenMatch = authHeader.match(/^Bearer\s+(.+)$/i);
451
+ const providedToken = tokenMatch?.[1];
452
+ return providedToken === gatewayToken;
453
+ }
454
+ /**
455
+ * Create routes for Principles Console.
456
+ * Returns an array of routes:
457
+ * 1. Static files route (no auth required for HTML/CSS/JS)
458
+ * 2. API route (gateway auth required)
459
+ */
460
+ export function createPrinciplesConsoleRoutes(api) {
461
+ // Route 1: Static files (HTML, CSS, JS) - no auth check
462
+ const staticRoute = {
463
+ path: ROUTE_PREFIX,
464
+ auth: 'plugin',
465
+ match: 'prefix',
466
+ async handler(req, res) {
467
+ const url = new URL(req.url || ROUTE_PREFIX, 'http://127.0.0.1');
468
+ const pathname = url.pathname;
469
+ const method = (req.method || 'GET').toUpperCase();
470
+ // Skip API routes - they'll be handled by the API route
471
+ if (pathname.startsWith(API_PREFIX)) {
472
+ return false; // Let the API route handle this
473
+ }
474
+ // Serve assets
475
+ if (pathname.startsWith(ASSETS_PREFIX)) {
476
+ if (method !== 'GET' && method !== 'HEAD') {
477
+ text(res, 405, 'Method Not Allowed');
478
+ return true;
479
+ }
480
+ const assetPath = safeStaticPath(api.rootDir, pathname);
481
+ if (!assetPath || !serveFile(res, assetPath)) {
482
+ text(res, 404, 'Asset Not Found');
483
+ }
484
+ return true;
485
+ }
486
+ // Serve index.html for the main route
487
+ if (method !== 'GET' && method !== 'HEAD') {
488
+ text(res, 405, 'Method Not Allowed');
489
+ return true;
490
+ }
491
+ const indexPath = path.join(api.rootDir, 'dist', 'web', 'index.html');
492
+ if (!serveFile(res, indexPath)) {
493
+ text(res, 503, 'Principles Console UI is not built yet.');
494
+ }
495
+ return true;
496
+ },
497
+ };
498
+ // Route 2: API endpoints - gateway auth required
499
+ const apiRoute = {
500
+ path: API_PREFIX,
501
+ auth: 'gateway',
502
+ match: 'prefix',
503
+ async handler(req, res) {
504
+ const url = new URL(req.url || API_PREFIX, 'http://127.0.0.1');
505
+ const pathname = url.pathname;
506
+ return handleApiRoute(api, pathname, req, res);
507
+ },
508
+ };
509
+ return [staticRoute, apiRoute];
510
+ }
511
+ // Legacy export for backwards compatibility
220
512
  export function createPrinciplesConsoleRoute(api) {
513
+ const routes = createPrinciplesConsoleRoutes(api);
514
+ // Return the combined behavior - this will be called from index.ts
221
515
  return {
222
516
  path: ROUTE_PREFIX,
223
- auth: 'gateway',
517
+ auth: 'plugin',
224
518
  match: 'prefix',
225
519
  async handler(req, res) {
226
520
  const url = new URL(req.url || ROUTE_PREFIX, 'http://127.0.0.1');
@@ -229,9 +523,15 @@ export function createPrinciplesConsoleRoute(api) {
229
523
  if (!pathname.startsWith(ROUTE_PREFIX)) {
230
524
  return false;
231
525
  }
526
+ // For API routes, check auth manually
232
527
  if (pathname.startsWith(API_PREFIX)) {
528
+ if (!validateGatewayAuth(req)) {
529
+ json(res, 401, { error: 'unauthorized', message: 'Valid Gateway token required.' });
530
+ return true;
531
+ }
233
532
  return handleApiRoute(api, pathname, req, res);
234
533
  }
534
+ // Static files - no auth required
235
535
  if (pathname.startsWith(ASSETS_PREFIX)) {
236
536
  if (method !== 'GET' && method !== 'HEAD') {
237
537
  text(res, 405, 'Method Not Allowed');
package/dist/index.js CHANGED
@@ -26,7 +26,6 @@ import { ensureWorkspaceTemplates } from './core/init.js';
26
26
  import { migrateDirectoryStructure } from './core/migration.js';
27
27
  import { SystemLogger } from './core/system-logger.js';
28
28
  import { createDeepReflectTool } from './tools/deep-reflect.js';
29
- import { createAgentSpawnTool } from './tools/agent-spawn.js';
30
29
  import { PathResolver } from './core/path-resolver.js';
31
30
  import { createPrinciplesConsoleRoute } from './http/principles-console-route.js';
32
31
  // Track initialization to avoid repeated calls
@@ -476,7 +475,6 @@ const plugin = {
476
475
  }
477
476
  });
478
477
  api.registerTool(createDeepReflectTool(api));
479
- api.registerTool(createAgentSpawnTool(api));
480
478
  }
481
479
  };
482
480
  export default plugin;