dsh-advisor-group 0.1.3 → 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) {
@@ -280,6 +297,12 @@ async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutM
280
297
  }
281
298
  //#endregion
282
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";
283
306
  const PROVIDER_PRESETS = {
284
307
  deepseek: {
285
308
  id: "deepseek",
@@ -701,6 +724,72 @@ const PROVIDER_PRESETS = {
701
724
  modelsBaseURL: "https://openrouter.ai/api/v1",
702
725
  modelsAuthMode: "bearer",
703
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 兼容那条基本相同,选一条即可。"
704
793
  }
705
794
  };
706
795
  function getProviderPreset(id) {
@@ -923,10 +1012,10 @@ async function streamDirectHttp(advisor, transcript, onDelta, signal, timeoutMs,
923
1012
  let result;
924
1013
  switch (protocol) {
925
1014
  case "openai":
926
- 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);
927
1016
  break;
928
1017
  case "anthropic":
929
- 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);
930
1019
  break;
931
1020
  case "gemini": {
932
1021
  const content = await callGemini(baseURL, advisor, apiKey, transcriptText, signal, timeoutMs);
@@ -950,7 +1039,7 @@ async function streamDirectHttp(advisor, transcript, onDelta, signal, timeoutMs,
950
1039
  }
951
1040
  return result;
952
1041
  }
953
- async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools) {
1042
+ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools, extraHeaders) {
954
1043
  const endpoint = baseURL.endsWith("/chat/completions") ? baseURL : `${baseURL.replace(/\/+$/, "")}/chat/completions`;
955
1044
  const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
956
1045
  const body = {
@@ -979,7 +1068,8 @@ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText,
979
1068
  method: "POST",
980
1069
  headers: {
981
1070
  "content-type": "application/json",
982
- authorization: `Bearer ${apiKey}`
1071
+ authorization: `Bearer ${apiKey}`,
1072
+ ...extraHeaders
983
1073
  },
984
1074
  body: JSON.stringify(body),
985
1075
  signal: pair.signal
@@ -1080,11 +1170,12 @@ function collectToolCalls(byIndex) {
1080
1170
  }
1081
1171
  return calls.length > 0 ? calls : void 0;
1082
1172
  }
1083
- 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) {
1084
1174
  const endpoint = baseURL.endsWith("/v1/messages") ? baseURL : baseURL.endsWith("/v1") ? `${baseURL}/messages` : `${baseURL.replace(/\/+$/, "")}/v1/messages`;
1085
1175
  const headers = {
1086
1176
  "content-type": "application/json",
1087
- "anthropic-version": "2023-06-01"
1177
+ "anthropic-version": "2023-06-01",
1178
+ ...extraHeaders
1088
1179
  };
1089
1180
  if (authMode === "bearer") headers["authorization"] = `Bearer ${apiKey}`;
1090
1181
  else headers["x-api-key"] = apiKey;
@@ -3109,10 +3200,32 @@ function parseResponse(body, config, advisors) {
3109
3200
  provider: config.provider
3110
3201
  };
3111
3202
  }
3112
- async function classifyWithJev(question, context, advisors, config, signal, questionEn) {
3113
- 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) {
3114
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;
3115
- 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);
3116
3229
  if (!apiKey) throw new Error("Jev 未配置 API key");
3117
3230
  const controller = new AbortController();
3118
3231
  const timeout = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`Jev 超时(${config.timeoutMs}ms)`)), config.timeoutMs);
@@ -3125,211 +3238,1833 @@ async function classifyWithJev(question, context, advisors, config, signal, ques
3125
3238
  authorization: `Bearer ${apiKey}`,
3126
3239
  "content-type": "application/json"
3127
3240
  },
3128
- body: JSON.stringify({
3129
- state: config.useEnglishState && questionEn?.trim() ? questionEn.trim() : `${question}\n\n背景:${context || "无"}`,
3130
- model: config.model || "jev-latest",
3131
- questions: {
3132
- needs_advisor: {
3133
- type: "noul",
3134
- instructions: "Does answering this question require expert-level knowledge?",
3135
- criteria: {
3136
- true: {
3137
- 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.",
3138
- examples: [
3139
- "professional-domain judgment such as accounting treatment, engineering trade-offs, or contract drafting",
3140
- "rare or long-tail situations with little public consensus",
3141
- "topics where reasonable experts disagree and picking correctly matters"
3142
- ]
3143
- },
3144
- false: {
3145
- what: "A competent generalist can answer from common knowledge, widely documented facts, or ordinary reasoning without specialist judgment.",
3146
- examples: ["everyday facts, definitions, or casual opinions", "well-documented mainstream topics with an agreed answer"]
3147
- }
3148
- }
3149
- },
3150
- web_search: {
3151
- type: "noul",
3152
- instructions: "Does answering this question primarily require current, real-time, or externally verifiable information?",
3153
- criteria: {
3154
- true: {
3155
- 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.",
3156
- examples: [
3157
- "today’s weather, prices, exchange rates, or quotes",
3158
- "recent releases, news, or version-specific changelogs",
3159
- "local or niche facts with no authoritative source in memory"
3160
- ]
3161
- },
3162
- false: {
3163
- what: "The answer follows from stable knowledge or reasoning already available in the state.",
3164
- examples: ["concepts, definitions, and established best practices", "questions fully answerable from the provided background"]
3165
- }
3166
- }
3167
- },
3168
- high_risk: {
3169
- type: "noul",
3170
- instructions: "Does this question involve medical, legal, financial, safety, compliance, or other high-risk advice?",
3171
- criteria: {
3172
- true: {
3173
- what: "A wrong or careless answer could materially harm the asker or a third party in health, money, safety, or legal standing.",
3174
- examples: [
3175
- "medical symptoms, medication, diagnosis, or treatment decisions",
3176
- "legal rights, contracts, litigation, or regulatory compliance",
3177
- "investment, insurance, tax, or other money decisions with real downside",
3178
- "physical safety, hazardous actions, or regulated activities"
3179
- ]
3180
- },
3181
- false: {
3182
- what: "Being wrong carries no material harm to health, money, safety, or legal standing.",
3183
- examples: ["general knowledge, opinions, or entertainment questions", "technical details whose worst case is wasted time"]
3184
- }
3185
- }
3186
- },
3187
- domain: {
3188
- type: "choice",
3189
- instructions: "Which domain best describes this question?",
3190
- criteria: {
3191
- legal: "law, contracts, litigation, compliance, or taxation",
3192
- medical: "medicine, symptoms, medication, diagnosis, or treatment",
3193
- finance: "investment, insurance, accounting, or financial risk",
3194
- code: "software, programming, architecture, debugging, or APIs",
3195
- general: "none of the listed domains"
3196
- }
3197
- }
3198
- }
3199
- }),
3241
+ body: payload,
3200
3242
  signal: controller.signal
3201
3243
  });
3202
3244
  if (!response.ok) throw new Error(`Jev HTTP ${response.status}`);
3203
- return parseResponse(await response.json(), config, advisors);
3245
+ return await response.json();
3204
3246
  } finally {
3205
3247
  clearTimeout(timeout);
3206
3248
  signal?.removeEventListener("abort", abort);
3207
3249
  }
3208
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
+ }
3209
3499
  //#endregion
3210
- //#region src/cross-session.ts
3500
+ //#region src/ask-store.ts
3211
3501
  /**
3212
- * Cross-session ask (v0): let the current session deliver one question to
3213
- * another existing ordinary session, wake it, and report what that session
3214
- * 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.
3215
3506
  *
3216
- * Facts this module is built on (DSH 0.2.0-rc.1/rc.2 types, verified):
3217
- * - `ctx.sessionController.resolveAgent(sessionId)` yields the live Agent
3218
- * (`{ agent }` or `{ error }`), resuming a cold session.
3219
- * - `agent.followup(createUserMessage({ content, source }))` queues one
3220
- * ordinary follow-up turn and wakes the driver.
3221
- * - `ctx.sessionController.prompt({ sessionId, content, mode:'queue', requestId })`
3222
- * is the cheap fallback, but the Host hardcodes `source` to a user prompt, so
3223
- * that path is identity-dishonest and must be reported as such.
3224
- * - There is NO API that returns "the answer to this message". The Host only
3225
- * guarantees delivery, so this module reports an INTERVAL, never a reply.
3226
- * - The Host enforces no caller identity, no cycle guard, and hard-excludes
3227
- * 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}).
3228
3626
  *
3229
- * 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.
3230
3631
  */
3231
- /** Maximum combined UTF-8 bytes of question + context (design §六.5). */
3232
- const ASK_CONTENT_MAX_BYTES = 32e3;
3233
- /** Default and maximum wait for the target's next idle (design §十). */
3234
- const ASK_WAIT_DEFAULT_MS = 18e4;
3235
- const ASK_WAIT_MAX_MS = 6e5;
3236
- /** Poll interval while waiting for the target to reach whole-session idle. */
3237
- 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;
3238
3645
  /**
3239
- * Envelope the receiving model reads. The Host offers no registration point for
3240
- * this framing (the official Agent Teams build their own too), so the identity
3241
- * is carried in the content itself: a header block naming the asking session,
3242
- * then the question, then optional background.
3243
- * @param senderName - title (or cwd+time fallback) of the asking session.
3244
- * @param question - the question to hand over.
3245
- * @param context - optional background; permanently recorded in the target log.
3246
- * @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.
3247
3649
  */
3248
- function buildAskEnvelope(senderName, question, context) {
3249
- const blocks = [{
3250
- type: "text",
3251
- text: `【来自会话「${senderName}」的提问】`
3252
- }, {
3253
- type: "text",
3254
- text: question
3255
- }];
3256
- if (context !== void 0 && context.trim().length > 0) blocks.push({
3257
- type: "text",
3258
- text: `【对方补充的背景】\n${context}`
3259
- });
3260
- return blocks;
3650
+ const NOISE_TITLE_PATTERN = /生成会话标题|^你好[,,\s]*$/u;
3651
+ function isRecord(value) {
3652
+ return value !== null && typeof value === "object";
3261
3653
  }
3262
3654
  /**
3263
- * Build the identified user message handed to `agent.followup()`.
3264
- * @param envelope - content blocks produced by {@link buildAskEnvelope}.
3265
- * @param source - producer-declared session-ask source.
3266
- * @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.
3267
3657
  */
3268
- function buildAskMessage(envelope, source) {
3269
- return createUserMessage({
3270
- content: envelope,
3271
- source
3272
- });
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();
3273
3664
  }
3274
3665
  /**
3275
- * UTF-8 byte length of the text a caller wants to hand over.
3276
- * @param question - the question text.
3277
- * @param context - optional background text.
3278
- * @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.
3279
3671
  */
3280
- function askContentBytes(question, context) {
3281
- 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];
3282
3683
  }
3283
- /** Deterministic fallback label: working directory plus most recent activity. */
3284
- function fallbackSessionName(cwd, updatedAt) {
3285
- const where = cwd === void 0 || cwd.length === 0 ? "(未知工作目录)" : cwd;
3286
- if (updatedAt === void 0 || !Number.isFinite(updatedAt)) return where;
3287
- const at = new Date(updatedAt);
3288
- 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;
3289
3707
  }
3290
3708
  /**
3291
- * Model-visible name of one listed session. The title lives in the `title`
3292
- * projection (a snapshot object), is optional, and may be a stale cache value,
3293
- * 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.
3294
3712
  */
3295
- function sessionDisplayName(summary) {
3296
- const raw = summary.projections?.values?.title;
3297
- const title = raw !== null && typeof raw === "object" && typeof raw.title === "string" ? raw.title.trim() : "";
3298
- if (title.length > 0) return title;
3299
- 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()}`;
3300
3718
  }
3301
- function candidateOf(summary) {
3302
- return {
3303
- sessionId: summary.sessionId,
3304
- name: sessionDisplayName(summary),
3305
- hint: fallbackSessionName(summary.cwd, summary.updatedAt)
3306
- };
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
+ }
3307
3770
  }
3308
- /** Most-recently-active first, running sessions ahead of idle ones. */
3309
- function orderCandidates(rows) {
3310
- return [...rows].sort((a, b) => {
3311
- if (a.running !== b.running) return a.running ? -1 : 1;
3312
- return b.updatedAt - a.updatedAt;
3313
- });
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;
3314
3792
  }
3315
- /** Partial candidates are capped: a broad needle must stay a readable list. */
3316
- const PARTIAL_CANDIDATE_LIMIT = 10;
3317
3793
  /**
3318
- * Resolve a user/model-supplied target reference against the session list.
3319
- * Ladder, in order: exact sessionId → exact display name → partial natural
3320
- * name (substring of the display name OR of the workspace cwd, case-insensitive)
3321
- * → missing. One hit resolves outright; several hits become candidates —
3322
- * this function NEVER guesses. Partial candidates are ordered most-recently-
3323
- * active first (running sessions first) and capped, so "ask the 极简遥控器
3324
- * session" works without anyone digging session ids out of log directories.
3325
- * @param sessions - rows from `sessionController.list()`.
3326
- * @param reference - a session id, display name, or natural partial name.
3327
- * @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.
3328
3803
  */
3329
- function matchTargetSession(sessions, reference) {
3330
- const needle = reference.trim();
3331
- if (needle.length === 0) return { kind: "missing" };
3332
- const byId = sessions.find((session) => session.sessionId === needle);
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) {
5065
+ const needle = reference.trim();
5066
+ if (needle.length === 0) return { kind: "missing" };
5067
+ const byId = sessions.find((session) => session.sessionId === needle);
3333
5068
  if (byId) return {
3334
5069
  kind: "unique",
3335
5070
  target: {
@@ -3385,6 +5120,22 @@ function nextAskHop(events) {
3385
5120
  return 0;
3386
5121
  }
3387
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
+ /**
3388
5139
  * Collect what the target session said inside the interval. Only
3389
5140
  * `assistant/message` events strictly after the delivery watermark count, so a
3390
5141
  * reply that predates the delivery cannot be reported as an answer. The
@@ -3413,6 +5164,33 @@ function collectAnswer(events, fromSeq) {
3413
5164
  function joinAnswerPieces(pieces) {
3414
5165
  return pieces.join("\n\n");
3415
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
+ }
3416
5194
  /** Highest event seq present, or -1 for an empty log (the session-cursor convention). */
3417
5195
  function logWatermark(events) {
3418
5196
  let highest = -1;
@@ -3421,9 +5199,21 @@ function logWatermark(events) {
3421
5199
  }
3422
5200
  /** Human-readable text the main model reads for one settled ask_session call. */
3423
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(" · ");
3424
5214
  return [{
3425
5215
  type: "text",
3426
- text: `${value.answer.trim().length > 0 ? value.answer : "(该区间内对方没有产出正文。)"}\n\n${value.note}`
5216
+ text: `${who.length > 0 ? `【${who}】\n` : ""}${body}\n\n${value.note}`
3427
5217
  }];
3428
5218
  }
3429
5219
  /**
@@ -3438,8 +5228,63 @@ function isSubagentDeliveryRejection(error) {
3438
5228
  const haystack = `${typeof reason === "string" ? reason : ""} ${typeof shape.message === "string" ? shape.message : ""}`;
3439
5229
  return haystack.includes("subagent delivery") || haystack.includes("owned by subagent routing");
3440
5230
  }
3441
- /** One unsettled wait per session: at most one ask_session may be in flight. */
3442
- 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
+ }
3443
5288
  /** Whether this session already has an unsettled ask_session wait. */
3444
5289
  function isWaiting(sessionId) {
3445
5290
  return pendingWaits.has(sessionId);
@@ -3499,7 +5344,7 @@ async function sleep(ms, signal) {
3499
5344
  * in `note` that this identity-dishonest path was used.
3500
5345
  */
3501
5346
  async function deliverAsk(controller, signal, input) {
3502
- const envelope = buildAskEnvelope(input.source.senderName, input.question, input.context);
5347
+ const envelope = buildAskEnvelope(input.source.senderName, input.question, input.context, input.source.askId);
3503
5348
  try {
3504
5349
  const resolved = await controller.resolveAgent(input.target.sessionId);
3505
5350
  if ("agent" in resolved && resolved.agent) {
@@ -3696,7 +5541,8 @@ function checkAskGuards(input) {
3696
5541
  const { guards, match } = input;
3697
5542
  if (match.kind === "missing") return {
3698
5543
  code: "target-not-found",
3699
- message: "没有找到匹配的会话。请用会话列表里的标题或 sessionId 指定目标;不确认时先列出候选再让用户选择。"
5544
+ message: "没有找到匹配的会话。下面列出最近的会话,请把候选交给用户挑选(标题或 sessionId 都可以);不要再去翻会话日志或目录找 id。",
5545
+ candidates: orderCandidates(input.sessions).slice(0, NOT_FOUND_CANDIDATE_LIMIT).map(candidateOf)
3700
5546
  };
3701
5547
  if (match.kind === "ambiguous") return {
3702
5548
  code: "target-ambiguous",
@@ -3743,11 +5589,21 @@ async function askSession(ctx, signal, input) {
3743
5589
  pendingWaits.add(input.currentSessionId);
3744
5590
  try {
3745
5591
  const sessions = [];
5592
+ const allowed = [];
5593
+ const entries = exclusionEntries$1(input.exclude);
3746
5594
  let match = { kind: "missing" };
3747
5595
  try {
3748
5596
  const listed = await controller.list({}, signal);
3749
5597
  sessions.push(...listed.items);
3750
- 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
+ }
3751
5607
  } catch (error) {
3752
5608
  return {
3753
5609
  ok: false,
@@ -3756,7 +5612,7 @@ async function askSession(ctx, signal, input) {
3756
5612
  };
3757
5613
  }
3758
5614
  return await runAsk(ctx, controller, signal, input, {
3759
- sessions,
5615
+ sessions: allowed,
3760
5616
  match,
3761
5617
  log: input.log ?? (() => {})
3762
5618
  });
@@ -3788,6 +5644,7 @@ async function runAsk(ctx, controller, signal, input, listed) {
3788
5644
  };
3789
5645
  const targetRow = listed.sessions.find((session) => session.sessionId === target.sessionId);
3790
5646
  const askId = nextAskId();
5647
+ const parentAskId = inboundAskId(input.currentEvents);
3791
5648
  const source = {
3792
5649
  kind: "session-ask",
3793
5650
  form: "relay",
@@ -3816,6 +5673,84 @@ async function runAsk(ctx, controller, signal, input, listed) {
3816
5673
  senderName: input.senderName,
3817
5674
  targetName: target.name
3818
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
+ }
3819
5754
  const outcome = await waitForTargetIdle(controller, signal, {
3820
5755
  sessionId: target.sessionId,
3821
5756
  targetWasRunning: delivery.receipt.targetWasRunning,
@@ -3824,7 +5759,13 @@ async function runAsk(ctx, controller, signal, input, listed) {
3824
5759
  fromSeq: delivery.receipt.fromSeq
3825
5760
  });
3826
5761
  const collected = await collectTargetAnswer(controller, target.sessionId, delivery.receipt.fromSeq);
3827
- 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
+ });
3828
5769
  const to = outcome.endedBecause === "idle" ? (/* @__PURE__ */ new Date()).toISOString() : null;
3829
5770
  const note = [
3830
5771
  "区间语义:从投递回执到对方会话下一次整体空闲,这两个时刻之间对方产出的文字都算在内——这是「这个区间里对方说的话」,不是逐条对应的回答。",
@@ -3837,7 +5778,7 @@ async function runAsk(ctx, controller, signal, input, listed) {
3837
5778
  askId,
3838
5779
  question: input.question,
3839
5780
  targetName: target.name,
3840
- answer,
5781
+ answer: rawAnswer,
3841
5782
  note
3842
5783
  });
3843
5784
  return {
@@ -3854,6 +5795,8 @@ async function runAsk(ctx, controller, signal, input, listed) {
3854
5795
  endedBecause: outcome.endedBecause
3855
5796
  },
3856
5797
  answer,
5798
+ ...reply === void 0 ? {} : { reply },
5799
+ status,
3857
5800
  note
3858
5801
  };
3859
5802
  }
@@ -4133,8 +6076,7 @@ function registerAdvisorTools(service, ctx) {
4133
6076
  parameters: {
4134
6077
  target: {
4135
6078
  type: "string",
4136
- 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.",
4137
- 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."
4138
6080
  },
4139
6081
  question: {
4140
6082
  type: "string",
@@ -4145,9 +6087,17 @@ function registerAdvisorTools(service, ctx) {
4145
6087
  type: "string",
4146
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."
4147
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
+ },
4148
6094
  waitMs: {
4149
6095
  type: "number",
4150
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."
4151
6101
  }
4152
6102
  },
4153
6103
  output: {
@@ -4177,6 +6127,7 @@ function registerAdvisorTools(service, ctx) {
4177
6127
  type: "string",
4178
6128
  enum: ["native", "fallback"]
4179
6129
  },
6130
+ detached: { type: "boolean" },
4180
6131
  askId: { type: "string" },
4181
6132
  interval: {
4182
6133
  type: "object",
@@ -4192,7 +6143,8 @@ function registerAdvisorTools(service, ctx) {
4192
6143
  enum: [
4193
6144
  "idle",
4194
6145
  "timeout",
4195
- "error"
6146
+ "error",
6147
+ "detached"
4196
6148
  ],
4197
6149
  required: true
4198
6150
  }
@@ -4203,6 +6155,23 @@ function registerAdvisorTools(service, ctx) {
4203
6155
  type: "string",
4204
6156
  required: true
4205
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
+ },
4206
6175
  note: {
4207
6176
  type: "string",
4208
6177
  required: true
@@ -4229,18 +6198,57 @@ function registerAdvisorTools(service, ctx) {
4229
6198
  }
4230
6199
  }
4231
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
+ }
4232
6226
  }
4233
6227
  }
4234
6228
  },
4235
6229
  render: (_args, value) => value.ok === true ? renderAskResult({
4236
6230
  answer: String(value.answer ?? ""),
4237
- 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 }
4238
6236
  }) : [{
4239
6237
  type: "text",
4240
- 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)}`
4241
6239
  }]
4242
6240
  },
4243
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);
4244
6252
  const controller = getSessionController(ctx);
4245
6253
  if (!controller) return {
4246
6254
  ok: false,
@@ -4263,10 +6271,38 @@ function registerAdvisorTools(service, ctx) {
4263
6271
  const question = typeof args.question === "string" ? args.question : "";
4264
6272
  const context = typeof args.context === "string" ? args.context : void 0;
4265
6273
  let senderName = "未命名会话";
6274
+ let listedItems = [];
4266
6275
  try {
4267
- 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);
4268
6279
  if (own) senderName = sessionDisplayName(own);
4269
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
+ }
4270
6306
  const cardLog = session;
4271
6307
  const onDelivered = (info) => {
4272
6308
  if (!cardLog) return;
@@ -4284,6 +6320,7 @@ function registerAdvisorTools(service, ctx) {
4284
6320
  console.warn("[dsh-advisor-group] 跨会话卡片(结束)写入失败:", error);
4285
6321
  }
4286
6322
  };
6323
+ const answerMode = resolveAskDefaults(args, swarm);
4287
6324
  const value = await askSession(ctx, exec.signal, {
4288
6325
  onDelivered,
4289
6326
  onSettled,
@@ -4293,7 +6330,9 @@ function registerAdvisorTools(service, ctx) {
4293
6330
  target: typeof args.target === "string" ? args.target : "",
4294
6331
  question,
4295
6332
  context,
4296
- waitMs: args.waitMs,
6333
+ waitMs: answerMode.waitMs,
6334
+ detach: answerMode.detach,
6335
+ exclude: hidden,
4297
6336
  log: (message) => console.log(`[dsh-advisor-group] ask_session: ${message}`)
4298
6337
  });
4299
6338
  if (value.ok !== true) return {
@@ -4312,12 +6351,286 @@ function registerAdvisorTools(service, ctx) {
4312
6351
  askId: String(value.askId ?? ""),
4313
6352
  interval: value.interval,
4314
6353
  answer: String(value.answer ?? ""),
6354
+ ...value.detached === true ? { detached: true } : {},
6355
+ ...value.reply === null || typeof value.reply !== "object" ? {} : { reply: value.reply },
4315
6356
  note: String(value.note ?? "")
4316
6357
  };
4317
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
+ }
4318
6610
  })
4319
6611
  ];
4320
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
+ }
4321
6634
  /**
4322
6635
  * Current session id from the tool execution context. `exec.agent.session` is
4323
6636
  * the Agent on whose behalf the call runs (same access `ask_advisors` uses for
@@ -4342,6 +6655,101 @@ function errorInterval() {
4342
6655
  endedBecause: "error"
4343
6656
  };
4344
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
+ }
4345
6753
  function formatRiskNotes(summary) {
4346
6754
  return summary.riskNotes.length > 0 ? `\n\n【风险提示】\n${summary.riskNotes.join("\n")}` : "";
4347
6755
  }
@@ -4664,6 +7072,22 @@ async function parseLookupParams(req) {
4664
7072
  const ALLOWED_API_KEY_ENVS = new Set(Object.values(PROVIDER_PRESETS).map((preset) => preset.apiKeyEnv));
4665
7073
  ALLOWED_API_KEY_ENVS.add("TYPESAFE_API_KEY");
4666
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
+ }
4667
7091
  function validateSecurity(input) {
4668
7092
  if (input.trigger.jev?.baseURL) {
4669
7093
  const url = input.trigger.jev.baseURL.trim();
@@ -4680,7 +7104,7 @@ function validateSecurity(input) {
4680
7104
  }
4681
7105
  if (advisor.apiKeyEnv) {
4682
7106
  const env = advisor.apiKeyEnv.trim();
4683
- 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}`;
4684
7108
  }
4685
7109
  }
4686
7110
  return null;
@@ -4826,10 +7250,10 @@ async function handleModelsRequest(ctx, service, req, res) {
4826
7250
  });
4827
7251
  return;
4828
7252
  }
4829
- if (!apiKey && effectiveEnv && !ALLOWED_API_KEY_ENVS.has(effectiveEnv)) {
7253
+ if (!apiKey && effectiveEnv && !allowedApiKeyEnvs(service.getConfig()).has(effectiveEnv)) {
4830
7254
  sendJson(res, 400, {
4831
7255
  ok: false,
4832
- error: "API Key 不能使用该环境变量名"
7256
+ error: `环境变量 ${effectiveEnv} 还不能用于测试:自定义供应商请先保存设置,或直接在「API Key」里填密钥。`
4833
7257
  });
4834
7258
  return;
4835
7259
  }
@@ -4916,10 +7340,10 @@ async function handleTestConnectionRequest(ctx, service, req, res) {
4916
7340
  });
4917
7341
  return;
4918
7342
  }
4919
- if (!apiKey && effectiveEnv && !ALLOWED_API_KEY_ENVS.has(effectiveEnv)) {
7343
+ if (!apiKey && effectiveEnv && !allowedApiKeyEnvs(service.getConfig()).has(effectiveEnv)) {
4920
7344
  sendJson(res, 400, {
4921
7345
  ok: false,
4922
- error: "API Key 不能使用该环境变量名"
7346
+ error: `环境变量 ${effectiveEnv} 还不能用于测试:自定义供应商请先保存设置,或直接在「API Key」里填密钥。`
4923
7347
  });
4924
7348
  return;
4925
7349
  }
@@ -5358,6 +7782,39 @@ const inject = [
5358
7782
  "systemPrompt",
5359
7783
  "sessions"
5360
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
+ }
5361
7818
  function apply(ctx, config) {
5362
7819
  const guarded = guardAdvisorPrompts(unwrapVolatileConfig(config));
5363
7820
  reportPromptGuardFindings(guarded.findings);
@@ -5365,12 +7822,65 @@ function apply(ctx, config) {
5365
7822
  registerAdvisorSettingsAndRoutes(ctx, guarded.config, service);
5366
7823
  for (const tool of registerAdvisorTools(service, ctx)) ctx.tools.register(tool);
5367
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
+ });
5368
7878
  if (systemPrompt) ctx.effect(() => systemPrompt.section({
5369
7879
  name: "advisor-group-boundary",
5370
7880
  order: 150,
5371
7881
  text: (assembly) => {
5372
7882
  const sessionId = assembly?.agent?.session?.id;
5373
- return buildAdvisorPrompt(service.getConfig(), service.getRepeatPressure(sessionId));
7883
+ return appendCrossSessionHint(buildAdvisorPrompt(service.getConfig(), service.getRepeatPressure(sessionId)), sessionId);
5374
7884
  }
5375
7885
  }), "dsh-advisor-group: system prompt section");
5376
7886
  }