dsh-advisor-group 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import Schema from "@deepseek-ai/schemastery";
2
2
  import { randomUUID } from "node:crypto";
3
- import { mkdirSync, promises, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { appendFileSync, mkdirSync, promises, readFileSync, readdirSync, 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";
@@ -221,7 +221,7 @@ function formatTranscript(entries) {
221
221
  * report `truncated` so the caller can surface it to the user instead of
222
222
  * treating the reply as complete. A caller-initiated abort still propagates.
223
223
  */
224
- async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutMs) {
224
+ async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutMs, sessionId) {
225
225
  const userText = formatTranscript(transcript);
226
226
  const messages = [createUserMessage({
227
227
  content: [{
@@ -240,6 +240,7 @@ async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutM
240
240
  ...reasoningEffort === void 0 ? {} : { reasoningEffort },
241
241
  temperature: advisor.temperature,
242
242
  maxTokens: advisor.maxTokens ?? 16384,
243
+ ...sessionId === void 0 ? {} : { sessionId },
243
244
  signal: pair.signal
244
245
  };
245
246
  let text = "";
@@ -1242,12 +1243,14 @@ const DRIVER_SYSTEM_PROMPT = `
1242
1243
  你是顾问群的主持人(驱动模型)。你的职责:
1243
1244
  1. 阅读项目背景、主模型问题与全部顾问回答;
1244
1245
  2. 提出一个更深入、聚焦的追问,推动讨论走向更细的根因、分歧或行动方案;
1245
- 3. 只输出追问本身(一句话,不超过 80 字),不要回答、不要打招呼、不要解释。
1246
+ 3. 只输出追问本身(一句话,不超过 80 字),不要回答、不要打招呼、不要解释;
1247
+ 4. 同一位顾问在多轮里多次发言,是**同一个人的多轮观点**,不要当成多位顾问。
1246
1248
  `;
1247
1249
  const CONCLUSION_SYSTEM_PROMPT = `
1248
1250
  你是顾问群的主持人(驱动模型)。请综合全部讨论,输出最终结论:
1249
1251
  - 用 3 段以内:共识、关键分歧(简要列出)、最可靠的行动建议;
1250
- - 不要复述每个顾问的完整回答,只提炼差异与要点。
1252
+ - 不要复述每个顾问的完整回答,只提炼差异与要点;
1253
+ - 提到顾问时按**顾问人数**说:同一位顾问多轮发言仍是一位顾问,不要把发言条数数成人数。
1251
1254
  `;
1252
1255
  /** Resolve the current agent's model config from the session header, or the config fallback. */
1253
1256
  function resolveDriverSource(sessionLog, driverModel) {
@@ -1263,9 +1266,20 @@ function resolveDriverSource(sessionLog, driverModel) {
1263
1266
  model: driverModel.model
1264
1267
  };
1265
1268
  }
1269
+ /**
1270
+ * Tell the driver how many advisors actually took part. One advisor can appear
1271
+ * in several rounds, and without this the model counts turns as people (it once
1272
+ * wrote 「三位顾问一致认定」 when a single advisor had answered three times).
1273
+ */
1274
+ function advisorRoster(session) {
1275
+ const names = [...new Set(session.messages.filter((message) => message.role === "advisor").map((message) => message.advisorName ?? message.advisorId ?? "顾问"))];
1276
+ if (names.length === 0) return "";
1277
+ return `\n\n本次讨论共有 ${names.length} 位顾问(${names.join("、")})。同一位顾问可能在多轮中重复发言——提到顾问时按**顾问人数**说,不要把同一位顾问的多轮发言数成多位顾问。`;
1278
+ }
1266
1279
  function transcriptOf(session) {
1267
1280
  return session.messages.filter((message) => message.role !== "system").map((message) => {
1268
- return `[${message.role === "main" ? "主模型" : message.advisorName ?? message.advisorId ?? "顾问"}]\n${message.content}`;
1281
+ if (message.role === "main") return `[主模型]\n${message.content}`;
1282
+ return `[${message.advisorName ?? message.advisorId ?? "顾问"}${typeof message.round === "number" ? ` · 第 ${message.round} 轮` : ""}]\n${message.content}`;
1269
1283
  }).join("\n\n").slice(-24e3);
1270
1284
  }
1271
1285
  async function generateWith(ctx, source, sessionId, system, prompt, signal, timeoutMs = 6e5) {
@@ -1283,6 +1297,7 @@ async function generateWith(ctx, source, sessionId, system, prompt, signal, time
1283
1297
  })],
1284
1298
  temperature: .7,
1285
1299
  maxTokens: 2048,
1300
+ sessionId,
1286
1301
  signal: withTimeout(timeoutMs, signal)
1287
1302
  };
1288
1303
  let text = "";
@@ -1308,12 +1323,12 @@ async function generateWith(ctx, source, sessionId, system, prompt, signal, time
1308
1323
  /** One deepening question for the next round (never throws; returns fallback text). */
1309
1324
  async function generateDeepenQuestion(ctx, session, source, signal, timeoutMs) {
1310
1325
  if (!source) return FALLBACK_DEEPEN_QUESTION;
1311
- return await generateWith(ctx, source, session.id, DRIVER_SYSTEM_PROMPT, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal, timeoutMs) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
1326
+ return await generateWith(ctx, source, session.dshSessionId ?? session.id, `${DRIVER_SYSTEM_PROMPT}${advisorRoster(session)}`, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal, timeoutMs) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
1312
1327
  }
1313
1328
  /** Final synthesis after all rounds (never throws; returns fallback text). */
1314
1329
  async function generateConclusion(ctx, session, source, signal, timeoutMs) {
1315
1330
  if (!source) return FALLBACK_CONCLUSION;
1316
- return await generateWith(ctx, source, session.id, CONCLUSION_SYSTEM_PROMPT, `以下是顾问群全部 ${session.messages.filter((m) => m.role === "advisor").length} 条顾问回答的讨论记录:\n\n${transcriptOf(session)}\n\n请给出综合结论。`, signal, timeoutMs) || "(⚠ 驱动模型未能生成综合结论:请主模型以上述顾问讨论为基础,自行总结共识、分歧与最可靠的行动建议。)";
1331
+ 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
1332
  }
1318
1333
  //#endregion
1319
1334
  //#region src/risk-notes.ts
@@ -1439,6 +1454,61 @@ function appendAdvisorEnd(log, session, summary) {
1439
1454
  };
1440
1455
  log.append("advisor-group/end", data);
1441
1456
  }
1457
+ /**
1458
+ * Open the cross-session ask card (waiting state). Reuses the registered
1459
+ * `advisor-group/start` event with `kind:'ask'` so no new event type needs
1460
+ * persistence registration; the client branches on `kind` for the ask layout.
1461
+ */
1462
+ function appendAskStart(log, info) {
1463
+ const { turn, step } = latestTurnStep(log);
1464
+ log.append("advisor-group/start", {
1465
+ sessionId: info.askId,
1466
+ turn,
1467
+ step,
1468
+ kind: "ask",
1469
+ question: info.question,
1470
+ ...info.context === void 0 ? {} : { context: info.context },
1471
+ advisors: [{
1472
+ id: "ask-sender",
1473
+ name: info.senderName
1474
+ }, {
1475
+ id: "ask-target",
1476
+ name: info.targetName
1477
+ }]
1478
+ });
1479
+ }
1480
+ /**
1481
+ * Close the cross-session ask card: the target's answer as an advisor-role
1482
+ * bubble, then the end event whose `summary.conclusion` is the note line.
1483
+ * Message is appended BEFORE end so the assembled state has the answer in
1484
+ * place when the status flips to completed.
1485
+ */
1486
+ function appendAskAnswer(log, info) {
1487
+ const { turn, step } = latestTurnStep(log);
1488
+ log.append("advisor-group/message", {
1489
+ sessionId: info.askId,
1490
+ turn,
1491
+ step,
1492
+ role: "advisor",
1493
+ advisorId: "ask-target",
1494
+ advisorName: info.targetName,
1495
+ content: info.answer.trim() ? info.answer : "(未收到回复,详见下方投递说明)"
1496
+ });
1497
+ log.append("advisor-group/end", {
1498
+ sessionId: info.askId,
1499
+ turn,
1500
+ step,
1501
+ summary: {
1502
+ question: info.question,
1503
+ advisors: [{
1504
+ advisorId: "ask-target",
1505
+ advisorName: info.targetName,
1506
+ corePoints: []
1507
+ }],
1508
+ conclusion: info.note
1509
+ }
1510
+ });
1511
+ }
1442
1512
  //#endregion
1443
1513
  //#region src/stream-channel.ts
1444
1514
  const BOOT_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -2449,6 +2519,7 @@ var AdvisorGroupService = class {
2449
2519
  round,
2450
2520
  ts: Date.now()
2451
2521
  };
2522
+ const routingSessionId = session.dshSessionId !== void 0 && isSafeDshSessionId(session.dshSessionId) ? session.dshSessionId : session.id;
2452
2523
  try {
2453
2524
  let content;
2454
2525
  let isDshLlmProvider = false;
@@ -2571,7 +2642,7 @@ var AdvisorGroupService = class {
2571
2642
  truncated = streamResult.truncated;
2572
2643
  }
2573
2644
  } else {
2574
- const result = await callViaCtxLlm(this.ctx, relational, transcript, signal, emitDelta, advisorTimeoutMs);
2645
+ const result = await callViaCtxLlm(this.ctx, relational, transcript, signal, emitDelta, advisorTimeoutMs, routingSessionId);
2575
2646
  flush();
2576
2647
  content = result.content;
2577
2648
  truncated = result.truncated;
@@ -3136,265 +3207,1140 @@ async function classifyWithJev(question, context, advisors, config, signal, ques
3136
3207
  }
3137
3208
  }
3138
3209
  //#endregion
3139
- //#region src/tools.ts
3140
- function registerAdvisorTools(service) {
3141
- return [defineTool({
3142
- name: "ask_advisors",
3143
- description: "Consult configured expert advisor models when the question is professional, long-tail world knowledge, high-risk, or the main model is uncertain. ALWAYS provide context with (a) a brief overview of the current project and (b) concrete details of the problem, including what has been tried and what went wrong. Also call immediately when the user @顾问群 or repeats the same question three times without resolution. Do NOT use for simple facts that web search can answer. Starting a new consultation runs the auto-deepen pipeline: up to maxRounds rounds of [driver deep-question → advisor A → advisor B (sees A) → advisor C (sees A+B) → …], closed by a driver-generated conclusion.",
3144
- parameters: {
3145
- question: {
3146
- type: "string",
3147
- description: "The question to ask the advisors. Required when starting a new consultation."
3148
- },
3149
- context: {
3150
- type: "string",
3151
- description: "Required background: (a) current project overview (domain/goal/stack/constraints) and (b) problem details (steps tried, expected vs actual, errors). Write \"该项背景未知\" for anything unknown; never leave it empty."
3152
- },
3153
- sessionId: {
3154
- type: "string",
3155
- description: "Existing consultation session id to continue."
3156
- },
3157
- followUp: {
3158
- type: "string",
3159
- description: "Follow-up question to send to the advisors in an existing session."
3160
- },
3161
- advisorIds: {
3162
- type: "array",
3163
- items: { type: "string" },
3164
- description: "Optional advisor ids to include; defaults to all configured advisors (capped by maxAdvisorsPerCall)."
3165
- },
3166
- confidence: {
3167
- type: "number",
3168
- description: "Optional self-assessed confidence 0-1. Values below the configured threshold escalate even without domain keywords."
3169
- },
3170
- questionEn: {
3171
- type: "string",
3172
- description: "Optional self-contained English gist of the question plus key background. Used as the Jev pre-classification input only when the「Jev 英文判定」setting is enabled; the displayed question, session records, and duplicate detection always keep using question/context in Chinese. Omit it otherwise."
3173
- }
3174
- },
3175
- output: {
3176
- schema: {
3177
- type: "object",
3178
- additionalProperties: false,
3179
- properties: {
3180
- sessionId: {
3181
- type: "string",
3182
- required: true
3183
- },
3184
- advice: {
3185
- type: "string",
3186
- required: true
3187
- },
3188
- skipped: { type: "boolean" },
3189
- reason: { type: "string" }
3210
+ //#region src/cross-session.ts
3211
+ /**
3212
+ * Cross-session ask (v0): let the current session deliver one question to
3213
+ * another existing ordinary session, wake it, and report what that session
3214
+ * said between the delivery receipt and its next whole-session idle.
3215
+ *
3216
+ * Facts this module is built on (DSH 0.2.0-rc.1/rc.2 types, verified):
3217
+ * - `ctx.sessionController.resolveAgent(sessionId)` yields the live Agent
3218
+ * (`{ agent }` or `{ error }`), resuming a cold session.
3219
+ * - `agent.followup(createUserMessage({ content, source }))` queues one
3220
+ * ordinary follow-up turn and wakes the driver.
3221
+ * - `ctx.sessionController.prompt({ sessionId, content, mode:'queue', requestId })`
3222
+ * is the cheap fallback, but the Host hardcodes `source` to a user prompt, so
3223
+ * that path is identity-dishonest and must be reported as such.
3224
+ * - There is NO API that returns "the answer to this message". The Host only
3225
+ * guarantees delivery, so this module reports an INTERVAL, never a reply.
3226
+ * - The Host enforces no caller identity, no cycle guard, and hard-excludes
3227
+ * subagent sessions, so every guard in `ask_session` lives here.
3228
+ *
3229
+ * The pure helpers below never touch `ctx`; only `askSession()` does I/O.
3230
+ */
3231
+ /** Maximum combined UTF-8 bytes of question + context (design §六.5). */
3232
+ const ASK_CONTENT_MAX_BYTES = 32e3;
3233
+ /** Default and maximum wait for the target's next idle (design §十). */
3234
+ const ASK_WAIT_DEFAULT_MS = 18e4;
3235
+ const ASK_WAIT_MAX_MS = 6e5;
3236
+ /** Poll interval while waiting for the target to reach whole-session idle. */
3237
+ const ASK_POLL_INTERVAL_MS = 1e3;
3238
+ /**
3239
+ * Envelope the receiving model reads. The Host offers no registration point for
3240
+ * this framing (the official Agent Teams build their own too), so the identity
3241
+ * is carried in the content itself: a header block naming the asking session,
3242
+ * then the question, then optional background.
3243
+ * @param senderName - title (or cwd+time fallback) of the asking session.
3244
+ * @param question - the question to hand over.
3245
+ * @param context - optional background; permanently recorded in the target log.
3246
+ * @returns content blocks, envelope first.
3247
+ */
3248
+ function buildAskEnvelope(senderName, question, context) {
3249
+ const blocks = [{
3250
+ type: "text",
3251
+ text: `【来自会话「${senderName}」的提问】`
3252
+ }, {
3253
+ type: "text",
3254
+ text: question
3255
+ }];
3256
+ if (context !== void 0 && context.trim().length > 0) blocks.push({
3257
+ type: "text",
3258
+ text: `【对方补充的背景】\n${context}`
3259
+ });
3260
+ return blocks;
3261
+ }
3262
+ /**
3263
+ * Build the identified user message handed to `agent.followup()`.
3264
+ * @param envelope - content blocks produced by {@link buildAskEnvelope}.
3265
+ * @param source - producer-declared session-ask source.
3266
+ * @returns an immutable identified user message carrying our source kind.
3267
+ */
3268
+ function buildAskMessage(envelope, source) {
3269
+ return createUserMessage({
3270
+ content: envelope,
3271
+ source
3272
+ });
3273
+ }
3274
+ /**
3275
+ * UTF-8 byte length of the text a caller wants to hand over.
3276
+ * @param question - the question text.
3277
+ * @param context - optional background text.
3278
+ * @returns byte length of question + context.
3279
+ */
3280
+ function askContentBytes(question, context) {
3281
+ return Buffer.byteLength(question, "utf8") + (context === void 0 ? 0 : Buffer.byteLength(context, "utf8"));
3282
+ }
3283
+ /** Deterministic fallback label: working directory plus most recent activity. */
3284
+ function fallbackSessionName(cwd, updatedAt) {
3285
+ const where = cwd === void 0 || cwd.length === 0 ? "(未知工作目录)" : cwd;
3286
+ if (updatedAt === void 0 || !Number.isFinite(updatedAt)) return where;
3287
+ const at = new Date(updatedAt);
3288
+ return `${where} · ${Number.isNaN(at.getTime()) ? String(updatedAt) : at.toISOString()}`;
3289
+ }
3290
+ /**
3291
+ * Model-visible name of one listed session. The title lives in the `title`
3292
+ * projection (a snapshot object), is optional, and may be a stale cache value,
3293
+ * so every failure degrades to the cwd+time fallback instead of throwing.
3294
+ */
3295
+ function sessionDisplayName(summary) {
3296
+ const raw = summary.projections?.values?.title;
3297
+ const title = raw !== null && typeof raw === "object" && typeof raw.title === "string" ? raw.title.trim() : "";
3298
+ if (title.length > 0) return title;
3299
+ return fallbackSessionName(summary.cwd, summary.updatedAt);
3300
+ }
3301
+ function candidateOf(summary) {
3302
+ return {
3303
+ sessionId: summary.sessionId,
3304
+ name: sessionDisplayName(summary),
3305
+ hint: fallbackSessionName(summary.cwd, summary.updatedAt)
3306
+ };
3307
+ }
3308
+ /** Most-recently-active first, running sessions ahead of idle ones. */
3309
+ function orderCandidates(rows) {
3310
+ return [...rows].sort((a, b) => {
3311
+ if (a.running !== b.running) return a.running ? -1 : 1;
3312
+ return b.updatedAt - a.updatedAt;
3313
+ });
3314
+ }
3315
+ /** Partial candidates are capped: a broad needle must stay a readable list. */
3316
+ const PARTIAL_CANDIDATE_LIMIT = 10;
3317
+ /**
3318
+ * Resolve a user/model-supplied target reference against the session list.
3319
+ * Ladder, in order: exact sessionId → exact display name → partial natural
3320
+ * name (substring of the display name OR of the workspace cwd, case-insensitive)
3321
+ * → missing. One hit resolves outright; several hits become candidates —
3322
+ * this function NEVER guesses. Partial candidates are ordered most-recently-
3323
+ * active first (running sessions first) and capped, so "ask the 极简遥控器
3324
+ * session" works without anyone digging session ids out of log directories.
3325
+ * @param sessions - rows from `sessionController.list()`.
3326
+ * @param reference - a session id, display name, or natural partial name.
3327
+ * @returns the unique target, the candidate list, or "missing".
3328
+ */
3329
+ function matchTargetSession(sessions, reference) {
3330
+ const needle = reference.trim();
3331
+ if (needle.length === 0) return { kind: "missing" };
3332
+ const byId = sessions.find((session) => session.sessionId === needle);
3333
+ if (byId) return {
3334
+ kind: "unique",
3335
+ target: {
3336
+ sessionId: byId.sessionId,
3337
+ name: sessionDisplayName(byId)
3338
+ }
3339
+ };
3340
+ const folded = needle.toLowerCase();
3341
+ const exact = sessions.filter((session) => sessionDisplayName(session).toLowerCase() === folded);
3342
+ if (exact.length === 1) return {
3343
+ kind: "unique",
3344
+ target: {
3345
+ sessionId: exact[0].sessionId,
3346
+ name: sessionDisplayName(exact[0])
3347
+ }
3348
+ };
3349
+ if (exact.length > 1) return {
3350
+ kind: "ambiguous",
3351
+ candidates: orderCandidates(exact).map(candidateOf)
3352
+ };
3353
+ const partial = sessions.filter((session) => sessionDisplayName(session).toLowerCase().includes(folded) || session.cwd !== void 0 && session.cwd.toLowerCase().includes(folded));
3354
+ if (partial.length === 1) return {
3355
+ kind: "unique",
3356
+ target: {
3357
+ sessionId: partial[0].sessionId,
3358
+ name: sessionDisplayName(partial[0])
3359
+ }
3360
+ };
3361
+ if (partial.length === 0) return { kind: "missing" };
3362
+ return {
3363
+ kind: "ambiguous",
3364
+ candidates: orderCandidates(partial).slice(0, PARTIAL_CANDIDATE_LIMIT).map(candidateOf)
3365
+ };
3366
+ }
3367
+ /**
3368
+ * Hop guard counter: the hop of the question this session is about to send.
3369
+ * The value is carried by the asking message that reached this session, so it
3370
+ * is read from the most recent inbound `user/message` whose source is
3371
+ * `session-ask`; a later plain human prompt does not reset it, because the
3372
+ * question being asked here still descends from that relay. No session-ask
3373
+ * source anywhere means this is a fresh, top-level question (hop 0).
3374
+ * @param events - this session's log events in order.
3375
+ * @returns the hop to stamp on the outgoing message.
3376
+ */
3377
+ function nextAskHop(events) {
3378
+ for (let i = events.length - 1; i >= 0; i -= 1) {
3379
+ const event = events[i];
3380
+ if (event.type !== "user/message") continue;
3381
+ const source = event.data.message?.source;
3382
+ if (source?.kind !== "session-ask") continue;
3383
+ return typeof source.hop === "number" && Number.isFinite(source.hop) ? source.hop + 1 : 1;
3384
+ }
3385
+ return 0;
3386
+ }
3387
+ /**
3388
+ * Collect what the target session said inside the interval. Only
3389
+ * `assistant/message` events strictly after the delivery watermark count, so a
3390
+ * reply that predates the delivery cannot be reported as an answer. The
3391
+ * watermark (highest seq already present at delivery) is the authoritative cut;
3392
+ * `fromMs` is kept only as the recorded interval start.
3393
+ * @param events - the target session's log events in order.
3394
+ * @param fromSeq - highest event seq already present at delivery time.
3395
+ * @returns the newest event time seen and the per-message text pieces.
3396
+ */
3397
+ function collectAnswer(events, fromSeq) {
3398
+ const pieces = [];
3399
+ let latestEventTime = null;
3400
+ for (const event of events) {
3401
+ if (Number.isFinite(event.time) && (latestEventTime === null || event.time > latestEventTime)) latestEventTime = event.time;
3402
+ if (event.type !== "assistant/message") continue;
3403
+ if (!(event.seq > fromSeq)) continue;
3404
+ const text = (event.data?.message?.content ?? []).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("").trim();
3405
+ if (text.length > 0) pieces.push(text);
3406
+ }
3407
+ return {
3408
+ latestEventTime,
3409
+ pieces
3410
+ };
3411
+ }
3412
+ /** Join the interval's text pieces into the returned answer body. */
3413
+ function joinAnswerPieces(pieces) {
3414
+ return pieces.join("\n\n");
3415
+ }
3416
+ /** Highest event seq present, or -1 for an empty log (the session-cursor convention). */
3417
+ function logWatermark(events) {
3418
+ let highest = -1;
3419
+ for (const event of events) if (typeof event.seq === "number" && event.seq > highest) highest = event.seq;
3420
+ return highest;
3421
+ }
3422
+ /** Human-readable text the main model reads for one settled ask_session call. */
3423
+ function renderAskResult(value) {
3424
+ return [{
3425
+ type: "text",
3426
+ text: `${value.answer.trim().length > 0 ? value.answer : "(该区间内对方没有产出正文。)"}\n\n${value.note}`
3427
+ }];
3428
+ }
3429
+ /**
3430
+ * The Host hard-excludes subagent sessions with `session/agent-busy` and the
3431
+ * reason "use subagent delivery for this child session"; recognise it so the
3432
+ * caller gets the actionable `subagent-target` code instead of `delivery-failed`.
3433
+ */
3434
+ function isSubagentDeliveryRejection(error) {
3435
+ if (error === null || typeof error !== "object") return false;
3436
+ const shape = error;
3437
+ const reason = shape.details !== null && typeof shape.details === "object" ? shape.details.reason : void 0;
3438
+ const haystack = `${typeof reason === "string" ? reason : ""} ${typeof shape.message === "string" ? shape.message : ""}`;
3439
+ return haystack.includes("subagent delivery") || haystack.includes("owned by subagent routing");
3440
+ }
3441
+ /** One unsettled wait per session: at most one ask_session may be in flight. */
3442
+ const pendingWaits = /* @__PURE__ */ new Set();
3443
+ /** Whether this session already has an unsettled ask_session wait. */
3444
+ function isWaiting(sessionId) {
3445
+ return pendingWaits.has(sessionId);
3446
+ }
3447
+ let askCounter = 0;
3448
+ /** Process-unique ask id (the receiver side may dedupe on it). */
3449
+ function nextAskId() {
3450
+ askCounter += 1;
3451
+ return `ask-${Date.now().toString(36)}-${askCounter}-${Math.random().toString(36).slice(2, 8)}`;
3452
+ }
3453
+ /**
3454
+ * Lazy host-service access: never a hard `inject` (older hosts lack this service).
3455
+ *
3456
+ * A bare `ctx.sessionController` read is gated by the plugin's `inject` list and
3457
+ * throws `cannot get property "sessionController" without inject` when the key is
3458
+ * not declared — so the property read itself must not be used. `ctx.get(name)` is
3459
+ * Cordis's documented read "without the inject requirement": it returns the service
3460
+ * or `undefined` when (not yet) provided.
3461
+ */
3462
+ function getSessionController(ctx) {
3463
+ if (ctx === null || ctx === void 0) return void 0;
3464
+ try {
3465
+ const context = ctx;
3466
+ const controller = typeof context.get === "function" ? context.get.call(ctx, "sessionController") : context.sessionController;
3467
+ if (controller === null || typeof controller !== "object") return void 0;
3468
+ const candidate = controller;
3469
+ if (typeof candidate.list !== "function" || typeof candidate.resolveAgent !== "function") return void 0;
3470
+ return candidate;
3471
+ } catch {
3472
+ return;
3473
+ }
3474
+ }
3475
+ /** Read a live Agent's own log through a defensive view (the log API is read-only here). */
3476
+ function agentEvents(agent) {
3477
+ try {
3478
+ return agent.session?.snapshotEvents() ?? [];
3479
+ } catch {
3480
+ return [];
3481
+ }
3482
+ }
3483
+ async function sleep(ms, signal) {
3484
+ if (signal.aborted) return;
3485
+ await new Promise((resolve) => {
3486
+ const timer = setTimeout(resolve, ms);
3487
+ signal.addEventListener("abort", () => {
3488
+ clearTimeout(timer);
3489
+ resolve();
3490
+ }, { once: true });
3491
+ });
3492
+ }
3493
+ /**
3494
+ * Hand the question to the target session.
3495
+ *
3496
+ * Preferred path: `resolveAgent` + `agent.followup(...)`, which preserves our
3497
+ * own source kind. Fallback: `sessionController.prompt(...)`, which the Host
3498
+ * stamps as a user prompt — the envelope still names us, and the caller is told
3499
+ * in `note` that this identity-dishonest path was used.
3500
+ */
3501
+ async function deliverAsk(controller, signal, input) {
3502
+ const envelope = buildAskEnvelope(input.source.senderName, input.question, input.context);
3503
+ try {
3504
+ const resolved = await controller.resolveAgent(input.target.sessionId);
3505
+ if ("agent" in resolved && resolved.agent) {
3506
+ const agent = resolved.agent;
3507
+ agent.followup(buildAskMessage(envelope, input.source));
3508
+ return {
3509
+ ok: true,
3510
+ receipt: {
3511
+ delivered: "native",
3512
+ note: "本次投递走了首选通道(自定义来源),对方模型能看到这条消息来自另一个会话。",
3513
+ fromSeq: logWatermark(agentEvents(agent)),
3514
+ fromMs: Date.now(),
3515
+ targetWasRunning: input.targetWasRunning
3190
3516
  }
3191
- },
3192
- render: (_args, value) => [{
3193
- type: "text",
3194
- text: value.advice
3195
- }]
3196
- },
3197
- async execute(args, exec) {
3198
- const cfg = service.getConfig();
3199
- if (args.confidence !== void 0 && (args.confidence < 0 || args.confidence > 1)) return {
3200
- sessionId: "",
3201
- advice: "confidence 参数必须在 0-1 之间。",
3202
- skipped: true,
3203
- reason: "invalid-confidence"
3204
3517
  };
3205
- if (!service.isEnabled()) return {
3206
- sessionId: "",
3207
- advice: "顾问群插件已禁用。请先调用 toggle_advisor_group 启用。",
3208
- skipped: true,
3209
- reason: "disabled"
3518
+ }
3519
+ const reason = "error" in resolved ? resolved.error : void 0;
3520
+ if (isSubagentDeliveryRejection(reason)) return {
3521
+ ok: false,
3522
+ code: "subagent-target",
3523
+ message: `目标会话 ${input.target.sessionId} 属于子代理(subagent)会话,跨会话投递被宿主硬性排除。请改用子代理通道(subagent / send_message)。`
3524
+ };
3525
+ return deliverViaPrompt(controller, signal, envelope, input, `首选通道不可用(${describeHostError(reason)})`);
3526
+ } catch (error) {
3527
+ if (signal.aborted) throw error;
3528
+ if (isSubagentDeliveryRejection(error)) return {
3529
+ ok: false,
3530
+ code: "subagent-target",
3531
+ message: `目标会话 ${input.target.sessionId} 属于子代理(subagent)会话,跨会话投递被宿主硬性排除。请改用子代理通道(subagent / send_message)。`
3532
+ };
3533
+ if (isTargetNotFound(error)) return {
3534
+ ok: false,
3535
+ code: "target-not-found",
3536
+ message: `找不到会话 ${input.target.sessionId}(宿主报告 session/not-found)。`
3537
+ };
3538
+ return deliverViaPrompt(controller, signal, envelope, input, `首选通道报错(${describeHostError(error)})`);
3539
+ }
3540
+ }
3541
+ async function deliverViaPrompt(controller, _signal, envelope, input, why) {
3542
+ try {
3543
+ await controller.prompt({
3544
+ requestId: input.source.askId,
3545
+ sessionId: input.target.sessionId,
3546
+ mode: "queue",
3547
+ content: envelope
3548
+ });
3549
+ const floor = await watermarkViaResolve(controller, input.target.sessionId);
3550
+ return {
3551
+ ok: true,
3552
+ receipt: {
3553
+ delivered: "fallback",
3554
+ note: `本次投递走了降级通道(宿主的 prompt 接口),宿主把来源写死成「用户输入」,对方模型会以为这是用户打的字;身份只能靠正文里的信封说明。原因:${why}`,
3555
+ fromSeq: floor,
3556
+ fromMs: Date.now(),
3557
+ targetWasRunning: input.targetWasRunning
3558
+ }
3559
+ };
3560
+ } catch (error) {
3561
+ if (isSubagentDeliveryRejection(error)) return {
3562
+ ok: false,
3563
+ code: "subagent-target",
3564
+ message: `目标会话 ${input.target.sessionId} 属于子代理(subagent)会话,跨会话投递被宿主硬性排除。请改用子代理通道(subagent / send_message)。`
3565
+ };
3566
+ if (isTargetNotFound(error)) return {
3567
+ ok: false,
3568
+ code: "target-not-found",
3569
+ message: `找不到会话 ${input.target.sessionId}(宿主报告 session/not-found)。`
3570
+ };
3571
+ return {
3572
+ ok: false,
3573
+ code: "delivery-failed",
3574
+ message: `投递失败:首选通道与降级通道都未能把问题送进目标会话。${why} 降级通道报错:${describeHostError(error)}`
3575
+ };
3576
+ }
3577
+ }
3578
+ async function watermarkViaResolve(controller, sessionId) {
3579
+ try {
3580
+ const resolved = await controller.resolveAgent(sessionId);
3581
+ if ("agent" in resolved && resolved.agent) return logWatermark(agentEvents(resolved.agent));
3582
+ } catch {}
3583
+ return -1;
3584
+ }
3585
+ function isTargetNotFound(error) {
3586
+ if (error === null || typeof error !== "object") return false;
3587
+ return error.code === "session/not-found";
3588
+ }
3589
+ function describeHostError(error) {
3590
+ if (error === null || error === void 0) return "未提供原因";
3591
+ if (typeof error === "string") return error;
3592
+ if (typeof error === "object") {
3593
+ const shape = error;
3594
+ const code = typeof shape.code === "string" ? shape.code : "";
3595
+ const message = typeof shape.message === "string" ? shape.message : "";
3596
+ if (code.length > 0 || message.length > 0) return `${code}${code && message ? " " : ""}${message}`;
3597
+ try {
3598
+ return JSON.stringify(error);
3599
+ } catch {
3600
+ return "宿主返回了无法序列化的错误";
3601
+ }
3602
+ }
3603
+ return String(error);
3604
+ }
3605
+ /**
3606
+ * Wait for the target session's next whole-session idle after delivery.
3607
+ * Never cancels the target turn — a timeout stops only this session's waiting.
3608
+ */
3609
+ async function waitForTargetIdle(controller, signal, input) {
3610
+ const startedAt = Date.now();
3611
+ let targetRunning = input.targetWasRunning;
3612
+ let observedBusy = input.targetWasRunning;
3613
+ const watermark = async () => {
3614
+ try {
3615
+ const resolved = await controller.resolveAgent(input.sessionId);
3616
+ if (!("agent" in resolved) || !resolved.agent) return void 0;
3617
+ return logWatermark(agentEvents(resolved.agent));
3618
+ } catch {
3619
+ return;
3620
+ }
3621
+ };
3622
+ while (true) {
3623
+ if (signal.aborted) return {
3624
+ endedBecause: "error",
3625
+ lastRunning: targetRunning,
3626
+ note: "本次调用被取消,等待中止;没有取消对方的回合,对方可能仍在作答。"
3627
+ };
3628
+ const elapsed = Date.now() - startedAt;
3629
+ if (elapsed >= input.waitMs) return {
3630
+ endedBecause: "timeout",
3631
+ lastRunning: targetRunning,
3632
+ note: `等待达到上限(${input.waitMs}ms),已返回当前区间内收集到的内容;没有取消对方的回合,对方可能仍在作答。`
3633
+ };
3634
+ await sleep(Math.min(ASK_POLL_INTERVAL_MS, Math.max(0, input.waitMs - elapsed)), signal);
3635
+ let row;
3636
+ try {
3637
+ row = (await controller.list({}, signal)).items.find((item) => item.sessionId === input.sessionId);
3638
+ } catch (error) {
3639
+ if (signal.aborted) return {
3640
+ endedBecause: "error",
3641
+ lastRunning: targetRunning,
3642
+ note: "本次调用被取消,等待中止;没有取消对方的回合。"
3210
3643
  };
3211
- if (cfg.advisors.length === 0) return {
3212
- sessionId: "",
3213
- advice: "顾问群尚未配置任何顾问。请先到 设置 → 插件 → 顾问群 添加顾问。",
3214
- skipped: true,
3215
- reason: "no-advisors"
3644
+ return {
3645
+ endedBecause: "error",
3646
+ lastRunning: targetRunning,
3647
+ note: `等待期间读取会话列表失败(${describeHostError(error)}),已返回当前区间内收集到的内容。`
3216
3648
  };
3217
- const withoutModel = advisorsMissingModel(cfg);
3218
- if (withoutModel.length > 0) return {
3219
- sessionId: "",
3220
- advice: `顾问群中有 ${withoutModel.length} 位顾问未配置模型(${withoutModel.map((a) => a.name).join("、")})。请先到 设置 → 插件 → 顾问群 补全模型后再发起,避免整轮调用失败。`,
3221
- skipped: true,
3222
- reason: "advisor-model-missing"
3649
+ }
3650
+ if (!row) continue;
3651
+ if (row.running) {
3652
+ observedBusy = true;
3653
+ targetRunning = true;
3654
+ continue;
3655
+ }
3656
+ if (observedBusy) return {
3657
+ endedBecause: "idle",
3658
+ lastRunning: false,
3659
+ note: "等待结束于该会话整体空闲(idle)。"
3660
+ };
3661
+ if (input.fromSeq !== void 0) {
3662
+ const now = await watermark();
3663
+ if (now !== void 0 && now > input.fromSeq) return {
3664
+ endedBecause: "idle",
3665
+ lastRunning: false,
3666
+ note: "等待结束:该会话已追加新内容且当前不忙(对方的回合发生在两次轮询之间,忙的状态未被采到)。"
3223
3667
  };
3224
- const sessionLog = exec.agent?.session;
3225
- const questionText = typeof args.question === "string" ? args.question : "";
3226
- const contextText = typeof args.context === "string" ? args.context : "";
3227
- const mentionedAdvisorGroup = /@顾问群|@顧問群/.test(`${questionText}\n${contextText}`);
3228
- const repeatCount = service.getRepeatPressure(sessionLog?.id)?.count ?? 0;
3229
- const forcedByRepeat = repeatCount >= 3;
3230
- let shadow;
3231
- let shadowWritten = false;
3232
- const recordShadow = (launched) => {
3233
- if (!shadow || shadowWritten) return;
3234
- shadowWritten = true;
3235
- appendShadowSample({
3236
- ...shadow,
3237
- launched
3238
- });
3668
+ }
3669
+ input.log(`waiting for ${input.sessionId} to start the delivered turn`);
3670
+ targetRunning = false;
3671
+ }
3672
+ }
3673
+ /** Read the target's log back and collect what it said inside the interval. */
3674
+ async function collectTargetAnswer(controller, sessionId, fromSeq) {
3675
+ try {
3676
+ const resolved = await controller.resolveAgent(sessionId);
3677
+ if (!("agent" in resolved) || !resolved.agent) return {
3678
+ pieces: [],
3679
+ readError: describeHostError("error" in resolved ? resolved.error : void 0)
3680
+ };
3681
+ return { pieces: collectAnswer(agentEvents(resolved.agent), fromSeq).pieces };
3682
+ } catch (error) {
3683
+ return {
3684
+ pieces: [],
3685
+ readError: describeHostError(error)
3686
+ };
3687
+ }
3688
+ }
3689
+ /**
3690
+ * Every pre-delivery guard, in one place. Pure: takes plain facts, returns a
3691
+ * rejection or nothing. Order matters — identity before ownership, ownership
3692
+ * before budget, budget before hop.
3693
+ * @returns the rejection, or undefined when the ask may proceed.
3694
+ */
3695
+ function checkAskGuards(input) {
3696
+ const { guards, match } = input;
3697
+ if (match.kind === "missing") return {
3698
+ code: "target-not-found",
3699
+ message: "没有找到匹配的会话。请用会话列表里的标题或 sessionId 指定目标;不确认时先列出候选再让用户选择。"
3700
+ };
3701
+ if (match.kind === "ambiguous") return {
3702
+ code: "target-ambiguous",
3703
+ message: `有 ${match.candidates.length} 个会话都叫这个名字,不能猜。请把候选交给用户挑选(用 sessionId 指定即可精确命中)。`,
3704
+ candidates: match.candidates
3705
+ };
3706
+ const target = input.target ?? match.target;
3707
+ if (target.sessionId === guards.currentSessionId) return {
3708
+ code: "self-target",
3709
+ message: "目标是当前会话自己,拒绝自问自答。"
3710
+ };
3711
+ if (input.sessions.find((session) => session.sessionId === target.sessionId)?.origin === "subagent") return {
3712
+ code: "subagent-target",
3713
+ message: "目标会话是子代理(subagent)会话,跨会话投递被宿主硬性排除。请改用子代理通道(subagent / send_message)。"
3714
+ };
3715
+ const bytes = askContentBytes(input.question, input.context);
3716
+ if (bytes > 32e3) return {
3717
+ code: "content-too-large",
3718
+ message: `问题加背景共 ${bytes} 字节,超过上限 ${ASK_CONTENT_MAX_BYTES} 字节。请缩短问题,或把背景留在自己的会话里分次提问。`
3719
+ };
3720
+ if (guards.hop > 2) return {
3721
+ code: "hop-limit",
3722
+ message: `本次提问会形成第 ${guards.hop} 跳转发(上限 3 跳:0→2)。会话链式互问会绕成环,已拒绝;请由用户直接指定目标会话,或让当前会话自己作答。`
3723
+ };
3724
+ }
3725
+ /** Normalise and validate the model-supplied wait budget. */
3726
+ function normalizeWaitMs(value) {
3727
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return ASK_WAIT_DEFAULT_MS;
3728
+ return Math.min(Math.floor(value), ASK_WAIT_MAX_MS);
3729
+ }
3730
+ async function askSession(ctx, signal, input) {
3731
+ const controller = getSessionController(ctx);
3732
+ if (!controller) return {
3733
+ ok: false,
3734
+ error: "service-missing",
3735
+ message: "此功能需要较新的 DSH 宿主:当前宿主没有提供 sessionController 服务,无法向其他会话投递问题。"
3736
+ };
3737
+ const waitMs = normalizeWaitMs(input.waitMs);
3738
+ if (typeof input.waitMs === "number" && input.waitMs > 6e5) (input.log ?? (() => {}))(`ask_session waitMs 超出上限,已按 ${waitMs}ms 处理`);
3739
+ if (isWaiting(input.currentSessionId)) return toToolError({
3740
+ code: "already-waiting",
3741
+ message: "当前会话已经在等另一个会话的答复(同一时刻只允许等一个),请等这次返回后再发起新的提问。"
3742
+ });
3743
+ pendingWaits.add(input.currentSessionId);
3744
+ try {
3745
+ const sessions = [];
3746
+ let match = { kind: "missing" };
3747
+ try {
3748
+ const listed = await controller.list({}, signal);
3749
+ sessions.push(...listed.items);
3750
+ match = matchTargetSession(sessions, input.target ?? "");
3751
+ } catch (error) {
3752
+ return {
3753
+ ok: false,
3754
+ error: "service-missing",
3755
+ message: `读取会话列表失败:${describeHostError(error)}`
3239
3756
  };
3240
- if (args.sessionId) {
3241
- if (!args.followUp) return {
3242
- sessionId: args.sessionId,
3243
- advice: "继续追问需要提供 followUp 参数。",
3757
+ }
3758
+ return await runAsk(ctx, controller, signal, input, {
3759
+ sessions,
3760
+ match,
3761
+ log: input.log ?? (() => {})
3762
+ });
3763
+ } finally {
3764
+ pendingWaits.delete(input.currentSessionId);
3765
+ }
3766
+ }
3767
+ /**
3768
+ * The ask itself, after the target list has been read and the wait latch is
3769
+ * held by the caller.
3770
+ */
3771
+ async function runAsk(ctx, controller, signal, input, listed) {
3772
+ const waitMs = normalizeWaitMs(input.waitMs);
3773
+ const hop = nextAskHop(input.currentEvents);
3774
+ const rejectionBase = checkAskGuards({
3775
+ guards: {
3776
+ currentSessionId: input.currentSessionId,
3777
+ hop
3778
+ },
3779
+ match: listed.match,
3780
+ sessions: listed.sessions,
3781
+ question: input.question,
3782
+ context: input.context
3783
+ });
3784
+ if (rejectionBase) return toToolError(rejectionBase);
3785
+ const target = listed.match.kind === "unique" ? listed.match.target : {
3786
+ sessionId: "",
3787
+ name: ""
3788
+ };
3789
+ const targetRow = listed.sessions.find((session) => session.sessionId === target.sessionId);
3790
+ const askId = nextAskId();
3791
+ const source = {
3792
+ kind: "session-ask",
3793
+ form: "relay",
3794
+ askId,
3795
+ senderSessionId: input.currentSessionId,
3796
+ senderName: input.senderName,
3797
+ hop
3798
+ };
3799
+ const from = (/* @__PURE__ */ new Date()).toISOString();
3800
+ const delivery = await deliverAsk(controller, signal, {
3801
+ target,
3802
+ question: input.question,
3803
+ context: input.context,
3804
+ source,
3805
+ targetWasRunning: targetRow?.running === true
3806
+ });
3807
+ if (!delivery.ok) return {
3808
+ ok: false,
3809
+ error: delivery.code,
3810
+ message: delivery.message
3811
+ };
3812
+ input.onDelivered?.({
3813
+ askId,
3814
+ question: input.question,
3815
+ ...input.context === void 0 ? {} : { context: input.context },
3816
+ senderName: input.senderName,
3817
+ targetName: target.name
3818
+ });
3819
+ const outcome = await waitForTargetIdle(controller, signal, {
3820
+ sessionId: target.sessionId,
3821
+ targetWasRunning: delivery.receipt.targetWasRunning,
3822
+ waitMs,
3823
+ log: listed.log,
3824
+ fromSeq: delivery.receipt.fromSeq
3825
+ });
3826
+ const collected = await collectTargetAnswer(controller, target.sessionId, delivery.receipt.fromSeq);
3827
+ const answer = joinAnswerPieces(collected.pieces);
3828
+ const to = outcome.endedBecause === "idle" ? (/* @__PURE__ */ new Date()).toISOString() : null;
3829
+ const note = [
3830
+ "区间语义:从投递回执到对方会话下一次整体空闲,这两个时刻之间对方产出的文字都算在内——这是「这个区间里对方说的话」,不是逐条对应的回答。",
3831
+ delivery.receipt.note,
3832
+ outcome.note,
3833
+ delivery.receipt.targetWasRunning ? "投递时对方正在忙,问题已排队,会在它当前回合结束后进入新回合。" : "投递时对方不忙,宿主已唤醒它开一个新回合。",
3834
+ collected.readError === void 0 ? "" : `读取对方会话日志时出错,answer 可能不完整:${collected.readError}`
3835
+ ].filter((line) => line.length > 0).join("\n");
3836
+ input.onSettled?.({
3837
+ askId,
3838
+ question: input.question,
3839
+ targetName: target.name,
3840
+ answer,
3841
+ note
3842
+ });
3843
+ return {
3844
+ ok: true,
3845
+ target: {
3846
+ sessionId: target.sessionId,
3847
+ name: target.name
3848
+ },
3849
+ delivered: delivery.receipt.delivered,
3850
+ askId,
3851
+ interval: {
3852
+ from,
3853
+ to,
3854
+ endedBecause: outcome.endedBecause
3855
+ },
3856
+ answer,
3857
+ note
3858
+ };
3859
+ }
3860
+ function toToolError(rejection) {
3861
+ return {
3862
+ ok: false,
3863
+ error: rejection.code,
3864
+ message: rejection.message,
3865
+ ...rejection.candidates ? { candidates: rejection.candidates } : {}
3866
+ };
3867
+ }
3868
+ //#endregion
3869
+ //#region src/tools.ts
3870
+ function registerAdvisorTools(service, ctx) {
3871
+ return [
3872
+ defineTool({
3873
+ name: "ask_advisors",
3874
+ description: "Consult configured expert advisor models when the question is professional, long-tail world knowledge, high-risk, or the main model is uncertain. ALWAYS provide context with (a) a brief overview of the current project and (b) concrete details of the problem, including what has been tried and what went wrong. Also call immediately when the user @顾问群 or repeats the same question three times without resolution. Do NOT use for simple facts that web search can answer. Starting a new consultation runs the auto-deepen pipeline: up to maxRounds rounds of [driver deep-question → advisor A → advisor B (sees A) → advisor C (sees A+B) → …], closed by a driver-generated conclusion.",
3875
+ parameters: {
3876
+ question: {
3877
+ type: "string",
3878
+ description: "The question to ask the advisors. Required when starting a new consultation."
3879
+ },
3880
+ context: {
3881
+ type: "string",
3882
+ description: "Required background: (a) current project overview (domain/goal/stack/constraints) and (b) problem details (steps tried, expected vs actual, errors). Write \"该项背景未知\" for anything unknown; never leave it empty."
3883
+ },
3884
+ sessionId: {
3885
+ type: "string",
3886
+ description: "Existing consultation session id to continue."
3887
+ },
3888
+ followUp: {
3889
+ type: "string",
3890
+ description: "Follow-up question to send to the advisors in an existing session."
3891
+ },
3892
+ advisorIds: {
3893
+ type: "array",
3894
+ items: { type: "string" },
3895
+ description: "Optional advisor ids to include; defaults to all configured advisors (capped by maxAdvisorsPerCall)."
3896
+ },
3897
+ confidence: {
3898
+ type: "number",
3899
+ description: "Optional self-assessed confidence 0-1. Values below the configured threshold escalate even without domain keywords."
3900
+ },
3901
+ questionEn: {
3902
+ type: "string",
3903
+ description: "Optional self-contained English gist of the question plus key background. Used as the Jev pre-classification input only when the「Jev 英文判定」setting is enabled; the displayed question, session records, and duplicate detection always keep using question/context in Chinese. Omit it otherwise."
3904
+ }
3905
+ },
3906
+ output: {
3907
+ schema: {
3908
+ type: "object",
3909
+ additionalProperties: false,
3910
+ properties: {
3911
+ sessionId: {
3912
+ type: "string",
3913
+ required: true
3914
+ },
3915
+ advice: {
3916
+ type: "string",
3917
+ required: true
3918
+ },
3919
+ skipped: { type: "boolean" },
3920
+ reason: { type: "string" }
3921
+ }
3922
+ },
3923
+ render: (_args, value) => [{
3924
+ type: "text",
3925
+ text: value.advice
3926
+ }]
3927
+ },
3928
+ async execute(args, exec) {
3929
+ const cfg = service.getConfig();
3930
+ if (args.confidence !== void 0 && (args.confidence < 0 || args.confidence > 1)) return {
3931
+ sessionId: "",
3932
+ advice: "confidence 参数必须在 0-1 之间。",
3244
3933
  skipped: true,
3245
- reason: "missing-follow-up"
3934
+ reason: "invalid-confidence"
3246
3935
  };
3247
- const session = service.getSession(args.sessionId);
3248
- if (!session) return {
3249
- sessionId: args.sessionId,
3250
- advice: "顾问群会话不存在或已过期,请重新发起 ask_advisors。",
3936
+ if (!service.isEnabled()) return {
3937
+ sessionId: "",
3938
+ advice: "顾问群插件已禁用。请先调用 toggle_advisor_group 启用。",
3251
3939
  skipped: true,
3252
- reason: "session-not-found"
3940
+ reason: "disabled"
3253
3941
  };
3254
- if (service.isSessionRunning(session.id)) return {
3255
- sessionId: session.id,
3256
- advice: "该顾问群会话正在运行中,请等本轮结束后再追问。",
3942
+ if (cfg.advisors.length === 0) return {
3943
+ sessionId: "",
3944
+ advice: "顾问群尚未配置任何顾问。请先到 设置 → 插件 → 顾问群 添加顾问。",
3257
3945
  skipped: true,
3258
- reason: "session-running"
3946
+ reason: "no-advisors"
3259
3947
  };
3260
- if (service.hasReachedMaxRounds(session)) return {
3261
- sessionId: session.id,
3262
- advice: "本轮已是顾问群讨论的最后一轮,请综合现有顾问意见给出最终答复,不要再追问。",
3948
+ const withoutModel = advisorsMissingModel(cfg);
3949
+ if (withoutModel.length > 0) return {
3950
+ sessionId: "",
3951
+ advice: `顾问群中有 ${withoutModel.length} 位顾问未配置模型(${withoutModel.map((a) => a.name).join("、")})。请先到 设置 → 插件 → 顾问群 补全模型后再发起,避免整轮调用失败。`,
3263
3952
  skipped: true,
3264
- reason: "max-rounds-reached"
3953
+ reason: "advisor-model-missing"
3265
3954
  };
3266
- service.appendFollowUp(session.id, args.followUp, sessionLog);
3267
- const summary = await service.runOneRoundAndSummarize(session, exec.signal, sessionLog, exec.agent);
3268
- const canContinue = !service.hasReachedMaxRounds(session);
3269
- return {
3270
- sessionId: session.id,
3271
- advice: formatConversation(session) + formatRiskNotes(summary) + (canContinue ? "\n\n(如需继续,可再次传入 sessionId 和 followUp 追问。)" : "\n\n(讨论轮数已达上限,请综合顾问意见给出最终答复。)"),
3272
- skipped: false
3273
- };
3274
- }
3275
- if (!args.question) return {
3276
- sessionId: "",
3277
- advice: "发起新讨论需要提供 question 参数。",
3278
- skipped: true,
3279
- reason: "missing-question"
3280
- };
3281
- if (cfg.trigger.requireClassifier && !mentionedAdvisorGroup && !forcedByRepeat) {
3282
- let classification;
3283
- let jevResult;
3284
- let jevError;
3285
- let jevLatencyMs;
3286
- const jev = cfg.trigger.jev;
3287
- if (jev?.enabled) {
3288
- const jevStartedAt = Date.now();
3289
- try {
3290
- jevResult = await classifyWithJev(args.question, args.context, cfg.advisors, jev, exec.signal, typeof args.questionEn === "string" ? args.questionEn : void 0);
3291
- classification = jevResult;
3292
- } catch (error) {
3293
- if (exec.signal.aborted) throw error;
3294
- jevError = error instanceof Error ? error.message : String(error);
3295
- console.warn("[dsh-advisor-group] Jev 分类失败,回退到本地分类器:", sessionLog?.id ?? "no-session", jevError);
3296
- } finally {
3297
- jevLatencyMs = Date.now() - jevStartedAt;
3298
- }
3299
- }
3300
- classification ??= classifyRequest(args.question, args.context, cfg.advisors, args.confidence, cfg.trigger.confidenceThreshold);
3301
- shadow = {
3302
- ts: Date.now(),
3303
- question: args.question,
3304
- ...typeof args.confidence === "number" ? { confidence: args.confidence } : {},
3305
- shouldEscalate: classification.shouldEscalate,
3306
- reason: classification.reason,
3307
- suggestWebSearch: classification.suggestWebSearch,
3308
- provider: jevResult ? "jev" : "local",
3309
- ...jevError === void 0 ? {} : { jevError },
3310
- ...jevLatencyMs === void 0 ? {} : { jevLatencyMs },
3311
- ...repeatCount > 0 ? { repeatCount } : {},
3312
- ...jevResult?.rawAnswers ? { scores: jevResult.rawAnswers } : {},
3313
- ...jevResult?.model ? { model: jevResult.model } : {}
3955
+ const sessionLog = exec.agent?.session;
3956
+ const questionText = typeof args.question === "string" ? args.question : "";
3957
+ const contextText = typeof args.context === "string" ? args.context : "";
3958
+ const mentionedAdvisorGroup = /@顾问群|@顧問群/.test(`${questionText}\n${contextText}`);
3959
+ const repeatCount = service.getRepeatPressure(sessionLog?.id)?.count ?? 0;
3960
+ const forcedByRepeat = repeatCount >= 3;
3961
+ let shadow;
3962
+ let shadowWritten = false;
3963
+ const recordShadow = (launched) => {
3964
+ if (!shadow || shadowWritten) return;
3965
+ shadowWritten = true;
3966
+ appendShadowSample({
3967
+ ...shadow,
3968
+ launched
3969
+ });
3314
3970
  };
3315
- if (!classification.shouldEscalate) {
3316
- recordShadow(false);
3317
- if (classification.suggestWebSearch && cfg.trigger.allowWebFallback) return {
3318
- sessionId: "",
3319
- advice: `前置分类器建议不启动顾问群:${classification.reason}`,
3971
+ if (args.sessionId) {
3972
+ if (!args.followUp) return {
3973
+ sessionId: args.sessionId,
3974
+ advice: "继续追问需要提供 followUp 参数。",
3320
3975
  skipped: true,
3321
- reason: "web-search-recommended"
3976
+ reason: "missing-follow-up"
3322
3977
  };
3323
- return {
3324
- sessionId: "",
3325
- advice: `前置分类器建议不启动顾问群:${classification.reason}`,
3978
+ const session = service.getSession(args.sessionId);
3979
+ if (!session) return {
3980
+ sessionId: args.sessionId,
3981
+ advice: "顾问群会话不存在或已过期,请重新发起 ask_advisors。",
3326
3982
  skipped: true,
3327
- reason: "classifier-rejected"
3983
+ reason: "session-not-found"
3984
+ };
3985
+ if (service.isSessionRunning(session.id)) return {
3986
+ sessionId: session.id,
3987
+ advice: "该顾问群会话正在运行中,请等本轮结束后再追问。",
3988
+ skipped: true,
3989
+ reason: "session-running"
3990
+ };
3991
+ if (service.hasReachedMaxRounds(session)) return {
3992
+ sessionId: session.id,
3993
+ advice: "本轮已是顾问群讨论的最后一轮,请综合现有顾问意见给出最终答复,不要再追问。",
3994
+ skipped: true,
3995
+ reason: "max-rounds-reached"
3996
+ };
3997
+ service.appendFollowUp(session.id, args.followUp, sessionLog);
3998
+ const summary = await service.runOneRoundAndSummarize(session, exec.signal, sessionLog, exec.agent);
3999
+ const canContinue = !service.hasReachedMaxRounds(session);
4000
+ return {
4001
+ sessionId: session.id,
4002
+ advice: formatConversation(session) + formatRiskNotes(summary) + (canContinue ? "\n\n(如需继续,可再次传入 sessionId 和 followUp 追问。)" : "\n\n(讨论轮数已达上限,请综合顾问意见给出最终答复。)"),
4003
+ skipped: false
3328
4004
  };
3329
4005
  }
3330
- } else if (cfg.trigger.requireClassifier) shadow = {
3331
- ts: Date.now(),
3332
- question: args.question,
3333
- shouldEscalate: true,
3334
- reason: mentionedAdvisorGroup ? "用户点名 @顾问群,跳过前置分类器。" : `同一问题已重复 ${repeatCount} 次,强制升级跳过前置分类器。`,
3335
- suggestWebSearch: false,
3336
- provider: "bypass",
3337
- bypass: mentionedAdvisorGroup ? "mention" : "repeat",
3338
- repeatCount
3339
- };
3340
- if (args.advisorIds && args.advisorIds.length > 0) {
3341
- const configuredIds = new Set(cfg.advisors.map((advisor) => advisor.id));
3342
- if (args.advisorIds.filter((id) => configuredIds.has(id)).length === 0) {
4006
+ if (!args.question) return {
4007
+ sessionId: "",
4008
+ advice: "发起新讨论需要提供 question 参数。",
4009
+ skipped: true,
4010
+ reason: "missing-question"
4011
+ };
4012
+ if (cfg.trigger.requireClassifier && !mentionedAdvisorGroup && !forcedByRepeat) {
4013
+ let classification;
4014
+ let jevResult;
4015
+ let jevError;
4016
+ let jevLatencyMs;
4017
+ const jev = cfg.trigger.jev;
4018
+ if (jev?.enabled) {
4019
+ const jevStartedAt = Date.now();
4020
+ try {
4021
+ jevResult = await classifyWithJev(args.question, args.context, cfg.advisors, jev, exec.signal, typeof args.questionEn === "string" ? args.questionEn : void 0);
4022
+ classification = jevResult;
4023
+ } catch (error) {
4024
+ if (exec.signal.aborted) throw error;
4025
+ jevError = error instanceof Error ? error.message : String(error);
4026
+ console.warn("[dsh-advisor-group] Jev 分类失败,回退到本地分类器:", sessionLog?.id ?? "no-session", jevError);
4027
+ } finally {
4028
+ jevLatencyMs = Date.now() - jevStartedAt;
4029
+ }
4030
+ }
4031
+ classification ??= classifyRequest(args.question, args.context, cfg.advisors, args.confidence, cfg.trigger.confidenceThreshold);
4032
+ shadow = {
4033
+ ts: Date.now(),
4034
+ question: args.question,
4035
+ ...typeof args.confidence === "number" ? { confidence: args.confidence } : {},
4036
+ shouldEscalate: classification.shouldEscalate,
4037
+ reason: classification.reason,
4038
+ suggestWebSearch: classification.suggestWebSearch,
4039
+ provider: jevResult ? "jev" : "local",
4040
+ ...jevError === void 0 ? {} : { jevError },
4041
+ ...jevLatencyMs === void 0 ? {} : { jevLatencyMs },
4042
+ ...repeatCount > 0 ? { repeatCount } : {},
4043
+ ...jevResult?.rawAnswers ? { scores: jevResult.rawAnswers } : {},
4044
+ ...jevResult?.model ? { model: jevResult.model } : {}
4045
+ };
4046
+ if (!classification.shouldEscalate) {
4047
+ recordShadow(false);
4048
+ if (classification.suggestWebSearch && cfg.trigger.allowWebFallback) return {
4049
+ sessionId: "",
4050
+ advice: `前置分类器建议不启动顾问群:${classification.reason}`,
4051
+ skipped: true,
4052
+ reason: "web-search-recommended"
4053
+ };
4054
+ return {
4055
+ sessionId: "",
4056
+ advice: `前置分类器建议不启动顾问群:${classification.reason}`,
4057
+ skipped: true,
4058
+ reason: "classifier-rejected"
4059
+ };
4060
+ }
4061
+ } else if (cfg.trigger.requireClassifier) shadow = {
4062
+ ts: Date.now(),
4063
+ question: args.question,
4064
+ shouldEscalate: true,
4065
+ reason: mentionedAdvisorGroup ? "用户点名 @顾问群,跳过前置分类器。" : `同一问题已重复 ${repeatCount} 次,强制升级跳过前置分类器。`,
4066
+ suggestWebSearch: false,
4067
+ provider: "bypass",
4068
+ bypass: mentionedAdvisorGroup ? "mention" : "repeat",
4069
+ repeatCount
4070
+ };
4071
+ if (args.advisorIds && args.advisorIds.length > 0) {
4072
+ const configuredIds = new Set(cfg.advisors.map((advisor) => advisor.id));
4073
+ if (args.advisorIds.filter((id) => configuredIds.has(id)).length === 0) {
4074
+ recordShadow(false);
4075
+ return {
4076
+ sessionId: "",
4077
+ advice: "未匹配到任何指定顾问,请检查 advisorIds 是否与设置中的顾问 id 一致。",
4078
+ skipped: true,
4079
+ reason: "no-matching-advisors"
4080
+ };
4081
+ }
4082
+ }
4083
+ const costGuard = service.tryStartConsultation();
4084
+ if (!costGuard.ok) {
3343
4085
  recordShadow(false);
3344
4086
  return {
3345
4087
  sessionId: "",
3346
- advice: "未匹配到任何指定顾问,请检查 advisorIds 是否与设置中的顾问 id 一致。",
4088
+ advice: costGuard.reason,
3347
4089
  skipped: true,
3348
- reason: "no-matching-advisors"
4090
+ reason: "daily-limit-reached"
3349
4091
  };
3350
4092
  }
3351
- }
3352
- const costGuard = service.tryStartConsultation();
3353
- if (!costGuard.ok) {
3354
- recordShadow(false);
4093
+ const session = service.createSession(args.question, args.context, args.advisorIds, sessionLog ? sessionLog.header.cwd : void 0, sessionLog ? sessionLog.id : void 0);
4094
+ if (sessionLog) appendAdvisorStart(sessionLog, session);
4095
+ recordShadow(true);
4096
+ const summary = await service.runAutoPipeline(session, exec.signal, sessionLog, exec.agent);
3355
4097
  return {
3356
- sessionId: "",
3357
- advice: costGuard.reason,
3358
- skipped: true,
3359
- reason: "daily-limit-reached"
4098
+ sessionId: session.id,
4099
+ advice: formatConversation(session) + (summary.conclusion ? `\n\n【综合结论】\n${summary.conclusion}` : "") + formatRiskNotes(summary) + (service.hasReachedMaxRounds(session) ? "\n\n(已达设置的最大讨论轮数,请参考上述顾问意见与综合结论给出最终答复。)" : ""),
4100
+ skipped: false
3360
4101
  };
3361
4102
  }
3362
- const session = service.createSession(args.question, args.context, args.advisorIds, sessionLog ? sessionLog.header.cwd : void 0, sessionLog ? sessionLog.id : void 0);
3363
- if (sessionLog) appendAdvisorStart(sessionLog, session);
3364
- recordShadow(true);
3365
- const summary = await service.runAutoPipeline(session, exec.signal, sessionLog, exec.agent);
3366
- return {
3367
- sessionId: session.id,
3368
- advice: formatConversation(session) + (summary.conclusion ? `\n\n【综合结论】\n${summary.conclusion}` : "") + formatRiskNotes(summary) + (service.hasReachedMaxRounds(session) ? "\n\n(已达设置的最大讨论轮数,请参考上述顾问意见与综合结论给出最终答复。)" : ""),
3369
- skipped: false
3370
- };
3371
- }
3372
- }), defineTool({
3373
- name: "toggle_advisor_group",
3374
- description: "Enable or disable the advisor group plugin at runtime. Omit enabled to toggle the current state.",
3375
- parameters: { enabled: {
3376
- type: "boolean",
3377
- description: "Optional desired state."
3378
- } },
3379
- output: {
3380
- schema: {
3381
- type: "object",
3382
- additionalProperties: false,
3383
- properties: { enabled: {
3384
- type: "boolean",
4103
+ }),
4104
+ defineTool({
4105
+ name: "toggle_advisor_group",
4106
+ description: "Enable or disable the advisor group plugin at runtime. Omit enabled to toggle the current state.",
4107
+ parameters: { enabled: {
4108
+ type: "boolean",
4109
+ description: "Optional desired state."
4110
+ } },
4111
+ output: {
4112
+ schema: {
4113
+ type: "object",
4114
+ additionalProperties: false,
4115
+ properties: { enabled: {
4116
+ type: "boolean",
4117
+ required: true
4118
+ } }
4119
+ },
4120
+ render: (_args, value) => [{
4121
+ type: "text",
4122
+ text: `顾问群插件已${value.enabled ? "启用" : "禁用"}。`
4123
+ }]
4124
+ },
4125
+ async execute(args) {
4126
+ const next = typeof args.enabled === "boolean" ? args.enabled : !service.isEnabled();
4127
+ return { enabled: await service.toggleEnabled(next) };
4128
+ }
4129
+ }),
4130
+ defineTool({
4131
+ name: "ask_session",
4132
+ description: "Ask another EXISTING ordinary session (another window/tab the user opened) a question, wake it, and return what that session said. Use ONLY when the user explicitly asks to ask another session/window (「去问另一个会话」「问一下那个窗口」). It wakes the target session and consumes its quota. The returned text is NOT a one-to-one reply: it is everything the target said between the delivery receipt and its next whole-session idle, so never present it as \"the answer to my message\". When the target title is ambiguous you MUST return the candidates and let the user choose — never guess. Never use it for subagent sessions or for yourself.",
4133
+ parameters: {
4134
+ target: {
4135
+ type: "string",
4136
+ description: "The target session: its full sessionId, its exact display title, or a natural partial name — the workspace/directory name the user mentioned is enough (「极简遥控器」 matches sessions under I:\\极简遥控器\\…). One unique match is used directly; several matches return the candidates (running / most recently active first) for the user to choose — never guess. Pass the name the user said — do NOT search log files or directories for session ids.",
3385
4137
  required: true
3386
- } }
4138
+ },
4139
+ question: {
4140
+ type: "string",
4141
+ description: "The question to hand over. It is permanently recorded in the target session log, so write it self-contained.",
4142
+ required: true
4143
+ },
4144
+ context: {
4145
+ type: "string",
4146
+ description: "Optional background the target needs. Default: none. Anything sent here stays in the target session log forever, so send only what is necessary."
4147
+ },
4148
+ waitMs: {
4149
+ type: "number",
4150
+ 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.`
4151
+ }
3387
4152
  },
3388
- render: (_args, value) => [{
3389
- type: "text",
3390
- text: `顾问群插件已${value.enabled ? "启用" : "禁用"}。`
3391
- }]
3392
- },
3393
- async execute(args) {
3394
- const next = typeof args.enabled === "boolean" ? args.enabled : !service.isEnabled();
3395
- return { enabled: await service.toggleEnabled(next) };
3396
- }
3397
- })];
4153
+ output: {
4154
+ schema: {
4155
+ type: "object",
4156
+ additionalProperties: false,
4157
+ properties: {
4158
+ ok: {
4159
+ type: "boolean",
4160
+ required: true
4161
+ },
4162
+ target: {
4163
+ type: "object",
4164
+ additionalProperties: false,
4165
+ properties: {
4166
+ sessionId: {
4167
+ type: "string",
4168
+ required: true
4169
+ },
4170
+ name: {
4171
+ type: "string",
4172
+ required: true
4173
+ }
4174
+ }
4175
+ },
4176
+ delivered: {
4177
+ type: "string",
4178
+ enum: ["native", "fallback"]
4179
+ },
4180
+ askId: { type: "string" },
4181
+ interval: {
4182
+ type: "object",
4183
+ additionalProperties: false,
4184
+ properties: {
4185
+ from: {
4186
+ type: "string",
4187
+ required: true
4188
+ },
4189
+ to: { oneOf: [{ type: "string" }, { type: "null" }] },
4190
+ endedBecause: {
4191
+ type: "string",
4192
+ enum: [
4193
+ "idle",
4194
+ "timeout",
4195
+ "error"
4196
+ ],
4197
+ required: true
4198
+ }
4199
+ },
4200
+ required: true
4201
+ },
4202
+ answer: {
4203
+ type: "string",
4204
+ required: true
4205
+ },
4206
+ note: {
4207
+ type: "string",
4208
+ required: true
4209
+ },
4210
+ error: { type: "string" },
4211
+ message: { type: "string" },
4212
+ candidates: {
4213
+ type: "array",
4214
+ items: {
4215
+ type: "object",
4216
+ additionalProperties: false,
4217
+ properties: {
4218
+ sessionId: {
4219
+ type: "string",
4220
+ required: true
4221
+ },
4222
+ name: {
4223
+ type: "string",
4224
+ required: true
4225
+ },
4226
+ hint: {
4227
+ type: "string",
4228
+ required: true
4229
+ }
4230
+ }
4231
+ }
4232
+ }
4233
+ }
4234
+ },
4235
+ render: (_args, value) => value.ok === true ? renderAskResult({
4236
+ answer: String(value.answer ?? ""),
4237
+ note: String(value.note ?? "")
4238
+ }) : [{
4239
+ type: "text",
4240
+ text: `【ask_session 未完成:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}${Array.isArray(value.candidates) && value.candidates.length > 0 ? `\n候选会话:\n${value.candidates.map((item) => `- ${String(item.name ?? "")} · ${String(item.sessionId ?? "")}`).join("\n")}` : ""}`
4241
+ }]
4242
+ },
4243
+ async execute(args, exec) {
4244
+ const controller = getSessionController(ctx);
4245
+ if (!controller) return {
4246
+ ok: false,
4247
+ error: "service-missing",
4248
+ message: "此功能需要较新的 DSH 宿主:当前宿主没有提供 sessionController 服务,无法向其他会话投递问题。",
4249
+ answer: "",
4250
+ note: "",
4251
+ interval: errorInterval()
4252
+ };
4253
+ const session = exec.agent?.session ?? getCurrentSessionFrom(exec);
4254
+ const sessionId = session?.id;
4255
+ if (typeof sessionId !== "string" || sessionId.length === 0) return {
4256
+ ok: false,
4257
+ error: "service-missing",
4258
+ message: "无法确定当前会话的身份(工具执行上下文没有暴露会话),已拒绝投递。",
4259
+ answer: "",
4260
+ note: "",
4261
+ interval: errorInterval()
4262
+ };
4263
+ const question = typeof args.question === "string" ? args.question : "";
4264
+ const context = typeof args.context === "string" ? args.context : void 0;
4265
+ let senderName = "未命名会话";
4266
+ try {
4267
+ const own = (await controller.list({}, exec.signal)).items.find((item) => item.sessionId === sessionId);
4268
+ if (own) senderName = sessionDisplayName(own);
4269
+ } catch {}
4270
+ const cardLog = session;
4271
+ const onDelivered = (info) => {
4272
+ if (!cardLog) return;
4273
+ try {
4274
+ appendAskStart(cardLog, info);
4275
+ } catch (error) {
4276
+ console.warn("[dsh-advisor-group] 跨会话卡片(开始)写入失败:", error);
4277
+ }
4278
+ };
4279
+ const onSettled = (info) => {
4280
+ if (!cardLog) return;
4281
+ try {
4282
+ appendAskAnswer(cardLog, info);
4283
+ } catch (error) {
4284
+ console.warn("[dsh-advisor-group] 跨会话卡片(结束)写入失败:", error);
4285
+ }
4286
+ };
4287
+ const value = await askSession(ctx, exec.signal, {
4288
+ onDelivered,
4289
+ onSettled,
4290
+ currentSessionId: sessionId,
4291
+ senderName,
4292
+ currentEvents: readCurrentEvents(session),
4293
+ target: typeof args.target === "string" ? args.target : "",
4294
+ question,
4295
+ context,
4296
+ waitMs: args.waitMs,
4297
+ log: (message) => console.log(`[dsh-advisor-group] ask_session: ${message}`)
4298
+ });
4299
+ if (value.ok !== true) return {
4300
+ ok: false,
4301
+ error: String(value.error ?? "delivery-failed"),
4302
+ message: String(value.message ?? ""),
4303
+ ...Array.isArray(value.candidates) ? { candidates: value.candidates } : {},
4304
+ answer: "",
4305
+ note: "",
4306
+ interval: errorInterval()
4307
+ };
4308
+ return {
4309
+ ok: true,
4310
+ target: value.target,
4311
+ delivered: value.delivered,
4312
+ askId: String(value.askId ?? ""),
4313
+ interval: value.interval,
4314
+ answer: String(value.answer ?? ""),
4315
+ note: String(value.note ?? "")
4316
+ };
4317
+ }
4318
+ })
4319
+ ];
4320
+ }
4321
+ /**
4322
+ * Current session id from the tool execution context. `exec.agent.session` is
4323
+ * the Agent on whose behalf the call runs (same access `ask_advisors` uses for
4324
+ * `exec.agent?.session`); the fallback covers hosts that only expose a raw
4325
+ * session object on the execution input.
4326
+ */
4327
+ function getCurrentSessionFrom(exec) {
4328
+ return exec?.session;
4329
+ }
4330
+ /** Read this session's own log for the inbound-source / hop guard; empty on failure. */
4331
+ function readCurrentEvents(session) {
4332
+ try {
4333
+ return session?.snapshotEvents() ?? [];
4334
+ } catch {
4335
+ return [];
4336
+ }
4337
+ }
4338
+ /** Empty interval carried on every not-ok result (no reply window ever opened). */
4339
+ function errorInterval() {
4340
+ return {
4341
+ from: (/* @__PURE__ */ new Date()).toISOString(),
4342
+ endedBecause: "error"
4343
+ };
3398
4344
  }
3399
4345
  function formatRiskNotes(summary) {
3400
4346
  return summary.riskNotes.length > 0 ? `\n\n【风险提示】\n${summary.riskNotes.join("\n")}` : "";
@@ -4040,12 +4986,95 @@ function handleStreamRequest(req, res) {
4040
4986
  subscribe(sessionId, res, lastEventId, bootId);
4041
4987
  });
4042
4988
  }
4989
+ /** First value of a possibly repeated header field. */
4990
+ function headerValue(value) {
4991
+ return Array.isArray(value) ? value[0] ?? "" : value ?? "";
4992
+ }
4993
+ /** Origin of an http(s) URL; `''` for absent, opaque (`null`) or non-web values. */
4994
+ function webOrigin(value) {
4995
+ if (value === "" || value === "null") return "";
4996
+ try {
4997
+ const parsed = new URL(value);
4998
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : "";
4999
+ } catch {
5000
+ return "";
5001
+ }
5002
+ }
5003
+ /**
5004
+ * True when the handshake request may be handed the token. Only a *provably
5005
+ * foreign web page* is refused: an `Origin`/`Referer` that parses as http(s) on
5006
+ * another host. Same-origin pages pass, and so do callers carrying no web
5007
+ * provenance at all — the desktop shell, which forwards the page's request
5008
+ * without those headers, and any local program, which could forge them anyway.
5009
+ *
5010
+ * What actually keeps the token from a foreign page is not this check but the
5011
+ * absent CORS header: a cross-origin fetch cannot read a response body without
5012
+ * `Access-Control-Allow-Origin`, and this route never sends one. The check only
5013
+ * decides whether a foreign page is answered at all.
5014
+ */
5015
+ function mayReadBootToken(req) {
5016
+ const host = headerValue(req.headers.host);
5017
+ const fromForeignPage = (value) => {
5018
+ const origin = webOrigin(value);
5019
+ if (origin === "" || host === "") return false;
5020
+ return origin !== `http://${host}` && origin !== `https://${host}`;
5021
+ };
5022
+ if (fromForeignPage(headerValue(req.headers.origin))) return false;
5023
+ if (fromForeignPage(headerValue(req.headers.referer))) return false;
5024
+ return true;
5025
+ }
5026
+ /** Set once the process has logged an accepted handshake (refusals always log). */
5027
+ let bootTokenAcceptedLogged = false;
5028
+ /**
5029
+ * Records one handshake attempt in
5030
+ * `$DSH_HOME/storages/advisor-group/boot-token.log`. Whether the desktop shell's
5031
+ * request even reaches this route is otherwise invisible from outside the app,
5032
+ * so each decision leaves a line; the file keeps only its most recent lines.
5033
+ * Query strings are dropped and the referer is reduced to its origin, so no
5034
+ * token can land in the log. A write failure never breaks the handshake.
5035
+ */
5036
+ function recordBootTokenAttempt(req, allowed) {
5037
+ if (allowed && bootTokenAcceptedLogged) return;
5038
+ bootTokenAcceptedLogged = true;
5039
+ try {
5040
+ const dshHome = process.env.DSH_HOME || join(homedir(), ".dsh");
5041
+ const target = join(dshHome, "storages", "advisor-group", "boot-token.log");
5042
+ const line = `${[
5043
+ (/* @__PURE__ */ new Date()).toISOString(),
5044
+ allowed ? "allow" : "deny",
5045
+ `site=${headerValue(req.headers["sec-fetch-site"]) || "-"}`,
5046
+ `origin=${headerValue(req.headers.origin) || "-"}`,
5047
+ `referer=${webOrigin(headerValue(req.headers.referer)) || "-"}`,
5048
+ `host=${headerValue(req.headers.host) || "-"}`,
5049
+ `ua=${headerValue(req.headers["user-agent"]).slice(0, 48) || "-"}`
5050
+ ].join(" ")}\n`;
5051
+ mkdirSync(dirname(target), { recursive: true });
5052
+ appendFileSync(target, line);
5053
+ if (statSync(target).size > 8192) writeFileSync(target, readFileSync(target, "utf8").split("\n").slice(-20).join("\n"));
5054
+ } catch {}
5055
+ }
4043
5056
  function handleConfigRequest(ctx, scopedCtx, service, authToken) {
4044
5057
  const namespace = "dsh-advisor-group";
4045
5058
  return async (req, res) => {
4046
5059
  const rawUrl = req.url ?? "";
4047
5060
  const url = new URL(rawUrl, "http://localhost");
4048
5061
  const pathname = url.pathname.replace(/\/+$/, "") || "/";
5062
+ if (req.method === "GET" && (pathname === "/advisor-group/boot-token" || pathname === "/advisor-group/boot-token/")) {
5063
+ const allowed = mayReadBootToken(req);
5064
+ recordBootTokenAttempt(req, allowed);
5065
+ if (!allowed) {
5066
+ sendJson(res, 403, {
5067
+ ok: false,
5068
+ error: "forbidden"
5069
+ });
5070
+ return;
5071
+ }
5072
+ sendJson(res, 200, {
5073
+ ok: true,
5074
+ token: authToken
5075
+ });
5076
+ return;
5077
+ }
4049
5078
  const token = String(req.headers["x-advisor-group-token"] ?? url.searchParams.get("token") ?? "");
4050
5079
  if (authToken && token !== authToken) {
4051
5080
  sendJson(res, 401, {
@@ -4334,7 +5363,7 @@ function apply(ctx, config) {
4334
5363
  reportPromptGuardFindings(guarded.findings);
4335
5364
  const service = new AdvisorGroupService(ctx, guarded.config);
4336
5365
  registerAdvisorSettingsAndRoutes(ctx, guarded.config, service);
4337
- for (const tool of registerAdvisorTools(service)) ctx.tools.register(tool);
5366
+ for (const tool of registerAdvisorTools(service, ctx)) ctx.tools.register(tool);
4338
5367
  const systemPrompt = ctx.systemPrompt;
4339
5368
  if (systemPrompt) ctx.effect(() => systemPrompt.section({
4340
5369
  name: "advisor-group-boundary",