dsh-advisor-group 0.1.4 → 0.1.5
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 +226 -153
- package/README.md +276 -207
- package/lib/client.js +1520 -890
- package/lib/index.d.mts +51 -1
- package/lib/index.mjs +1568 -150
- package/package.json +103 -100
package/lib/index.mjs
CHANGED
|
@@ -119,6 +119,7 @@ const SwarmConfig = Schema.object({
|
|
|
119
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
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
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
|
+
deliverBack: Schema.boolean().default(true).description("Copy a settled answer back into the asking session automatically (done only; timeout and error are never delivered back). Off leaves results readable only through ask_result."),
|
|
122
123
|
rosterIncludeSubagents: Schema.boolean().default(false).description("List subagent sessions in the session roster."),
|
|
123
124
|
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
125
|
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)."),
|
|
@@ -3508,13 +3509,20 @@ async function judgeCrossSessionWithJev(text, textEn, candidates, config, signal
|
|
|
3508
3509
|
* atomic tmp+rename writes, and reads that degrade to `undefined` instead of
|
|
3509
3510
|
* throwing on a missing or damaged file.
|
|
3510
3511
|
*/
|
|
3512
|
+
/**
|
|
3513
|
+
* Identity of this host process. A record created by another process can no
|
|
3514
|
+
* longer be waited on (its background loop died with that process), so a
|
|
3515
|
+
* `waiting` record carrying a different boot id is reconciled when read.
|
|
3516
|
+
*/
|
|
3517
|
+
const ASK_BOOT_ID = randomUUID();
|
|
3511
3518
|
/** askId doubles as a file name, so it must never escape the store directory. */
|
|
3512
3519
|
const ASK_ID_PATTERN = /^[A-Za-z0-9._-]{1,80}$/;
|
|
3513
3520
|
const STATES = /* @__PURE__ */ new Set([
|
|
3514
3521
|
"waiting",
|
|
3515
3522
|
"done",
|
|
3516
3523
|
"timeout",
|
|
3517
|
-
"error"
|
|
3524
|
+
"error",
|
|
3525
|
+
"interrupted"
|
|
3518
3526
|
]);
|
|
3519
3527
|
/** The store directory for a base dir such as `$DSH_HOME/storages/advisor-group`. */
|
|
3520
3528
|
function askStoreDir(baseDir) {
|
|
@@ -3538,6 +3546,21 @@ function parseRecord(text) {
|
|
|
3538
3546
|
return isAskRecord(value) ? value : void 0;
|
|
3539
3547
|
}
|
|
3540
3548
|
/**
|
|
3549
|
+
* Settle a `waiting` record whose wait belonged to a previous host process.
|
|
3550
|
+
* The background loop that would have collected the answer died with that
|
|
3551
|
+
* process, so leaving the record `waiting` promises something that will never
|
|
3552
|
+
* happen. Returns the rewritten record, or undefined when nothing is stale.
|
|
3553
|
+
*/
|
|
3554
|
+
function reconcileStaleAsk(record, bootId = ASK_BOOT_ID) {
|
|
3555
|
+
if (record.state !== "waiting" || record.bootId === bootId) return void 0;
|
|
3556
|
+
return {
|
|
3557
|
+
...record,
|
|
3558
|
+
state: "interrupted",
|
|
3559
|
+
settledAt: Date.now(),
|
|
3560
|
+
note: record.bootId === void 0 ? "这条异步提问来自更早的插件版本,等待状态已无法确认;对方可能已经作答,但答案没有被自动收回。请到对方会话里查看,或重新提问。" : "宿主在等待期间重启,等待被中断;对方可能已经作答,但答案没有被自动收回。请到对方会话里查看,或重新提问。"
|
|
3561
|
+
};
|
|
3562
|
+
}
|
|
3563
|
+
/**
|
|
3541
3564
|
* Write one record atomically: tmp file + rename, so a concurrent reader never
|
|
3542
3565
|
* sees a half-written JSON document. Creates `dir` when missing.
|
|
3543
3566
|
*/
|
|
@@ -3648,7 +3671,7 @@ const DRIVE_SEGMENT$1 = /^[A-Za-z]:$/u;
|
|
|
3648
3671
|
* and a bare greeting is an empty chat that never named itself.
|
|
3649
3672
|
*/
|
|
3650
3673
|
const NOISE_TITLE_PATTERN = /生成会话标题|^你好[,,\s]*$/u;
|
|
3651
|
-
function isRecord(value) {
|
|
3674
|
+
function isRecord$1(value) {
|
|
3652
3675
|
return value !== null && typeof value === "object";
|
|
3653
3676
|
}
|
|
3654
3677
|
/**
|
|
@@ -3656,11 +3679,11 @@ function isRecord(value) {
|
|
|
3656
3679
|
* `{ title }` wrapper. Returns '' when neither yields a non-empty string.
|
|
3657
3680
|
*/
|
|
3658
3681
|
function titleOf$1(projections) {
|
|
3659
|
-
if (!isRecord(projections)) return "";
|
|
3682
|
+
if (!isRecord$1(projections)) return "";
|
|
3660
3683
|
const values = projections.values;
|
|
3661
|
-
if (!isRecord(values)) return "";
|
|
3684
|
+
if (!isRecord$1(values)) return "";
|
|
3662
3685
|
const raw = values.title;
|
|
3663
|
-
return (typeof raw === "string" ? raw : isRecord(raw) && typeof raw.title === "string" ? raw.title : "").trim();
|
|
3686
|
+
return (typeof raw === "string" ? raw : isRecord$1(raw) && typeof raw.title === "string" ? raw.title : "").trim();
|
|
3664
3687
|
}
|
|
3665
3688
|
/**
|
|
3666
3689
|
* Whether a raw roster row must stay out of the roster and the routing pool:
|
|
@@ -3670,7 +3693,7 @@ function titleOf$1(projections) {
|
|
|
3670
3693
|
* @param currentSessionId - the asking session, dropped when its id matches.
|
|
3671
3694
|
*/
|
|
3672
3695
|
function isNoiseSession(session, currentSessionId) {
|
|
3673
|
-
if (!isRecord(session)) return false;
|
|
3696
|
+
if (!isRecord$1(session)) return false;
|
|
3674
3697
|
if (session.blank === true) return true;
|
|
3675
3698
|
const title = titleOf$1(session.projections);
|
|
3676
3699
|
if (title.length > 0 && NOISE_TITLE_PATTERN.test(title)) return true;
|
|
@@ -3847,12 +3870,12 @@ function withSessionLogSizes(sessions) {
|
|
|
3847
3870
|
const rows = Array.isArray(sessions) ? sessions : [];
|
|
3848
3871
|
const missing = [];
|
|
3849
3872
|
for (const row of rows) {
|
|
3850
|
-
if (!isRecord(row) || bytesOf$1(row.sizeBytes) !== void 0) continue;
|
|
3873
|
+
if (!isRecord$1(row) || bytesOf$1(row.sizeBytes) !== void 0) continue;
|
|
3851
3874
|
if (typeof row.sessionId === "string" && row.sessionId.length > 0) missing.push(row.sessionId);
|
|
3852
3875
|
}
|
|
3853
3876
|
const sizes = sessionLogSizes(missing);
|
|
3854
3877
|
return rows.map((row) => {
|
|
3855
|
-
if (!isRecord(row) || bytesOf$1(row.sizeBytes) !== void 0) return row;
|
|
3878
|
+
if (!isRecord$1(row) || bytesOf$1(row.sizeBytes) !== void 0) return row;
|
|
3856
3879
|
const size = typeof row.sessionId === "string" ? sizes.get(row.sessionId) : void 0;
|
|
3857
3880
|
return size === void 0 ? row : {
|
|
3858
3881
|
...row,
|
|
@@ -3872,7 +3895,7 @@ function buildSessionRoster(sessions, options) {
|
|
|
3872
3895
|
const query = typeof options?.query === "string" ? options.query.trim().toLowerCase() : "";
|
|
3873
3896
|
const rows = [];
|
|
3874
3897
|
for (const session of sessions) {
|
|
3875
|
-
if (!isRecord(session)) continue;
|
|
3898
|
+
if (!isRecord$1(session)) continue;
|
|
3876
3899
|
if (isNoiseSession(session)) continue;
|
|
3877
3900
|
const raw = session;
|
|
3878
3901
|
if (options?.hideSubagents === true && raw.origin === "subagent") continue;
|
|
@@ -3909,17 +3932,20 @@ function buildSessionRoster(sessions, options) {
|
|
|
3909
3932
|
});
|
|
3910
3933
|
}
|
|
3911
3934
|
/**
|
|
3912
|
-
* Render the roster as the text the model reads.
|
|
3913
|
-
*
|
|
3935
|
+
* Render the roster as the text the model reads. The sessionId is appended to
|
|
3936
|
+
* every line so identical titles stay distinguishable (the structured rows
|
|
3937
|
+
* already carry it). `hasTitle` stays out of the text on purpose: it is a
|
|
3938
|
+
* diagnostic fact, not a hint for the model.
|
|
3914
3939
|
* @param rows - ordered rows from {@link buildSessionRoster}.
|
|
3915
3940
|
* @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.
|
|
3941
|
+
* @returns heading plus one `- <name> · <sessionId> — <hint>` line per row, or the empty notice.
|
|
3917
3942
|
*/
|
|
3918
3943
|
function renderRoster(rows, options) {
|
|
3919
3944
|
if (rows.length === 0) return "没有匹配的会话。";
|
|
3920
3945
|
return [`${options?.filtered === true ? "匹配" : "共"} ${rows.length} 个会话`, ...rows.map((row) => {
|
|
3921
3946
|
const flags = `${row.running ? " · 运行中" : ""}${row.subagent ? " · 子代理" : ""}`;
|
|
3922
|
-
|
|
3947
|
+
const corps = row.roleName === void 0 ? "" : ` · 顾问团「${row.roleName}」${row.duty !== void 0 && row.duty.length > 0 ? `(${row.duty})` : ""}`;
|
|
3948
|
+
return `- ${shortSessionName(row.name, row.cwd)} · ${row.sessionId} — ${row.hint}${flags}${corps}`;
|
|
3923
3949
|
})].join("\n");
|
|
3924
3950
|
}
|
|
3925
3951
|
//#endregion
|
|
@@ -4955,6 +4981,10 @@ const ASK_WAIT_DEFAULT_MS = 18e4;
|
|
|
4955
4981
|
const ASK_WAIT_MAX_MS = 6e5;
|
|
4956
4982
|
/** Poll interval while waiting for the target to reach whole-session idle. */
|
|
4957
4983
|
const ASK_POLL_INTERVAL_MS = 1e3;
|
|
4984
|
+
/** Refusal/skip reason for a session the user archived in the workspace. */
|
|
4985
|
+
const ARCHIVED_SESSION_MESSAGE = "这个会话已归档,先在工作区里取消归档才能唤醒它";
|
|
4986
|
+
/** Refusal/skip reason for a session no workspace registers (the sidebar hides it). */
|
|
4987
|
+
const UNREGISTERED_SESSION_MESSAGE = "这个会话不在任何工作区里(可能已被移出工作区),先从工作区里找到它才能唤醒";
|
|
4958
4988
|
/**
|
|
4959
4989
|
* Envelope the receiving model reads. The Host offers no registration point for
|
|
4960
4990
|
* this framing (the official Agent Teams build their own too), so the identity
|
|
@@ -5020,13 +5050,79 @@ function sessionDisplayName(summary) {
|
|
|
5020
5050
|
if (title.length > 0) return title;
|
|
5021
5051
|
return fallbackSessionName(summary.cwd, summary.updatedAt);
|
|
5022
5052
|
}
|
|
5023
|
-
|
|
5053
|
+
const AGE_MINUTE_MS = 6e4;
|
|
5054
|
+
/**
|
|
5055
|
+
* Human age for one session, for display only: 「刚刚」 / 「N 分钟前」 /
|
|
5056
|
+
* 「N 小时前」 / 「N 天前」 / 「N 个月前」 / 「N 年前」.
|
|
5057
|
+
* @param updatedAt - last-activity timestamp in ms.
|
|
5058
|
+
* @param now - clock to measure against.
|
|
5059
|
+
*/
|
|
5060
|
+
function relativeAge(updatedAt, now) {
|
|
5061
|
+
const age = now - updatedAt;
|
|
5062
|
+
if (!Number.isFinite(age)) return "(未知时间)";
|
|
5063
|
+
if (age < AGE_MINUTE_MS) return "刚刚";
|
|
5064
|
+
const minutes = Math.floor(age / AGE_MINUTE_MS);
|
|
5065
|
+
if (minutes < 60) return `${minutes} 分钟前`;
|
|
5066
|
+
const hours = Math.floor(minutes / 60);
|
|
5067
|
+
if (hours < 24) return `${hours} 小时前`;
|
|
5068
|
+
const days = Math.floor(hours / 24);
|
|
5069
|
+
if (days < 30) return `${days} 天前`;
|
|
5070
|
+
const months = Math.floor(days / 30);
|
|
5071
|
+
if (months < 12) return `${months} 个月前`;
|
|
5072
|
+
return `${Math.floor(months / 12)} 年前`;
|
|
5073
|
+
}
|
|
5074
|
+
function candidateOf(summary, now = Date.now()) {
|
|
5075
|
+
const cwd = summary.cwd;
|
|
5024
5076
|
return {
|
|
5025
5077
|
sessionId: summary.sessionId,
|
|
5026
5078
|
name: sessionDisplayName(summary),
|
|
5027
|
-
hint: fallbackSessionName(
|
|
5079
|
+
hint: fallbackSessionName(cwd, summary.updatedAt),
|
|
5080
|
+
...typeof cwd === "string" && cwd.length > 0 ? { cwd } : {},
|
|
5081
|
+
age: relativeAge(summary.updatedAt, now)
|
|
5028
5082
|
};
|
|
5029
5083
|
}
|
|
5084
|
+
/** `- name · sessionId · cwd · 多久前` for one candidate (display only). */
|
|
5085
|
+
function candidateLine(candidate) {
|
|
5086
|
+
const cwd = typeof candidate.cwd === "string" && candidate.cwd.length > 0 ? candidate.cwd : "(未知工作目录)";
|
|
5087
|
+
const age = typeof candidate.age === "string" && candidate.age.length > 0 ? candidate.age : "(未知时间)";
|
|
5088
|
+
return `- ${candidate.name} · ${candidate.sessionId} · ${cwd} · ${age}`;
|
|
5089
|
+
}
|
|
5090
|
+
/**
|
|
5091
|
+
* The candidate block appended to an ambiguous / not-found refusal and to batch
|
|
5092
|
+
* skip reasons. Display only — matching never uses it.
|
|
5093
|
+
*
|
|
5094
|
+
* - One hint line when a title repeats.
|
|
5095
|
+
* - At most {@link CANDIDATE_DISPLAY_LIMIT} candidate lines, each carrying
|
|
5096
|
+
* name · sessionId · cwd · 多久前.
|
|
5097
|
+
* - A remainder note when the list was truncated; the rest stay addressable by
|
|
5098
|
+
* sessionId or directory, they are just not spread out.
|
|
5099
|
+
* - Ordering is the caller's: running first, then most recently updated. Names
|
|
5100
|
+
* and ids are never used for sorting.
|
|
5101
|
+
* @param candidates - the ordered candidate pool (may be longer than the cap).
|
|
5102
|
+
* @param options.total - full match count when the pool is already capped.
|
|
5103
|
+
* @param options.sameTitle - false makes the remainder note say 「会话」 rather
|
|
5104
|
+
* than 「同名会话」 (used for the not-found recent list).
|
|
5105
|
+
*/
|
|
5106
|
+
function renderCandidateBlock(candidates, options) {
|
|
5107
|
+
const pool = Array.isArray(candidates) ? candidates : [];
|
|
5108
|
+
const shown = pool.slice(0, 10);
|
|
5109
|
+
const total = Math.max(shown.length, Math.floor(options?.total ?? pool.length));
|
|
5110
|
+
const nameCounts = /* @__PURE__ */ new Map();
|
|
5111
|
+
for (const candidate of pool) nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1);
|
|
5112
|
+
const repeated = [...nameCounts.values()].some((count) => count > 1);
|
|
5113
|
+
const lines = [];
|
|
5114
|
+
if (repeated) lines.push("同名会话较多:按下面的 sessionId 或目录指定");
|
|
5115
|
+
lines.push(...shown.map(candidateLine));
|
|
5116
|
+
if (total > shown.length) {
|
|
5117
|
+
const noun = options?.sameTitle === false ? "个会话" : "个同名会话";
|
|
5118
|
+
lines.push(`…还有 ${total - shown.length} ${noun},可用目录关键词进一步收窄`);
|
|
5119
|
+
}
|
|
5120
|
+
return lines.join("\n");
|
|
5121
|
+
}
|
|
5122
|
+
/** Every listed session as a display candidate: running first, then most recent. */
|
|
5123
|
+
function recentCandidates(sessions, now = Date.now()) {
|
|
5124
|
+
return orderCandidates(sessions).map((session) => candidateOf(session, now));
|
|
5125
|
+
}
|
|
5030
5126
|
/** Most-recently-active first, running sessions ahead of idle ones. */
|
|
5031
5127
|
function orderCandidates(rows) {
|
|
5032
5128
|
return [...rows].sort((a, b) => {
|
|
@@ -5034,10 +5130,8 @@ function orderCandidates(rows) {
|
|
|
5034
5130
|
return b.updatedAt - a.updatedAt;
|
|
5035
5131
|
});
|
|
5036
5132
|
}
|
|
5037
|
-
/** Partial candidates are capped: a broad needle must stay a readable list. */
|
|
5038
|
-
const PARTIAL_CANDIDATE_LIMIT = 10;
|
|
5039
5133
|
/** When nothing matches, this many recent sessions come back as candidates. */
|
|
5040
|
-
const NOT_FOUND_CANDIDATE_LIMIT =
|
|
5134
|
+
const NOT_FOUND_CANDIDATE_LIMIT = 10;
|
|
5041
5135
|
/** The exclusion entries carried by `swarm.routingExclude`; malformed values are ignored. */
|
|
5042
5136
|
function exclusionEntries$1(value) {
|
|
5043
5137
|
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
@@ -5081,10 +5175,14 @@ function matchTargetSession(sessions, reference) {
|
|
|
5081
5175
|
name: sessionDisplayName(exact[0])
|
|
5082
5176
|
}
|
|
5083
5177
|
};
|
|
5084
|
-
if (exact.length > 1)
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5178
|
+
if (exact.length > 1) {
|
|
5179
|
+
const ordered = orderCandidates(exact).map((session) => candidateOf(session));
|
|
5180
|
+
return {
|
|
5181
|
+
kind: "ambiguous",
|
|
5182
|
+
candidates: ordered.slice(0, 10),
|
|
5183
|
+
total: ordered.length
|
|
5184
|
+
};
|
|
5185
|
+
}
|
|
5088
5186
|
const partial = sessions.filter((session) => sessionDisplayName(session).toLowerCase().includes(folded) || session.cwd !== void 0 && session.cwd.toLowerCase().includes(folded));
|
|
5089
5187
|
if (partial.length === 1) return {
|
|
5090
5188
|
kind: "unique",
|
|
@@ -5094,9 +5192,11 @@ function matchTargetSession(sessions, reference) {
|
|
|
5094
5192
|
}
|
|
5095
5193
|
};
|
|
5096
5194
|
if (partial.length === 0) return { kind: "missing" };
|
|
5195
|
+
const ordered = orderCandidates(partial).map((session) => candidateOf(session));
|
|
5097
5196
|
return {
|
|
5098
5197
|
kind: "ambiguous",
|
|
5099
|
-
candidates:
|
|
5198
|
+
candidates: ordered.slice(0, 10),
|
|
5199
|
+
total: ordered.length
|
|
5100
5200
|
};
|
|
5101
5201
|
}
|
|
5102
5202
|
/**
|
|
@@ -5164,6 +5264,55 @@ function collectAnswer(events, fromSeq) {
|
|
|
5164
5264
|
function joinAnswerPieces(pieces) {
|
|
5165
5265
|
return pieces.join("\n\n");
|
|
5166
5266
|
}
|
|
5267
|
+
/** A provisional body longer than this is truncated for display. */
|
|
5268
|
+
const PROVISIONAL_BODY_LIMIT = 2e3;
|
|
5269
|
+
/**
|
|
5270
|
+
* Reconstruct the delivery watermark from the recorded delivery time: the
|
|
5271
|
+
* highest event seq at or before `fromMs`. The waiting record does not persist
|
|
5272
|
+
* the seq cut, so this recovers an equivalent floor from timestamps for a
|
|
5273
|
+
* read-only probe (settling still uses the real recorded watermark).
|
|
5274
|
+
* @param events - the target's log events in order.
|
|
5275
|
+
* @param fromMs - delivery time in ms since epoch.
|
|
5276
|
+
*/
|
|
5277
|
+
function deliveryWatermark(events, fromMs) {
|
|
5278
|
+
if (!Number.isFinite(fromMs)) return -1;
|
|
5279
|
+
let highest = -1;
|
|
5280
|
+
for (const event of events) {
|
|
5281
|
+
if (!Number.isFinite(event.time) || event.time > fromMs) continue;
|
|
5282
|
+
if (typeof event.seq === "number" && event.seq > highest) highest = event.seq;
|
|
5283
|
+
}
|
|
5284
|
+
return highest;
|
|
5285
|
+
}
|
|
5286
|
+
/**
|
|
5287
|
+
* Read-only probe for a still-waiting detached ask: did the target already
|
|
5288
|
+
* write body text after this delivery? Reuses {@link collectAnswer} with the
|
|
5289
|
+
* reconstructed watermark. Display only — nothing is written back and the
|
|
5290
|
+
* record stays `waiting`. Any failure (bad timestamp, missing agent, host
|
|
5291
|
+
* error) returns undefined so the caller keeps the old output.
|
|
5292
|
+
*/
|
|
5293
|
+
async function probeWaitingBody(controller, record) {
|
|
5294
|
+
try {
|
|
5295
|
+
const fromMs = Date.parse(record.from);
|
|
5296
|
+
if (!Number.isFinite(fromMs)) return void 0;
|
|
5297
|
+
const resolved = await controller.resolveAgent(record.targetSessionId);
|
|
5298
|
+
if (!("agent" in resolved) || !resolved.agent) return void 0;
|
|
5299
|
+
const events = agentEvents(resolved.agent);
|
|
5300
|
+
const text = joinAnswerPieces(collectAnswer(events, deliveryWatermark(events, fromMs)).pieces).trim();
|
|
5301
|
+
return text.length > 0 ? text : void 0;
|
|
5302
|
+
} catch {
|
|
5303
|
+
return;
|
|
5304
|
+
}
|
|
5305
|
+
}
|
|
5306
|
+
/**
|
|
5307
|
+
* One labelled provisional-body block (display only), truncated with a note.
|
|
5308
|
+
* @param text - the body collected so far.
|
|
5309
|
+
* @returns a two-part block, or undefined when there is no body.
|
|
5310
|
+
*/
|
|
5311
|
+
function provisionalBodySection(text) {
|
|
5312
|
+
const trimmed = text.trim();
|
|
5313
|
+
if (trimmed.length === 0) return void 0;
|
|
5314
|
+
return `(尚未落定:目标可能仍在继续,下面是目前已收到的正文)\n${trimmed.slice(0, PROVISIONAL_BODY_LIMIT)}${trimmed.length > 2e3 ? "\n…(正文过长,仅显示前 2000 字)" : ""}`;
|
|
5315
|
+
}
|
|
5167
5316
|
/** One 「结论:…」-style line, tolerating markdown bullets and bold markers. */
|
|
5168
5317
|
const REPLY_FIELD = /^[\s>*#`-]*(结论|依据|不确定)[*\s]*[::]\s*(.*)$/u;
|
|
5169
5318
|
/** Best-effort parse of the reply convention; undefined when the text ignores it. */
|
|
@@ -5280,6 +5429,17 @@ function logJevDecision(entry) {
|
|
|
5280
5429
|
}
|
|
5281
5430
|
/** Background loops started by `detach: true`; cancelled when the plugin unloads. */
|
|
5282
5431
|
const detachedControllers = /* @__PURE__ */ new Set();
|
|
5432
|
+
/**
|
|
5433
|
+
* Track one background AbortController so plugin dispose ({@link
|
|
5434
|
+
* cancelDetachedAsks}) aborts it. Returns a disposer that stops tracking it.
|
|
5435
|
+
* The batch fan-out uses it so every in-flight target is cancelled on unload.
|
|
5436
|
+
*/
|
|
5437
|
+
function trackBackgroundController(controller) {
|
|
5438
|
+
detachedControllers.add(controller);
|
|
5439
|
+
return () => {
|
|
5440
|
+
detachedControllers.delete(controller);
|
|
5441
|
+
};
|
|
5442
|
+
}
|
|
5283
5443
|
/** Cancel every detached wait (plugin dispose). Safe to call repeatedly. */
|
|
5284
5444
|
function cancelDetachedAsks() {
|
|
5285
5445
|
for (const controller of detachedControllers) controller.abort();
|
|
@@ -5420,6 +5580,68 @@ async function deliverViaPrompt(controller, _signal, envelope, input, why) {
|
|
|
5420
5580
|
};
|
|
5421
5581
|
}
|
|
5422
5582
|
}
|
|
5583
|
+
/** One answer-copy message: source line plus the FULL answer (never truncated). */
|
|
5584
|
+
function buildAnswerBackText(targetName, askId, answer) {
|
|
5585
|
+
return `【来自「${targetName}」 · askId=${askId}】\n${answer}`;
|
|
5586
|
+
}
|
|
5587
|
+
/**
|
|
5588
|
+
* Copy-back text used when the ask already rendered a card in the asking
|
|
5589
|
+
* session: the full answer lives in that card and in the stored record, so the
|
|
5590
|
+
* chat message points at it instead of printing the same text a second time.
|
|
5591
|
+
*/
|
|
5592
|
+
function buildAnswerBackNotice(targetName, askId) {
|
|
5593
|
+
return `【来自「${targetName}」 · askId=${askId}】答复已就绪,并显示在跨会话卡片中。需要原文时调用 ask_result(askId=${askId})。`;
|
|
5594
|
+
}
|
|
5595
|
+
/** The merged batch copy-back: one message for every done answer. */
|
|
5596
|
+
function buildBatchAnswerBackText(entries) {
|
|
5597
|
+
if (entries.length === 1) {
|
|
5598
|
+
const only = entries[0];
|
|
5599
|
+
return buildAnswerBackText(only.targetName, only.askId, only.answer);
|
|
5600
|
+
}
|
|
5601
|
+
const blocks = entries.map((entry) => buildAnswerBackText(entry.targetName, entry.askId, entry.answer));
|
|
5602
|
+
return [`【批量回送 · 共 ${entries.length} 条】`, ...blocks].join("\n\n");
|
|
5603
|
+
}
|
|
5604
|
+
/**
|
|
5605
|
+
* Copy one settled answer back into the asking session. Reuses the same two
|
|
5606
|
+
* delivery paths as a question (native `followup`, then the host `prompt` queue
|
|
5607
|
+
* when the agent is not resolvable), so a busy asker simply receives it through
|
|
5608
|
+
* the host's inbox. Every failure is returned, never thrown.
|
|
5609
|
+
*/
|
|
5610
|
+
async function deliverAnswerBack(controller, input, text) {
|
|
5611
|
+
const content = [{
|
|
5612
|
+
type: "text",
|
|
5613
|
+
text
|
|
5614
|
+
}];
|
|
5615
|
+
const source = {
|
|
5616
|
+
kind: "session-ask",
|
|
5617
|
+
form: "relay",
|
|
5618
|
+
askId: `${input.askId}-answer`,
|
|
5619
|
+
senderSessionId: input.targetSessionId,
|
|
5620
|
+
senderName: input.targetName,
|
|
5621
|
+
hop: 0
|
|
5622
|
+
};
|
|
5623
|
+
try {
|
|
5624
|
+
const resolved = await controller.resolveAgent(input.askerSessionId);
|
|
5625
|
+
if ("agent" in resolved && resolved.agent) {
|
|
5626
|
+
resolved.agent.followup(buildAskMessage(content, source));
|
|
5627
|
+
return { ok: true };
|
|
5628
|
+
}
|
|
5629
|
+
} catch {}
|
|
5630
|
+
try {
|
|
5631
|
+
await controller.prompt({
|
|
5632
|
+
requestId: `${input.askId}-answer`,
|
|
5633
|
+
sessionId: input.askerSessionId,
|
|
5634
|
+
mode: "queue",
|
|
5635
|
+
content
|
|
5636
|
+
});
|
|
5637
|
+
return { ok: true };
|
|
5638
|
+
} catch (error) {
|
|
5639
|
+
return {
|
|
5640
|
+
ok: false,
|
|
5641
|
+
error: describeHostError(error)
|
|
5642
|
+
};
|
|
5643
|
+
}
|
|
5644
|
+
}
|
|
5423
5645
|
async function watermarkViaResolve(controller, sessionId) {
|
|
5424
5646
|
try {
|
|
5425
5647
|
const resolved = await controller.resolveAgent(sessionId);
|
|
@@ -5532,6 +5754,167 @@ async function collectTargetAnswer(controller, sessionId, fromSeq) {
|
|
|
5532
5754
|
}
|
|
5533
5755
|
}
|
|
5534
5756
|
/**
|
|
5757
|
+
* Wait for the target's next whole-session idle, then collect what it said in
|
|
5758
|
+
* the interval. Shared by `ask_session` and the `ask_sessions` fan-out, so the
|
|
5759
|
+
* wait/collect/classify semantics have exactly one implementation.
|
|
5760
|
+
*
|
|
5761
|
+
* All failure paths are already converted into outcome notes by
|
|
5762
|
+
* {@link waitForTargetIdle} / {@link collectTargetAnswer}; this helper only
|
|
5763
|
+
* throws if the caller's own abort mechanism throws.
|
|
5764
|
+
*/
|
|
5765
|
+
async function collectDeliveredAsk(controller, signal, input) {
|
|
5766
|
+
const outcome = await waitForTargetIdle(controller, signal, {
|
|
5767
|
+
sessionId: input.target.sessionId,
|
|
5768
|
+
targetWasRunning: input.targetWasRunning,
|
|
5769
|
+
waitMs: input.waitMs,
|
|
5770
|
+
log: input.log,
|
|
5771
|
+
fromSeq: input.fromSeq
|
|
5772
|
+
});
|
|
5773
|
+
const collected = await collectTargetAnswer(controller, input.target.sessionId, input.fromSeq);
|
|
5774
|
+
const rawAnswer = joinAnswerPieces(collected.pieces);
|
|
5775
|
+
const reply = parseAskReply(rawAnswer);
|
|
5776
|
+
const status = askStatus({
|
|
5777
|
+
endedBecause: outcome.endedBecause,
|
|
5778
|
+
answer: rawAnswer
|
|
5779
|
+
});
|
|
5780
|
+
const to = outcome.endedBecause === "idle" ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
5781
|
+
const notes = [
|
|
5782
|
+
"区间语义:从投递回执到对方会话下一次整体空闲,这两个时刻之间对方产出的文字都算在内——这是「这个区间里对方说的话」,不是逐条对应的回答。",
|
|
5783
|
+
input.deliveryNote,
|
|
5784
|
+
outcome.note,
|
|
5785
|
+
input.targetWasRunning ? "投递时对方正在忙,问题已排队,会在它当前回合结束后进入新回合。" : "投递时对方不忙,宿主已唤醒它开一个新回合。",
|
|
5786
|
+
collected.readError === void 0 ? "" : `读取对方会话日志时出错,answer 可能不完整:${collected.readError}`
|
|
5787
|
+
].filter((line) => line.length > 0);
|
|
5788
|
+
return {
|
|
5789
|
+
interval: {
|
|
5790
|
+
from: input.from,
|
|
5791
|
+
to,
|
|
5792
|
+
endedBecause: outcome.endedBecause
|
|
5793
|
+
},
|
|
5794
|
+
rawAnswer,
|
|
5795
|
+
...reply === void 0 ? {} : { reply },
|
|
5796
|
+
status,
|
|
5797
|
+
note: notes.join("\n"),
|
|
5798
|
+
answer: rawAnswer
|
|
5799
|
+
};
|
|
5800
|
+
}
|
|
5801
|
+
let registryProbeLogged = false;
|
|
5802
|
+
/** Print the registry probe at most once per process, for host-log diagnosis. */
|
|
5803
|
+
function logRegistryProbe(found, workspaces, sessions) {
|
|
5804
|
+
if (registryProbeLogged) return;
|
|
5805
|
+
registryProbeLogged = true;
|
|
5806
|
+
console.log(found ? `[dsh-advisor-group] workspaceRegistry: ok (${workspaces} 个工区, ${sessions} 个已登记会话)` : "[dsh-advisor-group] workspaceRegistry: unavailable — 已退回不过滤");
|
|
5807
|
+
}
|
|
5808
|
+
/**
|
|
5809
|
+
* Resolve the host workspace registry from the plugin context. Cordis exposes a
|
|
5810
|
+
* service either as a plain property or through reflection (`ctx.reflect.get`,
|
|
5811
|
+
* which in non-strict mode returns undefined when the service is absent); a
|
|
5812
|
+
* plain `ctx.get` is accepted as the last resort. Every path is tried in order
|
|
5813
|
+
* and any throw falls through, so a host without the service simply yields
|
|
5814
|
+
* `undefined` (no filtering) instead of failing the caller.
|
|
5815
|
+
*/
|
|
5816
|
+
function workspaceRegistryService(ctx) {
|
|
5817
|
+
const paths = [
|
|
5818
|
+
() => ctx?.workspaceRegistry,
|
|
5819
|
+
() => {
|
|
5820
|
+
const reflect = ctx?.reflect;
|
|
5821
|
+
const get = reflect?.get;
|
|
5822
|
+
if (typeof get !== "function") return void 0;
|
|
5823
|
+
return get.call(reflect, "workspaceRegistry", false);
|
|
5824
|
+
},
|
|
5825
|
+
() => {
|
|
5826
|
+
const get = ctx?.get;
|
|
5827
|
+
if (typeof get !== "function") return void 0;
|
|
5828
|
+
return get.call(ctx, "workspaceRegistry");
|
|
5829
|
+
}
|
|
5830
|
+
];
|
|
5831
|
+
for (const resolve of paths) try {
|
|
5832
|
+
const value = resolve();
|
|
5833
|
+
if (value !== null && typeof value === "object") return value;
|
|
5834
|
+
} catch {}
|
|
5835
|
+
}
|
|
5836
|
+
/**
|
|
5837
|
+
* Archived session ids from the host workspace registry
|
|
5838
|
+
* (`workspaceRegistry.archivedSessionIds`, iterable). Used for the explicit
|
|
5839
|
+
* "this session is archived" refusal, and subtracted from the registered set.
|
|
5840
|
+
*
|
|
5841
|
+
* The registry is resolved through {@link workspaceRegistryService}; a missing
|
|
5842
|
+
* service (older host) or a throwing accessor yields an empty set: no
|
|
5843
|
+
* filtering, no error.
|
|
5844
|
+
*/
|
|
5845
|
+
function archivedSessionIds(ctx) {
|
|
5846
|
+
try {
|
|
5847
|
+
const ids = workspaceRegistryService(ctx)?.archivedSessionIds;
|
|
5848
|
+
if (ids === null || ids === void 0) return /* @__PURE__ */ new Set();
|
|
5849
|
+
if (typeof ids[Symbol.iterator] !== "function") return /* @__PURE__ */ new Set();
|
|
5850
|
+
return new Set([...ids].map((id) => String(id)));
|
|
5851
|
+
} catch {
|
|
5852
|
+
return /* @__PURE__ */ new Set();
|
|
5853
|
+
}
|
|
5854
|
+
}
|
|
5855
|
+
/**
|
|
5856
|
+
* The session ids the host's sidebar shows: the union of every workspace's
|
|
5857
|
+
* `sessionIds` (`workspaceRegistry.list()`), minus the archived ones.
|
|
5858
|
+
*
|
|
5859
|
+
* This is the display universe: `sessionController.list()` also returns
|
|
5860
|
+
* sessions that no workspace registers (e.g. sessions the user moved out of
|
|
5861
|
+
* every workspace), and the sidebar hides those. Callers filter with this set.
|
|
5862
|
+
*
|
|
5863
|
+
* The registry itself is resolved through {@link workspaceRegistryService}
|
|
5864
|
+
* (plain property, then `ctx.reflect.get(..., false)`, then `ctx.get`), so it
|
|
5865
|
+
* works on hosts that only expose the service through reflection.
|
|
5866
|
+
*
|
|
5867
|
+
* Returns `undefined` when the host has no workspace registry, its `list` is not
|
|
5868
|
+
* a function, `list()` does not return an array, or anything throws — callers
|
|
5869
|
+
* then skip the filter entirely (old-host behaviour), never fail. The outcome
|
|
5870
|
+
* is logged once per process for diagnosis.
|
|
5871
|
+
*/
|
|
5872
|
+
function registeredSessionIds(ctx) {
|
|
5873
|
+
try {
|
|
5874
|
+
const registry = workspaceRegistryService(ctx);
|
|
5875
|
+
if (registry === void 0) {
|
|
5876
|
+
logRegistryProbe(false, 0, 0);
|
|
5877
|
+
return;
|
|
5878
|
+
}
|
|
5879
|
+
const list = registry.list;
|
|
5880
|
+
if (typeof list !== "function") {
|
|
5881
|
+
logRegistryProbe(false, 0, 0);
|
|
5882
|
+
return;
|
|
5883
|
+
}
|
|
5884
|
+
const workspaces = list.call(registry);
|
|
5885
|
+
if (!Array.isArray(workspaces)) {
|
|
5886
|
+
logRegistryProbe(false, 0, 0);
|
|
5887
|
+
return;
|
|
5888
|
+
}
|
|
5889
|
+
const ids = /* @__PURE__ */ new Set();
|
|
5890
|
+
let workspaceCount = 0;
|
|
5891
|
+
for (const workspace of workspaces) {
|
|
5892
|
+
if (workspace === null || typeof workspace !== "object") continue;
|
|
5893
|
+
workspaceCount += 1;
|
|
5894
|
+
const sessionIds = workspace.sessionIds;
|
|
5895
|
+
if (sessionIds === null || sessionIds === void 0) continue;
|
|
5896
|
+
if (typeof sessionIds[Symbol.iterator] !== "function") continue;
|
|
5897
|
+
for (const id of sessionIds) ids.add(String(id));
|
|
5898
|
+
}
|
|
5899
|
+
for (const archived of archivedSessionIds(ctx)) ids.delete(archived);
|
|
5900
|
+
logRegistryProbe(true, workspaceCount, ids.size);
|
|
5901
|
+
return ids;
|
|
5902
|
+
} catch {
|
|
5903
|
+
logRegistryProbe(false, 0, 0);
|
|
5904
|
+
return;
|
|
5905
|
+
}
|
|
5906
|
+
}
|
|
5907
|
+
/**
|
|
5908
|
+
* The listed sessions that may take part in an ask: `swarm.routingExclude`
|
|
5909
|
+
* matches are dropped, and so is every session the workspace registry does not
|
|
5910
|
+
* register. A `undefined` registered set means "no registry": no filtering.
|
|
5911
|
+
* Shared by `ask_session` and `ask_sessions`.
|
|
5912
|
+
*/
|
|
5913
|
+
function allowedAskSessions(sessions, exclude, registered) {
|
|
5914
|
+
const entries = exclusionEntries$1(exclude);
|
|
5915
|
+
return sessions.filter((session) => !summaryExcluded(entries, session) && (registered === void 0 || registered.has(session.sessionId)));
|
|
5916
|
+
}
|
|
5917
|
+
/**
|
|
5535
5918
|
* Every pre-delivery guard, in one place. Pure: takes plain facts, returns a
|
|
5536
5919
|
* rejection or nothing. Order matters — identity before ownership, ownership
|
|
5537
5920
|
* before budget, budget before hop.
|
|
@@ -5539,16 +5922,24 @@ async function collectTargetAnswer(controller, sessionId, fromSeq) {
|
|
|
5539
5922
|
*/
|
|
5540
5923
|
function checkAskGuards(input) {
|
|
5541
5924
|
const { guards, match } = input;
|
|
5542
|
-
if (match.kind === "missing")
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5925
|
+
if (match.kind === "missing") {
|
|
5926
|
+
const pool = recentCandidates(input.sessions);
|
|
5927
|
+
const block = renderCandidateBlock(pool, { sameTitle: false });
|
|
5928
|
+
return {
|
|
5929
|
+
code: "target-not-found",
|
|
5930
|
+
message: `没有找到匹配的会话。下面列出最近的会话,请把候选交给用户挑选(标题或 sessionId 都可以);不要再去翻会话日志或目录找 id。${block.length > 0 ? `\n${block}` : ""}`,
|
|
5931
|
+
candidates: pool.slice(0, NOT_FOUND_CANDIDATE_LIMIT)
|
|
5932
|
+
};
|
|
5933
|
+
}
|
|
5934
|
+
if (match.kind === "ambiguous") {
|
|
5935
|
+
const total = Math.max(match.candidates.length, match.total ?? match.candidates.length);
|
|
5936
|
+
const block = renderCandidateBlock(match.candidates, { total });
|
|
5937
|
+
return {
|
|
5938
|
+
code: "target-ambiguous",
|
|
5939
|
+
message: `有 ${total} 个会话都叫这个名字,不能猜。请把候选交给用户挑选(用 sessionId 指定即可精确命中)。${block.length > 0 ? `\n${block}` : ""}`,
|
|
5940
|
+
candidates: match.candidates
|
|
5941
|
+
};
|
|
5942
|
+
}
|
|
5552
5943
|
const target = input.target ?? match.target;
|
|
5553
5944
|
if (target.sessionId === guards.currentSessionId) return {
|
|
5554
5945
|
code: "self-target",
|
|
@@ -5591,15 +5982,32 @@ async function askSession(ctx, signal, input) {
|
|
|
5591
5982
|
const sessions = [];
|
|
5592
5983
|
const allowed = [];
|
|
5593
5984
|
const entries = exclusionEntries$1(input.exclude);
|
|
5985
|
+
const archived = archivedSessionIds(ctx);
|
|
5986
|
+
const registered = registeredSessionIds(ctx);
|
|
5594
5987
|
let match = { kind: "missing" };
|
|
5595
5988
|
try {
|
|
5596
5989
|
const listed = await controller.list({}, signal);
|
|
5597
5990
|
sessions.push(...listed.items);
|
|
5598
|
-
for (const session of sessions)
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5991
|
+
for (const session of sessions) {
|
|
5992
|
+
if (summaryExcluded(entries, session)) continue;
|
|
5993
|
+
if (registered !== void 0 && !registered.has(session.sessionId)) continue;
|
|
5994
|
+
allowed.push(session);
|
|
5995
|
+
}
|
|
5996
|
+
const targetText = (input.target ?? "").trim();
|
|
5997
|
+
match = matchTargetSession(allowed, targetText);
|
|
5998
|
+
if (match.kind === "missing" && targetText.length > 0) {
|
|
5999
|
+
const full = matchTargetSession(sessions, targetText);
|
|
6000
|
+
if (full.kind === "unique") {
|
|
6001
|
+
if (archived.has(full.target.sessionId)) return toToolError({
|
|
6002
|
+
code: "session-archived",
|
|
6003
|
+
message: ARCHIVED_SESSION_MESSAGE
|
|
6004
|
+
});
|
|
6005
|
+
if (registered !== void 0 && !registered.has(full.target.sessionId)) return toToolError({
|
|
6006
|
+
code: "session-unregistered",
|
|
6007
|
+
message: UNREGISTERED_SESSION_MESSAGE
|
|
6008
|
+
});
|
|
6009
|
+
}
|
|
6010
|
+
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))) return toToolError({
|
|
5603
6011
|
code: "swarm-excluded",
|
|
5604
6012
|
message: "这个会话被设置为不参与蜂群(设置 → 插件 → 顾问群 → 全局蜂群 → 不参与路由的会话)。"
|
|
5605
6013
|
});
|
|
@@ -5684,6 +6092,8 @@ async function runAsk(ctx, controller, signal, input, listed) {
|
|
|
5684
6092
|
state: "waiting",
|
|
5685
6093
|
createdAt: Date.now(),
|
|
5686
6094
|
from,
|
|
6095
|
+
bootId: ASK_BOOT_ID,
|
|
6096
|
+
fromSeq: delivery.receipt.fromSeq,
|
|
5687
6097
|
...parentAskId === void 0 ? {} : { parentAskId }
|
|
5688
6098
|
};
|
|
5689
6099
|
try {
|
|
@@ -5721,15 +6131,47 @@ async function runAsk(ctx, controller, signal, input, listed) {
|
|
|
5721
6131
|
error: error instanceof Error ? error.message : String(error)
|
|
5722
6132
|
};
|
|
5723
6133
|
} finally {
|
|
6134
|
+
let finalRecord = {
|
|
6135
|
+
...record,
|
|
6136
|
+
...settled,
|
|
6137
|
+
settledAt: Date.now()
|
|
6138
|
+
};
|
|
5724
6139
|
try {
|
|
5725
|
-
writeAskRecord(storeDir,
|
|
5726
|
-
...record,
|
|
5727
|
-
...settled,
|
|
5728
|
-
settledAt: Date.now()
|
|
5729
|
-
});
|
|
6140
|
+
writeAskRecord(storeDir, finalRecord);
|
|
5730
6141
|
} catch (error) {
|
|
5731
6142
|
console.warn("[dsh-advisor-group] 异步结果落盘失败:", error);
|
|
5732
6143
|
}
|
|
6144
|
+
if (input.onSettled) try {
|
|
6145
|
+
input.onSettled({
|
|
6146
|
+
askId,
|
|
6147
|
+
question: input.question,
|
|
6148
|
+
targetName: target.name,
|
|
6149
|
+
answer: finalRecord.answer ?? "",
|
|
6150
|
+
note: finalRecord.note ?? (finalRecord.error ? `等待/收集失败:${finalRecord.error}` : "等待结束。")
|
|
6151
|
+
});
|
|
6152
|
+
} catch (error) {
|
|
6153
|
+
console.warn("[dsh-advisor-group] 跨会话卡片(结束)回调失败:", error);
|
|
6154
|
+
}
|
|
6155
|
+
if (finalRecord.state === "done" && input.deliverBack !== false) {
|
|
6156
|
+
const outcome = await deliverAnswerBack(controller, {
|
|
6157
|
+
askerSessionId: finalRecord.sessionId,
|
|
6158
|
+
targetSessionId: finalRecord.targetSessionId,
|
|
6159
|
+
targetName: finalRecord.targetName,
|
|
6160
|
+
askId: finalRecord.askId
|
|
6161
|
+
}, input.onSettled ? buildAnswerBackNotice(finalRecord.targetName, finalRecord.askId) : buildAnswerBackText(finalRecord.targetName, finalRecord.askId, finalRecord.answer ?? ""));
|
|
6162
|
+
finalRecord = outcome.ok ? {
|
|
6163
|
+
...finalRecord,
|
|
6164
|
+
deliveredBack: true
|
|
6165
|
+
} : {
|
|
6166
|
+
...finalRecord,
|
|
6167
|
+
deliverBackNote: `自动回送失败:${outcome.error ?? "unknown"}(答案仍在记录里,可用 ask_result 取回)`
|
|
6168
|
+
};
|
|
6169
|
+
try {
|
|
6170
|
+
writeAskRecord(storeDir, finalRecord);
|
|
6171
|
+
} catch (error) {
|
|
6172
|
+
console.warn("[dsh-advisor-group] 回送状态落盘失败:", error);
|
|
6173
|
+
}
|
|
6174
|
+
}
|
|
5733
6175
|
detachedControllers.delete(background);
|
|
5734
6176
|
}
|
|
5735
6177
|
})();
|
|
@@ -5751,35 +6193,21 @@ async function runAsk(ctx, controller, signal, input, listed) {
|
|
|
5751
6193
|
note: `已投递(异步):问题已交给「${target.name}」,本次调用不等待答复。稍后用 ask_result(askId=${askId})读取结果。`
|
|
5752
6194
|
};
|
|
5753
6195
|
}
|
|
5754
|
-
const
|
|
5755
|
-
|
|
5756
|
-
targetWasRunning: delivery.receipt.targetWasRunning,
|
|
6196
|
+
const collected = await collectDeliveredAsk(controller, signal, {
|
|
6197
|
+
target,
|
|
5757
6198
|
waitMs,
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
const answer = rawAnswer;
|
|
5764
|
-
const reply = parseAskReply(rawAnswer);
|
|
5765
|
-
const status = askStatus({
|
|
5766
|
-
endedBecause: outcome.endedBecause,
|
|
5767
|
-
answer
|
|
6199
|
+
from,
|
|
6200
|
+
targetWasRunning: delivery.receipt.targetWasRunning,
|
|
6201
|
+
fromSeq: delivery.receipt.fromSeq,
|
|
6202
|
+
deliveryNote: delivery.receipt.note,
|
|
6203
|
+
log: listed.log
|
|
5768
6204
|
});
|
|
5769
|
-
const to = outcome.endedBecause === "idle" ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
5770
|
-
const note = [
|
|
5771
|
-
"区间语义:从投递回执到对方会话下一次整体空闲,这两个时刻之间对方产出的文字都算在内——这是「这个区间里对方说的话」,不是逐条对应的回答。",
|
|
5772
|
-
delivery.receipt.note,
|
|
5773
|
-
outcome.note,
|
|
5774
|
-
delivery.receipt.targetWasRunning ? "投递时对方正在忙,问题已排队,会在它当前回合结束后进入新回合。" : "投递时对方不忙,宿主已唤醒它开一个新回合。",
|
|
5775
|
-
collected.readError === void 0 ? "" : `读取对方会话日志时出错,answer 可能不完整:${collected.readError}`
|
|
5776
|
-
].filter((line) => line.length > 0).join("\n");
|
|
5777
6205
|
input.onSettled?.({
|
|
5778
6206
|
askId,
|
|
5779
6207
|
question: input.question,
|
|
5780
6208
|
targetName: target.name,
|
|
5781
|
-
answer: rawAnswer,
|
|
5782
|
-
note
|
|
6209
|
+
answer: collected.rawAnswer,
|
|
6210
|
+
note: collected.note
|
|
5783
6211
|
});
|
|
5784
6212
|
return {
|
|
5785
6213
|
ok: true,
|
|
@@ -5789,15 +6217,11 @@ async function runAsk(ctx, controller, signal, input, listed) {
|
|
|
5789
6217
|
},
|
|
5790
6218
|
delivered: delivery.receipt.delivered,
|
|
5791
6219
|
askId,
|
|
5792
|
-
interval:
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
answer,
|
|
5798
|
-
...reply === void 0 ? {} : { reply },
|
|
5799
|
-
status,
|
|
5800
|
-
note
|
|
6220
|
+
interval: collected.interval,
|
|
6221
|
+
answer: collected.answer,
|
|
6222
|
+
...collected.reply === void 0 ? {} : { reply: collected.reply },
|
|
6223
|
+
status: collected.status,
|
|
6224
|
+
note: collected.note
|
|
5801
6225
|
};
|
|
5802
6226
|
}
|
|
5803
6227
|
function toToolError(rejection) {
|
|
@@ -5808,46 +6232,536 @@ function toToolError(rejection) {
|
|
|
5808
6232
|
...rejection.candidates ? { candidates: rejection.candidates } : {}
|
|
5809
6233
|
};
|
|
5810
6234
|
}
|
|
6235
|
+
const SESSION_ID_MAX = 200;
|
|
6236
|
+
/** Path of the role-card fixture under the DSH home. */
|
|
6237
|
+
function corpsFile() {
|
|
6238
|
+
const home = process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
6239
|
+
return join(home, "storages", "advisor-group", "corps.json");
|
|
6240
|
+
}
|
|
6241
|
+
function isRecord(value) {
|
|
6242
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6243
|
+
}
|
|
6244
|
+
/**
|
|
6245
|
+
* Read the card map. A missing, damaged or partially invalid file degrades to
|
|
6246
|
+
* the valid cards it still contains (never throws): the panel and the target
|
|
6247
|
+
* resolver must keep working.
|
|
6248
|
+
*/
|
|
6249
|
+
function loadCorpsCards(file = corpsFile()) {
|
|
6250
|
+
let parsed;
|
|
6251
|
+
try {
|
|
6252
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
6253
|
+
} catch {
|
|
6254
|
+
return {};
|
|
6255
|
+
}
|
|
6256
|
+
if (!isRecord(parsed) || !isRecord(parsed.cards)) return {};
|
|
6257
|
+
const cards = {};
|
|
6258
|
+
for (const [sessionId, value] of Object.entries(parsed.cards)) {
|
|
6259
|
+
if (sessionId.length === 0 || sessionId.length > SESSION_ID_MAX || !isRecord(value)) continue;
|
|
6260
|
+
const name = typeof value.name === "string" ? value.name.trim() : "";
|
|
6261
|
+
const duty = typeof value.duty === "string" ? value.duty.trim() : "";
|
|
6262
|
+
if (name.length === 0 || name.length > 40 || duty.length > 200) continue;
|
|
6263
|
+
cards[sessionId] = {
|
|
6264
|
+
name,
|
|
6265
|
+
duty,
|
|
6266
|
+
updatedAt: typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt) ? value.updatedAt : Date.now()
|
|
6267
|
+
};
|
|
6268
|
+
}
|
|
6269
|
+
return cards;
|
|
6270
|
+
}
|
|
6271
|
+
/** Atomic tmp+rename write; creates the directory when missing. */
|
|
6272
|
+
function saveCorpsCards(cards, file = corpsFile()) {
|
|
6273
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
6274
|
+
const tmp = `${file}.tmp`;
|
|
6275
|
+
writeFileSync(tmp, JSON.stringify({
|
|
6276
|
+
version: 1,
|
|
6277
|
+
cards
|
|
6278
|
+
}, null, 2), "utf8");
|
|
6279
|
+
renameSync(tmp, file);
|
|
6280
|
+
}
|
|
6281
|
+
/**
|
|
6282
|
+
* Upsert one card. An empty name deletes that session's card (the panel's
|
|
6283
|
+
* 「移除」 action). Names are unique case-insensitively across the whole map —
|
|
6284
|
+
* `ask_session` resolves a role name to exactly one session, never a guess.
|
|
6285
|
+
*/
|
|
6286
|
+
function upsertCorpsCard(cards, sessionId, rawName, rawDuty, now = Date.now()) {
|
|
6287
|
+
const id = sessionId.trim();
|
|
6288
|
+
if (id.length === 0 || id.length > SESSION_ID_MAX) return {
|
|
6289
|
+
ok: false,
|
|
6290
|
+
code: "invalid-session",
|
|
6291
|
+
error: "会话 id 不合法。"
|
|
6292
|
+
};
|
|
6293
|
+
const name = rawName.trim();
|
|
6294
|
+
const duty = rawDuty.trim();
|
|
6295
|
+
if (name.length === 0) {
|
|
6296
|
+
const next = { ...cards };
|
|
6297
|
+
delete next[id];
|
|
6298
|
+
return {
|
|
6299
|
+
ok: true,
|
|
6300
|
+
cards: next
|
|
6301
|
+
};
|
|
6302
|
+
}
|
|
6303
|
+
if (name.length > 40) return {
|
|
6304
|
+
ok: false,
|
|
6305
|
+
code: "invalid-name",
|
|
6306
|
+
error: `姓名不能超过 40 个字符。`
|
|
6307
|
+
};
|
|
6308
|
+
if (duty.length > 200) return {
|
|
6309
|
+
ok: false,
|
|
6310
|
+
code: "invalid-duty",
|
|
6311
|
+
error: `职责不能超过 200 个字符。`
|
|
6312
|
+
};
|
|
6313
|
+
const lower = name.toLowerCase();
|
|
6314
|
+
for (const [otherId, card] of Object.entries(cards)) if (otherId !== id && card.name.toLowerCase() === lower) return {
|
|
6315
|
+
ok: false,
|
|
6316
|
+
code: "duplicate-name",
|
|
6317
|
+
error: `姓名「${name}」已经被另一个会话使用,请换一个。`
|
|
6318
|
+
};
|
|
6319
|
+
const card = {
|
|
6320
|
+
name,
|
|
6321
|
+
duty,
|
|
6322
|
+
updatedAt: now
|
|
6323
|
+
};
|
|
6324
|
+
return {
|
|
6325
|
+
ok: true,
|
|
6326
|
+
cards: {
|
|
6327
|
+
...cards,
|
|
6328
|
+
[id]: card
|
|
6329
|
+
},
|
|
6330
|
+
card
|
|
6331
|
+
};
|
|
6332
|
+
}
|
|
6333
|
+
/**
|
|
6334
|
+
* Exact, case-insensitive role-name lookup. Duplicate names (only possible in a
|
|
6335
|
+
* hand-edited file) resolve to nothing, so a target is never guessed.
|
|
6336
|
+
*/
|
|
6337
|
+
function findCorpsSession(cards, target) {
|
|
6338
|
+
const wanted = target.trim().toLowerCase();
|
|
6339
|
+
if (wanted.length === 0) return void 0;
|
|
6340
|
+
const hits = Object.entries(cards).filter(([, card]) => card.name.toLowerCase() === wanted);
|
|
6341
|
+
return hits.length === 1 ? hits[0][0] : void 0;
|
|
6342
|
+
}
|
|
5811
6343
|
//#endregion
|
|
5812
|
-
//#region src/
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
6344
|
+
//#region src/multi-ask.ts
|
|
6345
|
+
/**
|
|
6346
|
+
* Batch cross-session ask (`ask_sessions`): one question to 1–8 targets in one
|
|
6347
|
+
* call, with every result collected into one summary. Each target gets its own
|
|
6348
|
+
* delivery, its own wait and its own AskRecord, so `ask_result <askId>` keeps
|
|
6349
|
+
* working afterwards and `parentAskId` is filled the same way `ask_session`
|
|
6350
|
+
* fills it.
|
|
6351
|
+
*
|
|
6352
|
+
* This module owns no delivery or collection logic: it reuses
|
|
6353
|
+
* {@link deliverAsk} for the handover and {@link collectDeliveredAsk} for the
|
|
6354
|
+
* wait/collect half, plus {@link matchTargetSession} / {@link checkAskGuards}
|
|
6355
|
+
* for resolution and the per-target guards. There is no routing here: an
|
|
6356
|
+
* unnamed or ambiguous target is skipped with a reason, never guessed.
|
|
6357
|
+
*/
|
|
6358
|
+
/** Schema cap for one batch; extra targets are dropped (the schema says 1–8). */
|
|
6359
|
+
const BATCH_MAX_TARGETS = 8;
|
|
6360
|
+
const BATCH_SWARM_DISABLED = "蜂群模式已在设置里关闭(设置 → 插件 → 顾问群 → 全局蜂群)。";
|
|
6361
|
+
/** Concise per-target reason for a guard rejection (ask_session wording is long). */
|
|
6362
|
+
function skipReasonFor(rejection) {
|
|
6363
|
+
switch (rejection.code) {
|
|
6364
|
+
case "self-target": return "目标是当前会话自己";
|
|
6365
|
+
case "subagent-target": return "目标会话是子代理(subagent)会话";
|
|
6366
|
+
case "hop-limit": return "本次提问会超过 2 跳转发上限";
|
|
6367
|
+
default: return rejection.message;
|
|
6368
|
+
}
|
|
6369
|
+
}
|
|
6370
|
+
/** One child signal that aborts with its parent and on plugin dispose. */
|
|
6371
|
+
function linkedSignal(parent) {
|
|
6372
|
+
const controller = new AbortController();
|
|
6373
|
+
const onAbort = () => controller.abort();
|
|
6374
|
+
if (parent.aborted) controller.abort();
|
|
6375
|
+
else parent.addEventListener("abort", onAbort, { once: true });
|
|
6376
|
+
const untrack = trackBackgroundController(controller);
|
|
6377
|
+
return {
|
|
6378
|
+
signal: controller.signal,
|
|
6379
|
+
dispose: () => {
|
|
6380
|
+
parent.removeEventListener("abort", onAbort);
|
|
6381
|
+
untrack();
|
|
6382
|
+
}
|
|
6383
|
+
};
|
|
6384
|
+
}
|
|
6385
|
+
/**
|
|
6386
|
+
* Deliver to one target, write its waiting record, then wait/collect and write
|
|
6387
|
+
* the settled record. Never throws: a failure becomes an `error` result (and,
|
|
6388
|
+
* once delivered, an `error` record so `ask_result` still reports it).
|
|
6389
|
+
*/
|
|
6390
|
+
async function askOne(plan, batch) {
|
|
6391
|
+
const askId = nextAskId();
|
|
6392
|
+
const from = (/* @__PURE__ */ new Date()).toISOString();
|
|
6393
|
+
const linked = linkedSignal(batch.parentSignal);
|
|
6394
|
+
let record;
|
|
6395
|
+
try {
|
|
6396
|
+
const source = {
|
|
6397
|
+
kind: "session-ask",
|
|
6398
|
+
form: "relay",
|
|
6399
|
+
askId,
|
|
6400
|
+
senderSessionId: batch.currentSessionId,
|
|
6401
|
+
senderName: batch.senderName,
|
|
6402
|
+
hop: batch.hop
|
|
6403
|
+
};
|
|
6404
|
+
const delivery = await deliverAsk(batch.controller, linked.signal, {
|
|
6405
|
+
target: plan.target,
|
|
6406
|
+
question: batch.question,
|
|
6407
|
+
context: batch.context,
|
|
6408
|
+
source,
|
|
6409
|
+
targetWasRunning: plan.targetWasRunning
|
|
6410
|
+
});
|
|
6411
|
+
if (!delivery.ok) return {
|
|
6412
|
+
sessionId: plan.target.sessionId,
|
|
6413
|
+
name: plan.target.name,
|
|
6414
|
+
askId,
|
|
6415
|
+
state: "error",
|
|
6416
|
+
answer: "",
|
|
6417
|
+
error: `${delivery.code}: ${delivery.message}`
|
|
6418
|
+
};
|
|
6419
|
+
const waitingRecord = {
|
|
6420
|
+
askId,
|
|
6421
|
+
sessionId: batch.currentSessionId,
|
|
6422
|
+
targetSessionId: plan.target.sessionId,
|
|
6423
|
+
targetName: plan.target.name,
|
|
6424
|
+
question: batch.question,
|
|
6425
|
+
state: "waiting",
|
|
6426
|
+
createdAt: Date.now(),
|
|
6427
|
+
from,
|
|
6428
|
+
bootId: ASK_BOOT_ID,
|
|
6429
|
+
fromSeq: delivery.receipt.fromSeq,
|
|
6430
|
+
...batch.parentAskId === void 0 ? {} : { parentAskId: batch.parentAskId }
|
|
6431
|
+
};
|
|
6432
|
+
record = waitingRecord;
|
|
6433
|
+
try {
|
|
6434
|
+
writeAskRecord(batch.storeDir, waitingRecord);
|
|
6435
|
+
} catch (error) {
|
|
6436
|
+
console.warn("[dsh-advisor-group] 批量提问记录落盘失败(投递仍已发出):", error);
|
|
6437
|
+
}
|
|
6438
|
+
const collected = await collectDeliveredAsk(batch.controller, linked.signal, {
|
|
6439
|
+
target: plan.target,
|
|
6440
|
+
waitMs: batch.waitMs,
|
|
6441
|
+
from,
|
|
6442
|
+
targetWasRunning: delivery.receipt.targetWasRunning,
|
|
6443
|
+
fromSeq: delivery.receipt.fromSeq,
|
|
6444
|
+
deliveryNote: delivery.receipt.note,
|
|
6445
|
+
log: batch.log
|
|
6446
|
+
});
|
|
6447
|
+
const state = collected.interval.endedBecause === "idle" ? "done" : collected.interval.endedBecause === "timeout" ? "timeout" : "error";
|
|
6448
|
+
try {
|
|
6449
|
+
writeAskRecord(batch.storeDir, {
|
|
6450
|
+
...waitingRecord,
|
|
6451
|
+
state,
|
|
6452
|
+
answer: collected.rawAnswer,
|
|
6453
|
+
...collected.reply === void 0 ? {} : { reply: collected.reply },
|
|
6454
|
+
status: collected.status,
|
|
6455
|
+
note: collected.note,
|
|
6456
|
+
settledAt: Date.now()
|
|
6457
|
+
});
|
|
6458
|
+
} catch (error) {
|
|
6459
|
+
console.warn("[dsh-advisor-group] 批量提问记录落盘失败:", error);
|
|
6460
|
+
}
|
|
6461
|
+
return {
|
|
6462
|
+
sessionId: plan.target.sessionId,
|
|
6463
|
+
name: plan.target.name,
|
|
6464
|
+
askId,
|
|
6465
|
+
state,
|
|
6466
|
+
answer: collected.answer,
|
|
6467
|
+
status: collected.status,
|
|
6468
|
+
note: collected.note
|
|
6469
|
+
};
|
|
6470
|
+
} catch (error) {
|
|
6471
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6472
|
+
if (record !== void 0) try {
|
|
6473
|
+
writeAskRecord(batch.storeDir, {
|
|
6474
|
+
...record,
|
|
6475
|
+
state: "error",
|
|
6476
|
+
error: message,
|
|
6477
|
+
settledAt: Date.now()
|
|
6478
|
+
});
|
|
6479
|
+
} catch {}
|
|
6480
|
+
return {
|
|
6481
|
+
sessionId: plan.target.sessionId,
|
|
6482
|
+
name: plan.target.name,
|
|
6483
|
+
askId,
|
|
6484
|
+
state: "error",
|
|
6485
|
+
answer: "",
|
|
6486
|
+
error: message
|
|
6487
|
+
};
|
|
6488
|
+
} finally {
|
|
6489
|
+
linked.dispose();
|
|
6490
|
+
}
|
|
6491
|
+
}
|
|
6492
|
+
/**
|
|
6493
|
+
* One merged copy-back for the whole batch: only `done` answers are included
|
|
6494
|
+
* (timeout / error stay silent), sent after every target settled, and the
|
|
6495
|
+
* done records are marked (or annotated on failure). Best effort throughout:
|
|
6496
|
+
* a copy-back failure must never throw or lose the stored answer.
|
|
6497
|
+
*/
|
|
6498
|
+
async function deliverMergedBack(batch, results) {
|
|
6499
|
+
if (!batch.deliverBack) return;
|
|
6500
|
+
const done = results.filter((row) => row.state === "done");
|
|
6501
|
+
if (done.length === 0) return;
|
|
6502
|
+
const text = buildBatchAnswerBackText(done.map((row) => ({
|
|
6503
|
+
targetName: row.name,
|
|
6504
|
+
askId: row.askId,
|
|
6505
|
+
answer: row.answer
|
|
6506
|
+
})));
|
|
6507
|
+
const outcome = await deliverAnswerBack(batch.controller, {
|
|
6508
|
+
askerSessionId: batch.currentSessionId,
|
|
6509
|
+
targetSessionId: done[0].sessionId,
|
|
6510
|
+
targetName: done[0].name,
|
|
6511
|
+
askId: nextAskId()
|
|
6512
|
+
}, text);
|
|
6513
|
+
for (const row of done) try {
|
|
6514
|
+
const record = readAskRecord(batch.storeDir, row.askId);
|
|
6515
|
+
if (record === void 0 || record.deliveredBack === true) continue;
|
|
6516
|
+
writeAskRecord(batch.storeDir, outcome.ok ? {
|
|
6517
|
+
...record,
|
|
6518
|
+
deliveredBack: true
|
|
6519
|
+
} : {
|
|
6520
|
+
...record,
|
|
6521
|
+
deliverBackNote: `自动回送失败:${outcome.error ?? "unknown"}(答案仍在记录里,可用 ask_result 取回)`
|
|
6522
|
+
});
|
|
6523
|
+
} catch {}
|
|
6524
|
+
}
|
|
6525
|
+
/** `targets` → trimmed, de-duplicated (exact spelling), capped at the schema limit. */
|
|
6526
|
+
function readTargets(targets) {
|
|
6527
|
+
const values = Array.isArray(targets) ? targets : [];
|
|
6528
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6529
|
+
const picked = [];
|
|
6530
|
+
for (const value of values) {
|
|
6531
|
+
if (typeof value !== "string") continue;
|
|
6532
|
+
const trimmed = value.trim();
|
|
6533
|
+
if (seen.has(trimmed)) continue;
|
|
6534
|
+
seen.add(trimmed);
|
|
6535
|
+
picked.push(trimmed);
|
|
6536
|
+
if (picked.length >= BATCH_MAX_TARGETS) break;
|
|
6537
|
+
}
|
|
6538
|
+
return picked;
|
|
6539
|
+
}
|
|
6540
|
+
/** All-skipped answer: `ok: false` with the reasons, never an exception. */
|
|
6541
|
+
function skippedOnly(skipped) {
|
|
6542
|
+
return {
|
|
6543
|
+
ok: false,
|
|
6544
|
+
count: 0,
|
|
6545
|
+
results: [],
|
|
6546
|
+
skipped
|
|
6547
|
+
};
|
|
6548
|
+
}
|
|
6549
|
+
/**
|
|
6550
|
+
* Run one batch ask: resolve targets, apply every per-target guard, deliver
|
|
6551
|
+
* concurrently and collect the results.
|
|
6552
|
+
*
|
|
6553
|
+
* - `all`: wait for every target to settle (or its own ceiling).
|
|
6554
|
+
* - `any`: return the first settled target; the others keep running in the
|
|
6555
|
+
* background and still write their own settled AskRecords.
|
|
6556
|
+
*
|
|
6557
|
+
* Never guesses: unnamed, ambiguous, missing, excluded or guard-rejected
|
|
6558
|
+
* targets land in `skipped`. When every target is skipped the call reports
|
|
6559
|
+
* `ok: false` with those reasons instead of failing with an exception.
|
|
6560
|
+
*/
|
|
6561
|
+
async function askSessions(ctx, signal, input) {
|
|
6562
|
+
const mode = input.mode === "any" ? "any" : "all";
|
|
6563
|
+
const waitMs = normalizeWaitMs(typeof input.waitMs === "number" ? input.waitMs : input.defaultWaitMs);
|
|
6564
|
+
const log = input.log ?? (() => {});
|
|
6565
|
+
const targets = readTargets(input.targets);
|
|
6566
|
+
const skipped = [];
|
|
6567
|
+
if (targets.length === 0) return skippedOnly(skipped);
|
|
6568
|
+
if (!input.swarmEnabled) {
|
|
6569
|
+
for (const target of targets) skipped.push({
|
|
6570
|
+
target,
|
|
6571
|
+
reason: BATCH_SWARM_DISABLED
|
|
6572
|
+
});
|
|
6573
|
+
return skippedOnly(skipped);
|
|
6574
|
+
}
|
|
6575
|
+
if (typeof input.question !== "string" || input.question.trim().length === 0) {
|
|
6576
|
+
for (const target of targets) skipped.push({
|
|
6577
|
+
target,
|
|
6578
|
+
reason: "缺少问题"
|
|
6579
|
+
});
|
|
6580
|
+
return skippedOnly(skipped);
|
|
6581
|
+
}
|
|
6582
|
+
const controller = getSessionController(ctx);
|
|
6583
|
+
if (controller === void 0) {
|
|
6584
|
+
for (const target of targets) skipped.push({
|
|
6585
|
+
target,
|
|
6586
|
+
reason: "宿主没有提供 sessionController"
|
|
6587
|
+
});
|
|
6588
|
+
return skippedOnly(skipped);
|
|
6589
|
+
}
|
|
6590
|
+
if (typeof input.currentSessionId !== "string" || input.currentSessionId.length === 0) {
|
|
6591
|
+
for (const target of targets) skipped.push({
|
|
6592
|
+
target,
|
|
6593
|
+
reason: "无法确定当前会话的身份"
|
|
6594
|
+
});
|
|
6595
|
+
return skippedOnly(skipped);
|
|
6596
|
+
}
|
|
6597
|
+
let full;
|
|
6598
|
+
try {
|
|
6599
|
+
full = [...(await controller.list({}, signal)).items];
|
|
6600
|
+
} catch {
|
|
6601
|
+
for (const target of targets) skipped.push({
|
|
6602
|
+
target,
|
|
6603
|
+
reason: "读取会话列表失败"
|
|
6604
|
+
});
|
|
6605
|
+
return skippedOnly(skipped);
|
|
6606
|
+
}
|
|
6607
|
+
const archived = archivedSessionIds(ctx);
|
|
6608
|
+
const registered = registeredSessionIds(ctx);
|
|
6609
|
+
const allowed = allowedAskSessions(full, input.exclude, registered);
|
|
6610
|
+
const own = full.find((session) => session.sessionId === input.currentSessionId);
|
|
6611
|
+
const senderName = own === void 0 ? "未命名会话" : sessionDisplayName(own);
|
|
6612
|
+
const hop = nextAskHop(input.currentEvents);
|
|
6613
|
+
const parentAskId = inboundAskId(input.currentEvents);
|
|
6614
|
+
const plans = [];
|
|
6615
|
+
const plannedIds = /* @__PURE__ */ new Set();
|
|
6616
|
+
for (const target of targets) {
|
|
6617
|
+
if (target.length === 0) {
|
|
6618
|
+
skipped.push({
|
|
6619
|
+
target,
|
|
6620
|
+
reason: "没点名"
|
|
6621
|
+
});
|
|
6622
|
+
continue;
|
|
6623
|
+
}
|
|
6624
|
+
const match = matchTargetSession(allowed, target);
|
|
6625
|
+
if (match.kind === "ambiguous") {
|
|
6626
|
+
const total = Math.max(match.candidates.length, match.total ?? match.candidates.length);
|
|
6627
|
+
const block = renderCandidateBlock(match.candidates, { total });
|
|
6628
|
+
skipped.push({
|
|
6629
|
+
target,
|
|
6630
|
+
reason: `有 ${total} 个同名会话${block.length > 0 ? `\n${block}` : ""}`
|
|
6631
|
+
});
|
|
6632
|
+
continue;
|
|
6633
|
+
}
|
|
6634
|
+
if (match.kind === "missing") {
|
|
6635
|
+
const fullMatch = matchTargetSession(full, target);
|
|
6636
|
+
if (fullMatch.kind === "unique" && archived.has(fullMatch.target.sessionId)) skipped.push({
|
|
6637
|
+
target,
|
|
6638
|
+
reason: ARCHIVED_SESSION_MESSAGE
|
|
6639
|
+
});
|
|
6640
|
+
else if (fullMatch.kind === "unique" && registered !== void 0 && !registered.has(fullMatch.target.sessionId)) skipped.push({
|
|
6641
|
+
target,
|
|
6642
|
+
reason: UNREGISTERED_SESSION_MESSAGE
|
|
6643
|
+
});
|
|
6644
|
+
else if (fullMatch.kind === "unique" && !allowed.some((session) => session.sessionId === fullMatch.target.sessionId)) skipped.push({
|
|
6645
|
+
target,
|
|
6646
|
+
reason: "这个会话被设置为不参与蜂群"
|
|
6647
|
+
});
|
|
6648
|
+
else if (fullMatch.kind === "ambiguous") {
|
|
6649
|
+
const total = Math.max(fullMatch.candidates.length, fullMatch.total ?? fullMatch.candidates.length);
|
|
6650
|
+
const block = renderCandidateBlock(fullMatch.candidates, { total });
|
|
6651
|
+
skipped.push({
|
|
6652
|
+
target,
|
|
6653
|
+
reason: `有 ${total} 个同名会话${block.length > 0 ? `\n${block}` : ""}`
|
|
6654
|
+
});
|
|
6655
|
+
} else {
|
|
6656
|
+
const block = renderCandidateBlock(recentCandidates(allowed), { sameTitle: false });
|
|
6657
|
+
skipped.push({
|
|
6658
|
+
target,
|
|
6659
|
+
reason: `没有找到匹配的会话${block.length > 0 ? `\n${block}` : ""}`
|
|
6660
|
+
});
|
|
6661
|
+
}
|
|
6662
|
+
continue;
|
|
6663
|
+
}
|
|
6664
|
+
if (plannedIds.has(match.target.sessionId)) continue;
|
|
6665
|
+
const rejection = checkAskGuards({
|
|
6666
|
+
guards: {
|
|
6667
|
+
currentSessionId: input.currentSessionId,
|
|
6668
|
+
hop
|
|
6669
|
+
},
|
|
6670
|
+
match,
|
|
6671
|
+
sessions: allowed,
|
|
6672
|
+
question: input.question,
|
|
6673
|
+
context: input.context
|
|
6674
|
+
});
|
|
6675
|
+
if (rejection !== void 0) {
|
|
6676
|
+
skipped.push({
|
|
6677
|
+
target,
|
|
6678
|
+
reason: skipReasonFor(rejection)
|
|
6679
|
+
});
|
|
6680
|
+
continue;
|
|
6681
|
+
}
|
|
6682
|
+
const row = allowed.find((session) => session.sessionId === match.target.sessionId);
|
|
6683
|
+
plannedIds.add(match.target.sessionId);
|
|
6684
|
+
plans.push({
|
|
6685
|
+
target: match.target,
|
|
6686
|
+
targetWasRunning: row?.running === true
|
|
6687
|
+
});
|
|
6688
|
+
}
|
|
6689
|
+
if (plans.length === 0) return skippedOnly(skipped);
|
|
6690
|
+
const batch = {
|
|
6691
|
+
controller,
|
|
6692
|
+
currentSessionId: input.currentSessionId,
|
|
6693
|
+
senderName,
|
|
6694
|
+
question: input.question,
|
|
6695
|
+
context: input.context,
|
|
6696
|
+
hop,
|
|
6697
|
+
parentAskId,
|
|
6698
|
+
waitMs,
|
|
6699
|
+
storeDir: askStoreRoot(),
|
|
6700
|
+
log,
|
|
6701
|
+
parentSignal: signal,
|
|
6702
|
+
deliverBack: input.deliverBack !== false
|
|
6703
|
+
};
|
|
6704
|
+
const tasks = plans.map((plan) => askOne(plan, batch));
|
|
6705
|
+
if (mode === "any") {
|
|
6706
|
+
const allSettled = Promise.all(tasks);
|
|
6707
|
+
const results = [await Promise.race(tasks)];
|
|
6708
|
+
allSettled.then((all) => deliverMergedBack(batch, all));
|
|
6709
|
+
return {
|
|
6710
|
+
ok: true,
|
|
6711
|
+
count: results.length,
|
|
6712
|
+
results,
|
|
6713
|
+
skipped
|
|
6714
|
+
};
|
|
6715
|
+
}
|
|
6716
|
+
const results = await Promise.all(tasks);
|
|
6717
|
+
await deliverMergedBack(batch, results);
|
|
6718
|
+
return {
|
|
6719
|
+
ok: true,
|
|
6720
|
+
count: results.length,
|
|
6721
|
+
results,
|
|
6722
|
+
skipped
|
|
6723
|
+
};
|
|
6724
|
+
}
|
|
6725
|
+
//#endregion
|
|
6726
|
+
//#region src/tools.ts
|
|
6727
|
+
function registerAdvisorTools(service, ctx) {
|
|
6728
|
+
return [
|
|
6729
|
+
defineTool({
|
|
6730
|
+
name: "ask_advisors",
|
|
6731
|
+
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.",
|
|
6732
|
+
parameters: {
|
|
6733
|
+
question: {
|
|
6734
|
+
type: "string",
|
|
6735
|
+
description: "The question to ask the advisors. Required when starting a new consultation."
|
|
6736
|
+
},
|
|
6737
|
+
context: {
|
|
6738
|
+
type: "string",
|
|
6739
|
+
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."
|
|
6740
|
+
},
|
|
6741
|
+
sessionId: {
|
|
6742
|
+
type: "string",
|
|
6743
|
+
description: "Existing consultation session id to continue."
|
|
6744
|
+
},
|
|
6745
|
+
followUp: {
|
|
6746
|
+
type: "string",
|
|
6747
|
+
description: "Follow-up question to send to the advisors in an existing session."
|
|
6748
|
+
},
|
|
6749
|
+
advisorIds: {
|
|
6750
|
+
type: "array",
|
|
6751
|
+
items: { type: "string" },
|
|
6752
|
+
description: "Optional advisor ids to include; defaults to all configured advisors (capped by maxAdvisorsPerCall)."
|
|
6753
|
+
},
|
|
6754
|
+
confidence: {
|
|
6755
|
+
type: "number",
|
|
6756
|
+
description: "Optional self-assessed confidence 0-1. Values below the configured threshold escalate even without domain keywords."
|
|
6757
|
+
},
|
|
6758
|
+
questionEn: {
|
|
6759
|
+
type: "string",
|
|
6760
|
+
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."
|
|
6761
|
+
}
|
|
6762
|
+
},
|
|
6763
|
+
output: {
|
|
6764
|
+
schema: {
|
|
5851
6765
|
type: "object",
|
|
5852
6766
|
additionalProperties: false,
|
|
5853
6767
|
properties: {
|
|
@@ -6076,7 +6990,7 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6076
6990
|
parameters: {
|
|
6077
6991
|
target: {
|
|
6078
6992
|
type: "string",
|
|
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,
|
|
6993
|
+
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, a full sessionId, or an exact advisor-corps role name (see list_sessions). A title or the workspace/directory name is usually enough (「极简遥控器」 matches sessions under I:\\极简遥控器\\…). An exact role name resolves to the session it is assigned to and takes precedence over a same-named title. 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."
|
|
6080
6994
|
},
|
|
6081
6995
|
question: {
|
|
6082
6996
|
type: "string",
|
|
@@ -6097,7 +7011,7 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6097
7011
|
},
|
|
6098
7012
|
detach: {
|
|
6099
7013
|
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.
|
|
7014
|
+
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. When detach is omitted, passing waitMs means wait; pass only one of them."
|
|
6101
7015
|
}
|
|
6102
7016
|
},
|
|
6103
7017
|
output: {
|
|
@@ -6195,7 +7109,10 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6195
7109
|
hint: {
|
|
6196
7110
|
type: "string",
|
|
6197
7111
|
required: true
|
|
6198
|
-
}
|
|
7112
|
+
},
|
|
7113
|
+
/** Display-only fields that tell identical titles apart. */
|
|
7114
|
+
cwd: { type: "string" },
|
|
7115
|
+
age: { type: "string" }
|
|
6199
7116
|
}
|
|
6200
7117
|
}
|
|
6201
7118
|
},
|
|
@@ -6235,7 +7152,7 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6235
7152
|
...value.reply === null || typeof value.reply !== "object" ? {} : { reply: value.reply }
|
|
6236
7153
|
}) : [{
|
|
6237
7154
|
type: "text",
|
|
6238
|
-
text: `【ask_session 未完成:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}${
|
|
7155
|
+
text: `【ask_session 未完成:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}${renderRouteCandidates(value.routeCandidates)}`
|
|
6239
7156
|
}]
|
|
6240
7157
|
},
|
|
6241
7158
|
async execute(args, exec) {
|
|
@@ -6278,8 +7195,11 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6278
7195
|
const own = listed.items.find((item) => item.sessionId === sessionId);
|
|
6279
7196
|
if (own) senderName = sessionDisplayName(own);
|
|
6280
7197
|
} catch {}
|
|
6281
|
-
|
|
6282
|
-
|
|
7198
|
+
const targetText = typeof args.target === "string" ? args.target.trim() : "";
|
|
7199
|
+
const corpsTarget = targetText.length > 0 ? findCorpsSession(loadCorpsCards(), targetText) : void 0;
|
|
7200
|
+
if (targetText.length === 0 && swarm.routingEnabled !== false) {
|
|
7201
|
+
const registered = registeredSessionIds(ctx);
|
|
7202
|
+
const roster = visibleRosterSessions(listedItems, swarm.rosterIncludeSubagents).filter((row) => (registered === void 0 || registered.has(row.sessionId)) && !isSessionExcluded(hidden, {
|
|
6283
7203
|
sessionId: row.sessionId,
|
|
6284
7204
|
names: [sessionDisplayName(row)]
|
|
6285
7205
|
}));
|
|
@@ -6327,12 +7247,13 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6327
7247
|
currentSessionId: sessionId,
|
|
6328
7248
|
senderName,
|
|
6329
7249
|
currentEvents: readCurrentEvents(session),
|
|
6330
|
-
target:
|
|
7250
|
+
target: corpsTarget ?? targetText,
|
|
6331
7251
|
question,
|
|
6332
7252
|
context,
|
|
6333
7253
|
waitMs: answerMode.waitMs,
|
|
6334
7254
|
detach: answerMode.detach,
|
|
6335
7255
|
exclude: hidden,
|
|
7256
|
+
deliverBack: swarm.deliverBack !== false,
|
|
6336
7257
|
log: (message) => console.log(`[dsh-advisor-group] ask_session: ${message}`)
|
|
6337
7258
|
});
|
|
6338
7259
|
if (value.ok !== true) return {
|
|
@@ -6357,9 +7278,165 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6357
7278
|
};
|
|
6358
7279
|
}
|
|
6359
7280
|
}),
|
|
7281
|
+
defineTool({
|
|
7282
|
+
name: "ask_sessions",
|
|
7283
|
+
description: "Ask ONE question to 1–8 existing sessions in a single call and collect the results. targets uses the same spelling as ask_session (exact sessionId, exact title, or a natural partial name). mode \"all\" (default) waits until every target settles or its own ceiling passes; mode \"any\" returns as soon as the first target settles while the others keep running in the background (their ask_result records still settle). There is NO routing here: an unnamed or ambiguous target is skipped with a reason instead of being guessed. Every delivered target gets its own askId; read a later result with ask_result.",
|
|
7284
|
+
parameters: {
|
|
7285
|
+
targets: {
|
|
7286
|
+
type: "array",
|
|
7287
|
+
items: { type: "string" },
|
|
7288
|
+
description: "The sessions to ask, 1–8 entries, same spelling as ask_session (exact id, exact title, exact advisor-corps role name, or a natural partial name). Duplicates collapse into one; unresolvable or excluded targets are skipped with a reason.",
|
|
7289
|
+
required: true
|
|
7290
|
+
},
|
|
7291
|
+
question: {
|
|
7292
|
+
type: "string",
|
|
7293
|
+
description: "The question to hand to every target. It is permanently recorded in each target session log, so write it self-contained.",
|
|
7294
|
+
required: true
|
|
7295
|
+
},
|
|
7296
|
+
context: {
|
|
7297
|
+
type: "string",
|
|
7298
|
+
description: "Optional background every target needs. Default: none. Anything sent here stays in every target session log forever, so send only what is necessary."
|
|
7299
|
+
},
|
|
7300
|
+
mode: {
|
|
7301
|
+
type: "string",
|
|
7302
|
+
enum: ["all", "any"],
|
|
7303
|
+
description: "all (default): wait for every target. any: return at the first settled target; the rest keep running in the background."
|
|
7304
|
+
},
|
|
7305
|
+
waitMs: {
|
|
7306
|
+
type: "number",
|
|
7307
|
+
description: `Per-target wait ceiling in milliseconds (default ${ASK_WAIT_DEFAULT_MS}, max ${ASK_WAIT_MAX_MS}). A ceiling stops only this call's wait; the target turn is never cancelled.`
|
|
7308
|
+
}
|
|
7309
|
+
},
|
|
7310
|
+
output: {
|
|
7311
|
+
schema: {
|
|
7312
|
+
type: "object",
|
|
7313
|
+
additionalProperties: false,
|
|
7314
|
+
properties: {
|
|
7315
|
+
ok: {
|
|
7316
|
+
type: "boolean",
|
|
7317
|
+
required: true
|
|
7318
|
+
},
|
|
7319
|
+
count: {
|
|
7320
|
+
type: "number",
|
|
7321
|
+
required: true
|
|
7322
|
+
},
|
|
7323
|
+
results: {
|
|
7324
|
+
type: "array",
|
|
7325
|
+
required: true,
|
|
7326
|
+
items: {
|
|
7327
|
+
type: "object",
|
|
7328
|
+
additionalProperties: false,
|
|
7329
|
+
properties: {
|
|
7330
|
+
sessionId: {
|
|
7331
|
+
type: "string",
|
|
7332
|
+
required: true
|
|
7333
|
+
},
|
|
7334
|
+
name: {
|
|
7335
|
+
type: "string",
|
|
7336
|
+
required: true
|
|
7337
|
+
},
|
|
7338
|
+
askId: {
|
|
7339
|
+
type: "string",
|
|
7340
|
+
required: true
|
|
7341
|
+
},
|
|
7342
|
+
state: {
|
|
7343
|
+
type: "string",
|
|
7344
|
+
enum: [
|
|
7345
|
+
"done",
|
|
7346
|
+
"timeout",
|
|
7347
|
+
"error"
|
|
7348
|
+
],
|
|
7349
|
+
required: true
|
|
7350
|
+
},
|
|
7351
|
+
answer: {
|
|
7352
|
+
type: "string",
|
|
7353
|
+
required: true
|
|
7354
|
+
},
|
|
7355
|
+
status: {
|
|
7356
|
+
type: "string",
|
|
7357
|
+
enum: [
|
|
7358
|
+
"ok",
|
|
7359
|
+
"partial",
|
|
7360
|
+
"failed"
|
|
7361
|
+
]
|
|
7362
|
+
},
|
|
7363
|
+
note: { type: "string" },
|
|
7364
|
+
error: { type: "string" }
|
|
7365
|
+
}
|
|
7366
|
+
}
|
|
7367
|
+
},
|
|
7368
|
+
skipped: {
|
|
7369
|
+
type: "array",
|
|
7370
|
+
required: true,
|
|
7371
|
+
items: {
|
|
7372
|
+
type: "object",
|
|
7373
|
+
additionalProperties: false,
|
|
7374
|
+
properties: {
|
|
7375
|
+
target: {
|
|
7376
|
+
type: "string",
|
|
7377
|
+
required: true
|
|
7378
|
+
},
|
|
7379
|
+
reason: {
|
|
7380
|
+
type: "string",
|
|
7381
|
+
required: true
|
|
7382
|
+
}
|
|
7383
|
+
}
|
|
7384
|
+
}
|
|
7385
|
+
}
|
|
7386
|
+
}
|
|
7387
|
+
},
|
|
7388
|
+
render: (_args, value) => {
|
|
7389
|
+
const results = Array.isArray(value.results) ? value.results : [];
|
|
7390
|
+
const skipped = Array.isArray(value.skipped) ? value.skipped : [];
|
|
7391
|
+
const stateLabel = {
|
|
7392
|
+
done: "已完成",
|
|
7393
|
+
timeout: "超时",
|
|
7394
|
+
error: "出错"
|
|
7395
|
+
};
|
|
7396
|
+
const blocks = results.map((row) => {
|
|
7397
|
+
const head = `- ${String(row.name ?? "")} · ${stateLabel[String(row.state ?? "")] ?? String(row.state ?? "")}`;
|
|
7398
|
+
const answer = String(row.answer ?? "").trim();
|
|
7399
|
+
const note = String(row.note ?? "").trim();
|
|
7400
|
+
const error = String(row.error ?? "").trim();
|
|
7401
|
+
return `${head}\n ${answer.length > 0 ? answer : error.length > 0 ? `错误:${error}` : note}`.trimEnd();
|
|
7402
|
+
});
|
|
7403
|
+
if (skipped.length > 0) blocks.push("跳过:", ...skipped.map((row) => {
|
|
7404
|
+
const reason = String(row.reason ?? "").split("\n").join("\n ");
|
|
7405
|
+
return `- ${String(row.target ?? "")}:${reason}`;
|
|
7406
|
+
}));
|
|
7407
|
+
return [{
|
|
7408
|
+
type: "text",
|
|
7409
|
+
text: blocks.length > 0 ? blocks.join("\n") : "没有投递任何会话。"
|
|
7410
|
+
}];
|
|
7411
|
+
}
|
|
7412
|
+
},
|
|
7413
|
+
async execute(args, exec) {
|
|
7414
|
+
const swarm = service.getConfig().swarm;
|
|
7415
|
+
const session = exec.agent?.session ?? getCurrentSessionFrom(exec);
|
|
7416
|
+
const sessionId = session?.id;
|
|
7417
|
+
const corpsCards = loadCorpsCards();
|
|
7418
|
+
return await askSessions(ctx, exec.signal, {
|
|
7419
|
+
currentSessionId: typeof sessionId === "string" ? sessionId : "",
|
|
7420
|
+
currentEvents: readCurrentEvents(session),
|
|
7421
|
+
targets: Array.isArray(args.targets) ? args.targets.map((target) => {
|
|
7422
|
+
const text = typeof target === "string" ? target : "";
|
|
7423
|
+
return findCorpsSession(corpsCards, text) ?? text;
|
|
7424
|
+
}) : [],
|
|
7425
|
+
question: typeof args.question === "string" ? args.question : "",
|
|
7426
|
+
context: typeof args.context === "string" ? args.context : void 0,
|
|
7427
|
+
mode: args.mode === "any" ? "any" : "all",
|
|
7428
|
+
waitMs: typeof args.waitMs === "number" ? args.waitMs : void 0,
|
|
7429
|
+
defaultWaitMs: swarm.defaultWaitMs,
|
|
7430
|
+
exclude: exclusionEntries(swarm),
|
|
7431
|
+
swarmEnabled: swarm.enabled !== false,
|
|
7432
|
+
deliverBack: swarm.deliverBack !== false,
|
|
7433
|
+
log: (message) => console.log(`[dsh-advisor-group] ask_sessions: ${message}`)
|
|
7434
|
+
});
|
|
7435
|
+
}
|
|
7436
|
+
}),
|
|
6360
7437
|
defineTool({
|
|
6361
7438
|
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,
|
|
7439
|
+
description: "List the sessions this host knows: the title shown in the session list, its sessionId, working directory, how long ago it was active, whether it is running, and its advisor-corps role name and duty when assigned. Use it when the user names a session but you are unsure which one they mean, when you want to address a member by role name, 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
7440
|
parameters: {
|
|
6364
7441
|
query: {
|
|
6365
7442
|
type: "string",
|
|
@@ -6419,17 +7496,20 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6419
7496
|
type: "boolean",
|
|
6420
7497
|
required: true
|
|
6421
7498
|
},
|
|
7499
|
+
roleName: { type: "string" },
|
|
7500
|
+
duty: { type: "string" },
|
|
6422
7501
|
sizeHint: { type: "number" }
|
|
6423
7502
|
}
|
|
6424
7503
|
}
|
|
6425
7504
|
},
|
|
6426
7505
|
error: { type: "string" },
|
|
6427
|
-
message: { type: "string" }
|
|
7506
|
+
message: { type: "string" },
|
|
7507
|
+
note: { type: "string" }
|
|
6428
7508
|
}
|
|
6429
7509
|
},
|
|
6430
7510
|
render: (args, value) => value.ok === true ? [{
|
|
6431
7511
|
type: "text",
|
|
6432
|
-
text: renderRoster(value.sessions, { filtered: String(args.query ?? "").trim().length > 0 })
|
|
7512
|
+
text: renderRoster(value.sessions, { filtered: String(args.query ?? "").trim().length > 0 }) + (typeof value.note === "string" && value.note.length > 0 ? `\n\n${value.note}` : "")
|
|
6433
7513
|
}] : [{
|
|
6434
7514
|
type: "text",
|
|
6435
7515
|
text: `【list_sessions 失败:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}`
|
|
@@ -6453,18 +7533,30 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6453
7533
|
message: "宿主没有提供 sessionController,无法列出会话。"
|
|
6454
7534
|
};
|
|
6455
7535
|
try {
|
|
6456
|
-
const
|
|
7536
|
+
const listed = await controller.list({}, exec.signal);
|
|
7537
|
+
const registered = registeredSessionIds(ctx);
|
|
7538
|
+
const rows = buildSessionRoster(listed.items.filter((item) => !isHiddenSession(swarm, item) && (registered === void 0 || registered.has(item.sessionId))), {
|
|
6457
7539
|
query: typeof args.query === "string" ? args.query : "",
|
|
6458
7540
|
limit: typeof args.limit === "number" ? args.limit : void 0,
|
|
6459
7541
|
hideSubagents: swarm.rosterIncludeSubagents !== true
|
|
6460
|
-
})
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
7542
|
+
});
|
|
7543
|
+
const corpsCards = loadCorpsCards();
|
|
7544
|
+
const sessions = rows.map((row) => {
|
|
7545
|
+
const card = corpsCards[row.sessionId];
|
|
7546
|
+
return {
|
|
7547
|
+
...row,
|
|
7548
|
+
name: shortSessionName(row.name, row.cwd),
|
|
7549
|
+
...card === void 0 ? {} : {
|
|
7550
|
+
roleName: card.name,
|
|
7551
|
+
duty: card.duty
|
|
7552
|
+
}
|
|
7553
|
+
};
|
|
7554
|
+
});
|
|
6464
7555
|
return {
|
|
6465
7556
|
ok: true,
|
|
6466
7557
|
count: sessions.length,
|
|
6467
|
-
sessions
|
|
7558
|
+
sessions,
|
|
7559
|
+
...registered === void 0 ? { note: "当前宿主未提供工作区登记信息,名录未做「只显示侧栏可见会话」的过滤。" } : {}
|
|
6468
7560
|
};
|
|
6469
7561
|
} catch (error) {
|
|
6470
7562
|
return {
|
|
@@ -6479,7 +7571,7 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6479
7571
|
}),
|
|
6480
7572
|
defineTool({
|
|
6481
7573
|
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.",
|
|
7574
|
+
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, interrupted (the host restarted while waiting, so the answer was never collected — check the target session or ask again). Read-only: it never wakes the target.",
|
|
6483
7575
|
parameters: { askId: {
|
|
6484
7576
|
type: "string",
|
|
6485
7577
|
description: "The askId returned by ask_session with detach: true. Omit it to list the recent asks this session started."
|
|
@@ -6549,7 +7641,13 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6549
7641
|
}
|
|
6550
7642
|
},
|
|
6551
7643
|
note: { type: "string" },
|
|
6552
|
-
error: { type: "string" }
|
|
7644
|
+
error: { type: "string" },
|
|
7645
|
+
/** Display only: body probed from a still-waiting target. */
|
|
7646
|
+
provisional: { type: "string" },
|
|
7647
|
+
/** True when the settled answer was copied back automatically. */
|
|
7648
|
+
deliveredBack: { type: "boolean" },
|
|
7649
|
+
/** Why the automatic copy-back did not happen, when it failed. */
|
|
7650
|
+
deliverBackNote: { type: "string" }
|
|
6553
7651
|
}
|
|
6554
7652
|
}
|
|
6555
7653
|
},
|
|
@@ -6591,11 +7689,15 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6591
7689
|
pruneAskRecords(dir, ttlHours * 36e5);
|
|
6592
7690
|
} catch {}
|
|
6593
7691
|
const askId = typeof args.askId === "string" ? args.askId.trim() : "";
|
|
6594
|
-
const
|
|
7692
|
+
const records = askId.length > 0 ? [readAskRecord(dir, askId)].filter((row) => row !== void 0) : listAskRecords(dir, sessionId.length > 0 ? { sessionId } : void 0).slice(0, 20);
|
|
7693
|
+
const visible = (await withProvisionalBodies(getSessionController(ctx), records)).map((row) => {
|
|
7694
|
+
const { bootId: _bootId, fromSeq: _fromSeq, ...rest } = row;
|
|
7695
|
+
return rest;
|
|
7696
|
+
});
|
|
6595
7697
|
return {
|
|
6596
7698
|
ok: true,
|
|
6597
|
-
count:
|
|
6598
|
-
results
|
|
7699
|
+
count: visible.length,
|
|
7700
|
+
results: visible
|
|
6599
7701
|
};
|
|
6600
7702
|
} catch (error) {
|
|
6601
7703
|
return {
|
|
@@ -6610,24 +7712,59 @@ function registerAdvisorTools(service, ctx) {
|
|
|
6610
7712
|
})
|
|
6611
7713
|
];
|
|
6612
7714
|
}
|
|
7715
|
+
/**
|
|
7716
|
+
* Attach the provisional body to still-waiting records (display only). Skips
|
|
7717
|
+
* the probe entirely when the host exposes no session controller, and a failed
|
|
7718
|
+
* probe leaves the record unchanged, so the old output survives.
|
|
7719
|
+
*/
|
|
7720
|
+
async function withProvisionalBodies(controller, records) {
|
|
7721
|
+
return Promise.all(records.map(async (row) => {
|
|
7722
|
+
const stale = reconcileStaleAsk(row);
|
|
7723
|
+
if (stale !== void 0) {
|
|
7724
|
+
try {
|
|
7725
|
+
writeAskRecord(askStoreRoot(), stale);
|
|
7726
|
+
} catch (error) {
|
|
7727
|
+
console.warn("[dsh-advisor-group] 中断记录落盘失败:", error);
|
|
7728
|
+
}
|
|
7729
|
+
return { ...stale };
|
|
7730
|
+
}
|
|
7731
|
+
if (row.state !== "waiting" || controller === void 0) return { ...row };
|
|
7732
|
+
const text = await probeWaitingBody(controller, row);
|
|
7733
|
+
return text === void 0 ? { ...row } : {
|
|
7734
|
+
...row,
|
|
7735
|
+
provisional: text
|
|
7736
|
+
};
|
|
7737
|
+
}));
|
|
7738
|
+
}
|
|
6613
7739
|
/** One compact block per detached ask result, for the model to read. */
|
|
6614
7740
|
function renderAskRecords(rows) {
|
|
6615
7741
|
const stateLabel = {
|
|
6616
7742
|
waiting: "等待中",
|
|
6617
7743
|
done: "已完成",
|
|
6618
7744
|
timeout: "超时",
|
|
6619
|
-
error: "出错"
|
|
7745
|
+
error: "出错",
|
|
7746
|
+
interrupted: "已中断"
|
|
6620
7747
|
};
|
|
6621
7748
|
return rows.map((row) => {
|
|
7749
|
+
const head = `- [${stateLabel[row.state]}] ${row.targetName} · askId=${row.askId}${row.deliveredBack === true ? " · 已自动回送" : ""}`;
|
|
7750
|
+
const parent = row.parentAskId === void 0 ? "" : ` (由 ${row.parentAskId} 转问)`;
|
|
7751
|
+
const question = ` 问题:${row.question.replace(/\s+/gu, " ").slice(0, 120)}`;
|
|
7752
|
+
const backNote = row.deliverBackNote === void 0 ? "" : ` 回送说明:${row.deliverBackNote.replace(/\s+/gu, " ")}`;
|
|
7753
|
+
const body = row.reply !== void 0 ? [
|
|
7754
|
+
row.reply.conclusion === void 0 ? "" : ` 结论:${row.reply.conclusion}`,
|
|
7755
|
+
row.reply.basis === void 0 ? "" : ` 依据:${row.reply.basis}`,
|
|
7756
|
+
row.reply.uncertainty === void 0 ? "" : ` 不确定:${row.reply.uncertainty}`
|
|
7757
|
+
].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}` : "";
|
|
7758
|
+
const provisional = row.provisional === void 0 ? "" : provisionalBodySection(row.provisional) ?? "";
|
|
7759
|
+
const provisionalLines = provisional.length === 0 ? [] : provisional.split("\n").map((line) => ` ${line}`);
|
|
6622
7760
|
return [
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
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}` : ""
|
|
7761
|
+
head,
|
|
7762
|
+
parent,
|
|
7763
|
+
question,
|
|
7764
|
+
body,
|
|
7765
|
+
row.note === void 0 || row.note.length === 0 ? "" : ` 说明:${row.note.replace(/\s+/gu, " ")}`,
|
|
7766
|
+
backNote,
|
|
7767
|
+
...provisionalLines
|
|
6631
7768
|
].filter((line) => line.length > 0).join("\n");
|
|
6632
7769
|
}).join("\n\n");
|
|
6633
7770
|
}
|
|
@@ -6662,9 +7799,18 @@ const SWARM_DISABLED_MESSAGE = "蜂群模式已在设置里关闭(设置 →
|
|
|
6662
7799
|
* one falls back to the configured default (`defaultMode` / `defaultWaitMs`).
|
|
6663
7800
|
*/
|
|
6664
7801
|
function resolveAskDefaults(args, swarm) {
|
|
7802
|
+
const waitMs = typeof args.waitMs === "number" ? args.waitMs : swarm.defaultWaitMs;
|
|
7803
|
+
if (typeof args.detach === "boolean") return {
|
|
7804
|
+
waitMs,
|
|
7805
|
+
detach: args.detach
|
|
7806
|
+
};
|
|
7807
|
+
if (typeof args.waitMs === "number") return {
|
|
7808
|
+
waitMs,
|
|
7809
|
+
detach: false
|
|
7810
|
+
};
|
|
6665
7811
|
return {
|
|
6666
|
-
waitMs
|
|
6667
|
-
detach:
|
|
7812
|
+
waitMs,
|
|
7813
|
+
detach: swarm.defaultMode === "detach"
|
|
6668
7814
|
};
|
|
6669
7815
|
}
|
|
6670
7816
|
/**
|
|
@@ -7572,6 +8718,18 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
|
|
|
7572
8718
|
}
|
|
7573
8719
|
return;
|
|
7574
8720
|
}
|
|
8721
|
+
if (req.method === "GET" && (pathname === "/advisor-group/corps" || pathname === "/advisor-group/corps/")) {
|
|
8722
|
+
await handleCorpsGet(ctx, service, res);
|
|
8723
|
+
return;
|
|
8724
|
+
}
|
|
8725
|
+
if (req.method === "POST" && (pathname === "/advisor-group/corps" || pathname === "/advisor-group/corps/")) {
|
|
8726
|
+
await handleCorpsPost(req, res);
|
|
8727
|
+
return;
|
|
8728
|
+
}
|
|
8729
|
+
if (req.method === "POST" && (pathname === "/advisor-group/corps/generate" || pathname === "/advisor-group/corps/generate/")) {
|
|
8730
|
+
await handleCorpsGenerate(ctx, service, req, res);
|
|
8731
|
+
return;
|
|
8732
|
+
}
|
|
7575
8733
|
if (req.method === "GET" && (pathname === "/advisor-group/shadow" || pathname === "/advisor-group/shadow/")) {
|
|
7576
8734
|
sendJson(res, 200, {
|
|
7577
8735
|
ok: true,
|
|
@@ -7641,6 +8799,247 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
|
|
|
7641
8799
|
});
|
|
7642
8800
|
};
|
|
7643
8801
|
}
|
|
8802
|
+
function isPlainRecord(value) {
|
|
8803
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8804
|
+
}
|
|
8805
|
+
/**
|
|
8806
|
+
* The same visibility universe as list_sessions/ask_session: workspace-
|
|
8807
|
+
* registered, not archived, not swarm-excluded; subagents only when enabled.
|
|
8808
|
+
* `undefined` means the host exposed no session controller at all.
|
|
8809
|
+
*/
|
|
8810
|
+
async function collectCorpsRoster(ctx, service) {
|
|
8811
|
+
const controller = getSessionController(ctx);
|
|
8812
|
+
if (controller === void 0) return void 0;
|
|
8813
|
+
const listed = await controller.list({}, new AbortController().signal);
|
|
8814
|
+
const swarm = service.getConfig().swarm;
|
|
8815
|
+
const registered = registeredSessionIds(ctx);
|
|
8816
|
+
const archived = archivedSessionIds(ctx);
|
|
8817
|
+
const exclude = Array.isArray(swarm.routingExclude) ? swarm.routingExclude : [];
|
|
8818
|
+
const includeSubagents = swarm.rosterIncludeSubagents === true;
|
|
8819
|
+
const visible = listed.items.filter((item) => !archived.has(item.sessionId) && (registered === void 0 || registered.has(item.sessionId)) && !isSessionExcluded(exclude, {
|
|
8820
|
+
sessionId: item.sessionId,
|
|
8821
|
+
names: [sessionDisplayName(item)]
|
|
8822
|
+
}) && (includeSubagents || item.origin !== "subagent"));
|
|
8823
|
+
return {
|
|
8824
|
+
rows: buildSessionRoster(visible, {
|
|
8825
|
+
limit: 50,
|
|
8826
|
+
hideSubagents: !includeSubagents
|
|
8827
|
+
}),
|
|
8828
|
+
visibleIds: new Set(visible.map((item) => item.sessionId)),
|
|
8829
|
+
filtered: registered !== void 0
|
|
8830
|
+
};
|
|
8831
|
+
}
|
|
8832
|
+
function corpsOrphans(cards, visibleIds) {
|
|
8833
|
+
return Object.entries(cards).filter(([sessionId]) => !visibleIds.has(sessionId)).map(([sessionId, card]) => ({
|
|
8834
|
+
sessionId,
|
|
8835
|
+
...card
|
|
8836
|
+
})).sort((a, b) => a.name.localeCompare(b.name, "zh"));
|
|
8837
|
+
}
|
|
8838
|
+
/** One panel read: the visible session roster plus every saved role card. */
|
|
8839
|
+
async function handleCorpsGet(ctx, service, res) {
|
|
8840
|
+
const cards = loadCorpsCards();
|
|
8841
|
+
try {
|
|
8842
|
+
const roster = await collectCorpsRoster(ctx, service);
|
|
8843
|
+
if (roster === void 0) {
|
|
8844
|
+
sendJson(res, 200, {
|
|
8845
|
+
ok: true,
|
|
8846
|
+
filtered: false,
|
|
8847
|
+
sessions: [],
|
|
8848
|
+
orphans: corpsOrphans(cards, /* @__PURE__ */ new Set()),
|
|
8849
|
+
note: "宿主没有提供会话列表服务,当前只能看到已保存的角色卡。"
|
|
8850
|
+
});
|
|
8851
|
+
return;
|
|
8852
|
+
}
|
|
8853
|
+
sendJson(res, 200, {
|
|
8854
|
+
ok: true,
|
|
8855
|
+
filtered: roster.filtered,
|
|
8856
|
+
sessions: roster.rows.map((row) => ({
|
|
8857
|
+
...row,
|
|
8858
|
+
card: cards[row.sessionId] ?? null
|
|
8859
|
+
})),
|
|
8860
|
+
orphans: corpsOrphans(cards, roster.visibleIds)
|
|
8861
|
+
});
|
|
8862
|
+
} catch (error) {
|
|
8863
|
+
console.warn("[dsh-advisor-group] corps 读取失败:", error instanceof Error ? error.message : String(error));
|
|
8864
|
+
sendJson(res, 500, {
|
|
8865
|
+
ok: false,
|
|
8866
|
+
error: error instanceof Error ? error.message : String(error)
|
|
8867
|
+
});
|
|
8868
|
+
}
|
|
8869
|
+
}
|
|
8870
|
+
/** How many unassigned sessions one generation pass asks the model about. */
|
|
8871
|
+
const CORPS_GENERATE_LIMIT = 12;
|
|
8872
|
+
const CORPS_GENERATE_SYSTEM = `你是 DSH 顾问团的名册编辑。根据每个会话的标题、工作目录和活跃提示,推断它最适合承担什么任务,为它起一个简短的中文身份姓名,并写一句职责。只输出 JSON,不要解释。`;
|
|
8873
|
+
/** Accept a bare JSON object, a fenced block, or an array; validate against the roster. */
|
|
8874
|
+
function parseCorpsProposals(text, rows, cards) {
|
|
8875
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
8876
|
+
const raw = (fenced === null ? text : fenced[1]).trim();
|
|
8877
|
+
const objectStart = raw.indexOf("{");
|
|
8878
|
+
const objectEnd = raw.lastIndexOf("}");
|
|
8879
|
+
const arrayStart = raw.indexOf("[");
|
|
8880
|
+
const arrayEnd = raw.lastIndexOf("]");
|
|
8881
|
+
const candidate = arrayStart >= 0 && (objectStart < 0 || arrayStart < objectStart) && arrayEnd > arrayStart ? raw.slice(arrayStart, arrayEnd + 1) : objectStart >= 0 && objectEnd > objectStart ? raw.slice(objectStart, objectEnd + 1) : "";
|
|
8882
|
+
if (candidate.length === 0) return [];
|
|
8883
|
+
let parsed;
|
|
8884
|
+
try {
|
|
8885
|
+
parsed = JSON.parse(candidate);
|
|
8886
|
+
} catch {
|
|
8887
|
+
return [];
|
|
8888
|
+
}
|
|
8889
|
+
const items = isPlainRecord(parsed) && Array.isArray(parsed.cards) ? parsed.cards : Array.isArray(parsed) ? parsed : [];
|
|
8890
|
+
const allowed = new Set(rows.map((row) => row.sessionId));
|
|
8891
|
+
const usedNames = new Set(Object.values(cards).map((card) => card.name.trim().toLowerCase()));
|
|
8892
|
+
const usedIds = /* @__PURE__ */ new Set();
|
|
8893
|
+
const out = [];
|
|
8894
|
+
for (const item of items) {
|
|
8895
|
+
if (!isPlainRecord(item)) continue;
|
|
8896
|
+
const sessionId = typeof item.sessionId === "string" ? item.sessionId : "";
|
|
8897
|
+
const name = typeof item.name === "string" ? item.name.trim() : "";
|
|
8898
|
+
const duty = typeof item.duty === "string" ? item.duty.trim() : "";
|
|
8899
|
+
if (!allowed.has(sessionId) || usedIds.has(sessionId)) continue;
|
|
8900
|
+
if (name.length === 0 || name.length > 40 || duty.length > 200) continue;
|
|
8901
|
+
const lower = name.toLowerCase();
|
|
8902
|
+
if (usedNames.has(lower)) continue;
|
|
8903
|
+
usedNames.add(lower);
|
|
8904
|
+
usedIds.add(sessionId);
|
|
8905
|
+
out.push({
|
|
8906
|
+
sessionId,
|
|
8907
|
+
name,
|
|
8908
|
+
duty
|
|
8909
|
+
});
|
|
8910
|
+
}
|
|
8911
|
+
return out;
|
|
8912
|
+
}
|
|
8913
|
+
/**
|
|
8914
|
+
* Ask the CURRENT conversation's main model for unassigned role cards. The
|
|
8915
|
+
* panel then shows the proposals as drafts; nothing is saved until the user
|
|
8916
|
+
* confirms each row. Existing cards are never overwritten.
|
|
8917
|
+
*/
|
|
8918
|
+
async function handleCorpsGenerate(ctx, service, req, res) {
|
|
8919
|
+
let sessionId = "";
|
|
8920
|
+
try {
|
|
8921
|
+
const parsed = JSON.parse(await readBody(req));
|
|
8922
|
+
sessionId = typeof parsed.sessionId === "string" ? parsed.sessionId : "";
|
|
8923
|
+
} catch (error) {
|
|
8924
|
+
sendJson(res, 400, {
|
|
8925
|
+
ok: false,
|
|
8926
|
+
error: `请求格式不正确:${error instanceof Error ? error.message : String(error)}`
|
|
8927
|
+
});
|
|
8928
|
+
return;
|
|
8929
|
+
}
|
|
8930
|
+
if (sessionId.length === 0) {
|
|
8931
|
+
sendJson(res, 400, {
|
|
8932
|
+
ok: false,
|
|
8933
|
+
error: "缺少 sessionId"
|
|
8934
|
+
});
|
|
8935
|
+
return;
|
|
8936
|
+
}
|
|
8937
|
+
try {
|
|
8938
|
+
const roster = await collectCorpsRoster(ctx, service);
|
|
8939
|
+
if (roster === void 0) {
|
|
8940
|
+
sendJson(res, 400, {
|
|
8941
|
+
ok: false,
|
|
8942
|
+
error: "宿主没有提供会话列表服务,无法生成。"
|
|
8943
|
+
});
|
|
8944
|
+
return;
|
|
8945
|
+
}
|
|
8946
|
+
const cards = loadCorpsCards();
|
|
8947
|
+
const unassigned = roster.rows.filter((row) => cards[row.sessionId] === void 0).slice(0, CORPS_GENERATE_LIMIT);
|
|
8948
|
+
if (unassigned.length === 0) {
|
|
8949
|
+
sendJson(res, 200, {
|
|
8950
|
+
ok: true,
|
|
8951
|
+
proposals: [],
|
|
8952
|
+
note: "所有可见会话都已有角色卡。"
|
|
8953
|
+
});
|
|
8954
|
+
return;
|
|
8955
|
+
}
|
|
8956
|
+
const sessions = ctx.sessions;
|
|
8957
|
+
const source = resolveDriverSource(typeof sessions?.get === "function" ? sessions.get(sessionId) : void 0, service.getConfig().discussion.driverModel);
|
|
8958
|
+
if (source === void 0) {
|
|
8959
|
+
sendJson(res, 400, {
|
|
8960
|
+
ok: false,
|
|
8961
|
+
error: "当前会话没有可用的主模型配置,无法生成角色卡。"
|
|
8962
|
+
});
|
|
8963
|
+
return;
|
|
8964
|
+
}
|
|
8965
|
+
const prompt = [
|
|
8966
|
+
"请为下列 DSH 会话各生成一张顾问团角色卡。",
|
|
8967
|
+
"",
|
|
8968
|
+
...unassigned.map((row, index) => `${index + 1}. sessionId=${row.sessionId} | 标题=${row.name} | 目录=${row.cwd ?? "未知"} | ${row.hint}`),
|
|
8969
|
+
"",
|
|
8970
|
+
"要求:",
|
|
8971
|
+
"- 姓名简短,像同事的称呼,彼此不同,不要照抄标题;",
|
|
8972
|
+
"- 职责一句话,说明最适合承担什么任务,不超过 30 个汉字;",
|
|
8973
|
+
"- sessionId 必须原样照抄;",
|
|
8974
|
+
"- 只输出 JSON:{\"cards\":[{\"sessionId\":\"...\",\"name\":\"...\",\"duty\":\"...\"}]}"
|
|
8975
|
+
].join("\n");
|
|
8976
|
+
const text = await generateWith(ctx, source, sessionId, CORPS_GENERATE_SYSTEM, prompt, AbortSignal.timeout(9e4), 9e4);
|
|
8977
|
+
if (text === void 0 || text.trim().length === 0) {
|
|
8978
|
+
sendJson(res, 502, {
|
|
8979
|
+
ok: false,
|
|
8980
|
+
error: "主模型没有返回内容。"
|
|
8981
|
+
});
|
|
8982
|
+
return;
|
|
8983
|
+
}
|
|
8984
|
+
const proposals = parseCorpsProposals(text, unassigned, cards);
|
|
8985
|
+
if (proposals.length === 0) {
|
|
8986
|
+
sendJson(res, 200, {
|
|
8987
|
+
ok: false,
|
|
8988
|
+
error: "主模型没有返回可用的角色卡,请重试。",
|
|
8989
|
+
raw: text.slice(0, 500)
|
|
8990
|
+
});
|
|
8991
|
+
return;
|
|
8992
|
+
}
|
|
8993
|
+
sendJson(res, 200, {
|
|
8994
|
+
ok: true,
|
|
8995
|
+
proposals,
|
|
8996
|
+
...proposals.length < unassigned.length ? { note: `只生成了 ${proposals.length} / ${unassigned.length} 张,可以再点一次补齐。` } : {},
|
|
8997
|
+
model: {
|
|
8998
|
+
provider: source.provider,
|
|
8999
|
+
model: source.model
|
|
9000
|
+
}
|
|
9001
|
+
});
|
|
9002
|
+
} catch (error) {
|
|
9003
|
+
console.warn("[dsh-advisor-group] corps 生成失败:", error instanceof Error ? error.message : String(error));
|
|
9004
|
+
sendJson(res, 500, {
|
|
9005
|
+
ok: false,
|
|
9006
|
+
error: error instanceof Error ? error.message : String(error)
|
|
9007
|
+
});
|
|
9008
|
+
}
|
|
9009
|
+
}
|
|
9010
|
+
/** One panel write: upsert or delete a single role card. */
|
|
9011
|
+
async function handleCorpsPost(req, res) {
|
|
9012
|
+
try {
|
|
9013
|
+
const parsed = JSON.parse(await readBody(req));
|
|
9014
|
+
const result = upsertCorpsCard(loadCorpsCards(), typeof parsed.sessionId === "string" ? parsed.sessionId : "", typeof parsed.name === "string" ? parsed.name : "", typeof parsed.duty === "string" ? parsed.duty : "");
|
|
9015
|
+
if (!result.ok) {
|
|
9016
|
+
sendJson(res, result.code === "duplicate-name" ? 409 : 400, {
|
|
9017
|
+
ok: false,
|
|
9018
|
+
error: result.error,
|
|
9019
|
+
code: result.code
|
|
9020
|
+
});
|
|
9021
|
+
return;
|
|
9022
|
+
}
|
|
9023
|
+
try {
|
|
9024
|
+
saveCorpsCards(result.cards);
|
|
9025
|
+
} catch (error) {
|
|
9026
|
+
sendJson(res, 500, {
|
|
9027
|
+
ok: false,
|
|
9028
|
+
error: `保存角色卡失败:${error instanceof Error ? error.message : String(error)}`
|
|
9029
|
+
});
|
|
9030
|
+
return;
|
|
9031
|
+
}
|
|
9032
|
+
sendJson(res, 200, {
|
|
9033
|
+
ok: true,
|
|
9034
|
+
cards: result.cards
|
|
9035
|
+
});
|
|
9036
|
+
} catch (error) {
|
|
9037
|
+
sendJson(res, 400, {
|
|
9038
|
+
ok: false,
|
|
9039
|
+
error: `请求格式不正确:${error instanceof Error ? error.message : String(error)}`
|
|
9040
|
+
});
|
|
9041
|
+
}
|
|
9042
|
+
}
|
|
7644
9043
|
async function sendProviders(ctx, res) {
|
|
7645
9044
|
const providers = [];
|
|
7646
9045
|
try {
|
|
@@ -7734,7 +9133,8 @@ function buildAdvisorPrompt(config, repeatPressure) {
|
|
|
7734
9133
|
" b) 遇到问题的具体细节(做了什么、期望什么、实际发生了什么、已尝试哪些步骤、相关报错或现象)。",
|
|
7735
9134
|
" 如果某项信息确实未知,明确写“该项背景未知”,不要留空 context,也不要让顾问在缺少背景的情况下猜测。",
|
|
7736
9135
|
"6. 拿到顾问结论后:自行判断、综合并执行,不要原样复制整段顾问回复。",
|
|
7737
|
-
"7. 发起新咨询:插件会自动进行多轮深挖(默认 autoDeepen:每轮结束由驱动模型基于全部讨论生成更深入的追问,顾问按顺序 A→B→C 接力回答,直到 maxRounds 轮),并自动产出综合结论——你无需再手动 followUp;只有想追加一轮固定主题时才传 sessionId+followUp。"
|
|
9136
|
+
"7. 发起新咨询:插件会自动进行多轮深挖(默认 autoDeepen:每轮结束由驱动模型基于全部讨论生成更深入的追问,顾问按顺序 A→B→C 接力回答,直到 maxRounds 轮),并自动产出综合结论——你无需再手动 followUp;只有想追加一轮固定主题时才传 sessionId+followUp。",
|
|
9137
|
+
"8. 跨会话提问(ask_session)时,可以先调用 list_sessions 查看「顾问团」的姓名与职责,再直接用姓名作为 target;原来的会话标题、目录名和 sessionId 仍然可用。"
|
|
7738
9138
|
];
|
|
7739
9139
|
if (repeatPressure && repeatPressure.count >= 3) lines.push("", `【强制升级】当前会话中同一问题已被用户重复 ${repeatPressure.count} 次仍未解决。你必须立即调用 ask_advisors,把用户最新一次的问题原样作为 question 传入,并在 context 中补充项目概况与已尝试步骤;不得再让用户重复或自行空转。`);
|
|
7740
9140
|
else if (repeatPressure && repeatPressure.count > 1) lines.push("", `【提醒】当前会话中同一问题已出现 ${repeatPressure.count} 次。若本轮仍无法解决,必须调用 ask_advisors 升级到顾问群。`);
|
|
@@ -7783,6 +9183,22 @@ const inject = [
|
|
|
7783
9183
|
"sessions"
|
|
7784
9184
|
];
|
|
7785
9185
|
/**
|
|
9186
|
+
* Recall candidates for the background hint, restricted to the sessions the
|
|
9187
|
+
* host actually shows: the registered set (workspace `sessionIds` minus
|
|
9188
|
+
* archived). `registered === undefined` means "this host has no workspace
|
|
9189
|
+
* registry" and filters nothing; a session no workspace registers must never be
|
|
9190
|
+
* suggested by a hint. Pure except for the bounded log-size probe inside the
|
|
9191
|
+
* router.
|
|
9192
|
+
*/
|
|
9193
|
+
function recallHintCandidates(input) {
|
|
9194
|
+
return routeSessions(withSessionLogSizes(input.sessions.filter((session) => input.registered === void 0 || input.registered.has(session.sessionId))), input.text, {
|
|
9195
|
+
topN: input.topN,
|
|
9196
|
+
currentSessionId: input.currentSessionId,
|
|
9197
|
+
cache: false,
|
|
9198
|
+
exclude: input.exclude ?? []
|
|
9199
|
+
});
|
|
9200
|
+
}
|
|
9201
|
+
/**
|
|
7786
9202
|
* One background cross-session suggestion pass: recall the local candidates
|
|
7787
9203
|
* for this turn, ask Jev the batched judgment, and remember a one-turn hint.
|
|
7788
9204
|
* Everything is best effort — no hint, no error, no delivery on any failure.
|
|
@@ -7792,10 +9208,12 @@ async function suggestCrossSession(input) {
|
|
|
7792
9208
|
const controller = getSessionController(input.ctx);
|
|
7793
9209
|
if (controller === void 0) return;
|
|
7794
9210
|
const config = input.service.getConfig();
|
|
7795
|
-
const candidates =
|
|
9211
|
+
const candidates = recallHintCandidates({
|
|
9212
|
+
sessions: visibleRosterSessions((await controller.list({}, input.signal)).items, config.swarm.rosterIncludeSubagents),
|
|
9213
|
+
registered: registeredSessionIds(input.ctx),
|
|
9214
|
+
text: input.text,
|
|
7796
9215
|
topN: config.swarm.routingTopN,
|
|
7797
9216
|
currentSessionId: input.sessionId,
|
|
7798
|
-
cache: false,
|
|
7799
9217
|
exclude: Array.isArray(config.swarm.routingExclude) ? config.swarm.routingExclude : []
|
|
7800
9218
|
});
|
|
7801
9219
|
const judge = async (turn) => judgeCrossSessionWithJev(turn.text, turn.textEn, turn.candidates.map((candidate) => ({
|
|
@@ -7885,4 +9303,4 @@ function apply(ctx, config) {
|
|
|
7885
9303
|
}), "dsh-advisor-group: system prompt section");
|
|
7886
9304
|
}
|
|
7887
9305
|
//#endregion
|
|
7888
|
-
export { Config, apply, inject, name };
|
|
9306
|
+
export { Config, apply, inject, name, recallHintCandidates };
|