@shgroup/dsh-serenity-hooks 1.89.0 → 1.91.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 +349 -693
- 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/session.d.ts +1 -1
- package/lib/wake/scheduler.d.ts +11 -0
- 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.",
|
|
@@ -10341,13 +10428,39 @@ async function runWakeTick(ctx) {
|
|
|
10341
10428
|
* - ⚠️ **本函数任何失败都不得连累既有链路**(设计 §5 铁律):整体 try/catch 吞掉并留一行日志。
|
|
10342
10429
|
*/
|
|
10343
10430
|
async function runInterruptedSafetyNet(ctx, root, log) {
|
|
10431
|
+
const s0 = wakeSchedulerState();
|
|
10432
|
+
const tickNow = s0.ticks + 1;
|
|
10433
|
+
const phase = tickNow <= 1 ? "启动首拍" : tickNow <= 3 ? "刚启动窗口" : "常规 tick";
|
|
10434
|
+
const tickTag = `tick=${s0.ticks}(当前第${tickNow}拍·${phase})`;
|
|
10344
10435
|
try {
|
|
10345
10436
|
const agents = hostAgents(ctx);
|
|
10346
10437
|
const sc = hostService(ctx, "sessionController");
|
|
10347
|
-
|
|
10348
|
-
|
|
10438
|
+
/**
|
|
10439
|
+
* 🔴 **A105:live 排除的粒度 =「此刻有没有在跑」,不是「宿主认不认识它」。**
|
|
10440
|
+
*
|
|
10441
|
+
* 修前:`collectLiveSessionIdsForSafetyNet` 取的是 `hostSessions(ctx).list()`
|
|
10442
|
+
* ⇒ 那是**载入状态**(会话对象在本进程里),**不是**轮次状态 ⇒
|
|
10443
|
+
* 一条「已载入、但上一轮被重启打断」的会话会被**误排除**,
|
|
10444
|
+
* 而那恰恰是**最该被接走**的一类(2026-10-23 实测:这正是那次 13 分钟盲窗的形状)。
|
|
10445
|
+
*
|
|
10446
|
+
* ⇒ 现在与权威 `interrupted` 动作**用同一个取数口**(`listRunningSessionIds`),
|
|
10447
|
+
* 两处对"在跑"的认知**不可能再不一致**(A105 的全部目的就是这一条)。
|
|
10448
|
+
*/
|
|
10449
|
+
const liveIds = new Set(listRunningSessionIds(Date.now()));
|
|
10349
10450
|
const scan = scanInterruptedSessions(`${sessionsRootDir()}/${projectDirNameForCwd(root)}`, { liveIds });
|
|
10350
|
-
|
|
10451
|
+
/**
|
|
10452
|
+
* 🔴 **A106:无命中也要出声**(`ok`/`fail`/`not-ready`/`no-wake` 之后缺的第五格)。
|
|
10453
|
+
*
|
|
10454
|
+
* 修前这里 `return`,**一行都不写** ⇒ 宿主日志里没有任何「中断兜底」字样 ⇒
|
|
10455
|
+
* **「我扫了、没东西」与「我没被跑」在读数上完全一样**(D216 同族第四次)。
|
|
10456
|
+
* ⇒ 判据同 A102:**可以不出声地判"没事",但"判过"本身要留痕**。
|
|
10457
|
+
* ⚠️ 密度很低(每 tick 一行,5min 一次),且**必须带拍号与扫描读数**,
|
|
10458
|
+
* 否则又是一堆分不清是哪一拍的日志行(guard 明确要求)。
|
|
10459
|
+
*/
|
|
10460
|
+
if (scan.sessions.length === 0) {
|
|
10461
|
+
log.push(`· ${root}: 中断兜底 ${tickTag} → 扫 ${scan.scanned} 个 · 命中 0 · 排除在跑 ${liveIds.size} 个 · 跳过 ${scan.skipped} ⇒ 无需注入`);
|
|
10462
|
+
return;
|
|
10463
|
+
}
|
|
10351
10464
|
const resumeOne = createResumeOne({
|
|
10352
10465
|
agents,
|
|
10353
10466
|
sessionController: sc,
|
|
@@ -10359,6 +10472,7 @@ async function runInterruptedSafetyNet(ctx, root, log) {
|
|
|
10359
10472
|
source: PLUGIN_SOURCE
|
|
10360
10473
|
})
|
|
10361
10474
|
});
|
|
10475
|
+
log.push(`· ${root}: 中断兜底 ${tickTag} → 扫 ${scan.scanned} 个 · 命中 ${scan.sessions.length} · 排除在跑 ${liveIds.size} 个`);
|
|
10362
10476
|
for (const hit of scan.sessions) try {
|
|
10363
10477
|
const out = await resumeOne(hit);
|
|
10364
10478
|
log.push(`· ${root}: 中断兜底 ${hit.sessionId}(turn ${hit.turn ?? "?"})→ ` + (out.ok ? `已注入(${out.how ?? "?"})` : `未注入:${out.error ?? "未知"}`));
|
|
@@ -10366,18 +10480,20 @@ async function runInterruptedSafetyNet(ctx, root, log) {
|
|
|
10366
10480
|
log.push(`· ${root}: 中断兜底 ${hit.sessionId} 注入抛错(已忽略):${String(err?.message ?? err)}`);
|
|
10367
10481
|
}
|
|
10368
10482
|
} catch (err) {
|
|
10369
|
-
log.push(`· ${root}:
|
|
10483
|
+
log.push(`· ${root}: 中断兜底 ${tickTag} 整体失败(已忽略,不影响既有链路):${String(err?.message ?? err)}`);
|
|
10370
10484
|
}
|
|
10371
10485
|
}
|
|
10372
|
-
/**
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
|
|
10376
|
-
|
|
10377
|
-
|
|
10378
|
-
|
|
10379
|
-
|
|
10380
|
-
|
|
10486
|
+
/**
|
|
10487
|
+
* ⚠️ **2026-10-23 A105 已删除 `collectLiveSessionIdsForSafetyNet`。**
|
|
10488
|
+
*
|
|
10489
|
+
* 它取的是 `hostSessions(ctx).list()` =「宿主认不认识」,而 A105 的结论是
|
|
10490
|
+
* 判据必须是「**此刻有没有在跑**」。现在两处(本兜底与权威 `interrupted` 动作)
|
|
10491
|
+
* 都走 `listRunningSessionIds()` —— **同一张表、同一个口径**,不可能再打架。
|
|
10492
|
+
*
|
|
10493
|
+
* 🔴 后人若要"把 live 表也加回来做双保险":**不要** ——
|
|
10494
|
+
* `live ∧ ¬running` 恰是「已载入、但上一轮被打断」那一类,**它最该被接走**;
|
|
10495
|
+
* 加回来就是把最该救的那一格重新排除掉。
|
|
10496
|
+
*/
|
|
10381
10497
|
/**
|
|
10382
10498
|
* 装配调度器(index.ts apply 调用):5min tick + 生命周期 disposer +
|
|
10383
10499
|
* 会话出现/settings 变化时热启动(与 autopilot 同款触发面,互不干扰)。
|
|
@@ -10728,85 +10844,6 @@ function listResident(root) {
|
|
|
10728
10844
|
return lines.join("\n");
|
|
10729
10845
|
}
|
|
10730
10846
|
//#endregion
|
|
10731
|
-
//#region src/trajectory/live.ts
|
|
10732
|
-
/**
|
|
10733
|
-
* trajectory/live.ts — 进程内 live 会话取数 + 精简诊断
|
|
10734
|
-
*
|
|
10735
|
-
* ── 为什么单独成模块(R↓,2026-09-15 S142「ACC 侧 autopilot 退场」S1 搬迁)──
|
|
10736
|
-
* 这两个函数原先住在 `autopilot-trajectory.ts`,但它们的**主语不是 autopilot**:
|
|
10737
|
-
* `listLiveSessions` 服务 `acc-diag` ① 段的"**哪个会话属于哪个 CCC**"(会话维度,
|
|
10738
|
-
* 不是 CCC 维度),而"**独立进程看不到运行时**"正是 `acc-diag` 存在的理由。
|
|
10739
|
-
* autopilot 退场后它们必须活下去 ⇒ 独立成模块,不再挂着已死机制的名字。
|
|
10740
|
-
*
|
|
10741
|
-
* ── 迁移边界(不搬什么,以及为什么)──────────────────────────────────────
|
|
10742
|
-
* 原 `diagLive` 还含两段,**均不搬**(其主语是 autopilot,随机制退场):
|
|
10743
|
-
* · `autopilotCccs`(逐 CCC 目标命中 / agent 定位诊断)—— 依赖 `readAutopilotSettings` /
|
|
10744
|
-
* `getAutopilotStatus` / `resolveTargetAgent`
|
|
10745
|
-
* · `panelResolved`(无参面板请求落到哪个 CCC)—— 面板区块随机制退场
|
|
10746
|
-
* 留下的 = **进程 cwd/CCC + live 会话清单**(`acc-diag` ① 段仍需要)。
|
|
10747
|
-
*
|
|
10748
|
-
* 判据纪律:本模块**不得**反向依赖 autopilot 判据层(那会让退场半途而废)。
|
|
10749
|
-
*
|
|
10750
|
-
* ── 🔴 2026-09-25 I2 收敛(约束文档 §2.7 违反清单,L1 违反之一)────────────────
|
|
10751
|
-
* **本模块的 `ctx` 参数类型从 `Context` 改为 `unknown`** —— 消除本文件唯一的 `W2` 接触
|
|
10752
|
-
* (直接 import 宿主 `cordis` 的类型)。
|
|
10753
|
-
*
|
|
10754
|
-
* **为什么这是"真解耦"而不是"把类型藏起来"(判据):** `ctx` 在本模块**从不被解引用** ——
|
|
10755
|
-
* 它只被**原样透传**给 `hostSessions(ctx)`,而后者(`host/access.ts`,**L0**)的参数
|
|
10756
|
-
* 本就声明为 `ctx: unknown` ⇒ `Context` 这个 import **在语义上是多余的**,
|
|
10757
|
-
* 它没有参与任何类型检查,只贡献了一处 L1→宿主 的接触点。
|
|
10758
|
-
* 🔵 **证据**:grep 本文件 `ctx` 全部 5 处命中 —— 3 处在参数/调用位、2 处在注释,
|
|
10759
|
-
* **零处**属性访问(`ctx.xxx`)。
|
|
10760
|
-
*/
|
|
10761
|
-
/**
|
|
10762
|
-
* 从 dsh 会话 log 读取标题(latest-wins `session/title` 事件)——
|
|
10763
|
-
* **标题不在 `sessions.list()` 条目上**(wire/对象均无 title 字段;F3 命名经
|
|
10764
|
-
* `sessionTitle.rename` 写进 session log),必须从 events 提取(rebuild 同款读取模式)。
|
|
10765
|
-
*/
|
|
10766
|
-
function readSessionTitle(session) {
|
|
10767
|
-
try {
|
|
10768
|
-
const events = sessionEvents(session);
|
|
10769
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
10770
|
-
const e = events[i];
|
|
10771
|
-
if (e?.type === "session/title" && typeof e.data?.title === "string" && e.data.title.trim() !== "") return e.data.title.trim();
|
|
10772
|
-
}
|
|
10773
|
-
} catch {}
|
|
10774
|
-
return null;
|
|
10775
|
-
}
|
|
10776
|
-
/**
|
|
10777
|
-
* 逐条 live 会话(`cccRoot` = 该会话 cwd 的 CCC 归属,无 → null)。
|
|
10778
|
-
*
|
|
10779
|
-
* CCC 枚举("本机有哪些 CCC")**不在这里**,归 `ccc-roots.listCccs`(并集语义);
|
|
10780
|
-
* 本函数保留的是它**独有**的那部分:会话维度 + cwd 归属 + 标题 + id。
|
|
10781
|
-
*
|
|
10782
|
-
* @param ctx 插件上下文(**只透传**给 L0 取数口,本模块不解引用 ⇒ 类型为 `unknown`)
|
|
10783
|
-
* @returns 逐条 live 会话
|
|
10784
|
-
*/
|
|
10785
|
-
function listLiveSessions(ctx) {
|
|
10786
|
-
const out = [];
|
|
10787
|
-
try {
|
|
10788
|
-
const sessions = hostSessions(ctx);
|
|
10789
|
-
for (const s of sessions?.list?.() ?? []) {
|
|
10790
|
-
const cwd = s?.header?.cwd ?? null;
|
|
10791
|
-
out.push({
|
|
10792
|
-
id: s?.id ?? "",
|
|
10793
|
-
cwd,
|
|
10794
|
-
cccRoot: cwd ? cccRootForCwd(cwd) : null,
|
|
10795
|
-
title: readSessionTitle(s)
|
|
10796
|
-
});
|
|
10797
|
-
}
|
|
10798
|
-
} catch {}
|
|
10799
|
-
return out;
|
|
10800
|
-
}
|
|
10801
|
-
function diagLive(ctx) {
|
|
10802
|
-
const processCwd = process.cwd();
|
|
10803
|
-
return {
|
|
10804
|
-
processCwd,
|
|
10805
|
-
processCcc: cccRootForCwd(processCwd),
|
|
10806
|
-
liveSessions: listLiveSessions(ctx)
|
|
10807
|
-
};
|
|
10808
|
-
}
|
|
10809
|
-
//#endregion
|
|
10810
10847
|
//#region src/tools/cognitive-trajectory.ts
|
|
10811
10848
|
/**
|
|
10812
10849
|
* trajectory.ts — `cognitive_trajectory` 真实 DSH 工具定义(defineTool)
|
|
@@ -10848,17 +10885,44 @@ function diagLive(ctx) {
|
|
|
10848
10885
|
* 活跃会话跟踪:内存 Map(按 dsh 会话 id 隔离)+ 从 events 恢复(S134)。
|
|
10849
10886
|
*/
|
|
10850
10887
|
/**
|
|
10851
|
-
*
|
|
10888
|
+
* 当前**正在跑轮次**的 dsh 会话 id(用于把 live 会话排除出"待续"名单)。
|
|
10852
10889
|
*
|
|
10853
10890
|
* 🔴 **为什么必须排除**:一个正在跑的会话,其日志末条**不是** `session/end-seed`
|
|
10854
10891
|
* (它还在写),所以按判据本来也不会命中;但排除是**双保险** ——
|
|
10855
10892
|
* 万一某轮刚被中断、宿主还没来得及继续,把 live 会话当"待续"会导致**重复投递**。
|
|
10856
|
-
*
|
|
10893
|
+
*
|
|
10894
|
+
* ── 🔴🔴 2026-10-23 A105:**从「live 表」改为「在跑表」,且从 `ctx` 取数** ──────────
|
|
10895
|
+
*
|
|
10896
|
+
* **修前有两个缺陷,叠在一起让这段排除在生产上形同虚设:**
|
|
10897
|
+
*
|
|
10898
|
+
* ① **`ctx` 传的是 `undefined`** ⇒ `listLiveSessions(undefined)` ⇒ `hostSessions(undefined)`
|
|
10899
|
+
* ⇒ 取不到宿主 ⇒ **live 表恒空** ⇒ `scanInterruptedSessions` 的 `liveIds` 是空集。
|
|
10900
|
+
* ⚠️ 原注释写「ctx 在 execute 里拿不到(本工具只拿到 exec)」,**那句是错的**:
|
|
10901
|
+
* 本工具的工厂 `createCognitiveTrajectoryTool(ctx)` **闭包捕获了真 ctx**,
|
|
10902
|
+
* 且同文件的 `continue-all-interrupted` 分支早就靠它取 `hostAgents(ctx)` / `hostService(ctx,…)`
|
|
10903
|
+
* —— 见那一支的注释「**必须用工厂参数 `ctx`,不能用 `exec`**」。**同一份源码里,
|
|
10904
|
+
* 一处说"拿不到"、另一处说"必须用它"**,这正是两个读数打架的典型形态。
|
|
10905
|
+
*
|
|
10906
|
+
* ② **粒度错了:`live ≠ 在跑`。** `listLiveSessions` 答的是「宿主认不认识这个会话」
|
|
10907
|
+
* (会话对象已载入本进程),**不是**「它此刻有没有轮次在跑」。
|
|
10908
|
+
* ⇒ 一条**已载入但空闲**的会话会被误排除 ⇒ **它恰恰是"上一轮被重启打断、最该被接走"的那一类**。
|
|
10909
|
+
* 2026-10-23 实测:同一时刻 `acc-diag` 把一条会话列为 live,而本动作把它当命中扫出 ⇒
|
|
10910
|
+
* 两个口径两个答案;随后真机证明那条会话当时**确实该被接走**。
|
|
10911
|
+
*
|
|
10912
|
+
* ⇒ **现在答的是「此刻有没有在跑」**,取数口 = `listRunningSessionIds(nowMs)`(`cro/turns.ts`):
|
|
10913
|
+
* 由 `agent/status=running` 置位、`idle` 清位,`TURN_TTL_MS` 兜底。
|
|
10914
|
+
*
|
|
10915
|
+
* 🔴 **不要再改回「宿主认不认识它」** —— 这是本函数最容易被"改得更省事"而改回去的地方。
|
|
10916
|
+
* 「宿主认识」是**载入状态**,「在跑」是**轮次状态**,两者互不蕴含:
|
|
10917
|
+
* 载入而空闲(该接走)、在跑但刚被中断(该接走)、在跑且健康(才该排除)。
|
|
10918
|
+
* 只有第三格该排除 ⇒ 判据必须是轮次状态。
|
|
10919
|
+
*
|
|
10920
|
+
* 拿不到 running 表时退化为空集(不阻断扫描,只少一层保护)—— 与修前同口径。
|
|
10857
10921
|
*/
|
|
10858
|
-
function
|
|
10922
|
+
function collectRunningSessionIds(nowMs) {
|
|
10859
10923
|
const out = /* @__PURE__ */ new Set();
|
|
10860
10924
|
try {
|
|
10861
|
-
for (const
|
|
10925
|
+
for (const id of listRunningSessionIds(nowMs)) if (typeof id === "string" && id) out.add(id);
|
|
10862
10926
|
} catch {}
|
|
10863
10927
|
return out;
|
|
10864
10928
|
}
|
|
@@ -11380,7 +11444,7 @@ function createCognitiveTrajectoryTool(ctx) {
|
|
|
11380
11444
|
lookbackHours,
|
|
11381
11445
|
scan: scanInterruptedSessions(projectDir, {
|
|
11382
11446
|
lookbackMs: lookbackHours * 36e5,
|
|
11383
|
-
liveIds:
|
|
11447
|
+
liveIds: collectRunningSessionIds(Date.now())
|
|
11384
11448
|
})
|
|
11385
11449
|
});
|
|
11386
11450
|
}
|
|
@@ -11404,7 +11468,7 @@ function createCognitiveTrajectoryTool(ctx) {
|
|
|
11404
11468
|
const projectDir = join(sessionsRootPath, projectDirNameForCwd(cwdRead));
|
|
11405
11469
|
const scan = scanInterruptedSessions(projectDir, {
|
|
11406
11470
|
lookbackMs: lookbackHours * 36e5,
|
|
11407
|
-
liveIds:
|
|
11471
|
+
liveIds: collectRunningSessionIds(Date.now())
|
|
11408
11472
|
});
|
|
11409
11473
|
if (dryRun) return renderContinueResult({
|
|
11410
11474
|
projectDir,
|
|
@@ -12278,460 +12342,6 @@ function createContainerAdminTool(ctx) {
|
|
|
12278
12342
|
});
|
|
12279
12343
|
}
|
|
12280
12344
|
//#endregion
|
|
12281
|
-
//#region src/im/log-scan.ts
|
|
12282
|
-
/**
|
|
12283
|
-
* im/log-scan.ts — 读聊天记录落盘,汇总出「与谁有过往来」(`contacts` 的数据源)
|
|
12284
|
-
*
|
|
12285
|
-
* ## 这个文件解决什么
|
|
12286
|
-
*
|
|
12287
|
-
* owner 2026-10-22:「配路由需要的是**账户到轨迹**,轨迹 CCC 知道,**账户 CCC 不知道**,
|
|
12288
|
-
* 需要有个能力让 CCC 查询用户配好的账户」。这里的"账户"= **通道用户**(对方是谁),
|
|
12289
|
-
* 不是 IM 账号(我们自己登录的身份,`status` 早就能查)。
|
|
12290
|
-
*
|
|
12291
|
-
* 那个 id(微信 `xxx@im.wechat` / 飞书 `ou_…`)不在任何配置文件里 ——
|
|
12292
|
-
* 它**只在聊天记录里出现过**。⇒ 本文件把记录读成联系人表。
|
|
12293
|
-
*
|
|
12294
|
-
* ## 🔴 两条硬边界
|
|
12295
|
-
*
|
|
12296
|
-
* 1. **只读**:本文件不写任何东西。真相源仍是那些 jsonl(append-only 事件流),
|
|
12297
|
-
* 本文件只是它的一次投影 —— ⛔ 不产生第二真相源。
|
|
12298
|
-
* 2. **不猜渠道**:事件的 `channel` 字段为准;缺它(存量事件)⇒ 按
|
|
12299
|
-
* `accountId → im.accounts[].channel` 回填,仍取不到才落 `weixin`
|
|
12300
|
-
* (那是该字段存在之前唯一的渠道)。⛔ **绝不按 userId 的形状猜**
|
|
12301
|
-
* (`ou_` 前缀 / `@im.wechat` 后缀都是命名习惯,不是判据 —— D167 认族不认名)。
|
|
12302
|
-
*/
|
|
12303
|
-
/** 日志根目录族(**新旧两代并存**:`_im-logs/<channel>/` 与存量 `_weixin-logs/`) */
|
|
12304
|
-
const IM_LOG_DIR_NEW = "_im-logs";
|
|
12305
|
-
const IM_LOG_DIR_LEGACY = "_weixin-logs";
|
|
12306
|
-
/** 某 CCC 下**存在**的日志目录(相对根),新代在前 */
|
|
12307
|
-
function imLogRoots(root) {
|
|
12308
|
-
const out = [];
|
|
12309
|
-
const newBase = join(root, "AGENT_SESSIONS", IM_LOG_DIR_NEW);
|
|
12310
|
-
const legacy = join(root, "AGENT_SESSIONS", IM_LOG_DIR_LEGACY);
|
|
12311
|
-
if (existsSync(newBase)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_NEW));
|
|
12312
|
-
if (existsSync(legacy)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_LEGACY));
|
|
12313
|
-
return out;
|
|
12314
|
-
}
|
|
12315
|
-
/** 收集某个日志根下所有 `*.jsonl`(含 `_im-logs/<channel>/` 一层;按文件名排序) */
|
|
12316
|
-
function jsonlFilesIn(root, rel) {
|
|
12317
|
-
const abs = join(root, rel);
|
|
12318
|
-
const out = [];
|
|
12319
|
-
let entries = [];
|
|
12320
|
-
try {
|
|
12321
|
-
entries = readdirSync(abs, { withFileTypes: true }).map((d) => d.name);
|
|
12322
|
-
} catch {
|
|
12323
|
-
return out;
|
|
12324
|
-
}
|
|
12325
|
-
for (const name of entries.sort()) {
|
|
12326
|
-
if (name.startsWith(".")) continue;
|
|
12327
|
-
if (name.endsWith(".jsonl")) {
|
|
12328
|
-
out.push(join(rel, name));
|
|
12329
|
-
continue;
|
|
12330
|
-
}
|
|
12331
|
-
if (rel === join("AGENT_SESSIONS", "_im-logs")) {
|
|
12332
|
-
const sub = join(rel, name);
|
|
12333
|
-
try {
|
|
12334
|
-
for (const child of readdirSync(join(root, sub)).sort()) if (child.endsWith(".jsonl")) out.push(join(sub, child));
|
|
12335
|
-
} catch {}
|
|
12336
|
-
}
|
|
12337
|
-
}
|
|
12338
|
-
return out;
|
|
12339
|
-
}
|
|
12340
|
-
/** 从文件名 / 所在目录猜渠道(**只作为回填的最后一级**,不是判据) */
|
|
12341
|
-
function channelFromPath(rel) {
|
|
12342
|
-
const parts = rel.split("/");
|
|
12343
|
-
const idx = parts.indexOf(IM_LOG_DIR_NEW);
|
|
12344
|
-
const candidate = idx >= 0 && parts.length > idx + 2 ? parts[idx + 1] : void 0;
|
|
12345
|
-
return candidate !== void 0 && candidate !== "" ? candidate : null;
|
|
12346
|
-
}
|
|
12347
|
-
/**
|
|
12348
|
-
* 扫描本 CCC 的全部聊天记录,按 `(channel, userId)` 汇总。
|
|
12349
|
-
*
|
|
12350
|
-
* @param root CCC 根
|
|
12351
|
-
* @param accountChannel 存量事件缺 `channel` 时的回填表(`accountId → channel`)
|
|
12352
|
-
* @returns 汇总结果 + **扫描域** + 读取错误(三者都不可省)
|
|
12353
|
-
*/
|
|
12354
|
-
function scanImContacts(root, accountChannel = {}) {
|
|
12355
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
12356
|
-
const sources = [];
|
|
12357
|
-
const errors = [];
|
|
12358
|
-
for (const rel of imLogRoots(root)) for (const fileRel of jsonlFilesIn(root, rel)) {
|
|
12359
|
-
sources.push(fileRel);
|
|
12360
|
-
let text = "";
|
|
12361
|
-
try {
|
|
12362
|
-
text = readFileSync(join(root, fileRel), "utf8");
|
|
12363
|
-
} catch (err) {
|
|
12364
|
-
errors.push(`${fileRel}: ${err instanceof Error ? err.message : String(err)}`);
|
|
12365
|
-
continue;
|
|
12366
|
-
}
|
|
12367
|
-
const pathChannel = channelFromPath(fileRel);
|
|
12368
|
-
for (const line of text.split("\n")) {
|
|
12369
|
-
const trimmed = line.trim();
|
|
12370
|
-
if (trimmed === "") continue;
|
|
12371
|
-
let ev;
|
|
12372
|
-
try {
|
|
12373
|
-
ev = JSON.parse(trimmed);
|
|
12374
|
-
} catch {
|
|
12375
|
-
errors.push(`${fileRel}: 有 1 行不是合法 JSON(已跳过)`);
|
|
12376
|
-
continue;
|
|
12377
|
-
}
|
|
12378
|
-
const userId = typeof ev.userId === "string" ? ev.userId.trim() : "";
|
|
12379
|
-
if (userId === "") continue;
|
|
12380
|
-
const accountId = typeof ev.accountId === "string" ? ev.accountId.trim() : "";
|
|
12381
|
-
const channel = (typeof ev.channel === "string" && ev.channel.trim() !== "" ? ev.channel.trim() : null) ?? pathChannel ?? (accountId !== "" ? accountChannel[accountId] ?? null : null) ?? "weixin";
|
|
12382
|
-
const key = `${channel}\u0000${userId}`;
|
|
12383
|
-
let entry = byKey.get(key);
|
|
12384
|
-
if (!entry) {
|
|
12385
|
-
entry = {
|
|
12386
|
-
channel,
|
|
12387
|
-
userId,
|
|
12388
|
-
accountIds: [],
|
|
12389
|
-
roleAtEvent: null,
|
|
12390
|
-
firstSeen: null,
|
|
12391
|
-
lastSeen: null,
|
|
12392
|
-
messages: 0
|
|
12393
|
-
};
|
|
12394
|
-
byKey.set(key, entry);
|
|
12395
|
-
}
|
|
12396
|
-
if (accountId !== "" && !entry.accountIds.includes(accountId)) entry.accountIds.push(accountId);
|
|
12397
|
-
if (typeof ev.role === "string" && ev.role.trim() !== "") entry.roleAtEvent = ev.role.trim();
|
|
12398
|
-
const ts = typeof ev.ts === "number" && Number.isFinite(ev.ts) ? ev.ts : null;
|
|
12399
|
-
if (ts !== null) {
|
|
12400
|
-
if (entry.firstSeen === null || ts < entry.firstSeen) entry.firstSeen = ts;
|
|
12401
|
-
if (entry.lastSeen === null || ts > entry.lastSeen) entry.lastSeen = ts;
|
|
12402
|
-
}
|
|
12403
|
-
entry.messages += 1;
|
|
12404
|
-
}
|
|
12405
|
-
}
|
|
12406
|
-
return {
|
|
12407
|
-
contacts: [...byKey.values()].sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0)),
|
|
12408
|
-
sources,
|
|
12409
|
-
errors
|
|
12410
|
-
};
|
|
12411
|
-
}
|
|
12412
|
-
//#endregion
|
|
12413
|
-
//#region src/im/channels.ts
|
|
12414
|
-
/**
|
|
12415
|
-
* im/channels.ts — IM 发送能力层(v1.31.0,S142 用户洞察)
|
|
12416
|
-
*
|
|
12417
|
-
* 用户原话:「既然微信桥是我们 ACC 提供的,那么 weixin-send 应该是我们 ACC 提供的能力,
|
|
12418
|
-
* 当用户配置了微信桥则可用,不配置则不可见」+「设定一个复杂的多层工具,叫 im-bridge,
|
|
12419
|
-
* 里面有微信,然后保留后续可能兼容别的 im 的空间」。
|
|
12420
|
-
*
|
|
12421
|
-
* 分层(E↑):
|
|
12422
|
-
* `tools/im/channels.ts`(工具面:参数 → 结果渲染)
|
|
12423
|
-
* → **本模块**(通道注册表 + 可见性判据 + 动作分发 + 错误码翻译)
|
|
12424
|
-
* → 通道实现(`im/weixin-channel.ts`:协议/凭据/账号/记录)
|
|
12425
|
-
*
|
|
12426
|
-
* 边界(R↓):
|
|
12427
|
-
* - **本模块零宿主依赖**(不 import cordis/agent)——可见性判定与分发都是纯逻辑,可独立单测。
|
|
12428
|
-
* - **通道只提供机制**:本模块不认识"微信";新增 IM 只需注册一个通道实现(R3 家族式扩展)。
|
|
12429
|
-
* - **只能操作本会话 CCC**(R4):`root` 由调用方(工具面)从 agent cwd 解析后传入,
|
|
12430
|
-
* 本模块不接受"目标 CCC"参数——跨 CCC 发送不在能力面。
|
|
12431
|
-
* - **记录归通道**(R5):通道实现负责让每次发送都落既有 hook。
|
|
12432
|
-
*/
|
|
12433
|
-
/** 文本长度上限(微信侧单条实践上限;超限拒绝而非静默截断——与 HTTP 入口同值) */
|
|
12434
|
-
const IM_SEND_MAX_TEXT = 4e3;
|
|
12435
|
-
/** 文件大小上限(20MB,与媒体接收上限一致) */
|
|
12436
|
-
const IM_FILE_MAX_BYTES = 20971520;
|
|
12437
|
-
const channels = /* @__PURE__ */ new Map();
|
|
12438
|
-
/** 注册通道(index.ts apply 调用;重复 id 覆盖——测试与热重载友好) */
|
|
12439
|
-
function registerImChannel(channel) {
|
|
12440
|
-
channels.set(channel.id, channel);
|
|
12441
|
-
}
|
|
12442
|
-
/** 已注册通道 id(工具 `channel` 参数枚举) */
|
|
12443
|
-
function imChannelIds() {
|
|
12444
|
-
return [...channels.keys()];
|
|
12445
|
-
}
|
|
12446
|
-
/**
|
|
12447
|
-
* 可见性判据:本 CCC 是否**有任何可用的 IM 账号**。
|
|
12448
|
-
* 未启用 → 工具面对该 agent **不可见**(`tools.restrict` 移除,而非调用时报错)。
|
|
12449
|
-
*
|
|
12450
|
-
* ## 🔴 2026-10-22 判据修正(owner 令:「把开关升级为有任意账号即可,而不是盯着微信」)
|
|
12451
|
-
*
|
|
12452
|
-
* **旧判据** = 遍历已注册通道、任一 `channel.isEnabled(root)` 为真即可见。
|
|
12453
|
-
* **旧判据的缺陷**(实测于一个只配飞书的 CCC):`feishuChannel.isEnabled()` 首行是
|
|
12454
|
-
* `if (settings.enabled !== true) return false` —— 总开关一关,**后面的账号判断根本不执行**
|
|
12455
|
-
* ⇒ 账号明明配了、`enabled:true`,却因为一个**渠道无关的总开关**被永久判成"未启用"
|
|
12456
|
-
* ⇒ `im-bridge` 在模型清单里彻底消失,而配置面看上去是对的(`accounts[]` 里躺着一条 feishu)。
|
|
12457
|
-
* 这正是 D179 的形态:**配置说它有,行为说它没有**,且零报错。
|
|
12458
|
-
*
|
|
12459
|
-
* **新判据** = **账号即事实**:`readImSettings(root).accounts` 里存在
|
|
12460
|
-
* 「`enabled !== false` 的账号」即视为可见。理由三条:
|
|
12461
|
-
* ① **可见性问的是"这个 CCC 有没有 IM 能力"**,而能力的载体是**账号**,不是总开关;
|
|
12462
|
-
* ② 总开关的语义是"**要不要让桥跑起来**",把它当成可见性上限,会让"配好了但暂时不想让它跑"
|
|
12463
|
-
* 这种合法状态变成"工具凭空消失";
|
|
12464
|
-
* ③ 判**按账号判**后,微信与飞书不再各有一套"总开关 + 账号"的双重判据 ⇒ 消除同族漂移。
|
|
12465
|
-
*
|
|
12466
|
-
* ⚠️ **不放宽的部分**:没有账号 ⇒ 仍然不可见(`accounts` 为空即 false)。
|
|
12467
|
-
* ⚠️ 单个通道自己的 `isEnabled` 仍是**发送/启停**判据(那是通道自己的事),
|
|
12468
|
-
* 只是**不再兼任**工具可见性判据 —— 两件事被这次修正分开了。
|
|
12469
|
-
*
|
|
12470
|
-
* @param root 本会话 CCC 根(由调用方解析)
|
|
12471
|
-
*/
|
|
12472
|
-
function hasEnabledImChannel(root) {
|
|
12473
|
-
let settings;
|
|
12474
|
-
try {
|
|
12475
|
-
settings = readImSettings(root);
|
|
12476
|
-
} catch {
|
|
12477
|
-
return false;
|
|
12478
|
-
}
|
|
12479
|
-
return (settings.accounts ?? []).some((a) => a.enabled !== false);
|
|
12480
|
-
}
|
|
12481
|
-
/**
|
|
12482
|
-
* 可见性判据(**轨迹级**,2026-10-23 owner 令):
|
|
12483
|
-
* 这个 dsh 会话所属的认知轨迹,是否**被 IM 路由表点名**(= 它才是那个"接线人")。
|
|
12484
|
-
*
|
|
12485
|
-
* ## 它解决的问题(owner 原话的**去标识改写**,原文含私有角色名,按 D145 不入仓库)
|
|
12486
|
-
*
|
|
12487
|
-
* > 「很多轨迹发消息给我,但是我没空回,它们就会等待我,其实并不是,我希望他们继续工作;
|
|
12488
|
-
* > 所以只有 IM 桥绑定了任意账户的认知轨迹才拥有 `im-bridge` 这个 tool,
|
|
12489
|
-
* > 这样可以确保只有一个接线角色和我联系,其它轨迹和它联系即可,它会自行回复它们的」
|
|
12490
|
-
*
|
|
12491
|
-
* ⇒ 旧判据是 **CCC 级**({@link hasEnabledImChannel}:本 CCC 有任一启用账号 ⇒ 全体可见)。
|
|
12492
|
-
* 它的后果不是"少一个工具",而是**一条错误的工作流**:每条轨迹都以为自己该直接对 owner 说话,
|
|
12493
|
-
* 于是发完就**停下来等人回**——而 owner 根本没空逐条回。**工具在,就把等待也带上了。**
|
|
12494
|
-
*
|
|
12495
|
-
* ## 判据(两条,缺一不可)
|
|
12496
|
-
*
|
|
12497
|
-
* 1. 本 CCC 有可用账号({@link hasEnabledImChannel})—— 没有账号时路由表也没有意义;
|
|
12498
|
-
* 2. 本会话**绑定的轨迹编码**出现在 `routes[].trajectory` 里。
|
|
12499
|
-
*
|
|
12500
|
-
* ⚠️ **为什么不看 `routes[].user`**:那是"对端是谁"(owner 的通道 id),
|
|
12501
|
-
* 判"这条轨迹能不能说话"只跟**轨迹**有关,与哪个对端无关。
|
|
12502
|
-
*
|
|
12503
|
-
* ⚠️ **`*` 通配也算点名**:`{ user:"*", trajectory:"<某轨迹>" }` 这条路由的含义就是
|
|
12504
|
-
* "**没人认领的消息都归那条轨迹**" ⇒ 它确实是一条被授权的接线轨迹,必须算可见。
|
|
12505
|
-
* 这与 `matchWeixinRoute` 的通配兜底语义**同源**,不另立规则。
|
|
12506
|
-
*
|
|
12507
|
-
* ## 拿不到轨迹编码时怎么办(fail-closed,与 `exclusive` 同向)
|
|
12508
|
-
*
|
|
12509
|
-
* 会话 → 轨迹编码要读绑定。**取不到就判不可见**:
|
|
12510
|
-
* - 与 `exclusive`(未声明 ⇒ 隐藏)同向;
|
|
12511
|
-
* - 反向(取不到就放行)会让"绑定坏了"表现成"工具还在",
|
|
12512
|
-
* 而那正是本判据要防的形态(D179:配置说它有、行为说它没有,且零报错)。
|
|
12513
|
-
* ⚠️ 代价明示:绑定读取若短暂失败,该轮会少一个工具;下一轮绑定恢复即回。
|
|
12514
|
-
* 这比"误给一条轨迹发消息的权限"便宜 —— 后者会让它**停下来等 owner**。
|
|
12515
|
-
*
|
|
12516
|
-
* @param root CCC 根
|
|
12517
|
-
* @param trajectoryCode 本会话所属认知轨迹的编码(**由调用方解析**,见 `trajectory/policy/profile.ts`)
|
|
12518
|
-
* @returns true = 该轨迹接线,`im-bridge` 可见
|
|
12519
|
-
*/
|
|
12520
|
-
function isImWiredTrajectory(root, trajectoryCode) {
|
|
12521
|
-
if (typeof trajectoryCode !== "string" || trajectoryCode.trim() === "") return false;
|
|
12522
|
-
if (!hasEnabledImChannel(root)) return false;
|
|
12523
|
-
let settings;
|
|
12524
|
-
try {
|
|
12525
|
-
settings = readImSettings(root);
|
|
12526
|
-
} catch {
|
|
12527
|
-
return false;
|
|
12528
|
-
}
|
|
12529
|
-
const wanted = trajectoryCode.trim();
|
|
12530
|
-
for (const route of settings.routes ?? []) {
|
|
12531
|
-
const target = typeof route.trajectory === "string" ? route.trajectory.trim() : "";
|
|
12532
|
-
if (target !== "" && target === wanted) return true;
|
|
12533
|
-
}
|
|
12534
|
-
return false;
|
|
12535
|
-
}
|
|
12536
|
-
/**
|
|
12537
|
-
* 错误码 → 可行动提示(模型视角:说清怎么改)。
|
|
12538
|
-
*
|
|
12539
|
-
* 🔴 `CHANNEL_NOT_CONFIGURED` 的提示**不能写死某一个渠道的字段名**(2026-10-20 Q4 真机验收踩到):
|
|
12540
|
-
* 首版逐字写「enable it in … (weixin.enabled)」,而接第二个渠道后,**飞书能不能用不取决于
|
|
12541
|
-
* 这个开关**(它取决于有没有一条启用中的飞书账号)⇒ 按那句提示去改 `weixin.enabled`
|
|
12542
|
-
* **不会生效**,而错误码又指向"没配置" ⇒ 用户据此改完还是同一句报错。
|
|
12543
|
-
* ⇒ 提示按**渠道是否带自己的启用判据**分岔:带 `channelHint` 的就用它自己的说法。
|
|
12544
|
-
*/
|
|
12545
|
-
function remediationFor(code, channelHint) {
|
|
12546
|
-
switch (code) {
|
|
12547
|
-
case "CHANNEL_UNKNOWN": return "Use a registered channel id (see the tool description)";
|
|
12548
|
-
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";
|
|
12549
|
-
case "ACTION_UNKNOWN": return "Use send / send-file / users / status";
|
|
12550
|
-
case "ACTION_UNSUPPORTED": return "This channel does not support that action";
|
|
12551
|
-
case "ALIAS_UNKNOWN": return "Use a configured alias (see action=users) or a raw channel user id";
|
|
12552
|
-
case "TEXT_REQUIRED": return "Provide text";
|
|
12553
|
-
case "TEXT_TOO_LONG": return `Shorten the text to <= ${IM_SEND_MAX_TEXT} characters`;
|
|
12554
|
-
case "USER_REQUIRED": return "Provide user";
|
|
12555
|
-
case "FILE_REQUIRED": return "Provide file (a path inside this CCC)";
|
|
12556
|
-
case "FILE_ESCAPE": return "Use a path inside this CCC";
|
|
12557
|
-
case "FILE_NOT_FOUND": return "Check the path exists inside this CCC";
|
|
12558
|
-
case "FILE_TOO_LARGE": return `Use a file <= ${Math.round(IM_FILE_MAX_BYTES / 1048576)} MB`;
|
|
12559
|
-
default: return;
|
|
12560
|
-
}
|
|
12561
|
-
}
|
|
12562
|
-
function fail(code, error, remediation) {
|
|
12563
|
-
return {
|
|
12564
|
-
ok: false,
|
|
12565
|
-
code,
|
|
12566
|
-
error,
|
|
12567
|
-
...remediation ?? remediationFor(code) ? { remediation: remediation ?? remediationFor(code) } : {}
|
|
12568
|
-
};
|
|
12569
|
-
}
|
|
12570
|
-
/** CCC 内文件读取(路径逃逸/缺失/超限一律拒绝,返回稳定码) */
|
|
12571
|
-
function readCccFile(root, relOrAbs) {
|
|
12572
|
-
const abs = isAbsolute(relOrAbs) ? resolve(relOrAbs) : resolve(root, relOrAbs);
|
|
12573
|
-
const rel = relative(resolve(root), abs);
|
|
12574
|
-
if (rel.startsWith("..") || isAbsolute(rel)) return {
|
|
12575
|
-
ok: false,
|
|
12576
|
-
code: "FILE_ESCAPE",
|
|
12577
|
-
error: `file escapes the CCC root: ${relOrAbs}`
|
|
12578
|
-
};
|
|
12579
|
-
if (!existsSync(abs)) return {
|
|
12580
|
-
ok: false,
|
|
12581
|
-
code: "FILE_NOT_FOUND",
|
|
12582
|
-
error: `file not found: ${relOrAbs}`
|
|
12583
|
-
};
|
|
12584
|
-
let size = 0;
|
|
12585
|
-
try {
|
|
12586
|
-
size = statSync(abs).size;
|
|
12587
|
-
} catch (err) {
|
|
12588
|
-
return {
|
|
12589
|
-
ok: false,
|
|
12590
|
-
code: "FILE_NOT_FOUND",
|
|
12591
|
-
error: `cannot stat file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
|
|
12592
|
-
};
|
|
12593
|
-
}
|
|
12594
|
-
if (size > 20971520) return {
|
|
12595
|
-
ok: false,
|
|
12596
|
-
code: "FILE_TOO_LARGE",
|
|
12597
|
-
error: `file is ${size} bytes (> ${IM_FILE_MAX_BYTES})`
|
|
12598
|
-
};
|
|
12599
|
-
try {
|
|
12600
|
-
return {
|
|
12601
|
-
ok: true,
|
|
12602
|
-
data: readFileSync(abs),
|
|
12603
|
-
fileName: abs.split("/").pop() ?? "file"
|
|
12604
|
-
};
|
|
12605
|
-
} catch (err) {
|
|
12606
|
-
return {
|
|
12607
|
-
ok: false,
|
|
12608
|
-
code: "FILE_NOT_FOUND",
|
|
12609
|
-
error: `cannot read file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
|
|
12610
|
-
};
|
|
12611
|
-
}
|
|
12612
|
-
}
|
|
12613
|
-
/**
|
|
12614
|
-
* 执行一次 IM 动作(工具面唯一入口)。
|
|
12615
|
-
*
|
|
12616
|
-
* 顺序:通道解析 → 动作校验 → 用户解析(别名 → id)→ 输入校验 → 调用通道实现。
|
|
12617
|
-
* 任何一步失败都返回**稳定错误码 + 可行动提示**(不抛错——工具面统一渲染)。
|
|
12618
|
-
*
|
|
12619
|
-
* @param root 本会话 CCC 根(调用方从 agent cwd 解析;本函数不接受目标 CCC)
|
|
12620
|
-
* @param req 工具参数
|
|
12621
|
-
*/
|
|
12622
|
-
async function runImBridge(root, req) {
|
|
12623
|
-
const channelId = (req.channel ?? "").trim();
|
|
12624
|
-
const action = (req.action ?? "").trim();
|
|
12625
|
-
if (channelId === "") return fail("CHANNEL_REQUIRED", "channel is required", "Provide channel (e.g. \"weixin\")");
|
|
12626
|
-
const channel = channels.get(channelId);
|
|
12627
|
-
if (!channel) return fail("CHANNEL_UNKNOWN", `unknown channel "${channelId}" (registered: ${imChannelIds().join(", ") || "(none)"})`);
|
|
12628
|
-
if (!channel.isEnabled(root)) return fail("CHANNEL_NOT_CONFIGURED", `channel "${channelId}" is not configured for this CCC (${root})`, channel.notEnabledReason?.(root));
|
|
12629
|
-
if (action === "users") return {
|
|
12630
|
-
ok: true,
|
|
12631
|
-
channel: channelId,
|
|
12632
|
-
action,
|
|
12633
|
-
detail: { users: channel.listUsers(root) }
|
|
12634
|
-
};
|
|
12635
|
-
if (action === "contacts") {
|
|
12636
|
-
if (!channel.listContacts) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" has no record source for contacts`);
|
|
12637
|
-
const settings = readWeixinSettings(root);
|
|
12638
|
-
const accountChannel = {};
|
|
12639
|
-
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";
|
|
12640
|
-
const scanned = scanImContacts(root, accountChannel);
|
|
12641
|
-
const mine = scanned.contacts.filter((c) => c.channel === channelId);
|
|
12642
|
-
/** 当前路由表(`user → trajectory`);同一 user 多条时后者覆盖 */
|
|
12643
|
-
const routeMap = /* @__PURE__ */ new Map();
|
|
12644
|
-
for (const route of settings.routes ?? []) if (typeof route.user === "string" && route.user !== "") routeMap.set(route.user, typeof route.trajectory === "string" ? route.trajectory : "");
|
|
12645
|
-
const contacts = mine.map((c) => ({
|
|
12646
|
-
channel: c.channel,
|
|
12647
|
-
userId: c.userId,
|
|
12648
|
-
accountIds: c.accountIds,
|
|
12649
|
-
route: routeMap.get(c.userId) ?? null,
|
|
12650
|
-
firstSeen: c.firstSeen === null ? null : new Date(c.firstSeen).toISOString(),
|
|
12651
|
-
lastSeen: c.lastSeen === null ? null : new Date(c.lastSeen).toISOString(),
|
|
12652
|
-
messages: c.messages
|
|
12653
|
-
}));
|
|
12654
|
-
const seenUsers = new Set(mine.map((c) => c.userId));
|
|
12655
|
-
return {
|
|
12656
|
-
ok: true,
|
|
12657
|
-
channel: channelId,
|
|
12658
|
-
action,
|
|
12659
|
-
detail: {
|
|
12660
|
-
contacts,
|
|
12661
|
-
unrouted: mine.filter((c) => !routeMap.has(c.userId)).map((c) => c.userId),
|
|
12662
|
-
orphanRoutes: [...routeMap.keys()].filter((user) => !seenUsers.has(user)),
|
|
12663
|
-
sources: scanned.sources,
|
|
12664
|
-
...scanned.errors.length > 0 ? { errors: scanned.errors } : {}
|
|
12665
|
-
}
|
|
12666
|
-
};
|
|
12667
|
-
}
|
|
12668
|
-
if (action === "status") return {
|
|
12669
|
-
ok: true,
|
|
12670
|
-
channel: channelId,
|
|
12671
|
-
action,
|
|
12672
|
-
detail: channel.status(root)
|
|
12673
|
-
};
|
|
12674
|
-
if (action === "send") {
|
|
12675
|
-
const rawUser = (req.user ?? "").trim();
|
|
12676
|
-
if (rawUser === "") return fail("USER_REQUIRED", "user is required");
|
|
12677
|
-
const text = req.text ?? "";
|
|
12678
|
-
if (text === "") return fail("TEXT_REQUIRED", "text is required");
|
|
12679
|
-
if (text.length > 4e3) return fail("TEXT_TOO_LONG", `text is ${text.length} characters (> ${IM_SEND_MAX_TEXT})`);
|
|
12680
|
-
const resolved = channel.resolveUser(root, rawUser);
|
|
12681
|
-
if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
|
|
12682
|
-
try {
|
|
12683
|
-
return {
|
|
12684
|
-
ok: true,
|
|
12685
|
-
channel: channelId,
|
|
12686
|
-
action,
|
|
12687
|
-
detail: {
|
|
12688
|
-
...await channel.send({
|
|
12689
|
-
root,
|
|
12690
|
-
userId: resolved.id,
|
|
12691
|
-
text,
|
|
12692
|
-
accountId: req.account
|
|
12693
|
-
}),
|
|
12694
|
-
user: resolved.alias ?? resolved.id
|
|
12695
|
-
}
|
|
12696
|
-
};
|
|
12697
|
-
} catch (err) {
|
|
12698
|
-
return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
|
|
12699
|
-
}
|
|
12700
|
-
}
|
|
12701
|
-
if (action === "send-file") {
|
|
12702
|
-
if (!channel.sendFile) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" does not support send-file`);
|
|
12703
|
-
const rawUser = (req.user ?? "").trim();
|
|
12704
|
-
if (rawUser === "") return fail("USER_REQUIRED", "user is required");
|
|
12705
|
-
const fileArg = (req.file ?? "").trim();
|
|
12706
|
-
if (fileArg === "") return fail("FILE_REQUIRED", "file is required");
|
|
12707
|
-
const resolved = channel.resolveUser(root, rawUser);
|
|
12708
|
-
if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
|
|
12709
|
-
const file = readCccFile(root, fileArg);
|
|
12710
|
-
if (!file.ok) return fail(file.code, file.error);
|
|
12711
|
-
try {
|
|
12712
|
-
return {
|
|
12713
|
-
ok: true,
|
|
12714
|
-
channel: channelId,
|
|
12715
|
-
action,
|
|
12716
|
-
detail: {
|
|
12717
|
-
...await channel.sendFile({
|
|
12718
|
-
root,
|
|
12719
|
-
userId: resolved.id,
|
|
12720
|
-
data: file.data,
|
|
12721
|
-
fileName: file.fileName,
|
|
12722
|
-
caption: req.caption,
|
|
12723
|
-
accountId: req.account
|
|
12724
|
-
}),
|
|
12725
|
-
user: resolved.alias ?? resolved.id
|
|
12726
|
-
}
|
|
12727
|
-
};
|
|
12728
|
-
} catch (err) {
|
|
12729
|
-
return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
|
|
12730
|
-
}
|
|
12731
|
-
}
|
|
12732
|
-
return fail("ACTION_UNKNOWN", `unknown action "${action}"`);
|
|
12733
|
-
}
|
|
12734
|
-
//#endregion
|
|
12735
12345
|
//#region src/seams/tool-visibility.ts
|
|
12736
12346
|
/** ACC 自己注册的工具名集合(退化档只发这些 —— 它们一定在册) */
|
|
12737
12347
|
const REGISTERED_TOOLS_SET = new Set(REGISTERED_TOOLS);
|
|
@@ -15167,11 +14777,11 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15167
14777
|
sendJson$2(res, 400, { error: chk.error });
|
|
15168
14778
|
return;
|
|
15169
14779
|
}
|
|
15170
|
-
const { readWeixinSettings } = await import("./route-
|
|
14780
|
+
const { readWeixinSettings } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15171
14781
|
const { weixinBridgeStatus } = await Promise.resolve().then(() => bridge_exports);
|
|
15172
14782
|
const settings = readWeixinSettings(chk.root);
|
|
15173
14783
|
const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
|
|
15174
|
-
const { imCredentialBound } = await import("./route-
|
|
14784
|
+
const { imCredentialBound } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15175
14785
|
const { normalizeAccountChannel } = await import("./accounts-BHQbakUh.js").then((n) => n.t);
|
|
15176
14786
|
const accounts = (settings.accounts ?? []).map((a) => {
|
|
15177
14787
|
const channel = normalizeAccountChannel(a.channel);
|
|
@@ -15208,7 +14818,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15208
14818
|
const { fetchQRCode } = await import("./ilink-CsZ-f64q.js").then((n) => n.a);
|
|
15209
14819
|
const { randomUUID } = await import("node:crypto");
|
|
15210
14820
|
for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
|
|
15211
|
-
const { readWeixinSettings } = await import("./route-
|
|
14821
|
+
const { readWeixinSettings } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15212
14822
|
const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
|
|
15213
14823
|
const loginKey = randomUUID();
|
|
15214
14824
|
weixinLogins.set(loginKey, {
|
|
@@ -15229,7 +14839,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15229
14839
|
sendJson$2(res, 400, { error: "missing accountId" });
|
|
15230
14840
|
return;
|
|
15231
14841
|
}
|
|
15232
|
-
const { removeWeixinAccount } = await import("./route-
|
|
14842
|
+
const { removeWeixinAccount } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15233
14843
|
const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
|
|
15234
14844
|
removeWeixinAccount(root, body.accountId);
|
|
15235
14845
|
syncCccBridge(ctx, root);
|
|
@@ -15255,7 +14865,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15255
14865
|
trajectory: code
|
|
15256
14866
|
});
|
|
15257
14867
|
}
|
|
15258
|
-
const { saveWeixinRoutes } = await import("./route-
|
|
14868
|
+
const { saveWeixinRoutes } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15259
14869
|
const { findDirNameByCode } = await import("./profile-zxakCNXy.js").then((n) => n.a);
|
|
15260
14870
|
for (const r of routes) if (findDirNameByCode(root, r.trajectory) === null) {
|
|
15261
14871
|
sendJson$2(res, 400, { error: `unknown trajectory: ${r.trajectory} (no such trajectory code in ${root})` });
|
|
@@ -15267,7 +14877,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15267
14877
|
}
|
|
15268
14878
|
if (body.action === "set-enabled") {
|
|
15269
14879
|
const enabled = body.enabled === true;
|
|
15270
|
-
const { readWeixinSettings, setWeixinEnabled } = await import("./route-
|
|
14880
|
+
const { readWeixinSettings, setWeixinEnabled } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15271
14881
|
const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
|
|
15272
14882
|
const settings = readWeixinSettings(root);
|
|
15273
14883
|
if (enabled && (settings.accounts ?? []).length === 0) {
|
|
@@ -15290,7 +14900,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15290
14900
|
sendJson$2(res, 400, { error: "appId 与 appSecret 都必须填(只填一个等于没绑)" });
|
|
15291
14901
|
return;
|
|
15292
14902
|
}
|
|
15293
|
-
const { readWeixinSettings, upsertWeixinAccount, writeFeishuCredential, nextFeishuAccountId } = await import("./route-
|
|
14903
|
+
const { readWeixinSettings, upsertWeixinAccount, writeFeishuCredential, nextFeishuAccountId } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15294
14904
|
const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
|
|
15295
14905
|
const before = readWeixinSettings(root);
|
|
15296
14906
|
const accountId = (body.accountId ?? "").trim() !== "" ? body.accountId.trim() : nextFeishuAccountId(before);
|
|
@@ -15340,7 +14950,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15340
14950
|
return;
|
|
15341
14951
|
}
|
|
15342
14952
|
const { pollQRStatus } = await import("./ilink-CsZ-f64q.js").then((n) => n.a);
|
|
15343
|
-
const { readWeixinSettings } = await import("./route-
|
|
14953
|
+
const { readWeixinSettings } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15344
14954
|
const settings = readWeixinSettings(login.root);
|
|
15345
14955
|
const status = await pollQRStatus({
|
|
15346
14956
|
baseUrl: void 0,
|
|
@@ -15355,7 +14965,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
15355
14965
|
});
|
|
15356
14966
|
return;
|
|
15357
14967
|
}
|
|
15358
|
-
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-
|
|
14968
|
+
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-DxIKWeN9.js").then((n) => n.l);
|
|
15359
14969
|
const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
|
|
15360
14970
|
const accountId = nextWeixinAccountId(settings);
|
|
15361
14971
|
upsertWeixinAccount(login.root, {
|
|
@@ -19922,7 +19532,7 @@ function buildOutgoingHookEvent(input) {
|
|
|
19922
19532
|
sessionId: input.sessionId,
|
|
19923
19533
|
role: input.role,
|
|
19924
19534
|
reply: input.reply,
|
|
19925
|
-
...input.source
|
|
19535
|
+
...input.source ? { source: input.source } : {},
|
|
19926
19536
|
...input.file ? { file: input.file } : {}
|
|
19927
19537
|
};
|
|
19928
19538
|
}
|
|
@@ -20128,8 +19738,6 @@ const manualSessions = /* @__PURE__ */ new Map();
|
|
|
20128
19738
|
const sentTurn = /* @__PURE__ */ new Map();
|
|
20129
19739
|
/** 连续打回计数(成功发送即清零) */
|
|
20130
19740
|
const rebukeCounts = /* @__PURE__ */ new Map();
|
|
20131
|
-
/** 闸门是否装配成功(v1.30.17:桥据此判断能否信任"未发送"这一判定——未装配时不兜底,防重复发送) */
|
|
20132
|
-
let guardActive = false;
|
|
20133
19741
|
/** 桥:登记手动模式会话(每轮 incoming 都调用,幂等覆盖) */
|
|
20134
19742
|
function noteManualOutputSession(sessionId, session) {
|
|
20135
19743
|
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
@@ -20158,10 +19766,6 @@ function hasSentThisTurn(sessionId, turn) {
|
|
|
20158
19766
|
if (recorded === null || turn === null) return true;
|
|
20159
19767
|
return recorded === turn;
|
|
20160
19768
|
}
|
|
20161
|
-
/** 闸门是否装配成功(未装配 → 桥不做兜底:宁可静默也不冒重复发送的风险) */
|
|
20162
|
-
function isWeixinOutputGuardActive() {
|
|
20163
|
-
return guardActive;
|
|
20164
|
-
}
|
|
20165
19769
|
/** 从 agent / 会话对象取会话 id(形状宽容) */
|
|
20166
19770
|
function sessionIdOf$1(value) {
|
|
20167
19771
|
const v = value;
|
|
@@ -20178,7 +19782,7 @@ function sessionIdOf$1(value) {
|
|
|
20178
19782
|
* 🔴 **原名为 `isSuccessfulWeixinSend`,2026-10-20 改名**(Q4 出站飞书):
|
|
20179
19783
|
* 它曾硬比 `channel !== 'weixin' ⇒ false`。接第二个渠道后,那条判据的含义变成
|
|
20180
19784
|
* **"只有微信算发出去了"** —— 而实际后果是:飞书会话里 agent 真的发成功了,
|
|
20181
|
-
* 闸门仍判"未发送" ⇒ 打回 ≤2
|
|
19785
|
+
* 闸门仍判"未发送" ⇒ 打回 ≤2 次,且当时的 `fallbackOnNoSend` 还会再兜底转发一遍
|
|
20182
19786
|
* ⇒ **重复投递**。⇒ 判据从"是不是微信"改成"是不是**这个会话所在的那条渠道**",
|
|
20183
19787
|
* 函数名随之更正(**名字要能对着行为读**,D151/D179 同族)。
|
|
20184
19788
|
*
|
|
@@ -20260,10 +19864,32 @@ function registerWeixinOutputGuard(ctx) {
|
|
|
20260
19864
|
const session = manualSessions.get(sessionId);
|
|
20261
19865
|
if (!session) return;
|
|
20262
19866
|
if (hasSentThisTurn(sessionId, payload?.turn ?? null)) return;
|
|
19867
|
+
/**
|
|
19868
|
+
* 🔴🔴 **D234 修法(2026-10-24,D238 强制同批):先确认"它有权发",再打回。**
|
|
19869
|
+
*
|
|
19870
|
+
* 缺陷(我 2026-10-23 查出的真缺陷):v1.88.0 起 `im-bridge` **只对 IM 路由表点名的
|
|
19871
|
+
* 轨迹可见**(取不到轨迹编码即 fail-closed 隐藏)。而本闸门**无条件打回**,打回文案
|
|
19872
|
+
* (`buildManualOutputRebuke`)逐字要求调用的正是那个工具 ⇒ 对一条"能收到消息但没被
|
|
19873
|
+
* 路由表点名"的轨迹,**agent 无论如何都满足不了** ⇒ 连打 2 次后只剩告警,
|
|
19874
|
+
* 用户侧零输出。**打回命中一个不可满足的前置条件**(D205 同族:前置不成立必须显式红,
|
|
19875
|
+
* 而不是继续走必然失败的重试)。
|
|
19876
|
+
*
|
|
19877
|
+
* 🔴 为什么砍掉自动回发后这条变成**必配件**:手动模式成了唯一路径 ⇒ 本闸门**恒生效**
|
|
19878
|
+
* (不再有"自动模式"这条安全退路),缺陷从边缘情形升级为主路径。
|
|
19879
|
+
*
|
|
19880
|
+
* 判据(与 `seams/guards.ts` 的可见性判据**同源**,D235 的要求):
|
|
19881
|
+
* 用同一个 `isImWiredTrajectory` 判——**可见性判据与打回判据必须同源**,
|
|
19882
|
+
* 否则两处各判一套,又会长出"能发但被判成不能发"这类漂移。
|
|
19883
|
+
*/
|
|
19884
|
+
if (!isImWiredTrajectory(session.root, trajectoryCodeForSession(session.root, sessionId))) {
|
|
19885
|
+
console.warn(`[serenity-hooks] ✗ im 输出闸门:session=${sessionId} role=${session.role} **没有 im-bridge 发送权**(该轨迹不在本 CCC 的 IM 路由表 im.routes 里)⇒ **不打回**。本轮 agent 若想说话也无路可走 —— 请把该轨迹编码加进 .opencode/serenity.json 的 im.routes,或让它把话交给已被点名的接线轨迹。`);
|
|
19886
|
+
rebukeCounts.delete(sessionId);
|
|
19887
|
+
return;
|
|
19888
|
+
}
|
|
20263
19889
|
const count = (rebukeCounts.get(sessionId) ?? 0) + 1;
|
|
20264
19890
|
if (count > 2) {
|
|
20265
19891
|
rebukeCounts.delete(sessionId);
|
|
20266
|
-
console.warn(`[serenity-hooks] ✗ weixin 输出闸门:连续 2 次打回后仍未发送(session=${sessionId}, role=${session.role}
|
|
19892
|
+
console.warn(`[serenity-hooks] ✗ weixin 输出闸门:连续 2 次打回后仍未发送(session=${sessionId}, role=${session.role})——**本轮用户将收不到任何消息**(兜底转发已于 2026-10-24 整条退役,出站只剩 im-bridge 一条路)`);
|
|
20267
19893
|
return;
|
|
20268
19894
|
}
|
|
20269
19895
|
rebukeCounts.set(sessionId, count);
|
|
@@ -20284,7 +19910,6 @@ function registerWeixinOutputGuard(ctx) {
|
|
|
20284
19910
|
console.warn("[serenity-hooks] ✗ weixin 输出闸门未装配:agent/turn-stopping 不可用");
|
|
20285
19911
|
return;
|
|
20286
19912
|
}
|
|
20287
|
-
guardActive = true;
|
|
20288
19913
|
}
|
|
20289
19914
|
//#endregion
|
|
20290
19915
|
//#region src/im/inbound-event.ts
|
|
@@ -20567,31 +20192,30 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
20567
20192
|
if (workspaceMd) {
|
|
20568
20193
|
if (!ensureWorkspacePromptSection(ref.agent, sessionId, root)) parts.push(workspaceCognitiveTrajectoryLine(workspaceMd));
|
|
20569
20194
|
}
|
|
20570
|
-
const manualOutput = settings.autoReplyWithLastMessage === false;
|
|
20571
20195
|
if (text) parts.push(text);
|
|
20572
20196
|
parts.push(...mediaNotes, ...degradedNotes);
|
|
20573
|
-
|
|
20574
|
-
|
|
20575
|
-
|
|
20576
|
-
|
|
20577
|
-
|
|
20578
|
-
|
|
20579
|
-
|
|
20580
|
-
|
|
20581
|
-
|
|
20582
|
-
|
|
20583
|
-
|
|
20584
|
-
|
|
20585
|
-
|
|
20586
|
-
|
|
20587
|
-
|
|
20588
|
-
|
|
20589
|
-
|
|
20590
|
-
|
|
20591
|
-
|
|
20592
|
-
|
|
20593
|
-
|
|
20594
|
-
|
|
20197
|
+
/**
|
|
20198
|
+
* 🔴🔴 **2026-10-24(owner 令,D238):出站只有一条路 —— agent 自己调 `im-bridge`。**
|
|
20199
|
+
*
|
|
20200
|
+
* 此前这里是 `if (manualOutput) {…}` 条件分支:`autoReplyWithLastMessage=false` 才走手动,
|
|
20201
|
+
* 否则桥替 agent 转发最终文本。**该开关已整条退役** ⇒ 本段成为**唯一路径**:
|
|
20202
|
+
* 每轮都登记闸门 + 注入机制标记,桥**永不**自动回发。
|
|
20203
|
+
*
|
|
20204
|
+
* 为什么要这样(owner 原话):「autoReply 并不合适,主动性的 imBridge 仍然是更好的」
|
|
20205
|
+
* —— 自动回发让 agent 失去"说几条 / 何时说 / 要不要不说"的决定权,而这正是主动发送的价值。
|
|
20206
|
+
*
|
|
20207
|
+
* ⚠️ **由此产生的唯一代价(必须同时修)**:agent 不发 ⇒ 用户确实收不到。
|
|
20208
|
+
* 所以 D234 的修法是本改动的**必配件**:guard 打回前必须确认 `im-bridge` 对该轨迹可见,
|
|
20209
|
+
* 否则"没权发"会被无解地打回成"忘了发"。见 `im/guard.ts`。
|
|
20210
|
+
*/
|
|
20211
|
+
noteManualOutputSession(sessionId, {
|
|
20212
|
+
root,
|
|
20213
|
+
accountId,
|
|
20214
|
+
userId: fromUserId,
|
|
20215
|
+
role: trajectory,
|
|
20216
|
+
channel
|
|
20217
|
+
});
|
|
20218
|
+
parts.push(manualOutputMarker(accountId, fromUserId, channel));
|
|
20595
20219
|
const question = parts.join("\n");
|
|
20596
20220
|
const hookRel = settings.hook;
|
|
20597
20221
|
if (hookRel) invokeWeixinHook(root, hookRel, buildIncomingHookEvent({
|
|
@@ -20606,53 +20230,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
20606
20230
|
})).catch((err) => {
|
|
20607
20231
|
console.log(`[serenity-hooks] im hook incoming error: ${err instanceof Error ? err.message : String(err)}`);
|
|
20608
20232
|
});
|
|
20609
|
-
|
|
20610
|
-
if (manualOutput) {
|
|
20611
|
-
if (!manualOutputFallbackNeeded({
|
|
20612
|
-
fallbackOnNoSend: settings.fallbackOnNoSend,
|
|
20613
|
-
guardActive: isWeixinOutputGuardActive(),
|
|
20614
|
-
sentThisTurn: hasSentThisTurn(sessionId),
|
|
20615
|
-
answer
|
|
20616
|
-
})) return;
|
|
20617
|
-
const reply = markdownToPlainText(stripThink(answer));
|
|
20618
|
-
await io.sendText({
|
|
20619
|
-
toUserId: fromUserId,
|
|
20620
|
-
text: reply,
|
|
20621
|
-
replyRef: event.replyRef
|
|
20622
|
-
});
|
|
20623
|
-
console.warn(`[serenity-hooks] ⚠ im 输出兜底:agent 本轮未发送(闸门打回用尽)→ 桥转发最终文本(session=${sessionId}, trajectory=${trajectory})`);
|
|
20624
|
-
if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
|
|
20625
|
-
cccRoot: root,
|
|
20626
|
-
channel,
|
|
20627
|
-
accountId,
|
|
20628
|
-
userId: fromUserId,
|
|
20629
|
-
sessionId,
|
|
20630
|
-
role: trajectory,
|
|
20631
|
-
reply,
|
|
20632
|
-
source: "reply-fallback"
|
|
20633
|
-
})).catch((err) => {
|
|
20634
|
-
console.log(`[serenity-hooks] im hook outgoing(reply-fallback) error: ${err instanceof Error ? err.message : String(err)}`);
|
|
20635
|
-
});
|
|
20636
|
-
return;
|
|
20637
|
-
}
|
|
20638
|
-
if (answer === "") return;
|
|
20639
|
-
const reply = markdownToPlainText(stripThink(answer));
|
|
20640
|
-
await io.sendText({
|
|
20641
|
-
toUserId: fromUserId,
|
|
20642
|
-
text: reply,
|
|
20643
|
-
replyRef: event.replyRef
|
|
20644
|
-
});
|
|
20645
|
-
if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
|
|
20646
|
-
cccRoot: root,
|
|
20647
|
-
channel,
|
|
20648
|
-
accountId,
|
|
20649
|
-
userId: fromUserId,
|
|
20650
|
-
sessionId,
|
|
20651
|
-
role: trajectory,
|
|
20652
|
-
reply
|
|
20653
|
-
})).catch((err) => {
|
|
20654
|
-
console.log(`[serenity-hooks] im hook outgoing error: ${err instanceof Error ? err.message : String(err)}`);
|
|
20655
|
-
});
|
|
20233
|
+
await askTrajectory(ctx, ref.agent, question);
|
|
20656
20234
|
} finally {
|
|
20657
20235
|
await io.typingStop?.({ toUserId: fromUserId });
|
|
20658
20236
|
}
|
|
@@ -20666,7 +20244,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
20666
20244
|
}
|
|
20667
20245
|
}
|
|
20668
20246
|
/**
|
|
20669
|
-
*
|
|
20247
|
+
* 出站**机制标记**(v1.30.10 引入;v1.30.16 按「ACC 管机制、CCC 管措辞」重构)。
|
|
20670
20248
|
*
|
|
20671
20249
|
* 边界(R↓):ACC 只提供**机制与数据**——"桥不转发最终文本"这个事实 + 三个已填好的参数值。
|
|
20672
20250
|
* **纪律措辞归 CCC**:怎么写、必须怎么做、后果是什么,全部写在轨迹的 `PROMPT.md` 里。
|
|
@@ -20674,22 +20252,25 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
20674
20252
|
* 🔴 **渠道不是常量**(2026-10-20 Q4):此前这里硬写 `channel:"weixin"`,
|
|
20675
20253
|
* 而一条**飞书**会话收到的却是"发往微信"的命令 ⇒ 模型照着做必然失败(目标 id 是 open_id)。
|
|
20676
20254
|
* ⇒ 渠道由调用方传入;缺省 `'weixin'` 保留既有调用点与测试的读数。
|
|
20255
|
+
*
|
|
20256
|
+
* 🔴 2026-10-24(D238):本标记从"手动模式才注入"变成**每轮必注入**(手动模式已成唯一路径)。
|
|
20257
|
+
* ⚠️ **标记字符串 `[serenity:weixin-manual-output]` 不改名**:CCC 侧角色提示词按它做判据,
|
|
20258
|
+
* 改名会把已写好的纪律文档全打成废纸;它是**协议标记**,不是渠道名(与 A85 同一处置)。
|
|
20677
20259
|
*/
|
|
20678
20260
|
function manualOutputMarker(accountId, userId, channel = "weixin") {
|
|
20679
20261
|
return ["[serenity:weixin-manual-output]", `im-bridge({channel:"${channel}", action:"send", account:"${accountId}", user:"${userId}", text:"<回复>"})`].join("\n");
|
|
20680
20262
|
}
|
|
20681
20263
|
/**
|
|
20682
|
-
*
|
|
20264
|
+
* 🔴 **`manualOutputFallbackNeeded` 已于 2026-10-24 删除**(owner 令,D238)。
|
|
20683
20265
|
*
|
|
20684
|
-
*
|
|
20685
|
-
*
|
|
20686
|
-
*
|
|
20687
|
-
*
|
|
20688
|
-
*
|
|
20266
|
+
* 它实现的是「手动模式下 agent 一次都没发成功 ⇒ 桥兜底转发最终文本」。
|
|
20267
|
+
* **随 `fallbackOnNoSend` 一起退役** —— 那个开关只在 `autoReplyWithLastMessage=false` 时才有意义,
|
|
20268
|
+
* 而后者已整条删除(出站只剩 `im-bridge` 一条路)。
|
|
20269
|
+
*
|
|
20270
|
+
* ⛔ **不要把它改写成"自动兜底"再放回来**:owner 否决的正是"桥替 agent 说最后一句"这件事本身
|
|
20271
|
+
* (「autoReply 并不合适,主动性的 imBridge 仍然是更好的」),而不是它的某个实现细节。
|
|
20272
|
+
* 兜底带来的"不丢消息"收益,改由 guard 打回(可见性前置检查 + 响亮告警)承担。
|
|
20689
20273
|
*/
|
|
20690
|
-
function manualOutputFallbackNeeded(input) {
|
|
20691
|
-
return input.fallbackOnNoSend === true && input.guardActive && !input.sentThisTurn && input.answer !== "";
|
|
20692
|
-
}
|
|
20693
20274
|
//#endregion
|
|
20694
20275
|
//#region src/im/feishu-account.ts
|
|
20695
20276
|
/** 去重集合上限(飞书重推窗口有限;限量防内存无界增长) */
|
|
@@ -22958,8 +22539,6 @@ const weixinChannel = {
|
|
|
22958
22539
|
});
|
|
22959
22540
|
return {
|
|
22960
22541
|
enabled: settings.enabled === true,
|
|
22961
|
-
autoReplyWithLastMessage: settings.autoReplyWithLastMessage !== false,
|
|
22962
|
-
fallbackOnNoSend: settings.fallbackOnNoSend === true,
|
|
22963
22542
|
routes,
|
|
22964
22543
|
accounts
|
|
22965
22544
|
};
|
|
@@ -23200,8 +22779,6 @@ const feishuChannel = {
|
|
|
23200
22779
|
}));
|
|
23201
22780
|
return {
|
|
23202
22781
|
enabled: settings.enabled === true,
|
|
23203
|
-
autoReplyWithLastMessage: settings.autoReplyWithLastMessage !== false,
|
|
23204
|
-
fallbackOnNoSend: settings.fallbackOnNoSend === true,
|
|
23205
22782
|
routes,
|
|
23206
22783
|
accounts
|
|
23207
22784
|
};
|
|
@@ -23292,6 +22869,85 @@ function createImBridgeTool() {
|
|
|
23292
22869
|
});
|
|
23293
22870
|
}
|
|
23294
22871
|
//#endregion
|
|
22872
|
+
//#region src/trajectory/live.ts
|
|
22873
|
+
/**
|
|
22874
|
+
* trajectory/live.ts — 进程内 live 会话取数 + 精简诊断
|
|
22875
|
+
*
|
|
22876
|
+
* ── 为什么单独成模块(R↓,2026-09-15 S142「ACC 侧 autopilot 退场」S1 搬迁)──
|
|
22877
|
+
* 这两个函数原先住在 `autopilot-trajectory.ts`,但它们的**主语不是 autopilot**:
|
|
22878
|
+
* `listLiveSessions` 服务 `acc-diag` ① 段的"**哪个会话属于哪个 CCC**"(会话维度,
|
|
22879
|
+
* 不是 CCC 维度),而"**独立进程看不到运行时**"正是 `acc-diag` 存在的理由。
|
|
22880
|
+
* autopilot 退场后它们必须活下去 ⇒ 独立成模块,不再挂着已死机制的名字。
|
|
22881
|
+
*
|
|
22882
|
+
* ── 迁移边界(不搬什么,以及为什么)──────────────────────────────────────
|
|
22883
|
+
* 原 `diagLive` 还含两段,**均不搬**(其主语是 autopilot,随机制退场):
|
|
22884
|
+
* · `autopilotCccs`(逐 CCC 目标命中 / agent 定位诊断)—— 依赖 `readAutopilotSettings` /
|
|
22885
|
+
* `getAutopilotStatus` / `resolveTargetAgent`
|
|
22886
|
+
* · `panelResolved`(无参面板请求落到哪个 CCC)—— 面板区块随机制退场
|
|
22887
|
+
* 留下的 = **进程 cwd/CCC + live 会话清单**(`acc-diag` ① 段仍需要)。
|
|
22888
|
+
*
|
|
22889
|
+
* 判据纪律:本模块**不得**反向依赖 autopilot 判据层(那会让退场半途而废)。
|
|
22890
|
+
*
|
|
22891
|
+
* ── 🔴 2026-09-25 I2 收敛(约束文档 §2.7 违反清单,L1 违反之一)────────────────
|
|
22892
|
+
* **本模块的 `ctx` 参数类型从 `Context` 改为 `unknown`** —— 消除本文件唯一的 `W2` 接触
|
|
22893
|
+
* (直接 import 宿主 `cordis` 的类型)。
|
|
22894
|
+
*
|
|
22895
|
+
* **为什么这是"真解耦"而不是"把类型藏起来"(判据):** `ctx` 在本模块**从不被解引用** ——
|
|
22896
|
+
* 它只被**原样透传**给 `hostSessions(ctx)`,而后者(`host/access.ts`,**L0**)的参数
|
|
22897
|
+
* 本就声明为 `ctx: unknown` ⇒ `Context` 这个 import **在语义上是多余的**,
|
|
22898
|
+
* 它没有参与任何类型检查,只贡献了一处 L1→宿主 的接触点。
|
|
22899
|
+
* 🔵 **证据**:grep 本文件 `ctx` 全部 5 处命中 —— 3 处在参数/调用位、2 处在注释,
|
|
22900
|
+
* **零处**属性访问(`ctx.xxx`)。
|
|
22901
|
+
*/
|
|
22902
|
+
/**
|
|
22903
|
+
* 从 dsh 会话 log 读取标题(latest-wins `session/title` 事件)——
|
|
22904
|
+
* **标题不在 `sessions.list()` 条目上**(wire/对象均无 title 字段;F3 命名经
|
|
22905
|
+
* `sessionTitle.rename` 写进 session log),必须从 events 提取(rebuild 同款读取模式)。
|
|
22906
|
+
*/
|
|
22907
|
+
function readSessionTitle(session) {
|
|
22908
|
+
try {
|
|
22909
|
+
const events = sessionEvents(session);
|
|
22910
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
22911
|
+
const e = events[i];
|
|
22912
|
+
if (e?.type === "session/title" && typeof e.data?.title === "string" && e.data.title.trim() !== "") return e.data.title.trim();
|
|
22913
|
+
}
|
|
22914
|
+
} catch {}
|
|
22915
|
+
return null;
|
|
22916
|
+
}
|
|
22917
|
+
/**
|
|
22918
|
+
* 逐条 live 会话(`cccRoot` = 该会话 cwd 的 CCC 归属,无 → null)。
|
|
22919
|
+
*
|
|
22920
|
+
* CCC 枚举("本机有哪些 CCC")**不在这里**,归 `ccc-roots.listCccs`(并集语义);
|
|
22921
|
+
* 本函数保留的是它**独有**的那部分:会话维度 + cwd 归属 + 标题 + id。
|
|
22922
|
+
*
|
|
22923
|
+
* @param ctx 插件上下文(**只透传**给 L0 取数口,本模块不解引用 ⇒ 类型为 `unknown`)
|
|
22924
|
+
* @returns 逐条 live 会话
|
|
22925
|
+
*/
|
|
22926
|
+
function listLiveSessions(ctx) {
|
|
22927
|
+
const out = [];
|
|
22928
|
+
try {
|
|
22929
|
+
const sessions = hostSessions(ctx);
|
|
22930
|
+
for (const s of sessions?.list?.() ?? []) {
|
|
22931
|
+
const cwd = s?.header?.cwd ?? null;
|
|
22932
|
+
out.push({
|
|
22933
|
+
id: s?.id ?? "",
|
|
22934
|
+
cwd,
|
|
22935
|
+
cccRoot: cwd ? cccRootForCwd(cwd) : null,
|
|
22936
|
+
title: readSessionTitle(s)
|
|
22937
|
+
});
|
|
22938
|
+
}
|
|
22939
|
+
} catch {}
|
|
22940
|
+
return out;
|
|
22941
|
+
}
|
|
22942
|
+
function diagLive(ctx) {
|
|
22943
|
+
const processCwd = process.cwd();
|
|
22944
|
+
return {
|
|
22945
|
+
processCwd,
|
|
22946
|
+
processCcc: cccRootForCwd(processCwd),
|
|
22947
|
+
liveSessions: listLiveSessions(ctx)
|
|
22948
|
+
};
|
|
22949
|
+
}
|
|
22950
|
+
//#endregion
|
|
23295
22951
|
//#region src/surface/observation.ts
|
|
23296
22952
|
/**
|
|
23297
22953
|
* surface/observation.ts — 容器状态模型的**取数层唯一出口**(C5「观察面归一」,S142 2026-09-15)
|