clawmatrix 0.1.13 → 0.1.15

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/BOOTSTRAP.md CHANGED
@@ -122,7 +122,8 @@ openclaw clawmatrix status
122
122
  ],
123
123
  "models": [],
124
124
  "proxyModels": [
125
- { "id": "claude-sonnet", "nodeId": "office-01" }
125
+ { "id": "gpt-5", "nodeId": "office-01", "description": "GPT-5", "input": ["text", "image"], "contextWindow": 400000, "maxTokens": 128000 },
126
+ { "id": "gemini-2.5-pro", "nodeId": "office-01", "description": "Gemini 2.5 Pro", "input": ["text", "image"], "contextWindow": 1048576, "maxTokens": 65535 }
126
127
  ],
127
128
  "tags": ["home"]
128
129
  }
@@ -165,9 +166,40 @@ openclaw clawmatrix status
165
166
  | `toolProxy.deny` | array | `[]` | 禁止的工具名(优先于 allow) |
166
167
  | `toolProxy.maxOutputBytes` | number | `1048576` | 单次响应最大字节数 |
167
168
 
168
- ## 安装后 Agent 获得的工具
169
+ ## 启用集群工具
169
170
 
170
- 安装后,本节点的 Agent 自动获得 7 个集群工具:
171
+ ClawMatrix Agent 工具注册为可选工具(optional),需要在 OpenClaw 配置中显式启用。
172
+
173
+ 在 `openclaw.json` 中添加:
174
+
175
+ ```json
176
+ {
177
+ "tools": {
178
+ "allow": ["clawmatrix"]
179
+ }
180
+ }
181
+ ```
182
+
183
+ 或按 Agent 粒度启用:
184
+
185
+ ```json
186
+ {
187
+ "agents": {
188
+ "list": [
189
+ {
190
+ "id": "main",
191
+ "tools": {
192
+ "allow": ["clawmatrix"]
193
+ }
194
+ }
195
+ ]
196
+ }
197
+ }
198
+ ```
199
+
200
+ > 如果不配置,Agent 的系统提示中会提到集群工具,但实际无法调用。
201
+
202
+ 启用后,本节点的 Agent 获得 7 个集群工具:
171
203
 
172
204
  | 工具 | 用途 | 关键参数 |
173
205
  |------|------|----------|
@@ -199,13 +231,13 @@ openclaw clawmatrix status
199
231
  "api": "openai-completions",
200
232
  "models": [
201
233
  {
202
- "id": "<模型ID>",
203
- "name": "<显示名>",
234
+ "id": "gpt-5",
235
+ "name": "GPT-5",
204
236
  "reasoning": false,
205
- "input": ["text"],
237
+ "input": ["text", "image"],
206
238
  "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
207
- "contextWindow": 200000,
208
- "maxTokens": 32000
239
+ "contextWindow": 400000,
240
+ "maxTokens": 128000
209
241
  }
210
242
  ]
211
243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Decentralized mesh cluster plugin for OpenClaw — inter-gateway communication, model proxy, task handoff, and tool proxy.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,14 +33,16 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "ws": "^8.19.0",
36
- "zod": "^4.3.6"
36
+ "zod": "^4.3.6",
37
+ "@mariozechner/pi-coding-agent": ">=0.55.0"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@types/bun": "latest",
40
- "@types/ws": "^8.18.1",
41
- "openclaw": "^2026.3.2"
41
+ "@types/ws": "^8.18.1"
42
42
  },
43
43
  "peerDependencies": {
44
+ "openclaw": ">=2026.3.7",
44
45
  "typescript": "^5"
45
- }
46
+ },
47
+ "overrides": {}
46
48
  }
@@ -46,11 +46,11 @@ export class ClusterRuntime {
46
46
  readonly toolProxy: ToolProxy;
47
47
  private logger: PluginLogger;
48
48
 
49
- constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig) {
49
+ constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig, openclawVersion?: string) {
50
50
  this.config = config;
51
51
  this.logger = logger;
52
52
  const gatewayInfo = resolveGatewayInfo(openclawConfig);
53
- this.peerManager = new PeerManager(config);
53
+ this.peerManager = new PeerManager(config, openclawVersion);
54
54
  this.handoffManager = new HandoffManager(config, this.peerManager);
55
55
  this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo, openclawConfig);
56
56
  this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo);
@@ -72,7 +72,7 @@ export class ClusterRuntime {
72
72
 
73
73
  // Web dashboard (must be set before peerManager.start() creates the HTTP server)
74
74
  if (this.config.web?.enabled) {
75
- const webHandler = new WebHandler(this.config, this.peerManager);
75
+ const webHandler = new WebHandler(this.config, this.peerManager, this.handoffManager);
76
76
  this.peerManager.setHttpHandler((req, res) => webHandler.handle(req, res));
77
77
  this.logger.info(`[clawmatrix] Web dashboard enabled on listen port`);
78
78
  }
@@ -178,11 +178,12 @@ export function getClusterRuntime(): ClusterRuntime {
178
178
  export function createClusterService(
179
179
  config: ClawMatrixConfig,
180
180
  openclawConfig: OpenClawConfig,
181
+ openclawVersion?: string,
181
182
  ): OpenClawPluginService {
182
183
  return {
183
184
  id: "clawmatrix",
184
185
  start(ctx: OpenClawPluginServiceContext) {
185
- clusterRuntime = new ClusterRuntime(config, ctx.logger, openclawConfig);
186
+ clusterRuntime = new ClusterRuntime(config, ctx.logger, openclawConfig, openclawVersion);
186
187
  clusterRuntime.start();
187
188
  },
188
189
  async stop() {
package/src/config.ts CHANGED
@@ -61,19 +61,28 @@ const ToolProxyConfigSchema = z.object({
61
61
  maxOutputBytes: z.number().default(1_048_576),
62
62
  });
63
63
 
64
- const ProxyModelSchema = z.object({
64
+ // Per-model entry inside a proxy group (minimal — inherits group-level fields)
65
+ const ProxyModelEntrySchema = z.object({
65
66
  id: z.string(),
66
- nodeId: z.string().optional(),
67
67
  description: z.string().optional(),
68
68
  ...ModelParamsSchema,
69
69
  });
70
70
 
71
+ // Grouped proxy models: shared nodeId/api/provider, list of models
72
+ const ProxyModelGroupSchema = z.object({
73
+ nodeId: z.string(),
74
+ provider: z.string().optional(),
75
+ description: z.string().optional(),
76
+ ...ModelParamsSchema,
77
+ models: z.array(ProxyModelEntrySchema),
78
+ });
79
+
71
80
  const WebConfigSchema = z.object({
72
81
  enabled: z.boolean().default(false),
73
82
  token: z.string().min(8, "web token must be at least 8 characters"),
74
83
  }).optional();
75
84
 
76
- export const ClawMatrixConfigSchema = z.object({
85
+ const RawClawMatrixConfigSchema = z.object({
77
86
  nodeId: z.string(),
78
87
  secret: z.string().min(16, "secret must be at least 16 characters"),
79
88
  listen: z.boolean().default(false),
@@ -82,7 +91,7 @@ export const ClawMatrixConfigSchema = z.object({
82
91
  peers: z.array(PeerConfigSchema).default([]),
83
92
  agents: z.array(AgentInfoSchema).default([]),
84
93
  models: z.array(ModelInfoSchema).default([]),
85
- proxyModels: z.array(ProxyModelSchema).default([]),
94
+ proxyModels: z.array(ProxyModelGroupSchema).default([]),
86
95
  tags: z.array(z.string()).default([]),
87
96
  proxyPort: z.number().default(19001),
88
97
  toolProxy: ToolProxyConfigSchema.optional(),
@@ -90,10 +99,52 @@ export const ClawMatrixConfigSchema = z.object({
90
99
  web: WebConfigSchema,
91
100
  });
92
101
 
93
- export type ClawMatrixConfig = z.infer<typeof ClawMatrixConfigSchema>;
102
+ /** Flat proxy model after group expansion (used internally). */
103
+ export interface ProxyModel {
104
+ id: string;
105
+ nodeId: string;
106
+ provider?: string;
107
+ description?: string;
108
+ api?: string;
109
+ contextWindow?: number;
110
+ maxTokens?: number;
111
+ reasoning?: boolean;
112
+ input?: ("text" | "image")[];
113
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
114
+ compat?: z.infer<typeof ModelCompatSchema>;
115
+ }
116
+
117
+ export type ClawMatrixConfig = Omit<z.infer<typeof RawClawMatrixConfigSchema>, "proxyModels"> & {
118
+ proxyModels: ProxyModel[];
119
+ };
120
+
121
+ export { RawClawMatrixConfigSchema as ClawMatrixConfigSchema };
94
122
  export type PeerConfig = z.infer<typeof PeerConfigSchema>;
95
123
  export type ToolProxyConfig = z.infer<typeof ToolProxyConfigSchema>;
96
124
 
125
+ /** Parse and flatten grouped proxyModels into flat array. */
97
126
  export function parseConfig(raw: unknown): ClawMatrixConfig {
98
- return ClawMatrixConfigSchema.parse(raw);
127
+ const parsed = RawClawMatrixConfigSchema.parse(raw);
128
+
129
+ // Flatten proxy model groups
130
+ const proxyModels: ProxyModel[] = [];
131
+ for (const group of parsed.proxyModels) {
132
+ for (const m of group.models) {
133
+ proxyModels.push({
134
+ id: m.id,
135
+ nodeId: group.nodeId,
136
+ provider: group.provider,
137
+ description: m.description ?? group.description,
138
+ api: m.api ?? group.api,
139
+ contextWindow: m.contextWindow ?? group.contextWindow,
140
+ maxTokens: m.maxTokens ?? group.maxTokens,
141
+ reasoning: m.reasoning ?? group.reasoning,
142
+ input: m.input ?? group.input,
143
+ cost: m.cost ?? group.cost,
144
+ compat: m.compat ?? group.compat,
145
+ });
146
+ }
147
+ }
148
+
149
+ return { ...parsed, proxyModels };
99
150
  }
package/src/connection.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  AuthOk,
7
7
  AuthFail,
8
8
  ClusterFrame,
9
+ DeviceInfo,
9
10
  ModelInfo,
10
11
  NodeCapabilities,
11
12
  } from "./types.ts";
@@ -28,6 +29,7 @@ export interface WsTransport {
28
29
  export interface ConnectionEvents {
29
30
  message: [frame: AnyClusterFrame];
30
31
  authenticated: [capabilities: NodeCapabilities];
32
+ latency: [ms: number];
31
33
  close: [code: number, reason: string];
32
34
  error: [error: Error];
33
35
  }
@@ -48,6 +50,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
48
50
  private missedPongs = 0;
49
51
  private pendingNonce: string | null = null;
50
52
  private closed = false;
53
+ private lastPingSentAt = 0;
54
+ /** Exponential moving average of heartbeat RTT in milliseconds. */
55
+ latencyMs = 0;
51
56
 
52
57
  constructor(
53
58
  transport: WsTransport,
@@ -142,6 +147,12 @@ export class Connection extends EventEmitter<ConnectionEvents> {
142
147
  }
143
148
  if (frame.type === "pong") {
144
149
  this.missedPongs = 0;
150
+ if (this.lastPingSentAt > 0) {
151
+ const rtt = Date.now() - this.lastPingSentAt;
152
+ // Exponential moving average (α = 0.3)
153
+ this.latencyMs = this.latencyMs === 0 ? rtt : Math.round(this.latencyMs * 0.7 + rtt * 0.3);
154
+ this.emit("latency", this.latencyMs);
155
+ }
145
156
  return;
146
157
  }
147
158
 
@@ -151,12 +162,13 @@ export class Connection extends EventEmitter<ConnectionEvents> {
151
162
  private async handleAuthMessage(frame: AnyClusterFrame) {
152
163
  if (this.role === "inbound") {
153
164
  if (frame.type !== "auth") return;
154
- const { nodeId, sig, agents, models, tags } = frame.payload as {
165
+ const { nodeId, sig, agents, models, tags, deviceInfo } = frame.payload as {
155
166
  nodeId: string;
156
167
  sig: string;
157
168
  agents?: AgentInfo[];
158
169
  models?: ModelInfo[];
159
170
  tags?: string[];
171
+ deviceInfo?: DeviceInfo;
160
172
  };
161
173
  if (!this.pendingNonce) return;
162
174
 
@@ -180,6 +192,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
180
192
  agents: agents ?? [],
181
193
  models: models ?? [],
182
194
  tags: tags ?? [],
195
+ deviceInfo,
183
196
  };
184
197
  this.authenticated = true;
185
198
  this.clearAuthTimer();
@@ -193,8 +206,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
193
206
  agents: this.localCapabilities.agents,
194
207
  models: this.localCapabilities.models,
195
208
  tags: this.localCapabilities.tags,
209
+ deviceInfo: this.localCapabilities.deviceInfo,
196
210
  },
197
- } satisfies AuthOk);
211
+ } as AuthOk);
198
212
 
199
213
  this.startHeartbeat();
200
214
  this.emit("authenticated", this.remoteCapabilities);
@@ -212,6 +226,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
212
226
  agents: this.localCapabilities.agents,
213
227
  models: this.localCapabilities.models,
214
228
  tags: this.localCapabilities.tags,
229
+ deviceInfo: this.localCapabilities.deviceInfo,
215
230
  },
216
231
  });
217
232
  return;
@@ -225,6 +240,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
225
240
  agents: ok.payload.agents,
226
241
  models: ok.payload.models,
227
242
  tags: ok.payload.tags,
243
+ deviceInfo: ok.payload.deviceInfo,
228
244
  };
229
245
  this.authenticated = true;
230
246
  this.startHeartbeat();
@@ -258,10 +274,11 @@ export class Connection extends EventEmitter<ConnectionEvents> {
258
274
  this.close(4002, "heartbeat timeout");
259
275
  return;
260
276
  }
277
+ this.lastPingSentAt = Date.now();
261
278
  this.sendRaw({
262
279
  type: "ping",
263
280
  from: this.nodeId,
264
- timestamp: Date.now(),
281
+ timestamp: this.lastPingSentAt,
265
282
  });
266
283
  scheduleNext();
267
284
  }, interval);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Collect local device/system information using Node.js os module.
3
+ */
4
+ import { arch, cpus, hostname, totalmem, type, release } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { readFileSync } from "node:fs";
7
+ import type { DeviceInfo } from "./types.ts";
8
+
9
+ function readVersionFromPkgJson(dir: string): string | undefined {
10
+ try {
11
+ const raw = readFileSync(join(dir, "package.json"), "utf-8");
12
+ const pkg = JSON.parse(raw) as { name?: string; version?: string };
13
+ if (pkg.name === "openclaw" && pkg.version) return pkg.version;
14
+ } catch { /* not found */ }
15
+ return undefined;
16
+ }
17
+
18
+ function resolveOpenclawVersion(hint?: string): string | undefined {
19
+ if (hint && hint !== "unknown") return hint;
20
+ // Walk up from the OpenClaw entry script (process.argv[1]) to find package.json
21
+ try {
22
+ const entry = process.argv[1];
23
+ if (!entry) return undefined;
24
+ let dir = dirname(entry);
25
+ for (let i = 0; i < 10; i++) {
26
+ const v = readVersionFromPkgJson(dir);
27
+ if (v) return v;
28
+ const parent = dirname(dir);
29
+ if (parent === dir) break;
30
+ dir = parent;
31
+ }
32
+ } catch { /* best effort */ }
33
+ return undefined;
34
+ }
35
+
36
+ /** Collect device info once at startup. */
37
+ export function collectDeviceInfo(openclawVersion?: string): DeviceInfo {
38
+ const cpuList = cpus();
39
+ return {
40
+ os: `${type()} ${release()}`,
41
+ arch: arch(),
42
+ cpuModel: cpuList[0]?.model?.trim() ?? "unknown",
43
+ cpuCores: cpuList.length,
44
+ totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
45
+ hostname: hostname(),
46
+ openclawVersion: resolveOpenclawVersion(openclawVersion),
47
+ };
48
+ }
package/src/handoff.ts CHANGED
@@ -19,6 +19,7 @@ interface PendingHandoff {
19
19
  task: string;
20
20
  context?: string;
21
21
  accumulated: string;
22
+ onStream?: (delta: string) => void;
22
23
  }
23
24
 
24
25
  export class HandoffManager {
@@ -36,13 +37,19 @@ export class HandoffManager {
36
37
  target: string,
37
38
  task: string,
38
39
  context?: string,
40
+ options?: { nodeId?: string; onStream?: (delta: string) => void },
39
41
  ): Promise<HandoffResponse["payload"]> {
42
+ if (options?.nodeId) {
43
+ // Direct node targeting (e.g. from web UI) — skip router resolution
44
+ return this.sendHandoff(options.nodeId, target, task, context, 0, options.onStream);
45
+ }
46
+
40
47
  const route = this.peerManager.router.resolveAgent(target);
41
48
  if (!route) {
42
49
  throw new Error(`No reachable agent for target "${target}"`);
43
50
  }
44
51
 
45
- return this.sendHandoff(route.nodeId, target, task, context, MAX_RETRIES);
52
+ return this.sendHandoff(route.nodeId, target, task, context, MAX_RETRIES, options?.onStream);
46
53
  }
47
54
 
48
55
  private sendHandoff(
@@ -51,13 +58,14 @@ export class HandoffManager {
51
58
  task: string,
52
59
  context: string | undefined,
53
60
  retriesLeft: number,
61
+ onStream?: (delta: string) => void,
54
62
  ): Promise<HandoffResponse["payload"]> {
55
63
  const id = crypto.randomUUID();
56
64
 
57
65
  return new Promise<HandoffResponse["payload"]>((resolve, reject) => {
58
- const timer = this.createTimeout(id, targetNodeId, target, task, context, retriesLeft, resolve, reject);
66
+ const timer = this.createTimeout(id, targetNodeId, target, task, context, retriesLeft, resolve, reject, onStream);
59
67
 
60
- this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context, accumulated: "" });
68
+ this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context, accumulated: "", onStream });
61
69
 
62
70
  const frame: HandoffRequest = {
63
71
  type: "handoff_req",
@@ -77,7 +85,7 @@ export class HandoffManager {
77
85
  if (retriesLeft > 0) {
78
86
  const nextRoute = this.peerManager.router.resolveAgent(target);
79
87
  if (nextRoute && nextRoute.nodeId !== targetNodeId) {
80
- this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
88
+ this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1, onStream)
81
89
  .then(resolve)
82
90
  .catch(reject);
83
91
  return;
@@ -97,6 +105,7 @@ export class HandoffManager {
97
105
  retriesLeft: number,
98
106
  resolve: (result: HandoffResponse["payload"]) => void,
99
107
  reject: (error: Error) => void,
108
+ onStream?: (delta: string) => void,
100
109
  ): ReturnType<typeof setTimeout> {
101
110
  return setTimeout(() => {
102
111
  this.pending.delete(id);
@@ -106,7 +115,7 @@ export class HandoffManager {
106
115
  if (retriesLeft > 0) {
107
116
  const nextRoute = this.peerManager.router.resolveAgent(target);
108
117
  if (nextRoute && nextRoute.nodeId !== targetNodeId) {
109
- this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
118
+ this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1, onStream)
110
119
  .then(resolve)
111
120
  .catch(reject);
112
121
  return;
@@ -125,6 +134,11 @@ export class HandoffManager {
125
134
 
126
135
  pending.accumulated += frame.payload.delta;
127
136
 
137
+ // Notify stream listener
138
+ if (pending.onStream && frame.payload.delta) {
139
+ pending.onStream(frame.payload.delta);
140
+ }
141
+
128
142
  // Reset timeout — the remote agent is still working
129
143
  clearTimeout(pending.timer);
130
144
  pending.timer = this.createTimeout(
@@ -136,6 +150,7 @@ export class HandoffManager {
136
150
  pending.retriesLeft,
137
151
  pending.resolve,
138
152
  pending.reject,
153
+ pending.onStream,
139
154
  );
140
155
  }
141
156
 
package/src/index.ts CHANGED
@@ -39,7 +39,7 @@ const plugin = {
39
39
  const config = parseConfig(api.pluginConfig);
40
40
 
41
41
  // Background service: manages mesh connections, WS listener, heartbeat
42
- api.registerService(createClusterService(config, api.config));
42
+ api.registerService(createClusterService(config, api.config, api.runtime.version));
43
43
 
44
44
  // Model providers: register per-node providers so models are accessed as nodeId/modelId
45
45
  const baseUrl = `http://127.0.0.1:${config.proxyPort}/v1`;
@@ -50,12 +50,21 @@ const plugin = {
50
50
  // We must patch BOTH api.config (cfgAtStart) AND the runtimeConfigSnapshot
51
51
  // (returned by loadConfig()) because activateSecretsRuntimeSnapshot clones the
52
52
  // config before plugins load, so api.config and the snapshot are separate objects.
53
+ // Determine per-node API type from proxyModels (all models in a group share the same api)
54
+ const nodeApiType: Record<string, string> = {};
55
+ for (const m of config.proxyModels) {
56
+ if (m.api && !nodeApiType[m.nodeId]) {
57
+ nodeApiType[m.nodeId] = m.api;
58
+ }
59
+ }
60
+
53
61
  const patchProviders = (cfg: Record<string, unknown>) => {
54
62
  const models = ((cfg).models ??= {}) as Record<string, unknown>;
55
63
  const providers = (models.providers ??= {}) as Record<string, unknown>;
56
64
  for (const [nodeId, nodeModels] of Object.entries(modelsByNode)) {
57
65
  if (!providers[nodeId]) {
58
- providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: "openai-completions", models: nodeModels };
66
+ const api = nodeApiType[nodeId] ?? "openai-completions";
67
+ providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api, models: nodeModels };
59
68
  }
60
69
  }
61
70
  };
@@ -63,7 +72,8 @@ const plugin = {
63
72
  patchProviders(api.config as Record<string, unknown>);
64
73
 
65
74
  // Also patch the runtime config snapshot (loadConfig returns it by reference).
66
- // api.runtime.config.loadConfig() returns runtimeConfigSnapshot directly.
75
+ // activateSecretsRuntimeSnapshot clones the config, so api.config and the
76
+ // snapshot returned by loadConfig() are separate objects — patch both.
67
77
  try {
68
78
  const snapshot = api.runtime.config.loadConfig();
69
79
  if (snapshot && snapshot !== api.config) {
@@ -199,7 +209,7 @@ const plugin = {
199
209
  "This ensures the user always knows when operations leave the local node.",
200
210
  );
201
211
 
202
- return { prependContext: lines.join("\n") };
212
+ return { prependSystemContext: lines.join("\n") };
203
213
  } catch {
204
214
  return;
205
215
  }