dsh-session-conductor 1.3.10 → 1.3.11

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
@@ -26,6 +26,7 @@
26
26
  | 记忆取回 | 检测到上下文用量**明显下降**(默认 ≥10%,可配)即判定「上下文疑似丢失」——压缩、截断、实例重启后快照未加载**都算**(比只盯压缩更全)。此时自动把最近 **3 轮(可配)** 的**用户消息文本 + 该轮 AI 的第一条回应**(优先取当时的 reasoning)重新注入,让 AI 立刻恢复状态。实现要点:按 zstd 帧**流式**读日志(常量内存)、**只认真实用户消息**(排除插件自身注入,防反馈环)、同回合连发**合并成一轮**、注入总长与单条都有上限。配置项 `retrieveMemory*`(默认开)。**另提供 AI 工具 `recall_memory`**(参数 `rounds` = 取几轮,默认取配置、上限 50;参数 `session` = 目标会话:完整 id / 唯一前缀 / 标题子串,缺省或留空即取自身)让 AI 自己按需回溯,**也能取其它会话**(跨会话排查时在会话内直接看到对方上下文;命中多个候选会给出候选清单,不随便挑一个) | 每轮注入(`agent/pre-step`)+ AI 工具 `recall_memory` |
27
27
  | 上下文用量自查 | AI 按需查自己会话的上下文占用(百分比、已用/容量 tokens)与构成(系统提示词/工具定义/消息文本),占用达 85% 时附收敛建议。数据与界面圆环同源(官方 `contextPressure` / `contextBreakdown` 投影),只读、无参数。**另:每轮把实时用量注入消息尾部**(AI 每轮都能直接看到数字;注入在尾部而非系统提示词前缀 ⇒ 前缀稳定、prompt cache 仍命中;按 turn 门控,一回合只注一次;配置 `injectContextUsage` 可关) | AI 工具 `context_usage` + 每轮注入 |
28
28
  | 跨会话消息 | AI 直接给另一个会话发消息(不经过人转达):先 `list_sessions` 挑目标,再 `send_to_session` 投递;目标空闲时**由本体自动唤醒**它跑一个回合,运行中则排到其下一回合边界(`mode:"steer"` 改为注入当前回合)。全部走本体官方 `prompt` API(幂等 `requestId`、不自己写会话日志)。**支持 `dryRun` 预览**(报告目标状态与限流余量,不投递、不消耗配额)。`list_sessions` 支持 **`query` 子串过滤 + `limit`/`offset` 分页**(默认只给 20 条,避免整表塞进调用方上下文)。**索引快路径 + 后台化**:`fromIndex=true` 直接读落盘索引(秒回、不重建,不含 live 状态与归档态),默认刷新路径先等 3 秒拿热缓存结果、超时就把**同一次在途构建**交给宿主后台 job 并立刻返回 jobId(冷缓存全量构建实测 226 秒 / 201 会话 / 1.5 GB 日志,热缓存 47 毫秒;job 注册失败会同步降级并带回原因)。硬约束:禁自投、协作链跳数 ≥3 拒绝、同一对会话 60s 内最多 5 条 | AI 工具 `list_sessions` / `send_to_session` |
29
+ | 创建成员并选模型 | 一条命令完成「**创建团队成员 + 让第一轮真实任务就用指定模型**」。宿主的 `spawn_teammate` 参数里没有 provider/model(服务层 `SpawnTeammateRequest` 也只有 provider,模型来自 agent-team 插件的 freshProvider/forkProvider 配置),直接创建的话首轮一定用默认模型把真实任务跑掉。本工具因此拆成四步:① 用**占位任务**创建(只消耗一条极短提示)② **立刻中断**把这一轮停掉 ③ **等它真正空闲**(用宿主 `waitForChange` 等状态变化,不睡固定时间;否则取消信号会串到下一轮)④ 写 `model/selection`(与 `set_member_model` 同一条链路,默认不碰任何 @Remote)然后**投递真实任务**。参数里 **`provider`/`model` 是模型 provider**(agnes / wb / sa6400 …);创建成员另需**子代理 provider**(`ctx.subagents` 注册表,通常 `spawn`),默认自动取第一个,也可用可选参数 `subagentProvider` 指定 —— 两者是不同命名空间,混用会报 `no subagent provider registered`。设模型失败时**不投递**(否则等于用默认模型白跑),投递失败也如实回执。要彻底「创建前定模型」只能改宿主 agent-team(把 model 加进 request),宿主升级会被覆盖 | AI 工具 `create_teammate_with_model` |
29
30
  | 指定模型新建会话 | AI 新建会话并可**直接指定 provider/model**(建完即切到该模型),可选带一条初始消息让它立刻开始干活。**缺省工作目录 = 调用方会话自己的 cwd**(不落在 DSH 安装目录里)。本体 `create` 本身不带模型,所以是 `create` → `selectModel` 两步;任一步失败会如实回执(如「会话已建,但模型未设置」),不谎报成功。**支持 `dryRun` 预览**(含模型是否存在于目录的核对),不建会话 | AI 工具 `create_session` |
30
31
 
31
32
  ## 界面预览
@@ -159,7 +160,7 @@ preview { action: "serve", file: "<插件根>/assets/preview.html", backend: "h
159
160
 
160
161
  - **`services` 必须传**(0.1.11 新增):本插件 `apply()` 依赖 `storageDomain` 等宿主服务,不注入时 `/templates` 会 500、页面显示「storageDomain 不可用」;注入 `test/api/helpers/fake-services.mjs` 后 `/templates` 返回 200 真实数据(已实测)。
161
162
  - **autoMount**(0.1.7 起):无 spec 时 mock 宿主 `__ModuleLoader__` / `require` / `ctx`(slots / locale / effect),调插件 `apply(ctx)`,把 `slots.register(def, component)` 捕获到的组件 `createRoot().render()` 到 `#root`。**限制**:组件若依赖 `useSettings` / `useSession` 这类宿主 hooks 会渲染失败(页面会显示错误与已捕获插槽清单,不白屏);要 100% 还原仍需 spec 提供 mock。locale 取插件自己 `ctx.locale.register` 的字典,键名不一致会回退成 key。
162
- - `gen` 默认产物是 `<插件根>/assets/preview.html`(会写进仓库)⇒ 不想入库就传 `out`(如 `out: "/tmp/sc-preview.html"`),并让 `serve` 的 `file` 指向同一路径。本仓库已把该产物加进 `.gitignore`。
163
+ - `gen` 默认产物是 `<插件根>/assets/preview.html`(会写进仓库)⇒ 不想入库就传 `out`(如 `out: "<临时目录>/preview.html"`),并让 `serve` 的 `file` 指向同一路径。本仓库已把该产物加进 `.gitignore`。
163
164
  - `serve` 默认端口 **31188**,显式传 `port` 才用你给的;`backend` 可指向 minihost 的端口(`/api/*` 反代到模拟后端),也可指向**真实 DSH 后端**。
164
165
  - 显式传 `spec` 但文件不存在会**直接报错**(不静默降级)—— 故意的,避免指错文件还生成一个看不懂的页面。
165
166
  - **截图**(README 配图)走 **browser 插件的 `browser_screenshot`**;「自包含快照」形态不做(理由见 `dsh-useful-tools` 的 `docs/NOT-DONE.md`)。
package/lib/apply.js CHANGED
@@ -55,6 +55,7 @@ import { getValueVerdict, putValueVerdict, saveValueIndexNow } from './features/
55
55
  // 成员模型:本文件用到 findTargetAgent / switchAgentModel / getMemberModelOverride /
56
56
  // applyModelOverride / validateModelPair(拆出 apply.js 时漏了这一行 import,
57
57
  // 运行到相关代码路径会 ReferenceError: getMemberModelOverride is not defined)
58
+ import { createTeammateWithModel, renderCreateTeammateResult } from "./features/member-model/create-teammate.js";
58
59
  import { findTargetAgent, switchAgentModel, getMemberModelOverride, applyModelOverride, validateModelPair,
59
60
  registerSwitchWatch, noteRequestModel, takeSwitchOutcomes, readLastRequestModel,
60
61
  registerModelSwitchCommand } from './features/member-model/index.js';
@@ -221,6 +222,18 @@ export async function apply(ctx, config = {}) {
221
222
  ctx.inject(["webServer"], (wctx) => {
222
223
  const webServer = wctx.get("webServer");
223
224
 
225
+ // i18n 语言包:**只读一次并缓存**。原先每个请求都同步读两个 JSON(约 33 KB)并解析,
226
+ // 既阻塞事件循环(审计 io-risk 高风险),又纯属浪费 —— 语言包在一个进程生命周期内不会变。
227
+ let i18nCache = null;
228
+ const loadI18n = () => {
229
+ if (i18nCache !== null) return i18nCache;
230
+ i18nCache = {
231
+ zh: JSON.parse(readFileSync(new URL("./i18n/zh.json", import.meta.url), "utf8")),
232
+ en: JSON.parse(readFileSync(new URL("./i18n/en.json", import.meta.url), "utf8")),
233
+ };
234
+ return i18nCache;
235
+ };
236
+
224
237
  // ---------- GET /api/session-conductor/i18n ----------
225
238
  // 语言包外置 lib/i18n/{zh,en}.json(单份权威,改文案无需重打包 client bundle):
226
239
  // 浏览器端 ModuleLoader 的 require 不支持相对路径 JSON(require("./i18n/*.json") 抛 missed-the-module-table),
@@ -233,8 +246,7 @@ export async function apply(ctx, config = {}) {
233
246
  if ((req.method ?? "GET") !== "GET") {
234
247
  return send(res, 405, { ok: false, error: { code: "METHOD", message: "Method Not Allowed" } });
235
248
  }
236
- const zh = JSON.parse(readFileSync(new URL("./i18n/zh.json", import.meta.url), "utf8"));
237
- const en = JSON.parse(readFileSync(new URL("./i18n/en.json", import.meta.url), "utf8"));
249
+ const { zh, en } = loadI18n();
238
250
  return send(res, 200, { ok: true, zh, en });
239
251
  } catch (error) {
240
252
  return send(res, 500, { ok: false, error: { code: "INTERNAL", message: String(error?.message ?? error) } });
@@ -1512,6 +1524,35 @@ export async function apply(ctx, config = {}) {
1512
1524
  }
1513
1525
  }
1514
1526
  }));
1527
+ /**
1528
+ * 切换后的「查证快照」:最近一次实际请求 + 最近一次切换意图 + 一行判定。
1529
+ * 抽出来是因为这段有 4 个分支,塞在工具 execute 里把圈复杂度顶到 18;
1530
+ * 判定只认「请求时真实使用的模型」(request/header),不信注入的提示语。
1531
+ * @returns {Promise<{snap: string, verdict: string}>}
1532
+ */
1533
+ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
1534
+ const startedAt = Date.now();
1535
+ const { lastRequest, lastSelection, scanned } = await readLastRequestModel(sessionId);
1536
+ // 读整份日志取「最近一次请求」,大日志上这一步本身就要时间
1537
+ log(ctx, `切换查证快照耗时 ${Date.now() - startedAt}ms(命中请求记录 ${scanned ?? 0} 条)`);
1538
+ if (!lastRequest) {
1539
+ return {
1540
+ snap: "【查证·最近一次实际请求】会话日志里还没有 request/header 记录(该成员可能从未运行过)",
1541
+ verdict: "【查证·判定】⏳ 待该成员首次运行后生效",
1542
+ };
1543
+ }
1544
+ const when = lastRequest.time ? `(${new Date(lastRequest.time).toLocaleString("zh-CN")})` : "";
1545
+ const snap = `【查证·最近一次实际请求】${lastRequest.provider}/${lastRequest.model}${when}`
1546
+ + (lastSelection ? `\n【查证·最近一次切换意图】${lastSelection.provider}/${lastSelection.model}` : "");
1547
+ const alreadyNew = lastRequest.provider === provider && lastRequest.model === model;
1548
+ return {
1549
+ snap,
1550
+ verdict: alreadyNew
1551
+ ? "【查证·判定】✅ 该成员最近的请求已经是新模型"
1552
+ : "【查证·判定】⏳ 待下一回合生效:最近一次请求是切换前发出的(属正常,不代表失败)",
1553
+ };
1554
+ }
1555
+
1515
1556
  tools.register(defineTool({
1516
1557
  name: "set_member_model",
1517
1558
  description: "给指定成员/子代理切换模型(只影响该成员的会话,不动其他成员)。target 传成员名/sessionId/标题关键字;provider 是 list_models 里的分组名(如 agnes / wb),model 是该分组下的模型 id(如 global:deepseek-v4.1-flash)。切换前会校验组合是否存在,避免切到无效组合导致回合失败。",
@@ -1550,18 +1591,7 @@ export async function apply(ctx, config = {}) {
1550
1591
  let snap = "";
1551
1592
  let verdict = "";
1552
1593
  try {
1553
- const { lastRequest, lastSelection } = await readLastRequestModel(found.sessionId);
1554
- if (lastRequest) {
1555
- snap = `【查证·最近一次实际请求】${lastRequest.provider}/${lastRequest.model}`
1556
- + (lastRequest.time ? `(${new Date(lastRequest.time).toLocaleString("zh-CN")})` : "");
1557
- verdict = (lastRequest.provider === provider && lastRequest.model === model)
1558
- ? "【查证·判定】✅ 该成员最近的请求已经是新模型"
1559
- : "【查证·判定】⏳ 待下一回合生效:最近一次请求是切换前发出的(属正常,不代表失败)";
1560
- } else {
1561
- snap = "【查证·最近一次实际请求】会话日志里还没有 request/header 记录(该成员可能从未运行过)";
1562
- verdict = "【查证·判定】⏳ 待该成员首次运行后生效";
1563
- }
1564
- if (lastSelection) snap += `\n【查证·最近一次切换意图】${lastSelection.provider}/${lastSelection.model}`;
1594
+ ({ snap, verdict } = await describeSwitchSnapshot(ctx, found.sessionId, provider, model));
1565
1595
  } catch (error) {
1566
1596
  snap = `【查证·快照】读取会话日志失败:${String(error?.message ?? error)}`;
1567
1597
  }
@@ -1570,6 +1600,8 @@ export async function apply(ctx, config = {}) {
1570
1600
  const cmd = `node ${decodeURIComponent(scriptUrl.pathname)} ${found.sessionId}`;
1571
1601
  return [
1572
1602
  `已把成员 ${memberLabel}(${found.matched} 匹配)切到 ${provider}/${model};生效方式:${switchResult.via}`,
1603
+ // 组合校验默认关闭(避免 @Remote 卡顿);跳过时如实告知,别让人以为「拼错也会被拦住」
1604
+ pair.note ? `【校验】${pair.note}` : "",
1573
1605
  snap,
1574
1606
  verdict,
1575
1607
  "【查证·任务】已登记(不阻塞):该成员下次发起请求时按事件判定,结果会在下一次 step 注入给你",
@@ -1577,6 +1609,37 @@ export async function apply(ctx, config = {}) {
1577
1609
  ].filter(Boolean).join("\n");
1578
1610
  }
1579
1611
  }));
1612
+ // 创建成员并同时指定模型:宿主 spawn_teammate 没有 provider/model 参数,这里补齐。
1613
+ // 形状必须与其它工具一致(render 放在 output 里);且单独包 try/catch——
1614
+ // 这个回调里任何一个 defineTool 抛错都会让整个 inject("tools") 中止,四个工具一起消失。
1615
+ try {
1616
+ tools.register(defineTool({
1617
+ name: "create_teammate_with_model",
1618
+ description: "创建团队成员,并让第一轮真实任务就用指定模型(宿主 spawn_teammate 没有 provider/model 参数,此工具补齐)。内部流程:占位任务创建 → 立刻中断(避免用默认模型把首轮跑掉)→ 写 model/selection → 投递真实任务。只能由 Team Lead 调用。",
1619
+ parameters: {
1620
+ name: { type: "string", required: true, description: "成员名(lower-kebab-case,唯一)" },
1621
+ description: { type: "string", required: true, description: "职责简述" },
1622
+ prompt: { type: "string", required: true, description: "该成员的初始任务全文" },
1623
+ provider: { type: "string", required: true, description: "模型 provider 分组名(agnes / wb / d …;先用 list_models 查确切值)" },
1624
+ model: { type: "string", required: true, description: "模型 id(先用 list_models 查确切值)" },
1625
+ subagentProvider: { type: "string", description: "子代理 provider(与模型 provider 不是一回事;默认取 ctx.subagents.list() 第一个,通常就是 spawn)。一般不用填" },
1626
+ context: { type: "string", description: "上下文来源:fresh(默认,不继承)或 fork(继承调用方已有回合)" },
1627
+ },
1628
+ output: {
1629
+ schema: { type: "string" },
1630
+ render(args, value) {
1631
+ return [{ type: "text", text: String(value) }];
1632
+ },
1633
+ },
1634
+ async execute(args, exec) {
1635
+ const result = await createTeammateWithModel(ctx, exec, args);
1636
+ return renderCreateTeammateResult(args, result);
1637
+ },
1638
+ }));
1639
+ } catch (error) {
1640
+ log(ctx, `create_teammate_with_model 注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
1641
+ }
1642
+
1580
1643
  // 上下文用量自查工具:让 AI 自己按需查占用与构成,而不是每轮把数值注入系统提示词
1581
1644
  // (每轮注入会让系统提示词前缀每轮变化 → prompt cache 失效 → 成本上升)。
1582
1645
  if (registerContextUsageTool(ctx, defineTool, tools)) {
@@ -36,19 +36,26 @@ window.__ModuleLoader__.load({
36
36
  }
37
37
 
38
38
  /** 从会话节点里提取当前回合(正在执行)的用户消息文本。 */
39
+ /** 这些节点类型意味着「已经轮到 AI / 工具」,再往前就不该算「正在执行的用户消息」。 */
40
+ const PROCESSING_STOP_KINDS = new Set(["assistant", "assistant-step", "tool-result", "command"]);
41
+
42
+ /** 取一个用户节点的纯文本:优先 text 字段,其次 content 里的 text 块。 */
43
+ function userNodeText(node) {
44
+ if (typeof node?.text === "string") return node.text;
45
+ if (!Array.isArray(node?.content)) return "";
46
+ return node.content.filter((block) => block?.type === "text").map((block) => block.text).join(" ");
47
+ }
48
+
49
+ /** 从末尾往前找「当前正在执行的那条用户消息」的文本。 */
39
50
  function activeUserText(nodes) {
40
51
  if (!Array.isArray(nodes)) return "";
41
- for (let i = nodes.length - 1; i >= 0; i--) {
42
- const n = nodes[i];
43
- if (n === null || n === undefined) continue;
44
- const kind = n.kind;
45
- if (kind === "assistant" || kind === "assistant-step" || kind === "tool-result" || kind === "command") break;
46
- if (kind === "user") {
47
- const text = typeof n.text === "string" ? n.text
48
- : Array.isArray(n.content) ? n.content.filter((b) => b?.type === "text").map((b) => b.text).join(" ")
49
- : "";
50
- if (text) return text;
51
- }
52
+ for (let i = nodes.length - 1; i >= 0; i -= 1) {
53
+ const node = nodes[i];
54
+ if (!node) continue;
55
+ if (PROCESSING_STOP_KINDS.has(node.kind)) break;
56
+ if (node.kind !== "user") continue;
57
+ const text = userNodeText(node);
58
+ if (text) return text;
52
59
  }
53
60
  return "";
54
61
  }
package/lib/client.js CHANGED
@@ -2560,19 +2560,26 @@ window.__ModuleLoader__.load({
2560
2560
  }
2561
2561
 
2562
2562
  /** 从会话节点里提取当前回合(正在执行)的用户消息文本。 */
2563
+ /** 这些节点类型意味着「已经轮到 AI / 工具」,再往前就不该算「正在执行的用户消息」。 */
2564
+ const PROCESSING_STOP_KINDS = new Set(["assistant", "assistant-step", "tool-result", "command"]);
2565
+
2566
+ /** 取一个用户节点的纯文本:优先 text 字段,其次 content 里的 text 块。 */
2567
+ function userNodeText(node) {
2568
+ if (typeof node?.text === "string") return node.text;
2569
+ if (!Array.isArray(node?.content)) return "";
2570
+ return node.content.filter((block) => block?.type === "text").map((block) => block.text).join(" ");
2571
+ }
2572
+
2573
+ /** 从末尾往前找「当前正在执行的那条用户消息」的文本。 */
2563
2574
  function activeUserText(nodes) {
2564
2575
  if (!Array.isArray(nodes)) return "";
2565
- for (let i = nodes.length - 1; i >= 0; i--) {
2566
- const n = nodes[i];
2567
- if (n === null || n === undefined) continue;
2568
- const kind = n.kind;
2569
- if (kind === "assistant" || kind === "assistant-step" || kind === "tool-result" || kind === "command") break;
2570
- if (kind === "user") {
2571
- const text = typeof n.text === "string" ? n.text
2572
- : Array.isArray(n.content) ? n.content.filter((b) => b?.type === "text").map((b) => b.text).join(" ")
2573
- : "";
2574
- if (text) return text;
2575
- }
2576
+ for (let i = nodes.length - 1; i >= 0; i -= 1) {
2577
+ const node = nodes[i];
2578
+ if (!node) continue;
2579
+ if (PROCESSING_STOP_KINDS.has(node.kind)) break;
2580
+ if (node.kind !== "user") continue;
2581
+ const text = userNodeText(node);
2582
+ if (text) return text;
2576
2583
  }
2577
2584
  return "";
2578
2585
  }
@@ -6,11 +6,16 @@
6
6
 
7
7
  import path from "node:path";
8
8
  import { homedir } from "node:os";
9
- import { readFile, mkdir } from "node:fs/promises";
10
- import { writeFileSync, renameSync, statSync } from "node:fs";
9
+ import { readFile, mkdir, writeFile, rename } from "node:fs/promises";
10
+ import { statSync } from "node:fs";
11
11
  import { log } from "../shared/log.js";
12
12
 
13
13
  /** 插件默认配置(面板可改;cfg 的初值来源)。 */
14
+ /** 记忆取回的默认参数(提出来是为了让「0.10 / 4000 / 800」这些数字有名字,读代码不必猜含义)。 */
15
+ const DEFAULT_MEMORY_MIN_DROP_RATIO = 0.10; // 用量下降比例达到 10% 视为「上下文疑似丢失」
16
+ const DEFAULT_MEMORY_MAX_CHARS = 4000; // 注入总长上限(字符)
17
+ const DEFAULT_MEMORY_MAX_CHARS_PER_ITEM = 800; // 单条上限(字符)
18
+
14
19
  export const DEFAULTS = {
15
20
  enabled: true, // 自动续跑总开关
16
21
  defaultAutoContinue: false, // 单会话默认关闭,面板可逐会话显式开启
@@ -27,14 +32,22 @@ export const DEFAULTS = {
27
32
  // ── 记忆取回(memory-recall):检测到上下文疑似丢失时,把最近几轮的用户消息文本 + AI 首条回应重新注入 ──
28
33
  retrieveMemory: true, // 总开关(默认开)
29
34
  retrieveMemoryRounds: 3, // 取回最近几轮
30
- retrieveMemoryMinDropRatio: 0.10, // 触发阈值:已用量下降比例(0.10 = 10%)
35
+ retrieveMemoryMinDropRatio: DEFAULT_MEMORY_MIN_DROP_RATIO, // 触发阈值:已用量下降比例
31
36
  retrieveMemoryMinDropTokens: 0, // 触发阈值:下降绝对量(0 = 不限;两者满足其一即触发)
32
- retrieveMemoryMaxChars: 4000, // 注入总长上限(字符)
33
- retrieveMemoryMaxCharsPerItem: 800, // 单条(用户消息文本 / AI 首条回应)上限(字符)
37
+ retrieveMemoryMaxChars: DEFAULT_MEMORY_MAX_CHARS, // 注入总长上限(字符)
38
+ retrieveMemoryMaxCharsPerItem: DEFAULT_MEMORY_MAX_CHARS_PER_ITEM, // 单条(用户消息文本 / AI 首条回应)上限(字符)
34
39
  // ── 价值判定索引 ──
35
40
  // 同一条会话的价值结论在「内容没变(updatedAt 未变)+ 未超过该时长」时直接复用,
36
41
  // 不再重读日志(实测单条 0.2 MB≈85 ms / 7 MB≈1.1 s / 86 MB≈16 s)。默认 24 小时。
37
42
  valueIndexTtlHours: 24,
43
+ // ── 模型切换 ──
44
+ // 组合校验要读 modelCatalog(sessionController 是 Typert @Remote)——该调用可能长时间不返回,
45
+ // 表现为「set_member_model 卡很久」(聊天指令不走这一步,所以只有工具卡)。
46
+ // 默认关闭:切模型的主路径(写 model/selection 事件)实测可用且不依赖远程。
47
+ modelSwitchValidateCatalog: false,
48
+ // 切换末尾尝试官方接口(apiProxy.sessions.selectModel → sessionController.selectModel)。
49
+ // 同样可能久不返回。默认关闭:只写 model/selection 事件;需要「官方归一化/会话级持久」时可手动打开。
50
+ modelSwitchOfficialApi: false,
38
51
  // ── 回收站 ──
39
52
  // 删除只打标记;超过该天数才真正删盘(面板上改的值存在索引里,优先于这里)
40
53
  trashRetentionDays: 7,
@@ -100,39 +113,46 @@ export async function patchSwitch(ctx, group, sessionId, patch, defaultEntry = {
100
113
  }
101
114
 
102
115
  /** 从 config.json 载入两组开关到内存缓存(启动时/测试)。文件缺失或损坏 → 空配置。 */
103
- export async function loadPluginConfig(ctx) {
104
- const file = pluginConfigPath(ctx);
105
- const parsed = {};
106
- if (file) {
107
- try {
108
- const j = JSON.parse(await readFile(file, "utf8"));
109
- if (j && typeof j === "object") Object.assign(parsed, j);
110
- } catch { /* 文件不存在/损坏 → 空配置 */ }
116
+ /** 读插件配置文件:不存在/损坏都返回空对象(绝不抛)。 */
117
+ async function readPluginConfigFile(file) {
118
+ if (!file) return {};
119
+ try {
120
+ const parsed = JSON.parse(await readFile(file, "utf8"));
121
+ return parsed && typeof parsed === "object" ? parsed : {};
122
+ } catch {
123
+ return {};
111
124
  }
125
+ }
126
+
127
+ /** 用配置里的开关组整体覆盖内存缓存(clear 后重建,避免已删会话的残留开关)。 */
128
+ function applySwitchGroups(parsed) {
112
129
  for (const group of Object.keys(switchGroups)) {
113
- const src = parsed[group] && typeof parsed[group] === "object" ? parsed[group] : {};
130
+ const source = parsed[group] && typeof parsed[group] === "object" ? parsed[group] : {};
114
131
  switchGroups[group].clear();
115
- for (const [sid, e] of Object.entries(src)) if (e && typeof e === "object") switchGroups[group].set(sid, { ...e });
132
+ for (const [sessionId, entry] of Object.entries(source)) {
133
+ if (entry && typeof entry === "object") switchGroups[group].set(sessionId, { ...entry });
134
+ }
116
135
  }
117
136
  }
118
137
 
138
+ export async function loadPluginConfig(ctx) {
139
+ applySwitchGroups(await readPluginConfigFile(pluginConfigPath(ctx)));
140
+ }
141
+
119
142
  /** 原子落盘 config.json(临时文件 + rename;保留未知顶层字段)。 */
120
143
  export async function savePluginConfig(ctx) {
121
144
  const file = pluginConfigPath(ctx);
122
145
  if (!file) return;
123
- let rest = {};
124
- try {
125
- const j = JSON.parse(await readFile(file, "utf8"));
126
- if (j && typeof j === "object") rest = j;
127
- } catch { /* 文件不存在/损坏 → 全量写 */ }
146
+ const rest = await readPluginConfigFile(file); // 保留未知顶层字段
128
147
  await mkdir(path.dirname(file), { recursive: true });
129
148
  const tmpPath = file + ".tmp";
130
- writeFileSync(tmpPath, JSON.stringify({
149
+ // 异步写:这段在请求路径上会被调用,同步写盘会阻塞事件循环
150
+ await writeFile(tmpPath, JSON.stringify({
131
151
  ...rest,
132
152
  autoRename: Object.fromEntries(switchGroups.autoRename),
133
153
  autoContinue: Object.fromEntries(switchGroups.autoContinue),
134
154
  }, null, 2), "utf8");
135
- renameSync(tmpPath, file);
155
+ await rename(tmpPath, file);
136
156
  }
137
157
 
138
158
  /** 启动复位:自动续跑开关全部置 false(每次 DSH 启动默认关闭;autoRename 不复位)。幂等。 */
@@ -19,10 +19,11 @@
19
19
  // 【写入】原子写(tmp + rename)。整文件重写:当前规模 30~80 KB、序列化 ~1 ms,
20
20
  // 各模块的写盘本来就有节流/低频,代价可忽略;等真到上千条再评估换存储。
21
21
 
22
- import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs";
22
+ import { existsSync } from "node:fs";
23
23
  import path from "node:path";
24
24
 
25
25
  import { cfg, resolveDshHome } from "./config.js";
26
+ import { writeJsonAtomicSync, readJsonSafeSync } from "../shared/json-store.js";
26
27
 
27
28
  const STORE_VERSION = 1;
28
29
  const STORE_SUBDIR = ["storages", "dsh-session-conductor"];
@@ -58,28 +59,35 @@ export function loadStore(ctx) {
58
59
  storeCache = emptyStore();
59
60
  const file = storeFilePath(ctx);
60
61
  if (!file) return storeCache;
61
- let loaded = false;
62
- try {
63
- const parsed = JSON.parse(readFileSync(file, "utf8"));
64
- if (parsed && typeof parsed === "object") {
65
- storeCache = {
66
- version: STORE_VERSION,
67
- list: { entries: parsed.list?.entries && typeof parsed.list.entries === "object" ? parsed.list.entries : {} },
68
- value: { entries: parsed.value?.entries && typeof parsed.value.entries === "object" ? parsed.value.entries : {} },
69
- trash: {
70
- entries: parsed.trash?.entries && typeof parsed.trash.entries === "object" ? parsed.trash.entries : {},
71
- ...(Number.isFinite(parsed.trash?.retentionDays) ? { retentionDays: parsed.trash.retentionDays } : {}),
72
- },
73
- };
74
- loaded = true;
75
- }
76
- } catch {
77
- // 首次运行 / 文件损坏:保持空结构
62
+ const parsed = readStoreFile(file);
63
+ if (parsed) {
64
+ storeCache = normalizeStore(parsed);
65
+ } else {
66
+ // 文件不存在/损坏:先尝试一次性迁移旧文件
67
+ migrateLegacyFiles(ctx);
78
68
  }
79
- if (!loaded) migrateLegacyFiles(ctx);
80
69
  return storeCache;
81
70
  }
82
71
 
72
+ /** 读并解析索引文件;不存在或损坏返回 null(不抛)。 */
73
+ function readStoreFile(file) {
74
+ return readJsonSafeSync(file); // 不存在/损坏一律 null,不抛
75
+ }
76
+
77
+ /** 把「磁盘上读到的任意形状」规整成合法结构:每个 section 都有 entries 对象。 */
78
+ function normalizeStore(parsed) {
79
+ const entriesOf = (section) => (section?.entries && typeof section.entries === "object" ? section.entries : {});
80
+ return {
81
+ version: STORE_VERSION,
82
+ list: { entries: entriesOf(parsed.list) },
83
+ value: { entries: entriesOf(parsed.value) },
84
+ trash: {
85
+ entries: entriesOf(parsed.trash),
86
+ ...(Number.isFinite(parsed.trash?.retentionDays) ? { retentionDays: parsed.trash.retentionDays } : {}),
87
+ },
88
+ };
89
+ }
90
+
83
91
  /**
84
92
  * 一次性迁移旧文件:按 section 合并,成功则把旧文件改名 `.migrated`(保留可回滚)。
85
93
  * 任一文件解析失败就跳过它(不阻断启动)。
@@ -116,10 +124,7 @@ export function saveStoreNow(ctx) {
116
124
  const file = storeFilePath(ctx);
117
125
  if (!file || storeCache === null) return false;
118
126
  try {
119
- mkdirSync(path.dirname(file), { recursive: true });
120
- const tmpPath = `${file}.tmp-${process.pid}`;
121
- writeFileSync(tmpPath, JSON.stringify(storeCache), "utf8");
122
- renameSync(tmpPath, file);
127
+ writeJsonAtomicSync(file, storeCache); // 统一原子写(tmp + rename),见 shared/json-store.js
123
128
  return true;
124
129
  } catch {
125
130
  return false;
package/lib/eio-repair.js CHANGED
@@ -57,7 +57,8 @@ export async function probeEioBoundary(filePath) {
57
57
  // 打开/stat 失败(文件被删/无权限等)→ 记错误,不当作 EIO 会话
58
58
  return { ok: true, size: 0, goodBytes: 0, eioOffset: 0, error: `open/stat 失败: ${String(error?.message ?? error)}` };
59
59
  } finally {
60
- if (fd !== undefined) { try { closeSync(fd); } catch {} }
60
+ // 关闭失败无补救动作(fd 已不可用),忽略即可,不影响已得到的探测结果
61
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* 关闭失败忽略 */ } }
61
62
  }
62
63
  }
63
64
 
@@ -36,7 +36,7 @@ export function buildContinuePrompt(info) {
36
36
  const base = "这是一条由插件自动发出的「续跑」指令,不是用户的新问题。";
37
37
  switch (info?.kind) {
38
38
  case "interrupted":
39
- return `${base}\n检测到此会话此前的回合因进程/会话中断而未完成。请回顾你当时正在进行的任务,检查已完成与未完成的部分,然后继续完成它。若任务实际上已经完成,请直接说明完成情况,不要重复执行。`;
39
+ return `${base}\n检测到此前回合因进程中断或运行失败而未完成。请回顾你当时正在进行的任务,检查已完成与未完成的部分,然后继续完成它。若任务实际上已经完成,请直接说明完成情况,不要重复执行。`;
40
40
  case "error":
41
41
  return `${base}\n检测到此会话此前的回合因可重试的请求错误中断(${info.code ?? "未知"})。请回顾你当时正在进行的任务,继续完成它。若任务实际上已经完成,请直接说明完成情况,不要重复执行。`;
42
42
  case "aborted":
@@ -48,21 +48,7 @@ async function renderMemberList(ctx, invocation) {
48
48
  return "没读到任何成员。可能当前不是 Team 成员、名册不可用,或确实还没有队友。";
49
49
  }
50
50
  const lines = [`成员模型(共 ${members.length} 个)`, ""];
51
- for (const m of members) {
52
- let override = null;
53
- try { override = await getMemberModelOverride(ctx, m.id); } catch { /* domain 暂不可用:按无 override 显示 */ }
54
- const live = peekLastRequestModel(m.id); // 进程内最近一次请求:不读日志,瞬时
55
- const record = m.provider && m.model ? `${m.provider}/${m.model}` : null;
56
- const shown = [];
57
- if (override) shown.push(`目标=${override.provider}/${override.model}`);
58
- if (live) shown.push(`最近请求=${live.provider}/${live.model}`);
59
- if (shown.length === 0 && record) shown.push(`名册记录=${record}`);
60
- if (shown.length === 0) shown.push("未知(用下一条查实际模型)");
61
- const flag = live && override && (live.provider !== override.provider || live.model !== override.model)
62
- ? " ⚠️ 与目标不一致(下一回合生效前属正常)"
63
- : "";
64
- lines.push(`· ${m.name ?? "(无名)"}(${m.id}) — ${shown.join(" | ")}${flag}`);
65
- }
51
+ for (const m of members) lines.push(await describeMemberLine(ctx, m));
66
52
  lines.push("");
67
53
  lines.push("要查**实际**在用什么(含历史请求,不只本次进程)或切换:");
68
54
  lines.push(` /${COMMAND_NAME} <成员名或 sessionId> ← 查实际模型`);
@@ -70,6 +56,38 @@ async function renderMemberList(ctx, invocation) {
70
56
  return lines.join("\n");
71
57
  }
72
58
 
59
+ /**
60
+ * 把三个来源整理成展示片段:本插件 override → 进程内最近请求 → 名册记录值。
61
+ * 都没有时如实说「未知」,并提示用「一参」形态查实际模型。
62
+ */
63
+ function formatModelSources(override, live, record) {
64
+ const shown = [];
65
+ if (override) shown.push(`目标=${override.provider}/${override.model}`);
66
+ if (live) shown.push(`最近请求=${live.provider}/${live.model}`);
67
+ if (shown.length === 0 && record) shown.push(`名册记录=${record}`);
68
+ if (shown.length === 0) shown.push("未知(用下一条查实际模型)");
69
+ return shown.join(" | ");
70
+ }
71
+
72
+ /**
73
+ * 单个成员的一行摘要:本插件 override → 进程内最近一次请求 → 名册记录值(都不读日志,瞬时)。
74
+ * 三者都拿不到时如实说「未知」,并提示用「一参」形态查实际模型。
75
+ */
76
+ async function describeMemberLine(ctx, member) {
77
+ let override = null;
78
+ try {
79
+ override = await getMemberModelOverride(ctx, member.id);
80
+ } catch {
81
+ // domain 暂不可用:按「无 override」显示,不阻断列表
82
+ }
83
+ const live = peekLastRequestModel(member.id); // 进程内最近一次请求:不读日志,瞬时
84
+ const record = member.provider && member.model ? `${member.provider}/${member.model}` : null;
85
+ const shownText = formatModelSources(override, live, record);
86
+ const drift = Boolean(live && override && (live.provider !== override.provider || live.model !== override.model));
87
+ const flag = drift ? " ⚠️ 与目标不一致(下一回合生效前属正常)" : "";
88
+ return `· ${member.name ?? "(无名)"}(${member.id}) — ${shownText}${flag}`;
89
+ }
90
+
73
91
  /** 一参:查该成员**实际**在用哪个模型(优先读日志里的 request/header = 请求级权威记录)。 */
74
92
  async function renderMemberDetail(found) {
75
93
  const { lastRequest, lastSelection } = await readLastRequestModel(found.sessionId);