openclaw-linso 1.0.7 → 1.0.9

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.
@@ -73,16 +73,12 @@ export const linsoPlugin = {
73
73
  additionalProperties: false,
74
74
  properties: {
75
75
  enabled: { type: "boolean" },
76
- relayUrl: { type: "string" },
77
- pluginSecret: { type: "string" },
78
76
  appToken: { type: "string" },
79
77
  agentId: { type: "string" },
80
78
  dmPolicy: { type: "string", enum: ["open", "pairing"] },
81
79
  },
82
80
  },
83
81
  uiHints: {
84
- relayUrl: { label: "Relay Server 地址", placeholder: "wss://relay.yourdomain.com" },
85
- pluginSecret: { label: "Plugin 密钥(Plugin↔Relay 鉴权)", sensitive: true },
86
82
  appToken: { label: "App Token(iOS↔Relay 鉴权)", sensitive: true },
87
83
  agentId: { label: "Agent ID(默认 main)", placeholder: "main" },
88
84
  dmPolicy: { label: "连接策略" },
@@ -10,6 +10,23 @@ import { getLinsoRuntime } from "./runtime.js";
10
10
  import { connectToRelay, disconnectFromRelay } from "./relay-client.js";
11
11
  import { sendToClient } from "./store.js";
12
12
  import { resolveLinsoAccount } from "./types.js";
13
+ // ── 每设备会话状态 ───────────────────────────────────────────────────────────
14
+ /** 会话计数器:/new /reset 时自增,派生新 sessionKey */
15
+ const deviceSessionCounters = new Map();
16
+ /** stop 标志:/stop 时设置,deliver 回调里检查 */
17
+ const deviceStopFlags = new Map();
18
+ /** 模型覆盖:/model <name> 时设置,undefined = 使用全局默认 */
19
+ const deviceModelOverrides = new Map();
20
+ /** 当用户没指定 provider 时自动补全的默认 provider */
21
+ const DEFAULT_MODEL_PROVIDER = "liao-claude";
22
+ function getDevicePeerId(deviceId) {
23
+ const n = deviceSessionCounters.get(deviceId) ?? 0;
24
+ return n === 0 ? deviceId : `${deviceId}#${n}`;
25
+ }
26
+ function bumpDeviceSession(deviceId) {
27
+ deviceSessionCounters.set(deviceId, (deviceSessionCounters.get(deviceId) ?? 0) + 1);
28
+ }
29
+ // ── monitorLinsoProvider ─────────────────────────────────────────────────────
13
30
  export async function monitorLinsoProvider(opts = {}) {
14
31
  const cfg = opts.config;
15
32
  if (!cfg)
@@ -26,7 +43,6 @@ export async function monitorLinsoProvider(opts = {}) {
26
43
  return;
27
44
  }
28
45
  log(`[Linso] 连接 Relay Server: ${account.relayUrl}`);
29
- // Plugin 主动连接 Relay(outbound,和飞书连 open.feishu.cn 完全一致)
30
46
  connectToRelay(account.relayUrl, {
31
47
  appToken: account.appToken,
32
48
  log,
@@ -47,12 +63,32 @@ export async function monitorLinsoProvider(opts = {}) {
47
63
  const imageUrls = Array.isArray(msg.images)
48
64
  ? msg.images.filter((u) => typeof u === "string")
49
65
  : [];
50
- // 拦截 slash 命令,直接回复,不走 Agent
51
66
  if (text.startsWith("/")) {
52
- const runId = `linso-${Date.now()}`;
53
- sendToClient(deviceId, { type: "run_start", runId });
54
- sendToClient(deviceId, { type: "final", runId, text: handleSlashCommand(text) });
55
- sendToClient(deviceId, { type: "done", runId });
67
+ const result = handleSlashCommand(text, deviceId, cfg);
68
+ if (result.kind === "sync") {
69
+ const runId = `linso-${Date.now()}`;
70
+ sendToClient(deviceId, { type: "run_start", runId });
71
+ sendToClient(deviceId, { type: "final", runId, text: result.text });
72
+ sendToClient(deviceId, { type: "done", runId });
73
+ return;
74
+ }
75
+ if (result.kind === "async") {
76
+ const runId = `linso-${Date.now()}`;
77
+ sendToClient(deviceId, { type: "run_start", runId });
78
+ result.handler().then((text) => {
79
+ sendToClient(deviceId, { type: "final", runId, text });
80
+ sendToClient(deviceId, { type: "done", runId });
81
+ }).catch((err) => {
82
+ sendToClient(deviceId, { type: "final", runId, text: `❌ 获取状态失败: ${String(err)}` });
83
+ sendToClient(deviceId, { type: "done", runId });
84
+ });
85
+ return;
86
+ }
87
+ // kind === "agent":把处理后的文字转发给 Agent
88
+ void handleIncomingMessage(deviceId, result.text, [], cfg, log).catch((err) => {
89
+ log(`[Linso] 处理命令出错: ${String(err)}`);
90
+ sendToClient(deviceId, { type: "error", message: String(err) });
91
+ });
56
92
  return;
57
93
  }
58
94
  void handleIncomingMessage(deviceId, text, imageUrls, cfg, log).catch((err) => {
@@ -62,7 +98,6 @@ export async function monitorLinsoProvider(opts = {}) {
62
98
  }
63
99
  },
64
100
  });
65
- // 等待 abort 信号(Gateway 重启/重载时触发)
66
101
  if (opts.abortSignal) {
67
102
  await new Promise((resolve) => {
68
103
  opts.abortSignal.addEventListener("abort", () => {
@@ -73,10 +108,171 @@ export async function monitorLinsoProvider(opts = {}) {
73
108
  });
74
109
  }
75
110
  }
76
- /**
77
- * 下载图片 URL 到本地临时文件,返回本地路径
78
- * Telegram/Discord 插件处理方式一致
79
- */
111
+ // ── slash 命令处理 ────────────────────────────────────────────────────────────
112
+ function handleSlashCommand(raw, deviceId, cfg) {
113
+ const parts = raw.trim().split(/\s+/);
114
+ const cmd = parts[0].toLowerCase();
115
+ const args = parts.slice(1);
116
+ switch (cmd) {
117
+ // ── Session 管理 ──────────────────────────────────────────────────────────
118
+ case "/new":
119
+ case "/reset": {
120
+ bumpDeviceSession(deviceId);
121
+ return {
122
+ kind: "sync",
123
+ text: [
124
+ "✅ 已开启新会话",
125
+ "之前的对话历史已清除,可以重新开始了 🦞",
126
+ ].join("\n"),
127
+ };
128
+ }
129
+ case "/compact": {
130
+ // 转发给 Agent,让它自己 compact
131
+ const focus = args.length > 0 ? `重点关注:${args.join(" ")}` : "";
132
+ return {
133
+ kind: "agent",
134
+ text: [
135
+ "[系统指令] 请将当前对话历史压缩为简洁摘要,保留关键决策和上下文,删除冗余细节。",
136
+ focus,
137
+ ].filter(Boolean).join("\n"),
138
+ };
139
+ }
140
+ case "/stop": {
141
+ deviceStopFlags.set(deviceId, true);
142
+ return {
143
+ kind: "sync",
144
+ text: "🛑 已发送停止信号,当前任务将在下一个输出节点终止。",
145
+ };
146
+ }
147
+ // ── 模型切换 ──────────────────────────────────────────────────────────────
148
+ case "/model": {
149
+ if (args.length === 0) {
150
+ const current = deviceModelOverrides.get(deviceId);
151
+ return {
152
+ kind: "sync",
153
+ text: current
154
+ ? `🤖 当前模型:${current}\n\n用法:/model <name> 切换模型(自动补全 ${DEFAULT_MODEL_PROVIDER}/)\n /model <p/m> 指定 provider\n /model off 恢复默认`
155
+ : `🤖 当前模型:默认(${DEFAULT_MODEL_PROVIDER}/claude-sonnet-4-6)\n\n示例:/model claude-opus-4-5\n /model liao-claude/claude-opus-4-5`,
156
+ };
157
+ }
158
+ const name = args[0].toLowerCase();
159
+ if (name === "off" || name === "default" || name === "reset") {
160
+ deviceModelOverrides.delete(deviceId);
161
+ return { kind: "sync", text: "✅ 已恢复默认模型" };
162
+ }
163
+ // 没有 / 时自动补全默认 provider
164
+ const modelInput = args[0].includes("/") ? args[0] : `${DEFAULT_MODEL_PROVIDER}/${args[0]}`;
165
+ deviceModelOverrides.set(deviceId, modelInput);
166
+ return { kind: "sync", text: `✅ 模型已切换为:${modelInput}` };
167
+ }
168
+ // ── 状态信息 ──────────────────────────────────────────────────────────────
169
+ case "/context": {
170
+ const counter = deviceSessionCounters.get(deviceId) ?? 0;
171
+ const model = deviceModelOverrides.get(deviceId) ?? `${DEFAULT_MODEL_PROVIDER}/claude-sonnet-4-6(默认)`;
172
+ return {
173
+ kind: "sync",
174
+ text: [
175
+ "📊 当前上下文:",
176
+ `• 设备 ID:${deviceId}`,
177
+ `• 会话版本:#${counter}`,
178
+ `• 模型:${model}`,
179
+ `• 时间:${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`,
180
+ "",
181
+ "(上下文 token 用量需在 Agent 侧查询)",
182
+ ].join("\n"),
183
+ };
184
+ }
185
+ case "/status": {
186
+ return {
187
+ kind: "async",
188
+ handler: () => buildStatusCard(deviceId, cfg),
189
+ };
190
+ }
191
+ // ── Skill ─────────────────────────────────────────────────────────────────
192
+ case "/skill": {
193
+ if (args.length === 0) {
194
+ return {
195
+ kind: "sync",
196
+ text: "⚠️ 用法:/skill <name> [input]\n示例:/skill weather 上海明天天气",
197
+ };
198
+ }
199
+ const skillName = args[0];
200
+ const input = args.slice(1).join(" ");
201
+ return {
202
+ kind: "agent",
203
+ text: input
204
+ ? `[Skill: ${skillName}] ${input}`
205
+ : `请使用 skill "${skillName}" 执行任务。`,
206
+ };
207
+ }
208
+ // ── 帮助 ──────────────────────────────────────────────────────────────────
209
+ case "/commands": {
210
+ return {
211
+ kind: "sync",
212
+ text: FULL_COMMAND_LIST,
213
+ };
214
+ }
215
+ case "/help": {
216
+ return {
217
+ kind: "sync",
218
+ text: [
219
+ "📋 常用命令(/commands 查看完整列表):",
220
+ "",
221
+ "Session",
222
+ " /new 开启新会话(清除历史)",
223
+ " /reset 同 /new",
224
+ " /compact [focus] 压缩对话历史",
225
+ " /stop 停止当前任务",
226
+ "",
227
+ "模型",
228
+ " /model 查看当前模型",
229
+ " /model <name> 切换模型",
230
+ " /model off 恢复默认",
231
+ "",
232
+ "状态",
233
+ " /status 连接状态",
234
+ " /context 会话上下文信息",
235
+ "",
236
+ "Skills",
237
+ " /skill <name> [input]",
238
+ "",
239
+ "直接发消息即可和 AI 对话 🦞",
240
+ ].join("\n"),
241
+ };
242
+ }
243
+ default:
244
+ return {
245
+ kind: "sync",
246
+ text: `未知命令:${raw}\n输入 /help 查看常用命令,/commands 查看完整列表`,
247
+ };
248
+ }
249
+ }
250
+ const FULL_COMMAND_LIST = [
251
+ "📋 完整命令列表:",
252
+ "",
253
+ "── Session ──────────────────────",
254
+ " /new 开启新会话(清除历史)",
255
+ " /reset 同 /new",
256
+ " /compact [focus] 压缩对话历史,可指定关注点",
257
+ " /stop 停止当前正在运行的任务",
258
+ "",
259
+ "── 模型 ─────────────────────────",
260
+ " /model 查看当前模型",
261
+ " /model <name> 切换到指定模型",
262
+ " /model off 恢复全局默认模型",
263
+ "",
264
+ "── 状态信息 ─────────────────────",
265
+ " /status Relay & Plugin 连接状态",
266
+ " /context 当前会话上下文信息(含模型)",
267
+ "",
268
+ "── Skills ───────────────────────",
269
+ " /skill <name> [input] 调用指定 skill",
270
+ "",
271
+ "── 帮助 ─────────────────────────",
272
+ " /help 常用命令速览",
273
+ " /commands 本列表",
274
+ ].join("\n");
275
+ // ── 图片下载 ──────────────────────────────────────────────────────────────────
80
276
  async function downloadImageToTemp(url, index) {
81
277
  return new Promise((resolve) => {
82
278
  try {
@@ -91,10 +287,7 @@ async function downloadImageToTemp(url, index) {
91
287
  return;
92
288
  }
93
289
  res.pipe(file);
94
- file.on("finish", () => {
95
- file.close();
96
- resolve(tmpPath);
97
- });
290
+ file.on("finish", () => { file.close(); resolve(tmpPath); });
98
291
  });
99
292
  req.on("error", () => { file.close(); resolve(null); });
100
293
  req.setTimeout(15000, () => { req.destroy(); file.close(); resolve(null); });
@@ -104,12 +297,12 @@ async function downloadImageToTemp(url, index) {
104
297
  }
105
298
  });
106
299
  }
107
- /**
108
- * 把 iOS 消息路由到 Agent,流式回复(核心逻辑不变)
109
- */
300
+ // ── Agent 路由 ────────────────────────────────────────────────────────────────
110
301
  async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
111
302
  const core = getLinsoRuntime();
112
- // 下载图片到本地临时文件(和 Telegram/Discord 一致)
303
+ // 清除 stop 标志(新消息开始)
304
+ deviceStopFlags.delete(deviceId);
305
+ // 下载图片到本地临时文件
113
306
  const localPaths = [];
114
307
  if (imageUrls.length > 0) {
115
308
  log(`[Linso] 下载 ${imageUrls.length} 张图片...`);
@@ -120,14 +313,20 @@ async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
120
313
  }
121
314
  log(`[Linso] 下载完成: ${localPaths.length}/${imageUrls.length}`);
122
315
  }
316
+ // 使用带 session counter 的 peerId,实现会话隔离
317
+ const peerId = getDevicePeerId(deviceId);
123
318
  const route = core.channel.routing.resolveAgentRoute({
124
319
  cfg,
125
320
  channel: "linso",
126
321
  accountId: "default",
127
- peer: { kind: "direct", id: deviceId },
322
+ peer: { kind: "direct", id: peerId },
128
323
  });
129
- log(`[Linso] 路由: ${deviceId} → session=${route.sessionKey} agent=${route.agentId}`);
130
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
324
+ // 模型覆盖:patchAgentModel 修改 cfg.agents.list 里对应 agent 的 model 字段
325
+ // 经实测 resolveDefaultModelForAgent 会读取此字段,传 provider/model 格式即可生效
326
+ const modelOverride = deviceModelOverrides.get(deviceId);
327
+ const effectiveCfg = modelOverride ? patchAgentModel(cfg, route.agentId, modelOverride) : cfg;
328
+ log(`[Linso] 路由: ${deviceId} → session=${route.sessionKey} agent=${route.agentId}${modelOverride ? ` model=${modelOverride}` : ""}`);
329
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(effectiveCfg);
131
330
  const body = core.channel.reply.formatAgentEnvelope({
132
331
  channel: "Linso",
133
332
  from: deviceId,
@@ -162,6 +361,11 @@ async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
162
361
  const runId = `linso-${Date.now()}`;
163
362
  const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
164
363
  deliver: async (payload, info) => {
364
+ // 检查 stop 标志
365
+ if (deviceStopFlags.get(deviceId)) {
366
+ log(`[Linso] stop 标志命中,跳过输出: deviceId=${deviceId}`);
367
+ return;
368
+ }
165
369
  if (!payload.text || payload.isReasoning)
166
370
  return;
167
371
  sendToClient(deviceId, {
@@ -177,7 +381,7 @@ async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
177
381
  onSettled: () => markDispatchIdle(),
178
382
  run: () => core.channel.reply.dispatchReplyFromConfig({
179
383
  ctx: inboundCtx,
180
- cfg,
384
+ cfg: effectiveCfg,
181
385
  dispatcher,
182
386
  replyOptions,
183
387
  }),
@@ -185,27 +389,123 @@ async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
185
389
  sendToClient(deviceId, { type: "done", runId });
186
390
  log(`[Linso] 回复完成: deviceId=${deviceId}`);
187
391
  }
188
- function handleSlashCommand(text) {
189
- const cmd = text.toLowerCase().split(/\s+/)[0];
190
- switch (cmd) {
191
- case "/help":
192
- return [
193
- "📋 可用命令:",
194
- "/help 显示帮助",
195
- "/status — 查看连接状态",
196
- "/clear — 提示清除会话(实际需在 App 操作)",
197
- "",
198
- "直接发消息即可和 AI 对话 🦞",
199
- ].join("\n");
200
- case "/status":
201
- return [
202
- "✅ 连接状态:",
203
- "• Relay Server:已连接",
204
- "• Plugin:已就绪",
205
- "• AI Agent:可用",
206
- `• 时间:${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`,
207
- ].join("\n");
208
- default:
209
- return `未知命令:${text}\n输入 /help 查看可用命令`;
392
+ // ── 工具函数 ──────────────────────────────────────────────────────────────────
393
+ /**
394
+ * 读取 sessions.json 并格式化状态卡片。
395
+ */
396
+ async function buildStatusCard(deviceId, cfg) {
397
+ const core = getLinsoRuntime();
398
+ const peerId = getDevicePeerId(deviceId);
399
+ const route = core.channel.routing.resolveAgentRoute({
400
+ cfg,
401
+ channel: "linso",
402
+ accountId: "default",
403
+ peer: { kind: "direct", id: peerId },
404
+ });
405
+ const sessionKey = route.sessionKey;
406
+ const agentId = route.agentId ?? "main";
407
+ // 读取 sessions.json
408
+ const store = loadSessionsStore(agentId);
409
+ const entry = store[sessionKey] ?? null;
410
+ const version = core.version ?? "unknown";
411
+ const now = Date.now();
412
+ // 模型
413
+ const modelOverride = deviceModelOverrides.get(deviceId);
414
+ const modelProvider = modelOverride
415
+ ? (modelOverride.includes("/") ? modelOverride.split("/")[0] : "")
416
+ : (entry?.modelProvider ?? "");
417
+ const modelName = modelOverride
418
+ ? (modelOverride.includes("/") ? modelOverride.split("/")[1] : modelOverride)
419
+ : (entry?.model ?? "unknown");
420
+ const modelDisplay = modelProvider ? `${modelProvider}/${modelName}` : modelName;
421
+ // Tokens
422
+ const tokIn = entry?.inputTokens ?? 0;
423
+ const tokOut = entry?.outputTokens ?? 0;
424
+ const cacheRead = entry?.cacheRead ?? 0;
425
+ const cacheWrite = entry?.cacheWrite ?? 0;
426
+ const totalTokens = entry?.totalTokens ?? 0;
427
+ const contextWindow = entry?.contextTokens ?? 200000;
428
+ const compactions = entry?.compactionCount ?? 0;
429
+ // Cache hit %
430
+ const cacheHitPct = cacheWrite > 0 ? Math.round((cacheRead / cacheWrite) * 100) : 0;
431
+ const cacheReadK = Math.round(cacheRead / 1000);
432
+ const cacheWriteK = Math.round(cacheWrite / 1000);
433
+ // Context %
434
+ const ctxPct = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 100) : 0;
435
+ const totalK = Math.round(totalTokens / 1000);
436
+ const ctxK = Math.round(contextWindow / 1000);
437
+ // Updated
438
+ const updatedAt = entry?.updatedAt;
439
+ const updatedStr = updatedAt ? relativeTime(now - updatedAt) : "never";
440
+ const sessionCounter = deviceSessionCounters.get(deviceId) ?? 0;
441
+ const sessionDisplay = sessionCounter > 0 ? `${sessionKey}#${sessionCounter}` : sessionKey;
442
+ const lines = [
443
+ `🦞 OpenClaw ${version}`,
444
+ `🧠 Model: ${modelDisplay}`,
445
+ `🧮 Tokens: ${tokIn} in / ${tokOut} out`,
446
+ `🗄️ Cache: ${cacheHitPct}% hit · ${cacheReadK}k cached, ${cacheWriteK}k new`,
447
+ `📚 Context: ${totalK}k/${ctxK}k (${ctxPct}%) · 🧹 Compactions: ${compactions}`,
448
+ `🧵 Session: ${sessionDisplay} • updated ${updatedStr}`,
449
+ `⚙️ Runtime: direct · Think: low`,
450
+ ];
451
+ return lines.join("\n");
452
+ }
453
+ function relativeTime(ms) {
454
+ const s = Math.floor(ms / 1000);
455
+ if (s < 10)
456
+ return "just now";
457
+ if (s < 60)
458
+ return `${s}s ago`;
459
+ const m = Math.floor(s / 60);
460
+ if (m < 60)
461
+ return `${m}m ago`;
462
+ const h = Math.floor(m / 60);
463
+ if (h < 24)
464
+ return `${h}h ago`;
465
+ return `${Math.floor(h / 24)}d ago`;
466
+ }
467
+ /**
468
+ * 解析 sessions.json 路径
469
+ */
470
+ function resolveSessionsJsonPath(agentId) {
471
+ return path.join(os.homedir(), ".openclaw", "agents", agentId, "sessions", "sessions.json");
472
+ }
473
+ /**
474
+ * 读取 sessions.json store
475
+ */
476
+ function loadSessionsStore(agentId) {
477
+ try {
478
+ const raw = fs.readFileSync(resolveSessionsJsonPath(agentId), "utf-8");
479
+ return JSON.parse(raw);
480
+ }
481
+ catch {
482
+ return {};
210
483
  }
211
484
  }
485
+ /**
486
+ * 写回 sessions.json(用于 /status 的 buildStatusCard,只读不写)
487
+ */
488
+ function saveSessionsStore(agentId, store) {
489
+ fs.writeFileSync(resolveSessionsJsonPath(agentId), JSON.stringify(store, null, 2), "utf-8");
490
+ }
491
+ // saveSessionsStore 保留给将来可能的用途,此处避免 unused 警告
492
+ void saveSessionsStore;
493
+ /**
494
+ * 覆盖 cfg.agents.defaults.model,这是 resolveDefaultModelForAgent 实际读取的字段。
495
+ * agents.list 为空时,defaults 是唯一生效的模型配置。
496
+ */
497
+ function patchAgentModel(cfg, _agentId, modelOverride) {
498
+ const defaults = (cfg.agents?.defaults ?? {});
499
+ const existingModels = (defaults.models ?? {});
500
+ return {
501
+ ...cfg,
502
+ agents: {
503
+ ...(cfg.agents ?? {}),
504
+ defaults: {
505
+ ...defaults,
506
+ model: { primary: modelOverride },
507
+ models: { ...existingModels, [modelOverride]: {} },
508
+ },
509
+ },
510
+ };
511
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-linso",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Linso iOS App channel plugin for OpenClaw — connects via cloud Relay Server",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/channel.ts CHANGED
@@ -98,20 +98,16 @@ export const linsoPlugin: ChannelPlugin<ResolvedLinsoAccount> = {
98
98
  type: "object",
99
99
  additionalProperties: false,
100
100
  properties: {
101
- enabled: { type: "boolean" },
102
- relayUrl: { type: "string" },
103
- pluginSecret: { type: "string" },
104
- appToken: { type: "string" },
105
- agentId: { type: "string" },
106
- dmPolicy: { type: "string", enum: ["open", "pairing"] },
101
+ enabled: { type: "boolean" },
102
+ appToken: { type: "string" },
103
+ agentId: { type: "string" },
104
+ dmPolicy: { type: "string", enum: ["open", "pairing"] },
107
105
  },
108
106
  },
109
107
  uiHints: {
110
- relayUrl: { label: "Relay Server 地址", placeholder: "wss://relay.yourdomain.com" },
111
- pluginSecret: { label: "Plugin 密钥(Plugin↔Relay 鉴权)", sensitive: true },
112
- appToken: { label: "App Token(iOS↔Relay 鉴权)", sensitive: true },
113
- agentId: { label: "Agent ID(默认 main)", placeholder: "main" },
114
- dmPolicy: { label: "连接策略" },
108
+ appToken: { label: "App Token(iOS↔Relay 鉴权)", sensitive: true },
109
+ agentId: { label: "Agent ID(默认 main)", placeholder: "main" },
110
+ dmPolicy: { label: "连接策略" },
115
111
  },
116
112
  },
117
113
 
package/src/monitor.ts CHANGED
@@ -19,6 +19,38 @@ export type MonitorLinsoOpts = {
19
19
  abortSignal?: AbortSignal;
20
20
  };
21
21
 
22
+ // ── 每设备会话状态 ───────────────────────────────────────────────────────────
23
+
24
+ /** 会话计数器:/new /reset 时自增,派生新 sessionKey */
25
+ const deviceSessionCounters = new Map<string, number>();
26
+
27
+ /** stop 标志:/stop 时设置,deliver 回调里检查 */
28
+ const deviceStopFlags = new Map<string, boolean>();
29
+
30
+ /** 模型覆盖:/model <name> 时设置,undefined = 使用全局默认 */
31
+ const deviceModelOverrides = new Map<string, string>();
32
+
33
+ /** 当用户没指定 provider 时自动补全的默认 provider */
34
+ const DEFAULT_MODEL_PROVIDER = "liao-claude";
35
+
36
+ function getDevicePeerId(deviceId: string): string {
37
+ const n = deviceSessionCounters.get(deviceId) ?? 0;
38
+ return n === 0 ? deviceId : `${deviceId}#${n}`;
39
+ }
40
+
41
+ function bumpDeviceSession(deviceId: string): void {
42
+ deviceSessionCounters.set(deviceId, (deviceSessionCounters.get(deviceId) ?? 0) + 1);
43
+ }
44
+
45
+ // ── slash 命令分发结果类型 ────────────────────────────────────────────────────
46
+
47
+ type SlashResult =
48
+ | { kind: "sync"; text: string } // 直接回复,不走 Agent
49
+ | { kind: "agent"; text: string } // 转发给 Agent 处理
50
+ | { kind: "async"; handler: () => Promise<string> }; // 异步处理后同步回复
51
+
52
+ // ── monitorLinsoProvider ─────────────────────────────────────────────────────
53
+
22
54
  export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise<void> {
23
55
  const cfg = opts.config;
24
56
  if (!cfg) throw new Error("Config is required for Linso monitor");
@@ -39,7 +71,6 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
39
71
 
40
72
  log(`[Linso] 连接 Relay Server: ${account.relayUrl}`);
41
73
 
42
- // Plugin 主动连接 Relay(outbound,和飞书连 open.feishu.cn 完全一致)
43
74
  connectToRelay(account.relayUrl, {
44
75
  appToken: account.appToken,
45
76
  log,
@@ -64,12 +95,35 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
64
95
  ? (msg.images as unknown[]).filter((u): u is string => typeof u === "string")
65
96
  : [];
66
97
 
67
- // 拦截 slash 命令,直接回复,不走 Agent
68
98
  if (text.startsWith("/")) {
69
- const runId = `linso-${Date.now()}`;
70
- sendToClient(deviceId, { type: "run_start", runId });
71
- sendToClient(deviceId, { type: "final", runId, text: handleSlashCommand(text) });
72
- sendToClient(deviceId, { type: "done", runId });
99
+ const result = handleSlashCommand(text, deviceId, cfg);
100
+
101
+ if (result.kind === "sync") {
102
+ const runId = `linso-${Date.now()}`;
103
+ sendToClient(deviceId, { type: "run_start", runId });
104
+ sendToClient(deviceId, { type: "final", runId, text: result.text });
105
+ sendToClient(deviceId, { type: "done", runId });
106
+ return;
107
+ }
108
+
109
+ if (result.kind === "async") {
110
+ const runId = `linso-${Date.now()}`;
111
+ sendToClient(deviceId, { type: "run_start", runId });
112
+ result.handler().then((text) => {
113
+ sendToClient(deviceId, { type: "final", runId, text });
114
+ sendToClient(deviceId, { type: "done", runId });
115
+ }).catch((err) => {
116
+ sendToClient(deviceId, { type: "final", runId, text: `❌ 获取状态失败: ${String(err)}` });
117
+ sendToClient(deviceId, { type: "done", runId });
118
+ });
119
+ return;
120
+ }
121
+
122
+ // kind === "agent":把处理后的文字转发给 Agent
123
+ void handleIncomingMessage(deviceId, result.text, [], cfg, log).catch((err) => {
124
+ log(`[Linso] 处理命令出错: ${String(err)}`);
125
+ sendToClient(deviceId, { type: "error", message: String(err) });
126
+ });
73
127
  return;
74
128
  }
75
129
 
@@ -81,7 +135,6 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
81
135
  },
82
136
  });
83
137
 
84
- // 等待 abort 信号(Gateway 重启/重载时触发)
85
138
  if (opts.abortSignal) {
86
139
  await new Promise<void>((resolve) => {
87
140
  opts.abortSignal!.addEventListener("abort", () => {
@@ -93,10 +146,191 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
93
146
  }
94
147
  }
95
148
 
96
- /**
97
- * 下载图片 URL 到本地临时文件,返回本地路径
98
- * Telegram/Discord 插件处理方式一致
99
- */
149
+ // ── slash 命令处理 ────────────────────────────────────────────────────────────
150
+
151
+ function handleSlashCommand(raw: string, deviceId: string, cfg: OpenClawConfig): SlashResult {
152
+ const parts = raw.trim().split(/\s+/);
153
+ const cmd = parts[0].toLowerCase();
154
+ const args = parts.slice(1);
155
+
156
+ switch (cmd) {
157
+
158
+ // ── Session 管理 ──────────────────────────────────────────────────────────
159
+
160
+ case "/new":
161
+ case "/reset": {
162
+ bumpDeviceSession(deviceId);
163
+ return {
164
+ kind: "sync",
165
+ text: [
166
+ "✅ 已开启新会话",
167
+ "之前的对话历史已清除,可以重新开始了 🦞",
168
+ ].join("\n"),
169
+ };
170
+ }
171
+
172
+ case "/compact": {
173
+ // 转发给 Agent,让它自己 compact
174
+ const focus = args.length > 0 ? `重点关注:${args.join(" ")}` : "";
175
+ return {
176
+ kind: "agent",
177
+ text: [
178
+ "[系统指令] 请将当前对话历史压缩为简洁摘要,保留关键决策和上下文,删除冗余细节。",
179
+ focus,
180
+ ].filter(Boolean).join("\n"),
181
+ };
182
+ }
183
+
184
+ case "/stop": {
185
+ deviceStopFlags.set(deviceId, true);
186
+ return {
187
+ kind: "sync",
188
+ text: "🛑 已发送停止信号,当前任务将在下一个输出节点终止。",
189
+ };
190
+ }
191
+
192
+ // ── 模型切换 ──────────────────────────────────────────────────────────────
193
+
194
+ case "/model": {
195
+ if (args.length === 0) {
196
+ const current = deviceModelOverrides.get(deviceId);
197
+ return {
198
+ kind: "sync",
199
+ text: current
200
+ ? `🤖 当前模型:${current}\n\n用法:/model <name> 切换模型(自动补全 ${DEFAULT_MODEL_PROVIDER}/)\n /model <p/m> 指定 provider\n /model off 恢复默认`
201
+ : `🤖 当前模型:默认(${DEFAULT_MODEL_PROVIDER}/claude-sonnet-4-6)\n\n示例:/model claude-opus-4-5\n /model liao-claude/claude-opus-4-5`,
202
+ };
203
+ }
204
+ const name = args[0].toLowerCase();
205
+ if (name === "off" || name === "default" || name === "reset") {
206
+ deviceModelOverrides.delete(deviceId);
207
+ return { kind: "sync", text: "✅ 已恢复默认模型" };
208
+ }
209
+ // 没有 / 时自动补全默认 provider
210
+ const modelInput = args[0].includes("/") ? args[0] : `${DEFAULT_MODEL_PROVIDER}/${args[0]}`;
211
+ deviceModelOverrides.set(deviceId, modelInput);
212
+ return { kind: "sync", text: `✅ 模型已切换为:${modelInput}` };
213
+ }
214
+
215
+ // ── 状态信息 ──────────────────────────────────────────────────────────────
216
+
217
+ case "/context": {
218
+ const counter = deviceSessionCounters.get(deviceId) ?? 0;
219
+ const model = deviceModelOverrides.get(deviceId) ?? `${DEFAULT_MODEL_PROVIDER}/claude-sonnet-4-6(默认)`;
220
+ return {
221
+ kind: "sync",
222
+ text: [
223
+ "📊 当前上下文:",
224
+ `• 设备 ID:${deviceId}`,
225
+ `• 会话版本:#${counter}`,
226
+ `• 模型:${model}`,
227
+ `• 时间:${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`,
228
+ "",
229
+ "(上下文 token 用量需在 Agent 侧查询)",
230
+ ].join("\n"),
231
+ };
232
+ }
233
+
234
+ case "/status": {
235
+ return {
236
+ kind: "async",
237
+ handler: () => buildStatusCard(deviceId, cfg),
238
+ };
239
+ }
240
+
241
+ // ── Skill ─────────────────────────────────────────────────────────────────
242
+
243
+ case "/skill": {
244
+ if (args.length === 0) {
245
+ return {
246
+ kind: "sync",
247
+ text: "⚠️ 用法:/skill <name> [input]\n示例:/skill weather 上海明天天气",
248
+ };
249
+ }
250
+ const skillName = args[0];
251
+ const input = args.slice(1).join(" ");
252
+ return {
253
+ kind: "agent",
254
+ text: input
255
+ ? `[Skill: ${skillName}] ${input}`
256
+ : `请使用 skill "${skillName}" 执行任务。`,
257
+ };
258
+ }
259
+
260
+ // ── 帮助 ──────────────────────────────────────────────────────────────────
261
+
262
+ case "/commands": {
263
+ return {
264
+ kind: "sync",
265
+ text: FULL_COMMAND_LIST,
266
+ };
267
+ }
268
+
269
+ case "/help": {
270
+ return {
271
+ kind: "sync",
272
+ text: [
273
+ "📋 常用命令(/commands 查看完整列表):",
274
+ "",
275
+ "Session",
276
+ " /new 开启新会话(清除历史)",
277
+ " /reset 同 /new",
278
+ " /compact [focus] 压缩对话历史",
279
+ " /stop 停止当前任务",
280
+ "",
281
+ "模型",
282
+ " /model 查看当前模型",
283
+ " /model <name> 切换模型",
284
+ " /model off 恢复默认",
285
+ "",
286
+ "状态",
287
+ " /status 连接状态",
288
+ " /context 会话上下文信息",
289
+ "",
290
+ "Skills",
291
+ " /skill <name> [input]",
292
+ "",
293
+ "直接发消息即可和 AI 对话 🦞",
294
+ ].join("\n"),
295
+ };
296
+ }
297
+
298
+ default:
299
+ return {
300
+ kind: "sync",
301
+ text: `未知命令:${raw}\n输入 /help 查看常用命令,/commands 查看完整列表`,
302
+ };
303
+ }
304
+ }
305
+
306
+ const FULL_COMMAND_LIST = [
307
+ "📋 完整命令列表:",
308
+ "",
309
+ "── Session ──────────────────────",
310
+ " /new 开启新会话(清除历史)",
311
+ " /reset 同 /new",
312
+ " /compact [focus] 压缩对话历史,可指定关注点",
313
+ " /stop 停止当前正在运行的任务",
314
+ "",
315
+ "── 模型 ─────────────────────────",
316
+ " /model 查看当前模型",
317
+ " /model <name> 切换到指定模型",
318
+ " /model off 恢复全局默认模型",
319
+ "",
320
+ "── 状态信息 ─────────────────────",
321
+ " /status Relay & Plugin 连接状态",
322
+ " /context 当前会话上下文信息(含模型)",
323
+ "",
324
+ "── Skills ───────────────────────",
325
+ " /skill <name> [input] 调用指定 skill",
326
+ "",
327
+ "── 帮助 ─────────────────────────",
328
+ " /help 常用命令速览",
329
+ " /commands 本列表",
330
+ ].join("\n");
331
+
332
+ // ── 图片下载 ──────────────────────────────────────────────────────────────────
333
+
100
334
  async function downloadImageToTemp(url: string, index: number): Promise<string | null> {
101
335
  return new Promise((resolve) => {
102
336
  try {
@@ -106,16 +340,9 @@ async function downloadImageToTemp(url: string, index: number): Promise<string |
106
340
  const client = url.startsWith("https") ? https : http;
107
341
 
108
342
  const req = client.get(url, (res) => {
109
- if (res.statusCode !== 200) {
110
- file.close();
111
- resolve(null);
112
- return;
113
- }
343
+ if (res.statusCode !== 200) { file.close(); resolve(null); return; }
114
344
  res.pipe(file);
115
- file.on("finish", () => {
116
- file.close();
117
- resolve(tmpPath);
118
- });
345
+ file.on("finish", () => { file.close(); resolve(tmpPath); });
119
346
  });
120
347
  req.on("error", () => { file.close(); resolve(null); });
121
348
  req.setTimeout(15000, () => { req.destroy(); file.close(); resolve(null); });
@@ -125,9 +352,8 @@ async function downloadImageToTemp(url: string, index: number): Promise<string |
125
352
  });
126
353
  }
127
354
 
128
- /**
129
- * 把 iOS 消息路由到 Agent,流式回复(核心逻辑不变)
130
- */
355
+ // ── Agent 路由 ────────────────────────────────────────────────────────────────
356
+
131
357
  async function handleIncomingMessage(
132
358
  deviceId: string,
133
359
  text: string,
@@ -137,27 +363,36 @@ async function handleIncomingMessage(
137
363
  ): Promise<void> {
138
364
  const core = getLinsoRuntime();
139
365
 
140
- // 下载图片到本地临时文件(和 Telegram/Discord 一致)
366
+ // 清除 stop 标志(新消息开始)
367
+ deviceStopFlags.delete(deviceId);
368
+
369
+ // 下载图片到本地临时文件
141
370
  const localPaths: string[] = [];
142
371
  if (imageUrls.length > 0) {
143
372
  log(`[Linso] 下载 ${imageUrls.length} 张图片...`);
144
373
  const results = await Promise.all(imageUrls.map((url, i) => downloadImageToTemp(url, i)));
145
- for (const p of results) {
146
- if (p) localPaths.push(p);
147
- }
374
+ for (const p of results) { if (p) localPaths.push(p); }
148
375
  log(`[Linso] 下载完成: ${localPaths.length}/${imageUrls.length}`);
149
376
  }
150
377
 
378
+ // 使用带 session counter 的 peerId,实现会话隔离
379
+ const peerId = getDevicePeerId(deviceId);
380
+
151
381
  const route = core.channel.routing.resolveAgentRoute({
152
382
  cfg,
153
383
  channel: "linso",
154
384
  accountId: "default",
155
- peer: { kind: "direct", id: deviceId },
385
+ peer: { kind: "direct", id: peerId },
156
386
  });
157
387
 
158
- log(`[Linso] 路由: ${deviceId} → session=${route.sessionKey} agent=${route.agentId}`);
388
+ // 模型覆盖:patchAgentModel 修改 cfg.agents.list 里对应 agent 的 model 字段
389
+ // 经实测 resolveDefaultModelForAgent 会读取此字段,传 provider/model 格式即可生效
390
+ const modelOverride = deviceModelOverrides.get(deviceId);
391
+ const effectiveCfg = modelOverride ? patchAgentModel(cfg, route.agentId, modelOverride) : cfg;
392
+
393
+ log(`[Linso] 路由: ${deviceId} → session=${route.sessionKey} agent=${route.agentId}${modelOverride ? ` model=${modelOverride}` : ""}`);
159
394
 
160
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
395
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(effectiveCfg);
161
396
  const body = core.channel.reply.formatAgentEnvelope({
162
397
  channel: "Linso",
163
398
  from: deviceId,
@@ -196,6 +431,11 @@ async function handleIncomingMessage(
196
431
  const { dispatcher, replyOptions, markDispatchIdle } =
197
432
  core.channel.reply.createReplyDispatcherWithTyping({
198
433
  deliver: async (payload, info) => {
434
+ // 检查 stop 标志
435
+ if (deviceStopFlags.get(deviceId)) {
436
+ log(`[Linso] stop 标志命中,跳过输出: deviceId=${deviceId}`);
437
+ return;
438
+ }
199
439
  if (!payload.text || payload.isReasoning) return;
200
440
  sendToClient(deviceId, {
201
441
  type: info.kind === "final" ? "final" : "delta",
@@ -213,7 +453,7 @@ async function handleIncomingMessage(
213
453
  run: () =>
214
454
  core.channel.reply.dispatchReplyFromConfig({
215
455
  ctx: inboundCtx,
216
- cfg,
456
+ cfg: effectiveCfg,
217
457
  dispatcher,
218
458
  replyOptions,
219
459
  }),
@@ -223,29 +463,136 @@ async function handleIncomingMessage(
223
463
  log(`[Linso] 回复完成: deviceId=${deviceId}`);
224
464
  }
225
465
 
226
- function handleSlashCommand(text: string): string {
227
- const cmd = text.toLowerCase().split(/\s+/)[0];
228
- switch (cmd) {
229
- case "/help":
230
- return [
231
- "📋 可用命令:",
232
- "/help — 显示帮助",
233
- "/status — 查看连接状态",
234
- "/clear — 提示清除会话(实际需在 App 操作)",
235
- "",
236
- "直接发消息即可和 AI 对话 🦞",
237
- ].join("\n");
238
-
239
- case "/status":
240
- return [
241
- "✅ 连接状态:",
242
- "• Relay Server:已连接",
243
- "• Plugin:已就绪",
244
- "• AI Agent:可用",
245
- `• 时间:${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`,
246
- ].join("\n");
466
+ // ── 工具函数 ──────────────────────────────────────────────────────────────────
247
467
 
248
- default:
249
- return `未知命令:${text}\n输入 /help 查看可用命令`;
468
+ /**
469
+ * 读取 sessions.json 并格式化状态卡片。
470
+ */
471
+ async function buildStatusCard(deviceId: string, cfg: OpenClawConfig): Promise<string> {
472
+ const core = getLinsoRuntime();
473
+ const peerId = getDevicePeerId(deviceId);
474
+ const route = core.channel.routing.resolveAgentRoute({
475
+ cfg,
476
+ channel: "linso",
477
+ accountId: "default",
478
+ peer: { kind: "direct", id: peerId },
479
+ });
480
+
481
+ const sessionKey = route.sessionKey;
482
+ const agentId = route.agentId ?? "main";
483
+
484
+ // 读取 sessions.json
485
+ const store = loadSessionsStore(agentId);
486
+ const entry = store[sessionKey] ?? null;
487
+
488
+ const version = core.version ?? "unknown";
489
+ const now = Date.now();
490
+
491
+ // 模型
492
+ const modelOverride = deviceModelOverrides.get(deviceId);
493
+ const modelProvider = modelOverride
494
+ ? (modelOverride.includes("/") ? modelOverride.split("/")[0] : "")
495
+ : (entry?.modelProvider as string ?? "");
496
+ const modelName = modelOverride
497
+ ? (modelOverride.includes("/") ? modelOverride.split("/")[1] : modelOverride)
498
+ : (entry?.model as string ?? "unknown");
499
+ const modelDisplay = modelProvider ? `${modelProvider}/${modelName}` : modelName;
500
+
501
+ // Tokens
502
+ const tokIn = (entry?.inputTokens as number) ?? 0;
503
+ const tokOut = (entry?.outputTokens as number) ?? 0;
504
+ const cacheRead = (entry?.cacheRead as number) ?? 0;
505
+ const cacheWrite = (entry?.cacheWrite as number) ?? 0;
506
+ const totalTokens = (entry?.totalTokens as number) ?? 0;
507
+ const contextWindow = (entry?.contextTokens as number) ?? 200000;
508
+ const compactions = (entry?.compactionCount as number) ?? 0;
509
+
510
+ // Cache hit %
511
+ const cacheHitPct = cacheWrite > 0 ? Math.round((cacheRead / cacheWrite) * 100) : 0;
512
+ const cacheReadK = Math.round(cacheRead / 1000);
513
+ const cacheWriteK = Math.round(cacheWrite / 1000);
514
+
515
+ // Context %
516
+ const ctxPct = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 100) : 0;
517
+ const totalK = Math.round(totalTokens / 1000);
518
+ const ctxK = Math.round(contextWindow / 1000);
519
+
520
+ // Updated
521
+ const updatedAt = entry?.updatedAt as number | undefined;
522
+ const updatedStr = updatedAt ? relativeTime(now - updatedAt) : "never";
523
+
524
+ const sessionCounter = deviceSessionCounters.get(deviceId) ?? 0;
525
+ const sessionDisplay = sessionCounter > 0 ? `${sessionKey}#${sessionCounter}` : sessionKey;
526
+
527
+ const lines = [
528
+ `🦞 OpenClaw ${version}`,
529
+ `🧠 Model: ${modelDisplay}`,
530
+ `🧮 Tokens: ${tokIn} in / ${tokOut} out`,
531
+ `🗄️ Cache: ${cacheHitPct}% hit · ${cacheReadK}k cached, ${cacheWriteK}k new`,
532
+ `📚 Context: ${totalK}k/${ctxK}k (${ctxPct}%) · 🧹 Compactions: ${compactions}`,
533
+ `🧵 Session: ${sessionDisplay} • updated ${updatedStr}`,
534
+ `⚙️ Runtime: direct · Think: low`,
535
+ ];
536
+
537
+ return lines.join("\n");
538
+ }
539
+
540
+ function relativeTime(ms: number): string {
541
+ const s = Math.floor(ms / 1000);
542
+ if (s < 10) return "just now";
543
+ if (s < 60) return `${s}s ago`;
544
+ const m = Math.floor(s / 60);
545
+ if (m < 60) return `${m}m ago`;
546
+ const h = Math.floor(m / 60);
547
+ if (h < 24) return `${h}h ago`;
548
+ return `${Math.floor(h / 24)}d ago`;
549
+ }
550
+
551
+ /**
552
+ * 解析 sessions.json 路径
553
+ */
554
+ function resolveSessionsJsonPath(agentId: string): string {
555
+ return path.join(os.homedir(), ".openclaw", "agents", agentId, "sessions", "sessions.json");
556
+ }
557
+
558
+ /**
559
+ * 读取 sessions.json store
560
+ */
561
+ function loadSessionsStore(agentId: string): Record<string, Record<string, unknown>> {
562
+ try {
563
+ const raw = fs.readFileSync(resolveSessionsJsonPath(agentId), "utf-8");
564
+ return JSON.parse(raw) as Record<string, Record<string, unknown>>;
565
+ } catch {
566
+ return {};
250
567
  }
251
568
  }
569
+
570
+ /**
571
+ * 写回 sessions.json(用于 /status 的 buildStatusCard,只读不写)
572
+ */
573
+ function saveSessionsStore(agentId: string, store: Record<string, Record<string, unknown>>): void {
574
+ fs.writeFileSync(resolveSessionsJsonPath(agentId), JSON.stringify(store, null, 2), "utf-8");
575
+ }
576
+
577
+ // saveSessionsStore 保留给将来可能的用途,此处避免 unused 警告
578
+ void saveSessionsStore;
579
+
580
+ /**
581
+ * 覆盖 cfg.agents.defaults.model,这是 resolveDefaultModelForAgent 实际读取的字段。
582
+ * agents.list 为空时,defaults 是唯一生效的模型配置。
583
+ */
584
+ function patchAgentModel(cfg: OpenClawConfig, _agentId: string, modelOverride: string): OpenClawConfig {
585
+ const defaults = (cfg.agents?.defaults ?? {}) as Record<string, unknown>;
586
+ const existingModels = (defaults.models ?? {}) as Record<string, unknown>;
587
+ return {
588
+ ...cfg,
589
+ agents: {
590
+ ...(cfg.agents ?? {}),
591
+ defaults: {
592
+ ...defaults,
593
+ model: { primary: modelOverride },
594
+ models: { ...existingModels, [modelOverride]: {} },
595
+ },
596
+ },
597
+ } as unknown as OpenClawConfig;
598
+ }