@shgroup/dsh-serenity-hooks 1.90.0 → 1.92.0
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/dsh.plugin.json +1 -1
- package/lib/channels-CuCsUfcY.js +602 -0
- package/lib/im/bridge.d.ts +2 -2
- package/lib/im/guard.d.ts +7 -1
- package/lib/im/hook.d.ts +14 -6
- package/lib/im/inbound-event.d.ts +14 -14
- package/lib/index.js +255 -628
- package/lib/kernel/ccc.d.ts +0 -34
- package/lib/{route-3OzOAjec.js → route-DxIKWeN9.js} +0 -2
- package/lib/{session-Bb4GSSFJ.js → session-D5cEPEUY.js} +1 -1
- package/lib/tools/msm/service.d.ts +34 -1
- package/lib/trajectory/core.d.ts +6 -3
- package/lib/trajectory/interrupted.d.ts +8 -29
- package/lib/trajectory/session.d.ts +1 -1
- package/package.json +1 -1
- package/lib/settings-DAorSseV.js +0 -145
package/lib/index.js
CHANGED
|
@@ -4,17 +4,17 @@ import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as
|
|
|
4
4
|
import { A as namingTitleFor, B as summarize, C as createSession, E as getActiveSessionInfo, F as resolveSessionByTitle, G as readTrajectoryFile, H as useSession, I as sessionEvents, J as localHuman, K as isoLocal, L as sessionsRoot, M as parseTrajectoryCode, O as listActiveTrajectories, P as readTrajectoryUid, R as setActiveSessionInfo, S as clearActiveSessionInfo, T as findSession, U as TRAJECTORY_FILENAME, V as trajectoryDescOf, W as TRAJECTORY_LEGACY_FILENAMES, Y as localIdStamp, b as UID_FILENAME, c as replacesDefaultIdentity, i as listTrajectoryConstraints, j as parseSessionContextFromEvents, k as listSessions, l as trajectoryCodeForSession, n as findDirNameByCode, o as readTrajectoryConstraintAt, q as localFileStamp, r as isTrajectoryConstrained, s as readTrajectoryConstraintByCode, t as TRAJECTORY_CONSTRAINT_FILENAME, u as trajectoryConstraintForSession, v as COGNITIVE_TRAJECTORY_ACTIONS, w as extractSessionMdPathFromText, x as archiveSession, z as showSession } from "./profile-zxakCNXy.js";
|
|
5
5
|
import { n as checkLocalstoreGitCompliance, o as readStore, s as runLocalStore, t as LOCALSTORE_SCOPES } from "./service-DDmHSglV.js";
|
|
6
6
|
import { _ as resolveConstraintSystemPrompt, a as listBoundSessionIds, d as constraintCognitiveTrajectory, f as constraintSystemPromptSource, g as profileWriteWhitelist, h as profileToolWhitelist, i as carrierState, l as TRAJECTORY_CONSTRAINT_HINT, n as PLUGIN_SOURCE, o as readLastBound, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, t as ACC_MESSAGE_KIND, v as ACC_VERSION, y as findPackageRoot } from "./message-source-BAMwXu6i.js";
|
|
7
|
-
import { A as buildResourceIndex, C as verifyPassword, D as recordToolUsage, E as verifyTotpCode, F as hasTrajectoryPrompt, M as isSafeSkillName, N as truncateContent, O as REFERENCE_DIR, P as TRAJECTORY_PROMPT_FILENAME, S as toWire, T as MECHANISM_PORTS, _ as registerEntrySkillSectionGlobal, a as trajectoryConstraintFor, b as projectKnownWorkspaces, c as trajectoryWorkspaceMdPath, d as CHEAP_WORKER_GUIDE, f as providerAvailabilityError, g as registerEntrySkillSection, h as registerCognitiveTrajectorySkillSection, i as getTrajectoryAgent, j as buildResourceNameSuffix, k as SKILL_ONTOLOGY_FILE, l as unregisterTrajectorySession, m as splitModel, n as createTrajectoryAgent, o as trajectoryConstraintMsmGate, p as requireWhitelistedModel, r as ensureWorkspacePromptSection, s as trajectoryConstraintParticipates, t as askTrajectory, u as workspaceCognitiveTrajectoryLine, v as applyWirePatch, w as FACE_PORTS, x as readAdvancedSettings, y as migrateLegacyLocalstore } from "./session-
|
|
7
|
+
import { A as buildResourceIndex, C as verifyPassword, D as recordToolUsage, E as verifyTotpCode, F as hasTrajectoryPrompt, M as isSafeSkillName, N as truncateContent, O as REFERENCE_DIR, P as TRAJECTORY_PROMPT_FILENAME, S as toWire, T as MECHANISM_PORTS, _ as registerEntrySkillSectionGlobal, a as trajectoryConstraintFor, b as projectKnownWorkspaces, c as trajectoryWorkspaceMdPath, d as CHEAP_WORKER_GUIDE, f as providerAvailabilityError, g as registerEntrySkillSection, h as registerCognitiveTrajectorySkillSection, i as getTrajectoryAgent, j as buildResourceNameSuffix, k as SKILL_ONTOLOGY_FILE, l as unregisterTrajectorySession, m as splitModel, n as createTrajectoryAgent, o as trajectoryConstraintMsmGate, p as requireWhitelistedModel, r as ensureWorkspacePromptSection, s as trajectoryConstraintParticipates, t as askTrajectory, u as workspaceCognitiveTrajectoryLine, v as applyWirePatch, w as FACE_PORTS, x as readAdvancedSettings, y as migrateLegacyLocalstore } from "./session-D5cEPEUY.js";
|
|
8
8
|
import { i as sessionsRootDir, n as findSessionLog, r as hasSessionLogById } from "./cleanup-CPl1SSaK.js";
|
|
9
|
-
import { n as readSimpleSettings, r as registerSettingsSection } from "./
|
|
10
|
-
import { a as readFeishuCredential, c as readWeixinSettings, d as weixinInboundDir, f as weixinSessionIdForTrajectory, i as matchWeixinRoute, n as extractWeixinText,
|
|
9
|
+
import { i as runImBridge, n as isImWiredTrajectory, o as readSimpleSettings, r as registerImChannel, s as registerSettingsSection, t as imChannelIds } from "./channels-CuCsUfcY.js";
|
|
10
|
+
import { a as readFeishuCredential, c as readWeixinSettings, d as weixinInboundDir, f as weixinSessionIdForTrajectory, i as matchWeixinRoute, n as extractWeixinText, r as hasVoiceItem, s as readWeixinCredential, t as extractWeixinMedia, u as sanitizeFileName$1 } from "./route-DxIKWeN9.js";
|
|
11
11
|
import { REGISTERED_TOOLS } from "./invariant.js";
|
|
12
12
|
import { c as sendTextMessage, i as getUpdates, l as sendTyping, n as downloadMedia, o as markdownToPlainText, r as getConfig, s as sendFileMessage, t as TypingStatus, u as sniffImageExt } from "./ilink-CsZ-f64q.js";
|
|
13
13
|
import { a as registerImAccountImpl, i as normalizeAccountChannel, n as getImAccountImpl, r as imAccountChannels } from "./accounts-BHQbakUh.js";
|
|
14
14
|
import { createRequire } from "node:module";
|
|
15
15
|
import z from "@deepseek-ai/schemastery";
|
|
16
16
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
17
|
-
import { basename, dirname, extname,
|
|
17
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
18
18
|
import { appendFileSync, chmodSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
20
20
|
import { homedir, hostname, platform } from "node:os";
|
|
@@ -1373,36 +1373,30 @@ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC
|
|
|
1373
1373
|
历史(v1.30.9~v1.30.x):CCC 侧 MSM weixin-send(走 loopback HTTP 入口 3082)——
|
|
1374
1374
|
v1.31.0 已退役(脚本删除 + 注册表注销);3082 入口保留给**非 agent 调用者**。
|
|
1375
1375
|
|
|
1376
|
-
▸
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
代价(显式):角色失去"故意静默"能力(它总会产出一段最终文本)→ 需严格静默的角色保持缺省。
|
|
1396
|
-
三态:① autoReplyWithLastMessage=true(缺省)桥总是回发;② =false + fallbackOnNoSend=true
|
|
1397
|
-
= agent 优先、桥兜底(推荐);③ 两者皆 false(缺省)= 严格静默(v1.30.10 语义)。
|
|
1398
|
-
⚠️ 兜底只在**闸门装配成功**时生效(未装配则无法判定是否发送过 → 不冒重复发送风险)。
|
|
1376
|
+
▸ 🔴🔴 **出站只有一条路:agent 自己调 im-bridge**(2026-10-24 起,owner 令)。
|
|
1377
|
+
桥**永不**替你回发最终文本 —— 你说什么、说几条、什么时候说、要不要不说,全由你决定。
|
|
1378
|
+
⛔ 不要指望"我把话写进回答里就会自动发出去":**不会**。必须真的调工具。
|
|
1379
|
+
⇒ 每轮用户消息末尾都会附一行**已填好参数**的 im-bridge 调用模板(机制标记
|
|
1380
|
+
[serenity:weixin-manual-output]),照它改 text 即可。
|
|
1381
|
+
⇒ 本轮结束仍未成功调用(send / send-file)→ 你会被打回(≤2 次,提示 = 标记 + 事实);
|
|
1382
|
+
达上限后**用户将收不到任何消息**(没有任何兜底转发)。
|
|
1383
|
+
⇒ 成功调用才算送达;失败调用不算(不会被当成本轮已发送)。
|
|
1384
|
+
⚠️ **前提**:本轨迹必须在 \`.opencode/serenity.json\` 的 \`im.routes\` 里被点名,否则 \`im-bridge\`
|
|
1385
|
+
对你不**可见**——那种情况下闸门不会打回你(改成一条响亮告警,指明"该轨迹无发送权"),
|
|
1386
|
+
因为打回一个你根本满足不了的要求只会空转。
|
|
1387
|
+
|
|
1388
|
+
▸ **已退役(不要再配,配了也不生效)**:
|
|
1389
|
+
· \`weixin.autoReplyWithLastMessage\`(v1.30.10~2026-10-24)= 桥替 agent 回发最终文本。
|
|
1390
|
+
**被否理由**(owner 原话):「autoReply 并不合适,主动性的 imBridge 仍然是更好的」——
|
|
1391
|
+
它让 agent 失去"说几条/何时说/要不要不说"的决定权,而那正是主动发送的价值。
|
|
1392
|
+
· \`weixin.fallbackOnNoSend\`(v1.30.17~2026-10-24)= 手动模式下 agent 没发成功时桥兜底转发。
|
|
1393
|
+
它只在上面那个开关 =false 时才有意义 ⇒ 一起退役。
|
|
1394
|
+
⚠️ 旧配置里残留这两个键**静默忽略**(不会报错,也不会生效)。
|
|
1399
1395
|
|
|
1400
1396
|
Config:
|
|
1401
1397
|
{ "weixin": {
|
|
1402
1398
|
"enabled": true,
|
|
1403
1399
|
"hook": "scripts/weixin-message-hook.ts",
|
|
1404
|
-
"autoReplyWithLastMessage": true,
|
|
1405
|
-
"fallbackOnNoSend": false,
|
|
1406
1400
|
"accounts": [{ "accountId": "wechat-1", "name": "家庭助手", "enabled": true }],
|
|
1407
1401
|
"routes": [{ "user": "*", "role": "my-role" }]
|
|
1408
1402
|
} }
|
|
@@ -1414,9 +1408,9 @@ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC
|
|
|
1414
1408
|
触发(双向):
|
|
1415
1409
|
incoming = 用户 → bot:路由命中后、媒体落盘后触发(文本含语音转写;媒体带落盘 relPath)
|
|
1416
1410
|
outgoing = bot → 用户:发送成功后触发(reply = 用户实际收到的纯文本,已剥离 think)
|
|
1417
|
-
· source="
|
|
1418
|
-
|
|
1419
|
-
|
|
1411
|
+
· source="proactive" = agent 经 im-bridge 主动发起(**2026-10-24 起这是唯一值**)
|
|
1412
|
+
· 历史值 reply / reply-fallback(桥自动回发 / 桥兜底转发)已随两个开关一起退役;
|
|
1413
|
+
存量日志里仍能看到它们,那是历史事实,不是当前行为
|
|
1420
1414
|
· file(仅 send-file 补记):{ "name": "报告.pdf", "size": 12345, "caption": "可选" }
|
|
1421
1415
|
|
|
1422
1416
|
脚本约定(事件 JSON 单行经 stdin 传入;bun 优先 node 兜底):
|
|
@@ -1431,7 +1425,7 @@ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC
|
|
|
1431
1425
|
"message": { "text": "你好", "media": [{ "kind": "image", "relPath": "_tmp/weixin-inbound/<hash>/img_x.jpg" }] } }
|
|
1432
1426
|
{ "event": "outgoing", "ts": ..., "cccRoot": ..., "accountId": ...,
|
|
1433
1427
|
"userId": ..., "sessionId": ..., "role": ...,
|
|
1434
|
-
"reply": "已记录(纯文本)", "source": "
|
|
1428
|
+
"reply": "已记录(纯文本)", "source": "proactive" }
|
|
1435
1429
|
|
|
1436
1430
|
示例脚本(追加到按日文件——持久化归 CCC 自选:文件/DB/远端均可):
|
|
1437
1431
|
const fs = require('node:fs'); const p = '/path/ccc/AGENT_SESSIONS/_weixin-log.jsonl';
|
|
@@ -1541,6 +1535,51 @@ function hasCliEntry(absPath) {
|
|
|
1541
1535
|
return /\b(?:async\s+)?function\s+main\s*\(/.test(text);
|
|
1542
1536
|
}
|
|
1543
1537
|
/**
|
|
1538
|
+
* **把 issue 列表压成「按检查码分组的一行摘要」**(D237:成功/失败类调用的返回精简)。
|
|
1539
|
+
*
|
|
1540
|
+
* ## 为什么需要它(owner 2026-10-24 令)
|
|
1541
|
+
*
|
|
1542
|
+
* > 「tool 的返回内容会影响 Agent 注意力……对于返回成功失败类的调用,如果成功要大幅精简;
|
|
1543
|
+
* > 对于其它的调用,简练其结构,在确保信息有效的情况下降低大小」
|
|
1544
|
+
*
|
|
1545
|
+
* 实测症状(本仓真读数):`container_admin msm check` 返回 **36 条 issue,每条一个三字段对象**,
|
|
1546
|
+
* 而其中 **M1 有 20 条、detail 逐字相同**(`no .test.ts / .spec.ts`)、**M3 有 16 条**(同样同 detail)。
|
|
1547
|
+
* ⇒ 同一句话重复 20 遍**不携带额外信息**,只吃掉注意力。
|
|
1548
|
+
*
|
|
1549
|
+
* ## 判据(照 D237 分档)
|
|
1550
|
+
*
|
|
1551
|
+
* - **有 issue(失败面)⇒ 不省略任何一条**:失败条目是唯一能改变下一步的东西(D186「出声必须指名哪一半」)。
|
|
1552
|
+
* 但可以**折叠结构**:同 `(check, detail)` 的条目合并成「一组 + 名字列表」,逐条信息一个字不丢。
|
|
1553
|
+
* - **零 issue(成功面)⇒ 只留一行结论 + 计数**:通过的条目对下一步零信息量。
|
|
1554
|
+
*
|
|
1555
|
+
* @param report 原始报告(逐条 issue)
|
|
1556
|
+
* @returns 压缩后的**面向 agent 的**结构(键名短、重复消除;entries 全保留只做分组)
|
|
1557
|
+
*/
|
|
1558
|
+
function compactRegistryQuality(report) {
|
|
1559
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1560
|
+
for (const it of report.issues) {
|
|
1561
|
+
const key = `${it.check}\u0000${it.detail}`;
|
|
1562
|
+
const hit = byKey.get(key);
|
|
1563
|
+
if (hit) hit.names.push(it.name);
|
|
1564
|
+
else byKey.set(key, {
|
|
1565
|
+
check: it.check,
|
|
1566
|
+
detail: it.detail,
|
|
1567
|
+
names: [it.name]
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
const groups = [...byKey.values()].sort((a, b) => a.check === b.check ? b.names.length - a.names.length : a.check < b.check ? -1 : 1).map((g) => ({
|
|
1571
|
+
check: g.check,
|
|
1572
|
+
detail: g.detail,
|
|
1573
|
+
count: g.names.length,
|
|
1574
|
+
names: g.names
|
|
1575
|
+
}));
|
|
1576
|
+
return {
|
|
1577
|
+
checked: report.checked,
|
|
1578
|
+
issueCount: report.issues.length,
|
|
1579
|
+
groups
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1544
1583
|
* **注册表「质量契约」判据(DC-M1~M4)** —— 具名出口(C5 观察面归一,2026-09-15)。
|
|
1545
1584
|
*
|
|
1546
1585
|
* 此前这段判据**只存在于 `runMsm` 的 `case 'check'` 分支体内**(没有名字、无法从别处取用),
|
|
@@ -1748,7 +1787,7 @@ function runMsm(root, args) {
|
|
|
1748
1787
|
throw new Error(`MSM not registered: "${name}"`);
|
|
1749
1788
|
}
|
|
1750
1789
|
case "ccc-config": return docsPointer("config", "CCC 配置参考");
|
|
1751
|
-
case "check": return checkRegistryQuality(root);
|
|
1790
|
+
case "check": return compactRegistryQuality(checkRegistryQuality(root));
|
|
1752
1791
|
default: throw new Error(`Unknown action: ${args.action}`);
|
|
1753
1792
|
}
|
|
1754
1793
|
}
|
|
@@ -1938,20 +1977,68 @@ function buildMsmIndex(root) {
|
|
|
1938
1977
|
bySkill.set(k, (bySkill.get(k) ?? 0) + 1);
|
|
1939
1978
|
}
|
|
1940
1979
|
const groups = [...bySkill.entries()].sort((a, b) => b[1] - a[1]).map(([skill, count]) => ` ${skill}: ${count} MSM`).join("\n");
|
|
1941
|
-
return `${entries.length} MSMs registered (by skill):\n${groups}\n\nExecute: msm("<name>", ["<args>"]) — e.g. msm("ssh-connect", ["status"])\nInspect: msm("<name>", [], inspect=true) — view usage/flags\nPartial name returns matching candidates.\
|
|
1980
|
+
return `${entries.length} MSMs registered (by skill):\n${groups}\n\nExecute: msm("<name>", ["<args>"]) — e.g. msm("ssh-connect", ["status"])\nInspect: msm("<name>", [], inspect=true) — view usage/flags\nPartial name returns matching candidates.\nRegistry maintenance: container_admin (domain=msm, action=register|deregister|check).`;
|
|
1942
1981
|
}
|
|
1943
1982
|
/** 模糊候选:name/description 子串匹配(未精确命中时返回 top-K,供 LLM 下轮选对) */
|
|
1944
1983
|
function suggestMsm(root, query, limit = 5) {
|
|
1945
1984
|
const q = query.toLowerCase();
|
|
1946
|
-
const
|
|
1947
|
-
|
|
1985
|
+
const entries = loadMsmEntries(root);
|
|
1986
|
+
const hits = entries.filter((e) => e.name.toLowerCase().includes(q) || (e.description ?? "").toLowerCase().includes(q)).slice(0, limit);
|
|
1987
|
+
if (hits.length === 0) {
|
|
1988
|
+
const skills = new Set(entries.map((e) => e.skill ?? "uncategorized"));
|
|
1989
|
+
return `No MSM matches "${query}" (${entries.length} registered across ${skills.size} skills).\nTry a shorter fragment, e.g. msm("${query.slice(0, 3)}"), or msm() for counts by skill.`;
|
|
1990
|
+
}
|
|
1948
1991
|
const lines = hits.map((e) => {
|
|
1949
|
-
const base = `${e.name} | ${e.skill ?? "-"} | ${e.category ?? "-"} | ${e.description
|
|
1992
|
+
const base = `${e.name} | ${e.skill ?? "-"} | ${e.category ?? "-"} | ${shortDesc(e.description)}`;
|
|
1950
1993
|
const flags = (e.flags ?? []).map((f) => `--${f.name} <${f.type ?? "string"}>`).join(", ");
|
|
1951
1994
|
return flags ? `${base} [flags: ${flags}]` : base;
|
|
1952
1995
|
});
|
|
1953
1996
|
return `No exact MSM "${query}" — did you mean one of these?\n` + lines.join("\n");
|
|
1954
1997
|
}
|
|
1998
|
+
/**
|
|
1999
|
+
* 候选行的**描述截断**(D237:其它调用的"简练结构"那一档)。
|
|
2000
|
+
*
|
|
2001
|
+
* 判据 = **「帮不帮得了下决定」**:选哪个 MSM 靠前半句的分句就够,后面那些
|
|
2002
|
+
* 「子命令清单 / 设计取舍 / 历史沿革」属于**选中之后**才需要的细节(`inspect=true` 给全)。
|
|
2003
|
+
* 实测:一条候选描述可达 200+ 字,5 条候选就能吃掉大半屏。
|
|
2004
|
+
*
|
|
2005
|
+
* ⚠️ 截断点选**分号/句号/破折号**这类天然断句符,不做"每 N 字硬切"——硬切会把词切两半
|
|
2006
|
+
* (中文尤其明显),反而降低可读性。
|
|
2007
|
+
*
|
|
2008
|
+
* 🔴 **实现更正(自查抓到)**:初版先 `slice(0, max)` 再在里面找断句符 ⇒ 当第一个断句符
|
|
2009
|
+
* 出现在很靠前的位置时(如「短说明;后面全是细节」),它落在 `max/3` 之外就被放弃了,
|
|
2010
|
+
* 于是退化成硬切 —— **正好违反"在天然断句符处收尾"这条本意**。
|
|
2011
|
+
* ⇒ 正确做法:**先找 max 之后最近的那个断句符**,让它成为截断点(宁可略超 max,
|
|
2012
|
+
* 也要停在句读上);只有在整串都没有断句符时才硬切。
|
|
2013
|
+
*/
|
|
2014
|
+
function shortDesc(desc, max = 64) {
|
|
2015
|
+
const s = (desc ?? "").trim();
|
|
2016
|
+
if (s === "") return "";
|
|
2017
|
+
if (s.length <= max) return s;
|
|
2018
|
+
const BREAKS = [
|
|
2019
|
+
";",
|
|
2020
|
+
";",
|
|
2021
|
+
"。",
|
|
2022
|
+
"——",
|
|
2023
|
+
"·",
|
|
2024
|
+
",",
|
|
2025
|
+
","
|
|
2026
|
+
];
|
|
2027
|
+
const windowEnd = Math.min(s.length, max * 2);
|
|
2028
|
+
let best = -1;
|
|
2029
|
+
for (const brk of BREAKS) {
|
|
2030
|
+
const at = s.indexOf(brk, max);
|
|
2031
|
+
if (at !== -1 && at < windowEnd && (best === -1 || at < best)) best = at;
|
|
2032
|
+
}
|
|
2033
|
+
if (best !== -1) return s.slice(0, best).trimEnd() + "…";
|
|
2034
|
+
let back = -1;
|
|
2035
|
+
for (const brk of BREAKS) {
|
|
2036
|
+
const at = s.lastIndexOf(brk, max);
|
|
2037
|
+
if (at > back) back = at;
|
|
2038
|
+
}
|
|
2039
|
+
if (back >= Math.floor(max / 4)) return s.slice(0, back).trimEnd() + "…";
|
|
2040
|
+
return s.slice(0, max).trimEnd() + "…";
|
|
2041
|
+
}
|
|
1955
2042
|
const msmTool = defineTool({
|
|
1956
2043
|
name: "msm",
|
|
1957
2044
|
description: "Execute a registered CCC MSM (Mech & Semi-Mech). msm(name, args) runs it directly; name not found returns matching candidates; inspect=true shows usage/flags without running; no args returns a summary index. The full CCC MSM list is discoverable via partial-name matching or the index.",
|
|
@@ -8870,10 +8957,14 @@ function appendCroWakeLog(root, dirName, input, nowMs = Date.now()) {
|
|
|
8870
8957
|
*
|
|
8871
8958
|
* ## 判据(三条同时成立,缺一不可)
|
|
8872
8959
|
*
|
|
8873
|
-
* 1. 日志里**最后一条 `turn/end`** 的 `reason.kind
|
|
8960
|
+
* 1. 日志里**最后一条 `turn/end`** 的 `reason.kind` ∈ {@link UNFINISHED_KINDS}
|
|
8961
|
+
* (= `interrupted` 崩溃补收尾 / `aborted` 正常终止,两者都是"这一轮没收完");
|
|
8874
8962
|
* 2. 该 `turn/end` **之后没有新的 `turn/start`**(否则已被后续轮次接续);
|
|
8875
8963
|
* 3. ⛔ **不要求**末条是 `session/end-seed`(2026-10-23 放宽,原因见 {@link detectInterruptedEnd})。
|
|
8876
8964
|
*
|
|
8965
|
+
* 🔴 **2026-10-24 补入 `aborted`**:修前只认 `interrupted` ⇒「被正常终止」整类漏扫
|
|
8966
|
+
* (发布重启/换宿主最常见的形态)。判据问的是「这一轮收完了吗」,**不是"进程死得惨不惨"**。
|
|
8967
|
+
*
|
|
8877
8968
|
* ⚠️ **为什么不能只看"存在过 interrupted"**:一个长会话历史上可能中断几十次
|
|
8878
8969
|
* (本容器实测单会话 33 次)。只看"曾经中断"会把早已恢复的会话全部当成待续 ⇒ 灾难。
|
|
8879
8970
|
*
|
|
@@ -8966,34 +9057,54 @@ function readTailEvents(logPath, want) {
|
|
|
8966
9057
|
return events.slice(-want);
|
|
8967
9058
|
}
|
|
8968
9059
|
/**
|
|
8969
|
-
*
|
|
9060
|
+
* 判定一组尾部事件是否「以未完成的轮次结尾」。
|
|
8970
9061
|
* 不命中 → null。
|
|
8971
9062
|
*
|
|
8972
|
-
* ##
|
|
9063
|
+
* ## 🔴🔴 2026-10-24 判据放宽(owner 报障:「有的会话在重启时不是崩溃的就扫不到,还是没修好」)
|
|
9064
|
+
*
|
|
9065
|
+
* ### 漏的是什么(宿主源码实读,非推测)
|
|
9066
|
+
*
|
|
9067
|
+
* 宿主只有**两条**会写出「轮次没收完」的 `turn/end`,而它们的 `kind` **不是同一个词**:
|
|
9068
|
+
*
|
|
9069
|
+
* | 形态 | 写谁 | `reason.kind` | 修前是否命中 |
|
|
9070
|
+
* |---|---|---|---|
|
|
9071
|
+
* | 进程**崩溃**/被强杀,下一轮 resume 时补收尾 | `interruptedTurnClosers()` → `openTurnClosers(events,{kind:'interrupted'})` | **`interrupted`** | ✅ |
|
|
9072
|
+
* | 进程**正常退出**(`restart-web` 的 SIGTERM、`agent/disposed`、用户中断) | 驱动 catch 里 `abortedCancelCause(signal)` | **`aborted`** + 内层 `reason:{kind:'user'\|'parent'\|'disposed'\|'hook'}` | ❌ **漏** |
|
|
9073
|
+
*
|
|
9074
|
+
* ⇒ 修前只认 `interrupted` ⇒ **「被正常终止」这一大类全部漏掉**,而它恰恰是
|
|
9075
|
+
* **发布重启/换宿主** 最常见的形态(比崩溃常见得多)。
|
|
8973
9076
|
*
|
|
8974
|
-
*
|
|
8975
|
-
*
|
|
8976
|
-
*
|
|
8977
|
-
* · 其中满足原判据的只有 **24** 条 ⇒ **漏 2 条**,形态逐字为:
|
|
8978
|
-
* `… turn/end[interrupted] -> session/end-seed -> agent/inbox/spliced -> agent/inbox/spliced`
|
|
8979
|
-
* ⇒ 重启后宿主把这**两条**追加在 end-seed 之后(它们是"恢复时把待办消息重新塞进收件箱"),
|
|
8980
|
-
* 于是"末条必须 end-seed"这条**在恢复动作发生之后就恒不成立**。
|
|
8981
|
-
* ⚠️ 讽刺的是:**正是那些被恢复过、又再次被打断的会话**最容易长成这样
|
|
8982
|
-
* ⇒ 原判据恰好对"反复中断"的会话失明,而它们才是最需要守护的。
|
|
9077
|
+
* 🔴 **这两条是同一个状态的两种成因,不是"一个对一个不对"**:
|
|
9078
|
+
* 判据问的是「**这一轮有没有收完**」,不是「进程死得惨不惨」。
|
|
9079
|
+
* 拿死法当判据 ⇒ 换一种死法就漏一批(这正是 owner 反复看到的"还是没修好")。
|
|
8983
9080
|
*
|
|
8984
|
-
* ###
|
|
8985
|
-
*
|
|
8986
|
-
*
|
|
8987
|
-
*
|
|
9081
|
+
* ### 为什么不能只加 `aborted` 就完事
|
|
9082
|
+
* `aborted.reason.kind` 里还可能有 `hook`(宿主主动 hook 取消)——它同属"没跑完"。
|
|
9083
|
+
* ⇒ 本判据**只看外层 `kind`**,理由:外层已经回答了"这一轮收完了吗",
|
|
9084
|
+
* 内层回答的是"谁让它停的",**那是另一个问题**,混进来会让判据随"谁取消的"漂移。
|
|
8988
9085
|
*
|
|
8989
|
-
* ###
|
|
8990
|
-
*
|
|
8991
|
-
*
|
|
8992
|
-
*
|
|
9086
|
+
* ### ⛔ 刻意**不**纳入的形态(保守边界,写明理由)
|
|
9087
|
+
* - `error`:本轮**收完了**(宿主记了错误收尾),它不是"停在这里等人续";
|
|
9088
|
+
* - 完全没有 `turn/end` 的开轮(例如日志末尾是 `turn/start` 后再无事件):
|
|
9089
|
+
* 那**也可能是"进程正活着、这一轮正在跑"**,与真中断在读数上不可分
|
|
9090
|
+
* ⇒ 纳入会误唤醒在跑的会话。⚠️ 这是**已知边界**,不是遗漏。
|
|
8993
9091
|
*
|
|
8994
9092
|
* ### 为什么不会误报"曾经中断但已恢复"
|
|
8995
9093
|
* 恢复一定伴随 `turn/start`(新一轮)或新的 `turn/end`(`completed` 等)⇒ 判据 1/2 各自挡住。
|
|
8996
|
-
|
|
9094
|
+
*/
|
|
9095
|
+
/**
|
|
9096
|
+
* 「这一轮没跑完」的 `turn/end.reason.kind` 取值域。
|
|
9097
|
+
*
|
|
9098
|
+
* 🔴🔴 **2026-10-24(owner 报障)**:原判据只认 `'interrupted'` 一个词 ⇒
|
|
9099
|
+
* **「被正常终止」整类漏扫**。两个值对应的形态见 {@link detectInterruptedEnd} 的头注。
|
|
9100
|
+
*
|
|
9101
|
+
* ⚠️ **这是判据的唯一取值点** —— 别在别处再写一份字面量(两份会漂)。
|
|
9102
|
+
*/
|
|
9103
|
+
const UNFINISHED_KINDS = /* @__PURE__ */ new Set(["interrupted", "aborted"]);
|
|
9104
|
+
/**
|
|
9105
|
+
* 命中判定。
|
|
9106
|
+
*
|
|
9107
|
+
* @returns 命中时给出被中断的轮次与"可能有副作用"的工具名;不命中 null
|
|
8997
9108
|
*/
|
|
8998
9109
|
function detectInterruptedEnd(events) {
|
|
8999
9110
|
if (events.length === 0) return null;
|
|
@@ -9004,9 +9115,10 @@ function detectInterruptedEnd(events) {
|
|
|
9004
9115
|
}
|
|
9005
9116
|
if (idx < 0) return null;
|
|
9006
9117
|
const turnEnd = events[idx];
|
|
9007
|
-
|
|
9118
|
+
const kind = turnEnd?.data?.reason?.kind;
|
|
9119
|
+
if (typeof kind !== "string" || !UNFINISHED_KINDS.has(kind)) return null;
|
|
9008
9120
|
for (let i = idx + 1; i < events.length; i++) if (events[i]?.type === "turn/start") return null;
|
|
9009
|
-
const turn = typeof turnEnd
|
|
9121
|
+
const turn = typeof turnEnd?.data?.turn === "number" ? turnEnd.data.turn : null;
|
|
9010
9122
|
return {
|
|
9011
9123
|
turn,
|
|
9012
9124
|
pendingTools: findPendingTools(events, turn)
|
|
@@ -10848,12 +10960,12 @@ function collectRunningSessionIds(nowMs) {
|
|
|
10848
10960
|
function renderInterruptedScan(input) {
|
|
10849
10961
|
const { projectDir, lookbackHours, scan } = input;
|
|
10850
10962
|
const lines = [];
|
|
10851
|
-
lines.push(`═══
|
|
10963
|
+
lines.push(`═══ 末轮未跑完的 dsh 会话(回看 ${lookbackHours}h)═══`);
|
|
10852
10964
|
lines.push(` 扫描域: ${projectDir}`);
|
|
10853
10965
|
lines.push(` 检查 ${scan.scanned} 个 · 命中 ${scan.sessions.length} · 跳过 ${scan.skipped} · 耗时 ${scan.elapsedMs}ms${scan.errors.length > 0 ? ` · 读取失败 ${scan.errors.length}` : ""}`);
|
|
10854
10966
|
if (scan.sessions.length === 0) {
|
|
10855
10967
|
lines.push("");
|
|
10856
|
-
lines.push("✓
|
|
10968
|
+
lines.push("✓ 没有末轮未跑完的会话 —— 无需处理。");
|
|
10857
10969
|
return lines.join("\n");
|
|
10858
10970
|
}
|
|
10859
10971
|
lines.push("");
|
|
@@ -10864,7 +10976,9 @@ function renderInterruptedScan(input) {
|
|
|
10864
10976
|
if (s.pendingTools.length > 0) lines.push(` ⚠️ 有调用无结果的工具(副作用未知): ${s.pendingTools.join(", ")}`);
|
|
10865
10977
|
}
|
|
10866
10978
|
lines.push("");
|
|
10867
|
-
lines.push("判据: 日志末条
|
|
10979
|
+
lines.push("判据: 日志末条 `turn/end` 的 `reason.kind` ∈ {interrupted, aborted},且其后没有新的 turn/start。");
|
|
10980
|
+
lines.push(" interrupted = 进程崩溃后被 resume 补收尾 / aborted = 被正常终止(重启、销毁、用户中断)。");
|
|
10981
|
+
lines.push(" 两者都表示「这一轮没跑完」,都会被收进来。");
|
|
10868
10982
|
lines.push("🔴 本动作**只读** —— 是否续跑、续哪条、几点续,由调用方(你的认知轨迹)决定。");
|
|
10869
10983
|
lines.push("⚠️ 未完成的工具调用「副作用未知」≠「没执行」:它们可能已写文件 / 已发消息。");
|
|
10870
10984
|
lines.push(" 续跑前应先核对副作用,而不是把那一轮整段重放。");
|
|
@@ -10890,7 +11004,7 @@ function renderContinueResult(input) {
|
|
|
10890
11004
|
lines.push(` 检查 ${scan.scanned} 个 · 命中 ${scan.sessions.length} · 跳过 ${scan.skipped} · 耗时 ${scan.elapsedMs}ms`);
|
|
10891
11005
|
if (scan.sessions.length === 0) {
|
|
10892
11006
|
lines.push("");
|
|
10893
|
-
lines.push("✓
|
|
11007
|
+
lines.push("✓ 没有末轮未跑完的会话 —— 无需续跑。");
|
|
10894
11008
|
return lines.join("\n");
|
|
10895
11009
|
}
|
|
10896
11010
|
lines.push("");
|
|
@@ -11039,7 +11153,7 @@ function createCognitiveTrajectoryTool(ctx) {
|
|
|
11039
11153
|
"list (inventory + stats) / show (read one SESSION.md) / create (make a NEW trajectory) / use (bind THIS conversation) / rebuild (clear-and-rebuild THIS conversation in place) / archive (move a trajectory out of the active list; one-way, never overwrites).",
|
|
11040
11154
|
"(B) DELIVERY — sends ONE message to ANOTHER trajectory; your own binding is unchanged: send-now (right now) / send-later (one future instant).",
|
|
11041
11155
|
"(C) REFERENCE — cro-guide: how to give a trajectory its own program that decides when it should be woken.",
|
|
11042
|
-
"(D) INSPECTION — interrupted: list dsh sessions whose LAST turn
|
|
11156
|
+
"(D) INSPECTION — interrupted: list dsh sessions whose LAST turn did not finish — reason.kind is \"interrupted\" (process died; the host synthesised the closer on resume) OR \"aborted\" (terminated normally: restart, dispose, user cancel). Both mean \"this turn did not complete\"; picking only one misses an entire class. PURE READ: no periodic scheduling, no auto-continue, no log mutation — deciding IF/WHEN/WHICH to resume belongs to the calling trajectory (this is the \"entry point only\" contract). The criterion is EXACTLY \"the last turn did not finish\", NOT \"was ever interrupted\": one long session may have been interrupted dozens of times historically, and treating those as pending would resurrect sessions that already recovered. Each hit reports the turn number and the tool names that had a call but no result (best-effort — those side effects are UNKNOWN, so re-running them blindly is dangerous).",
|
|
11043
11157
|
"HOW TO CHOOSE (a rule, not a guess):",
|
|
11044
11158
|
"not yet bound + starting new work ⇒ create (with no prior binding it activates immediately, so the new trajectory has a carrier at once).",
|
|
11045
11159
|
"already bound + recording a SEPARATE future line ⇒ create (it does NOT steal your binding; activate the new one later by send-now).",
|
|
@@ -11335,16 +11449,16 @@ function createCognitiveTrajectoryTool(ctx) {
|
|
|
11335
11449
|
return listResident(root);
|
|
11336
11450
|
case "interrupted": {
|
|
11337
11451
|
/**
|
|
11338
|
-
*
|
|
11452
|
+
* 以未完成轮次结尾的 dsh 会话(owner 2026-10-22 令:**只出入口**)。
|
|
11339
11453
|
*
|
|
11340
11454
|
* 🔴 **边界(owner 原话:「我们 ACC 不关心周期」)**:本动作**只回答**"此刻哪些会话
|
|
11341
|
-
*
|
|
11455
|
+
* 的最后一轮没跑完",**不判周期、不自动续跑、不改日志**。
|
|
11342
11456
|
* 何时扫、续谁、怎么续 = 调用方(CCC 的认知轨迹)的事。
|
|
11343
11457
|
*
|
|
11344
11458
|
* 🔴 **为什么不是"曾经中断过"**:长会话历史上可中断几十次(实测单会话 33 次);
|
|
11345
|
-
* 按"曾经"判会把早已恢复的会话全部当成待续 ⇒
|
|
11346
|
-
*
|
|
11347
|
-
*
|
|
11459
|
+
* 按"曾经"判会把早已恢复的会话全部当成待续 ⇒ 灾难。
|
|
11460
|
+
* 🔴 **判据只在一处**:`trajectory/interrupted.ts` 的 `detectInterruptedEnd`
|
|
11461
|
+
* (⛔ 别在此处复述取值域 —— 2026-10-24 就是这份复述漏掉了 `aborted`)。
|
|
11348
11462
|
*
|
|
11349
11463
|
* 🔴 **正在跑的会话要排除**:live 会话不该被判成"待续"(它正在跑,不是中断)。
|
|
11350
11464
|
*/
|
|
@@ -12255,460 +12369,6 @@ function createContainerAdminTool(ctx) {
|
|
|
12255
12369
|
});
|
|
12256
12370
|
}
|
|
12257
12371
|
//#endregion
|
|
12258
|
-
//#region src/im/log-scan.ts
|
|
12259
|
-
/**
|
|
12260
|
-
* im/log-scan.ts — 读聊天记录落盘,汇总出「与谁有过往来」(`contacts` 的数据源)
|
|
12261
|
-
*
|
|
12262
|
-
* ## 这个文件解决什么
|
|
12263
|
-
*
|
|
12264
|
-
* owner 2026-10-22:「配路由需要的是**账户到轨迹**,轨迹 CCC 知道,**账户 CCC 不知道**,
|
|
12265
|
-
* 需要有个能力让 CCC 查询用户配好的账户」。这里的"账户"= **通道用户**(对方是谁),
|
|
12266
|
-
* 不是 IM 账号(我们自己登录的身份,`status` 早就能查)。
|
|
12267
|
-
*
|
|
12268
|
-
* 那个 id(微信 `xxx@im.wechat` / 飞书 `ou_…`)不在任何配置文件里 ——
|
|
12269
|
-
* 它**只在聊天记录里出现过**。⇒ 本文件把记录读成联系人表。
|
|
12270
|
-
*
|
|
12271
|
-
* ## 🔴 两条硬边界
|
|
12272
|
-
*
|
|
12273
|
-
* 1. **只读**:本文件不写任何东西。真相源仍是那些 jsonl(append-only 事件流),
|
|
12274
|
-
* 本文件只是它的一次投影 —— ⛔ 不产生第二真相源。
|
|
12275
|
-
* 2. **不猜渠道**:事件的 `channel` 字段为准;缺它(存量事件)⇒ 按
|
|
12276
|
-
* `accountId → im.accounts[].channel` 回填,仍取不到才落 `weixin`
|
|
12277
|
-
* (那是该字段存在之前唯一的渠道)。⛔ **绝不按 userId 的形状猜**
|
|
12278
|
-
* (`ou_` 前缀 / `@im.wechat` 后缀都是命名习惯,不是判据 —— D167 认族不认名)。
|
|
12279
|
-
*/
|
|
12280
|
-
/** 日志根目录族(**新旧两代并存**:`_im-logs/<channel>/` 与存量 `_weixin-logs/`) */
|
|
12281
|
-
const IM_LOG_DIR_NEW = "_im-logs";
|
|
12282
|
-
const IM_LOG_DIR_LEGACY = "_weixin-logs";
|
|
12283
|
-
/** 某 CCC 下**存在**的日志目录(相对根),新代在前 */
|
|
12284
|
-
function imLogRoots(root) {
|
|
12285
|
-
const out = [];
|
|
12286
|
-
const newBase = join(root, "AGENT_SESSIONS", IM_LOG_DIR_NEW);
|
|
12287
|
-
const legacy = join(root, "AGENT_SESSIONS", IM_LOG_DIR_LEGACY);
|
|
12288
|
-
if (existsSync(newBase)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_NEW));
|
|
12289
|
-
if (existsSync(legacy)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_LEGACY));
|
|
12290
|
-
return out;
|
|
12291
|
-
}
|
|
12292
|
-
/** 收集某个日志根下所有 `*.jsonl`(含 `_im-logs/<channel>/` 一层;按文件名排序) */
|
|
12293
|
-
function jsonlFilesIn(root, rel) {
|
|
12294
|
-
const abs = join(root, rel);
|
|
12295
|
-
const out = [];
|
|
12296
|
-
let entries = [];
|
|
12297
|
-
try {
|
|
12298
|
-
entries = readdirSync(abs, { withFileTypes: true }).map((d) => d.name);
|
|
12299
|
-
} catch {
|
|
12300
|
-
return out;
|
|
12301
|
-
}
|
|
12302
|
-
for (const name of entries.sort()) {
|
|
12303
|
-
if (name.startsWith(".")) continue;
|
|
12304
|
-
if (name.endsWith(".jsonl")) {
|
|
12305
|
-
out.push(join(rel, name));
|
|
12306
|
-
continue;
|
|
12307
|
-
}
|
|
12308
|
-
if (rel === join("AGENT_SESSIONS", "_im-logs")) {
|
|
12309
|
-
const sub = join(rel, name);
|
|
12310
|
-
try {
|
|
12311
|
-
for (const child of readdirSync(join(root, sub)).sort()) if (child.endsWith(".jsonl")) out.push(join(sub, child));
|
|
12312
|
-
} catch {}
|
|
12313
|
-
}
|
|
12314
|
-
}
|
|
12315
|
-
return out;
|
|
12316
|
-
}
|
|
12317
|
-
/** 从文件名 / 所在目录猜渠道(**只作为回填的最后一级**,不是判据) */
|
|
12318
|
-
function channelFromPath(rel) {
|
|
12319
|
-
const parts = rel.split("/");
|
|
12320
|
-
const idx = parts.indexOf(IM_LOG_DIR_NEW);
|
|
12321
|
-
const candidate = idx >= 0 && parts.length > idx + 2 ? parts[idx + 1] : void 0;
|
|
12322
|
-
return candidate !== void 0 && candidate !== "" ? candidate : null;
|
|
12323
|
-
}
|
|
12324
|
-
/**
|
|
12325
|
-
* 扫描本 CCC 的全部聊天记录,按 `(channel, userId)` 汇总。
|
|
12326
|
-
*
|
|
12327
|
-
* @param root CCC 根
|
|
12328
|
-
* @param accountChannel 存量事件缺 `channel` 时的回填表(`accountId → channel`)
|
|
12329
|
-
* @returns 汇总结果 + **扫描域** + 读取错误(三者都不可省)
|
|
12330
|
-
*/
|
|
12331
|
-
function scanImContacts(root, accountChannel = {}) {
|
|
12332
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
12333
|
-
const sources = [];
|
|
12334
|
-
const errors = [];
|
|
12335
|
-
for (const rel of imLogRoots(root)) for (const fileRel of jsonlFilesIn(root, rel)) {
|
|
12336
|
-
sources.push(fileRel);
|
|
12337
|
-
let text = "";
|
|
12338
|
-
try {
|
|
12339
|
-
text = readFileSync(join(root, fileRel), "utf8");
|
|
12340
|
-
} catch (err) {
|
|
12341
|
-
errors.push(`${fileRel}: ${err instanceof Error ? err.message : String(err)}`);
|
|
12342
|
-
continue;
|
|
12343
|
-
}
|
|
12344
|
-
const pathChannel = channelFromPath(fileRel);
|
|
12345
|
-
for (const line of text.split("\n")) {
|
|
12346
|
-
const trimmed = line.trim();
|
|
12347
|
-
if (trimmed === "") continue;
|
|
12348
|
-
let ev;
|
|
12349
|
-
try {
|
|
12350
|
-
ev = JSON.parse(trimmed);
|
|
12351
|
-
} catch {
|
|
12352
|
-
errors.push(`${fileRel}: 有 1 行不是合法 JSON(已跳过)`);
|
|
12353
|
-
continue;
|
|
12354
|
-
}
|
|
12355
|
-
const userId = typeof ev.userId === "string" ? ev.userId.trim() : "";
|
|
12356
|
-
if (userId === "") continue;
|
|
12357
|
-
const accountId = typeof ev.accountId === "string" ? ev.accountId.trim() : "";
|
|
12358
|
-
const channel = (typeof ev.channel === "string" && ev.channel.trim() !== "" ? ev.channel.trim() : null) ?? pathChannel ?? (accountId !== "" ? accountChannel[accountId] ?? null : null) ?? "weixin";
|
|
12359
|
-
const key = `${channel}\u0000${userId}`;
|
|
12360
|
-
let entry = byKey.get(key);
|
|
12361
|
-
if (!entry) {
|
|
12362
|
-
entry = {
|
|
12363
|
-
channel,
|
|
12364
|
-
userId,
|
|
12365
|
-
accountIds: [],
|
|
12366
|
-
roleAtEvent: null,
|
|
12367
|
-
firstSeen: null,
|
|
12368
|
-
lastSeen: null,
|
|
12369
|
-
messages: 0
|
|
12370
|
-
};
|
|
12371
|
-
byKey.set(key, entry);
|
|
12372
|
-
}
|
|
12373
|
-
if (accountId !== "" && !entry.accountIds.includes(accountId)) entry.accountIds.push(accountId);
|
|
12374
|
-
if (typeof ev.role === "string" && ev.role.trim() !== "") entry.roleAtEvent = ev.role.trim();
|
|
12375
|
-
const ts = typeof ev.ts === "number" && Number.isFinite(ev.ts) ? ev.ts : null;
|
|
12376
|
-
if (ts !== null) {
|
|
12377
|
-
if (entry.firstSeen === null || ts < entry.firstSeen) entry.firstSeen = ts;
|
|
12378
|
-
if (entry.lastSeen === null || ts > entry.lastSeen) entry.lastSeen = ts;
|
|
12379
|
-
}
|
|
12380
|
-
entry.messages += 1;
|
|
12381
|
-
}
|
|
12382
|
-
}
|
|
12383
|
-
return {
|
|
12384
|
-
contacts: [...byKey.values()].sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0)),
|
|
12385
|
-
sources,
|
|
12386
|
-
errors
|
|
12387
|
-
};
|
|
12388
|
-
}
|
|
12389
|
-
//#endregion
|
|
12390
|
-
//#region src/im/channels.ts
|
|
12391
|
-
/**
|
|
12392
|
-
* im/channels.ts — IM 发送能力层(v1.31.0,S142 用户洞察)
|
|
12393
|
-
*
|
|
12394
|
-
* 用户原话:「既然微信桥是我们 ACC 提供的,那么 weixin-send 应该是我们 ACC 提供的能力,
|
|
12395
|
-
* 当用户配置了微信桥则可用,不配置则不可见」+「设定一个复杂的多层工具,叫 im-bridge,
|
|
12396
|
-
* 里面有微信,然后保留后续可能兼容别的 im 的空间」。
|
|
12397
|
-
*
|
|
12398
|
-
* 分层(E↑):
|
|
12399
|
-
* `tools/im/channels.ts`(工具面:参数 → 结果渲染)
|
|
12400
|
-
* → **本模块**(通道注册表 + 可见性判据 + 动作分发 + 错误码翻译)
|
|
12401
|
-
* → 通道实现(`im/weixin-channel.ts`:协议/凭据/账号/记录)
|
|
12402
|
-
*
|
|
12403
|
-
* 边界(R↓):
|
|
12404
|
-
* - **本模块零宿主依赖**(不 import cordis/agent)——可见性判定与分发都是纯逻辑,可独立单测。
|
|
12405
|
-
* - **通道只提供机制**:本模块不认识"微信";新增 IM 只需注册一个通道实现(R3 家族式扩展)。
|
|
12406
|
-
* - **只能操作本会话 CCC**(R4):`root` 由调用方(工具面)从 agent cwd 解析后传入,
|
|
12407
|
-
* 本模块不接受"目标 CCC"参数——跨 CCC 发送不在能力面。
|
|
12408
|
-
* - **记录归通道**(R5):通道实现负责让每次发送都落既有 hook。
|
|
12409
|
-
*/
|
|
12410
|
-
/** 文本长度上限(微信侧单条实践上限;超限拒绝而非静默截断——与 HTTP 入口同值) */
|
|
12411
|
-
const IM_SEND_MAX_TEXT = 4e3;
|
|
12412
|
-
/** 文件大小上限(20MB,与媒体接收上限一致) */
|
|
12413
|
-
const IM_FILE_MAX_BYTES = 20971520;
|
|
12414
|
-
const channels = /* @__PURE__ */ new Map();
|
|
12415
|
-
/** 注册通道(index.ts apply 调用;重复 id 覆盖——测试与热重载友好) */
|
|
12416
|
-
function registerImChannel(channel) {
|
|
12417
|
-
channels.set(channel.id, channel);
|
|
12418
|
-
}
|
|
12419
|
-
/** 已注册通道 id(工具 `channel` 参数枚举) */
|
|
12420
|
-
function imChannelIds() {
|
|
12421
|
-
return [...channels.keys()];
|
|
12422
|
-
}
|
|
12423
|
-
/**
|
|
12424
|
-
* 可见性判据:本 CCC 是否**有任何可用的 IM 账号**。
|
|
12425
|
-
* 未启用 → 工具面对该 agent **不可见**(`tools.restrict` 移除,而非调用时报错)。
|
|
12426
|
-
*
|
|
12427
|
-
* ## 🔴 2026-10-22 判据修正(owner 令:「把开关升级为有任意账号即可,而不是盯着微信」)
|
|
12428
|
-
*
|
|
12429
|
-
* **旧判据** = 遍历已注册通道、任一 `channel.isEnabled(root)` 为真即可见。
|
|
12430
|
-
* **旧判据的缺陷**(实测于一个只配飞书的 CCC):`feishuChannel.isEnabled()` 首行是
|
|
12431
|
-
* `if (settings.enabled !== true) return false` —— 总开关一关,**后面的账号判断根本不执行**
|
|
12432
|
-
* ⇒ 账号明明配了、`enabled:true`,却因为一个**渠道无关的总开关**被永久判成"未启用"
|
|
12433
|
-
* ⇒ `im-bridge` 在模型清单里彻底消失,而配置面看上去是对的(`accounts[]` 里躺着一条 feishu)。
|
|
12434
|
-
* 这正是 D179 的形态:**配置说它有,行为说它没有**,且零报错。
|
|
12435
|
-
*
|
|
12436
|
-
* **新判据** = **账号即事实**:`readImSettings(root).accounts` 里存在
|
|
12437
|
-
* 「`enabled !== false` 的账号」即视为可见。理由三条:
|
|
12438
|
-
* ① **可见性问的是"这个 CCC 有没有 IM 能力"**,而能力的载体是**账号**,不是总开关;
|
|
12439
|
-
* ② 总开关的语义是"**要不要让桥跑起来**",把它当成可见性上限,会让"配好了但暂时不想让它跑"
|
|
12440
|
-
* 这种合法状态变成"工具凭空消失";
|
|
12441
|
-
* ③ 判**按账号判**后,微信与飞书不再各有一套"总开关 + 账号"的双重判据 ⇒ 消除同族漂移。
|
|
12442
|
-
*
|
|
12443
|
-
* ⚠️ **不放宽的部分**:没有账号 ⇒ 仍然不可见(`accounts` 为空即 false)。
|
|
12444
|
-
* ⚠️ 单个通道自己的 `isEnabled` 仍是**发送/启停**判据(那是通道自己的事),
|
|
12445
|
-
* 只是**不再兼任**工具可见性判据 —— 两件事被这次修正分开了。
|
|
12446
|
-
*
|
|
12447
|
-
* @param root 本会话 CCC 根(由调用方解析)
|
|
12448
|
-
*/
|
|
12449
|
-
function hasEnabledImChannel(root) {
|
|
12450
|
-
let settings;
|
|
12451
|
-
try {
|
|
12452
|
-
settings = readImSettings(root);
|
|
12453
|
-
} catch {
|
|
12454
|
-
return false;
|
|
12455
|
-
}
|
|
12456
|
-
return (settings.accounts ?? []).some((a) => a.enabled !== false);
|
|
12457
|
-
}
|
|
12458
|
-
/**
|
|
12459
|
-
* 可见性判据(**轨迹级**,2026-10-23 owner 令):
|
|
12460
|
-
* 这个 dsh 会话所属的认知轨迹,是否**被 IM 路由表点名**(= 它才是那个"接线人")。
|
|
12461
|
-
*
|
|
12462
|
-
* ## 它解决的问题(owner 原话的**去标识改写**,原文含私有角色名,按 D145 不入仓库)
|
|
12463
|
-
*
|
|
12464
|
-
* > 「很多轨迹发消息给我,但是我没空回,它们就会等待我,其实并不是,我希望他们继续工作;
|
|
12465
|
-
* > 所以只有 IM 桥绑定了任意账户的认知轨迹才拥有 `im-bridge` 这个 tool,
|
|
12466
|
-
* > 这样可以确保只有一个接线角色和我联系,其它轨迹和它联系即可,它会自行回复它们的」
|
|
12467
|
-
*
|
|
12468
|
-
* ⇒ 旧判据是 **CCC 级**({@link hasEnabledImChannel}:本 CCC 有任一启用账号 ⇒ 全体可见)。
|
|
12469
|
-
* 它的后果不是"少一个工具",而是**一条错误的工作流**:每条轨迹都以为自己该直接对 owner 说话,
|
|
12470
|
-
* 于是发完就**停下来等人回**——而 owner 根本没空逐条回。**工具在,就把等待也带上了。**
|
|
12471
|
-
*
|
|
12472
|
-
* ## 判据(两条,缺一不可)
|
|
12473
|
-
*
|
|
12474
|
-
* 1. 本 CCC 有可用账号({@link hasEnabledImChannel})—— 没有账号时路由表也没有意义;
|
|
12475
|
-
* 2. 本会话**绑定的轨迹编码**出现在 `routes[].trajectory` 里。
|
|
12476
|
-
*
|
|
12477
|
-
* ⚠️ **为什么不看 `routes[].user`**:那是"对端是谁"(owner 的通道 id),
|
|
12478
|
-
* 判"这条轨迹能不能说话"只跟**轨迹**有关,与哪个对端无关。
|
|
12479
|
-
*
|
|
12480
|
-
* ⚠️ **`*` 通配也算点名**:`{ user:"*", trajectory:"<某轨迹>" }` 这条路由的含义就是
|
|
12481
|
-
* "**没人认领的消息都归那条轨迹**" ⇒ 它确实是一条被授权的接线轨迹,必须算可见。
|
|
12482
|
-
* 这与 `matchWeixinRoute` 的通配兜底语义**同源**,不另立规则。
|
|
12483
|
-
*
|
|
12484
|
-
* ## 拿不到轨迹编码时怎么办(fail-closed,与 `exclusive` 同向)
|
|
12485
|
-
*
|
|
12486
|
-
* 会话 → 轨迹编码要读绑定。**取不到就判不可见**:
|
|
12487
|
-
* - 与 `exclusive`(未声明 ⇒ 隐藏)同向;
|
|
12488
|
-
* - 反向(取不到就放行)会让"绑定坏了"表现成"工具还在",
|
|
12489
|
-
* 而那正是本判据要防的形态(D179:配置说它有、行为说它没有,且零报错)。
|
|
12490
|
-
* ⚠️ 代价明示:绑定读取若短暂失败,该轮会少一个工具;下一轮绑定恢复即回。
|
|
12491
|
-
* 这比"误给一条轨迹发消息的权限"便宜 —— 后者会让它**停下来等 owner**。
|
|
12492
|
-
*
|
|
12493
|
-
* @param root CCC 根
|
|
12494
|
-
* @param trajectoryCode 本会话所属认知轨迹的编码(**由调用方解析**,见 `trajectory/policy/profile.ts`)
|
|
12495
|
-
* @returns true = 该轨迹接线,`im-bridge` 可见
|
|
12496
|
-
*/
|
|
12497
|
-
function isImWiredTrajectory(root, trajectoryCode) {
|
|
12498
|
-
if (typeof trajectoryCode !== "string" || trajectoryCode.trim() === "") return false;
|
|
12499
|
-
if (!hasEnabledImChannel(root)) return false;
|
|
12500
|
-
let settings;
|
|
12501
|
-
try {
|
|
12502
|
-
settings = readImSettings(root);
|
|
12503
|
-
} catch {
|
|
12504
|
-
return false;
|
|
12505
|
-
}
|
|
12506
|
-
const wanted = trajectoryCode.trim();
|
|
12507
|
-
for (const route of settings.routes ?? []) {
|
|
12508
|
-
const target = typeof route.trajectory === "string" ? route.trajectory.trim() : "";
|
|
12509
|
-
if (target !== "" && target === wanted) return true;
|
|
12510
|
-
}
|
|
12511
|
-
return false;
|
|
12512
|
-
}
|
|
12513
|
-
/**
|
|
12514
|
-
* 错误码 → 可行动提示(模型视角:说清怎么改)。
|
|
12515
|
-
*
|
|
12516
|
-
* 🔴 `CHANNEL_NOT_CONFIGURED` 的提示**不能写死某一个渠道的字段名**(2026-10-20 Q4 真机验收踩到):
|
|
12517
|
-
* 首版逐字写「enable it in … (weixin.enabled)」,而接第二个渠道后,**飞书能不能用不取决于
|
|
12518
|
-
* 这个开关**(它取决于有没有一条启用中的飞书账号)⇒ 按那句提示去改 `weixin.enabled`
|
|
12519
|
-
* **不会生效**,而错误码又指向"没配置" ⇒ 用户据此改完还是同一句报错。
|
|
12520
|
-
* ⇒ 提示按**渠道是否带自己的启用判据**分岔:带 `channelHint` 的就用它自己的说法。
|
|
12521
|
-
*/
|
|
12522
|
-
function remediationFor(code, channelHint) {
|
|
12523
|
-
switch (code) {
|
|
12524
|
-
case "CHANNEL_UNKNOWN": return "Use a registered channel id (see the tool description)";
|
|
12525
|
-
case "CHANNEL_NOT_CONFIGURED": return channelHint ?? "This CCC has not configured that channel — check .opencode/serenity.json (the channel's own enabled flag / accounts) or the settings panel";
|
|
12526
|
-
case "ACTION_UNKNOWN": return "Use send / send-file / users / status";
|
|
12527
|
-
case "ACTION_UNSUPPORTED": return "This channel does not support that action";
|
|
12528
|
-
case "ALIAS_UNKNOWN": return "Use a configured alias (see action=users) or a raw channel user id";
|
|
12529
|
-
case "TEXT_REQUIRED": return "Provide text";
|
|
12530
|
-
case "TEXT_TOO_LONG": return `Shorten the text to <= ${IM_SEND_MAX_TEXT} characters`;
|
|
12531
|
-
case "USER_REQUIRED": return "Provide user";
|
|
12532
|
-
case "FILE_REQUIRED": return "Provide file (a path inside this CCC)";
|
|
12533
|
-
case "FILE_ESCAPE": return "Use a path inside this CCC";
|
|
12534
|
-
case "FILE_NOT_FOUND": return "Check the path exists inside this CCC";
|
|
12535
|
-
case "FILE_TOO_LARGE": return `Use a file <= ${Math.round(IM_FILE_MAX_BYTES / 1048576)} MB`;
|
|
12536
|
-
default: return;
|
|
12537
|
-
}
|
|
12538
|
-
}
|
|
12539
|
-
function fail(code, error, remediation) {
|
|
12540
|
-
return {
|
|
12541
|
-
ok: false,
|
|
12542
|
-
code,
|
|
12543
|
-
error,
|
|
12544
|
-
...remediation ?? remediationFor(code) ? { remediation: remediation ?? remediationFor(code) } : {}
|
|
12545
|
-
};
|
|
12546
|
-
}
|
|
12547
|
-
/** CCC 内文件读取(路径逃逸/缺失/超限一律拒绝,返回稳定码) */
|
|
12548
|
-
function readCccFile(root, relOrAbs) {
|
|
12549
|
-
const abs = isAbsolute(relOrAbs) ? resolve(relOrAbs) : resolve(root, relOrAbs);
|
|
12550
|
-
const rel = relative(resolve(root), abs);
|
|
12551
|
-
if (rel.startsWith("..") || isAbsolute(rel)) return {
|
|
12552
|
-
ok: false,
|
|
12553
|
-
code: "FILE_ESCAPE",
|
|
12554
|
-
error: `file escapes the CCC root: ${relOrAbs}`
|
|
12555
|
-
};
|
|
12556
|
-
if (!existsSync(abs)) return {
|
|
12557
|
-
ok: false,
|
|
12558
|
-
code: "FILE_NOT_FOUND",
|
|
12559
|
-
error: `file not found: ${relOrAbs}`
|
|
12560
|
-
};
|
|
12561
|
-
let size = 0;
|
|
12562
|
-
try {
|
|
12563
|
-
size = statSync(abs).size;
|
|
12564
|
-
} catch (err) {
|
|
12565
|
-
return {
|
|
12566
|
-
ok: false,
|
|
12567
|
-
code: "FILE_NOT_FOUND",
|
|
12568
|
-
error: `cannot stat file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
|
|
12569
|
-
};
|
|
12570
|
-
}
|
|
12571
|
-
if (size > 20971520) return {
|
|
12572
|
-
ok: false,
|
|
12573
|
-
code: "FILE_TOO_LARGE",
|
|
12574
|
-
error: `file is ${size} bytes (> ${IM_FILE_MAX_BYTES})`
|
|
12575
|
-
};
|
|
12576
|
-
try {
|
|
12577
|
-
return {
|
|
12578
|
-
ok: true,
|
|
12579
|
-
data: readFileSync(abs),
|
|
12580
|
-
fileName: abs.split("/").pop() ?? "file"
|
|
12581
|
-
};
|
|
12582
|
-
} catch (err) {
|
|
12583
|
-
return {
|
|
12584
|
-
ok: false,
|
|
12585
|
-
code: "FILE_NOT_FOUND",
|
|
12586
|
-
error: `cannot read file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
|
|
12587
|
-
};
|
|
12588
|
-
}
|
|
12589
|
-
}
|
|
12590
|
-
/**
|
|
12591
|
-
* 执行一次 IM 动作(工具面唯一入口)。
|
|
12592
|
-
*
|
|
12593
|
-
* 顺序:通道解析 → 动作校验 → 用户解析(别名 → id)→ 输入校验 → 调用通道实现。
|
|
12594
|
-
* 任何一步失败都返回**稳定错误码 + 可行动提示**(不抛错——工具面统一渲染)。
|
|
12595
|
-
*
|
|
12596
|
-
* @param root 本会话 CCC 根(调用方从 agent cwd 解析;本函数不接受目标 CCC)
|
|
12597
|
-
* @param req 工具参数
|
|
12598
|
-
*/
|
|
12599
|
-
async function runImBridge(root, req) {
|
|
12600
|
-
const channelId = (req.channel ?? "").trim();
|
|
12601
|
-
const action = (req.action ?? "").trim();
|
|
12602
|
-
if (channelId === "") return fail("CHANNEL_REQUIRED", "channel is required", "Provide channel (e.g. \"weixin\")");
|
|
12603
|
-
const channel = channels.get(channelId);
|
|
12604
|
-
if (!channel) return fail("CHANNEL_UNKNOWN", `unknown channel "${channelId}" (registered: ${imChannelIds().join(", ") || "(none)"})`);
|
|
12605
|
-
if (!channel.isEnabled(root)) return fail("CHANNEL_NOT_CONFIGURED", `channel "${channelId}" is not configured for this CCC (${root})`, channel.notEnabledReason?.(root));
|
|
12606
|
-
if (action === "users") return {
|
|
12607
|
-
ok: true,
|
|
12608
|
-
channel: channelId,
|
|
12609
|
-
action,
|
|
12610
|
-
detail: { users: channel.listUsers(root) }
|
|
12611
|
-
};
|
|
12612
|
-
if (action === "contacts") {
|
|
12613
|
-
if (!channel.listContacts) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" has no record source for contacts`);
|
|
12614
|
-
const settings = readWeixinSettings(root);
|
|
12615
|
-
const accountChannel = {};
|
|
12616
|
-
for (const account of settings.accounts ?? []) if (typeof account.accountId === "string" && account.accountId !== "") accountChannel[account.accountId] = typeof account.channel === "string" && account.channel !== "" ? account.channel : "weixin";
|
|
12617
|
-
const scanned = scanImContacts(root, accountChannel);
|
|
12618
|
-
const mine = scanned.contacts.filter((c) => c.channel === channelId);
|
|
12619
|
-
/** 当前路由表(`user → trajectory`);同一 user 多条时后者覆盖 */
|
|
12620
|
-
const routeMap = /* @__PURE__ */ new Map();
|
|
12621
|
-
for (const route of settings.routes ?? []) if (typeof route.user === "string" && route.user !== "") routeMap.set(route.user, typeof route.trajectory === "string" ? route.trajectory : "");
|
|
12622
|
-
const contacts = mine.map((c) => ({
|
|
12623
|
-
channel: c.channel,
|
|
12624
|
-
userId: c.userId,
|
|
12625
|
-
accountIds: c.accountIds,
|
|
12626
|
-
route: routeMap.get(c.userId) ?? null,
|
|
12627
|
-
firstSeen: c.firstSeen === null ? null : new Date(c.firstSeen).toISOString(),
|
|
12628
|
-
lastSeen: c.lastSeen === null ? null : new Date(c.lastSeen).toISOString(),
|
|
12629
|
-
messages: c.messages
|
|
12630
|
-
}));
|
|
12631
|
-
const seenUsers = new Set(mine.map((c) => c.userId));
|
|
12632
|
-
return {
|
|
12633
|
-
ok: true,
|
|
12634
|
-
channel: channelId,
|
|
12635
|
-
action,
|
|
12636
|
-
detail: {
|
|
12637
|
-
contacts,
|
|
12638
|
-
unrouted: mine.filter((c) => !routeMap.has(c.userId)).map((c) => c.userId),
|
|
12639
|
-
orphanRoutes: [...routeMap.keys()].filter((user) => !seenUsers.has(user)),
|
|
12640
|
-
sources: scanned.sources,
|
|
12641
|
-
...scanned.errors.length > 0 ? { errors: scanned.errors } : {}
|
|
12642
|
-
}
|
|
12643
|
-
};
|
|
12644
|
-
}
|
|
12645
|
-
if (action === "status") return {
|
|
12646
|
-
ok: true,
|
|
12647
|
-
channel: channelId,
|
|
12648
|
-
action,
|
|
12649
|
-
detail: channel.status(root)
|
|
12650
|
-
};
|
|
12651
|
-
if (action === "send") {
|
|
12652
|
-
const rawUser = (req.user ?? "").trim();
|
|
12653
|
-
if (rawUser === "") return fail("USER_REQUIRED", "user is required");
|
|
12654
|
-
const text = req.text ?? "";
|
|
12655
|
-
if (text === "") return fail("TEXT_REQUIRED", "text is required");
|
|
12656
|
-
if (text.length > 4e3) return fail("TEXT_TOO_LONG", `text is ${text.length} characters (> ${IM_SEND_MAX_TEXT})`);
|
|
12657
|
-
const resolved = channel.resolveUser(root, rawUser);
|
|
12658
|
-
if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
|
|
12659
|
-
try {
|
|
12660
|
-
return {
|
|
12661
|
-
ok: true,
|
|
12662
|
-
channel: channelId,
|
|
12663
|
-
action,
|
|
12664
|
-
detail: {
|
|
12665
|
-
...await channel.send({
|
|
12666
|
-
root,
|
|
12667
|
-
userId: resolved.id,
|
|
12668
|
-
text,
|
|
12669
|
-
accountId: req.account
|
|
12670
|
-
}),
|
|
12671
|
-
user: resolved.alias ?? resolved.id
|
|
12672
|
-
}
|
|
12673
|
-
};
|
|
12674
|
-
} catch (err) {
|
|
12675
|
-
return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
|
|
12676
|
-
}
|
|
12677
|
-
}
|
|
12678
|
-
if (action === "send-file") {
|
|
12679
|
-
if (!channel.sendFile) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" does not support send-file`);
|
|
12680
|
-
const rawUser = (req.user ?? "").trim();
|
|
12681
|
-
if (rawUser === "") return fail("USER_REQUIRED", "user is required");
|
|
12682
|
-
const fileArg = (req.file ?? "").trim();
|
|
12683
|
-
if (fileArg === "") return fail("FILE_REQUIRED", "file is required");
|
|
12684
|
-
const resolved = channel.resolveUser(root, rawUser);
|
|
12685
|
-
if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
|
|
12686
|
-
const file = readCccFile(root, fileArg);
|
|
12687
|
-
if (!file.ok) return fail(file.code, file.error);
|
|
12688
|
-
try {
|
|
12689
|
-
return {
|
|
12690
|
-
ok: true,
|
|
12691
|
-
channel: channelId,
|
|
12692
|
-
action,
|
|
12693
|
-
detail: {
|
|
12694
|
-
...await channel.sendFile({
|
|
12695
|
-
root,
|
|
12696
|
-
userId: resolved.id,
|
|
12697
|
-
data: file.data,
|
|
12698
|
-
fileName: file.fileName,
|
|
12699
|
-
caption: req.caption,
|
|
12700
|
-
accountId: req.account
|
|
12701
|
-
}),
|
|
12702
|
-
user: resolved.alias ?? resolved.id
|
|
12703
|
-
}
|
|
12704
|
-
};
|
|
12705
|
-
} catch (err) {
|
|
12706
|
-
return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
|
|
12707
|
-
}
|
|
12708
|
-
}
|
|
12709
|
-
return fail("ACTION_UNKNOWN", `unknown action "${action}"`);
|
|
12710
|
-
}
|
|
12711
|
-
//#endregion
|
|
12712
12372
|
//#region src/seams/tool-visibility.ts
|
|
12713
12373
|
/** ACC 自己注册的工具名集合(退化档只发这些 —— 它们一定在册) */
|
|
12714
12374
|
const REGISTERED_TOOLS_SET = new Set(REGISTERED_TOOLS);
|
|
@@ -15144,11 +14804,11 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15144
14804
|
sendJson$2(res, 400, { error: chk.error });
|
|
15145
14805
|
return;
|
|
15146
14806
|
}
|
|
15147
|
-
const { readWeixinSettings } = await import("./route-
|
|
14807
|
+
const { readWeixinSettings } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15148
14808
|
const { weixinBridgeStatus } = await Promise.resolve().then(() => bridge_exports);
|
|
15149
14809
|
const settings = readWeixinSettings(chk.root);
|
|
15150
14810
|
const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
|
|
15151
|
-
const { imCredentialBound } = await import("./route-
|
|
14811
|
+
const { imCredentialBound } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15152
14812
|
const { normalizeAccountChannel } = await import("./accounts-BHQbakUh.js").then((n) => n.t);
|
|
15153
14813
|
const accounts = (settings.accounts ?? []).map((a) => {
|
|
15154
14814
|
const channel = normalizeAccountChannel(a.channel);
|
|
@@ -15185,7 +14845,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15185
14845
|
const { fetchQRCode } = await import("./ilink-CsZ-f64q.js").then((n) => n.a);
|
|
15186
14846
|
const { randomUUID } = await import("node:crypto");
|
|
15187
14847
|
for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
|
|
15188
|
-
const { readWeixinSettings } = await import("./route-
|
|
14848
|
+
const { readWeixinSettings } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15189
14849
|
const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
|
|
15190
14850
|
const loginKey = randomUUID();
|
|
15191
14851
|
weixinLogins.set(loginKey, {
|
|
@@ -15206,7 +14866,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15206
14866
|
sendJson$2(res, 400, { error: "missing accountId" });
|
|
15207
14867
|
return;
|
|
15208
14868
|
}
|
|
15209
|
-
const { removeWeixinAccount } = await import("./route-
|
|
14869
|
+
const { removeWeixinAccount } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15210
14870
|
const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
|
|
15211
14871
|
removeWeixinAccount(root, body.accountId);
|
|
15212
14872
|
syncCccBridge(ctx, root);
|
|
@@ -15232,7 +14892,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15232
14892
|
trajectory: code
|
|
15233
14893
|
});
|
|
15234
14894
|
}
|
|
15235
|
-
const { saveWeixinRoutes } = await import("./route-
|
|
14895
|
+
const { saveWeixinRoutes } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15236
14896
|
const { findDirNameByCode } = await import("./profile-zxakCNXy.js").then((n) => n.a);
|
|
15237
14897
|
for (const r of routes) if (findDirNameByCode(root, r.trajectory) === null) {
|
|
15238
14898
|
sendJson$2(res, 400, { error: `unknown trajectory: ${r.trajectory} (no such trajectory code in ${root})` });
|
|
@@ -15244,7 +14904,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15244
14904
|
}
|
|
15245
14905
|
if (body.action === "set-enabled") {
|
|
15246
14906
|
const enabled = body.enabled === true;
|
|
15247
|
-
const { readWeixinSettings, setWeixinEnabled } = await import("./route-
|
|
14907
|
+
const { readWeixinSettings, setWeixinEnabled } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15248
14908
|
const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
|
|
15249
14909
|
const settings = readWeixinSettings(root);
|
|
15250
14910
|
if (enabled && (settings.accounts ?? []).length === 0) {
|
|
@@ -15267,7 +14927,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15267
14927
|
sendJson$2(res, 400, { error: "appId 与 appSecret 都必须填(只填一个等于没绑)" });
|
|
15268
14928
|
return;
|
|
15269
14929
|
}
|
|
15270
|
-
const { readWeixinSettings, upsertWeixinAccount, writeFeishuCredential, nextFeishuAccountId } = await import("./route-
|
|
14930
|
+
const { readWeixinSettings, upsertWeixinAccount, writeFeishuCredential, nextFeishuAccountId } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15271
14931
|
const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
|
|
15272
14932
|
const before = readWeixinSettings(root);
|
|
15273
14933
|
const accountId = (body.accountId ?? "").trim() !== "" ? body.accountId.trim() : nextFeishuAccountId(before);
|
|
@@ -15317,7 +14977,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15317
14977
|
return;
|
|
15318
14978
|
}
|
|
15319
14979
|
const { pollQRStatus } = await import("./ilink-CsZ-f64q.js").then((n) => n.a);
|
|
15320
|
-
const { readWeixinSettings } = await import("./route-
|
|
14980
|
+
const { readWeixinSettings } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15321
14981
|
const settings = readWeixinSettings(login.root);
|
|
15322
14982
|
const status = await pollQRStatus({
|
|
15323
14983
|
baseUrl: void 0,
|
|
@@ -15332,7 +14992,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15332
14992
|
});
|
|
15333
14993
|
return;
|
|
15334
14994
|
}
|
|
15335
|
-
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-
|
|
14995
|
+
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15336
14996
|
const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
|
|
15337
14997
|
const accountId = nextWeixinAccountId(settings);
|
|
15338
14998
|
upsertWeixinAccount(login.root, {
|
|
@@ -19899,7 +19559,7 @@ function buildOutgoingHookEvent(input) {
|
|
|
19899
19559
|
sessionId: input.sessionId,
|
|
19900
19560
|
role: input.role,
|
|
19901
19561
|
reply: input.reply,
|
|
19902
|
-
...input.source
|
|
19562
|
+
...input.source ? { source: input.source } : {},
|
|
19903
19563
|
...input.file ? { file: input.file } : {}
|
|
19904
19564
|
};
|
|
19905
19565
|
}
|
|
@@ -20105,8 +19765,6 @@ const manualSessions = /* @__PURE__ */ new Map();
|
|
|
20105
19765
|
const sentTurn = /* @__PURE__ */ new Map();
|
|
20106
19766
|
/** 连续打回计数(成功发送即清零) */
|
|
20107
19767
|
const rebukeCounts = /* @__PURE__ */ new Map();
|
|
20108
|
-
/** 闸门是否装配成功(v1.30.17:桥据此判断能否信任"未发送"这一判定——未装配时不兜底,防重复发送) */
|
|
20109
|
-
let guardActive = false;
|
|
20110
19768
|
/** 桥:登记手动模式会话(每轮 incoming 都调用,幂等覆盖) */
|
|
20111
19769
|
function noteManualOutputSession(sessionId, session) {
|
|
20112
19770
|
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
@@ -20135,10 +19793,6 @@ function hasSentThisTurn(sessionId, turn) {
|
|
|
20135
19793
|
if (recorded === null || turn === null) return true;
|
|
20136
19794
|
return recorded === turn;
|
|
20137
19795
|
}
|
|
20138
|
-
/** 闸门是否装配成功(未装配 → 桥不做兜底:宁可静默也不冒重复发送的风险) */
|
|
20139
|
-
function isWeixinOutputGuardActive() {
|
|
20140
|
-
return guardActive;
|
|
20141
|
-
}
|
|
20142
19796
|
/** 从 agent / 会话对象取会话 id(形状宽容) */
|
|
20143
19797
|
function sessionIdOf$1(value) {
|
|
20144
19798
|
const v = value;
|
|
@@ -20155,7 +19809,7 @@ function sessionIdOf$1(value) {
|
|
|
20155
19809
|
* 🔴 **原名为 `isSuccessfulWeixinSend`,2026-10-20 改名**(Q4 出站飞书):
|
|
20156
19810
|
* 它曾硬比 `channel !== 'weixin' ⇒ false`。接第二个渠道后,那条判据的含义变成
|
|
20157
19811
|
* **"只有微信算发出去了"** —— 而实际后果是:飞书会话里 agent 真的发成功了,
|
|
20158
|
-
* 闸门仍判"未发送" ⇒ 打回 ≤2
|
|
19812
|
+
* 闸门仍判"未发送" ⇒ 打回 ≤2 次,且当时的 `fallbackOnNoSend` 还会再兜底转发一遍
|
|
20159
19813
|
* ⇒ **重复投递**。⇒ 判据从"是不是微信"改成"是不是**这个会话所在的那条渠道**",
|
|
20160
19814
|
* 函数名随之更正(**名字要能对着行为读**,D151/D179 同族)。
|
|
20161
19815
|
*
|
|
@@ -20237,10 +19891,32 @@ function registerWeixinOutputGuard(ctx) {
|
|
|
20237
19891
|
const session = manualSessions.get(sessionId);
|
|
20238
19892
|
if (!session) return;
|
|
20239
19893
|
if (hasSentThisTurn(sessionId, payload?.turn ?? null)) return;
|
|
19894
|
+
/**
|
|
19895
|
+
* 🔴🔴 **D234 修法(2026-10-24,D238 强制同批):先确认"它有权发",再打回。**
|
|
19896
|
+
*
|
|
19897
|
+
* 缺陷(我 2026-10-23 查出的真缺陷):v1.88.0 起 `im-bridge` **只对 IM 路由表点名的
|
|
19898
|
+
* 轨迹可见**(取不到轨迹编码即 fail-closed 隐藏)。而本闸门**无条件打回**,打回文案
|
|
19899
|
+
* (`buildManualOutputRebuke`)逐字要求调用的正是那个工具 ⇒ 对一条"能收到消息但没被
|
|
19900
|
+
* 路由表点名"的轨迹,**agent 无论如何都满足不了** ⇒ 连打 2 次后只剩告警,
|
|
19901
|
+
* 用户侧零输出。**打回命中一个不可满足的前置条件**(D205 同族:前置不成立必须显式红,
|
|
19902
|
+
* 而不是继续走必然失败的重试)。
|
|
19903
|
+
*
|
|
19904
|
+
* 🔴 为什么砍掉自动回发后这条变成**必配件**:手动模式成了唯一路径 ⇒ 本闸门**恒生效**
|
|
19905
|
+
* (不再有"自动模式"这条安全退路),缺陷从边缘情形升级为主路径。
|
|
19906
|
+
*
|
|
19907
|
+
* 判据(与 `seams/guards.ts` 的可见性判据**同源**,D235 的要求):
|
|
19908
|
+
* 用同一个 `isImWiredTrajectory` 判——**可见性判据与打回判据必须同源**,
|
|
19909
|
+
* 否则两处各判一套,又会长出"能发但被判成不能发"这类漂移。
|
|
19910
|
+
*/
|
|
19911
|
+
if (!isImWiredTrajectory(session.root, trajectoryCodeForSession(session.root, sessionId))) {
|
|
19912
|
+
console.warn(`[serenity-hooks] ✗ im 输出闸门:session=${sessionId} role=${session.role} **没有 im-bridge 发送权**(该轨迹不在本 CCC 的 IM 路由表 im.routes 里)⇒ **不打回**。本轮 agent 若想说话也无路可走 —— 请把该轨迹编码加进 .opencode/serenity.json 的 im.routes,或让它把话交给已被点名的接线轨迹。`);
|
|
19913
|
+
rebukeCounts.delete(sessionId);
|
|
19914
|
+
return;
|
|
19915
|
+
}
|
|
20240
19916
|
const count = (rebukeCounts.get(sessionId) ?? 0) + 1;
|
|
20241
19917
|
if (count > 2) {
|
|
20242
19918
|
rebukeCounts.delete(sessionId);
|
|
20243
|
-
console.warn(`[serenity-hooks] ✗ weixin 输出闸门:连续 2 次打回后仍未发送(session=${sessionId}, role=${session.role}
|
|
19919
|
+
console.warn(`[serenity-hooks] ✗ weixin 输出闸门:连续 2 次打回后仍未发送(session=${sessionId}, role=${session.role})——**本轮用户将收不到任何消息**(兜底转发已于 2026-10-24 整条退役,出站只剩 im-bridge 一条路)`);
|
|
20244
19920
|
return;
|
|
20245
19921
|
}
|
|
20246
19922
|
rebukeCounts.set(sessionId, count);
|
|
@@ -20261,7 +19937,6 @@ function registerWeixinOutputGuard(ctx) {
|
|
|
20261
19937
|
console.warn("[serenity-hooks] ✗ weixin 输出闸门未装配:agent/turn-stopping 不可用");
|
|
20262
19938
|
return;
|
|
20263
19939
|
}
|
|
20264
|
-
guardActive = true;
|
|
20265
19940
|
}
|
|
20266
19941
|
//#endregion
|
|
20267
19942
|
//#region src/im/inbound-event.ts
|
|
@@ -20544,31 +20219,30 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
20544
20219
|
if (workspaceMd) {
|
|
20545
20220
|
if (!ensureWorkspacePromptSection(ref.agent, sessionId, root)) parts.push(workspaceCognitiveTrajectoryLine(workspaceMd));
|
|
20546
20221
|
}
|
|
20547
|
-
const manualOutput = settings.autoReplyWithLastMessage === false;
|
|
20548
20222
|
if (text) parts.push(text);
|
|
20549
20223
|
parts.push(...mediaNotes, ...degradedNotes);
|
|
20550
|
-
|
|
20551
|
-
|
|
20552
|
-
|
|
20553
|
-
|
|
20554
|
-
|
|
20555
|
-
|
|
20556
|
-
|
|
20557
|
-
|
|
20558
|
-
|
|
20559
|
-
|
|
20560
|
-
|
|
20561
|
-
|
|
20562
|
-
|
|
20563
|
-
|
|
20564
|
-
|
|
20565
|
-
|
|
20566
|
-
|
|
20567
|
-
|
|
20568
|
-
|
|
20569
|
-
|
|
20570
|
-
|
|
20571
|
-
|
|
20224
|
+
/**
|
|
20225
|
+
* 🔴🔴 **2026-10-24(owner 令,D238):出站只有一条路 —— agent 自己调 `im-bridge`。**
|
|
20226
|
+
*
|
|
20227
|
+
* 此前这里是 `if (manualOutput) {…}` 条件分支:`autoReplyWithLastMessage=false` 才走手动,
|
|
20228
|
+
* 否则桥替 agent 转发最终文本。**该开关已整条退役** ⇒ 本段成为**唯一路径**:
|
|
20229
|
+
* 每轮都登记闸门 + 注入机制标记,桥**永不**自动回发。
|
|
20230
|
+
*
|
|
20231
|
+
* 为什么要这样(owner 原话):「autoReply 并不合适,主动性的 imBridge 仍然是更好的」
|
|
20232
|
+
* —— 自动回发让 agent 失去"说几条 / 何时说 / 要不要不说"的决定权,而这正是主动发送的价值。
|
|
20233
|
+
*
|
|
20234
|
+
* ⚠️ **由此产生的唯一代价(必须同时修)**:agent 不发 ⇒ 用户确实收不到。
|
|
20235
|
+
* 所以 D234 的修法是本改动的**必配件**:guard 打回前必须确认 `im-bridge` 对该轨迹可见,
|
|
20236
|
+
* 否则"没权发"会被无解地打回成"忘了发"。见 `im/guard.ts`。
|
|
20237
|
+
*/
|
|
20238
|
+
noteManualOutputSession(sessionId, {
|
|
20239
|
+
root,
|
|
20240
|
+
accountId,
|
|
20241
|
+
userId: fromUserId,
|
|
20242
|
+
role: trajectory,
|
|
20243
|
+
channel
|
|
20244
|
+
});
|
|
20245
|
+
parts.push(manualOutputMarker(accountId, fromUserId, channel));
|
|
20572
20246
|
const question = parts.join("\n");
|
|
20573
20247
|
const hookRel = settings.hook;
|
|
20574
20248
|
if (hookRel) invokeWeixinHook(root, hookRel, buildIncomingHookEvent({
|
|
@@ -20583,53 +20257,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
20583
20257
|
})).catch((err) => {
|
|
20584
20258
|
console.log(`[serenity-hooks] im hook incoming error: ${err instanceof Error ? err.message : String(err)}`);
|
|
20585
20259
|
});
|
|
20586
|
-
|
|
20587
|
-
if (manualOutput) {
|
|
20588
|
-
if (!manualOutputFallbackNeeded({
|
|
20589
|
-
fallbackOnNoSend: settings.fallbackOnNoSend,
|
|
20590
|
-
guardActive: isWeixinOutputGuardActive(),
|
|
20591
|
-
sentThisTurn: hasSentThisTurn(sessionId),
|
|
20592
|
-
answer
|
|
20593
|
-
})) return;
|
|
20594
|
-
const reply = markdownToPlainText(stripThink(answer));
|
|
20595
|
-
await io.sendText({
|
|
20596
|
-
toUserId: fromUserId,
|
|
20597
|
-
text: reply,
|
|
20598
|
-
replyRef: event.replyRef
|
|
20599
|
-
});
|
|
20600
|
-
console.warn(`[serenity-hooks] ⚠ im 输出兜底:agent 本轮未发送(闸门打回用尽)→ 桥转发最终文本(session=${sessionId}, trajectory=${trajectory})`);
|
|
20601
|
-
if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
|
|
20602
|
-
cccRoot: root,
|
|
20603
|
-
channel,
|
|
20604
|
-
accountId,
|
|
20605
|
-
userId: fromUserId,
|
|
20606
|
-
sessionId,
|
|
20607
|
-
role: trajectory,
|
|
20608
|
-
reply,
|
|
20609
|
-
source: "reply-fallback"
|
|
20610
|
-
})).catch((err) => {
|
|
20611
|
-
console.log(`[serenity-hooks] im hook outgoing(reply-fallback) error: ${err instanceof Error ? err.message : String(err)}`);
|
|
20612
|
-
});
|
|
20613
|
-
return;
|
|
20614
|
-
}
|
|
20615
|
-
if (answer === "") return;
|
|
20616
|
-
const reply = markdownToPlainText(stripThink(answer));
|
|
20617
|
-
await io.sendText({
|
|
20618
|
-
toUserId: fromUserId,
|
|
20619
|
-
text: reply,
|
|
20620
|
-
replyRef: event.replyRef
|
|
20621
|
-
});
|
|
20622
|
-
if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
|
|
20623
|
-
cccRoot: root,
|
|
20624
|
-
channel,
|
|
20625
|
-
accountId,
|
|
20626
|
-
userId: fromUserId,
|
|
20627
|
-
sessionId,
|
|
20628
|
-
role: trajectory,
|
|
20629
|
-
reply
|
|
20630
|
-
})).catch((err) => {
|
|
20631
|
-
console.log(`[serenity-hooks] im hook outgoing error: ${err instanceof Error ? err.message : String(err)}`);
|
|
20632
|
-
});
|
|
20260
|
+
await askTrajectory(ctx, ref.agent, question);
|
|
20633
20261
|
} finally {
|
|
20634
20262
|
await io.typingStop?.({ toUserId: fromUserId });
|
|
20635
20263
|
}
|
|
@@ -20643,7 +20271,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
20643
20271
|
}
|
|
20644
20272
|
}
|
|
20645
20273
|
/**
|
|
20646
|
-
*
|
|
20274
|
+
* 出站**机制标记**(v1.30.10 引入;v1.30.16 按「ACC 管机制、CCC 管措辞」重构)。
|
|
20647
20275
|
*
|
|
20648
20276
|
* 边界(R↓):ACC 只提供**机制与数据**——"桥不转发最终文本"这个事实 + 三个已填好的参数值。
|
|
20649
20277
|
* **纪律措辞归 CCC**:怎么写、必须怎么做、后果是什么,全部写在轨迹的 `PROMPT.md` 里。
|
|
@@ -20651,22 +20279,25 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
20651
20279
|
* 🔴 **渠道不是常量**(2026-10-20 Q4):此前这里硬写 `channel:"weixin"`,
|
|
20652
20280
|
* 而一条**飞书**会话收到的却是"发往微信"的命令 ⇒ 模型照着做必然失败(目标 id 是 open_id)。
|
|
20653
20281
|
* ⇒ 渠道由调用方传入;缺省 `'weixin'` 保留既有调用点与测试的读数。
|
|
20282
|
+
*
|
|
20283
|
+
* 🔴 2026-10-24(D238):本标记从"手动模式才注入"变成**每轮必注入**(手动模式已成唯一路径)。
|
|
20284
|
+
* ⚠️ **标记字符串 `[serenity:weixin-manual-output]` 不改名**:CCC 侧角色提示词按它做判据,
|
|
20285
|
+
* 改名会把已写好的纪律文档全打成废纸;它是**协议标记**,不是渠道名(与 A85 同一处置)。
|
|
20654
20286
|
*/
|
|
20655
20287
|
function manualOutputMarker(accountId, userId, channel = "weixin") {
|
|
20656
20288
|
return ["[serenity:weixin-manual-output]", `im-bridge({channel:"${channel}", action:"send", account:"${accountId}", user:"${userId}", text:"<回复>"})`].join("\n");
|
|
20657
20289
|
}
|
|
20658
20290
|
/**
|
|
20659
|
-
*
|
|
20291
|
+
* 🔴 **`manualOutputFallbackNeeded` 已于 2026-10-24 删除**(owner 令,D238)。
|
|
20292
|
+
*
|
|
20293
|
+
* 它实现的是「手动模式下 agent 一次都没发成功 ⇒ 桥兜底转发最终文本」。
|
|
20294
|
+
* **随 `fallbackOnNoSend` 一起退役** —— 那个开关只在 `autoReplyWithLastMessage=false` 时才有意义,
|
|
20295
|
+
* 而后者已整条删除(出站只剩 `im-bridge` 一条路)。
|
|
20660
20296
|
*
|
|
20661
|
-
*
|
|
20662
|
-
*
|
|
20663
|
-
*
|
|
20664
|
-
* ③ 本轮**没有**成功发送(agent 自己发过 → 输出权归 agent,桥不插话)
|
|
20665
|
-
* ④ 有最终文本可转发(空文本无意义)
|
|
20297
|
+
* ⛔ **不要把它改写成"自动兜底"再放回来**:owner 否决的正是"桥替 agent 说最后一句"这件事本身
|
|
20298
|
+
* (「autoReply 并不合适,主动性的 imBridge 仍然是更好的」),而不是它的某个实现细节。
|
|
20299
|
+
* 兜底带来的"不丢消息"收益,改由 guard 打回(可见性前置检查 + 响亮告警)承担。
|
|
20666
20300
|
*/
|
|
20667
|
-
function manualOutputFallbackNeeded(input) {
|
|
20668
|
-
return input.fallbackOnNoSend === true && input.guardActive && !input.sentThisTurn && input.answer !== "";
|
|
20669
|
-
}
|
|
20670
20301
|
//#endregion
|
|
20671
20302
|
//#region src/im/feishu-account.ts
|
|
20672
20303
|
/** 去重集合上限(飞书重推窗口有限;限量防内存无界增长) */
|
|
@@ -22935,8 +22566,6 @@ const weixinChannel = {
|
|
|
22935
22566
|
});
|
|
22936
22567
|
return {
|
|
22937
22568
|
enabled: settings.enabled === true,
|
|
22938
|
-
autoReplyWithLastMessage: settings.autoReplyWithLastMessage !== false,
|
|
22939
|
-
fallbackOnNoSend: settings.fallbackOnNoSend === true,
|
|
22940
22569
|
routes,
|
|
22941
22570
|
accounts
|
|
22942
22571
|
};
|
|
@@ -23177,8 +22806,6 @@ const feishuChannel = {
|
|
|
23177
22806
|
}));
|
|
23178
22807
|
return {
|
|
23179
22808
|
enabled: settings.enabled === true,
|
|
23180
|
-
autoReplyWithLastMessage: settings.autoReplyWithLastMessage !== false,
|
|
23181
|
-
fallbackOnNoSend: settings.fallbackOnNoSend === true,
|
|
23182
22809
|
routes,
|
|
23183
22810
|
accounts
|
|
23184
22811
|
};
|