@mzzsfy/dsh-session-manager 0.7.0 → 0.8.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mzzsfy/dsh-session-manager
2
2
 
3
- DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 + 归档删除 + 回收站还原重挂载 + 历史输入浮层。
3
+ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 + 归档删除 + 回收站还原重挂载 + 历史输入浮层 + 插话撤回。
4
4
 
5
5
  ## 功能
6
6
 
@@ -8,9 +8,10 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
8
8
  - **范围导航**:浮层内 `←/→` 切换范围,范围序为 **常用 → 当前会话(默认落点)→ 本工作区 → 全部工作区**,`→` 向更大范围,`←` 返回收藏,边界停住;范围显示为浮层左上角徽标。
9
9
  - **工作区持久缓存**:每工作区一个 JSON 文件(`~/.dsh/historyPrompt/<工作区>-<路径哈希>.json`,可辨认 + 防同名冲突),内容为该工作区聚合后的输入条目(带 `sid` 来源会话溯源)。浮层请求直接读缓存文件立即返回(毫秒级,响应 `{ inputs, aligned }`);**对齐**在后台进行:解压范围内最近会话产物(经官方 `sessionQuery.readSession`,仅 `source.kind === 'user'` 的人类输入,工具结果回填与插件注入排除)、与缓存合并去重后原子写回(临时文件 + 改名)。对齐时机:插件启动对齐最近会话覆盖的工作区、每次浮层请求触发(同工作区 30 秒节流)、**运行中会话参与对齐(输入实时追加)**。
10
10
  - **范围语义**:`prompts` 用户显式收藏的常用提示词(全局单文件 `prompts.json`,不按工作区,详见收藏交互);`session` 该会话输入(缓存按 `sid` 过滤);`workspace` 同 cwd 工作区缓存;`global` 全部工作区缓存并集。
11
- - **上限与首次对齐**:全部历史最多保留最新 200 条(`HISTORY_INPUT_LIMIT`)、单条截 2 万字符(`HISTORY_INPUT_MAX_CHARS`),聚合精确文本去重保留最新时间。首次(该工作区尚无缓存文件)`aligned=false`,client 显示「首次对齐历史中」提示并每 3 秒(`HISTORY_REPULL_MS`)静默重拉;单会话产物读取失败仅告警跳过;scope 非法或缺省回退 `session`。
11
+ - **上限与首次对齐**:全部历史最多保留最新 200 条(`HISTORY_INPUT_LIMIT`)、单条截 2 万字符(`HISTORY_INPUT_MAX_CHARS`),聚合精确文本去重保留最新时间;**每会话首条保护**:超出上限被挤出时,每个来源会话在去重后集合中的最早条目仍保留(会话开场提示词最旧也最有复用价值,时间倒序截断总先挤掉它们;保护在去重后集合上计算,首条文本已被更新的会话复用时不再按旧会话重复追加;无 sid 的收藏条目不触发)。首次(该工作区尚无缓存文件)`aligned=false`,client 显示「首次对齐历史中」提示并每 3 秒(`HISTORY_REPULL_MS`)静默重拉;单会话产物读取失败仅告警跳过;scope 非法或缺省回退 `session`。
12
12
  - 常用提示词收藏:`prompts` 以外的范围,历史行鼠标悬停时行尾出现收藏星标(已收藏实星、未收藏空心),点击即收藏/取消(经 `POST /api/session-manager/prompts/toggle`),toast 反馈并同步星标;`prompts` 范围右上角「编辑」按钮进入编辑态:行内文本框直接改(Enter/失焦保存,实现为移除旧文本 + 收藏新文本)、行尾 `×` 删除、底部输入框 Enter 新增,「完成」退出;上限 `HISTORY_PROMPTS_MAX` 条,超出裁掉最旧;收藏与自动对齐的历史缓存互不覆写(工作区缓存列举跳过 prompts.json);global 范围读取走 stat 指纹缓存(`listWorkspaceCachesCached`),产物未变的文件不重复读盘;client 与 core 的 `HISTORY_SCOPES` 镜像由 parity 测试锁定。
13
13
  - 历史浮层启停开关:低频功能,归档面板末行小尺寸开关(规约 switch 形态 `sm-histsw`,状态选择器锚定 `input[type=checkbox]`);值存宿主 settings(`historyEnabled`,默认启用,原生设置页与本面板同存储即时同值),读写经本插件 `GET/POST /api/session-manager/history-enabled`(client 写 settings 不经宿主 remotes,零版本脆弱依赖);浮层挂载拉取一次,停用即锚点/快捷键/浮层整体不渲染、键盘监听放行,变更刷新页面生效。
14
+ - 插话撤回:插话发送(steer)后、消息尚未被智能体应用(宿主队列快照中仍存在 `placement=steering` 条目,即 Inbox next-step 等待 step 边界 claim)期间,撤回图标**直接出现在该插话气泡的原生操作图标排**(复制图标旁,与其同排同款):经 MutationObserver 定位官方 pending steering 气泡的语义标记 `data-pending-steering` 及其图标排(CSS module 类名后缀 `_actions`),注入 undo 弯箭头按钮并**克隆官方按钮 className**——尺寸/hover/悬停显隐(reveal)全部原生;气泡被应用后整棵卸载,注入按钮随之消失。dock 插槽本体 `display:none` 零占位,仅承载观察与注入;官方 DOM 结构漂移时注入静默跳过(不报错,干净禁用精神);点击经官方 `ISession.updateQueue(itemId, {kind:'remove'})` 摘除(对 next-step 同样有效,无运行状态限制——智能体停止后残留的未送达插话同样可撤),成功才回填输入框草稿(`inputActions.setDraft` 整体替换,与历史浮层回填同语义,**会覆盖输入框中未发送的既有草稿**)并 toast 提示;回填自身失败(会话切换等竞态)时经 toast 兜底展示原文;消息已被应用(竞态窗口内被 claim,宿主返回 `session/queue-item-not-found`)时专用文案报错且不动草稿。含附件的插话(text 为 null)图标禁用并提示(与官方队列编辑同为纯文本限制,撤回丢附件);subagent 非 continuable 会话不注入(与官方队列面板 queueMutable 同规);RPC 走会话绑定面 `binding.session.updateQueue`(RemoteResult 不抛错,与官方 conversation 服务零耦合),会话绑定缺失或会话面无 `updateQueue`(旧宿主)时返回空 props 整体禁用。归档面板提供启停开关(`steerRecallEnabled`,默认启用,开关行在历史浮层开关之下,复用同款 switch 形态),读写经本插件 `GET/POST /api/session-manager/steer-recall-enabled`,停用即不注入并移除已注入按钮,变更刷新页面生效。
14
15
  - 自动归档:host 半区对超期会话走官方 `workspace.archiveSession` 通道,三个触发源共用同一评估与门闩:新会话创建(`session/created`,按该会话所属工作区限定)、settings 就绪后的启动补扫(全量,清掉停机期间积压)、每日周期轮(全量,经宿主 timer 服务固定短 tick + 到期判断)。幂等:已归档会话不参与评估。阈值 `autoArchiveDays` 默认 7,`0` 关闭;周期间隔 `autoArchiveIntervalHours` 默认 24,`0` 关闭周期轮,变更经周期 tick 对账(关闭即时暂停、重启用最迟下个 tick 生效、缩短经锚定钳制前移,最长等新间隔而非等满旧周期)。评估筛选:createdAt 纯内存预筛 + 已归档跳过 + stat 后活跃度判定,locate 缺失(第三方后端)与产物不可读的会话一律跳过,宁可漏归档不可误归档。经 settings 命名空间 `session-manager` 注册(schema 拒绝负数与非整数)。timer 为软依赖:宿主定时服务不可用时仅周期轮停用,面板顶部提示,插件其余能力不受影响。
15
16
  - 自动归档配置:归档面板「已删除」区下方的配置行,阈值天数与检查周期小时数两个数字输入,失焦或 Enter 单字段即时提交,非法输入前端拦截不发请求,服务端失败回读生效值回滚;读写经本插件 `GET/POST /api/session-manager/auto-archive`(POST 部分更新,仅接受出现且为非负整数的字段,空字段拒绝),值存宿主 settings 与评估逻辑同源,`0` = 关闭,GET 返回含默认回退的当前生效值。
16
17
  - 归档面板:设置页「会话归档」分区,数据为 client 侧 `session.list` 行 ∩ `workspaces.follow` 归档快照的交集,按更新时间倒序;主列表只展开最近 `ARCHIVE_PAGE_SIZE`(100)条,列表尾部「按工作区浏览」按钮直接进入二级视图查看全部(主列表不翻页,防归档集合无限增长拖垮渲染)。二级视图:无搜索词时按工作区分组(组间按组内最新活跃倒序,分组默认收起,展开即见组内首页并可继续翻页,`workspace` 假值并入「未分组」组);输入搜索词后平铺展示标题命中(不区分大小写子串,标题缺失回退会话 id 天然可搜)的全部归档会话并显示匹配计数,同样分页。行内标题前显示所属工作区徽标——映射优先走 workspace 账本(`sessionIds`,与官方 dsh-client-ui-workspace 的 workspaceBySession 同构),cwd 命中某登记工作区路径时回退该工作区标题(较官方「cwd basename 直接回退」收紧,防未登记目录名冒充工作区),均未命中显示「未分组」;工作区 title 缺失回退路径末段。镜像函数 client 与 core 双实现由 parity 测试锁定。行内支持取消归档与删除。
@@ -71,12 +72,12 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
71
72
  - 对齐扫描各工作区最近 20 个会话(`HISTORY_SESSION_SCAN_LIMIT`,更早会话的输入不入缓存),解压发生在后台、永不阻塞浮层响应,代价是首次对齐期间列表为空、需等静默重拉。
72
73
  - **解压不重复执行**:每会话提取结果持久化在工作区缓存 `extracts` 段并附产物 stat 指纹(mtimeMs+size),对齐时先 stat 比对、产物未变零解压,判断完全基于磁盘、跨重启生效;运行中会话(当前会话)提取结果只保留内存不落盘——产物持续变化,落盘指纹立即失效。
73
74
  - 启动零解压(历史直接读磁盘缓存,重启前的输入本就在 entries 里),全量对齐延迟 30 秒(`HISTORY_STARTUP_DELAY_MS`)再跑且只回溯最近 100 个会话(`HISTORY_STARTUP_SCAN_LIMIT`)覆盖的工作区。
74
- - 对齐中连续解压占用超 200ms(`HISTORY_ALIGN_SLICE_MS`)即让出 100ms(`HISTORY_ALIGN_YIELD_MS`,zstd 解压为同步 CPU 操作,不设让出会让宿主启动阻塞数十秒);超 8MiB(`HISTORY_ALIGN_MAX_ARTIFACT_BYTES`)的巨产物跳过自动对齐(单次 readSession 内部同步解压不可让出,实时性让位于不卡主循环)。
75
+ - 对齐中连续解压占用超 200ms(`HISTORY_ALIGN_SLICE_MS`)即让出 100ms(`HISTORY_ALIGN_YIELD_MS`,解压为 CPU 操作,不设让出会长时间霸占主循环);巨产物跳过自动对齐,跳过线按宿主版本黑名单取值:0.1.1/0.1.2 系列(解压实现无周期让出)8MiB,其余(0.1.5 实测 `readZstdPrefix` 逐帧解压 + 周期让出,不阻塞主循环)16MiB(`HISTORY_ALIGN_LEGACY/MODERN_MAX_ARTIFACT_BYTES`,压缩字节数)——上限防的是解压耗时与事件对象内存峰值失控(压缩比约 1:3.4,超线产物解压后可达数百 MB);版本探测经 require 链解析宿主清单,失败保守按旧线。
75
76
  - header 查找走 ctx 级 TTL 缓存(inputs 路由不每次全量扫描 listSessions)。
76
77
  - 对齐为双车道:批量车道(窗口对齐)与焦点车道(单会话实时追加)各自串行互不等待,会话范围请求检测到产物指纹失配即触发焦点对齐并返回 aligned=false。
77
78
  - 浮层打开期间固定节奏轮询当前范围(数据可能晚于首次响应到达,一次性重拉预算会让迟到数据永不出现);单会话首条数据可见预算 3s:会话范围路由触发焦点对齐后就地等待 2s(`HISTORY_FOCUS_WAIT_MS`),正常会话单次请求内拿到新数据,超时(巨产物)走轮询流式补齐。
78
79
  - 不解压搬进 worker:产物为多帧 zstd + 帧扫描 + 记录解码 + 版本协商,`readSession` 深度耦合宿主持持层,worker 内复刻等于维护第二份产物解析实现,宿主升级即漂移。
79
- - 缓存上限 200 条(`HISTORY_INPUT_LIMIT`),超出裁掉最旧(超长会话史会丢早期输入);新输入从发送到浮层可见有秒级延迟(宿主批量刷盘 200ms + 焦点对齐 + 轮询节奏),非严格实时。
80
+ - 缓存上限 200 条(`HISTORY_INPUT_LIMIT`),超出裁掉最旧,每会话最早条目例外保留(首条保护,见「上限与首次对齐」);巨产物(超线跳过对齐,见对齐参数)会话的条目只能来自既有缓存,首条从未入库则无法恢复;新输入从发送到浮层可见有秒级延迟(宿主批量刷盘 200ms + 焦点对齐 + 轮询节奏),非严格实时。
80
81
  - 缓存文件为明文 JSON(输入内容落盘 `~/.dsh/historyPrompt`,与产物本身同级敏感);单条超长文本(大段粘贴)在 host 侧截断,回填与展示均用截断后文本。
81
82
  - 交互收敛为快捷键唤起浮层(`Alt+↑`,无 mac 系统占用,与编辑器光标移动无冲突),无常驻界面元素。
82
83
  - 重挂载的并发窗口:attachSession 后、台账清除前进程中断,残留条目重挂载时因官方 attachSession 幂等(已在 sessionIds 中则跳过校验直写)无副作用,再次点按或「移除记录」即可收尾。台账域打开失败时已删除 / 重挂载 / forget 以 400 拒绝;删除路由走 partial 降级(其余步骤照常,提示「已移入回收站,但重挂载记录失败」),不做无台账中断——产物进回收站后中断删除只会造成状态不一致。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-session-manager",
3
3
  "description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
4
- "version": "0.7.0",
4
+ "version": "0.8.1",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -14,11 +14,11 @@ window.__ModuleLoader__.load({
14
14
  id: '@mzzsfy/dsh-session-manager',
15
15
  factory(require) {
16
16
  const React = require('react')
17
- const { useState, useEffect, useSyncExternalStore } = React
17
+ const { useState, useEffect, useRef, useSyncExternalStore } = React
18
18
 
19
- // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染(本插件分区 → archive);
20
- // 该插件未就绪时入队,由其启动时排空
21
- const NAV_ICON = { '会话归档': 'archive' }
19
+ // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;双键 = 分区 label + 市场
20
+ // 短名(发现页收录显示形态);该插件未就绪时入队,由其启动时排空
21
+ const NAV_ICON = { '会话归档': 'archive', 'dsh-session-manager': 'archive' }
22
22
  if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
23
23
  else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
24
24
  else window.__navicIconQueue = [NAV_ICON]
@@ -173,6 +173,9 @@ const CSS = [
173
173
  '.sm-hist__banner { padding:7px 14px; flex:none; text-align:center; font:var(--dsw-font-xxs-12, 12px/18px sans-serif);',
174
174
  ' color:light-dark(rgba(15,17,21,.55), rgba(232,234,237,.55));',
175
175
  ' background:light-dark(rgba(15,17,21,.04), rgba(255,255,255,.06)); }',
176
+ // 插话撤回图标:注入原生 pending steering 气泡的操作图标排,样式经克隆官方
177
+ // 按钮类名原生同款;dock 本体零 DOM,仅承载注入逻辑
178
+ '.sm-steer-host { display:none; }',
176
179
  ].join('\n')
177
180
 
178
181
  const UNARCHIVE_URL = '/api/session-manager/unarchive'
@@ -649,6 +652,7 @@ function SessionManagerApp(props) {
649
652
  }),
650
653
  h(AutoArchiveConfig, null),
651
654
  h(HistorySwitchRow, null),
655
+ h(SteerSwitchRow, null),
652
656
  )
653
657
  }
654
658
 
@@ -682,6 +686,34 @@ function HistorySwitchRow() {
682
686
  )
683
687
  }
684
688
 
689
+ // 插话撤回启停开关:与历史浮层开关同构,复用同款 switch 形态与样式(同前缀);
690
+ // 值存宿主 settings,切换经本插件路由中转,变更刷新页面生效
691
+ function SteerSwitchRow() {
692
+ const [enabled, setEnabled] = useState(null)
693
+ useEffect(() => {
694
+ api(STEER_ENABLED_URL)
695
+ .then((payload) => setEnabled(payload ? payload.enabled !== false : true))
696
+ .catch(() => setEnabled(true))
697
+ }, [])
698
+ const flip = (event) => {
699
+ const next = event.target.checked
700
+ setEnabled(next)
701
+ api(STEER_ENABLED_URL, { method: 'POST', body: JSON.stringify({ enabled: next }) })
702
+ .then((payload) => {
703
+ toast('插话撤回已' + (payload && payload.enabled !== false ? '启用' : '停用') + ',刷新页面后生效')
704
+ })
705
+ .catch(() => {
706
+ setEnabled(!next)
707
+ toast('切换失败', { kind: 'error' })
708
+ })
709
+ }
710
+ return h('label', { className: 'sm-histsw' },
711
+ h('input', { type: 'checkbox', checked: enabled !== false, onChange: flip }),
712
+ h('span', { className: 'sm-histsw__track' }, h('span', { className: 'sm-histsw__thumb' })),
713
+ h('span', { className: 'sm-histsw__label', onClick: (event) => event.preventDefault() }, '插话撤回图标'),
714
+ )
715
+ }
716
+
685
717
  // 自动归档配置:阈值天数与检查周期小时数,数值语义,失焦或 Enter 单字段即时提交;
686
718
  // 值存宿主 settings(与评估逻辑同源,0 = 关闭),非法输入前端拒绝不发包,
687
719
  // 服务端失败后回读生效值回滚显示
@@ -776,6 +808,7 @@ const HISTORY_SCOPE_DEFAULT = HISTORY_SCOPES.indexOf('session')
776
808
  const HISTORY_REPULL_MS = 3 * 1000
777
809
  const PROMPTS_TOGGLE_URL = '/api/session-manager/prompts/toggle'
778
810
  const HISTORY_ENABLED_URL = '/api/session-manager/history-enabled'
811
+ const STEER_ENABLED_URL = '/api/session-manager/steer-recall-enabled'
779
812
 
780
813
  function HistoryDock({ session, inputActions }) {
781
814
  const [open, setOpen] = useState(false)
@@ -1097,6 +1130,158 @@ function HistoryDock({ session, inputActions }) {
1097
1130
  )
1098
1131
  }
1099
1132
 
1133
+ // 插话撤回投影:仅用户插话(宿主队列 placement=steering,已发出但未被 step 边界
1134
+ // claim 应用);queued 由官方队列面板呈现,context 为注入上下文非用户消息
1135
+ function steerRowsOf(queue) {
1136
+ const rows = Array.isArray(queue) ? queue : []
1137
+ return rows.filter((row) => row && row.placement === 'steering')
1138
+ }
1139
+
1140
+ // 撤回失败文案:queue-item-not-found = 消息已被 step 边界 claim(应用),无法撤回;
1141
+ // 其余透传宿主错误消息
1142
+ function withdrawFailureText(result) {
1143
+ const error = result && result.error
1144
+ if (error && error.code === 'session/queue-item-not-found') return '插话已被应用,无法撤回'
1145
+ return '撤回失败: ' + ((error && error.message) || '未知错误')
1146
+ }
1147
+
1148
+ // 撤回动作:先宿主移除(inbox next-step 摘除,未应用即消失),成功才回填草稿,
1149
+ // 顺序保证「移除失败绝不覆盖输入框」;actions 注入便于测试与组件解耦
1150
+ async function withdrawSteer(row, actions) {
1151
+ let result
1152
+ try {
1153
+ result = await actions.updateQueue(row.id, { kind: 'remove' })
1154
+ } catch (error) {
1155
+ actions.notify('撤回失败: ' + String(error && error.message || error), { kind: 'error' })
1156
+ return false
1157
+ }
1158
+ if (!(result && result.ok)) {
1159
+ actions.notify(withdrawFailureText(result), { kind: 'error' })
1160
+ return false
1161
+ }
1162
+ try {
1163
+ actions.setDraft(row.text)
1164
+ } catch (error) {
1165
+ // 摘除已成功,草稿回填失败(会话切换等竞态)时文本必须可达:通知兜底展示原文
1166
+ actions.notify('草稿回填失败,原文: ' + row.text, { kind: 'error' })
1167
+ return false
1168
+ }
1169
+ actions.notify('已撤回到输入框')
1170
+ return true
1171
+ }
1172
+
1173
+ // 撤回图标 svg(undo 弯箭头,stroke 继承 currentColor,尺寸由所克隆的官方按钮类控制)
1174
+ function steerRecallSvg() {
1175
+ const namespace = 'http://www.w3.org/2000/svg'
1176
+ const svg = document.createElementNS(namespace, 'svg')
1177
+ svg.setAttribute('viewBox', '0 0 16 16')
1178
+ svg.setAttribute('fill', 'none')
1179
+ svg.setAttribute('stroke', 'currentColor')
1180
+ svg.setAttribute('stroke-width', '1.5')
1181
+ svg.setAttribute('stroke-linecap', 'round')
1182
+ svg.setAttribute('stroke-linejoin', 'round')
1183
+ svg.setAttribute('aria-hidden', 'true')
1184
+ const paths = ['M3 6.5h6.5a3.5 3.5 0 0 1 0 7H6', 'M6 3.5 3 6.5l3 3']
1185
+ for (const d of paths) {
1186
+ const path = document.createElementNS(namespace, 'path')
1187
+ path.setAttribute('d', d)
1188
+ svg.appendChild(path)
1189
+ }
1190
+ return svg
1191
+ }
1192
+
1193
+ // 注入按钮标记:识别自家节点与官方按钮,防止重复注入与误删官方节点
1194
+ const STEER_BTN_FLAG = 'data-sm-steer-recall'
1195
+ // 官方 pending steering 气泡的语义标记(官方同构:UserStyleBubble data 属性)与
1196
+ // 其操作图标排的 CSS module 类名后缀(哈希前缀随构建漂移,后缀稳定)
1197
+ const STEER_BUBBLE_SELECTOR = '[data-pending-steering]'
1198
+ const STEER_ACTIONS_SUFFIX = '[class$="_actions"]'
1199
+
1200
+ // 插话撤回:注入原生 pending steering 气泡的操作图标排(复制图标旁),样式经克隆
1201
+ // 官方按钮 className 完全原生;气泡被应用后整棵卸载,注入按钮随之消失。
1202
+ // dock 本体 display:none 零占位,仅承载观察与注入;官方 DOM 结构漂移时注入静默
1203
+ // 跳过(不报错),干净禁用精神
1204
+ function SteerRecallDock({ session, useSession, inputActions, updateQueue }) {
1205
+ const queue = useSession((state) => state.queue)
1206
+ const queueMutable = useSession((state) => state.subagent === null || state.subagent.address.mode === 'continuable')
1207
+ const [busy, setBusy] = useState(false)
1208
+ // 启停开关(设置页):挂载拉取一次,停用即不注入并移除已注入按钮;失败按启用兜底
1209
+ const [enabled, setEnabled] = useState(true)
1210
+ useEffect(() => {
1211
+ api(STEER_ENABLED_URL)
1212
+ .then((payload) => setEnabled(payload ? payload.enabled !== false : true))
1213
+ .catch(() => {})
1214
+ }, [])
1215
+ // 观察回调经由 ref 读最新状态,观察器只挂一次不随渲染重挂
1216
+ const rowsRef = useRef([])
1217
+ const mutableRef = useRef(true)
1218
+ const busyRef = useRef(false)
1219
+ const actionsRef = useRef(null)
1220
+ rowsRef.current = steerRowsOf(queue)
1221
+ mutableRef.current = queueMutable
1222
+ busyRef.current = busy
1223
+ actionsRef.current = { updateQueue, setDraft: inputActions ? inputActions.setDraft : undefined }
1224
+ const scanRef = useRef(null)
1225
+ useEffect(() => {
1226
+ function withdraw(row) {
1227
+ const actions = actionsRef.current
1228
+ if (busyRef.current || !actions || typeof actions.updateQueue !== 'function') return
1229
+ setBusy(true)
1230
+ void withdrawSteer(row, { updateQueue: actions.updateQueue, setDraft: actions.setDraft, notify: toast })
1231
+ .finally(() => setBusy(false))
1232
+ }
1233
+ function syncButton(button, row) {
1234
+ const textOnly = row.text !== null
1235
+ button.disabled = busyRef.current || !textOnly
1236
+ button.title = textOnly ? '撤回到输入框: ' + row.preview : '含附件的插话不支持撤回'
1237
+ }
1238
+ function scan() {
1239
+ const bubbles = document.querySelectorAll(STEER_BUBBLE_SELECTOR)
1240
+ const rows = mutableRef.current ? rowsRef.current : []
1241
+ bubbles.forEach((bubble, index) => {
1242
+ const actionsRow = bubble.querySelector(STEER_ACTIONS_SUFFIX)
1243
+ if (!actionsRow) return
1244
+ let button = actionsRow.querySelector('[' + STEER_BTN_FLAG + ']')
1245
+ const row = rows[index]
1246
+ if (!row) {
1247
+ // 气泡尚存但行已被应用/撤回(帧差瞬态):按钮随气泡卸载,先禁用防误触
1248
+ if (button) button.disabled = true
1249
+ return
1250
+ }
1251
+ if (!button) {
1252
+ const official = actionsRow.querySelector('button:not([' + STEER_BTN_FLAG + '])')
1253
+ if (!official) return
1254
+ button = document.createElement('button')
1255
+ button.type = 'button'
1256
+ button.setAttribute(STEER_BTN_FLAG, '')
1257
+ // 克隆官方按钮类名:尺寸/hover/悬停显隐(reveal)全部原生
1258
+ button.className = official.className
1259
+ button.addEventListener('click', () => withdraw(row))
1260
+ button.appendChild(steerRecallSvg())
1261
+ actionsRow.appendChild(button)
1262
+ }
1263
+ syncButton(button, row)
1264
+ })
1265
+ }
1266
+ scanRef.current = scan
1267
+ const observer = new MutationObserver(scan)
1268
+ observer.observe(document.documentElement, { childList: true, subtree: true })
1269
+ scan()
1270
+ return () => {
1271
+ scanRef.current = null
1272
+ observer.disconnect()
1273
+ document.querySelectorAll('[' + STEER_BTN_FLAG + ']').forEach((button) => button.remove())
1274
+ }
1275
+ }, [])
1276
+ // 行集/开关/忙碌变化即时重扫(观察器只覆盖 DOM 变更,状态变化需主动对账)
1277
+ useEffect(() => {
1278
+ if (scanRef.current) scanRef.current()
1279
+ }, [queue, enabled, busy, queueMutable])
1280
+ if (session === undefined || inputActions === undefined || updateQueue === undefined) return null
1281
+ if (!enabled || !queueMutable) return null
1282
+ return h('div', { className: 'sm-steer-host' })
1283
+ }
1284
+
1100
1285
  return {
1101
1286
  inject: ['slots', 'sessions', 'workspaces'],
1102
1287
  apply(ctx) {
@@ -1144,6 +1329,29 @@ function HistoryDock({ session, inputActions }) {
1144
1329
  console.warn('[session-manager] 历史输入入口未注册(宿主无 conversation.input.dock 插槽)', error)
1145
1330
  }
1146
1331
 
1332
+ // 插话撤回入口:未应用的插话撤回到输入框重新编辑。RPC 走会话绑定面
1333
+ // binding.session.updateQueue(RemoteResult 不抛错,与官方 conversation 服务
1334
+ // 无耦合);会话面缺失或无 updateQueue(旧宿主)返回空 props,组件不渲染
1335
+ try {
1336
+ ctx.slots.inject('conversation.input.dock', () =>
1337
+ ctx.slots.register(
1338
+ {
1339
+ name: 'conversation.input.dock',
1340
+ id: 'session-manager-steer',
1341
+ order: 25,
1342
+ inject: (sessionId) => {
1343
+ const binding = sessions.binding(sessionId)
1344
+ const updateQueue = binding && binding.session && binding.session.updateQueue
1345
+ if (typeof updateQueue !== 'function') return {}
1346
+ return { updateQueue: (itemId, action) => binding.session.updateQueue(itemId, action) }
1347
+ },
1348
+ },
1349
+ SteerRecallDock,
1350
+ ))
1351
+ } catch (error) {
1352
+ console.warn('[session-manager] 插话撤回入口未注册(宿主无 conversation.input.dock 插槽)', error)
1353
+ }
1354
+
1147
1355
  ctx.effect(() => unsubscribe, 'session-manager archived diff')
1148
1356
 
1149
1357
  function SessionManagerPanel({ sessions: sessionSvc, workspaces: workspaceSvc }) {
package/src/core.mjs CHANGED
@@ -251,10 +251,10 @@ export function filterArchiveRows(rows, query) {
251
251
  // 范围按 ←/→ 切换序排列:索引 0 为常用提示词(个人收藏),索引 1 为当前会话
252
252
  // (浮层默认落点),→ 向更大范围,← 返回收藏
253
253
  // 对齐 = 后台解压范围内会话产物与持久缓存合并;两次对齐最小间隔防持续解压。
254
- // 解压是同步 CPU 操作会阻塞主循环:启动零解压(历史直接读磁盘缓存),
255
- // 全量对齐延迟 STARTUP_DELAY 再跑、只回溯最近 STARTUP_SCAN 个会话、
256
- // 跳过超 MAX_ARTIFACT 的巨产物(单次 readSession 内部不可让出,巨产物一解卡死主循环);
257
- // 对齐中连续解压占用超 SLICE 即让出 YIELD。
254
+ // 解压是 CPU 操作:启动零解压(历史直接读磁盘缓存),全量对齐延迟 STARTUP_DELAY
255
+ // 再跑、只回溯最近 STARTUP_SCAN 个会话;超 MAX_ARTIFACT 的巨产物跳过——
256
+ // 上限防的是解压耗时与事件对象内存峰值失控,非阻塞主循环;跳过线按宿主版本
257
+ // 黑名单取值(见 isLegacyDecompressHost),对齐中连续解压占用超 SLICE 即让出 YIELD。
258
258
  // 运行中会话(当前会话)提取结果只保留内存不落盘——产物持续变化,落盘指纹立即失效
259
259
  export const HISTORY_SESSION_SCAN_LIMIT = 20
260
260
  export const HISTORY_INPUT_LIMIT = 200
@@ -266,7 +266,26 @@ export const HISTORY_STARTUP_DELAY_MS = 30 * 1000
266
266
  export const HISTORY_STARTUP_SCAN_LIMIT = 100
267
267
  export const HISTORY_ALIGN_SLICE_MS = 200
268
268
  export const HISTORY_ALIGN_YIELD_MS = 100
269
- export const HISTORY_ALIGN_MAX_ARTIFACT_BYTES = 8 * 1024 * 1024
269
+ // 巨产物跳过线(压缩字节数):现役最大产物约 3.5MiB。新解压线 16MiB 留 4 倍余量,
270
+ // 超线产物解压后可达数百 MB(压缩比约 1:3.4,事件对象再放大),内存峰值失控;
271
+ // 旧解压线 8MiB 面向 0.1.1/0.1.2 黑名单宿主——其解压实现尚无周期让出,从严
272
+ export const HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
273
+ export const HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES = 8 * 1024 * 1024
274
+ // 与官方 semver 语义对齐的宽松校验:黑名单判定只取主/次/修订号,prerelease 无关
275
+ const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)/
276
+ // 旧解压实现黑名单:仅 0.1.1 与 0.1.2 系列(含各 prerelease);0.1.5 实测
277
+ // readZstdPrefix 逐帧解压 + 周期让出。版本非法/缺失按黑名单保守回退旧线
278
+ export function isLegacyDecompressHost(version) {
279
+ const match = SEMVER_PATTERN.exec(typeof version === 'string' ? version.trim() : '')
280
+ if (!match) return true
281
+ const major = Number(match[1])
282
+ const minor = Number(match[2])
283
+ const patch = Number(match[3])
284
+ return major === 0 && minor === 1 && (patch === 1 || patch === 2)
285
+ }
286
+ export function maxArtifactBytesForHost(version) {
287
+ return isLegacyDecompressHost(version) ? HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES : HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES
288
+ }
270
289
  // 会话范围路由触发焦点对齐后就地等待的时限:首条数据可见预算(3s)内
271
290
  // 留出解压+读盘份额,超时改走轮询流式补齐
272
291
  export const HISTORY_FOCUS_WAIT_MS = 2 * 1000
@@ -294,16 +313,29 @@ export function extractUserInputs(events) {
294
313
  }
295
314
 
296
315
  /** 历史输入聚合:精确文本去重保留最新时间,时间倒序,截 limit 条,单条截 maxChars 字符。
297
- * 条目可携带 sid(来源会话)用于 session 范围过滤,聚合时原样保留。 */
316
+ * 条目可携带 sid(来源会话)用于 session 范围过滤,聚合时原样保留。
317
+ * 首条保护:被 limit 挤出时,每个来源会话在去重后集合中的最早条目仍保留——
318
+ * 会话开场提示词是最有复用价值也最旧的一批,时间倒序截断总会先挤掉它们;
319
+ * 保护在去重后集合上计算,文本已被更新的会话复用时不再按旧会话重复追加。 */
298
320
  export function aggregateInputs(entries, { limit, maxChars }) {
299
321
  const latestByText = new Map()
300
322
  for (const entry of entries || []) {
301
323
  const known = latestByText.get(entry.text)
302
324
  if (known === undefined || entry.at > known.at) latestByText.set(entry.text, entry)
303
325
  }
304
- return [...latestByText.values()]
326
+ const earliestBySid = new Map()
327
+ for (const entry of latestByText.values()) {
328
+ if (entry.sid === undefined) continue
329
+ const known = earliestBySid.get(entry.sid)
330
+ if (known === undefined || entry.at < known.at) earliestBySid.set(entry.sid, entry)
331
+ }
332
+ const kept = [...latestByText.values()].sort((left, right) => right.at - left.at).slice(0, limit)
333
+ const keptTexts = new Set(kept.map((entry) => entry.text))
334
+ for (const first of earliestBySid.values()) {
335
+ if (!keptTexts.has(first.text)) kept.push(first)
336
+ }
337
+ return kept
305
338
  .sort((left, right) => right.at - left.at)
306
- .slice(0, limit)
307
339
  .map((entry) => ({
308
340
  text: entry.text.slice(0, maxChars),
309
341
  at: entry.at,
package/src/index.js CHANGED
@@ -19,7 +19,6 @@ import {
19
19
  DAY_MS,
20
20
  DEFAULT_AUTO_ARCHIVE_DAYS,
21
21
  DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
22
- HISTORY_ALIGN_MAX_ARTIFACT_BYTES,
23
22
  HISTORY_ALIGN_SLICE_MS,
24
23
  HISTORY_ALIGN_THROTTLE_MS,
25
24
  HISTORY_ALIGN_YIELD_MS,
@@ -36,7 +35,9 @@ import {
36
35
  artifactLooksBlank,
37
36
  deleteEligibility,
38
37
  extractUserInputs,
38
+ isLegacyDecompressHost,
39
39
  isSessionRunning,
40
+ maxArtifactBytesForHost,
40
41
  mergeDeletedEntry,
41
42
  removeDeletedEntry,
42
43
  selectArchiveCandidates,
@@ -44,6 +45,7 @@ import {
44
45
  } from './core.mjs'
45
46
  import { ensureCacheDir, listWorkspaceCachesCached, readPrompts, readWorkspaceCache, writePrompts, writeWorkspaceCache } from './history-cache.mjs'
46
47
  import { trashPath } from './trash.mjs'
48
+ import { createRequire } from 'node:module'
47
49
 
48
50
  export const name = 'dsh-session-manager'
49
51
 
@@ -109,6 +111,8 @@ const SETTINGS_SCHEMA = schemastery.object({
109
111
  .description('周期评估间隔小时数,0 表示关闭周期评估;变更经周期 tick 对账,关闭即时暂停、重启用最迟下个 tick 生效'),
110
112
  historyEnabled: schemastery.boolean().default(true)
111
113
  .description('历史输入浮层(Alt+↑),低频功能,关闭后输入框锚点与快捷键整体不渲染;变更刷新页面生效'),
114
+ steerRecallEnabled: schemastery.boolean().default(true)
115
+ .description('插话撤回图标(输入框上方),低频功能,关闭后撤回图标整体不渲染;变更刷新页面生效'),
112
116
  })
113
117
 
114
118
  function sendJson(res, status, payload) {
@@ -136,6 +140,7 @@ function readSettings(ctx) {
136
140
  days: Number.isInteger(days) && days >= 0 ? days : DEFAULT_AUTO_ARCHIVE_DAYS,
137
141
  intervalHours: Number.isInteger(hours) && hours >= 0 ? hours : DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
138
142
  historyEnabled: !value || value.historyEnabled !== false,
143
+ steerRecallEnabled: !value || value.steerRecallEnabled !== false,
139
144
  }
140
145
  }
141
146
 
@@ -382,7 +387,7 @@ export function apply(ctx, config) {
382
387
  }
383
388
 
384
389
  // 历史输入:工作区粒度持久缓存(~/.dsh/historyPrompt/<工作区>-<hash>.json)。
385
- // 浮层请求直接读缓存文件立即返回(毫秒级);对齐在后台解压范围内最近会话产物,
390
+ // 浮层请求直接读缓存文件立即返回(毫秒级);对齐在后台解压范围内会话产物,
386
391
  // 与缓存合并去重后写回——运行中会话也参与(实时追加其新输入)。
387
392
  // 解压不重复执行:每会话提取结果持久化在工作区缓存 extracts 段,附产物 stat 指纹
388
393
  // (mtimeMs+size),对齐时先 stat 比对,产物没变零解压——判断全部基于磁盘,跨重启生效。
@@ -391,6 +396,20 @@ export function apply(ctx, config) {
391
396
  // STARTUP_DELAY 再跑,绝不阻塞宿主启动。测试经 env DSH_HISTORY_CACHE_DIR 注入临时目录
392
397
  const cacheDir = process.env.DSH_HISTORY_CACHE_DIR
393
398
  || join(homedir(), '.dsh', 'historyPrompt')
399
+ // 巨产物跳过线按宿主版本黑名单取值:0.1.1/0.1.2 解压实现无周期让出从严 8MiB,
400
+ // 其余 16MiB。运行宿主清单经 require 链解析(插件随宿主树部署时必命中),
401
+ // 探测失败按黑名单保守回退旧线。激活时求值一次即冻结,热路径只读值;
402
+ // 宿主热升级后重启生效
403
+ let maxArtifactBytes = resolveMaxArtifactBytes()
404
+ function resolveMaxArtifactBytes() {
405
+ let version = null
406
+ try {
407
+ version = createRequire(import.meta.url)('@deepseek-ai/dsh/package.json').version
408
+ } catch {
409
+ version = null
410
+ }
411
+ return maxArtifactBytesForHost(version)
412
+ }
394
413
  const alignThrottle = new Map()
395
414
  const runtimeExtracts = new Map()
396
415
  const headerCache = new Map()
@@ -447,8 +466,8 @@ export function apply(ctx, config) {
447
466
 
448
467
  // 批量对齐执行体:列出范围会话,逐个按指纹增量提取(磁盘 extracts 复用),
449
468
  // 与缓存合并后原子写回,extracts 裁剪到完整扫描窗口。经批量队列入队。
450
- // 超过 MAX_ARTIFACT 的巨产物直接跳过:单次 readSession 内部同步解压不可让出,
451
- // 巨会话一解卡死主循环;其历史来自缓存 entries 的既有贡献
469
+ // 超过 MAX_ARTIFACT 的巨产物直接跳过:解压耗时与内存峰值随产物规模失控
470
+ // (宿主内部逐帧解压带周期让出,不阻塞主循环);其历史来自缓存 entries 的既有贡献
452
471
  function alignWorkspaceNow(cwd) {
453
472
  return enqueueBulkAlign(async () => {
454
473
  const agents = ctx.get('agents')
@@ -465,7 +484,7 @@ export function apply(ctx, config) {
465
484
  const isRunning = isSessionRunning({ agents, sessionId: recordItem.header.id })
466
485
  const located = persistence ? persistence.locate(recordItem.header) : undefined
467
486
  const fingerprint = located ? await safeStat(located.path) : null
468
- if (fingerprint !== null && fingerprint.size > HISTORY_ALIGN_MAX_ARTIFACT_BYTES) continue
487
+ if (fingerprint !== null && fingerprint.size > resolveMaxArtifactBytes()) continue
469
488
  try {
470
489
  const entries = await extractSession(recordItem, fingerprint, extracts, isRunning)
471
490
  fresh.push(...entries.map((entry) => ({ ...entry, sid: recordItem.header.id })))
@@ -501,7 +520,7 @@ export function apply(ctx, config) {
501
520
  const persistence = ctx.get('sessionPersistence')
502
521
  const located = persistence ? persistence.locate(header) : undefined
503
522
  const fingerprint = located ? await safeStat(located.path) : null
504
- if (fingerprint === null || fingerprint.size > HISTORY_ALIGN_MAX_ARTIFACT_BYTES) return
523
+ if (fingerprint === null || fingerprint.size > resolveMaxArtifactBytes()) return
505
524
  const isRunning = isSessionRunning({ agents: ctx.get('agents'), sessionId })
506
525
  const cached = await readWorkspaceCache(cacheDir, cwd)
507
526
  const extracts = cached && cached.extracts ? { ...cached.extracts } : {}
@@ -576,7 +595,7 @@ export function apply(ctx, config) {
576
595
  // 实时追加:产物 stat 与已知指纹不一致 = 会话有新输入未入缓存——聚焦对齐
577
596
  // 只解该会话并写回。路由有界等待本次对齐(首条 <3s 预算):正常会话单次
578
597
  // 请求内直接拿到新数据;超时(巨产物等)返回既有数据 aligned=false,后续
579
- // 轮询流式补齐。巨产物跳过聚焦(单次解压不可让出),维持 aligned=true
598
+ // 轮询流式补齐。巨产物跳过聚焦(解压耗时与内存峰值失控),维持 aligned=true
580
599
  const check = async (cached) => {
581
600
  const agents = ctx.get('agents')
582
601
  const running = isSessionRunning({ agents, sessionId })
@@ -585,7 +604,7 @@ export function apply(ctx, config) {
585
604
  const fingerprint = located ? await safeStat(located.path) : null
586
605
  return {
587
606
  stale: fingerprint !== null && !(known && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size),
588
- oversize: fingerprint !== null && fingerprint.size > HISTORY_ALIGN_MAX_ARTIFACT_BYTES,
607
+ oversize: fingerprint !== null && fingerprint.size > resolveMaxArtifactBytes(),
589
608
  }
590
609
  }
591
610
  let { cached, mine } = await read()
@@ -898,6 +917,32 @@ export function apply(ctx, config) {
898
917
  }
899
918
  },
900
919
  },
920
+ {
921
+ // 插话撤回启停:与历史浮层开关同构,读走 settings,写经 settings.update
922
+ path: '/api/session-manager/steer-recall-enabled',
923
+ handler: async (req, res) => {
924
+ try {
925
+ if (req.method === 'GET') {
926
+ sendJson(res, 200, { enabled: readSettings(ctx).steerRecallEnabled })
927
+ return
928
+ }
929
+ if (!rejectMethod(req, res, 'POST')) return
930
+ let body
931
+ try {
932
+ body = JSON.parse(await readBody(req))
933
+ } catch {
934
+ throw new Error(MESSAGES.badJsonBody)
935
+ }
936
+ const settings = ctx.get('settings')
937
+ if (!settings) throw new Error('宿主设置服务不可用')
938
+ // update 异步落盘后才提交新值:await 保证持久化完成后再读回
939
+ await settings.update(NAMESPACE, { steerRecallEnabled: Boolean(body && body.enabled) })
940
+ sendJson(res, 200, { enabled: readSettings(ctx).steerRecallEnabled })
941
+ } catch (error) {
942
+ respondError(ctx, res, error)
943
+ }
944
+ },
945
+ },
901
946
  {
902
947
  // 自动归档配置:读回生效值(含默认回退),写经 settings.update 部分更新;
903
948
  // 0 = 关闭(阈值天数关自动归档,间隔小时数关周期轮),与评估逻辑同源语义
@@ -0,0 +1,266 @@
1
+ // 插话撤回(steer recall)接线与纯函数测试:加载真实 src/client.js,
2
+ // 捕获 slots.register 断言 dock 条目结构,切片实例化纯函数锁定撤回动作与失败文案。
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { readFileSync } from 'node:fs'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { dirname, join } from 'node:path'
8
+
9
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
10
+ const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
11
+
12
+ // 加载 client.js 并执行 apply(mock 最小服务面),返回插槽注册捕获。
13
+ // slots.inject 收到注册回调即同步执行;binding 桩仅对会话 s1 生效
14
+ function loadClient({ binding } = {}) {
15
+ const modules = []
16
+ const registered = []
17
+ const injectedNames = []
18
+ const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }
19
+ const reactStub = {
20
+ useState: (value) => [value, () => {}],
21
+ useEffect: () => {},
22
+ useSyncExternalStore: () => [],
23
+ useRef: (value) => ({ current: value }),
24
+ createElement: () => null,
25
+ }
26
+ const requireStub = (name) => {
27
+ if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
28
+ return reactStub
29
+ }
30
+ const factory = new Function('window', 'require', 'document', CLIENT_SRC + '\n;return null')
31
+ factory(
32
+ windowStub,
33
+ requireStub,
34
+ { createElement: () => ({ style: {}, remove() {} }), head: { appendChild: () => {} }, body: { appendChild: () => {} } },
35
+ )
36
+ assert.equal(modules.length, 1, 'client.js 模块未被捕获')
37
+ const mod = modules[0].factory(requireStub)
38
+
39
+ const sessions = {
40
+ binding: (sessionId) => (sessionId === 's1' ? binding : undefined),
41
+ list: { subscribe: () => () => {}, getSnapshot: () => ({ ids: [], byId: {} }) },
42
+ }
43
+ const workspaces = {
44
+ list: { subscribe: () => () => {}, getSnapshot: () => undefined },
45
+ }
46
+ const ctx = {
47
+ get: (name) => (name === 'sessions' ? sessions : name === 'workspaces' ? workspaces : {}),
48
+ effect: () => {},
49
+ slots: {
50
+ inject: (name, register) => { injectedNames.push(name); register() },
51
+ register: (options, component) => registered.push({ options, component }),
52
+ },
53
+ }
54
+ mod.apply(ctx)
55
+ return { registered, injectedNames }
56
+ }
57
+
58
+ // 插话撤回 dock 条目捕获:与官方 queue dock(order 20)、goal dock(order 10)同槽位
59
+ function findSteerEntry(options) {
60
+ const { registered, injectedNames } = loadClient(options)
61
+ assert.ok(injectedNames.includes('conversation.input.dock'), 'apply 未注入 conversation.input.dock 插槽')
62
+ const entries = registered.filter((item) => item.options.name === 'conversation.input.dock')
63
+ const entry = entries.find((item) => item.options.id === 'session-manager-steer')
64
+ assert.ok(entry, '未注册 id=session-manager-steer 的插话撤回 dock 条目')
65
+ return { entry, entries }
66
+ }
67
+
68
+ test('Given client.js 装载, When apply, Then 注册插话撤回 dock 条目且历史浮层条目保留', () => {
69
+ const { entry, entries } = findSteerEntry()
70
+ assert.equal(typeof entry.component, 'function', 'dock 条目缺组件')
71
+ assert.equal(entry.options.order, 25, '插话撤回条目 order 应紧邻官方队列面板(20)之后')
72
+ assert.ok(entries.some((item) => item.options.id === 'session-manager-history'), '历史浮层条目被挤掉')
73
+ })
74
+
75
+ test('Given 会话绑定存在, When dock inject, Then updateQueue 路由到 binding.session.updateQueue', async () => {
76
+ const calls = []
77
+ const binding = {
78
+ session: {
79
+ updateQueue: async (itemId, action) => {
80
+ calls.push({ itemId, action })
81
+ return { ok: true }
82
+ },
83
+ },
84
+ }
85
+ const { entry } = findSteerEntry({ binding })
86
+ const props = entry.options.inject('s1')
87
+ assert.equal(typeof props.updateQueue, 'function', 'inject 未返回 updateQueue')
88
+ const result = await props.updateQueue('m1', { kind: 'remove' })
89
+ assert.deepEqual(calls, [{ itemId: 'm1', action: { kind: 'remove' } }], 'updateQueue 未按 remove 动作透传')
90
+ assert.deepEqual(result, { ok: true })
91
+ })
92
+
93
+ test('Given 会话绑定缺失, When dock inject, Then 返回空 props(干净禁用)', () => {
94
+ const { entry } = findSteerEntry()
95
+ assert.deepEqual(entry.options.inject('s1'), {}, '绑定缺失应返回空 props')
96
+ assert.deepEqual(entry.options.inject('missing'), {}, '未知会话应返回空 props')
97
+ })
98
+
99
+ test('Given 会话面无 updateQueue(旧宿主), When dock inject, Then 返回空 props(干净禁用)', () => {
100
+ const { entry } = findSteerEntry({ binding: { session: {} } })
101
+ assert.deepEqual(entry.options.inject('s1'), {}, '会话面无 updateQueue 应返回空 props')
102
+ })
103
+
104
+ // 纯函数切片:steerRowsOf / withdrawFailureText / withdrawSteer(无外部依赖,动作经注入)
105
+ const PURE_START = CLIENT_SRC.indexOf('function steerRowsOf(')
106
+ const PURE_END = CLIENT_SRC.indexOf('function SteerRecallDock(')
107
+ assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js 插话撤回纯函数切片定位失败')
108
+ const pure = new Function(
109
+ CLIENT_SRC.slice(PURE_START, PURE_END)
110
+ + '; return { steerRowsOf: steerRowsOf, withdrawFailureText: withdrawFailureText, withdrawSteer: withdrawSteer }',
111
+ )()
112
+
113
+ const STEERING_ROW = { id: 'm1', placement: 'steering', preview: '帮我看下', text: '帮我看下' }
114
+
115
+ test('steerRowsOf:仅保留 placement=steering,防非数组与非行形态', () => {
116
+ const queue = [
117
+ { id: 'q1', placement: 'queued' },
118
+ STEERING_ROW,
119
+ { id: 'c1', placement: 'context' },
120
+ STEERING_ROW,
121
+ null,
122
+ ]
123
+ assert.deepEqual(pure.steerRowsOf(queue), [STEERING_ROW, STEERING_ROW])
124
+ assert.deepEqual(pure.steerRowsOf([]), [])
125
+ assert.deepEqual(pure.steerRowsOf(undefined), [])
126
+ })
127
+
128
+ test('withdrawSteer 主路径:remove 成功后回填草稿并提示,返回 true', async () => {
129
+ const calls = []
130
+ const actions = {
131
+ updateQueue: async (itemId, action) => { calls.push({ itemId, action }); return { ok: true } },
132
+ setDraft: (text) => calls.push({ setDraft: text }),
133
+ notify: (text, opts) => calls.push({ notify: text, opts }),
134
+ }
135
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
136
+ assert.equal(done, true)
137
+ assert.deepEqual(calls, [
138
+ { itemId: 'm1', action: { kind: 'remove' } },
139
+ { setDraft: '帮我看下' },
140
+ { notify: '已撤回到输入框', opts: undefined },
141
+ ])
142
+ })
143
+
144
+ test('withdrawSteer 已被应用:queue-item-not-found 不回填草稿,专用文案报错', async () => {
145
+ const calls = []
146
+ const actions = {
147
+ updateQueue: async () => ({ ok: false, error: { code: 'session/queue-item-not-found', message: 'queued item is no longer pending' } }),
148
+ setDraft: (text) => calls.push({ setDraft: text }),
149
+ notify: (text, opts) => calls.push({ notify: text, opts }),
150
+ }
151
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
152
+ assert.equal(done, false)
153
+ assert.deepEqual(calls, [{ notify: '插话已被应用,无法撤回', opts: { kind: 'error' } }])
154
+ })
155
+
156
+ test('withdrawSteer 其他失败:透传错误消息且不回填草稿', async () => {
157
+ const calls = []
158
+ const actions = {
159
+ updateQueue: async () => ({ ok: false, error: { code: 'session/steer-unavailable', message: 'busy' } }),
160
+ setDraft: (text) => calls.push({ setDraft: text }),
161
+ notify: (text, opts) => calls.push({ notify: text, opts }),
162
+ }
163
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
164
+ assert.equal(done, false)
165
+ assert.deepEqual(calls, [{ notify: '撤回失败: busy', opts: { kind: 'error' } }])
166
+ })
167
+
168
+ test('withdrawSteer 传输层拒绝:按错误处理且不回填草稿', async () => {
169
+ const calls = []
170
+ const actions = {
171
+ updateQueue: async () => { throw new Error('network down') },
172
+ setDraft: (text) => calls.push({ setDraft: text }),
173
+ notify: (text, opts) => calls.push({ notify: text, opts }),
174
+ }
175
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
176
+ assert.equal(done, false)
177
+ assert.deepEqual(calls, [{ notify: '撤回失败: network down', opts: { kind: 'error' } }])
178
+ })
179
+
180
+ test('withdrawSteer 摘除成功但回填抛错:兜底展示原文,不报成功', async () => {
181
+ const calls = []
182
+ const actions = {
183
+ updateQueue: async () => ({ ok: true }),
184
+ setDraft: () => { throw new Error('shell released') },
185
+ notify: (text, opts) => calls.push({ notify: text, opts }),
186
+ }
187
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
188
+ assert.equal(done, false)
189
+ assert.deepEqual(calls, [{ notify: '草稿回填失败,原文: 帮我看下', opts: { kind: 'error' } }])
190
+ })
191
+
192
+ // ── 组件级门控:干净禁用规约的落地断言(整源加载 + 记录型 createElement 直接调组件) ──
193
+
194
+ // createElement 桩记录调用树:h(type, props, ...children) -> {type, props, children}
195
+ function loadComponent() {
196
+ const modules = []
197
+ const registered = []
198
+ const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }
199
+ const element = (type, props, ...children) => ({ type, props, children })
200
+ const reactStub = {
201
+ useState: (value) => [value, () => {}],
202
+ useEffect: () => {},
203
+ useRef: (value) => ({ current: value }),
204
+ createElement: element,
205
+ }
206
+ const requireStub = (name) => {
207
+ if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
208
+ return reactStub
209
+ }
210
+ const factory = new Function('window', 'require', 'document', CLIENT_SRC + '\n;return null')
211
+ factory(
212
+ windowStub,
213
+ requireStub,
214
+ { createElement: () => ({ style: {}, remove() {} }), head: { appendChild: () => {} }, body: { appendChild: () => {} } },
215
+ )
216
+ const mod = modules[0].factory(requireStub)
217
+ const sessions = { binding: () => undefined, list: { subscribe: () => () => {}, getSnapshot: () => ({ ids: [], byId: {} }) } }
218
+ const workspaces = { list: { subscribe: () => () => {}, getSnapshot: () => undefined } }
219
+ const ctx = {
220
+ get: (name) => (name === 'sessions' ? sessions : name === 'workspaces' ? workspaces : {}),
221
+ effect: () => {},
222
+ slots: {
223
+ inject: (name, register) => register(),
224
+ register: (options, component) => registered.push({ options, component }),
225
+ },
226
+ }
227
+ mod.apply(ctx)
228
+ const entry = registered.find((item) => item.options.id === 'session-manager-steer')
229
+ assert.ok(entry, '组件级测试未捕获插话撤回条目')
230
+ return entry.component
231
+ }
232
+
233
+ const DOCK_COMPONENT = loadComponent()
234
+ const NO_ACTIONS = { setDraft: () => {} }
235
+ const OK_UPDATE = async () => ({ ok: true })
236
+
237
+ // useSession 桩:两次 selector 读取共用同一快照(与官方 SessionSnapshotSelector 单快照同构)
238
+ function dockWith(snapshot) {
239
+ const useSession = (selector) => selector(snapshot)
240
+ return (props) => DOCK_COMPONENT({ session: {}, useSession, inputActions: NO_ACTIONS, updateQueue: OK_UPDATE, ...props })
241
+ }
242
+
243
+ test('SteerRecallDock 干净禁用:依赖任一缺失即返回 null', () => {
244
+ const dock = dockWith({ queue: [STEERING_ROW], subagent: null })
245
+ assert.equal(dock({ session: undefined }), null, 'session 缺失应不渲染')
246
+ assert.equal(dock({ inputActions: undefined }), null, 'inputActions 缺失应不渲染')
247
+ assert.equal(dock({ updateQueue: undefined }), null, 'updateQueue 缺失应不渲染')
248
+ })
249
+
250
+ test('SteerRecallDock 干净禁用:subagent 非 continuable 即返回 null,启用时零占位宿主', () => {
251
+ const locked = dockWith({ queue: [STEERING_ROW], subagent: { address: { mode: 'prompt' } } })
252
+ assert.equal(locked({}), null, 'queueMutable 为假应不渲染')
253
+ const host = dockWith({ queue: [], subagent: null })({})
254
+ assert.equal(host.type, 'div')
255
+ assert.equal(host.props.className, 'sm-steer-host', '启用时应渲染 display:none 宿主承载注入逻辑')
256
+ })
257
+
258
+ // 注入契约(源码级守卫):选择器/标记/清理必须存在,官方 DOM 结构漂移时静默降级
259
+ test('SteerRecallDock 注入契约:语义标记选择器、按钮标记与卸载清理齐备', () => {
260
+ assert.ok(CLIENT_SRC.includes('[data-pending-steering]'), '应定位官方气泡语义标记')
261
+ assert.ok(CLIENT_SRC.includes('[class$="_actions"]'), '应按 CSS module 类名后缀定位官方图标排')
262
+ assert.ok(CLIENT_SRC.includes("data-sm-steer-recall"), '应有自家按钮标记防重复注入')
263
+ assert.ok(CLIENT_SRC.includes('new MutationObserver'), '应经 MutationObserver 跟随气泡出现')
264
+ assert.ok(CLIENT_SRC.includes('official.className'), '应克隆官方按钮类名保证样式原生')
265
+ assert.ok(CLIENT_SRC.includes('observer.disconnect()'), '卸载应断开观察')
266
+ })
@@ -9,6 +9,8 @@ import {
9
9
  DAY_MS,
10
10
  DEFAULT_AUTO_ARCHIVE_DAYS,
11
11
  DELETE_MESSAGES,
12
+ HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES,
13
+ HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES,
12
14
  TOAST_MAX_TITLES,
13
15
  aggregateDeleteOutcome,
14
16
  aggregateInputs,
@@ -21,6 +23,7 @@ import {
21
23
  filterArchiveRows,
22
24
  groupArchiveRowsByWorkspace,
23
25
  isSessionRunning,
26
+ maxArtifactBytesForHost,
24
27
  mergeDeletedEntry,
25
28
  pageArchiveRows,
26
29
  projectArchiveRows,
@@ -515,3 +518,78 @@ test('历史输入聚合:limit 裁剪与单条截断', () => {
515
518
  ], { limit: 1, maxChars: 3 })
516
519
  assert.deepEqual(merged, [{ text: 'xy', at: 2 }])
517
520
  })
521
+
522
+ // ── 历史输入:每会话首条保护(挤出不做永久丢弃)──
523
+
524
+ test('历史输入聚合:超 limit 挤出时每个会话最早条目仍保留', () => {
525
+ const merged = aggregateInputs([
526
+ { text: '开场提示词', at: 1, sid: 's-old' },
527
+ { text: '继续', at: 2, sid: 's-old' },
528
+ { text: '新会话开场', at: 3, sid: 's-new' },
529
+ { text: '收尾', at: 4, sid: 's-new' },
530
+ ], { limit: 2, maxChars: 100 })
531
+ assert.deepEqual(merged, [
532
+ { text: '收尾', at: 4, sid: 's-new' },
533
+ { text: '新会话开场', at: 3, sid: 's-new' },
534
+ { text: '开场提示词', at: 1, sid: 's-old' },
535
+ ])
536
+ })
537
+
538
+ test('历史输入聚合:首条已在 limit 内不多余追加,结果仍为时间倒序', () => {
539
+ const merged = aggregateInputs([
540
+ { text: 'a', at: 1, sid: 's1' },
541
+ { text: 'b', at: 2, sid: 's2' },
542
+ ], { limit: 10, maxChars: 100 })
543
+ assert.deepEqual(merged, [{ text: 'b', at: 2, sid: 's2' }, { text: 'a', at: 1, sid: 's1' }])
544
+ })
545
+
546
+ test('历史输入聚合:首条保护在去重后集合上计算,与 limit 内文本重复不产生双条目', () => {
547
+ // s-old 的首条文本与 s-new 的某条相同:去重保留最新,归属 s-new,不再重复追加
548
+ const merged = aggregateInputs([
549
+ { text: '继续', at: 1, sid: 's-old' },
550
+ { text: '继续', at: 9, sid: 's-new' },
551
+ { text: '其他', at: 5, sid: 's-new' },
552
+ ], { limit: 1, maxChars: 100 })
553
+ assert.deepEqual(merged, [{ text: '继续', at: 9, sid: 's-new' }, { text: '其他', at: 5, sid: 's-new' }])
554
+ })
555
+
556
+ test('历史输入聚合:无 sid 条目不触发首条保护', () => {
557
+ const merged = aggregateInputs([
558
+ { text: 'old', at: 1 },
559
+ { text: 'new', at: 2 },
560
+ ], { limit: 1, maxChars: 100 })
561
+ assert.deepEqual(merged, [{ text: 'new', at: 2 }])
562
+ })
563
+
564
+ test('历史输入聚合:首条保护同样受 maxChars 截断', () => {
565
+ const merged = aggregateInputs([
566
+ { text: 'abcdef', at: 1, sid: 's1' },
567
+ { text: 'xy', at: 2, sid: 's2' },
568
+ ], { limit: 1, maxChars: 3 })
569
+ assert.deepEqual(merged, [{ text: 'xy', at: 2, sid: 's2' }, { text: 'abc', at: 1, sid: 's1' }])
570
+ })
571
+
572
+ // ── 巨产物跳过线:宿主版本黑名单 ──
573
+
574
+ test('巨产物跳过线:0.1.1 与 0.1.2 系列(含 prerelease)按旧线', () => {
575
+ for (const version of ['0.1.1', '0.1.1-rc.2', '0.1.2', '0.1.2-rc.1', '0.1.2-alpha.2']) {
576
+ assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES, version)
577
+ }
578
+ })
579
+
580
+ test('巨产物跳过线:0.1.3 起与未知未来版本按新线', () => {
581
+ for (const version of ['0.1.3', '0.1.5', '0.1.5-rc.1', '0.2.0', '1.0.0']) {
582
+ assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES, version)
583
+ }
584
+ })
585
+
586
+ test('巨产物跳过线:版本缺失或非法保守回退旧线', () => {
587
+ for (const version of [null, undefined, '', 'garbage', '0.1']) {
588
+ assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES, String(version))
589
+ }
590
+ })
591
+
592
+ test('巨产物跳过线:新旧线常量与实测依据自洽', () => {
593
+ assert.equal(HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES, 8 * 1024 * 1024)
594
+ assert.equal(HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES, 16 * 1024 * 1024)
595
+ })
@@ -1639,12 +1639,14 @@ test('历史输入路由:运行中会话参与对齐(实时追加),缓存上限
1639
1639
  },
1640
1640
  })
1641
1641
  const res = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
1642
- (body) => body.aligned && body.inputs.length === HISTORY_INPUT_LIMIT)
1642
+ (body) => body.aligned && body.inputs.length === HISTORY_INPUT_LIMIT + 1)
1643
1643
  const texts = res.body.inputs.map((item) => item.text)
1644
- assert.equal(texts.length, HISTORY_INPUT_LIMIT)
1644
+ // 运行中会话输入量远超上限:limit 之外仅追加该会话去重后最早一条(s1 首条已在 limit 内)
1645
+ assert.equal(texts.length, HISTORY_INPUT_LIMIT + 1)
1645
1646
  assert.ok(texts.includes('历史输入'), '最新既有输入保留')
1646
1647
  assert.ok(texts.includes('运行输入' + (HISTORY_INPUT_LIMIT + 49)), '次新运行输入保留')
1647
- assert.ok(!texts.includes('运行输入0'), '最旧输入被上限裁剪')
1648
+ assert.ok(texts.includes('运行输入0'), '运行中会话最早输入受首条保护')
1649
+ assert.ok(!texts.includes('运行输入1'), '首条之外的旧输入仍被上限裁剪')
1648
1650
  assert.ok(readCounts.get('running1') >= 1, '运行中会话被对齐解压')
1649
1651
  const sessionRes = await requestUntilAligned(handlers, '?sessionId=running1&scope=session')
1650
1652
  assert.ok(sessionRes.body.inputs.length > 0, 'session 范围可取运行中会话自身输入')