@openbmb/clawxrouter 1.0.4

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,204 @@
1
+ {
2
+ "$schema": "https://docs.openclaw.ai/schema.json",
3
+ "plugins": {
4
+ "enabled": ["clawxrouter"]
5
+ },
6
+ "privacy": {
7
+ "enabled": true,
8
+ "s2Policy": "proxy",
9
+ "proxyPort": 8403,
10
+ "checkpoints": {
11
+ "onUserMessage": ["ruleDetector", "localModelDetector"],
12
+ "onToolCallProposed": ["ruleDetector"],
13
+ "onToolCallExecuted": ["ruleDetector", "localModelDetector"]
14
+ },
15
+ "rules": {
16
+ "keywords": {
17
+ "S2": [
18
+ "password", "api_key", "secret", "token", "credential", "auth_token",
19
+ "salary", "地址", "电话", "手机号", "合同", "客户", "甲方", "乙方",
20
+ "交易", "金额", "intranet", "域控"
21
+ ],
22
+ "S3": [
23
+ "ssh", "id_rsa", "private_key", ".pem", ".key", ".env", "master_password",
24
+ "身份证", "银行卡", "社保", "病历", "诊断", "处方", "密码", "密钥",
25
+ "简历", "resume"
26
+ ]
27
+ },
28
+ "patterns": {
29
+ "S2": [
30
+ "\\b(?:10|172\\.(?:1[6-9]|2\\d|3[01])|192\\.168)\\.\\d{1,3}\\.\\d{1,3}\\b",
31
+ "(?:mysql|postgres|mongodb|redis)://[^\\s]+",
32
+ "\\b(?:sk|key|token)-[A-Za-z0-9]{16,}\\b",
33
+ "1[3-9]\\d{9}",
34
+ "(?i)ghp_[a-zA-Z0-9]{36}",
35
+ "(?i)xox[bsrap]-[a-zA-Z0-9-]+",
36
+ "(?i)(?:contract|agreement)[-_]?\\w{6,}",
37
+ "(?i)¥[\\d,]+\\.?\\d*|\\$[\\d,]+\\.?\\d*",
38
+ "(?i)[a-z]+-(?:srv|dc|db|web|app)-\\d+",
39
+ "(?i)[a-z]+\\\\[a-z0-9._-]+"
40
+ ],
41
+ "S3": [
42
+ "-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
43
+ "AKIA[0-9A-Z]{16}",
44
+ "\\d{17}[0-9Xx]",
45
+ "\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}",
46
+ "(?i)(password|passwd|pwd)\\s*[=:]\\s*['\"][^'\"]{8,}"
47
+ ]
48
+ },
49
+ "tools": {
50
+ "S2": {
51
+ "tools": ["execute_sql"],
52
+ "paths": ["~/secrets", "~/private"]
53
+ },
54
+ "S3": {
55
+ "tools": ["sudo"],
56
+ "paths": ["~/.ssh", "~/.aws", "~/.config/credentials", "/root", "/credentials/"]
57
+ }
58
+ }
59
+ },
60
+
61
+ "_comment_localModel": "Edge provider examples — pick ONE of the configurations below",
62
+
63
+ "_example_ollama": {
64
+ "localModel": {
65
+ "enabled": true,
66
+ "type": "openai-compatible",
67
+ "provider": "ollama",
68
+ "model": "minicpm4.1",
69
+ "endpoint": "http://localhost:11434"
70
+ },
71
+ "guardAgent": { "model": "ollama/minicpm4.1" }
72
+ },
73
+
74
+ "_example_ollama_native": {
75
+ "localModel": {
76
+ "enabled": true,
77
+ "type": "ollama-native",
78
+ "provider": "ollama",
79
+ "model": "minicpm4.1",
80
+ "endpoint": "http://localhost:11434"
81
+ },
82
+ "guardAgent": { "model": "ollama/minicpm4.1" }
83
+ },
84
+
85
+ "_example_vllm": {
86
+ "localModel": {
87
+ "enabled": true,
88
+ "type": "openai-compatible",
89
+ "provider": "vllm",
90
+ "model": "openbmb/MiniCPM4.1",
91
+ "endpoint": "http://localhost:8000"
92
+ },
93
+ "guardAgent": { "model": "vllm/openbmb/MiniCPM4.1" }
94
+ },
95
+
96
+ "_example_lmstudio": {
97
+ "localModel": {
98
+ "enabled": true,
99
+ "type": "openai-compatible",
100
+ "provider": "lmstudio",
101
+ "model": "openbmb/MiniCPM4.1-GGUF",
102
+ "endpoint": "http://localhost:1234"
103
+ },
104
+ "guardAgent": { "model": "lmstudio/openbmb/MiniCPM4.1-GGUF" }
105
+ },
106
+
107
+ "_example_sglang": {
108
+ "localModel": {
109
+ "enabled": true,
110
+ "type": "openai-compatible",
111
+ "provider": "sglang",
112
+ "model": "openbmb/MiniCPM4.1",
113
+ "endpoint": "http://localhost:30000"
114
+ },
115
+ "guardAgent": { "model": "sglang/openbmb/MiniCPM4.1" }
116
+ },
117
+
118
+ "_example_custom": {
119
+ "localModel": {
120
+ "enabled": true,
121
+ "type": "custom",
122
+ "provider": "my-inference",
123
+ "model": "my-model",
124
+ "endpoint": "http://localhost:9999",
125
+ "module": "./my-edge-provider.js"
126
+ },
127
+ "localProviders": ["my-inference"],
128
+ "guardAgent": { "model": "my-inference/my-model" }
129
+ },
130
+
131
+ "localModel": {
132
+ "enabled": true,
133
+ "type": "openai-compatible",
134
+ "provider": "ollama",
135
+ "model": "minicpm4.1",
136
+ "endpoint": "http://localhost:11434"
137
+ },
138
+ "guardAgent": {
139
+ "id": "guard",
140
+ "workspace": "~/.openclaw/workspace-guard",
141
+ "model": "ollama/minicpm4.1"
142
+ },
143
+ "routers": {
144
+ "privacy": { "enabled": true, "type": "builtin", "weight": 90 },
145
+ "token-saver": {
146
+ "enabled": true,
147
+ "type": "builtin",
148
+ "options": {
149
+ "judgeEndpoint": "https://openrouter.ai/api/v1",
150
+ "judgeModel": "gemini-2.5-flash",
151
+ "judgeProviderType": "openai-compatible",
152
+ "defaultTier": "MEDIUM",
153
+ "tiers": {
154
+ "SIMPLE": {
155
+ "provider": "openrouter",
156
+ "model": "glm-4.5-air",
157
+ "description": "lookup, greeting, yes/no, factual questions with short answers, confirming readiness, reading a short file to answer ONE factual question"
158
+ },
159
+ "MEDIUM": {
160
+ "provider": "openrouter",
161
+ "model": "minimax-m2.5",
162
+ "description": "moderate writing (email, blog, letter), text rewriting or humanizing, using a skill on text, CSV/spreadsheet/Excel data analysis, search-and-replace across config files, summarizing a plain-text file"
163
+ },
164
+ "COMPLEX": {
165
+ "provider": "openrouter",
166
+ "model": "deepseek-v3.2",
167
+ "description": "code generation, file and project structure creation, multi-step workflows, email triage or classification across multiple messages, email search and summarization, competitive/market research, multi-file refactoring, creating calendar events or ICS files"
168
+ },
169
+ "RESEARCH": {
170
+ "provider": "openrouter",
171
+ "model": "glm-5",
172
+ "description": "tasks requiring web search or finding real-time information: stock prices, upcoming events or conferences, live market data, current news"
173
+ },
174
+ "REASONING": {
175
+ "provider": "openrouter",
176
+ "model": "kimi-k2.5",
177
+ "description": "reading a PDF or long document then summarizing or explaining it, answering questions about a PDF or report, math proof, formal logic, structured information extraction from lengthy documents"
178
+ }
179
+ },
180
+ "rules": [
181
+ "Writing a blog post, email, or letter from scratch → MEDIUM.",
182
+ "CSV/Excel data processing and summarization → MEDIUM.",
183
+ "Creating Python scripts, project structures, or ICS files → COMPLEX.",
184
+ "Reading multiple emails/files and classifying them → COMPLEX.",
185
+ "Searching the web for real-world data (stock, events) → RESEARCH.",
186
+ "Reading a PDF then summarizing or answering questions → REASONING."
187
+ ]
188
+ }
189
+ }
190
+ },
191
+ "localProviders": [],
192
+ "session": {
193
+ "isolateGuardHistory": true,
194
+ "baseDir": "~/.openclaw",
195
+ "injectDualHistory": true,
196
+ "historyLimit": 20
197
+ },
198
+ "pipeline": {
199
+ "onUserMessage": ["token-saver"],
200
+ "onToolCallProposed": [],
201
+ "onToolCallExecuted": ["privacy"]
202
+ }
203
+ }
204
+ }
package/index.ts ADDED
@@ -0,0 +1,398 @@
1
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
3
+ import { join } from "node:path";
4
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { clawXrouterConfigSchema, defaultPrivacyConfig } from "./src/config-schema.js";
6
+ import { registerHooks } from "./src/hooks.js";
7
+ import { clawXrouterPrivacyProvider, setActiveProxy, mirrorAllProviderModels, collectTierModelIds, ensureModelMirrored } from "./src/provider.js";
8
+ import { startPrivacyProxy, setDefaultProviderTarget, registerModelTarget } from "./src/privacy-proxy.js";
9
+ import { RouterPipeline, setGlobalPipeline } from "./src/router-pipeline.js";
10
+ import { privacyRouter } from "./src/routers/privacy.js";
11
+ import { tokenSaverRouter } from "./src/routers/token-saver.js";
12
+ import { TokenStatsCollector, setGlobalCollector } from "./src/token-stats.js";
13
+ import { initLiveConfig, watchConfigFile } from "./src/live-config.js";
14
+ import { initDashboard, statsHttpHandler } from "./src/stats-dashboard.js";
15
+ import type { PrivacyConfig, PipelineConfig, RouterRegistration } from "./src/types.js";
16
+ import type { ProxyHandle } from "./src/privacy-proxy.js";
17
+ import { resolveDefaultBaseUrl } from "./src/utils.js";
18
+
19
+ const OPENCLAW_DIR = join(process.env.HOME ?? "/tmp", ".openclaw");
20
+ const CLAWXROUTER_CONFIG_PATH = join(OPENCLAW_DIR, "clawxrouter.json");
21
+ const LEGACY_DASHBOARD_PATH = join(OPENCLAW_DIR, "clawxrouter-dashboard.json");
22
+
23
+ function loadClawXrouterConfigFile(): Record<string, unknown> | null {
24
+ try {
25
+ return JSON.parse(readFileSync(CLAWXROUTER_CONFIG_PATH, "utf-8")) as Record<string, unknown>;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function loadLegacyDashboardOverrides(): Record<string, unknown> | null {
32
+ try {
33
+ return JSON.parse(readFileSync(LEGACY_DASHBOARD_PATH, "utf-8")) as Record<string, unknown>;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function writeClawXrouterConfigFile(config: Record<string, unknown>): void {
40
+ try {
41
+ mkdirSync(OPENCLAW_DIR, { recursive: true });
42
+ writeFileSync(CLAWXROUTER_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
43
+ } catch { /* best-effort */ }
44
+ }
45
+
46
+ function getPrivacyConfig(pluginConfig: Record<string, unknown> | undefined): PrivacyConfig {
47
+ const userConfig = (pluginConfig?.privacy ?? {}) as PrivacyConfig;
48
+ return { ...defaultPrivacyConfig, ...userConfig } as PrivacyConfig;
49
+ }
50
+
51
+ /**
52
+ * Determine the API type to register for the clawxrouter-privacy provider.
53
+ *
54
+ * The proxy is a transparent HTTP relay, so we need the SDK to send requests
55
+ * in a format that both the proxy can parse and the downstream provider accepts.
56
+ *
57
+ * - For Google-native APIs: use "openai-completions" since most Google gateways
58
+ * accept OpenAI format, and Google's native SDK may bypass the HTTP proxy.
59
+ * - For Anthropic: use "anthropic-messages" so the SDK sends the right format
60
+ * and auth scheme. The proxy handles forwarding transparently.
61
+ * - For everything else: use the original API type (usually "openai-completions").
62
+ */
63
+ function resolveProxyApi(originalApi: string): string {
64
+ const api = originalApi.toLowerCase();
65
+ // Google native SDKs construct their own URLs and may bypass the HTTP proxy;
66
+ // fall back to openai-completions which Google gateways typically accept.
67
+ if (api.includes("google") || api.includes("gemini")) {
68
+ return "openai-completions";
69
+ }
70
+ // Anthropic's native API is proxy-friendly (standard HTTP POST to /v1/messages)
71
+ if (api === "anthropic-messages") {
72
+ return "anthropic-messages";
73
+ }
74
+ return originalApi;
75
+ }
76
+
77
+ export default definePluginEntry({
78
+ id: "ClawXRouter",
79
+ name: "ClawXRouter",
80
+ description: "Privacy-aware plugin with extensible router pipeline, guard agent, and built-in privacy proxy",
81
+ configSchema: clawXrouterConfigSchema,
82
+
83
+ register(api: OpenClawPluginApi) {
84
+ // ── Resolve config: clawxrouter.json > (openclaw.json + legacy overrides) ──
85
+ let resolvedPluginConfig: Record<string, unknown>;
86
+ const fileConfig = loadClawXrouterConfigFile();
87
+ if (fileConfig) {
88
+ resolvedPluginConfig = fileConfig;
89
+ api.logger.info("[ClawXrouter] Config loaded from clawxrouter.json");
90
+ } else {
91
+ // First run: generate clawxrouter.json from openclaw.json plugin config + defaults
92
+ const userPrivacy = ((api.pluginConfig ?? {}) as Record<string, unknown>).privacy as Record<string, unknown> | undefined;
93
+ const legacyOverrides = loadLegacyDashboardOverrides();
94
+ const mergedPrivacy = {
95
+ ...defaultPrivacyConfig,
96
+ ...(userPrivacy ?? {}),
97
+ ...(legacyOverrides ?? {}),
98
+ };
99
+ if (legacyOverrides) {
100
+ api.logger.info("[ClawXrouter] Migrated legacy clawxrouter-dashboard.json overrides");
101
+ }
102
+ resolvedPluginConfig = { privacy: mergedPrivacy };
103
+ writeClawXrouterConfigFile(resolvedPluginConfig);
104
+ api.logger.info("[ClawXrouter] Generated clawxrouter.json with full defaults");
105
+ }
106
+
107
+ const privacyConfig = getPrivacyConfig(resolvedPluginConfig);
108
+
109
+ if (privacyConfig.enabled === false) {
110
+ api.logger.info("[ClawXrouter] Plugin disabled via config");
111
+ return;
112
+ }
113
+
114
+ api.registerProvider(clawXrouterPrivacyProvider as Parameters<typeof api.registerProvider>[0]);
115
+
116
+ const proxyPort = privacyConfig.proxyPort ?? 8403;
117
+ if (!api.config.models) {
118
+ (api.config as Record<string, unknown>).models = { providers: {} };
119
+ }
120
+ const models = api.config.models as { providers?: Record<string, unknown> };
121
+ if (!models.providers) models.providers = {};
122
+
123
+ // Detect the default provider's API type so the proxy can adapt
124
+ const agentDefaults = (api.config.agents as Record<string, unknown> | undefined)?.defaults as Record<string, unknown> | undefined;
125
+ const primaryModelStr = (agentDefaults?.model as Record<string, unknown> | undefined)?.primary as string ?? "";
126
+ const defaultProvider = (agentDefaults?.provider as string) || primaryModelStr.split("/")[0] || "openai";
127
+ const providerConfig = models.providers?.[defaultProvider] as Record<string, unknown> | undefined;
128
+ const originalApi = (providerConfig?.api as string) ?? "openai-completions";
129
+
130
+ // Use openai-completions for the proxy provider: the proxy acts as a transparent
131
+ // HTTP relay and most providers (including Google gateways) accept OpenAI format.
132
+ // For Anthropic-native, we match the API so the SDK sends the right format.
133
+ const proxyApi = resolveProxyApi(originalApi);
134
+
135
+ // Phase 1a: mirror all models explicitly listed in provider configs
136
+ const mirroredModels = mirrorAllProviderModels(
137
+ api.config as { models?: { providers?: Record<string, { models?: unknown }> } },
138
+ );
139
+
140
+ // Phase 1b: also pre-register models referenced by router tier configs
141
+ // (e.g. token-saver tiers) that may not appear in any provider's models list
142
+ const tierModels = collectTierModelIds(resolvedPluginConfig);
143
+ const mirroredIds = new Set(mirroredModels.map((m) => (m as Record<string, unknown>).id));
144
+ for (const { provider: tierProv, modelId: tierModel } of tierModels) {
145
+ if (mirroredIds.has(tierModel)) continue;
146
+ const tierProvConfig = models.providers?.[tierProv] as Record<string, unknown> | undefined;
147
+ const tierProvModels = tierProvConfig?.models;
148
+ let entry: Record<string, unknown> | undefined;
149
+ if (Array.isArray(tierProvModels)) {
150
+ const found = tierProvModels.find((m: unknown) => (m as Record<string, unknown>).id === tierModel);
151
+ if (found) entry = { ...(found as Record<string, unknown>) };
152
+ }
153
+ if (!entry) {
154
+ const firstModel = Array.isArray(tierProvModels) && tierProvModels.length > 0
155
+ ? tierProvModels[0] as Record<string, unknown>
156
+ : null;
157
+ entry = {
158
+ id: tierModel,
159
+ name: tierModel,
160
+ ...(firstModel?.contextWindow != null ? { contextWindow: firstModel.contextWindow } : {}),
161
+ ...(firstModel?.maxTokens != null ? { maxTokens: firstModel.maxTokens } : {}),
162
+ };
163
+ }
164
+ mirroredModels.push(entry);
165
+ mirroredIds.add(tierModel);
166
+ if (tierProvConfig) {
167
+ registerModelTarget(tierModel, {
168
+ baseUrl: (tierProvConfig.baseUrl as string) ?? resolveDefaultBaseUrl(tierProv, tierProvConfig.api as string | undefined),
169
+ apiKey: (tierProvConfig.apiKey as string) ?? "",
170
+ provider: tierProv,
171
+ api: tierProvConfig.api as string | undefined,
172
+ });
173
+ }
174
+ }
175
+
176
+ const privacyProviderEntry = {
177
+ baseUrl: `http://127.0.0.1:${proxyPort}/v1`,
178
+ api: proxyApi,
179
+ apiKey: "clawxrouter-proxy-handles-auth",
180
+ models: mirroredModels,
181
+ };
182
+ models.providers["clawxrouter-privacy"] = privacyProviderEntry;
183
+
184
+ // Patch the runtime config snapshot (structuredClone of api.config) so
185
+ // that model resolution inside the embedded agent runner can find the
186
+ // clawxrouter-privacy virtual provider.
187
+ const runtimeLoadConfig = (): Record<string, unknown> | undefined => {
188
+ try { return api.runtime.config.loadConfig(); } catch { return undefined; }
189
+ };
190
+ try {
191
+ const runtimeCfg = runtimeLoadConfig();
192
+ if (runtimeCfg && runtimeCfg !== api.config) {
193
+ if (!runtimeCfg.models) {
194
+ (runtimeCfg as Record<string, unknown>).models = { providers: {} };
195
+ }
196
+ const rtModels = runtimeCfg.models as { providers?: Record<string, unknown> };
197
+ if (!rtModels.providers) rtModels.providers = {};
198
+ rtModels.providers["clawxrouter-privacy"] = privacyProviderEntry;
199
+ }
200
+ } catch {
201
+ // Non-fatal: runtime config patching is best-effort
202
+ }
203
+
204
+ // Propagate thinking + streaming defaults for all mirrored models.
205
+ // Uses ensureModelMirrored's internal propagateThinkingForModel for
206
+ // reasoning models; streaming propagation is handled separately below.
207
+ for (const m of mirroredModels as Array<Record<string, unknown>>) {
208
+ if (m.reasoning === true && typeof m.id === "string") {
209
+ ensureModelMirrored(
210
+ api.config as Record<string, unknown>,
211
+ m.id as string,
212
+ defaultProvider,
213
+ runtimeLoadConfig,
214
+ );
215
+ }
216
+ }
217
+
218
+ // Propagate streaming=false for models that have it set in agent defaults
219
+ const existingModelsOverrides = (agentDefaults?.models as Record<string, Record<string, unknown>> | undefined) ?? {};
220
+ for (const [key, override] of Object.entries(existingModelsOverrides)) {
221
+ if (override?.streaming === false) {
222
+ const modelId = key.includes("/") ? key.split("/").slice(1).join("/") : key;
223
+ const proxyKey = `clawxrouter-privacy/${modelId}`;
224
+ if (!existingModelsOverrides[proxyKey]) {
225
+ existingModelsOverrides[proxyKey] = { streaming: false };
226
+ }
227
+ }
228
+ }
229
+ try {
230
+ const runtimeCfg = runtimeLoadConfig();
231
+ if (runtimeCfg) {
232
+ const rtAgents = (runtimeCfg as Record<string, unknown>).agents as Record<string, unknown> | undefined;
233
+ const rtDefaults = rtAgents?.defaults as Record<string, unknown> | undefined;
234
+ if (rtDefaults) {
235
+ const rtModelsOverrides = (rtDefaults.models ?? {}) as Record<string, Record<string, unknown>>;
236
+ for (const [key, override] of Object.entries(existingModelsOverrides)) {
237
+ if (key.startsWith("clawxrouter-privacy/")) {
238
+ rtModelsOverrides[key] = override;
239
+ }
240
+ }
241
+ rtDefaults.models = rtModelsOverrides;
242
+ }
243
+ }
244
+ } catch {
245
+ // Non-fatal
246
+ }
247
+
248
+ // Set default provider target for the proxy
249
+ if (providerConfig) {
250
+ const defaultBaseUrl = resolveDefaultBaseUrl(defaultProvider, originalApi);
251
+ const modelsOverrides = (agentDefaults?.models as Record<string, Record<string, unknown>> | undefined) ?? {};
252
+ const modelStreamingPref = modelsOverrides[primaryModelStr]?.streaming;
253
+ setDefaultProviderTarget({
254
+ baseUrl: (providerConfig.baseUrl as string) ?? defaultBaseUrl,
255
+ apiKey: (providerConfig.apiKey as string) ?? "",
256
+ provider: defaultProvider,
257
+ api: originalApi,
258
+ ...(modelStreamingPref === false ? { streaming: false } : {}),
259
+ });
260
+ }
261
+
262
+ api.logger.info(`[ClawXrouter] Privacy provider registered (proxy port: ${proxyPort})`);
263
+
264
+ const patchExtraPaths = (cfg: Record<string, unknown>) => {
265
+ const agts = (cfg.agents ?? {}) as Record<string, unknown>;
266
+ const defs = (agts.defaults ?? {}) as Record<string, unknown>;
267
+ const ms = (defs.memorySearch ?? {}) as Record<string, unknown>;
268
+ const existing = (ms.extraPaths ?? []) as string[];
269
+ const requiredPaths = ["MEMORY-FULL.md", "memory-full"];
270
+ const missing = requiredPaths.filter((p) => !existing.includes(p));
271
+ if (missing.length === 0) return false;
272
+ const updated = [...existing, ...missing];
273
+ if (!cfg.agents) cfg.agents = { defaults: {} };
274
+ const a = cfg.agents as Record<string, unknown>;
275
+ if (!a.defaults) a.defaults = {};
276
+ const d = a.defaults as Record<string, unknown>;
277
+ if (!d.memorySearch) d.memorySearch = {};
278
+ (d.memorySearch as Record<string, unknown>).extraPaths = updated;
279
+ return true;
280
+ };
281
+ if (patchExtraPaths(api.config as Record<string, unknown>)) {
282
+ api.logger.info(`[ClawXrouter] Added to memorySearch.extraPaths: MEMORY-FULL.md, memory-full`);
283
+ }
284
+ try {
285
+ const runtimeCfg = api.runtime.config.loadConfig();
286
+ if (runtimeCfg && runtimeCfg !== api.config) {
287
+ patchExtraPaths(runtimeCfg as Record<string, unknown>);
288
+ }
289
+ } catch { /* best-effort */ }
290
+
291
+ let proxyHandle: ProxyHandle | null = null;
292
+ api.registerService({
293
+ id: "clawxrouter-proxy",
294
+ start: async () => {
295
+ try {
296
+ proxyHandle = await startPrivacyProxy(proxyPort, api.logger);
297
+ setActiveProxy(proxyHandle);
298
+ api.logger.info(`[ClawXrouter] Privacy proxy started on port ${proxyPort}`);
299
+ } catch (err) {
300
+ api.logger.error(`[ClawXrouter] Failed to start privacy proxy: ${String(err)}`);
301
+ }
302
+ },
303
+ stop: async () => {
304
+ if (proxyHandle) {
305
+ try {
306
+ await proxyHandle.close();
307
+ api.logger.info("[ClawXrouter] Privacy proxy stopped");
308
+ } catch (err) {
309
+ api.logger.warn(`[ClawXrouter] Failed to close proxy: ${String(err)}`);
310
+ }
311
+ }
312
+ },
313
+ });
314
+
315
+ const pipeline = new RouterPipeline(api.logger);
316
+
317
+ // Register built-in routers
318
+ const routerConfigs = (privacyConfig as Record<string, unknown>).routers as Record<string, RouterRegistration> | undefined;
319
+ pipeline.register(privacyRouter, routerConfigs?.privacy ?? { enabled: true, type: "builtin" });
320
+ pipeline.register(tokenSaverRouter, routerConfigs?.["token-saver"] ?? { enabled: false, type: "builtin" });
321
+
322
+ // Configure pipeline from user config
323
+ pipeline.configure({
324
+ routers: routerConfigs,
325
+ pipeline: (privacyConfig as Record<string, unknown>).pipeline as PipelineConfig | undefined,
326
+ });
327
+
328
+ // Load custom routers (async, non-blocking)
329
+ pipeline.loadCustomRouters().then(() => {
330
+ const routers = pipeline.listRouters();
331
+ if (routers.length > 1) {
332
+ api.logger.info(`[ClawXrouter] Pipeline routers: ${routers.join(", ")}`);
333
+ }
334
+ }).catch((err) => {
335
+ api.logger.error(`[ClawXrouter] Failed to load custom routers: ${String(err)}`);
336
+ });
337
+
338
+ setGlobalPipeline(pipeline);
339
+ api.logger.info(`[ClawXrouter] Router pipeline initialized (built-in: privacy)`);
340
+
341
+ initLiveConfig(resolvedPluginConfig);
342
+ watchConfigFile(CLAWXROUTER_CONFIG_PATH, api.logger);
343
+
344
+ const statsPath = join(process.env.HOME ?? "/tmp", ".openclaw", "clawxrouter-stats.json");
345
+ const collector = new TokenStatsCollector(statsPath);
346
+ setGlobalCollector(collector);
347
+ collector.load().then(() => {
348
+ collector.startAutoFlush();
349
+ api.logger.info(`[ClawXrouter] Token stats initialized (${statsPath})`);
350
+ }).catch((err) => {
351
+ api.logger.error(`[ClawXrouter] Failed to load token stats: ${String(err)}`);
352
+ });
353
+
354
+ initDashboard({
355
+ pluginId: "clawxrouter",
356
+ pluginConfig: resolvedPluginConfig,
357
+ pipeline,
358
+ });
359
+
360
+ api.registerHttpRoute({
361
+ path: "/plugins/clawxrouter/stats",
362
+ auth: "plugin",
363
+ match: "prefix",
364
+ handler: async (req, res) => {
365
+ const handled = await statsHttpHandler(req, res);
366
+ if (!handled) {
367
+ res.writeHead(404);
368
+ res.end("Not Found");
369
+ }
370
+ },
371
+ });
372
+
373
+ api.logger.info("[ClawXrouter] Dashboard registered at /plugins/clawxrouter/stats");
374
+
375
+ registerHooks(api);
376
+
377
+ api.logger.info("[ClawXrouter] Plugin initialized (pipeline + privacy proxy + guard agent + dashboard)");
378
+
379
+ const c = "\x1b[36m", g = "\x1b[32m", y = "\x1b[33m", b = "\x1b[1m", d = "\x1b[2m", r = "\x1b[0m", bg = "\x1b[46m\x1b[30m";
380
+ const W = 70;
381
+ const bar = "═".repeat(W);
382
+ const pad = (colored: string, visLen: number) => {
383
+ const sp = " ".repeat(Math.max(0, W - visLen));
384
+ return `${c} ║${r}${colored}${sp}${c}║${r}`;
385
+ };
386
+
387
+ api.logger.info("");
388
+ api.logger.info(`${c} ╔${bar}╗${r}`);
389
+ api.logger.info(pad(` ${bg}${b} 🛡️ ClawXrouter ${r}${g}${b} Ready!${r}`, 25));
390
+ api.logger.info(pad("", 0));
391
+ api.logger.info(pad(` ${y}Dashboard${r} ${d}→${r} ${b}http://127.0.0.1:18789/plugins/clawxrouter/stats${r}`, 62));
392
+ api.logger.info(pad(` ${y}Config${r} ${d}→${r} ${b}~/.openclaw/clawxrouter.json${r}`, 40));
393
+ api.logger.info(pad("", 0));
394
+ api.logger.info(pad(` ${d}Use the Dashboard to configure routers, rules & prompts.${r}`, 58));
395
+ api.logger.info(`${c} ╚${bar}╝${r}`);
396
+ api.logger.info("");
397
+ },
398
+ });