@y9931666/dsh-plugin-crossfire 0.1.0 → 0.1.1

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.
@@ -1,3 +1,3 @@
1
- import { a as newReviewId, c as reviewDirOf, d as withCrossfireLock, f as writeJsonAtomic, i as listReviewIds, l as reviewLockKey, n as appendJsonLine, o as readJsonFile, p as writeTextAtomic, r as crossfireRootOf, s as readJsonLines, t as CROSSFIRE_NAMESPACE, u as stateRootOf } from "./state-DzrgVdrB.js";
1
+ import { a as newReviewId, c as reviewDirOf, d as withCrossfireLock, f as writeJsonAtomic, i as listReviewIds, l as reviewLockKey, m as writeTextAtomicWithBom, n as appendJsonLine, o as readJsonFile, p as writeTextAtomic, r as crossfireRootOf, s as readJsonLines, t as CROSSFIRE_NAMESPACE, u as stateRootOf } from "./state-DvF_Jqcg.js";
2
2
 
3
- export { CROSSFIRE_NAMESPACE, appendJsonLine, crossfireRootOf, listReviewIds, newReviewId, readJsonFile, readJsonLines, reviewDirOf, reviewLockKey, stateRootOf, withCrossfireLock, writeJsonAtomic, writeTextAtomic };
3
+ export { CROSSFIRE_NAMESPACE, appendJsonLine, crossfireRootOf, listReviewIds, newReviewId, readJsonFile, readJsonLines, reviewDirOf, reviewLockKey, stateRootOf, withCrossfireLock, writeJsonAtomic, writeTextAtomic, writeTextAtomicWithBom };
@@ -71,6 +71,18 @@ async function writeTextAtomic(file, text) {
71
71
  await writeFile(tmp, text, "utf8");
72
72
  await rename(tmp, file);
73
73
  }
74
+ /**
75
+ * 原子写一份**带 UTF-8 BOM** 的文本,供人用记事本直接打开。
76
+ *
77
+ * 为什么单独一个函数:Windows 记事本对"无 BOM 的 UTF-8"时常误判成 ANSI,中文变乱码。
78
+ * 而 `.md` 扩展名在不少机器上被别的程序抢走,双击进不了记事本。所以导出时额外写一份
79
+ * `.txt`(带 BOM)—— 用户反馈"导出来的产物我无法用记事本查看"就是这个原因。
80
+ *
81
+ * BOM 只加在这份人读副本上:`.md` 保持无 BOM,交给 Markdown 渲染器更干净。
82
+ */
83
+ async function writeTextAtomicWithBom(file, text) {
84
+ await writeTextAtomic(file, `\uFEFF${text}`);
85
+ }
74
86
  /** Atomically publish a JSON file (same-directory tmp + rename). */
75
87
  async function writeJsonAtomic(file, value) {
76
88
  await writeTextAtomic(file, JSON.stringify(value, null, 2));
@@ -135,4 +147,4 @@ async function listReviewIds(stateRoot) {
135
147
  }
136
148
 
137
149
  //#endregion
138
- export { newReviewId as a, reviewDirOf as c, withCrossfireLock as d, writeJsonAtomic as f, listReviewIds as i, reviewLockKey as l, appendJsonLine as n, readJsonFile as o, writeTextAtomic as p, crossfireRootOf as r, readJsonLines as s, CROSSFIRE_NAMESPACE as t, stateRootOf as u };
150
+ export { newReviewId as a, reviewDirOf as c, withCrossfireLock as d, writeJsonAtomic as f, listReviewIds as i, reviewLockKey as l, writeTextAtomicWithBom as m, appendJsonLine as n, readJsonFile as o, writeTextAtomic as p, crossfireRootOf as r, readJsonLines as s, CROSSFIRE_NAMESPACE as t, stateRootOf as u };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 观点标题:一条缺陷卡片的"抬头"。
3
+ *
4
+ * 红队的报告信封要求每条发言以 `观点 N(指向 X,维度Y):正文` 开头,而这个前缀
5
+ * 恰恰就是最有信息量的一行(编号 + 定位 + 维度)。用户反馈:"观点虽然说我可以理解,
6
+ * 把完整内容给写出来了,但我觉得还是要在最开始那里立一个标题,这样目的明确"。
7
+ *
8
+ * ## 为什么单独一个模块
9
+ *
10
+ * 标题按钮是**折叠态唯一可见的内容**,所以这里的两条边界(空括号信封、内嵌换行)
11
+ * 一旦退化,肉眼看到的是"整张卡片塌成一团",而不是一条错误文案。它是纯字符串函数,
12
+ * 因此抽出来直接单测 —— 不需要 jsdom / react 测试环境(本仓库只有 `node:test`)。
13
+ *
14
+ * 兜底顺序:① 用信封前缀(`观点 N(指向 X,维度Y)`)→ ② 未按格式提交时取首句
15
+ * (按换行、再按中英文句末标点切,限长)。**绝不**返回空串 —— 标题位空了比不显示更糟。
16
+ */
17
+ /**
18
+ * 从一条缺陷正文里导出标题。
19
+ *
20
+ * @param content 缺陷原文(可以是任意多行文本)。
21
+ * @returns 非空标题:信封前缀,或首句/首行,最长 {@link TITLE_MAX} 字符。
22
+ */
23
+ export declare function defectTitle(content: string): string;
@@ -8,7 +8,7 @@
8
8
  */
9
9
  export declare const NS = "crossfire";
10
10
  /** 本插件用到的全部文案键。新增键必须同时补 zh 与 en,否则 Record 类型会报错。 */
11
- export type CrossfireKey = 'renderFailed' | 'renderFailedHint' | 'tab' | 'settingsNav' | 'settingsTitle' | 'settingsIntro' | 'boundaryTitle' | 'boundaryLine1' | 'boundaryLine2' | 'boundaryLine3' | 'boundaryLine4' | 'defaultIntensity' | 'intensityQuick' | 'intensityStandard' | 'intensityDeep' | 'intensityHint' | 'intensityPerDimension' | 'intensityCrossYes' | 'intensityCrossNo' | 'intensityRecheckCap' | 'dimensionsTitle' | 'dimensionsHint' | 'poolLabel' | 'customLabel' | 'customEmpty' | 'customPlaceholder' | 'customAdd' | 'customRemove' | 'customLimit' | 'customInvalid' | 'budgetTitle' | 'budgetHint' | 'budgetRounds' | 'budgetTokens' | 'expertMaxTokens' | 'expertMaxOpinions' | 'unlimited' | 'loading' | 'loadFailed' | 'saved' | 'saveFailed' | 'batchNote' | 'cardTitle' | 'statusDraft' | 'statusAttacking' | 'statusJudging' | 'statusSettled' | 'statusNext' | 'statusStrip' | 'statusHintDraft' | 'statusHintAttacking' | 'statusHintJudging' | 'statusHintSettled' | 'passBadge' | 'subjectLabel' | 'stanceLabel' | 'stanceEmpty' | 'dimensionsLabel' | 'expertsLabel' | 'expertPending' | 'expertRunning' | 'expertDone' | 'expertFailed' | 'expertSilent' | 'expertDefects' | 'expertReported' | 'expertSubmitted' | 'expertNoOutput' | 'impactTitle' | 'countEndorsed' | 'countRejected' | 'countPending' | 'defectEmpty' | 'defectsTitle' | 'evidenceRepro' | 'evidenceArgument' | 'strikesStance' | 'dimensionFallback' | 'recheckLabel' | 'recheckNote' | 'verdictFixed' | 'verdictUnfixed' | 'verdictPartial' | 'verdictUndecided' | 'recheckNoteNeedsVerdict' | 'btnAttack' | 'btnCollect' | 'btnFinish' | 'btnRecheck' | 'btnExport' | 'btnEndorse' | 'btnReject' | 'btnPending' | 'rejectPrompt' | 'rejectPlaceholder' | 'rejectConfirm' | 'rejectCancel' | 'busyWorking' | 'noReviews' | 'cardHint' | 'actionFailed' | 'cardFooterNote' | 'noSessionHint' | 'expand' | 'collapse' | 'expandSection' | 'collapseSection' | 'expertsTitle' | 'expertRosterHint' | 'expertRoleLabel' | 'expertRolePlaceholder' | 'expertRoleEdit' | 'expertRoleSaved' | 'expertRoleFromRoster' | 'stanceSectionTitle' | 'stanceExpand' | 'stanceCollapse' | 'explainDismiss' | 'recheckApprovalAsk' | 'recheckApprovalYes' | 'recheckApprovalNo' | 'settingsExplainTitle' | 'settingsExplainRestore' | 'settingsExplainNone' | 'settingsExplainCount' | 'stanceEdit' | 'stanceSave' | 'stanceSaved' | 'stanceHint' | 'exportDone' | 'exportFailed' | 'settingsPresetsTitle' | 'settingsPresetsHint' | 'settingsPresetsEmpty' | 'presetName' | 'presetRole' | 'presetProvider' | 'presetModel' | 'modelInherit' | 'presetAdd' | 'presetAddCount' | 'presetLimitInline' | 'modelRoute' | 'modelCatalogLoading' | 'modelCatalogFailed' | 'modelCatalogEmpty' | 'presetProviderNeeded' | 'presetSave' | 'presetCancel' | 'presetEdit' | 'presetDelete' | 'presetInvalid' | 'presetLimit' | 'presetSaved' | 'settingsSquadsTitle' | 'settingsSquadsHint' | 'settingsSquadsEmpty' | 'squadAdd' | 'squadAddCount' | 'squadName' | 'squadMembers' | 'squadMemberKey' | 'squadMemberRole' | 'squadAddMember' | 'squadRemoveMember' | 'squadSave' | 'squadCancel' | 'squadEdit' | 'squadDelete' | 'squadInvalid' | 'squadSaved' | 'squadLimit' | 'squadMemberCount';
11
+ export type CrossfireKey = 'renderFailed' | 'renderFailedHint' | 'tab' | 'settingsNav' | 'settingsTitle' | 'settingsIntro' | 'boundaryTitle' | 'boundaryLine1' | 'boundaryLine2' | 'boundaryLine3' | 'boundaryLine4' | 'defaultIntensity' | 'intensityQuick' | 'intensityStandard' | 'intensityDeep' | 'intensityHint' | 'intensityPerDimension' | 'intensityCrossYes' | 'intensityCrossNo' | 'intensityRecheckCap' | 'dimensionsTitle' | 'dimensionsHint' | 'poolLabel' | 'customLabel' | 'customEmpty' | 'customPlaceholder' | 'customAdd' | 'customRemove' | 'customLimit' | 'customInvalid' | 'budgetTitle' | 'budgetHint' | 'budgetRounds' | 'budgetTokens' | 'expertMaxTokens' | 'expertMaxOpinions' | 'unlimited' | 'loading' | 'loadFailed' | 'saved' | 'saveFailed' | 'batchNote' | 'cardTitle' | 'statusDraft' | 'statusAttacking' | 'statusJudging' | 'statusSettled' | 'statusNext' | 'statusStrip' | 'statusHintDraft' | 'statusHintAttacking' | 'statusHintJudging' | 'statusHintSettled' | 'passBadge' | 'subjectLabel' | 'stanceLabel' | 'stanceEmpty' | 'dimensionsLabel' | 'expertsLabel' | 'expertPending' | 'expertRunning' | 'expertDone' | 'expertFailed' | 'expertSilent' | 'expertDefects' | 'expertReported' | 'expertSubmitted' | 'expertNoOutput' | 'impactTitle' | 'countEndorsed' | 'countRejected' | 'countPending' | 'defectEmpty' | 'defectsTitle' | 'evidenceRepro' | 'evidenceArgument' | 'strikesStance' | 'dimensionFallback' | 'recheckLabel' | 'recheckNote' | 'verdictFixed' | 'verdictUnfixed' | 'verdictPartial' | 'verdictUndecided' | 'recheckNoteNeedsVerdict' | 'btnAttack' | 'btnCollect' | 'btnFinish' | 'btnRecheck' | 'btnExport' | 'btnEndorse' | 'btnReject' | 'btnPending' | 'rejectPrompt' | 'rejectPlaceholder' | 'rejectConfirm' | 'rejectCancel' | 'busyWorking' | 'noReviews' | 'cardHint' | 'actionFailed' | 'cardFooterNote' | 'noSessionHint' | 'expand' | 'collapse' | 'expandSection' | 'collapseSection' | 'expertsTitle' | 'expertRosterHint' | 'expertRoleLabel' | 'expertRolePlaceholder' | 'expertRoleEdit' | 'expertRoleSaved' | 'expertRoleFromRoster' | 'expertPromptView' | 'expertPromptTitle' | 'stanceSectionTitle' | 'stanceExpand' | 'stanceCollapse' | 'explainDismiss' | 'recheckApprovalAsk' | 'recheckApprovalYes' | 'recheckApprovalNo' | 'settingsExplainTitle' | 'settingsExplainRestore' | 'settingsExplainNone' | 'settingsExplainCount' | 'stanceEdit' | 'stanceSave' | 'stanceSaved' | 'stanceHint' | 'exportDone' | 'exportFailed' | 'settingsPresetsTitle' | 'settingsPresetsHint' | 'settingsPresetsEmpty' | 'presetName' | 'presetRole' | 'presetProvider' | 'presetModel' | 'modelInherit' | 'presetAdd' | 'presetAddCount' | 'presetLimitInline' | 'modelRoute' | 'modelCatalogLoading' | 'modelCatalogFailed' | 'modelCatalogEmpty' | 'presetProviderNeeded' | 'presetSave' | 'presetCancel' | 'presetEdit' | 'presetDelete' | 'presetInvalid' | 'presetLimit' | 'presetSaved' | 'settingsSquadsTitle' | 'settingsSquadsHint' | 'settingsSquadsEmpty' | 'squadAdd' | 'squadAddCount' | 'squadName' | 'squadMembers' | 'squadMemberKey' | 'squadMemberRole' | 'squadAddMember' | 'squadRemoveMember' | 'squadSave' | 'squadCancel' | 'squadEdit' | 'squadDelete' | 'squadInvalid' | 'squadSaved' | 'squadLimit' | 'squadMemberCount';
12
12
  declare module '@deepseek-ai/dsh-client-ui-slots' {
13
13
  interface LocaleNamespaceMap {
14
14
  /** The crossfire settings page copy. */
@@ -0,0 +1,63 @@
1
+ /**
2
+ * "查看专家原提示词"的展开状态机。
3
+ *
4
+ * ## 它修的是什么
5
+ *
6
+ * 展开区此前用"`promptViews[key]` 是否存在"同时表达两件事:**是否有缓存**和
7
+ * **是否展开**。一个变量两个含义,于是两处都错:
8
+ *
9
+ * 1. 命中缓存时直接 `delete`,注释承诺的"只拉一次"变成"每次收起都丢掉缓存",
10
+ * 再点就是一次新的 RPC;
11
+ * 2. 收起发生在请求返回**之前**时,条目被删掉,但异步回调**无条件**把结果写回,
12
+ * 界面在用户已经收起之后又自己冒出来(反弹)。
13
+ *
14
+ * 修法是显式三态(`loading` / `ready` / `error`)+ 两个纯函数:
15
+ * - {@link togglePromptView}:**看有无条目**决定"发起加载"还是"收起",绝不二义;
16
+ * - {@link applyPromptResult}:只在**该键仍处于 loading**时写回结果 —— 用户已收起
17
+ * (键被删)或已经拿到别的结果时,迟到的响应一律丢弃,不再靠 `alive` ref 兜底
18
+ * (那个只在组件卸载时变化,挡不住"收起")。
19
+ *
20
+ * 状态转移与 React 无关,因此可以脱离渲染直接单测。
21
+ */
22
+ /** 一个 viewKey 对应的展开状态。`loading` 的条目存在 = 已展开且正在等结果。 */
23
+ export type PromptView = {
24
+ status: 'loading';
25
+ } | {
26
+ status: 'ready';
27
+ persona: string;
28
+ } | {
29
+ status: 'error';
30
+ message: string;
31
+ };
32
+ /** 展开表:键为 `${reviewId}::${expertKey}`。 */
33
+ export type PromptViewTable = Record<string, PromptView>;
34
+ /**
35
+ * 一次点击的结果:新的展开表 + 这次点击**是否需要真的发请求**。
36
+ *
37
+ * `start` 为 false 即"这只是一次收起",调用方不得发 RPC。
38
+ */
39
+ export interface PromptViewToggle {
40
+ readonly table: PromptViewTable;
41
+ readonly start: boolean;
42
+ }
43
+ /**
44
+ * 点击"查看原提示词":无条目 → 写入 `loading` 并要求发请求;有条目 → 删除该条目
45
+ * (收起),`start` 为 false。
46
+ *
47
+ * 三态一律同样对待:`ready` / `error` / 正在 `loading` 的条目,再点都是收起。
48
+ * 失败后保留 `error` 条目正是为此 —— 用户再点即收起,而不是无限重拉。
49
+ */
50
+ export declare function togglePromptView(table: PromptViewTable, key: string): PromptViewToggle;
51
+ /**
52
+ * 把一次 RPC 结果写回展开表。
53
+ *
54
+ * **只有该键仍处于 `loading` 时才写回**。用户已收起(键不存在)、或该键已经处于
55
+ * 终态时,返回原表**引用不变** —— 这样 `setState` 不会触发一次无意义的重渲染。
56
+ */
57
+ export declare function applyPromptResult(table: PromptViewTable, key: string, result: {
58
+ readonly ok: true;
59
+ readonly persona: string;
60
+ } | {
61
+ readonly ok: false;
62
+ readonly message: string;
63
+ }): PromptViewTable;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 会话身份的**浏览器侧缓存**(设置页保存失败的真实修复)。
3
+ *
4
+ * ## 它修的是什么
5
+ *
6
+ * `crossfire/prefs.set` 在宿主侧要求"必须声称一个身份"(`judgeActor(undefined, session)`)。
7
+ * 而宿主的 slot 契约决定了设置页**结构上拿不到** `sessionId` —— 运行时 slot 目录里:
8
+ *
9
+ * conversation.view → standardProps 含 `sessionId: SessionId`
10
+ * tool.call.toolview → 含
11
+ * settings.section → **不含**(只有 useResource / useWorkspaces / usePanelInfo /
12
+ * useSessions / useSessionStatus / useSessionRetainInfo)
13
+ *
14
+ * 于是卡片(conversation.view / toolcall)保存偏好成功,而设置页的每一次保存都被
15
+ * 拒成"保存失败"—— Bug 的真实形态:**角色预设 / 阵容预设 / 强度 / 预算全部存不进去**。
16
+ *
17
+ * ## 为什么用一个模块级缓存而不是给设置页造假身份
18
+ *
19
+ * 设置页确实是全局的(偏好也是全局的),但它不能在**不知道自己是谁**的情况下写入。
20
+ * 而会话标签页(`conversation.view`)与轨迹卡片(`tool.call.toolview`)都拿到了宿主
21
+ * 注入的真实 `sessionId` —— 用户打开过任意一个,就等于把身份告诉了插件。这里把它记下来,
22
+ * 供设置页在提交时声明。
23
+ *
24
+ * ## 诚实的边界
25
+ *
26
+ * 冷启动后**直接**打开设置页、且本次会话从未渲染过上述任一入口时,缓存是空的,
27
+ * 保存仍会被宿主拒绝(这是刻意的:插件不编造身份)。绕开方式很简单——先看一眼
28
+ * 「针锋」标签页或对话卡片,再回来保存。
29
+ */
30
+ /**
31
+ * 记下宿主注入的会话 id。
32
+ *
33
+ * 只接受非空字符串;重复值不产生副作用。返回是否**发生了变化**,便于调用方避免
34
+ * 无意义的重渲染。
35
+ */
36
+ export declare function rememberSessionId(value: unknown): boolean;
37
+ /** 当前已知的会话 id(空串 = 尚未观察到,调用方不得据此编造一个)。 */
38
+ export declare function knownSessionId(): string;
39
+ /** 仅供测试:把缓存恢复到初始状态。 */
40
+ export declare function resetSessionIdCache(): void;
41
+ /**
42
+ * 把一个 payload 补上会话身份(已有 `session` 时不覆盖)。
43
+ *
44
+ * 这是设置页真正用得上的那个函数:它只在**确实知道**身份时才附加字段,否则原样返回,
45
+ * 让宿主按它自己的规则拒绝 —— 而不是伪造一个 `session: ''` 去骗过存在性检查。
46
+ */
47
+ export declare function withSessionIdentity<T extends Record<string, unknown>>(payload: T): T & {
48
+ session?: string;
49
+ };
@@ -149,5 +149,24 @@ export declare function rosterFrom(input: {
149
149
  }): ExpertRosterEntry[];
150
150
  /**
151
151
  * 把收集结果并入记录(原地修改;调用方负责落盘)。
152
+ *
153
+ * ## 幂等(A2)
154
+ *
155
+ * 此前是纯拼接(`[...record.defects, ...added]`),于是**同一条发言被收集两次**
156
+ * 就在裁决面上出现两条一模一样的缺陷:重试、双击、两个并发请求都能触发它。
157
+ * 用户面对的是一个"必须点两次驳回、理由还得写两遍"的列表。
158
+ *
159
+ * 现在按 `defect.id` 去重(`id` 由 `collectDefects` 从 `utteranceId` + 序号生成,
160
+ * 因此同一批输入两次得到同一批 id):**保留先到的那一条**,后来的同 id 项直接丢弃。
161
+ * 保留先到的而不是新的,是因为"用户已经开始表态"的那一条不能被后来的副本顶掉
162
+ * (缺陷的三态与驳回理由挂在 id 上,换掉实例等于丢掉已做出的判断)。
163
+ *
164
+ * `collectDefects` 里的 `utteranceId` 幂等键是第一道闸,这里的是第二道:前者防的是
165
+ * "转录又被读了一遍",后者防的是"合并写回被施加了同一批结果" —— 两道都需要,
166
+ * 因为并发请求可以在第一道闸之后、第二道闸之前交错。
167
+ *
168
+ * @returns **实际追加的条数**(被去重丢弃的不算)。调用方必须如实上报这个数字,
169
+ * 而不是 `added.length` —— 那会把"请求成功了但什么都没新增"报成
170
+ * "新增了 N 条",用户看到条数却对不上列表。
152
171
  */
153
- export declare function applyCollected(record: CrossfireRecord, added: readonly Defect[]): void;
172
+ export declare function applyCollected(record: CrossfireRecord, added: readonly Defect[]): number;
@@ -48,6 +48,15 @@ export interface Config {
48
48
  memberMaxDepth?: number;
49
49
  /** 用法段在全局系统提示里的排序位(默认 117,避开圆桌的 116)。 */
50
50
  promptSectionOrder?: number;
51
+ /**
52
+ * 专家**继承主持人**时实际生效的模型路由(provider / model)。
53
+ *
54
+ * 它只影响可观测性:开打时把这个生效值写回专家记录,卡片才能把"继承"画成真实
55
+ * 品牌(DeepSeek 的蓝色 DS 块),而不是一个中性灰 `?`。取不到时不写,界面会退化成
56
+ * `?` + `title="继承主持人"`,不会显示一个并非事实的路由。
57
+ */
58
+ defaultProvider?: string;
59
+ defaultModel?: string;
51
60
  }
52
61
  /**
53
62
  * 配置 schema(schemastery)。
@@ -64,6 +73,8 @@ export declare const Config: z<Schemastery.ObjectS<{
64
73
  maxExperts: z<number, number>;
65
74
  defaultIntensity: z<"quick" | "standard" | "deep", "quick" | "standard" | "deep">;
66
75
  memberMaxDepth: z<number, number>;
76
+ defaultProvider: z<string, string>;
77
+ defaultModel: z<string, string>;
67
78
  promptSectionOrder: z<number, number>;
68
79
  }>, Schemastery.ObjectT<{
69
80
  stateDir: z<string, string>;
@@ -71,6 +82,8 @@ export declare const Config: z<Schemastery.ObjectS<{
71
82
  maxExperts: z<number, number>;
72
83
  defaultIntensity: z<"quick" | "standard" | "deep", "quick" | "standard" | "deep">;
73
84
  memberMaxDepth: z<number, number>;
85
+ defaultProvider: z<string, string>;
86
+ defaultModel: z<string, string>;
74
87
  promptSectionOrder: z<number, number>;
75
88
  }>>;
76
89
  export declare function apply(ctx: Context, config: Config): void;
@@ -40,6 +40,18 @@ export interface MemberRuntimeConfig {
40
40
  provider: string;
41
41
  /** 委派深度上限(默认 1:红队专家不得再招人)。 */
42
42
  maxDepth?: number;
43
+ /**
44
+ * 专家**继承**主持人时实际生效的模型路由。
45
+ *
46
+ * 主要用途是可观测性:写回记录后卡片才能把"继承"画成真实品牌(DeepSeek 的蓝色
47
+ * DS 块),而不是一个中性灰 `?`。取不到时不写(界面退化为 `?` + "继承主持人"提示)。
48
+ *
49
+ * 另一处用途(A5):阵容成员**只填了 model** 时,`defaultProvider` 是真正参与路由
50
+ * 的那个 provider(补进 agentOptions 并写回记录)。只在纯继承的情形下它才是纯
51
+ * 可观测值 —— 而且它**不会**被用来顶替某个成员自己指定的 model。
52
+ */
53
+ defaultProvider?: string;
54
+ defaultModel?: string;
43
55
  }
44
56
  /** 一位专家的输出限制。 */
45
57
  export interface ExpertRuntimeLimits {
@@ -48,6 +60,61 @@ export interface ExpertRuntimeLimits {
48
60
  /** 这位专家最多提几条缺陷(0 = 不限制)。 */
49
61
  maxOpinions?: number;
50
62
  }
63
+ /** 一位专家最终生效的路由决定。 */
64
+ export interface ExpertRoute {
65
+ /** 写回记录的 provider;缺席 = "继承 / 未知"(界面退化为中性块,而不是一个假路由)。 */
66
+ provider?: string;
67
+ /** 写回记录的 model;缺席 = 同上。 */
68
+ model?: string;
69
+ /** **真正**交给 `startContinuable` 的 agentOptions(空对象 = 完全不干预路由)。 */
70
+ agentOptions: {
71
+ provider?: string;
72
+ model?: string;
73
+ };
74
+ }
75
+ /**
76
+ * 把"专家显式指定的路由"与"宿主默认路由"收敛成一份确定的路由决定(A5 + B3)。
77
+ *
78
+ * ## 一条铁律
79
+ *
80
+ * **写回记录的值 = 真正下发的值。** 记录与下发必须由**同一次决定**产生,否则界面
81
+ * 会显示一条并非事实的路由(红队抓出的原缺陷:只填了 provider 的成员,记录里却被
82
+ * 写成 `defaultProvider` + `defaultModel` —— 界面说跑 A,实际跑 B)。
83
+ *
84
+ * ## 四种组合的行为(全都是确定的,没有"看情况")
85
+ *
86
+ * | 专家显式指定 | agentOptions(下发) | 写回记录 |
87
+ * |---|---|---|
88
+ * | provider + model | `{provider, model}` | provider、model |
89
+ * | 只有 provider | `{provider}` | provider(**不补 model**) |
90
+ * | 只有 model | `{defaultProvider?, model}` | provider(补上的那个)、model |
91
+ * | 都没有(继承) | `{}`(不干预) | defaultProvider、defaultModel |
92
+ *
93
+ * 逐条理由:
94
+ * - **只有 provider 时刻意不补 model**:`provider` 自己会选它的默认模型,我们没有
95
+ * 任何依据知道那会是哪一个;拿 `config.defaultModel` 去顶替就是编造(那正是原
96
+ * 缺陷的写法)。记录的 `model` 缺席是诚实的"不知道"。
97
+ * - **只有 model 时补 `defaultProvider`**:一个不给 provider 的 model id 在多数路由上
98
+ * 无法解析,而"只写 model、不写 provider"在用户眼里就是"用默认 provider 跑这个
99
+ * 模型"。补上的值同时写回记录 —— 于是界面显示的正是真正下发的。
100
+ * - **都没有时 agentOptions 为空**:这一种情形下我们**不替宿主做决定**,真正的继承
101
+ * 必须保持是继承。记录里那份 `defaultProvider`/`defaultModel` 是配置里声明的
102
+ * "继承主持人时**实际生效**的模型路由"(见 index.ts / ToolsConfig 的同一段注释),
103
+ * 写它只为把"继承"画成真实品牌块(否则头像永远是中性灰 `?`,那是用户报过的缺陷)。
104
+ * 这是**唯一**一处记录值不等于 agentOptions 的字段 —— 而它们仍然同源:记录里出现的
105
+ * 每一个字段,要么是我们显式下发的,要么是宿主默认路由本身。
106
+ * - **取不到生效值的字段一律不写**(保持"缺失 = 继承"的诚实语义),也不写空串。
107
+ *
108
+ * 纯函数:没有 I/O、没有时间、不改入参,所以四种组合都能被单测直接钉住
109
+ * (见 `test/members.test.mjs`)。
110
+ */
111
+ export declare function resolveExpertRoute(expert: {
112
+ provider?: string;
113
+ model?: string;
114
+ }, config: {
115
+ defaultProvider?: string;
116
+ defaultModel?: string;
117
+ }): ExpertRoute;
51
118
  /**
52
119
  * 拉起一位红队专家,返回它的子会话 id。
53
120
  *
@@ -30,6 +30,14 @@ export declare function transcriptFile(stateRoot: string, reviewId: string): str
30
30
  export declare function userActionsFile(stateRoot: string, reviewId: string): string;
31
31
  /** `<reviewDir>/export.md`。 */
32
32
  export declare function exportFile(stateRoot: string, reviewId: string): string;
33
+ /**
34
+ * `<reviewDir>/export.txt` —— 同一份内容的**人读副本**(UTF-8 带 BOM)。
35
+ *
36
+ * 为什么导出两份:`.md` 交给 Markdown 渲染器,但 Windows 上双击 `.md` 常被别的程序
37
+ * 抢走,加上无 BOM 的 UTF-8 会被记事本误判成 ANSI —— 于是"导出物打不开"。`.txt` 带
38
+ * BOM 后双击直接进记事本,中文也不乱码(用户反馈的正是这一点)。
39
+ */
40
+ export declare function exportTextFile(stateRoot: string, reviewId: string): string;
33
41
  /** `<stateRoot>/crossfire`(诊断用)。 */
34
42
  export declare function namespaceRoot(stateRoot: string): string;
35
43
  /**
@@ -133,10 +133,24 @@ export declare const SPLIT_TOOL: {
133
133
  readonly required: readonly ["defects"];
134
134
  };
135
135
  };
136
+ /**
137
+ * 判定这段文字里专家自己写的「针对立场」结论是不是**打穿**。
138
+ *
139
+ * ## 为什么不能只写 `includes('打穿')`(这是真实存在的坑)
140
+ *
141
+ * `未打穿` 的后两个字就是 `打穿`,纯子串匹配会把"没打穿"读成"打穿了",方向整个反过来。
142
+ * 所以这里匹配有界的标记(`针对立场…:打穿|未打穿`)并**显式取反**:只有 `未` 缺席时
143
+ * 才算打穿;标记压根不存在(含 `undefined`)也一律算没打穿。
144
+ *
145
+ * 之所以要有这个本地判定:专家的信封格式是**固定**的,报告正文里本来就写着结论。
146
+ * 模型拆分失败退到本地启发式({@link splitByMarkers})时不该把这个信号丢掉 ——
147
+ * 那正是"打穿立场"标签永远显示不出来的原因之一。
148
+ */
149
+ export declare function stanceMarkerStrikes(text: string | undefined): boolean;
136
150
  export declare function splitByMarkers(content: string, allowed: readonly string[]): SplitLine[] | null;
137
151
  /**
138
152
  * 拆分一条发言为若干独立缺陷;任何失败返回 null(调用方整条兜底)。
139
153
  * quote 与发言原文做规范化子串校验(防线③),不匹配即丢弃。
140
154
  * dimension 一律过 `resolveDimension`(落库值必须 ∈ allowed)。
141
155
  */
142
- export declare function splitUtterance(llm: SplitLlmLike, config: ReviewSplitConfig, expertKey: string, content: string, allowed: readonly string[]): Promise<SplitLine[] | null>;
156
+ export declare function splitUtterance(llm: SplitLlmLike, config: ReviewSplitConfig, expertKey: string, content: string, allowed: readonly string[], stance: string): Promise<SplitLine[] | null>;
@@ -43,6 +43,16 @@ export declare function reviewDirOf(stateRoot: string, reviewId: string): string
43
43
  export declare function newReviewId(now?: number, random?: string): string;
44
44
  /** Atomically publish a UTF-8 text file. */
45
45
  export declare function writeTextAtomic(file: string, text: string): Promise<void>;
46
+ /**
47
+ * 原子写一份**带 UTF-8 BOM** 的文本,供人用记事本直接打开。
48
+ *
49
+ * 为什么单独一个函数:Windows 记事本对"无 BOM 的 UTF-8"时常误判成 ANSI,中文变乱码。
50
+ * 而 `.md` 扩展名在不少机器上被别的程序抢走,双击进不了记事本。所以导出时额外写一份
51
+ * `.txt`(带 BOM)—— 用户反馈"导出来的产物我无法用记事本查看"就是这个原因。
52
+ *
53
+ * BOM 只加在这份人读副本上:`.md` 保持无 BOM,交给 Markdown 渲染器更干净。
54
+ */
55
+ export declare function writeTextAtomicWithBom(file: string, text: string): Promise<void>;
46
56
  /** Atomically publish a JSON file (same-directory tmp + rename). */
47
57
  export declare function writeJsonAtomic(file: string, value: unknown): Promise<void>;
48
58
  /** 确保目录存在(幂等)。 */
@@ -37,6 +37,14 @@ export interface ToolsConfig {
37
37
  memberProvider: string;
38
38
  maxExperts: number;
39
39
  memberMaxDepth?: number;
40
+ /**
41
+ * 专家**继承主持人**时实际生效的模型路由。
42
+ *
43
+ * 只为可观测性:开打时把这个生效值写回专家记录,卡片才能把"继承"画成真实品牌
44
+ * (DeepSeek 的蓝色 DS 块),而不是中性灰 `?`。它不参与任何路由决策。
45
+ */
46
+ defaultProvider?: string;
47
+ defaultModel?: string;
40
48
  /** 观点拆分的 LLM 路由(不可用时自动退到本地兜底)。 */
41
49
  reviewSplit: ReviewSplitConfig;
42
50
  /** 实时读取的默认值。 */
@@ -145,6 +145,27 @@ export interface RedTeamExpert {
145
145
  * 2. 缺席时 persona 与从前逐字一致(老记录不受影响)。
146
146
  */
147
147
  role?: string;
148
+ /**
149
+ * 这位专家**真正下发出去的那份 persona 的逐字副本**。
150
+ *
151
+ * ## 为什么必须落盘(红队抓出的"原提示词"缺陷)
152
+ *
153
+ * persona 是每次开打时由 `expertPersona()` 现场重建的。攻击开始之后用户还能改
154
+ * 专家的 role,用户也能改立场 —— 而**已经下发的那一份不会变**。于是"查看原提示词"
155
+ * 端点若拿当前记录重建,返回的文本在任何"开打后改字段"的场景下都不是原文,
156
+ * 却能以上下文自洽的样子显示出来:一句看起来精确、实际是伪造的"原文"。
157
+ *
158
+ * 所以这里落盘的是**同一次调用的返回值**(`members.ts` 里先提成局部常量,再同时
159
+ * 用于下发与落盘),端点优先返回它,只有旧记录缺这个字段时才回退到现场重建。
160
+ *
161
+ * 缺席 = 这份记录创建于本字段之前(旧记录),不是"没有 persona"。
162
+ *
163
+ * **注意(写在这里是给下一个改 schema 的人)**:新字段必须同时登记进
164
+ * `review.ts` 的 `sanitizeExpert()` 白名单 —— 那是一个显式字段清单,未登记的
165
+ * 字段会在每次 `readRecord()` 时被静默丢弃,功能会"落盘了但读不到",
166
+ * 而且不看断言根本发现不了。
167
+ */
168
+ persona?: string;
148
169
  status: 'pending' | 'running' | 'done' | 'failed' | 'silent';
149
170
  /**
150
171
  * 这位专家产出的**缺陷**条数。
@@ -9,9 +9,9 @@
9
9
  * @module dsh-plugin-crossfire/version
10
10
  */
11
11
  /** 当前插件版本(必须与 package.json 的 version 逐字一致)。 */
12
- export declare const PLUGIN_VERSION = "0.1.0";
12
+ export declare const PLUGIN_VERSION = "0.1.1";
13
13
  /** 导出物头部使用的插件标识行。 */
14
- export declare const PLUGIN_ID = "@y9931666/dsh-plugin-crossfire v0.1.0";
14
+ export declare const PLUGIN_ID = "@y9931666/dsh-plugin-crossfire v0.1.1";
15
15
  /** DeepSeek Harness 版本区间(导出头部展示用)。
16
16
  * 必须与 `compatibility.json` 的 `recommendedHost` 一致 —— 由
17
17
  * `test/version.test.mjs` 强制。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@y9931666/dsh-plugin-crossfire",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -68,7 +68,7 @@
68
68
  },
69
69
  "scripts": {
70
70
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
71
- "test": "node --test test/",
71
+ "test": "node --test --test-isolation=none \"test/*.test.mjs\"",
72
72
  "test:inline": "node --test --test-isolation=none \"test/*.test.mjs\"",
73
73
  "build": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit && tsdown -c tsdown.config.ts && tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.client.json --emitDeclarationOnly",
74
74
  "bundle:client": "tsdown -c tsdown.config.ts"
@@ -94,24 +94,60 @@
94
94
  "react": "^18.2.0"
95
95
  },
96
96
  "peerDependenciesMeta": {
97
- "@deepseek-ai/cordis": { "optional": true },
98
- "@deepseek-ai/schemastery": { "optional": true },
99
- "@deepseek-ai/dsh-agent": { "optional": true },
100
- "@deepseek-ai/dsh-atomic-write": { "optional": true },
101
- "@deepseek-ai/dsh-client-connection": { "optional": true },
102
- "@deepseek-ai/dsh-client-locale": { "optional": true },
103
- "@deepseek-ai/dsh-client-ui-conversation": { "optional": true },
104
- "@deepseek-ai/dsh-client-ui-renderer": { "optional": true },
105
- "@deepseek-ai/dsh-client-ui-settings": { "optional": true },
106
- "@deepseek-ai/dsh-client-ui-tool": { "optional": true },
107
- "@deepseek-ai/dsh-host-webserver": { "optional": true },
108
- "@deepseek-ai/dsh-llm": { "optional": true },
109
- "@deepseek-ai/dsh-session": { "optional": true },
110
- "@deepseek-ai/dsh-subagent": { "optional": true },
111
- "@deepseek-ai/dsh-system-prompt": { "optional": true },
112
- "@deepseek-ai/dsh-tools": { "optional": true },
113
- "@deepseek-ai/dsh-workspace": { "optional": true },
114
- "react": { "optional": true }
97
+ "@deepseek-ai/cordis": {
98
+ "optional": true
99
+ },
100
+ "@deepseek-ai/schemastery": {
101
+ "optional": true
102
+ },
103
+ "@deepseek-ai/dsh-agent": {
104
+ "optional": true
105
+ },
106
+ "@deepseek-ai/dsh-atomic-write": {
107
+ "optional": true
108
+ },
109
+ "@deepseek-ai/dsh-client-connection": {
110
+ "optional": true
111
+ },
112
+ "@deepseek-ai/dsh-client-locale": {
113
+ "optional": true
114
+ },
115
+ "@deepseek-ai/dsh-client-ui-conversation": {
116
+ "optional": true
117
+ },
118
+ "@deepseek-ai/dsh-client-ui-renderer": {
119
+ "optional": true
120
+ },
121
+ "@deepseek-ai/dsh-client-ui-settings": {
122
+ "optional": true
123
+ },
124
+ "@deepseek-ai/dsh-client-ui-tool": {
125
+ "optional": true
126
+ },
127
+ "@deepseek-ai/dsh-host-webserver": {
128
+ "optional": true
129
+ },
130
+ "@deepseek-ai/dsh-llm": {
131
+ "optional": true
132
+ },
133
+ "@deepseek-ai/dsh-session": {
134
+ "optional": true
135
+ },
136
+ "@deepseek-ai/dsh-subagent": {
137
+ "optional": true
138
+ },
139
+ "@deepseek-ai/dsh-system-prompt": {
140
+ "optional": true
141
+ },
142
+ "@deepseek-ai/dsh-tools": {
143
+ "optional": true
144
+ },
145
+ "@deepseek-ai/dsh-workspace": {
146
+ "optional": true
147
+ },
148
+ "react": {
149
+ "optional": true
150
+ }
115
151
  },
116
152
  "devDependencies": {
117
153
  "@deepseek-ai/cordis": "4.0.4",
@@ -0,0 +1,58 @@
1
+ # 针锋 Crossfire v0.1.1
2
+
3
+ 修复版。全部改动来自**用针锋自己审自己**的两轮红队评审(GPT-5.6-Terra 与默认路由各一轮),
4
+ 以及评审过程中暴露出来的一个真实可用性缺陷。
5
+
6
+ ## 修了什么
7
+
8
+ ### 1. 设置页**完全无法保存**(最严重)
9
+
10
+ **症状**:角色预设、阵容预设、默认强度、预算**全部存不进去**,界面显示「保存失败」。
11
+
12
+ **根因**(不是 UI 问题,是身份问题):宿主的 slot 契约里,`settings.section` 的
13
+ `standardProps` **不含 `sessionId`**(只有 `conversation.view` / `tool.call.toolview` 含),
14
+ 而 `crossfire/prefs.set` 在宿主侧要求"必须声称一个身份"。于是卡片里能保存、设置页必然失败。
15
+
16
+ **修法**:新增 `src/client/session-cache.ts`,把会话标签页/轨迹卡片从宿主注入里拿到的**真实**
17
+ 身份幂等记下来,设置页提交时声明同一个值。缓存为空时**绝不编造身份**(宁可让宿主按规则拒绝)。
18
+
19
+ ### 2. 「打穿立场」链路从未生效
20
+
21
+ **症状**:专家在发言里明明写了"针对立场:打穿",但每条缺陷的 `strikesStance` 都是 `false` ——
22
+ 卡片上的「打穿立场」标签和"打穿优先排序"是死的。
23
+
24
+ **根因**:`split.ts` 的 `splitUtterance()` 被要求判断"是否打穿立场",但它的参数里
25
+ **根本没有立场文本**,提示词只能靠正文里的字面标记去猜。
26
+
27
+ **修法**:立场全文随拆分请求下发;立场为空时在提示词里明说"未填写立场、不得标打穿";
28
+ 另加一道本地兜底,在 LLM 拆分失败退到启发式时仍能识别「针对立场:打穿」
29
+ (并特意区分「**未**打穿」,不让子串误匹配)。
30
+
31
+ ### 3. 视觉与缓存
32
+
33
+ - **状态药丸四态同色**:顶部状态区的药丸底色此前恒为品牌蓝,而抬头徽章是四态四色,
34
+ 于是同一句「攻击中」在同一屏以黄色与蓝色各出现一次。现在药丸与徽章共用同一套主题令牌。
35
+ - **折叠态标题溢出**:标题行此前没有单行约束,最长 80 字的标题会把折叠体撑成 2–4 行。
36
+ 现在 `nowrap + ellipsis + overflow-wrap: anywhere`。
37
+ - **客户端废掉宿主的 ETag/304**:`fetchReviews` 用了 `cache: 'no-store'`,浏览器因此
38
+ 永不回传 `If-None-Match`,每 2 秒一次全量读盘 + 全量传输。改为 `no-cache`
39
+ (语义仍是"每次回源校验",但允许保存副本)。
40
+
41
+ ## 版本号
42
+
43
+ `package.json` 与 `src/version.ts` 必须逐字一致(`test/version.test.mjs` 强制)。
44
+
45
+ ## 安装
46
+
47
+ ```sh
48
+ dsh plugin --profile web add @y9931666/dsh-plugin-crossfire
49
+ ```
50
+
51
+ 安装后**重启 Harness 进程**并刷新浏览器。要求 DeepSeek Harness `0.2.0-rc.2+`。
52
+
53
+ ## 已知限制(未变)
54
+
55
+ - 状态锁仍是**进程内**的:多窗口同时操作同一场评审时,后写者覆盖前者。
56
+ - 设置页的身份来自浏览器侧缓存:**冷启动后直接打开设置页**、且本次会话从未渲染过
57
+ 「针锋」标签页或任意一张卡片时,保存仍会被宿主拒绝(这是刻意的 —— 插件不伪造身份)。
58
+ 看一眼「针锋」标签页再保存即可。