@mrrisega/dsh-remote 0.8.0 → 0.8.1-beta.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.8.0",
3
+ "version": "0.8.1-beta.2",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 手动应用「待安装的插件更新」(桌面端专用)。
4
+ *
5
+ * 背景:桌面端应用运行期间**不能**改写它的 profile 目录(会让应用退出/重启)。
6
+ * 正常路径是:面板点「检查更新」→ 更新被暂存到 ~/.dsh-remote/pending-plugin/ +
7
+ * 写 `.plugin-update-pending.json` → 桥在**检测到应用退出后**自动替换。
8
+ *
9
+ * 但实测(2026-11-10)在部分机器上桥的"应用退出检测"没能触发(应用重启太快 /
10
+ * 桥被 launchd 重启导致计时清零 / 端口探测误判),于是用户反复重启仍停在旧版。
11
+ * 这个脚本就是那条链路的**手动、确定性版本**:应用完全退出后跑一次即可。
12
+ *
13
+ * 用法:
14
+ * node ~/.dsh-remote/clients/dsh-remote/apply-pending-plugin.mjs
15
+ * node ... --force # 跳过"应用是否在运行"的检查(仅在确认已退出时用)
16
+ * node ... --dry-run # 只看会做什么,不动文件
17
+ *
18
+ * 安全:先复制到 `<插件目录>.staging-<pid>` → 校验必需文件 → 原子 rename 替换;
19
+ * 失败保留旧版并回滚。绝不删除用户数据。
20
+ */
21
+
22
+ import fs from "node:fs";
23
+ import net from "node:net";
24
+ import path from "node:path";
25
+ import os from "node:os";
26
+ import { execSync } from "node:child_process";
27
+
28
+ const RELAY_DIR = process.env.DSH_RELAY_DIR || path.join(os.homedir(), ".dsh-remote");
29
+ const MARKER = path.join(RELAY_DIR, ".plugin-update-pending.json");
30
+ const APPLIED = path.join(RELAY_DIR, ".plugin-update-applied.json");
31
+ const REQUIRED = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"];
32
+
33
+ const args = new Set(process.argv.slice(2));
34
+ const force = args.has("--force");
35
+ const dryRun = args.has("--dry-run");
36
+
37
+ function fail(msg) {
38
+ console.error(`✖ ${msg}`);
39
+ process.exit(1);
40
+ }
41
+ function ok(msg) {
42
+ console.log(`✔ ${msg}`);
43
+ }
44
+
45
+ function readJson(file) {
46
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
47
+ }
48
+
49
+ /** 桌面应用的 web 上游是否还活着(活=应用没退干净)。 */
50
+ function probePort(port, timeoutMs = 500) {
51
+ return new Promise((resolve) => {
52
+ if (!port) return resolve(false);
53
+ const sock = net.connect({ host: "127.0.0.1", port });
54
+ let done = false;
55
+ const fin = (v) => { if (done) return; done = true; try { sock.destroy(); } catch { /* 忽略 */ } resolve(v); };
56
+ sock.setTimeout(timeoutMs, () => fin(false));
57
+ sock.once("connect", () => fin(true));
58
+ sock.once("error", () => fin(false));
59
+ });
60
+ }
61
+
62
+ /** 桌面应用进程是否在跑(macOS:DeepSeek Harness;Linux/其它:dsh web / electron)。 */
63
+ function appProcessRunning() {
64
+ try {
65
+ const out = execSync("ps -axo pid=,command=", { encoding: "utf8", timeout: 4000 });
66
+ const self = process.pid;
67
+ return out
68
+ .split("\n")
69
+ .filter(Boolean)
70
+ .some((line) => {
71
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
72
+ if (!m) return false;
73
+ if (Number(m[1]) === self) return false;
74
+ const cmd = m[2];
75
+ if (/apply-pending-plugin\.mjs/.test(cmd)) return false;
76
+ return /DeepSeek Harness\.app\/Contents\/MacOS\/DeepSeek Harness/.test(cmd)
77
+ || /(?:^|\s)dsh(?:\s|$)/.test(cmd) && /web/.test(cmd)
78
+ || /electron.*dsh/i.test(cmd);
79
+ });
80
+ } catch {
81
+ return false; // 检测不可用 → 不阻塞(有 --force 语义兜底)
82
+ }
83
+ }
84
+
85
+ /** 从状态/上次日志里猜上游端口(应用每次启动端口随机)。 */
86
+ function guessUpstreamPort() {
87
+ const st = readJson(path.join(RELAY_DIR, ".dsh-bridge-state.json")) || {};
88
+ for (const k of ["upstream", "upstream_url", "UPSTREAM"]) {
89
+ const v = st[k];
90
+ if (typeof v === "string" && /:\d+/.test(v)) {
91
+ try { return Number(new URL(v).port) || 0; } catch { /* 继续 */ }
92
+ }
93
+ }
94
+ return 0;
95
+ }
96
+
97
+ async function main() {
98
+ const marker = readJson(MARKER);
99
+ if (!marker) {
100
+ console.log("没有待安装的插件更新(marker 不存在)。");
101
+ console.log("如果面板刚点过「检查更新」,请先回到面板再点一次,然后再运行本命令。");
102
+ process.exit(0);
103
+ }
104
+ const stageDir = String(marker.stageDir || "");
105
+ const profileDir = String(marker.profileDir || "");
106
+ const name = String(marker.pluginDirName || "dsh-remote-web-plugin");
107
+ const version = String(marker.version || "?");
108
+ if (!stageDir || !profileDir || !fs.existsSync(stageDir)) {
109
+ fail(`暂存目录不存在:${stageDir || "(空)"}。请回面板重新点一次「检查更新」。`);
110
+ }
111
+ console.log(`待安装版本:${version}`);
112
+ console.log(`暂存目录:${stageDir}`);
113
+ console.log(`目标插件:${path.join(profileDir, name)}`);
114
+
115
+ const missing = REQUIRED.filter((rel) => !fs.existsSync(path.join(stageDir, rel)));
116
+ if (missing.length) fail(`暂存包不完整,缺少:${missing.join(", ")}。请回面板重新更新。`);
117
+
118
+ const port = guessUpstreamPort();
119
+ const portAlive = port ? await probePort(port) : false;
120
+ const procAlive = appProcessRunning();
121
+ const running = portAlive || procAlive;
122
+ if (running && !force) {
123
+ fail(
124
+ `检测到桌面应用仍在运行(${portAlive ? `127.0.0.1:${port} 在监听` : "进程还在"})。\n` +
125
+ ` 请先 Cmd+Q **完全退出** DeepSeek Harness(不是关窗口),再运行本命令。\n` +
126
+ ` 若你确认已退出仍报这个错:加 --force 跳过检查。`
127
+ );
128
+ }
129
+ if (running && force) console.warn(`⚠️ 应用可能仍在运行(端口 ${port} 在监听),按 --force 继续;若应用崩溃请重开。`);
130
+
131
+ const dest = path.join(profileDir, name);
132
+ const stage = `${dest}.staging-${process.pid}`;
133
+ const backup = `${dest}.backup-${process.pid}`;
134
+
135
+ if (dryRun) {
136
+ console.log(`[dry-run] 将把 ${stageDir} 原子替换到 ${dest}(当前版本 ${readJson(path.join(dest, "package.json"))?.version || "?"})`);
137
+ process.exit(0);
138
+ }
139
+
140
+ try {
141
+ fs.rmSync(stage, { recursive: true, force: true });
142
+ fs.cpSync(stageDir, stage, { recursive: true });
143
+ for (const rel of REQUIRED) {
144
+ if (!fs.existsSync(path.join(stage, rel))) throw new Error(`暂存包缺少 ${rel}`);
145
+ }
146
+ const had = fs.existsSync(dest);
147
+ if (had) {
148
+ fs.rmSync(backup, { recursive: true, force: true });
149
+ fs.renameSync(dest, backup);
150
+ }
151
+ try {
152
+ fs.renameSync(stage, dest);
153
+ } catch (e) {
154
+ if (had) { try { fs.renameSync(backup, dest); } catch { /* 忽略 */ } }
155
+ throw e;
156
+ }
157
+ if (had) { try { fs.rmSync(backup, { recursive: true, force: true }); } catch { /* 忽略 */ } }
158
+ try {
159
+ fs.writeFileSync(APPLIED, JSON.stringify({ version, appliedAt: Date.now(), profileDir, via: "manual-cli" }, null, 2) + "\n");
160
+ } catch { /* 忽略 */ }
161
+ try { fs.rmSync(MARKER, { force: true }); fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
162
+ const nowVer = readJson(path.join(dest, "package.json"))?.version || "?";
163
+ ok(`插件已更新:${version} → 当前 ${nowVer}`);
164
+ console.log("现在可以重新打开 DeepSeek Harness(打开后版本与更新应显示新版本)。");
165
+ } catch (e) {
166
+ try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* 忽略 */ }
167
+ fail(`安装失败(已保留旧版):${e && e.message ? e.message : e}`);
168
+ }
169
+ }
170
+
171
+ main();
@@ -50,7 +50,12 @@ import { EventEmitter } from "node:events";
50
50
  import WebSocket from "ws";
51
51
 
52
52
  import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
53
- import { formatForChannel } from "./im-format.mjs";
53
+ import {
54
+ SUMMARY_MAX_CHARS,
55
+ buildTurnNotification,
56
+ extractTurnSummary,
57
+ formatForChannel
58
+ } from "./im-format.mjs";
54
59
  // 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在进模型前拦下执行(三家共用)
55
60
  import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
56
61
  import {
@@ -1247,27 +1252,49 @@ export function createDingtalkBot(options = {}) {
1247
1252
  }
1248
1253
  }
1249
1254
 
1255
+ /**
1256
+ * 事件订阅 → 出站通知。
1257
+ * · turn/end、session-error:**一条**消息 = 标题行 + 总结正文(同 turn 已去重)
1258
+ * · 其它(审批/提问/简报…):沿用 toMarkdownNotification(审批卡片由别处处理,不混入总结)
1259
+ */
1250
1260
  function onEventNode(node) {
1251
1261
  if (!node || typeof node !== "object") return;
1252
1262
  if (typeof node.sessionId === "string" && node.sessionId) lastSeenSessionId = node.sessionId;
1253
1263
  if (stopped || !notifyAllowed || !state.authenticated) return;
1254
- // 回答回传:入站后的同一会话 turn/end → 把 agent 最终回答发回钉钉(与企微同语义)
1255
- if (shouldRelayAnswer(node)) {
1256
- const key = notifyKey(node);
1264
+ const key = notifyKey(node);
1265
+ if (node.kind === NODE_KINDS.TURN_END || node.kind === NODE_KINDS.SESSION_ERROR) {
1257
1266
  if (key && !rememberNotifyKey(key)) return;
1258
- const sid = node.sessionId;
1267
+ if (!notifyRateOk()) { log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)"); return; }
1259
1268
  lastInbound = { sessionId: "", at: 0 };
1260
- void relayAnswer(node, sid);
1269
+ void sendTurnNotice(node).catch(() => {});
1261
1270
  return;
1262
1271
  }
1263
1272
  const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
1264
1273
  if (!markdown) return;
1265
- const key = notifyKey(node);
1266
1274
  if (key && !rememberNotifyKey(key)) return;
1267
1275
  if (!notifyRateOk()) { log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)"); return; }
1268
1276
  void sendMarkdown(markdown).catch(() => {});
1269
1277
  }
1270
1278
 
1279
+ /** 任务完成/出错通知:标题 + 总结正文(优先订阅器 lastAssistantText;取不到降级仅标题)。日志只打长度。 */
1280
+ async function sendTurnNotice(node) {
1281
+ let summary = { text: "", source: "none" };
1282
+ try {
1283
+ summary = await extractTurnSummary({ node, subscriber, sessionId: node.sessionId, maxChars: SUMMARY_MAX_CHARS });
1284
+ } catch { summary = { text: "", source: "none" }; }
1285
+ if (stopped) return;
1286
+ const text = buildTurnNotification({
1287
+ node,
1288
+ channel: "dingtalk",
1289
+ summary: summary.text,
1290
+ sessionTitle: "",
1291
+ maxChars: SUMMARY_MAX_CHARS
1292
+ });
1293
+ if (!text) return;
1294
+ log.info(`任务通知 kind=${node.kind} reason=${node.reason || "?"} summary=${summary.source} len=${Array.from(summary.text).length}`);
1295
+ await sendMarkdown(text);
1296
+ }
1297
+
1271
1298
  const notifiedKeys = new Map();
1272
1299
  const notifyTimes = [];
1273
1300
  function notifyKey(node) {
@@ -1292,30 +1319,6 @@ export function createDingtalkBot(options = {}) {
1292
1319
  notifyTimes.push(at);
1293
1320
  return true;
1294
1321
  }
1295
- function shouldRelayAnswer(node) {
1296
- if (node.kind !== NODE_KINDS.TURN_END) return false;
1297
- if (node.fromSnapshot === true) return false;
1298
- if (!lastInbound.sessionId || node.sessionId !== lastInbound.sessionId) return false;
1299
- if (now() - lastInbound.at >= 10 * 60 * 1000) return false;
1300
- if (Number.isFinite(node.at) && node.at > 0 && node.at < lastInbound.at - 5000) return false;
1301
- return true;
1302
- }
1303
- async function relayAnswer(node, sessionId) {
1304
- let text = "";
1305
- const sub = subscriber;
1306
- if (sub && typeof sub.lastAssistantText === "function") {
1307
- try {
1308
- const r = await sub.lastAssistantText({ sessionId });
1309
- if (r && r.ok && typeof r.text === "string") text = r.text.trim();
1310
- } catch { /* 忽略 */ }
1311
- }
1312
- const markdown = text || toMarkdownNotification(node, { sessionTitle: options.sessionTitle }) || "";
1313
- if (!markdown) return;
1314
- if (!notifyRateOk()) { log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)"); return; }
1315
- const r = await sendMarkdown(markdown);
1316
- log.info(`回答回传 chat=${chatIdHash(state.boundChatId) || "?"} len=${markdown.length}${r && r.ok ? "" : ` 发送失败=${(r && r.reason) || "?"}`}`);
1317
- }
1318
-
1319
1322
  // ---------- 对外 API ----------
1320
1323
 
1321
1324
  function shortSession(sessionId) {
@@ -80,7 +80,12 @@ import {
80
80
  IM_BOT_STATUS_FILE,
81
81
  APPROVAL_CARD_TTL_MS
82
82
  } from "./wecom-bot.mjs";
83
- import { formatForChannel } from "./im-format.mjs";
83
+ import {
84
+ SUMMARY_MAX_CHARS,
85
+ buildTurnNotification,
86
+ extractTurnSummary,
87
+ formatForChannel
88
+ } from "./im-format.mjs";
84
89
  // 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在进模型前拦下执行(三家共用)
85
90
  import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
86
91
  import {
@@ -1544,6 +1549,26 @@ export function createFeishuBotController(options = {}) {
1544
1549
  log.warn("通知超过每分钟上限,已丢弃(防事件风暴)");
1545
1550
  return;
1546
1551
  }
1552
+ // 任务完成/出错:**一条**消息 = 标题行 + 总结正文(同 turn 已去重;审批类不混入总结)
1553
+ if (node.kind === NODE_KINDS.TURN_END || node.kind === NODE_KINDS.SESSION_ERROR) {
1554
+ const key = notifyKey(node);
1555
+ if (key && !rememberNotifyKey(key)) return;
1556
+ let summary = { text: "", source: "none" };
1557
+ try {
1558
+ summary = await extractTurnSummary({ node, subscriber, sessionId: node.sessionId, maxChars: SUMMARY_MAX_CHARS });
1559
+ } catch { summary = { text: "", source: "none" }; }
1560
+ const text = buildTurnNotification({
1561
+ node,
1562
+ channel: "feishu",
1563
+ summary: summary.text,
1564
+ sessionTitle: String((configNow && configNow.sessionTitle) || ""),
1565
+ maxChars: SUMMARY_MAX_CHARS
1566
+ });
1567
+ if (!text) return;
1568
+ log.info(`任务通知 kind=${node.kind} reason=${node.reason || "?"} summary=${summary.source} len=${Array.from(summary.text).length}`);
1569
+ await bot.sendText(target, text);
1570
+ return;
1571
+ }
1547
1572
  // 审批:优先卡片(可点按钮)
1548
1573
  if (node.kind === NODE_KINDS.APPROVAL_REQUEST) {
1549
1574
  const key = notifyKey(node);
@@ -35,6 +35,8 @@ export const CHANNEL_CAPS = Object.freeze({
35
35
 
36
36
  /** 截断提示(与 wecom-bot 的 ANSWER_TRUNCATED_NOTE 同文,便于去重)。 */
37
37
  export const TRUNCATED_NOTE = "…(已截断,完整内容看电脑)";
38
+ /** 完成/出错通知里「总结正文」的长度上限(2026-11-10 业主:只有标题没有总结)。 */
39
+ export const SUMMARY_MAX_CHARS = 1500;
38
40
 
39
41
  /** 各通道默认字节上限(wecom 官方 4096;其它先给宽松值,留 maxBytes 覆盖)。 */
40
42
  const DEFAULT_MAX_BYTES = Object.freeze({
@@ -370,3 +372,106 @@ export function formatForChannel(channel, options = {}) {
370
372
  }
371
373
  return fitted;
372
374
  }
375
+
376
+
377
+ // ===========================================================================
378
+ // 任务总结(turn_done / turn_error / turn_blocked 通知带正文)
379
+ // ===========================================================================
380
+
381
+ function charsOf(text) { return Array.from(String(text == null ? "" : text)); }
382
+
383
+ function truncateChars(text, maxChars) {
384
+ const chars = charsOf(text);
385
+ if (!Number.isFinite(maxChars) || maxChars <= 0 || chars.length <= maxChars) return String(text == null ? "" : text);
386
+ return `${chars.slice(0, Math.max(1, maxChars - 1)).join("")}…`;
387
+ }
388
+
389
+ /** 截断到 maxChars(含「已截断」提示;与 formatForChannel 的提示同文,避免重复)。 */
390
+ function truncateCharsWithNote(text, maxChars) {
391
+ let body = String(text == null ? "" : text);
392
+ const hadNote = body.endsWith(TRUNCATED_NOTE);
393
+ if (hadNote) body = body.slice(0, -TRUNCATED_NOTE.length).replace(/\s+$/, "");
394
+ const chars = charsOf(body);
395
+ if (!Number.isFinite(maxChars) || maxChars <= 0) return hadNote ? `${body}${TRUNCATED_NOTE}` : body;
396
+ const noteChars = charsOf(TRUNCATED_NOTE).length + 1; // 换行 + 提示一起算进上限
397
+ if (chars.length + noteChars <= maxChars) return hadNote ? `${body}${TRUNCATED_NOTE}` : body;
398
+ const budget = Math.max(1, maxChars - noteChars);
399
+ return `${chars.slice(0, budget).join("").replace(/\s+$/, "")}\n${TRUNCATED_NOTE}`;
400
+ }
401
+
402
+ /** 事件里能当"摘要"的字段(订阅器取不到正文时的降级来源)。 */
403
+ export function extractSummaryFromNode(node) {
404
+ if (!node || typeof node !== "object") return "";
405
+ for (const key of ["summary", "message", "detail", "text"]) {
406
+ const v = node[key];
407
+ if (typeof v === "string" && v.trim()) return v.trim();
408
+ }
409
+ return "";
410
+ }
411
+
412
+ /**
413
+ * 提取一次 turn 的**总结正文**(优先级:订阅器 lastAssistantText → 事件字段 → 空)。
414
+ *
415
+ * · `subscriber.lastAssistantText({sessionId})` 是最准的(agent 最终回答原文);
416
+ * · turn/end 瞬间正文可能还没落库 → 支持 `retryDelays`(如 [0,400])短重试;
417
+ * · 都取不到 → 返回空 text(调用方降级"仅标题",不报错);
418
+ * · 返回值只含正文;**日志由调用方只打长度**(正文可能有代码/密钥/隐私)。
419
+ *
420
+ * @returns {Promise<{text:string, source:"assistant"|"node"|"none"}>}
421
+ */
422
+ export async function extractTurnSummary({ node, subscriber, sessionId, maxChars = SUMMARY_MAX_CHARS, retryDelays = null } = {}) {
423
+ const sid = String(sessionId || (node && node.sessionId) || "");
424
+ const delays = Array.isArray(retryDelays) && retryDelays.length ? retryDelays : [0];
425
+ let lastEmpty = "";
426
+ if (subscriber && typeof subscriber.lastAssistantText === "function" && sid) {
427
+ for (const delay of delays) {
428
+ if (Number(delay) > 0) {
429
+ try { await new Promise((r) => setTimeout(r, Math.min(Number(delay) || 0, 5000))); } catch { /* 忽略 */ }
430
+ }
431
+ try {
432
+ const r = await subscriber.lastAssistantText({ sessionId: sid });
433
+ if (r && r.ok === true && typeof r.text === "string" && r.text.trim()) {
434
+ return { text: truncateCharsWithNote(r.text.trim(), maxChars), source: "assistant" };
435
+ }
436
+ lastEmpty = String((r && (r.code || r.message)) || "empty");
437
+ } catch { /* 下一次重试 */ }
438
+ }
439
+ }
440
+ const fallback = extractSummaryFromNode(node);
441
+ if (fallback) return { text: truncateCharsWithNote(fallback, maxChars), source: "node" };
442
+ return { text: "", source: "none", reason: lastEmpty };
443
+ }
444
+
445
+ /** 各 reason 的通知首行(与既有通知文案保持一致)。 */
446
+ export function turnHeadline(node) {
447
+ if (!node || typeof node !== "object") return "";
448
+ const kind = String(node.kind || "");
449
+ if (kind === "session-error") return "❌ 出错了";
450
+ if (kind !== "turn-end") return "";
451
+ const reason = String(node.reason || "unknown");
452
+ if (reason === "completed") return "✅ 任务完成";
453
+ if (reason === "error" || reason === "max-tokens") return "❌ 出错了";
454
+ if (reason === "blocked") return "⛔ 被拦下了";
455
+ if (reason === "aborted" || reason === "interrupted") return "⏹ 已停止";
456
+ return "ℹ️ 任务结束";
457
+ }
458
+
459
+ /**
460
+ * 组装「任务完成/出错」通知:`✅ 任务完成|<标题>\n\n<总结正文>`。
461
+ *
462
+ * · 正文为空 → 只返回标题行(降级,不报错);
463
+ * · 正文按通道渲染(表格转可读列表、按通道字节上限截断),并限制 **1500 字**;
464
+ * · 非 turn/session-error 事件返回 null(审批类仍走卡片,不混入总结)。
465
+ *
466
+ * @returns {string|null}
467
+ */
468
+ export function buildTurnNotification({ node, channel = "wecom", summary = "", sessionTitle = "", maxChars = SUMMARY_MAX_CHARS } = {}) {
469
+ const head = turnHeadline(node);
470
+ if (!head) return null;
471
+ const title = truncateChars(String(sessionTitle || "").replace(/\s+/g, " ").trim(), 40);
472
+ const line = title ? `${head}|${title}` : head;
473
+ const raw = String(summary == null ? "" : summary).trim();
474
+ if (!raw) return line; // 取不到正文 → 保持"仅标题"
475
+ const formatted = formatForChannel(channel, { text: raw, maxChars });
476
+ return formatted ? `${line}\n\n${formatted}` : line;
477
+ }
@@ -52,7 +52,12 @@ import WebSocket from "ws";
52
52
 
53
53
  import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
54
54
  // 通道感知格式化(2026-11-08:企微/钉钉 markdown 不支持表格,发前统一适配;飞书/微信各有分支)
55
- import { formatForChannel } from "./im-format.mjs";
55
+ import {
56
+ SUMMARY_MAX_CHARS,
57
+ buildTurnNotification,
58
+ extractTurnSummary,
59
+ formatForChannel
60
+ } from "./im-format.mjs";
56
61
  // 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在**进模型前**拦下执行;
57
62
  // 解析/文案/能力门禁三家共用(与会员升级文案同口径)。
58
63
  import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
@@ -2053,102 +2058,42 @@ export function createWecomBot(options = {}) {
2053
2058
  }
2054
2059
 
2055
2060
  /**
2056
- * 这个 turn/end 是不是「我们刚转发进去的那次对话」的收尾?
2061
+ * 任务通知(完成/出错/被拦/停止):**一条**消息 = 标题行 + 总结正文。
2057
2062
  *
2058
- * 判据(宁可不回传,也不要瞎回传):
2059
- * · 必须**实时** turn/end(`fromSnapshot` = 重连时补报的旧 turn,绝不能拿旧回答当新回答);
2060
- * · 会话必须是最近一次入站转发成功的那个;
2061
- * · 距那次入站 < TTL(默认 10 分钟)—— 超时就当另一次对话,走原来的标题通知;
2062
- * · 事件时间不能早于入站时间(给 5s 时钟/批处理余量)。
2063
+ * 正文优先订阅器 `lastAssistantText({sessionId})`(turn/end 瞬间可能还没落库 → 沿用旧短重试),
2064
+ * 取不到再用事件字段、再取不到降级"仅标题";正文按企业微信通道渲染并限制 1500 字。
2065
+ * **日志只打长度/来源,绝不打印正文**(可能有代码/密钥/隐私)。
2063
2066
  */
2064
- function shouldRelayAnswer(node) {
2065
- if (!answerRelayEnabled) return false;
2066
- if (node.kind !== NODE_KINDS.TURN_END) return false;
2067
- if (node.fromSnapshot === true) return false;
2068
- if (!lastInbound.sessionId || node.sessionId !== lastInbound.sessionId) return false;
2069
- const at = now();
2070
- if (at - lastInbound.at >= answerRelayTtlMs) return false;
2071
- if (Number.isFinite(node.at) && node.at > 0 && node.at < lastInbound.at - 5000) return false;
2072
- return true;
2067
+ async function sendTurnNotice(node, target) {
2068
+ let summary = { text: "", source: "none" };
2069
+ try {
2070
+ summary = await extractTurnSummary({
2071
+ node,
2072
+ subscriber: answerRelayEnabled ? subscriber : null,
2073
+ sessionId: node.sessionId,
2074
+ maxChars: SUMMARY_MAX_CHARS,
2075
+ retryDelays: answerRelayEnabled ? ANSWER_FETCH_RETRY_DELAYS_MS : null
2076
+ });
2077
+ } catch { summary = { text: "", source: "none" }; }
2078
+ if (stopped) return;
2079
+ const text = buildTurnNotification({
2080
+ node,
2081
+ channel: "wecom",
2082
+ summary: summary.text,
2083
+ sessionTitle: titleOfSession(node.sessionId, ""),
2084
+ maxChars: SUMMARY_MAX_CHARS
2085
+ });
2086
+ if (!text) return;
2087
+ log.info(`任务通知 kind=${node.kind} reason=${node.reason || "?"} summary=${summary.source} len=${Array.from(summary.text).length}`);
2088
+ sendMarkdown(target, text);
2073
2089
  }
2074
2090
 
2075
2091
  /**
2076
- * 回答回传:把 agent 的最终回答正文发回企微(不再发「✅ 任务完成|标题」那条)。
2077
- *
2078
- * 取正文用事件订阅器的 `lastAssistantText({sessionId})`(dsh-events 已有,永不抛);
2079
- * 取不到 → **降级退回原来的标题通知**(不丢消息,也不假装有正文);
2080
- * 日志只打长度/会话短 id,**绝不打印正文**(回答里可能有代码/密钥/隐私)。
2092
+ * 事件订阅 → 出站通知。
2093
+ * · turn/end、session-error:**一条**消息 = 标题行 + 总结正文(sendTurnNotice;同 turn 已去重)
2094
+ * · 审批/提问/计划:优先可点卡片(**不混入总结**),卡片失败退回文本
2095
+ * · 其它(简报/额度/会员/故障):沿用 toMarkdownNotification
2081
2096
  */
2082
- async function relayAnswer(node, sessionId) {
2083
- const target = state.boundChatId || targetChatId;
2084
- if (!target) {
2085
- const at = now();
2086
- if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
2087
- lastNoTargetLogAt = at;
2088
- log.info("有回答要回传,但还没绑定接收方:请先在企业微信里给机器人发一句话(或在配置里填 targetChatId)");
2089
- }
2090
- return;
2091
- }
2092
- let text = "";
2093
- let why = "";
2094
- const sub = subscriber;
2095
- if (!sub || typeof sub.lastAssistantText !== "function") {
2096
- why = "no_subscriber";
2097
- } else {
2098
- for (const delay of ANSWER_FETCH_RETRY_DELAYS_MS) {
2099
- if (delay > 0) {
2100
- await sleepTimer(delay);
2101
- if (stopped) return; // 等待期间桥已退出:不再发
2102
- }
2103
- try {
2104
- const r = await sub.lastAssistantText({ sessionId });
2105
- if (r && r.ok && typeof r.text === "string" && r.text.trim()) {
2106
- text = r.text.trim();
2107
- break;
2108
- }
2109
- why = String((r && (r.code || r.message)) || "empty");
2110
- // 硬失败(page-failed / session-not-found)重试也没意义;只有「ok 但正文还没落库」才补一次
2111
- if (!(r && r.ok)) break;
2112
- } catch (e) {
2113
- why = truncateText(e && e.message ? e.message : e, 60);
2114
- break;
2115
- }
2116
- }
2117
- }
2118
-
2119
- // 降级:拿不到正文 → 退回标题通知(与改动前的行为一致)
2120
- if (!text) {
2121
- const fallback = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
2122
- if (!fallback) return;
2123
- if (!notifyRateOk()) {
2124
- log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
2125
- return;
2126
- }
2127
- state.notified += 1;
2128
- sendMarkdown(target, fallback);
2129
- log.info(`取不到回答正文(${truncateText(why || "empty", 60)}),已降级为标题通知`);
2130
- return;
2131
- }
2132
-
2133
- const truncated = text.length > answerMaxChars;
2134
- let body = truncated ? `${text.slice(0, answerMaxChars)}${ANSWER_TRUNCATED_NOTE}` : text;
2135
- // 非正常结束(出错/被拦/中止)时保留一句状态行,避免"看到回答却不知道它不完整"
2136
- const reasonKind = String(node.reason || "");
2137
- if (reasonKind && reasonKind !== "completed") {
2138
- const status = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
2139
- if (status) body = `${status}\n\n${body}`;
2140
- }
2141
- if (!notifyRateOk()) {
2142
- log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
2143
- return;
2144
- }
2145
- state.notified += 1;
2146
- state.answers += 1;
2147
- const sent = sendMarkdown(target, body);
2148
- // 只打长度与截断标记:正文绝不能进日志
2149
- log.info(`回答回传 chat=${chatIdHash(target)} len=${body.length}${truncated ? "(已截断)" : ""}${sent && sent.ok ? "" : ` 发送失败=${(sent && sent.reason) || "?"}`}`);
2150
- }
2151
-
2152
2097
  function onEventNode(node) {
2153
2098
  if (!node || typeof node !== "object") return;
2154
2099
  if (typeof node.sessionId === "string" && node.sessionId) {
@@ -2156,19 +2101,31 @@ export function createWecomBot(options = {}) {
2156
2101
  scheduleSessionRefresh();
2157
2102
  }
2158
2103
  if (stopped || !notifyAllowed || !state.authenticated) return;
2159
- // ── 回答回传:入站后的同一会话 turn/end → 发回答正文(消费一次)──
2160
- if (shouldRelayAnswer(node)) {
2161
- const key = notifyKey(node);
2104
+ const key = notifyKey(node);
2105
+ // ── 任务完成/出错:标题 + 总结正文(一次 turn 只发一条)──
2106
+ if (node.kind === NODE_KINDS.TURN_END || node.kind === NODE_KINDS.SESSION_ERROR) {
2162
2107
  if (key && !rememberNotifyKey(key)) return;
2163
- const sid = node.sessionId;
2108
+ const target = state.boundChatId || targetChatId;
2109
+ if (!target) {
2110
+ const at = now();
2111
+ if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
2112
+ lastNoTargetLogAt = at;
2113
+ log.info("有任务通知要发,但还没绑定接收方:请先在企业微信里给机器人发一句话(或在配置里填 targetChatId)");
2114
+ }
2115
+ return;
2116
+ }
2117
+ if (!notifyRateOk()) {
2118
+ log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
2119
+ return;
2120
+ }
2121
+ state.notified += 1;
2164
2122
  lastInbound = { sessionId: "", at: 0 }; // 同一次入站只回传一次
2165
- void relayAnswer(node, sid);
2123
+ void sendTurnNotice(node, target);
2166
2124
  return;
2167
2125
  }
2168
2126
  // ── 审批类事件:优先发**可点按钮的卡片**(允许一次/拒绝),发不出去再退回 markdown ──
2169
2127
  if (node.kind === NODE_KINDS.APPROVAL_REQUEST) {
2170
2128
  const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
2171
- const key = notifyKey(node);
2172
2129
  if (key && !rememberNotifyKey(key)) return;
2173
2130
  const requestId = String(node.eventId || "");
2174
2131
  const target = state.boundChatId || targetChatId;
@@ -2200,11 +2157,9 @@ export function createWecomBot(options = {}) {
2200
2157
  }
2201
2158
  const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
2202
2159
  if (!markdown) return;
2203
- const key = notifyKey(node);
2204
2160
  if (key && !rememberNotifyKey(key)) return;
2205
2161
  const target = state.boundChatId || targetChatId;
2206
2162
  if (!target) {
2207
- // 首次使用:用户还没给机器人发过话,也没有配 targetChatId → 无处可推(如实记日志,不刷屏)
2208
2163
  const at = now();
2209
2164
  if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
2210
2165
  lastNoTargetLogAt = at;
@@ -2232,7 +2187,6 @@ export function createWecomBot(options = {}) {
2232
2187
 
2233
2188
  function rememberNotifyKey(key) {
2234
2189
  const at = now();
2235
- // 有界 + TTL:Map 保插入序,超上限丢最旧
2236
2190
  for (const [k, t] of notifiedKeys) {
2237
2191
  if (at - t > 10 * 60 * 1000) notifiedKeys.delete(k);
2238
2192
  else break;
@@ -149,7 +149,7 @@ function findDepWs(startDir) {
149
149
  * 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
150
150
  * 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。`src/channel-ref.mjs` 同理(本安装器也 import)。
151
151
  */
152
- const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "im-commands.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
152
+ const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "im-commands.mjs", "apply-pending-plugin.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
153
153
 
154
154
  /**
155
155
  * 把"客户端脚本"补齐到配置目录。