dsh-session-conductor 1.3.11 → 1.3.16

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.
Files changed (86) hide show
  1. package/README.md +23 -1
  2. package/cordis.patch.yml +0 -0
  3. package/lib/apply.js +28 -1243
  4. package/lib/client-parts/apply.js +0 -0
  5. package/lib/client-parts/components/compaction.js +0 -0
  6. package/lib/client-parts/components/conductor-settings.js +0 -0
  7. package/lib/client-parts/components/group.js +0 -0
  8. package/lib/client-parts/components/panel.js +0 -0
  9. package/lib/client-parts/components/processing.js +0 -0
  10. package/lib/client-parts/components/session-icon.js +0 -0
  11. package/lib/client-parts/components/settings.js +0 -0
  12. package/lib/client-parts/foundation/bootstrap.js +0 -0
  13. package/lib/client-parts/foundation/i18n.js +0 -0
  14. package/lib/client-parts/foundation/list-cache.js +0 -0
  15. package/lib/client-parts/foundation/styles.js +0 -0
  16. package/lib/core/config.js +0 -0
  17. package/lib/core/domain.js +0 -0
  18. package/lib/core/index-store.js +0 -0
  19. package/lib/core/lazy.js +0 -0
  20. package/lib/core/state.js +0 -0
  21. package/lib/core.js +0 -0
  22. package/lib/eio-repair.js +0 -0
  23. package/lib/features/base-routes.js +156 -0
  24. package/lib/features/context-usage/index.js +0 -0
  25. package/lib/features/continue/eligibility.js +0 -0
  26. package/lib/features/continue/scan.js +0 -0
  27. package/lib/features/continue/session.js +0 -0
  28. package/lib/features/inject/preset.js +0 -0
  29. package/lib/features/member-model/batch.js +97 -0
  30. package/lib/features/member-model/command.js +61 -7
  31. package/lib/features/member-model/create-teammate.js +0 -0
  32. package/lib/features/member-model/index.js +15 -1
  33. package/lib/features/member-model/lead.js +73 -0
  34. package/lib/features/member-model/locate.js +82 -1
  35. package/lib/features/member-model/override.js +0 -0
  36. package/lib/features/member-model/permission-command.js +138 -0
  37. package/lib/features/member-model/permission.js +213 -0
  38. package/lib/features/member-model/setup.js +0 -0
  39. package/lib/features/member-model/switch.js +254 -23
  40. package/lib/features/member-model/tools.js +324 -0
  41. package/lib/features/memory-recall/index.js +0 -0
  42. package/lib/features/rename/analysis.js +0 -0
  43. package/lib/features/rename/title.js +0 -0
  44. package/lib/features/rename/value.js +0 -0
  45. package/lib/features/session-action-routes.js +435 -0
  46. package/lib/features/session-messaging/index.js +0 -0
  47. package/lib/features/session-messaging/logic.js +0 -0
  48. package/lib/features/session-messaging/tools.js +0 -0
  49. package/lib/features/session-ops-routes.js +0 -0
  50. package/lib/features/settings-routes.js +400 -0
  51. package/lib/features/trash.js +0 -0
  52. package/lib/features/value-index.js +0 -0
  53. package/lib/features/value-report-routes.js +211 -0
  54. package/lib/group.js +0 -0
  55. package/lib/i18n/en.json +0 -0
  56. package/lib/i18n/zh.json +0 -0
  57. package/lib/index.js +0 -0
  58. package/lib/repair.js +0 -0
  59. package/lib/seq-gap-repair.js +0 -0
  60. package/lib/session-codec.js +0 -0
  61. package/lib/session-log.js +0 -0
  62. package/lib/sessions/detach.js +0 -0
  63. package/lib/sessions/events.js +0 -0
  64. package/lib/sessions/interruption.js +0 -0
  65. package/lib/sessions/list-cache.js +0 -0
  66. package/lib/sessions/list.js +0 -0
  67. package/lib/sessions/lookup.js +0 -0
  68. package/lib/sessions/route.js +0 -0
  69. package/lib/sessions/session-id.js +0 -0
  70. package/lib/sessions/tail-io.js +185 -0
  71. package/lib/sessions/turn.js +0 -0
  72. package/lib/shared/atomic-file.js +0 -0
  73. package/lib/shared/constants.js +0 -0
  74. package/lib/shared/json-store.js +0 -0
  75. package/lib/shared/log.js +0 -0
  76. package/lib/shared/timing.js +0 -0
  77. package/lib/shared/util.js +0 -0
  78. package/lib/template-inject.js +0 -0
  79. package/lib/value.js +0 -0
  80. package/lib/zstd-frames.js +0 -0
  81. package/package.json +1 -1
  82. package/scripts/check-member-model.mjs +0 -0
  83. package/skills/dsh-session-conductor-functions.md +0 -0
  84. package/skills/dsh-session-conductor.md +0 -0
  85. package/skills/dsh-session-official-doc.md +0 -0
  86. package/skills/task-completion-report.md +0 -0
package/lib/apply.js CHANGED
@@ -1,66 +1,34 @@
1
1
  /** 插件入口 apply:注册路由、工具、设置页与各类钩子(本轮拆分后仍含各 handler 实现,下一步继续抽出)。 */
2
2
 
3
- import { readFileSync } from "node:fs";
4
- import path from "node:path";
5
- import { findSessionLog } from "./session-log.js";
6
3
  // 渲染/校验函数:供 /api/task-completion/render|check 端点使用(LLM 侧工具注册已移除,
7
4
  // VALID_STATUSES 不再需要导入——它只在 core.js 的 renderCompletionBlock 内部使用)。
8
- import { renderCompletionBlock, checkCompletionText } from "./core.js";
9
5
  import { log } from "./shared/log.js";
10
- import {
11
- MSG_METHOD_ONLY_GET_POST, MSG_NEED_SESSION_ID,
12
- MAX_ATTACHED_LIMIT, MAX_CONTINUES_PER_SESSION_LIMIT, MAX_STALE_DAYS,
13
- } from "./shared/constants.js";
14
- import { lazyRepair, lazySeqGap, lazyEio, lazyValue, lazyFs } from "./core/lazy.js";
6
+ import { MAX_ATTACHED_LIMIT, MAX_CONTINUES_PER_SESSION_LIMIT } from './shared/constants.js';
15
7
  import { pluginDomain, isDomainLive, resetDomainForTest } from "./core/domain.js";
16
- import {
17
- continueTimers, continueLocks, continueJobs, deleteLocks,
18
- withDeleteLock, withSessionLock, withConcurrencyGate, cancelSessionTimers,
19
- getScanTimer, setScanTimer, resetRuntimeStateForTest,
20
- } from "./core/state.js";
21
- import {
22
- cfg, DEFAULTS, setConfig, resolveDshHome, resolveBrowseRoot,
23
- readSwitch, patchSwitch, loadPluginConfig, savePluginConfig, resetAutoContinueOnStart,
24
- autoRenameEnabled, effectiveAutoContinue, resetSwitchGroupsForTest, deleteSwitch, getSwitchGroupsForTest,
25
- } from "./core/config.js";
26
- import { continueSession, sendMessageToSession } from "./features/continue/session.js";
27
- import {
28
- maybeScheduleContinue, runAutoContinueSession, runAutoScan, scheduleScan, AUTO_CONTINUE_SCAN_DELAY_MS,
29
- } from "./features/continue/scan.js";
8
+ import { continueTimers, getScanTimer } from './core/state.js';
9
+ import { cfg, DEFAULTS, setConfig, resolveDshHome, patchSwitch, resetAutoContinueOnStart } from './core/config.js';
10
+ import { maybeScheduleContinue, runAutoScan, scheduleScan, AUTO_CONTINUE_SCAN_DELAY_MS } from './features/continue/scan.js';
30
11
  import { num } from "./shared/util.js";
31
- import { registerContextUsageTool, createContextUsageInjector, readContextUsage } from "./features/context-usage/index.js";
12
+ import { createContextUsageInjector, readContextUsage } from './features/context-usage/index.js';
32
13
  import { buildMemoryRecall } from "./features/memory-recall/index.js";
33
- import { registerSessionMessagingTools, readListIndex } from "./features/session-messaging/index.js";
34
- import { registerMemoryRecallTool } from "./features/memory-recall/index.js";
35
14
  import { registerGroupRoutes } from "./group.js";
36
- 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";
15
+ import { readCompletionConventionSync, collectTemplateSlotText, planGateAllows, planEnforceDenyMessage, TEMPLATE_DEFAULTS, TEMPLATE_SLOTS, PLAN_ENFORCE_EDIT_TOOLS, PLAN_ENFORCE_BASH_WRITE_RE } from './template-inject.js';
37
16
  import { name } from "./shared/constants.js";
38
- import { sessionEventsOf } from "./sessions/events.js";
39
- import { detachSessionAgent, detachAllIdleSessions } from "./sessions/detach.js";
40
- import {
41
- invalidateSessionListCache, scheduleSaveListDiskCache, saveListDiskCacheNow, resetListCacheForTest,
42
- } from "./sessions/list-cache.js";
43
- import { buildSessionListCached } from "./sessions/list.js";
44
- import {
45
- scheduleAnalysis, runAnalysis, resolveModelOverride, analyzeSession, resolveRoute,
46
- driftAnalysisLlm, extractTitleOnly, parseDriftJson, pendingTimers, resetAnalysisForTest,
47
- } from "./features/rename/analysis.js";
48
- import { analyzeValueWithLlm } from "./features/rename/value.js";
49
- import {
50
- collectSessionTitleMessages, resolveSessionTitle, stateSuffixOf, stripTitleStateSuffix, refreshTitleState,
51
- } from "./features/rename/title.js";
52
- import { SEARCH_MIN_QUERY, searchSessions } from './features/search.js';
17
+ import { invalidateSessionListCache } from './sessions/list-cache.js';
18
+ import { scheduleAnalysis, analyzeSession } from './features/rename/analysis.js';
19
+ import { refreshTitleState } from './features/rename/title.js';
53
20
  // 价值判定索引:同一份会话内容在 TTL(默认 24 小时)内只算一次,避免每次筛选都重读日志
54
- import { getValueVerdict, putValueVerdict, saveValueIndexNow } from './features/value-index.js';
55
21
  // 成员模型:本文件用到 findTargetAgent / switchAgentModel / getMemberModelOverride /
56
22
  // applyModelOverride / validateModelPair(拆出 apply.js 时漏了这一行 import,
57
23
  // 运行到相关代码路径会 ReferenceError: getMemberModelOverride is not defined)
58
- import { createTeammateWithModel, renderCreateTeammateResult } from "./features/member-model/create-teammate.js";
59
- import { findTargetAgent, switchAgentModel, getMemberModelOverride, applyModelOverride, validateModelPair,
60
- registerSwitchWatch, noteRequestModel, takeSwitchOutcomes, readLastRequestModel,
61
- registerModelSwitchCommand } from './features/member-model/index.js';
24
+ import { getMemberModelOverride, applyModelOverride, noteRequestModel, takeSwitchOutcomes } from './features/member-model/index.js';
62
25
  // 会话操作的路由注册已抽到独立模块(handler 实现见该模块;此处只留注册入口)
63
26
  import { registerSessionOpsRoutes } from './features/session-ops-routes.js';
27
+ import { registerMemberModelTools } from './features/member-model/tools.js';
28
+ import { registerSessionActionRoutes } from './features/session-action-routes.js';
29
+ import { registerBaseRoutes } from './features/base-routes.js';
30
+ import { registerSettingsRoutes } from './features/settings-routes.js';
31
+ import { registerValueReportRoutes } from './features/value-report-routes.js';
64
32
  import { registerTrashRoutes } from './features/session-ops-routes.js';
65
33
  import { purgeExpiredTrash } from './features/session-ops.js';
66
34
  import { installProcessGuards } from './guards.js';
@@ -222,451 +190,11 @@ export async function apply(ctx, config = {}) {
222
190
  ctx.inject(["webServer"], (wctx) => {
223
191
  const webServer = wctx.get("webServer");
224
192
 
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
-
237
- // ---------- GET /api/session-conductor/i18n ----------
238
- // 语言包外置 lib/i18n/{zh,en}.json(单份权威,改文案无需重打包 client bundle):
239
- // 浏览器端 ModuleLoader 的 require 不支持相对路径 JSON(require("./i18n/*.json") 抛 missed-the-module-table),
240
- // 故由宿主侧读外置 JSON 经 HTTP 暴露、客户端 fetch 拉取合并(语言包外置做法)。
241
- webServer.register({
242
- kind: "exact",
243
- path: "/api/session-conductor/i18n",
244
- handler: async (req, res) => {
245
- try {
246
- if ((req.method ?? "GET") !== "GET") {
247
- return send(res, 405, { ok: false, error: { code: "METHOD", message: "Method Not Allowed" } });
248
- }
249
- const { zh, en } = loadI18n();
250
- return send(res, 200, { ok: true, zh, en });
251
- } catch (error) {
252
- return send(res, 500, { ok: false, error: { code: "INTERNAL", message: String(error?.message ?? error) } });
253
- }
254
- },
255
- });
193
+ // ---------- 基础路由(i18n 语言包 / 会话列表):整块拆到 features/base-routes.js ----------
194
+ registerBaseRoutes({ webServer, ctx });
256
195
 
257
- // ---------- 预览页已移出本插件 ----------
258
- // 原先这里用 /api/session-conductor/preview/<settings|panel> 同源服务 assets/preview-*.html
259
- // (那两份 html 由已移除的 tools/preview-snapshot.sh 生成)。现在预览能力统一由独立插件
260
- // dsh-useful-tools 的 `preview` 工具提供:gen 生成 + serve 带 backend 反代真实后端,
261
- // 效果等价且本插件不必再托管静态页,故路由与产物一并移除。
262
-
263
- // ---------- GET /api/session-conductor/list ----------
264
- webServer.register({
265
- kind: "exact",
266
- path: "/api/session-conductor/list",
267
- handler: async (req, res) => {
268
- try {
269
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
270
- if (req.method !== "GET") invalidateSessionListCache();
271
- // 索引直读(`?fromIndex=true`):与 `list_sessions` 工具的 `fromIndex` **同义** ——
272
- // 直接读落盘索引、**不做全量构建**,秒回精简列表(不含 live 状态与归档态)。
273
- // 原先路由只认 job / stream 两个声明,传 fromIndex 会被**静默忽略** ⇒ 悄悄退化成
274
- // 226 秒级的全量构建(本机实测一次 41 秒才回),调用方以为拿到了快照、实际被钉在慢路径上。
275
- if (String(req.url ?? "").includes("fromIndex=true")) {
276
- const items = await readListIndex(ctx);
277
- if (!items) {
278
- send(res, 200, { ok: true, fromIndex: true, sessions: [], hint: "落盘索引为空或尚未建立;去掉 fromIndex 走一次刷新(会重建索引,冷缓存可能很慢)" });
279
- return;
280
- }
281
- send(res, 200, { ok: true, fromIndex: true, sessions: items, hint: "来自落盘索引,不含 live 状态与归档态;要实时状态请去掉 fromIndex 走刷新" });
282
- return;
283
- }
284
- // job 模式(`?job=1` 或 `Accept: application/x-job`):冷缓存下全量构建很慢
285
- // (本机实测 201 份日志 / 1.5 GB 时 226 秒),把构建交给宿主后台 job 并**立即**返回 jobId,
286
- // 调用方改用 job_output / job_list 取结果,前台不再被钉住。
287
- // 默认(无 job 声明)仍走下面的普通 JSON / NDJSON 流式分支 —— 一字不动,避免改坏面板。
288
- const wantJob = /application\/x-job/i.test(req.headers?.accept ?? "")
289
- || String(req.url ?? "").includes("job=1");
290
- if (wantJob) {
291
- const jobs = ctx?.get?.("jobs");
292
- if (!jobs || typeof jobs.start !== "function") {
293
- send(res, 501, { ok: false, error: { code: "unsupported", message: "宿主未挂载 jobs 服务,无法注册后台 job;请改用默认(流式/JSON)路径" } });
294
- return;
295
- }
296
- // 构建只做一次;完成后顺手失效 TTL,保证随后任何一条路径读到的都是本次结果
297
- const done = buildSessionListCached(ctx).then(
298
- (sessions) => { invalidateSessionListCache(); return { status: "completed", output: JSON.stringify({ ok: true, sessions }) }; },
299
- (error) => ({ status: "failed", detail: String(error?.message ?? error) }),
300
- );
301
- const spec = { kind: "session-conductor-list", label: "list(会话列表刷新)", run: () => ({ cancel: () => {}, done }) };
302
- // HTTP 入口没有执行主体 ⇒ 用「无主」注册(宿主存在全局 job 控制器即可);
303
- // 无主不可用时如实回 501,由调用方决定是否改用默认路径(不静默吞掉)。
304
- try {
305
- send(res, 200, { ok: true, async: true, jobId: jobs.start(spec), hint: "用宿主 job_output / job_list 取结果" });
306
- } catch (error) {
307
- // 后台 job 不可用(HTTP 入口**没有执行主体**,宿主 job 控制器会拒绝无主注册 ——
308
- // 实测回的是「no job controller serves this agent」):不再直接 501 把调用方堵死;
309
- // 退一步给**索引快照**(秒回、明确标注来源与「不含 live 状态」),索引也没有才如实 501。
310
- const items = await readListIndex(ctx).catch(() => null);
311
- if (items) {
312
- send(res, 200, {
313
- ok: true,
314
- async: false,
315
- fromIndex: true,
316
- sessions: items,
317
- jobUnavailable: String(error?.message ?? error),
318
- hint: "后台 job 不可用,已回退为落盘索引快照(不含 live 状态);要实时状态请走默认或流式分支",
319
- });
320
- return;
321
- }
322
- send(res, 501, { ok: false, error: { code: "unsupported", message: `注册后台 job 失败:${String(error?.message ?? error)}(且落盘索引不可用,无回退)` } });
323
- }
324
- return;
325
- }
326
- // list 流式(仅当请求声明 Accept: application/x-ndjson)——live 会话先快速
327
- // 写出,冷会话串行逐个解析边写边出,前端 fetch stream 逐行追加(每获取一个立即显示,
328
- // 不等全部);排序由前端按 updatedAt 倒序插入保持(流式下后端不再整体排序)。
329
- // 默认(无 Accept 头/旧前端/预览页垫片)仍返回普通 JSON——兼容不匹配的调用方。
330
- const wantStream =
331
- /application\/x-ndjson/i.test(req.headers?.accept ?? "") || String(req.url ?? "").includes("stream=1");
332
- if (!wantStream) {
333
- const sessions = await buildSessionListCached(ctx);
334
- send(res, 200, { ok: true, sessions });
335
- return;
336
- }
337
- res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
338
- res.setHeader("Cache-Control", "no-store");
339
- if (res.socket?.setNoDelay) res.socket.setNoDelay(true);
340
- res.write(JSON.stringify({ ok: true, stream: true }) + "\n"); // 头行(前端跳过非会话行)
341
- // 立即把头行推出去:冷缓存时后面要逐份解压上百份日志(实测 41~226 秒),
342
- // 若头行压在缓冲里,浏览器这侧会一直「没有响应」,看起来就像接口挂了。
343
- // flushHeaders/flush 老版本不一定有,取不到就跳过(写操作本身已尽量及时)。
344
- try {
345
- if (typeof res.flushHeaders === "function") res.flushHeaders();
346
- if (typeof res.flush === "function") res.flush();
347
- } catch { /* 不支持 flush 的宿主不影响主流程 */ }
348
- const onItem = (item) => { try { res.write(JSON.stringify(item) + "\n"); } catch { /* 连接已断 */ } };
349
- await buildSessionListCached(ctx, { onItem, serial: true });
350
- try { res.end(); } catch { /* 连接已断 */ }
351
- } catch (error) {
352
- try {
353
- res.end(JSON.stringify({ ok: false, error: { code: "internal", message: String(error?.message ?? error) } }) + "\n");
354
- } catch { /* 连接已断 */ }
355
- }
356
- },
357
- });
358
-
359
- // ---------- GET/POST /api/session-conductor/templates ----------
360
- // 会话模板注入():plan(方案模板)/ closing(收尾模板)两个固定槽位,
361
- // 支持上传本地 md 文件或在线 md 网址(host 下载转存),内容落盘
362
- // <DSH_HOME>/template-inject-md/<slot>.md → systemPrompt section「session-templates」注入。
363
- // GET → {ok, slots: {plan:{enabled,name,url,bytes,updatedAt}, closing:{...}}, maxBytes}
364
- // POST {slot, enabled?} → 只改开关;
365
- // POST {slot, name, content} → 上传本地 md;
366
- // POST {slot, url} → 在线 md 网址下载转存;
367
- // POST {slot, action:"remove"} → 清空槽位。
368
- {
369
- const tplHome = resolveDshHome(ctx, cfg);
370
- // 浏览根与选用校验根唯一来源 = resolveBrowseRoot(工作区目录)。
371
- // 同一 browseRoot 变量贯穿本块内两个路由:/templates 的 pickPath 校验、/templates/dir 浏览。
372
- // 落盘仍 tplHome(DSH_HOME/template-inject-md/),浏览/校验/落盘三者职责清晰。
373
- const browseRoot = resolveBrowseRoot(ctx, cfg);
374
- if (tplHome) {
375
- webServer.register({
376
- kind: "exact",
377
- path: "/api/session-conductor/templates",
378
- handler: async (req, res) => {
379
- try {
380
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
381
- if (req.method !== "GET") invalidateSessionListCache();
382
- const domain = await pluginDomain(ctx);
383
- const st = domain.global.get();
384
- const meta = st.sessionTemplates ?? structuredClone(TEMPLATE_DEFAULTS);
385
- // 缓存写入口必须是归属模块的 setter(import 绑定对导入方只读,直接赋值会 TypeError)
386
- setTemplateStateCache(meta);
387
- // 构造带 content 的 slots(GET/POST 统一:导入/编辑后前端编辑框直接读到内容)
388
- const buildSlotsWithContent = (meta2) => {
389
- const out = {};
390
- for (const slot of TEMPLATE_SLOTS) {
391
- const slotMeta = meta2[slot] ?? { enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 };
392
- let content = "";
393
- if (slotMeta.bytes > 0 || slotMeta.name) {
394
- // readTemplateSync 返回 sanitized 字符串(无文件/不可读返回空串),不是 {ok,text} 对象
395
- try { content = String(readTemplateSync(tplHome, slot) ?? "").slice(0, TEMPLATE_MAX_BYTES); } catch { content = ""; }
396
- }
397
- out[slot] = { ...slotMeta, content };
398
- }
399
- return out;
400
- };
401
- if (req.method === "GET") {
402
- return send(res, 200, { ok: true, slots: buildSlotsWithContent(meta), maxBytes: TEMPLATE_MAX_BYTES });
403
- }
404
- if (req.method === "POST") {
405
- const body = await readJson(req);
406
- // 目录内选用 md 文件(action="pickPath")——校验用工作区浏览根,落盘用 DSH_HOME
407
- if (body?.action === "pickPath") {
408
- const slot = String(body?.slot ?? "");
409
- if (!TEMPLATE_SLOTS.includes(slot)) {
410
- return send(res, 400, { ok: false, error: { code: "bad-slot", message: `模板槽位须为 ${TEMPLATE_SLOTS.join("|")}` } });
411
- }
412
- const saved = await saveTemplateFromPath(browseRoot, slot, body.path, tplHome);
413
- if (!saved.ok) return send(res, 400, saved);
414
- const cur = meta[slot] ?? { enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 };
415
- const next = {
416
- ...meta,
417
- [slot]: {
418
- enabled: cur.enabled,
419
- enforce: cur.enforce === true,
420
- name: saved.name || cur.name,
421
- url: "",
422
- bytes: saved.bytes,
423
- updatedAt: Date.now(),
424
- },
425
- };
426
- setTemplateStateCache(next);
427
- await domain.global.set({ ...st, sessionTemplates: next });
428
- return send(res, 200, { ok: true, slots: buildSlotsWithContent(next), message: `模板「${slot}」已从目录选用(${saved.name}),记得点「开启注入」` });
429
- }
430
- const slot = String(body?.slot ?? "");
431
- if (!TEMPLATE_SLOTS.includes(slot)) {
432
- return send(res, 400, { ok: false, error: { code: "bad-slot", message: `模板槽位须为 ${TEMPLATE_SLOTS.join("|")}` } });
433
- }
434
- const cur = meta[slot] ?? { enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 };
435
- if (body?.action === "remove") {
436
- const r = await removeTemplate(tplHome, slot);
437
- if (!r.ok) return send(res, 400, r);
438
- const next = { ...meta, [slot]: { ...cur, enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 } };
439
- setTemplateStateCache(next);
440
- await domain.global.set({ ...st, sessionTemplates: next });
441
- return send(res, 200, { ok: true, slots: buildSlotsWithContent(next), message: `模板「${slot}」已清空` });
442
- }
443
- let saved = null;
444
- if (typeof body?.url === "string" && body.url.trim()) {
445
- saved = await saveTemplateFromUrl(tplHome, slot, body.url);
446
- } else if (typeof body?.content === "string") {
447
- // 编辑保存:可只带 content(name 保留现值);也可带 name(本地导入)
448
- saved = await saveTemplate(tplHome, slot, { name: (typeof body?.name === "string" && body.name.trim()) ? body.name : cur.name, content: body.content });
449
- }
450
- if (saved && saved.ok) {
451
- const next = {
452
- ...meta,
453
- [slot]: {
454
- enabled: cur.enabled, // 上传内容不自动开,用户再点开关
455
- enforce: cur.enforce === true,
456
- name: saved.name || cur.name,
457
- url: typeof body?.url === "string" && body.url.trim() ? body.url.trim() : "",
458
- bytes: saved.bytes,
459
- updatedAt: Date.now(),
460
- },
461
- };
462
- setTemplateStateCache(next);
463
- await domain.global.set({ ...st, sessionTemplates: next });
464
- return send(res, 200, { ok: true, slots: buildSlotsWithContent(next), message: `模板「${slot}」已保存,记得点「开启注入」` });
465
- }
466
- if (saved && !saved.ok) return send(res, 400, saved);
467
- if (typeof body?.enabled === "boolean") {
468
- const next = { ...meta, [slot]: { ...cur, enabled: body.enabled } };
469
- setTemplateStateCache(next);
470
- await domain.global.set({ ...st, sessionTemplates: next });
471
- return send(res, 200, { ok: true, slots: buildSlotsWithContent(next), message: body.enabled ? `模板「${slot}」已开启注入` : `模板「${slot}」已关闭` });
472
- }
473
- // 方案模板强制门禁开关({slot:"plan", enforce:bool})——即时生效,无需重启
474
- if (slot === "plan" && typeof body?.enforce === "boolean") {
475
- const next = { ...meta, plan: { ...cur, enforce: body.enforce } };
476
- setTemplateStateCache(next);
477
- await domain.global.set({ ...st, sessionTemplates: next });
478
- return send(res, 200, { ok: true, slots: buildSlotsWithContent(next), message: body.enforce
479
- ? `方案模板强制门禁已开启(未出提案并获确认前,代码修改类工具调用被拒绝)`
480
- : `方案模板强制门禁已关闭` });
481
- }
482
- return send(res, 400, { ok: false, error: { code: "bad-request", message: "需要 {slot, name, content}(本地 md)、{slot, url}(在线 md)、{slot, enabled} 或 {slot:'plan', enforce}" } });
483
- }
484
- return send(res, 405, { ok: false, error: { code: "method", message: MSG_METHOD_ONLY_GET_POST } });
485
- } catch (error) {
486
- send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
487
- }
488
- },
489
- });
490
-
491
- // ---------- GET /api/session-conductor/templates/dir ----------
492
- // 模板目录浏览(「在工作区目录内找」):列 DSH 工作区目录树内一层目录。
493
- // 浏览根与选用校验根同源(上方 browseRoot);落盘仍在 DSH_HOME/template-inject-md/。
494
- // GET ?path=... → {ok, path, parent, entries:[{name,path,isDir,isMd}]}(缺省从工作区根开始)
495
- if (browseRoot) {
496
- webServer.register({
497
- kind: "exact",
498
- path: "/api/session-conductor/templates/dir",
499
- handler: async (req, res) => {
500
- try {
501
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
502
- if (req.method !== "GET") invalidateSessionListCache();
503
- if (req.method !== "GET") return send(res, 405, { ok: false, error: { code: "method", message: "仅 GET" } });
504
- const url = new URL(req.url, "http://localhost");
505
- const path = url.searchParams.get("path") ?? "";
506
- const r = await listTemplateDir(browseRoot, path);
507
- if (!r.ok) return send(res, 400, r);
508
- return send(res, 200, r);
509
- } catch (error) {
510
- send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
511
- }
512
- },
513
- });
514
- }
515
- }
516
- }
517
-
518
- // ---------- GET /api/session-conductor/fts-status ----------
519
- // 全文搜索诊断( 并入,原独立插件 dsh-session-search 已合并):
520
- // 报告官方 FTS5(sessionQuery)是否挂载 + 内置 zstd 扫描兜底是否可用。
521
- webServer.register({
522
- kind: "exact",
523
- path: "/api/session-conductor/fts-status",
524
- handler: async (req, res) => {
525
- try {
526
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
527
- if (req.method !== "GET") invalidateSessionListCache();
528
- const sessionQuery = ctx.get("sessionQuery");
529
- const sessionEvents = ctx.get("sessionEvents");
530
- send(res, 200, {
531
- ok: true,
532
- plugin: "dsh-session-conductor",
533
- sessionQueryMounted: sessionQuery !== void 0,
534
- ftsMode: sessionQuery === void 0 ? "unavailable" : "first-search",
535
- builtinScanAvailable: typeof searchSessions === "function",
536
- note:
537
- sessionQuery === void 0
538
- ? "官方 FTS5 服务未挂载(检查 session-query-sqlite openAt 配置),使用内置 zstd 扫描兜底"
539
- : "官方 FTS5 内容搜索已解锁(openAt: first-search),内置 zstd 扫描为兜底",
540
- });
541
- } catch (error) {
542
- send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
543
- }
544
- },
545
- });
546
-
547
- // ---------- GET/POST /api/session-conductor/compaction-model ----------
548
- // 压缩模型选择:会话模型旁单独选压缩用模型。
549
- // GET → {ok, compactionModel: {provider,model}|null, follow: true(跟随会话模型)}
550
- // POST {provider, model} → 设置;POST {follow:true} → 重置为跟随会话模型
551
- webServer.register({
552
- kind: "exact",
553
- path: "/api/session-conductor/compaction-model",
554
- handler: async (req, res) => {
555
- try {
556
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
557
- if (req.method !== "GET") invalidateSessionListCache();
558
- const domain = await pluginDomain(ctx);
559
- const state = domain.global.get();
560
- const cur = state.compactionModel ?? null;
561
- if (req.method === "GET") {
562
- return send(res, 200, { ok: true, compactionModel: cur, follow: cur === null });
563
- }
564
- if (req.method === "POST") {
565
- const body = await readJson(req);
566
- if (body?.follow === true) {
567
- await domain.global.set({ ...state, compactionModel: null });
568
- return send(res, 200, { ok: true, compactionModel: null, follow: true, message: "压缩模型已重置为跟随会话模型" });
569
- }
570
- const provider = String(body?.provider ?? "").trim();
571
- const model = String(body?.model ?? "").trim();
572
- if (!provider || !model) return send(res, 400, { ok: false, error: { code: "bad-request", message: "需要 provider 和 model" } });
573
- await domain.global.set({ ...state, compactionModel: { provider, model } });
574
- return send(res, 200, { ok: true, compactionModel: { provider, model }, follow: false, message: `压缩模型已设为 ${provider}/${model}(重启后生效)` });
575
- }
576
- return send(res, 405, { ok: false, error: { code: "method", message: MSG_METHOD_ONLY_GET_POST } });
577
- } catch (error) {
578
- send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
579
- }
580
- },
581
- });
582
-
583
- // ---------- GET/POST /api/session-conductor/auto-rename-model ----------
584
- // 自动重命名模型选择:设置页「DSH 同款解析选择器」选定。
585
- // GET → {ok, selection: {provider,model}|null, follow: true(跟随会话模型), catalog}
586
- // catalog = 官方 modelCatalog(provider 分组 + 模型 + 默认),供前端渲染选择器。
587
- // POST {provider, model} → 设置;POST {follow:true} → 重置为跟随会话模型。
588
- webServer.register({
589
- kind: "exact",
590
- path: "/api/session-conductor/auto-rename-model",
591
- handler: async (req, res) => {
592
- try {
593
- const domain = await pluginDomain(ctx);
594
- const state = domain.global.get();
595
- const cur = state.autoRenameModel ?? null;
596
- if (req.method === "GET") {
597
- // 官方解析选择器的数据源:provider 分组 + 模型列表 + 部署默认
598
- let catalog = null;
599
- try {
600
- const sc = ctx.get("sessionController");
601
- catalog = sc?.modelCatalog ? await sc.modelCatalog() : null;
602
- } catch (error) {
603
- log(ctx, `auto-rename-model: 官方 modelCatalog 获取失败(前端仍可手动输入): ${String(error?.message ?? error)}`);
604
- catalog = null;
605
- }
606
- return send(res, 200, { ok: true, selection: cur, follow: cur === null, catalog });
607
- }
608
- if (req.method === "POST") {
609
- const body = await readJson(req);
610
- if (body?.follow === true) {
611
- await domain.global.set({ ...state, autoRenameModel: null });
612
- return send(res, 200, { ok: true, selection: null, follow: true, message: "自动重命名模型已重置为跟随会话模型" });
613
- }
614
- const provider = String(body?.provider ?? "").trim();
615
- const model = String(body?.model ?? "").trim();
616
- if (!provider || !model) return send(res, 400, { ok: false, error: { code: "bad-request", message: "需要 provider 和 model" } });
617
- await domain.global.set({ ...state, autoRenameModel: { provider, model } });
618
- return send(res, 200, { ok: true, selection: { provider, model }, follow: false, message: `自动重命名模型已设为 ${provider}/${model}` });
619
- }
620
- return send(res, 405, { ok: false, error: { code: "method", message: MSG_METHOD_ONLY_GET_POST } });
621
- } catch (error) {
622
- send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
623
- }
624
- },
625
- });
626
-
627
- // ---------- GET/POST /api/session-conductor/auto-continue-gate ----------
628
- // 自动续跑全局闸门(与 guardian 联动):
629
- // GET → {ok, gate: "open"|"closed", note}
630
- // POST {gate:"open"|"closed"} → 设置;closed 时一切自动续跑跳过(周期扫描/scan 自动续跑部分)
631
- // guardian 在 DSH 恢复健康时置 closed;用户手动开启或首次手动对话(turn/start user)后自动置 open
632
- webServer.register({
633
- kind: "exact",
634
- path: "/api/session-conductor/auto-continue-gate",
635
- handler: async (req, res) => {
636
- try {
637
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
638
- if (req.method !== "GET") invalidateSessionListCache();
639
- const domain = await pluginDomain(ctx);
640
- const state = domain.global.get();
641
- const cur = state.autoContinueGate ?? "open";
642
- if (req.method === "GET") {
643
- return send(res, 200, {
644
- ok: true,
645
- gate: cur,
646
- note: cur === "closed"
647
- ? "自动续跑全局闸门关闭:一切自动续跑跳过(DSH 刚启动/崩溃恢复后),用户手动开启或第一次手动对话后自动放行"
648
- : "自动续跑全局闸门开放:按原有判定(单会话开关 → 全局默认)",
649
- });
650
- }
651
- if (req.method === "POST") {
652
- const body = await readJson(req);
653
- const gate = String(body?.gate ?? "");
654
- if (gate !== "open" && gate !== "closed") {
655
- return send(res, 400, { ok: false, error: { code: "bad-request", message: 'gate 必须为 "open" 或 "closed"' } });
656
- }
657
- await domain.global.set({ ...state, autoContinueGate: gate });
658
- return send(res, 200, {
659
- ok: true,
660
- gate,
661
- message: gate === "closed" ? "自动续跑闸门已关闭(所有自动续跑暂停)" : "自动续跑闸门已开放",
662
- });
663
- }
664
- return send(res, 405, { ok: false, error: { code: "method", message: MSG_METHOD_ONLY_GET_POST } });
665
- } catch (error) {
666
- send(res, 500, { ok: false, error: { code: "internal", message: String(error?.message ?? error) } });
667
- }
668
- },
669
- });
196
+ // ---------- 模板与设置类路由:整块拆到 features/settings-routes.js ----------
197
+ registerSettingsRoutes({ webServer, ctx });
670
198
 
671
199
  // ---------- 会话操作路由(归档/取消归档/删除/撤回)----------
672
200
  // 从本文件抽出为 lib/features/session-ops-routes.js:apply 只保留「注册入口」,
@@ -687,586 +215,12 @@ export async function apply(ctx, config = {}) {
687
215
  ctx.effect?.(() => () => { try { clearInterval(purgeTimer); } catch { /* 已清理 */ } }, "session-conductor: trash purge timer");
688
216
  } catch { /* 定时器建不起来也不影响手动清理 */ }
689
217
 
690
- // ---------- POST /api/session-conductor/search ----------
691
- // 全文搜索:跨会话搜消息内容,返回命中会话 + 上下文片段
692
- webServer.register({
693
- kind: "exact",
694
- path: "/api/session-conductor/search",
695
- handler: async (req, res) => {
696
- try {
697
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
698
- if (req.method !== "GET") invalidateSessionListCache();
699
- const body = await readJson(req);
700
- const query = typeof body?.query === "string" ? body.query : "";
701
- const scope = ["all", "active", "archived"].includes(body?.scope) ? body.scope : "all";
702
- if (query.trim().length < SEARCH_MIN_QUERY) {
703
- return send(res, 400, { ok: false, error: { code: "bad-request", message: `关键词至少 ${SEARCH_MIN_QUERY} 个字符` } });
704
- }
705
- const searchResult = await searchSessions(ctx, query, { scope });
706
- send(res, 200, { ok: true, ...searchResult });
707
- } catch (error) {
708
- send(res, 400, { ok: false, error: { code: "search-failed", message: String(error?.message ?? error) } });
709
- }
710
- },
711
- });
712
-
713
- // ---------- POST /api/session-conductor/auto-rename ----------
714
- webServer.register({
715
- kind: "exact",
716
- path: "/api/session-conductor/auto-rename",
717
- handler: async (req, res) => {
718
- try {
719
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
720
- if (req.method !== "GET") invalidateSessionListCache();
721
- const body = await readJson(req);
722
- if (typeof body?.sessionId !== "string" || body.sessionId === "") {
723
- return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
724
- }
725
- const enabled = body.enabled === true;
726
- const setting = await patchSwitch(ctx, "autoRename", body.sessionId, { enabled }, { enabled: false });
727
- let analyzed = null;
728
- if (enabled) analyzed = await runAnalysis(ctx, body.sessionId); // 开启后立即分析一次
729
- send(res, 200, { ok: true, enabled: setting.enabled, analyzed });
730
- } catch (error) {
731
- send(res, 400, { ok: false, error: { code: "auto-rename-failed", message: String(error?.message ?? error) } });
732
- }
733
- },
734
- });
735
-
736
- // ---------- POST /api/session-conductor/analyze ----------
737
- webServer.register({
738
- kind: "exact",
739
- path: "/api/session-conductor/analyze",
740
- handler: async (req, res) => {
741
- try {
742
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
743
- if (req.method !== "GET") invalidateSessionListCache();
744
- const body = await readJson(req);
745
- if (typeof body?.sessionId !== "string" || body.sessionId === "") {
746
- return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
747
- }
748
- const analysisResult = await runAnalysis(ctx, body.sessionId, {
749
- manual: true, // 手动 API:跳过限频/新消息数门槛(显式意图即执行)
750
- model: typeof body?.model === "string" && body.model !== "" ? body.model : undefined,
751
- });
752
- if (!analysisResult.ok) return send(res, 409, analysisResult);
753
- send(res, 200, analysisResult);
754
- } catch (error) {
755
- send(res, 400, { ok: false, error: { code: "analyze-failed", message: String(error?.message ?? error) } });
756
- }
757
- },
758
- });
759
-
760
- // ---------- POST /api/session-conductor/continue ----------
761
- // 手动续跑:异步执行(回合可能耗时数分钟),立即返回 accepted,列表用 continueRunning 反映进行中。
762
- webServer.register({
763
- kind: "exact",
764
- path: "/api/session-conductor/continue",
765
- handler: async (req, res) => {
766
- try {
767
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
768
- if (req.method !== "GET") invalidateSessionListCache();
769
- const body = await readJson(req);
770
- if (typeof body?.sessionId !== "string" || body.sessionId === "") {
771
- return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
772
- }
773
- const sessionId = body.sessionId;
774
- const running = continueJobs.has(sessionId) || (() => {
775
- try {
776
- const agent = ctx.get("agents")?.get(sessionId);
777
- return agent?.status === "running";
778
- } catch {
779
- return false;
780
- }
781
- })();
782
- if (running) {
783
- return send(res, 409, { ok: false, error: { code: "busy", message: "该会话已有续跑/回合在进行中" } });
784
- }
785
- continueSession(ctx, sessionId, { auto: false }).then((result) => {
786
- if (!result?.ok) log(ctx, `手动续跑 ${sessionId} 未成功: ${result?.error?.message ?? "?"}`);
787
- }).catch((error) => {
788
- // 手动续跑异步链兜底——continueSession 永不 reject(prepare 段已包 try),
789
- // 此处再兜一层防未来回归把 rejection 逃逸成 unhandledRejection 杀进程。
790
- log(ctx, `手动续跑 ${sessionId} 异常: ${String(error?.message ?? error)}`);
791
- });
792
- send(res, 200, { ok: true, accepted: true, sessionId });
793
- } catch (error) {
794
- send(res, 400, { ok: false, error: { code: "continue-failed", message: String(error?.message ?? error) } });
795
- }
796
- },
797
- });
798
-
799
- // ---------- POST /api/session-conductor/message ----------
800
- // 跨会话消息投递:{targetSessionId, message, fromSessionId?}
801
- // 向目标会话投递用户消息并唤起(live followup / cold resume+followup),不受"中断"限制。
802
- webServer.register({
803
- kind: "exact",
804
- path: "/api/session-conductor/message",
805
- handler: async (req, res) => {
806
- try {
807
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
808
- if (req.method !== "GET") invalidateSessionListCache();
809
- const body = await readJson(req);
810
- const sessionId = body?.targetSessionId || body?.sessionId;
811
- const text = body?.message;
812
- if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string" || text === "") {
813
- return send(res, 400, { ok: false, error: { code: "bad-request", message: "缺少 targetSessionId 或 message" } });
814
- }
815
- // 异步执行(回合可能耗时),立即返回 accepted
816
- const r = await sendMessageToSession(ctx, sessionId, text, { fromSessionId: body?.fromSessionId || "" });
817
- return send(res, r.ok ? 200 : (r.error?.code === "not-found" ? 404 : 400), r);
818
- } catch (error) {
819
- return send(res, 400, { ok: false, error: { code: "message-failed", message: String(error?.message ?? error) } });
820
- }
821
- },
822
- });
823
-
824
- // ---------- POST /api/session-conductor/auto-continue ----------
825
- // 开启/关闭某会话的自动续跑(显式 enabled 覆盖全局默认)。
826
- webServer.register({
827
- kind: "exact",
828
- path: "/api/session-conductor/auto-continue",
829
- handler: async (req, res) => {
830
- try {
831
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
832
- if (req.method !== "GET") invalidateSessionListCache();
833
- const body = await readJson(req);
834
- if (typeof body?.sessionId !== "string" || body.sessionId === "") {
835
- return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
836
- }
837
- const enabled = body.enabled === true;
838
- // 开关落盘 config.json;先取旧 entry 保留记账字段,enabled 以本次为准。
839
- const entry = readSwitch("autoContinue", body.sessionId) ?? {};
840
- await patchSwitch(ctx, "autoContinue", body.sessionId, { ...entry, enabled });
841
- // 开启开关即触发一次续跑(force 跳过失败重试延迟)——原先要等最长
842
- // scanIntervalMs(默认 5 分钟)周期扫描才续,用户体感「打开后处理中很久」。
843
- // 关闭开关不触发;开启是幂等触发点,重复点不会叠加(内部有会话串行锁 + 门槛)。
844
- if (enabled) {
845
- runAutoContinueSession(ctx, body.sessionId, { force: true }).catch((error) => {
846
- log(ctx, `开启自动续跑后立即续跑失败 ${body.sessionId}: ${String(error?.message ?? error)}`);
847
- });
848
- }
849
- send(res, 200, { ok: true, enabled, sessionId: body.sessionId, triggered: enabled });
850
- } catch (error) {
851
- send(res, 400, { ok: false, error: { code: "auto-continue-failed", message: String(error?.message ?? error) } });
852
- }
853
- },
854
- });
855
-
856
- // ---------- POST /api/session-conductor/scan ----------
857
- // 立即扫描一次全部会话并自动续跑(测试/手动触发用)。
858
- webServer.register({
859
- kind: "exact",
860
- path: "/api/session-conductor/scan",
861
- handler: async (req, res) => {
862
- try {
863
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
864
- if (req.method !== "GET") invalidateSessionListCache();
865
- runAutoScan(ctx).then((count) => {
866
- log(ctx, `手动扫描完成(排队 ${count ?? 0})`);
867
- }).catch((error) => {
868
- log(ctx, `手动扫描失败: ${String(error?.message ?? error)}`);
869
- });
870
- send(res, 200, { ok: true, accepted: true });
871
- } catch (error) {
872
- send(res, 400, { ok: false, error: { code: "scan-failed", message: String(error?.message ?? error) } });
873
- }
874
- },
875
- });
876
-
877
- // ---------- POST /api/session-conductor/detach ----------
878
- // 手动释放(置为不活跃):把 live 空闲会话拆回冷状态(日志保留,可重新挂载)。
879
- webServer.register({
880
- kind: "exact",
881
- path: "/api/session-conductor/detach",
882
- handler: async (req, res) => {
883
- try {
884
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
885
- if (req.method !== "GET") invalidateSessionListCache();
886
- const body = await readJson(req);
887
- if (typeof body?.sessionId !== "string" || body.sessionId === "") {
888
- return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
889
- }
890
- const detachResult = await detachSessionAgent(ctx, body.sessionId);
891
- if (!detachResult.ok) return send(res, 409, detachResult);
892
- send(res, 200, detachResult);
893
- } catch (error) {
894
- send(res, 400, { ok: false, error: { code: "detach-failed", message: String(error?.message ?? error) } });
895
- }
896
- },
897
- });
898
-
899
- // ---------- POST /api/session-conductor/detach-all ----------
900
- // 释放全部 live 空闲会话(跳过运行中 / subagent / 续跑中)。
901
- webServer.register({
902
- kind: "exact",
903
- path: "/api/session-conductor/detach-all",
904
- handler: async (req, res) => {
905
- try {
906
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
907
- if (req.method !== "GET") invalidateSessionListCache();
908
- const detachAllResult = await detachAllIdleSessions(ctx);
909
- send(res, 200, detachAllResult);
910
- } catch (error) {
911
- send(res, 400, { ok: false, error: { code: "detach-all-failed", message: String(error?.message ?? error) } });
912
- }
913
- },
914
- });
915
-
916
- // ---------- 会话日志修复(扫描/修复损坏会话) ----------
917
- webServer.register({
918
- kind: "exact",
919
- path: "/api/session-conductor/repair-sessions",
920
- handler: async (req, res) => {
921
- try {
922
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
923
- if (req.method !== "GET") invalidateSessionListCache();
924
- const body = await readJson(req);
925
- const { supportsRepair } = await lazyRepair();
926
- if (!supportsRepair(ctx)) {
927
- return send(res, 501, { ok: false, error: { code: "unsupported", message: "当前持久化后端不支持修复(需要 jsonl 后端)" } });
928
- }
929
- const dryRun = body?.dryRun === true;
930
- const { scanCorruptSessions, repairCorruptSessions } = await lazyRepair();
931
- const report = dryRun ? await scanCorruptSessions(ctx) : await repairCorruptSessions(ctx, { dryRun: false });
932
- if (report.error) return send(res, 500, { ok: false, error: { code: "repair-failed", message: report.error } });
933
- send(res, 200, { ok: true, dryRun, report });
934
- } catch (error) {
935
- send(res, 400, { ok: false, error: { code: "repair-failed", message: String(error?.message ?? error) } });
936
- }
937
- },
938
- });
939
-
940
- // ---------- POST /api/session-conductor/repair-frames ----------
941
- // 会话日志 zstd 帧修复(救援恢复,):会话列表消失/corrupt Zstandard 时,
942
- // 扫描并修复帧损坏(逐帧解码→多帧正确写回,自动备份)。与 repair-sessions 互补:
943
- // repair-sessions 修 tool-result content 结构;repair-frames 修 zstd 帧格式。
944
- webServer.register({
945
- kind: "exact",
946
- path: "/api/session-conductor/repair-frames",
947
- handler: async (req, res) => {
948
- try {
949
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
950
- if (req.method !== "GET") invalidateSessionListCache();
951
- const body = await readJson(req);
952
- const dryRun = body?.dryRun === true;
953
- const { scanCorruptFrames, repairCorruptFrames } = await lazyRepair();
954
- const report = dryRun ? await scanCorruptFrames() : await repairCorruptFrames({ dryRun: false });
955
- send(res, 200, { ok: true, dryRun, report });
956
- } catch (error) {
957
- send(res, 400, { ok: false, error: { code: "repair-frames-failed", message: String(error?.message ?? error) } });
958
- }
959
- },
960
- });
961
-
962
- // ---------- POST /api/session-conductor/repair-seq-gap ----------
963
- // 会话日志 seq-gap + token-surface 修复(实测):
964
- // seq gap 错位(spliced 拼接未重编号)+ replace 引用缺失 + compaction shadowedRange 未映射
965
- // 三层叠加导致 history unavailable(token surface: no adjacent shadow price)。
966
- // dryRun=true 扫描全部会话列出疑似 seq-gap;sessionId 指定则修复单个(自动备份 .seq-gap-backup/)。
967
- webServer.register({
968
- kind: "exact",
969
- path: "/api/session-conductor/repair-seq-gap",
970
- handler: async (req, res) => {
971
- try {
972
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
973
- if (req.method !== "GET") invalidateSessionListCache();
974
- const body = await readJson(req);
975
- const sessionsRoot = path.join(process.env.DSH_HOME || path.join(process.env.HOME ?? "", ".dsh"), "sessions");
976
- if (body?.dryRun === true) {
977
- const { scanSeqGapSessions } = await lazySeqGap();
978
- const list = await scanSeqGapSessions(sessionsRoot);
979
- send(res, 200, { ok: true, dryRun: true, corrupt: list, total: list.length });
980
- return;
981
- }
982
- const sessionId = body?.sessionId;
983
- if (!sessionId) { send(res, 400, { ok: false, error: { code: "session-id-required", message: "need sessionId" } }); return; }
984
- // 定位会话日志路径(sessionId 需含 session- 前缀,cwd 目录未知 → 全仓查找)
985
- const { readdirSync, existsSync } = await lazyFs();
986
- let targetPath = null;
987
- for (const proj of readdirSync(sessionsRoot, { withFileTypes: true })) {
988
- if (!proj.isDirectory()) continue;
989
- const p = findSessionLog(path.join(sessionsRoot, proj.name, sessionId));
990
- if (p) { targetPath = p; break; }
991
- }
992
- if (!targetPath) { send(res, 404, { ok: false, error: { code: "session-not-found", message: sessionId } }); return; }
993
- const { repairSeqGap } = await lazySeqGap();
994
- const report = await repairSeqGap(targetPath, { dryRun: false });
995
- send(res, report.ok ? 200 : 400, { ok: report.ok, sessionId, report });
996
- } catch (error) {
997
- send(res, 400, { ok: false, error: { code: "repair-seq-gap-failed", message: String(error?.message ?? error) } });
998
- }
999
- },
1000
- });
1001
-
1002
- // ---------- POST /api/session-conductor/repair-eio ----------
1003
- // 会话日志 EIO 坏块修复(实测固化,来源 sa6400-nested-vm-io-panic skill):
1004
- // 底层存储(BTRFS csum 损坏 / iSCSI LUN I/O 错误)导致 session.jsonl.zstd 某些 4KB 块
1005
- // 读取抛 EIO(Errno 5)→ 网关 observe 会话报 "EIO: i/o error, read"。
1006
- // 修复 = 块级探测找出第一个 EIO 边界 → 读 [0, 边界) 完好前缀 → 原子写回
1007
- // (BTRFS COW 换新块)→ DSH 加载时 readZstdPrefix/commitRepair 自动收尾 torn tail。
1008
- // dryRun=true 全仓扫描;sessionId 指定则修复单个(全仓定位)。
1009
- webServer.register({
1010
- kind: "exact",
1011
- path: "/api/session-conductor/repair-eio",
1012
- handler: async (req, res) => {
1013
- try {
1014
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
1015
- if (req.method !== "GET") invalidateSessionListCache();
1016
- const body = await readJson(req);
1017
- const { sessionsRootOf: eioSessionsRootOf } = await lazyEio();
1018
- const sessionsRoot = eioSessionsRootOf();
1019
- if (body?.dryRun === true) {
1020
- const { scanEioSessions } = await lazyEio();
1021
- const list = await scanEioSessions(sessionsRoot);
1022
- send(res, 200, { ok: true, dryRun: true, eio: list.eio, total: list.total, healthy: list.healthy, error: list.error });
1023
- return;
1024
- }
1025
- const sessionId = body?.sessionId;
1026
- if (sessionId) {
1027
- // 定位会话日志路径(sessionId 需含 session- 前缀,cwd 目录未知 → 全仓查找)
1028
- const { readdirSync, existsSync } = await lazyFs();
1029
- let targetPath = null;
1030
- for (const proj of readdirSync(sessionsRoot, { withFileTypes: true })) {
1031
- if (!proj.isDirectory()) continue;
1032
- const p = findSessionLog(path.join(sessionsRoot, proj.name, sessionId));
1033
- if (p) { targetPath = p; break; }
1034
- }
1035
- if (!targetPath) { send(res, 404, { ok: false, error: { code: "session-not-found", message: sessionId } }); return; }
1036
- const { repairEioFile } = await lazyEio();
1037
- const report = await repairEioFile(targetPath);
1038
- send(res, report.ok ? 200 : 400, { ok: report.ok, sessionId, report });
1039
- return;
1040
- }
1041
- const { repairEioSessions } = await lazyEio();
1042
- const report = await repairEioSessions({ dryRun: false });
1043
- send(res, 200, { ok: true, dryRun: false, report });
1044
- } catch (error) {
1045
- send(res, 400, { ok: false, error: { code: "repair-eio-failed", message: String(error?.message ?? error) } });
1046
- }
1047
- },
1048
- });
1049
-
1050
- // ---------- POST /api/session-conductor/repair-dual-format ----------
1051
- // 双格式会话修复:会话目录同时存在 session.jsonl(明文)+ session.jsonl.zstd
1052
- // (压缩)→ 官方 listArtifacts() 抛 encodingMismatch → 会话列表全失败(侧边栏会话消失)。
1053
- // 纯磁盘级扫描(不走 sessionPersistence,后者自身会被 encodingMismatch 阻断),
1054
- // 把多余明文移入 .dual-format-backup/ 保留 zstd 官方格式;dryRun=true 只扫不写。
1055
- webServer.register({
1056
- kind: "exact",
1057
- path: "/api/session-conductor/repair-dual-format",
1058
- handler: async (req, res) => {
1059
- try {
1060
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
1061
- if (req.method !== "GET") invalidateSessionListCache();
1062
- const body = await readJson(req);
1063
- const dryRun = body?.dryRun === true;
1064
- // live 会话跳过:正在写入的会话不应被移动(按 id 查 sessions 服务)
1065
- let skipIds = [];
1066
- try {
1067
- const sessionsSvc = ctx.get("sessions");
1068
- skipIds = (sessionsSvc?.list?.() ?? []).map((s) => s.id);
1069
- } catch {
1070
- // sessions 服务不可用时不做 live 跳过(保守:仍只移非 live 目录)
1071
- }
1072
- const report = dryRun
1073
- ? await (await lazyRepair()).scanDualFormatSessions()
1074
- // 修复:原为裸调 `repairDualFormatSessions(...)`,但该名字从未导入 ⇒ 抛
1075
- // ReferenceError 被下面的 catch 变成 400「repair-dual-format-failed」,非 dryRun 的
1076
- // 双格式修复实际从未执行过。改为与上一行同源的懒加载命名空间调用。
1077
- : await (await lazyRepair()).repairDualFormatSessions({ dryRun: false, skipIds });
1078
- if (report.error) {
1079
- send(res, 500, { ok: false, error: { code: "repair-dual-format-failed", message: report.error } });
1080
- return;
1081
- }
1082
- send(res, 200, { ok: true, dryRun, skipped: report.skipped ?? 0, report });
1083
- } catch (error) {
1084
- send(res, 400, { ok: false, error: { code: "repair-dual-format-failed", message: String(error?.message ?? error) } });
1085
- }
1086
- },
1087
- });
1088
-
1089
- // ---------- POST /api/session-conductor/value-analysis ----------
1090
- // 会话价值分析( 规则判定 + 特征/LLM/关键词价值):
1091
- // 规则分类 completed/unfinished/stale/active + 最后回复摘要;
1092
- // 高/低价值 = assessValue 特征评分(活跃/长度/完成/未完成/细节补充);
1093
- // 可选 body.keywords=[...] 记录指定关键词 → 标题/最后用户消息命中任一关键词的会话无条件最高价值;
1094
- // 可选 body.llm=true 额外用 LLM 打分(fail-soft)。
1095
- webServer.register({
1096
- kind: "exact",
1097
- path: "/api/session-conductor/value-analysis",
1098
- handler: async (req, res) => {
1099
- try {
1100
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
1101
- if (req.method !== "GET") invalidateSessionListCache();
1102
- const body = await readJson(req);
1103
- const staleDays = typeof body?.staleDays === "number" && Number.isFinite(body.staleDays)
1104
- ? Math.min(MAX_STALE_DAYS, Math.max(1, Math.round(body.staleDays)))
1105
- : 3;
1106
- const useLlm = body?.llm === true;
1107
- const sessions = await buildSessionListCached(ctx); // 价值分析走缓存(容忍 5s 陈旧,避免与面板刷新叠加扫描)
1108
- // 流式(`?stream=1` 或 Accept: application/x-ndjson):**每分析完一个会话就推一行**。
1109
- // 前端拿到 value==="low" 立刻勾上(分析出一个勾一个),不必等全量跑完 ——
1110
- // 全量要逐个读上百份日志,慢的是读取本身,而读取是天然可增量的。
1111
- // 判定规则与批量路径同源(value.js 的 classifySessionLevel),不在这里另写一套。
1112
- const wantValueStream = /application\/x-ndjson/i.test(req.headers?.accept ?? "")
1113
- || String(req.url ?? "").includes("stream=1");
1114
- if (wantValueStream) {
1115
- // body.ids:只分析这些会话(面板已先用归档/时间/目录在本地筛过一轮,
1116
- // 没必要把全量会话都读一遍事件)。不传 = 全量。
1117
- const wantedIds = Array.isArray(body?.ids)
1118
- ? new Set(body.ids.filter((id) => typeof id === "string" && id !== ""))
1119
- : null;
1120
- const streamTargets = wantedIds ? sessions.filter((s) => wantedIds.has(s.id)) : sessions;
1121
- res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
1122
- res.setHeader("Cache-Control", "no-store");
1123
- if (res.socket?.setNoDelay) res.socket.setNoDelay(true);
1124
- res.write(JSON.stringify({ ok: true, stream: true, total: streamTargets.length }) + "\n");
1125
- try {
1126
- if (typeof res.flushHeaders === "function") res.flushHeaders();
1127
- } catch { /* 老宿主不支持 flush,不影响主流程 */ }
1128
- const { classifySessionLevel } = await lazyValue();
1129
- let done = 0;
1130
- for (const s of streamTargets) {
1131
- done += 1;
1132
- // ① 索引命中(内容没变 + 未超 TTL)→ 零 I/O 直接给结论
1133
- const cachedVerdict = getValueVerdict(ctx, s.id, s.updatedAt);
1134
- if (cachedVerdict) {
1135
- try {
1136
- res.write(JSON.stringify({
1137
- id: s.id, value: cachedVerdict.value, score: cachedVerdict.score,
1138
- reason: cachedVerdict.reason, cached: true, done, total: streamTargets.length,
1139
- }) + "\n");
1140
- } catch { break; }
1141
- continue;
1142
- }
1143
- // ② 没索引 / 会话变过 / 超 TTL → 真读一次日志
1144
- let events = [];
1145
- try {
1146
- const found = await sessionEventsOf(ctx, s.id);
1147
- events = found?.events ?? [];
1148
- } catch { /* 读不到就当空事件:会走 artifact-safe 保守判 high,不误删 */ }
1149
- const verdict = classifySessionLevel(s, events, new Date(), staleDays);
1150
- putValueVerdict(ctx, s.id, s.updatedAt, verdict);
1151
- try {
1152
- res.write(JSON.stringify({ ...verdict, cached: false, done, total: streamTargets.length }) + "\n");
1153
- } catch { break; }
1154
- }
1155
- saveValueIndexNow(ctx); // 本次算出来的结论立刻落盘(下次筛选直接命中索引)
1156
- try { res.end(); } catch { /* 连接已断 */ }
1157
- return;
1158
- }
1159
- // 一次性读取各会话事件(复用,避免大会话重复解压导致超时/失败)
1160
- const eventsById = {};
1161
- // 逐个会话读取最后 assistant 文本 + 最后用户消息(损坏/不可读会话跳过,用空串)
1162
- const texts = {};
1163
- const userTexts = {};
1164
- for (const s of sessions) {
1165
- try {
1166
- const found = await sessionEventsOf(ctx, s.id);
1167
- if (found) {
1168
- eventsById[s.id] = found.events || [];
1169
- const { lastAssistantText, lastUserText, filterSessionsByKeywords } = await lazyValue();
1170
- texts[s.id] = lastAssistantText(found.events);
1171
- userTexts[s.id] = lastUserText(found.events);
1172
- }
1173
- } catch {
1174
- /* 单会话失败跳过 */
1175
- }
1176
- }
1177
- // 可选 LLM 高/低价值判断
1178
- let llmById = {};
1179
- if (useLlm) {
1180
- llmById = await analyzeValueWithLlm(ctx, sessions, texts, (m) => log(ctx, m));
1181
- }
1182
- // 特征评分(活跃/长度/完成/未完成/细节补充)+ 关键词命中→无条件最高
1183
- const featuresById = {};
1184
- for (const s of sessions) {
1185
- try {
1186
- const { buildValueFeatures } = await lazyValue();
1187
- featuresById[s.id] = buildValueFeatures(eventsById[s.id] || [], s, new Date());
1188
- } catch {
1189
- const { buildValueFeatures: buildValueFeatures2 } = await lazyValue();
1190
- featuresById[s.id] = buildValueFeatures2([], s, new Date());
1191
- }
1192
- }
1193
- const keywords = Array.isArray(body?.keywords) ? body.keywords : [];
1194
- const { analyzeValuesWithKeywords } = await lazyValue();
1195
- const analysisSummary = analyzeValuesWithKeywords(sessions, texts, userTexts, featuresById, keywords, llmById, new Date(), staleDays);
1196
- const counts = {};
1197
- for (const key of Object.keys(analysisSummary)) counts[key] = analysisSummary[key].length;
1198
- send(res, 200, {
1199
- ok: true,
1200
- staleDays,
1201
- llm: !!useLlm,
1202
- keywords: keywords.length ? keywords : void 0,
1203
- keywordHits: keywords.length ? filterSessionsByKeywords(sessions, texts, userTexts, keywords, new Date()) : [],
1204
- generatedAt: Date.now(),
1205
- counts,
1206
- completed: analysisSummary.completed,
1207
- unfinished: analysisSummary.unfinished,
1208
- stale: analysisSummary.stale,
1209
- active: analysisSummary.active,
1210
- high: analysisSummary.high,
1211
- low: analysisSummary.low,
1212
- });
1213
- } catch (error) {
1214
- send(res, 400, { ok: false, error: { code: "value-analysis-failed", message: String(error?.message ?? error) } });
1215
- }
1216
- },
1217
- });
1218
-
1219
- // ---------- 任务完成汇报(task-completion-report 管道) ----------
1220
- // 收尾约定不再由代码写死注入 systemPrompt——改由模板注入「收尾模板」槽位承担
1221
- // (设置 → 会话管理 → 模板注入 → closing,可自定义内容)。
1222
- // LLM 侧的 render/check 工具已移除,只剩下面的 HTTP 端点(面板/自检用),故 tools 如实回报空列表。
1223
- webServer.register({
1224
- kind: "exact",
1225
- path: "/api/task-completion/status",
1226
- handler: async (req, res) => {
1227
- send(res, 200, {
1228
- ok: true,
1229
- name: "dsh-session-conductor",
1230
- skill: "task-completion-report",
1231
- conventionSource: "template-inject:closing(不再代码写死,由收尾模板槽位注入)",
1232
- tools: []
1233
- });
1234
- },
1235
- });
1236
-
1237
- webServer.register({
1238
- kind: "exact",
1239
- path: "/api/task-completion/render",
1240
- handler: async (req, res) => {
1241
- try {
1242
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
1243
- if (req.method !== "GET") invalidateSessionListCache();
1244
- const body = await readJson(req);
1245
- const block = renderCompletionBlock(body);
1246
- send(res, 200, { ok: true, block });
1247
- } catch (error) {
1248
- send(res, 400, { ok: false, error: { code: "render-failed", message: String(error?.message ?? error) } });
1249
- }
1250
- },
1251
- });
1252
-
1253
- webServer.register({
1254
- kind: "exact",
1255
- path: "/api/task-completion/check",
1256
- handler: async (req, res) => {
1257
- try {
1258
- // 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
1259
- if (req.method !== "GET") invalidateSessionListCache();
1260
- const body = await readJson(req);
1261
- if (typeof body?.text !== "string") {
1262
- return send(res, 400, { ok: false, error: { code: "bad-request", message: "缺少 text" } });
1263
- }
1264
- send(res, 200, { ok: true, ...checkCompletionText(body.text) });
1265
- } catch (error) {
1266
- send(res, 400, { ok: false, error: { code: "check-failed", message: String(error?.message ?? error) } });
1267
- }
1268
- },
1269
- });
218
+ // ---------- /search 已并入下面的 session-action-routes(同一类「对会话做动作」)----------
219
+ // 与 /auto-rename、/analyze、/continue、/message、/auto-continue、/scan、/detach、
220
+ // /detach-all 以及 /repair-* 五条修复路由一起,整块拆到 features/session-action-routes.js。
221
+ registerSessionActionRoutes({ webServer, ctx });
222
+ // ---------- 价值分析 + 任务完成汇报:整块拆到 features/value-report-routes.js ----------
223
+ registerValueReportRoutes({ webServer, ctx });
1270
224
 
1271
225
  log(ctx, `API 路由已注册 (/api/session-conductor/* + /api/task-completion/*);自动续跑: enabled=${cfg.enabled}, 默认开启=${cfg.defaultAutoContinue}, maxAttached=${cfg.maxAttached}, maxConcurrent=${cfg.maxConcurrent}`);
1272
226
  });
@@ -1496,177 +450,8 @@ export async function apply(ctx, config = {}) {
1496
450
  ctx.inject(["tools"], (tctx) => {
1497
451
  const tools = tctx.get("tools");
1498
452
  if (!defineTool) return; // 无 DSH 依赖时不注册(避免 ReferenceError 炸掉整个 inject 回调)
1499
- tools.register(defineTool({
1500
- name: "list_models",
1501
- description: "列出当前可用模型(provider → models[])。切模型前先用它拿到确切 model id,不要凭记忆猜模型名。",
1502
- parameters: {},
1503
- output: {
1504
- schema: { type: "string" },
1505
- render(args, value) {
1506
- return [{ type: "text", text: String(value) }];
1507
- }
1508
- },
1509
- async execute() {
1510
- try {
1511
- const sc = ctx.get("sessionController");
1512
- if (!sc?.modelCatalog) return "modelCatalog 服务不可用(无法列出模型)";
1513
- const catalog = await sc.modelCatalog();
1514
- const groups = catalog?.groups ?? [];
1515
- if (groups.length === 0) return "模型目录为空(检查 provider 配置)";
1516
- return groups.map((g) => {
1517
- const models = (g.models ?? [])
1518
- .map((m) => ` - ${m.id}${m.name && m.name !== m.id ? `(${m.name})` : ""}`)
1519
- .join("\n");
1520
- return `${g.id}${g.name && g.name !== g.id ? `(${g.name})` : ""}:\n${models}`;
1521
- }).join("\n\n");
1522
- } catch (error) {
1523
- return `列出模型失败:${String(error?.message ?? error)}`;
1524
- }
1525
- }
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
-
1556
- tools.register(defineTool({
1557
- name: "set_member_model",
1558
- description: "给指定成员/子代理切换模型(只影响该成员的会话,不动其他成员)。target 传成员名/sessionId/标题关键字;provider 是 list_models 里的分组名(如 agnes / wb),model 是该分组下的模型 id(如 global:deepseek-v4.1-flash)。切换前会校验组合是否存在,避免切到无效组合导致回合失败。",
1559
- parameters: {
1560
- target: { type: "string", required: true, description: "目标成员:成员名(如 model-test)、sessionId 或标题关键字" },
1561
- provider: { type: "string", required: true, description: "provider 分组名(list_models 输出的顶层分组,如 agnes / wb / ai-gateway)" },
1562
- model: { type: "string", required: true, description: "该 provider 下的模型 id(含前缀,如 global:deepseek-v4.1-flash / agnes-3.0-flash)" }
1563
- },
1564
- output: {
1565
- schema: { type: "string" },
1566
- render(args, value) {
1567
- return [{ type: "text", text: String(value) }];
1568
- }
1569
- },
1570
- async execute(args, exec) {
1571
- const provider = String(args?.provider ?? "").trim();
1572
- const model = String(args?.model ?? "").trim();
1573
- if (!provider || !model) return "切换失败:provider 和 model 都必填(先用 list_models 查确切值)";
1574
- // 先校验组合有效性——无效组合会让后续回合的模型请求被拒(表现为「切换生效但回合失败」)
1575
- const pair = await validateModelPair(ctx, provider, model);
1576
- if (pair.error) return `切换失败:${pair.error}`;
1577
- // 显式传发起方:与聊天指令 /model-switch 走**同一条**解析路径(抽公共后两边只此一份)
1578
- const found = findTargetAgent(ctx, args?.target, { caller: exec?.agent ?? null });
1579
- if (found.error) return `切换失败:${found.error}`;
1580
- const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
1581
- if (switchResult.error) return `切换失败:${switchResult.error}`;
1582
- const memberLabel = found.member?.name ? `${found.member.name}(${found.sessionId})` : found.sessionId;
1583
- // 查证(注册 job,不阻塞):登记待查证项 → 该成员下次发起请求时由 agent/request 事件判定
1584
- // → 结果注入回本次调用方。同时立刻附一份快照:最近一次实际请求 + 最近一次切换意图。
1585
- // 判定只认「请求时真实使用的模型」(request/header),不信注入的提示语。
1586
- try {
1587
- registerSwitchWatch(found.sessionId, provider, model, exec?.agent?.session?.id ?? null);
1588
- } catch (error) {
1589
- log(ctx, `切换查证登记失败(不影响本次切换): ${String(error?.message ?? error)}`);
1590
- }
1591
- let snap = "";
1592
- let verdict = "";
1593
- try {
1594
- ({ snap, verdict } = await describeSwitchSnapshot(ctx, found.sessionId, provider, model));
1595
- } catch (error) {
1596
- snap = `【查证·快照】读取会话日志失败:${String(error?.message ?? error)}`;
1597
- }
1598
- // 复核命令按**当前插件安装位置**生成(不写死本机路径),可直接复制执行
1599
- const scriptUrl = new URL("../scripts/check-member-model.mjs", import.meta.url);
1600
- const cmd = `node ${decodeURIComponent(scriptUrl.pathname)} ${found.sessionId}`;
1601
- return [
1602
- `已把成员 ${memberLabel}(${found.matched} 匹配)切到 ${provider}/${model};生效方式:${switchResult.via}`,
1603
- // 组合校验默认关闭(避免 @Remote 卡顿);跳过时如实告知,别让人以为「拼错也会被拦住」
1604
- pair.note ? `【校验】${pair.note}` : "",
1605
- snap,
1606
- verdict,
1607
- "【查证·任务】已登记(不阻塞):该成员下次发起请求时按事件判定,结果会在下一次 step 注入给你",
1608
- `复核命令(可直接复制):\n${cmd}`,
1609
- ].filter(Boolean).join("\n");
1610
- }
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
-
1643
- // 上下文用量自查工具:让 AI 自己按需查占用与构成,而不是每轮把数值注入系统提示词
1644
- // (每轮注入会让系统提示词前缀每轮变化 → prompt cache 失效 → 成本上升)。
1645
- if (registerContextUsageTool(ctx, defineTool, tools)) {
1646
- log(ctx, "上下文用量工具已注册 (context_usage)");
1647
- }
1648
- // 跨会话消息 + 指定模型新建会话(三个工具一次注册)。
1649
- // 整体包 try/catch:注册期抛错会连累同批其它工具(本项目历史事故),失败只留痕、不阻断。
1650
- try {
1651
- if (registerSessionMessagingTools(ctx, defineTool, tools)) {
1652
- log(ctx, "跨会话工具已注册 (list_sessions / send_to_session / create_session)");
1653
- }
1654
- } catch (error) {
1655
- log(ctx, `跨会话工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
1656
- }
1657
- try {
1658
- // 记忆取回的手动通道:让 AI 自己按需回溯(自动注入之外的补充)
1659
- if (registerMemoryRecallTool(ctx, defineTool, tools)) {
1660
- log(ctx, "记忆取回工具已注册 (recall_memory)");
1661
- }
1662
- } catch (error) {
1663
- log(ctx, `记忆取回工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
1664
- }
1665
- log(ctx, "成员模型工具已注册 (list_models / set_member_model)");
1666
- // 聊天框指令 `/model-switch`:用户自己就能看/切成员模型的入口(与 AI 工具同一套切换 + 查证路径)。
1667
- // 异步注册 + 独立兜底:宿主 commands 服务或 brand 包不可用时只留痕,绝不影响其它功能(同既有约定)。
1668
- registerModelSwitchCommand(ctx)
1669
- .then((ok) => { if (ok) log(ctx, "聊天框指令已注册 (/model-switch)"); })
1670
- .catch((error) => log(ctx, `聊天框指令注册失败(不影响其它功能): ${String(error?.message ?? error)}`));
453
+ // 成员模型/权限相关的 5 个工具与 2 条聊天指令:整块拆到 member-model/tools.js
454
+ // (原先内联在这里 293 行,把 inject 回调撑成巨型函数;handler 体与错误隔离策略不变)。
455
+ registerMemberModelTools({ ctx, tools, defineTool });
1671
456
  });
1672
457
  }