dsh-advisor-group 0.1.2 → 0.1.4

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/lib/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import Schema from "@deepseek-ai/schemastery";
2
2
  import { randomUUID } from "node:crypto";
3
- import { appendFileSync, mkdirSync, promises, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { appendFileSync, mkdirSync, promises, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join } from "node:path";
6
6
  import { KNOWN_SESSION_EVENT_TYPES, Session } from "@deepseek-ai/dsh-session";
@@ -114,12 +114,29 @@ const QuotaConfig = Schema.object({
114
114
  enabled: Schema.boolean().default(true).description("Enable the daily new-consultation cap (cost safety valve)."),
115
115
  maxPerDay: Schema.natural().min(1).max(1e5).default(50).description("Max new consultations per UTC day (1–100000); ignored when enabled is false.")
116
116
  });
117
+ const SwarmConfig = Schema.object({
118
+ enabled: Schema.boolean().default(true).description("Master switch for cross-session asks; off makes ask_session / list_sessions / ask_result refuse with swarm-disabled."),
119
+ defaultMode: Schema.union([Schema.const("wait"), Schema.const("detach")]).default("wait").description("Default response mode when the caller omits both waitMs and detach: wait (block for the answer) or detach (return an askId, read later with ask_result)."),
120
+ defaultWaitMs: Schema.number().min(1e3).max(6e5).default(18e4).description("Default wait ceiling in milliseconds when the caller omits waitMs (1–10 minutes)."),
121
+ resultTtlHours: Schema.number().min(1).max(720).default(24).description("How long detached ask results are kept on disk, in hours (clamped to 1–720 before pruning)."),
122
+ rosterIncludeSubagents: Schema.boolean().default(false).description("List subagent sessions in the session roster."),
123
+ routingEnabled: Schema.boolean().default(true).description("Route an unnamed question to candidate sessions by keyword (no model call) instead of guessing a target; off keeps the old missing-target refusal."),
124
+ routingTopN: Schema.number().min(1).max(8).default(3).description("How many routing candidates ask_session returns when the caller names no target (1–8)."),
125
+ routingEngine: Schema.union([Schema.const("local"), Schema.const("jev")]).default("local").description("Which engine orders the routing candidates: local (keyword scorer alone, default) or jev (recall stays local, then Jev re-ranks the closed candidate list; any Jev failure falls back to the local order silently). Jev reuses the trigger Jev credentials/endpoint."),
126
+ routingAsk: Schema.union([
127
+ Schema.const("off"),
128
+ Schema.const("gated"),
129
+ Schema.const("always")
130
+ ]).default("gated").description("Whether a user turn asks Jev for a cross-session suggestion: off (never), gated (default; only when the local, network-free prefilter thinks the turn points at another session/task) or always (one batched Jev call on every turn). The answer is only a one-turn hint; nothing is delivered."),
131
+ routingExclude: Schema.array(Schema.string()).default([]).description("Sessions excluded from the whole swarm: each entry matches a session id (exact, case-insensitive) or is a case-insensitive substring of its title / fallback display name. Excluded sessions are hidden from the roster and routing candidates, their routing aliases are dropped, and an explicit ask_session target that resolves to one is refused.")
132
+ });
117
133
  const Config = Schema.object({
118
134
  enabled: Schema.boolean().default(true).volatile(),
119
135
  discussion: DiscussionConfig.volatile(),
120
136
  trigger: TriggerConfig.volatile(),
121
137
  ui: UiConfig.volatile(),
122
138
  quota: QuotaConfig.volatile(),
139
+ swarm: SwarmConfig.volatile(),
123
140
  advisors: Schema.array(AdvisorConfig).default([]).volatile()
124
141
  });
125
142
  function unwrapVolatileConfig(value) {
@@ -221,7 +238,7 @@ function formatTranscript(entries) {
221
238
  * report `truncated` so the caller can surface it to the user instead of
222
239
  * treating the reply as complete. A caller-initiated abort still propagates.
223
240
  */
224
- async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutMs) {
241
+ async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutMs, sessionId) {
225
242
  const userText = formatTranscript(transcript);
226
243
  const messages = [createUserMessage({
227
244
  content: [{
@@ -240,6 +257,7 @@ async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutM
240
257
  ...reasoningEffort === void 0 ? {} : { reasoningEffort },
241
258
  temperature: advisor.temperature,
242
259
  maxTokens: advisor.maxTokens ?? 16384,
260
+ ...sessionId === void 0 ? {} : { sessionId },
243
261
  signal: pair.signal
244
262
  };
245
263
  let text = "";
@@ -279,6 +297,12 @@ async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutM
279
297
  }
280
298
  //#endregion
281
299
  //#region src/providers/presets.ts
300
+ /**
301
+ * Stable routing key OpenCode Go asks every client for. A fixed value makes the
302
+ * same trade-off the DSH-side route took on 2026-09-17 (static header: works
303
+ * immediately, costs cache locality) — per-conversation ids stay an upgrade.
304
+ */
305
+ const OPENCODE_GO_SESSION_ID = "dsh-advisor-group";
282
306
  const PROVIDER_PRESETS = {
283
307
  deepseek: {
284
308
  id: "deepseek",
@@ -700,6 +724,72 @@ const PROVIDER_PRESETS = {
700
724
  modelsBaseURL: "https://openrouter.ai/api/v1",
701
725
  modelsAuthMode: "bearer",
702
726
  notes: "OpenRouter Anthropic Skin;使用 ANTHROPIC_BASE_URL=https://openrouter.ai/api + ANTHROPIC_AUTH_TOKEN=OpenRouter Key(Bearer)。"
727
+ },
728
+ /**
729
+ * OpenCode Go — subscription plan ($10/月 Go、$40/月 Go Plus). KB X:
730
+ * `01-AI大模型供应/opencode/05-Console/05-Go.md`(2026-10-06 收录)。
731
+ *
732
+ * Live probe (2026-10-06, `GET/POST https://opencode.ai/zen/go/v1/…`) settled
733
+ * two things the KB table alone did not: the gateway serves nearly the whole
734
+ * catalogue on **both** surfaces (its per-model table describes the OpenCode
735
+ * client's preferred routing, not a hard restriction), and the Anthropic
736
+ * surface authenticates with `x-api-key` — a Bearer header there comes back
737
+ * as 「Missing API key」. Only `grok-4.6`/`grok-4.7` are excluded: they answer
738
+ * 「not supported for format oa-compat」/「…format anthropic」, i.e. they need
739
+ * the `/v1/responses` API, which neither the direct-HTTP path nor these
740
+ * presets speak.
741
+ */
742
+ "opencode-go-openai": {
743
+ id: "opencode-go-openai",
744
+ label: "OpenCode Go(订阅制 · OpenAI 兼容)",
745
+ protocol: "openai",
746
+ baseURL: "https://opencode.ai/zen/go/v1",
747
+ apiKeyEnv: "OPENCODE_GO_API_KEY",
748
+ defaultModels: [
749
+ "glm-5.3-flash",
750
+ "glm-5.3",
751
+ "glm-5.2",
752
+ "kimi-k3",
753
+ "kimi-k2.7-code",
754
+ "kimi-k2.6",
755
+ "longcat-2.0",
756
+ "longcat-2.5-preview-free",
757
+ "deepseek-v4.1-flash",
758
+ "deepseek-v4-pro",
759
+ "deepseek-v4-flash",
760
+ "deepseek-v4-flash-vision-exp",
761
+ "mimo-v2.6-flash",
762
+ "mimo-v2.6-pro",
763
+ "mimo-v2.5",
764
+ "mimo-v2.5-pro",
765
+ "hy4-preview",
766
+ "hy3",
767
+ "space-bunny"
768
+ ],
769
+ authMode: "bearer",
770
+ modelsBaseURL: "https://opencode.ai/zen/go/v1",
771
+ modelsAuthMode: "bearer",
772
+ headers: { "x-opencode-session": OPENCODE_GO_SESSION_ID },
773
+ notes: "订阅制:在 opencode.ai/console 订阅后复制 API key(Go $10/月、Go Plus $40/月)。这条端点用 Authorization: Bearer,并要求每次请求带稳定的 x-opencode-session 会话号——直连通道用固定标识 dsh-advisor-group,DSH 线路则由宿主按会话自动带。grok-4.6/4.7 只能用 /v1/responses(插件不支持),故不在清单内。"
774
+ },
775
+ "opencode-go-anthropic": {
776
+ id: "opencode-go-anthropic",
777
+ label: "OpenCode Go(订阅制 · Anthropic 兼容)",
778
+ protocol: "anthropic",
779
+ baseURL: "https://opencode.ai/zen/go/v1",
780
+ apiKeyEnv: "OPENCODE_GO_API_KEY",
781
+ defaultModels: [
782
+ "minimax-m3",
783
+ "minimax-m2.7",
784
+ "qwen3.8-max",
785
+ "qwen3.8-flash",
786
+ "qwen3.7-plus"
787
+ ],
788
+ authMode: "x-api-key",
789
+ modelsBaseURL: "https://opencode.ai/zen/go/v1",
790
+ modelsAuthMode: "bearer",
791
+ headers: { "x-opencode-session": OPENCODE_GO_SESSION_ID },
792
+ notes: "同一份订阅与 key,走 Anthropic 的 /v1/messages。**认证必须用 x-api-key(不是 Bearer)**——2026-10-06 实测:Bearer 会被网关判为未携带钥匙而 401;插件会自动补 anthropic-version。可用的模型与 OpenAI 兼容那条基本相同,选一条即可。"
703
793
  }
704
794
  };
705
795
  function getProviderPreset(id) {
@@ -922,10 +1012,10 @@ async function streamDirectHttp(advisor, transcript, onDelta, signal, timeoutMs,
922
1012
  let result;
923
1013
  switch (protocol) {
924
1014
  case "openai":
925
- result = await streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools);
1015
+ result = await streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools, preset?.headers);
926
1016
  break;
927
1017
  case "anthropic":
928
- result = await streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs, tools);
1018
+ result = await streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs, tools, preset?.headers);
929
1019
  break;
930
1020
  case "gemini": {
931
1021
  const content = await callGemini(baseURL, advisor, apiKey, transcriptText, signal, timeoutMs);
@@ -949,7 +1039,7 @@ async function streamDirectHttp(advisor, transcript, onDelta, signal, timeoutMs,
949
1039
  }
950
1040
  return result;
951
1041
  }
952
- async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools) {
1042
+ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools, extraHeaders) {
953
1043
  const endpoint = baseURL.endsWith("/chat/completions") ? baseURL : `${baseURL.replace(/\/+$/, "")}/chat/completions`;
954
1044
  const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
955
1045
  const body = {
@@ -978,7 +1068,8 @@ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText,
978
1068
  method: "POST",
979
1069
  headers: {
980
1070
  "content-type": "application/json",
981
- authorization: `Bearer ${apiKey}`
1071
+ authorization: `Bearer ${apiKey}`,
1072
+ ...extraHeaders
982
1073
  },
983
1074
  body: JSON.stringify(body),
984
1075
  signal: pair.signal
@@ -1079,11 +1170,12 @@ function collectToolCalls(byIndex) {
1079
1170
  }
1080
1171
  return calls.length > 0 ? calls : void 0;
1081
1172
  }
1082
- async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs, tools) {
1173
+ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs, tools, extraHeaders) {
1083
1174
  const endpoint = baseURL.endsWith("/v1/messages") ? baseURL : baseURL.endsWith("/v1") ? `${baseURL}/messages` : `${baseURL.replace(/\/+$/, "")}/v1/messages`;
1084
1175
  const headers = {
1085
1176
  "content-type": "application/json",
1086
- "anthropic-version": "2023-06-01"
1177
+ "anthropic-version": "2023-06-01",
1178
+ ...extraHeaders
1087
1179
  };
1088
1180
  if (authMode === "bearer") headers["authorization"] = `Bearer ${apiKey}`;
1089
1181
  else headers["x-api-key"] = apiKey;
@@ -1242,12 +1334,14 @@ const DRIVER_SYSTEM_PROMPT = `
1242
1334
  你是顾问群的主持人(驱动模型)。你的职责:
1243
1335
  1. 阅读项目背景、主模型问题与全部顾问回答;
1244
1336
  2. 提出一个更深入、聚焦的追问,推动讨论走向更细的根因、分歧或行动方案;
1245
- 3. 只输出追问本身(一句话,不超过 80 字),不要回答、不要打招呼、不要解释。
1337
+ 3. 只输出追问本身(一句话,不超过 80 字),不要回答、不要打招呼、不要解释;
1338
+ 4. 同一位顾问在多轮里多次发言,是**同一个人的多轮观点**,不要当成多位顾问。
1246
1339
  `;
1247
1340
  const CONCLUSION_SYSTEM_PROMPT = `
1248
1341
  你是顾问群的主持人(驱动模型)。请综合全部讨论,输出最终结论:
1249
1342
  - 用 3 段以内:共识、关键分歧(简要列出)、最可靠的行动建议;
1250
- - 不要复述每个顾问的完整回答,只提炼差异与要点。
1343
+ - 不要复述每个顾问的完整回答,只提炼差异与要点;
1344
+ - 提到顾问时按**顾问人数**说:同一位顾问多轮发言仍是一位顾问,不要把发言条数数成人数。
1251
1345
  `;
1252
1346
  /** Resolve the current agent's model config from the session header, or the config fallback. */
1253
1347
  function resolveDriverSource(sessionLog, driverModel) {
@@ -1263,9 +1357,20 @@ function resolveDriverSource(sessionLog, driverModel) {
1263
1357
  model: driverModel.model
1264
1358
  };
1265
1359
  }
1360
+ /**
1361
+ * Tell the driver how many advisors actually took part. One advisor can appear
1362
+ * in several rounds, and without this the model counts turns as people (it once
1363
+ * wrote 「三位顾问一致认定」 when a single advisor had answered three times).
1364
+ */
1365
+ function advisorRoster(session) {
1366
+ const names = [...new Set(session.messages.filter((message) => message.role === "advisor").map((message) => message.advisorName ?? message.advisorId ?? "顾问"))];
1367
+ if (names.length === 0) return "";
1368
+ return `\n\n本次讨论共有 ${names.length} 位顾问(${names.join("、")})。同一位顾问可能在多轮中重复发言——提到顾问时按**顾问人数**说,不要把同一位顾问的多轮发言数成多位顾问。`;
1369
+ }
1266
1370
  function transcriptOf(session) {
1267
1371
  return session.messages.filter((message) => message.role !== "system").map((message) => {
1268
- return `[${message.role === "main" ? "主模型" : message.advisorName ?? message.advisorId ?? "顾问"}]\n${message.content}`;
1372
+ if (message.role === "main") return `[主模型]\n${message.content}`;
1373
+ return `[${message.advisorName ?? message.advisorId ?? "顾问"}${typeof message.round === "number" ? ` · 第 ${message.round} 轮` : ""}]\n${message.content}`;
1269
1374
  }).join("\n\n").slice(-24e3);
1270
1375
  }
1271
1376
  async function generateWith(ctx, source, sessionId, system, prompt, signal, timeoutMs = 6e5) {
@@ -1283,6 +1388,7 @@ async function generateWith(ctx, source, sessionId, system, prompt, signal, time
1283
1388
  })],
1284
1389
  temperature: .7,
1285
1390
  maxTokens: 2048,
1391
+ sessionId,
1286
1392
  signal: withTimeout(timeoutMs, signal)
1287
1393
  };
1288
1394
  let text = "";
@@ -1308,12 +1414,12 @@ async function generateWith(ctx, source, sessionId, system, prompt, signal, time
1308
1414
  /** One deepening question for the next round (never throws; returns fallback text). */
1309
1415
  async function generateDeepenQuestion(ctx, session, source, signal, timeoutMs) {
1310
1416
  if (!source) return FALLBACK_DEEPEN_QUESTION;
1311
- return await generateWith(ctx, source, session.id, DRIVER_SYSTEM_PROMPT, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal, timeoutMs) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
1417
+ return await generateWith(ctx, source, session.dshSessionId ?? session.id, `${DRIVER_SYSTEM_PROMPT}${advisorRoster(session)}`, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal, timeoutMs) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
1312
1418
  }
1313
1419
  /** Final synthesis after all rounds (never throws; returns fallback text). */
1314
1420
  async function generateConclusion(ctx, session, source, signal, timeoutMs) {
1315
1421
  if (!source) return FALLBACK_CONCLUSION;
1316
- return await generateWith(ctx, source, session.id, CONCLUSION_SYSTEM_PROMPT, `以下是顾问群全部 ${session.messages.filter((m) => m.role === "advisor").length} 条顾问回答的讨论记录:\n\n${transcriptOf(session)}\n\n请给出综合结论。`, signal, timeoutMs) || "(⚠ 驱动模型未能生成综合结论:请主模型以上述顾问讨论为基础,自行总结共识、分歧与最可靠的行动建议。)";
1422
+ return await generateWith(ctx, source, session.dshSessionId ?? session.id, `${CONCLUSION_SYSTEM_PROMPT}${advisorRoster(session)}`, `以下是顾问群全部 ${session.messages.filter((m) => m.role === "advisor").length} 条顾问回答的讨论记录:\n\n${transcriptOf(session)}\n\n请给出综合结论。`, signal, timeoutMs) || "(⚠ 驱动模型未能生成综合结论:请主模型以上述顾问讨论为基础,自行总结共识、分歧与最可靠的行动建议。)";
1317
1423
  }
1318
1424
  //#endregion
1319
1425
  //#region src/risk-notes.ts
@@ -2504,6 +2610,7 @@ var AdvisorGroupService = class {
2504
2610
  round,
2505
2611
  ts: Date.now()
2506
2612
  };
2613
+ const routingSessionId = session.dshSessionId !== void 0 && isSafeDshSessionId(session.dshSessionId) ? session.dshSessionId : session.id;
2507
2614
  try {
2508
2615
  let content;
2509
2616
  let isDshLlmProvider = false;
@@ -2626,7 +2733,7 @@ var AdvisorGroupService = class {
2626
2733
  truncated = streamResult.truncated;
2627
2734
  }
2628
2735
  } else {
2629
- const result = await callViaCtxLlm(this.ctx, relational, transcript, signal, emitDelta, advisorTimeoutMs);
2736
+ const result = await callViaCtxLlm(this.ctx, relational, transcript, signal, emitDelta, advisorTimeoutMs, routingSessionId);
2630
2737
  flush();
2631
2738
  content = result.content;
2632
2739
  truncated = result.truncated;
@@ -3093,10 +3200,32 @@ function parseResponse(body, config, advisors) {
3093
3200
  provider: config.provider
3094
3201
  };
3095
3202
  }
3096
- async function classifyWithJev(question, context, advisors, config, signal, questionEn) {
3097
- const endpoint = config.baseURL?.trim() || DEFAULT_ENDPOINTS[config.provider];
3203
+ /** The effective endpoint for a Jev config. */
3204
+ function jevEndpointOf(config) {
3205
+ return config.baseURL?.trim() || DEFAULT_ENDPOINTS[config.provider];
3206
+ }
3207
+ /** The effective API key: the configured env var first, then the stored key. */
3208
+ function jevApiKeyOf(config) {
3098
3209
  const storedKey = typeof config.apiKey === "string" ? config.apiKey : config.apiKey && typeof config.apiKey === "object" && "value" in config.apiKey ? String(config.apiKey.value ?? "") : void 0;
3099
- const apiKey = (config.apiKeyEnv ? process.env[config.apiKeyEnv] : void 0) || storedKey;
3210
+ return (config.apiKeyEnv ? process.env[config.apiKeyEnv] : void 0) || storedKey;
3211
+ }
3212
+ /**
3213
+ * True when Jev can actually be called: a config exists and resolves to a
3214
+ * non-empty API key. The routing engine uses this to tell "not configured"
3215
+ * (local order plus a note) from "configured but failed" (silent fallback).
3216
+ */
3217
+ function jevConfigured(config) {
3218
+ if (config === void 0 || config === null || typeof config !== "object") return false;
3219
+ const key = jevApiKeyOf(config);
3220
+ return key !== void 0 && key.length > 0;
3221
+ }
3222
+ /**
3223
+ * POST one serialised payload to the configured Jev endpoint. Timeout and the
3224
+ * caller's abort signal are handled once, here, for every request shape.
3225
+ */
3226
+ async function postJev(config, payload, signal) {
3227
+ const endpoint = jevEndpointOf(config);
3228
+ const apiKey = jevApiKeyOf(config);
3100
3229
  if (!apiKey) throw new Error("Jev 未配置 API key");
3101
3230
  const controller = new AbortController();
3102
3231
  const timeout = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`Jev 超时(${config.timeoutMs}ms)`)), config.timeoutMs);
@@ -3109,208 +3238,1830 @@ async function classifyWithJev(question, context, advisors, config, signal, ques
3109
3238
  authorization: `Bearer ${apiKey}`,
3110
3239
  "content-type": "application/json"
3111
3240
  },
3112
- body: JSON.stringify({
3113
- state: config.useEnglishState && questionEn?.trim() ? questionEn.trim() : `${question}\n\n背景:${context || "无"}`,
3114
- model: config.model || "jev-latest",
3115
- questions: {
3116
- needs_advisor: {
3117
- type: "noul",
3118
- instructions: "Does answering this question require expert-level knowledge?",
3119
- criteria: {
3120
- true: {
3121
- what: "The subject matter is professional, specialist, or long-tail knowledge a competent generalist would not reliably get right, or the field itself has no single settled answer without specialist judgment.",
3122
- examples: [
3123
- "professional-domain judgment such as accounting treatment, engineering trade-offs, or contract drafting",
3124
- "rare or long-tail situations with little public consensus",
3125
- "topics where reasonable experts disagree and picking correctly matters"
3126
- ]
3127
- },
3128
- false: {
3129
- what: "A competent generalist can answer from common knowledge, widely documented facts, or ordinary reasoning without specialist judgment.",
3130
- examples: ["everyday facts, definitions, or casual opinions", "well-documented mainstream topics with an agreed answer"]
3131
- }
3132
- }
3133
- },
3134
- web_search: {
3135
- type: "noul",
3136
- instructions: "Does answering this question primarily require current, real-time, or externally verifiable information?",
3137
- criteria: {
3138
- true: {
3139
- what: "The correct answer depends on facts that change over time or live outside the provided state, so it must be looked up rather than reasoned out.",
3140
- examples: [
3141
- "today’s weather, prices, exchange rates, or quotes",
3142
- "recent releases, news, or version-specific changelogs",
3143
- "local or niche facts with no authoritative source in memory"
3144
- ]
3145
- },
3146
- false: {
3147
- what: "The answer follows from stable knowledge or reasoning already available in the state.",
3148
- examples: ["concepts, definitions, and established best practices", "questions fully answerable from the provided background"]
3149
- }
3150
- }
3151
- },
3152
- high_risk: {
3153
- type: "noul",
3154
- instructions: "Does this question involve medical, legal, financial, safety, compliance, or other high-risk advice?",
3155
- criteria: {
3156
- true: {
3157
- what: "A wrong or careless answer could materially harm the asker or a third party in health, money, safety, or legal standing.",
3158
- examples: [
3159
- "medical symptoms, medication, diagnosis, or treatment decisions",
3160
- "legal rights, contracts, litigation, or regulatory compliance",
3161
- "investment, insurance, tax, or other money decisions with real downside",
3162
- "physical safety, hazardous actions, or regulated activities"
3163
- ]
3164
- },
3165
- false: {
3166
- what: "Being wrong carries no material harm to health, money, safety, or legal standing.",
3167
- examples: ["general knowledge, opinions, or entertainment questions", "technical details whose worst case is wasted time"]
3168
- }
3169
- }
3170
- },
3171
- domain: {
3172
- type: "choice",
3173
- instructions: "Which domain best describes this question?",
3174
- criteria: {
3175
- legal: "law, contracts, litigation, compliance, or taxation",
3176
- medical: "medicine, symptoms, medication, diagnosis, or treatment",
3177
- finance: "investment, insurance, accounting, or financial risk",
3178
- code: "software, programming, architecture, debugging, or APIs",
3179
- general: "none of the listed domains"
3180
- }
3181
- }
3182
- }
3183
- }),
3241
+ body: payload,
3184
3242
  signal: controller.signal
3185
3243
  });
3186
3244
  if (!response.ok) throw new Error(`Jev HTTP ${response.status}`);
3187
- return parseResponse(await response.json(), config, advisors);
3245
+ return await response.json();
3188
3246
  } finally {
3189
3247
  clearTimeout(timeout);
3190
3248
  signal?.removeEventListener("abort", abort);
3191
3249
  }
3192
3250
  }
3251
+ async function classifyWithJev(question, context, advisors, config, signal, questionEn) {
3252
+ return parseResponse(await postJev(config, JSON.stringify({
3253
+ state: config.useEnglishState && questionEn?.trim() ? questionEn.trim() : `${question}\n\n背景:${context || "无"}`,
3254
+ model: config.model || "jev-latest",
3255
+ questions: {
3256
+ needs_advisor: {
3257
+ type: "noul",
3258
+ instructions: "Does answering this question require expert-level knowledge?",
3259
+ criteria: {
3260
+ true: {
3261
+ what: "The subject matter is professional, specialist, or long-tail knowledge a competent generalist would not reliably get right, or the field itself has no single settled answer without specialist judgment.",
3262
+ examples: [
3263
+ "professional-domain judgment such as accounting treatment, engineering trade-offs, or contract drafting",
3264
+ "rare or long-tail situations with little public consensus",
3265
+ "topics where reasonable experts disagree and picking correctly matters"
3266
+ ]
3267
+ },
3268
+ false: {
3269
+ what: "A competent generalist can answer from common knowledge, widely documented facts, or ordinary reasoning without specialist judgment.",
3270
+ examples: ["everyday facts, definitions, or casual opinions", "well-documented mainstream topics with an agreed answer"]
3271
+ }
3272
+ }
3273
+ },
3274
+ web_search: {
3275
+ type: "noul",
3276
+ instructions: "Does answering this question primarily require current, real-time, or externally verifiable information?",
3277
+ criteria: {
3278
+ true: {
3279
+ what: "The correct answer depends on facts that change over time or live outside the provided state, so it must be looked up rather than reasoned out.",
3280
+ examples: [
3281
+ "today’s weather, prices, exchange rates, or quotes",
3282
+ "recent releases, news, or version-specific changelogs",
3283
+ "local or niche facts with no authoritative source in memory"
3284
+ ]
3285
+ },
3286
+ false: {
3287
+ what: "The answer follows from stable knowledge or reasoning already available in the state.",
3288
+ examples: ["concepts, definitions, and established best practices", "questions fully answerable from the provided background"]
3289
+ }
3290
+ }
3291
+ },
3292
+ high_risk: {
3293
+ type: "noul",
3294
+ instructions: "Does this question involve medical, legal, financial, safety, compliance, or other high-risk advice?",
3295
+ criteria: {
3296
+ true: {
3297
+ what: "A wrong or careless answer could materially harm the asker or a third party in health, money, safety, or legal standing.",
3298
+ examples: [
3299
+ "medical symptoms, medication, diagnosis, or treatment decisions",
3300
+ "legal rights, contracts, litigation, or regulatory compliance",
3301
+ "investment, insurance, tax, or other money decisions with real downside",
3302
+ "physical safety, hazardous actions, or regulated activities"
3303
+ ]
3304
+ },
3305
+ false: {
3306
+ what: "Being wrong carries no material harm to health, money, safety, or legal standing.",
3307
+ examples: ["general knowledge, opinions, or entertainment questions", "technical details whose worst case is wasted time"]
3308
+ }
3309
+ }
3310
+ },
3311
+ domain: {
3312
+ type: "choice",
3313
+ instructions: "Which domain best describes this question?",
3314
+ criteria: {
3315
+ legal: "law, contracts, litigation, compliance, or taxation",
3316
+ medical: "medicine, symptoms, medication, diagnosis, or treatment",
3317
+ finance: "investment, insurance, accounting, or financial risk",
3318
+ code: "software, programming, architecture, debugging, or APIs",
3319
+ general: "none of the listed domains"
3320
+ }
3321
+ }
3322
+ }
3323
+ }), signal), config, advisors);
3324
+ }
3325
+ /** The escape option: Jev says none of the listed candidates is the target. */
3326
+ const JEV_NONE_OPTION = "none";
3327
+ /** Finite numbers only, keyed by option id; undefined when nothing usable is left. */
3328
+ function numberMap(value) {
3329
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
3330
+ const out = {};
3331
+ for (const [key, raw] of Object.entries(value)) {
3332
+ const number = numberValue(raw);
3333
+ if (number !== void 0) out[key] = number;
3334
+ }
3335
+ return Object.keys(out).length === 0 ? void 0 : out;
3336
+ }
3337
+ /**
3338
+ * Read one closed-set routing answer. Throws on anything unparseable (no
3339
+ * answers, no route answer, no usable choice) so the router keeps the local
3340
+ * order instead of acting on a guess.
3341
+ */
3342
+ function parseRouteResponse(body) {
3343
+ if (!body || typeof body !== "object") throw new Error("Jev 响应不是对象");
3344
+ const answers = body.answers;
3345
+ if (!answers || typeof answers !== "object") throw new Error("Jev 响应缺少 answers");
3346
+ const answer = answers.route;
3347
+ if (answer === null || typeof answer !== "object") throw new Error("Jev 响应缺少 route 判定");
3348
+ const choice = typeof answer.choice === "string" ? answer.choice.trim() : "";
3349
+ if (choice.length === 0) throw new Error("Jev 响应缺少 route.choice");
3350
+ const probabilities = numberMap(answer.probabilities);
3351
+ const confidence = numberValue(answer.confidence) ?? probabilities?.[choice] ?? (probabilities === void 0 ? void 0 : Math.max(...Object.values(probabilities)));
3352
+ const reportedModel = body.model;
3353
+ return {
3354
+ choice,
3355
+ ...confidence === void 0 ? {} : { confidence },
3356
+ ...probabilities === void 0 ? {} : { probabilities },
3357
+ ...typeof reportedModel === "string" ? { model: reportedModel } : {}
3358
+ };
3359
+ }
3360
+ /** Closed-set criteria for the candidates: id → `title — scope`, plus the escape. */
3361
+ function optionCriteria(options, noneText) {
3362
+ const criteria = {};
3363
+ for (const option of options) {
3364
+ const id = typeof option.id === "string" ? option.id.trim() : "";
3365
+ if (id.length === 0) continue;
3366
+ const title = typeof option.title === "string" ? option.title.trim() : "";
3367
+ const scope = typeof option.scope === "string" ? option.scope.trim() : "";
3368
+ criteria[id] = scope.length > 0 ? `${title} — ${scope}` : title;
3369
+ }
3370
+ criteria[JEV_NONE_OPTION] = noneText;
3371
+ return criteria;
3372
+ }
3373
+ /**
3374
+ * Ask Jev which routed candidate is the best target: one closed-set `choice`
3375
+ * question, English instructions, an explicit `none` escape, and the
3376
+ * candidates' own titles/scopes kept in their original language.
3377
+ *
3378
+ * Rejects (never resolves to a guess) on a missing key, an empty option list,
3379
+ * any transport/HTTP failure, or a malformed response; the router treats every
3380
+ * rejection as "keep the local order".
3381
+ */
3382
+ async function routeWithJev(question, questionEn, options, config, signal) {
3383
+ if (!jevConfigured(config)) throw new Error("Jev 未配置 API key");
3384
+ if (!Array.isArray(options) || options.length === 0) throw new Error("Jev 路由没有候选可判断");
3385
+ const criteria = optionCriteria(options, "None of the listed sessions is the right target for this question.");
3386
+ const gloss = typeof questionEn === "string" ? questionEn.trim() : "";
3387
+ const state = [
3388
+ "A user asked a question and wants it sent to exactly one existing session. Choose the option whose session is the best target for that question.",
3389
+ gloss.length > 0 ? `English gloss of the question: ${gloss}` : void 0,
3390
+ `Question (verbatim, may be Chinese): ${question}`
3391
+ ].filter((line) => line !== void 0).join("\n");
3392
+ return parseRouteResponse(await postJev(config, JSON.stringify({
3393
+ state,
3394
+ model: config.model || "jev-latest",
3395
+ questions: { route: {
3396
+ type: "choice",
3397
+ instructions: "Pick exactly one option: the session this question should be sent to. Pick none when no listed session fits.",
3398
+ criteria
3399
+ } }
3400
+ }), signal));
3401
+ }
3402
+ /** A noul answer's confidence: the explicit `confidence`, else the `noul` value. */
3403
+ function noulConfidence(answer) {
3404
+ return numberValue(answer?.confidence) ?? numberValue(answer?.noul);
3405
+ }
3406
+ /** Read one batched judgment; throws unless `ask` and `sensitive` are usable. */
3407
+ function parseCrossSessionResponse(body) {
3408
+ if (!body || typeof body !== "object") throw new Error("Jev 响应不是对象");
3409
+ const answers = body.answers;
3410
+ if (!answers || typeof answers !== "object") throw new Error("Jev 响应缺少 answers");
3411
+ const askConfidence = noulConfidence(answers.ask);
3412
+ if (askConfidence === void 0) throw new Error("Jev 响应缺少 ask 的有效判定");
3413
+ const sensitiveConfidence = noulConfidence(answers.sensitive);
3414
+ if (sensitiveConfidence === void 0) throw new Error("Jev 响应缺少 sensitive 的有效判定");
3415
+ const pickAnswer = answers.pick;
3416
+ const pick = pickAnswer !== null && typeof pickAnswer === "object" && typeof pickAnswer.choice === "string" ? pickAnswer.choice.trim() : "";
3417
+ const probabilities = pickAnswer !== null && typeof pickAnswer === "object" ? numberMap(pickAnswer.probabilities) : void 0;
3418
+ const pickConfidence = pick.length === 0 ? void 0 : numberValue(pickAnswer?.confidence) ?? probabilities?.[pick] ?? (probabilities === void 0 ? void 0 : Math.max(...Object.values(probabilities)));
3419
+ const reportedModel = body.model;
3420
+ return {
3421
+ askConfidence,
3422
+ sensitiveConfidence,
3423
+ ...pick.length === 0 ? {} : { pick },
3424
+ ...pickConfidence === void 0 ? {} : { pickConfidence },
3425
+ ...typeof reportedModel === "string" ? { model: reportedModel } : {}
3426
+ };
3427
+ }
3428
+ /**
3429
+ * One batched judgment for the cross-session suggestion: `ask` (noul), `pick`
3430
+ * (choice, present only when there are candidates) and `sensitive` (noul) all
3431
+ * travel in a single request, so one hint costs one call. English
3432
+ * instructions; candidate titles/scopes stay in their original language.
3433
+ *
3434
+ * Throws when the response is missing or its ask/sensitive answers are
3435
+ * unusable; the caller treats that as "no hint".
3436
+ */
3437
+ async function judgeCrossSessionWithJev(text, textEn, candidates, config, signal) {
3438
+ if (!jevConfigured(config)) throw new Error("Jev 未配置 API key");
3439
+ const options = Array.isArray(candidates) ? candidates : [];
3440
+ const questions = {
3441
+ ask: {
3442
+ type: "noul",
3443
+ instructions: "Should answering this turn consult another existing session of the same user?",
3444
+ criteria: {
3445
+ true: {
3446
+ what: "The turn depends on facts that live in another session: the state, progress, recent changes, or version of a project or task that session has been working on.",
3447
+ examples: [
3448
+ "asking for the current progress or status of work started elsewhere",
3449
+ "asking what a different session decided, changed, or last talked about",
3450
+ "continuing a task whose details were only ever discussed in another session"
3451
+ ]
3452
+ },
3453
+ false: {
3454
+ what: "The turn can be answered from this session alone, from general knowledge, or does not depend on any other session.",
3455
+ examples: [
3456
+ "self-contained coding or writing requests",
3457
+ "general questions and opinions",
3458
+ "questions about this very session only"
3459
+ ]
3460
+ }
3461
+ }
3462
+ },
3463
+ sensitive: {
3464
+ type: "noul",
3465
+ instructions: "Is this turn private or sensitive enough that it must not be routed to another session automatically?",
3466
+ criteria: {
3467
+ true: {
3468
+ what: "The content carries secrets or personal or confidential material that should not leave this session on its own.",
3469
+ examples: [
3470
+ "credentials, API keys, tokens, passwords, or private keys",
3471
+ "personal, medical, legal, or financial details about a person",
3472
+ "content the user marked private, confidential, or off the record"
3473
+ ]
3474
+ },
3475
+ false: {
3476
+ what: "Ordinary project, technical, or general questions with nothing private in them.",
3477
+ examples: ["code, build, and repository questions", "general knowledge or everyday planning"]
3478
+ }
3479
+ }
3480
+ }
3481
+ };
3482
+ if (options.length > 0) questions.pick = {
3483
+ type: "choice",
3484
+ instructions: "Which of these sessions is the best one to ask about this turn? Pick none when none of them fits.",
3485
+ criteria: optionCriteria(options, "None of the listed sessions is the right target for this turn.")
3486
+ };
3487
+ const gloss = typeof textEn === "string" ? textEn.trim() : "";
3488
+ const state = [
3489
+ "A user turn arrived in one session of a coding agent. Judge that turn for the three questions in this request.",
3490
+ gloss.length > 0 ? `English gloss of the turn: ${gloss}` : void 0,
3491
+ `Turn (verbatim, may be Chinese): ${text}`
3492
+ ].filter((line) => line !== void 0).join("\n");
3493
+ return parseCrossSessionResponse(await postJev(config, JSON.stringify({
3494
+ state,
3495
+ model: config.model || "jev-latest",
3496
+ questions
3497
+ }), signal));
3498
+ }
3193
3499
  //#endregion
3194
- //#region src/cross-session.ts
3500
+ //#region src/ask-store.ts
3195
3501
  /**
3196
- * Cross-session ask (v0): let the current session deliver one question to
3197
- * another existing ordinary session, wake it, and report what that session
3198
- * said between the delivery receipt and its next whole-session idle.
3502
+ * Durable store for detached (async) asks: `ask_session` will deliver a
3503
+ * question now and collect the answer later, possibly in a later turn, so the
3504
+ * settled result must survive a turn boundary. One JSON file per ask under
3505
+ * `<baseDir>/asks/<askId>.json`; no host services, no timers.
3199
3506
  *
3200
- * Facts this module is built on (DSH 0.2.0-rc.1/rc.2 types, verified):
3201
- * - `ctx.sessionController.resolveAgent(sessionId)` yields the live Agent
3202
- * (`{ agent }` or `{ error }`), resuming a cold session.
3203
- * - `agent.followup(createUserMessage({ content, source }))` queues one
3204
- * ordinary follow-up turn and wakes the driver.
3205
- * - `ctx.sessionController.prompt({ sessionId, content, mode:'queue', requestId })`
3206
- * is the cheap fallback, but the Host hardcodes `source` to a user prompt, so
3207
- * that path is identity-dishonest and must be reported as such.
3208
- * - There is NO API that returns "the answer to this message". The Host only
3209
- * guarantees delivery, so this module reports an INTERVAL, never a reply.
3210
- * - The Host enforces no caller identity, no cycle guard, and hard-excludes
3211
- * subagent sessions, so every guard in `ask_session` lives here.
3507
+ * Follows the persistence conventions of this repo: plain `node:fs`,
3508
+ * atomic tmp+rename writes, and reads that degrade to `undefined` instead of
3509
+ * throwing on a missing or damaged file.
3510
+ */
3511
+ /** askId doubles as a file name, so it must never escape the store directory. */
3512
+ const ASK_ID_PATTERN = /^[A-Za-z0-9._-]{1,80}$/;
3513
+ const STATES = /* @__PURE__ */ new Set([
3514
+ "waiting",
3515
+ "done",
3516
+ "timeout",
3517
+ "error"
3518
+ ]);
3519
+ /** The store directory for a base dir such as `$DSH_HOME/storages/advisor-group`. */
3520
+ function askStoreDir(baseDir) {
3521
+ return `${baseDir}/asks`;
3522
+ }
3523
+ function assertAskId(askId) {
3524
+ if (!ASK_ID_PATTERN.test(askId)) throw new Error("invalid askId");
3525
+ }
3526
+ function isAskRecord(value) {
3527
+ if (typeof value !== "object" || value === null) return false;
3528
+ const record = value;
3529
+ return typeof record.askId === "string" && typeof record.sessionId === "string" && typeof record.question === "string" && typeof record.createdAt === "number" && typeof record.state === "string" && STATES.has(record.state);
3530
+ }
3531
+ function parseRecord(text) {
3532
+ let value;
3533
+ try {
3534
+ value = JSON.parse(text);
3535
+ } catch {
3536
+ return;
3537
+ }
3538
+ return isAskRecord(value) ? value : void 0;
3539
+ }
3540
+ /**
3541
+ * Write one record atomically: tmp file + rename, so a concurrent reader never
3542
+ * sees a half-written JSON document. Creates `dir` when missing.
3543
+ */
3544
+ function writeAskRecord(dir, record) {
3545
+ assertAskId(record.askId);
3546
+ mkdirSync(dir, { recursive: true });
3547
+ const file = join(dir, `${record.askId}.json`);
3548
+ const tmp = `${file}.tmp`;
3549
+ writeFileSync(tmp, JSON.stringify(record, null, 2), "utf8");
3550
+ renameSync(tmp, file);
3551
+ }
3552
+ /** Missing or unparsable records read as `undefined`; only a bad id throws. */
3553
+ function readAskRecord(dir, askId) {
3554
+ assertAskId(askId);
3555
+ try {
3556
+ return parseRecord(readFileSync(join(dir, `${askId}.json`), "utf8"));
3557
+ } catch {
3558
+ return;
3559
+ }
3560
+ }
3561
+ /** All valid records, newest `createdAt` first, optionally filtered. */
3562
+ function listAskRecords(dir, filter) {
3563
+ let names;
3564
+ try {
3565
+ names = readdirSync(dir);
3566
+ } catch {
3567
+ return [];
3568
+ }
3569
+ const records = [];
3570
+ for (const name of names) {
3571
+ if (!name.endsWith(".json")) continue;
3572
+ let text;
3573
+ try {
3574
+ text = readFileSync(join(dir, name), "utf8");
3575
+ } catch {
3576
+ continue;
3577
+ }
3578
+ const record = parseRecord(text);
3579
+ if (record === void 0) continue;
3580
+ if (filter?.sessionId !== void 0 && record.sessionId !== filter.sessionId) continue;
3581
+ if (filter?.state !== void 0 && record.state !== filter.state) continue;
3582
+ records.push(record);
3583
+ }
3584
+ records.sort((a, b) => b.createdAt - a.createdAt);
3585
+ return records;
3586
+ }
3587
+ /**
3588
+ * Delete settled-or-ageing records (age keyed on `settledAt ?? createdAt`) and
3589
+ * stray `.tmp` files left by a crashed writer; returns how many files went.
3590
+ * Best-effort by design: a missing directory is 0, per-file failures are skipped.
3591
+ */
3592
+ function pruneAskRecords(dir, olderThanMs, now = Date.now()) {
3593
+ let names;
3594
+ try {
3595
+ names = readdirSync(dir);
3596
+ } catch {
3597
+ return 0;
3598
+ }
3599
+ const cutoff = now - olderThanMs;
3600
+ let removed = 0;
3601
+ for (const name of names) {
3602
+ const file = join(dir, name);
3603
+ try {
3604
+ if (name.endsWith(".tmp")) {
3605
+ unlinkSync(file);
3606
+ removed += 1;
3607
+ continue;
3608
+ }
3609
+ if (!name.endsWith(".json")) continue;
3610
+ const record = parseRecord(readFileSync(file, "utf8"));
3611
+ if (record === void 0) continue;
3612
+ if ((record.settledAt ?? record.createdAt) < cutoff) {
3613
+ unlinkSync(file);
3614
+ removed += 1;
3615
+ }
3616
+ } catch {}
3617
+ }
3618
+ return removed;
3619
+ }
3620
+ //#endregion
3621
+ //#region src/session-roster.ts
3622
+ /**
3623
+ * Session roster (pure): turn `sessionController.list()` rows into a compact,
3624
+ * model-readable list. No host services; the only I/O is one bounded probe of
3625
+ * `$DSH_HOME/sessions/` for the log-size hint (see {@link sessionLogSizes}).
3212
3626
  *
3213
- * The pure helpers below never touch `ctx`; only `askSession()` does I/O.
3627
+ * The title projection is published by the host (`dsh-session-title`) as a
3628
+ * **plain string**; a `{ title }` wrapper is accepted for older cached rows.
3629
+ * Every miss degrades to the same cwd+time fallback `cross-session.ts` uses,
3630
+ * and no malformed row may throw.
3214
3631
  */
3215
- /** Maximum combined UTF-8 bytes of question + context (design §六.5). */
3216
- const ASK_CONTENT_MAX_BYTES = 32e3;
3217
- /** Default and maximum wait for the target's next idle (design §十). */
3218
- const ASK_WAIT_DEFAULT_MS = 18e4;
3219
- const ASK_WAIT_MAX_MS = 6e5;
3220
- /** Poll interval while waiting for the target to reach whole-session idle. */
3221
- const ASK_POLL_INTERVAL_MS = 1e3;
3632
+ const UNKNOWN_CWD = "(未知工作目录)";
3633
+ const UNKNOWN_TIME = "(未知时间)";
3634
+ const DEFAULT_LIMIT = 30;
3635
+ const MIN_LIMIT = 1;
3636
+ const MAX_LIMIT = 50;
3637
+ /** Hard cap for one hint line, keeping each roster entry readable. */
3638
+ const HINT_MAX = 40;
3639
+ /** Separator the fallback label puts between the working directory and the ISO time. */
3640
+ const FALLBACK_SEPARATOR = " · ";
3641
+ /** ISO-8601 UTC stamp, the time half of the fallback label. */
3642
+ const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/u;
3643
+ const PATH_SPLIT$1 = /[\\/]+/u;
3644
+ const DRIVE_SEGMENT$1 = /^[A-Za-z]:$/u;
3222
3645
  /**
3223
- * Envelope the receiving model reads. The Host offers no registration point for
3224
- * this framing (the official Agent Teams build their own too), so the identity
3225
- * is carried in the content itself: a header block naming the asking session,
3226
- * then the question, then optional background.
3227
- * @param senderName - title (or cwd+time fallback) of the asking session.
3228
- * @param question - the question to hand over.
3229
- * @param context - optional background; permanently recorded in the target log.
3230
- * @returns content blocks, envelope first.
3646
+ * The exclusion list for roster/routing candidates: titles that carry no
3647
+ * meaning. `生成会话标题` is the leftover of the title-generation session,
3648
+ * and a bare greeting is an empty chat that never named itself.
3231
3649
  */
3232
- function buildAskEnvelope(senderName, question, context) {
3233
- const blocks = [{
3234
- type: "text",
3235
- text: `【来自会话「${senderName}」的提问】`
3236
- }, {
3237
- type: "text",
3238
- text: question
3239
- }];
3240
- if (context !== void 0 && context.trim().length > 0) blocks.push({
3241
- type: "text",
3242
- text: `【对方补充的背景】\n${context}`
3243
- });
3244
- return blocks;
3650
+ const NOISE_TITLE_PATTERN = /生成会话标题|^你好[,,\s]*$/u;
3651
+ function isRecord(value) {
3652
+ return value !== null && typeof value === "object";
3245
3653
  }
3246
3654
  /**
3247
- * Build the identified user message handed to `agent.followup()`.
3248
- * @param envelope - content blocks produced by {@link buildAskEnvelope}.
3249
- * @param source - producer-declared session-ask source.
3250
- * @returns an immutable identified user message carrying our source kind.
3655
+ * Read the title projection: the host plain string first, then the legacy
3656
+ * `{ title }` wrapper. Returns '' when neither yields a non-empty string.
3251
3657
  */
3252
- function buildAskMessage(envelope, source) {
3253
- return createUserMessage({
3254
- content: envelope,
3255
- source
3256
- });
3658
+ function titleOf$1(projections) {
3659
+ if (!isRecord(projections)) return "";
3660
+ const values = projections.values;
3661
+ if (!isRecord(values)) return "";
3662
+ const raw = values.title;
3663
+ return (typeof raw === "string" ? raw : isRecord(raw) && typeof raw.title === "string" ? raw.title : "").trim();
3257
3664
  }
3258
3665
  /**
3259
- * UTF-8 byte length of the text a caller wants to hand over.
3260
- * @param question - the question text.
3261
- * @param context - optional background text.
3262
- * @returns byte length of question + context.
3666
+ * Whether a raw roster row must stay out of the roster and the routing pool:
3667
+ * a blank session, a title-generation leftover, or the asking session itself.
3668
+ * Malformed input reads as "keep" here — the caller's own parser degrades it.
3669
+ * @param session - any raw row.
3670
+ * @param currentSessionId - the asking session, dropped when its id matches.
3263
3671
  */
3264
- function askContentBytes(question, context) {
3265
- return Buffer.byteLength(question, "utf8") + (context === void 0 ? 0 : Buffer.byteLength(context, "utf8"));
3672
+ function isNoiseSession(session, currentSessionId) {
3673
+ if (!isRecord(session)) return false;
3674
+ if (session.blank === true) return true;
3675
+ const title = titleOf$1(session.projections);
3676
+ if (title.length > 0 && NOISE_TITLE_PATTERN.test(title)) return true;
3677
+ return typeof currentSessionId === "string" && currentSessionId.length > 0 && session.sessionId === currentSessionId;
3678
+ }
3679
+ /** Last non-empty segment of a path, for the human-readable fallback name. */
3680
+ function lastPathSegment(path) {
3681
+ const parts = path.split(PATH_SPLIT$1).filter((part) => part.length > 0);
3682
+ return parts.length === 0 ? "" : parts[parts.length - 1];
3266
3683
  }
3267
- /** Deterministic fallback label: working directory plus most recent activity. */
3268
- function fallbackSessionName(cwd, updatedAt) {
3269
- const where = cwd === void 0 || cwd.length === 0 ? "(未知工作目录)" : cwd;
3270
- if (updatedAt === void 0 || !Number.isFinite(updatedAt)) return where;
3271
- const at = new Date(updatedAt);
3272
- return `${where} · ${Number.isNaN(at.getTime()) ? String(updatedAt) : at.toISOString()}`;
3684
+ /**
3685
+ * Turn the fallback display name (`<path> · <ISO time>`) into something a
3686
+ * person can read: the last path segment. A real title is returned unchanged,
3687
+ * so this is safe to call at every render site.
3688
+ *
3689
+ * The last segment is used as-is; when it carries no name of its own (a bare
3690
+ * drive such as `I:`) the label adds ` · 未命名`.
3691
+ * @param name - the row name as the host/renderer produced it.
3692
+ * @param cwd - the row's working directory, used to confirm the fallback shape.
3693
+ * @returns a short display name, or `name` unchanged.
3694
+ */
3695
+ function shortSessionName(name, cwd) {
3696
+ if (typeof name !== "string" || name.length === 0) return name;
3697
+ const at = name.lastIndexOf(FALLBACK_SEPARATOR);
3698
+ if (at < 0) return name;
3699
+ const where = name.slice(0, at);
3700
+ const when = name.slice(at + 3);
3701
+ if (!ISO_TIME.test(when)) return name;
3702
+ if (!(cwd !== void 0 && cwd.length > 0 ? where === cwd : /^[A-Za-z]:[\\/]/u.test(where) || where.startsWith("\\\\"))) return name;
3703
+ const segment = lastPathSegment(where);
3704
+ if (segment.length === 0) return "未命名";
3705
+ if (DRIVE_SEGMENT$1.test(segment)) return `${segment} · 未命名`;
3706
+ return segment;
3273
3707
  }
3274
3708
  /**
3275
- * Model-visible name of one listed session. The title lives in the `title`
3276
- * projection (a snapshot object), is optional, and may be a stale cache value,
3277
- * so every failure degrades to the cwd+time fallback instead of throwing.
3709
+ * Deterministic fallback label, identical to `fallbackSessionName` in
3710
+ * `cross-session.ts`: working directory plus the ISO time, or only the
3711
+ * directory when the time is unknown.
3278
3712
  */
3279
- function sessionDisplayName(summary) {
3280
- const raw = summary.projections?.values?.title;
3281
- const title = raw !== null && typeof raw === "object" && typeof raw.title === "string" ? raw.title.trim() : "";
3282
- if (title.length > 0) return title;
3283
- return fallbackSessionName(summary.cwd, summary.updatedAt);
3713
+ function fallbackName(cwd, updatedAt) {
3714
+ const where = cwd === void 0 ? UNKNOWN_CWD : cwd;
3715
+ if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) return where;
3716
+ const at = new Date(updatedAt);
3717
+ return `${where} · ${Number.isNaN(at.getTime()) ? String(updatedAt) : at.toISOString()}`;
3284
3718
  }
3285
- function candidateOf(summary) {
3286
- return {
3287
- sessionId: summary.sessionId,
3288
- name: sessionDisplayName(summary),
3289
- hint: fallbackSessionName(summary.cwd, summary.updatedAt)
3290
- };
3719
+ /** Relative recency in Chinese: 刚刚 / N 分钟前 / N 小时前 / N 天前. */
3720
+ function relativeTime(updatedAt) {
3721
+ const diff = Date.now() - updatedAt;
3722
+ if (!Number.isFinite(diff)) return UNKNOWN_TIME;
3723
+ if (diff < 6e4) return "刚刚";
3724
+ if (diff < 36e5) return `${Math.floor(diff / 6e4)} 分钟前`;
3725
+ if (diff < 864e5) return `${Math.floor(diff / 36e5)} 小时前`;
3726
+ return `${Math.floor(diff / 864e5)} 天前`;
3727
+ }
3728
+ /** Directory plus recency, truncated so the whole hint stays within {@link HINT_MAX}. */
3729
+ function buildHint(cwd, updatedAt) {
3730
+ const where = cwd === void 0 ? UNKNOWN_CWD : cwd;
3731
+ const suffix = ` · ${updatedAt === void 0 ? UNKNOWN_TIME : relativeTime(updatedAt)}`;
3732
+ const room = HINT_MAX - suffix.length;
3733
+ return `${where.length <= room ? where : `${where.slice(0, Math.max(0, room - 1))}…`}${suffix}`;
3734
+ }
3735
+ /** Default 30, hard-clamped to 1..50; a malformed limit falls back to the default. */
3736
+ function clampLimit(value) {
3737
+ if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_LIMIT;
3738
+ return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(value)));
3739
+ }
3740
+ /** Running sessions first, then most recently updated first. */
3741
+ function compareRows(a, b) {
3742
+ if (a.running !== b.running) return a.running ? -1 : 1;
3743
+ if (a.updatedAt === b.updatedAt) return 0;
3744
+ return a.updatedAt > b.updatedAt ? -1 : 1;
3745
+ }
3746
+ function matchesQuery(row, query) {
3747
+ return row.name.toLowerCase().includes(query) || row.cwd !== void 0 && row.cwd.toLowerCase().includes(query) || row.hint.toLowerCase().includes(query);
3748
+ }
3749
+ /** Uncompressed or zstd log file inside a session directory. */
3750
+ const SESSION_LOG = /^session(?:\.v\d+)?\.jsonl(?:\.zstd)?$/u;
3751
+ /** Flat layout: `<id>*.jsonl[.zstd]` / `session-<id>*.jsonl[.zstd]` under the root. */
3752
+ const FLAT_LOG = /^(?:session-)?(.+?)\.jsonl(?:\.zstd)?$/u;
3753
+ /** A byte count from an untrusted value: finite and non-negative, or undefined. */
3754
+ function bytesOf$1(value) {
3755
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : void 0;
3756
+ }
3757
+ /** Root of the host's session logs; `DSH_HOME` mirrors the host default (`~/.dsh`). */
3758
+ function sessionLogRoot() {
3759
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
3760
+ return join(dshHome, "sessions");
3761
+ }
3762
+ /** Bytes of one log file, or undefined when it is missing or not a file. */
3763
+ function fileBytes(file) {
3764
+ try {
3765
+ const stat = statSync(file);
3766
+ return stat.isFile() ? stat.size : void 0;
3767
+ } catch {
3768
+ return;
3769
+ }
3291
3770
  }
3292
- /** Most-recently-active first, running sessions ahead of idle ones. */
3293
- function orderCandidates(rows) {
3294
- return [...rows].sort((a, b) => {
3295
- if (a.running !== b.running) return a.running ? -1 : 1;
3296
- return b.updatedAt - a.updatedAt;
3297
- });
3771
+ /** Bytes of the log inside one session directory: the plain file first, then the zstd one. */
3772
+ function logBytesIn(dir) {
3773
+ let names;
3774
+ try {
3775
+ names = readdirSync(dir);
3776
+ } catch {
3777
+ return;
3778
+ }
3779
+ const logs = names.filter((name) => SESSION_LOG.test(name)).sort();
3780
+ const picked = logs.find((name) => !name.endsWith(".zstd")) ?? logs[0];
3781
+ return picked === void 0 ? void 0 : fileBytes(join(dir, picked));
3782
+ }
3783
+ /** The wanted session id a flat log name belongs to, if any (longest id wins). */
3784
+ function flatLogId(name, wanted) {
3785
+ const match = FLAT_LOG.exec(name);
3786
+ if (match === null) return void 0;
3787
+ const stem = match[1] ?? "";
3788
+ if (wanted.has(stem)) return stem;
3789
+ let best;
3790
+ for (const id of wanted) if (stem.startsWith(id) && (best === void 0 || id.length > best.length)) best = id;
3791
+ return best;
3298
3792
  }
3299
- /** Partial candidates are capped: a broad needle must stay a readable list. */
3300
- const PARTIAL_CANDIDATE_LIMIT = 10;
3301
3793
  /**
3302
- * Resolve a user/model-supplied target reference against the session list.
3303
- * Ladder, in order: exact sessionId → exact display name → partial natural
3304
- * name (substring of the display name OR of the workspace cwd, case-insensitive)
3305
- * → missing. One hit resolves outright; several hits become candidates —
3306
- * this function NEVER guesses. Partial candidates are ordered most-recently-
3307
- * active first (running sessions first) and capped, so "ask the 极简遥控器
3308
- * session" works without anyone digging session ids out of log directories.
3309
- * @param sessions - rows from `sessionController.list()`.
3310
- * @param reference - a session id, display name, or natural partial name.
3311
- * @returns the unique target, the candidate list, or "missing".
3794
+ * Byte sizes of the given sessions' own logs, keyed by session id.
3795
+ *
3796
+ * One bounded pass: `readdir` of `$DSH_HOME/sessions/`, then one level of cwd
3797
+ * groups, then the log file inside each matching session directory (the nested
3798
+ * layout) or matching flat file names at the root. Unknown layouts, missing
3799
+ * directories and unreadable entries are simply absent — never an exception,
3800
+ * and no session is touched beyond reading its file size.
3801
+ * @param sessionIds - ids to look up; non-string and empty ids are ignored.
3802
+ * @returns id → bytes; ids without a readable log are missing from the map.
3312
3803
  */
3313
- function matchTargetSession(sessions, reference) {
3804
+ function sessionLogSizes(sessionIds) {
3805
+ const sizes = /* @__PURE__ */ new Map();
3806
+ const wanted = new Set((Array.isArray(sessionIds) ? sessionIds : []).filter((id) => typeof id === "string" && id.length > 0));
3807
+ if (wanted.size === 0) return sizes;
3808
+ const root = sessionLogRoot();
3809
+ let entries;
3810
+ try {
3811
+ entries = readdirSync(root, { withFileTypes: true });
3812
+ } catch {
3813
+ return sizes;
3814
+ }
3815
+ for (const entry of entries) {
3816
+ if (entry.isDirectory()) {
3817
+ const group = join(root, entry.name);
3818
+ let children;
3819
+ try {
3820
+ children = readdirSync(group, { withFileTypes: true });
3821
+ } catch {
3822
+ continue;
3823
+ }
3824
+ for (const child of children) {
3825
+ if (!wanted.has(child.name) || sizes.has(child.name)) continue;
3826
+ const size = child.isDirectory() ? logBytesIn(join(group, child.name)) : fileBytes(join(group, child.name));
3827
+ if (size !== void 0) sizes.set(child.name, size);
3828
+ }
3829
+ continue;
3830
+ }
3831
+ if (!entry.isFile()) continue;
3832
+ const id = flatLogId(entry.name, wanted);
3833
+ if (id === void 0 || sizes.has(id)) continue;
3834
+ const size = fileBytes(join(root, entry.name));
3835
+ if (size !== void 0) sizes.set(id, size);
3836
+ }
3837
+ return sizes;
3838
+ }
3839
+ /**
3840
+ * Attach the log-size hint (`sizeBytes`) to every row that does not carry one,
3841
+ * probing once for the whole batch. Rows that already know their size and
3842
+ * malformed entries pass through untouched.
3843
+ * @param sessions - roster rows; the input is never mutated.
3844
+ * @returns rows, with `sizeBytes` filled where a log was found.
3845
+ */
3846
+ function withSessionLogSizes(sessions) {
3847
+ const rows = Array.isArray(sessions) ? sessions : [];
3848
+ const missing = [];
3849
+ for (const row of rows) {
3850
+ if (!isRecord(row) || bytesOf$1(row.sizeBytes) !== void 0) continue;
3851
+ if (typeof row.sessionId === "string" && row.sessionId.length > 0) missing.push(row.sessionId);
3852
+ }
3853
+ const sizes = sessionLogSizes(missing);
3854
+ return rows.map((row) => {
3855
+ if (!isRecord(row) || bytesOf$1(row.sizeBytes) !== void 0) return row;
3856
+ const size = typeof row.sessionId === "string" ? sizes.get(row.sessionId) : void 0;
3857
+ return size === void 0 ? row : {
3858
+ ...row,
3859
+ sizeBytes: size
3860
+ };
3861
+ });
3862
+ }
3863
+ /**
3864
+ * Build the roster: drop unusable rows, filter (optional), order, then cap.
3865
+ * @param sessions - rows from `sessionController.list()`; malformed rows are degraded, never fatal.
3866
+ * @param options.query - case-insensitive substring over name, cwd, or hint; empty means no filter.
3867
+ * @param options.limit - max rows, default 30, clamped to 1..50.
3868
+ * @param options.hideSubagents - also drop subagent rows (the setting's default).
3869
+ * @returns ordered rows with display names, flags, and recency hints.
3870
+ */
3871
+ function buildSessionRoster(sessions, options) {
3872
+ const query = typeof options?.query === "string" ? options.query.trim().toLowerCase() : "";
3873
+ const rows = [];
3874
+ for (const session of sessions) {
3875
+ if (!isRecord(session)) continue;
3876
+ if (isNoiseSession(session)) continue;
3877
+ const raw = session;
3878
+ if (options?.hideSubagents === true && raw.origin === "subagent") continue;
3879
+ const cwd = typeof raw.cwd === "string" && raw.cwd.length > 0 ? raw.cwd : void 0;
3880
+ const hasTime = typeof raw.updatedAt === "number" && Number.isFinite(raw.updatedAt);
3881
+ const title = titleOf$1(raw.projections);
3882
+ const sizeHint = bytesOf$1(raw.sizeBytes);
3883
+ const row = {
3884
+ sessionId: typeof raw.sessionId === "string" ? raw.sessionId : "",
3885
+ name: title || fallbackName(cwd, raw.updatedAt),
3886
+ ...cwd === void 0 ? {} : { cwd },
3887
+ updatedAt: hasTime ? raw.updatedAt : 0,
3888
+ running: raw.running === true,
3889
+ subagent: raw.origin === "subagent",
3890
+ hint: buildHint(cwd, hasTime ? raw.updatedAt : void 0),
3891
+ hasTitle: title.length > 0,
3892
+ ...sizeHint === void 0 ? {} : { sizeHint }
3893
+ };
3894
+ if (query.length > 0 && !matchesQuery(row, query)) continue;
3895
+ rows.push(row);
3896
+ }
3897
+ rows.sort(compareRows);
3898
+ const kept = rows.slice(0, clampLimit(options?.limit));
3899
+ const unknown = kept.filter((row) => row.sizeHint === void 0).map((row) => row.sessionId);
3900
+ if (unknown.length === 0) return kept;
3901
+ const sizes = sessionLogSizes(unknown);
3902
+ return kept.map((row) => {
3903
+ if (row.sizeHint !== void 0) return row;
3904
+ const size = sizes.get(row.sessionId);
3905
+ return size === void 0 ? row : {
3906
+ ...row,
3907
+ sizeHint: size
3908
+ };
3909
+ });
3910
+ }
3911
+ /**
3912
+ * Render the roster as the text the model reads. `hasTitle` stays out of the
3913
+ * text on purpose: it is a diagnostic fact, not a hint for the model.
3914
+ * @param rows - ordered rows from {@link buildSessionRoster}.
3915
+ * @param options.filtered - true when a query produced these rows, so the head reads 匹配.
3916
+ * @returns heading plus one `- <name> — <hint>` line per row, or the empty notice.
3917
+ */
3918
+ function renderRoster(rows, options) {
3919
+ if (rows.length === 0) return "没有匹配的会话。";
3920
+ return [`${options?.filtered === true ? "匹配" : "共"} ${rows.length} 个会话`, ...rows.map((row) => {
3921
+ const flags = `${row.running ? " · 运行中" : ""}${row.subagent ? " · 子代理" : ""}`;
3922
+ return `- ${shortSessionName(row.name, row.cwd)} — ${row.hint}${flags}`;
3923
+ })].join("\n");
3924
+ }
3925
+ //#endregion
3926
+ //#region src/session-router.ts
3927
+ /**
3928
+ * Question → session router (pure): the keyword retrieval layer behind
3929
+ * "ask a question without naming a session". `ask_session` hands an unnamed
3930
+ * question to {@link routeSessions}, gets back a small candidate list, and
3931
+ * returns it to the caller for the USER to pick — nothing is guessed and
3932
+ * nothing is delivered here.
3933
+ *
3934
+ * Determinism notes:
3935
+ * - No model call, no network. The only I/O is the alias-file pair at the
3936
+ * bottom, which the tool layer uses to remember a user's own phrasing.
3937
+ * - The one time input is `updatedAt` from the roster, so a test can freeze
3938
+ * time by choosing the rows; the wide-pool decay is measured from the newest
3939
+ * row, never from the wall clock. (The activity label and the route cache are
3940
+ * the two exceptions: both are coarse, wall-clock facts.)
3941
+ * - Odd input (a null row, a missing title, a NaN timestamp, a corrupt alias
3942
+ * file) degrades to "no match", never to an exception.
3943
+ */
3944
+ /**
3945
+ * Pure exclusion matcher (swarm.routingExclude): an entry matches when it
3946
+ * equals the session id (case-insensitive) or is a case-insensitive substring
3947
+ * of any provided name — the session's title and/or its fallback display name.
3948
+ * Blank entries never match, so an empty list excludes nothing.
3949
+ */
3950
+ function isSessionExcluded(entries, session) {
3951
+ const list = Array.isArray(entries) ? entries : [];
3952
+ if (list.length === 0 || session === void 0 || session === null) return false;
3953
+ const id = typeof session.sessionId === "string" ? session.sessionId.trim().toLowerCase() : "";
3954
+ const names = Array.isArray(session.names) ? session.names.filter((name) => typeof name === "string" && name.trim().length > 0).map((name) => name.trim().toLowerCase()) : [];
3955
+ for (const raw of list) {
3956
+ if (typeof raw !== "string") continue;
3957
+ const entry = raw.trim().toLowerCase();
3958
+ if (entry.length === 0) continue;
3959
+ if (id.length > 0 && entry === id) return true;
3960
+ if (names.some((name) => name.includes(entry))) return true;
3961
+ }
3962
+ return false;
3963
+ }
3964
+ /** Cache-key fragment: the exclusion list, normalised so order/case do not matter. */
3965
+ function excludeKey(entries) {
3966
+ return (Array.isArray(entries) ? entries : []).filter((entry) => typeof entry === "string").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0).sort().join("\0");
3967
+ }
3968
+ /** True when this indexed row is on the exclusion list. */
3969
+ function rowExcluded(entries, row) {
3970
+ const names = row.hasTitle ? [row.name] : [row.name, row.cwd];
3971
+ return isSessionExcluded(entries, {
3972
+ sessionId: row.sessionId,
3973
+ names
3974
+ });
3975
+ }
3976
+ /** Default and hard caps for how many candidates one pass returns (schema: 1–8). */
3977
+ const DEFAULT_TOP_N = 3;
3978
+ const MIN_TOP_N = 1;
3979
+ const MAX_TOP_N = 8;
3980
+ /** Jev routing: at/above this confidence the pick moves to the front. */
3981
+ const JEV_RECOMMEND_THRESHOLD = .75;
3982
+ /** Below this the answer is not even a preference; 0.45–0.75 keeps the local order. */
3983
+ const JEV_DEFER_THRESHOLD = .45;
3984
+ /** Candidate notes for the Jev engine. */
3985
+ const JEV_PICK_NOTE = " · Jev 推荐";
3986
+ const JEV_UNCONFIGURED_NOTE = " · Jev 未配置";
3987
+ /** Shortest run that may act as an exact keyword; also the CJK bigram width. */
3988
+ const MIN_KEYWORD_LENGTH = 2;
3989
+ /**
3990
+ * A bigram-only match needs this many shared bigrams. One shared bigram is
3991
+ * almost noise (2–3 character titles would otherwise float to the top on a
3992
+ * single accidental pair).
3993
+ */
3994
+ const BIGRAM_MIN_HITS = 2;
3995
+ /**
3996
+ * The Dice/bigram contribution is a fuzzy signal: it is capped here so a
3997
+ * merely similar title can never outrank a real hit, whatever its length.
3998
+ */
3999
+ const DICE_SCORE_CAP = 40;
4000
+ /**
4001
+ * Hard-channel bonuses, added on top of the capped Dice score and clamped at
4002
+ * 100. They stay decisive: any one of them lifts the row above the fuzzy cap.
4003
+ */
4004
+ const TITLE_KEYWORD_BONUS = 40;
4005
+ const CWD_KEYWORD_BONUS = 30;
4006
+ const FULL_PHRASE_BONUS = 60;
4007
+ const ALIAS_BONUS = 60;
4008
+ const RUNNING_BONUS = 10;
4009
+ /** Activity-label boundaries: 热 ≤ 1h, 温 ≤ 24h, anything older is 冷. */
4010
+ const HOT_WINDOW_MS = 36e5;
4011
+ const WARM_WINDOW_MS = 864e5;
4012
+ /** Route cache: the same question inside this window reuses one routing pass. */
4013
+ const ROUTE_CACHE_TTL_MS = 18e4;
4014
+ const ROUTE_CACHE_MAX = 64;
4015
+ /**
4016
+ * In-memory route cache: routing reads only the roster and the question, so the
4017
+ * same question inside the TTL can reuse the previous answer. Bounded (oldest
4018
+ * evicted) and never persisted; {@link clearRouteCache} is the test seam.
4019
+ */
4020
+ const routeCache = /* @__PURE__ */ new Map();
4021
+ /**
4022
+ * A title hit must outrank a plain directory-only hit whatever their Dice
4023
+ * similarity: the directory channel's own contribution tops out at
4024
+ * DICE_SCORE_CAP + CWD_KEYWORD_BONUS (= 70), so every title hit is lifted above
4025
+ * that ceiling. Without this a title's extra token can lower its Dice score and
4026
+ * invert the intended priority — the live bug: a titled row at 27 behind
4027
+ * cwd-only rows at 28.
4028
+ */
4029
+ const TITLE_HIT_FLOOR = 71;
4030
+ /**
4031
+ * Shortest run of a session's own name that counts as "the question named this
4032
+ * session" when spoken inside the question. Chinese is written without spaces,
4033
+ * so a project name is normally embedded in a longer run ("那个做极简遥控器的
4034
+ * 会话"); a run this long is specific enough to be a hard channel, while a
4035
+ * shorter one stays fuzzy (3 characters are routinely a coincidence).
4036
+ */
4037
+ const TITLE_MENTION_MIN_CHARS = 4;
4038
+ /**
4039
+ * The same, for one working-directory segment. A folder name is a project
4040
+ * identifier rather than prose, so one character less already carries weight.
4041
+ */
4042
+ const CWD_MENTION_MIN_CHARS = 3;
4043
+ /** Zero-hit fallback pool: fuzzy top 20 ∪ most recent 10, deduped. */
4044
+ const WIDE_FUZZY_LIMIT = 20;
4045
+ const WIDE_RECENT_LIMIT = 10;
4046
+ /** Half-life of the fallback time decay, measured from the newest row. */
4047
+ const DECAY_HALF_LIFE_MS = 864e5;
4048
+ const CJK_CHAR = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/u;
4049
+ const TOKEN_RUN = /[\p{L}\p{N}]+/gu;
4050
+ const RUN_SEGMENT = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+|[A-Za-z0-9]+/gu;
4051
+ const PATH_SPLIT = /[\\/]+/u;
4052
+ const DRIVE_SEGMENT = /^[A-Za-z]:$/u;
4053
+ /** Whitespace, punctuation and symbols carry no routing signal. */
4054
+ const NOISE = /[\s\p{P}\p{S}]+/gu;
4055
+ function emptyTokens() {
4056
+ return {
4057
+ words: /* @__PURE__ */ new Set(),
4058
+ bigrams: /* @__PURE__ */ new Set(),
4059
+ phrases: /* @__PURE__ */ new Set()
4060
+ };
4061
+ }
4062
+ /** The host publishes the title as a plain string; the `{ title }` wrapper is tolerated. */
4063
+ function titleOf(projections) {
4064
+ if (projections === null || typeof projections !== "object") return "";
4065
+ const values = projections.values;
4066
+ if (values === null || typeof values !== "object") return "";
4067
+ const raw = values.title;
4068
+ return (typeof raw === "string" ? raw : raw !== null && typeof raw === "object" && typeof raw.title === "string" ? raw.title : "").trim();
4069
+ }
4070
+ /**
4071
+ * Lowercase, then strip whitespace/punctuation/symbols. This is the key form
4072
+ * used for exact keywords, whole-phrase containment and alias entries, so a
4073
+ * user's phrasing survives their own spacing and punctuation.
4074
+ * @param text - any string; a non-string reads as ''.
4075
+ * @returns the normalised key (possibly empty).
4076
+ */
4077
+ function normalizeAliasKey(text) {
4078
+ if (typeof text !== "string") return "";
4079
+ return text.toLowerCase().replace(NOISE, "");
4080
+ }
4081
+ /**
4082
+ * How recently a session was active, as one readable character: 热 ≤ 1h,
4083
+ * 温 ≤ 24h, 冷 beyond that (a week old and yesterday alike). A label only —
4084
+ * nothing is excluded from the candidates on account of it.
4085
+ * @param updatedAt - the row's last-activity timestamp (ms).
4086
+ * @param now - the clock to measure against, passed in so tests can pin it.
4087
+ * @returns the label; a non-finite age reads as 冷.
4088
+ */
4089
+ function activityLabel(updatedAt, now) {
4090
+ const age = now - updatedAt;
4091
+ if (!Number.isFinite(age)) return "冷";
4092
+ if (age <= HOT_WINDOW_MS) return "热";
4093
+ if (age <= WARM_WINDOW_MS) return "温";
4094
+ return "冷";
4095
+ }
4096
+ /**
4097
+ * Add one text's tokens to a token set.
4098
+ *
4099
+ * CJK runs are additionally cut into 2-character bigrams; ASCII runs stay whole
4100
+ * words. A CJK run that is a single character is skipped — too weak to carry a
4101
+ * routing decision on its own.
4102
+ */
4103
+ function addText(target, text) {
4104
+ if (typeof text !== "string" || text.length === 0) return;
4105
+ for (const run of text.match(TOKEN_RUN) ?? []) {
4106
+ if (!CJK_CHAR.test(run)) {
4107
+ const word = run.toLowerCase();
4108
+ target.words.add(word);
4109
+ if (word.length >= MIN_KEYWORD_LENGTH) target.phrases.add(word);
4110
+ continue;
4111
+ }
4112
+ for (const segment of run.match(RUN_SEGMENT) ?? []) {
4113
+ if (!CJK_CHAR.test(segment)) {
4114
+ const word = segment.toLowerCase();
4115
+ target.words.add(word);
4116
+ if (word.length >= MIN_KEYWORD_LENGTH) target.phrases.add(word);
4117
+ continue;
4118
+ }
4119
+ if (segment.length < MIN_KEYWORD_LENGTH) continue;
4120
+ target.phrases.add(segment);
4121
+ for (let i = 0; i + MIN_KEYWORD_LENGTH <= segment.length; i += 1) target.bigrams.add(segment.slice(i, i + MIN_KEYWORD_LENGTH));
4122
+ }
4123
+ }
4124
+ }
4125
+ /** Index one roster row; a row without a sessionId cannot be routed and is skipped. */
4126
+ function indexSession(session) {
4127
+ if (session === null || typeof session !== "object") return void 0;
4128
+ const raw = session;
4129
+ const sessionId = typeof raw.sessionId === "string" ? raw.sessionId : "";
4130
+ if (sessionId.length === 0) return void 0;
4131
+ const title = titleOf(raw.projections);
4132
+ const cwd = typeof raw.cwd === "string" ? raw.cwd : "";
4133
+ const cwdSegments = cwd.split(PATH_SPLIT).filter((segment) => segment.length > 0 && !DRIVE_SEGMENT.test(segment));
4134
+ const tokens = emptyTokens();
4135
+ const cwdTexts = [];
4136
+ if (title.length > 0) addText(tokens, title);
4137
+ for (const segment of cwdSegments) {
4138
+ addText(tokens, segment);
4139
+ cwdTexts.push(normalizeAliasKey(segment));
4140
+ }
4141
+ const folderName = cwdSegments.length > 0 ? cwdSegments[cwdSegments.length - 1] : "";
4142
+ const sizeHint = bytesOf(raw.sizeHint ?? raw.sizeBytes);
4143
+ return {
4144
+ sessionId,
4145
+ name: title.length > 0 ? title : folderName.length > 0 ? folderName : cwd.length > 0 ? cwd : sessionId,
4146
+ ...cwd.length > 0 ? { cwd } : {},
4147
+ titleText: normalizeAliasKey(title),
4148
+ hasTitle: title.length > 0,
4149
+ cwdTexts,
4150
+ tokens,
4151
+ running: raw.running === true,
4152
+ updatedAt: typeof raw.updatedAt === "number" && Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0,
4153
+ ...sizeHint === void 0 ? {} : { sizeHint }
4154
+ };
4155
+ }
4156
+ /** A byte count from an untrusted value: finite and non-negative, or undefined. */
4157
+ function bytesOf(value) {
4158
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : void 0;
4159
+ }
4160
+ /** Read the question once: tokens, longest-first keywords, and the whole phrase. */
4161
+ function readQuestion(question) {
4162
+ const text = typeof question === "string" ? question : "";
4163
+ const tokens = emptyTokens();
4164
+ addText(tokens, text);
4165
+ return {
4166
+ tokens,
4167
+ keywords: [...tokens.phrases].map((phrase) => normalizeAliasKey(phrase)).filter((phrase) => phrase.length >= MIN_KEYWORD_LENGTH).sort((a, b) => b.length - a.length || (a < b ? -1 : a > b ? 1 : 0)),
4168
+ phrase: normalizeAliasKey(text)
4169
+ };
4170
+ }
4171
+ /** Shared token count, iterating the smaller set. */
4172
+ function intersectionSize(a, b) {
4173
+ const [small, large] = a.size <= b.size ? [a, b] : [b, a];
4174
+ let hits = 0;
4175
+ for (const token of small) if (large.has(token)) hits += 1;
4176
+ return hits;
4177
+ }
4178
+ /** Dice coefficient over the two token sets: 2|A∩B|/(|A|+|B|). */
4179
+ function diceScore(question, session) {
4180
+ const total = question.words.size + question.bigrams.size + session.words.size + session.bigrams.size;
4181
+ if (total === 0) return 0;
4182
+ return 2 * (intersectionSize(question.words, session.words) + intersectionSize(question.bigrams, session.bigrams)) / total;
4183
+ }
4184
+ /** Longest keyword found as an exact substring of any haystack, or undefined. */
4185
+ function keywordHit(keywords, haystacks) {
4186
+ for (const keyword of keywords) for (const haystack of haystacks) if (haystack.length > 0 && haystack.includes(keyword)) return keyword;
4187
+ }
4188
+ /**
4189
+ * Longest run of `text` that also occurs inside `phrase`, provided it is at
4190
+ * least `minChars` long; undefined otherwise.
4191
+ *
4192
+ * This is the reverse direction of {@link keywordHit}: it asks whether a
4193
+ * session's own name was spoken inside the question. Chinese has no spaces, so
4194
+ * "那个做极简遥控器的会话" never yields a keyword, yet it clearly names 极简遥控器.
4195
+ * Anchoring on a minimum length keeps a short accidental overlap fuzzy.
4196
+ */
4197
+ function mentionIn(text, phrase, minChars) {
4198
+ if (text.length < minChars || phrase.length === 0) return void 0;
4199
+ let best = "";
4200
+ for (let start = 0; start + minChars <= text.length; start += 1) {
4201
+ const seed = text.slice(start, start + minChars);
4202
+ for (let at = phrase.indexOf(seed); at >= 0; at = phrase.indexOf(seed, at + 1)) {
4203
+ let length = minChars;
4204
+ while (start + length < text.length && at + length < phrase.length && text[start + length] === phrase[at + length]) length += 1;
4205
+ if (length > best.length) best = text.slice(start, start + length);
4206
+ }
4207
+ }
4208
+ return best.length > 0 ? best : void 0;
4209
+ }
4210
+ /** The longest mention across several haystacks, or undefined. */
4211
+ function mentionInAny(texts, phrase, minChars) {
4212
+ let best;
4213
+ for (const text of texts) {
4214
+ const hit = mentionIn(text, phrase, minChars);
4215
+ if (hit !== void 0 && (best === void 0 || hit.length > best.length)) best = hit;
4216
+ }
4217
+ return best;
4218
+ }
4219
+ /** Score one indexed row, or return undefined when it is neither matched nor hard-channel. */
4220
+ function scoreRow(row, query, aliasHit) {
4221
+ const wordHits = intersectionSize(query.tokens.words, row.tokens.words);
4222
+ const bigramHits = intersectionSize(query.tokens.bigrams, row.tokens.bigrams);
4223
+ const matched = wordHits > 0 || bigramHits >= BIGRAM_MIN_HITS;
4224
+ const titleHit = keywordHit(query.keywords, [row.titleText]) ?? mentionIn(row.titleText, query.phrase, TITLE_MENTION_MIN_CHARS);
4225
+ const cwdKeyword = titleHit === void 0 ? keywordHit(query.keywords, row.cwdTexts) : void 0;
4226
+ const cwdHit = titleHit !== void 0 ? void 0 : cwdKeyword ?? mentionInAny(row.cwdTexts, query.phrase, CWD_MENTION_MIN_CHARS);
4227
+ const fullPhrase = query.phrase.length >= MIN_KEYWORD_LENGTH && (row.titleText.includes(query.phrase) || row.cwdTexts.some((text) => text.includes(query.phrase)));
4228
+ let score = matched ? Math.min(DICE_SCORE_CAP, Math.round(diceScore(query.tokens, row.tokens) * 100)) : 0;
4229
+ if (titleHit !== void 0) score += TITLE_KEYWORD_BONUS;
4230
+ else if (cwdHit !== void 0) score += CWD_KEYWORD_BONUS;
4231
+ if (fullPhrase) score += FULL_PHRASE_BONUS;
4232
+ if (aliasHit !== void 0) score += ALIAS_BONUS;
4233
+ if (row.running) score += RUNNING_BONUS;
4234
+ score = Math.min(100, Math.max(0, score));
4235
+ if (titleHit !== void 0) score = Math.max(score, TITLE_HIT_FLOOR);
4236
+ const hard = titleHit !== void 0 || cwdHit !== void 0 || fullPhrase || aliasHit !== void 0 || row.running;
4237
+ if (!matched && !hard) return void 0;
4238
+ const reason = aliasHit !== void 0 ? "别名命中" : titleHit !== void 0 ? `标题命中「${titleHit}」` : cwdHit !== void 0 ? "目录命中" : fullPhrase ? "整句命中" : matched ? "问题相关" : "运行中";
4239
+ return {
4240
+ row,
4241
+ score,
4242
+ reason,
4243
+ titleHit: titleHit !== void 0
4244
+ };
4245
+ }
4246
+ /** Project identity for the size tie-break: separators and case are not identity. */
4247
+ function projectKey(cwd) {
4248
+ if (typeof cwd !== "string") return "";
4249
+ return cwd.trim().replace(/[\\/]+/gu, "/").replace(/\/+$/u, "").toLowerCase();
4250
+ }
4251
+ /** Same project = both rows name the same normalised cwd; an unknown cwd never matches. */
4252
+ function sameProject(a, b) {
4253
+ const left = projectKey(a.cwd);
4254
+ return left.length > 0 && left === projectKey(b.cwd);
4255
+ }
4256
+ /**
4257
+ * Recency band as an order key: 热 = 0, 温 = 1, 冷 = 2. The three bands are the
4258
+ * ones {@link activityLabel} already defines, so the order and the label a
4259
+ * candidate shows can never disagree.
4260
+ */
4261
+ function bandRank(updatedAt, now) {
4262
+ const label = activityLabel(updatedAt, now);
4263
+ return label === "热" ? 0 : label === "温" ? 1 : 2;
4264
+ }
4265
+ /**
4266
+ * True when the row's log size is known AND below {@link ROUTE_MIN_CONTEXT_BYTES}.
4267
+ * An unknown size is never thin: a probe miss must not cost a session a slot.
4268
+ */
4269
+ function tooThin(row) {
4270
+ return row.sizeHint !== void 0 && row.sizeHint < 160380;
4271
+ }
4272
+ /**
4273
+ * Deterministic order: score desc, a title hit above a directory-only hit,
4274
+ * running first, then substance (a known-small row behind every row that
4275
+ * clears the floor), then the recency band (热 > 温 > 冷), then the bigger
4276
+ * known context inside one project of the SAME band, then recency, then
4277
+ * shorter title.
4278
+ */
4279
+ function compareScored(a, b, now) {
4280
+ if (a.score !== b.score) return b.score - a.score;
4281
+ if (a.titleHit !== b.titleHit) return a.titleHit ? -1 : 1;
4282
+ if (a.row.running !== b.row.running) return a.row.running ? -1 : 1;
4283
+ const thinA = tooThin(a.row);
4284
+ if (thinA !== tooThin(b.row)) return thinA ? 1 : -1;
4285
+ const bandA = bandRank(a.row.updatedAt, now);
4286
+ const bandB = bandRank(b.row.updatedAt, now);
4287
+ if (bandA !== bandB) return bandA - bandB;
4288
+ if (sameProject(a.row, b.row)) {
4289
+ const left = a.row.sizeHint;
4290
+ const right = b.row.sizeHint;
4291
+ if (left !== void 0 && right !== void 0 && left !== right) return right - left;
4292
+ }
4293
+ if (a.row.updatedAt !== b.row.updatedAt) return b.row.updatedAt - a.row.updatedAt;
4294
+ if (a.row.name.length !== b.row.name.length) return a.row.name.length - b.row.name.length;
4295
+ return a.row.sessionId < b.row.sessionId ? -1 : a.row.sessionId > b.row.sessionId ? 1 : 0;
4296
+ }
4297
+ /** Default 3; a non-finite value falls back to it, everything else is clamped to 1..8. */
4298
+ function clampTopN(value) {
4299
+ if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_TOP_N;
4300
+ return Math.min(MAX_TOP_N, Math.max(MIN_TOP_N, Math.floor(value)));
4301
+ }
4302
+ /** Normalised alias phrasing → sessionId; malformed entries are dropped. */
4303
+ function readAliasMap(aliases) {
4304
+ const map = /* @__PURE__ */ new Map();
4305
+ if (aliases === null || typeof aliases !== "object" || Array.isArray(aliases)) return map;
4306
+ for (const [phrasing, sessionId] of Object.entries(aliases)) {
4307
+ if (typeof sessionId !== "string" || sessionId.length === 0) continue;
4308
+ const key = normalizeAliasKey(phrasing);
4309
+ if (key.length === 0) continue;
4310
+ map.set(key, sessionId);
4311
+ }
4312
+ return map;
4313
+ }
4314
+ /** Sessions whose phrasing occurs inside the question; the longest phrasing per session wins. */
4315
+ function aliasHits(questionPhrase, aliases) {
4316
+ const hits = /* @__PURE__ */ new Map();
4317
+ if (questionPhrase.length === 0) return hits;
4318
+ for (const [phrasing, sessionId] of aliases) {
4319
+ if (!questionPhrase.includes(phrasing)) continue;
4320
+ const current = hits.get(sessionId);
4321
+ if (current === void 0 || phrasing.length > current.length) hits.set(sessionId, phrasing);
4322
+ }
4323
+ return hits;
4324
+ }
4325
+ function candidateOf$1(scored, now) {
4326
+ const thin = tooThin(scored.row) ? " · 体积小" : "";
4327
+ return {
4328
+ sessionId: scored.row.sessionId,
4329
+ name: shortSessionName(scored.row.name, scored.row.cwd),
4330
+ score: scored.score,
4331
+ reason: `${scored.reason} · ${activityLabel(scored.row.updatedAt, now)}${thin}`
4332
+ };
4333
+ }
4334
+ /**
4335
+ * True when only the bigger known context puts `a` ahead of `b`: equal rank
4336
+ * keys, the same recency band, same project, and without the size key `b` would
4337
+ * come first (newer, then shorter title, then id). Used only to label the reason
4338
+ * 「积累多」, so the label never claims a placement the score or the freshness
4339
+ * band already decided.
4340
+ */
4341
+ function sizeDecided(a, b, now) {
4342
+ if (a.score !== b.score || a.titleHit !== b.titleHit || a.row.running !== b.row.running) return false;
4343
+ if (bandRank(a.row.updatedAt, now) !== bandRank(b.row.updatedAt, now)) return false;
4344
+ if (!sameProject(a.row, b.row)) return false;
4345
+ const left = a.row.sizeHint;
4346
+ const right = b.row.sizeHint;
4347
+ if (left === void 0 || right === void 0 || left <= right) return false;
4348
+ if (a.row.updatedAt !== b.row.updatedAt) return b.row.updatedAt > a.row.updatedAt;
4349
+ if (a.row.name.length !== b.row.name.length) return b.row.name.length < a.row.name.length;
4350
+ return b.row.sessionId < a.row.sessionId;
4351
+ }
4352
+ /**
4353
+ * Take the best `topN` rows, in order, without spending two slots on one
4354
+ * fallback name: several untitled sessions in the same folder all render as
4355
+ * "极简遥控器", and letting them fill the list hides the titled match behind
4356
+ * identical labels. A titled row is never skipped or displaced by that rule —
4357
+ * only a title-less row whose label an earlier title-less row already used.
4358
+ */
4359
+ function selectTop(scored, topN, now) {
4360
+ const usedFallbackNames = /* @__PURE__ */ new Set();
4361
+ const picked = [];
4362
+ for (const row of scored) {
4363
+ if (!row.row.hasTitle) {
4364
+ const label = shortSessionName(row.row.name, row.row.cwd);
4365
+ if (usedFallbackNames.has(label)) continue;
4366
+ usedFallbackNames.add(label);
4367
+ }
4368
+ picked.push(row);
4369
+ if (picked.length >= topN) break;
4370
+ }
4371
+ return picked.map((row, index) => {
4372
+ const candidate = candidateOf$1(row, now);
4373
+ const next = picked[index + 1];
4374
+ return next !== void 0 && sizeDecided(row, next, now) ? {
4375
+ ...candidate,
4376
+ reason: `${candidate.reason} · 积累多`
4377
+ } : candidate;
4378
+ });
4379
+ }
4380
+ /**
4381
+ * Zero-hit fallback: fuzzy top 20 ∪ most recent 10, deduped, then scored by the
4382
+ * same Dice formula under a time decay. The decay is measured from the newest
4383
+ * row in the pool, so the result depends only on the roster, not on the clock.
4384
+ */
4385
+ function widePool(indexed, query, topN, now) {
4386
+ const newest = indexed.reduce((max, row) => row.updatedAt > max ? row.updatedAt : max, 0);
4387
+ const pool = /* @__PURE__ */ new Map();
4388
+ const byDice = [...indexed].sort((a, b) => diceScore(query.tokens, b.tokens) - diceScore(query.tokens, a.tokens));
4389
+ for (const row of byDice.slice(0, WIDE_FUZZY_LIMIT)) pool.set(row.sessionId, row);
4390
+ const byRecency = [...indexed].sort((a, b) => b.updatedAt - a.updatedAt);
4391
+ for (const row of byRecency.slice(0, WIDE_RECENT_LIMIT)) pool.set(row.sessionId, row);
4392
+ const scored = [...pool.values()].map((row) => {
4393
+ const base = Math.min(DICE_SCORE_CAP, Math.round(diceScore(query.tokens, row.tokens) * 100));
4394
+ const age = newest - row.updatedAt;
4395
+ const decay = age > 0 ? Math.pow(.5, age / DECAY_HALF_LIFE_MS) : 1;
4396
+ return {
4397
+ row,
4398
+ score: Math.min(100, Math.max(0, Math.round(base * decay))),
4399
+ reason: base > 0 ? "弱匹配" : "最近活跃",
4400
+ titleHit: false
4401
+ };
4402
+ });
4403
+ scored.sort((a, b) => compareScored(a, b, now));
4404
+ return selectTop(scored, topN, now);
4405
+ }
4406
+ /**
4407
+ * Score every roster session against a question and return the candidates.
4408
+ *
4409
+ * Hard channels always join the candidate set regardless of score (unless the
4410
+ * topN cap pushes them out): a title (or directory) name spoken inside the
4411
+ * question, a whole question keyword inside the title, a known alias phrasing,
4412
+ * and "the session is running". Everything else must clear the bigram
4413
+ * threshold. When nothing matches, the wide pool answers with the most
4414
+ * relevant/recent sessions instead of an empty list.
4415
+ *
4416
+ * The result is cached in memory for 180 s (64 entries, oldest evicted), keyed
4417
+ * by the normalised question, the cap, and the asking session: the same
4418
+ * question asked twice does no duplicate work and returns the same candidates.
4419
+ *
4420
+ * @param sessions - rows from `sessionController.list()`; malformed rows are skipped.
4421
+ * @param question - the question as the caller wrote it; no target name needed.
4422
+ * @param options.topN - max candidates, default 3, clamped to 1..8.
4423
+ * @param options.aliases - normalised user phrasing → sessionId; the caller
4424
+ * drops entries whose session is no longer in the roster.
4425
+ * @param options.currentSessionId - the asking session, filtered out so the
4426
+ * model is never offered the session it is already in.
4427
+ * @param options.cache - false bypasses (and does not fill) the route cache.
4428
+ * @param options.engine - which engine owns the cache entry; the local pass
4429
+ * itself is engine-agnostic (Jev re-ranks afterwards, see
4430
+ * {@link routeSessionsWithEngine}).
4431
+ * @returns candidates ordered best-first; never throws, never delivers.
4432
+ */
4433
+ function routeSessions(sessions, question, options) {
4434
+ const topN = clampTopN(options?.topN);
4435
+ const engine = options?.engine === "jev" ? "jev" : "local";
4436
+ const cache = options?.cache !== false;
4437
+ const key = routeCacheKey(question, topN, options?.currentSessionId, engine, options?.exclude);
4438
+ if (cache) {
4439
+ const cached = readCachedRoute(key);
4440
+ if (cached !== void 0) return [...cached];
4441
+ }
4442
+ const candidates = routeUncached(sessions, question, topN, options);
4443
+ if (cache) writeCachedRoute(key, candidates);
4444
+ return [...candidates];
4445
+ }
4446
+ /**
4447
+ * What a Jev route answer amounts to — the single source of truth for both the
4448
+ * ordering below and the diagnostics log:
4449
+ * - `recommend`: ≥ 0.75 and the picked candidate is in the list and not first;
4450
+ * - `keep`: 0.45–0.75, or a confident pick that is already first (nothing moves);
4451
+ * - `none`: missing/unknown/"none"/low confidence (below 0.45).
4452
+ */
4453
+ function classifyJevRouteDecision(candidates, decision) {
4454
+ if (decision === void 0 || decision === null) return { kind: "none" };
4455
+ const choice = typeof decision.choice === "string" ? decision.choice.trim() : "";
4456
+ const confidence = typeof decision.confidence === "number" && Number.isFinite(decision.confidence) ? decision.confidence : void 0;
4457
+ if (choice.length === 0 || confidence === void 0) return { kind: "none" };
4458
+ const picked = candidates.find((candidate) => candidate.sessionId === choice);
4459
+ if (confidence < JEV_DEFER_THRESHOLD) return {
4460
+ kind: "none",
4461
+ confidence
4462
+ };
4463
+ if (confidence < JEV_RECOMMEND_THRESHOLD) return {
4464
+ kind: "keep",
4465
+ confidence,
4466
+ ...picked === void 0 ? {} : { picked }
4467
+ };
4468
+ if (picked === void 0) return {
4469
+ kind: "none",
4470
+ confidence
4471
+ };
4472
+ if (candidates[0]?.sessionId === picked.sessionId) return {
4473
+ kind: "keep",
4474
+ confidence,
4475
+ picked
4476
+ };
4477
+ return {
4478
+ kind: "recommend",
4479
+ confidence,
4480
+ picked
4481
+ };
4482
+ }
4483
+ /**
4484
+ * Re-order the local candidate list with Jev's closed-set answer.
4485
+ *
4486
+ * Only a confident pick (≥ {@link JEV_RECOMMEND_THRESHOLD}) for a session that
4487
+ * is actually in the list is obeyed: that option moves to the front and carries
4488
+ * 「Jev 推荐」. The 0.45–0.75 band keeps the local order, and so do a lower
4489
+ * confidence, the explicit "none" escape, an unknown id and a missing value.
4490
+ */
4491
+ function applyJevDecision(candidates, decision) {
4492
+ const list = Array.isArray(candidates) ? [...candidates] : [];
4493
+ const verdict = classifyJevRouteDecision(list, decision);
4494
+ if (verdict.kind !== "recommend") return list;
4495
+ const picked = verdict.picked;
4496
+ if (picked === void 0) return list;
4497
+ const index = list.findIndex((candidate) => candidate.sessionId === picked.sessionId);
4498
+ if (index <= 0) return list;
4499
+ const [found] = list.splice(index, 1);
4500
+ return [{
4501
+ ...found,
4502
+ reason: `${found.reason}${JEV_PICK_NOTE}`
4503
+ }, ...list];
4504
+ }
4505
+ /**
4506
+ * Route with the selected engine.
4507
+ *
4508
+ * The local scorer always runs first: it is the recall step AND the safety net.
4509
+ * In `jev` mode its top candidates are handed to Jev as a closed set and
4510
+ * {@link applyJevDecision} applies the answer. Every failure — missing
4511
+ * credentials, timeout, HTTP error, unparseable response, an id that is not in
4512
+ * the list — silently returns the local order and never throws: routing is a
4513
+ * proposal, never a delivery, so an engine fault must not fail the ask.
4514
+ *
4515
+ * Diagnostics: one log line per Jev decision attempt (a pure local pass writes
4516
+ * nothing). Logging is injected so this module stays free of the store.
4517
+ */
4518
+ async function routeSessionsWithEngine(sessions, question, options) {
4519
+ const engine = options?.engine === "jev" ? "jev" : "local";
4520
+ const local = routeSessions(sessions, question, {
4521
+ ...options,
4522
+ engine
4523
+ });
4524
+ if (engine !== "jev") return local;
4525
+ const log = options?.log;
4526
+ const recorded = {
4527
+ kind: "route",
4528
+ question,
4529
+ candidates: local.map((candidate) => ({
4530
+ id: candidate.sessionId,
4531
+ title: candidate.name
4532
+ }))
4533
+ };
4534
+ if (local.length === 0) {
4535
+ log?.({
4536
+ ...recorded,
4537
+ called: false,
4538
+ configured: options?.jev !== void 0,
4539
+ decision: "skipped"
4540
+ });
4541
+ return local;
4542
+ }
4543
+ if (options?.jev === void 0) {
4544
+ log?.({
4545
+ ...recorded,
4546
+ called: false,
4547
+ configured: false,
4548
+ decision: "skipped"
4549
+ });
4550
+ return local.map((candidate) => ({
4551
+ ...candidate,
4552
+ reason: `${candidate.reason}${JEV_UNCONFIGURED_NOTE}`
4553
+ }));
4554
+ }
4555
+ try {
4556
+ const decision = await options.jev({
4557
+ question,
4558
+ questionEn: options.questionEn,
4559
+ candidates: local
4560
+ });
4561
+ const verdict = classifyJevRouteDecision(local, decision);
4562
+ const picked = verdict.picked;
4563
+ const confidence = verdict.confidence;
4564
+ log?.({
4565
+ ...recorded,
4566
+ called: true,
4567
+ configured: true,
4568
+ decision: verdict.kind,
4569
+ ...picked === void 0 ? {} : {
4570
+ pickedId: picked.sessionId,
4571
+ pickedTitle: picked.name
4572
+ },
4573
+ ...confidence === void 0 ? {} : { confidence }
4574
+ });
4575
+ return applyJevDecision(local, decision);
4576
+ } catch (error) {
4577
+ log?.({
4578
+ ...recorded,
4579
+ called: true,
4580
+ configured: true,
4581
+ decision: "error",
4582
+ error: error instanceof Error ? error.message : String(error)
4583
+ });
4584
+ return local;
4585
+ }
4586
+ }
4587
+ /** At/above this confidence an answer counts as "yes" (ask / sensitive alike). */
4588
+ const CROSS_SESSION_THRESHOLD = .6;
4589
+ /**
4590
+ * Phrases that point at work which may already live in another session. Kept
4591
+ * deliberately narrow: this prefilter is the whole cost control for `gated`
4592
+ * mode, so a normal self-contained request should not pass it.
4593
+ */
4594
+ const CROSS_SESSION_POINTERS = [
4595
+ "问一下",
4596
+ "问下",
4597
+ "问一问",
4598
+ "帮我问",
4599
+ "帮忙问",
4600
+ "请问一下",
4601
+ "那个会话",
4602
+ "这个会话",
4603
+ "另一个会话",
4604
+ "别的会话",
4605
+ "其他会话",
4606
+ "那个窗口",
4607
+ "另一个窗口",
4608
+ "别处",
4609
+ "别的地方",
4610
+ "另一个地方",
4611
+ "进度",
4612
+ "版本",
4613
+ "状态",
4614
+ "最近",
4615
+ "之前",
4616
+ "上次",
4617
+ "说过",
4618
+ "聊过",
4619
+ "话题"
4620
+ ];
4621
+ /** A concrete path or hyphenated project id, e.g. `I:\DSH\dsh-advisor-group`. */
4622
+ const PROJECT_LIKE = /[a-z]:[\\/]|(?:[a-z0-9][a-z0-9._-]*[\\/][a-z0-9._-]+)|(?:[a-z]{2,}(?:-[a-z0-9]{2,}){1,})/iu;
4623
+ /** The turn already names where to go: a tool call, a session id, a quoted title. */
4624
+ const EXPLICIT_TARGET = [
4625
+ /ask_session/iu,
4626
+ /(?:session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/iu,
4627
+ /(?:问(?:一下|下)?|发给|发到|投递到|对)\s*[「『"][^」』"]{1,60}[」』"]\s*(?:那个)?会话/u
4628
+ ];
4629
+ /**
4630
+ * Harness-injected context blocks. They arrive as user-role messages but are
4631
+ * never the user's own words, so the cross-session judgment must ignore them.
4632
+ * Observed live (2026-10-20): the runtime-context snapshot, the Mnemon memory
4633
+ * snapshot and the per-turn time line were each judged as a user turn.
4634
+ */
4635
+ const INJECTED_CONTEXT_PREFIXES = [
4636
+ "Current runtime context",
4637
+ "MNEM RUNTIME MEMORY SNAPSHOT",
4638
+ "Time sampled while preparing turn",
4639
+ "Browser time zone for this request",
4640
+ "You are an AI agent powered by DeepSeek Harness"
4641
+ ];
4642
+ /**
4643
+ * True when the trimmed text starts with a known harness-injected prefix. This
4644
+ * is the belt-and-braces guard behind the source-kind gate: a host that
4645
+ * mislabels injected context as a user prompt still must not trigger a Jev call.
4646
+ */
4647
+ function isInjectedContextText(text) {
4648
+ if (typeof text !== "string") return false;
4649
+ const trimmed = text.trim();
4650
+ if (trimmed.length === 0) return false;
4651
+ return INJECTED_CONTEXT_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
4652
+ }
4653
+ /** Text blocks of one inbound `user/message` event, joined; undefined otherwise. */
4654
+ function userMessageTextOf(event) {
4655
+ if (event === null || typeof event !== "object") return void 0;
4656
+ const value = event;
4657
+ if (value.type !== "user/message") return void 0;
4658
+ const data = value.data;
4659
+ if (data === null || typeof data !== "object") return void 0;
4660
+ const content = data.content;
4661
+ if (!Array.isArray(content)) return void 0;
4662
+ const text = content.filter((part) => part !== null && typeof part === "object").filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join(" ").trim();
4663
+ return text.length > 0 ? text : void 0;
4664
+ }
4665
+ /**
4666
+ * True only when the event is a genuine human prompt.
4667
+ *
4668
+ * Evidence (DSH `MessageSourceMap` in `@deepseek-ai/dsh-llm` + this host's real
4669
+ * session logs): a `user/message` event carries its producer on
4670
+ * `data.source` — a typed prompt is `{ kind: 'user' }`, an injected runtime
4671
+ * context snapshot is `{ kind: 'runtime-context', form: 'snapshot', sections:
4672
+ * [...] }`, and a cross-session relay is `{ kind: 'session-ask', askId, ... }`.
4673
+ * Anything other than exactly `kind: 'user'` (including a missing source) is
4674
+ * not the user's own words.
4675
+ */
4676
+ function isGenuineUserPrompt(event) {
4677
+ if (event === null || typeof event !== "object") return false;
4678
+ const value = event;
4679
+ if (value.type !== "user/message") return false;
4680
+ const source = value.data?.source;
4681
+ if (source === null || typeof source !== "object") return false;
4682
+ return source.kind === "user";
4683
+ }
4684
+ /**
4685
+ * Per-session mark: an injected-context skip was already logged for the current
4686
+ * turn, so the next injected block stays silent. One line per turn, never one
4687
+ * line per block.
4688
+ */
4689
+ const injectedSkipLogged = /* @__PURE__ */ new Set();
4690
+ /** True when this injected block should write its once-per-turn skip line. */
4691
+ function shouldLogInjectedSkip(sessionId) {
4692
+ if (typeof sessionId !== "string" || sessionId.length === 0) return true;
4693
+ if (injectedSkipLogged.has(sessionId)) return false;
4694
+ injectedSkipLogged.add(sessionId);
4695
+ return true;
4696
+ }
4697
+ /** A genuine prompt starts a new turn: allow the next injected skip line. */
4698
+ function resetInjectedSkipLog(sessionId) {
4699
+ if (typeof sessionId === "string" && sessionId.length > 0) injectedSkipLogged.delete(sessionId);
4700
+ }
4701
+ /**
4702
+ * Pure, network-free prefilter: does this text look like it points at another
4703
+ * session or at work another session has already done? True only when a
4704
+ * pointer phrase / project-like token is present AND the turn does not already
4705
+ * name an explicit target (a session id, the ask_session tool, or a quoted
4706
+ * session title being addressed).
4707
+ */
4708
+ function shouldConsiderCrossSession(text) {
4709
+ if (typeof text !== "string") return false;
4710
+ const trimmed = text.trim();
4711
+ if (trimmed.length === 0) return false;
4712
+ if (EXPLICIT_TARGET.some((pattern) => pattern.test(trimmed))) return false;
4713
+ return CROSS_SESSION_POINTERS.some((phrase) => trimmed.includes(phrase)) || PROJECT_LIKE.test(trimmed);
4714
+ }
4715
+ /**
4716
+ * The one-line suggestion for a judgment, or undefined when it must stay
4717
+ * silent: no yes (ask < 0.6), a sensitive question (sensitive ≥ 0.6), no pick,
4718
+ * "none", or an id that is not one of the offered candidates.
4719
+ */
4720
+ function crossSessionSuggestionLine(judgment, candidates) {
4721
+ if (judgment === void 0 || judgment === null) return void 0;
4722
+ const ask = judgment.askConfidence;
4723
+ if (typeof ask !== "number" || !Number.isFinite(ask) || ask < CROSS_SESSION_THRESHOLD) return void 0;
4724
+ const sensitive = judgment.sensitiveConfidence;
4725
+ if (typeof sensitive === "number" && Number.isFinite(sensitive) && sensitive >= CROSS_SESSION_THRESHOLD) return;
4726
+ const pick = typeof judgment.pick === "string" ? judgment.pick.trim() : "";
4727
+ if (pick.length === 0) return void 0;
4728
+ const chosen = candidates.find((candidate) => candidate.sessionId === pick);
4729
+ if (chosen === void 0) return void 0;
4730
+ return `「这件事可能更适合问:${chosen.name}(ask_session)」`;
4731
+ }
4732
+ /**
4733
+ * Decide whether this turn earns a hint: `off` never asks, `gated` asks only
4734
+ * after the prefilter passes, `always` asks on every turn. Never throws and
4735
+ * never returns a hint for a sensitive / low-confidence / unparseable answer.
4736
+ *
4737
+ * Diagnostics: one log line per judgment attempt — injected context, a
4738
+ * prefilter stop or a no-candidates stop is recorded as `skipped` with
4739
+ * `called: false` (a deliberate `off` mode writes nothing), and a Jev answer as
4740
+ * `recommend` (a hint was produced) or `none` (any veto).
4741
+ */
4742
+ async function crossSessionHintForTurn(input) {
4743
+ if (input.mode === "off") return void 0;
4744
+ const candidates = Array.isArray(input.candidates) ? input.candidates : [];
4745
+ const recorded = {
4746
+ kind: "ask",
4747
+ question: input.text,
4748
+ candidates: candidates.map((candidate) => ({
4749
+ id: candidate.sessionId,
4750
+ title: candidate.name
4751
+ }))
4752
+ };
4753
+ if (isInjectedContextText(input.text)) {
4754
+ input.log?.({
4755
+ ...recorded,
4756
+ called: false,
4757
+ configured: input.judge !== void 0,
4758
+ decision: "skipped"
4759
+ });
4760
+ return;
4761
+ }
4762
+ if (input.mode === "gated" && !shouldConsiderCrossSession(input.text)) {
4763
+ input.log?.({
4764
+ ...recorded,
4765
+ called: false,
4766
+ configured: input.judge !== void 0,
4767
+ decision: "skipped"
4768
+ });
4769
+ return;
4770
+ }
4771
+ if (candidates.length === 0) {
4772
+ input.log?.({
4773
+ ...recorded,
4774
+ called: false,
4775
+ configured: input.judge !== void 0,
4776
+ decision: "skipped"
4777
+ });
4778
+ return;
4779
+ }
4780
+ if (input.judge === void 0) {
4781
+ input.log?.({
4782
+ ...recorded,
4783
+ called: false,
4784
+ configured: false,
4785
+ decision: "skipped"
4786
+ });
4787
+ return;
4788
+ }
4789
+ try {
4790
+ const judgment = await input.judge({
4791
+ text: input.text,
4792
+ ...input.textEn === void 0 ? {} : { textEn: input.textEn },
4793
+ candidates
4794
+ });
4795
+ const hint = crossSessionSuggestionLine(judgment, candidates);
4796
+ const pickId = judgment === void 0 || typeof judgment.pick !== "string" ? "" : judgment.pick.trim();
4797
+ const picked = pickId.length > 0 ? candidates.find((candidate) => candidate.sessionId === pickId) : void 0;
4798
+ const confidence = judgment !== void 0 && typeof judgment.askConfidence === "number" ? judgment.askConfidence : void 0;
4799
+ input.log?.({
4800
+ ...recorded,
4801
+ called: true,
4802
+ configured: true,
4803
+ decision: hint === void 0 ? "none" : "recommend",
4804
+ ...picked === void 0 ? {} : {
4805
+ pickedId: picked.sessionId,
4806
+ pickedTitle: picked.name
4807
+ },
4808
+ ...confidence === void 0 ? {} : { confidence }
4809
+ });
4810
+ return hint;
4811
+ } catch (error) {
4812
+ input.log?.({
4813
+ ...recorded,
4814
+ called: true,
4815
+ configured: true,
4816
+ decision: "error",
4817
+ error: error instanceof Error ? error.message : String(error)
4818
+ });
4819
+ return;
4820
+ }
4821
+ }
4822
+ /** Pending one-turn hints, per agent session. */
4823
+ const pendingCrossSessionHints = /* @__PURE__ */ new Map();
4824
+ /** Remember the hint for this session's next prompt assembly (overwrites). */
4825
+ function rememberCrossSessionHint(sessionId, hint) {
4826
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
4827
+ if (typeof hint !== "string" || hint.trim().length === 0) return;
4828
+ pendingCrossSessionHints.set(sessionId, hint.trim());
4829
+ }
4830
+ /** Consume the pending hint: it is returned once, then forgotten. */
4831
+ function takeCrossSessionHint(sessionId) {
4832
+ if (typeof sessionId !== "string" || sessionId.length === 0) return void 0;
4833
+ const hint = pendingCrossSessionHints.get(sessionId);
4834
+ if (hint === void 0) return void 0;
4835
+ pendingCrossSessionHints.delete(sessionId);
4836
+ return hint;
4837
+ }
4838
+ /**
4839
+ * Append (and consume) the pending cross-session hint to one assembled system
4840
+ * prompt. One-turn timing: the background judgment of turn N stores the hint,
4841
+ * the NEXT assembly reads and clears it — so the hint is visible for exactly
4842
+ * one assembly and never repeats.
4843
+ */
4844
+ function appendCrossSessionHint(prompt, sessionId) {
4845
+ const hint = takeCrossSessionHint(sessionId);
4846
+ if (hint === void 0) return prompt;
4847
+ return prompt.length === 0 ? hint : `${prompt}\n\n${hint}`;
4848
+ }
4849
+ /** Cache key: normalised question, cap, asking session, engine, and exclusions. */
4850
+ function routeCacheKey(question, topN, currentSessionId, engine, exclude) {
4851
+ return `${normalizeAliasKey(typeof question === "string" ? question : "")}\u0000${topN}\u0000${typeof currentSessionId === "string" ? currentSessionId : ""}\u0000${engine}\u0000${excludeKey(exclude)}`;
4852
+ }
4853
+ /** A live cache entry, or undefined when it is absent or older than the TTL. */
4854
+ function readCachedRoute(key) {
4855
+ const entry = routeCache.get(key);
4856
+ if (entry === void 0) return void 0;
4857
+ if (Date.now() - entry.at > ROUTE_CACHE_TTL_MS) {
4858
+ routeCache.delete(key);
4859
+ return;
4860
+ }
4861
+ return entry.candidates;
4862
+ }
4863
+ /** Store one route, evicting the oldest entries beyond {@link ROUTE_CACHE_MAX}. */
4864
+ function writeCachedRoute(key, candidates) {
4865
+ routeCache.delete(key);
4866
+ routeCache.set(key, {
4867
+ at: Date.now(),
4868
+ candidates: [...candidates]
4869
+ });
4870
+ while (routeCache.size > ROUTE_CACHE_MAX) {
4871
+ const oldest = routeCache.keys().next().value;
4872
+ if (oldest === void 0) break;
4873
+ routeCache.delete(oldest);
4874
+ }
4875
+ }
4876
+ /** The uncached routing pass: score, order, and label the candidates. */
4877
+ function routeUncached(sessions, question, topN, options) {
4878
+ const now = Date.now();
4879
+ const query = readQuestion(question);
4880
+ const hitsBySession = aliasHits(query.phrase, readAliasMap(options?.aliases));
4881
+ const indexed = [];
4882
+ for (const session of Array.isArray(sessions) ? sessions : []) {
4883
+ if (isNoiseSession(session, options?.currentSessionId)) continue;
4884
+ const row = indexSession(session);
4885
+ if (row !== void 0 && !rowExcluded(options?.exclude, row)) indexed.push(row);
4886
+ }
4887
+ if (indexed.length === 0) return [];
4888
+ const scored = [];
4889
+ for (const row of indexed) {
4890
+ const hit = scoreRow(row, query, hitsBySession.get(row.sessionId));
4891
+ if (hit !== void 0) scored.push(hit);
4892
+ }
4893
+ if (scored.length > 0) {
4894
+ scored.sort((a, b) => compareScored(a, b, now));
4895
+ return selectTop(scored, topN, now);
4896
+ }
4897
+ return widePool(indexed, query, topN, now);
4898
+ }
4899
+ /**
4900
+ * Read the alias file: normalised phrasing → sessionId.
4901
+ * A missing, unreadable or corrupt file reads as {}, and no call ever throws.
4902
+ * @param file - absolute path of the JSON alias file.
4903
+ * @returns the usable alias map.
4904
+ */
4905
+ function loadAliases(file) {
4906
+ if (typeof file !== "string" || file.length === 0) return {};
4907
+ let text;
4908
+ try {
4909
+ text = readFileSync(file, "utf8");
4910
+ } catch {
4911
+ return {};
4912
+ }
4913
+ let value;
4914
+ try {
4915
+ value = JSON.parse(text);
4916
+ } catch {
4917
+ return {};
4918
+ }
4919
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
4920
+ const aliases = {};
4921
+ for (const [phrasing, sessionId] of Object.entries(value)) {
4922
+ if (typeof sessionId !== "string" || sessionId.length === 0) continue;
4923
+ const key = normalizeAliasKey(phrasing);
4924
+ if (key.length === 0) continue;
4925
+ aliases[key] = sessionId;
4926
+ }
4927
+ return aliases;
4928
+ }
4929
+ //#endregion
4930
+ //#region src/cross-session.ts
4931
+ /**
4932
+ * Cross-session ask (v0): let the current session deliver one question to
4933
+ * another existing ordinary session, wake it, and report what that session
4934
+ * said between the delivery receipt and its next whole-session idle.
4935
+ *
4936
+ * Facts this module is built on (DSH 0.2.0-rc.1/rc.2 types, verified):
4937
+ * - `ctx.sessionController.resolveAgent(sessionId)` yields the live Agent
4938
+ * (`{ agent }` or `{ error }`), resuming a cold session.
4939
+ * - `agent.followup(createUserMessage({ content, source }))` queues one
4940
+ * ordinary follow-up turn and wakes the driver.
4941
+ * - `ctx.sessionController.prompt({ sessionId, content, mode:'queue', requestId })`
4942
+ * is the cheap fallback, but the Host hardcodes `source` to a user prompt, so
4943
+ * that path is identity-dishonest and must be reported as such.
4944
+ * - There is NO API that returns "the answer to this message". The Host only
4945
+ * guarantees delivery, so this module reports an INTERVAL, never a reply.
4946
+ * - The Host enforces no caller identity, no cycle guard, and hard-excludes
4947
+ * subagent sessions, so every guard in `ask_session` lives here.
4948
+ *
4949
+ * The pure helpers below never touch `ctx`; only `askSession()` does I/O.
4950
+ */
4951
+ /** Maximum combined UTF-8 bytes of question + context (design §六.5). */
4952
+ const ASK_CONTENT_MAX_BYTES = 32e3;
4953
+ /** Default and maximum wait for the target's next idle (design §十). */
4954
+ const ASK_WAIT_DEFAULT_MS = 18e4;
4955
+ const ASK_WAIT_MAX_MS = 6e5;
4956
+ /** Poll interval while waiting for the target to reach whole-session idle. */
4957
+ const ASK_POLL_INTERVAL_MS = 1e3;
4958
+ /**
4959
+ * Envelope the receiving model reads. The Host offers no registration point for
4960
+ * this framing (the official Agent Teams build their own too), so the identity
4961
+ * is carried in the content itself: a header block naming the asking session,
4962
+ * then the question, then optional background, then the reply convention.
4963
+ * @param senderName - title (or cwd+time fallback) of the asking session.
4964
+ * @param question - the question to hand over.
4965
+ * @param context - optional background; permanently recorded in the target log.
4966
+ * @returns content blocks, envelope first.
4967
+ */
4968
+ function buildAskEnvelope(senderName, question, context, askId) {
4969
+ const blocks = [{
4970
+ type: "text",
4971
+ text: askId === void 0 || askId.length === 0 ? `【来自会话「${senderName}」的提问】` : `【来自会话「${senderName}」的提问 · askId=${askId}】`
4972
+ }, {
4973
+ type: "text",
4974
+ text: question
4975
+ }];
4976
+ if (context !== void 0 && context.trim().length > 0) blocks.push({
4977
+ type: "text",
4978
+ text: `【对方补充的背景】\n${context}`
4979
+ });
4980
+ return blocks;
4981
+ }
4982
+ /**
4983
+ * Build the identified user message handed to `agent.followup()`.
4984
+ * @param envelope - content blocks produced by {@link buildAskEnvelope}.
4985
+ * @param source - producer-declared session-ask source.
4986
+ * @returns an immutable identified user message carrying our source kind.
4987
+ */
4988
+ function buildAskMessage(envelope, source) {
4989
+ return createUserMessage({
4990
+ content: envelope,
4991
+ source
4992
+ });
4993
+ }
4994
+ /**
4995
+ * UTF-8 byte length of the text a caller wants to hand over.
4996
+ * @param question - the question text.
4997
+ * @param context - optional background text.
4998
+ * @returns byte length of question + context.
4999
+ */
5000
+ function askContentBytes(question, context) {
5001
+ return Buffer.byteLength(question, "utf8") + (context === void 0 ? 0 : Buffer.byteLength(context, "utf8"));
5002
+ }
5003
+ /** Deterministic fallback label: working directory plus most recent activity. */
5004
+ function fallbackSessionName(cwd, updatedAt) {
5005
+ const where = cwd === void 0 || cwd.length === 0 ? "(未知工作目录)" : cwd;
5006
+ if (updatedAt === void 0 || !Number.isFinite(updatedAt)) return where;
5007
+ const at = new Date(updatedAt);
5008
+ return `${where} · ${Number.isNaN(at.getTime()) ? String(updatedAt) : at.toISOString()}`;
5009
+ }
5010
+ /**
5011
+ * Model-visible name of one listed session. The host publishes the `title`
5012
+ * projection as a **plain string** (`dsh-session-title`: `z.string().min(1).nullable()`),
5013
+ * so a string is the normal shape here; the `{ title }` wrapper is accepted as
5014
+ * well because older cached rows used it. Every miss degrades to the cwd+time
5015
+ * fallback instead of throwing.
5016
+ */
5017
+ function sessionDisplayName(summary) {
5018
+ const raw = summary.projections?.values?.title;
5019
+ const title = (typeof raw === "string" ? raw : raw !== null && typeof raw === "object" && typeof raw.title === "string" ? raw.title : "").trim();
5020
+ if (title.length > 0) return title;
5021
+ return fallbackSessionName(summary.cwd, summary.updatedAt);
5022
+ }
5023
+ function candidateOf(summary) {
5024
+ return {
5025
+ sessionId: summary.sessionId,
5026
+ name: sessionDisplayName(summary),
5027
+ hint: fallbackSessionName(summary.cwd, summary.updatedAt)
5028
+ };
5029
+ }
5030
+ /** Most-recently-active first, running sessions ahead of idle ones. */
5031
+ function orderCandidates(rows) {
5032
+ return [...rows].sort((a, b) => {
5033
+ if (a.running !== b.running) return a.running ? -1 : 1;
5034
+ return b.updatedAt - a.updatedAt;
5035
+ });
5036
+ }
5037
+ /** Partial candidates are capped: a broad needle must stay a readable list. */
5038
+ const PARTIAL_CANDIDATE_LIMIT = 10;
5039
+ /** When nothing matches, this many recent sessions come back as candidates. */
5040
+ const NOT_FOUND_CANDIDATE_LIMIT = 8;
5041
+ /** The exclusion entries carried by `swarm.routingExclude`; malformed values are ignored. */
5042
+ function exclusionEntries$1(value) {
5043
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
5044
+ }
5045
+ /** True when this listed session is on the user's exclusion list. */
5046
+ function summaryExcluded(entries, summary) {
5047
+ return isSessionExcluded(entries, {
5048
+ sessionId: summary.sessionId,
5049
+ names: [sessionDisplayName(summary)]
5050
+ });
5051
+ }
5052
+ /**
5053
+ * Resolve a user/model-supplied target reference against the session list.
5054
+ * Ladder, in order: exact sessionId → exact display name → partial natural
5055
+ * name (substring of the display name OR of the workspace cwd, case-insensitive)
5056
+ * → missing. One hit resolves outright; several hits become candidates —
5057
+ * this function NEVER guesses. Partial candidates are ordered most-recently-
5058
+ * active first (running sessions first) and capped, so "ask the 极简遥控器
5059
+ * session" works without anyone digging session ids out of log directories.
5060
+ * @param sessions - rows from `sessionController.list()`.
5061
+ * @param reference - a session id, display name, or natural partial name.
5062
+ * @returns the unique target, the candidate list, or "missing".
5063
+ */
5064
+ function matchTargetSession(sessions, reference) {
3314
5065
  const needle = reference.trim();
3315
5066
  if (needle.length === 0) return { kind: "missing" };
3316
5067
  const byId = sessions.find((session) => session.sessionId === needle);
@@ -3369,6 +5120,22 @@ function nextAskHop(events) {
3369
5120
  return 0;
3370
5121
  }
3371
5122
  /**
5123
+ * The askId of the inbound cross-session ask this session is currently
5124
+ * answering, if any. Reads the same source vocabulary as {@link nextAskHop}
5125
+ * but only the newest user message: once a plain human prompt arrives, the
5126
+ * model is answering that instead, so no parent is recorded. Returns undefined
5127
+ * when there is no inbound ask — the id is never parsed out of message text.
5128
+ * @param events - this session's log events in order.
5129
+ */
5130
+ function inboundAskId(events) {
5131
+ for (let i = events.length - 1; i >= 0; i -= 1) {
5132
+ const event = events[i];
5133
+ if (event.type !== "user/message") continue;
5134
+ const source = event.data.message?.source;
5135
+ return source?.kind === "session-ask" && typeof source.askId === "string" && source.askId.length > 0 ? source.askId : void 0;
5136
+ }
5137
+ }
5138
+ /**
3372
5139
  * Collect what the target session said inside the interval. Only
3373
5140
  * `assistant/message` events strictly after the delivery watermark count, so a
3374
5141
  * reply that predates the delivery cannot be reported as an answer. The
@@ -3397,6 +5164,33 @@ function collectAnswer(events, fromSeq) {
3397
5164
  function joinAnswerPieces(pieces) {
3398
5165
  return pieces.join("\n\n");
3399
5166
  }
5167
+ /** One 「结论:…」-style line, tolerating markdown bullets and bold markers. */
5168
+ const REPLY_FIELD = /^[\s>*#`-]*(结论|依据|不确定)[*\s]*[::]\s*(.*)$/u;
5169
+ /** Best-effort parse of the reply convention; undefined when the text ignores it. */
5170
+ function parseAskReply(answer) {
5171
+ const fields = {};
5172
+ for (const line of answer.split(/\r?\n/u)) {
5173
+ const match = REPLY_FIELD.exec(line);
5174
+ if (match === null) continue;
5175
+ const value = (match[2] ?? "").trim();
5176
+ if (value.length === 0) continue;
5177
+ if (match[1] === "结论" && fields.conclusion === void 0) fields.conclusion = value;
5178
+ else if (match[1] === "依据" && fields.basis === void 0) fields.basis = value;
5179
+ else if (match[1] === "不确定" && fields.uncertainty === void 0) fields.uncertainty = value;
5180
+ }
5181
+ return fields.conclusion === void 0 && fields.basis === void 0 && fields.uncertainty === void 0 ? void 0 : fields;
5182
+ }
5183
+ /**
5184
+ * `ok` = the peer settled idle and named a conclusion; `partial` = a timeout, a
5185
+ * missing conclusion, or prose that ignored the convention; `failed` = an error
5186
+ * or nothing said. An unfinished async ask is still `waiting`, never `failed`.
5187
+ */
5188
+ function askStatus(input) {
5189
+ if (input.endedBecause === "error") return "failed";
5190
+ if (input.answer.trim().length === 0) return "failed";
5191
+ if (input.endedBecause !== "idle") return "partial";
5192
+ return "ok";
5193
+ }
3400
5194
  /** Highest event seq present, or -1 for an empty log (the session-cursor convention). */
3401
5195
  function logWatermark(events) {
3402
5196
  let highest = -1;
@@ -3405,9 +5199,21 @@ function logWatermark(events) {
3405
5199
  }
3406
5200
  /** Human-readable text the main model reads for one settled ask_session call. */
3407
5201
  function renderAskResult(value) {
5202
+ const reply = value.reply;
5203
+ const structured = reply === void 0 ? "" : [
5204
+ reply.conclusion === void 0 ? "" : `结论:${reply.conclusion}`,
5205
+ reply.basis === void 0 ? "" : `依据:${reply.basis}`,
5206
+ reply.uncertainty === void 0 ? "" : `不确定:${reply.uncertainty}`
5207
+ ].filter((line) => line.length > 0).join("\n");
5208
+ const body = structured.length > 0 ? structured : value.answer.trim().length > 0 ? value.answer : value.status === void 0 && value.askId !== void 0 ? "(已异步投递,结果稍后用 ask_result 读取。)" : "(该区间内对方没有产出正文。)";
5209
+ const who = [
5210
+ value.targetName === void 0 ? "" : `来自「${value.targetName}」`,
5211
+ value.askId === void 0 || value.askId.length === 0 ? "" : `askId=${value.askId}`,
5212
+ value.status === void 0 ? "" : `状态 ${value.status}`
5213
+ ].filter((part) => part.length > 0).join(" · ");
3408
5214
  return [{
3409
5215
  type: "text",
3410
- text: `${value.answer.trim().length > 0 ? value.answer : "(该区间内对方没有产出正文。)"}\n\n${value.note}`
5216
+ text: `${who.length > 0 ? `【${who}】\n` : ""}${body}\n\n${value.note}`
3411
5217
  }];
3412
5218
  }
3413
5219
  /**
@@ -3422,8 +5228,63 @@ function isSubagentDeliveryRejection(error) {
3422
5228
  const haystack = `${typeof reason === "string" ? reason : ""} ${typeof shape.message === "string" ? shape.message : ""}`;
3423
5229
  return haystack.includes("subagent delivery") || haystack.includes("owned by subagent routing");
3424
5230
  }
3425
- /** One unsettled wait per session: at most one ask_session may be in flight. */
3426
- const pendingWaits = /* @__PURE__ */ new Set();
5231
+ /** One unsettled wait per session: at most one ask_session may be in flight. */
5232
+ const pendingWaits = /* @__PURE__ */ new Set();
5233
+ /** Directory holding detached (async) ask results. */
5234
+ function askStoreRoot() {
5235
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
5236
+ return askStoreDir(join(dshHome, "storages", "advisor-group"));
5237
+ }
5238
+ /** Longest question text kept in one diagnostics line. */
5239
+ const JEV_LOG_QUESTION_MAX = 300;
5240
+ /** Longest candidate title kept in one diagnostics line. */
5241
+ const JEV_LOG_TITLE_MAX = 80;
5242
+ /** Clamp a diagnostic string; non-strings read as ''. */
5243
+ function clipLogText(value, max) {
5244
+ const text = typeof value === "string" ? value : "";
5245
+ return text.length <= max ? text : text.slice(0, max);
5246
+ }
5247
+ /**
5248
+ * Append ONE JSON line describing a Jev decision attempt to
5249
+ * `<askStoreRoot()>/routing-jev.log`.
5250
+ *
5251
+ * Diagnostics only: the directory is created on demand, `question` is clamped
5252
+ * to 300 characters and each candidate title to 80, and EVERY failure is
5253
+ * ignored so a log write can never disturb the routing path. Nothing reads the
5254
+ * file back; humans (and the settings diagnostics) do.
5255
+ * @param entry - the attempt: kind, question, whether Jev was called/configured,
5256
+ * the resulting decision, and (when known) the pick and its confidence.
5257
+ */
5258
+ function logJevDecision(entry) {
5259
+ try {
5260
+ const line = JSON.stringify({
5261
+ at: typeof entry.at === "string" && entry.at.length > 0 ? entry.at : (/* @__PURE__ */ new Date()).toISOString(),
5262
+ kind: entry.kind === "ask" ? "ask" : "route",
5263
+ question: clipLogText(entry.question, JEV_LOG_QUESTION_MAX),
5264
+ called: entry.called === true,
5265
+ configured: entry.configured === true,
5266
+ decision: entry.decision,
5267
+ ...typeof entry.pickedId === "string" && entry.pickedId.length > 0 ? { pickedId: entry.pickedId } : {},
5268
+ ...typeof entry.pickedTitle === "string" && entry.pickedTitle.length > 0 ? { pickedTitle: clipLogText(entry.pickedTitle, JEV_LOG_TITLE_MAX) } : {},
5269
+ ...typeof entry.confidence === "number" && Number.isFinite(entry.confidence) ? { confidence: entry.confidence } : {},
5270
+ ...Array.isArray(entry.candidates) ? { candidates: entry.candidates.map((candidate) => ({
5271
+ id: clipLogText(candidate?.id, JEV_LOG_TITLE_MAX),
5272
+ title: clipLogText(candidate?.title, JEV_LOG_TITLE_MAX)
5273
+ })) } : {},
5274
+ ...typeof entry.error === "string" && entry.error.length > 0 ? { error: entry.error } : {}
5275
+ });
5276
+ const dir = askStoreRoot();
5277
+ mkdirSync(dir, { recursive: true });
5278
+ appendFileSync(join(dir, "routing-jev.log"), `${line}\n`, "utf8");
5279
+ } catch {}
5280
+ }
5281
+ /** Background loops started by `detach: true`; cancelled when the plugin unloads. */
5282
+ const detachedControllers = /* @__PURE__ */ new Set();
5283
+ /** Cancel every detached wait (plugin dispose). Safe to call repeatedly. */
5284
+ function cancelDetachedAsks() {
5285
+ for (const controller of detachedControllers) controller.abort();
5286
+ detachedControllers.clear();
5287
+ }
3427
5288
  /** Whether this session already has an unsettled ask_session wait. */
3428
5289
  function isWaiting(sessionId) {
3429
5290
  return pendingWaits.has(sessionId);
@@ -3483,7 +5344,7 @@ async function sleep(ms, signal) {
3483
5344
  * in `note` that this identity-dishonest path was used.
3484
5345
  */
3485
5346
  async function deliverAsk(controller, signal, input) {
3486
- const envelope = buildAskEnvelope(input.source.senderName, input.question, input.context);
5347
+ const envelope = buildAskEnvelope(input.source.senderName, input.question, input.context, input.source.askId);
3487
5348
  try {
3488
5349
  const resolved = await controller.resolveAgent(input.target.sessionId);
3489
5350
  if ("agent" in resolved && resolved.agent) {
@@ -3680,7 +5541,8 @@ function checkAskGuards(input) {
3680
5541
  const { guards, match } = input;
3681
5542
  if (match.kind === "missing") return {
3682
5543
  code: "target-not-found",
3683
- message: "没有找到匹配的会话。请用会话列表里的标题或 sessionId 指定目标;不确认时先列出候选再让用户选择。"
5544
+ message: "没有找到匹配的会话。下面列出最近的会话,请把候选交给用户挑选(标题或 sessionId 都可以);不要再去翻会话日志或目录找 id。",
5545
+ candidates: orderCandidates(input.sessions).slice(0, NOT_FOUND_CANDIDATE_LIMIT).map(candidateOf)
3684
5546
  };
3685
5547
  if (match.kind === "ambiguous") return {
3686
5548
  code: "target-ambiguous",
@@ -3727,11 +5589,21 @@ async function askSession(ctx, signal, input) {
3727
5589
  pendingWaits.add(input.currentSessionId);
3728
5590
  try {
3729
5591
  const sessions = [];
5592
+ const allowed = [];
5593
+ const entries = exclusionEntries$1(input.exclude);
3730
5594
  let match = { kind: "missing" };
3731
5595
  try {
3732
5596
  const listed = await controller.list({}, signal);
3733
5597
  sessions.push(...listed.items);
3734
- match = matchTargetSession(sessions, input.target ?? "");
5598
+ for (const session of sessions) if (!summaryExcluded(entries, session)) allowed.push(session);
5599
+ match = matchTargetSession(allowed, input.target ?? "");
5600
+ if (match.kind === "missing" && entries.length > 0) {
5601
+ const full = matchTargetSession(sessions, input.target ?? "");
5602
+ if ((full.kind === "unique" ? sessions.some((session) => session.sessionId === full.target.sessionId && summaryExcluded(entries, session)) : full.kind === "ambiguous" && full.candidates.some((candidate) => !allowed.some((session) => session.sessionId === candidate.sessionId))) && (input.target ?? "").trim().length > 0) return toToolError({
5603
+ code: "swarm-excluded",
5604
+ message: "这个会话被设置为不参与蜂群(设置 → 插件 → 顾问群 → 全局蜂群 → 不参与路由的会话)。"
5605
+ });
5606
+ }
3735
5607
  } catch (error) {
3736
5608
  return {
3737
5609
  ok: false,
@@ -3740,7 +5612,7 @@ async function askSession(ctx, signal, input) {
3740
5612
  };
3741
5613
  }
3742
5614
  return await runAsk(ctx, controller, signal, input, {
3743
- sessions,
5615
+ sessions: allowed,
3744
5616
  match,
3745
5617
  log: input.log ?? (() => {})
3746
5618
  });
@@ -3772,6 +5644,7 @@ async function runAsk(ctx, controller, signal, input, listed) {
3772
5644
  };
3773
5645
  const targetRow = listed.sessions.find((session) => session.sessionId === target.sessionId);
3774
5646
  const askId = nextAskId();
5647
+ const parentAskId = inboundAskId(input.currentEvents);
3775
5648
  const source = {
3776
5649
  kind: "session-ask",
3777
5650
  form: "relay",
@@ -3800,6 +5673,84 @@ async function runAsk(ctx, controller, signal, input, listed) {
3800
5673
  senderName: input.senderName,
3801
5674
  targetName: target.name
3802
5675
  });
5676
+ if (input.detach === true) {
5677
+ const storeDir = askStoreRoot();
5678
+ const record = {
5679
+ askId,
5680
+ sessionId: input.currentSessionId,
5681
+ targetSessionId: target.sessionId,
5682
+ targetName: target.name,
5683
+ question: input.question,
5684
+ state: "waiting",
5685
+ createdAt: Date.now(),
5686
+ from,
5687
+ ...parentAskId === void 0 ? {} : { parentAskId }
5688
+ };
5689
+ try {
5690
+ writeAskRecord(storeDir, record);
5691
+ } catch (error) {
5692
+ console.warn("[dsh-advisor-group] 异步结果落盘失败(投递仍已发出):", error);
5693
+ }
5694
+ const background = new AbortController();
5695
+ detachedControllers.add(background);
5696
+ (async () => {
5697
+ let settled = {};
5698
+ try {
5699
+ const outcome = await waitForTargetIdle(controller, background.signal, {
5700
+ sessionId: target.sessionId,
5701
+ targetWasRunning: delivery.receipt.targetWasRunning,
5702
+ waitMs,
5703
+ log: listed.log,
5704
+ fromSeq: delivery.receipt.fromSeq
5705
+ });
5706
+ const rawAnswer = joinAnswerPieces((await collectTargetAnswer(controller, target.sessionId, delivery.receipt.fromSeq)).pieces);
5707
+ const reply = parseAskReply(rawAnswer);
5708
+ settled = {
5709
+ state: outcome.endedBecause === "idle" ? "done" : outcome.endedBecause === "timeout" ? "timeout" : "error",
5710
+ answer: rawAnswer,
5711
+ ...reply === void 0 ? {} : { reply },
5712
+ status: askStatus({
5713
+ endedBecause: outcome.endedBecause,
5714
+ answer: rawAnswer
5715
+ }),
5716
+ note: outcome.note
5717
+ };
5718
+ } catch (error) {
5719
+ settled = {
5720
+ state: "error",
5721
+ error: error instanceof Error ? error.message : String(error)
5722
+ };
5723
+ } finally {
5724
+ try {
5725
+ writeAskRecord(storeDir, {
5726
+ ...record,
5727
+ ...settled,
5728
+ settledAt: Date.now()
5729
+ });
5730
+ } catch (error) {
5731
+ console.warn("[dsh-advisor-group] 异步结果落盘失败:", error);
5732
+ }
5733
+ detachedControllers.delete(background);
5734
+ }
5735
+ })();
5736
+ return {
5737
+ ok: true,
5738
+ detached: true,
5739
+ target: {
5740
+ sessionId: target.sessionId,
5741
+ name: target.name
5742
+ },
5743
+ delivered: delivery.receipt.delivered,
5744
+ askId,
5745
+ interval: {
5746
+ from,
5747
+ to: null,
5748
+ endedBecause: "detached"
5749
+ },
5750
+ answer: "",
5751
+ note: `已投递(异步):问题已交给「${target.name}」,本次调用不等待答复。稍后用 ask_result(askId=${askId})读取结果。`
5752
+ };
5753
+ }
3803
5754
  const outcome = await waitForTargetIdle(controller, signal, {
3804
5755
  sessionId: target.sessionId,
3805
5756
  targetWasRunning: delivery.receipt.targetWasRunning,
@@ -3808,7 +5759,13 @@ async function runAsk(ctx, controller, signal, input, listed) {
3808
5759
  fromSeq: delivery.receipt.fromSeq
3809
5760
  });
3810
5761
  const collected = await collectTargetAnswer(controller, target.sessionId, delivery.receipt.fromSeq);
3811
- const answer = joinAnswerPieces(collected.pieces);
5762
+ const rawAnswer = joinAnswerPieces(collected.pieces);
5763
+ const answer = rawAnswer;
5764
+ const reply = parseAskReply(rawAnswer);
5765
+ const status = askStatus({
5766
+ endedBecause: outcome.endedBecause,
5767
+ answer
5768
+ });
3812
5769
  const to = outcome.endedBecause === "idle" ? (/* @__PURE__ */ new Date()).toISOString() : null;
3813
5770
  const note = [
3814
5771
  "区间语义:从投递回执到对方会话下一次整体空闲,这两个时刻之间对方产出的文字都算在内——这是「这个区间里对方说的话」,不是逐条对应的回答。",
@@ -3821,7 +5778,7 @@ async function runAsk(ctx, controller, signal, input, listed) {
3821
5778
  askId,
3822
5779
  question: input.question,
3823
5780
  targetName: target.name,
3824
- answer,
5781
+ answer: rawAnswer,
3825
5782
  note
3826
5783
  });
3827
5784
  return {
@@ -3838,6 +5795,8 @@ async function runAsk(ctx, controller, signal, input, listed) {
3838
5795
  endedBecause: outcome.endedBecause
3839
5796
  },
3840
5797
  answer,
5798
+ ...reply === void 0 ? {} : { reply },
5799
+ status,
3841
5800
  note
3842
5801
  };
3843
5802
  }
@@ -4117,8 +6076,7 @@ function registerAdvisorTools(service, ctx) {
4117
6076
  parameters: {
4118
6077
  target: {
4119
6078
  type: "string",
4120
- description: "The target session: its full sessionId, its exact display title, or a natural partial name — the workspace/directory name the user mentioned is enough (「极简遥控器」 matches sessions under I:\\极简遥控器\\…). One unique match is used directly; several matches return the candidates (running / most recently active first) for the user to choose — never guess. Pass the name the user said — do NOT search log files or directories for session ids.",
4121
- required: true
6079
+ description: "The target session, named the way the user names it: its exact title as shown in the session list (the session name in the sidebar), a natural partial name, or a full sessionId. A title or the workspace/directory name is usually enough (「极简遥控器」 matches sessions under I:\\极简遥控器\\…). One unique match is used directly; several matches return the candidates (running / most recently active first) and a miss returns the recent sessions — both for the USER to choose: never guess, and do NOT search session logs or directories for session ids."
4122
6080
  },
4123
6081
  question: {
4124
6082
  type: "string",
@@ -4129,9 +6087,17 @@ function registerAdvisorTools(service, ctx) {
4129
6087
  type: "string",
4130
6088
  description: "Optional background the target needs. Default: none. Anything sent here stays in the target session log forever, so send only what is necessary."
4131
6089
  },
6090
+ questionEn: {
6091
+ type: "string",
6092
+ description: "Optional one-line English gloss of the question. Sent to the Jev routing engine as extra context when 「路由方式」 is Jev; the question, the candidates and every session record keep the original language."
6093
+ },
4132
6094
  waitMs: {
4133
6095
  type: "number",
4134
6096
  description: `How long to wait for the target to finish, in milliseconds (default ${ASK_WAIT_DEFAULT_MS}, max ${ASK_WAIT_MAX_MS}). On timeout the already-collected text is returned and the target is NOT interrupted.`
6097
+ },
6098
+ detach: {
6099
+ type: "boolean",
6100
+ description: "Deliver without waiting: return immediately with an askId, then read the answer later with ask_result. Use it when the other session may take minutes, so this turn is not blocked. Ignored when waitMs is also given: pass only one."
4135
6101
  }
4136
6102
  },
4137
6103
  output: {
@@ -4161,6 +6127,7 @@ function registerAdvisorTools(service, ctx) {
4161
6127
  type: "string",
4162
6128
  enum: ["native", "fallback"]
4163
6129
  },
6130
+ detached: { type: "boolean" },
4164
6131
  askId: { type: "string" },
4165
6132
  interval: {
4166
6133
  type: "object",
@@ -4176,7 +6143,8 @@ function registerAdvisorTools(service, ctx) {
4176
6143
  enum: [
4177
6144
  "idle",
4178
6145
  "timeout",
4179
- "error"
6146
+ "error",
6147
+ "detached"
4180
6148
  ],
4181
6149
  required: true
4182
6150
  }
@@ -4187,6 +6155,23 @@ function registerAdvisorTools(service, ctx) {
4187
6155
  type: "string",
4188
6156
  required: true
4189
6157
  },
6158
+ status: {
6159
+ type: "string",
6160
+ enum: [
6161
+ "ok",
6162
+ "partial",
6163
+ "failed"
6164
+ ]
6165
+ },
6166
+ reply: {
6167
+ type: "object",
6168
+ additionalProperties: false,
6169
+ properties: {
6170
+ conclusion: { type: "string" },
6171
+ basis: { type: "string" },
6172
+ uncertainty: { type: "string" }
6173
+ }
6174
+ },
4190
6175
  note: {
4191
6176
  type: "string",
4192
6177
  required: true
@@ -4213,18 +6198,57 @@ function registerAdvisorTools(service, ctx) {
4213
6198
  }
4214
6199
  }
4215
6200
  }
6201
+ },
6202
+ routeCandidates: {
6203
+ type: "array",
6204
+ items: {
6205
+ type: "object",
6206
+ additionalProperties: false,
6207
+ properties: {
6208
+ sessionId: {
6209
+ type: "string",
6210
+ required: true
6211
+ },
6212
+ name: {
6213
+ type: "string",
6214
+ required: true
6215
+ },
6216
+ score: {
6217
+ type: "number",
6218
+ required: true
6219
+ },
6220
+ reason: {
6221
+ type: "string",
6222
+ required: true
6223
+ }
6224
+ }
6225
+ }
4216
6226
  }
4217
6227
  }
4218
6228
  },
4219
6229
  render: (_args, value) => value.ok === true ? renderAskResult({
4220
6230
  answer: String(value.answer ?? ""),
4221
- note: String(value.note ?? "")
6231
+ note: String(value.note ?? ""),
6232
+ targetName: String(value.target?.name ?? ""),
6233
+ askId: String(value.askId ?? ""),
6234
+ ...value.status === "ok" || value.status === "partial" || value.status === "failed" ? { status: value.status } : {},
6235
+ ...value.reply === null || typeof value.reply !== "object" ? {} : { reply: value.reply }
4222
6236
  }) : [{
4223
6237
  type: "text",
4224
- text: `【ask_session 未完成:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}${Array.isArray(value.candidates) && value.candidates.length > 0 ? `\n候选会话:\n${value.candidates.map((item) => `- ${String(item.name ?? "")} · ${String(item.sessionId ?? "")}`).join("\n")}` : ""}`
6238
+ text: `【ask_session 未完成:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}${Array.isArray(value.candidates) && value.candidates.length > 0 ? `\n候选会话:\n${value.candidates.map((item) => `- ${String(item.name ?? "")} · ${String(item.sessionId ?? "")}`).join("\n")}` : ""}${renderRouteCandidates(value.routeCandidates)}`
4225
6239
  }]
4226
6240
  },
4227
6241
  async execute(args, exec) {
6242
+ const swarm = service.getConfig().swarm;
6243
+ if (swarm.enabled === false) return {
6244
+ ok: false,
6245
+ error: "swarm-disabled",
6246
+ message: SWARM_DISABLED_MESSAGE,
6247
+ answer: "",
6248
+ note: "",
6249
+ interval: errorInterval()
6250
+ };
6251
+ const hidden = exclusionEntries(swarm);
4228
6252
  const controller = getSessionController(ctx);
4229
6253
  if (!controller) return {
4230
6254
  ok: false,
@@ -4247,10 +6271,38 @@ function registerAdvisorTools(service, ctx) {
4247
6271
  const question = typeof args.question === "string" ? args.question : "";
4248
6272
  const context = typeof args.context === "string" ? args.context : void 0;
4249
6273
  let senderName = "未命名会话";
6274
+ let listedItems = [];
4250
6275
  try {
4251
- const own = (await controller.list({}, exec.signal)).items.find((item) => item.sessionId === sessionId);
6276
+ const listed = await controller.list({}, exec.signal);
6277
+ listedItems = listed.items;
6278
+ const own = listed.items.find((item) => item.sessionId === sessionId);
4252
6279
  if (own) senderName = sessionDisplayName(own);
4253
6280
  } catch {}
6281
+ if ((typeof args.target === "string" ? args.target.trim() : "").length === 0 && swarm.routingEnabled !== false) {
6282
+ const roster = visibleRosterSessions(listedItems, swarm.rosterIncludeSubagents).filter((row) => !isSessionExcluded(hidden, {
6283
+ sessionId: row.sessionId,
6284
+ names: [sessionDisplayName(row)]
6285
+ }));
6286
+ const routeCandidates = await routeSessionsWithEngine(withSessionLogSizes(roster), question, {
6287
+ topN: swarm.routingTopN,
6288
+ aliases: knownRoutingAliases(routingAliasFile(), roster),
6289
+ currentSessionId: sessionId,
6290
+ engine: swarm.routingEngine === "jev" ? "jev" : "local",
6291
+ exclude: hidden,
6292
+ questionEn: typeof args.questionEn === "string" ? args.questionEn : void 0,
6293
+ jev: routingJevRouter(service.getConfig().trigger.jev, exec.signal),
6294
+ log: logJevDecision
6295
+ });
6296
+ if (routeCandidates.length > 0) return {
6297
+ ok: false,
6298
+ error: "target-unspecified",
6299
+ message: "没有指定会话。下面按问题匹配出的候选,请让用户挑一个(或直接点名会话)再投递。",
6300
+ answer: "",
6301
+ note: "",
6302
+ interval: errorInterval(),
6303
+ routeCandidates: markRecentAsks(routeCandidates, sessionId)
6304
+ };
6305
+ }
4254
6306
  const cardLog = session;
4255
6307
  const onDelivered = (info) => {
4256
6308
  if (!cardLog) return;
@@ -4268,6 +6320,7 @@ function registerAdvisorTools(service, ctx) {
4268
6320
  console.warn("[dsh-advisor-group] 跨会话卡片(结束)写入失败:", error);
4269
6321
  }
4270
6322
  };
6323
+ const answerMode = resolveAskDefaults(args, swarm);
4271
6324
  const value = await askSession(ctx, exec.signal, {
4272
6325
  onDelivered,
4273
6326
  onSettled,
@@ -4277,7 +6330,9 @@ function registerAdvisorTools(service, ctx) {
4277
6330
  target: typeof args.target === "string" ? args.target : "",
4278
6331
  question,
4279
6332
  context,
4280
- waitMs: args.waitMs,
6333
+ waitMs: answerMode.waitMs,
6334
+ detach: answerMode.detach,
6335
+ exclude: hidden,
4281
6336
  log: (message) => console.log(`[dsh-advisor-group] ask_session: ${message}`)
4282
6337
  });
4283
6338
  if (value.ok !== true) return {
@@ -4296,12 +6351,286 @@ function registerAdvisorTools(service, ctx) {
4296
6351
  askId: String(value.askId ?? ""),
4297
6352
  interval: value.interval,
4298
6353
  answer: String(value.answer ?? ""),
6354
+ ...value.detached === true ? { detached: true } : {},
6355
+ ...value.reply === null || typeof value.reply !== "object" ? {} : { reply: value.reply },
4299
6356
  note: String(value.note ?? "")
4300
6357
  };
4301
6358
  }
6359
+ }),
6360
+ defineTool({
6361
+ name: "list_sessions",
6362
+ description: "List the sessions this host knows: the title shown in the session list, its sessionId, working directory, how long ago it was active, and whether it is running. Use it when the user names a session but you are unsure which one they mean, or after ask_session returns target-not-found / target-ambiguous — show the rows to the user instead of digging session ids out of log files. Read-only: it never wakes a session and costs no model call.",
6363
+ parameters: {
6364
+ query: {
6365
+ type: "string",
6366
+ description: "Optional case-insensitive filter over the session title, its working directory, or its activity hint. Empty lists the most recent sessions (running first)."
6367
+ },
6368
+ limit: {
6369
+ type: "number",
6370
+ description: "Maximum rows (default 30, clamped to 1–50)."
6371
+ }
6372
+ },
6373
+ output: {
6374
+ schema: {
6375
+ type: "object",
6376
+ additionalProperties: false,
6377
+ properties: {
6378
+ ok: {
6379
+ type: "boolean",
6380
+ required: true
6381
+ },
6382
+ count: {
6383
+ type: "number",
6384
+ required: true
6385
+ },
6386
+ sessions: {
6387
+ type: "array",
6388
+ required: true,
6389
+ items: {
6390
+ type: "object",
6391
+ additionalProperties: false,
6392
+ properties: {
6393
+ sessionId: {
6394
+ type: "string",
6395
+ required: true
6396
+ },
6397
+ name: {
6398
+ type: "string",
6399
+ required: true
6400
+ },
6401
+ cwd: { type: "string" },
6402
+ updatedAt: {
6403
+ type: "number",
6404
+ required: true
6405
+ },
6406
+ running: {
6407
+ type: "boolean",
6408
+ required: true
6409
+ },
6410
+ subagent: {
6411
+ type: "boolean",
6412
+ required: true
6413
+ },
6414
+ hint: {
6415
+ type: "string",
6416
+ required: true
6417
+ },
6418
+ hasTitle: {
6419
+ type: "boolean",
6420
+ required: true
6421
+ },
6422
+ sizeHint: { type: "number" }
6423
+ }
6424
+ }
6425
+ },
6426
+ error: { type: "string" },
6427
+ message: { type: "string" }
6428
+ }
6429
+ },
6430
+ render: (args, value) => value.ok === true ? [{
6431
+ type: "text",
6432
+ text: renderRoster(value.sessions, { filtered: String(args.query ?? "").trim().length > 0 })
6433
+ }] : [{
6434
+ type: "text",
6435
+ text: `【list_sessions 失败:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}`
6436
+ }]
6437
+ },
6438
+ async execute(args, exec) {
6439
+ const swarm = service.getConfig().swarm;
6440
+ if (swarm.enabled === false) return {
6441
+ ok: false,
6442
+ count: 0,
6443
+ sessions: [],
6444
+ error: "swarm-disabled",
6445
+ message: SWARM_DISABLED_MESSAGE
6446
+ };
6447
+ const controller = getSessionController(ctx);
6448
+ if (controller === void 0) return {
6449
+ ok: false,
6450
+ count: 0,
6451
+ sessions: [],
6452
+ error: "service-missing",
6453
+ message: "宿主没有提供 sessionController,无法列出会话。"
6454
+ };
6455
+ try {
6456
+ const sessions = buildSessionRoster((await controller.list({}, exec.signal)).items.filter((item) => !isHiddenSession(swarm, item)), {
6457
+ query: typeof args.query === "string" ? args.query : "",
6458
+ limit: typeof args.limit === "number" ? args.limit : void 0,
6459
+ hideSubagents: swarm.rosterIncludeSubagents !== true
6460
+ }).map((row) => ({
6461
+ ...row,
6462
+ name: shortSessionName(row.name, row.cwd)
6463
+ }));
6464
+ return {
6465
+ ok: true,
6466
+ count: sessions.length,
6467
+ sessions
6468
+ };
6469
+ } catch (error) {
6470
+ return {
6471
+ ok: false,
6472
+ count: 0,
6473
+ sessions: [],
6474
+ error: "list-failed",
6475
+ message: String(error instanceof Error ? error.message : error)
6476
+ };
6477
+ }
6478
+ }
6479
+ }),
6480
+ defineTool({
6481
+ name: "ask_result",
6482
+ description: "Read the result of a detached ask_session (called with detach: true). Pass askId for one result, or omit it to list this session's recent asks. States: waiting (the peer is still working — this is NOT a failure and NOT a reason to ask again; the send was already durable), done (answer ready), timeout (the wait window ended; a partial answer may exist), error. Read-only: it never wakes the target.",
6483
+ parameters: { askId: {
6484
+ type: "string",
6485
+ description: "The askId returned by ask_session with detach: true. Omit it to list the recent asks this session started."
6486
+ } },
6487
+ output: {
6488
+ schema: {
6489
+ type: "object",
6490
+ additionalProperties: false,
6491
+ properties: {
6492
+ ok: {
6493
+ type: "boolean",
6494
+ required: true
6495
+ },
6496
+ count: {
6497
+ type: "number",
6498
+ required: true
6499
+ },
6500
+ results: {
6501
+ type: "array",
6502
+ required: true,
6503
+ items: {
6504
+ type: "object",
6505
+ additionalProperties: false,
6506
+ properties: {
6507
+ askId: {
6508
+ type: "string",
6509
+ required: true
6510
+ },
6511
+ sessionId: { type: "string" },
6512
+ targetSessionId: { type: "string" },
6513
+ /** Inbound ask this one descends from (call-chain trace), when any. */
6514
+ parentAskId: { type: "string" },
6515
+ from: { type: "string" },
6516
+ status: {
6517
+ type: "string",
6518
+ enum: [
6519
+ "ok",
6520
+ "partial",
6521
+ "failed"
6522
+ ]
6523
+ },
6524
+ targetName: {
6525
+ type: "string",
6526
+ required: true
6527
+ },
6528
+ question: {
6529
+ type: "string",
6530
+ required: true
6531
+ },
6532
+ state: {
6533
+ type: "string",
6534
+ required: true
6535
+ },
6536
+ createdAt: {
6537
+ type: "number",
6538
+ required: true
6539
+ },
6540
+ settledAt: { type: "number" },
6541
+ answer: { type: "string" },
6542
+ reply: {
6543
+ type: "object",
6544
+ additionalProperties: false,
6545
+ properties: {
6546
+ conclusion: { type: "string" },
6547
+ basis: { type: "string" },
6548
+ uncertainty: { type: "string" }
6549
+ }
6550
+ },
6551
+ note: { type: "string" },
6552
+ error: { type: "string" }
6553
+ }
6554
+ }
6555
+ },
6556
+ error: { type: "string" },
6557
+ message: { type: "string" }
6558
+ }
6559
+ },
6560
+ render: (_args, value) => {
6561
+ if (value.ok !== true) return [{
6562
+ type: "text",
6563
+ text: `【ask_result 失败:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}`
6564
+ }];
6565
+ const rows = Array.isArray(value.results) ? value.results : [];
6566
+ if (rows.length === 0) return [{
6567
+ type: "text",
6568
+ text: "没有找到异步提问的结果。"
6569
+ }];
6570
+ return [{
6571
+ type: "text",
6572
+ text: renderAskRecords(rows)
6573
+ }];
6574
+ }
6575
+ },
6576
+ async execute(args, exec) {
6577
+ const swarm = service.getConfig().swarm;
6578
+ if (swarm.enabled === false) return {
6579
+ ok: false,
6580
+ count: 0,
6581
+ results: [],
6582
+ error: "swarm-disabled",
6583
+ message: SWARM_DISABLED_MESSAGE
6584
+ };
6585
+ const session = getCurrentSessionFrom(exec);
6586
+ const sessionId = typeof session?.id === "string" ? session.id : "";
6587
+ try {
6588
+ const dir = askStoreRoot();
6589
+ const ttlHours = Math.min(720, Math.max(1, Math.floor(swarm.resultTtlHours)));
6590
+ try {
6591
+ pruneAskRecords(dir, ttlHours * 36e5);
6592
+ } catch {}
6593
+ const askId = typeof args.askId === "string" ? args.askId.trim() : "";
6594
+ const results = askId.length > 0 ? [readAskRecord(dir, askId)].filter((row) => row !== void 0) : listAskRecords(dir, sessionId.length > 0 ? { sessionId } : void 0).slice(0, 20);
6595
+ return {
6596
+ ok: true,
6597
+ count: results.length,
6598
+ results
6599
+ };
6600
+ } catch (error) {
6601
+ return {
6602
+ ok: false,
6603
+ count: 0,
6604
+ results: [],
6605
+ error: "store-failed",
6606
+ message: String(error instanceof Error ? error.message : error)
6607
+ };
6608
+ }
6609
+ }
4302
6610
  })
4303
6611
  ];
4304
6612
  }
6613
+ /** One compact block per detached ask result, for the model to read. */
6614
+ function renderAskRecords(rows) {
6615
+ const stateLabel = {
6616
+ waiting: "等待中",
6617
+ done: "已完成",
6618
+ timeout: "超时",
6619
+ error: "出错"
6620
+ };
6621
+ return rows.map((row) => {
6622
+ return [
6623
+ `- [${stateLabel[row.state]}] ${row.targetName} · askId=${row.askId}`,
6624
+ row.parentAskId === void 0 ? "" : ` (由 ${row.parentAskId} 转问)`,
6625
+ ` 问题:${row.question.replace(/\s+/gu, " ").slice(0, 120)}`,
6626
+ row.reply !== void 0 ? [
6627
+ row.reply.conclusion === void 0 ? "" : ` 结论:${row.reply.conclusion}`,
6628
+ row.reply.basis === void 0 ? "" : ` 依据:${row.reply.basis}`,
6629
+ row.reply.uncertainty === void 0 ? "" : ` 不确定:${row.reply.uncertainty}`
6630
+ ].filter((line) => line.length > 0).join("\n") : row.answer !== void 0 && row.answer.length > 0 ? ` ${row.answer.slice(0, 600)}` : row.error !== void 0 ? ` 错误:${row.error}` : ""
6631
+ ].filter((line) => line.length > 0).join("\n");
6632
+ }).join("\n\n");
6633
+ }
4305
6634
  /**
4306
6635
  * Current session id from the tool execution context. `exec.agent.session` is
4307
6636
  * the Agent on whose behalf the call runs (same access `ask_advisors` uses for
@@ -4326,6 +6655,101 @@ function errorInterval() {
4326
6655
  endedBecause: "error"
4327
6656
  };
4328
6657
  }
6658
+ /** Refusal text shared by the three cross-session tools when the swarm switch is off. */
6659
+ const SWARM_DISABLED_MESSAGE = "蜂群模式已在设置里关闭(设置 → 插件 → 顾问群 → 全局蜂群)。";
6660
+ /**
6661
+ * Effective ask mode: an argument the caller passes always wins; an omitted
6662
+ * one falls back to the configured default (`defaultMode` / `defaultWaitMs`).
6663
+ */
6664
+ function resolveAskDefaults(args, swarm) {
6665
+ return {
6666
+ waitMs: typeof args.waitMs === "number" ? args.waitMs : swarm.defaultWaitMs,
6667
+ detach: typeof args.detach === "boolean" ? args.detach : swarm.defaultMode === "detach"
6668
+ };
6669
+ }
6670
+ /**
6671
+ * Roster visibility: subagent sessions are dropped unless the setting lists
6672
+ * them. Pure, so the filter is testable without a host session controller.
6673
+ */
6674
+ function visibleRosterSessions(sessions, includeSubagents) {
6675
+ return includeSubagents ? sessions : sessions.filter((session) => session.origin !== "subagent");
6676
+ }
6677
+ /** The exclusion entries from settings; anything malformed is ignored. */
6678
+ function exclusionEntries(swarm) {
6679
+ return Array.isArray(swarm.routingExclude) ? swarm.routingExclude.filter((entry) => typeof entry === "string") : [];
6680
+ }
6681
+ /** The exclusion matcher's view of one listed session (title or fallback name). */
6682
+ function excludableOf(summary) {
6683
+ return {
6684
+ sessionId: summary.sessionId,
6685
+ names: [sessionDisplayName(summary)]
6686
+ };
6687
+ }
6688
+ /** True when this listed session is hidden by `swarm.routingExclude`. */
6689
+ function isHiddenSession(swarm, summary) {
6690
+ return isSessionExcluded(exclusionEntries(swarm), excludableOf(summary));
6691
+ }
6692
+ /** Alias file location: beside the detached ask records, under the plugin store. */
6693
+ function routingAliasFile() {
6694
+ return `${askStoreRoot()}/aliases.json`;
6695
+ }
6696
+ /**
6697
+ * Alias map restricted to sessions that still exist. A remembered phrasing that
6698
+ * points at a deleted session is dropped here — the router never decides that.
6699
+ */
6700
+ function knownRoutingAliases(file, sessions) {
6701
+ const known = new Set(sessions.map((session) => session.sessionId));
6702
+ return Object.fromEntries(Object.entries(loadAliases(file)).filter(([, sessionId]) => known.has(sessionId)));
6703
+ }
6704
+ /**
6705
+ * The Jev re-ranker for the routing engine, or undefined when Jev has no
6706
+ * usable credentials. `undefined` is meaningful: the router then behaves
6707
+ * exactly like the local engine and says so on the candidates, instead of
6708
+ * logging a failure on every routing pass.
6709
+ */
6710
+ function routingJevRouter(config, signal) {
6711
+ if (config === void 0 || !jevConfigured(config)) return void 0;
6712
+ const jev = config;
6713
+ return async (input) => {
6714
+ const decision = await routeWithJev(input.question, input.questionEn, input.candidates.map((candidate) => ({
6715
+ id: candidate.sessionId,
6716
+ title: candidate.name,
6717
+ scope: candidate.reason
6718
+ })), jev, signal);
6719
+ return {
6720
+ choice: decision.choice,
6721
+ ...decision.confidence === void 0 ? {} : { confidence: decision.confidence }
6722
+ };
6723
+ };
6724
+ }
6725
+ /** Error-path render of the routing candidates: the user picks one, then names it. */
6726
+ function renderRouteCandidates(value) {
6727
+ if (!Array.isArray(value) || value.length === 0) return "";
6728
+ return `\n按问题匹配出的候选:\n${value.map((item) => {
6729
+ const row = item;
6730
+ return `- ${typeof row.name === "string" ? row.name : ""} · ${typeof row.sessionId === "string" ? row.sessionId : ""} · ${typeof row.reason === "string" ? row.reason : ""}${typeof row.score === "number" ? `(${row.score} 分)` : ""}`;
6731
+ }).join("\n")}`;
6732
+ }
6733
+ /** A repeat ask inside this window earns the 「刚问过」 hint. */
6734
+ const RECENT_ASK_WINDOW_MS = 6e5;
6735
+ /**
6736
+ * Append 「刚问过」 to a candidate this asking session already asked in the last
6737
+ * ten minutes, so a repeat is visible before delivering. Best effort and
6738
+ * side-effect free: a missing or damaged ask store only means no hint.
6739
+ */
6740
+ function markRecentAsks(candidates, sessionId, now = Date.now()) {
6741
+ let recent;
6742
+ try {
6743
+ recent = new Set(listAskRecords(askStoreRoot(), { sessionId }).filter((record) => now - record.createdAt <= RECENT_ASK_WINDOW_MS).map((record) => record.targetSessionId).filter((id) => typeof id === "string" && id.length > 0));
6744
+ } catch {
6745
+ return [...candidates];
6746
+ }
6747
+ if (recent.size === 0) return [...candidates];
6748
+ return candidates.map((candidate) => recent.has(candidate.sessionId) ? {
6749
+ ...candidate,
6750
+ reason: `${candidate.reason} · 刚问过`
6751
+ } : candidate);
6752
+ }
4329
6753
  function formatRiskNotes(summary) {
4330
6754
  return summary.riskNotes.length > 0 ? `\n\n【风险提示】\n${summary.riskNotes.join("\n")}` : "";
4331
6755
  }
@@ -4648,6 +7072,22 @@ async function parseLookupParams(req) {
4648
7072
  const ALLOWED_API_KEY_ENVS = new Set(Object.values(PROVIDER_PRESETS).map((preset) => preset.apiKeyEnv));
4649
7073
  ALLOWED_API_KEY_ENVS.add("TYPESAFE_API_KEY");
4650
7074
  ALLOWED_API_KEY_ENVS.add("OPENROUTER_API_KEY");
7075
+ /**
7076
+ * Env names the diagnostics may read. Presets contribute their fixed names; on top of that we
7077
+ * trust every name the user has already **saved** in this plugin's config — that is what lets a
7078
+ * custom provider use its own variable. Trusting the saved config keeps the guard meaningful: a
7079
+ * caller cannot invent a name, only reuse one the user declared here.
7080
+ */
7081
+ function allowedApiKeyEnvs(config) {
7082
+ const allowed = new Set(ALLOWED_API_KEY_ENVS);
7083
+ for (const advisor of config.advisors ?? []) {
7084
+ const env = advisor.apiKeyEnv?.trim();
7085
+ if (env) allowed.add(env);
7086
+ }
7087
+ const jevEnv = config.trigger?.jev?.apiKeyEnv?.trim();
7088
+ if (jevEnv) allowed.add(jevEnv);
7089
+ return allowed;
7090
+ }
4651
7091
  function validateSecurity(input) {
4652
7092
  if (input.trigger.jev?.baseURL) {
4653
7093
  const url = input.trigger.jev.baseURL.trim();
@@ -4664,7 +7104,7 @@ function validateSecurity(input) {
4664
7104
  }
4665
7105
  if (advisor.apiKeyEnv) {
4666
7106
  const env = advisor.apiKeyEnv.trim();
4667
- if (!ALLOWED_API_KEY_ENVS.has(env)) return `顾问 ${advisor.id} 的 API Key 不被允许:${env}。请使用预设供应商对应的 API Key。`;
7107
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(env)) return `顾问 ${advisor.id} 的 API Key 环境变量名无效:${env}`;
4668
7108
  }
4669
7109
  }
4670
7110
  return null;
@@ -4810,10 +7250,10 @@ async function handleModelsRequest(ctx, service, req, res) {
4810
7250
  });
4811
7251
  return;
4812
7252
  }
4813
- if (!apiKey && effectiveEnv && !ALLOWED_API_KEY_ENVS.has(effectiveEnv)) {
7253
+ if (!apiKey && effectiveEnv && !allowedApiKeyEnvs(service.getConfig()).has(effectiveEnv)) {
4814
7254
  sendJson(res, 400, {
4815
7255
  ok: false,
4816
- error: "API Key 不能使用该环境变量名"
7256
+ error: `环境变量 ${effectiveEnv} 还不能用于测试:自定义供应商请先保存设置,或直接在「API Key」里填密钥。`
4817
7257
  });
4818
7258
  return;
4819
7259
  }
@@ -4900,10 +7340,10 @@ async function handleTestConnectionRequest(ctx, service, req, res) {
4900
7340
  });
4901
7341
  return;
4902
7342
  }
4903
- if (!apiKey && effectiveEnv && !ALLOWED_API_KEY_ENVS.has(effectiveEnv)) {
7343
+ if (!apiKey && effectiveEnv && !allowedApiKeyEnvs(service.getConfig()).has(effectiveEnv)) {
4904
7344
  sendJson(res, 400, {
4905
7345
  ok: false,
4906
- error: "API Key 不能使用该环境变量名"
7346
+ error: `环境变量 ${effectiveEnv} 还不能用于测试:自定义供应商请先保存设置,或直接在「API Key」里填密钥。`
4907
7347
  });
4908
7348
  return;
4909
7349
  }
@@ -5342,6 +7782,39 @@ const inject = [
5342
7782
  "systemPrompt",
5343
7783
  "sessions"
5344
7784
  ];
7785
+ /**
7786
+ * One background cross-session suggestion pass: recall the local candidates
7787
+ * for this turn, ask Jev the batched judgment, and remember a one-turn hint.
7788
+ * Everything is best effort — no hint, no error, no delivery on any failure.
7789
+ */
7790
+ async function suggestCrossSession(input) {
7791
+ try {
7792
+ const controller = getSessionController(input.ctx);
7793
+ if (controller === void 0) return;
7794
+ const config = input.service.getConfig();
7795
+ const candidates = routeSessions(withSessionLogSizes(visibleRosterSessions((await controller.list({}, input.signal)).items, config.swarm.rosterIncludeSubagents)), input.text, {
7796
+ topN: config.swarm.routingTopN,
7797
+ currentSessionId: input.sessionId,
7798
+ cache: false,
7799
+ exclude: Array.isArray(config.swarm.routingExclude) ? config.swarm.routingExclude : []
7800
+ });
7801
+ const judge = async (turn) => judgeCrossSessionWithJev(turn.text, turn.textEn, turn.candidates.map((candidate) => ({
7802
+ id: candidate.sessionId,
7803
+ title: candidate.name,
7804
+ scope: candidate.reason
7805
+ })), input.jev, input.signal);
7806
+ const hint = await crossSessionHintForTurn({
7807
+ text: input.text,
7808
+ mode: config.swarm.routingAsk ?? "gated",
7809
+ candidates,
7810
+ judge,
7811
+ log: logJevDecision
7812
+ });
7813
+ if (hint === void 0) return;
7814
+ rememberCrossSessionHint(input.sessionId, hint);
7815
+ console.log(`[dsh-advisor-group] 跨会话提示(仅本轮):${hint}`);
7816
+ } catch {}
7817
+ }
5345
7818
  function apply(ctx, config) {
5346
7819
  const guarded = guardAdvisorPrompts(unwrapVolatileConfig(config));
5347
7820
  reportPromptGuardFindings(guarded.findings);
@@ -5349,12 +7822,65 @@ function apply(ctx, config) {
5349
7822
  registerAdvisorSettingsAndRoutes(ctx, guarded.config, service);
5350
7823
  for (const tool of registerAdvisorTools(service, ctx)) ctx.tools.register(tool);
5351
7824
  const systemPrompt = ctx.systemPrompt;
7825
+ ctx.effect(() => () => cancelDetachedAsks());
7826
+ const hintAborts = new AbortController();
7827
+ ctx.effect(() => () => hintAborts.abort());
7828
+ ctx.on("session/event", (session, event) => {
7829
+ const text = userMessageTextOf(event);
7830
+ if (text === void 0) return;
7831
+ const cfg = service.getConfig();
7832
+ if (cfg.enabled === false || cfg.swarm.enabled === false) return;
7833
+ const mode = cfg.swarm.routingAsk ?? "gated";
7834
+ if (mode === "off") return;
7835
+ const injected = isInjectedContextText(text);
7836
+ if (!isGenuineUserPrompt(event) || injected) {
7837
+ if (injected && shouldLogInjectedSkip(session.id)) logJevDecision({
7838
+ kind: "ask",
7839
+ question: text,
7840
+ called: false,
7841
+ configured: jevConfigured(cfg.trigger.jev),
7842
+ decision: "skipped"
7843
+ });
7844
+ return;
7845
+ }
7846
+ resetInjectedSkipLog(session.id);
7847
+ const configured = jevConfigured(cfg.trigger.jev);
7848
+ if (mode === "gated" && !shouldConsiderCrossSession(text)) {
7849
+ logJevDecision({
7850
+ kind: "ask",
7851
+ question: text,
7852
+ called: false,
7853
+ configured,
7854
+ decision: "skipped"
7855
+ });
7856
+ return;
7857
+ }
7858
+ const jev = cfg.trigger.jev;
7859
+ if (jev === void 0 || !configured) {
7860
+ logJevDecision({
7861
+ kind: "ask",
7862
+ question: text,
7863
+ called: false,
7864
+ configured: false,
7865
+ decision: "skipped"
7866
+ });
7867
+ return;
7868
+ }
7869
+ suggestCrossSession({
7870
+ ctx,
7871
+ service,
7872
+ jev,
7873
+ sessionId: session.id,
7874
+ text,
7875
+ signal: hintAborts.signal
7876
+ });
7877
+ });
5352
7878
  if (systemPrompt) ctx.effect(() => systemPrompt.section({
5353
7879
  name: "advisor-group-boundary",
5354
7880
  order: 150,
5355
7881
  text: (assembly) => {
5356
7882
  const sessionId = assembly?.agent?.session?.id;
5357
- return buildAdvisorPrompt(service.getConfig(), service.getRepeatPressure(sessionId));
7883
+ return appendCrossSessionHint(buildAdvisorPrompt(service.getConfig(), service.getRepeatPressure(sessionId)), sessionId);
5358
7884
  }
5359
7885
  }), "dsh-advisor-group: system prompt section");
5360
7886
  }