@shgroup/dsh-serenity-hooks 1.92.0 → 1.94.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.92.0",
3
+ "version": "1.94.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/index.js CHANGED
@@ -4,7 +4,7 @@ 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-D5cEPEUY.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 deliverToTrajectory, o as trajectoryConstraintMsmGate, p as requireWhitelistedModel, r as ensureWorkspacePromptSection, s as trajectoryConstraintParticipates, t as createTrajectoryAgent, u as workspaceCognitiveTrajectoryLine, v as applyWirePatch, w as FACE_PORTS, x as readAdvancedSettings, y as migrateLegacyLocalstore } from "./session-DFNtReQY.js";
8
8
  import { i as sessionsRootDir, n as findSessionLog, r as hasSessionLogById } from "./cleanup-CPl1SSaK.js";
9
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
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";
@@ -9010,10 +9010,33 @@ const ZSTD_MAGIC = Buffer.from([
9010
9010
  ]);
9011
9011
  /** 默认回看窗口:8 小时(owner 2026-10-22 定) */
9012
9012
  const DEFAULT_LOOKBACK_MS = 288e5;
9013
- /** 每次从文件尾部读取的字节数(够覆盖数百个末尾事件) */
9014
- const TAIL_WINDOW_BYTES = 1 << 20;
9015
- /** 需要凑够的末尾事件数(够跨过 turn/end + step/end + end-seed 即可,留余量) */
9016
- const TAIL_EVENT_TARGET = 60;
9013
+ /**
9014
+ * 每次从文件尾部读取的字节数。
9015
+ *
9016
+ * 🔴 **2026-10-24 从 1 MiB 提到 8 MiB**:与 `TAIL_EVENT_TARGET` 提到 400 配套。
9017
+ * 1 MiB 常常装不下 400 个事件(大 step 的 `tool/result` 单条就可几十 KB)——
9018
+ * 那样"要 400 个"只是愿望,实际被字节数截断(**两种上限里小的那个说了算**)。
9019
+ */
9020
+ const TAIL_WINDOW_BYTES = 8 << 20;
9021
+ /**
9022
+ * 需要凑够的末尾事件数。
9023
+ *
9024
+ * 🔴🔴 **2026-10-24 从 60 提到 400 —— 这是"啥都扫不到"的直接成因(实测,不是估算)**。
9025
+ *
9026
+ * 原值 60 的前提是「`turn/end` + `step/end` + `end-seed` 三个事件就在尾部附近」——
9027
+ * 那个前提**在"轮内被打断"时完全不成立**:
9028
+ * · 一轮跑到一半死掉 ⇒ 尾部全是 `tool/call` / `tool/result` / `assistant/message` / `step/start`,
9029
+ * **一条 `turn/start`/`turn/end` 都凑不齐**;
9030
+ * · 实测本机 10 个窗口内会话,**4 个**在 60 事件窗口里凑不到 `turn/start`
9031
+ * (被误判成"没开轮")⇒ 换成 400 事件**全部命中**(turn=56/105/192/24)。
9032
+ * · 最坏一条:57 MB 的日志里,一个 step 就可能塞进几十个 `tool/call`。
9033
+ *
9034
+ * ⚠️ **这不是"调参",是判据前提变了**:一旦允许"轮内任意位置中断"这个形态,
9035
+ * 尾部窗口就**必须**宽到能跨过一整轮的碎片。
9036
+ * 💰 成本:解压帧数上升(实测 6 会话 × 400 事件仍是**几十毫秒**量级)——
9037
+ * 用几十毫秒换"不再漏掉一整类",划算。
9038
+ */
9039
+ const TAIL_EVENT_TARGET = 400;
9017
9040
  /**
9018
9041
  * 从文件尾部倒序解压,凑够 `want` 个事件。
9019
9042
  *
@@ -9102,7 +9125,12 @@ function readTailEvents(logPath, want) {
9102
9125
  */
9103
9126
  const UNFINISHED_KINDS = /* @__PURE__ */ new Set(["interrupted", "aborted"]);
9104
9127
  /**
9105
- * 命中判定。
9128
+ * 命中判定(**形态 A:靠 `turn/end.reason.kind`**)。
9129
+ *
9130
+ * ⚠️ **本函数刻意不判「开了没收」** —— 那种形态与「此刻正在跑」在日志读数上**不可分**,
9131
+ * 只有扫描层手里有 `liveIds`(正在跑的会话 id)才判得准。
9132
+ * ⇒ 分工:**本函数判"收尾原因",`scanInterruptedSessions` 判"开了没收"**(见 `detectUnclosedTurn`)。
9133
+ * ⛔ 不要把这些情形塞进本函数 —— 会让它同时依赖"日志形态"与"进程状态",那种判据无法单独证伪。
9106
9134
  *
9107
9135
  * @returns 命中时给出被中断的轮次与"可能有副作用"的工具名;不命中 null
9108
9136
  */
@@ -9125,6 +9153,61 @@ function detectInterruptedEnd(events) {
9125
9153
  };
9126
9154
  }
9127
9155
  /**
9156
+ * 命中判定(**形态 B:一轮开了却没收**)—— 🔴🔴 **2026-10-24 owner 报障的正解**。
9157
+ *
9158
+ * ## 为什么这一格以前整类漏掉
9159
+ *
9160
+ * 进程在**轮内任意位置**死掉时,宿主**永远没机会写 `turn/end`**
9161
+ * ⇒ 日志就停在 `tool/call` / `step/start` / `assistant/message` 上。
9162
+ * 修前 `detectInterruptedEnd` 首行是 `if (idx < 0) return null`(找不到 turn/end 就放弃)
9163
+ * ⇒ **这一整类被判成"没中断"**。
9164
+ *
9165
+ * ## 判据
9166
+ * **最后一条 `turn/start` 之后没有 `turn/end`** ⇒ 那一轮开了没收。
9167
+ *
9168
+ * ## 为什么必须由**扫描层**调用(而不是并进 `detectInterruptedEnd`)
9169
+ * 「开了没收」与「此刻正在跑」在**日志读数上完全一样**。
9170
+ * 唯一能分开它们的东西是 `liveIds`(宿主报的"此刻在跑的会话")——
9171
+ * 而那是**扫描层**的参数,不是日志的属性。
9172
+ * ⇒ 本函数只回答"日志形态是不是开了没收",**由调用方保证已排除在跑的会话**。
9173
+ * ⚠️ 判据纪律:两个不同的问题(日志形态 / 进程状态)**不许塞进同一个函数**,
9174
+ * 否则那个函数无法被单独证伪(改任一侧都可能让测试红,而说不清是哪侧错了)。
9175
+ *
9176
+ * @param events 尾部事件
9177
+ * @returns 命中时给出未收尾的轮次与"可能有副作用"的工具名;没有未收尾的轮次 ⇒ null
9178
+ */
9179
+ function detectUnclosedTurn(events) {
9180
+ if (events.length === 0) return null;
9181
+ let lastEnd = -1;
9182
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/end") {
9183
+ lastEnd = i;
9184
+ break;
9185
+ }
9186
+ let startIdx = -1;
9187
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/start") {
9188
+ startIdx = i;
9189
+ break;
9190
+ }
9191
+ if (startIdx < 0 || startIdx < lastEnd) return null;
9192
+ /**
9193
+ * ⚠️ **不要拿 `session/end-seed` 当"已封口"判据**(我第一版这样写,当场被真机用例打红)。
9194
+ *
9195
+ * 真实日志里最常见的恢复形态就是
9196
+ * `turn/end[interrupted] → session/end-seed → turn/start(新轮)`
9197
+ * —— `end-seed` 是**上一轮**的收尾标记,**不是新一轮的**。
9198
+ * 拿它判"新一轮已封口" ⇒ 会把「新一轮开了就死」误判成"已接续" ⇒ **又回到"啥都扫不到"**。
9199
+ *
9200
+ * ⇒ 判据保持最简:**最后一条 `turn/start` 是否晚于最后一条 `turn/end`**。
9201
+ * "会不会重复拉起正在跑的会话"由 `liveIds` 负责(扫描层),不是本函数的事。
9202
+ */
9203
+ const t = events[startIdx]?.data?.turn;
9204
+ const turn = typeof t === "number" ? t : null;
9205
+ return {
9206
+ turn,
9207
+ pendingTools: findPendingTools(events, turn)
9208
+ };
9209
+ }
9210
+ /**
9128
9211
  * 找出末轮里发起了但没有结果的工具调用名。
9129
9212
  *
9130
9213
  * ⚠️ **尽力而为**,非权威:尾部窗口可能截断更早的调用。上层应把它当
@@ -9196,7 +9279,22 @@ function scanInterruptedSessions(projectDir, options = {}) {
9196
9279
  result.scanned++;
9197
9280
  try {
9198
9281
  const events = readTailEvents(logPath, TAIL_EVENT_TARGET);
9199
- const hit = detectInterruptedEnd(events);
9282
+ /**
9283
+ * 🔴🔴 **两种形态都要接**(2026-10-24 owner 报障:「我要求你抓到非工具调用的中断,
9284
+ * 现在什么都抓不到了」)。
9285
+ *
9286
+ * - 形态 A `detectInterruptedEnd`:日志里有 `turn/end`,且它的 `reason.kind` 是
9287
+ * `interrupted`(崩溃后被 resume 补收尾)/`aborted`(被正常终止)。
9288
+ * - 形态 B `detectUnclosedTurn`:**日志里压根没有 `turn/end`** —— 进程在轮内任意位置死掉,
9289
+ * 宿主没机会写收尾 ⇒ 日志停在 `tool/call` / `step/start` / `assistant/message`。
9290
+ * ⚠️ **这一格必须靠"已排除在跑的会话"才成立**,而 `liveIds` 恰好在本函数手里
9291
+ * (循环开头 `if (liveIds.has(sessionId)) continue`)⇒ 这就是它该待在这里、而不是
9292
+ * 并进 `detectInterruptedEnd` 的原因。
9293
+ *
9294
+ * 实测(本机 10 个窗口内会话):**4 条**只能由形态 B 抓到(末条 `tool/call` / `step/start`),
9295
+ * 修前全落到 `skipped`。
9296
+ */
9297
+ const hit = detectInterruptedEnd(events) ?? detectUnclosedTurn(events);
9200
9298
  if (!hit) {
9201
9299
  result.skipped++;
9202
9300
  continue;
@@ -20133,15 +20231,22 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
20133
20231
  try {
20134
20232
  const sessionId = weixinSessionIdForTrajectory(trajectory);
20135
20233
  /**
20136
- * 🔴 **同会话串行化**(2026-10-21,修"同一条消息被处理两轮"的第二个缺陷)。
20234
+ * 🔴 **同会话串行化**(2026-10-21 引入;**2026-10-24 理由已变,见下**)。
20235
+ *
20236
+ * ## 原理由(已失效,留档 R↓)
20237
+ * `askTrajectory` 的成功判据是"`assistant/message` **条数增长**",两条入站并发时
20238
+ * 后一条的快照包含前一条尚未落盘的产出 ⇒ 前一轮的产出满足后一条的判据 ⇒
20239
+ * 兜底可能拿到**错轮**的答案(真机实测:把内部过程话发给了家人)。
20137
20240
  *
20138
- * `askTrajectory` 的成功判据是"`assistant/message` **条数增长**"。两条入站并发时,
20139
- * 后一条的快照包含前一条尚未落盘的产出 ⇒ **前一轮的产出会满足后一条的成功判据**
20140
- * ⇒ 兜底/`reply-fallback` 可能拿到**错轮**的答案(真机实测:把内部过程话发给了家人)。
20241
+ * ## 🔴 现在的理由(owner 2026-10-24 令「正在跑就直接进、不排队」之后)
20242
+ * 入站改走 `deliverToTrajectory`(**不等回答**)⇒ 上面那条"错轮答案"的路径**已不存在**。
20243
+ * 锁**仍然需要**,但它现在防的是另一件事:
20244
+ * **`createTrajectoryAgent` 的"查-没有-再建"** —— 两条入站同时到达且都发现"没有载体"时,
20245
+ * 会各自建一个 ⇒ **同一轨迹两个载体**(撞 D-族「一条轨迹至多一个载体」的底线)。
20246
+ * ⇒ 锁的**覆盖范围应当只包住"投递"这一步**,⛔ 不该再包住整轮等待
20247
+ * (那正是 owner 说的"排队")。投递本身是同步的几十微秒,串行化代价可忽略。
20141
20248
  *
20142
- * 锁的粒度 = **一个会话同一时刻只跑一条入站**(不同轨迹各跑各的,互不拖慢)。
20143
- * ⚠️ 与投递侧"不排队"不是一回事:`send-now` 的"当场注入"是**投递**语义,
20144
- * 这里排的是**处理**语义 —— 两者不冲突。
20249
+ * ⚠️ 粒度 = **一个会话同一时刻只处理一条入站的投递**(不同轨迹各跑各的)。
20145
20250
  */
20146
20251
  releaseLock = await acquireSessionLock(sessionId);
20147
20252
  const existing = getTrajectoryAgent(sessionId);
@@ -20257,7 +20362,53 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
20257
20362
  })).catch((err) => {
20258
20363
  console.log(`[serenity-hooks] im hook incoming error: ${err instanceof Error ? err.message : String(err)}`);
20259
20364
  });
20260
- await askTrajectory(ctx, ref.agent, question);
20365
+ /**
20366
+ * 🔴🔴 **2026-10-24(owner 令):正在跑就**直接进**,不排队。**
20367
+ *
20368
+ * owner 原话:「当用户消息进来,且绑定轨迹正在活动时,消息是排队而不是直接进入,
20369
+ * 应当改为直接进入,这样确保来回通信的流畅性」。
20370
+ *
20371
+ * **修前**:`await askTrajectory(...)` = `followup()`(排队语义)+ **等整轮跑完**。
20372
+ * ⇒ 用户连说两句,第二句要等第一句那一整轮结束才被看见 ⇒ **来回通信卡顿**。
20373
+ *
20374
+ * **为什么现在可以不等**:D238(同日,出站只剩 `im-bridge`)之后,
20375
+ * 那个 `answer` **已经没有消费方**(上一版这里就是 `void result`)——
20376
+ * 整轮等待**只剩"把后一条堵在前面"这一个副作用**。
20377
+ *
20378
+ * **投递形态**(`deliverToTrajectory`,与 `interrupted.ts` 的 `createResumeOne` 同判据):
20379
+ * 在跑 ⇒ `steer`(当场进当前轮,模型同一次生成里就看到);
20380
+ * 空闲 ⇒ `followup`(立刻起一轮);`steer` 缺失 ⇒ 排队也比丢掉好。
20381
+ */
20382
+ const delivered = deliverToTrajectory(ref.agent, question);
20383
+ /**
20384
+ * 🔴🔴 **2026-10-24(owner 令,D238):这里不再有任何"桥转发最终文本"的分支。**
20385
+ *
20386
+ * 删掉的两段(历史留档,R↓):
20387
+ * ① `manualOutput` 的兜底转发(原 `fallbackOnNoSend` + `source:'reply-fallback'`);
20388
+ * ② 自动模式的直接转发(原 `io.sendText` + `source:'reply'` 缺省)。
20389
+ *
20390
+ * **留下的唯一事实**:投递之后**什么都不发** —— 出站只可能由 agent
20391
+ * 自己调 `im-bridge` 产生(`source:'proactive'`)。
20392
+ *
20393
+ * ⚠️ `delivered.how` 要记:它回答"这条消息是怎么进去的"
20394
+ * (`steer` = 当场进当前轮 / `followup` = 排进下一轮 / `none` = 无通道)。
20395
+ * **不记它就无法从日志上分清"不排队"到底生效了没有**(D216 同族)。
20396
+ */
20397
+ if (delivered.how === "none") console.log(`[serenity-hooks] im 投递失败:会话 ${sessionId} 既无 steer 也无 followup(宿主契约漂移)`);
20398
+ else console.log(`[serenity-hooks] im 投递:会话 ${sessionId} → ${delivered.how}(不排队;出站走 im-bridge)`);
20399
+ /**
20400
+ * 🔴🔴 **2026-10-24(owner 令「不排队」):锁在这里就放,⛔ 不再等整轮。**
20401
+ *
20402
+ * 投递已在上一步**同步**完成(`steer` / `followup` 都是同步调用)⇒
20403
+ * 锁要保护的"查载体-没有-再建"那一段**已经结束**。
20404
+ * ⇒ 从这里往后(typingStop、日志、hook 回调)没有任何"必须串行"的事,
20405
+ * 让锁继续持有 = **把下一条消息堵在这里** = owner 说的"排队"。
20406
+ *
20407
+ * ⚠️ 早期释放是**安全**的:下面 `finally` 里的 `releaseLock?.()` 有 `released` 幂等判据
20408
+ * (见 `acquireSessionLock` 的返回),重复调用是空操作 ⇒ 不会二次释放。
20409
+ */
20410
+ releaseLock?.();
20411
+ releaseLock = null;
20261
20412
  } finally {
20262
20413
  await io.typingStop?.({ toUserId: fromUserId });
20263
20414
  }
@@ -142,47 +142,6 @@ function trajectoryPromptBlock(text) {
142
142
  ].join("\n");
143
143
  }
144
144
  //#endregion
145
- //#region src/trajectory/carrier.ts
146
- /**
147
- * 等待 agent 空闲或销毁。
148
- * @param ctx 宿主插件上下文(事件通道来源;**结构化访问**,故类型为 `unknown`,见文件头 I2 段)
149
- * @param agent 目标 agent
150
- * @returns 空闲、agent 销毁、或会话销毁时结算(永不 reject)
151
- */
152
- function waitAgentIdle(ctx, agent) {
153
- return new Promise((resolve) => {
154
- let settled = false;
155
- const disposers = [];
156
- const finish = () => {
157
- if (settled) return;
158
- settled = true;
159
- for (const dispose of disposers) try {
160
- dispose();
161
- } catch {}
162
- resolve();
163
- };
164
- const subscribe = (name, cb) => {
165
- try {
166
- const off = ctx.on?.(name, cb);
167
- if (typeof off === "function") disposers.push(off);
168
- } catch {}
169
- };
170
- subscribe("agent/status", (payload) => {
171
- const p = payload;
172
- if (p?.agent === agent && p?.status === "idle") finish();
173
- });
174
- subscribe("agent/disposed", (payload) => {
175
- if (payload?.agent === agent) finish();
176
- });
177
- const sessionId = agent.session?.id;
178
- subscribe("session/disposed", (session) => {
179
- const id = session?.id;
180
- if (typeof id === "string" && id === sessionId) finish();
181
- });
182
- if (agent.status === "idle") finish();
183
- });
184
- }
185
- //#endregion
186
145
  //#region src/skills/discovery.ts
187
146
  /**
188
147
  * skills/discovery.ts — CCC 入口 skill 自动发现(纯逻辑,零 DSH 依赖)
@@ -2528,136 +2487,52 @@ async function createOrResumeAgent(ctx, id, root, model, setup, fixedId) {
2528
2487
  };
2529
2488
  }
2530
2489
  }
2531
- /** 会话事件读取(兼容 `snapshotEvents()` 与测试替身的 `.events`) */
2532
- function sessionEvents(session) {
2533
- const s = session;
2534
- if (typeof s.snapshotEvents === "function") return s.snapshotEvents();
2535
- return s.events ?? [];
2536
- }
2537
- /** 读会话最后一个 assistant/message 文本 */
2538
- function lastAssistantText(agent) {
2539
- const events = sessionEvents(agent.session);
2540
- for (let i = events.length - 1; i >= 0; i--) {
2541
- const e = events[i];
2542
- if (e && e.type === "assistant/message") {
2543
- const text = (e.data?.message?.content ?? e.data?.content ?? []).filter((b) => b.type === "text" && b.text).map((b) => b.text).join("\n");
2544
- if (text) return text;
2545
- }
2546
- }
2547
- return "";
2548
- }
2549
- /** 本轮等待上限(毫秒):超时即判「本轮无产出」——**绝不**把上一轮的旧答案当本轮答案 */
2550
- const ASK_TURN_TIMEOUT_MS = 18e4;
2551
- /** 会话里 `assistant/message` 的条数(判「本轮是否真的产出了回答」) */
2552
- function countAssistantMessages(session) {
2553
- let n = 0;
2554
- for (const e of sessionEvents(session)) if (e?.type === "assistant/message") n += 1;
2555
- return n;
2556
- }
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
2490
  /**
2585
- * **本轮**(快照之后)是否产出了 assistant 消息。
2491
+ * 把一条用户消息**投进**载体会话 —— 🔴 **2026-10-24 owner 令:正在跑就直接进,不排队**。
2586
2492
  *
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
- /**
2603
- * 等**本轮**产出一条 assistant 消息。
2493
+ * ## owner 原话
2494
+ * 「**当用户消息进来,且绑定轨迹正在活动时,消息是排队而不是直接进入,应当改为直接进入,
2495
+ * 这样确保来回通信的流畅性**」。
2604
2496
  *
2605
- * ## 为什么不能用 `waitAgentIdle` 单独当判据(2026-10-19 复读缺陷)
2497
+ * ## 修前为什么是"排队"
2498
+ * 入站走 {@link askTrajectory}:它 `followup()`(**排队语义**,等本轮结束才开新一轮)
2499
+ * 然后**等整轮跑完**读答案。而 D238(2026-10-24,出站只剩 `im-bridge`)之后
2500
+ * 那个答案**已经没有消费方**(调用点是 `void result`)⇒
2501
+ * **整轮等待只剩副作用:把后一条消息堵在前一条那一轮后面**。
2606
2502
  *
2607
- * `waitAgentIdle` 的首行是 `if (agent.status === 'idle') finish()` —— 而 `followup()`
2608
- * 是**异步投递**:刚投出去的这一刻 agent 状态**仍是 idle** ⇒ 等待**立刻结算** ⇒
2609
- * `lastAssistantText()` 读到的是**上一轮的** `assistant/message` ⇒ 桥把它当"本轮答案"转发。
2610
- * 而当时若 CCC 又开了 `fallbackOnNoSend`(该开关已于 D238 退役),同一条旧答案会被反复重发(实测三次间隔 300~400ms、逐字相同)。
2503
+ * ## 判据(与 `trajectory/interrupted.ts` 的 `createResumeOne` 同源)
2504
+ * - **`agent.status === 'running'` 且 `steer` 可用** ⇒ `steer`:**当场注入当前轮**
2505
+ * (模型在同一次生成里就看到这句话 ⇒ 来回通信连续)。
2506
+ * - 否则 `followup`:空闲 ⇒ 立刻起一轮;`steer` 缺失 ⇒ 排队也比丢掉好。
2611
2507
  *
2612
- * ## 判据(`idle` 只是"可以开始看"的信号,不是成功信号)
2508
+ * ## 为什么本函数**不等**回答
2509
+ * ① 答案没人读(D238 之后出站只能由 agent 自己调 `im-bridge`);
2510
+ * ② **等待正是"排队"的机制本体** —— 不等,锁就只覆盖投递那一下,
2511
+ * 下一条消息才能也当场进去。
2512
+ * ⇒ 语义从"**问一句、等回答**"变成"**把话递进去**"。这也更贴近 IM 的真实形态:
2513
+ * 用户是连续说话的,不是一问一答的请求-响应。
2613
2514
  *
2614
- * ① 期望值 = 调用前 `assistant/message` 的**条数**;
2615
- * ② 成功 ⟺ 该计数**增长**;
2616
- * ③ 超时或有界轮询耗尽 ⇒ `false`(调用方据此给空答案,兜底不触发)。
2515
+ * ⚠️ **与 {@link askTrajectory} 的分工**:后者保留"问 + 等本轮答案"的完整语义
2516
+ * (仍有消费方/测试在用);本函数是**入站投递**专用。
2617
2517
  */
2618
- async function waitForTurnAssistant(ctx, agent, before, opts = {}) {
2619
- const timeoutMs = opts.timeoutMs ?? ASK_TURN_TIMEOUT_MS;
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);
2631
- if (await Promise.race([waitAgentIdle(ctx, agent).then(() => true), new Promise((resolve) => {
2632
- setTimeout(() => resolve(false), timeoutMs);
2633
- })])) return grew();
2634
- return grew();
2635
- }
2636
- /**
2637
- * 提问一轮:followup → 等**本轮**回答 → 读答案。
2638
- *
2639
- * ## 🔴 2026-10-21:快照从"条数"升级为"(轮号, 条数, 事件位)"
2640
- *
2641
- * 并发入站时,后一条的快照里**前一条那一轮还在产出** ⇒ 旧判据(条数增长)会被满足
2642
- * ⇒ 后一条把**别人那一轮的文本**当自己的答案。新快照让"增长"只在**本轮的窗口内**才成立。
2643
- * 配套:调用侧(`im/inbound-event.ts`)给**同一会话**加了串行锁 —— 两层都要,缺一不可:
2644
- * 锁防"同时开两轮",快照防"锁被绕过时(如别的路径直接调本函数)把错轮的文本当答案"。
2645
- *
2646
- * @param options.timeoutMs 本轮等待上限(测试注入;生产用缺省)
2647
- */
2648
- async function askTrajectory(ctx, agent, question, options) {
2649
- const beforeSnapshot = snapshotSession(agent.session);
2650
- agent.followup(createUserMessage({
2518
+ function deliverToTrajectory(agent, text) {
2519
+ const a = agent;
2520
+ const msg = createUserMessage({
2651
2521
  content: [{
2652
2522
  type: "text",
2653
- text: question
2523
+ text
2654
2524
  }],
2655
2525
  source: PLUGIN_SOURCE
2656
- }));
2657
- return {
2658
- answer: await waitForTurnAssistant(ctx, agent, beforeSnapshot, { timeoutMs: options?.timeoutMs }) ? lastAssistantText(agent) : "",
2659
- sessionId: String(agent.session.id ?? "")
2660
- };
2526
+ });
2527
+ if (a.status === "running" && typeof a.steer === "function") {
2528
+ a.steer(msg);
2529
+ return { how: "steer" };
2530
+ }
2531
+ if (typeof a.followup === "function") {
2532
+ a.followup(msg);
2533
+ return { how: "followup" };
2534
+ }
2535
+ return { how: "none" };
2661
2536
  }
2662
2537
  /**
2663
2538
  * 轨迹约束会话的轨迹纪律参与判定:**不是 轨迹约束恒 true**(正常参与);
@@ -2692,4 +2567,4 @@ function trajectoryConstraintMsmGate(root, sessionId, action, name) {
2692
2567
  return {};
2693
2568
  }
2694
2569
  //#endregion
2695
- export { buildResourceIndex as A, verifyPassword as C, recordToolUsage as D, verifyTotpCode as E, hasTrajectoryPrompt as F, isSafeSkillName as M, truncateContent as N, REFERENCE_DIR as O, TRAJECTORY_PROMPT_FILENAME as P, toWire as S, MECHANISM_PORTS as T, registerEntrySkillSectionGlobal as _, trajectoryConstraintFor as a, projectKnownWorkspaces as b, trajectoryWorkspaceMdPath as c, CHEAP_WORKER_GUIDE as d, providerAvailabilityError as f, registerEntrySkillSection as g, registerCognitiveTrajectorySkillSection as h, getTrajectoryAgent as i, buildResourceNameSuffix as j, SKILL_ONTOLOGY_FILE as k, unregisterTrajectorySession as l, splitModel as m, createTrajectoryAgent as n, trajectoryConstraintMsmGate as o, requireWhitelistedModel as p, ensureWorkspacePromptSection as r, trajectoryConstraintParticipates as s, askTrajectory as t, workspaceCognitiveTrajectoryLine as u, applyWirePatch as v, FACE_PORTS as w, readAdvancedSettings as x, migrateLegacyLocalstore as y };
2570
+ export { buildResourceIndex as A, verifyPassword as C, recordToolUsage as D, verifyTotpCode as E, hasTrajectoryPrompt as F, isSafeSkillName as M, truncateContent as N, REFERENCE_DIR as O, TRAJECTORY_PROMPT_FILENAME as P, toWire as S, MECHANISM_PORTS as T, registerEntrySkillSectionGlobal as _, trajectoryConstraintFor as a, projectKnownWorkspaces as b, trajectoryWorkspaceMdPath as c, CHEAP_WORKER_GUIDE as d, providerAvailabilityError as f, registerEntrySkillSection as g, registerCognitiveTrajectorySkillSection as h, getTrajectoryAgent as i, buildResourceNameSuffix as j, SKILL_ONTOLOGY_FILE as k, unregisterTrajectorySession as l, splitModel as m, deliverToTrajectory as n, trajectoryConstraintMsmGate as o, requireWhitelistedModel as p, ensureWorkspacePromptSection as r, trajectoryConstraintParticipates as s, createTrajectoryAgent as t, workspaceCognitiveTrajectoryLine as u, applyWirePatch as v, FACE_PORTS as w, readAdvancedSettings as x, migrateLegacyLocalstore as y };
@@ -100,7 +100,12 @@ export interface ScanResult {
100
100
  elapsedMs: number;
101
101
  }
102
102
  /**
103
- * 命中判定。
103
+ * 命中判定(**形态 A:靠 `turn/end.reason.kind`**)。
104
+ *
105
+ * ⚠️ **本函数刻意不判「开了没收」** —— 那种形态与「此刻正在跑」在日志读数上**不可分**,
106
+ * 只有扫描层手里有 `liveIds`(正在跑的会话 id)才判得准。
107
+ * ⇒ 分工:**本函数判"收尾原因",`scanInterruptedSessions` 判"开了没收"**(见 `detectUnclosedTurn`)。
108
+ * ⛔ 不要把这些情形塞进本函数 —— 会让它同时依赖"日志形态"与"进程状态",那种判据无法单独证伪。
104
109
  *
105
110
  * @returns 命中时给出被中断的轮次与"可能有副作用"的工具名;不命中 null
106
111
  */
@@ -108,6 +113,34 @@ export declare function detectInterruptedEnd(events: SessionEvent[]): {
108
113
  turn: number | null;
109
114
  pendingTools: string[];
110
115
  } | null;
116
+ /**
117
+ * 命中判定(**形态 B:一轮开了却没收**)—— 🔴🔴 **2026-10-24 owner 报障的正解**。
118
+ *
119
+ * ## 为什么这一格以前整类漏掉
120
+ *
121
+ * 进程在**轮内任意位置**死掉时,宿主**永远没机会写 `turn/end`**
122
+ * ⇒ 日志就停在 `tool/call` / `step/start` / `assistant/message` 上。
123
+ * 修前 `detectInterruptedEnd` 首行是 `if (idx < 0) return null`(找不到 turn/end 就放弃)
124
+ * ⇒ **这一整类被判成"没中断"**。
125
+ *
126
+ * ## 判据
127
+ * **最后一条 `turn/start` 之后没有 `turn/end`** ⇒ 那一轮开了没收。
128
+ *
129
+ * ## 为什么必须由**扫描层**调用(而不是并进 `detectInterruptedEnd`)
130
+ * 「开了没收」与「此刻正在跑」在**日志读数上完全一样**。
131
+ * 唯一能分开它们的东西是 `liveIds`(宿主报的"此刻在跑的会话")——
132
+ * 而那是**扫描层**的参数,不是日志的属性。
133
+ * ⇒ 本函数只回答"日志形态是不是开了没收",**由调用方保证已排除在跑的会话**。
134
+ * ⚠️ 判据纪律:两个不同的问题(日志形态 / 进程状态)**不许塞进同一个函数**,
135
+ * 否则那个函数无法被单独证伪(改任一侧都可能让测试红,而说不清是哪侧错了)。
136
+ *
137
+ * @param events 尾部事件
138
+ * @returns 命中时给出未收尾的轮次与"可能有副作用"的工具名;没有未收尾的轮次 ⇒ null
139
+ */
140
+ export declare function detectUnclosedTurn(events: SessionEvent[]): {
141
+ turn: number | null;
142
+ pendingTools: string[];
143
+ } | null;
111
144
  /**
112
145
  * 扫描一个 **project 目录**(形如 `<sessions-root>/--home-yh-home-home-serenity--/`)。
113
146
  *
@@ -137,9 +137,45 @@ export declare function waitForTurnAssistant(ctx: unknown, agent: Agent, before:
137
137
  timeoutMs?: number;
138
138
  pollMs?: number;
139
139
  }): Promise<boolean>;
140
+ /**
141
+ * 把一条用户消息**投进**载体会话 —— 🔴 **2026-10-24 owner 令:正在跑就直接进,不排队**。
142
+ *
143
+ * ## owner 原话
144
+ * 「**当用户消息进来,且绑定轨迹正在活动时,消息是排队而不是直接进入,应当改为直接进入,
145
+ * 这样确保来回通信的流畅性**」。
146
+ *
147
+ * ## 修前为什么是"排队"
148
+ * 入站走 {@link askTrajectory}:它 `followup()`(**排队语义**,等本轮结束才开新一轮)
149
+ * 然后**等整轮跑完**读答案。而 D238(2026-10-24,出站只剩 `im-bridge`)之后
150
+ * 那个答案**已经没有消费方**(调用点是 `void result`)⇒
151
+ * **整轮等待只剩副作用:把后一条消息堵在前一条那一轮后面**。
152
+ *
153
+ * ## 判据(与 `trajectory/interrupted.ts` 的 `createResumeOne` 同源)
154
+ * - **`agent.status === 'running'` 且 `steer` 可用** ⇒ `steer`:**当场注入当前轮**
155
+ * (模型在同一次生成里就看到这句话 ⇒ 来回通信连续)。
156
+ * - 否则 `followup`:空闲 ⇒ 立刻起一轮;`steer` 缺失 ⇒ 排队也比丢掉好。
157
+ *
158
+ * ## 为什么本函数**不等**回答
159
+ * ① 答案没人读(D238 之后出站只能由 agent 自己调 `im-bridge`);
160
+ * ② **等待正是"排队"的机制本体** —— 不等,锁就只覆盖投递那一下,
161
+ * 下一条消息才能也当场进去。
162
+ * ⇒ 语义从"**问一句、等回答**"变成"**把话递进去**"。这也更贴近 IM 的真实形态:
163
+ * 用户是连续说话的,不是一问一答的请求-响应。
164
+ *
165
+ * ⚠️ **与 {@link askTrajectory} 的分工**:后者保留"问 + 等本轮答案"的完整语义
166
+ * (仍有消费方/测试在用);本函数是**入站投递**专用。
167
+ */
168
+ export declare function deliverToTrajectory(agent: Agent, text: string): {
169
+ how: string;
170
+ };
140
171
  /**
141
172
  * 提问一轮:followup → 等**本轮**回答 → 读答案。
142
173
  *
174
+ * ⚠️ **2026-10-24:本函数已不再是 IM 入站的投递路径**(改用 {@link deliverToTrajectory},
175
+ * 因为 owner 要求"正在跑就直接进、不排队",且 D238 之后答案已无消费方)。
176
+ * 保留它是因为"问一句并等这一轮的答案"这个语义本身仍然有效(测试与将来的同步调用方)。
177
+ * ⛔ **不要把它改回入站路径** —— 那会把排队一起带回来。
178
+ *
143
179
  * ## 🔴 2026-10-21:快照从"条数"升级为"(轮号, 条数, 事件位)"
144
180
  *
145
181
  * 并发入站时,后一条的快照里**前一条那一轮还在产出** ⇒ 旧判据(条数增长)会被满足
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.92.0",
3
+ "version": "1.94.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": {