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