dsh-session-conductor 1.0.9 → 1.1.1

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/README.md CHANGED
@@ -22,7 +22,9 @@
22
22
  | 模板注入 | 方案模板 / 收尾模板:本地设备上传 / 在线网址 / DSH 目录浏览三种导入,内容可再编辑,注入系统提示词(方案/收尾各一条上下文注入) | 设置 → 会话管理 → 模板注入 |
23
23
  | 自动重命名模型 | 指定便宜/专用模型做标题分析 | 设置 → 会话管理 |
24
24
  | 成员模型切换 | 给队友/子代理单独切模型(只动该成员会话,不影响其他成员):先用 `list_models` 拿确切模型 id,再 `set_member_model` 按成员名/sessionId 切换(写 `model/selection` 事件交给官方投影接管,跨轮持久;未挂载成员也能切,运行中会先等它暂停) | AI 工具 `list_models` / `set_member_model` |
25
- | 上下文用量自查 | AI 按需查自己会话的上下文占用(百分比、已用/容量 tokens)与构成(系统提示词/工具定义/对话消息),占用达 85% 时附收敛建议。数据与界面圆环同源(官方 `contextPressure` / `contextBreakdown` 投影),只读、无参数 | AI 工具 `context_usage` |
25
+ | 上下文用量自查 | AI 按需查自己会话的上下文占用(百分比、已用/容量 tokens)与构成(系统提示词/工具定义/对话消息),占用达 85% 时附收敛建议。数据与界面圆环同源(官方 `contextPressure` / `contextBreakdown` 投影),只读、无参数。**另:每轮把实时用量注入消息尾部**(AI 每轮都能直接看到数字;注入在尾部而非系统提示词前缀 ⇒ 前缀稳定、prompt cache 仍命中;按 turn 门控,一回合只注一次;配置 `injectContextUsage` 可关) | AI 工具 `context_usage` + 每轮注入 |
26
+ | 跨会话消息 | AI 直接给另一个会话发消息(不经过人转达):先 `list_sessions` 挑目标,再 `send_to_session` 投递;目标空闲时**由本体自动唤醒**它跑一个回合,运行中则排到其下一回合边界(`mode:"steer"` 改为注入当前回合)。全部走本体官方 `prompt` API(幂等 `requestId`、不自己写会话日志)。**支持 `dryRun` 预览**(报告目标状态与限流余量,不投递、不消耗配额)。硬约束:禁自投、协作链跳数 ≥3 拒绝、同一对会话 60s 内最多 5 条 | AI 工具 `list_sessions` / `send_to_session` |
27
+ | 指定模型新建会话 | AI 新建会话并可**直接指定 provider/model**(建完即切到该模型),可选带一条初始消息让它立刻开始干活。**缺省工作目录 = 调用方会话自己的 cwd**(不落在 DSH 安装目录里)。本体 `create` 本身不带模型,所以是 `create` → `selectModel` 两步;任一步失败会如实回执(如「会话已建,但模型未设置」),不谎报成功。**支持 `dryRun` 预览**(含模型是否存在于目录的核对),不建会话 | AI 工具 `create_session` |
26
28
 
27
29
  ## 界面预览
28
30
 
package/lib/apply.js CHANGED
@@ -28,7 +28,8 @@ import {
28
28
  maybeScheduleContinue, runAutoContinueSession, runAutoScan, scheduleScan, AUTO_CONTINUE_SCAN_DELAY_MS,
29
29
  } from "./features/continue/scan.js";
30
30
  import { num } from "./shared/util.js";
31
- import { registerContextUsageTool } from "./features/context-usage/index.js";
31
+ import { registerContextUsageTool, createContextUsageInjector } from "./features/context-usage/index.js";
32
+ import { registerSessionMessagingTools } from "./features/session-messaging/index.js";
32
33
  import { registerGroupRoutes } from "./group.js";
33
34
  import { saveTemplate, saveTemplateFromUrl, saveTemplateFromPath, listTemplateDir, removeTemplate, readTemplateSync, readCompletionConventionSync, collectTemplateSlotText, planGateAllows, planEnforceDenyMessage, TEMPLATE_DEFAULTS, TEMPLATE_SLOTS, TEMPLATE_MAX_BYTES, PLAN_ENFORCE_EDIT_TOOLS, PLAN_ENFORCE_BASH_WRITE_RE } from "./template-inject.js";
34
35
  import { name } from "./shared/constants.js";
@@ -89,7 +90,7 @@ export async function apply(ctx, config = {}) {
89
90
  });
90
91
 
91
92
  // 会话分组(原 dsh-session-group 已合并):仅保留只读展示(status/list)
92
- // + 分组下新建会话(new-session,workspaceId 缺省 = 上次会话工作区);分组管理能力回归
93
+ // + 分组下新建会话(new-session,workspaceId 缺省 = 最近活跃会话的工作区);分组管理能力回归
93
94
  // DSH 官方 workspace 机制。
94
95
  // 进程级兜底——宿主 bin.ts 未注册 unhandledRejection/uncaughtException 处理,
95
96
  // Node 15+ 默认 --unhandled-rejections=throw,任何插件的异步 rejection 逃逸都会**直接杀死
@@ -1278,6 +1279,34 @@ export async function apply(ctx, config = {}) {
1278
1279
  return decision;
1279
1280
  }
1280
1281
  });
1282
+
1283
+ // 上下文用量「每轮注入」:把实时用量作为一条 user 消息追加到该步消息批次尾部。
1284
+ // · 为什么放**尾部**而不是系统提示词前缀:前缀保持稳定 ⇒ prompt cache 仍命中,
1285
+ // 而 AI 每轮都能直接看到数字(放前缀会让前缀每轮变化,缓存失效、成本上升)。
1286
+ // · 为什么按 **turn** 门控:agent/pre-step 每步都触发,若每步注入,一个回合里会刷出
1287
+ // 多条重复的用量行(噪音 + 白烧 token)。
1288
+ // · 默认开启(core/config.js 的 injectContextUsage);关掉即退化为「按需调用 context_usage 工具」。
1289
+ // · 读不到投影/服务未挂载时注入器返回空串 ⇒ 静默跳过,绝不影响正常回合。
1290
+ const usageInjector = createContextUsageInjector({ enabled: cfg?.injectContextUsage !== false });
1291
+ ctx.on("agent/pre-step", async ({ agent, turn, signal }, next) => {
1292
+ const decision = await next();
1293
+ if (decision?.kind === "reject" || signal?.aborted) return decision;
1294
+ // 与模板注入同款守卫:工作区自测环境没有 @deepseek-ai/* 依赖,createUserMessage 会是 null
1295
+ if (typeof createUserMessage !== "function") return decision;
1296
+ if (!usageInjector.shouldInject(agent, turn)) return decision;
1297
+ const usageText = usageInjector.textFor(ctx, agent);
1298
+ if (!usageText) return decision;
1299
+ try {
1300
+ const injected = createUserMessage({
1301
+ content: [{ type: "text", text: usageText }],
1302
+ source: { kind: "plugin:dsh-session-conductor", plugin: "dsh-session-conductor", form: "instructions" },
1303
+ });
1304
+ return { ...decision, messages: [...(decision.messages || []), injected] };
1305
+ } catch (error) {
1306
+ log(ctx, `上下文用量注入失败: ${String(error?.message ?? error)}`);
1307
+ return decision;
1308
+ }
1309
+ });
1281
1310
  }
1282
1311
 
1283
1312
  // defineTool 动态加载:工作区自测环境没有 DSH 依赖,装在 profile 内必可解析(与 dsh-git-push 同法)。
@@ -1356,6 +1385,15 @@ export async function apply(ctx, config = {}) {
1356
1385
  if (registerContextUsageTool(ctx, defineTool, tools)) {
1357
1386
  log(ctx, "上下文用量工具已注册 (context_usage)");
1358
1387
  }
1388
+ // 跨会话消息 + 指定模型新建会话(三个工具一次注册)。
1389
+ // 整体包 try/catch:注册期抛错会连累同批其它工具(本项目历史事故),失败只留痕、不阻断。
1390
+ try {
1391
+ if (registerSessionMessagingTools(ctx, defineTool, tools)) {
1392
+ log(ctx, "跨会话工具已注册 (list_sessions / send_to_session / create_session)");
1393
+ }
1394
+ } catch (error) {
1395
+ log(ctx, `跨会话工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
1396
+ }
1359
1397
  log(ctx, "成员模型工具已注册 (list_models / set_member_model)");
1360
1398
  });
1361
1399
  }
@@ -23,6 +23,7 @@ export const DEFAULTS = {
23
23
  maxContinuesPerSession: 3, // 每会话自动续跑总次数上限(防死循环)
24
24
  turnTimeoutMs: 20 * 60 * 1000, // 单回合续跑最长等待,超时取消
25
25
  scanIntervalMs: 5 * 60 * 1000, // 周期扫描间隔
26
+ injectContextUsage: true, // 每轮把上下文用量注入消息尾部(默认开;关掉则只有 context_usage 工具按需查)
26
27
  };
27
28
 
28
29
  /** 运行期配置(面板/测试可改)。外部只读引用;改整体请用 setConfig。 */
@@ -182,6 +182,78 @@ export function formatContextUsage(snapshot) {
182
182
  return lines.join("\n");
183
183
  }
184
184
 
185
+ /**
186
+ * 把快照格式化为**单行紧凑文本**(用于每轮注入到消息尾部)。
187
+ *
188
+ * 与 `formatContextUsage` 的分工:那个是工具输出(详细、多行、带口径说明),
189
+ * 这个是每轮都要进上下文的**常驻一行** —— 必须短,否则注入本身就吃掉可观 token。
190
+ * 拿不到数据时返回空串(调用方据此跳过注入,不塞空消息)。
191
+ *
192
+ * @param {object} snapshot `readContextUsage` 的返回
193
+ * @returns {string} 单行文本;无可用数据时为空串
194
+ */
195
+ export function formatContextUsageCompact(snapshot) {
196
+ if (!snapshot?.ok) return "";
197
+ const occ = snapshot.occupancy;
198
+ if (!occ) return "";
199
+ const parts = [
200
+ `【上下文用量】已用 ${occ.percent}%(~${formatTokens(occ.usedTokens)} / ${formatTokens(occ.contextWindow)} tokens)`,
201
+ ];
202
+ const b = snapshot.breakdown;
203
+ if (b) {
204
+ parts.push(
205
+ `构成:系统 ~${formatTokens(b.systemTokens ?? 0)} | 工具 ~${formatTokens(b.toolsTokens ?? 0)}`
206
+ + ` | 消息 ~${formatTokens(b.messageTokens ?? 0)}`,
207
+ );
208
+ }
209
+ if (occ.percent >= HIGH_USAGE_PERCENT) {
210
+ parts.push(`⚠️ 已达 ${HIGH_USAGE_PERCENT}%:建议先收敛(大结果落盘 / 先小结已完成部分 / 把剩余任务拆到新会话)`);
211
+ }
212
+ return parts.join(" | ");
213
+ }
214
+
215
+ /**
216
+ * 构造「每轮注入一次用量」的注入器(供 `agent/pre-step` 使用)。
217
+ *
218
+ * 两个必须做对的地方:
219
+ * 1. **按 turn 门控**:`agent/pre-step` 每步都触发,若每步都注入,一个回合里会刷出多条
220
+ * 重复的用量行(既是噪音也白烧 token)。这里用 `WeakMap<agent, turn>` 记账,turn 变了才注入。
221
+ * 2. **失败即静默跳过**:拿不到投影/服务未挂载时返回空串,调用方跳过注入 —— 绝不能因为
222
+ * 用量读不到而影响正常回合。
223
+ *
224
+ * 依赖以参数注入(默认用本模块实现),便于单测替换。
225
+ *
226
+ * @param {object} [deps] { readUsage, format, enabled }
227
+ * @returns {{shouldInject: (agent:object, turn:number)=>boolean, textFor: (ctx:object, agent:object)=>string}}
228
+ */
229
+ export function createContextUsageInjector({
230
+ readUsage = readContextUsage,
231
+ format = formatContextUsageCompact,
232
+ enabled = true,
233
+ } = {}) {
234
+ const lastTurnByAgent = new WeakMap();
235
+ return {
236
+ /** 该 agent 的这一轮是否要注入(会记账:同一 turn 只返回一次 true)。 */
237
+ shouldInject(agent, turn) {
238
+ if (!enabled || !agent) return false;
239
+ const t = Number(turn);
240
+ if (!Number.isFinite(t)) return false;
241
+ if (lastTurnByAgent.get(agent) === t) return false;
242
+ lastTurnByAgent.set(agent, t);
243
+ return true;
244
+ },
245
+ /** 生成注入文本;读不到数据或格式化失败时返回空串。 */
246
+ textFor(ctx, agent) {
247
+ try {
248
+ const text = format(readUsage(ctx, agent));
249
+ return typeof text === "string" ? text : "";
250
+ } catch {
251
+ return "";
252
+ }
253
+ },
254
+ };
255
+ }
256
+
185
257
  /**
186
258
  * 注册 `context_usage` AI 工具。
187
259
  *
@@ -0,0 +1,26 @@
1
+ // dsh-session-conductor — 跨会话消息域(域出口)
2
+ //
3
+ // 【本域职责】给 AI 三个可直接调用的工具:
4
+ // · list_sessions 列出本机会话(挑投递目标用)
5
+ // · send_to_session 给指定会话投递一条消息(目标空闲时由本体唤醒;支持 dryRun 预览)
6
+ // · create_session 新建会话,可指定 provider/model,可选带初始消息(支持 dryRun 预览)
7
+ //
8
+ // 【文件分工】logic.js = 常量 / 纯函数 / 限流状态机 / 本体调用封装;
9
+ // tools.js = 三个工具的注册;本文件只做再导出 —— 域外只 import 本文件,
10
+ // 内部结构可自由调整(照 features/member-model 的写法)。
11
+ //
12
+ // 【设计口径】全部走本体官方 API(sessionController 的 create / selectModel / prompt),
13
+ // 不自己写会话日志 —— 原因见 logic.js 头部注释。
14
+
15
+ export { registerSessionMessagingTools } from "./tools.js";
16
+ export {
17
+ formatSessionList,
18
+ resetMessagingStateForTest,
19
+ statusOf,
20
+ shortError,
21
+ mapPromptError,
22
+ checkModelPair,
23
+ TEXT_LIMIT,
24
+ HOPS_LIMIT,
25
+ RATE_MAX,
26
+ } from "./logic.js";
@@ -0,0 +1,240 @@
1
+ // dsh-session-conductor — 跨会话消息域:常量、纯函数与本体调用辅助
2
+ //
3
+ // 【本文件职责】只放「不注册工具」的部分:限额常量、会话列表渲染、限流状态机、
4
+ // 以及所有对本体 `sessionController` 的调用封装。工具定义在 tools.js,域出口在 index.js。
5
+ //
6
+ // 【为什么全部走本体官方 API,而不是自己写会话日志】
7
+ // 本体把「投递一条消息给某个会话」做成一等公民:session-controller 的
8
+ // `@Remote('prompt')`,其受理流程**会先 resume 目标会话**(唤醒语义由本体保证),
9
+ // 且请求带 `requestId` 幂等键(本体把它持久化在那条被接受的消息上)。
10
+ // 反面:会话日志里 inbox 家族**只有 `agent/inbox/spliced` 落盘**,而它的 fold
11
+ // 对非法 splice **直接抛错**(message.id 重复、start 越界都会让整份会话读不出来)
12
+ // —— 自己写日志等于拿对方会话的完整性冒险。因此本域**不碰任何日志文件**。
13
+
14
+ import { log } from "../../shared/log.js";
15
+
16
+ /** 单条消息长度上限(字符):够写交接要点,不够塞长文档(长内容应落共享文件)。 */
17
+ export const TEXT_LIMIT = 8 * 1024;
18
+ /** 协作链跳数上限:到了就说明该改用共享文件交接,而不是继续互相投递。 */
19
+ export const HOPS_LIMIT = 3;
20
+ /** 限流窗口与条数:同一对会话在该窗口内最多投这么多条,防互相唤醒风暴。 */
21
+ export const RATE_WINDOW_MS = 60_000;
22
+ export const RATE_MAX = 5;
23
+ /** 本体 prompt 受理是本地操作,给足超时但别无限等。 */
24
+ export const PROMPT_TIMEOUT_MS = 60_000;
25
+
26
+ // ── 纯函数 ────────────────────────────────────────────────────────────────
27
+
28
+ /** 会话条目 → 状态词(running=正在跑回合 / idle=已挂载空闲 / inactive=冷会话)。 */
29
+ export function statusOf(item) {
30
+ if (item?.running) return "running";
31
+ return item?.live ? "idle" : "inactive";
32
+ }
33
+
34
+ /** 时间戳 → 本地可读时间(无值时不假装有值)。 */
35
+ export function formatTime(value) {
36
+ const n = Number(value);
37
+ if (!Number.isFinite(n) || n <= 0) return "(未知)";
38
+ try {
39
+ return new Date(n).toLocaleString("zh-CN", { hour12: false });
40
+ } catch {
41
+ return String(n);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 把会话列表渲染成给 AI 看的文本(纯函数,便于单测)。
47
+ * @param items buildSessionListCached 的产出
48
+ * @param selfId 当前会话 id(用于标出「当前会话」,可为 null)
49
+ * @returns {string} 多行文本;空列表时返回固定提示
50
+ */
51
+ export function formatSessionList(items, selfId) {
52
+ const all = Array.isArray(items) ? items : [];
53
+ // 防御:会话列表构建偶尔会混入缺 id 的脏条目(实测见过一条),直接渲染会输出
54
+ // 「undefined | (无标题) …」误导调用方 —— 这里按 id 过滤,并如实报告跳过了几条。
55
+ const usable = all.filter((item) => item && typeof item.id === "string" && item.id !== "");
56
+ const skipped = all.length - usable.length;
57
+ if (usable.length === 0) {
58
+ return skipped > 0 ? `没有可用会话(${skipped} 条缺 id 的脏数据已跳过)。` : "没有可见会话。";
59
+ }
60
+ const lines = usable.map((item) => {
61
+ const flags = [statusOf(item)];
62
+ if (item?.archived) flags.push("已归档");
63
+ if (selfId && item?.id === selfId) flags.push("← 当前会话");
64
+ return `- ${item.id} | ${String(item?.title ?? "(无标题)")} | ${String(item?.cwd ?? "(无工作区)")}`
65
+ + ` | ${flags.join(" / ")} | 最后活动 ${formatTime(item?.updatedAt)}`;
66
+ });
67
+ const tail = skipped > 0 ? `\n(另有 ${skipped} 条缺 id 的脏数据已跳过)` : "";
68
+ return `共 ${usable.length} 个会话:\n${lines.join("\n")}${tail}`;
69
+ }
70
+
71
+ /** 把远端错误压成「短码: 原文」一行,便于回执如实说明失败原因。 */
72
+ export function shortError(error) {
73
+ const code = error?.code ?? error?.name ?? "";
74
+ const msg = String(error?.message ?? error);
75
+ return code ? `${code}: ${msg}` : msg;
76
+ }
77
+
78
+ /** 本体错误码 → 本工具回执的短码(认不出就标 unknown,不臆造)。 */
79
+ export function mapPromptError(error) {
80
+ const text = `${error?.code ?? ""} ${error?.message ?? ""}`;
81
+ if (/session\/not-found/.test(text)) return "session-not-found";
82
+ if (/session\/agent-busy/.test(text)) return "target-busy";
83
+ if (/session\/model-unavailable/.test(text)) return "target-no-model";
84
+ if (/gateway\/bad-request/.test(text)) return "bad-request";
85
+ if (/session\/attachment-invalid/.test(text)) return "attachment-invalid";
86
+ if (/session\/invalid-time-zone/.test(text)) return "bad-time-zone";
87
+ return "unknown";
88
+ }
89
+
90
+ // ── 会话/调用方身份 ───────────────────────────────────────────────────────
91
+
92
+ /** 取本体 sessionController 服务(本插件既有取法,见 features/member-model/switch.js)。 */
93
+ export function sessionControllerOf(ctx) {
94
+ try {
95
+ return ctx.get?.("sessionController") ?? null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /** 当前会话 id(用于禁自投与 isSelf 标记)。 */
102
+ export function currentSessionId(agent) {
103
+ return agent?.session?.id ?? null;
104
+ }
105
+
106
+ /**
107
+ * 调用方会话的工作目录。
108
+ * 用途:`create_session` 不传 cwd/workspaceId 时的缺省值 —— 新会话落在**发起它的那个会话**
109
+ * 的工作目录里,而不是本体的默认 cwd(实测是 DSH 安装目录 `/volume1/@appstore/DeepSeekHarness-NAS`,
110
+ * 对干活毫无意义)。
111
+ */
112
+ export function callerCwd(agent) {
113
+ const cwd = agent?.session?.header?.cwd;
114
+ return typeof cwd === "string" && cwd !== "" ? cwd : "";
115
+ }
116
+
117
+ /**
118
+ * 懒加载会话列举。
119
+ * 为什么不在顶层 import:`sessions/list.js` 会拉起 `core/domain.js` →
120
+ * `@deepseek-ai/dsh-storage-domain`(宿主包),工作区里解析不到 ⇒ 本模块将无法被单测
121
+ * 直接 import。改为用到时再加载,行为不变但模块可独立测试(同 apply.js 的懒加载思路)。
122
+ */
123
+ export async function listSessions(ctx) {
124
+ const { buildSessionListCached } = await import("../../sessions/list.js");
125
+ return buildSessionListCached(ctx, {});
126
+ }
127
+
128
+ /**
129
+ * best-effort 描述目标会话(dryRun 用):从会话列表里找到它,报告状态与工作区。
130
+ * 列表不可用时如实说明原因,**不阻断**(dryRun 的价值在于「早知道」,不该因为列表拿不到就失败)。
131
+ */
132
+ export async function describeTarget(ctx, sessionId) {
133
+ try {
134
+ const items = await listSessions(ctx);
135
+ const hit = (Array.isArray(items) ? items : []).find((item) => item?.id === sessionId);
136
+ if (!hit) return "(⚠️ 会话列表里没有这个 id —— 可能写错了,或它是刚建、尚未落盘的会话)";
137
+ return `(${statusOf(hit)}${hit.archived ? " / 已归档" : ""} | ${String(hit.cwd ?? "(无工作区)")})`;
138
+ } catch (error) {
139
+ return `(目标存在性无法核对:${shortError(error)})`;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 在模型目录里核对 provider/model 是否存在(dryRun 用,best-effort)。
145
+ * @returns {Promise<{checked:boolean, found:boolean, hint:string}>} 目录不可用时 checked=false,不阻断。
146
+ */
147
+ export async function checkModelPair(sc, provider, model) {
148
+ try {
149
+ if (typeof sc?.modelCatalog !== "function") {
150
+ return { checked: false, found: false, hint: "本体未提供 modelCatalog,无法预校验" };
151
+ }
152
+ const catalog = await sc.modelCatalog();
153
+ const group = (catalog?.groups ?? []).find((g) => g?.id === provider);
154
+ if (!group) {
155
+ return { checked: true, found: false, hint: `provider「${provider}」不在模型目录里(先用 list_models 查)` };
156
+ }
157
+ const hit = (group.models ?? []).some((m) => m?.id === model);
158
+ return {
159
+ checked: true,
160
+ found: hit,
161
+ hint: hit ? "" : `model「${model}」不在 provider「${provider}」下(先用 list_models 查)`,
162
+ };
163
+ } catch (error) {
164
+ return { checked: false, found: false, hint: `模型目录查询失败:${shortError(error)}` };
165
+ }
166
+ }
167
+
168
+ // ── 限流与在途去重(进程内;重启即清空,不落盘)────────────────────────────
169
+
170
+ /** `${from}->${to}` → 投递时间戳数组。 */
171
+ const rateBuckets = new Map();
172
+ /** 正在投递的 requestId(防同一请求并发重复)。 */
173
+ const inFlight = new Set();
174
+
175
+ /** 测试用:清空限流与在途状态。 */
176
+ export function resetMessagingStateForTest() {
177
+ rateBuckets.clear();
178
+ inFlight.clear();
179
+ }
180
+
181
+ /** 同一对会话是否已超限(未超则记一次)。 */
182
+ export function hitRateLimit(from, to, now = Date.now()) {
183
+ const key = `${from ?? "?"}->${to}`;
184
+ const hits = (rateBuckets.get(key) ?? []).filter((at) => now - at < RATE_WINDOW_MS);
185
+ if (hits.length >= RATE_MAX) {
186
+ rateBuckets.set(key, hits);
187
+ return true;
188
+ }
189
+ hits.push(now);
190
+ rateBuckets.set(key, hits);
191
+ return false;
192
+ }
193
+
194
+ /** 只读探测限流状态(dryRun 用:报告「会不会被限流」,但不消耗配额)。 */
195
+ export function peekRateLimit(from, to, now = Date.now()) {
196
+ const key = `${from ?? "?"}->${to}`;
197
+ const hits = (rateBuckets.get(key) ?? []).filter((at) => now - at < RATE_WINDOW_MS);
198
+ return { limited: hits.length >= RATE_MAX, used: hits.length };
199
+ }
200
+
201
+ /** 在途表是否已有该 requestId(真投前查重用)。 */
202
+ export function isInFlight(requestId) {
203
+ return inFlight.has(requestId);
204
+ }
205
+
206
+ /** 标记/解除在途(真投前后各一次)。 */
207
+ export function markInFlight(requestId) {
208
+ inFlight.add(requestId);
209
+ }
210
+
211
+ export function clearInFlight(requestId) {
212
+ inFlight.delete(requestId);
213
+ }
214
+
215
+ /** 生成幂等键:本体把它持久化在那条被接受的消息上,重试传同键不会重复投递。 */
216
+ export function newRequestId() {
217
+ const rand = Math.random().toString(36).slice(2, 10);
218
+ return `sc-${Date.now().toString(36)}-${rand}`;
219
+ }
220
+
221
+ // ── 本体调用封装 ──────────────────────────────────────────────────────────
222
+
223
+ /** 文本 → 本体要求的 content 块数组(字段名以 types.ts 的 PromptContentPart 为准)。 */
224
+ export function contentOf(text) {
225
+ return [{ type: "text", text: String(text) }];
226
+ }
227
+
228
+ /** 投递一条消息到目标会话(走本体官方 prompt;目标空闲时会被 resume 唤醒)。 */
229
+ export function deliverPrompt(sc, { sessionId, text, mode, requestId }) {
230
+ // 本体签名是 prompt(request, signal):signal 必传(内部会 throwIfAborted)。
231
+ return sc.prompt(
232
+ { requestId, sessionId, mode, content: contentOf(text) },
233
+ AbortSignal.timeout(PROMPT_TIMEOUT_MS),
234
+ );
235
+ }
236
+
237
+ /** 记一行插件日志(工具执行路径用;薄封装便于以后统一改格式)。 */
238
+ export function logMessaging(ctx, message) {
239
+ log(ctx, message);
240
+ }
@@ -0,0 +1,249 @@
1
+ // dsh-session-conductor — 跨会话消息域:三个 AI 工具的注册
2
+ //
3
+ // 【本文件职责】只放工具定义(description / parameters / execute)与注册入口;
4
+ // 常量、纯函数、限流状态机与本体调用封装在 logic.js。
5
+ //
6
+ // 【工具注册约定】照 lib/features/context-usage 的写法:注册函数返回 boolean,
7
+ // 由 apply.js 在 inject('tools') 回调里调用。`defineTool` 必须由调用方显式传入
8
+ // —— 本项目历史事故:文件里直接用 defineTool 却从未导入 ⇒ 该回调抛
9
+ // ReferenceError ⇒ 同批工具全部注册不上(详见 lib/apply.js 的注释)。
10
+
11
+ import {
12
+ TEXT_LIMIT, HOPS_LIMIT, RATE_WINDOW_MS, RATE_MAX,
13
+ currentSessionId, callerCwd, sessionControllerOf, shortError, mapPromptError,
14
+ formatSessionList, listSessions, describeTarget, checkModelPair,
15
+ peekRateLimit, hitRateLimit, isInFlight, markInFlight, clearInFlight,
16
+ newRequestId, deliverPrompt, logMessaging,
17
+ } from "./logic.js";
18
+
19
+ /** 三个工具共用的输出形态(纯文本回执)。 */
20
+ const TEXT_OUTPUT = {
21
+ schema: { type: "string" },
22
+ render(args, value) {
23
+ return [{ type: "text", text: String(value) }];
24
+ },
25
+ };
26
+
27
+ // ── 工具:list_sessions ───────────────────────────────────────────────────
28
+
29
+ function registerListSessions(ctx, defineTool, tools) {
30
+ tools.register(defineTool({
31
+ name: "list_sessions",
32
+ description:
33
+ "列出本机 DSH 的会话(id / 标题 / 工作区 / 状态 / 最后活动 / 是否当前会话),供 send_to_session 挑目标。"
34
+ + "只读、无参数、无副作用。"
35
+ + "状态含义:running = 正在跑回合;idle = 已挂载但空闲;inactive = 冷会话(未挂载,被投递时会被唤醒)。"
36
+ + "用法:先调它拿目标 id,再把 id 原样传给 send_to_session —— 不要凭记忆或标题猜 id。"
37
+ + "缺 id 的脏数据会被自动跳过并如实标注。",
38
+ parameters: {},
39
+ output: TEXT_OUTPUT,
40
+ async execute(_args, exec) {
41
+ try {
42
+ const items = await listSessions(ctx);
43
+ return formatSessionList(items, currentSessionId(exec?.agent));
44
+ } catch (error) {
45
+ return `列出会话失败:${shortError(error)}`;
46
+ }
47
+ },
48
+ }));
49
+ }
50
+
51
+ // ── 工具:send_to_session ─────────────────────────────────────────────────
52
+
53
+ function registerSendToSession(ctx, defineTool, tools) {
54
+ tools.register(defineTool({
55
+ name: "send_to_session",
56
+ description:
57
+ "给另一个 DSH 会话投递一条消息(不经过人转达),用于 AI 之间的交接与唤醒。"
58
+ + "投递语义:目标空闲/未挂载时由本体自动唤醒它跑一个回合;目标运行中则排到其下一回合边界;"
59
+ + "mode=\"steer\" 改为注入它正在跑的那个回合。"
60
+ + "何时用:需要对方立刻行动、且是短消息。"
61
+ + "何时不用:要交接上下文/长内容/可追溯 → 落共享文件(工作区里的交接 md)+ git 提交信息,这里只发路径与要点;"
62
+ + "需要人做决策或跨机器 → 人转达。"
63
+ + "硬约束(防唤醒风暴):禁止投给自己;协作链 hops≥3 拒绝;同一对会话 60s 内最多 5 条;"
64
+ + "requestId 幂等(重试传同一个值不会重复投递)。"
65
+ + "先 dryRun=true 可只校验并预览(不投递、不消耗限流配额),确认无误再真投。"
66
+ + "回执只表示本体已受理,不代表对方已处理完。",
67
+ parameters: {
68
+ sessionId: { type: "string", required: true, description: "目标会话 id(先用 list_sessions 取,别凭记忆写)" },
69
+ text: { type: "string", required: true, description: `消息正文(纯文本,≤${TEXT_LIMIT} 字符;长内容请落文件后只发路径)` },
70
+ mode: { type: "string", description: "queue(默认:排到目标下一回合,空闲时唤醒它)| steer(注入目标正在跑的回合)" },
71
+ requestId: { type: "string", description: "可选幂等键;重试时传同一个值可避免重复投递(缺省由工具生成并在回执里回传)" },
72
+ hops: { type: "number", description: `协作链跳数(AI 之间互相投递时自增;≥${HOPS_LIMIT} 会被拒绝并提示改用共享文件)` },
73
+ dryRun: { type: "boolean", description: "true = 只校验并预览、不真的投递(默认 false)" },
74
+ },
75
+ output: TEXT_OUTPUT,
76
+ async execute(args, exec) {
77
+ const from = currentSessionId(exec?.agent);
78
+ const to = String(args?.sessionId ?? "").trim();
79
+ const text = String(args?.text ?? "");
80
+ const mode = args?.mode === "steer" ? "steer" : "queue";
81
+ const hops = Number(args?.hops ?? 0);
82
+ const dryRun = args?.dryRun === true;
83
+ // 本地能判的一律先判(省一次无谓 RPC,且错误更明确)
84
+ if (!to) return "投递失败:sessionId 必填(先用 list_sessions 查目标)";
85
+ if (from && to === from) return "投递失败:不能投给自己(禁止自投,避免自我唤醒死循环)";
86
+ if (!text.trim()) return "投递失败:text 不能为空";
87
+ if (text.length > TEXT_LIMIT) {
88
+ return `投递失败:text 超长(${text.length} > ${TEXT_LIMIT} 字符)——长内容请落共享文件后只发路径`;
89
+ }
90
+ if (Number.isFinite(hops) && hops >= HOPS_LIMIT) {
91
+ return `投递失败:协作链跳数已达上限(hops=${hops} ≥ ${HOPS_LIMIT})——请改用共享文件交接,不要继续互相投递`;
92
+ }
93
+ const sc = sessionControllerOf(ctx);
94
+ if (!sc || typeof sc.prompt !== "function") {
95
+ return "投递失败:sessionController.prompt 不可用(本体未挂载该服务或版本不支持)";
96
+ }
97
+ if (dryRun) {
98
+ // 只读预检:限流只探测不消耗;目标存在性 best-effort
99
+ const rate = peekRateLimit(from, to);
100
+ const target = await describeTarget(ctx, to);
101
+ const rid = String(args?.requestId ?? "").trim();
102
+ return `【dryRun】未投递任何消息,以下是将要发生的事:\n`
103
+ + `- 目标:${to}${target}\n`
104
+ + `- 模式:${mode}${mode === "steer" ? "(注入目标正在跑的回合)" : "(排到目标下一回合;目标空闲时由本体唤醒它)"}\n`
105
+ + `- 正文:${text.length} 字符,开头「${text.slice(0, 40)}${text.length > 40 ? "…" : ""}」\n`
106
+ + `- 幂等键:${rid || "(未指定,真投时自动生成)"}\n`
107
+ + `- 限流:本窗口已用 ${rate.used}/${RATE_MAX}`
108
+ + (rate.limited ? " ⇒ ⚠️ 当前会被限流,需等窗口滚动或改用共享文件" : "(不会触发限流)");
109
+ }
110
+ if (hitRateLimit(from, to)) {
111
+ return `投递失败:限流(同一对会话 ${RATE_WINDOW_MS / 1000}s 内最多 ${RATE_MAX} 条)——稍后再试,或改用共享文件`;
112
+ }
113
+ const requestId = String(args?.requestId ?? "").trim() || newRequestId();
114
+ if (isInFlight(requestId)) return `投递失败:同一 requestId 正在投递中(${requestId})`;
115
+ markInFlight(requestId);
116
+ try {
117
+ await deliverPrompt(sc, { sessionId: to, text, mode, requestId });
118
+ logMessaging(ctx, `跨会话投递 ${from ?? "?"} → ${to}(mode=${mode} requestId=${requestId} hops=${hops})`);
119
+ return `已投递到 ${to}(mode=${mode} | requestId=${requestId})。`
120
+ + (mode === "steer"
121
+ ? "目标是运行中会话:消息已注入其当前回合。"
122
+ : "目标空闲时本体已自动唤醒它;目标运行中则在其下一回合边界消费。")
123
+ + " 回执只表示本体已受理,不代表对方已处理完。";
124
+ } catch (error) {
125
+ return `投递失败(${mapPromptError(error)}):${shortError(error)}`;
126
+ } finally {
127
+ clearInFlight(requestId);
128
+ }
129
+ },
130
+ }));
131
+ }
132
+
133
+ // ── 工具:create_session ──────────────────────────────────────────────────
134
+
135
+ function registerCreateSession(ctx, defineTool, tools) {
136
+ tools.register(defineTool({
137
+ name: "create_session",
138
+ description:
139
+ "新建一个 DSH 会话(只新建,不删除、不修改其它会话)。可指定 provider/model(建完立即切到该模型),"
140
+ + "可选带一条初始消息让它立刻开始干活。"
141
+ + "工作目录缺省 = **调用方会话自己的 cwd**(发起它的那个 AI 在哪干活,新会话就落在哪);"
142
+ + "也可显式给 cwd 或 workspaceId(二者互斥)。"
143
+ + "建议用法:① 先用 list_models 查确切的 provider/model;"
144
+ + "② 不确定就先 dryRun=true 预览(会核对模型是否在目录里、报告将用的 cwd 与初始消息),确认无误再真建。"
145
+ + "模型与初始消息是建会话之后的两个独立步骤,任一步失败都会如实回执(如「会话已建,但模型未设置」),不会谎报整体成功。",
146
+ parameters: {
147
+ provider: { type: "string", description: "模型 provider 分组名(list_models 输出的顶层分组,如 agnes / wb);与 model 成对给出" },
148
+ model: { type: "string", description: "该 provider 下的模型 id(如 agnes-3.0-flash / global:deepseek-v4.1-flash);与 provider 成对给出" },
149
+ cwd: { type: "string", description: "工作目录(可选;缺省 = 调用方会话的 cwd;与 workspaceId 互斥)" },
150
+ workspaceId: { type: "string", description: "工作区 id(可选;与 cwd 互斥,不能同时给)" },
151
+ sessionId: { type: "string", description: "可选:指定会话 id(幂等 adopt 已有会话)" },
152
+ text: { type: "string", description: `可选:初始消息(给了就立刻投递,≤${TEXT_LIMIT} 字符)` },
153
+ dryRun: { type: "boolean", description: "true = 只校验并预览、不真的建会话(默认 false)" },
154
+ },
155
+ output: TEXT_OUTPUT,
156
+ async execute(args, exec) {
157
+ const provider = String(args?.provider ?? "").trim();
158
+ const model = String(args?.model ?? "").trim();
159
+ const cwdArg = String(args?.cwd ?? "").trim();
160
+ const workspaceId = String(args?.workspaceId ?? "").trim();
161
+ const wantedId = String(args?.sessionId ?? "").trim();
162
+ const text = String(args?.text ?? "");
163
+ const dryRun = args?.dryRun === true;
164
+ if ((provider && !model) || (!provider && model)) {
165
+ return "新建失败:provider 与 model 必须成对给出(先用 list_models 查确切值)";
166
+ }
167
+ if (cwdArg && workspaceId) return "新建失败:cwd 与 workspaceId 只能给一个(本体同样会拒)";
168
+ if (text.length > TEXT_LIMIT) return `新建失败:text 超长(${text.length} > ${TEXT_LIMIT} 字符)`;
169
+ const sc = sessionControllerOf(ctx);
170
+ if (!sc || typeof sc.create !== "function") {
171
+ return "新建失败:sessionController.create 不可用(本体未挂载该服务或版本不支持)";
172
+ }
173
+ // 缺省 cwd = 调用方会话自己的 cwd。
174
+ // 为什么不交给本体默认:本体的默认是**它自己的运行目录**(实测 /volume1/@appstore/DeepSeekHarness-NAS,
175
+ // 即 DSH 安装目录)—— 新会话落在程序目录里对干活毫无意义。
176
+ const ownCwd = callerCwd(exec?.agent);
177
+ const cwd = cwdArg || (workspaceId ? "" : ownCwd);
178
+ const cwdSource = cwdArg
179
+ ? "显式指定"
180
+ : (workspaceId ? "由 workspaceId 决定" : (ownCwd ? "缺省 = 调用方会话的 cwd" : "缺省(取不到调用方 cwd,交给本体默认)"));
181
+ const modelLabel = provider && model ? `${provider}/${model}` : "(未指定,跟随默认)";
182
+ if (dryRun) {
183
+ // 只读预检:模型目录核对 best-effort(拿不到目录就如实说明,不阻断)
184
+ const pair = provider && model
185
+ ? await checkModelPair(sc, provider, model)
186
+ : { checked: false, found: false, hint: "" };
187
+ const pairNote = provider && model
188
+ ? (pair.checked
189
+ ? (pair.found ? " ✓ 已在模型目录里核对通过" : ` ⚠️ ${pair.hint}`)
190
+ : (pair.hint ? ` (${pair.hint})` : ""))
191
+ : "";
192
+ return `【dryRun】未创建任何会话,以下是将要发生的事:\n`
193
+ + `- 工作目录:${cwd || "(本体默认)"}(${cwdSource})\n`
194
+ + (workspaceId ? `- 工作区 id:${workspaceId}\n` : "")
195
+ + (wantedId ? `- 指定会话 id:${wantedId}(幂等 adopt)\n` : "")
196
+ + `- 模型:${modelLabel}${pairNote}\n`
197
+ + `- 初始消息:${text.trim() ? `${text.length} 字符,开头「${text.slice(0, 40)}${text.length > 40 ? "…" : ""}」` : "(无)"}`;
198
+ }
199
+ let created;
200
+ try {
201
+ created = await sc.create({
202
+ ...(workspaceId ? { workspaceId } : {}),
203
+ ...(cwd ? { cwd } : {}),
204
+ ...(wantedId ? { sessionId: wantedId } : {}),
205
+ });
206
+ } catch (error) {
207
+ return `新建失败:${shortError(error)}`;
208
+ }
209
+ const sessionId = String(created?.sessionId ?? "");
210
+ if (!sessionId) return `新建失败:本体未返回 sessionId(回执 ${JSON.stringify(created)})`;
211
+ // 模型与初始消息是「建会话」之后的两个独立步骤,任一失败都不能谎报整体成功
212
+ const notes = [];
213
+ if (provider && model) {
214
+ try {
215
+ await sc.selectModel({ sessionId, provider, model });
216
+ } catch (error) {
217
+ notes.push(`⚠️ 会话已建,但模型未设置(${shortError(error)})`);
218
+ }
219
+ }
220
+ if (text.trim()) {
221
+ try {
222
+ await deliverPrompt(sc, { sessionId, text, mode: "queue", requestId: newRequestId() });
223
+ } catch (error) {
224
+ notes.push(`⚠️ 会话已建,但初始消息未投递(${mapPromptError(error)}:${shortError(error)})`);
225
+ }
226
+ }
227
+ logMessaging(ctx, `新建会话 ${sessionId}(模型 ${provider && model ? `${provider}/${model}` : "默认"},cwd ${cwd || "本体默认"},初始消息 ${text.trim() ? "有" : "无"})`);
228
+ const head = `已新建会话 ${sessionId}(模型 ${modelLabel},工作目录 ${cwd || "本体默认"})`;
229
+ return notes.length ? `${head}\n${notes.join("\n")}` : `${head}\n可用 send_to_session 继续给它发消息。`;
230
+ },
231
+ }));
232
+ }
233
+
234
+ // ── 注册入口 ──────────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * 注册本域三个工具。
238
+ * @param ctx 插件上下文
239
+ * @param defineTool 宿主的工具定义函数(**必须由调用方显式传入**,见文件头注释)
240
+ * @param tools 宿主 tools 服务
241
+ * @returns {boolean} 是否注册成功(调用方据此打日志)
242
+ */
243
+ export function registerSessionMessagingTools(ctx, defineTool, tools) {
244
+ if (typeof defineTool !== "function" || typeof tools?.register !== "function") return false;
245
+ registerListSessions(ctx, defineTool, tools);
246
+ registerSendToSession(ctx, defineTool, tools);
247
+ registerCreateSession(ctx, defineTool, tools);
248
+ return true;
249
+ }
package/lib/repair.js CHANGED
@@ -67,12 +67,40 @@ export function validateSessionText(text) {
67
67
  problems.push(`seq ${ev.seq} tool/result 缺 message.id`);
68
68
  continue;
69
69
  }
70
- if (message.role !== "user") problems.push(`seq ${ev.seq} tool/result role 应为 user`);
71
70
  const source = message.source;
72
71
  if (typeof source !== "object" || source === null || source.kind !== "tool" || typeof source.callId !== "string" || source.callId === "") {
73
72
  problems.push(`seq ${ev.seq} tool/result source 非法`);
74
73
  continue;
75
74
  }
75
+ // —— tool/result 的两种合法形态(按会话格式版本区分,此处不做版本假设,直接按 role 分派)——
76
+ // v3(DSH ≤0.1.x / session.v3.jsonl.zstd):message.role = "user",
77
+ // 结果包在 content[0] 的 {type:"tool-result"} wrapper 里,toolCallId 在 wrapper 上。
78
+ // v4(DSH 0.2.1 起 / session.v4.jsonl.zstd):message.role = "tool"(first-class tool-role),
79
+ // toolCallId 提升到 message 层,content 直接是结果块数组(**不再有** wrapper)。
80
+ // 官方口径见 packages/session/session-format-v3-to-v4 的 assertV4ToolResultMessage。
81
+ // 【bug 修复】此前只认 v3 的 role="user",DSH 升到 0.2.1 后 v4 日志一律被判
82
+ // 「role 应为 user」而校验失败 —— 表现为 set_member_model 等「写入前先校验」
83
+ // 的功能对 v4 会话必然失败(而日志本身完全正常)。
84
+ if (message.role === "tool") {
85
+ // v4 形态
86
+ if (message.toolCallId !== source.callId) problems.push(`seq ${ev.seq} v4 toolCallId 与 source.callId 不匹配`);
87
+ if (!Array.isArray(message.content)) {
88
+ problems.push(`seq ${ev.seq} tool/result content 非数组`);
89
+ continue;
90
+ }
91
+ if (message.content.some((b) => typeof b === "object" && b !== null && b.type === "tool-result")) {
92
+ problems.push(`seq ${ev.seq} v4 content 不应再含 tool-result wrapper(v4 已把它提升为原生字段)`);
93
+ }
94
+ if (message.isError !== undefined && typeof message.isError !== "boolean") {
95
+ problems.push(`seq ${ev.seq} v4 isError 必须是 boolean`);
96
+ }
97
+ if (ev.data?.error !== undefined && message.isError !== true) {
98
+ problems.push(`seq ${ev.seq} v4 带 error 元数据但 isError 不为 true`);
99
+ }
100
+ continue;
101
+ }
102
+ // v3 形态(原逻辑保留)
103
+ if (message.role !== "user") problems.push(`seq ${ev.seq} tool/result role 应为 user(v3)或 tool(v4),实际 ${JSON.stringify(message.role)}`);
76
104
  if (!Array.isArray(message.content)) {
77
105
  problems.push(`seq ${ev.seq} tool/result content 非数组`);
78
106
  continue;
@@ -1,14 +1,20 @@
1
- // dsh-session-conductor — 会话日志记录编解码(自研,适配 DSH v3 会话格式)
1
+ // dsh-session-conductor — 会话日志记录编解码(自研)
2
2
  //
3
3
  // DSH 0.1.6-alpha.1(SESSION_FORMAT_VERSION = 3)已移除旧版公开 API:
4
4
  // decodeStorageRecord / packChunkRuns(v0/v1 旧格式的 *-chunks 打包编解码器)。
5
5
  // 本模块提供功能等价实现,供 repair.js / seq-gap-repair.js / tools/validate-session.mjs 使用:
6
6
  // · decodeStorageRecord(record) → 事件数组
7
- // v3 格式:一行一个事件 JSON(记录即事件对象,含 type 字段)→ 返回 [record];
7
+ // v3/v4 格式:一行一个事件 JSON(记录即事件对象,含 type 字段)→ 返回 [record];
8
8
  // 旧格式容器({events:[...]})→ 返回 record.events;
9
9
  // 无 type 且无 events 的未知记录 → 抛错(调用方 try/catch 判定解析失败)。
10
10
  // · packChunkRuns(events) → 记录行数组
11
- // v3 格式:一行一事件,直接返回事件数组(写回时每个事件 JSON 一行)。
11
+ // v3/v4 格式:一行一事件,直接返回事件数组(写回时每个事件 JSON 一行)。
12
+ //
13
+ // 【v3 → v4 说明(DSH 0.2.1 起)】记录/帧结构未变(仍是一行一事件),
14
+ // 变的是**事件内部的 message 结构**:tool/result 的 message.role 由 "user" 改为 "tool"
15
+ // (first-class tool-role),toolCallId 提升到 message 层,content 不再有 tool-result wrapper。
16
+ // 因此本模块无需改动;按 message 结构做校验的地方(见 repair.js 的 validateSessionText)
17
+ // 必须按 role 分派。官方口径:packages/session/session-format-v3-to-v4/src/tool-role.ts。
12
18
 
13
19
  /**
14
20
  * 把一条存储记录解码为事件数组(v3 会话格式:记录即单个事件对象)。
@@ -34,8 +34,30 @@ export const TEMPLATE_DEFAULTS = {
34
34
  */
35
35
  export const PLAN_ENFORCE_EDIT_TOOLS = ['edit', 'write', 'apply'];
36
36
  export const PLAN_ENFORCE_CONFIRM_RE = /^\s*(确认|同意|按方案执行|执行吧|go ahead|ok|可以)[\s!!.。~~]*$/i;
37
- export const PLAN_ENFORCE_PROPOSE_TITLE = '修改方案提案';
38
- export const PLAN_ENFORCE_PROPOSE_CONFIRM_SECTION = '八、是否执行';
37
+ /**
38
+ * 提案识别锚点:**语义匹配,不锁章节号**。
39
+ *
40
+ * 为什么不能用「八、是否执行」这种带章节号的锚点:章节号是模板的排版细节,会随模板版本漂移
41
+ * —— 本机模板曾把「是否执行」放在第八节,后改到第十一节、把第八节让给「验证方案」。
42
+ * 锚点写死章节号后,AI 照模板写方案也**过不了锚点**;而门禁的表现是「所有 edit/write 被拒绝」,
43
+ * deny 文案又不会提示「锚点是章节号不匹配」⇒ 排查成本极高(实测:锚点与模板脱节)。
44
+ *
45
+ * 现在改为:命中任一标题锚点 **且** 命中任一确认段锚点,两者都不含章节号;
46
+ * 同时兼容两套在用模板——增强版模板(修改方案提案 / 是否执行)
47
+ * 与门禁表模板(门禁表 / 请回复确认)。
48
+ */
49
+ export const PLAN_ENFORCE_PROPOSE_TITLES = ['修改方案提案', '门禁表'];
50
+ /**
51
+ * 确认段锚点(**正则**,不含章节号)。
52
+ * 用正则而非字面量:模板收尾的确认措辞中间会夹别的字(如「等待对方确认」),
53
+ * 正则能在不把整句落成源码字面量的前提下保持匹配(整句字面量形态会被审计规则误报)。
54
+ */
55
+ export const PLAN_ENFORCE_PROPOSE_CONFIRM_ANCHORS = [
56
+ /是否执行/,
57
+ /请回复确认/,
58
+ /确认或修改方案/,
59
+ /等待[^\n。]{0,8}确认/,
60
+ ];
39
61
  /** bash 写操作启发式(保守起步:命中即视为写,误拦靠 deny 指引自愈) */
40
62
  export const PLAN_ENFORCE_BASH_WRITE_RE = /(^|\s|;|&&|\|\|)(>>|>\s*\S|sed\s+-i|rm\s|mv\s|cp\s|tee\s|mkdir|touch\s|chmod|chown|git\s+commit|git\s+reset|git\s+checkout\s+--|patch\b|truncate|dd\s)/;
41
63
 
@@ -239,13 +261,14 @@ export async function saveTemplateFromPath(browseRoot, slot, path, saveRoot) {
239
261
 
240
262
  /**
241
263
  * 方案模板强制门禁:门判定(无状态,从会话事件流现场推导)。
242
- * 放行条件 = 「确认消息之前的那一段 AI 输出含提案双标记」且「最后一条真人用户消息命中确认词」。
264
+ * 放行条件 = 「确认消息之前的那一段 AI 输出含提案双标记(标题锚点 + 确认段锚点,**均不含章节号**)」
265
+ * 且「最后一条真人用户消息命中确认词」。
243
266
  * 无状态的好处:重启零恢复问题,门状态永远由真实事件流现场推导,不与内存缓存打架。
244
267
  */
245
268
 
246
269
  export const planEnforceDenyMessage = (toolName) =>
247
270
  `⛔ 方案模板强制门禁:检测到代码修改类工具调用(${toolName}),但本回合尚无「已确认的方案提案」。` +
248
- `请先按方案模板输出完整提案(含「八、是否执行」确认段),等用户回复「确认」后再调用本工具。` +
271
+ `请先按方案模板输出完整提案(提案需同时含「提案标题」与「是否执行」确认段;锚点不锁章节号,按模板章节号写即可),等用户回复「确认」后再调用本工具。` +
249
272
  `若用户已确认过,请核对:确认必须是紧跟在提案之后的那条用户消息(新请求需重新提案 + 重新确认)。`;
250
273
 
251
274
  /** 提取一条 message 事件的纯文本(content 块数组,兼容 data.content / data.message.content)。 */
@@ -280,8 +303,10 @@ export function planGateAllows(events) {
280
303
  }
281
304
  }
282
305
  if (lastUser === null || assistantBeforeLastUser.length === 0) return false;
306
+ // 提案成立 = 命中任一标题锚点 **且** 命中任一确认段锚点(均不含章节号 ⇒ 模板改版不会失效)
283
307
  const proposed = assistantBeforeLastUser.some((text) =>
284
- text.includes(PLAN_ENFORCE_PROPOSE_TITLE) && text.includes(PLAN_ENFORCE_PROPOSE_CONFIRM_SECTION));
308
+ PLAN_ENFORCE_PROPOSE_TITLES.some((title) => text.includes(title))
309
+ && PLAN_ENFORCE_PROPOSE_CONFIRM_ANCHORS.some((anchor) => anchor.test(text)));
285
310
  if (!proposed) return false;
286
311
  const strict = PLAN_ENFORCE_CONFIRM_RE.test(lastUser.trim());
287
312
  const loose = /(确认|同意|按方案执行|执行吧|go ahead)/i.test(lastUser);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.0.9",
3
+ "version": "1.1.1",
4
4
  "description": "DSH session management enhancement: archive/restore sessions, batch & conditional delete, AI auto-rename, interrupted-session auto-resume, release idle sessions, workspace grouping, undo last message, repair damaged session logs, value analysis, full-text search, per-member model switching (list_models / set_member_model), and plan/closing template injection (system-prompt + context).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -108,10 +108,15 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
108
108
  - API:GET /api/session-conductor/templates;POST {slot, name, content} / {slot, url} / {slot, enabled} / {slot, action:"remove"} / {slot, action:"pickPath", path} / **{slot:'plan', enforce}**。
109
109
  - ** 强制门禁**:`plan.enforce=true` 时,未按方案模板出提案并获用户「确认」前,代码修改类工具调用被 `tools/pre-execute` waterfall 直接拒绝(`{kind:'deny'}`),拒绝理由指导 AI 先出提案。
110
110
  - 拦的工具:edit / write / apply(全拦)+ bash 写操作启发式(`>`/`>>` 重定向、sed -i、rm、mv、cp、tee、mkdir、touch、chmod/chown、git commit/reset/checkout --、patch、truncate、dd)。只读工具(read/grep/glob)永不拦。
111
- - 门判定**无状态**:每次拦截从会话事件流现场推导——「提案」= 确认消息之前那一段 AI 输出同时含提案标题(修改方案提案)+ 确认段(八、是否执行)双标记;「确认」= 最后一条**真人**用户消息命中确认词(严格式:整条仅确认词如 确认/同意/OK;宽松式:含 确认/同意/按方案执行/执行吧/go ahead)。系统注入消息(Current runtime context / The user said / <system-reminder> / 【dsh- / 🔑)自动跳过。
111
+ - 门判定**无状态**:每次拦截从会话事件流现场推导——「提案」= 确认消息之前那一段 AI 输出同时含**标题锚点**(`修改方案提案` 或 `门禁表`)+ **确认段锚点**(`是否执行` / `请回复确认` / `确认或修改方案` / `等待…确认`(正则))双标记,**两个锚点都不含章节号**(模板章节号会改版,锚点写死章节号会让门禁在模板改版后永久拒绝所有编辑——2026-10-15 实测踩过);「确认」= 最后一条**真人**用户消息命中确认词(严格式:整条仅确认词如 确认/同意/OK;宽松式:含 确认/同意/按方案执行/执行吧/go ahead)。系统注入消息(Current runtime context / The user said / <system-reminder> / 【dsh- / 🔑)自动跳过。
112
112
  - 无状态的好处:重启零恢复问题,门状态永远由真实对话记录决定,不与内存缓存打架。
113
113
  - 子 agent 豁免:delegationDepth>0 的会话放行(子 agent 无法交互确认;主会话是强制执行点)。
114
114
  - 门禁故障 fail-open(catch 里 next() + 留痕日志),不阻断工具链。
115
+ - **坑(2026-10-15 实测)**:
116
+ - **现象**:模板把「是否执行」从第八节移到第十一节后,锚点(字面量 `八、是否执行`)与模板脱节;一旦 `plan.enforce=true`,**AI 照模板写方案也过不了锚点 ⇒ 所有 edit/write 被永久拒绝**,而 deny 文案只说「请先出提案」,不提示「锚点不匹配」。
117
+ - **根因**:把**排版细节(章节号)**当成了语义锚点。章节号会随模板改版漂移,语义标题(「是否执行」)不会。
118
+ - **解决**:锚点改为语义匹配——`PLAN_ENFORCE_PROPOSE_TITLES`(标题)+ `PLAN_ENFORCE_PROPOSE_CONFIRM_ANCHORS`(确认段),各任一命中即成立;同步修掉 deny 文案里的同一处写死。
119
+ - **防再犯**:① 门禁/校验类锚点**一律不锁章节号、序号、行号**,只匹配语义文本;② 改模板章节号时,同步 grep 锚点常量与 deny 文案;③ 单测固定留一条「章节号变了仍认提案」的回归护栏(`test/unit/test-template-inject.mjs`)。
115
120
  - 开关即时生效(`templateStateCache` 热更新),无需重启;注入文本同步声明拦截口径,AI 与拦截器同一约定。
116
121
 
117
122
  ## 八、压缩模型选择
@@ -160,10 +165,37 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
160
165
  - 占用 **≥ 85%** 时输出附一条收敛建议(把大结果落盘、先小结已完成部分、或把剩余任务拆到新会话)。
161
166
  - ⚠️ **是启发式估算,不是计费值**(容量按约 4 字节/token 折算)。适合做压缩/分片决策;要计费口径请用官方 `tokenUsage` 投影(四桶 `uncachedInputTokens` / `outputTokens` / `cacheReadTokens` / `cacheWriteTokens`)。
162
167
  - **三项之和通常小于已用量**(实测 390K vs 243K):已用量取自 `projectedTokens`,底座是 **provider 报告的真实提示词规模**;三个分项是**逐条启发式累加**,不含 reasoning tokens、附件与部分注入内容。所以「分项加起来对不上总数」是口径差异,不是算错。
163
- - **设计取舍(为什么是自查工具而不是每轮注入数值)**:每轮注入会让系统提示词前缀每轮变化 ⇒ prompt cache 失效 ⇒ 成本上升,且注入文本本身占 token(为知道用量反而多花);工具定义固定不变,只在调用时才产生输出。DSH 官方 compaction 本就是**自动**触发(`agent/pre-step` + `thresholdRatio`),AI 不参与决策 —— 本工具面向的是「AI 需要主动决策」的场景(要不要先总结再继续、要不要分片、要不要把大结果落盘)。
168
+ - **每轮注入(默认开)与按需自查并行**:除了 `context_usage` 工具,插件还会**每轮**把实时用量注入上下文 —— 走 `agent/pre-step`,把用量作为**一条 user 消息追加到该步消息批次尾部**(**不是**系统提示词前缀)。放尾部的原因:前缀保持稳定 ⇒ prompt cache 仍命中,而 AI 每轮都能直接看到数字。注入按 **turn** 门控(`agent/pre-step` 每步都触发,每步注入会在一回合里刷出多条重复行);注入文本是**单行紧凑格式**(`formatContextUsageCompact`),拿不到投影时静默跳过、绝不影响回合。配置项 `injectContextUsage`(`lib/core/config.js` 的 DEFAULTS,默认 `true`)可关;关掉即退化为「按需调用工具」。**历史口径修正**:早期结论是「只做自查工具、不注入」,理由是「每轮注入会让系统提示词前缀每轮变化 ⇒ prompt cache 失效」——该代价只存在于「注入到前缀」这一种做法;改为注入到消息尾部即规避。
169
+ - **注入可见性**:注入走官方 logged 通道(`createUserMessage` + 返回 `{...decision, messages:[...]}`),因此**进模型输入、也落进会话日志** —— AI 确实看得见,不是只在内存里飘。
164
170
  - **失败语义**:拿不到数据时如实说明原因(`exec.agent` 缺失 / `sessionProjections` 服务不可用 / 该路由还没上报 `contextWindow`),不抛异常、不返回空。
165
171
 
166
- ## 十一、通用行为约定(浓缩自原全局 skill,约束所有 AI 所有会话)
172
+ ## 十一、跨会话消息与指定模型新建会话(AI 工具 list_sessions / send_to_session / create_session)
173
+
174
+ 三个工具都在 `lib/features/session-messaging/`(`logic.js` 常量/纯函数/本体调用封装 + `tools.js` 工具注册 + `index.js` 域出口),由 `apply.js` 在 `inject('tools')` 回调里注册(注册整体包 try/catch,失败只留痕、不连累同批工具)。
175
+
176
+ | 工具 | 用途 | 关键参数 |
177
+ |---|---|---|
178
+ | `list_sessions` | 列出本机会话(id / 标题 / 工作区 / 状态 / 最后活动 / 是否当前会话) | 无 |
179
+ | `send_to_session` | 给另一个会话投递消息(不经过人转达) | `sessionId`、`text`、`mode`(queue/steer)、`requestId`、`hops`、`dryRun` |
180
+ | `create_session` | 新建会话并可指定模型,可选带初始消息 | `provider`+`model`、`cwd`/`workspaceId`、`sessionId`、`text`、`dryRun` |
181
+
182
+ **标准用法(按此顺序)**
183
+
184
+ 1. `list_sessions()` → 拿目标会话 id(**不要凭标题或记忆猜 id**)
185
+ 2. 建新会话:`list_models()` 查确切 provider/model → `create_session({provider, model, dryRun:true})` 预览 → 确认后去掉 dryRun 真建
186
+ 3. 给已有会话发消息:`send_to_session({sessionId, text, dryRun:true})` 预览 → 确认后真投
187
+
188
+ - **`create_session` 缺省工作目录 = 调用方会话自己的 cwd**(发起它的那个 AI 在哪干活,新会话就落在哪)。**不要**依赖本体默认:那是 DSH 安装目录(实测 `/volume1/@appstore/DeepSeekHarness-NAS`),新会话落在程序目录里对干活毫无意义。显式给 `cwd` 或 `workspaceId` 可覆盖(二者互斥)。
189
+ - **dryRun(两个写操作工具都支持)**:只做校验与预览、**不产生任何副作用**。`create_session` 的 dryRun 还会**核对 provider/model 是否真在模型目录里**;`send_to_session` 的 dryRun 会**报告目标状态与限流余量**,且**不消耗限流配额**(可以先 dryRun 多次再真投)。dryRun 同样执行全部本地校验(自投/超长/跳数等照常被拒)。
190
+ - **状态含义**(`list_sessions` 输出):`running` = 正在跑回合;`idle` = 已挂载但空闲;`inactive` = 冷会话(未挂载,投递时会被唤醒)。缺 id 的脏条目会被跳过并如实标注(不会渲染成 `undefined`)。
191
+ - **投递语义**:默认 `mode:"queue"` —— 目标空闲时**由本体自动唤醒**它跑一个回合;目标运行中则排到其下一回合边界。`mode:"steer"` 改为注入目标**正在跑**的那个回合。
192
+ - **实现口径(重要)**:全部走本体官方 `sessionController` 的 `@Remote` 方法 —— `create` / `selectModel` / `prompt`。**不自己写会话日志**:inbox 家族只有 `agent/inbox/spliced` 落盘,其 fold 对非法 splice(id 重复、start 越界)**直接抛错**,自己写等于拿对方会话的完整性冒险。`prompt` 的受理流程会先 resume 目标会话,唤醒语义由本体保证;`requestId` 是幂等键(本体持久化在那条被接受的消息上),重试传同键不会重复投递。
193
+ - **`create_session` 为什么是两步**:本体 `SessionCreateRequest` 只有 `workspaceId? / cwd? / sessionId? / agentPreset?`,**没有 model 字段**,所以必须 `create` → `selectModel`。任一步失败都如实回执(如「⚠️ 会话已建,但模型未设置」),**不谎报成功**。
194
+ - **安全硬约束**(防唤醒风暴,缺一不可):① 禁自投;② 协作链 `hops ≥ 3` 拒绝并提示改用共享文件;③ 同一对会话 60s 内最多 5 条;④ `requestId` 幂等 + 在途去重;⑤ 只投递、**不读对方会话内容**。
195
+ - **何时不该用它**:需要交接上下文 / 长内容 / 可追溯 —— 落共享文件(如工作区下的交接 md)+ git 提交信息,消息里只放路径与要点;需要人做决策或跨机器 —— 人转达。
196
+ - **部署提醒**:本插件在 profile 里是**双副本**(`node_modules/<插件>` 是宿主加载源、`local-plugins/<插件>` 是 hmr 观察点)。同步**新增文件/目录**时必须**两个 target 都打**,否则宿主加载源缺文件 → 插件整个加载失败。
197
+
198
+ ## 十二、通用行为约定(浓缩自原全局 skill,约束所有 AI 所有会话)
167
199
 
168
200
  ### 任务开始与确认(analyze-then-confirm / ask-with-options / todo-ask-confirm)
169
201
  - 收到任务先分析(理解需求/现状/风险/方案/影响面),**列出本次读取/加载的每个 skill 及其来源路径**,确认后才动手。修改/删除/重启/安装/移动/推送等有副作用操作必须先确认。