Elari39/dsh-session-insight ↗★ 0

dsh-session-insight

提供会话健康度状态条及只读卡片 适合需要监控会话质量的用户,实时展示步骤、工具调用及失败率等指标。

套件
dsh-session-insight
相容性
待驗證
Harness 依賴範圍
^0.2.0-rc.2
Cordis 依賴範圍
~4.0.4
版本
0.1.0
授權
MIT
最近更新
2026年10月3日

安裝

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:Elari39/dsh-session-insight

dsh-session-insight

一个 DSH 练手插件:走通 sessionProjections 投影 → wire.view → 客户端 useProjection → slot 卡片 这条完整链路。

功能本身刻意做小:在输入框上方显示一条只读的会话健康度状态条 —— 步数、工具调用数、失败数、重复调用数、失败率。它不重复官方 sessionStats(那个回答"多久/多少次"),而是回答"这个会话健康吗"。


安装

本包是一个 dsh bundle(package.json 里的 dsh.bundle.patch 指向 cordis.patch.yml), 从 GitHub 直接安装即可,不需要 npm,也不需要任何构建步骤 —— 仓库里的源码就是最终产物 (index.js 是 host 半,client.js 是预先构建好的浏览器半)。

# 用 Desktop 自带的 CLI 绝对路径;PATH 上的旧 dsh 管不了新 profile
$dsh = "D:\Downloads\software\DSH\resources\runtime\cli\bin\dsh.cmd"

# 1) 装进目标 profile(pnpm 会克隆仓库,并自动装好 zod 与 peer 依赖)
& $dsh plugin --profile web add github:Elari39/dsh-session-insight

# 2) 让 profile 真的加载它:编辑 ~/.dsh/profiles/web/package.json,
#    在 dsh.profile.bundles 数组里加上 "dsh-session-insight"

生产环境建议钉到固定版本(tag 对应的 tarball 内容不会变):

& $dsh plugin --profile web add https://github.com/Elari39/dsh-session-insight/archive/refs/tags/v0.1.0.tar.gz

这条路径已实测验证:pnpm add github:Elari39/dsh-session-insight 之后, import('dsh-session-insight') 正常返回 apply, inject, name 三个导出(inject 没有被 unwrapExports 折叠丢掉),zod@4.6.5 经 pnpm 虚拟 store 正确解析,peer 依赖自动落到 @deepseek-ai/cordis@4.0.4 与 @deepseek-ai/dsh-session-projection@0.2.0-rc.2。

npm 渠道目前未发布。若以后要发,publishConfig 已配好,执行 npm publish --otp= 即可 —— 注意 npm 现已强制要求 2FA 才能建包/发包。

Desktop profile(Electron 独占,不能用 dsh plugin)

dsh plugin 会拒绝 desktop profile(profile "desktop" is managed exclusively by the Electron application), 只能直接改 patch 文件:

pwsh -File .\install.ps1              # 幂等,可重复跑
pwsh -File .\install.ps1 -Uninstall   # 卸载

⚠️ 任何插件操作前都要完全退出 Electron 应用。

装完确认

重启后输入框上方应出现健康度状态条;也可以直接向运行中的 host 请求浏览器半的 bundle: /plugins/??dsh-session-insight/client.js&rev= 返回 200 才算加载成功, 404 意味着 host 半没挂上(最常见原因是 loader 行指向了目录而不是 index.js)。


文件

文件作用
index.jsHost 半:向 ctx.sessionProjections 注册 sessionInsight 投影单元
client.jsClient 半:向 conversation.input.dock 注册一张只读卡片
package.jsondsh.bundle.patch + dsh.client 双声明
cordis.patch.yml把插件插进 profile 的 patch 行

一、投影单元契约(全部从已安装包源码核实)

ctx.sessionProjections.register({
  key: 'sessionInsight',
  stateVersion: 1,                 // 必须是非负安全整数;同 key 不同版本会被拒绝
  stateSchema,                     // zod(不是 schemastery!)
  init: (header, inheritedEventCount) => state,
  apply: (state, event) => state,  // 必须同步;不关心的事件必须返回同一个引用
  wire: { viewSchema, view },      // zod;省略 wire 则该单元仅 host 可见
})

来源:@deepseek-ai/dsh-session-projection/README.md(§Define a projection unit、§Register and read)、 dsh-session-stats/lib/index.js、dsh-tool-todo/lib/index.js。

两个 schema 是 zod,不是 schemastery —— 这是最容易搞错的一点。首方包确实同时用两者: Config 用 @deepseek-ai/schemastery,而 stateSchema/viewSchema 用 zod (dsh-session-stats/lib/index.js:1 是 import { z } from "zod", dsh-tool-todo/lib/index.js:1-2 两个都 import 了)。

注册表真实的校验(dsh-session-projection/lib/index.js:68-101):只检查 stateVersion 是非负安全整数、 以及同 key 不能跨版本共享。所以版本号写错是最容易踩的运行时错误。

引用相等是契约,不是优化

注册表有两道 Object.is 闸门:

  1. apply 返回同一个 state 引用 → 直接跳过 wire.view 计算;
  2. wire.view 返回同一个引用 → 抑制发布。

所以本插件把算好的 view 缓存在 state 里(view 字段),只在可见计数器变化时才重算。 否则每次 tool/result 成功、assistant/message 记账这类内部状态变化都会向客户端发布一次 它看不见的变化。dsh-session-stats 的 view 每次返回新对象,是接受这种多余发布的写法;本插件做得更严格。


二、折叠的事件(每个字段都核实过,没有猜)

事件用到的字段来源
assistant/messagedata.message.content[] 里 {type:'tool-call', id, name, arguments}dsh-llm/lib/types/assembler.js:100-105
tool/calldata.callIddsh-session/lib/index.js:830、types/invariant.js:87-89
tool/resultdata.message.source.callId、data.message.isErrortypes/invariant.js:101-105、lib/index.js:862
step/enddata.turndsh-session-stats/lib/index.js(注释说明它才是 step 生命周期的权威)
turn/end—同上;首方也在这里清 pendingCalls

为什么工具名要从 assistant/message 取:tool/call 事件只带 callId, 工具名和参数在 assistant 消息的 tool-call 块上。所以用 callId 把两者配对。 这也顺带解释了为什么重复调用检测能做:签名 = name + "\0" + arguments。

arguments 是原始 JSON 字符串(assembler.js:104),不是解析后的对象 —— 直接拿来做签名最稳。

失败率怎么算

失败率 = 失败的 tool/result 数 ÷ 派发的 tool/call 事件数

两个已知口径边界,都是刻意的:

  • 分母是「已派发」的调用。 被 ctx.tools.guard() 拦下、根本没派发的调用不产生 tool/call, 所以它既不进分子也不进分母 —— 这个数字反映的是「已派发调用的失败率」,不是「模型意图调用的失败率」。
  • 重复调用是「连续」判定,不是累计:read a.txt 中间夹了别的调用再回来不算重复 (中间做过别的事,说明不是在死循环)。

显示的精度策略

投影里的 failureRate 保留三位小数,但展示层(client 半)另有一套策略, 因为百分比会隐藏自己的分母 —— 8.3% 无论是 1/12 还是 1000/12000 读起来都一样, 而会话早期比率摆动极大,一个小数位会诱发过度反应。

样本量显示例子
toolCalls === 0—还没跑过,不假装是 0%
≥ 10一位小数17/198 → 8.6%(旧版整数会显示 9%)
曾考虑过用 Wilson 上界( 且极端样本会饱和到 100% 而失去信息量。原始分数更简单也更诚实,故改为此方案。

这套策略只改 client 半,failureRate 的投影契约没变,所以不需要 bump stateVersion。


三、Client 半:useProjection 是怎么来的

这是整条链路里最难查、也最容易卡住的一环。useProjection 不是 import 来的, 它是 renderer 动态合成后通过 props 发给组件的:

dsh-client-ui-session/lib/client.js:127
  keyedHooks: { projection: (key) => binding.session.projections.faceOf(key) }

dsh-client-ui-renderer/lib/client.js:648-654
  每个声明的 hook 源 → standardHookPropName(name) 变成 prop
  ("projection" → "useProjection")

dsh-client-ui-renderer/lib/client.js:703-746, 763-769
  standardKit() 把 standard 摊进 kit,ContextualEntry 渲染时 {...kit} 传给组件

关键前提:只有注册进 scope: "session" 的 slot,组件才会拿到 useProjection。 conversation.input.dock 声明为 { kind: 'list', scope: 'session' } (dsh-client-ui-conversation/lib/client.js:18167-18170),所以可用。 官方 goal dock 就是这么用的(dsh-client-ui-goal/lib/client.js:372-373)。

组件收到的相关 props:

function Card({ useProjection, t }) {
  const insight = useProjection('sessionInsight');  // 第二参可选 selector
}

t 只在注册时声明了 locale 才会注入;本插件声明了 locale: 'sessionInsight' 并通过 ctx.locale.register(ns, { zh, en }) 提供字典(字典是扁平的 { key: string })。

一条安全约束

client 半只 require('react')。 官方明确禁止第三方 UI 插件 import 任何 @deepseek-ai/dsh-client-ui-*(首方自己可以,goal 就 require 了 primitives)—— 因为一个抛错的组件会让整块 slot entry 崩掉(slot entry crashed in '')。 本插件的 InsightCard 在 useProjection 不可用时返回 null 而不是抛错, 就是为了让 slot 作用域万一变化时降级为不显示,而不是拖垮整个 slot。


四、验证结果

不是"应该能跑",是实际跑过的:

验证方法结果
端到端(真实运行中)从正在运行的 Web 服务请求 /plugins/??dsh-session-insight/client.js&rev=200 + 真实 bundle
界面渲染(真实运行中)用户在 desktop profile 的 GUI 里实际看到状态条✅ 「留意 步数 150 工具 176 失败 16 重复 2 失败率 9%」
Host fold 逻辑真实 zod 4.6.5 跑 11 组事件序列,每步校验 stateSchema + viewSchema24 项断言通过
引用闸门契约断言内部状态变化时 state.view 引用不变、可见变化时变通过
Client 半 + 失败率格式打桩 window.__ModuleLoader__ + require,真实执行 client.js 并渲染;formatRate 按渲染输出断言(含真实数字、分数/百分比切换、阈值 10 边界、零调用)42 项断言通过
模块加载从插件目录 import('./index.js'),真实解析 zod通过
unwrapExports 陷阱复现 cordis-plugin-loader 的折叠逻辑inject 未丢,无 export default
目录 vs 文件 specifier实测 import() 目录失败、文件成功,并模拟 nearestPackage 发现链目录 FAIL、文件 OK
真实加载器Desktop CLI --dump-config(改动前后回归对比)两者都 exit=0、零错误
对齐宽度代入官方 token 化简,与 TodoPanel 的有效宽度逐字比对一致:min(100% - 64px, 920px)

⚠️ 注意 --dump-config 那行:它通过不代表插件能用。目录 specifier 的错误版本同样 exit=0、 零报错,但浏览器半永远拿不到。真正的判据是上面第一行(bundle 200)与第二行(界面可见)。

复现验证(下面这些脚本在作者的开发工作区里,不在本仓库内,路径按需替换):

node \_verify_insight\test.mjs
node \_verify_insight\test-client.mjs
node \_probe_bundle.mjs      # 对运行中的 host 探测 bundle
node \_probe_discovery.mjs   # 模拟 host 的 client 发现链

五、怎么装上跑起来

装进 desktop profile(本机实际采用的方式)

desktop profile 由 Electron 独占,dsh plugin 会直接拒绝:

error: profile "desktop" is managed exclusively by the Electron application

所以只能直接改 patch 文件。用附带脚本(幂等,可重复跑):

pwsh -File F:\WorkSpace\Coding\DSH_plug\demo01\dsh-session-insight\install.ps1
# 卸载:
pwsh -File F:\WorkSpace\Coding\DSH_plug\demo01\dsh-session-insight\install.ps1 -Uninstall

它会往 C:\Users\Elaina\.dsh\profiles\desktop\cordis.patch.yml 追加:

- insert:
    - id: session-insight
      name: 'file:///F:/WorkSpace/Coding/DSH_plug/demo01/dsh-session-insight/index.js'

⚠️ 指向文件,不要指向目录

name: 必须指向入口文件 index.js。写成包目录会静默失败,且症状极具误导性:

file:///.../dsh-session-insight          ← ❌ 目录
  → Node ESM: ERR_UNSUPPORTED_DIR_IMPORT
  → host 半没有 fiber
  → dsh-client-modules 只扫描「拥有 fiber 的 Loader 行」(lib/index.js:836)
  → 浏览器半永不被发现 → /plugins//client.js 返回 404
  → 界面上什么都不显示,但配置校验(--dump-config)完全通过

改成 file:///.../dsh-session-insight/index.js 后,locatePkgJson 的路径分支 (lib/index.js:748-770)会用 nearestPackage 从文件所在目录向上找到 package.json, 读出 dsh.client 与 exports["./client"],浏览器半才被服务。

注:~/.dsh/desktop-overlay/cordis.yml 里官方那个覆盖层插件指向的也是文件(index.mjs), 这是同一个道理。

其它装法(开发靶场)

$dsh = "D:\Downloads\software\DSH\resources\runtime\cli\bin\dsh.cmd"   # 必须绝对路径

# 一次性覆盖层,不落盘
& $dsh --profile web --patch "F:\WorkSpace\Coding\DSH_plug\demo01\_overlay-insight.yml"

⚠️ 做插件操作前要完全退出 Electron 应用。

重启后确认真的加载了

不看界面也能验证 —— 直接问正在运行的 Web 服务要 bundle:

node F:\WorkSpace\Coding\DSH_plug\demo01\_probe_bundle.mjs

它按 host 的算法(sha1 over 文件 stat)算出 rev 并请求 /plugins/??/client.js&rev=,返回 200 才算加载成功。 (脚本里带一个确定已加载的对照包 dsh-plugin:它 200 而你的 404,说明 URL 格式对、插件没加载。)

关于 node_modules/zod

本目录里预置了一份 zod 4.6.5(从 Desktop 运行时 asar 内提取), 这样本地路径插件开箱即可解析 import { z } from 'zod',不需要联网安装。 正式发布时应删掉它,让 dependencies 里的 zod: ^4.4.3 由包管理器安装。


六、踩坑记录

坑说明
Loader 行必须指向文件,不能指向目录file: 目录 → ERR_UNSUPPORTED_DIR_IMPORT → host 半无 fiber → client 半不被扫描 → 404 且无任何报错。详见第五节
dock 里的条目要自己居中conversation.input.dock 是整条 composer 栈的全宽容器,裸 div 会贴左边。必须用 composer token 算宽度 + margin: 0 auto,见第八节
投影 schema 用 zodConfig 才是 schemastery。写错的话 register() 不报错,直到第一次 viewSchema.parse 才炸
stateVersion 必须是非负安全整数同 key 跨版本注册会抛 refusing to share it with stateVersion
apply 必须同步且返回同引用异步 view 会返回 Promise,被 viewSchema.parse 拒绝;不同引用会让下游每次都重算
stateSchema 要包含 view 字段从检查点恢复走 stateSchema.parse(row.val)(lib/index.js:297)。若 view 缓存在 state 里却没写进 schema,.strict() 会拒绝恢复
useProjection 只在 session 作用域 slot 可用root 作用域 slot 拿不到它,会静默 undefined
client 半不能 import @deepseek-ai/dsh-client-ui-*一个抛错组件会崩掉整块 slot entry
函数插件不能有 export defaultLoader 的 unwrapExports 会折叠模块并丢掉 inject,且无任何警告
tool/call 事件不带工具名必须从 assistant/message 的 tool-call 块按 callId 配对

七、对齐:让条目和输入框一样宽

conversation.input.dock 渲染在 composerStack 里(dsh-client-ui-conversation/lib/client.js:16309), 而 composerStack 的 CSS 只有 flex-direction: column(.Dc7zOa_composerStack)—— 它是全宽的。所以一个只设了 padding 的 div 会贴在左边,而不是对齐输入框。

官方同 slot 的 todo 面板(TodoPanel root,.aSus8q_root)是靠三件事对齐的:

box-sizing: border-box;
flex: none;
width: calc(100% - 2×var(--dsh-composer-side-clearance) - 4×var(--dsh-composer-dock-inset));
max-width: calc(var(--dsh-composer-card-max-width) - 4×var(--dsh-composer-dock-inset));
margin: 0 auto;   /* ← 居中 */

代入本机实际值(全部在官方 CSS 里有定义):

变量值
--dsh-chat-content-widthmin(100% - 32px, 920px)
--dsh-composer-card-max-widthcalc(chat-content-width + 32px) = min(100%, 952px)
--dsh-composer-side-clearance16px
--dsh-composer-dock-inset8px

化简后:width = 100% - 64px,max-width = min(100% - 32px, 920px) → 有效宽度 min(100% - 64px, 920px),与官方 todo 面板逐字一致。

这些 token 由 composer 作用域提供,不要硬编码 px —— 否则窗口变窄或用户改缩放时又会错位。


八、下一步可以做什么

  • 加一个 invariant companion:exports["./invariant"] 可以挂一个校验本包持久化关系的独立插件 (见 dsh-tool-todo/lib/invariant.js)。这是报告里确认的另一片空白。
  • 把卡片做成投影驱动的更细视图:比如按工具名展开失败排行 —— 状态里已经记了 pending[callId].name, 只需扩展 errorByTool 与 viewSchema,记得 bump stateVersion(改语义必须 bump)。
  • 加 ctx.storageDomain 持久化:目前只有进程内状态;跨会话的失败率趋势需要落盘。