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/README.en.md +96 -37
- package/README.md +98 -38
- package/lib/client.js +183 -1
- package/lib/index.d.mts +45 -0
- package/lib/index.mjs +2717 -207
- package/package.json +14 -14
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
|
-
|
|
3113
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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/
|
|
3500
|
+
//#region src/ask-store.ts
|
|
3211
3501
|
/**
|
|
3212
|
-
*
|
|
3213
|
-
*
|
|
3214
|
-
*
|
|
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
|
-
*
|
|
3217
|
-
*
|
|
3218
|
-
*
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
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
|
|
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
|
-
|
|
3232
|
-
const
|
|
3233
|
-
|
|
3234
|
-
const
|
|
3235
|
-
const
|
|
3236
|
-
/**
|
|
3237
|
-
const
|
|
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
|
-
*
|
|
3240
|
-
*
|
|
3241
|
-
*
|
|
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
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
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
|
-
*
|
|
3264
|
-
*
|
|
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
|
|
3269
|
-
return
|
|
3270
|
-
|
|
3271
|
-
|
|
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
|
-
*
|
|
3276
|
-
*
|
|
3277
|
-
*
|
|
3278
|
-
* @
|
|
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
|
|
3281
|
-
|
|
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
|
-
/**
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
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
|
-
*
|
|
3292
|
-
*
|
|
3293
|
-
*
|
|
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
|
|
3296
|
-
const
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
return
|
|
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
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
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
|
-
/**
|
|
3309
|
-
function
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
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
|
-
*
|
|
3319
|
-
*
|
|
3320
|
-
*
|
|
3321
|
-
*
|
|
3322
|
-
*
|
|
3323
|
-
*
|
|
3324
|
-
*
|
|
3325
|
-
* @param
|
|
3326
|
-
* @
|
|
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
|
|
3330
|
-
const
|
|
3331
|
-
|
|
3332
|
-
|
|
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: `${
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 (
|
|
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 && !
|
|
7253
|
+
if (!apiKey && effectiveEnv && !allowedApiKeyEnvs(service.getConfig()).has(effectiveEnv)) {
|
|
4830
7254
|
sendJson(res, 400, {
|
|
4831
7255
|
ok: false,
|
|
4832
|
-
error:
|
|
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 && !
|
|
7343
|
+
if (!apiKey && effectiveEnv && !allowedApiKeyEnvs(service.getConfig()).has(effectiveEnv)) {
|
|
4920
7344
|
sendJson(res, 400, {
|
|
4921
7345
|
ok: false,
|
|
4922
|
-
error:
|
|
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
|
}
|