@y9931666/dsh-plugin-roundtable 1.0.0-rc.2
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/LICENSE +21 -0
- package/README.md +328 -0
- package/cordis.patch.yml +22 -0
- package/lib/client.js +4276 -0
- package/lib/edge-helper-iqXOG5o_.js +12 -0
- package/lib/index.js +4535 -0
- package/lib/state-BzBQig2q.js +479 -0
- package/lib/state-pJShWoUr.js +3 -0
- package/lib/types/aggregator.d.ts +15 -0
- package/lib/types/budget.d.ts +46 -0
- package/lib/types/charter.d.ts +13 -0
- package/lib/types/client/RoundTableSettings.d.ts +14 -0
- package/lib/types/client/RoundTableView.d.ts +27 -0
- package/lib/types/client/brand-logos.generated.d.ts +5 -0
- package/lib/types/client/index.d.ts +23 -0
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/slot-boundary.d.ts +49 -0
- package/lib/types/client/tool-views-model.d.ts +65 -0
- package/lib/types/client/tool-views.d.ts +31 -0
- package/lib/types/client/wire.d.ts +238 -0
- package/lib/types/edge-helper.d.ts +7 -0
- package/lib/types/harness-compat.d.ts +204 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/kb-digest.d.ts +47 -0
- package/lib/types/members.d.ts +83 -0
- package/lib/types/node-events.d.ts +90 -0
- package/lib/types/plan.d.ts +86 -0
- package/lib/types/preferences.d.ts +143 -0
- package/lib/types/prompt.d.ts +52 -0
- package/lib/types/proxy-thinking.d.ts +12 -0
- package/lib/types/review-split.d.ts +61 -0
- package/lib/types/rpc.d.ts +89 -0
- package/lib/types/skills.d.ts +61 -0
- package/lib/types/snapshot.d.ts +96 -0
- package/lib/types/state.d.ts +142 -0
- package/lib/types/token-usage.d.ts +71 -0
- package/lib/types/tools.d.ts +59 -0
- package/lib/types/types.d.ts +330 -0
- package/lib/types/version.d.ts +21 -0
- package/lib/types/web-guard.d.ts +37 -0
- package/lib/types/workspace-candidates.d.ts +17 -0
- package/lib/workspace-candidates-B7isleLt.js +3 -0
- package/lib/workspace-candidates-DNmGPpSc.js +20 -0
- package/package.json +161 -0
- package/release-notes/v0.2.0.md +39 -0
- package/release-notes/v0.2.1.md +39 -0
- package/release-notes/v0.2.2.md +54 -0
- package/release-notes/v0.2.21.md +42 -0
- package/release-notes/v0.2.31.md +70 -0
- package/release-notes/v0.2.32.md +52 -0
- package/release-notes/v0.2.35.md +116 -0
- package/release-notes/v0.2.36.md +84 -0
- package/release-notes/v1.0.0-rc.1.md +165 -0
- package/release-notes/v1.0.0-rc.2.md +180 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,4535 @@
|
|
|
1
|
+
import { C as withMeetingLock, D as writeTextAtomic, E as writeReview, S as stateRootOf, T as writeMeeting, _ as readUserActions, a as clearFeedback, b as sanitizeKey, c as listMeetings, f as readFeedback, g as readTranscript, h as readReview, i as appendUtterance, l as meetingDirOf, m as readMeeting, n as appendFeedback, o as clearFeedbackForMeeting, p as readKbDigest, r as appendUserAction, s as clearUserActions, v as readUserActionsReport, w as writeKbDigest, x as setViewpointStatus, y as resetMeetingDirectory } from "./state-BzBQig2q.js";
|
|
2
|
+
import { n as workspaceCandidates, t as setWorkspaceCandidates } from "./workspace-candidates-DNmGPpSc.js";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
|
|
7
|
+
import { dirname, extname, isAbsolute, join } from "node:path";
|
|
8
|
+
import { ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
|
|
11
|
+
//#region src/types.ts
|
|
12
|
+
/** Node statuses that still count as participants. */
|
|
13
|
+
const ACTIVE_NODE_STATUSES = [
|
|
14
|
+
"idle",
|
|
15
|
+
"working",
|
|
16
|
+
"ready"
|
|
17
|
+
];
|
|
18
|
+
/** Reserved speaker key of the captain (the owning session). */
|
|
19
|
+
const CAPTAIN_KEY = "captain";
|
|
20
|
+
/** Reserved speaker key of the aggregation gateway. */
|
|
21
|
+
const AGGREGATOR_KEY = "aggregator";
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/kb-digest.ts
|
|
25
|
+
/** 缓存条目数上限;超出按 `ts` 最旧淘汰。 */
|
|
26
|
+
const KB_DIGEST_MAX_ENTRIES = 50;
|
|
27
|
+
/** 单条摘要字符上限;超出截断(缓存是"要点",不是全文副本)。 */
|
|
28
|
+
const KB_DIGEST_MAX_CHARS = 2e3;
|
|
29
|
+
/**
|
|
30
|
+
* 失效判定(纯函数):失效键完全一致才算命中。
|
|
31
|
+
*
|
|
32
|
+
* `current === undefined`(文件已不存在 / 读不到)一律视为失效——宁可让
|
|
33
|
+
* 主持人多读一次,也不要拿一份可能过期的摘要去回答专家。
|
|
34
|
+
*/
|
|
35
|
+
function digestIsFresh(entry, current) {
|
|
36
|
+
if (current === void 0) return false;
|
|
37
|
+
return entry.size === current.size && entry.mtimeMs === current.mtimeMs;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 合并一条摘要(纯函数):同 `path` 覆盖,其余按 `ts` 从新到旧保留,
|
|
41
|
+
* 超出 `maxEntries` 的部分淘汰最旧。返回新数组,不改动入参。
|
|
42
|
+
*/
|
|
43
|
+
function mergeKbDigestEntry(entries, next, maxEntries = KB_DIGEST_MAX_ENTRIES) {
|
|
44
|
+
const kept = entries.filter((entry) => entry.path !== next.path);
|
|
45
|
+
kept.push({
|
|
46
|
+
...next,
|
|
47
|
+
digest: next.digest.slice(0, KB_DIGEST_MAX_CHARS)
|
|
48
|
+
});
|
|
49
|
+
kept.sort((a, b) => b.ts - a.ts);
|
|
50
|
+
return kept.slice(0, Math.max(1, maxEntries));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 用磁盘现状标注每条缓存的 `valid`。
|
|
54
|
+
*
|
|
55
|
+
* 每条一次 `stat`:条目上限是 {@link KB_DIGEST_MAX_ENTRIES}(50),
|
|
56
|
+
* 而且只在 `roundtable_status` 里调用,成本可忽略。
|
|
57
|
+
*/
|
|
58
|
+
async function evaluateKbDigests(entries) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
let valid = false;
|
|
62
|
+
try {
|
|
63
|
+
const info = await stat(entry.path);
|
|
64
|
+
valid = info.isFile() && digestIsFresh(entry, {
|
|
65
|
+
size: info.size,
|
|
66
|
+
mtimeMs: info.mtimeMs
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
valid = false;
|
|
70
|
+
}
|
|
71
|
+
out.push({
|
|
72
|
+
...entry,
|
|
73
|
+
valid
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/version.ts
|
|
81
|
+
/**
|
|
82
|
+
* 插件版本号的源码侧单一来源。
|
|
83
|
+
*
|
|
84
|
+
* `package.json` 的 `version` 是发布侧的权威值,两者必须一致:
|
|
85
|
+
* `test/version.test.mjs` 会在不一致时直接让测试失败。加这道闸门是因为
|
|
86
|
+
* `renderReviewMarkdown` 的导出头部曾长期硬编码 `v0.2.21`,而 package.json
|
|
87
|
+
* 已经走到 0.2.32 —— 导出物里的版本号说了谎,却没有任何测试会发现。
|
|
88
|
+
*
|
|
89
|
+
* @module dsh-plugin-roundtable/version
|
|
90
|
+
*/
|
|
91
|
+
/** 当前插件版本(必须与 package.json 的 version 逐字一致)。 */
|
|
92
|
+
const PLUGIN_VERSION = "1.0.0-rc.2";
|
|
93
|
+
/** 导出物头部使用的插件标识行。 */
|
|
94
|
+
const PLUGIN_ID = `@y9931666/dsh-plugin-roundtable v${PLUGIN_VERSION}`;
|
|
95
|
+
/** DeepSeek Harness 版本区间(导出头部展示用)。
|
|
96
|
+
*
|
|
97
|
+
* 必须与 `compatibility.json` 的 `recommendedHost` 一致 —— 由
|
|
98
|
+
* `test/compatibility.test.mjs` 强制,`scripts/compatibility.mjs` 参与发布门禁。
|
|
99
|
+
* 此前这里是硬编码字符串,与 devDependencies 一起漂到了 rc.2 而无人发现;
|
|
100
|
+
* 0.2.0-rc.2 这次跨大版本升级同样由这两道闸门兜住。 */
|
|
101
|
+
const HARNESS_RANGE = "DeepSeek Harness 0.2.0-rc.2+";
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/charter.ts
|
|
105
|
+
/** Build the charter text for one meeting's current roster and edges. */
|
|
106
|
+
function buildCharter(meeting) {
|
|
107
|
+
const roster = meeting.nodes.filter((node) => node.status !== "removed").map((node) => `${node.key}${node.role !== void 0 && node.role !== "" ? `(${node.role})` : ""}`).join("、");
|
|
108
|
+
const edges = meeting.edges.map((edge) => `${edge.from} ${edge.direction === "bidirectional" ? "⇄" : "→"} ${edge.to}`).join("、");
|
|
109
|
+
const redteamRules = meeting.mode === "redteam" ? [
|
|
110
|
+
"",
|
|
111
|
+
"五、针锋相对评审协议(本会议为「针锋相对」模式)",
|
|
112
|
+
"- 只找主持人已定稿方案的真实缺陷与认知盲区,严禁提出替代方案;指出问题要具体,不抬杠。",
|
|
113
|
+
"- 每条观点只聚焦一个缺陷(1~3 条);如无缺陷可明确说明\"暂无\"。",
|
|
114
|
+
"- 证据分级:代码/bug 类缺陷附可复现步骤(\"可复现:1. …\");设计类缺陷附论证链(\"论证:因为…所以…\"),不得为设计类缺陷编造伪复现步骤。",
|
|
115
|
+
"- 闭环复审只核对上一轮已认定的缺陷是否被修复,禁止引入全新打分项。"
|
|
116
|
+
] : [];
|
|
117
|
+
return [
|
|
118
|
+
"《全局协作总纲》",
|
|
119
|
+
"",
|
|
120
|
+
"一、会议背景与核心目标",
|
|
121
|
+
meeting.goal.trim() === "" ? "(未提供,以主持人现场说明为准)" : meeting.goal,
|
|
122
|
+
"",
|
|
123
|
+
"二、团队成员与角色边界",
|
|
124
|
+
"主持人(DeepSeek):全局编排与最终汇总,唯一对用户负责。",
|
|
125
|
+
`专家节点:${roster === "" ? "(暂无)" : roster}`,
|
|
126
|
+
`连线通道:${edges === "" ? "(暂无)" : edges}`,
|
|
127
|
+
"权限隔离:节点只做本职,严禁越权代管其他节点的工作;只有主持人可以创建/移除节点、修改连线、发起人类决策、结束会议。",
|
|
128
|
+
"",
|
|
129
|
+
"三、标准化协作协议",
|
|
130
|
+
"- 每次发言以 [当前状态] 开头、以 [核心产出] 与 [下一步建议] 结尾;严禁\"好的\"\"收到\"这类无信息量内容。",
|
|
131
|
+
"- 发言通过 roundtable_speak 写入会议记录(to 留空 = 交汇聚网关;定向回复填对方 key)。",
|
|
132
|
+
"",
|
|
133
|
+
"四、全局约束与安全红线",
|
|
134
|
+
"- 遇到分歧或无法独自决定的事项,建议主持人触发 [需人类决策],严禁自行替用户拍板。",
|
|
135
|
+
"- 严禁编造不存在的 API、数据或事实;不确定时明确说明。",
|
|
136
|
+
"- 超预算(轮数/Token)时会议自动闭麦。",
|
|
137
|
+
...redteamRules
|
|
138
|
+
].join("\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/aggregator.ts
|
|
143
|
+
const MAX_LENGTH = 4e3;
|
|
144
|
+
const MAX_PER_UTTERANCE = 160;
|
|
145
|
+
/** Pull the "core output" / stance out of a formatted contribution, else compact the whole text.
|
|
146
|
+
*
|
|
147
|
+
* 导出给 `roundtable_status` 复用:同一条 400 字符的配额,取 `[核心产出]`
|
|
148
|
+
* 比取发言开头(`[当前状态]`)信息量大得多 —— 不是配额不够,是取错了地方。 */
|
|
149
|
+
function extractCore(text) {
|
|
150
|
+
const core = text.match(/\[核心产出\]\s*([\s\S]*?)(?=\n\s*\[|$)/)?.[1]?.trim();
|
|
151
|
+
if (core) return core;
|
|
152
|
+
const stance = text.match(/(?:立场|主张|观点|结论)[::]\s*([\s\S]*?)(?=\n|$)/)?.[1]?.trim();
|
|
153
|
+
if (stance) return stance;
|
|
154
|
+
return text.replace(/\s+/g, " ").trim();
|
|
155
|
+
}
|
|
156
|
+
function compact(text, limit) {
|
|
157
|
+
const single = text.replace(/\s+/g, " ").trim();
|
|
158
|
+
return single.length > limit ? `${single.slice(0, limit)}…` : single;
|
|
159
|
+
}
|
|
160
|
+
/** Merge utterances into a compact, chronological exchange (who → whom: core point). */
|
|
161
|
+
function aggregateUtterances(utterances, maxLength = MAX_LENGTH) {
|
|
162
|
+
const lines = [];
|
|
163
|
+
for (const utterance of utterances) {
|
|
164
|
+
if (utterance.kind !== "speech" && utterance.kind !== "proxy-thinking") continue;
|
|
165
|
+
const from = utterance.nodeKey;
|
|
166
|
+
const to = utterance.to ?? "网关";
|
|
167
|
+
const body = compact(extractCore(utterance.summary ?? utterance.content), MAX_PER_UTTERANCE);
|
|
168
|
+
lines.push(`[R${utterance.round}] ${from} → ${to}\n${body}`);
|
|
169
|
+
}
|
|
170
|
+
const text = `[汇聚网关·交锋摘要]\n${lines.join("\n\n") || "(暂无发言)"}`;
|
|
171
|
+
return text.length > maxLength ? `${text.slice(0, maxLength)}\n…(截断)` : text;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/budget.ts
|
|
176
|
+
/** Which budget axis is exceeded, if any. */
|
|
177
|
+
function budgetExceeded(meeting) {
|
|
178
|
+
if (meeting.round > meeting.budget.maxRounds) return "rounds";
|
|
179
|
+
if (meeting.budget.usedTokens >= meeting.budget.maxTokens) return "tokens";
|
|
180
|
+
}
|
|
181
|
+
/** Error when the meeting has ended. */
|
|
182
|
+
var MeetingEndedError = class extends Error {
|
|
183
|
+
constructor(meetingId) {
|
|
184
|
+
super(`roundtable: meeting "${meetingId}" has ended — start a new meeting`);
|
|
185
|
+
this.name = "MeetingEndedError";
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
/** Error when the meeting is muted (闭麦) on a budget axis. */
|
|
189
|
+
var MeetingMutedError = class extends Error {
|
|
190
|
+
axis;
|
|
191
|
+
constructor(meetingId, axis) {
|
|
192
|
+
super(`roundtable: meeting "${meetingId}" is muted on ${axis} — the captain may top up the budget with roundtable_set_budget or close the meeting`);
|
|
193
|
+
this.name = "MeetingMutedError";
|
|
194
|
+
this.axis = axis;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* Gate a write against the meeting's status and budget WITHOUT mutating it.
|
|
199
|
+
*
|
|
200
|
+
* The previous `ensureActive` both flipped `status` to `'muted'` and then threw
|
|
201
|
+
* on the same call. Because the throw happened before the caller could persist
|
|
202
|
+
* anything, `muted` never reached disk, and the very tools meant to resolve the
|
|
203
|
+
* situation (`roundtable_set_budget`, `roundtable_close`, the two exporters)
|
|
204
|
+
* were rejected by that same gate — a meeting that had spent its budget stayed
|
|
205
|
+
* `active` forever while refusing every action.
|
|
206
|
+
*
|
|
207
|
+
* @param meeting - meeting to test.
|
|
208
|
+
* @param options.allowMuted - resolve/escalate paths (top up the budget, close,
|
|
209
|
+
* export, clear the user-action queue) stay reachable while muted; everything
|
|
210
|
+
* that produces new work still throws.
|
|
211
|
+
*/
|
|
212
|
+
function assertUsable(meeting, options = {}) {
|
|
213
|
+
if (meeting.status === "ended" || meeting.status === "archived") throw new MeetingEndedError(meeting.id);
|
|
214
|
+
if (options.allowMuted === true) return;
|
|
215
|
+
const axis = budgetExceeded(meeting);
|
|
216
|
+
if (axis !== void 0) throw new MeetingMutedError(meeting.id, axis);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Persist the fact that the meeting is out of budget.
|
|
220
|
+
*
|
|
221
|
+
* @param meeting - meeting mutated in place.
|
|
222
|
+
* @returns true when the status just became `'muted'`, in which case the caller
|
|
223
|
+
* owns writing the meeting back so the mute is actually observable.
|
|
224
|
+
*/
|
|
225
|
+
function markMuted(meeting) {
|
|
226
|
+
if (meeting.status !== "active") return false;
|
|
227
|
+
if (budgetExceeded(meeting) === void 0) return false;
|
|
228
|
+
meeting.status = "muted";
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
/** Estimate the token cost of a text (coarse: ~0.6 tokens per CJK char, ~0.3 per latin). */
|
|
232
|
+
function estimateTokens(text) {
|
|
233
|
+
let cjk = 0;
|
|
234
|
+
let other = 0;
|
|
235
|
+
for (const char of text) if ((char.codePointAt(0) ?? 0) >= 11904) cjk += 1;
|
|
236
|
+
else if (char.trim() !== "") other += 1;
|
|
237
|
+
return Math.ceil(cjk * .6 + other * .3);
|
|
238
|
+
}
|
|
239
|
+
/** Advance the meeting round and return the new round number. */
|
|
240
|
+
function beginRound(meeting) {
|
|
241
|
+
meeting.round += 1;
|
|
242
|
+
meeting.budget.usedRounds = meeting.round;
|
|
243
|
+
return meeting.round;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/node-events.ts
|
|
248
|
+
/** childId → meeting location. Process-local on purpose (see module docs). */
|
|
249
|
+
const nodeIndex = /* @__PURE__ */ new Map();
|
|
250
|
+
/** Remember which meeting a freshly spawned node belongs to. */
|
|
251
|
+
function registerNodeChild(childId, location) {
|
|
252
|
+
if (childId === "") return;
|
|
253
|
+
nodeIndex.set(childId, location);
|
|
254
|
+
}
|
|
255
|
+
/** Drop one node's index entry (node removed, or failed spawn rolled back). */
|
|
256
|
+
function forgetNodeChild(childId) {
|
|
257
|
+
if (childId === "") return;
|
|
258
|
+
nodeIndex.delete(childId);
|
|
259
|
+
}
|
|
260
|
+
/** Look up a node's meeting by its child session id. */
|
|
261
|
+
function locateNodeChild(childId) {
|
|
262
|
+
return nodeIndex.get(childId);
|
|
263
|
+
}
|
|
264
|
+
/** Meeting-level lock key; mirrors `meetingLockKey` in tools.ts. */
|
|
265
|
+
function lockKey(location) {
|
|
266
|
+
return `meeting:${location.stateRoot}:${location.meetingId}`;
|
|
267
|
+
}
|
|
268
|
+
/** Flatten the text blocks of one assistant output (empty when there is none). */
|
|
269
|
+
function blocksToText(blocks) {
|
|
270
|
+
if (blocks === void 0 || blocks.length === 0) return "";
|
|
271
|
+
const parts = [];
|
|
272
|
+
for (const block of blocks) {
|
|
273
|
+
const candidate = block;
|
|
274
|
+
if (candidate.type === "text" && typeof candidate.text === "string") parts.push(candidate.text);
|
|
275
|
+
}
|
|
276
|
+
return parts.join("\n").trim();
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Decide what a settled child run contributes to its meeting.
|
|
280
|
+
*
|
|
281
|
+
* A run that ends `completed` but carries no text is *not* a hard failure (the
|
|
282
|
+
* expert may have only acknowledged), so it gets its own wording — the point is
|
|
283
|
+
* to never leave the captain guessing between "still working" and "died".
|
|
284
|
+
*/
|
|
285
|
+
function describeNodeEnd(info) {
|
|
286
|
+
const text = blocksToText(info.lastAssistantMessage);
|
|
287
|
+
if (text !== "") return {
|
|
288
|
+
text,
|
|
289
|
+
note: ""
|
|
290
|
+
};
|
|
291
|
+
const reason = info.stopReason ?? "unknown";
|
|
292
|
+
return {
|
|
293
|
+
text: "",
|
|
294
|
+
note: reason === "completed" ? "本轮结束但没有产出内容(可能只回了确认,未见 roundtable_speak)" : `本轮未产出内容(stopReason=${reason})`
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/** One-line readable description of a provider request failure. */
|
|
298
|
+
function describeRequestFailure(provider, failure) {
|
|
299
|
+
const status = typeof failure?.status === "number" ? ` HTTP ${failure.status}` : "";
|
|
300
|
+
const code = typeof failure?.code === "string" && failure.code !== "" ? ` ${failure.code}` : "";
|
|
301
|
+
const message = typeof failure?.message === "string" ? failure.message.trim().slice(0, 200) : "";
|
|
302
|
+
return `请求失败(provider=${provider}${status}${code})${message === "" ? "" : `:${message}`}`;
|
|
303
|
+
}
|
|
304
|
+
/** Write one node-level note (cleared by passing `undefined`). */
|
|
305
|
+
async function writeNodeNote(location, childId, note) {
|
|
306
|
+
await withMeetingLock(lockKey(location), async () => {
|
|
307
|
+
const meeting = await readMeeting(location.stateRoot, location.meetingId);
|
|
308
|
+
if (meeting === void 0) return;
|
|
309
|
+
const node = meeting.nodes.find((candidate) => candidate.id === childId && candidate.status !== "removed");
|
|
310
|
+
if (node === void 0) return;
|
|
311
|
+
if (node.lastError === note) return;
|
|
312
|
+
node.lastError = note;
|
|
313
|
+
meeting.updatedAt = Date.now();
|
|
314
|
+
await writeMeeting(location.stateRoot, meeting);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Capture one settled child run.
|
|
319
|
+
*
|
|
320
|
+
* With output: append it as an `auto-capture` utterance and clear `lastError`,
|
|
321
|
+
* so a node that spoke via the event path still lands in the transcript even
|
|
322
|
+
* though it never called `roundtable_speak`.
|
|
323
|
+
* Without output: leave a readable `lastError` on the node.
|
|
324
|
+
*/
|
|
325
|
+
async function onNodeEnd(ctx, info) {
|
|
326
|
+
const childId = String(info.id ?? "");
|
|
327
|
+
if (childId === "") return;
|
|
328
|
+
const location = locateNodeChild(childId);
|
|
329
|
+
if (location === void 0) return;
|
|
330
|
+
const outcome = describeNodeEnd(info);
|
|
331
|
+
try {
|
|
332
|
+
if (outcome.text === "") {
|
|
333
|
+
await writeNodeNote(location, childId, outcome.note);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
await withMeetingLock(lockKey(location), async () => {
|
|
337
|
+
const meeting = await readMeeting(location.stateRoot, location.meetingId);
|
|
338
|
+
if (meeting === void 0) return;
|
|
339
|
+
const node = meeting.nodes.find((candidate) => candidate.id === childId && candidate.status !== "removed");
|
|
340
|
+
if (node === void 0) return;
|
|
341
|
+
const utterance = {
|
|
342
|
+
id: randomUUID(),
|
|
343
|
+
nodeKey: node.key,
|
|
344
|
+
kind: "auto-capture",
|
|
345
|
+
content: outcome.text,
|
|
346
|
+
round: meeting.round,
|
|
347
|
+
ts: Date.now()
|
|
348
|
+
};
|
|
349
|
+
await appendUtterance(location.stateRoot, meeting.id, utterance);
|
|
350
|
+
meeting.budget.usedTokens += estimateTokens(outcome.text);
|
|
351
|
+
node.lastError = void 0;
|
|
352
|
+
meeting.updatedAt = Date.now();
|
|
353
|
+
await writeMeeting(location.stateRoot, meeting);
|
|
354
|
+
});
|
|
355
|
+
} catch (error) {
|
|
356
|
+
ctx.logger.warn(`roundtable: capturing output of node ${childId} failed: ${String(error)}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/** Remember the last provider failure per node (observability, not recovery). */
|
|
360
|
+
function onRequestError(ctx, payload) {
|
|
361
|
+
const agentId = String(payload.agent?.id ?? "");
|
|
362
|
+
if (agentId === "") return;
|
|
363
|
+
const location = locateNodeChild(agentId);
|
|
364
|
+
if (location === void 0) return;
|
|
365
|
+
writeNodeNote(location, agentId, describeRequestFailure(String(payload.provider ?? ""), payload.failure)).catch((error) => {
|
|
366
|
+
ctx.logger.warn(`roundtable: recording request failure of node ${agentId} failed: ${String(error)}`);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Subscribe the two host events that make expert output and expert failure
|
|
371
|
+
* visible.
|
|
372
|
+
*
|
|
373
|
+
* Deliberately non-invasive: `subagent/end` is a plain emit, and the
|
|
374
|
+
* `agent/request-error` waterfall listener always delegates through `next()` so
|
|
375
|
+
* the host's own retry/recovery policy is untouched — this plugin only observes.
|
|
376
|
+
*/
|
|
377
|
+
function attachNodeEvents(ctx) {
|
|
378
|
+
ctx.on("subagent/end", (info) => {
|
|
379
|
+
onNodeEnd(ctx, info);
|
|
380
|
+
});
|
|
381
|
+
ctx.on("agent/request-error", (payload, next) => {
|
|
382
|
+
try {
|
|
383
|
+
onRequestError(ctx, payload);
|
|
384
|
+
} catch {}
|
|
385
|
+
return next();
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/** Host-known durable child ids; `undefined` when the catalog is unavailable. */
|
|
389
|
+
async function knownChildIds(ctx, parentSessionId) {
|
|
390
|
+
try {
|
|
391
|
+
const children = await ctx.subagents.listChildren(parentSessionId);
|
|
392
|
+
return new Set(children.map((child) => String(child.id)));
|
|
393
|
+
} catch (error) {
|
|
394
|
+
ctx.logger.warn(`roundtable: listing child subagents failed: ${String(error)}`);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Reconcile node liveness against the host's real subagent tree.
|
|
400
|
+
*
|
|
401
|
+
* `ctx.agents.get(id)` only knows **live** drivers, so a node whose process is
|
|
402
|
+
* gone looks exactly like one that is merely between turns — both report
|
|
403
|
+
* `ready`, and the captain cannot tell "待唤醒" from "已经没了". Asking the
|
|
404
|
+
* durable catalog separates them: an id the host still lists is a cold-but-real
|
|
405
|
+
* child, an id it no longer knows is `missing` — which is precisely the old
|
|
406
|
+
* "把消息投进死节点、静默丢一整轮" failure seen in the meeting records.
|
|
407
|
+
*
|
|
408
|
+
* Runs one listing per meeting (not per node) and degrades to the previous
|
|
409
|
+
* semantics when the catalog is unavailable.
|
|
410
|
+
*/
|
|
411
|
+
async function reconcileNodeLiveness(ctx, captainSessionId, nodes) {
|
|
412
|
+
const result = /* @__PURE__ */ new Map();
|
|
413
|
+
let catalog;
|
|
414
|
+
for (const node of nodes) {
|
|
415
|
+
if (node.id === "" || !ACTIVE_NODE_STATUSES.includes(node.status)) continue;
|
|
416
|
+
const live = ctx.agents.get(node.id);
|
|
417
|
+
if (live !== void 0) {
|
|
418
|
+
result.set(node.key, live.status === "running" ? "running" : "idle");
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (catalog === void 0) catalog = await knownChildIds(ctx, captainSessionId);
|
|
422
|
+
result.set(node.key, catalog === void 0 || catalog.has(node.id) ? "ready" : "missing");
|
|
423
|
+
}
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/harness-compat.ts
|
|
429
|
+
/** 进程级审计表:每个能力只记第一次探测(避免 1 秒轮询刷爆)。 */
|
|
430
|
+
const audit = /* @__PURE__ */ new Map();
|
|
431
|
+
/**
|
|
432
|
+
* 记录一次探测结果(**每个能力只保留第一条**)。
|
|
433
|
+
*
|
|
434
|
+
* 只记第一条是刻意的:探测点常在 tick / 轮询路径上,每次都记会把内存和日志
|
|
435
|
+
* 都吃掉;而"第一次看到它是什么状态"已经足够定位问题。
|
|
436
|
+
*/
|
|
437
|
+
function noteCapability(id, state, note) {
|
|
438
|
+
if (!audit.has(id)) audit.set(id, {
|
|
439
|
+
state,
|
|
440
|
+
note,
|
|
441
|
+
ts: Date.now()
|
|
442
|
+
});
|
|
443
|
+
return state;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* 读一个**可选**宿主服务。
|
|
447
|
+
*
|
|
448
|
+
* 统一走 `ctx.get`(cordis 核心、非严格:缺失返回 `undefined`)而不是属性访问
|
|
449
|
+
* —— 后者在没有 `inject` 时会**直接抛错**:
|
|
450
|
+
* `cannot get property "connection" without inject`。
|
|
451
|
+
*/
|
|
452
|
+
function optionalService(ctx, name$1) {
|
|
453
|
+
const getter = ctx.get;
|
|
454
|
+
if (typeof getter !== "function") {
|
|
455
|
+
noteCapability(name$1, "missing", "ctx.get 不存在(宿主 Context 形状变化)");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
let service;
|
|
459
|
+
try {
|
|
460
|
+
service = getter.call(ctx, name$1);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
noteCapability(name$1, "degraded", `ctx.get('${name$1}') 抛错:${error instanceof Error ? error.message : String(error)}`);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (service === void 0 || service === null) {
|
|
466
|
+
noteCapability(name$1, "missing", "宿主未挂载该服务");
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
noteCapability(name$1, "available", "已解析");
|
|
470
|
+
return service;
|
|
471
|
+
}
|
|
472
|
+
/** 可选服务键的结构式候选(宿主可能改名;按序取第一个可用的)。 */
|
|
473
|
+
const WEB_SERVER_KEYS = ["webServer", "httpServer"];
|
|
474
|
+
const WORKSPACE_KEYS = ["workspaceRegistry", "workspace"];
|
|
475
|
+
/**
|
|
476
|
+
* 解析 webServer 服务面。
|
|
477
|
+
*
|
|
478
|
+
* 服务键是**结构式候选**而非固定名:宿主可能把 `webServer` 换成别的名字,
|
|
479
|
+
* 所以按 `WEB_SERVER_KEYS` 顺序探测。headless profile 下取不到属预期,
|
|
480
|
+
* 只降级不报错。
|
|
481
|
+
*/
|
|
482
|
+
function webRouteHostOf(ctx) {
|
|
483
|
+
for (const key of WEB_SERVER_KEYS) {
|
|
484
|
+
const host = optionalService(ctx, key);
|
|
485
|
+
if (host !== void 0 && typeof host.register === "function") {
|
|
486
|
+
noteCapability("webServer", "available", `经 ${key} 解析`);
|
|
487
|
+
return host;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
noteCapability("webServer", "missing", `候选键都不可用:${WEB_SERVER_KEYS.join(" / ")}`);
|
|
491
|
+
}
|
|
492
|
+
/** 解析 workspace 注册表面(同上,结构式候选 + 降级留痕)。 */
|
|
493
|
+
function workspaceRegistryOf(ctx) {
|
|
494
|
+
for (const key of WORKSPACE_KEYS) {
|
|
495
|
+
const host = optionalService(ctx, key);
|
|
496
|
+
if (host !== void 0 && typeof host.list === "function") {
|
|
497
|
+
noteCapability("workspaceRegistry", "available", `经 ${key} 解析`);
|
|
498
|
+
return host;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
noteCapability("workspaceRegistry", "missing", `候选键都不可用:${WORKSPACE_KEYS.join(" / ")}`);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* 判定一个 `rpc.handle(...)` 返回值能不能当清理函数用。
|
|
505
|
+
*
|
|
506
|
+
* 宿主签名在 rc 线上变过:0.1.1 时代第三参 `{ authority }` 被移除,返回值在
|
|
507
|
+
* 不同版本里出现过 `disposer` / `undefined` / Promise。这里只做**形状判定**,
|
|
508
|
+
* 不 import 宿主类型:合法就交出去,不合法就交给调用方记一条降级。
|
|
509
|
+
*/
|
|
510
|
+
function asDisposer(value) {
|
|
511
|
+
return typeof value === "function" ? value : void 0;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* 在可选的清理函数存在时注册一个 effect。
|
|
515
|
+
*
|
|
516
|
+
* `connection.rpc.handle` 的返回值形状随版本变化,直接 `ctx.effect(() => () =>
|
|
517
|
+
* release())` 会在返回值不是函数时静默留下一个永远不执行的清理器(通道泄漏)。
|
|
518
|
+
*/
|
|
519
|
+
function effectWithOptionalDisposer(ctx, disposer, label) {
|
|
520
|
+
const release = asDisposer(disposer);
|
|
521
|
+
if (release === void 0) return false;
|
|
522
|
+
ctx.effect(() => () => {
|
|
523
|
+
release();
|
|
524
|
+
}, label);
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* 读一个 live session 的投影值。
|
|
529
|
+
*
|
|
530
|
+
* **返回 `undefined`,绝不抛错**:测量用量是观察行为,不能因为宿主投影形状
|
|
531
|
+
* 变化就把 `roundtable_status` 弄崩 —— 与 `token-usage.ts` 的既有契约一致。
|
|
532
|
+
*/
|
|
533
|
+
function projectionValuesOf(ctx, session) {
|
|
534
|
+
if (session === void 0 || session === null) return void 0;
|
|
535
|
+
try {
|
|
536
|
+
const projections = optionalService(ctx, "sessionProjections");
|
|
537
|
+
if (projections === void 0 || typeof projections.snapshot !== "function") return void 0;
|
|
538
|
+
const values = projections.snapshot(session, ["tokenUsage", "contextPressure"])?.values;
|
|
539
|
+
if (values === null || typeof values !== "object") return void 0;
|
|
540
|
+
return values;
|
|
541
|
+
} catch (error) {
|
|
542
|
+
noteCapability("sessionProjections", "degraded", `snapshot() 抛错:${error instanceof Error ? error.message : String(error)}`);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* ## 0.2.0-rc.2 —— 两处真破坏(核对日期 2026-09-30)
|
|
548
|
+
*
|
|
549
|
+
* 从 0.1.5-rc.3 抬到 0.2.0-rc.2 后,**宿主侧 `tsc` 只有 4 个错误**,浏览器侧 0 个。
|
|
550
|
+
* 4 个错误归为两类,两类都不是「改一行签名」能解决的。
|
|
551
|
+
*
|
|
552
|
+
* ### ① `settings` 服务被重构成 `SettingsForms`
|
|
553
|
+
*
|
|
554
|
+
* | | 0.1.5-rc.3 | 0.2.0-rc.2 |
|
|
555
|
+
* | --- | --- | --- |
|
|
556
|
+
* | 形状 | `SettingsScope`:插件注册自己的命名空间 | `SettingsForms`:把 profile 配置投影成表单 |
|
|
557
|
+
* | API | `settings.register(ns, schema, { base })` | `configure` / `describe` / `update` / `replace` / `mutate` |
|
|
558
|
+
* | 插件自管偏好 | `scope.get` / `update` / `watch` | **没有对应物** |
|
|
559
|
+
*
|
|
560
|
+
* 编译期症状:
|
|
561
|
+
* `Property 'register' does not exist on type 'SettingsForms'`
|
|
562
|
+
* `Module '"@deepseek-ai/dsh-settings"' has no exported member 'SettingsScope'`
|
|
563
|
+
*
|
|
564
|
+
* **应对**:偏好改为自持久化(`src/preferences.ts` →
|
|
565
|
+
* `<DSH_HOME>/roundtable/preferences.json`),并同步从 `OPTIONAL_CAPABILITIES`、
|
|
566
|
+
* `CAPABILITY_SPECS`、`compatibility.json` 三处移除 `settings`。
|
|
567
|
+
*
|
|
568
|
+
* ⚠️ **不要再试图用 `ctx.inject(['settings'])` 注册命名空间** —— 那个能力在新模型
|
|
569
|
+
* 里不存在。插件现在的可配置面是它自己的 Cordis `Config`(Loader YAML 提供、宿主
|
|
570
|
+
* 自动生成配置页),而写 `Config` 会触发 entry 重载,不适合承载"改一个偏好"。
|
|
571
|
+
*
|
|
572
|
+
* ### ② `MessageSourceMap` 不再有 catch-all 的 `plugin` kind
|
|
573
|
+
*
|
|
574
|
+
* 基础表只剩 `user` / `model` / `tool` / `system-prompt`,其余由**生产者在自己
|
|
575
|
+
* 的模块里** `declare module '@deepseek-ai/dsh-llm'` 补上 —— 宿主内部插件
|
|
576
|
+
* (`tool-registry`、`ptc-mode`、`agent-message`、`subagent-settled` …)都是这么做的。
|
|
577
|
+
* 原文注释写得很直白:**"there is no shared catch-all `plugin` kind"**。
|
|
578
|
+
*
|
|
579
|
+
* 编译期症状:
|
|
580
|
+
* `Type '"plugin"' is not assignable to type '"user" | "model" | "tool" | …'`
|
|
581
|
+
*
|
|
582
|
+
* **应对**:`src/members.ts` 自声明 `roundtable` kind(`{ kind: 'roundtable'; plugin: string }`),
|
|
583
|
+
* 而不是退化成匿名的 `user` —— 转录消费者需要把"插件注入的提示"与"用户说的话"分开。
|
|
584
|
+
*
|
|
585
|
+
* ### 保持不变的(本轮逐项复核确认)
|
|
586
|
+
*
|
|
587
|
+
* `inject` 的四个必需服务、`ctx.subagents` 的派发与 `interruptByParent`、
|
|
588
|
+
* `ctx.agents.get`、`subagent/end` 与 `agent/request-error` 事件、
|
|
589
|
+
* `webServer.register` 的形状、`connection.rpc.handle` 的两参签名与 disposer
|
|
590
|
+
* 返回值、`ctx.skills` / `ctx.userQuestions` / `sessionProjections.snapshot(session, keys)`、
|
|
591
|
+
* 以及全部浏览器侧槽位(`conversation.view` / `settings.section` /
|
|
592
|
+
* `tool.call.toolview`)—— 一律原样可用。
|
|
593
|
+
*
|
|
594
|
+
* ### 环境侧的老教训仍然有效
|
|
595
|
+
*
|
|
596
|
+
* 升级后**先跑 `node scripts/doctor.mjs`**:cordis 必须是同一物理副本,否则
|
|
597
|
+
* `declare module` 合并失效,会出现几十处「Context 上不存在某属性」的假故障,
|
|
598
|
+
* 看起来像 API 全崩。本轮 doctor 一次通过(混装 ✓ / cordis 同一性 ✓)。
|
|
599
|
+
*/
|
|
600
|
+
|
|
601
|
+
//#endregion
|
|
602
|
+
//#region src/token-usage.ts
|
|
603
|
+
function countOf(raw, key) {
|
|
604
|
+
const value = raw[key];
|
|
605
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
606
|
+
return Math.max(0, Math.floor(value));
|
|
607
|
+
}
|
|
608
|
+
/** Coerce one projection value into a {@link SessionUsage}; undefined when empty. */
|
|
609
|
+
function normalizeUsage(value) {
|
|
610
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
611
|
+
const raw = value;
|
|
612
|
+
const inputTokens = countOf(raw, "uncachedInputTokens");
|
|
613
|
+
const outputTokens = countOf(raw, "outputTokens");
|
|
614
|
+
const cacheReadTokens = countOf(raw, "cacheReadTokens");
|
|
615
|
+
const cacheWriteTokens = countOf(raw, "cacheWriteTokens");
|
|
616
|
+
const total = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
|
617
|
+
if (total === 0) return void 0;
|
|
618
|
+
return {
|
|
619
|
+
inputTokens,
|
|
620
|
+
outputTokens,
|
|
621
|
+
cacheReadTokens,
|
|
622
|
+
cacheWriteTokens,
|
|
623
|
+
total
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Read the projection values of one LIVE session.
|
|
628
|
+
*
|
|
629
|
+
* Returns `undefined` — never throws — when the agent is not live, the
|
|
630
|
+
* projection service is unmounted, or the host shape differs: measuring cost is
|
|
631
|
+
* an observation, and it must never be able to break `roundtable_status`.
|
|
632
|
+
*/
|
|
633
|
+
function projectionValuesOf$1(ctx, sessionId) {
|
|
634
|
+
if (sessionId === "") return void 0;
|
|
635
|
+
try {
|
|
636
|
+
return projectionValuesOf(ctx, ctx.agents.get(sessionId)?.session);
|
|
637
|
+
} catch {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/** Provider-reported usage of one live session (undefined when unavailable). */
|
|
642
|
+
function liveSessionUsage(ctx, sessionId) {
|
|
643
|
+
return normalizeUsage(projectionValuesOf$1(ctx, sessionId)?.tokenUsage);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Sum real usage across one meeting's expert child sessions.
|
|
647
|
+
*
|
|
648
|
+
* Each distinct session is measured once; ids that no longer resolve to a live
|
|
649
|
+
* agent are counted as `unmeasuredSessions` so the caller can say "3 of 5
|
|
650
|
+
* measured" instead of silently reporting a too-small total.
|
|
651
|
+
*/
|
|
652
|
+
function meetingRealUsage(ctx, sessionIds) {
|
|
653
|
+
const seen = /* @__PURE__ */ new Set();
|
|
654
|
+
let measuredTotal = 0;
|
|
655
|
+
let measuredSessions = 0;
|
|
656
|
+
let unmeasuredSessions = 0;
|
|
657
|
+
for (const id of sessionIds) {
|
|
658
|
+
if (id === "" || seen.has(id)) continue;
|
|
659
|
+
seen.add(id);
|
|
660
|
+
const usage = liveSessionUsage(ctx, id);
|
|
661
|
+
if (usage === void 0) {
|
|
662
|
+
unmeasuredSessions += 1;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
measuredTotal += usage.total;
|
|
666
|
+
measuredSessions += 1;
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
measuredTotal,
|
|
670
|
+
measuredSessions,
|
|
671
|
+
unmeasuredSessions
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
//#endregion
|
|
676
|
+
//#region src/proxy-thinking.ts
|
|
677
|
+
/**
|
|
678
|
+
* Proxy Thinking (代理思考): when a black-box worker model (no visible
|
|
679
|
+
* reasoning) must execute a task, the director model (DeepSeek) first writes
|
|
680
|
+
* its own reasoning as `[DeepSeek 代理思考]`, then translates the goal into
|
|
681
|
+
* exact worker parameters. The UI prefixes stages with
|
|
682
|
+
* `[DeepSeek 代理思考]` / `[<worker> 渲染中]`.
|
|
683
|
+
* @module dsh-plugin-roundtable/proxy-thinking
|
|
684
|
+
*/
|
|
685
|
+
/** The director-side prompt template (spec §6.2). */
|
|
686
|
+
const PROXY_THINKING_TEMPLATE = `【代理思考(Proxy Thinking)—— 导演模型侧】
|
|
687
|
+
|
|
688
|
+
你当前扮演"导演模型",负责把一个模糊目标交给一个"无思考过程的干活模型" <worker_model> 执行。
|
|
689
|
+
<worker_model> 不会展示思考过程、不会自我纠错、不会追问澄清。
|
|
690
|
+
因此你的职责是替它完成思考,并把任务翻译成它能直接执行的指令。
|
|
691
|
+
|
|
692
|
+
请严格按以下顺序输出,不要遗漏任何一节:
|
|
693
|
+
|
|
694
|
+
一、[DeepSeek 代理思考]
|
|
695
|
+
用自然语言写出你的完整推理过程,至少覆盖:
|
|
696
|
+
- 你如何理解这个目标(拆解成哪些子意图)
|
|
697
|
+
- 你选择什么策略,为什么
|
|
698
|
+
- 关键参数为什么这样定(给出取舍理由)
|
|
699
|
+
- 预期结果是什么
|
|
700
|
+
- 可能的失败点与兜底方案
|
|
701
|
+
(这一节是"代理思考",直接展示给用户,补齐 <worker_model> 缺失的透明性。)
|
|
702
|
+
|
|
703
|
+
二、[参数翻译 → <worker_model>]
|
|
704
|
+
把目标翻译成 <worker_model> 能直接执行的精确指令/参数:
|
|
705
|
+
- 字段必须符合 <worker_model> 的输入规范(schema)
|
|
706
|
+
- 禁止含糊描述:不用"好看一点/高级一点",必须给出具体风格、构图、时长、尺寸、语气等
|
|
707
|
+
- 输出为结构化 JSON 或精确指令文本(以 <worker_model> 实际接受的格式为准)
|
|
708
|
+
|
|
709
|
+
三、[预期与兜底]
|
|
710
|
+
- 预期结果:执行成功时应得到什么
|
|
711
|
+
- 失败兜底:若结果不符合预期,你接下来如何修正或降级`;
|
|
712
|
+
/** Compose the director-side prompt for one worker task. */
|
|
713
|
+
function proxyThinkingPrompt(workerModel, task) {
|
|
714
|
+
return `${PROXY_THINKING_TEMPLATE.replaceAll("<worker_model>", workerModel)}\n\n本次任务:\n${task}`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
//#endregion
|
|
718
|
+
//#region src/prompt.ts
|
|
719
|
+
/** 任务信封(主持人 → 专家):固定四段,可机械转发,不粘贴全文。 */
|
|
720
|
+
const TASK_ENVELOPE = "[TASK R{轮} → {key}] 目标|输入(引用 R{n}[{发言人}] 或文件路径)|约束|期望产出";
|
|
721
|
+
/** 报告信封(专家 → 网关/主持人):`[核心产出]` 就是网关抓取的段落。 */
|
|
722
|
+
const REPORT_ENVELOPE = "[REPORT R{轮} {key}] 结论(≤3 条)|证据|待决策";
|
|
723
|
+
/**
|
|
724
|
+
* 主持人(DeepSeek 本体)的常驻 usage 段。
|
|
725
|
+
*
|
|
726
|
+
* **它每一次请求都要付费**,包括完全不开会的日常对话。因此这里只保留
|
|
727
|
+
* "何时触发、按什么顺序、红线在哪",把流程细节交给各工具的 description。
|
|
728
|
+
* 也**不再逐个列出工具名**:工具 schema 本来就在请求里,列一遍是纯冗余。
|
|
729
|
+
*/
|
|
730
|
+
function usageSectionText() {
|
|
731
|
+
return `When the user asks to run a round-table meeting (圆桌会议) you are the captain (主持人). This section only fixes WHEN each tool applies; each tool's own description carries its details.
|
|
732
|
+
|
|
733
|
+
1. Never create a meeting directly: call roundtable_plan_meeting first (name, goal, intended experts, parameters) — it shows the human a SETTINGS CARD and blocks. Create only with the confirmed values, and always show the card, even for a single expert.
|
|
734
|
+
2. Then roundtable_add_node once per expert and roundtable_connect to wire the topology. A node inherits your provider/model unless the user explicitly asked for another route.
|
|
735
|
+
3. Delegate, do not duplicate: hand work to nodes, watch with roundtable_status, pull roundtable_summarize. Two channels — never both for the same content: roundtable_speak is the durable record and gateway input (nobody is woken); roundtable_send_message wakes the recipient and is only for something they must act on now.
|
|
736
|
+
4. Relay compactly. Forward with ${TASK_ENVELOPE} — quote the transcript reference instead of pasting the full text, since an expert can read the transcript itself. Experts answer as ${REPORT_ENVELOPE}, and [核心产出] is exactly the segment the gateway captures.
|
|
737
|
+
5. Open every round with roundtable_status: execute each pending_actions line with the matching tool, and call roundtable_actions_clear only after every one succeeded. Pass back its next_since cursor so each poll returns only new lines.
|
|
738
|
+
6. Real forks go to the human via roundtable_request_decision. Never decide for the user.
|
|
739
|
+
7. Budget honesty: the token figure is an ESTIMATE OF SPOKEN TEXT (≈0.6 token/CJK char), NOT the real spend — say so when you report it. Provider-reported real usage is shown when measurable. A muted (闭麦) meeting is topped up with roundtable_set_budget.
|
|
740
|
+
8. Knowledge base and skills are relayed by you, never copied wholesale: read the one file or skill, hand over only what the expert needs, and cache the distilled points with roundtable_kb_digest — a later HIT then costs nothing.
|
|
741
|
+
9. 针锋相对: when a plan is settled, ASK the user first, then roundtable_start_review; red-team experts only attack the plan, and a re-review may only re-check the previous pass's endorsed flaws. Close the meeting with roundtable_export_meeting (or roundtable_export_review for the review record alone).
|
|
742
|
+
10. Handing work to a black-box worker model (image/video) goes through roundtable_proxy_think so the reasoning chain stays visible.`;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* skill 段落:把"选了哪些 skill / 用哪种方式"写进 persona。
|
|
746
|
+
*
|
|
747
|
+
* direct 模式下这是**必须**的:`skill` 工具本身不带白名单参数,专家只有
|
|
748
|
+
* 在 persona 里被告知可用清单,才知道能调什么(R2.3)。
|
|
749
|
+
*/
|
|
750
|
+
function skillSection(meeting, skill) {
|
|
751
|
+
const names = (skill.names ?? meeting.skills ?? []).filter((name$1) => name$1 !== "");
|
|
752
|
+
const delivery = skill.delivery ?? meeting.skillDelivery ?? "relay";
|
|
753
|
+
if (names.length === 0 && delivery !== "direct") return "";
|
|
754
|
+
const list = names.length === 0 ? "(本次会议未选中任何 skill)" : names.map((name$1) => `\`${name$1}\``).join("、");
|
|
755
|
+
if (delivery === "direct") return `skill:${list}。传递方式为「专家直接调用」:${names.length > 0 && skill.skillToolAvailable === true ? "需要完整说明时自己调用 `skill` 工具(name 传上方清单里的名字)加载全文,并严格按其约束工作。" : "当前 `skill` 工具不可用或未选中 skill,不要尝试调用,用你自己的通用能力完成任务。"}`;
|
|
756
|
+
return `skill:${list}。传递方式为「主持人中转」:主持人会把要点转交给你,你不要也不应该自己调用 \`skill\` 工具;把这些 skill 的约束当作既定工作前提。`;
|
|
757
|
+
}
|
|
758
|
+
/** The node's system prompt (persona): the charter plus node working rules.
|
|
759
|
+
*
|
|
760
|
+
* 与 charter 严格分工:**总纲写共同红线(格式、编造、拍板),这里只写
|
|
761
|
+
* "你这个节点具体怎么做"**。同一约束绝不在这两处各写一遍 —— persona 会
|
|
762
|
+
* 跟着专家的每一次请求付费。 */
|
|
763
|
+
function nodePersona(meeting, node, stateDir, limits = {}, skill = {}) {
|
|
764
|
+
const modeRule = meeting.mode === "egalitarian" ? "- 协作模式为\"多模型平等\":你可以用 roundtable_send_message 直接与其他节点交换意见,无需主持人中转。" : "- 协作模式为\"主持人统筹\":你只向主持人汇报,其他节点的观点由主持人转达。";
|
|
765
|
+
const opinionRule = limits.maxOpinions !== void 0 && limits.maxOpinions > 0 ? ` 每轮最多 ${limits.maxOpinions} 条意见,宁缺毋滥。` : "";
|
|
766
|
+
return `${meeting.charter}
|
|
767
|
+
|
|
768
|
+
你是会议"${meeting.name}"中的专家节点 ${node.key}${node.role !== void 0 && node.role !== "" ? `,角色:${node.role}` : ""}。
|
|
769
|
+
|
|
770
|
+
工作规则:
|
|
771
|
+
1. 收到任务后执行一整轮工作,再用 roundtable_speak 汇报(to 留空 = 交网关);只有需要某人**立刻行动**时才用 roundtable_send_message。
|
|
772
|
+
2. 按总纲第三节的格式发言,并采用报告信封 ${REPORT_ENVELOPE}:结论 ≤3 条,其余内容一律省略([核心产出] 即网关抓取的段落)。
|
|
773
|
+
3. 主持人转来的引用不足以判断时,自己读 ${stateDir}/${meeting.id}/transcript.jsonl 取原文(只读,严禁直接修改;一切状态变更走 roundtable_* 工具)。
|
|
774
|
+
4. 你是专家不是主持人:不创建/移除节点、不修改连线、不发起人类决策、不结束会议。
|
|
775
|
+
${skillSection(meeting, skill)}
|
|
776
|
+
${modeRule}
|
|
777
|
+
|
|
778
|
+
回答限制(省 token,务必遵守):
|
|
779
|
+
- 只回答与议题直接相关的内容;无关问题直接说明"与议题无关"。
|
|
780
|
+
- 不用假设代替事实;不确定就明确说"不确定"。
|
|
781
|
+
- 不举无关的例子;举例必须直接服务于论点。
|
|
782
|
+
- 语言简洁明了,不用华丽修辞与空话套话。${opinionRule}`;
|
|
783
|
+
}
|
|
784
|
+
/** The initial user message delivered when the node is created. */
|
|
785
|
+
function nodeWelcome(meeting, node) {
|
|
786
|
+
return `你已加入圆桌会议"${meeting.name}"(会议 id ${meeting.id})作为专家节点 ${node.key}。主持人会通过消息布置任务或转达其他节点的观点;收到后执行一整轮工作并用 roundtable_speak 汇报。现在等待主持人的指令。`;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
//#endregion
|
|
790
|
+
//#region src/members.ts
|
|
791
|
+
/** Node display label prefix persisted as the child's creation label. */
|
|
792
|
+
const NODE_LABEL_PREFIX = "roundtable:";
|
|
793
|
+
/** Captain-only RoundTable tools hidden from expert nodes. */
|
|
794
|
+
const NODE_DENIED_TOOLS = [
|
|
795
|
+
"roundtable_create",
|
|
796
|
+
"roundtable_plan_meeting",
|
|
797
|
+
"roundtable_add_node",
|
|
798
|
+
"roundtable_remove_node",
|
|
799
|
+
"roundtable_connect",
|
|
800
|
+
"roundtable_disconnect",
|
|
801
|
+
"roundtable_request_decision",
|
|
802
|
+
"roundtable_set_budget",
|
|
803
|
+
"roundtable_close",
|
|
804
|
+
"roundtable_collect_review",
|
|
805
|
+
"roundtable_finish_review",
|
|
806
|
+
"roundtable_export_review",
|
|
807
|
+
"roundtable_export_meeting",
|
|
808
|
+
"roundtable_kb_digest"
|
|
809
|
+
];
|
|
810
|
+
/**
|
|
811
|
+
* 专家节点在 direct 模式下可以保留的工具白名单。
|
|
812
|
+
*
|
|
813
|
+
* `allow` 的语义是"只有列出的全局工具保持可见",因此这里必须列全专家
|
|
814
|
+
* 真正需要的工具(文件/搜索/shell/技能/协作/自身管理);一旦宿主改名,
|
|
815
|
+
* 专家会失去该能力但**不会**因此获得额外权限 —— 这是刻意选择的失败方向。
|
|
816
|
+
* `deny` 仍然保留(两者是与关系):它保证将来有人从本清单里删掉某项时,
|
|
817
|
+
* 主持人专属工具不会顺带被放开。
|
|
818
|
+
*/
|
|
819
|
+
const NODE_ALLOWED_TOOLS = [
|
|
820
|
+
"read",
|
|
821
|
+
"read_image",
|
|
822
|
+
"write",
|
|
823
|
+
"edit",
|
|
824
|
+
"str_replace_editor",
|
|
825
|
+
"glob",
|
|
826
|
+
"grep",
|
|
827
|
+
"pwsh",
|
|
828
|
+
"bash",
|
|
829
|
+
"todo_write",
|
|
830
|
+
"web_search",
|
|
831
|
+
"web_fetch",
|
|
832
|
+
"send_message",
|
|
833
|
+
"interrupt_agent",
|
|
834
|
+
"list_agents",
|
|
835
|
+
"list_subagent_models",
|
|
836
|
+
"job_list",
|
|
837
|
+
"job_output",
|
|
838
|
+
"job_kill",
|
|
839
|
+
"roundtable_speak",
|
|
840
|
+
"roundtable_send_message",
|
|
841
|
+
"roundtable_summarize",
|
|
842
|
+
"roundtable_status",
|
|
843
|
+
"roundtable_actions_clear",
|
|
844
|
+
"roundtable_start_review",
|
|
845
|
+
"roundtable_proxy_think",
|
|
846
|
+
"skill"
|
|
847
|
+
];
|
|
848
|
+
/** The node's tool restriction: deny captain-only tools, and — in direct
|
|
849
|
+
* skill-delivery mode — narrow the surface to the explicit expert allowlist
|
|
850
|
+
* so the host's `skill` loader is reachable from a node. */
|
|
851
|
+
function nodeToolRestriction(skillDelivery = "relay") {
|
|
852
|
+
if (skillDelivery === "direct") return {
|
|
853
|
+
allow: [...NODE_ALLOWED_TOOLS],
|
|
854
|
+
deny: [...NODE_DENIED_TOOLS]
|
|
855
|
+
};
|
|
856
|
+
return { deny: [...NODE_DENIED_TOOLS] };
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Spawn one node as a durable continuable subagent of the captain and fill
|
|
860
|
+
* `node.id` with its child session id. On failure nothing is persisted.
|
|
861
|
+
*/
|
|
862
|
+
async function spawnNode(ctx, config, meeting, node, captain, stateDir, signal, limits = {}, skill = {}) {
|
|
863
|
+
const provider = ctx.subagents.getProvider(config.provider);
|
|
864
|
+
if (provider === void 0) throw new Error(`roundtable: no subagent provider "${config.provider}" is registered (available: ${ctx.subagents.list().join(", ") || "none"}) — check that the subagent provider row (e.g. subagent-spawn) is mounted in the composition`);
|
|
865
|
+
if (provider.prepareContinuable === void 0) throw new Error(`roundtable: provider "${config.provider}" does not support continuable nodes`);
|
|
866
|
+
if (!provider.capabilities.persona) throw new Error(`roundtable: provider "${config.provider}" cannot apply a node persona`);
|
|
867
|
+
if (!provider.capabilities.toolFilter) throw new Error(`roundtable: provider "${config.provider}" cannot restrict captain-only tools for nodes`);
|
|
868
|
+
const label = `${NODE_LABEL_PREFIX}${meeting.id}:${node.key}`;
|
|
869
|
+
const agentOptions = {};
|
|
870
|
+
if (node.provider !== void 0 && node.model !== void 0) {
|
|
871
|
+
agentOptions.provider = node.provider;
|
|
872
|
+
agentOptions.model = node.model;
|
|
873
|
+
}
|
|
874
|
+
const reasoningEffort = node.reasoningEffort?.trim() ?? "";
|
|
875
|
+
if (reasoningEffort !== "") agentOptions.reasoningEffort = ReasoningEffortId(reasoningEffort);
|
|
876
|
+
if (limits.maxTokens !== void 0 && limits.maxTokens > 0) agentOptions.maxTokens = limits.maxTokens;
|
|
877
|
+
const delivery = skill.delivery ?? meeting.skillDelivery ?? "relay";
|
|
878
|
+
const skillToolAvailable = ctx.tools.get("skill", captain) !== void 0;
|
|
879
|
+
const started = await ctx.subagents.startContinuable({
|
|
880
|
+
provider: config.provider,
|
|
881
|
+
label,
|
|
882
|
+
request: {
|
|
883
|
+
prompt: [{
|
|
884
|
+
type: "text",
|
|
885
|
+
text: nodeWelcome(meeting, node)
|
|
886
|
+
}],
|
|
887
|
+
parent: captain,
|
|
888
|
+
persona: nodePersona(meeting, node, stateDir, limits, {
|
|
889
|
+
...skill,
|
|
890
|
+
delivery,
|
|
891
|
+
skillToolAvailable
|
|
892
|
+
}),
|
|
893
|
+
toolFilter: nodeToolRestriction(delivery),
|
|
894
|
+
...Object.keys(agentOptions).length > 0 ? { agentOptions } : {},
|
|
895
|
+
...config.maxDepth !== void 0 ? { maxDepth: config.maxDepth } : {}
|
|
896
|
+
},
|
|
897
|
+
signal
|
|
898
|
+
});
|
|
899
|
+
node.id = String(started.childId);
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Deliver one message to a node as its next FIFO turn. Best effort: a failure
|
|
903
|
+
* is logged and reported as `false` so the caller can decide.
|
|
904
|
+
*/
|
|
905
|
+
async function deliverToNode(ctx, captain, childId, text, signal) {
|
|
906
|
+
try {
|
|
907
|
+
await ctx.subagents.sendMessage(captain, childId, [{
|
|
908
|
+
type: "text",
|
|
909
|
+
text
|
|
910
|
+
}], { signal });
|
|
911
|
+
return true;
|
|
912
|
+
} catch (error) {
|
|
913
|
+
ctx.logger.warn(`roundtable: followup to node ${childId} failed: ${String(error)}`);
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
/** Request cancellation of one live node's current turn (fire and return). */
|
|
918
|
+
function interruptNode(ctx, captain, childId) {
|
|
919
|
+
try {
|
|
920
|
+
ctx.subagents.interrupt(childId, {
|
|
921
|
+
kind: "ancestor",
|
|
922
|
+
agent: captain
|
|
923
|
+
});
|
|
924
|
+
} catch (error) {
|
|
925
|
+
ctx.logger.warn(`roundtable: interrupt of node ${childId} failed: ${String(error)}`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Interrupt one node **without** needing a live captain Agent.
|
|
930
|
+
*
|
|
931
|
+
* `ctx.subagents.interrupt` needs an ancestor Agent as its authority, so it is
|
|
932
|
+
* useless on paths that only hold session ids (the RPC surface, or a captain
|
|
933
|
+
* whose Agent is offline). The host's remote face is explicitly documented as
|
|
934
|
+
* "what keeps a live child interruptible while its parent Agent is offline".
|
|
935
|
+
*
|
|
936
|
+
* @returns true when the host accepted the request.
|
|
937
|
+
*/
|
|
938
|
+
function interruptNodeByParent(ctx, childId, parentSessionId) {
|
|
939
|
+
if (childId === "" || parentSessionId === "") return false;
|
|
940
|
+
const subagents = ctx.subagents;
|
|
941
|
+
if (typeof subagents.interruptByParent !== "function") return false;
|
|
942
|
+
try {
|
|
943
|
+
subagents.interruptByParent(childId, parentSessionId, "continuable");
|
|
944
|
+
return true;
|
|
945
|
+
} catch (error) {
|
|
946
|
+
ctx.logger.warn(`roundtable: interruptByParent of node ${childId} failed: ${String(error)}`);
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
/** Steer a live message into the captain at its nearest model boundary (best effort). */
|
|
951
|
+
function steerCaptain(captain, text) {
|
|
952
|
+
try {
|
|
953
|
+
captain.steer(createUserMessage({
|
|
954
|
+
content: [{
|
|
955
|
+
type: "text",
|
|
956
|
+
text
|
|
957
|
+
}],
|
|
958
|
+
source: {
|
|
959
|
+
kind: "roundtable",
|
|
960
|
+
plugin: "dsh-plugin-roundtable"
|
|
961
|
+
}
|
|
962
|
+
}));
|
|
963
|
+
return true;
|
|
964
|
+
} catch {
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
//#endregion
|
|
970
|
+
//#region src/review-split.ts
|
|
971
|
+
/** 拆分工具的 JSON schema(防线①)。 */
|
|
972
|
+
const SPLIT_TOOL = {
|
|
973
|
+
name: "split_redteam_viewpoint",
|
|
974
|
+
description: "把一条红队专家发言拆成 1~3 条独立观点(每条观点只含一个缺陷)。",
|
|
975
|
+
parameters: {
|
|
976
|
+
type: "object",
|
|
977
|
+
additionalProperties: false,
|
|
978
|
+
properties: { viewpoints: {
|
|
979
|
+
type: "array",
|
|
980
|
+
minItems: 1,
|
|
981
|
+
maxItems: 3,
|
|
982
|
+
items: {
|
|
983
|
+
type: "object",
|
|
984
|
+
additionalProperties: false,
|
|
985
|
+
properties: {
|
|
986
|
+
content: {
|
|
987
|
+
type: "string",
|
|
988
|
+
description: "单条观点/缺陷的完整表述。"
|
|
989
|
+
},
|
|
990
|
+
quote: {
|
|
991
|
+
type: "string",
|
|
992
|
+
description: "发言原文中与该观点对应的子串(可选,必须逐字来自发言原文)。"
|
|
993
|
+
},
|
|
994
|
+
dimension: {
|
|
995
|
+
type: "string",
|
|
996
|
+
description: "维度标签,如 交互/数据/安全/性能/兼容/流程(不确定用\"其他\")。"
|
|
997
|
+
},
|
|
998
|
+
evidence: {
|
|
999
|
+
type: "object",
|
|
1000
|
+
description: "证据分级(C1):代码/bug 类缺陷填 repro(附可复现步骤 1. 2. 3.);设计类缺陷填 argument(附论证链,不编造伪复现步骤)。无法给出证据时省略该字段。",
|
|
1001
|
+
additionalProperties: false,
|
|
1002
|
+
properties: {
|
|
1003
|
+
kind: {
|
|
1004
|
+
type: "string",
|
|
1005
|
+
enum: ["repro", "argument"],
|
|
1006
|
+
description: "repro=可复现步骤(代码/bug 类);argument=论证链(设计类)。"
|
|
1007
|
+
},
|
|
1008
|
+
text: {
|
|
1009
|
+
type: "string",
|
|
1010
|
+
description: "证据正文:复现步骤(编号 1. 2. 3.)或论证链,≤600 字符。"
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
required: ["kind", "text"]
|
|
1014
|
+
}
|
|
1015
|
+
},
|
|
1016
|
+
required: ["content", "dimension"]
|
|
1017
|
+
}
|
|
1018
|
+
} },
|
|
1019
|
+
required: ["viewpoints"]
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
const SPLIT_TIMEOUT_MS = 6e4;
|
|
1023
|
+
/** 调用 LLM 一次,收集工具调用参数(或文本 JSON)为字符串;失败返回 null。 */
|
|
1024
|
+
async function callSplitLlm(llm, config, system, userText) {
|
|
1025
|
+
const controller = new AbortController();
|
|
1026
|
+
const timer = setTimeout(() => controller.abort(), SPLIT_TIMEOUT_MS);
|
|
1027
|
+
try {
|
|
1028
|
+
const deltas = [];
|
|
1029
|
+
let assembled = null;
|
|
1030
|
+
const stream = llm.stream({
|
|
1031
|
+
provider: config.provider,
|
|
1032
|
+
model: config.model,
|
|
1033
|
+
messages: [{
|
|
1034
|
+
id: randomUUID(),
|
|
1035
|
+
role: "user",
|
|
1036
|
+
content: [{
|
|
1037
|
+
type: "text",
|
|
1038
|
+
text: userText
|
|
1039
|
+
}],
|
|
1040
|
+
source: { kind: "user" }
|
|
1041
|
+
}],
|
|
1042
|
+
system,
|
|
1043
|
+
tools: [SPLIT_TOOL],
|
|
1044
|
+
temperature: 0,
|
|
1045
|
+
maxTokens: 1024,
|
|
1046
|
+
signal: controller.signal
|
|
1047
|
+
});
|
|
1048
|
+
for await (const chunk of stream) {
|
|
1049
|
+
const raw$1 = chunk;
|
|
1050
|
+
if (raw$1.type === "tool-call-delta" && typeof raw$1.argumentsDelta === "string") deltas.push(raw$1.argumentsDelta);
|
|
1051
|
+
else if (raw$1.type === "block-end" && raw$1.block?.type === "tool-call" && typeof raw$1.block.arguments === "string") assembled = raw$1.block.arguments;
|
|
1052
|
+
else if (raw$1.type === "text" && typeof raw$1.delta === "string") deltas.push(raw$1.delta);
|
|
1053
|
+
else if (raw$1.type === "text" && typeof raw$1.text === "string") deltas.push(raw$1.text);
|
|
1054
|
+
else if (raw$1.type === "text-delta" && typeof raw$1.text === "string") deltas.push(raw$1.text);
|
|
1055
|
+
}
|
|
1056
|
+
const raw = (assembled ?? deltas.join("")).trim();
|
|
1057
|
+
return raw === "" ? null : raw;
|
|
1058
|
+
} catch {
|
|
1059
|
+
return null;
|
|
1060
|
+
} finally {
|
|
1061
|
+
clearTimeout(timer);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* 本地启发式兜底拆分(零 token、确定性):红队专家发言通常按
|
|
1066
|
+
* "**观点 N(指向 X,维度Y)**" 的固定结构分条,直接按该标记切段,
|
|
1067
|
+
* 并从段首括号里提取维度标签。找不到任何观点标记时返回 null,
|
|
1068
|
+
* 由调用方整条兜底(seq=0)。
|
|
1069
|
+
*/
|
|
1070
|
+
const VIEWPOINT_MARKER = /(?:\*{1,2})?\s*观点\s*(\d+)\s*[((]([^))]*)[))]/g;
|
|
1071
|
+
const TAIL_SECTION = /\[(?:核心产出|下一步建议)[^\]]*\]/;
|
|
1072
|
+
function splitByMarkers(content) {
|
|
1073
|
+
const trimmed = content.replace(/\s+/g, " ").trim();
|
|
1074
|
+
if (trimmed === "") return null;
|
|
1075
|
+
const marks = [];
|
|
1076
|
+
VIEWPOINT_MARKER.lastIndex = 0;
|
|
1077
|
+
let m;
|
|
1078
|
+
while ((m = VIEWPOINT_MARKER.exec(trimmed)) !== null) marks.push({
|
|
1079
|
+
start: m.index,
|
|
1080
|
+
end: m.index + m[0].length,
|
|
1081
|
+
label: m[1] ?? "",
|
|
1082
|
+
head: m[2] ?? ""
|
|
1083
|
+
});
|
|
1084
|
+
if (marks.length === 0) return null;
|
|
1085
|
+
const lines = [];
|
|
1086
|
+
for (let i = 0; i < marks.length; i++) {
|
|
1087
|
+
const mark = marks[i];
|
|
1088
|
+
if (mark === void 0) continue;
|
|
1089
|
+
const next = marks[i + 1];
|
|
1090
|
+
const rawEnd = next !== void 0 ? next.start : trimmed.length;
|
|
1091
|
+
let raw = trimmed.slice(mark.end, rawEnd);
|
|
1092
|
+
const tail = raw.search(TAIL_SECTION);
|
|
1093
|
+
if (tail !== -1) raw = raw.slice(0, tail);
|
|
1094
|
+
const text = raw.replace(/^[\s*::]+/, "").replace(/\s+/g, " ").trim();
|
|
1095
|
+
if (text === "") continue;
|
|
1096
|
+
const dims = (mark.head.match(/[①-④]/g) ?? []).join("+");
|
|
1097
|
+
lines.push({
|
|
1098
|
+
content: `观点 ${mark.label}(${mark.head}):${text}`,
|
|
1099
|
+
dimension: dims !== "" ? `维度${dims}` : "其他",
|
|
1100
|
+
quote: void 0
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
return lines.length > 0 ? lines : null;
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* 拆分一条发言为若干独立观点;任何失败返回 null(调用方整条兜底)。
|
|
1107
|
+
* quote 与发言原文做规范化子串校验(防线③),不匹配即丢弃。
|
|
1108
|
+
*/
|
|
1109
|
+
async function splitUtterance(llm, config, nodeKey, content) {
|
|
1110
|
+
const trimmed = content.replace(/\s+/g, " ").trim();
|
|
1111
|
+
if (trimmed === "") return null;
|
|
1112
|
+
const raw = await callSplitLlm(llm, config, "你是 RoundTable「针锋相对」评审的观点拆分器。把下面一条红队专家发言拆成 1~3 条独立观点,每条观点只包含一个缺陷,不得发明原文没有的内容。对每条观点给出证据分级(C1):代码/bug 类缺陷的 evidence.kind=repro 并写可复现步骤(1. 2. 3.);设计类缺陷的 evidence.kind=argument 并写论证链——禁止为设计类缺陷编造\"运行某命令即可复现\"这类伪复现步骤。无法从原文提取证据时省略 evidence 字段。必须调用 split_redteam_viewpoint 工具返回结构化结果。", `专家 ${nodeKey} 的发言:\n${trimmed}`);
|
|
1113
|
+
if (raw !== null) {
|
|
1114
|
+
let parsed;
|
|
1115
|
+
try {
|
|
1116
|
+
parsed = JSON.parse(raw);
|
|
1117
|
+
} catch {
|
|
1118
|
+
parsed = void 0;
|
|
1119
|
+
}
|
|
1120
|
+
const list = parsed?.viewpoints;
|
|
1121
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
1122
|
+
const lines = [];
|
|
1123
|
+
for (const item of list) {
|
|
1124
|
+
const obj = item;
|
|
1125
|
+
const line$1 = typeof obj?.content === "string" ? obj.content.replace(/\s+/g, " ").trim() : "";
|
|
1126
|
+
if (line$1 === "") continue;
|
|
1127
|
+
let quote;
|
|
1128
|
+
if (typeof obj?.quote === "string") {
|
|
1129
|
+
const candidate = obj.quote.replace(/\s+/g, " ").trim();
|
|
1130
|
+
if (candidate !== "" && candidate.length <= 120 && trimmed.includes(candidate)) quote = candidate;
|
|
1131
|
+
}
|
|
1132
|
+
const dimension = typeof obj?.dimension === "string" && obj.dimension.trim() !== "" ? obj.dimension.trim().slice(0, 20) : "其他";
|
|
1133
|
+
let evidence;
|
|
1134
|
+
const ev = obj?.evidence;
|
|
1135
|
+
if (ev !== null && typeof ev === "object" && (ev.kind === "repro" || ev.kind === "argument") && typeof ev.text === "string") {
|
|
1136
|
+
const text = ev.text.replace(/\s+/g, " ").trim();
|
|
1137
|
+
if (text !== "") evidence = {
|
|
1138
|
+
kind: ev.kind,
|
|
1139
|
+
text: text.slice(0, 600)
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
lines.push({
|
|
1143
|
+
content: line$1,
|
|
1144
|
+
quote,
|
|
1145
|
+
dimension,
|
|
1146
|
+
evidence
|
|
1147
|
+
});
|
|
1148
|
+
if (lines.length >= Math.max(1, config.maxOpinions)) break;
|
|
1149
|
+
}
|
|
1150
|
+
if (lines.length > 0) return lines;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return splitByMarkers(trimmed);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
//#endregion
|
|
1157
|
+
//#region src/plan.ts
|
|
1158
|
+
/** 卡片上"按此创建"选项的固定标签;`intent.approve` 必须与它逐字一致。 */
|
|
1159
|
+
const PLAN_APPROVE_LABEL = "按此创建";
|
|
1160
|
+
/** 卡片上的次选项:用户想改点什么。 */
|
|
1161
|
+
const PLAN_REVISE_LABEL = "我要修改";
|
|
1162
|
+
/** 一行 `键:值`,值为空时显示占位符。 */
|
|
1163
|
+
function line(label, value, empty = "(未设置)") {
|
|
1164
|
+
return `- **${label}**:${value.trim() === "" ? empty : value.trim()}`;
|
|
1165
|
+
}
|
|
1166
|
+
/** 可选 skill 清单在卡片上最多列出的条数(超出只给数量)。 */
|
|
1167
|
+
const SKILL_LIST_LIMIT = 12;
|
|
1168
|
+
/**
|
|
1169
|
+
* 把一份草案渲染成卡片正文(Markdown)。
|
|
1170
|
+
*
|
|
1171
|
+
* 这里是"让用户看懂将要创建什么"的唯一位置:每一项参数都显示当前值,
|
|
1172
|
+
* 专家逐个列出 key / 角色 / 路由,并把**当前可选**的 skill 清单也列出来
|
|
1173
|
+
* (用户要在「我要修改」的自由文本里增删 skill,就得先看得到名字)。
|
|
1174
|
+
* 禁止在其中塞伪交互说明。
|
|
1175
|
+
*/
|
|
1176
|
+
function formatMeetingDraft(draft, experts, options = {}) {
|
|
1177
|
+
const modeLabel = draft.mode === "orchestrated" ? "orchestrated(主持人统筹,一切经由主持人转达)" : draft.mode === "egalitarian" ? "egalitarian(多模型平等,专家互相直达,超预算闭麦)" : "redteam(针锋相对,专家只挑已定稿方案的毛病)";
|
|
1178
|
+
const roster = experts.length === 0 ? "(暂无专家——建议至少 1 位)" : experts.map((expert, index) => {
|
|
1179
|
+
const role = expert.role !== void 0 && expert.role.trim() !== "" ? expert.role.trim() : "(未填角色)";
|
|
1180
|
+
const route = expert.provider !== void 0 && expert.provider !== "" && expert.model !== void 0 && expert.model !== "" ? `${expert.provider}/${expert.model}` : "继承主持人当前 provider/model";
|
|
1181
|
+
return `${index + 1}. \`${expert.key}\` — ${role} — ${route}`;
|
|
1182
|
+
}).join("\n");
|
|
1183
|
+
const skills = draft.skills.length === 0 ? "(未选)" : draft.skills.map((skill) => `\`${skill}\``).join("、");
|
|
1184
|
+
const deliveryLabel = draft.skillDelivery === "relay" ? "relay(主持人中转:主持人读正文后按需转交,省 token、行为可预测)" : "direct(专家直接调用:专家自己用 `skill` 工具加载,更自主、各自读取)";
|
|
1185
|
+
return [
|
|
1186
|
+
`# 会议设置确认 · ${draft.name.trim() === "" ? "(未命名)" : draft.name.trim()}`,
|
|
1187
|
+
"",
|
|
1188
|
+
options.revised === true ? "> 已按你的意见更新草案,请再确认一次;还有出入就再选「我要修改」。" : "> 以下是主持人推导的会议草案。**确认后才会真正创建会议。**",
|
|
1189
|
+
"",
|
|
1190
|
+
"## 会议参数",
|
|
1191
|
+
line("会议名称", draft.name, "(未命名)"),
|
|
1192
|
+
line("协作模式", modeLabel),
|
|
1193
|
+
line("轮数上限", `${draft.maxRounds} 轮(超限自动闭麦)`),
|
|
1194
|
+
line("Token 预算", `${draft.maxTokens} tokens(超限自动闭麦)`),
|
|
1195
|
+
line("知识库", draft.kbPath, "(未设置:专家需要资料时由主持人按需读取转交)"),
|
|
1196
|
+
line("选中 skill", skills, "(未选)"),
|
|
1197
|
+
line("skill 传递方式", deliveryLabel),
|
|
1198
|
+
"",
|
|
1199
|
+
"## 专家名单",
|
|
1200
|
+
roster,
|
|
1201
|
+
"",
|
|
1202
|
+
"## 可选 skill 清单(写进「我要修改」的说明里即可增删)",
|
|
1203
|
+
formatAvailableSkills(options.availableSkills ?? []),
|
|
1204
|
+
"",
|
|
1205
|
+
"## 议题 / 目标",
|
|
1206
|
+
draft.goal.trim() === "" ? "(未提供,以主持人现场说明为准)" : draft.goal.trim(),
|
|
1207
|
+
"",
|
|
1208
|
+
"---",
|
|
1209
|
+
"选「我要修改」时,请在输入框里写清改什么(例:把 reviewer 换成 zai-coding-cn/glm-5.2、预算降到 5 轮、加上 skill `pdf-fill`)。主持人据此更新草案后**会再弹一次**这张卡片。"
|
|
1210
|
+
].join("\n");
|
|
1211
|
+
}
|
|
1212
|
+
/** 渲染"当前可选 skill"清单(空清单给出可操作提示)。 */
|
|
1213
|
+
function formatAvailableSkills(available) {
|
|
1214
|
+
if (available.length === 0) return "(本机当前没有可选 skill:把 skill 放进 `<工作区>/.dsh/skills/`、`<工作区>/.agents/skills/` 或 `<DSH_HOME>/skills/` 即会出现)";
|
|
1215
|
+
const lines = available.slice(0, SKILL_LIST_LIMIT).map((skill) => {
|
|
1216
|
+
const description = skill.description === void 0 || skill.description.trim() === "" ? "" : ` — ${skill.description.trim()}`;
|
|
1217
|
+
return ` - \`${skill.name}\`${description}`;
|
|
1218
|
+
});
|
|
1219
|
+
if (available.length > SKILL_LIST_LIMIT) lines.push(` - …另有 ${available.length - SKILL_LIST_LIMIT} 个`);
|
|
1220
|
+
return lines.join("\n");
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* 解析用户的卡片回答。
|
|
1224
|
+
*
|
|
1225
|
+
* 协议与 `intent.kind: 'plan-review'` 的语义一致:`selected` 含
|
|
1226
|
+
* `PLAN_APPROVE_LABEL` 即视为批准,其余选项(或只有 custom 文本)视为
|
|
1227
|
+
* 要求修改。不识别 intent 的 UI 会退回普通选项列表,答复编码完全相同。
|
|
1228
|
+
*/
|
|
1229
|
+
function resolvePlanConfirmation(answer, approveLabel = PLAN_APPROVE_LABEL) {
|
|
1230
|
+
if (answer === void 0) return {
|
|
1231
|
+
kind: "unavailable",
|
|
1232
|
+
note: ""
|
|
1233
|
+
};
|
|
1234
|
+
const selected = Array.isArray(answer.selected) ? answer.selected : [];
|
|
1235
|
+
const note = answer.custom === void 0 ? "" : answer.custom.trim();
|
|
1236
|
+
if (selected.includes(approveLabel)) return { kind: "approved" };
|
|
1237
|
+
if (note !== "") return {
|
|
1238
|
+
kind: "revise",
|
|
1239
|
+
note
|
|
1240
|
+
};
|
|
1241
|
+
return {
|
|
1242
|
+
kind: "unavailable",
|
|
1243
|
+
note: ""
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
//#endregion
|
|
1248
|
+
//#region src/skills.ts
|
|
1249
|
+
/** 读取 skill 服务;未挂载时返回 undefined(不抛错、不影响插件加载)。 */
|
|
1250
|
+
function skillRegistryOf(ctx) {
|
|
1251
|
+
const skills = ctx.get("skills");
|
|
1252
|
+
if (skills === void 0 || skills === null || typeof skills.list !== "function") return void 0;
|
|
1253
|
+
return skills;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* 列出某个工作区可见的、**可被模型调用**的 skill 清单。
|
|
1257
|
+
*
|
|
1258
|
+
* 服务缺失、发现失败或工作区不可读时都降级为空数组:设置卡片上的 skill
|
|
1259
|
+
* 列表因此永远不会成为创建会议的阻塞点。
|
|
1260
|
+
*/
|
|
1261
|
+
async function listInvocableSkills(ctx, cwd, signal) {
|
|
1262
|
+
const registry = skillRegistryOf(ctx);
|
|
1263
|
+
if (registry === void 0) return [];
|
|
1264
|
+
let summaries;
|
|
1265
|
+
try {
|
|
1266
|
+
summaries = await registry.list({
|
|
1267
|
+
cwd,
|
|
1268
|
+
...signal !== void 0 ? { signal } : {}
|
|
1269
|
+
});
|
|
1270
|
+
} catch {
|
|
1271
|
+
return [];
|
|
1272
|
+
}
|
|
1273
|
+
return summaries.filter((summary) => summary !== void 0 && typeof summary.name === "string" && summary.name !== "").filter((summary) => summary.invocation?.modelInvocable !== false).sort((left, right) => left.name.localeCompare(right.name));
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
//#endregion
|
|
1277
|
+
//#region src/tools.ts
|
|
1278
|
+
const DEFAULT_MAX_ROUNDS = 10;
|
|
1279
|
+
const DEFAULT_MAX_TOKENS = 2e5;
|
|
1280
|
+
/** The caller agent, or a loud failure for non-agent callers. */
|
|
1281
|
+
function requireCaptain(exec) {
|
|
1282
|
+
if (exec.agent === void 0) throw new Error("roundtable tools require a calling agent (exec.agent was undefined)");
|
|
1283
|
+
return exec.agent;
|
|
1284
|
+
}
|
|
1285
|
+
/** The captain's workspace directory (meeting state root parent). */
|
|
1286
|
+
function workspaceOf(agent) {
|
|
1287
|
+
return agent.session.header?.cwd ?? process.cwd();
|
|
1288
|
+
}
|
|
1289
|
+
/** Process-local lock key enforcing one active meeting per captain session. */
|
|
1290
|
+
function captainLockKey(stateRoot, captainId) {
|
|
1291
|
+
return `captain:${stateRoot}:${captainId}`;
|
|
1292
|
+
}
|
|
1293
|
+
/** Process-local lock key for one meeting directory. */
|
|
1294
|
+
function meetingLockKey(stateRoot, meetingId) {
|
|
1295
|
+
return `meeting:${stateRoot}:${meetingId}`;
|
|
1296
|
+
}
|
|
1297
|
+
/** Find the active meeting this captain leads (or undefined). */
|
|
1298
|
+
async function findMeetingByCaptain(stateRoot, captainId) {
|
|
1299
|
+
const { listMeetings: listMeetings$1 } = await import("./state-pJShWoUr.js");
|
|
1300
|
+
for (const id of await listMeetings$1(stateRoot)) {
|
|
1301
|
+
const meeting = await readMeeting(stateRoot, id);
|
|
1302
|
+
if (meeting !== void 0 && meeting.captainSessionId === captainId && meeting.status !== "ended" && meeting.status !== "archived") return meeting;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/** Find the active meeting this agent participates in (captain or node). */
|
|
1306
|
+
async function findMeetingByParticipant(stateRoot, agentId) {
|
|
1307
|
+
const { listMeetings: listMeetings$1 } = await import("./state-pJShWoUr.js");
|
|
1308
|
+
for (const id of await listMeetings$1(stateRoot)) {
|
|
1309
|
+
const meeting = await readMeeting(stateRoot, id);
|
|
1310
|
+
if (meeting === void 0 || meeting.status === "ended" || meeting.status === "archived") continue;
|
|
1311
|
+
if (meeting.captainSessionId === agentId) return meeting;
|
|
1312
|
+
if (meeting.nodes.some((node) => node.id === agentId && ACTIVE_NODE_STATUSES.includes(node.status))) return meeting;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
/** Locate the meeting the captain leads, or fail loudly (captain-tool boilerplate). */
|
|
1316
|
+
async function locateCaptainMeeting(stateRoot, captainId) {
|
|
1317
|
+
const located = await findMeetingByCaptain(stateRoot, captainId);
|
|
1318
|
+
if (located === void 0) throw new Error("you are not leading any meeting — call roundtable_create first");
|
|
1319
|
+
return located;
|
|
1320
|
+
}
|
|
1321
|
+
/** Locate the meeting the caller participates in, or fail loudly. */
|
|
1322
|
+
async function locateParticipantMeeting(stateRoot, agentId) {
|
|
1323
|
+
const located = await findMeetingByParticipant(stateRoot, agentId);
|
|
1324
|
+
if (located === void 0) throw new Error("you do not belong to any active meeting yet");
|
|
1325
|
+
return located;
|
|
1326
|
+
}
|
|
1327
|
+
/** Lock + captain-permission gate + budget gate: the inner captain-tool boilerplate. */
|
|
1328
|
+
async function withCaptainLock(stateRoot, meetingId, captainId, action, operation, options = {}) {
|
|
1329
|
+
return withMeetingLock(meetingLockKey(stateRoot, meetingId), async () => {
|
|
1330
|
+
const fresh = await readMeeting(stateRoot, meetingId);
|
|
1331
|
+
if (fresh === void 0 || fresh.captainSessionId !== captainId) throw new Error(`only the captain of meeting "${meetingId}" may ${action}`);
|
|
1332
|
+
assertUsable(fresh, options);
|
|
1333
|
+
return operation(fresh);
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Assert a write may proceed, and make the muted fact durable before rethrowing.
|
|
1338
|
+
*
|
|
1339
|
+
* `assertUsable` never mutates, so somebody has to persist the mute — otherwise
|
|
1340
|
+
* the UI keeps showing an `active` meeting that rejects everything (P1).
|
|
1341
|
+
*/
|
|
1342
|
+
async function assertUsablePersisting(stateRoot, meeting) {
|
|
1343
|
+
try {
|
|
1344
|
+
assertUsable(meeting);
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
if (markMuted(meeting)) {
|
|
1347
|
+
meeting.updatedAt = Date.now();
|
|
1348
|
+
await writeMeeting(stateRoot, meeting);
|
|
1349
|
+
}
|
|
1350
|
+
throw error;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
/** Derive the caller's role from fresh state. */
|
|
1354
|
+
function participantIdentityOf(meeting, agentId) {
|
|
1355
|
+
if (meeting.captainSessionId === agentId) return { kind: "captain" };
|
|
1356
|
+
const node = meeting.nodes.find((candidate) => candidate.id === agentId && candidate.status !== "removed");
|
|
1357
|
+
return node === void 0 ? void 0 : {
|
|
1358
|
+
kind: "node",
|
|
1359
|
+
name: node.key
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
/** Fresh meeting plus caller identity, rechecked inside the lock. */
|
|
1363
|
+
async function requireFreshParticipant(stateRoot, meetingId, callerId) {
|
|
1364
|
+
const fresh = await readMeeting(stateRoot, meetingId);
|
|
1365
|
+
if (fresh === void 0) throw new Error(`meeting "${meetingId}" no longer exists`);
|
|
1366
|
+
const identity = participantIdentityOf(fresh, callerId);
|
|
1367
|
+
if (identity === void 0) throw new Error(`you are no longer a participant in meeting "${fresh.name}"`);
|
|
1368
|
+
return {
|
|
1369
|
+
meeting: fresh,
|
|
1370
|
+
identity
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
/** Look up one live node by key. */
|
|
1374
|
+
function requireNode(meeting, key) {
|
|
1375
|
+
const node = meeting.nodes.find((candidate) => candidate.key === key && candidate.status !== "removed");
|
|
1376
|
+
if (node === void 0) throw new Error(`no active node named "${key}" in meeting "${meeting.name}"`);
|
|
1377
|
+
return node;
|
|
1378
|
+
}
|
|
1379
|
+
/** Resolve a node for the given key (captain/aggregator are not nodes). */
|
|
1380
|
+
function isNodeKey(meeting, key) {
|
|
1381
|
+
return meeting.nodes.some((candidate) => candidate.key === key && candidate.status !== "removed");
|
|
1382
|
+
}
|
|
1383
|
+
/** Valid edge endpoints: captain, aggregator, or a live node. */
|
|
1384
|
+
function validEndpoint(meeting, key) {
|
|
1385
|
+
return key === CAPTAIN_KEY || key === AGGREGATOR_KEY || isNodeKey(meeting, key);
|
|
1386
|
+
}
|
|
1387
|
+
/** Record one utterance and its token cost; throws when the meeting is muted. */
|
|
1388
|
+
async function recordUtterance(stateRoot, meeting, utterance, options = {}) {
|
|
1389
|
+
if (options.opensNewRound === true) beginRound(meeting);
|
|
1390
|
+
await assertUsablePersisting(stateRoot, meeting);
|
|
1391
|
+
const full = {
|
|
1392
|
+
...utterance,
|
|
1393
|
+
id: randomUUID(),
|
|
1394
|
+
round: meeting.round,
|
|
1395
|
+
ts: Date.now()
|
|
1396
|
+
};
|
|
1397
|
+
meeting.budget.usedTokens += estimateTokens(full.content);
|
|
1398
|
+
meeting.updatedAt = Date.now();
|
|
1399
|
+
await appendUtterance(stateRoot, meeting.id, full);
|
|
1400
|
+
await writeMeeting(stateRoot, meeting);
|
|
1401
|
+
return full;
|
|
1402
|
+
}
|
|
1403
|
+
/** Normalize one meeting-mode argument (throws on garbage). */
|
|
1404
|
+
function normalizeMode(value, fallback) {
|
|
1405
|
+
if (value === void 0 || value === null) return fallback;
|
|
1406
|
+
if (value === "orchestrated" || value === "egalitarian" || value === "redteam") return value;
|
|
1407
|
+
throw new Error(`mode must be "orchestrated" | "egalitarian" | "redteam", got "${String(value)}"`);
|
|
1408
|
+
}
|
|
1409
|
+
/** Normalize one skill-delivery argument (throws on garbage). */
|
|
1410
|
+
function normalizeSkillDelivery(value, fallback) {
|
|
1411
|
+
if (value === void 0 || value === null) return fallback;
|
|
1412
|
+
if (value === "relay" || value === "direct") return value;
|
|
1413
|
+
throw new Error(`skill_delivery must be "relay" | "direct", got "${String(value)}"`);
|
|
1414
|
+
}
|
|
1415
|
+
/** Sanitize a skill-name list: trim, drop empties, de-duplicate, cap at 20. */
|
|
1416
|
+
function normalizeSkillNames(value) {
|
|
1417
|
+
if (!Array.isArray(value)) return [];
|
|
1418
|
+
const out = [];
|
|
1419
|
+
for (const item of value) {
|
|
1420
|
+
const name$1 = String(item ?? "").trim();
|
|
1421
|
+
if (name$1 === "" || out.includes(name$1)) continue;
|
|
1422
|
+
out.push(name$1);
|
|
1423
|
+
if (out.length >= 20) break;
|
|
1424
|
+
}
|
|
1425
|
+
return out;
|
|
1426
|
+
}
|
|
1427
|
+
/** 会议参数显式传入 > 设置页偏好 > Config 默认值。 */
|
|
1428
|
+
function plannedDefaults(config, args) {
|
|
1429
|
+
const injected = config.getPlannedDefaults?.() ?? {
|
|
1430
|
+
mode: config.defaultMode,
|
|
1431
|
+
maxRounds: DEFAULT_MAX_ROUNDS,
|
|
1432
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
1433
|
+
skillDelivery: "relay"
|
|
1434
|
+
};
|
|
1435
|
+
const maxRounds = typeof args.max_rounds === "number" && Number.isFinite(args.max_rounds) && args.max_rounds >= 1 ? Math.floor(args.max_rounds) : injected.maxRounds;
|
|
1436
|
+
const maxTokens = typeof args.max_tokens === "number" && Number.isFinite(args.max_tokens) && args.max_tokens >= 1e3 ? Math.floor(args.max_tokens) : injected.maxTokens;
|
|
1437
|
+
return {
|
|
1438
|
+
mode: normalizeMode(args.mode, injected.mode),
|
|
1439
|
+
maxRounds,
|
|
1440
|
+
maxTokens,
|
|
1441
|
+
skillDelivery: normalizeSkillDelivery(args.skill_delivery, injected.skillDelivery)
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
/** 渲染一张设置卡片的正文(放 `AskUserQuestionItem.detail`)。
|
|
1445
|
+
* 渲染规则集中在 plan.ts,这里只做"加上当前可选 skill 清单"的转接。 */
|
|
1446
|
+
function renderPlanCard(draft, experts, available, options) {
|
|
1447
|
+
return formatMeetingDraft(draft, experts, {
|
|
1448
|
+
revised: options.revised,
|
|
1449
|
+
availableSkills: available
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
/** Register every `roundtable_*` tool into the shared tools registry. */
|
|
1453
|
+
function registerRoundTableTools(ctx, config) {
|
|
1454
|
+
ctx.tools.register(defineTool({
|
|
1455
|
+
name: "roundtable_create",
|
|
1456
|
+
description: "Create a new RoundTable meeting: you (the calling agent) become the captain (主持人). A captain leads one active meeting at a time. Preferred flow (v0.2.31): call roundtable_plan_meeting first, let the user confirm the draft card, then create the meeting with exactly the confirmed values (name/goal/mode/max_rounds/max_tokens/kb_path/skills/skill_delivery). Choose the collaboration mode: \"orchestrated\" means you decide who speaks and relay everything; \"egalitarian\" means experts message each other directly (a budget of max rounds/tokens then mutes the meeting — 闭麦).",
|
|
1457
|
+
parameters: {
|
|
1458
|
+
name: {
|
|
1459
|
+
type: "string",
|
|
1460
|
+
required: true,
|
|
1461
|
+
description: "Name for the new meeting (used as its stable id)."
|
|
1462
|
+
},
|
|
1463
|
+
goal: {
|
|
1464
|
+
type: "string",
|
|
1465
|
+
required: true,
|
|
1466
|
+
description: "Meeting background and core goal (charter section one) — the ultimate deliverable."
|
|
1467
|
+
},
|
|
1468
|
+
mode: {
|
|
1469
|
+
type: "string",
|
|
1470
|
+
enum: [
|
|
1471
|
+
"orchestrated",
|
|
1472
|
+
"egalitarian",
|
|
1473
|
+
"redteam"
|
|
1474
|
+
],
|
|
1475
|
+
description: `Collaboration mode. Defaults to "${config.defaultMode}". "orchestrated" = captain relays everything; "egalitarian" = experts debate peer-to-peer under a budget (use max_rounds/max_tokens to bound it); "redteam" = 针锋相对评审 of a settled plan (experts attack the plan).`
|
|
1476
|
+
},
|
|
1477
|
+
max_rounds: {
|
|
1478
|
+
type: "integer",
|
|
1479
|
+
description: `Debate round cap (default ${DEFAULT_MAX_ROUNDS}); exceeding it mutes the meeting.`
|
|
1480
|
+
},
|
|
1481
|
+
max_tokens: {
|
|
1482
|
+
type: "integer",
|
|
1483
|
+
description: `Total token budget for the meeting transcript (default ${DEFAULT_MAX_TOKENS}); exceeding it mutes the meeting.`
|
|
1484
|
+
},
|
|
1485
|
+
kb_path: {
|
|
1486
|
+
type: "string",
|
|
1487
|
+
description: "知识库目录(可选):主持人按需读取其中文件并转交专家。相对路径按工作区解析。"
|
|
1488
|
+
},
|
|
1489
|
+
skills: {
|
|
1490
|
+
type: "array",
|
|
1491
|
+
items: { type: "string" },
|
|
1492
|
+
description: "本次会议选中的 skill 名称清单(可选,来自 roundtable_plan_meeting 的卡片确认结果)。"
|
|
1493
|
+
},
|
|
1494
|
+
skill_delivery: {
|
|
1495
|
+
type: "string",
|
|
1496
|
+
enum: ["relay", "direct"],
|
|
1497
|
+
description: "skill 传递方式:relay=主持人中转(默认);direct=专家自行用 skill 工具调用。"
|
|
1498
|
+
}
|
|
1499
|
+
},
|
|
1500
|
+
output: {
|
|
1501
|
+
schema: {
|
|
1502
|
+
type: "object",
|
|
1503
|
+
additionalProperties: false,
|
|
1504
|
+
properties: {
|
|
1505
|
+
meeting_id: {
|
|
1506
|
+
type: "string",
|
|
1507
|
+
required: true
|
|
1508
|
+
},
|
|
1509
|
+
meeting_name: {
|
|
1510
|
+
type: "string",
|
|
1511
|
+
required: true
|
|
1512
|
+
},
|
|
1513
|
+
mode: {
|
|
1514
|
+
type: "string",
|
|
1515
|
+
required: true
|
|
1516
|
+
},
|
|
1517
|
+
max_rounds: {
|
|
1518
|
+
type: "integer",
|
|
1519
|
+
required: true
|
|
1520
|
+
},
|
|
1521
|
+
max_tokens: {
|
|
1522
|
+
type: "integer",
|
|
1523
|
+
required: true
|
|
1524
|
+
},
|
|
1525
|
+
kb_path: {
|
|
1526
|
+
type: "string",
|
|
1527
|
+
required: true
|
|
1528
|
+
},
|
|
1529
|
+
skills: {
|
|
1530
|
+
type: "array",
|
|
1531
|
+
required: true,
|
|
1532
|
+
items: { type: "string" }
|
|
1533
|
+
},
|
|
1534
|
+
skill_delivery: {
|
|
1535
|
+
type: "string",
|
|
1536
|
+
required: true
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
},
|
|
1540
|
+
render: (_args, value) => [{
|
|
1541
|
+
type: "text",
|
|
1542
|
+
text: `RoundTable meeting "${value.meeting_name}" created (id ${value.meeting_id}, mode ${value.mode}, budget ${value.max_rounds} rounds / ${value.max_tokens} tokens, kb ${value.kb_path === "" ? "none" : value.kb_path}, skills ${value.skills.length === 0 ? "none" : value.skills.join(", ")} via ${value.skill_delivery}). You are the captain. Add expert nodes with roundtable_add_node.`
|
|
1543
|
+
}]
|
|
1544
|
+
},
|
|
1545
|
+
async execute(args, exec) {
|
|
1546
|
+
const captain = requireCaptain(exec);
|
|
1547
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
1548
|
+
const meetingName = String(args.name ?? "").trim();
|
|
1549
|
+
if (meetingName === "") throw new Error("meeting name must not be empty");
|
|
1550
|
+
const meetingId = sanitizeKey(meetingName);
|
|
1551
|
+
const defaults = plannedDefaults(config, args);
|
|
1552
|
+
const mode = defaults.mode;
|
|
1553
|
+
const requestedSkills = normalizeSkillNames(args.skills);
|
|
1554
|
+
const skillDelivery = defaults.skillDelivery;
|
|
1555
|
+
const kbPath = typeof args.kb_path === "string" ? args.kb_path.trim() : "";
|
|
1556
|
+
const maxRounds = defaults.maxRounds;
|
|
1557
|
+
const maxTokens = defaults.maxTokens;
|
|
1558
|
+
return withMeetingLock(captainLockKey(stateRoot, captain.id), async () => {
|
|
1559
|
+
const current = await findMeetingByCaptain(stateRoot, captain.id);
|
|
1560
|
+
if (current !== void 0) throw new Error(`you already lead meeting "${current.name}" — close it before creating another`);
|
|
1561
|
+
return withMeetingLock(meetingLockKey(stateRoot, meetingId), async () => {
|
|
1562
|
+
const existing = await readMeeting(stateRoot, meetingId);
|
|
1563
|
+
if (existing !== void 0 && existing.status !== "ended" && existing.status !== "archived") throw new Error(`meeting id "${meetingId}" is taken — pick a different meeting name`);
|
|
1564
|
+
if (existing !== void 0) await resetMeetingDirectory(stateRoot, meetingId);
|
|
1565
|
+
const now = Date.now();
|
|
1566
|
+
const meeting = {
|
|
1567
|
+
id: meetingId,
|
|
1568
|
+
name: meetingName,
|
|
1569
|
+
goal: String(args.goal ?? ""),
|
|
1570
|
+
mode,
|
|
1571
|
+
captainSessionId: captain.id,
|
|
1572
|
+
charter: "",
|
|
1573
|
+
nodes: [],
|
|
1574
|
+
edges: [],
|
|
1575
|
+
decisions: [],
|
|
1576
|
+
budget: {
|
|
1577
|
+
maxRounds,
|
|
1578
|
+
maxTokens,
|
|
1579
|
+
usedRounds: 0,
|
|
1580
|
+
usedTokens: 0
|
|
1581
|
+
},
|
|
1582
|
+
round: 0,
|
|
1583
|
+
...kbPath === "" ? {} : { kbPath },
|
|
1584
|
+
...requestedSkills.length === 0 ? {} : { skills: requestedSkills },
|
|
1585
|
+
skillDelivery,
|
|
1586
|
+
status: "active",
|
|
1587
|
+
createdAt: now,
|
|
1588
|
+
updatedAt: now
|
|
1589
|
+
};
|
|
1590
|
+
meeting.charter = buildCharter(meeting);
|
|
1591
|
+
await writeMeeting(stateRoot, meeting);
|
|
1592
|
+
return {
|
|
1593
|
+
meeting_id: meeting.id,
|
|
1594
|
+
meeting_name: meeting.name,
|
|
1595
|
+
mode: meeting.mode,
|
|
1596
|
+
max_rounds: meeting.budget.maxRounds,
|
|
1597
|
+
max_tokens: meeting.budget.maxTokens,
|
|
1598
|
+
kb_path: meeting.kbPath ?? "",
|
|
1599
|
+
skills: meeting.skills ?? [],
|
|
1600
|
+
skill_delivery: meeting.skillDelivery ?? skillDelivery
|
|
1601
|
+
};
|
|
1602
|
+
});
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
}));
|
|
1606
|
+
ctx.tools.register(defineTool({
|
|
1607
|
+
name: "roundtable_plan_meeting",
|
|
1608
|
+
description: "Show the human a MEETING SETTINGS CARD before creating anything (R1). This tool does NOT create a meeting: it composes a readable draft (experts, mode, budget, knowledge base, selected skills) from the user's request plus your plugin defaults, then BLOCKS until the user confirms. Returns decision=\"approved\" plus the confirmed plan to pass to roundtable_create, or decision=\"revise\" with the user's own words — update the draft and call this tool again (the card is shown again, revised=true). Call it for EVERY meeting, even a single-expert one.",
|
|
1609
|
+
parameters: {
|
|
1610
|
+
name: {
|
|
1611
|
+
type: "string",
|
|
1612
|
+
required: true,
|
|
1613
|
+
description: "草案里的会议名称(也是会议 id 的来源)。"
|
|
1614
|
+
},
|
|
1615
|
+
goal: {
|
|
1616
|
+
type: "string",
|
|
1617
|
+
required: true,
|
|
1618
|
+
description: "会议背景与核心目标(总纲第一节)。"
|
|
1619
|
+
},
|
|
1620
|
+
mode: {
|
|
1621
|
+
type: "string",
|
|
1622
|
+
enum: [
|
|
1623
|
+
"orchestrated",
|
|
1624
|
+
"egalitarian",
|
|
1625
|
+
"redteam"
|
|
1626
|
+
],
|
|
1627
|
+
description: "协作模式;缺省用设置页的默认模式。"
|
|
1628
|
+
},
|
|
1629
|
+
experts: {
|
|
1630
|
+
type: "array",
|
|
1631
|
+
description: "草案里的专家名单(卡片上逐个列出)。",
|
|
1632
|
+
items: {
|
|
1633
|
+
type: "object",
|
|
1634
|
+
additionalProperties: false,
|
|
1635
|
+
properties: {
|
|
1636
|
+
key: {
|
|
1637
|
+
type: "string",
|
|
1638
|
+
required: true,
|
|
1639
|
+
description: "专家唯一标识,如 researcher。"
|
|
1640
|
+
},
|
|
1641
|
+
role: {
|
|
1642
|
+
type: "string",
|
|
1643
|
+
description: "角色说明,如 安全审查。"
|
|
1644
|
+
},
|
|
1645
|
+
provider: {
|
|
1646
|
+
type: "string",
|
|
1647
|
+
description: "模型路由(与 model 同时给出才生效;缺省继承主持人当前路由)。"
|
|
1648
|
+
},
|
|
1649
|
+
model: {
|
|
1650
|
+
type: "string",
|
|
1651
|
+
description: "模型名。"
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
},
|
|
1656
|
+
max_rounds: {
|
|
1657
|
+
type: "integer",
|
|
1658
|
+
description: "轮数上限;缺省用设置页默认值。"
|
|
1659
|
+
},
|
|
1660
|
+
max_tokens: {
|
|
1661
|
+
type: "integer",
|
|
1662
|
+
description: "Token 预算;缺省用设置页默认值。"
|
|
1663
|
+
},
|
|
1664
|
+
kb_path: {
|
|
1665
|
+
type: "string",
|
|
1666
|
+
description: "知识库目录(可空)。"
|
|
1667
|
+
},
|
|
1668
|
+
skills: {
|
|
1669
|
+
type: "array",
|
|
1670
|
+
items: { type: "string" },
|
|
1671
|
+
description: "选中 skill 的名称清单(可空)。"
|
|
1672
|
+
},
|
|
1673
|
+
skill_delivery: {
|
|
1674
|
+
type: "string",
|
|
1675
|
+
enum: ["relay", "direct"],
|
|
1676
|
+
description: "skill 传递方式;缺省用设置页默认值。"
|
|
1677
|
+
},
|
|
1678
|
+
revised: {
|
|
1679
|
+
type: "boolean",
|
|
1680
|
+
description: "本次是\"用户要求修改后\"的再次确认(卡片会标注已按意见更新)。"
|
|
1681
|
+
}
|
|
1682
|
+
},
|
|
1683
|
+
output: {
|
|
1684
|
+
schema: {
|
|
1685
|
+
type: "object",
|
|
1686
|
+
additionalProperties: false,
|
|
1687
|
+
properties: {
|
|
1688
|
+
decision: {
|
|
1689
|
+
type: "string",
|
|
1690
|
+
required: true
|
|
1691
|
+
},
|
|
1692
|
+
user_note: {
|
|
1693
|
+
type: "string",
|
|
1694
|
+
required: true
|
|
1695
|
+
},
|
|
1696
|
+
name: {
|
|
1697
|
+
type: "string",
|
|
1698
|
+
required: true
|
|
1699
|
+
},
|
|
1700
|
+
goal: {
|
|
1701
|
+
type: "string",
|
|
1702
|
+
required: true
|
|
1703
|
+
},
|
|
1704
|
+
mode: {
|
|
1705
|
+
type: "string",
|
|
1706
|
+
required: true
|
|
1707
|
+
},
|
|
1708
|
+
max_rounds: {
|
|
1709
|
+
type: "integer",
|
|
1710
|
+
required: true
|
|
1711
|
+
},
|
|
1712
|
+
max_tokens: {
|
|
1713
|
+
type: "integer",
|
|
1714
|
+
required: true
|
|
1715
|
+
},
|
|
1716
|
+
kb_path: {
|
|
1717
|
+
type: "string",
|
|
1718
|
+
required: true
|
|
1719
|
+
},
|
|
1720
|
+
skills: {
|
|
1721
|
+
type: "array",
|
|
1722
|
+
required: true,
|
|
1723
|
+
items: { type: "string" }
|
|
1724
|
+
},
|
|
1725
|
+
skill_delivery: {
|
|
1726
|
+
type: "string",
|
|
1727
|
+
required: true
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
},
|
|
1731
|
+
render: (_args, value) => [{
|
|
1732
|
+
type: "text",
|
|
1733
|
+
text: value.decision === "approved" ? `用户已确认会议设置:创建 "${value.name}"(${value.mode},${value.max_rounds} 轮 / ${value.max_tokens} tokens,kb ${value.kb_path === "" ? "未设置" : value.kb_path},skills ${value.skills.length === 0 ? "未选" : value.skills.join(", ")},skill 传递 ${value.skill_delivery})。请用 roundtable_create 按这些值创建会议,然后逐个 roundtable_add_node。` : value.decision === "revise" ? `用户要求修改:${value.user_note}\n请据此更新草案(保持未改动的项原样),再次调用 roundtable_plan_meeting 让用户确认;确认前不要创建会议。` : `设置卡片未能取得用户答复(userQuestions 服务不可用或被中止)${value.user_note === "" ? "" : `:${value.user_note}`}。请把草案内容用文字告知用户,取得明确同意后再调用 roundtable_create。`
|
|
1734
|
+
}]
|
|
1735
|
+
},
|
|
1736
|
+
async execute(args, exec) {
|
|
1737
|
+
const captain = requireCaptain(exec);
|
|
1738
|
+
const workspace = workspaceOf(captain);
|
|
1739
|
+
const defaults = plannedDefaults(config, args);
|
|
1740
|
+
const name$1 = String(args.name ?? "").trim();
|
|
1741
|
+
const goal = String(args.goal ?? "").trim();
|
|
1742
|
+
const kbPath = typeof args.kb_path === "string" ? args.kb_path.trim() : "";
|
|
1743
|
+
const skills = normalizeSkillNames(args.skills);
|
|
1744
|
+
const experts = Array.isArray(args.experts) ? args.experts.flatMap((raw) => {
|
|
1745
|
+
const entry = raw;
|
|
1746
|
+
const key = String(entry?.key ?? "").trim();
|
|
1747
|
+
if (key === "") return [];
|
|
1748
|
+
return [{
|
|
1749
|
+
key,
|
|
1750
|
+
role: entry?.role === void 0 ? void 0 : String(entry.role),
|
|
1751
|
+
provider: entry?.provider === void 0 ? void 0 : String(entry.provider),
|
|
1752
|
+
model: entry?.model === void 0 ? void 0 : String(entry.model)
|
|
1753
|
+
}];
|
|
1754
|
+
}) : [];
|
|
1755
|
+
const draft = {
|
|
1756
|
+
name: name$1,
|
|
1757
|
+
goal,
|
|
1758
|
+
mode: defaults.mode,
|
|
1759
|
+
maxRounds: defaults.maxRounds,
|
|
1760
|
+
maxTokens: defaults.maxTokens,
|
|
1761
|
+
kbPath,
|
|
1762
|
+
skills,
|
|
1763
|
+
skillDelivery: defaults.skillDelivery
|
|
1764
|
+
};
|
|
1765
|
+
const detail = renderPlanCard(draft, experts, await listInvocableSkills(ctx, workspace, exec.signal), { revised: args.revised === true });
|
|
1766
|
+
const userQuestions = ctx.get("userQuestions");
|
|
1767
|
+
const fallback = {
|
|
1768
|
+
decision: "unavailable",
|
|
1769
|
+
user_note: userQuestions === void 0 ? "the userQuestions service is not mounted in this composition" : "",
|
|
1770
|
+
name: draft.name,
|
|
1771
|
+
goal: draft.goal,
|
|
1772
|
+
mode: draft.mode,
|
|
1773
|
+
max_rounds: draft.maxRounds,
|
|
1774
|
+
max_tokens: draft.maxTokens,
|
|
1775
|
+
kb_path: draft.kbPath,
|
|
1776
|
+
skills: draft.skills,
|
|
1777
|
+
skill_delivery: draft.skillDelivery
|
|
1778
|
+
};
|
|
1779
|
+
if (userQuestions === void 0) return fallback;
|
|
1780
|
+
const questionId = randomUUID();
|
|
1781
|
+
let answer;
|
|
1782
|
+
try {
|
|
1783
|
+
answer = await userQuestions.ask({
|
|
1784
|
+
questions: [{
|
|
1785
|
+
id: questionId,
|
|
1786
|
+
question: `确认「${draft.name.trim() === "" ? "未命名会议" : draft.name.trim()}」的会议设置?`,
|
|
1787
|
+
detail,
|
|
1788
|
+
header: "圆桌会议 · 会议设置确认",
|
|
1789
|
+
options: [{
|
|
1790
|
+
label: PLAN_APPROVE_LABEL,
|
|
1791
|
+
description: "按上方参数创建会议(专家随后由主持人逐个拉入)"
|
|
1792
|
+
}, {
|
|
1793
|
+
label: PLAN_REVISE_LABEL,
|
|
1794
|
+
description: "在输入框写明要改的地方,主持人会更新草案并再弹一次"
|
|
1795
|
+
}],
|
|
1796
|
+
intent: {
|
|
1797
|
+
kind: "plan-review",
|
|
1798
|
+
approve: PLAN_APPROVE_LABEL
|
|
1799
|
+
}
|
|
1800
|
+
}],
|
|
1801
|
+
agent: captain,
|
|
1802
|
+
signal: exec.signal
|
|
1803
|
+
});
|
|
1804
|
+
} catch (error) {
|
|
1805
|
+
return {
|
|
1806
|
+
...fallback,
|
|
1807
|
+
decision: "unavailable",
|
|
1808
|
+
user_note: `设置卡片不可用:${error.message}`
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
const confirmation = resolvePlanConfirmation(answer.answers.find((candidate) => candidate.id === questionId));
|
|
1812
|
+
return {
|
|
1813
|
+
decision: confirmation.kind,
|
|
1814
|
+
user_note: confirmation.kind === "revise" || confirmation.kind === "unavailable" ? confirmation.note : "",
|
|
1815
|
+
name: draft.name,
|
|
1816
|
+
goal: draft.goal,
|
|
1817
|
+
mode: draft.mode,
|
|
1818
|
+
max_rounds: draft.maxRounds,
|
|
1819
|
+
max_tokens: draft.maxTokens,
|
|
1820
|
+
kb_path: draft.kbPath,
|
|
1821
|
+
skills: draft.skills,
|
|
1822
|
+
skill_delivery: draft.skillDelivery
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
}));
|
|
1826
|
+
ctx.tools.register(defineTool({
|
|
1827
|
+
name: "roundtable_add_node",
|
|
1828
|
+
description: "Add an expert node to your meeting: spawns a durable continuable subagent with the meeting charter as its persona. By default the node inherits your current provider/model. Supply provider/model only when the user explicitly wants a different route for this expert.",
|
|
1829
|
+
parameters: {
|
|
1830
|
+
name: {
|
|
1831
|
+
type: "string",
|
|
1832
|
+
required: true,
|
|
1833
|
+
description: "Unique node key inside the meeting (e.g. researcher, engineer, reviewer)."
|
|
1834
|
+
},
|
|
1835
|
+
role: {
|
|
1836
|
+
type: "string",
|
|
1837
|
+
description: "Role description for this expert (e.g. \"security reviewer\")."
|
|
1838
|
+
},
|
|
1839
|
+
provider: {
|
|
1840
|
+
type: "string",
|
|
1841
|
+
description: "Optional LLM provider route. Use only when the user explicitly requests a different provider; requires model."
|
|
1842
|
+
},
|
|
1843
|
+
model: {
|
|
1844
|
+
type: "string",
|
|
1845
|
+
description: "Optional model override. Omit to inherit your current model."
|
|
1846
|
+
},
|
|
1847
|
+
reasoning_effort: {
|
|
1848
|
+
type: "string",
|
|
1849
|
+
description: "Optional adapter-owned reasoning-effort id for this expert (e.g. high / medium / low — the exact vocabulary is defined by the model capability, not by this plugin). Omit to inherit your own route-owned effort."
|
|
1850
|
+
}
|
|
1851
|
+
},
|
|
1852
|
+
output: {
|
|
1853
|
+
schema: {
|
|
1854
|
+
type: "object",
|
|
1855
|
+
additionalProperties: false,
|
|
1856
|
+
properties: {
|
|
1857
|
+
node_name: {
|
|
1858
|
+
type: "string",
|
|
1859
|
+
required: true
|
|
1860
|
+
},
|
|
1861
|
+
node_id: {
|
|
1862
|
+
type: "string",
|
|
1863
|
+
required: true
|
|
1864
|
+
},
|
|
1865
|
+
provider: {
|
|
1866
|
+
type: "string",
|
|
1867
|
+
required: true
|
|
1868
|
+
},
|
|
1869
|
+
model: {
|
|
1870
|
+
type: "string",
|
|
1871
|
+
required: true
|
|
1872
|
+
},
|
|
1873
|
+
reasoning_effort: {
|
|
1874
|
+
type: "string",
|
|
1875
|
+
required: true
|
|
1876
|
+
},
|
|
1877
|
+
status: {
|
|
1878
|
+
type: "string",
|
|
1879
|
+
required: true
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
},
|
|
1883
|
+
render: (_args, value) => [{
|
|
1884
|
+
type: "text",
|
|
1885
|
+
text: `Node "${value.node_name}" joined (subagent id ${value.node_id}, ${value.provider}/${value.model}${value.reasoning_effort === "" ? "" : ` @ ${value.reasoning_effort}`}, status ${value.status}).`
|
|
1886
|
+
}]
|
|
1887
|
+
},
|
|
1888
|
+
async execute(args, exec) {
|
|
1889
|
+
const captain = requireCaptain(exec);
|
|
1890
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
1891
|
+
const located = await findMeetingByCaptain(stateRoot, captain.id);
|
|
1892
|
+
if (located === void 0) throw new Error("you are not leading any meeting — call roundtable_create first");
|
|
1893
|
+
const meetingId = located.id;
|
|
1894
|
+
return await withCaptainLock(stateRoot, meetingId, captain.id, "add nodes", async (fresh) => {
|
|
1895
|
+
const nodeKey = sanitizeKey(String(args.name ?? "").trim());
|
|
1896
|
+
if (nodeKey === "") throw new Error("node name must not be empty");
|
|
1897
|
+
if (nodeKey === CAPTAIN_KEY || nodeKey === AGGREGATOR_KEY) throw new Error(`node name "${nodeKey}" is reserved`);
|
|
1898
|
+
if (fresh.nodes.some((candidate) => candidate.key === nodeKey && candidate.status !== "removed")) throw new Error(`node "${nodeKey}" already exists in meeting "${fresh.name}"`);
|
|
1899
|
+
if (fresh.nodes.filter((candidate) => candidate.status !== "removed").length >= config.maxNodes) throw new Error(`meeting "${fresh.name}" is at its node cap (${config.maxNodes})`);
|
|
1900
|
+
const reasoningEffort = args.reasoning_effort !== void 0 ? String(args.reasoning_effort).trim() : "";
|
|
1901
|
+
const node = {
|
|
1902
|
+
id: "",
|
|
1903
|
+
key: nodeKey,
|
|
1904
|
+
role: args.role !== void 0 ? String(args.role) : void 0,
|
|
1905
|
+
provider: args.provider !== void 0 ? String(args.provider) : captain.options.provider,
|
|
1906
|
+
model: args.model !== void 0 ? String(args.model) : captain.options.model,
|
|
1907
|
+
reasoningEffort: reasoningEffort === "" ? void 0 : reasoningEffort,
|
|
1908
|
+
status: "idle",
|
|
1909
|
+
joinedAt: Date.now()
|
|
1910
|
+
};
|
|
1911
|
+
const limits = config.getExpertLimits?.() ?? {
|
|
1912
|
+
maxTokens: 0,
|
|
1913
|
+
maxOpinions: 0
|
|
1914
|
+
};
|
|
1915
|
+
await spawnNode(ctx, {
|
|
1916
|
+
provider: config.memberProvider,
|
|
1917
|
+
maxDepth: config.memberMaxDepth
|
|
1918
|
+
}, fresh, node, captain, config.stateDir, exec.signal, limits, {
|
|
1919
|
+
names: fresh.skills ?? [],
|
|
1920
|
+
delivery: fresh.skillDelivery ?? config.getSkillDelivery?.() ?? "relay"
|
|
1921
|
+
});
|
|
1922
|
+
fresh.nodes.push(node);
|
|
1923
|
+
fresh.charter = buildCharter(fresh);
|
|
1924
|
+
try {
|
|
1925
|
+
await writeMeeting(stateRoot, fresh);
|
|
1926
|
+
} catch (error) {
|
|
1927
|
+
if (node.id !== "") interruptNode(ctx, captain, node.id);
|
|
1928
|
+
throw error;
|
|
1929
|
+
}
|
|
1930
|
+
registerNodeChild(node.id, {
|
|
1931
|
+
stateRoot,
|
|
1932
|
+
meetingId
|
|
1933
|
+
});
|
|
1934
|
+
return {
|
|
1935
|
+
node_name: node.key,
|
|
1936
|
+
node_id: node.id,
|
|
1937
|
+
provider: node.provider ?? "",
|
|
1938
|
+
model: node.model ?? "",
|
|
1939
|
+
reasoning_effort: node.reasoningEffort ?? "",
|
|
1940
|
+
status: node.status
|
|
1941
|
+
};
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
}));
|
|
1945
|
+
ctx.tools.register(defineTool({
|
|
1946
|
+
name: "roundtable_remove_node",
|
|
1947
|
+
description: "Remove an expert node from your meeting: interrupts its live turn, marks it removed, and drops every edge touching it.",
|
|
1948
|
+
parameters: { name: {
|
|
1949
|
+
type: "string",
|
|
1950
|
+
required: true,
|
|
1951
|
+
description: "Key of the node to remove."
|
|
1952
|
+
} },
|
|
1953
|
+
output: {
|
|
1954
|
+
schema: {
|
|
1955
|
+
type: "object",
|
|
1956
|
+
additionalProperties: false,
|
|
1957
|
+
properties: {
|
|
1958
|
+
node_name: {
|
|
1959
|
+
type: "string",
|
|
1960
|
+
required: true
|
|
1961
|
+
},
|
|
1962
|
+
removed_edges: {
|
|
1963
|
+
type: "integer",
|
|
1964
|
+
required: true
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
},
|
|
1968
|
+
render: (_args, value) => [{
|
|
1969
|
+
type: "text",
|
|
1970
|
+
text: `Node "${value.node_name}" removed; ${value.removed_edges} edge(s) dropped.`
|
|
1971
|
+
}]
|
|
1972
|
+
},
|
|
1973
|
+
async execute(args, exec) {
|
|
1974
|
+
const captain = requireCaptain(exec);
|
|
1975
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
1976
|
+
const removed = await withCaptainLock(stateRoot, (await locateCaptainMeeting(stateRoot, captain.id)).id, captain.id, "remove nodes", async (fresh) => {
|
|
1977
|
+
const node = requireNode(fresh, String(args.name ?? ""));
|
|
1978
|
+
node.status = "removed";
|
|
1979
|
+
const before = fresh.edges.length;
|
|
1980
|
+
fresh.edges = fresh.edges.filter((edge) => edge.from !== node.key && edge.to !== node.key);
|
|
1981
|
+
fresh.charter = buildCharter(fresh);
|
|
1982
|
+
await writeMeeting(stateRoot, fresh);
|
|
1983
|
+
return {
|
|
1984
|
+
node,
|
|
1985
|
+
removedEdges: before - fresh.edges.length
|
|
1986
|
+
};
|
|
1987
|
+
});
|
|
1988
|
+
if (removed.node.id !== "") interruptNode(ctx, captain, removed.node.id);
|
|
1989
|
+
forgetNodeChild(removed.node.id);
|
|
1990
|
+
return {
|
|
1991
|
+
node_name: removed.node.key,
|
|
1992
|
+
removed_edges: removed.removedEdges
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
}));
|
|
1996
|
+
ctx.tools.register(defineTool({
|
|
1997
|
+
name: "roundtable_connect",
|
|
1998
|
+
description: "Create a directed channel between two participants (a node key, \"captain\", or \"aggregator\"). Direction \"forward\" = pipeline hand-off; \"bidirectional\" = debate channel (both sides may address each other).",
|
|
1999
|
+
parameters: {
|
|
2000
|
+
from: {
|
|
2001
|
+
type: "string",
|
|
2002
|
+
required: true,
|
|
2003
|
+
description: "Source endpoint: a node key, \"captain\", or \"aggregator\"."
|
|
2004
|
+
},
|
|
2005
|
+
to: {
|
|
2006
|
+
type: "string",
|
|
2007
|
+
required: true,
|
|
2008
|
+
description: "Target endpoint: a node key, \"captain\", or \"aggregator\"."
|
|
2009
|
+
},
|
|
2010
|
+
direction: {
|
|
2011
|
+
type: "string",
|
|
2012
|
+
enum: ["forward", "bidirectional"],
|
|
2013
|
+
description: "Channel direction (default forward)."
|
|
2014
|
+
}
|
|
2015
|
+
},
|
|
2016
|
+
output: {
|
|
2017
|
+
schema: {
|
|
2018
|
+
type: "object",
|
|
2019
|
+
additionalProperties: false,
|
|
2020
|
+
properties: {
|
|
2021
|
+
edge_id: {
|
|
2022
|
+
type: "string",
|
|
2023
|
+
required: true
|
|
2024
|
+
},
|
|
2025
|
+
from: {
|
|
2026
|
+
type: "string",
|
|
2027
|
+
required: true
|
|
2028
|
+
},
|
|
2029
|
+
to: {
|
|
2030
|
+
type: "string",
|
|
2031
|
+
required: true
|
|
2032
|
+
},
|
|
2033
|
+
direction: {
|
|
2034
|
+
type: "string",
|
|
2035
|
+
required: true
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
},
|
|
2039
|
+
render: (_args, value) => [{
|
|
2040
|
+
type: "text",
|
|
2041
|
+
text: `Channel ${value.edge_id}: ${value.from} → ${value.to} (${value.direction}).`
|
|
2042
|
+
}]
|
|
2043
|
+
},
|
|
2044
|
+
async execute(args, exec) {
|
|
2045
|
+
const captain = requireCaptain(exec);
|
|
2046
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
2047
|
+
return withCaptainLock(stateRoot, (await locateCaptainMeeting(stateRoot, captain.id)).id, captain.id, "edit channels", async (fresh) => {
|
|
2048
|
+
const from = String(args.from ?? "").trim();
|
|
2049
|
+
const to = String(args.to ?? "").trim();
|
|
2050
|
+
if (!validEndpoint(fresh, from)) throw new Error(`unknown endpoint "${from}"`);
|
|
2051
|
+
if (!validEndpoint(fresh, to)) throw new Error(`unknown endpoint "${to}"`);
|
|
2052
|
+
if (from === to) throw new Error("an edge cannot connect a participant to itself");
|
|
2053
|
+
if (fresh.edges.some((edge$1) => edge$1.from === from && edge$1.to === to)) throw new Error(`channel ${from} → ${to} already exists`);
|
|
2054
|
+
const direction = args.direction ?? "forward";
|
|
2055
|
+
const edge = {
|
|
2056
|
+
id: randomUUID(),
|
|
2057
|
+
from,
|
|
2058
|
+
to,
|
|
2059
|
+
direction,
|
|
2060
|
+
createdAt: Date.now()
|
|
2061
|
+
};
|
|
2062
|
+
fresh.edges.push(edge);
|
|
2063
|
+
fresh.charter = buildCharter(fresh);
|
|
2064
|
+
await writeMeeting(stateRoot, fresh);
|
|
2065
|
+
return {
|
|
2066
|
+
edge_id: edge.id,
|
|
2067
|
+
from,
|
|
2068
|
+
to,
|
|
2069
|
+
direction
|
|
2070
|
+
};
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
}));
|
|
2074
|
+
ctx.tools.register(defineTool({
|
|
2075
|
+
name: "roundtable_disconnect",
|
|
2076
|
+
description: "Remove a channel by edge id, or by its from/to endpoints.",
|
|
2077
|
+
parameters: {
|
|
2078
|
+
edge_id: {
|
|
2079
|
+
type: "string",
|
|
2080
|
+
description: "Id of the edge to remove (from roundtable_status or connect)."
|
|
2081
|
+
},
|
|
2082
|
+
from: {
|
|
2083
|
+
type: "string",
|
|
2084
|
+
description: "Source endpoint (alternative to edge_id)."
|
|
2085
|
+
},
|
|
2086
|
+
to: {
|
|
2087
|
+
type: "string",
|
|
2088
|
+
description: "Target endpoint (alternative to edge_id)."
|
|
2089
|
+
}
|
|
2090
|
+
},
|
|
2091
|
+
output: {
|
|
2092
|
+
schema: {
|
|
2093
|
+
type: "object",
|
|
2094
|
+
additionalProperties: false,
|
|
2095
|
+
properties: { removed: {
|
|
2096
|
+
type: "boolean",
|
|
2097
|
+
required: true
|
|
2098
|
+
} }
|
|
2099
|
+
},
|
|
2100
|
+
render: (_args, value) => [{
|
|
2101
|
+
type: "text",
|
|
2102
|
+
text: value.removed ? "Channel removed." : "No matching channel found."
|
|
2103
|
+
}]
|
|
2104
|
+
},
|
|
2105
|
+
async execute(args, exec) {
|
|
2106
|
+
const captain = requireCaptain(exec);
|
|
2107
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
2108
|
+
return withCaptainLock(stateRoot, (await locateCaptainMeeting(stateRoot, captain.id)).id, captain.id, "edit channels", async (fresh) => {
|
|
2109
|
+
const before = fresh.edges.length;
|
|
2110
|
+
if (args.edge_id !== void 0) fresh.edges = fresh.edges.filter((edge) => edge.id !== String(args.edge_id));
|
|
2111
|
+
else if (args.from !== void 0 && args.to !== void 0) fresh.edges = fresh.edges.filter((edge) => edge.from !== String(args.from) || edge.to !== String(args.to));
|
|
2112
|
+
else throw new Error("provide edge_id, or both from and to");
|
|
2113
|
+
fresh.charter = buildCharter(fresh);
|
|
2114
|
+
await writeMeeting(stateRoot, fresh);
|
|
2115
|
+
return { removed: before !== fresh.edges.length };
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
}));
|
|
2119
|
+
ctx.tools.register(defineTool({
|
|
2120
|
+
name: "roundtable_speak",
|
|
2121
|
+
description: "Report into the meeting transcript — the default channel: durable, feeds the aggregation gateway, and wakes nobody. Use this instead of roundtable_send_message unless someone must act right now. Keep the charter format and the report envelope [REPORT R{轮} {key}] 结论(≤3 条)|证据|待决策; [核心产出] is the exact segment the gateway captures. Leave `to` empty to submit to the gateway; set it to a participant key for a directed remark.",
|
|
2122
|
+
parameters: {
|
|
2123
|
+
content: {
|
|
2124
|
+
type: "string",
|
|
2125
|
+
required: true,
|
|
2126
|
+
description: "The full contribution text."
|
|
2127
|
+
},
|
|
2128
|
+
to: {
|
|
2129
|
+
type: "string",
|
|
2130
|
+
description: "Directed audience: a node key or \"captain\". Empty submits to the aggregation gateway."
|
|
2131
|
+
},
|
|
2132
|
+
kind: {
|
|
2133
|
+
type: "string",
|
|
2134
|
+
enum: [
|
|
2135
|
+
"speech",
|
|
2136
|
+
"proxy-thinking",
|
|
2137
|
+
"retrieval"
|
|
2138
|
+
],
|
|
2139
|
+
description: "Contribution kind (default speech). Use \"proxy-thinking\" when you speak for a black-box worker model, \"retrieval\" when reporting knowledge-base retrieval."
|
|
2140
|
+
}
|
|
2141
|
+
},
|
|
2142
|
+
output: {
|
|
2143
|
+
schema: {
|
|
2144
|
+
type: "object",
|
|
2145
|
+
additionalProperties: false,
|
|
2146
|
+
properties: {
|
|
2147
|
+
utterance_id: {
|
|
2148
|
+
type: "string",
|
|
2149
|
+
required: true
|
|
2150
|
+
},
|
|
2151
|
+
round: {
|
|
2152
|
+
type: "integer",
|
|
2153
|
+
required: true
|
|
2154
|
+
},
|
|
2155
|
+
tokens_used: {
|
|
2156
|
+
type: "integer",
|
|
2157
|
+
required: true
|
|
2158
|
+
},
|
|
2159
|
+
budget_remaining_tokens: {
|
|
2160
|
+
type: "integer",
|
|
2161
|
+
required: true
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
},
|
|
2165
|
+
render: (_args, value) => [{
|
|
2166
|
+
type: "text",
|
|
2167
|
+
text: `Contribution recorded (id ${value.utterance_id}, round ${value.round}); meeting token budget remaining ${value.budget_remaining_tokens}.`
|
|
2168
|
+
}]
|
|
2169
|
+
},
|
|
2170
|
+
async execute(args, exec) {
|
|
2171
|
+
const caller = requireCaptain(exec);
|
|
2172
|
+
const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
|
|
2173
|
+
const located = await locateParticipantMeeting(stateRoot, caller.id);
|
|
2174
|
+
return await withMeetingLock(meetingLockKey(stateRoot, located.id), async () => {
|
|
2175
|
+
const { meeting, identity } = await requireFreshParticipant(stateRoot, located.id, caller.id);
|
|
2176
|
+
const content = String(args.content ?? "").trim();
|
|
2177
|
+
if (content === "") throw new Error("content must not be empty");
|
|
2178
|
+
const speaker = identity.kind === "captain" ? CAPTAIN_KEY : identity.name;
|
|
2179
|
+
const to = args.to !== void 0 ? String(args.to).trim() : void 0;
|
|
2180
|
+
if (to !== void 0 && to !== "" && !validEndpoint(meeting, to)) throw new Error(`unknown directed audience "${to}"`);
|
|
2181
|
+
const utterance = await recordUtterance(stateRoot, meeting, {
|
|
2182
|
+
nodeKey: speaker,
|
|
2183
|
+
kind: args.kind ?? "speech",
|
|
2184
|
+
content,
|
|
2185
|
+
to: to === "" ? void 0 : to
|
|
2186
|
+
}, { opensNewRound: identity.kind === "captain" });
|
|
2187
|
+
return {
|
|
2188
|
+
utterance_id: utterance.id,
|
|
2189
|
+
round: utterance.round,
|
|
2190
|
+
tokens_used: estimateTokens(content),
|
|
2191
|
+
budget_remaining_tokens: Math.max(0, meeting.budget.maxTokens - meeting.budget.usedTokens)
|
|
2192
|
+
};
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
}));
|
|
2196
|
+
ctx.tools.register(defineTool({
|
|
2197
|
+
name: "roundtable_send_message",
|
|
2198
|
+
description: "Send a direct message that WAKES the recipient as its next turn — use it only when they must act right now; ordinary reporting goes through roundtable_speak (durable, wakes nobody). When forwarding someone else's opinion, quote the reference R{n}[{speaker}] instead of pasting the full text. In \"orchestrated\" mode only the captain may message nodes (nodes report to the captain); in \"egalitarian\" mode any participant may message any other — this is how experts debate peer-to-peer.",
|
|
2199
|
+
parameters: {
|
|
2200
|
+
to: {
|
|
2201
|
+
type: "string",
|
|
2202
|
+
required: true,
|
|
2203
|
+
description: "Recipient: \"captain\" or a node key."
|
|
2204
|
+
},
|
|
2205
|
+
content: {
|
|
2206
|
+
type: "string",
|
|
2207
|
+
required: true,
|
|
2208
|
+
description: "The message text."
|
|
2209
|
+
}
|
|
2210
|
+
},
|
|
2211
|
+
output: {
|
|
2212
|
+
schema: {
|
|
2213
|
+
type: "object",
|
|
2214
|
+
additionalProperties: false,
|
|
2215
|
+
properties: { delivered: {
|
|
2216
|
+
type: "string",
|
|
2217
|
+
required: true,
|
|
2218
|
+
description: "wake (recipient node woken), live (captain steered), or dropped (best effort failed)."
|
|
2219
|
+
} }
|
|
2220
|
+
},
|
|
2221
|
+
render: (_args, value) => [{
|
|
2222
|
+
type: "text",
|
|
2223
|
+
text: `Message delivered via ${value.delivered}.`
|
|
2224
|
+
}]
|
|
2225
|
+
},
|
|
2226
|
+
async execute(args, exec) {
|
|
2227
|
+
const caller = requireCaptain(exec);
|
|
2228
|
+
const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
|
|
2229
|
+
const located = await locateParticipantMeeting(stateRoot, caller.id);
|
|
2230
|
+
const content = String(args.content ?? "").trim();
|
|
2231
|
+
if (content === "") throw new Error("content must not be empty");
|
|
2232
|
+
const to = String(args.to ?? "").trim();
|
|
2233
|
+
if (to === "") throw new Error("recipient must not be empty");
|
|
2234
|
+
const prepared = await withMeetingLock(meetingLockKey(stateRoot, located.id), async () => {
|
|
2235
|
+
const { meeting, identity } = await requireFreshParticipant(stateRoot, located.id, caller.id);
|
|
2236
|
+
await assertUsablePersisting(stateRoot, meeting);
|
|
2237
|
+
const speaker = identity.kind === "captain" ? CAPTAIN_KEY : identity.name;
|
|
2238
|
+
if (meeting.mode !== "egalitarian" && identity.kind === "node" && to !== CAPTAIN_KEY) throw new Error("orchestrated/redteam mode: nodes report to the captain only — the captain relays between nodes");
|
|
2239
|
+
if (to === CAPTAIN_KEY) {
|
|
2240
|
+
await recordUtterance(stateRoot, meeting, {
|
|
2241
|
+
nodeKey: speaker,
|
|
2242
|
+
kind: "speech",
|
|
2243
|
+
content,
|
|
2244
|
+
to: CAPTAIN_KEY
|
|
2245
|
+
});
|
|
2246
|
+
return {
|
|
2247
|
+
kind: "captain",
|
|
2248
|
+
meeting,
|
|
2249
|
+
speaker
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
const recipient = requireNode(meeting, to);
|
|
2253
|
+
if (recipient.id === "") throw new Error(`node "${to}" has no live subagent yet`);
|
|
2254
|
+
await recordUtterance(stateRoot, meeting, {
|
|
2255
|
+
nodeKey: speaker,
|
|
2256
|
+
kind: "speech",
|
|
2257
|
+
content,
|
|
2258
|
+
to
|
|
2259
|
+
});
|
|
2260
|
+
return {
|
|
2261
|
+
kind: "node",
|
|
2262
|
+
meeting,
|
|
2263
|
+
speaker,
|
|
2264
|
+
recipient
|
|
2265
|
+
};
|
|
2266
|
+
});
|
|
2267
|
+
const captainLive = ctx.agents.get(prepared.meeting.captainSessionId);
|
|
2268
|
+
if (prepared.kind === "captain") {
|
|
2269
|
+
if (captainLive !== void 0 && prepared.speaker !== CAPTAIN_KEY) return { delivered: steerCaptain(captainLive, `RoundTable message from ${prepared.speaker}:\n\n${content}`) ? "live" : "dropped" };
|
|
2270
|
+
return { delivered: "dropped" };
|
|
2271
|
+
}
|
|
2272
|
+
if (captainLive !== void 0) return { delivered: await deliverToNode(ctx, captainLive, prepared.recipient.id, content, exec.signal) ? "wake" : "dropped" };
|
|
2273
|
+
return { delivered: "dropped" };
|
|
2274
|
+
}
|
|
2275
|
+
}));
|
|
2276
|
+
ctx.tools.register(defineTool({
|
|
2277
|
+
name: "roundtable_summarize",
|
|
2278
|
+
description: "Pull the aggregation gateway digest: a deterministic structured merge of the transcript (per-speaker recent lines). Use it to stay aligned without reading every raw line; you may then condense it further in your own reply.",
|
|
2279
|
+
parameters: {},
|
|
2280
|
+
output: {
|
|
2281
|
+
schema: {
|
|
2282
|
+
type: "object",
|
|
2283
|
+
additionalProperties: false,
|
|
2284
|
+
properties: {
|
|
2285
|
+
digest: {
|
|
2286
|
+
type: "string",
|
|
2287
|
+
required: true
|
|
2288
|
+
},
|
|
2289
|
+
speech_count: {
|
|
2290
|
+
type: "integer",
|
|
2291
|
+
required: true
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
},
|
|
2295
|
+
render: (_args, value) => [{
|
|
2296
|
+
type: "text",
|
|
2297
|
+
text: `[Gateway digest]\n${value.digest}`
|
|
2298
|
+
}]
|
|
2299
|
+
},
|
|
2300
|
+
async execute(_args, exec) {
|
|
2301
|
+
const caller = requireCaptain(exec);
|
|
2302
|
+
const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
|
|
2303
|
+
const utterances = await readTranscript(stateRoot, (await locateParticipantMeeting(stateRoot, caller.id)).id);
|
|
2304
|
+
return {
|
|
2305
|
+
digest: aggregateUtterances(utterances),
|
|
2306
|
+
speech_count: utterances.filter((utterance) => utterance.kind === "speech" || utterance.kind === "proxy-thinking").length
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
}));
|
|
2310
|
+
ctx.tools.register(defineTool({
|
|
2311
|
+
name: "roundtable_request_decision",
|
|
2312
|
+
description: "Pause the meeting and ask the human for a decision (Human-in-the-loop). The user picks one option or types their own. The meeting turn blocks until the user answers; the answer is then returned to you. Use for real forks or disagreements the experts cannot settle.",
|
|
2313
|
+
parameters: {
|
|
2314
|
+
question: {
|
|
2315
|
+
type: "string",
|
|
2316
|
+
required: true,
|
|
2317
|
+
description: "The decision to put to the user (e.g. \"架构方案 A 还是 B?\")."
|
|
2318
|
+
},
|
|
2319
|
+
options: {
|
|
2320
|
+
type: "array",
|
|
2321
|
+
items: { type: "string" },
|
|
2322
|
+
description: "Option labels (e.g. [\"方案 A:模块化\", \"方案 B:一体化\"]). The user may also type a custom answer."
|
|
2323
|
+
},
|
|
2324
|
+
multi_select: {
|
|
2325
|
+
type: "boolean",
|
|
2326
|
+
description: "Let the user tick MORE THAN ONE option in a single card. Use it for \"which of these findings are real?\" (review endorsements, defect shortlists) instead of asking one card per item — the selected labels come back joined by \" · \". Leave it off for \"pick one plan\" decisions."
|
|
2327
|
+
}
|
|
2328
|
+
},
|
|
2329
|
+
output: {
|
|
2330
|
+
schema: {
|
|
2331
|
+
type: "object",
|
|
2332
|
+
additionalProperties: false,
|
|
2333
|
+
properties: {
|
|
2334
|
+
decision_id: {
|
|
2335
|
+
type: "string",
|
|
2336
|
+
required: true
|
|
2337
|
+
},
|
|
2338
|
+
chosen: {
|
|
2339
|
+
type: "string",
|
|
2340
|
+
required: true
|
|
2341
|
+
},
|
|
2342
|
+
custom_answer: { type: "string" }
|
|
2343
|
+
}
|
|
2344
|
+
},
|
|
2345
|
+
render: (_args, value) => [{
|
|
2346
|
+
type: "text",
|
|
2347
|
+
text: `Human decision ${value.decision_id}: ${value.chosen}${value.custom_answer !== void 0 && value.custom_answer !== "" ? ` (custom: ${value.custom_answer})` : ""}`
|
|
2348
|
+
}]
|
|
2349
|
+
},
|
|
2350
|
+
async execute(args, exec) {
|
|
2351
|
+
const captain = requireCaptain(exec);
|
|
2352
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
2353
|
+
const located = await locateCaptainMeeting(stateRoot, captain.id);
|
|
2354
|
+
const multiSelect = args.multi_select === true;
|
|
2355
|
+
const question = String(args.question ?? "").trim();
|
|
2356
|
+
if (question === "") throw new Error("question must not be empty");
|
|
2357
|
+
const optionLabels = Array.isArray(args.options) ? args.options.map((option) => String(option).trim()).filter((option) => option !== "") : [];
|
|
2358
|
+
const decision = {
|
|
2359
|
+
id: randomUUID(),
|
|
2360
|
+
question,
|
|
2361
|
+
options: optionLabels,
|
|
2362
|
+
status: "pending",
|
|
2363
|
+
ts: Date.now()
|
|
2364
|
+
};
|
|
2365
|
+
await withCaptainLock(stateRoot, located.id, captain.id, "request decisions", async (fresh) => {
|
|
2366
|
+
fresh.decisions.push(decision);
|
|
2367
|
+
await writeMeeting(stateRoot, fresh);
|
|
2368
|
+
});
|
|
2369
|
+
const userQuestions = ctx.get("userQuestions");
|
|
2370
|
+
if (userQuestions === void 0) {
|
|
2371
|
+
decision.status = "resolved";
|
|
2372
|
+
decision.chosen = "(unavailable)";
|
|
2373
|
+
await withMeetingLock(meetingLockKey(stateRoot, located.id), async () => {
|
|
2374
|
+
const fresh = await readMeeting(stateRoot, located.id);
|
|
2375
|
+
if (fresh !== void 0) await writeMeeting(stateRoot, fresh);
|
|
2376
|
+
});
|
|
2377
|
+
throw new Error("roundtable: human decision unavailable — the userQuestions service is not mounted in this composition");
|
|
2378
|
+
}
|
|
2379
|
+
const item = (await userQuestions.ask({
|
|
2380
|
+
questions: [{
|
|
2381
|
+
id: decision.id,
|
|
2382
|
+
question,
|
|
2383
|
+
header: "圆桌会议 · 需人类决策",
|
|
2384
|
+
...optionLabels.length > 0 ? { options: optionLabels.map((label) => ({ label })) } : {},
|
|
2385
|
+
...multiSelect ? { multiSelect: true } : {}
|
|
2386
|
+
}],
|
|
2387
|
+
agent: captain,
|
|
2388
|
+
signal: exec.signal
|
|
2389
|
+
})).answers.find((candidate) => candidate.id === decision.id);
|
|
2390
|
+
const selected = Array.isArray(item?.selected) ? item.selected : [];
|
|
2391
|
+
const chosen = multiSelect && selected.length > 1 ? selected.join(" · ") : selected[0];
|
|
2392
|
+
const customAnswer = item?.custom !== void 0 && item.custom !== "" ? item.custom : void 0;
|
|
2393
|
+
await withMeetingLock(meetingLockKey(stateRoot, located.id), async () => {
|
|
2394
|
+
const fresh = await readMeeting(stateRoot, located.id);
|
|
2395
|
+
if (fresh === void 0) return;
|
|
2396
|
+
const target = fresh.decisions.find((candidate) => candidate.id === decision.id);
|
|
2397
|
+
if (target !== void 0) {
|
|
2398
|
+
target.status = "resolved";
|
|
2399
|
+
target.chosen = chosen;
|
|
2400
|
+
if (customAnswer !== void 0) target.customAnswer = customAnswer;
|
|
2401
|
+
}
|
|
2402
|
+
await writeMeeting(stateRoot, fresh);
|
|
2403
|
+
});
|
|
2404
|
+
return {
|
|
2405
|
+
decision_id: decision.id,
|
|
2406
|
+
chosen: chosen ?? "(no selection)",
|
|
2407
|
+
...customAnswer !== void 0 ? { custom_answer: customAnswer } : {}
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
}));
|
|
2411
|
+
ctx.tools.register(defineTool({
|
|
2412
|
+
name: "roundtable_status",
|
|
2413
|
+
description: "Meeting snapshot: nodes with live activity, edges, budget, pending decisions, and the transcript tail. Poll it at the start of every round. Pass since=<the previous call's next_since> to receive ONLY new lines — cursor pagination that never skips a line; omitted, it returns the last 10. The cached knowledge-base digests print as one line each unless you pass kb_full=true.",
|
|
2414
|
+
parameters: {
|
|
2415
|
+
since: {
|
|
2416
|
+
type: "integer",
|
|
2417
|
+
description: "Cursor returned as next_since by the previous call: return only utterances at or after it. Omit to get the last 10."
|
|
2418
|
+
},
|
|
2419
|
+
kb_full: {
|
|
2420
|
+
type: "boolean",
|
|
2421
|
+
description: "Print the full cached KB digests instead of only their first line (default false — keeps every poll cheap)."
|
|
2422
|
+
}
|
|
2423
|
+
},
|
|
2424
|
+
output: {
|
|
2425
|
+
schema: {
|
|
2426
|
+
type: "object",
|
|
2427
|
+
additionalProperties: true,
|
|
2428
|
+
properties: {}
|
|
2429
|
+
},
|
|
2430
|
+
render: (_args, value) => [{
|
|
2431
|
+
type: "text",
|
|
2432
|
+
text: renderStatus(value)
|
|
2433
|
+
}]
|
|
2434
|
+
},
|
|
2435
|
+
async execute(args, exec) {
|
|
2436
|
+
const caller = requireCaptain(exec);
|
|
2437
|
+
const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
|
|
2438
|
+
const located = await locateParticipantMeeting(stateRoot, caller.id);
|
|
2439
|
+
const { meeting, identity } = await withMeetingLock(meetingLockKey(stateRoot, located.id), () => requireFreshParticipant(stateRoot, located.id, caller.id));
|
|
2440
|
+
const activity = await reconcileNodeLiveness(ctx, meeting.captainSessionId, meeting.nodes);
|
|
2441
|
+
const utterances = await readTranscript(stateRoot, meeting.id);
|
|
2442
|
+
const userActions = await readUserActions(stateRoot, meeting.id);
|
|
2443
|
+
const sinceArg = args.since;
|
|
2444
|
+
const since = typeof sinceArg === "number" && Number.isFinite(sinceArg) && sinceArg > 0 ? Math.min(Math.floor(sinceArg), utterances.length) : Math.max(0, utterances.length - STATUS_TAIL_DEFAULT);
|
|
2445
|
+
const tail = utterances.slice(since);
|
|
2446
|
+
const shownUtterances = tail.slice(0, STATUS_TAIL_MAX);
|
|
2447
|
+
const nextSince = since + shownUtterances.length;
|
|
2448
|
+
const kbFull = args.kb_full === true;
|
|
2449
|
+
const realUsage = meetingRealUsage(ctx, meeting.nodes.filter((node) => node.status !== "removed" && node.id !== "").map((node) => node.id));
|
|
2450
|
+
if (realUsage.measuredSessions > 0 && meeting.budget.usedTokensReal !== realUsage.measuredTotal) try {
|
|
2451
|
+
await withMeetingLock(meetingLockKey(stateRoot, meeting.id), async () => {
|
|
2452
|
+
const fresh = await readMeeting(stateRoot, meeting.id);
|
|
2453
|
+
if (fresh === void 0) return;
|
|
2454
|
+
fresh.budget.usedTokensReal = realUsage.measuredTotal;
|
|
2455
|
+
await writeMeeting(stateRoot, fresh);
|
|
2456
|
+
});
|
|
2457
|
+
} catch {}
|
|
2458
|
+
return {
|
|
2459
|
+
meeting_id: meeting.id,
|
|
2460
|
+
meeting_name: meeting.name,
|
|
2461
|
+
mode: meeting.mode,
|
|
2462
|
+
status: meeting.status,
|
|
2463
|
+
viewer: identity.kind === "captain" ? CAPTAIN_KEY : identity.name,
|
|
2464
|
+
round: meeting.round,
|
|
2465
|
+
kb_path: meeting.kbPath ?? "",
|
|
2466
|
+
skills: meeting.skills ?? [],
|
|
2467
|
+
skill_delivery: meeting.skillDelivery ?? config.getSkillDelivery?.() ?? "relay",
|
|
2468
|
+
budget: {
|
|
2469
|
+
max_rounds: meeting.budget.maxRounds,
|
|
2470
|
+
max_tokens: meeting.budget.maxTokens,
|
|
2471
|
+
used_rounds: meeting.budget.usedRounds,
|
|
2472
|
+
used_tokens: meeting.budget.usedTokens
|
|
2473
|
+
},
|
|
2474
|
+
tokens_real: {
|
|
2475
|
+
measured_total: realUsage.measuredTotal,
|
|
2476
|
+
measured_sessions: realUsage.measuredSessions,
|
|
2477
|
+
unmeasured_sessions: realUsage.unmeasuredSessions
|
|
2478
|
+
},
|
|
2479
|
+
nodes: meeting.nodes.filter((node) => node.status !== "removed").map((node) => ({
|
|
2480
|
+
key: node.key,
|
|
2481
|
+
role: node.role ?? "",
|
|
2482
|
+
provider: node.provider ?? "",
|
|
2483
|
+
model: node.model ?? "",
|
|
2484
|
+
reasoning_effort: node.reasoningEffort ?? "",
|
|
2485
|
+
status: node.status,
|
|
2486
|
+
activity: activity.get(node.key) ?? "unspawned",
|
|
2487
|
+
last_error: node.lastError ?? ""
|
|
2488
|
+
})),
|
|
2489
|
+
edges: meeting.edges.map((edge) => ({
|
|
2490
|
+
id: edge.id,
|
|
2491
|
+
from: edge.from,
|
|
2492
|
+
to: edge.to,
|
|
2493
|
+
direction: edge.direction
|
|
2494
|
+
})),
|
|
2495
|
+
pending_decisions: meeting.decisions.filter((decision) => decision.status === "pending").map((decision) => ({
|
|
2496
|
+
id: decision.id,
|
|
2497
|
+
question: decision.question,
|
|
2498
|
+
options: decision.options
|
|
2499
|
+
})),
|
|
2500
|
+
pending_actions: userActions.map((action) => ({
|
|
2501
|
+
id: action.id,
|
|
2502
|
+
kind: action.kind,
|
|
2503
|
+
node_key: action.nodeKey ?? "",
|
|
2504
|
+
role: action.role ?? "",
|
|
2505
|
+
provider: action.provider ?? "",
|
|
2506
|
+
model: action.model ?? "",
|
|
2507
|
+
text: action.text
|
|
2508
|
+
})),
|
|
2509
|
+
recent_utterances: shownUtterances.map((utterance) => ({
|
|
2510
|
+
speaker: utterance.nodeKey,
|
|
2511
|
+
kind: utterance.kind,
|
|
2512
|
+
content: extractCore(utterance.summary ?? utterance.content).slice(0, STATUS_LINE_CHARS),
|
|
2513
|
+
to: utterance.to ?? "",
|
|
2514
|
+
round: utterance.round
|
|
2515
|
+
})),
|
|
2516
|
+
next_since: nextSince,
|
|
2517
|
+
has_more: tail.length > shownUtterances.length,
|
|
2518
|
+
kb_full: kbFull,
|
|
2519
|
+
kb_digest: await kbDigestOverview(stateRoot, meeting.id)
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
}));
|
|
2523
|
+
ctx.tools.register(defineTool({
|
|
2524
|
+
name: "roundtable_kb_digest",
|
|
2525
|
+
description: "Record (or refresh) a knowledge-base digest in the meeting cache after YOU read a KB file. The plugin stats the file itself and stores path+size+mtimeMs as the invalidation key; from then on roundtable_status shows that entry with valid=true and you reuse the summary instead of reading the file again — this is what stops you and the expert paying for the same file twice, and the cache itself costs no tokens. Check roundtable_status FIRST: if the entry is already valid, reuse it and do not read the file. Requires the captain.",
|
|
2526
|
+
parameters: {
|
|
2527
|
+
path: {
|
|
2528
|
+
type: "string",
|
|
2529
|
+
required: true,
|
|
2530
|
+
description: "KB file path: absolute, or relative to the meeting's kb_path."
|
|
2531
|
+
},
|
|
2532
|
+
digest: {
|
|
2533
|
+
type: "string",
|
|
2534
|
+
required: true,
|
|
2535
|
+
description: "The distilled points you will actually reuse (a summary, not a copy of the file)."
|
|
2536
|
+
}
|
|
2537
|
+
},
|
|
2538
|
+
output: {
|
|
2539
|
+
schema: {
|
|
2540
|
+
type: "object",
|
|
2541
|
+
additionalProperties: false,
|
|
2542
|
+
properties: {
|
|
2543
|
+
path: {
|
|
2544
|
+
type: "string",
|
|
2545
|
+
required: true
|
|
2546
|
+
},
|
|
2547
|
+
size: {
|
|
2548
|
+
type: "integer",
|
|
2549
|
+
required: true
|
|
2550
|
+
},
|
|
2551
|
+
mtime_ms: {
|
|
2552
|
+
type: "integer",
|
|
2553
|
+
required: true
|
|
2554
|
+
},
|
|
2555
|
+
cached_entries: {
|
|
2556
|
+
type: "integer",
|
|
2557
|
+
required: true
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
},
|
|
2561
|
+
render: (_args, value) => [{
|
|
2562
|
+
type: "text",
|
|
2563
|
+
text: `Knowledge-base digest cached for ${value.path} (${value.size} bytes); ${value.cached_entries} entry(ies) in this meeting's cache.`
|
|
2564
|
+
}]
|
|
2565
|
+
},
|
|
2566
|
+
async execute(args, exec) {
|
|
2567
|
+
const captain = requireCaptain(exec);
|
|
2568
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
2569
|
+
return withCaptainLock(stateRoot, (await locateCaptainMeeting(stateRoot, captain.id)).id, captain.id, "record a knowledge-base digest", async (fresh) => {
|
|
2570
|
+
const raw = String(args.path ?? "").trim();
|
|
2571
|
+
if (raw === "") throw new Error("path must not be empty");
|
|
2572
|
+
const kbPath = fresh.kbPath ?? "";
|
|
2573
|
+
const resolved = isAbsolute(raw) ? raw : kbPath === "" ? raw : join(kbPath, raw);
|
|
2574
|
+
let info;
|
|
2575
|
+
try {
|
|
2576
|
+
info = await stat(resolved);
|
|
2577
|
+
} catch {
|
|
2578
|
+
throw new Error(`knowledge-base file not found or unreadable: ${resolved}`);
|
|
2579
|
+
}
|
|
2580
|
+
if (!info.isFile()) throw new Error(`not a file: ${resolved}`);
|
|
2581
|
+
const digestText = String(args.digest ?? "").trim();
|
|
2582
|
+
if (digestText === "") throw new Error("digest must not be empty");
|
|
2583
|
+
const entries = mergeKbDigestEntry((await readKbDigest(stateRoot, fresh.id)).entries, {
|
|
2584
|
+
path: resolved,
|
|
2585
|
+
size: info.size,
|
|
2586
|
+
mtimeMs: info.mtimeMs,
|
|
2587
|
+
digest: digestText,
|
|
2588
|
+
ts: Date.now()
|
|
2589
|
+
});
|
|
2590
|
+
await writeKbDigest(stateRoot, fresh.id, entries);
|
|
2591
|
+
return {
|
|
2592
|
+
path: resolved,
|
|
2593
|
+
size: info.size,
|
|
2594
|
+
mtime_ms: Math.round(info.mtimeMs),
|
|
2595
|
+
cached_entries: entries.length
|
|
2596
|
+
};
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
}));
|
|
2600
|
+
ctx.tools.register(defineTool({
|
|
2601
|
+
name: "roundtable_actions_clear",
|
|
2602
|
+
description: "Clear the meeting's pending user-actions file (user-actions.jsonl) after you executed every recorded action with the roundtable_* tools. Only call this after each action was applied successfully; on a failure keep the record and explain why. Returns how many actions were cleared, plus how many unparseable lines had to be dropped (report a non-zero count to the user — an action was lost).",
|
|
2603
|
+
parameters: {},
|
|
2604
|
+
output: {
|
|
2605
|
+
schema: {
|
|
2606
|
+
type: "object",
|
|
2607
|
+
additionalProperties: false,
|
|
2608
|
+
properties: {
|
|
2609
|
+
cleared: {
|
|
2610
|
+
type: "integer",
|
|
2611
|
+
required: true
|
|
2612
|
+
},
|
|
2613
|
+
malformed: {
|
|
2614
|
+
type: "integer",
|
|
2615
|
+
required: true
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
},
|
|
2619
|
+
render: (_args, value) => [{
|
|
2620
|
+
type: "text",
|
|
2621
|
+
text: value.malformed > 0 ? `Cleared ${value.cleared} pending user action(s), and dropped ${value.malformed} unparseable line(s) — tell the user those operations were lost and could not be executed.` : `Cleared ${value.cleared} pending user action(s).`
|
|
2622
|
+
}]
|
|
2623
|
+
},
|
|
2624
|
+
async execute(_args, exec) {
|
|
2625
|
+
const captain = requireCaptain(exec);
|
|
2626
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
2627
|
+
return withCaptainLock(stateRoot, (await locateCaptainMeeting(stateRoot, captain.id)).id, captain.id, "clear user actions", async (fresh) => {
|
|
2628
|
+
return clearUserActions(stateRoot, fresh.id);
|
|
2629
|
+
}, { allowMuted: true });
|
|
2630
|
+
}
|
|
2631
|
+
}));
|
|
2632
|
+
ctx.tools.register(defineTool({
|
|
2633
|
+
name: "roundtable_start_review",
|
|
2634
|
+
description: "Start a 针锋相对 (adversarial) review pass of a settled plan: records the user's original question and the captain's plan into review.json, so the Web review window can show them. First pass = reviewPass 1. After a previous pass was finished (status done), calling this again starts the next review pass (reviewPass + 1, closed loop 闭环复审) and preserves the previous pass in history; the captain may open at most 2 re-review passes beyond the first (maxReviewPass 3). To go beyond the cap, the user must have explicitly approved: pass user_approved_extra_pass=true. Requires the captain.",
|
|
2635
|
+
parameters: {
|
|
2636
|
+
question: {
|
|
2637
|
+
type: "string",
|
|
2638
|
+
required: true,
|
|
2639
|
+
description: "The user's original question / topic."
|
|
2640
|
+
},
|
|
2641
|
+
plan: {
|
|
2642
|
+
type: "string",
|
|
2643
|
+
required: true,
|
|
2644
|
+
description: "The settled plan and its explanation (the object under review)."
|
|
2645
|
+
},
|
|
2646
|
+
user_approved_extra_pass: {
|
|
2647
|
+
type: "boolean",
|
|
2648
|
+
description: "Set true ONLY when the user explicitly approved exceeding the re-review cap (maxReviewPass). Never set on your own."
|
|
2649
|
+
}
|
|
2650
|
+
},
|
|
2651
|
+
output: {
|
|
2652
|
+
schema: {
|
|
2653
|
+
type: "object",
|
|
2654
|
+
additionalProperties: false,
|
|
2655
|
+
properties: {
|
|
2656
|
+
review_id: {
|
|
2657
|
+
type: "string",
|
|
2658
|
+
required: true
|
|
2659
|
+
},
|
|
2660
|
+
status: {
|
|
2661
|
+
type: "string",
|
|
2662
|
+
required: true
|
|
2663
|
+
},
|
|
2664
|
+
review_pass: {
|
|
2665
|
+
type: "integer",
|
|
2666
|
+
required: true
|
|
2667
|
+
},
|
|
2668
|
+
max_review_pass: {
|
|
2669
|
+
type: "integer",
|
|
2670
|
+
required: true
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
},
|
|
2674
|
+
render: (_args, value) => [{
|
|
2675
|
+
type: "text",
|
|
2676
|
+
text: `Review started (id ${value.review_id}, status ${value.status}, review pass ${value.review_pass}/${value.max_review_pass}). Tell the experts to attack ONLY the plan (red-team).`
|
|
2677
|
+
}]
|
|
2678
|
+
},
|
|
2679
|
+
async execute(args, exec) {
|
|
2680
|
+
const captain = requireCaptain(exec);
|
|
2681
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
2682
|
+
const located = await locateCaptainMeeting(stateRoot, captain.id);
|
|
2683
|
+
const question = String(args.question ?? "").trim();
|
|
2684
|
+
const plan = String(args.plan ?? "").trim();
|
|
2685
|
+
if (question === "" || plan === "") throw new Error("question and plan must not be empty");
|
|
2686
|
+
return withCaptainLock(stateRoot, located.id, captain.id, "start a review", async (fresh) => {
|
|
2687
|
+
const existing = await readReview(stateRoot, fresh.id);
|
|
2688
|
+
if (existing !== void 0 && existing.status !== "done") throw new Error(`a review is already in progress (status ${existing.status}) — finish it before starting a new pass`);
|
|
2689
|
+
const reviewPass = (existing?.status === "done" && typeof existing.reviewPass === "number" ? existing.reviewPass : 0) + 1;
|
|
2690
|
+
const maxReviewPass = existing?.maxReviewPass ?? 3;
|
|
2691
|
+
if (reviewPass > maxReviewPass && args.user_approved_extra_pass !== true) throw new Error(`re-review cap reached (pass ${reviewPass} > max ${maxReviewPass}) — ask the user to approve continuing, then retry with user_approved_extra_pass=true, or export and finish the review`);
|
|
2692
|
+
const now = Date.now();
|
|
2693
|
+
const review = {
|
|
2694
|
+
meetingId: fresh.id,
|
|
2695
|
+
question,
|
|
2696
|
+
plan,
|
|
2697
|
+
status: "reviewing",
|
|
2698
|
+
reviewPass,
|
|
2699
|
+
maxReviewPass,
|
|
2700
|
+
schemaVersion: 2,
|
|
2701
|
+
viewpoints: [],
|
|
2702
|
+
startedAt: now,
|
|
2703
|
+
updatedAt: now,
|
|
2704
|
+
history: existing?.status === "done" ? [...existing.history ?? [], {
|
|
2705
|
+
pass: existing.reviewPass,
|
|
2706
|
+
question: existing.question,
|
|
2707
|
+
plan: existing.plan,
|
|
2708
|
+
viewpoints: existing.viewpoints,
|
|
2709
|
+
finishedAt: existing.finishedAt ?? now
|
|
2710
|
+
}] : []
|
|
2711
|
+
};
|
|
2712
|
+
await writeReview(stateRoot, fresh.id, review);
|
|
2713
|
+
return {
|
|
2714
|
+
review_id: fresh.id,
|
|
2715
|
+
status: review.status,
|
|
2716
|
+
review_pass: review.reviewPass,
|
|
2717
|
+
max_review_pass: review.maxReviewPass
|
|
2718
|
+
};
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
}));
|
|
2722
|
+
ctx.tools.register(defineTool({
|
|
2723
|
+
name: "roundtable_collect_review",
|
|
2724
|
+
description: "Collect the red-team experts' objections from the transcript into review.json (viewpoints), then the Web review window becomes ready. Call this after the experts have spoken (roundtable_speak). Idempotent: already-collected utterances are skipped. Requires the captain.",
|
|
2725
|
+
parameters: {},
|
|
2726
|
+
output: {
|
|
2727
|
+
schema: {
|
|
2728
|
+
type: "object",
|
|
2729
|
+
additionalProperties: false,
|
|
2730
|
+
properties: {
|
|
2731
|
+
collected: {
|
|
2732
|
+
type: "integer",
|
|
2733
|
+
required: true
|
|
2734
|
+
},
|
|
2735
|
+
split_attempts: {
|
|
2736
|
+
type: "integer",
|
|
2737
|
+
required: true
|
|
2738
|
+
},
|
|
2739
|
+
status: {
|
|
2740
|
+
type: "string",
|
|
2741
|
+
required: true
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
},
|
|
2745
|
+
render: (_args, value) => [{
|
|
2746
|
+
type: "text",
|
|
2747
|
+
text: `Review collected: ${value.collected} viewpoint(s) (${value.split_attempts} split attempt(s)), status ${value.status}.`
|
|
2748
|
+
}]
|
|
2749
|
+
},
|
|
2750
|
+
async execute(_args, exec) {
|
|
2751
|
+
const captain = requireCaptain(exec);
|
|
2752
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
2753
|
+
return withCaptainLock(stateRoot, (await locateCaptainMeeting(stateRoot, captain.id)).id, captain.id, "collect review", async (fresh) => {
|
|
2754
|
+
const review = await readReview(stateRoot, fresh.id);
|
|
2755
|
+
if (review === void 0) throw new Error("no review in progress — call roundtable_start_review first");
|
|
2756
|
+
const collectedUtteranceIds = new Set(review.viewpoints.map((viewpoint) => viewpoint.utteranceId));
|
|
2757
|
+
const utterances = await readTranscript(stateRoot, fresh.id);
|
|
2758
|
+
const llm = ctx.get("llm");
|
|
2759
|
+
const splitConfig = config.reviewSplit;
|
|
2760
|
+
let collected = 0;
|
|
2761
|
+
let splitAttempts = 0;
|
|
2762
|
+
for (const utterance of utterances) {
|
|
2763
|
+
if (utterance.nodeKey === CAPTAIN_KEY) continue;
|
|
2764
|
+
if (utterance.kind !== "speech" && utterance.kind !== "proxy-thinking") continue;
|
|
2765
|
+
if (utterance.ts < review.startedAt) continue;
|
|
2766
|
+
if (collectedUtteranceIds.has(utterance.id)) continue;
|
|
2767
|
+
const content = utterance.content.replace(/\s+/g, " ").trim();
|
|
2768
|
+
if (content === "") continue;
|
|
2769
|
+
let lines = null;
|
|
2770
|
+
if (llm !== void 0 && splitConfig !== void 0) try {
|
|
2771
|
+
lines = await splitUtterance(llm, splitConfig, utterance.nodeKey, content);
|
|
2772
|
+
splitAttempts += 1;
|
|
2773
|
+
} catch {
|
|
2774
|
+
lines = null;
|
|
2775
|
+
}
|
|
2776
|
+
if (lines === null) try {
|
|
2777
|
+
lines = splitByMarkers(content);
|
|
2778
|
+
} catch {
|
|
2779
|
+
lines = null;
|
|
2780
|
+
}
|
|
2781
|
+
if (lines !== null && lines.length > 0) lines.forEach((line$1, index) => {
|
|
2782
|
+
review.viewpoints.push({
|
|
2783
|
+
id: `${utterance.id}#${index + 1}`,
|
|
2784
|
+
utteranceId: utterance.id,
|
|
2785
|
+
nodeKey: utterance.nodeKey,
|
|
2786
|
+
content: line$1.content,
|
|
2787
|
+
status: "pending",
|
|
2788
|
+
quote: line$1.quote,
|
|
2789
|
+
dimension: line$1.dimension,
|
|
2790
|
+
evidence: line$1.evidence,
|
|
2791
|
+
ts: utterance.ts,
|
|
2792
|
+
seq: index + 1
|
|
2793
|
+
});
|
|
2794
|
+
collected += 1;
|
|
2795
|
+
});
|
|
2796
|
+
else {
|
|
2797
|
+
review.viewpoints.push({
|
|
2798
|
+
id: `${utterance.id}#0`,
|
|
2799
|
+
utteranceId: utterance.id,
|
|
2800
|
+
nodeKey: utterance.nodeKey,
|
|
2801
|
+
content,
|
|
2802
|
+
status: "pending",
|
|
2803
|
+
dimension: "其他",
|
|
2804
|
+
ts: utterance.ts,
|
|
2805
|
+
seq: 0
|
|
2806
|
+
});
|
|
2807
|
+
collected += 1;
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
if (collected > 0) review.status = "ready";
|
|
2811
|
+
await writeReview(stateRoot, fresh.id, review);
|
|
2812
|
+
return {
|
|
2813
|
+
collected,
|
|
2814
|
+
split_attempts: splitAttempts,
|
|
2815
|
+
status: review.status
|
|
2816
|
+
};
|
|
2817
|
+
});
|
|
2818
|
+
}
|
|
2819
|
+
}));
|
|
2820
|
+
ctx.tools.register(defineTool({
|
|
2821
|
+
name: "roundtable_finish_review",
|
|
2822
|
+
description: "Finish the current 针锋相对 review pass: marks it done (closed loop 闭环 can then start the next pass with roundtable_start_review). Records remaining endorsed (已认定) defects as the known-flaws checklist. Call this after the user finished endorsing/rejecting viewpoints and you have revised the plan (or decided to stop). Requires the captain.",
|
|
2823
|
+
parameters: { revised_plan_summary: {
|
|
2824
|
+
type: "string",
|
|
2825
|
+
description: "Optional one-line summary of how the endorsed defects were addressed in the revised plan (写入历史,供下一轮核对)."
|
|
2826
|
+
} },
|
|
2827
|
+
output: {
|
|
2828
|
+
schema: {
|
|
2829
|
+
type: "object",
|
|
2830
|
+
additionalProperties: false,
|
|
2831
|
+
properties: {
|
|
2832
|
+
status: {
|
|
2833
|
+
type: "string",
|
|
2834
|
+
required: true
|
|
2835
|
+
},
|
|
2836
|
+
review_pass: {
|
|
2837
|
+
type: "integer",
|
|
2838
|
+
required: true
|
|
2839
|
+
},
|
|
2840
|
+
endorsed_count: {
|
|
2841
|
+
type: "integer",
|
|
2842
|
+
required: true
|
|
2843
|
+
},
|
|
2844
|
+
can_review_again: {
|
|
2845
|
+
type: "boolean",
|
|
2846
|
+
required: true
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
},
|
|
2850
|
+
render: (_args, value) => [{
|
|
2851
|
+
type: "text",
|
|
2852
|
+
text: `Review pass ${value.review_pass} finished (status ${value.status}, ${value.endorsed_count} endorsed defect(s)). ${value.can_review_again ? "You may start the next re-review pass with roundtable_start_review." : "Re-review cap reached — export the record and present the consolidated result."}`
|
|
2853
|
+
}]
|
|
2854
|
+
},
|
|
2855
|
+
async execute(args, exec) {
|
|
2856
|
+
const captain = requireCaptain(exec);
|
|
2857
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
2858
|
+
return withCaptainLock(stateRoot, (await locateCaptainMeeting(stateRoot, captain.id)).id, captain.id, "finish the review", async (fresh) => {
|
|
2859
|
+
const review = await readReview(stateRoot, fresh.id);
|
|
2860
|
+
if (review === void 0) throw new Error("no review in progress — call roundtable_start_review first");
|
|
2861
|
+
if (review.status === "done") return {
|
|
2862
|
+
status: review.status,
|
|
2863
|
+
review_pass: review.reviewPass,
|
|
2864
|
+
endorsed_count: countEndorsed(review),
|
|
2865
|
+
can_review_again: review.reviewPass < review.maxReviewPass
|
|
2866
|
+
};
|
|
2867
|
+
if (review.status === "reviewing") throw new Error("review is still collecting viewpoints — call roundtable_collect_review before finishing");
|
|
2868
|
+
review.status = "done";
|
|
2869
|
+
review.finishedAt = Date.now();
|
|
2870
|
+
const summary = String(args.revised_plan_summary ?? "").trim();
|
|
2871
|
+
if (summary !== "") {
|
|
2872
|
+
const last = review.history.length > 0 ? review.history[review.history.length - 1] : void 0;
|
|
2873
|
+
if (last !== void 0 && last.pass === review.reviewPass) last.revisedPlanSummary = summary;
|
|
2874
|
+
else review.history.push({
|
|
2875
|
+
pass: review.reviewPass,
|
|
2876
|
+
question: review.question,
|
|
2877
|
+
plan: review.plan,
|
|
2878
|
+
viewpoints: review.viewpoints,
|
|
2879
|
+
finishedAt: Date.now(),
|
|
2880
|
+
revisedPlanSummary: summary
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
await writeReview(stateRoot, fresh.id, review);
|
|
2884
|
+
return {
|
|
2885
|
+
status: review.status,
|
|
2886
|
+
review_pass: review.reviewPass,
|
|
2887
|
+
endorsed_count: countEndorsed(review),
|
|
2888
|
+
can_review_again: review.reviewPass < review.maxReviewPass
|
|
2889
|
+
};
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
}));
|
|
2893
|
+
ctx.tools.register(defineTool({
|
|
2894
|
+
name: "roundtable_export_review",
|
|
2895
|
+
description: "Export the full 针锋相对 review record (all passes, viewpoints, endorsements, reject reasons, revision summaries) as a Markdown deliverable with a prefilled issue header (plugin version, mode, expert providers/models) — ready to paste into a GitHub issue or keep as a review record. Requires the captain.",
|
|
2896
|
+
parameters: {},
|
|
2897
|
+
output: {
|
|
2898
|
+
schema: {
|
|
2899
|
+
type: "object",
|
|
2900
|
+
additionalProperties: false,
|
|
2901
|
+
properties: { markdown: {
|
|
2902
|
+
type: "string",
|
|
2903
|
+
required: true
|
|
2904
|
+
} }
|
|
2905
|
+
},
|
|
2906
|
+
render: (_args, value) => [{
|
|
2907
|
+
type: "text",
|
|
2908
|
+
text: value.markdown
|
|
2909
|
+
}]
|
|
2910
|
+
},
|
|
2911
|
+
async execute(_args, exec) {
|
|
2912
|
+
const captain = requireCaptain(exec);
|
|
2913
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
2914
|
+
return withCaptainLock(stateRoot, (await locateCaptainMeeting(stateRoot, captain.id)).id, captain.id, "export the review", async (fresh) => {
|
|
2915
|
+
const review = await readReview(stateRoot, fresh.id);
|
|
2916
|
+
if (review === void 0) throw new Error("no review record yet — call roundtable_start_review first");
|
|
2917
|
+
return { markdown: renderReviewMarkdown(review, fresh) };
|
|
2918
|
+
}, { allowMuted: true });
|
|
2919
|
+
}
|
|
2920
|
+
}));
|
|
2921
|
+
ctx.tools.register(defineTool({
|
|
2922
|
+
name: "roundtable_export_meeting",
|
|
2923
|
+
description: "Export the WHOLE meeting — issue-style header (plugin version, mode, time span, budget), goal, expert roster, the decision log, every round of the transcript with speaker → audience, the review record when one exists, and the user adjustment log — as a Markdown deliverable. Returns the Markdown AND writes it to <meetingDir>/export.md so the user can open the file directly. It works on an in-progress meeting too (the header then marks it as a snapshot). Requires the captain.",
|
|
2924
|
+
parameters: { save: {
|
|
2925
|
+
type: "boolean",
|
|
2926
|
+
description: "Also write the Markdown to <meetingDir>/export.md (default true). Pass false to only return the text."
|
|
2927
|
+
} },
|
|
2928
|
+
output: {
|
|
2929
|
+
schema: {
|
|
2930
|
+
type: "object",
|
|
2931
|
+
additionalProperties: false,
|
|
2932
|
+
properties: {
|
|
2933
|
+
markdown: {
|
|
2934
|
+
type: "string",
|
|
2935
|
+
required: true
|
|
2936
|
+
},
|
|
2937
|
+
saved_to: {
|
|
2938
|
+
type: "string",
|
|
2939
|
+
required: true
|
|
2940
|
+
},
|
|
2941
|
+
bytes: {
|
|
2942
|
+
type: "integer",
|
|
2943
|
+
required: true
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
},
|
|
2947
|
+
render: (_args, value) => [{
|
|
2948
|
+
type: "text",
|
|
2949
|
+
text: value.saved_to === "" ? value.markdown : `${value.markdown}\n\n---\n(已同时写入 ${value.saved_to},${value.bytes} 字节)`
|
|
2950
|
+
}]
|
|
2951
|
+
},
|
|
2952
|
+
async execute(args, exec) {
|
|
2953
|
+
const captain = requireCaptain(exec);
|
|
2954
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
2955
|
+
return withCaptainLock(stateRoot, (await locateCaptainMeeting(stateRoot, captain.id)).id, captain.id, "export the meeting", async (fresh) => {
|
|
2956
|
+
const utterances = await readTranscript(stateRoot, fresh.id);
|
|
2957
|
+
const { actions } = await readUserActionsReport(stateRoot, fresh.id);
|
|
2958
|
+
const markdown = renderMeetingMarkdown(fresh, utterances, actions, await readReview(stateRoot, fresh.id));
|
|
2959
|
+
const bytes = Buffer.byteLength(markdown, "utf8");
|
|
2960
|
+
if (args.save === false) return {
|
|
2961
|
+
markdown,
|
|
2962
|
+
saved_to: "",
|
|
2963
|
+
bytes
|
|
2964
|
+
};
|
|
2965
|
+
const file = join(meetingDirOf(stateRoot, fresh.id), "export.md");
|
|
2966
|
+
await writeTextAtomic(file, markdown);
|
|
2967
|
+
return {
|
|
2968
|
+
markdown,
|
|
2969
|
+
saved_to: file,
|
|
2970
|
+
bytes
|
|
2971
|
+
};
|
|
2972
|
+
}, { allowMuted: true });
|
|
2973
|
+
}
|
|
2974
|
+
}));
|
|
2975
|
+
ctx.tools.register(defineTool({
|
|
2976
|
+
name: "roundtable_set_budget",
|
|
2977
|
+
description: "Adjust the meeting budget. Top up max_rounds/max_tokens to unmute (闭麦后恢复) a muted meeting, or tighten them. Requires the captain.",
|
|
2978
|
+
parameters: {
|
|
2979
|
+
max_rounds: {
|
|
2980
|
+
type: "integer",
|
|
2981
|
+
description: "New round cap."
|
|
2982
|
+
},
|
|
2983
|
+
max_tokens: {
|
|
2984
|
+
type: "integer",
|
|
2985
|
+
description: "New total token budget."
|
|
2986
|
+
}
|
|
2987
|
+
},
|
|
2988
|
+
output: {
|
|
2989
|
+
schema: {
|
|
2990
|
+
type: "object",
|
|
2991
|
+
additionalProperties: false,
|
|
2992
|
+
properties: {
|
|
2993
|
+
status: {
|
|
2994
|
+
type: "string",
|
|
2995
|
+
required: true
|
|
2996
|
+
},
|
|
2997
|
+
max_rounds: {
|
|
2998
|
+
type: "integer",
|
|
2999
|
+
required: true
|
|
3000
|
+
},
|
|
3001
|
+
max_tokens: {
|
|
3002
|
+
type: "integer",
|
|
3003
|
+
required: true
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
},
|
|
3007
|
+
render: (_args, value) => [{
|
|
3008
|
+
type: "text",
|
|
3009
|
+
text: `Budget updated: ${value.max_rounds} rounds / ${value.max_tokens} tokens (meeting status ${value.status}).`
|
|
3010
|
+
}]
|
|
3011
|
+
},
|
|
3012
|
+
async execute(args, exec) {
|
|
3013
|
+
const captain = requireCaptain(exec);
|
|
3014
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
3015
|
+
return withCaptainLock(stateRoot, (await locateCaptainMeeting(stateRoot, captain.id)).id, captain.id, "change the budget", async (fresh) => {
|
|
3016
|
+
if (typeof args.max_rounds === "number") fresh.budget.maxRounds = Math.floor(args.max_rounds);
|
|
3017
|
+
if (typeof args.max_tokens === "number") fresh.budget.maxTokens = Math.floor(args.max_tokens);
|
|
3018
|
+
if (fresh.status === "muted" && budgetExceeded(fresh) === void 0) fresh.status = "active";
|
|
3019
|
+
await writeMeeting(stateRoot, fresh);
|
|
3020
|
+
return {
|
|
3021
|
+
status: fresh.status,
|
|
3022
|
+
max_rounds: fresh.budget.maxRounds,
|
|
3023
|
+
max_tokens: fresh.budget.maxTokens
|
|
3024
|
+
};
|
|
3025
|
+
}, { allowMuted: true });
|
|
3026
|
+
}
|
|
3027
|
+
}));
|
|
3028
|
+
ctx.tools.register(defineTool({
|
|
3029
|
+
name: "roundtable_close",
|
|
3030
|
+
description: "End your meeting: interrupts all nodes (best effort) and marks the meeting ended. The record stays on disk for review.",
|
|
3031
|
+
parameters: {},
|
|
3032
|
+
output: {
|
|
3033
|
+
schema: {
|
|
3034
|
+
type: "object",
|
|
3035
|
+
additionalProperties: false,
|
|
3036
|
+
properties: {
|
|
3037
|
+
closed: {
|
|
3038
|
+
type: "boolean",
|
|
3039
|
+
required: true
|
|
3040
|
+
},
|
|
3041
|
+
meeting_name: {
|
|
3042
|
+
type: "string",
|
|
3043
|
+
required: true
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
},
|
|
3047
|
+
render: (_args, value) => [{
|
|
3048
|
+
type: "text",
|
|
3049
|
+
text: `Meeting "${value.meeting_name}" closed.`
|
|
3050
|
+
}]
|
|
3051
|
+
},
|
|
3052
|
+
async execute(_args, exec) {
|
|
3053
|
+
const captain = requireCaptain(exec);
|
|
3054
|
+
const stateRoot = stateRootOf(workspaceOf(captain), config.stateDir);
|
|
3055
|
+
const located = await locateCaptainMeeting(stateRoot, captain.id);
|
|
3056
|
+
const nodes = await withCaptainLock(stateRoot, located.id, captain.id, "close it", async (fresh) => {
|
|
3057
|
+
const roster = fresh.nodes.map((node) => ({ ...node }));
|
|
3058
|
+
for (const node of fresh.nodes) if (node.status !== "removed") node.status = "removed";
|
|
3059
|
+
fresh.status = "ended";
|
|
3060
|
+
await writeMeeting(stateRoot, fresh);
|
|
3061
|
+
return roster;
|
|
3062
|
+
}, { allowMuted: true });
|
|
3063
|
+
for (const node of nodes) if (node.id !== "") interruptNode(ctx, captain, node.id);
|
|
3064
|
+
return {
|
|
3065
|
+
closed: true,
|
|
3066
|
+
meeting_name: located.name
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
}));
|
|
3070
|
+
ctx.tools.register(defineTool({
|
|
3071
|
+
name: "roundtable_proxy_think",
|
|
3072
|
+
description: "Proxy Thinking (代理思考): prepare to hand a task to a black-box worker model (no visible reasoning). Returns the director-side template you must fill in: write [DeepSeek 代理思考] reasoning, translate the task into exact worker parameters, and state expectations/fallbacks. Then deliver the translated parameters to the worker model.",
|
|
3073
|
+
parameters: {
|
|
3074
|
+
worker_model: {
|
|
3075
|
+
type: "string",
|
|
3076
|
+
required: true,
|
|
3077
|
+
description: "The black-box worker model (e.g. seedance, an image model)."
|
|
3078
|
+
},
|
|
3079
|
+
task: {
|
|
3080
|
+
type: "string",
|
|
3081
|
+
required: true,
|
|
3082
|
+
description: "The goal to translate for the worker model."
|
|
3083
|
+
}
|
|
3084
|
+
},
|
|
3085
|
+
output: {
|
|
3086
|
+
schema: {
|
|
3087
|
+
type: "object",
|
|
3088
|
+
additionalProperties: false,
|
|
3089
|
+
properties: { template: {
|
|
3090
|
+
type: "string",
|
|
3091
|
+
required: true
|
|
3092
|
+
} }
|
|
3093
|
+
},
|
|
3094
|
+
render: (_args, value) => [{
|
|
3095
|
+
type: "text",
|
|
3096
|
+
text: value.template
|
|
3097
|
+
}]
|
|
3098
|
+
},
|
|
3099
|
+
async execute(args, exec) {
|
|
3100
|
+
const caller = requireCaptain(exec);
|
|
3101
|
+
const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
|
|
3102
|
+
const located = await findMeetingByParticipant(stateRoot, caller.id);
|
|
3103
|
+
const workerModel = String(args.worker_model ?? "").trim();
|
|
3104
|
+
const task = String(args.task ?? "").trim();
|
|
3105
|
+
if (workerModel === "" || task === "") throw new Error("worker_model and task must not be empty");
|
|
3106
|
+
if (located !== void 0) await withMeetingLock(meetingLockKey(stateRoot, located.id), async () => {
|
|
3107
|
+
const { meeting, identity } = await requireFreshParticipant(stateRoot, located.id, caller.id);
|
|
3108
|
+
await recordUtterance(stateRoot, meeting, {
|
|
3109
|
+
nodeKey: identity.kind === "captain" ? CAPTAIN_KEY : identity.name,
|
|
3110
|
+
kind: "proxy-thinking",
|
|
3111
|
+
content: `[代理思考 → ${workerModel}] ${task}`
|
|
3112
|
+
});
|
|
3113
|
+
});
|
|
3114
|
+
return { template: proxyThinkingPrompt(workerModel, task) };
|
|
3115
|
+
}
|
|
3116
|
+
}));
|
|
3117
|
+
}
|
|
3118
|
+
/** Render the status snapshot as compact text for the model. */
|
|
3119
|
+
/** 知识库摘要缓存概览(C2)。
|
|
3120
|
+
*
|
|
3121
|
+
* 每条都用磁盘现状标注 valid:主持人据此决定"直接用摘要"还是"重读文件",
|
|
3122
|
+
* 不必自己猜 mtime。条目上限 50、每条摘要上限 2000 字符(见 kb-digest.ts)。 */
|
|
3123
|
+
async function kbDigestOverview(stateRoot, meetingId) {
|
|
3124
|
+
const evaluated = await evaluateKbDigests((await readKbDigest(stateRoot, meetingId)).entries);
|
|
3125
|
+
return {
|
|
3126
|
+
count: evaluated.length,
|
|
3127
|
+
valid_count: evaluated.filter((entry) => entry.valid).length,
|
|
3128
|
+
entries: evaluated.map((entry) => ({
|
|
3129
|
+
path: entry.path,
|
|
3130
|
+
size: entry.size,
|
|
3131
|
+
mtime_ms: Math.round(entry.mtimeMs),
|
|
3132
|
+
digest: entry.digest,
|
|
3133
|
+
valid: entry.valid,
|
|
3134
|
+
updated_at: new Date(entry.ts).toISOString()
|
|
3135
|
+
}))
|
|
3136
|
+
};
|
|
3137
|
+
}
|
|
3138
|
+
/** 状态行里最多列几条缓存、每条摘要最多显示多少字符(避免状态输出失控)。 */
|
|
3139
|
+
const KB_DIGEST_RENDER_LIMIT = 10;
|
|
3140
|
+
const KB_DIGEST_RENDER_CHARS = 800;
|
|
3141
|
+
/** KB 摘要的默认呈现宽度(只印首行):每次轮询都重印整份缓存是最容易被忽视
|
|
3142
|
+
* 的重复成本 —— 正文只在主持人真要照它干活时才用 kb_full=true 取。 */
|
|
3143
|
+
const KB_DIGEST_PREVIEW_CHARS = 120;
|
|
3144
|
+
/** 无 since 时返回的尾部条数(与原行为一致)。 */
|
|
3145
|
+
const STATUS_TAIL_DEFAULT = 10;
|
|
3146
|
+
/** 单次最多返回条数;超出部分留给下一次 since 调用,绝不跳过任何一条。 */
|
|
3147
|
+
const STATUS_TAIL_MAX = 40;
|
|
3148
|
+
/** status 里每条发言的字符上限(已先用 extractCore 取出核心产出)。 */
|
|
3149
|
+
const STATUS_LINE_CHARS = 240;
|
|
3150
|
+
/** 取摘要的第一行作为预览(跳过空行)。 */
|
|
3151
|
+
function digestFirstLine(digest, limit) {
|
|
3152
|
+
const line$1 = digest.split("\n").map((part) => part.trim()).find((part) => part !== "") ?? "";
|
|
3153
|
+
return line$1.length > limit ? `${line$1.slice(0, limit)}…` : line$1;
|
|
3154
|
+
}
|
|
3155
|
+
function renderStatus(value) {
|
|
3156
|
+
const nodes = Array.isArray(value.nodes) ? value.nodes : [];
|
|
3157
|
+
const edges = Array.isArray(value.edges) ? value.edges : [];
|
|
3158
|
+
const budget = value.budget ?? {};
|
|
3159
|
+
const pending = Array.isArray(value.pending_decisions) ? value.pending_decisions : [];
|
|
3160
|
+
const pendingActions = Array.isArray(value.pending_actions) ? value.pending_actions : [];
|
|
3161
|
+
const recent = Array.isArray(value.recent_utterances) ? value.recent_utterances : [];
|
|
3162
|
+
const kbDigest = value.kb_digest ?? {};
|
|
3163
|
+
const real = value.tokens_real ?? {};
|
|
3164
|
+
const realMeasured = Number(real.measured_sessions ?? 0);
|
|
3165
|
+
const realMissing = Number(real.unmeasured_sessions ?? 0);
|
|
3166
|
+
const realLine = realMeasured === 0 ? "Real token usage: not measurable right now (provider-reported usage requires a live child session)" : `Real token usage (provider-reported): ${String(real.measured_total ?? 0)} tokens across ${realMeasured} expert session(s)${realMissing > 0 ? `; ${realMissing} not measurable` : ""}`;
|
|
3167
|
+
const kbEntries = Array.isArray(kbDigest.entries) ? kbDigest.entries : [];
|
|
3168
|
+
const kbListed = kbEntries.slice(0, KB_DIGEST_RENDER_LIMIT);
|
|
3169
|
+
const kbFull = value.kb_full === true;
|
|
3170
|
+
const nextSince = Number(value.next_since ?? 0);
|
|
3171
|
+
const hasMore = value.has_more === true;
|
|
3172
|
+
return [
|
|
3173
|
+
`Meeting "${String(value.meeting_name)}" (id ${String(value.meeting_id)}, mode ${String(value.mode)}, status ${String(value.status)}, round ${String(value.round)})`,
|
|
3174
|
+
`Knowledge base path: ${String(value.kb_path ?? "") === "" ? "(none)" : String(value.kb_path)}`,
|
|
3175
|
+
`Budget: ${String(budget.used_rounds)}/${String(budget.max_rounds)} rounds, ${String(budget.used_tokens)}/${String(budget.max_tokens)} tokens (spoken-text estimate only — NOT the real LLM spend)`,
|
|
3176
|
+
realLine,
|
|
3177
|
+
`Nodes (${nodes.length}):`,
|
|
3178
|
+
...nodes.map((node) => {
|
|
3179
|
+
const effort = String(node.reasoning_effort ?? "");
|
|
3180
|
+
const activity = String(node.activity ?? "");
|
|
3181
|
+
const base = ` - ${String(node.key)} [${String(node.role ?? "")}] ${String(node.status)}/${activity} · ${String(node.provider ?? "")}/${String(node.model ?? "")}${effort === "" ? "" : ` @${effort}`}`;
|
|
3182
|
+
const orphan = activity === "missing" ? "\n ⚠ 宿主已不认识该专家的子代理(消息会投进虚空,建议重加)" : "";
|
|
3183
|
+
const error = String(node.last_error ?? "");
|
|
3184
|
+
return `${base}${orphan}${error === "" ? "" : `\n ⚠ ${error}`}`;
|
|
3185
|
+
}),
|
|
3186
|
+
`Edges (${edges.length}):`,
|
|
3187
|
+
...edges.map((edge) => ` - ${String(edge.from)} → ${String(edge.to)} (${String(edge.direction)})`),
|
|
3188
|
+
`Pending decisions: ${pending.length === 0 ? "none" : pending.map((decision) => `"${String(decision.question)}"`).join("; ")}`,
|
|
3189
|
+
`Pending user actions (${pendingActions.length}): ${pendingActions.length === 0 ? "none" : pendingActions.map((action) => `"${String(action.text)}"`).join("; ")}`,
|
|
3190
|
+
`KB digest cache: ${String(kbDigest.count ?? 0)} entry(ies), ${String(kbDigest.valid_count ?? 0)} still valid (HIT = reuse the digest; STALE = the file changed, read it again):`,
|
|
3191
|
+
...kbListed.map((entry) => {
|
|
3192
|
+
const digest = String(entry.digest ?? "");
|
|
3193
|
+
const shown = kbFull ? digest.length > KB_DIGEST_RENDER_CHARS ? `${digest.slice(0, KB_DIGEST_RENDER_CHARS)}…` : digest : digestFirstLine(digest, KB_DIGEST_PREVIEW_CHARS);
|
|
3194
|
+
return ` - [${entry.valid === true ? "HIT" : "STALE"}] ${String(entry.path)}: ${shown}`;
|
|
3195
|
+
}),
|
|
3196
|
+
...kbEntries.length > kbListed.length ? [` - …and ${kbEntries.length - kbListed.length} more cached entr(ies)`] : [],
|
|
3197
|
+
...!kbFull && kbListed.length > 0 ? [" (pass kb_full=true only if you actually need these digests in full)"] : [],
|
|
3198
|
+
`Transcript${hasMore ? " (more new lines remain — poll again with the cursor below)" : ""}:`,
|
|
3199
|
+
...recent.map((utterance) => {
|
|
3200
|
+
const kind = String(utterance.kind ?? "");
|
|
3201
|
+
const tag = kind === "" || kind === "speech" ? "" : ` {${kind}}`;
|
|
3202
|
+
return ` [R${String(utterance.round)}] ${String(utterance.speaker)}${tag}${String(utterance.to ?? "") === "" ? "" : ` → ${String(utterance.to)}`}: ${String(utterance.content)}`;
|
|
3203
|
+
}),
|
|
3204
|
+
`Cursor: pass since=${nextSince} on the next poll to receive only new lines.`
|
|
3205
|
+
].join("\n");
|
|
3206
|
+
}
|
|
3207
|
+
/** Count endorsed (已认定) viewpoints in the current pass. */
|
|
3208
|
+
function countEndorsed(review) {
|
|
3209
|
+
return review.viewpoints.filter((viewpoint) => viewpoint.status === "endorsed").length;
|
|
3210
|
+
}
|
|
3211
|
+
/** One-line viewpoint marker inside the exported markdown. */
|
|
3212
|
+
function viewpointStatusLabel(status) {
|
|
3213
|
+
if (status === "endorsed") return "✅ 已认定";
|
|
3214
|
+
if (status === "rejected") return "❌ 已驳回";
|
|
3215
|
+
return "⬜ 未表态";
|
|
3216
|
+
}
|
|
3217
|
+
/** Evidence line inside the exported markdown. */
|
|
3218
|
+
function evidenceLine(evidence) {
|
|
3219
|
+
if (evidence === void 0) return "";
|
|
3220
|
+
return `\n - **${evidence.kind === "repro" ? "证据(可复现步骤)" : "证据(论证链)"}**:${evidence.text}`;
|
|
3221
|
+
}
|
|
3222
|
+
/** Render the full review record as a Markdown deliverable (C5/E2).
|
|
3223
|
+
* The header carries prefilled issue metadata: plugin version, meeting mode,
|
|
3224
|
+
* expert providers/models and budget usage — ready to paste into a
|
|
3225
|
+
* GitHub issue. */
|
|
3226
|
+
function renderReviewMarkdown(review, meeting) {
|
|
3227
|
+
const out = [];
|
|
3228
|
+
out.push("# 针锋相对评审记录", "");
|
|
3229
|
+
out.push("---");
|
|
3230
|
+
out.push("<!-- 以下为预填的 issue 元数据,可整段作为 GitHub issue 模板 -->");
|
|
3231
|
+
out.push(`- **插件**:${PLUGIN_ID}(${HARNESS_RANGE})`);
|
|
3232
|
+
out.push(`- **协作模式**:${meeting.mode}`);
|
|
3233
|
+
const expertRoutes = meeting.nodes.filter((node) => node.status !== "removed").map((node) => `${node.key}(${node.provider ?? "-"}/${node.model ?? "-"})`);
|
|
3234
|
+
out.push(`- **专家**:${expertRoutes.length === 0 ? "-" : expertRoutes.join("、")}`);
|
|
3235
|
+
out.push(`- **预算用量**:${meeting.budget.usedRounds}/${meeting.budget.maxRounds} 轮 · ${meeting.budget.usedTokens}/${meeting.budget.maxTokens} token`);
|
|
3236
|
+
out.push(`- **真实用量(provider 上报)**:${meeting.budget.usedTokensReal ?? 0} token —— 上面的 token 数是"发言文本粗估",不是真实账单`);
|
|
3237
|
+
out.push("---", "");
|
|
3238
|
+
out.push(renderReviewBody(review));
|
|
3239
|
+
return out.join("\n");
|
|
3240
|
+
}
|
|
3241
|
+
/** 评审主体(不含 issue 头部):整场会议导出复用它,避免头部重复。 */
|
|
3242
|
+
function renderReviewBody(review) {
|
|
3243
|
+
const out = [];
|
|
3244
|
+
out.push(`## 评审轮次 ${review.reviewPass}/${review.maxReviewPass} · ${review.status === "done" ? "已完成" : review.status === "ready" ? "待表态" : "收集中"}`);
|
|
3245
|
+
out.push("", "## 原始问题", "", review.question, "", "## 本轮方案(待攻击对象)", "", review.plan, "");
|
|
3246
|
+
const endorsed = review.viewpoints.filter((viewpoint) => viewpoint.status === "endorsed");
|
|
3247
|
+
const rejected = review.viewpoints.filter((viewpoint) => viewpoint.status === "rejected");
|
|
3248
|
+
const pending = review.viewpoints.filter((viewpoint) => viewpoint.status === "pending");
|
|
3249
|
+
out.push(`## 本轮观点(${review.viewpoints.length} 条:已认定 ${endorsed.length} / 已驳回 ${rejected.length} / 未表态 ${pending.length})`, "");
|
|
3250
|
+
if (review.viewpoints.length === 0) out.push("(暂无观点)");
|
|
3251
|
+
for (const viewpoint of review.viewpoints) {
|
|
3252
|
+
out.push(`- **[${viewpointStatusLabel(viewpoint.status)}]** 观点 ${viewpoint.seq}(${viewpoint.dimension},来自 ${viewpoint.nodeKey})`, "", ` ${viewpoint.content}`, evidenceLine(viewpoint.evidence));
|
|
3253
|
+
if (viewpoint.quote !== void 0) out.push(`\n > 原文引用:${viewpoint.quote}`);
|
|
3254
|
+
if (viewpoint.status === "rejected" && viewpoint.rejectReason !== void 0) out.push(`\n - **驳回理由**:${viewpoint.rejectReason}`);
|
|
3255
|
+
out.push("");
|
|
3256
|
+
}
|
|
3257
|
+
if (review.history !== void 0 && review.history.length > 0) {
|
|
3258
|
+
out.push("---", "", "## 历史轮次(闭环复审对照)", "");
|
|
3259
|
+
for (const pass of review.history) {
|
|
3260
|
+
out.push(`### 第 ${pass.pass} 轮`, "", `- 问题:${pass.question}`, `- 方案:${pass.plan}`);
|
|
3261
|
+
if (pass.revisedPlanSummary !== void 0) out.push(`- 修订说明:${pass.revisedPlanSummary}`);
|
|
3262
|
+
const endorsedInPass = pass.viewpoints.filter((viewpoint) => viewpoint.status === "endorsed");
|
|
3263
|
+
if (endorsedInPass.length > 0) {
|
|
3264
|
+
out.push("- 该轮已认定缺陷:");
|
|
3265
|
+
for (const viewpoint of endorsedInPass) out.push(` - ${viewpoint.content}`);
|
|
3266
|
+
}
|
|
3267
|
+
out.push("");
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
return out.join("\n");
|
|
3271
|
+
}
|
|
3272
|
+
/** 单条发言在导出里的字符上限(超出截断并标注,避免导出物失控)。 */
|
|
3273
|
+
const EXPORT_UTTERANCE_CHARS = 4e3;
|
|
3274
|
+
/** 本地时区的可读时间戳(导出物是给人看的,不用 UTC)。 */
|
|
3275
|
+
function formatStamp(ts) {
|
|
3276
|
+
const date = new Date(ts);
|
|
3277
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
3278
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
3279
|
+
}
|
|
3280
|
+
/**
|
|
3281
|
+
* 把一场会议整体渲染成 Markdown 交付物(B6)。
|
|
3282
|
+
*
|
|
3283
|
+
* 只读既有磁盘状态(`meeting.json` / `transcript.jsonl` / `review.json` /
|
|
3284
|
+
* `user-actions.jsonl`),**不新增任何状态**;会议进行中也能导出,此时头部
|
|
3285
|
+
* 会标注"进行中快照",避免把半场会议误当成结论。
|
|
3286
|
+
*/
|
|
3287
|
+
function renderMeetingMarkdown(meeting, utterances, userActions, review) {
|
|
3288
|
+
const out = [];
|
|
3289
|
+
const running = meeting.status !== "ended";
|
|
3290
|
+
out.push(`# 圆桌会议记录 · ${meeting.name}`, "");
|
|
3291
|
+
out.push("---");
|
|
3292
|
+
out.push("<!-- 以下为预填的会议元数据,可整段作为 issue / 归档记录的头部 -->");
|
|
3293
|
+
out.push(`- **插件**:${PLUGIN_ID}(${HARNESS_RANGE})`);
|
|
3294
|
+
out.push(`- **会议 id**:${meeting.id}`);
|
|
3295
|
+
out.push(`- **协作模式**:${meeting.mode}`);
|
|
3296
|
+
out.push(`- **状态**:${meeting.status}${running ? "(进行中快照)" : ""}`);
|
|
3297
|
+
out.push(`- **创建时间**:${formatStamp(meeting.createdAt)}`);
|
|
3298
|
+
out.push(`- **${running ? "最后更新" : "结束时间"}**:${formatStamp(meeting.updatedAt)}`);
|
|
3299
|
+
out.push(`- **预算用量**:${meeting.budget.usedRounds}/${meeting.budget.maxRounds} 轮 · ${meeting.budget.usedTokens}/${meeting.budget.maxTokens} token`);
|
|
3300
|
+
out.push(`- **真实用量(provider 上报)**:${meeting.budget.usedTokensReal ?? 0} token —— 上面的 token 数是"发言文本粗估",不是真实账单`);
|
|
3301
|
+
if ((meeting.kbPath ?? "") !== "") out.push(`- **知识库**:${meeting.kbPath}`);
|
|
3302
|
+
if ((meeting.skills ?? []).length > 0) out.push(`- **skill**:${(meeting.skills ?? []).map((skill) => `\`${skill}\``).join("、")}(传递方式 ${meeting.skillDelivery ?? "relay"})`);
|
|
3303
|
+
out.push("---", "");
|
|
3304
|
+
out.push("## 议题 / 目标", "", meeting.goal.trim() === "" ? "(未提供)" : meeting.goal.trim(), "");
|
|
3305
|
+
out.push(`## 专家名单(${meeting.nodes.length})`, "");
|
|
3306
|
+
if (meeting.nodes.length === 0) out.push("(无)");
|
|
3307
|
+
for (const node of meeting.nodes) {
|
|
3308
|
+
const route = `${node.provider ?? "(继承主持人)"}/${node.model ?? "(继承主持人)"}`;
|
|
3309
|
+
const effort = (node.reasoningEffort ?? "") === "" ? "" : ` @${node.reasoningEffort}`;
|
|
3310
|
+
const role = (node.role ?? "") === "" ? "(未填角色)" : node.role;
|
|
3311
|
+
out.push(`- \`${node.key}\` — ${role} — ${route}${effort} — 状态 ${node.status}`);
|
|
3312
|
+
}
|
|
3313
|
+
out.push("");
|
|
3314
|
+
out.push(`## 决策记录(${meeting.decisions.length})`, "");
|
|
3315
|
+
if (meeting.decisions.length === 0) out.push("(无)");
|
|
3316
|
+
for (const decision of meeting.decisions) {
|
|
3317
|
+
out.push(`- **[${decision.status === "resolved" ? "已决" : "待决"}]** ${decision.question}`);
|
|
3318
|
+
out.push(` - 选项:${decision.options.length === 0 ? "(无)" : decision.options.join(" / ")}`);
|
|
3319
|
+
if (decision.chosen !== void 0) out.push(` - 用户选择:${decision.chosen}`);
|
|
3320
|
+
if (decision.customAnswer !== void 0) out.push(` - 自定义答案:${decision.customAnswer}`);
|
|
3321
|
+
out.push(` - 时间:${formatStamp(decision.ts)}`);
|
|
3322
|
+
}
|
|
3323
|
+
out.push("");
|
|
3324
|
+
out.push(`## 发言记录(${utterances.length} 条)`, "");
|
|
3325
|
+
if (utterances.length === 0) out.push("(无)", "");
|
|
3326
|
+
else {
|
|
3327
|
+
const rounds = [...new Set(utterances.map((utterance) => utterance.round))].sort((a, b) => a - b);
|
|
3328
|
+
for (const round of rounds) {
|
|
3329
|
+
out.push(`### 第 ${round} 轮`, "");
|
|
3330
|
+
for (const utterance of utterances.filter((candidate) => candidate.round === round)) {
|
|
3331
|
+
const audience = (utterance.to ?? "") === "" ? "汇聚网关" : utterance.to;
|
|
3332
|
+
const kind = utterance.kind === "speech" ? "" : ` · ${utterance.kind}`;
|
|
3333
|
+
out.push(`**${utterance.nodeKey} → ${audience}** · ${formatStamp(utterance.ts)}${kind}`);
|
|
3334
|
+
const shown = utterance.content.length > EXPORT_UTTERANCE_CHARS ? `${utterance.content.slice(0, EXPORT_UTTERANCE_CHARS)}\n\n…(已截断,原长 ${utterance.content.length} 字符)` : utterance.content;
|
|
3335
|
+
out.push("", shown, "");
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
if (review !== void 0) out.push("---", "", "# 针锋相对评审记录", "", renderReviewBody(review), "");
|
|
3340
|
+
out.push("---", "", `## 用户调整记录(${userActions.length})`, "");
|
|
3341
|
+
out.push("> 只包含尚未被主持人清空的记录;已执行并清空的调整不会出现在导出里。", "");
|
|
3342
|
+
if (userActions.length === 0) out.push("(无)");
|
|
3343
|
+
for (const action of userActions) {
|
|
3344
|
+
const key = (action.nodeKey ?? "") === "" ? "" : ` · ${action.nodeKey}`;
|
|
3345
|
+
out.push(`- ${formatStamp(action.ts)} · ${action.kind}${key}:${action.text}`);
|
|
3346
|
+
}
|
|
3347
|
+
out.push("");
|
|
3348
|
+
return out.join("\n");
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
//#endregion
|
|
3352
|
+
//#region src/web-guard.ts
|
|
3353
|
+
/** Request-body cap for the plugin RPC route (1 MiB — same order as the host's own API). */
|
|
3354
|
+
const MAX_RPC_BODY_BYTES = 1048576;
|
|
3355
|
+
/** Pick the fence out of a possibly-absent service (never load-gating). */
|
|
3356
|
+
function connectionFenceOf(service) {
|
|
3357
|
+
if (service === null || service === void 0) return void 0;
|
|
3358
|
+
const candidate = service;
|
|
3359
|
+
if (typeof candidate.requestRejection !== "function") return void 0;
|
|
3360
|
+
return candidate;
|
|
3361
|
+
}
|
|
3362
|
+
/**
|
|
3363
|
+
* Decide whether one inbound request may proceed.
|
|
3364
|
+
*
|
|
3365
|
+
* @returns the HTTP status to reject with, or `undefined` to proceed.
|
|
3366
|
+
*
|
|
3367
|
+
* A composition without the Connection service has no fence to apply and is let
|
|
3368
|
+
* through — the plugin must stay usable in minimal profiles. A fence that
|
|
3369
|
+
* *throws* is treated as a rejection: this is a security boundary, and failing
|
|
3370
|
+
* open would silently undo it.
|
|
3371
|
+
*/
|
|
3372
|
+
function rejectWebRequest(fence, headers) {
|
|
3373
|
+
if (fence === void 0) return void 0;
|
|
3374
|
+
try {
|
|
3375
|
+
return fence.requestRejection({ headers });
|
|
3376
|
+
} catch {
|
|
3377
|
+
return 403;
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
//#endregion
|
|
3382
|
+
//#region src/snapshot.ts
|
|
3383
|
+
/** 按 key 去重节点列表:同名 key 多次加入(删了重建)时保留最新一条,
|
|
3384
|
+
* 且非 removed 优先。返回顺序 = meeting.nodes 首次出现顺序。 */
|
|
3385
|
+
function dedupeNodesByKey(nodes) {
|
|
3386
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
3387
|
+
for (const node of nodes) {
|
|
3388
|
+
const prev = byKey.get(node.key);
|
|
3389
|
+
if (prev === void 0) {
|
|
3390
|
+
byKey.set(node.key, node);
|
|
3391
|
+
continue;
|
|
3392
|
+
}
|
|
3393
|
+
const prevActive = prev.status !== "removed";
|
|
3394
|
+
const nodeActive = node.status !== "removed";
|
|
3395
|
+
if (nodeActive !== prevActive) {
|
|
3396
|
+
if (nodeActive) byKey.set(node.key, node);
|
|
3397
|
+
continue;
|
|
3398
|
+
}
|
|
3399
|
+
if ((node.joinedAt ?? 0) >= (prev.joinedAt ?? 0)) byKey.set(node.key, node);
|
|
3400
|
+
}
|
|
3401
|
+
return [...byKey.values()];
|
|
3402
|
+
}
|
|
3403
|
+
/** Orchestrated meetings show an implicit star topology even before the
|
|
3404
|
+
* captain wires explicit edges: captain ⇄ each node, each node → aggregator.
|
|
3405
|
+
*
|
|
3406
|
+
* Real edges (the ones the user drags) are always included AND the synthetic
|
|
3407
|
+
* skeleton is kept as a faded backdrop for any pair not explicitly wired, so
|
|
3408
|
+
* dragging a new channel never makes the whole topology jump/vanish — the
|
|
3409
|
+
* user sees their wire land on top of a stable skeleton instead of the
|
|
3410
|
+
* skeleton disappearing the moment they add one edge.
|
|
3411
|
+
*/
|
|
3412
|
+
function synthesizedEdges(meeting) {
|
|
3413
|
+
const removedKeys = new Set(meeting.nodes.filter((node) => node.status === "removed").map((node) => node.key));
|
|
3414
|
+
const real = meeting.edges.filter((edge) => !removedKeys.has(edge.from) && !removedKeys.has(edge.to)).map((edge) => ({
|
|
3415
|
+
id: edge.id,
|
|
3416
|
+
from: edge.from,
|
|
3417
|
+
to: edge.to,
|
|
3418
|
+
direction: edge.direction
|
|
3419
|
+
}));
|
|
3420
|
+
if (meeting.mode === "egalitarian") return real;
|
|
3421
|
+
const covered = new Set(real.flatMap((edge) => [`${edge.from}→${edge.to}`, `${edge.to}→${edge.from}`]));
|
|
3422
|
+
const out = [...real];
|
|
3423
|
+
for (const node of meeting.nodes) {
|
|
3424
|
+
if (node.status === "removed") continue;
|
|
3425
|
+
if (!covered.has(`${CAPTAIN_KEY}→${node.key}`)) out.push({
|
|
3426
|
+
id: `synthetic:${CAPTAIN_KEY}:${node.key}`,
|
|
3427
|
+
from: CAPTAIN_KEY,
|
|
3428
|
+
to: node.key,
|
|
3429
|
+
direction: "bidirectional"
|
|
3430
|
+
});
|
|
3431
|
+
if (!covered.has(`${node.key}→${AGGREGATOR_KEY}`)) out.push({
|
|
3432
|
+
id: `synthetic:${node.key}:${AGGREGATOR_KEY}`,
|
|
3433
|
+
from: node.key,
|
|
3434
|
+
to: AGGREGATOR_KEY,
|
|
3435
|
+
direction: "forward"
|
|
3436
|
+
});
|
|
3437
|
+
}
|
|
3438
|
+
return out;
|
|
3439
|
+
}
|
|
3440
|
+
/** Recent directed message pulses for the flow animation (newest first). */
|
|
3441
|
+
function recentDirectedMessages(utterances) {
|
|
3442
|
+
const out = [];
|
|
3443
|
+
for (let i = utterances.length - 1; i >= 0 && out.length < 8; i--) {
|
|
3444
|
+
const utterance = utterances[i];
|
|
3445
|
+
if (utterance === void 0) continue;
|
|
3446
|
+
if (utterance.kind !== "speech" && utterance.kind !== "proxy-thinking") continue;
|
|
3447
|
+
out.push({
|
|
3448
|
+
id: utterance.id,
|
|
3449
|
+
from: utterance.nodeKey,
|
|
3450
|
+
to: utterance.to ?? AGGREGATOR_KEY,
|
|
3451
|
+
ts: utterance.ts
|
|
3452
|
+
});
|
|
3453
|
+
}
|
|
3454
|
+
return out;
|
|
3455
|
+
}
|
|
3456
|
+
/** One-line compaction for the sidebar timeline. */
|
|
3457
|
+
function compactText(text, limit) {
|
|
3458
|
+
const single = text.replace(/\s+/g, " ").trim();
|
|
3459
|
+
return single.length > limit ? `${single.slice(0, limit)}…` : single;
|
|
3460
|
+
}
|
|
3461
|
+
/** Recent contributions for the sidebar activity log (newest first). */
|
|
3462
|
+
function recentUtterances(utterances) {
|
|
3463
|
+
const out = [];
|
|
3464
|
+
for (let i = utterances.length - 1; i >= 0 && out.length < 20; i--) {
|
|
3465
|
+
const utterance = utterances[i];
|
|
3466
|
+
if (utterance === void 0) continue;
|
|
3467
|
+
if (utterance.kind !== "speech" && utterance.kind !== "proxy-thinking") continue;
|
|
3468
|
+
out.push({
|
|
3469
|
+
id: utterance.id,
|
|
3470
|
+
from: utterance.nodeKey,
|
|
3471
|
+
to: utterance.to ?? AGGREGATOR_KEY,
|
|
3472
|
+
text: compactText(utterance.summary ?? utterance.content, 90),
|
|
3473
|
+
ts: utterance.ts,
|
|
3474
|
+
round: utterance.round
|
|
3475
|
+
});
|
|
3476
|
+
}
|
|
3477
|
+
return out;
|
|
3478
|
+
}
|
|
3479
|
+
/** Collect snapshots across state roots, optionally filtered by captain session. */
|
|
3480
|
+
async function collectMeetingSnapshots(ctx, roots, sessionFilter) {
|
|
3481
|
+
const wireAction = (action) => ({
|
|
3482
|
+
id: action.id,
|
|
3483
|
+
kind: action.kind,
|
|
3484
|
+
nodeKey: action.nodeKey ?? "",
|
|
3485
|
+
role: action.role ?? "",
|
|
3486
|
+
provider: action.provider ?? "",
|
|
3487
|
+
model: action.model ?? "",
|
|
3488
|
+
text: action.text
|
|
3489
|
+
});
|
|
3490
|
+
const snapshots = [];
|
|
3491
|
+
for (const root of roots) for (const meetingId of await listMeetings(root.stateRoot)) {
|
|
3492
|
+
const meeting = await readMeeting(root.stateRoot, meetingId);
|
|
3493
|
+
if (meeting === void 0) continue;
|
|
3494
|
+
if (sessionFilter !== void 0 && meeting.captainSessionId !== sessionFilter) continue;
|
|
3495
|
+
const utterances = await readTranscript(root.stateRoot, meetingId);
|
|
3496
|
+
const userActions = await readUserActions(root.stateRoot, meetingId);
|
|
3497
|
+
const review = await readReview(root.stateRoot, meetingId);
|
|
3498
|
+
snapshots.push({
|
|
3499
|
+
id: meeting.id,
|
|
3500
|
+
name: meeting.name,
|
|
3501
|
+
goal: meeting.goal,
|
|
3502
|
+
mode: meeting.mode,
|
|
3503
|
+
status: meeting.status,
|
|
3504
|
+
round: meeting.round,
|
|
3505
|
+
workspace: root.workspace,
|
|
3506
|
+
captainSessionId: meeting.captainSessionId,
|
|
3507
|
+
kbPath: meeting.kbPath ?? "",
|
|
3508
|
+
skills: meeting.skills ?? [],
|
|
3509
|
+
skillDelivery: meeting.skillDelivery ?? "relay",
|
|
3510
|
+
budget: {
|
|
3511
|
+
maxRounds: meeting.budget.maxRounds,
|
|
3512
|
+
maxTokens: meeting.budget.maxTokens,
|
|
3513
|
+
usedRounds: meeting.budget.usedRounds,
|
|
3514
|
+
usedTokens: meeting.budget.usedTokens,
|
|
3515
|
+
usedTokensReal: meeting.budget.usedTokensReal ?? 0
|
|
3516
|
+
},
|
|
3517
|
+
nodes: dedupeNodesByKey(meeting.nodes).map((node) => {
|
|
3518
|
+
let activity = "unspawned";
|
|
3519
|
+
if (node.status === "removed") activity = "removed";
|
|
3520
|
+
else if (node.id !== "" && ACTIVE_NODE_STATUSES.includes(node.status)) {
|
|
3521
|
+
const live = ctx.agents.get(node.id);
|
|
3522
|
+
activity = live === void 0 ? "ready" : live.status;
|
|
3523
|
+
}
|
|
3524
|
+
return {
|
|
3525
|
+
id: node.id,
|
|
3526
|
+
key: node.key,
|
|
3527
|
+
role: node.role ?? "",
|
|
3528
|
+
provider: node.provider ?? "",
|
|
3529
|
+
model: node.model ?? "",
|
|
3530
|
+
status: node.status,
|
|
3531
|
+
activity
|
|
3532
|
+
};
|
|
3533
|
+
}),
|
|
3534
|
+
edges: synthesizedEdges(meeting),
|
|
3535
|
+
pendingDecisions: meeting.decisions.filter((decision) => decision.status === "pending").map((decision) => ({
|
|
3536
|
+
id: decision.id,
|
|
3537
|
+
question: decision.question,
|
|
3538
|
+
options: decision.options
|
|
3539
|
+
})),
|
|
3540
|
+
pendingActions: userActions.map(wireAction),
|
|
3541
|
+
review: review === void 0 ? null : {
|
|
3542
|
+
status: review.status,
|
|
3543
|
+
reviewPass: review.reviewPass ?? 1,
|
|
3544
|
+
maxReviewPass: review.maxReviewPass ?? 3,
|
|
3545
|
+
question: review.question,
|
|
3546
|
+
plan: review.plan,
|
|
3547
|
+
viewpoints: review.viewpoints.map((viewpoint) => ({
|
|
3548
|
+
id: viewpoint.id,
|
|
3549
|
+
utteranceId: viewpoint.utteranceId,
|
|
3550
|
+
nodeKey: viewpoint.nodeKey,
|
|
3551
|
+
content: viewpoint.content,
|
|
3552
|
+
status: viewpoint.status,
|
|
3553
|
+
endorsed: viewpoint.status === "endorsed",
|
|
3554
|
+
rejected: viewpoint.status === "rejected",
|
|
3555
|
+
rejectReason: viewpoint.rejectReason,
|
|
3556
|
+
evidence: viewpoint.evidence,
|
|
3557
|
+
quote: viewpoint.quote,
|
|
3558
|
+
dimension: viewpoint.dimension,
|
|
3559
|
+
seq: viewpoint.seq
|
|
3560
|
+
}))
|
|
3561
|
+
},
|
|
3562
|
+
digest: aggregateUtterances(utterances),
|
|
3563
|
+
messages: recentDirectedMessages(utterances),
|
|
3564
|
+
recent: recentUtterances(utterances)
|
|
3565
|
+
});
|
|
3566
|
+
}
|
|
3567
|
+
return snapshots;
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
//#endregion
|
|
3571
|
+
//#region src/preferences.ts
|
|
3572
|
+
/**
|
|
3573
|
+
* 解析 DeepSeek Harness home。
|
|
3574
|
+
*
|
|
3575
|
+
* 与宿主 `@deepseek-ai/dsh-home-paths` 的 `resolveDshHome` 优先级对齐:
|
|
3576
|
+
* `configured` > `$DSH_HOME` > `~/.dsh`。只有第一级做不到 —— 它是宿主进程内部
|
|
3577
|
+
* 传入的显式覆盖,插件拿不到 —— 但环境变量与默认值这两级完全一致,因此在
|
|
3578
|
+
* 任何正常启动的宿主里都会落到同一个目录。
|
|
3579
|
+
*
|
|
3580
|
+
* 刻意**不**静态 import `@deepseek-ai/dsh-home-paths`:那会把它变成加载门禁,
|
|
3581
|
+
* 而本模块只是要一个路径(见 harness-compat 的铁律 #1)。
|
|
3582
|
+
*/
|
|
3583
|
+
function dshHomeDir() {
|
|
3584
|
+
const fromEnv = process.env.DSH_HOME?.trim();
|
|
3585
|
+
if (fromEnv !== void 0 && fromEnv !== "") return fromEnv;
|
|
3586
|
+
return join(homedir(), ".dsh");
|
|
3587
|
+
}
|
|
3588
|
+
/** 偏好文件的绝对路径(DSH home 下的 `roundtable/preferences.json`)。 */
|
|
3589
|
+
function preferencesFilePath() {
|
|
3590
|
+
return join(dshHomeDir(), "roundtable", "preferences.json");
|
|
3591
|
+
}
|
|
3592
|
+
/** 没有任何持久化数据时的偏好。`defaultMode` 由调用方按插件 Config 覆盖。 */
|
|
3593
|
+
function defaultPreferences(defaultMode) {
|
|
3594
|
+
return {
|
|
3595
|
+
defaultMode,
|
|
3596
|
+
maxRounds: 10,
|
|
3597
|
+
maxTokens: 2e5,
|
|
3598
|
+
showAllMeetings: true,
|
|
3599
|
+
expertMaxTokens: 0,
|
|
3600
|
+
expertMaxOpinions: 0,
|
|
3601
|
+
feedbackEnabled: true,
|
|
3602
|
+
skillDelivery: "relay",
|
|
3603
|
+
hiddenPanels: [],
|
|
3604
|
+
legendHidden: false,
|
|
3605
|
+
rolePresets: [],
|
|
3606
|
+
squads: []
|
|
3607
|
+
};
|
|
3608
|
+
}
|
|
3609
|
+
/** 右栏可隐藏的面板 id(客户端与服务端共用的稳定标识)。 */
|
|
3610
|
+
const ROUNDTABLE_PANELS = [
|
|
3611
|
+
"agents",
|
|
3612
|
+
"tasks",
|
|
3613
|
+
"kb",
|
|
3614
|
+
"skills",
|
|
3615
|
+
"activity",
|
|
3616
|
+
"review",
|
|
3617
|
+
"files"
|
|
3618
|
+
];
|
|
3619
|
+
/** 把任意输入收敛成合法的隐藏面板清单(未知 id 丢弃,去重)。 */
|
|
3620
|
+
function sanitizeHiddenPanels(value) {
|
|
3621
|
+
if (!Array.isArray(value)) return [];
|
|
3622
|
+
const out = [];
|
|
3623
|
+
for (const item of value) {
|
|
3624
|
+
const id = typeof item === "string" ? item.trim() : "";
|
|
3625
|
+
if (id === "" || !ROUNDTABLE_PANELS.includes(id) || out.includes(id)) continue;
|
|
3626
|
+
out.push(id);
|
|
3627
|
+
}
|
|
3628
|
+
return out;
|
|
3629
|
+
}
|
|
3630
|
+
/** 预设条目的硬上限(客户端与服务端共用;服务端截断,客户端提前拦截)。 */
|
|
3631
|
+
const ROLE_PRESET_MAX = 50;
|
|
3632
|
+
const ROLE_PRESET_ID_MAX = 64;
|
|
3633
|
+
const ROLE_PRESET_NAME_MAX = 40;
|
|
3634
|
+
const ROLE_PRESET_ROLE_MAX = 400;
|
|
3635
|
+
/**
|
|
3636
|
+
* 把任意输入收敛成合法的角色预设清单(B3)。
|
|
3637
|
+
*
|
|
3638
|
+
* schemastery 的 `z.object` 在 resolve 阶段**不校验缺失的 required 字段**
|
|
3639
|
+
* (`s({ rolePresets: [{ id: 'a' }] })` 直接通过),所以条目级校验不能依赖
|
|
3640
|
+
* schema,必须在这里手写 —— 与 {@link sanitizeHiddenPanels} 同一套路:
|
|
3641
|
+
* - `name` 与 `role` 非空是硬要求,二者缺一即丢弃该条;
|
|
3642
|
+
* - `id` 为空或与前面的条目重复时**重新分配**(保数据,不静默丢条目);
|
|
3643
|
+
* - `provider`/`model` 必须成对出现,否则整体视为"继承主持人";
|
|
3644
|
+
* - 单字段超长截断,总条数超 {@link ROLE_PRESET_MAX} 截断。
|
|
3645
|
+
*/
|
|
3646
|
+
function sanitizeRolePresets(value) {
|
|
3647
|
+
if (!Array.isArray(value)) return [];
|
|
3648
|
+
const out = [];
|
|
3649
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3650
|
+
for (const item of value) {
|
|
3651
|
+
if (out.length >= ROLE_PRESET_MAX) break;
|
|
3652
|
+
if (item === null || typeof item !== "object") continue;
|
|
3653
|
+
const raw = item;
|
|
3654
|
+
const name$1 = typeof raw.name === "string" ? raw.name.trim().slice(0, ROLE_PRESET_NAME_MAX) : "";
|
|
3655
|
+
const role = typeof raw.role === "string" ? raw.role.trim().slice(0, ROLE_PRESET_ROLE_MAX) : "";
|
|
3656
|
+
if (name$1 === "" || role === "") continue;
|
|
3657
|
+
const candidate = typeof raw.id === "string" ? raw.id.trim().slice(0, ROLE_PRESET_ID_MAX) : "";
|
|
3658
|
+
const id = candidate === "" || seen.has(candidate) ? randomUUID() : candidate;
|
|
3659
|
+
seen.add(id);
|
|
3660
|
+
const provider = typeof raw.provider === "string" ? raw.provider.trim() : "";
|
|
3661
|
+
const model = typeof raw.model === "string" ? raw.model.trim() : "";
|
|
3662
|
+
const routed = provider !== "" && model !== "";
|
|
3663
|
+
out.push({
|
|
3664
|
+
id,
|
|
3665
|
+
name: name$1,
|
|
3666
|
+
role,
|
|
3667
|
+
...routed ? {
|
|
3668
|
+
provider,
|
|
3669
|
+
model
|
|
3670
|
+
} : {}
|
|
3671
|
+
});
|
|
3672
|
+
}
|
|
3673
|
+
return out;
|
|
3674
|
+
}
|
|
3675
|
+
/** 阵容预设的硬上限(客户端与服务端共用;服务端截断,客户端提前拦截)。 */
|
|
3676
|
+
const SQUAD_MAX = 20;
|
|
3677
|
+
const SQUAD_MEMBER_MAX = 8;
|
|
3678
|
+
const SQUAD_ID_MAX = 64;
|
|
3679
|
+
const SQUAD_NAME_MAX = 40;
|
|
3680
|
+
const SQUAD_MEMBER_ROLE_MAX = 400;
|
|
3681
|
+
/** 阵容成员 key 的净化规则:与客户端 `sanitizeKey` 同源
|
|
3682
|
+
* (小写、保留中日韩、非法字符折成 '-'、去首尾)。 */
|
|
3683
|
+
function sanitizeSquadKey(value) {
|
|
3684
|
+
return (typeof value === "string" ? value.trim().toLowerCase() : "").replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3685
|
+
}
|
|
3686
|
+
/**
|
|
3687
|
+
* 把任意输入收敛成合法的阵容预设清单(B3+)。
|
|
3688
|
+
*
|
|
3689
|
+
* 与 {@link sanitizeRolePresets} 同一套路(schemastery 不校验 required,
|
|
3690
|
+
* 条目级校验只能手写),差别只有两条:
|
|
3691
|
+
* - 成员的**去重键是 key**(同一阵容里不能有两位同名专家);
|
|
3692
|
+
* - **没有任何成员的阵容被丢弃** —— 否则设置页会出现一条点了没反应的条目。
|
|
3693
|
+
*
|
|
3694
|
+
* 兼容性(这一条就是"结构变更必须自带迁移"的落地方式):`squads` 是
|
|
3695
|
+
* **可选新增字段**,旧偏好对象里没有它时读到的是 `[]`,读侧归一化、
|
|
3696
|
+
* 只有用户真的保存时才写回,因此不需要版本号迁移,也不可能损坏已有数据。
|
|
3697
|
+
*/
|
|
3698
|
+
function sanitizeSquads(value) {
|
|
3699
|
+
if (!Array.isArray(value)) return [];
|
|
3700
|
+
const out = [];
|
|
3701
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
3702
|
+
for (const item of value) {
|
|
3703
|
+
if (out.length >= SQUAD_MAX) break;
|
|
3704
|
+
if (item === null || typeof item !== "object") continue;
|
|
3705
|
+
const raw = item;
|
|
3706
|
+
const name$1 = typeof raw.name === "string" ? raw.name.trim().slice(0, SQUAD_NAME_MAX) : "";
|
|
3707
|
+
if (name$1 === "") continue;
|
|
3708
|
+
const members = [];
|
|
3709
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
3710
|
+
if (Array.isArray(raw.members)) for (const entry of raw.members) {
|
|
3711
|
+
if (members.length >= SQUAD_MEMBER_MAX) break;
|
|
3712
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
3713
|
+
const member = entry;
|
|
3714
|
+
const key = sanitizeSquadKey(member.key);
|
|
3715
|
+
if (key === "" || seenKeys.has(key)) continue;
|
|
3716
|
+
seenKeys.add(key);
|
|
3717
|
+
const role = typeof member.role === "string" ? member.role.trim().slice(0, SQUAD_MEMBER_ROLE_MAX) : "";
|
|
3718
|
+
const provider = typeof member.provider === "string" ? member.provider.trim() : "";
|
|
3719
|
+
const model = typeof member.model === "string" ? member.model.trim() : "";
|
|
3720
|
+
const routed = provider !== "" && model !== "";
|
|
3721
|
+
members.push({
|
|
3722
|
+
key,
|
|
3723
|
+
role,
|
|
3724
|
+
...routed ? {
|
|
3725
|
+
provider,
|
|
3726
|
+
model
|
|
3727
|
+
} : {}
|
|
3728
|
+
});
|
|
3729
|
+
}
|
|
3730
|
+
if (members.length === 0) continue;
|
|
3731
|
+
const candidate = typeof raw.id === "string" ? raw.id.trim().slice(0, SQUAD_ID_MAX) : "";
|
|
3732
|
+
const id = candidate === "" || seenIds.has(candidate) ? randomUUID() : candidate;
|
|
3733
|
+
seenIds.add(id);
|
|
3734
|
+
out.push({
|
|
3735
|
+
id,
|
|
3736
|
+
name: name$1,
|
|
3737
|
+
members
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
return out;
|
|
3741
|
+
}
|
|
3742
|
+
/** 0 = 不限制;任何有限非负整数都接受。 */
|
|
3743
|
+
function clampLimit(value, fallback) {
|
|
3744
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
|
|
3745
|
+
}
|
|
3746
|
+
/**
|
|
3747
|
+
* 把 `layer` 叠在 `base` 之上并逐字段净化。
|
|
3748
|
+
*
|
|
3749
|
+
* 语义与宿主 `SettingsScope.update(patch)` + `get()` 的组合一致:**只覆盖
|
|
3750
|
+
* `layer` 中出现的字段**,其余沿用 `base`。因此同一个函数同时服务于两条路径:
|
|
3751
|
+
* - 读盘:`normalizePreferences(fileContent, defaults)` —— 旧文件缺新字段时补默认;
|
|
3752
|
+
* - 写入:`normalizePreferences(patch, current)` —— 设置页的部分更新。
|
|
3753
|
+
*/
|
|
3754
|
+
function normalizePreferences(layer, base) {
|
|
3755
|
+
const raw = layer !== null && typeof layer === "object" ? layer : {};
|
|
3756
|
+
const pick = (key, fallback, accept) => {
|
|
3757
|
+
if (!(key in raw)) return fallback;
|
|
3758
|
+
const accepted = accept(raw[key]);
|
|
3759
|
+
return accepted === void 0 ? fallback : accepted;
|
|
3760
|
+
};
|
|
3761
|
+
const asMode = (value) => value === "orchestrated" || value === "egalitarian" || value === "redteam" ? value : void 0;
|
|
3762
|
+
const asPositiveInt = (value) => typeof value === "number" && Number.isFinite(value) && value >= 1 ? Math.floor(value) : void 0;
|
|
3763
|
+
const asTokens = (value) => typeof value === "number" && Number.isFinite(value) && value >= 1e3 ? Math.floor(value) : void 0;
|
|
3764
|
+
const asBoolean = (value) => typeof value === "boolean" ? value : void 0;
|
|
3765
|
+
const asDelivery = (value) => value === "relay" || value === "direct" ? value : void 0;
|
|
3766
|
+
return {
|
|
3767
|
+
defaultMode: pick("defaultMode", base.defaultMode, asMode),
|
|
3768
|
+
maxRounds: pick("maxRounds", base.maxRounds, asPositiveInt),
|
|
3769
|
+
maxTokens: pick("maxTokens", base.maxTokens, asTokens),
|
|
3770
|
+
showAllMeetings: pick("showAllMeetings", base.showAllMeetings, asBoolean),
|
|
3771
|
+
expertMaxTokens: pick("expertMaxTokens", base.expertMaxTokens, (value) => clampLimit(value, base.expertMaxTokens)),
|
|
3772
|
+
expertMaxOpinions: pick("expertMaxOpinions", base.expertMaxOpinions, (value) => clampLimit(value, base.expertMaxOpinions)),
|
|
3773
|
+
feedbackEnabled: pick("feedbackEnabled", base.feedbackEnabled, asBoolean),
|
|
3774
|
+
skillDelivery: pick("skillDelivery", base.skillDelivery, asDelivery),
|
|
3775
|
+
hiddenPanels: pick("hiddenPanels", base.hiddenPanels, sanitizeHiddenPanels),
|
|
3776
|
+
legendHidden: pick("legendHidden", base.legendHidden, asBoolean),
|
|
3777
|
+
rolePresets: pick("rolePresets", base.rolePresets, sanitizeRolePresets),
|
|
3778
|
+
squads: pick("squads", base.squads, sanitizeSquads)
|
|
3779
|
+
};
|
|
3780
|
+
}
|
|
3781
|
+
/**
|
|
3782
|
+
* 创建偏好存储。
|
|
3783
|
+
*
|
|
3784
|
+
* 读盘是**惰性且异步**的(构造后立刻开始),因此 `get()` 在首次读盘完成前
|
|
3785
|
+
* 返回基准值而不是阻塞宿主启动 —— 这与宿主设置服务的可用性无关,也是它比
|
|
3786
|
+
* `ctx.inject(['settings'])` 更稳的地方:没有可选能力可缺。
|
|
3787
|
+
*/
|
|
3788
|
+
function createPreferenceStore(options) {
|
|
3789
|
+
const file = options.file ?? preferencesFilePath();
|
|
3790
|
+
let current = options.base;
|
|
3791
|
+
let writeQueue = Promise.resolve();
|
|
3792
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
3793
|
+
const load = (async () => {
|
|
3794
|
+
try {
|
|
3795
|
+
const text = await readFile(file, "utf8");
|
|
3796
|
+
current = normalizePreferences(JSON.parse(text), options.base);
|
|
3797
|
+
} catch (error) {
|
|
3798
|
+
if (error.code !== "ENOENT") options.log?.(`roundtable: preferences at ${file} unreadable; using defaults`, error);
|
|
3799
|
+
}
|
|
3800
|
+
})();
|
|
3801
|
+
const publish = (next) => {
|
|
3802
|
+
current = next;
|
|
3803
|
+
for (const listener of listeners) try {
|
|
3804
|
+
listener(next);
|
|
3805
|
+
} catch (error) {
|
|
3806
|
+
options.log?.("roundtable: a preference listener threw", error);
|
|
3807
|
+
}
|
|
3808
|
+
};
|
|
3809
|
+
return {
|
|
3810
|
+
get: () => current,
|
|
3811
|
+
ready: () => load.then(() => void 0),
|
|
3812
|
+
watch(listener) {
|
|
3813
|
+
listeners.add(listener);
|
|
3814
|
+
return () => {
|
|
3815
|
+
listeners.delete(listener);
|
|
3816
|
+
};
|
|
3817
|
+
},
|
|
3818
|
+
async update(patch) {
|
|
3819
|
+
await load;
|
|
3820
|
+
const next = normalizePreferences(patch, current);
|
|
3821
|
+
publish(next);
|
|
3822
|
+
writeQueue = writeQueue.then(async () => {
|
|
3823
|
+
try {
|
|
3824
|
+
await mkdir(dirname(file), { recursive: true });
|
|
3825
|
+
await writeTextAtomic(file, JSON.stringify(next, null, 2));
|
|
3826
|
+
} catch (error) {
|
|
3827
|
+
options.log?.(`roundtable: failed to persist preferences to ${file}`, error);
|
|
3828
|
+
}
|
|
3829
|
+
});
|
|
3830
|
+
await writeQueue;
|
|
3831
|
+
return next;
|
|
3832
|
+
}
|
|
3833
|
+
};
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
//#endregion
|
|
3837
|
+
//#region src/rpc.ts
|
|
3838
|
+
/**
|
|
3839
|
+
* Browser-facing POST route for this plugin's RPC.
|
|
3840
|
+
*
|
|
3841
|
+
* It mirrors the snapshot route's transport (the plugin's own `webServer`
|
|
3842
|
+
* registration) instead of relying on the host's generic connection channel,
|
|
3843
|
+
* which is what the client actually reaches.
|
|
3844
|
+
*/
|
|
3845
|
+
const RPC_ROUTE = "/plugins/dsh-plugin-roundtable/rpc";
|
|
3846
|
+
function ok(value) {
|
|
3847
|
+
return {
|
|
3848
|
+
ok: true,
|
|
3849
|
+
value
|
|
3850
|
+
};
|
|
3851
|
+
}
|
|
3852
|
+
function fail(message) {
|
|
3853
|
+
return {
|
|
3854
|
+
ok: false,
|
|
3855
|
+
error: {
|
|
3856
|
+
code: "internal",
|
|
3857
|
+
message
|
|
3858
|
+
}
|
|
3859
|
+
};
|
|
3860
|
+
}
|
|
3861
|
+
/** Run one meeting mutation under the meeting lock, resolving its state root first. */
|
|
3862
|
+
async function withMeetingRpcLock(runtime, meetingId, operation, options = {}) {
|
|
3863
|
+
const { workspaceOfMeeting } = await import("./edge-helper-iqXOG5o_.js");
|
|
3864
|
+
const workspace = await workspaceOfMeeting(runtime.stateDir, meetingId);
|
|
3865
|
+
if (workspace === void 0) return fail(`meeting "${meetingId}" not found in any workspace`);
|
|
3866
|
+
const stateRoot = stateRootOf(workspace, runtime.stateDir);
|
|
3867
|
+
return withMeetingLock(`meeting:${stateRoot}:${meetingId}`, async () => {
|
|
3868
|
+
if (options.requireActive === true) {
|
|
3869
|
+
const meeting = await readMeeting(stateRoot, meetingId);
|
|
3870
|
+
if (meeting !== void 0 && (meeting.status === "ended" || meeting.status === "archived")) return fail(`meeting "${meetingId}" has ended — its topology is read-only`);
|
|
3871
|
+
}
|
|
3872
|
+
return operation(stateRoot);
|
|
3873
|
+
});
|
|
3874
|
+
}
|
|
3875
|
+
/** 观点三态写入(V0.2.2)。幂等;状态变化时写结构化 user-action(V2 回写负载)。
|
|
3876
|
+
* 驳回必须附理由(C2),由 setViewpointStatus 强制非空。
|
|
3877
|
+
* P1 务实降级:connection 通道不暴露调用者 session 身份(handler 仅
|
|
3878
|
+
* endpoint/payload/signal),无法做 captainSessionId 校验——以「会议归属
|
|
3879
|
+
* 校验(withMeetingRpcLock)+ 状态机校验(reviewing 阶段拒绝)」作为防线。 */
|
|
3880
|
+
async function applyViewpointStatus(runtime, meetingId, viewpointId, status, rejectReason) {
|
|
3881
|
+
return withMeetingRpcLock(runtime, meetingId, async (stateRoot) => {
|
|
3882
|
+
const review = await readReview(stateRoot, meetingId);
|
|
3883
|
+
if (review === void 0) return fail(`no review in progress for meeting "${meetingId}"`);
|
|
3884
|
+
const viewpoint = review.viewpoints.find((candidate) => candidate.id === viewpointId);
|
|
3885
|
+
if (viewpoint === void 0) return fail(`viewpoint "${viewpointId}" not found`);
|
|
3886
|
+
if (review.status === "reviewing") return fail("review is still collecting viewpoints — call roundtable_collect_review before endorsing or rejecting");
|
|
3887
|
+
if (review.status === "done") return fail("this review pass is finished — start a new pass (roundtable_start_review) to re-review");
|
|
3888
|
+
let changed;
|
|
3889
|
+
try {
|
|
3890
|
+
changed = await setViewpointStatus(stateRoot, meetingId, viewpointId, status, rejectReason);
|
|
3891
|
+
} catch (error) {
|
|
3892
|
+
return fail(error.message);
|
|
3893
|
+
}
|
|
3894
|
+
if (changed && status !== "pending") {
|
|
3895
|
+
const label = status === "endorsed" ? "用户认定缺陷" : "用户驳回观点";
|
|
3896
|
+
const reasonText = status === "rejected" && rejectReason !== void 0 && rejectReason.trim() !== "" ? `;理由:${rejectReason.trim().replace(/\s+/g, " ").slice(0, 120)}` : "";
|
|
3897
|
+
await appendUserAction(stateRoot, meetingId, {
|
|
3898
|
+
id: randomUUID(),
|
|
3899
|
+
ts: Date.now(),
|
|
3900
|
+
kind: "other",
|
|
3901
|
+
nodeKey: viewpoint.nodeKey,
|
|
3902
|
+
text: `${label}(针锋相对评审 ${viewpoint.id})${reasonText}:${viewpoint.content.replace(/\s+/g, " ").slice(0, 60)}`
|
|
3903
|
+
});
|
|
3904
|
+
}
|
|
3905
|
+
return ok({
|
|
3906
|
+
changed,
|
|
3907
|
+
status
|
|
3908
|
+
});
|
|
3909
|
+
});
|
|
3910
|
+
}
|
|
3911
|
+
/**
|
|
3912
|
+
* Register the RoundTable RPC handler.
|
|
3913
|
+
*
|
|
3914
|
+
* Two transports share ONE dispatch body, so the browser can reach the plugin
|
|
3915
|
+
* even when the host's generic `connection` channel is not mounted (or its
|
|
3916
|
+
* `/roundtable` prefix got dropped by a live plugin reload):
|
|
3917
|
+
* 1. the host `connection` RPC channel `/roundtable` (kept for compatibility);
|
|
3918
|
+
* 2. the plugin's own `webServer` POST route `<RPC_ROUTE>` — the same
|
|
3919
|
+
* transport as the `/plugins/dsh-plugin-roundtable/state` snapshot route,
|
|
3920
|
+
* which is what the browser actually uses.
|
|
3921
|
+
*
|
|
3922
|
+
* @returns the dispatch function, so the caller can mount it on a web route.
|
|
3923
|
+
*/
|
|
3924
|
+
function registerRpc(ctx, runtime) {
|
|
3925
|
+
const dispatch = async (endpoint, payload) => {
|
|
3926
|
+
try {
|
|
3927
|
+
switch (endpoint) {
|
|
3928
|
+
case "roundtable/prefs.get": {
|
|
3929
|
+
const prefs = runtime.prefs.get();
|
|
3930
|
+
return ok({
|
|
3931
|
+
defaultMode: prefs.defaultMode,
|
|
3932
|
+
maxRounds: prefs.maxRounds,
|
|
3933
|
+
maxTokens: prefs.maxTokens,
|
|
3934
|
+
showAllMeetings: prefs.showAllMeetings,
|
|
3935
|
+
expertMaxTokens: prefs.expertMaxTokens ?? 0,
|
|
3936
|
+
expertMaxOpinions: prefs.expertMaxOpinions ?? 0,
|
|
3937
|
+
feedbackEnabled: prefs.feedbackEnabled ?? true,
|
|
3938
|
+
skillDelivery: prefs.skillDelivery === "direct" ? "direct" : "relay",
|
|
3939
|
+
hiddenPanels: sanitizeHiddenPanels(prefs.hiddenPanels),
|
|
3940
|
+
legendHidden: prefs.legendHidden === true,
|
|
3941
|
+
rolePresets: sanitizeRolePresets(prefs.rolePresets),
|
|
3942
|
+
squads: sanitizeSquads(prefs.squads)
|
|
3943
|
+
});
|
|
3944
|
+
}
|
|
3945
|
+
case "roundtable/prefs.set": {
|
|
3946
|
+
const patch = payload;
|
|
3947
|
+
if (patch === void 0 || typeof patch !== "object" || patch === null) return fail("payload must be a preferences patch object");
|
|
3948
|
+
const sanitized = { ...patch };
|
|
3949
|
+
if (patch.hiddenPanels !== void 0) sanitized.hiddenPanels = sanitizeHiddenPanels(patch.hiddenPanels);
|
|
3950
|
+
if (patch.legendHidden !== void 0) sanitized.legendHidden = patch.legendHidden === true;
|
|
3951
|
+
if (patch.rolePresets !== void 0) sanitized.rolePresets = sanitizeRolePresets(patch.rolePresets);
|
|
3952
|
+
if (patch.squads !== void 0) sanitized.squads = sanitizeSquads(patch.squads);
|
|
3953
|
+
const next = await runtime.prefs.update(sanitized);
|
|
3954
|
+
return ok({
|
|
3955
|
+
defaultMode: next.defaultMode,
|
|
3956
|
+
maxRounds: next.maxRounds,
|
|
3957
|
+
maxTokens: next.maxTokens,
|
|
3958
|
+
showAllMeetings: next.showAllMeetings,
|
|
3959
|
+
expertMaxTokens: next.expertMaxTokens ?? 0,
|
|
3960
|
+
expertMaxOpinions: next.expertMaxOpinions ?? 0,
|
|
3961
|
+
feedbackEnabled: next.feedbackEnabled ?? true,
|
|
3962
|
+
skillDelivery: next.skillDelivery === "direct" ? "direct" : "relay",
|
|
3963
|
+
hiddenPanels: sanitizeHiddenPanels(next.hiddenPanels),
|
|
3964
|
+
legendHidden: next.legendHidden === true,
|
|
3965
|
+
rolePresets: sanitizeRolePresets(next.rolePresets),
|
|
3966
|
+
squads: sanitizeSquads(next.squads)
|
|
3967
|
+
});
|
|
3968
|
+
}
|
|
3969
|
+
case "roundtable/edge.set": {
|
|
3970
|
+
const body = payload;
|
|
3971
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
3972
|
+
const edgeId = typeof body?.edgeId === "string" ? body.edgeId : "";
|
|
3973
|
+
if (meetingId === "" || edgeId === "") return fail("payload must be { meetingId, edgeId, direction }");
|
|
3974
|
+
const directionRaw = typeof body?.direction === "string" ? body.direction : "";
|
|
3975
|
+
if (directionRaw !== "forward" && directionRaw !== "bidirectional") return fail("direction must be \"forward\" or \"bidirectional\"");
|
|
3976
|
+
const direction = directionRaw;
|
|
3977
|
+
return withMeetingRpcLock(runtime, meetingId, async (stateRoot) => {
|
|
3978
|
+
const meeting = await readMeeting(stateRoot, meetingId);
|
|
3979
|
+
if (meeting === void 0) return fail(`meeting "${meetingId}" not found`);
|
|
3980
|
+
const edge = meeting.edges.find((candidate) => candidate.id === edgeId);
|
|
3981
|
+
if (edge === void 0) return fail(`edge "${edgeId}" not found`);
|
|
3982
|
+
edge.direction = direction;
|
|
3983
|
+
meeting.charter = buildCharter(meeting);
|
|
3984
|
+
await writeMeeting(stateRoot, meeting);
|
|
3985
|
+
return ok({ direction: edge.direction });
|
|
3986
|
+
}, { requireActive: true });
|
|
3987
|
+
}
|
|
3988
|
+
case "roundtable/edge.add": {
|
|
3989
|
+
const body = payload;
|
|
3990
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
3991
|
+
const from = typeof body?.from === "string" ? body.from.trim() : "";
|
|
3992
|
+
const to = typeof body?.to === "string" ? body.to.trim() : "";
|
|
3993
|
+
if (meetingId === "" || from === "" || to === "") return fail("payload must be { meetingId, from, to, direction }");
|
|
3994
|
+
const direction = body?.direction === "bidirectional" ? "bidirectional" : "forward";
|
|
3995
|
+
return withMeetingRpcLock(runtime, meetingId, async (stateRoot) => {
|
|
3996
|
+
const meeting = await readMeeting(stateRoot, meetingId);
|
|
3997
|
+
if (meeting === void 0) return fail(`meeting "${meetingId}" not found`);
|
|
3998
|
+
const valid = (key) => key === "captain" || key === "aggregator" || meeting.nodes.some((node) => node.key === key && node.status !== "removed");
|
|
3999
|
+
if (!valid(from)) return fail(`unknown endpoint "${from}"`);
|
|
4000
|
+
if (!valid(to)) return fail(`unknown endpoint "${to}"`);
|
|
4001
|
+
if (from === to) return fail("an edge cannot connect a participant to itself");
|
|
4002
|
+
if (meeting.edges.some((edge$1) => edge$1.from === from && edge$1.to === to)) return fail(`channel ${from} → ${to} already exists`);
|
|
4003
|
+
const edge = {
|
|
4004
|
+
id: randomUUID(),
|
|
4005
|
+
from,
|
|
4006
|
+
to,
|
|
4007
|
+
direction,
|
|
4008
|
+
createdAt: Date.now()
|
|
4009
|
+
};
|
|
4010
|
+
meeting.edges.push(edge);
|
|
4011
|
+
meeting.charter = buildCharter(meeting);
|
|
4012
|
+
await writeMeeting(stateRoot, meeting);
|
|
4013
|
+
return ok({
|
|
4014
|
+
edgeId: edge.id,
|
|
4015
|
+
from: edge.from,
|
|
4016
|
+
to: edge.to,
|
|
4017
|
+
direction: edge.direction
|
|
4018
|
+
});
|
|
4019
|
+
}, { requireActive: true });
|
|
4020
|
+
}
|
|
4021
|
+
case "roundtable/edge.remove": {
|
|
4022
|
+
const body = payload;
|
|
4023
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
4024
|
+
const edgeId = typeof body?.edgeId === "string" ? body.edgeId : "";
|
|
4025
|
+
if (meetingId === "" || edgeId === "") return fail("payload must be { meetingId, edgeId }");
|
|
4026
|
+
return withMeetingRpcLock(runtime, meetingId, async (stateRoot) => {
|
|
4027
|
+
const meeting = await readMeeting(stateRoot, meetingId);
|
|
4028
|
+
if (meeting === void 0) return fail(`meeting "${meetingId}" not found`);
|
|
4029
|
+
const before = meeting.edges.length;
|
|
4030
|
+
meeting.edges = meeting.edges.filter((edge) => edge.id !== edgeId);
|
|
4031
|
+
meeting.charter = buildCharter(meeting);
|
|
4032
|
+
await writeMeeting(stateRoot, meeting);
|
|
4033
|
+
return ok({ removed: before !== meeting.edges.length });
|
|
4034
|
+
}, { requireActive: true });
|
|
4035
|
+
}
|
|
4036
|
+
case "roundtable/meeting.delete": {
|
|
4037
|
+
const body = payload;
|
|
4038
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
4039
|
+
if (meetingId === "") return fail("payload must be { meetingId }");
|
|
4040
|
+
return withMeetingRpcLock(runtime, meetingId, async (stateRoot) => {
|
|
4041
|
+
const meeting = await readMeeting(stateRoot, meetingId);
|
|
4042
|
+
if (meeting === void 0) return ok({ deleted: false });
|
|
4043
|
+
for (const node of meeting.nodes) {
|
|
4044
|
+
if (node.id === "") continue;
|
|
4045
|
+
if (interruptNodeByParent(ctx, node.id, meeting.captainSessionId)) continue;
|
|
4046
|
+
const agents = ctx.agents;
|
|
4047
|
+
try {
|
|
4048
|
+
agents?.interrupt?.(node.id);
|
|
4049
|
+
} catch {}
|
|
4050
|
+
}
|
|
4051
|
+
await rm(meetingDirOf(stateRoot, meetingId), {
|
|
4052
|
+
recursive: true,
|
|
4053
|
+
force: true
|
|
4054
|
+
});
|
|
4055
|
+
return ok({ deleted: true });
|
|
4056
|
+
});
|
|
4057
|
+
}
|
|
4058
|
+
case "roundtable/user-actions.append": {
|
|
4059
|
+
const body = payload;
|
|
4060
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
4061
|
+
const kindRaw = typeof body?.kind === "string" ? body.kind : "";
|
|
4062
|
+
if (meetingId === "" || kindRaw !== "add-node" && kindRaw !== "remove-node" && kindRaw !== "kb-path" && kindRaw !== "other") return fail("payload must be { meetingId, kind, text } with kind in add-node|remove-node|kb-path|other");
|
|
4063
|
+
const text = typeof body?.text === "string" ? body.text.trim() : "";
|
|
4064
|
+
if (text === "") return fail("payload must include a non-empty text sentence");
|
|
4065
|
+
return withMeetingRpcLock(runtime, meetingId, async (stateRoot) => {
|
|
4066
|
+
const action = {
|
|
4067
|
+
id: randomUUID(),
|
|
4068
|
+
ts: Date.now(),
|
|
4069
|
+
kind: kindRaw,
|
|
4070
|
+
nodeKey: typeof body?.nodeKey === "string" && body.nodeKey.trim() !== "" ? body.nodeKey.trim() : void 0,
|
|
4071
|
+
role: typeof body?.role === "string" && body.role.trim() !== "" ? body.role.trim() : void 0,
|
|
4072
|
+
provider: typeof body?.provider === "string" && body.provider.trim() !== "" ? body.provider.trim() : void 0,
|
|
4073
|
+
model: typeof body?.model === "string" && body.model.trim() !== "" ? body.model.trim() : void 0,
|
|
4074
|
+
text
|
|
4075
|
+
};
|
|
4076
|
+
await appendUserAction(stateRoot, meetingId, action);
|
|
4077
|
+
return ok({ id: action.id });
|
|
4078
|
+
}, { requireActive: true });
|
|
4079
|
+
}
|
|
4080
|
+
case "roundtable/user-actions.list": {
|
|
4081
|
+
const body = payload;
|
|
4082
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
4083
|
+
if (meetingId === "") return fail("payload must be { meetingId }");
|
|
4084
|
+
return withMeetingRpcLock(runtime, meetingId, async (stateRoot) => {
|
|
4085
|
+
return ok(await readUserActions(stateRoot, meetingId));
|
|
4086
|
+
});
|
|
4087
|
+
}
|
|
4088
|
+
case "roundtable/models.list": {
|
|
4089
|
+
const llm = ctx.get("llm");
|
|
4090
|
+
if (llm === void 0) return ok({ providers: [] });
|
|
4091
|
+
const providers = [];
|
|
4092
|
+
for (const provider of llm.listProviders()) {
|
|
4093
|
+
let models = [];
|
|
4094
|
+
try {
|
|
4095
|
+
models = await llm.listModels(provider.id);
|
|
4096
|
+
} catch {
|
|
4097
|
+
models = [];
|
|
4098
|
+
}
|
|
4099
|
+
providers.push({
|
|
4100
|
+
id: provider.id,
|
|
4101
|
+
name: provider.name,
|
|
4102
|
+
models: models.map((model) => ({
|
|
4103
|
+
id: model.id,
|
|
4104
|
+
name: model.name
|
|
4105
|
+
}))
|
|
4106
|
+
});
|
|
4107
|
+
}
|
|
4108
|
+
return ok({ providers });
|
|
4109
|
+
}
|
|
4110
|
+
case "roundtable/kb.path.set": {
|
|
4111
|
+
const body = payload;
|
|
4112
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
4113
|
+
const raw = typeof body?.path === "string" ? body.path.trim() : "";
|
|
4114
|
+
if (meetingId === "" || raw === "") return fail("payload must be { meetingId, path }");
|
|
4115
|
+
return withMeetingRpcLock(runtime, meetingId, async (stateRoot) => {
|
|
4116
|
+
const meeting = await readMeeting(stateRoot, meetingId);
|
|
4117
|
+
if (meeting === void 0) return fail(`meeting "${meetingId}" not found`);
|
|
4118
|
+
const workspace = dirname(stateRoot);
|
|
4119
|
+
const resolved = isAbsolute(raw) ? raw : join(workspace, raw);
|
|
4120
|
+
let info;
|
|
4121
|
+
try {
|
|
4122
|
+
info = await stat(resolved);
|
|
4123
|
+
} catch {
|
|
4124
|
+
return fail(`path not found or unreadable: ${resolved}`);
|
|
4125
|
+
}
|
|
4126
|
+
if (!info.isDirectory()) return fail(`path is not a directory: ${resolved}`);
|
|
4127
|
+
meeting.kbPath = resolved;
|
|
4128
|
+
await writeMeeting(stateRoot, meeting);
|
|
4129
|
+
return ok({ path: resolved });
|
|
4130
|
+
}, { requireActive: true });
|
|
4131
|
+
}
|
|
4132
|
+
case "roundtable/kb.list": {
|
|
4133
|
+
const body = payload;
|
|
4134
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
4135
|
+
if (meetingId === "") return fail("payload must be { meetingId }");
|
|
4136
|
+
return withMeetingRpcLock(runtime, meetingId, async (stateRoot) => {
|
|
4137
|
+
const meeting = await readMeeting(stateRoot, meetingId);
|
|
4138
|
+
if (meeting === void 0) return fail(`meeting "${meetingId}" not found`);
|
|
4139
|
+
const configured = meeting.kbPath ?? "";
|
|
4140
|
+
if (configured === "") return ok({
|
|
4141
|
+
path: "",
|
|
4142
|
+
configured: false,
|
|
4143
|
+
error: "",
|
|
4144
|
+
files: []
|
|
4145
|
+
});
|
|
4146
|
+
const listing = await listKbDirectory(configured);
|
|
4147
|
+
return ok({
|
|
4148
|
+
path: configured,
|
|
4149
|
+
configured: true,
|
|
4150
|
+
error: listing.error,
|
|
4151
|
+
files: listing.files
|
|
4152
|
+
});
|
|
4153
|
+
});
|
|
4154
|
+
}
|
|
4155
|
+
case "roundtable/review.get": {
|
|
4156
|
+
const body = payload;
|
|
4157
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
4158
|
+
if (meetingId === "") return fail("payload must be { meetingId }");
|
|
4159
|
+
return withMeetingRpcLock(runtime, meetingId, async (stateRoot) => {
|
|
4160
|
+
return ok({ review: await readReview(stateRoot, meetingId) ?? null });
|
|
4161
|
+
});
|
|
4162
|
+
}
|
|
4163
|
+
case "roundtable/review.endorse": {
|
|
4164
|
+
const body = payload;
|
|
4165
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
4166
|
+
const viewpointId = typeof body?.viewpointId === "string" ? body.viewpointId : "";
|
|
4167
|
+
if (meetingId === "" || viewpointId === "") return fail("payload must be { meetingId, viewpointId }");
|
|
4168
|
+
return applyViewpointStatus(runtime, meetingId, viewpointId, "endorsed");
|
|
4169
|
+
}
|
|
4170
|
+
case "roundtable/review.setStatus": {
|
|
4171
|
+
const body = payload;
|
|
4172
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
4173
|
+
const viewpointId = typeof body?.viewpointId === "string" ? body.viewpointId : "";
|
|
4174
|
+
const status = body?.status;
|
|
4175
|
+
if (meetingId === "" || viewpointId === "") return fail("payload must be { meetingId, viewpointId, status }");
|
|
4176
|
+
if (status !== "pending" && status !== "endorsed" && status !== "rejected") return fail("status must be \"pending\" | \"endorsed\" | \"rejected\"");
|
|
4177
|
+
const rejectReason = typeof body?.reject_reason === "string" ? body.reject_reason : void 0;
|
|
4178
|
+
if (status === "rejected" && (rejectReason === void 0 || rejectReason.trim() === "")) return fail("rejecting a viewpoint requires a non-empty reject_reason (驳回必填理由)");
|
|
4179
|
+
return applyViewpointStatus(runtime, meetingId, viewpointId, status, rejectReason);
|
|
4180
|
+
}
|
|
4181
|
+
case "roundtable/feedback.append": {
|
|
4182
|
+
const body = payload;
|
|
4183
|
+
const meetingId = typeof body?.meetingId === "string" ? body.meetingId : "";
|
|
4184
|
+
const mode = typeof body?.mode === "string" ? body.mode : "";
|
|
4185
|
+
if (meetingId === "" || mode === "") return fail("payload must be { meetingId, mode, rating, ... }");
|
|
4186
|
+
const ratingRaw = typeof body?.rating === "string" ? body.rating : "";
|
|
4187
|
+
if (ratingRaw !== "good" && ratingRaw !== "meh" && ratingRaw !== "bad") return fail("rating must be \"good\" | \"meh\" | \"bad\"");
|
|
4188
|
+
const asStringList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string").slice(0, 20) : [];
|
|
4189
|
+
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 1e3) : "";
|
|
4190
|
+
const entry = {
|
|
4191
|
+
id: randomUUID(),
|
|
4192
|
+
ts: Date.now(),
|
|
4193
|
+
meetingId,
|
|
4194
|
+
mode,
|
|
4195
|
+
providers: asStringList(body?.providers),
|
|
4196
|
+
models: asStringList(body?.models),
|
|
4197
|
+
usedRounds: typeof body?.usedRounds === "number" ? Math.max(0, Math.floor(body.usedRounds)) : 0,
|
|
4198
|
+
usedTokens: typeof body?.usedTokens === "number" ? Math.max(0, Math.floor(body.usedTokens)) : 0,
|
|
4199
|
+
rating: ratingRaw,
|
|
4200
|
+
note: note === "" ? void 0 : note
|
|
4201
|
+
};
|
|
4202
|
+
return withMeetingRpcLock(runtime, meetingId, async (stateRoot) => {
|
|
4203
|
+
await clearFeedbackForMeeting(stateRoot, meetingId);
|
|
4204
|
+
await appendFeedback(stateRoot, entry);
|
|
4205
|
+
return ok({ id: entry.id });
|
|
4206
|
+
});
|
|
4207
|
+
}
|
|
4208
|
+
case "roundtable/feedback.list": {
|
|
4209
|
+
const body = payload;
|
|
4210
|
+
const roots = await feedbackStateRoots(runtime, typeof body?.workspace === "string" ? body.workspace : "");
|
|
4211
|
+
const entries = [];
|
|
4212
|
+
for (const stateRoot of roots) entries.push(...await readFeedback(stateRoot));
|
|
4213
|
+
entries.sort((a, b) => b.ts - a.ts);
|
|
4214
|
+
return ok({ entries: entries.slice(0, 200) });
|
|
4215
|
+
}
|
|
4216
|
+
case "roundtable/feedback.clear": {
|
|
4217
|
+
const body = payload;
|
|
4218
|
+
const roots = await feedbackStateRoots(runtime, typeof body?.workspace === "string" ? body.workspace : "");
|
|
4219
|
+
let cleared = 0;
|
|
4220
|
+
for (const stateRoot of roots) cleared += await clearFeedback(stateRoot);
|
|
4221
|
+
return ok({ cleared });
|
|
4222
|
+
}
|
|
4223
|
+
default: return fail(`unknown endpoint: ${endpoint}`);
|
|
4224
|
+
}
|
|
4225
|
+
} catch (error) {
|
|
4226
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
4227
|
+
}
|
|
4228
|
+
};
|
|
4229
|
+
ctx.inject(["connection"], (connectionCtx) => {
|
|
4230
|
+
effectWithOptionalDisposer(connectionCtx, connectionCtx.connection.rpc.handle("/roundtable", dispatch), "roundtable: rpc channel");
|
|
4231
|
+
});
|
|
4232
|
+
return dispatch;
|
|
4233
|
+
}
|
|
4234
|
+
/**
|
|
4235
|
+
* Resolve the state roots whose feedback.jsonl should be read/cleared:
|
|
4236
|
+
* every known workspace candidate (optionally narrowed to one named
|
|
4237
|
+
* workspace). A candidate whose state root directory does not exist yet
|
|
4238
|
+
* contributes nothing.
|
|
4239
|
+
*/
|
|
4240
|
+
async function feedbackStateRoots(runtime, workspaceName) {
|
|
4241
|
+
const { workspaceCandidates: workspaceCandidates$1 } = await import("./workspace-candidates-B7isleLt.js");
|
|
4242
|
+
const { stat: stat$1 } = await import("node:fs/promises");
|
|
4243
|
+
const candidates = workspaceCandidates$1().filter((candidate) => workspaceName === void 0 || candidate.title === workspaceName);
|
|
4244
|
+
const roots = [];
|
|
4245
|
+
for (const candidate of candidates) {
|
|
4246
|
+
const root = stateRootOf(candidate.path, runtime.stateDir);
|
|
4247
|
+
try {
|
|
4248
|
+
if ((await stat$1(root)).isDirectory()) roots.push(root);
|
|
4249
|
+
} catch {}
|
|
4250
|
+
}
|
|
4251
|
+
return roots;
|
|
4252
|
+
}
|
|
4253
|
+
/**
|
|
4254
|
+
* List the first level of a knowledge-base directory (browse-only): skips
|
|
4255
|
+
* dot-hidden entries, caps the result at 300 items so a huge folder cannot
|
|
4256
|
+
* blow up the UI, and tolerates per-entry stat failures.
|
|
4257
|
+
*/
|
|
4258
|
+
async function listKbDirectory(dir) {
|
|
4259
|
+
let info;
|
|
4260
|
+
try {
|
|
4261
|
+
info = await stat(dir);
|
|
4262
|
+
} catch {
|
|
4263
|
+
return {
|
|
4264
|
+
error: "path not found or unreadable",
|
|
4265
|
+
files: []
|
|
4266
|
+
};
|
|
4267
|
+
}
|
|
4268
|
+
if (!info.isDirectory()) return {
|
|
4269
|
+
error: "not a directory",
|
|
4270
|
+
files: []
|
|
4271
|
+
};
|
|
4272
|
+
let entries;
|
|
4273
|
+
try {
|
|
4274
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
4275
|
+
} catch {
|
|
4276
|
+
return {
|
|
4277
|
+
error: "directory unreadable",
|
|
4278
|
+
files: []
|
|
4279
|
+
};
|
|
4280
|
+
}
|
|
4281
|
+
const files = [];
|
|
4282
|
+
for (const entry of entries) {
|
|
4283
|
+
if (entry.name.startsWith(".")) continue;
|
|
4284
|
+
const full = join(dir, entry.name);
|
|
4285
|
+
let size = 0;
|
|
4286
|
+
let mtimeMs = 0;
|
|
4287
|
+
try {
|
|
4288
|
+
const entryStat = await stat(full);
|
|
4289
|
+
size = entryStat.size;
|
|
4290
|
+
mtimeMs = entryStat.mtimeMs;
|
|
4291
|
+
} catch {}
|
|
4292
|
+
files.push({
|
|
4293
|
+
name: entry.name,
|
|
4294
|
+
kind: entry.isDirectory() ? "dir" : "file",
|
|
4295
|
+
ext: entry.isDirectory() ? "" : extname(entry.name).replace(/^\./, "").toLowerCase(),
|
|
4296
|
+
size,
|
|
4297
|
+
mtimeMs
|
|
4298
|
+
});
|
|
4299
|
+
if (files.length >= 300) break;
|
|
4300
|
+
}
|
|
4301
|
+
return {
|
|
4302
|
+
error: "",
|
|
4303
|
+
files
|
|
4304
|
+
};
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4307
|
+
//#endregion
|
|
4308
|
+
//#region src/index.ts
|
|
4309
|
+
const name = "roundtable";
|
|
4310
|
+
/**
|
|
4311
|
+
* REQUIRED services only — and the list is load-gating, which is why it is
|
|
4312
|
+
* short. cordis has no "optional inject" form (`Inject` is `string[] | Record`
|
|
4313
|
+
* and the record form is only intercept config), so every name listed here
|
|
4314
|
+
* keeps the whole plugin fiber PENDING until that service exists: no
|
|
4315
|
+
* `roundtable_*` tools, no `/plugins/dsh-plugin-roundtable/*` routes and no GUI
|
|
4316
|
+
* tab. A missing OPTIONAL capability listed here therefore presents to the user
|
|
4317
|
+
* as "圆桌会议整个不见了 / 无法调用界面" with no error anywhere.
|
|
4318
|
+
*
|
|
4319
|
+
* `userQuestions` and `skills` are deliberately NOT listed: both are optional
|
|
4320
|
+
* capabilities that the plugin reads live through `ctx.get(...)`:
|
|
4321
|
+
* - skills.ts hard constraint #1 — the plugin MUST load in a profile without
|
|
4322
|
+
* the skill service and degrade to an empty catalog;
|
|
4323
|
+
* - tools.ts falls back to `decision: 'unavailable'` when the userQuestions
|
|
4324
|
+
* service is absent (the captain then states the draft in words).
|
|
4325
|
+
* v0.2.36 removed both from this list; v0.2.21 had already fixed the mirrored
|
|
4326
|
+
* over-constraint on the browser half (`connection`).
|
|
4327
|
+
*/
|
|
4328
|
+
const inject = [
|
|
4329
|
+
"tools",
|
|
4330
|
+
"subagents",
|
|
4331
|
+
"agents",
|
|
4332
|
+
"systemPrompt"
|
|
4333
|
+
];
|
|
4334
|
+
const Config = z.object({
|
|
4335
|
+
stateDir: z.string().default(".roundtable"),
|
|
4336
|
+
memberProvider: z.string().default("spawn"),
|
|
4337
|
+
maxNodes: z.natural().min(1).default(8),
|
|
4338
|
+
defaultMode: z.union([
|
|
4339
|
+
"orchestrated",
|
|
4340
|
+
"egalitarian",
|
|
4341
|
+
"redteam"
|
|
4342
|
+
]).default("orchestrated"),
|
|
4343
|
+
memberMaxDepth: z.natural().default(1),
|
|
4344
|
+
promptSectionOrder: z.natural().default(116)
|
|
4345
|
+
});
|
|
4346
|
+
function apply(ctx, config) {
|
|
4347
|
+
const resolved = {
|
|
4348
|
+
stateDir: config.stateDir ?? ".roundtable",
|
|
4349
|
+
memberProvider: config.memberProvider ?? "spawn",
|
|
4350
|
+
maxNodes: config.maxNodes ?? 8,
|
|
4351
|
+
defaultMode: config.defaultMode ?? "orchestrated",
|
|
4352
|
+
memberMaxDepth: config.memberMaxDepth ?? 1
|
|
4353
|
+
};
|
|
4354
|
+
ctx.systemPrompt.section({
|
|
4355
|
+
name: "roundtable:usage",
|
|
4356
|
+
order: config.promptSectionOrder ?? 116,
|
|
4357
|
+
text: usageSectionText()
|
|
4358
|
+
});
|
|
4359
|
+
const runtime = {
|
|
4360
|
+
prefs: createPreferenceStore({
|
|
4361
|
+
base: defaultPreferences(resolved.defaultMode),
|
|
4362
|
+
log: (message, error) => {
|
|
4363
|
+
if (error === void 0) ctx.logger.warn(message);
|
|
4364
|
+
else ctx.logger.warn(message, error);
|
|
4365
|
+
}
|
|
4366
|
+
}),
|
|
4367
|
+
stateDir: resolved.stateDir
|
|
4368
|
+
};
|
|
4369
|
+
const applyDefaultMode = (preference) => {
|
|
4370
|
+
if (preference.defaultMode === "orchestrated" || preference.defaultMode === "egalitarian" || preference.defaultMode === "redteam") resolved.defaultMode = preference.defaultMode;
|
|
4371
|
+
};
|
|
4372
|
+
runtime.prefs.watch(applyDefaultMode);
|
|
4373
|
+
runtime.prefs.ready().then(() => {
|
|
4374
|
+
applyDefaultMode(runtime.prefs.get());
|
|
4375
|
+
}).catch(() => {});
|
|
4376
|
+
registerRoundTableTools(ctx, {
|
|
4377
|
+
stateDir: resolved.stateDir,
|
|
4378
|
+
memberProvider: resolved.memberProvider,
|
|
4379
|
+
maxNodes: resolved.maxNodes,
|
|
4380
|
+
defaultMode: resolved.defaultMode,
|
|
4381
|
+
memberMaxDepth: resolved.memberMaxDepth,
|
|
4382
|
+
reviewSplit: {
|
|
4383
|
+
provider: "deepseek-official",
|
|
4384
|
+
model: "deepseek-v4-flash",
|
|
4385
|
+
maxOpinions: 3
|
|
4386
|
+
},
|
|
4387
|
+
getExpertLimits: () => {
|
|
4388
|
+
const prefs = runtime.prefs.get();
|
|
4389
|
+
return {
|
|
4390
|
+
maxTokens: prefs.expertMaxTokens ?? 0,
|
|
4391
|
+
maxOpinions: prefs.expertMaxOpinions ?? 0
|
|
4392
|
+
};
|
|
4393
|
+
},
|
|
4394
|
+
getSkillDelivery: () => runtime.prefs.get().skillDelivery ?? "relay",
|
|
4395
|
+
getPlannedDefaults: () => {
|
|
4396
|
+
const prefs = runtime.prefs.get();
|
|
4397
|
+
return {
|
|
4398
|
+
mode: prefs.defaultMode,
|
|
4399
|
+
maxRounds: prefs.maxRounds,
|
|
4400
|
+
maxTokens: prefs.maxTokens,
|
|
4401
|
+
skillDelivery: prefs.skillDelivery ?? "relay"
|
|
4402
|
+
};
|
|
4403
|
+
}
|
|
4404
|
+
});
|
|
4405
|
+
attachNodeEvents(ctx);
|
|
4406
|
+
const dispatchRpc = registerRpc(ctx, runtime);
|
|
4407
|
+
let webRegistered = false;
|
|
4408
|
+
let rpcRouteRegistered = false;
|
|
4409
|
+
const registerWebSurface = () => {
|
|
4410
|
+
const webServer = webRouteHostOf(ctx);
|
|
4411
|
+
if (webServer === void 0) return;
|
|
4412
|
+
const fence = connectionFenceOf(ctx.get("connection"));
|
|
4413
|
+
if (!rpcRouteRegistered) {
|
|
4414
|
+
rpcRouteRegistered = true;
|
|
4415
|
+
ctx.effect(() => webServer.register({
|
|
4416
|
+
kind: "exact",
|
|
4417
|
+
path: RPC_ROUTE,
|
|
4418
|
+
handler: async (req, res) => {
|
|
4419
|
+
const send = (status, body$1) => {
|
|
4420
|
+
res.writeHead(status, {
|
|
4421
|
+
"content-type": "application/json; charset=utf-8",
|
|
4422
|
+
"cache-control": "no-store"
|
|
4423
|
+
});
|
|
4424
|
+
res.end(JSON.stringify(body$1));
|
|
4425
|
+
};
|
|
4426
|
+
const rejection = rejectWebRequest(fence, req.headers);
|
|
4427
|
+
if (rejection !== void 0) {
|
|
4428
|
+
send(rejection, {
|
|
4429
|
+
ok: false,
|
|
4430
|
+
error: {
|
|
4431
|
+
code: "forbidden",
|
|
4432
|
+
message: "connection trust check failed"
|
|
4433
|
+
}
|
|
4434
|
+
});
|
|
4435
|
+
return;
|
|
4436
|
+
}
|
|
4437
|
+
if ((req.method ?? "GET").toUpperCase() !== "POST") {
|
|
4438
|
+
send(405, {
|
|
4439
|
+
ok: false,
|
|
4440
|
+
error: {
|
|
4441
|
+
code: "internal",
|
|
4442
|
+
message: "this route accepts POST only"
|
|
4443
|
+
}
|
|
4444
|
+
});
|
|
4445
|
+
return;
|
|
4446
|
+
}
|
|
4447
|
+
let body;
|
|
4448
|
+
try {
|
|
4449
|
+
const chunks = [];
|
|
4450
|
+
let size = 0;
|
|
4451
|
+
for await (const chunk of req) {
|
|
4452
|
+
size += chunk.length;
|
|
4453
|
+
if (size > MAX_RPC_BODY_BYTES) {
|
|
4454
|
+
send(413, {
|
|
4455
|
+
ok: false,
|
|
4456
|
+
error: {
|
|
4457
|
+
code: "internal",
|
|
4458
|
+
message: "request body too large"
|
|
4459
|
+
}
|
|
4460
|
+
});
|
|
4461
|
+
return;
|
|
4462
|
+
}
|
|
4463
|
+
chunks.push(chunk);
|
|
4464
|
+
}
|
|
4465
|
+
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
4466
|
+
} catch {
|
|
4467
|
+
send(400, {
|
|
4468
|
+
ok: false,
|
|
4469
|
+
error: {
|
|
4470
|
+
code: "internal",
|
|
4471
|
+
message: "body must be JSON"
|
|
4472
|
+
}
|
|
4473
|
+
});
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4476
|
+
const message = body;
|
|
4477
|
+
if (message === null || typeof message !== "object" || typeof message.endpoint !== "string") {
|
|
4478
|
+
send(400, {
|
|
4479
|
+
ok: false,
|
|
4480
|
+
error: {
|
|
4481
|
+
code: "internal",
|
|
4482
|
+
message: "body must be { endpoint, payload? }"
|
|
4483
|
+
}
|
|
4484
|
+
});
|
|
4485
|
+
return;
|
|
4486
|
+
}
|
|
4487
|
+
send(200, await dispatchRpc(message.endpoint, message.payload));
|
|
4488
|
+
}
|
|
4489
|
+
}), "roundtable: rpc route");
|
|
4490
|
+
}
|
|
4491
|
+
if (webRegistered) return;
|
|
4492
|
+
const workspaceRegistry = workspaceRegistryOf(ctx);
|
|
4493
|
+
if (workspaceRegistry === void 0) return;
|
|
4494
|
+
webRegistered = true;
|
|
4495
|
+
const refreshCandidates = () => {
|
|
4496
|
+
setWorkspaceCandidates(workspaceRegistry.list().map((workspace) => ({
|
|
4497
|
+
title: workspace.title,
|
|
4498
|
+
path: workspace.path
|
|
4499
|
+
})));
|
|
4500
|
+
};
|
|
4501
|
+
refreshCandidates();
|
|
4502
|
+
ctx.effect(() => webServer.register({
|
|
4503
|
+
kind: "exact",
|
|
4504
|
+
path: "/plugins/dsh-plugin-roundtable/state",
|
|
4505
|
+
handler: async (req, res) => {
|
|
4506
|
+
const rejection = rejectWebRequest(fence, req.headers);
|
|
4507
|
+
if (rejection !== void 0) {
|
|
4508
|
+
res.writeHead(rejection, {
|
|
4509
|
+
"content-type": "application/json; charset=utf-8",
|
|
4510
|
+
"cache-control": "no-store"
|
|
4511
|
+
});
|
|
4512
|
+
res.end(JSON.stringify({ meetings: [] }));
|
|
4513
|
+
return;
|
|
4514
|
+
}
|
|
4515
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
4516
|
+
const snapshots = await collectMeetingSnapshots(ctx, workspaceCandidates().map((workspace) => ({
|
|
4517
|
+
workspace: workspace.title,
|
|
4518
|
+
stateRoot: join(workspace.path, resolved.stateDir)
|
|
4519
|
+
})), url.searchParams.get("session") ?? void 0);
|
|
4520
|
+
res.writeHead(200, {
|
|
4521
|
+
"content-type": "application/json; charset=utf-8",
|
|
4522
|
+
"cache-control": "no-store"
|
|
4523
|
+
});
|
|
4524
|
+
res.end(JSON.stringify({ meetings: snapshots }));
|
|
4525
|
+
}
|
|
4526
|
+
}), "roundtable: snapshot route");
|
|
4527
|
+
};
|
|
4528
|
+
registerWebSurface();
|
|
4529
|
+
ctx.on("internal/service", (serviceName) => {
|
|
4530
|
+
if (WEB_SERVER_KEYS.includes(serviceName) || WORKSPACE_KEYS.includes(serviceName)) registerWebSurface();
|
|
4531
|
+
});
|
|
4532
|
+
}
|
|
4533
|
+
|
|
4534
|
+
//#endregion
|
|
4535
|
+
export { Config, apply, inject, name };
|