@shgroup/dsh-serenity-hooks 1.81.0 → 1.83.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/im/bridge.d.ts +6 -0
- package/lib/im/guard.d.ts +27 -5
- package/lib/im/inbound-event.d.ts +6 -0
- package/lib/index.js +234 -19
- package/lib/{session-KBRbYb52.js → session-BF4C3hJL.js} +66 -4
- package/lib/trajectory/session.d.ts +20 -1
- package/package.json +1 -1
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-serenity-hooks",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.83.0",
|
|
4
4
|
"main": "lib/index.js",
|
|
5
5
|
"description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——14 个工具(container_fs/cognitive_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 认知轨迹日志与原地重建 + 网页登录入口/IM 桥/认知轨迹唤醒注册表。轨迹声明 = 轨迹目录下的 trajectory.json(约束面 + 旧标识 uid + CRO 机器绑定 cro.host;旧名 trajectory-constraint.json / skiff.json 仍在读取族里);轨迹提示词 = 轨迹目录下的 PROMPT.md(一条轨迹「是谁、按什么章程工作」,所有轨迹通用,追加在容器层与质量层之后,不替换它们)。适配 DSH 0.1.7-rc.1 / 0.2.0-rc.1 / 0.2.1-rc.1(三档,deepseek-ai/deepseek-harness)。",
|
|
6
6
|
"engines": {
|
package/lib/im/bridge.d.ts
CHANGED
|
@@ -29,6 +29,12 @@ import { type WeixinMessage } from './ilink.js';
|
|
|
29
29
|
import { resolveInboundTarget, manualOutputFallbackNeeded, type ResolvedInboundTarget } from './inbound-event.js';
|
|
30
30
|
export { manualOutputFallbackNeeded, resolveInboundTarget };
|
|
31
31
|
export type { ResolvedInboundTarget };
|
|
32
|
+
/** 记下最近一次入站带来的 `context_token`(**只存非空**;空串会把好票覆盖成坏票) */
|
|
33
|
+
export declare function rememberContextToken(accountId: string, userId: string, token: string | undefined): void;
|
|
34
|
+
/** 取该用户最近一次的 `context_token`(没有 ⇒ `undefined`,调用方按"裸发"处理并如实回执) */
|
|
35
|
+
export declare function recallContextToken(accountId: string, userId: string): string | undefined;
|
|
36
|
+
/** 测试辅助:清空 context_token 缓存(生产零调用;与 resetWeixinTypingCache 同款) */
|
|
37
|
+
export declare function resetWeixinContextCache(): void;
|
|
32
38
|
/** 测试辅助:清空 typing_ticket 缓存(生产零调用) */
|
|
33
39
|
export declare function resetWeixinTypingCache(): void;
|
|
34
40
|
/**
|
package/lib/im/guard.d.ts
CHANGED
|
@@ -51,13 +51,35 @@ export declare function noteManualOutputSession(sessionId: string, session: Manu
|
|
|
51
51
|
export declare function forgetManualOutputSession(sessionId: string): void;
|
|
52
52
|
/** 是否手动模式会话(测试/诊断用) */
|
|
53
53
|
export declare function isManualOutputSession(sessionId: string): boolean;
|
|
54
|
-
/** 本轮是否已成功发送(测试/诊断用;桥在每轮结束时读它决定是否兜底) */
|
|
55
|
-
export declare function hasSentThisTurn(sessionId: string): boolean;
|
|
56
54
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
55
|
+
* 本轮是否已成功发送。
|
|
56
|
+
*
|
|
57
|
+
* @param turn 期望的轮号(`agent/turn-stopping` 的 `payload.turn`)。
|
|
58
|
+
* - **省略** ⇒ 旧语义:"这个会话发过就算"(测试与桥的兜底判定用)。
|
|
59
|
+
* - **给了** ⇒ 按轮比:只有"**这一轮**发的"才算。
|
|
60
|
+
* - **任一侧取不到轮号**(记录是 `null`,或 `turn` 传了 `null`)⇒ **偏向"发过"**。
|
|
61
|
+
* 🔴 方向是刻意的:误判"没发"的代价 = 打回 + 重复回答(用户看到两轮,本缺陷本尊);
|
|
62
|
+
* 误判"发过"的代价 = 少打回一次(用户可能真没收到一条)。前者已经发生过、后者尚未。
|
|
63
|
+
*/
|
|
64
|
+
export declare function hasSentThisTurn(sessionId: string, turn?: number | null): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* 🔴 **桥不再调用本函数**(2026-10-21)。
|
|
67
|
+
*
|
|
68
|
+
* 旧语义 `clearSentThisTurn(sessionId)` = "来了新消息就当这个会话没发过",
|
|
69
|
+
* 它**连正在跑那一轮的证据一起抹掉** —— 这正是"同一条消息被处理两轮"的根因:
|
|
70
|
+
* `12:08:25 发送成功 → 12:08:56 新入站(抹) → 12:09:19 那一轮 turn-stopping 判"没发" ⇒ 打回`。
|
|
71
|
+
*
|
|
72
|
+
* ## 现在的分工(判据只有一处:轮号)
|
|
73
|
+
*
|
|
74
|
+
* - **置位**:`tools/post-execute` 发送成功 ⇒ 记下**当前轮号**(`currentTurnOf`)。
|
|
75
|
+
* - **判定**:`agent/turn-stopping` 拿 `payload.turn` 与记录比对(`hasSentThisTurn`)。
|
|
76
|
+
* - **陈旧**:靠"轮号不等"自然淘汰 —— 上一轮发的记录,在下一轮 stopping 时天然不匹配。
|
|
77
|
+
* ⇒ **不再需要任何"清空"动作**,也就不存在"清错人的记录"这件事。
|
|
78
|
+
*
|
|
79
|
+
* 本函数只留给**测试与显式重置**(如切换手动/自动模式)。`turn` 给定时按轮清,
|
|
80
|
+
* 是给诊断用的窄口子,生产路径不调用。
|
|
59
81
|
*/
|
|
60
|
-
export declare function clearSentThisTurn(sessionId: string): void;
|
|
82
|
+
export declare function clearSentThisTurn(sessionId: string, turn?: number | null): void;
|
|
61
83
|
/** 闸门是否装配成功(未装配 → 桥不做兜底:宁可静默也不冒重复发送的风险) */
|
|
62
84
|
export declare function isWeixinOutputGuardActive(): boolean;
|
|
63
85
|
/**
|
|
@@ -124,6 +124,11 @@ export declare function resolveInboundTarget(ctx: Context, input: {
|
|
|
124
124
|
trajectory: string;
|
|
125
125
|
} | null;
|
|
126
126
|
}): Promise<ResolvedInboundTarget | null>;
|
|
127
|
+
declare function acquireSessionLock(key: string): Promise<() => void>;
|
|
128
|
+
/** 测试/诊断用:当前有几条会话仍持有锁位(0 = 全部释放) */
|
|
129
|
+
export declare function __sessionLockCountForTest(): number;
|
|
130
|
+
/** 测试专用:直接拿真锁(不复制实现 —— 实现换形状时本导出会先炸,不会静默失效) */
|
|
131
|
+
export declare const __acquireSessionLockForTest: typeof acquireSessionLock;
|
|
127
132
|
/**
|
|
128
133
|
* **一条入站消息的主流程**(通道中立)。
|
|
129
134
|
*
|
|
@@ -165,3 +170,4 @@ export declare function manualOutputFallbackNeeded(input: {
|
|
|
165
170
|
sentThisTurn: boolean;
|
|
166
171
|
answer: string;
|
|
167
172
|
}): boolean;
|
|
173
|
+
export {};
|
package/lib/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as
|
|
|
4
4
|
import { A as sessionEvents, B as isoLocal, C as listSessions, E as parseTrajectoryCode, F as trajectoryDescOf, H as localHuman, I as useSession, L as TRAJECTORY_FILENAME, M as setActiveSessionInfo, N as showSession, O as readTrajectoryUid, P as summarize, R as TRAJECTORY_LEGACY_FILENAMES, S as listActiveTrajectories, T as parseSessionContextFromEvents, U as localIdStamp, V as localFileStamp, _ as createSession, a as readTrajectoryConstraintAt, b as getActiveSessionInfo, c as trajectoryConstraintForSession, f as COGNITIVE_TRAJECTORY_ACTIONS, g as clearActiveSessionInfo, h as archiveSession, j as sessionsRoot, k as resolveSessionByTitle, m as UID_FILENAME, n as isTrajectoryConstrained, o as readTrajectoryConstraintByCode, r as listTrajectoryConstraints, s as replacesDefaultIdentity, t as TRAJECTORY_CONSTRAINT_FILENAME, v as extractSessionMdPathFromText, w as namingTitleFor, y as findSession, z as readTrajectoryFile } from "./profile-CiRWh2a2.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-CfqdGIi2.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-BF4C3hJL.js";
|
|
8
8
|
import { n as hasSessionLogById, r as sessionsRootDir } from "./cleanup-C-92qnDs.js";
|
|
9
9
|
import { n as readSimpleSettings, r as registerSettingsSection } from "./settings-Dj4_ppTC.js";
|
|
10
10
|
import { REGISTERED_TOOLS } from "./invariant.js";
|
|
@@ -9461,15 +9461,51 @@ async function createAndBindTarget(ctx, root, dirName) {
|
|
|
9461
9461
|
createdAsUser: true
|
|
9462
9462
|
});
|
|
9463
9463
|
const openingNote = deliverCarrierOpening(agent, buildCarrierOpening(parseTrajectoryCode(dirName) ?? dirName, trajectoryDescOf(dirName))) ? ", opening-as-user" : ", ⚠️ 开场白未投递";
|
|
9464
|
+
const titleNote = renameCarrierTitle(ctx, agent, root, dirName);
|
|
9464
9465
|
const wsNote = workspaceId !== void 0 ? "" : await attachToWorkspace(ctx, root, sessionId);
|
|
9465
9466
|
const viaNote = workspaceId !== void 0 ? ", via-workspaceId(=用户同一形态)" : ", via-cwd+attach";
|
|
9466
|
-
const how = `auto-create(${sessionId}${bound.ok ? ", bound" : `, ⚠️ 绑定写入失败(${bound.code})`}${viaNote}${openingNote}${wsNote})`;
|
|
9467
|
+
const how = `auto-create(${sessionId}${bound.ok ? ", bound" : `, ⚠️ 绑定写入失败(${bound.code})`}${viaNote}${openingNote}${titleNote}${wsNote})`;
|
|
9467
9468
|
return {
|
|
9468
9469
|
agent,
|
|
9469
9470
|
how
|
|
9470
9471
|
};
|
|
9471
9472
|
}
|
|
9472
9473
|
/**
|
|
9474
|
+
* 把刚建的载体会话标题**钉成与 `use` 同一形态**(2026-10-21,owner 报「标题不规范」)。
|
|
9475
|
+
*
|
|
9476
|
+
* ## 为什么单独成函数
|
|
9477
|
+
*
|
|
9478
|
+
* 它是**全仓第二个**写会话标题的点(第一个是 `use`/`rebuild` 的 `renameDshSessionOnUse`)。
|
|
9479
|
+
* 集中在一处,将来若要改命名规则,判据仍在 `namingTitleFor` 一个地方。
|
|
9480
|
+
*
|
|
9481
|
+
* ## 三者顺序不可换
|
|
9482
|
+
*
|
|
9483
|
+
* 必须在 ③b(投开场白)**之后**调用:
|
|
9484
|
+
* - 开场白让 `titleInput.count` > 0,是标题得以成立的前提;
|
|
9485
|
+
* - 放在之后还有**降级好处**:万一 `rename` 失败,宿主自己的 fallback 标题仍在
|
|
9486
|
+
* (难看但存在),而不是两者皆无。
|
|
9487
|
+
*
|
|
9488
|
+
* @returns 回执片段(成功 `, titled` / 失败 `, ⚠️ 标题未写(原因)`)—— **必须出声**,
|
|
9489
|
+
* 否则"标题没写"会表现成"这条轨迹就是这名字"(D186:守卫可以拒绝,但不许不出声)。
|
|
9490
|
+
*/
|
|
9491
|
+
function renameCarrierTitle(ctx, agent, root, dirName) {
|
|
9492
|
+
try {
|
|
9493
|
+
const titles = hostService(ctx, "sessionTitle");
|
|
9494
|
+
if (!titles || typeof titles.rename !== "function") return ", ⚠️ 标题未写(sessionTitle 不可用)";
|
|
9495
|
+
const session = agent.session;
|
|
9496
|
+
if (!session || typeof session !== "object") return ", ⚠️ 标题未写(无 session)";
|
|
9497
|
+
const title = namingTitleFor({
|
|
9498
|
+
sessionId: parseTrajectoryCode(dirName) ?? dirName,
|
|
9499
|
+
dirName,
|
|
9500
|
+
mdPath: join(root, "AGENT_SESSIONS", dirName, "SESSION.md")
|
|
9501
|
+
});
|
|
9502
|
+
titles.rename(session, title);
|
|
9503
|
+
return ", titled";
|
|
9504
|
+
} catch (err) {
|
|
9505
|
+
return `, ⚠️ 标题未写(${String(err?.message ?? err)})`;
|
|
9506
|
+
}
|
|
9507
|
+
}
|
|
9508
|
+
/**
|
|
9473
9509
|
* 把刚建的会话挂进 **cwd 所属的那个工作区**(2026-10-17 缺陷修复)。
|
|
9474
9510
|
*
|
|
9475
9511
|
* 🔴 **为什么必须我们自己做(宿主源码取证,不是推断)**:
|
|
@@ -18997,10 +19033,23 @@ function installWeixinAccountImpl(d) {
|
|
|
18997
19033
|
}
|
|
18998
19034
|
const manualSessions = /* @__PURE__ */ new Map();
|
|
18999
19035
|
/**
|
|
19000
|
-
*
|
|
19001
|
-
*
|
|
19036
|
+
* 「已发送」记账 —— 值是**发送发生时的轮号**(`null` = 取不到轮号,退化为旧语义)。
|
|
19037
|
+
*
|
|
19038
|
+
* ## 🔴 为什么必须是「轮号」而不是一个布尔(2026-10-21 实测缺陷)
|
|
19039
|
+
*
|
|
19040
|
+
* 旧形态是 `Set<sessionId>`:桥在**每条入站一到达**就 `clearSentThisTurn()`(`inbound-event.ts`),
|
|
19041
|
+
* 而闸门只在 `tools/post-execute` 置位、`turn-stopping` 只读。
|
|
19042
|
+
* ⇒ **一个还在跑的轮,它"我已经发了"的证据会被后到的那条入站抹掉。**
|
|
19043
|
+
*
|
|
19044
|
+
* 现场时刻线(真机,12:08~12:10):`12:08:25` 发送成功 → `12:08:56` 新入站(抹)→
|
|
19045
|
+
* `12:09:19` 那一轮的 `turn-stopping` 读空 ⇒ 判"没发" ⇒ 打回 ⇒ 模型重答。
|
|
19046
|
+
* 用户看到的就是"同一条消息被处理了两轮"。
|
|
19047
|
+
*
|
|
19048
|
+
* ⇒ 记**轮号**之后:抹除操作可以问"我要抹的这一发,是不是**当前这一轮**发的";
|
|
19049
|
+
* `turn-stopping` 也可以问"这一发是不是**我这一轮**发的"。两处都从"是不是同一轮"出发,
|
|
19050
|
+
* 而不是从"这个会话有没有发过"出发。
|
|
19002
19051
|
*/
|
|
19003
|
-
const
|
|
19052
|
+
const sentTurn = /* @__PURE__ */ new Map();
|
|
19004
19053
|
/** 连续打回计数(成功发送即清零) */
|
|
19005
19054
|
const rebukeCounts = /* @__PURE__ */ new Map();
|
|
19006
19055
|
/** 闸门是否装配成功(v1.30.17:桥据此判断能否信任"未发送"这一判定——未装配时不兜底,防重复发送) */
|
|
@@ -19013,19 +19062,25 @@ function noteManualOutputSession(sessionId, session) {
|
|
|
19013
19062
|
/** 桥:非手动模式 / 会话失效 → 注销(闸门随之失效,零干预) */
|
|
19014
19063
|
function forgetManualOutputSession(sessionId) {
|
|
19015
19064
|
manualSessions.delete(sessionId);
|
|
19016
|
-
|
|
19065
|
+
sentTurn.delete(sessionId);
|
|
19017
19066
|
rebukeCounts.delete(sessionId);
|
|
19018
19067
|
}
|
|
19019
|
-
/** 本轮是否已成功发送(测试/诊断用;桥在每轮结束时读它决定是否兜底) */
|
|
19020
|
-
function hasSentThisTurn(sessionId) {
|
|
19021
|
-
return sentThisTurn.has(sessionId);
|
|
19022
|
-
}
|
|
19023
19068
|
/**
|
|
19024
|
-
*
|
|
19025
|
-
*
|
|
19069
|
+
* 本轮是否已成功发送。
|
|
19070
|
+
*
|
|
19071
|
+
* @param turn 期望的轮号(`agent/turn-stopping` 的 `payload.turn`)。
|
|
19072
|
+
* - **省略** ⇒ 旧语义:"这个会话发过就算"(测试与桥的兜底判定用)。
|
|
19073
|
+
* - **给了** ⇒ 按轮比:只有"**这一轮**发的"才算。
|
|
19074
|
+
* - **任一侧取不到轮号**(记录是 `null`,或 `turn` 传了 `null`)⇒ **偏向"发过"**。
|
|
19075
|
+
* 🔴 方向是刻意的:误判"没发"的代价 = 打回 + 重复回答(用户看到两轮,本缺陷本尊);
|
|
19076
|
+
* 误判"发过"的代价 = 少打回一次(用户可能真没收到一条)。前者已经发生过、后者尚未。
|
|
19026
19077
|
*/
|
|
19027
|
-
function
|
|
19028
|
-
|
|
19078
|
+
function hasSentThisTurn(sessionId, turn) {
|
|
19079
|
+
if (!sentTurn.has(sessionId)) return false;
|
|
19080
|
+
if (turn === void 0) return true;
|
|
19081
|
+
const recorded = sentTurn.get(sessionId) ?? null;
|
|
19082
|
+
if (recorded === null || turn === null) return true;
|
|
19083
|
+
return recorded === turn;
|
|
19029
19084
|
}
|
|
19030
19085
|
/** 闸门是否装配成功(未装配 → 桥不做兜底:宁可静默也不冒重复发送的风险) */
|
|
19031
19086
|
function isWeixinOutputGuardActive() {
|
|
@@ -19075,6 +19130,29 @@ function buildManualOutputRebuke(session) {
|
|
|
19075
19130
|
].join("\n");
|
|
19076
19131
|
}
|
|
19077
19132
|
/**
|
|
19133
|
+
* 从 `tools/post-execute` / 会话事件里取**当前轮号**(取不到 ⇒ `null`)。
|
|
19134
|
+
*
|
|
19135
|
+
* 为什么要费这个劲:修"同一条消息处理两轮"的判据是「这一发是不是**我这一轮**发的」,
|
|
19136
|
+
* 而 `tools/post-execute` 的 `exec` 上**不一定**带 turn(宿主形状随版本变)。
|
|
19137
|
+
* ⇒ 三级兜底:① `exec.turn` ② `agent.session` 的最后一个 `turn/start` ③ `null`(退化为旧语义)。
|
|
19138
|
+
* ⛔ `null` 时**绝不**当成"发过了/没发过"的强判据 —— 见 `hasSentThisTurn` 的降级说明。
|
|
19139
|
+
*/
|
|
19140
|
+
function currentTurnOf(exec, agent) {
|
|
19141
|
+
const direct = exec?.turn;
|
|
19142
|
+
if (typeof direct === "number" && Number.isFinite(direct)) return direct;
|
|
19143
|
+
const events = agent?.session;
|
|
19144
|
+
const list = typeof events?.snapshotEvents === "function" ? events.snapshotEvents() : events?.events;
|
|
19145
|
+
if (!Array.isArray(list)) return null;
|
|
19146
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
19147
|
+
const e = list[i];
|
|
19148
|
+
if (e?.type === "turn/start") {
|
|
19149
|
+
const t = e.data?.turn;
|
|
19150
|
+
return typeof t === "number" && Number.isFinite(t) ? t : null;
|
|
19151
|
+
}
|
|
19152
|
+
}
|
|
19153
|
+
return null;
|
|
19154
|
+
}
|
|
19155
|
+
/**
|
|
19078
19156
|
* 装配闸门(index.ts apply 调用)。
|
|
19079
19157
|
* 只对**已登记的手动模式会话**生效——其他会话(主舱/其他 轨迹约束会话)零干预。
|
|
19080
19158
|
*/
|
|
@@ -19086,7 +19164,7 @@ function registerWeixinOutputGuard(ctx) {
|
|
|
19086
19164
|
const sessionId = sessionIdOf$1(exec?.agent);
|
|
19087
19165
|
if (sessionId && manualSessions.has(sessionId)) {
|
|
19088
19166
|
if (isSuccessfulImSend(exec, result, manualSessions.get(sessionId)?.channel ?? "weixin")) {
|
|
19089
|
-
|
|
19167
|
+
sentTurn.set(sessionId, currentTurnOf(exec, exec.agent));
|
|
19090
19168
|
rebukeCounts.delete(sessionId);
|
|
19091
19169
|
}
|
|
19092
19170
|
}
|
|
@@ -19105,7 +19183,7 @@ function registerWeixinOutputGuard(ctx) {
|
|
|
19105
19183
|
if (!sessionId) return;
|
|
19106
19184
|
const session = manualSessions.get(sessionId);
|
|
19107
19185
|
if (!session) return;
|
|
19108
|
-
if (
|
|
19186
|
+
if (hasSentThisTurn(sessionId, payload?.turn ?? null)) return;
|
|
19109
19187
|
const count = (rebukeCounts.get(sessionId) ?? 0) + 1;
|
|
19110
19188
|
if (count > 2) {
|
|
19111
19189
|
rebukeCounts.delete(sessionId);
|
|
@@ -19235,6 +19313,41 @@ async function resolveInboundTarget(ctx, input) {
|
|
|
19235
19313
|
};
|
|
19236
19314
|
}
|
|
19237
19315
|
/**
|
|
19316
|
+
* 同会话入站**串行化**(2026-10-21,修"同一条消息被处理两轮"的第二个缺陷)。
|
|
19317
|
+
*
|
|
19318
|
+
* ## 为什么必须有锁(R↓,代价换来的)
|
|
19319
|
+
*
|
|
19320
|
+
* `askTrajectory` 的成功判据是"`assistant/message` **条数增长**"。两条入站并发时,
|
|
19321
|
+
* 后一条的快照包含前一条尚未落盘的产出 ⇒ **前一轮的产出会满足后一条的成功判据**
|
|
19322
|
+
* ⇒ 兜底 / `reply-fallback` 可能拿到**错轮**的答案(真机实测:把内部过程话发给了家人)。
|
|
19323
|
+
*
|
|
19324
|
+
* ## 粒度与边界
|
|
19325
|
+
*
|
|
19326
|
+
* - 按**会话 id**加锁:不同轨迹(各 IM 账号)各跑各的,互不拖慢。
|
|
19327
|
+
* - ⚠️ 与投递侧"不排队"**不是一回事**:`send-now` 的"当场注入"是**投递**语义,
|
|
19328
|
+
* 这里排的是**处理**语义 —— 同一会话不该同时开两轮。两者不冲突。
|
|
19329
|
+
* - 用 acquire/release 而不是 callback 形态:主流程已在 `try/catch/finally` 里,
|
|
19330
|
+
* callback 会把整段重新缩进,diff 看不清"到底改了什么"。
|
|
19331
|
+
*/
|
|
19332
|
+
const sessionLocks = /* @__PURE__ */ new Map();
|
|
19333
|
+
async function acquireSessionLock(key) {
|
|
19334
|
+
const prev = sessionLocks.get(key) ?? Promise.resolve();
|
|
19335
|
+
let release = () => {};
|
|
19336
|
+
const held = new Promise((resolve) => {
|
|
19337
|
+
release = resolve;
|
|
19338
|
+
});
|
|
19339
|
+
const mine = prev.catch(() => void 0).then(() => held);
|
|
19340
|
+
sessionLocks.set(key, mine);
|
|
19341
|
+
await prev.catch(() => void 0);
|
|
19342
|
+
let released = false;
|
|
19343
|
+
return () => {
|
|
19344
|
+
if (released) return;
|
|
19345
|
+
released = true;
|
|
19346
|
+
if (sessionLocks.get(key) === mine) sessionLocks.delete(key);
|
|
19347
|
+
release();
|
|
19348
|
+
};
|
|
19349
|
+
}
|
|
19350
|
+
/**
|
|
19238
19351
|
* **一条入站消息的主流程**(通道中立)。
|
|
19239
19352
|
*
|
|
19240
19353
|
* 职责:路由 → 固定会话 id 创建/延续 → 提问 → 回复(或被闸门拦下后兜底)。
|
|
@@ -19288,8 +19401,21 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
19288
19401
|
}).catch(() => {});
|
|
19289
19402
|
return;
|
|
19290
19403
|
}
|
|
19404
|
+
let releaseLock = null;
|
|
19291
19405
|
try {
|
|
19292
19406
|
const sessionId = weixinSessionIdForTrajectory(trajectory);
|
|
19407
|
+
/**
|
|
19408
|
+
* 🔴 **同会话串行化**(2026-10-21,修"同一条消息被处理两轮"的第二个缺陷)。
|
|
19409
|
+
*
|
|
19410
|
+
* `askTrajectory` 的成功判据是"`assistant/message` **条数增长**"。两条入站并发时,
|
|
19411
|
+
* 后一条的快照包含前一条尚未落盘的产出 ⇒ **前一轮的产出会满足后一条的成功判据**
|
|
19412
|
+
* ⇒ 兜底/`reply-fallback` 可能拿到**错轮**的答案(真机实测:把内部过程话发给了家人)。
|
|
19413
|
+
*
|
|
19414
|
+
* 锁的粒度 = **一个会话同一时刻只跑一条入站**(不同轨迹各跑各的,互不拖慢)。
|
|
19415
|
+
* ⚠️ 与投递侧"不排队"不是一回事:`send-now` 的"当场注入"是**投递**语义,
|
|
19416
|
+
* 这里排的是**处理**语义 —— 两者不冲突。
|
|
19417
|
+
*/
|
|
19418
|
+
releaseLock = await acquireSessionLock(sessionId);
|
|
19293
19419
|
const existing = getTrajectoryAgent(sessionId);
|
|
19294
19420
|
const hc = readCheapWorkerConfig(root);
|
|
19295
19421
|
const ref = existing ? {
|
|
@@ -19369,7 +19495,18 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
19369
19495
|
if (text) parts.push(text);
|
|
19370
19496
|
parts.push(...mediaNotes, ...degradedNotes);
|
|
19371
19497
|
if (manualOutput) {
|
|
19372
|
-
|
|
19498
|
+
/**
|
|
19499
|
+
* 🔴 **不在这里清空"已发送"标记**(2026-10-21 修"同一条消息被处理两轮")。
|
|
19500
|
+
*
|
|
19501
|
+
* 旧写法 `clearSentThisTurn(sessionId)` 的语义是"来了新消息就当这个会话没发过"——
|
|
19502
|
+
* 而它**连正在跑那一轮的证据一起抹掉**:真机时刻线
|
|
19503
|
+
* `12:08:25 发送成功 → 12:08:56 新入站(抹) → 12:09:19 那一轮 turn-stopping 判"没发" ⇒ 打回`
|
|
19504
|
+
* ⇒ 用户看到的就是"同一条消息被处理了两轮"。
|
|
19505
|
+
*
|
|
19506
|
+
* 现在**没有任何清空动作**:标记记的是**轮号**,上一轮发的记录在下一轮 `turn-stopping`
|
|
19507
|
+
* 时天然不匹配(`hasSentThisTurn` 按轮比)⇒ 陈旧记录靠"轮号不等"淘汰,
|
|
19508
|
+
* 不靠"谁来抹掉"。清空动作本身就是误伤面。
|
|
19509
|
+
*/
|
|
19373
19510
|
noteManualOutputSession(sessionId, {
|
|
19374
19511
|
root,
|
|
19375
19512
|
accountId,
|
|
@@ -19445,6 +19582,8 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
19445
19582
|
console.log(`[serenity-hooks] im-bridge error (ccc=${root}): ${msg}`);
|
|
19446
19583
|
const stack = err instanceof Error ? err.stack : void 0;
|
|
19447
19584
|
if (stack) console.log(`[serenity-hooks] im-bridge stack:\n${stack.slice(0, 1500)}`);
|
|
19585
|
+
} finally {
|
|
19586
|
+
releaseLock?.();
|
|
19448
19587
|
}
|
|
19449
19588
|
}
|
|
19450
19589
|
/**
|
|
@@ -19673,7 +19812,9 @@ function installFeishuAccountImpl() {
|
|
|
19673
19812
|
var bridge_exports = /* @__PURE__ */ __exportAll({
|
|
19674
19813
|
handleIncoming: () => handleIncoming,
|
|
19675
19814
|
handleMachineIncoming: () => handleMachineIncoming,
|
|
19815
|
+
recallContextToken: () => recallContextToken,
|
|
19676
19816
|
registerWeixinBridge: () => registerWeixinBridge,
|
|
19817
|
+
rememberContextToken: () => rememberContextToken,
|
|
19677
19818
|
resolveInboundTarget: () => resolveInboundTarget,
|
|
19678
19819
|
resolveWeixinAccount: () => resolveWeixinAccount,
|
|
19679
19820
|
sendProactiveText: () => sendProactiveText,
|
|
@@ -19697,6 +19838,52 @@ const MACHINE_SCOPE = "<machine>";
|
|
|
19697
19838
|
const machineLoops = /* @__PURE__ */ new Map();
|
|
19698
19839
|
/** typing_ticket 缓存(每 (accountId, fromUserId);getconfig 一次后续复用——对齐参考实现 typingTicketCache) */
|
|
19699
19840
|
const typingTicketByUser = /* @__PURE__ */ new Map();
|
|
19841
|
+
/**
|
|
19842
|
+
* 🔴 **最近一次 `context_token` 缓存**(每 `(accountId, userId)`)—— 2026-10-21,owner 报
|
|
19843
|
+
* 「久不在微信发言,认知轨迹主动发消息就不通」。
|
|
19844
|
+
*
|
|
19845
|
+
* ## 为什么必须有它(协议规范,不是我们的发明)
|
|
19846
|
+
*
|
|
19847
|
+
* 微信 iLink 协议的 `context_token` **不是"用户 ID"**,而是
|
|
19848
|
+
* **「当前对话上下文的会话能力令牌」**([公开协议规范] §5.10)。规范逐字:
|
|
19849
|
+
*
|
|
19850
|
+
* - **文本回复:必填。图片/文件/视频回复:必填。**(§6.4)
|
|
19851
|
+
* - 「以 `(accountId, userId)` 为 key **缓存最近一次 `context_token`**」(§5.10 工程建议)
|
|
19852
|
+
* - 「**主动发消息能力**:公开实现普遍依赖**最近一次入站消息**带来的 `context_token`」(§12 对比表)
|
|
19853
|
+
* —— 这是微信相对 Telegram/Slack 的**根本差异**(那两家只要知道 chat_id 就行)。
|
|
19854
|
+
* - 「协议里**未观察到**单独"打开会话"的 API」(§5.10)⇒ **没有任何办法主动"开一个会话"**,
|
|
19855
|
+
* 只能靠人发一条消息来刷新这张票。
|
|
19856
|
+
*
|
|
19857
|
+
* ## 🔴 修前我们是什么状态
|
|
19858
|
+
*
|
|
19859
|
+
* 收到 token 后**只用一次就丢**(唯一消费点是"回复这一条消息")⇒ 那条回复发完,
|
|
19860
|
+
* 手上就**再没有票**了 ⇒ 之后任何**主动**发送都是裸发(不带 `context_token`)。
|
|
19861
|
+
* ⇒ 这与 owner 的观察完全吻合:**刚聊过就通**(票还在窗口内/刚被刷新),**隔久了就不通**。
|
|
19862
|
+
* ⚠️ 这**不是心跳问题** —— 规范里根本没有心跳;是**票没留住**。
|
|
19863
|
+
*
|
|
19864
|
+
* ## 边界(照规范原话)
|
|
19865
|
+
*
|
|
19866
|
+
* - **不跨用户复用、不跨 bot 账号复用**(key 含 accountId 与 userId 两段)。
|
|
19867
|
+
* - **不伪造 token**:只存服务端真给过的。
|
|
19868
|
+
* - **进程重启即丢失**(内存缓存)——规范也确认这条,因为**没有开新会话的 API**;
|
|
19869
|
+
* 重启后的第一条主动消息仍可能失败,这是**平台限制**,由 O3 的失败文案如实告知。
|
|
19870
|
+
*/
|
|
19871
|
+
const contextTokenByUser = /* @__PURE__ */ new Map();
|
|
19872
|
+
/** context_token 的缓存键(与 typing 同款两段式,但**语义不同**:这里存的是会话票) */
|
|
19873
|
+
function contextCacheKey(accountId, userId) {
|
|
19874
|
+
return `${accountId}|${userId}`;
|
|
19875
|
+
}
|
|
19876
|
+
/** 记下最近一次入站带来的 `context_token`(**只存非空**;空串会把好票覆盖成坏票) */
|
|
19877
|
+
function rememberContextToken(accountId, userId, token) {
|
|
19878
|
+
if (!userId) return;
|
|
19879
|
+
const t = (token ?? "").trim();
|
|
19880
|
+
if (t === "") return;
|
|
19881
|
+
contextTokenByUser.set(contextCacheKey(accountId, userId), t);
|
|
19882
|
+
}
|
|
19883
|
+
/** 取该用户最近一次的 `context_token`(没有 ⇒ `undefined`,调用方按"裸发"处理并如实回执) */
|
|
19884
|
+
function recallContextToken(accountId, userId) {
|
|
19885
|
+
return contextTokenByUser.get(contextCacheKey(accountId, userId));
|
|
19886
|
+
}
|
|
19700
19887
|
function typingCacheKey(accountId, fromUserId) {
|
|
19701
19888
|
return `${accountId}|${fromUserId}`;
|
|
19702
19889
|
}
|
|
@@ -19777,6 +19964,7 @@ async function sendTypingStop(cred, accountId, fromUserId) {
|
|
|
19777
19964
|
async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
|
|
19778
19965
|
const fromUserId = msg.from_user_id;
|
|
19779
19966
|
if (!fromUserId) return;
|
|
19967
|
+
rememberContextToken(accountId, fromUserId, msg.context_token);
|
|
19780
19968
|
await handleInboundEvent(ctx, root, accountId, {
|
|
19781
19969
|
sendText: async ({ toUserId, text, replyRef }) => {
|
|
19782
19970
|
await sendTextMessage({
|
|
@@ -20107,18 +20295,45 @@ async function sendProactiveText(input) {
|
|
|
20107
20295
|
const { accountId, cred } = account;
|
|
20108
20296
|
const trajectory = matchWeixinRoute(settings.routes ?? [], toUserId) ?? "";
|
|
20109
20297
|
const sessionId = weixinSessionIdForTrajectory(trajectory !== "" ? trajectory : sanitizeFileName$1(toUserId) || "unknown");
|
|
20298
|
+
/**
|
|
20299
|
+
* 🔴 **带上最近一次的 `context_token`**(2026-10-21,协议规范 §5.10/§6.4)。
|
|
20300
|
+
*
|
|
20301
|
+
* 修前这里是**裸发**(`sendTextMessage` 不传 `contextToken`)。而规范把 `context_token`
|
|
20302
|
+
* 定为「会话能力令牌」:主动发送**依赖最近一次入站消息**带来的那张票(§12 对比表逐字:
|
|
20303
|
+
* 这正是微信相对 Telegram/Slack 的根本差异)。
|
|
20304
|
+
* ⇒ 没有票时,服务端无法把消息关联到对话窗口 ⇒ 表现为"久不发言就发不出去"。
|
|
20305
|
+
*
|
|
20306
|
+
* ⚠️ **仍然允许无票发送**(不改成硬拒绝):修前就是这么发的,而且**部分时机它是能成功的**
|
|
20307
|
+
* (票还在服务端窗口内的那些时刻)。改成硬拒绝 = 把"有时能发"变成"永远不发"。
|
|
20308
|
+
* ⇒ 取**尽力而为 + 如实回执**:能带就带;没票照发,但把"没票"这件事**写进回执**,
|
|
20309
|
+
* 让人知道这一条是"运气发出去的"(D186:可以降级,但不许不出声)。
|
|
20310
|
+
*/
|
|
20311
|
+
const contextToken = recallContextToken(accountId, toUserId);
|
|
20110
20312
|
try {
|
|
20111
20313
|
await sendTextMessage({
|
|
20112
20314
|
baseUrl: cred.baseUrl,
|
|
20113
20315
|
token: cred.token,
|
|
20114
20316
|
toUserId,
|
|
20115
|
-
text: input.text
|
|
20317
|
+
text: input.text,
|
|
20318
|
+
...contextToken === void 0 ? {} : { contextToken }
|
|
20116
20319
|
});
|
|
20117
20320
|
} catch (err) {
|
|
20321
|
+
/**
|
|
20322
|
+
* 🔴 **失败必须落盘**(2026-10-21,O1)。修前失败只回给调用方、**不写任何流水**
|
|
20323
|
+
* (outgoing hook 只在**成功后**触发)⇒ 「失败过没有、失败成什么样」在盘上不可见
|
|
20324
|
+
* ⇒ 下次报障只能猜。这里补上**只有失败才会产生**的那行日志。
|
|
20325
|
+
*
|
|
20326
|
+
* ⚠️ 按规范 §11:`ret=-2` 在社区实现里被误报成"rate limited",
|
|
20327
|
+
* 而真因常是**缺 `context_token`**(第三方实测)⇒ 故把「有没有票」一起记进日志,
|
|
20328
|
+
* 这样一眼能分辨"票的问题"还是"账号/网络的问题"。
|
|
20329
|
+
*/
|
|
20330
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
20331
|
+
console.warn(`[serenity-hooks] ✗ im 主动发送失败: account=${accountId} user=${toUserId} contextToken=${contextToken === void 0 ? "ABSENT(无票,裸发)" : "present"} err=${detail}`);
|
|
20118
20332
|
return {
|
|
20119
20333
|
ok: false,
|
|
20120
20334
|
code: "SEND_FAILED",
|
|
20121
|
-
error: `发送失败: ${
|
|
20335
|
+
error: `发送失败: ${detail}`,
|
|
20336
|
+
...contextToken === void 0 ? { remediation: "该用户近期未发过消息 ⇒ 手上没有 context_token(会话能力令牌)。协议没有\"主动开新会话\"的 API ⇒ 请先让用户在微信里发一条消息激活会话,再重试。" } : {}
|
|
20122
20337
|
};
|
|
20123
20338
|
}
|
|
20124
20339
|
if (settings.hook) invokeWeixinHook(root, settings.hook, buildOutgoingHookEvent({
|
|
@@ -2555,6 +2555,51 @@ function countAssistantMessages(session) {
|
|
|
2555
2555
|
return n;
|
|
2556
2556
|
}
|
|
2557
2557
|
/**
|
|
2558
|
+
* 会话当前轮号(最后一个 `turn/start` 的 `turn`;取不到 ⇒ `null`)。
|
|
2559
|
+
*
|
|
2560
|
+
* 🔴 为什么需要它(2026-10-21,"同一条消息被处理两轮"的第二处缺陷):
|
|
2561
|
+
* 判"本轮是否产出"用的是 **`assistant/message` 条数增长**。而在**并发入站**下,
|
|
2562
|
+
* 后一条的快照里,**前一条那一轮**还在继续产出 assistant 消息 ⇒ 计数照样增长
|
|
2563
|
+
* ⇒ 后一条会把**别人那一轮的文本**当成自己的答案(真机实测:兜底把内部过程话发给了家人)。
|
|
2564
|
+
* ⇒ 增长必须限定在「**这一轮开始之后**新出现的事件」里。
|
|
2565
|
+
*/
|
|
2566
|
+
function currentTurnOfSession(session) {
|
|
2567
|
+
const events = sessionEvents(session);
|
|
2568
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
2569
|
+
const e = events[i];
|
|
2570
|
+
if (e?.type === "turn/start") {
|
|
2571
|
+
const t = e.data?.turn;
|
|
2572
|
+
return typeof t === "number" && Number.isFinite(t) ? t : null;
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
return null;
|
|
2576
|
+
}
|
|
2577
|
+
function snapshotSession(session) {
|
|
2578
|
+
return {
|
|
2579
|
+
turn: currentTurnOfSession(session),
|
|
2580
|
+
assistantCount: countAssistantMessages(session),
|
|
2581
|
+
eventCount: sessionEvents(session).length
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* **本轮**(快照之后)是否产出了 assistant 消息。
|
|
2586
|
+
*
|
|
2587
|
+
* 判据顺序(每一条都是被真账打出来的):
|
|
2588
|
+
* ① 只数**快照之后**新增的事件 ⇒ 前一轮续跑的产出**不进账**(2026-10-21);
|
|
2589
|
+
* ② 若快照时的轮号已知,且现在的轮号变了 ⇒ 那是**别的轮**在产出 ⇒ 不算我的;
|
|
2590
|
+
* ③ 条数确实增长 ⇒ 成功。
|
|
2591
|
+
*/
|
|
2592
|
+
function grewWithFreshAssistant(session, snap) {
|
|
2593
|
+
const events = sessionEvents(session);
|
|
2594
|
+
if (events.length <= snap.eventCount) return false;
|
|
2595
|
+
const now = currentTurnOfSession(session);
|
|
2596
|
+
if (snap.turn !== null && now !== null && now !== snap.turn) return false;
|
|
2597
|
+
let fresh = 0;
|
|
2598
|
+
for (let i = snap.eventCount; i < events.length; i++) if (events[i]?.type === "assistant/message") fresh += 1;
|
|
2599
|
+
if (fresh > 0) return true;
|
|
2600
|
+
return countAssistantMessages(session) > snap.assistantCount;
|
|
2601
|
+
}
|
|
2602
|
+
/**
|
|
2558
2603
|
* 等**本轮**产出一条 assistant 消息。
|
|
2559
2604
|
*
|
|
2560
2605
|
* ## 为什么不能用 `waitAgentIdle` 单独当判据(2026-10-19 复读缺陷)
|
|
@@ -2570,9 +2615,19 @@ function countAssistantMessages(session) {
|
|
|
2570
2615
|
* ② 成功 ⟺ 该计数**增长**;
|
|
2571
2616
|
* ③ 超时或有界轮询耗尽 ⇒ `false`(调用方据此给空答案,兜底不触发)。
|
|
2572
2617
|
*/
|
|
2573
|
-
async function waitForTurnAssistant(ctx, agent,
|
|
2618
|
+
async function waitForTurnAssistant(ctx, agent, before, opts = {}) {
|
|
2574
2619
|
const timeoutMs = opts.timeoutMs ?? ASK_TURN_TIMEOUT_MS;
|
|
2575
|
-
|
|
2620
|
+
/**
|
|
2621
|
+
* 快照兼容两种入参:数字(旧调用方的"条数")与 `SessionSnapshot`(新,带轮号与事件位)。
|
|
2622
|
+
* 🔴 数字形态**仍然可用**,因为既有测试与调用点按数字传,而它们的语义("条数增长")
|
|
2623
|
+
* 在本轮修法之后依然成立 —— 只是**并发**时不够。给 `SessionSnapshot` 才拿到全部判别力。
|
|
2624
|
+
*/
|
|
2625
|
+
const snap = typeof before === "number" ? {
|
|
2626
|
+
turn: null,
|
|
2627
|
+
assistantCount: before,
|
|
2628
|
+
eventCount: 0
|
|
2629
|
+
} : before;
|
|
2630
|
+
const grew = () => typeof before === "number" ? countAssistantMessages(agent.session) > before : grewWithFreshAssistant(agent.session, snap);
|
|
2576
2631
|
if (await Promise.race([waitAgentIdle(ctx, agent).then(() => true), new Promise((resolve) => {
|
|
2577
2632
|
setTimeout(() => resolve(false), timeoutMs);
|
|
2578
2633
|
})])) return grew();
|
|
@@ -2581,10 +2636,17 @@ async function waitForTurnAssistant(ctx, agent, beforeAssistantCount, opts = {})
|
|
|
2581
2636
|
/**
|
|
2582
2637
|
* 提问一轮:followup → 等**本轮**回答 → 读答案。
|
|
2583
2638
|
*
|
|
2639
|
+
* ## 🔴 2026-10-21:快照从"条数"升级为"(轮号, 条数, 事件位)"
|
|
2640
|
+
*
|
|
2641
|
+
* 并发入站时,后一条的快照里**前一条那一轮还在产出** ⇒ 旧判据(条数增长)会被满足
|
|
2642
|
+
* ⇒ 后一条把**别人那一轮的文本**当自己的答案。新快照让"增长"只在**本轮的窗口内**才成立。
|
|
2643
|
+
* 配套:调用侧(`im/inbound-event.ts`)给**同一会话**加了串行锁 —— 两层都要,缺一不可:
|
|
2644
|
+
* 锁防"同时开两轮",快照防"锁被绕过时(如别的路径直接调本函数)把错轮的文本当答案"。
|
|
2645
|
+
*
|
|
2584
2646
|
* @param options.timeoutMs 本轮等待上限(测试注入;生产用缺省)
|
|
2585
2647
|
*/
|
|
2586
2648
|
async function askTrajectory(ctx, agent, question, options) {
|
|
2587
|
-
const
|
|
2649
|
+
const beforeSnapshot = snapshotSession(agent.session);
|
|
2588
2650
|
agent.followup(createUserMessage({
|
|
2589
2651
|
content: [{
|
|
2590
2652
|
type: "text",
|
|
@@ -2593,7 +2655,7 @@ async function askTrajectory(ctx, agent, question, options) {
|
|
|
2593
2655
|
source: PLUGIN_SOURCE
|
|
2594
2656
|
}));
|
|
2595
2657
|
return {
|
|
2596
|
-
answer: await waitForTurnAssistant(ctx, agent,
|
|
2658
|
+
answer: await waitForTurnAssistant(ctx, agent, beforeSnapshot, { timeoutMs: options?.timeoutMs }) ? lastAssistantText(agent) : "",
|
|
2597
2659
|
sessionId: String(agent.session.id ?? "")
|
|
2598
2660
|
};
|
|
2599
2661
|
}
|
|
@@ -105,6 +105,18 @@ interface TrajectoryAskResult {
|
|
|
105
105
|
answer: string;
|
|
106
106
|
sessionId: string;
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* 会话快照:**调用那一刻**的(轮号, assistant 条数, 事件条数)。
|
|
110
|
+
*
|
|
111
|
+
* 三个值一起记,是因为判"增长"要能回答两个问题:
|
|
112
|
+
* ① 有没有新的 assistant 消息?(条数)
|
|
113
|
+
* ② 那些新消息是不是**我这一轮**产的?(轮号 + 只看快照之后的事件)
|
|
114
|
+
*/
|
|
115
|
+
interface SessionSnapshot {
|
|
116
|
+
turn: number | null;
|
|
117
|
+
assistantCount: number;
|
|
118
|
+
eventCount: number;
|
|
119
|
+
}
|
|
108
120
|
/**
|
|
109
121
|
* 等**本轮**产出一条 assistant 消息。
|
|
110
122
|
*
|
|
@@ -121,13 +133,20 @@ interface TrajectoryAskResult {
|
|
|
121
133
|
* ② 成功 ⟺ 该计数**增长**;
|
|
122
134
|
* ③ 超时或有界轮询耗尽 ⇒ `false`(调用方据此给空答案,兜底不触发)。
|
|
123
135
|
*/
|
|
124
|
-
export declare function waitForTurnAssistant(ctx: unknown, agent: Agent,
|
|
136
|
+
export declare function waitForTurnAssistant(ctx: unknown, agent: Agent, before: number | SessionSnapshot, opts?: {
|
|
125
137
|
timeoutMs?: number;
|
|
126
138
|
pollMs?: number;
|
|
127
139
|
}): Promise<boolean>;
|
|
128
140
|
/**
|
|
129
141
|
* 提问一轮:followup → 等**本轮**回答 → 读答案。
|
|
130
142
|
*
|
|
143
|
+
* ## 🔴 2026-10-21:快照从"条数"升级为"(轮号, 条数, 事件位)"
|
|
144
|
+
*
|
|
145
|
+
* 并发入站时,后一条的快照里**前一条那一轮还在产出** ⇒ 旧判据(条数增长)会被满足
|
|
146
|
+
* ⇒ 后一条把**别人那一轮的文本**当自己的答案。新快照让"增长"只在**本轮的窗口内**才成立。
|
|
147
|
+
* 配套:调用侧(`im/inbound-event.ts`)给**同一会话**加了串行锁 —— 两层都要,缺一不可:
|
|
148
|
+
* 锁防"同时开两轮",快照防"锁被绕过时(如别的路径直接调本函数)把错轮的文本当答案"。
|
|
149
|
+
*
|
|
131
150
|
* @param options.timeoutMs 本轮等待上限(测试注入;生产用缺省)
|
|
132
151
|
*/
|
|
133
152
|
export declare function askTrajectory(ctx: Context, agent: Agent, question: string, options?: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shgroup/dsh-serenity-hooks",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.83.0",
|
|
4
4
|
"description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:14 个工具(container_fs/cognitive_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 认知轨迹日志与原地重建 + 网页登录入口/IM 桥/认知轨迹唤醒注册表。轨迹声明 = 轨迹目录下的 trajectory.json(约束面 + 旧标识 uid + CRO 机器绑定 cro.host;旧名 trajectory-constraint.json / skiff.json 仍在读取族里);轨迹提示词 = 轨迹目录下的 PROMPT.md(一条轨迹「是谁、按什么章程工作」,所有轨迹通用,追加在容器层与质量层之后,不替换它们)。适配 DSH 0.1.7-rc.1 / 0.2.0-rc.1 / 0.2.1-rc.1(三档)。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|