@mars.liu/dsh-cybernetics 0.25.1 → 0.25.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/CHANGELOG.md +20 -0
- package/lib/client.js +61 -4
- package/package.json +1 -1
- package/src/index.ts +176 -22
- package/src/sessionRoles.ts +130 -0
package/README.md
CHANGED
|
@@ -172,7 +172,7 @@ The settings card's「预置方案」dropdown — ten options: **auto-recommend*
|
|
|
172
172
|
- **Vendor entries (data-driven, `status: 'ready'`)**: each vendor carries per-role model + reasoning-effort token (heterogeneous: OpenAI/Minimax five-level low/medium/high/xhigh/max, DeepSeek thinking switch, others model default), upgrade tier, availability fallback, per-role rationale, per-vendor note (⚠️ caveats — e.g. Qwen's 「verify 绝不用 Turbo」幻觉共振 warning, Kimi's K2-retirement avoidance, DeepSeek's 「成本不应成为降级 verify 的理由」) and VARIANTS (Claude 常规/高要求, Qwen A/B/C, DeepSeek 仅本家/verify 外置, Kimi 长期/最高验收质量, 火山 A/B, GLM 标准组合/重活命名角色). Selecting a preset applies its **default variant** (the first one); the outcome record discloses the variant applied and lists the remaining ones. Vendor-canonical model ids are applied verbatim — an id absent from this deployment's catalog shows the card's model-unavailable warning and the round's availability fallback (v0.8) keeps rounds alive.
|
|
173
173
|
- **Auto-recommend (since v0.18.2 = a session-agent handoff, user ruling)**: selecting auto runs **NO independent LLM call and spawns NO agent** (the previous one guarded one-shot LLM call plus its route config field were removed — re-submitting the whole context to a second model call is a waste of both time and money). The plugin now probes the live catalog (`probeCatalogOptions`, the same directory smart selection reads), builds the handoff brief — embedding the catalog (the only selectable source), the cross-vendor principles (verify ≥ task; verify/task kept cross-generation/cross-family independent; verify must not be over-confident; cost cuts hit task/triage first and never verify; triage light), the current role config and every vendor preset as advisory reference — writes a `pending` record to the card, and hands the brief to the session agent through the plugin-registered `preset_recommend` tool (a steer/followup reminder is delivered to live session agents). The SESSION AGENT reads the catalog in its OWN context, answers for task/verify/triage, and calls the same tool again to submit; the plugin only validates (catalog-matched models only; per-role `reasoningEffort` validated against `ctx.llm.resolveModelInfo` and dropped to the model default when unsupported) and writes. Until the session agent answers, the card shows the pending note; an unusable answer (off-catalog / unparseable / zero picks) degrades to a written `degraded` record with the reason and is reported back to the session agent for a corrected re-submission — nothing is applied and no round is blocked. The tool survives the hard-gate allowlist alongside the cybernetics tool itself.
|
|
174
174
|
- **Selecting is one-shot**: re-selecting the same entry does not re-fire (the handler guards on the last handled value; a value persisted across a restart is not re-applied at boot). Switch to「(未选择)」first to re-apply an entry.
|
|
175
|
-
- **`/cybernetics use [id]` (v0.22.0,
|
|
175
|
+
- **`/cybernetics use [id]` (v0.22.0; session-only since v0.25.5, user ruling)**: switch the role composition of **THIS SESSION** to any preset plan without opening the settings card. With no argument the command replies with a plain-text list of every applicable plan — all built-in registry ids plus the configured custom presets (`custom:<id>`) — and asks the user to confirm with a second `/cybernetics use <id>`. With an id the resolved task/verify/triage picks (models + reasoning efforts) are stored in an **in-memory per-session override** (`src/sessionRoles.ts`) — **zero settings-service writes**: the six global role fields, the `presetPlan` selector and the `presetRecommendation` record are never touched. The round dispatch folds the override with "session override > global fields" precedence, per role and per field. The receipt states it explicitly: `已在本会话应用预置方案 "<label>":task=... verify=... triage=...;仅本会话生效(会话结束失效),不修改全局配置`. A repeated `use <id>` (same or different plan) simply replaces the override; `/cybernetics use off` clears it and the session falls back to the global fields (the `status` reply carries the current override state and this clear command). To change the GLOBAL configuration use the settings card — note that its writes are subject to the host's Include write-gate defect (see the settings-card section); `use` deliberately takes no part in that path. `use auto` hands the auto-recommendation brief to THIS session's agent and lands the `preset_recommend` result as the session override too (the card's own auto path is unchanged).
|
|
176
176
|
- **Editor-side preset picker (v0.24.1, popupSelect)**: the picker appears only after you express the `use` intent. Picking `/cybernetics` in the `/` menu completes the composer text to `/cybernetics` (no popup — keep typing `use ...`); pressing Enter on the bare `/cybernetics` token opens a searchable popup listing every applicable plan (the 12 built-in ids and the configured `custom:<id>` entries, grouped, fuzzy-filtered as you type, current plan highlighted); typing `/cybernetics use` and pressing Enter executes the plain-text listing and then opens the same popup over it. Picking a row runs `/cybernetics use <id>` through the same command transport (validation/apply/receipt stay server-authoritative; errors surface in the popup). This rides the host's own mechanism for commands with pickable values (`commandUi.decorate` + the public `popupFor`/`command/executed` faces, the same pattern as the official `/model` picker); hosts without the `commandUi` service keep the plain-text v0.22 behavior, and the typed `/cybernetics use <id>` path is untouched. Note: inline argument completion *inside the draft* (a dropdown right after typing `use `) is not possible with the host protocol — the slash trigger scan stops at the first whitespace (re-verified v0.25.0: the host composer publishes no draft-change event, the four `slash/input-*` scope events are plugin→shell bail-request channels only, and trigger-source outcomes are limited to `{claim}/{text}/{insert}` — nothing can observe the `/cybernetics use` draft before Enter). **Type `/cybernetics use`, then press Enter to open the preset picker** (the bare-enter path lands the plain-text listing first and the popup opens over it).
|
|
177
177
|
|
|
178
178
|
## Installation and Usage
|
package/README.zh-CN.md
CHANGED
|
@@ -172,7 +172,7 @@ goal 一经存在不再原地修改。`revise` 是唯一的改目标动词(必
|
|
|
172
172
|
- **厂商方案(数据驱动,`status: 'ready'`)**:每厂商含逐角色模型 + 思考强度 token(异构:OpenAI/Minimax 五档 low/medium/high/xhigh/max、DeepSeek thinking 开关、其余模型默认)、可升级档、可用性 fallback、逐角色 rationale、厂商备注(⚠️ 注意事项——如 Qwen 的「verify 绝不用 Turbo(幻觉共振)」、Kimi 的避开退役 K2、DeepSeek 的「成本不应成为降级 verify 的理由」)与**多变体**(Claude 常规/高要求,Qwen A/B/C,DeepSeek 仅本家/verify 外置,Kimi 长期组合/最高验收质量,火山 A/B,GLM 标准组合/重活命名角色)。选择方案应用其**默认变体**(第一个);结果记录披露所应用变体并列出其余变体。厂商原生模型 id 逐字应用——部署目录中不存在的 id 会显示卡片不可用警告,轮内可用性 fallback(v0.8)保证轮次存活。
|
|
173
173
|
- **自动推荐(v0.18.2 起 = session agent 交办,用户裁定)**:选择 auto 时插件**不发起任何独立 LLM 调用、不新起任何子 agent**(v0.17.2 及之前的「一次一次性 LLM 调用 + 推荐所用模型配置项」已删除——另起一次调用等于把上下文重复提交一遍,时间与经济性都不合理)。现在的流程:插件探测实时目录(`probeCatalogOptions`,与智能选择同源),把推荐交办单——内嵌目录(唯一可选来源)、跨厂商通用原则(task/verify/triage 三角色可灵活配置,允许同款模型或任意档位组合;verify 不能过度自信;省成本先压 task/triage、verify 最后动且成本不是降级 verify 的理由;triage 轻量)、当前角色配置与全部厂商方案(仅作参考)——经插件注册的 `preset_recommend` 工具交给 session agent,并在卡片写入 `pending` 记录、向在线 session agent 投递一条 steer/followup 提醒。session agent 在**自己的上下文中**读取目录、给出 task/verify/triage 的答案,再次调用该工具提交;插件只负责校验(逐字命中目录;逐角色 `reasoningEffort` 经 `ctx.llm.resolveModelInfo` 校验,不支持时降为模型默认并注明)与写入。session agent 未作答时卡片保持 pending;答案不可用(未命中目录/不可解析/零选择)时降级为带原因的 `degraded` 记录并回执给 session agent 修正重交——不应用任何更改、不阻塞任何轮次。该工具在硬门禁 allowlist 下与 cybernetics 工具一同保留。
|
|
174
174
|
- **选择是一次性的**:重复选择同一项不会再次触发(处理器按最后处理值守卫;跨重启持久化的值不会在启动时重放)。要重复应用某项,先切「(未选择)」再选。
|
|
175
|
-
- **`/cybernetics use [方案id]`(v0.22.0
|
|
175
|
+
- **`/cybernetics use [方案id]`(v0.22.0;v0.25.5 起仅当前会话生效,用户裁定)**:不开设置卡片,把**本会话**的角色组合切换为任意预置方案。无参数时命令回执纯文本清单——全部内置方案 id + 已配置的自定义方案(`custom:<id>`)——并提示用户再次输入 `/cybernetics use <id>` 确认;带 id 时解析出的 task/verify/triage picks(模型 + 思考强度)存入**内存中的按会话覆盖表**(`src/sessionRoles.ts`)——**零 settings 写入**:六个全局角色字段、`presetPlan` 选择器、`presetRecommendation` 记录均不触碰。轮次派工按「会话覆盖 > 全局字段」逐角色逐字段合并。回执明确语义:`已在本会话应用预置方案 "<label>":task=... verify=... triage=...;仅本会话生效(会话结束失效),不修改全局配置`。重复 `use <id>`(同方案或换方案)直接覆盖更新;`/cybernetics use off` 清除覆盖、回到全局字段(status 回执带当前覆盖状态与该清除指令)。修改**全局**配置请使用设置卡片——注意卡片的全局写入受宿主 Include 写闸门缺陷影响(见设置卡片章节);`use` 刻意不参与该路径。`use auto` 把自动推荐 brief 交给本会话 agent,`preset_recommend` 结果同样落为会话覆盖(卡片自身的 auto 全局路径不变)。
|
|
176
176
|
- **编辑器侧方案选择器(v0.24.1,popupSelect)**:选择器只在你表达 `use` 意图之后出现。在 / 菜单点选 `\cybernetics` 只把编辑器文本补全为 `/cybernetics`(不弹层——继续输入 `use ...` 即可);对裸 `/cybernetics` 回车弹出可搜索的方案选择器(12 个内置 id 与已配置的 `custom:<id>` 条目分组列出,输入即模糊过滤,当前方案高亮);键入 `\cybernetics use` 回车则先落地纯文本清单,随后同一弹层在其上弹出。点选一项即经同一条命令通道提交 `/cybernetics use <id>`(校验/应用/回执仍以服务端为唯一权威,错误就地显示在弹层)。实现走宿主为「带可选值的命令」提供的原生机制(`commandUi.decorate` + 公开的 `popupFor`/`command/executed` 面,与官方 `/model` 选择器同一模式);宿主无 commandUi 服务时保持 v0.22 纯文本行为,键入路径 `/cybernetics use <id>` 完全不动。说明:草稿内的参数级补全(输入 `use ` 后就地弹下拉)在宿主协议下不可实现——斜杠触发扫描遇空格即止(v0.25.0 复核:宿主 composer 不对外发射任何草稿变更事件,四个 `slash/input-*` 作用域事件均为插件→shell 的 bail 请求通道,触发源 outcome 仅 `{claim}/{text}/{insert}` 三种,均无法在回车前感知 `/cybernetics use` 草稿)。**请输入 `/cybernetics use` 后按 Enter 唤起方案选择器**(无参回车先落地纯文本清单,弹层随即在其上出现)。
|
|
177
177
|
|
|
178
178
|
## 安装与使用
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@
|
|
|
4
4
|
> 当下的功能与使用方式以 [README.md](../README.md) 为准;协议细节见 [protocol.md](protocol.md)。
|
|
5
5
|
> 约定:`src/` 改动需宿主重载生效;`lib/client.js` 改动需重启宿主(客户端模块图在宿主启动时构建)并刷新浏览器;`workflows/round.js` 改动热生效。
|
|
6
6
|
|
|
7
|
+
## v0.25.5(2026-11-11):`/cybernetics use` 改为仅当前会话生效——零 settings 写入(用户裁定,推翻 v0.25.4 方向)
|
|
8
|
+
|
|
9
|
+
**用户裁定(2026-11-11,最高优先级)**:`use` 一律实现为**仅当前会话生效**——完全不调用 settings mutate(不写六角色字段、不写 `presetPlan`、不写 `presetRecommendation`),不做「先全局再降级」。背景:宿主存在写闸门缺陷(v0.7.10:dsh-app-boot 双模块实例 → WeakMap miss → 所有 settings 写入被拒 "profile reload requires the root Include entry"),某些启动方式下 v0.22–v0.25.4 的全局写路径完全无效。
|
|
10
|
+
|
|
11
|
+
- **实现**:新增 `src/sessionRoles.ts`——进程内存中的按会话角色覆盖表(`{preset, label, roles: {task/verify/triage: {provider, model, reasoningEffort}}, appliedAt}`),参考 `sessionMode.ts` 的会话级覆盖模式但不做任何持久化(随宿主进程/会话生命周期消失)。`use <id>` 解析方案(内置 id / `custom:<id>`)后把 picks 存入覆盖表;轮次派工读取点(src/index.ts 平铺字段折叠处)按「会话覆盖 > 全局字段」逐角色逐字段合并(`mergeSessionRolesOverride`)。
|
|
12
|
+
- **auto**:`use auto` 走会话管道——标记会话 pending 并把 brief 交给本会话 agent,`preset_recommend` 工具第二跳校验后把推荐落为**会话覆盖**(strip 到 provider/model/reasoningEffort),全程零 settings 写入;设置卡片自身的 auto 全局路径(下拉框 → pending 记录 → 全局写)完全不动。
|
|
13
|
+
- **清除**:`/cybernetics use off` 清除覆盖回到全局字段;status 回执新增「会话预置方案覆盖」行(无覆盖显示「无(跟随全局配置)」,有覆盖显示 label + picks + 清除指令)。
|
|
14
|
+
- **回执**:`已在本会话应用预置方案 "<label>":task=... verify=... triage=...;仅本会话生效(会话结束失效),不修改全局配置`。无参数清单、未知方案报错行为保留。
|
|
15
|
+
- **清理**:`writePresetRecord` / `handlePresetSelection` 的 `extraOps` 参数与 use 分支的 `presetPlan` 同步 op 一并移除(v0.25.4 的全局同步属于被推翻方向);卡片路径行为不变;`ALLOWED_OP_PATHS` 未放宽。
|
|
16
|
+
- **诊断**:`presetDiag` 新增 `event:'use-session-apply'`(preset、label、picks、cleared),use 路径不再出现任何 `write-*` 事件。
|
|
17
|
+
- **测试**:`test/v23-use-subcommand.mjs` 重写为会话语义——use 后零 settings mutate(pin)、覆盖含 effort 被 merge 语义取到(pin)、重复 use 更新、`use off` 恢复、status 含覆盖行、`use auto` 落会话不落全局(含 degraded/rejected 也零写入)。全部通过;`tsc --noEmit` 真实错误 0(TS5097 基线噪音除外)。
|
|
18
|
+
|
|
19
|
+
## v0.25.4(2026-11-11,已被 v0.25.5 推翻):`/cybernetics use` 应用后同步设置卡片「预置方案」下拉框
|
|
20
|
+
|
|
21
|
+
**变更**:`/cybernetics use <id>` 之前只把 task/verify/triage 的模型与思考强度字段写入 settings(模型配置已生效并持久化),但不写 volatile 选择器 `presetPlan`,导致配置界面的预置方案下拉框停留在旧方案。现在 `use` 分支给 `handlePresetSelection`(→ `writePresetRecord` 新增可选 `extraOps` 参数)传入一条 `{op:'set', path:['presetPlan'], value:<id>}`,与六字段 ops + 结果记录在**同一次 mutate** 中原子写入(内置 id、`auto`、`custom:<id>` 均同步)。失败/degraded/placeholder 结果不经过 extraOps 调用点——下拉框保持原值;未知方案在命令层即报错、零写入。one-shot 守卫保持一致:写入触发的 loader volatile-update 回环中 `plan === lastHandledPresetPlan` → `willHandle=false`,不会重复应用(测试 6/6b 已 pin);重复 `use` 同一方案仍可重复应用;设置卡片自身选择路径不变;`ALLOWED_OP_PATHS` 六字段白名单未放宽。测试 `test/v23-use-subcommand.mjs` 更新并新增 6/6b/6c 三个断言组,全部通过。
|
|
22
|
+
|
|
23
|
+
## v0.25.3(2026-11-10):消除 dshmarket 静态巡检对 `custom:` 自定义方案选择器的误报(纯代码形态修正,无功能变化)
|
|
24
|
+
|
|
25
|
+
**变更**:`lib/client.js` 两处形态修正——①方案选择器 `custom:` 前缀拼接改为数组 `join`(行为等价),②设置卡片注释改写,消除 `configForms.get(id)` 字样。目的为避免 dshmarket 静态巡检把 `custom:` 自定义方案选择器的 `custom:<id>` 字符串误报为配置条目 id。无功能变化;`node --check` 通过。
|
|
26
|
+
|
|
7
27
|
## v0.25.1(2026-11-10):去除 verify≥task 且跨家族独立的硬约束,角色配置改为灵活权衡
|
|
8
28
|
|
|
9
29
|
**变更**:删除「verify 档位不得低于 task 且与 task 保持跨代际/跨家族独立(同家族 verify 升高一档补偿)」这条硬约束。保留的底线仅两条:triage 选便宜快速的模型;verify 不能过度自信(优先判断力强、能承认不确定性的模型)。task/verify/triage 三个角色改为可灵活配置——允许同款模型或任意档位组合,由使用者/推荐者按任务需要权衡审计增益与成本。同步修改:`src/modelPresets.ts` 的 `CROSS_VENDOR_PRINCIPLES` 与推荐 system prompt 规则第 2 条(reasoningEffort 规则第 5 条改为「verify 建议 high,可按需调整」)、绑定被删约束的厂商 rationale 措辞改为中性描述(预置方案本身不变)、`test/v17-model-presets.mjs` 断言更新(新原则存在、旧硬约束与「跨家族独立」不存在)、README 预置方案章节描述同步。
|
package/lib/client.js
CHANGED
|
@@ -221,6 +221,10 @@ window.__ModuleLoader__.load({
|
|
|
221
221
|
// richer mode.tip.* wording (source + round detail).
|
|
222
222
|
'mode.pillCounts': '控制模式:{stopped}/{total}',
|
|
223
223
|
'mode.pillUnknown': '控制模式:…',
|
|
224
|
+
// 编辑器下方提示条标签(v0.25.3):括号式「控制模式({stopped}/{total})({plan})」,
|
|
225
|
+
// plan 为当前会话使用的 agent preset(方案)名称,未选择任何方案时为「未命名」。
|
|
226
|
+
'mode.hintCounts': '控制模式({stopped}/{total})({plan})',
|
|
227
|
+
'mode.plan.unnamed': '未命名',
|
|
224
228
|
'mode.state.on': '开',
|
|
225
229
|
'mode.state.off': '关',
|
|
226
230
|
'mode.state.unknown': '…',
|
|
@@ -407,6 +411,11 @@ window.__ModuleLoader__.load({
|
|
|
407
411
|
// v0.18.x: same label contract as the zh dictionary (key parity).
|
|
408
412
|
'mode.pillCounts': 'Control {stopped}/{total}',
|
|
409
413
|
'mode.pillUnknown': 'Control …',
|
|
414
|
+
// Composer hint bar label (v0.25.3): parenthesized segments
|
|
415
|
+
// 「控制模式({stopped}/{total})({plan})」 — plan = the session's
|
|
416
|
+
// active agent-preset name, or "Unnamed" when none is selected.
|
|
417
|
+
'mode.hintCounts': '控制模式({stopped}/{total})({plan})',
|
|
418
|
+
'mode.plan.unnamed': 'Unnamed',
|
|
410
419
|
'mode.state.on': 'On',
|
|
411
420
|
'mode.state.off': 'Off',
|
|
412
421
|
'mode.state.unknown': '…',
|
|
@@ -1464,7 +1473,7 @@ window.__ModuleLoader__.load({
|
|
|
1464
1473
|
}
|
|
1465
1474
|
} catch { /* corrupt payload → built-ins only */ }
|
|
1466
1475
|
for (const entry of list) {
|
|
1467
|
-
const id = 'custom:'
|
|
1476
|
+
const id = ['custom:', entry.id].join('');
|
|
1468
1477
|
rows.push({
|
|
1469
1478
|
id,
|
|
1470
1479
|
label: entry.name,
|
|
@@ -1597,7 +1606,7 @@ window.__ModuleLoader__.load({
|
|
|
1597
1606
|
* pass no form of their own;
|
|
1598
1607
|
* - the HOST's ConfigPageForm { state, mutate }: the plugins.item and
|
|
1599
1608
|
* plugins.row.config detail pages compute it from the same shared
|
|
1600
|
-
* mirror (configForms
|
|
1609
|
+
* mirror (the configForms getter: getSnapshot()/mutate) and pass it as
|
|
1601
1610
|
* an OWNER prop, which spreads AFTER inject at the render seam
|
|
1602
1611
|
* (@deepseek-ai/dsh-client-ui-renderer renderEntry: ...injected,
|
|
1603
1612
|
* ...ownerProps) — on those pages this shape WINS over the injected
|
|
@@ -2392,6 +2401,43 @@ window.__ModuleLoader__.load({
|
|
|
2392
2401
|
const PILL_RING_STROKE = 2;
|
|
2393
2402
|
const PILL_RING_DASHARRAY = '28.3 9.4';
|
|
2394
2403
|
|
|
2404
|
+
// v0.25.2: resolve the display name of the session's active agent
|
|
2405
|
+
// preset (方案) for the composer hint bar's third segment. Data
|
|
2406
|
+
// source: the plugin ConfigForm snapshot's live value — `presetPlan`
|
|
2407
|
+
// is the current selector ('' = nothing selected → 「未命名」;
|
|
2408
|
+
// 'custom:<id>' = a user-saved preset whose display name lives in
|
|
2409
|
+
// the `customPresets` JSON list; anything else = a built-in vendor
|
|
2410
|
+
// preset id whose label comes from PRESET_CHOICES via the locale
|
|
2411
|
+
// dicts). Shape-tolerant everywhere: a malformed/missing snapshot
|
|
2412
|
+
// or a dangling custom id degrades to 「未命名」, never throws.
|
|
2413
|
+
// Exported on module.exports for offline unit tests.
|
|
2414
|
+
function resolvePlanName(cfgSnap, t) {
|
|
2415
|
+
const cfg = cfgSnap !== undefined && cfgSnap !== null && typeof cfgSnap === 'object'
|
|
2416
|
+
&& cfgSnap.status === 'ready' && cfgSnap.value !== null && typeof cfgSnap.value === 'object'
|
|
2417
|
+
? cfgSnap.value
|
|
2418
|
+
: {};
|
|
2419
|
+
const plan = typeof cfg.presetPlan === 'string' ? cfg.presetPlan : '';
|
|
2420
|
+
if (plan === '') return t('mode.plan.unnamed');
|
|
2421
|
+
if (plan.startsWith('custom:')) {
|
|
2422
|
+
const customId = plan.slice('custom:'.length);
|
|
2423
|
+
try {
|
|
2424
|
+
const parsed = JSON.parse(cfg.customPresets);
|
|
2425
|
+
if (Array.isArray(parsed)) {
|
|
2426
|
+
for (const entry of parsed) {
|
|
2427
|
+
if (entry !== null && typeof entry === 'object'
|
|
2428
|
+
&& entry.id === customId
|
|
2429
|
+
&& typeof entry.name === 'string' && entry.name.trim() !== '') {
|
|
2430
|
+
return entry.name.trim();
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
} catch { /* corrupt payload → unnamed */ }
|
|
2435
|
+
return t('mode.plan.unnamed');
|
|
2436
|
+
}
|
|
2437
|
+
const choice = PRESET_CHOICES.find((opt) => opt.id === plan);
|
|
2438
|
+
return choice ? t(choice.labelKey) : plan;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2395
2441
|
// v0.12.0: the running-state animation styles, injected into document.head
|
|
2396
2442
|
// exactly once per page load (guarded by the data attribute). Later
|
|
2397
2443
|
// change: the running indicator is a slowly rotating ring instead of a
|
|
@@ -2646,7 +2692,18 @@ window.__ModuleLoader__.load({
|
|
|
2646
2692
|
style: modeIndicatorStyles.hint,
|
|
2647
2693
|
children: [
|
|
2648
2694
|
renderModeRing(running.count > 0, MODE_ON_COLOR),
|
|
2649
|
-
|
|
2695
|
+
// v0.25.2: three-segment label「控制模式|{stopped}/{total}|{plan}」
|
|
2696
|
+
// — same goal counts as the session pill, plus the active
|
|
2697
|
+
// agent-preset name (「未命名」 when no preset is selected).
|
|
2698
|
+
// The insert/queue wording stays in the tooltip.
|
|
2699
|
+
jsx.jsx('span', {
|
|
2700
|
+
style: { overflow: 'hidden', textOverflow: 'ellipsis' },
|
|
2701
|
+
children: t('mode.hintCounts', {
|
|
2702
|
+
stopped: counts.stopped,
|
|
2703
|
+
total: counts.total,
|
|
2704
|
+
plan: resolvePlanName(cfgSnap, t),
|
|
2705
|
+
}),
|
|
2706
|
+
}),
|
|
2650
2707
|
],
|
|
2651
2708
|
});
|
|
2652
2709
|
}
|
|
@@ -3145,7 +3202,7 @@ window.__ModuleLoader__.load({
|
|
|
3145
3202
|
// singleton) exported for the offline harness (test/v20-home-entry.mjs).
|
|
3146
3203
|
module.exports = {
|
|
3147
3204
|
name, inject, apply, countRunningGoals, countGoals, readHintLocation,
|
|
3148
|
-
usePopupRows,
|
|
3205
|
+
usePopupRows, resolvePlanName,
|
|
3149
3206
|
homeSessionOf, requestHome, consumeHomeRequest,
|
|
3150
3207
|
};
|
|
3151
3208
|
return module.exports;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -43,6 +43,10 @@ import {
|
|
|
43
43
|
import type { CustomPreset, PresetRecommendationRecord, SettingsOp } from './modelPresets.ts'
|
|
44
44
|
import { linkRunToGoal, noteWorkflowAgent, setBoardSource, unlinkRun } from './agents.ts'
|
|
45
45
|
import { cyberneticsGoalsProjection, goalsFromBoard, publishGoals, setPublishDebugRoot, appendModeRecord } from './projection.ts'
|
|
46
|
+
import {
|
|
47
|
+
clearSessionRolesOverride, formatSessionRolesOverride, getSessionRolesOverride, mergeSessionRolesOverride,
|
|
48
|
+
peekSessionAutoPending, sessionRolesKeyOf, setSessionAutoPending, setSessionRolesOverride, takeSessionAutoPending,
|
|
49
|
+
} from './sessionRoles.ts'
|
|
46
50
|
import {
|
|
47
51
|
MODE_EVENT_TYPE, MODE_LEGACY_EVENT_TYPE, cyberneticsModeProjection, effectiveEnabled, modeOverrideOf, planWanted,
|
|
48
52
|
} from './sessionMode.ts'
|
|
@@ -1512,6 +1516,14 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
1512
1516
|
logger?.warn?.(message, error)
|
|
1513
1517
|
} catch { /* logging must never throw */ }
|
|
1514
1518
|
}
|
|
1519
|
+
/**
|
|
1520
|
+
* (v0.25.5) writePresetRecord: ONE atomic settings mutate — the six-field
|
|
1521
|
+
* ops (or none for record-only outcomes) plus the outcome record. This is
|
|
1522
|
+
* the settings-CARD path only: the `/cybernetics use` subcommand no longer
|
|
1523
|
+
* routes through here at all (v0.25.5 user ruling: use is session-only,
|
|
1524
|
+
* zero settings writes — see src/sessionRoles.ts), so no extraOps channel
|
|
1525
|
+
* remains and no presetPlan op is ever written from the plugin.
|
|
1526
|
+
*/
|
|
1515
1527
|
const writePresetRecord = async (ops: SettingsOp[], record: PresetRecommendationRecord): Promise<boolean> => {
|
|
1516
1528
|
const mutate = settingsMutate()
|
|
1517
1529
|
if (mutate === undefined) {
|
|
@@ -1844,16 +1856,20 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
1844
1856
|
: 'cybernetics 控制已对本会话关闭:不再注入控制 guidance,硬门禁放行,新输入回归 DSH 原生行为。/cybernetics on 可恢复。',
|
|
1845
1857
|
}
|
|
1846
1858
|
}
|
|
1847
|
-
// --- `use` subcommand (v0.22.0
|
|
1848
|
-
// verify/triage model +
|
|
1849
|
-
//
|
|
1850
|
-
// (
|
|
1851
|
-
//
|
|
1852
|
-
//
|
|
1853
|
-
//
|
|
1854
|
-
//
|
|
1859
|
+
// --- `use` subcommand (v0.22.0; v0.25.5 session-only, user ruling
|
|
1860
|
+
// 2026-11-11): switch the role composition (task/verify/triage model +
|
|
1861
|
+
// reasoning effort) for THIS SESSION to any preset plan — the built-in
|
|
1862
|
+
// registry ids (PRESET_IDS) or a user-saved custom preset
|
|
1863
|
+
// (`custom:<id>`), or `use off` to clear. ZERO settings writes: the
|
|
1864
|
+
// resolved picks land in the in-memory per-session override store
|
|
1865
|
+
// (src/sessionRoles.ts), which the round-dispatch read point folds in
|
|
1866
|
+
// with "session override > global fields" precedence. The host's
|
|
1867
|
+
// write-gate defect (v0.7.10, dual-module Include instance) is therefore
|
|
1868
|
+
// irrelevant here, and the settings-card dropdown / global fields stay
|
|
1869
|
+
// exactly where the card left them.
|
|
1855
1870
|
if (arg === 'use' || arg.startsWith('use ')) {
|
|
1856
1871
|
const planArg = rawInput.slice(3).trim()
|
|
1872
|
+
const sessionKey = sessionRolesKeyOf(agent)
|
|
1857
1873
|
const listPlans = (): string[] => {
|
|
1858
1874
|
const lines = PRESET_IDS.map((id) => `- ${id}:${presetChoiceLabel(id)}`)
|
|
1859
1875
|
try {
|
|
@@ -1866,11 +1882,24 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
1866
1882
|
if (planArg === '') {
|
|
1867
1883
|
// No argument: plain-text listing (the command channel has no
|
|
1868
1884
|
// picker UI); the user confirms with a second invocation.
|
|
1885
|
+
const current = getSessionRolesOverride(sessionKey)
|
|
1869
1886
|
return {
|
|
1870
1887
|
kind: 'success' as const,
|
|
1871
1888
|
text:
|
|
1872
1889
|
'cybernetics 可用预置方案:\n' + listPlans().join('\n') +
|
|
1873
|
-
|
|
1890
|
+
(current ? `\n当前会话覆盖:${current.label}(${formatSessionRolesOverride(current)})` : '') +
|
|
1891
|
+
'\n再次输入 /cybernetics use <id> 应用其中一个方案;/cybernetics use off 清除会话覆盖。',
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
// Clear: back to the global fields for this session.
|
|
1895
|
+
if (planArg === 'off') {
|
|
1896
|
+
const had = clearSessionRolesOverride(sessionKey)
|
|
1897
|
+
presetDiag({ event: 'use-session-apply', preset: '', label: '', cleared: true, had })
|
|
1898
|
+
return {
|
|
1899
|
+
kind: 'success' as const,
|
|
1900
|
+
text: had
|
|
1901
|
+
? 'cybernetics: 已清除本会话的预置方案覆盖,回到全局配置(下一轮 goal 生效)。'
|
|
1902
|
+
: 'cybernetics: 本会话当前没有预置方案覆盖(一直跟随全局配置)。',
|
|
1874
1903
|
}
|
|
1875
1904
|
}
|
|
1876
1905
|
const isCustom = isCustomPresetId(planArg)
|
|
@@ -1888,28 +1917,107 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
1888
1917
|
'\n输入 /cybernetics use <id> 应用其中一个方案。',
|
|
1889
1918
|
}
|
|
1890
1919
|
}
|
|
1891
|
-
|
|
1892
|
-
|
|
1920
|
+
if (planArg === PRESET_AUTO_ID) {
|
|
1921
|
+
// Session-only auto: mark this session pending and hand the brief to
|
|
1922
|
+
// THIS session's agent (the preset_recommend tool hop 1), whose hop-2
|
|
1923
|
+
// answer lands in the session override instead of the global fields.
|
|
1924
|
+
// The settings card path (dropdown auto) is untouched.
|
|
1925
|
+
setSessionAutoPending(sessionKey)
|
|
1926
|
+
try {
|
|
1927
|
+
const target = agent as { status?: string; steer?: (m: unknown) => void; followup?: (m: unknown) => void }
|
|
1928
|
+
const message = {
|
|
1929
|
+
id: randomUUID(),
|
|
1930
|
+
role: 'user',
|
|
1931
|
+
content: [{
|
|
1932
|
+
type: 'text',
|
|
1933
|
+
text:
|
|
1934
|
+
'<cybernetics_preset>\n用户通过 /cybernetics use auto 请求本会话的预置方案自动推荐(仅本会话生效,不修改全局配置)。\n' +
|
|
1935
|
+
'请直接调用 preset_recommend 工具(不带 recommendation 参数)获取实时模型目录与推荐要求,' +
|
|
1936
|
+
'在自己的上下文中给出 task/verify/triage 的选择,然后再次调用 preset_recommend 并把推荐 JSON 传入 recommendation 参数提交。\n' +
|
|
1937
|
+
'本次提交将应用为本会话的角色覆盖。\n</cybernetics_preset>',
|
|
1938
|
+
}],
|
|
1939
|
+
source: { kind: 'cybernetics', preset: 'auto', scope: 'session' },
|
|
1940
|
+
}
|
|
1941
|
+
if (target?.status === 'running' && typeof target.steer === 'function') target.steer(message)
|
|
1942
|
+
else if (typeof target.followup === 'function') target.followup(message)
|
|
1943
|
+
} catch { /* best-effort: the tool remains callable from context */ }
|
|
1944
|
+
presetDiag({ event: 'use-session-apply', preset: PRESET_AUTO_ID, label: presetChoiceLabel(PRESET_AUTO_ID), phase: 'pending-handoff' })
|
|
1945
|
+
return {
|
|
1946
|
+
kind: 'success' as const,
|
|
1947
|
+
text:
|
|
1948
|
+
`cybernetics: 已交办本会话的预置方案自动推荐(${presetChoiceLabel(PRESET_AUTO_ID)})。` +
|
|
1949
|
+
'preset_recommend 的推荐结果将仅应用为本会话的 task/verify/triage 覆盖;不修改全局配置。',
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
// Built-in vendor plan or user-saved custom plan: resolve the roles
|
|
1953
|
+
// and store them as the session override (no settings mutate at all).
|
|
1954
|
+
let overrideRoles: Partial<Record<'task' | 'verify' | 'triage', { provider?: string; model?: string; reasoningEffort?: string }>> = {}
|
|
1955
|
+
let overrideLabel = planArg
|
|
1956
|
+
let placeholder = false
|
|
1957
|
+
if (isCustom) {
|
|
1958
|
+
const entryId = customPresetEntryId(planArg)
|
|
1959
|
+
let entry
|
|
1960
|
+
try {
|
|
1961
|
+
entry = parseCustomPresets(readConfigField(config.customPresets, '')).find((p) => p.id === entryId)
|
|
1962
|
+
} catch { entry = undefined }
|
|
1963
|
+
if (entry === undefined) {
|
|
1964
|
+
return { kind: 'error' as const, text: `cybernetics: 自定义方案 "${planArg}" 不存在(可能已被删除),未应用任何更改。` }
|
|
1965
|
+
}
|
|
1966
|
+
const appliedCustom = vendorPresetApplyOps(customPresetAsVendor(entry))
|
|
1967
|
+
overrideRoles = (appliedCustom.variant !== undefined ? appliedCustom.variant.roles : entry.roles) as typeof overrideRoles
|
|
1968
|
+
overrideLabel = entry.name
|
|
1969
|
+
} else {
|
|
1970
|
+
const preset = presetById(planArg)!
|
|
1971
|
+
const applied = vendorPresetApplyOps(preset)
|
|
1972
|
+
if (!applied.ready) placeholder = true
|
|
1973
|
+
else overrideRoles = (applied.variant !== undefined ? applied.variant.roles : preset.roles) as typeof overrideRoles
|
|
1974
|
+
overrideLabel = presetChoiceLabel(planArg)
|
|
1975
|
+
}
|
|
1976
|
+
if (placeholder) {
|
|
1977
|
+
return {
|
|
1978
|
+
kind: 'error' as const,
|
|
1979
|
+
text: `cybernetics: 预置方案 "${planArg}" 为占位条目(模型组合待填充),本次未应用任何更改。`,
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
setSessionRolesOverride(sessionKey, {
|
|
1983
|
+
preset: planArg,
|
|
1984
|
+
label: overrideLabel,
|
|
1985
|
+
roles: overrideRoles,
|
|
1986
|
+
appliedAt: new Date().toISOString(),
|
|
1987
|
+
})
|
|
1988
|
+
presetDiag({
|
|
1989
|
+
event: 'use-session-apply',
|
|
1990
|
+
preset: planArg,
|
|
1991
|
+
label: overrideLabel,
|
|
1992
|
+
picks: overrideRoles,
|
|
1993
|
+
cleared: false,
|
|
1994
|
+
})
|
|
1893
1995
|
return {
|
|
1894
1996
|
kind: 'success' as const,
|
|
1895
1997
|
text:
|
|
1896
|
-
|
|
1897
|
-
'
|
|
1998
|
+
`已在本会话应用预置方案 "${overrideLabel}":${formatSessionRolesOverride({ preset: planArg, label: overrideLabel, roles: overrideRoles, appliedAt: '' })};` +
|
|
1999
|
+
'仅本会话生效(会话结束失效),不修改全局配置。/cybernetics use off 可清除覆盖、回到全局配置。',
|
|
1898
2000
|
}
|
|
1899
2001
|
}
|
|
1900
2002
|
// No argument (or unknown text) = status report.
|
|
1901
2003
|
const effective = override === null ? globalOn : override
|
|
1902
2004
|
const overrideText = override === null ? '未设置(遵循全局配置)' : override ? 'on' : 'off'
|
|
2005
|
+
const sessionOverride = getSessionRolesOverride(sessionRolesKeyOf(agent))
|
|
2006
|
+
const sessionOverrideText = sessionOverride === undefined
|
|
2007
|
+
? '无(跟随全局配置)'
|
|
2008
|
+
: `${sessionOverride.label}(${formatSessionRolesOverride(sessionOverride)})— 清除:/cybernetics use off` +
|
|
2009
|
+
(peekSessionAutoPending(sessionRolesKeyOf(agent)) ? ';auto 推荐待提交(preset_recommend)' : '')
|
|
1903
2010
|
return {
|
|
1904
2011
|
kind: 'success' as const,
|
|
1905
2012
|
text:
|
|
1906
2013
|
`cybernetics 本会话状态:${effective ? '生效' : '关闭'}\n` +
|
|
1907
2014
|
`- 会话 override:${overrideText}\n` +
|
|
2015
|
+
`- 会话预置方案覆盖:${sessionOverrideText}\n` +
|
|
1908
2016
|
`- 全局 allGoalsMode:${globalOn ? 'on' : 'off'}\n` +
|
|
1909
2017
|
`- 硬门禁 hardGate:${hardGate() ? (resolveGateMode(config, true) === 'allowlist' ? 'on(allowlist)' : 'on(denylist)') : 'off'}\n` +
|
|
1910
2018
|
`- 新输入策略 inputPolicy:${readConfigField(config.inputPolicy, 'insert')}\n` +
|
|
1911
2019
|
`- 轮完成通知策略 noticePolicy:${readConfigField(config.noticePolicy, 'insert')}\n` +
|
|
1912
|
-
`用法:/cybernetics on | /cybernetics off | 无参数或 status = 显示状态 | /cybernetics use [方案id] =
|
|
2020
|
+
`用法:/cybernetics on | /cybernetics off | 无参数或 status = 显示状态 | /cybernetics use [方案id] = 应用预置方案(仅本会话生效;无参数列出全部可用方案;use off 清除覆盖)`,
|
|
1913
2021
|
}
|
|
1914
2022
|
}
|
|
1915
2023
|
const registerCommand = (session: unknown): void => {
|
|
@@ -2233,6 +2341,13 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
2233
2341
|
if (verifyEffort) roleOverrides.verify = { ...roleOverrides.verify, reasoningEffort: verifyEffort }
|
|
2234
2342
|
const triageEffort = sanitizeReasoningEffort(readConfigField(config.triageAgentReasoningEffort, ''))
|
|
2235
2343
|
if (triageEffort) roleOverrides.triage = { ...roleOverrides.triage, reasoningEffort: triageEffort }
|
|
2344
|
+
// v0.25.5 (user ruling 2026-11-11): a `/cybernetics use <id>` session
|
|
2345
|
+
// override wins over the global fields above — applied LAST, per role
|
|
2346
|
+
// and per field, so uncovered roles/fields keep the global value and a
|
|
2347
|
+
// session without `use` behaves exactly as before. In-memory only; the
|
|
2348
|
+
// global fields (and the settings card) are never read-modified here.
|
|
2349
|
+
const sessionOverride = getSessionRolesOverride(sessionRolesKeyOf(parent))
|
|
2350
|
+
const effectiveRoleOverrides = mergeSessionRolesOverride(roleOverrides, sessionOverride)
|
|
2236
2351
|
|
|
2237
2352
|
// The session's durable objective, as a classification-input fallback
|
|
2238
2353
|
// for targets that carry none (v0.16 smart selection reads it).
|
|
@@ -2243,7 +2358,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
2243
2358
|
// start so a settings-panel edit governs the next round (0/undefined
|
|
2244
2359
|
// falls back to the controller's built-in default).
|
|
2245
2360
|
roundsPerSubgoal: Math.max(1, Math.floor(readConfigField(config.roundsPerSubgoal, 3))),
|
|
2246
|
-
roles:
|
|
2361
|
+
roles: effectiveRoleOverrides,
|
|
2247
2362
|
}
|
|
2248
2363
|
/**
|
|
2249
2364
|
* v0.16 smart agent model selection — per LAUNCH, not per call. A
|
|
@@ -2272,15 +2387,15 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
2272
2387
|
depth: target.depth ?? 'full',
|
|
2273
2388
|
executor: target.executor ?? 'ephemeral',
|
|
2274
2389
|
taskRoleKey: target.role ?? 'default',
|
|
2275
|
-
explicit:
|
|
2390
|
+
explicit: effectiveRoleOverrides,
|
|
2276
2391
|
options: catalog.options,
|
|
2277
2392
|
session: sessionRouteOf(parent),
|
|
2278
2393
|
})
|
|
2279
2394
|
// The pure merge only produces rows for roles without explicit
|
|
2280
2395
|
// provider/model, so spreading them over the folded config cannot
|
|
2281
2396
|
// clobber an explicit value; a merged COPY keeps controllerConfig
|
|
2282
|
-
// (and
|
|
2283
|
-
const roles: RolesConfig = { ...
|
|
2397
|
+
// (and effectiveRoleOverrides) untouched for the next launch.
|
|
2398
|
+
const roles: RolesConfig = { ...effectiveRoleOverrides }
|
|
2284
2399
|
for (const [roleKey, spec] of Object.entries(overrides)) {
|
|
2285
2400
|
if (spec) roles[roleKey] = { ...roles[roleKey], ...spec }
|
|
2286
2401
|
}
|
|
@@ -2615,9 +2730,13 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
2615
2730
|
return [{ type: 'text', text: `推荐未应用(${value?.status}):${String(value?.reason ?? 'unknown')}` }]
|
|
2616
2731
|
},
|
|
2617
2732
|
},
|
|
2618
|
-
async execute(args: { recommendation?: string },
|
|
2733
|
+
async execute(args: { recommendation?: string }, exec: any) {
|
|
2619
2734
|
const llmFace = resolveLlmFace()
|
|
2620
2735
|
const llmCtx = llmFace !== undefined ? { llm: llmFace } : ctx
|
|
2736
|
+
// v0.25.5 session-only auto: when THIS session carries a `use auto`
|
|
2737
|
+
// pending marker, the tool's second hop lands as the session role
|
|
2738
|
+
// override (zero settings writes) instead of the global card path.
|
|
2739
|
+
const sessionKey = sessionRolesKeyOf(exec?.agent)
|
|
2621
2740
|
const answer = typeof args?.recommendation === 'string' ? args.recommendation.trim() : ''
|
|
2622
2741
|
if (answer === '') {
|
|
2623
2742
|
// Hop 1: hand the material over (pure probe + render, zero LLM calls).
|
|
@@ -2631,14 +2750,21 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
2631
2750
|
const probe = await probeCatalogOptions(llmCtx)
|
|
2632
2751
|
if (!Array.isArray(probe.options) || probe.options.length === 0) {
|
|
2633
2752
|
const reason = '当前系统模型目录不可用或为空,无法校验推荐'
|
|
2634
|
-
|
|
2753
|
+
if (!takeSessionAutoPending(sessionKey)) {
|
|
2754
|
+
await writePresetRecord([], recordOf(PRESET_AUTO_ID, { status: 'degraded', catalogSize: 0, reason }))
|
|
2755
|
+
} else {
|
|
2756
|
+
presetDiag({ event: 'use-session-apply', preset: PRESET_AUTO_ID, phase: 'degraded-catalog' })
|
|
2757
|
+
}
|
|
2635
2758
|
return { status: 'degraded', catalogSize: 0, reason }
|
|
2636
2759
|
}
|
|
2637
2760
|
const outcome = await applyRecommendationAnswer(llmCtx, answer, probe.options)
|
|
2761
|
+
// Consume the pending marker exactly once (before any early return).
|
|
2762
|
+
const sessionAutoApply = takeSessionAutoPending(sessionKey)
|
|
2638
2763
|
presetDiag(outcome.ok ? {
|
|
2639
2764
|
event: 'preset-tool-apply',
|
|
2640
2765
|
status: 'applied',
|
|
2641
2766
|
catalogSize: probe.options.length,
|
|
2767
|
+
...(sessionAutoApply ? { sessionScope: true } : {}),
|
|
2642
2768
|
picks: Object.fromEntries((['task', 'verify', 'triage'] as const).filter((k) => outcome.picks[k] !== undefined).map((k) => [k, `${outcome.picks[k]!.provider}/${outcome.picks[k]!.model}`])),
|
|
2643
2769
|
ignoredSession: outcome.ignoredSession,
|
|
2644
2770
|
notes: outcome.notes,
|
|
@@ -2649,8 +2775,11 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
2649
2775
|
reason: outcome.reason,
|
|
2650
2776
|
})
|
|
2651
2777
|
if (!outcome.ok) {
|
|
2652
|
-
// Rejected: the card learns why, the caller gets a
|
|
2653
|
-
|
|
2778
|
+
// Rejected: the card learns why (card path only), the caller gets a
|
|
2779
|
+
// correctable error. The session-only path writes nothing anywhere.
|
|
2780
|
+
if (!sessionAutoApply) {
|
|
2781
|
+
await writePresetRecord([], recordOf(PRESET_AUTO_ID, { status: 'degraded', catalogSize: probe.options.length, reason: outcome.reason }))
|
|
2782
|
+
}
|
|
2654
2783
|
return { status: 'rejected', catalogSize: probe.options.length, reason: outcome.reason }
|
|
2655
2784
|
}
|
|
2656
2785
|
const picks: Record<string, string> = {}
|
|
@@ -2661,6 +2790,31 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
2661
2790
|
picks[roleKey] = `${pick.provider}/${pick.model}`
|
|
2662
2791
|
efforts[roleKey] = pick.reasoningEffort ?? ''
|
|
2663
2792
|
}
|
|
2793
|
+
if (sessionAutoApply) {
|
|
2794
|
+
// v0.25.5 session-only landing: store the validated picks as THIS
|
|
2795
|
+
// session's role override; no settings.mutate, no card record.
|
|
2796
|
+
setSessionRolesOverride(sessionKey, {
|
|
2797
|
+
preset: PRESET_AUTO_ID,
|
|
2798
|
+
label: presetChoiceLabel(PRESET_AUTO_ID),
|
|
2799
|
+
// Keep only the dispatch-relevant fields (the catalog rows carry
|
|
2800
|
+
// display metadata like `name` that roles consumers never read).
|
|
2801
|
+
roles: Object.fromEntries(
|
|
2802
|
+
(['task', 'verify', 'triage'] as const).filter((k) => outcome.picks[k]).map((k) => {
|
|
2803
|
+
const { provider, model, reasoningEffort } = outcome.picks[k]!
|
|
2804
|
+
return [k, { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) }]
|
|
2805
|
+
}),
|
|
2806
|
+
),
|
|
2807
|
+
appliedAt: new Date().toISOString(),
|
|
2808
|
+
})
|
|
2809
|
+
presetDiag({ event: 'use-session-apply', preset: PRESET_AUTO_ID, label: presetChoiceLabel(PRESET_AUTO_ID), picks, cleared: false })
|
|
2810
|
+
return {
|
|
2811
|
+
status: 'applied',
|
|
2812
|
+
catalogSize: probe.options.length,
|
|
2813
|
+
picks,
|
|
2814
|
+
...(outcome.notes.length > 0 ? { notes: outcome.notes } : {}),
|
|
2815
|
+
reason: '已应用为本会话的角色覆盖(仅本会话生效,会话结束失效;不修改全局配置)',
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2664
2818
|
const written = await writePresetRecord(outcome.ops, recordOf(PRESET_AUTO_ID, {
|
|
2665
2819
|
catalogSize: probe.options.length,
|
|
2666
2820
|
picks,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session preset-plan role override (v0.25.5, user ruling 2026-11-11):
|
|
3
|
+
* the durable-free `/cybernetics use <id>` target. Unlike the settings-card
|
|
4
|
+
* dropdown path (which writes the six global role fields through the settings
|
|
5
|
+
* service), `use` resolves a plan (built-in PRESET_IDS id or `custom:<id>`,
|
|
6
|
+
* or auto via the preset_recommend tool) into task/verify/triage
|
|
7
|
+
* {provider, model, reasoningEffort} picks and stores them HERE — an
|
|
8
|
+
* in-memory, per-session map. Zero settings.mutate calls anywhere on the use
|
|
9
|
+
* path: the host's write-gate defect (docs/CHANGELOG.md v0.7.10, dsh-app-boot
|
|
10
|
+
* dual-module instance → WeakMap miss) becomes irrelevant for `use`, and the
|
|
11
|
+
* global configuration is never touched.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle: the map lives in module scope of the plugin entry — it is not
|
|
14
|
+
* persisted anywhere and dies with the host process, i.e. exactly the
|
|
15
|
+
* "session-only" semantics the ruling requires (a session restart falls back
|
|
16
|
+
* to the global fields). `use off` (or the status line's instruction) clears
|
|
17
|
+
* the override and the session falls back to the global fields immediately.
|
|
18
|
+
*
|
|
19
|
+
* Resolution rule consumed by the round-dispatch read point (src/index.ts,
|
|
20
|
+
* where the flat taskAgentModel/… fields fold into `roles`): SESSION OVERRIDE
|
|
21
|
+
* > GLOBAL FIELDS. Uncovered roles and uncovered fields fall through to the
|
|
22
|
+
* global value / session-agent inheritance, so a session without `use`
|
|
23
|
+
* behaves byte-for-byte as before.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-cybernetics/sessionRoles
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** One role's override spec (same shape as RolesConfig rows). */
|
|
29
|
+
export interface SessionRoleSpec {
|
|
30
|
+
provider?: string
|
|
31
|
+
model?: string
|
|
32
|
+
reasoningEffort?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What `use <id>` stores for one session. */
|
|
36
|
+
export interface SessionRolesOverride {
|
|
37
|
+
preset: string
|
|
38
|
+
label: string
|
|
39
|
+
roles: Partial<Record<'task' | 'verify' | 'triage', SessionRoleSpec>>
|
|
40
|
+
appliedAt: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Canonical session key: the session log id, falling back to the agent id. */
|
|
44
|
+
export function sessionRolesKeyOf(agent: unknown): string | undefined {
|
|
45
|
+
const a = agent as { session?: { header?: { id?: unknown } }; id?: unknown } | undefined
|
|
46
|
+
const id = a?.session?.header?.id
|
|
47
|
+
if (typeof id === 'string' && id.length > 0) return id
|
|
48
|
+
if (typeof a?.id === 'string' && a.id.length > 0) return a.id
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const store = new Map<string, SessionRolesOverride>()
|
|
53
|
+
/** Sessions with a pending `use auto` handoff (the tool's second hop lands here). */
|
|
54
|
+
const autoPending = new Map<string, string>()
|
|
55
|
+
|
|
56
|
+
/** Record (or replace — repeated `use` switches) the override for one session. */
|
|
57
|
+
export function setSessionRolesOverride(key: string | undefined, override: SessionRolesOverride): void {
|
|
58
|
+
if (key === undefined) return
|
|
59
|
+
store.set(key, override)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read the override; undefined = no override, the session follows global. */
|
|
63
|
+
export function getSessionRolesOverride(key: string | undefined): SessionRolesOverride | undefined {
|
|
64
|
+
if (key === undefined) return undefined
|
|
65
|
+
return store.get(key)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Clear the override; the session returns to the global fields. */
|
|
69
|
+
export function clearSessionRolesOverride(key: string | undefined): boolean {
|
|
70
|
+
if (key === undefined) return false
|
|
71
|
+
autoPending.delete(key)
|
|
72
|
+
return store.delete(key)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Mark a session as waiting for the `use auto` recommendation (tool hop 2). */
|
|
76
|
+
export function setSessionAutoPending(key: string | undefined, preset = 'auto'): void {
|
|
77
|
+
autoPending.set(key!, preset)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether this session's preset_recommend hop 2 must land as a session override. */
|
|
81
|
+
export function takeSessionAutoPending(key: string | undefined): boolean {
|
|
82
|
+
if (key === undefined) return false
|
|
83
|
+
const pending = autoPending.get(key)
|
|
84
|
+
if (pending === undefined) return false
|
|
85
|
+
autoPending.delete(key)
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Non-consuming check (status rendering). */
|
|
90
|
+
export function peekSessionAutoPending(key: string | undefined): boolean {
|
|
91
|
+
if (key === undefined) return false
|
|
92
|
+
return autoPending.has(key)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The pure merge the round-dispatch read point applies AFTER folding the
|
|
97
|
+
* global fields: session override rows win per role, per field — a role the
|
|
98
|
+
* override does not mention, and fields the row does not set, keep the global
|
|
99
|
+
* (or inherited) value. Returns a NEW object; inputs are never mutated.
|
|
100
|
+
*/
|
|
101
|
+
export function mergeSessionRolesOverride(
|
|
102
|
+
roles: Record<string, SessionRoleSpec | undefined>,
|
|
103
|
+
override: SessionRolesOverride | undefined,
|
|
104
|
+
): Record<string, SessionRoleSpec | undefined> {
|
|
105
|
+
if (override === undefined) return roles
|
|
106
|
+
const merged: Record<string, SessionRoleSpec | undefined> = { ...roles }
|
|
107
|
+
// The override's task role maps onto the canonical 'default' dispatch key.
|
|
108
|
+
const pairs: Array<['default' | 'verify' | 'triage', SessionRoleSpec | undefined]> = [
|
|
109
|
+
['default', override.roles.task],
|
|
110
|
+
['verify', override.roles.verify],
|
|
111
|
+
['triage', override.roles.triage],
|
|
112
|
+
]
|
|
113
|
+
for (const [key, spec] of pairs) {
|
|
114
|
+
if (!spec) continue
|
|
115
|
+
merged[key] = { ...merged[key], ...spec }
|
|
116
|
+
}
|
|
117
|
+
return merged
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Render the override's picks as `task=a/b(effort), …` for receipts/status. */
|
|
121
|
+
export function formatSessionRolesOverride(override: SessionRolesOverride): string {
|
|
122
|
+
return (['task', 'verify', 'triage'] as const)
|
|
123
|
+
.map((roleKey) => {
|
|
124
|
+
const spec = override.roles[roleKey]
|
|
125
|
+
if (!spec) return `${roleKey}=(未指定,跟随全局)`
|
|
126
|
+
const target = [spec.provider, spec.model].filter(Boolean).join('/') || '(未指定)'
|
|
127
|
+
return spec.reasoningEffort ? `${roleKey}=${target}(${spec.reasoningEffort})` : `${roleKey}=${target}`
|
|
128
|
+
})
|
|
129
|
+
.join(' ')
|
|
130
|
+
}
|