dsh-xray 0.8.0 → 0.9.0
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 +62 -41
- package/README.zh.md +142 -83
- package/assets/hero.svg +1 -0
- package/assets/section-agent.svg +1 -0
- package/assets/section-capabilities.svg +1 -0
- package/assets/section-cli.svg +1 -0
- package/assets/section-context-tax.svg +15 -0
- package/assets/section-features.svg +1 -0
- package/assets/section-install.svg +1 -0
- package/assets/section-safety.svg +1 -0
- package/assets/tab-cost.webp +0 -0
- package/assets/tab-entry.webp +0 -0
- package/assets/tab-expand.webp +0 -0
- package/docs/demo.svg +1 -0
- package/lib/client.js +159 -26
- package/lib/collect/runtime.js +65 -0
- package/lib/index.js +39 -3
- package/lib/model.js +38 -0
- package/lib/panel.js +16 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,17 +19,53 @@
|
|
|
19
19
|
<a href="./README.zh.md">🇨🇳 中文文档</a>
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
Every plugin you mount quietly bills every LLM request: prompt sections, tool schemas, tokens. dsh-xray sits inside your running harness as an **X-Ray tab beside Chat and Trajectory** and itemizes that bill — per plugin, per entry, down to the exact text:
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
Unfold a plugin to see what it registered; click any entry to read the exact text it puts into every request:
|
|
29
|
+
|
|
30
|
+
<table>
|
|
31
|
+
<tr>
|
|
32
|
+
<td width="50%">
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
</td>
|
|
37
|
+
<td width="50%">
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
</td>
|
|
42
|
+
</tr>
|
|
43
|
+
</table>
|
|
44
|
+
|
|
45
|
+
Three clicks: plugin rollup → entry list → the actual words. The number stops being an estimate you trust and becomes a fact you checked.
|
|
23
46
|
|
|
24
47
|
---
|
|
25
48
|
|
|
26
49
|
## The Problem
|
|
27
50
|
|
|
28
|
-
`dsh --dump-config` shows you the composed tree. The plugin panel shows you a flat list. Neither tells you **why** a plugin is there, **what breaks** if you disable it, or **what it silently costs you
|
|
51
|
+
`dsh --dump-config` shows you the composed tree. The plugin panel shows you a flat list. Neither tells you **why** a plugin is there, **what breaks** if you disable it, or **what it silently costs you** on every single request.
|
|
52
|
+
|
|
53
|
+
**dsh-xray does.** And when the answer is "this plugin taxes every request and nothing depends on it" — the `deps` view confirms the disable is safe, one patch line removes it, and `attribute` verifies it took.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
<p align="center">
|
|
58
|
+
<img src="./assets/section-context-tax.svg" width="100%" alt="Context Tax">
|
|
59
|
+
</p>
|
|
60
|
+
|
|
61
|
+
The cost view answers the question no other tool asks: **who put this in my context, and what does it cost?**
|
|
29
62
|
|
|
30
|
-
|
|
63
|
+
- **Attribution** — every prompt section and tool schema is joined to the plugin that registered it, reconstructed live from the registries (ambiguous entries stay `unattributed`, never guessed).
|
|
64
|
+
- **By-plugin rollup** — each plugin's per-request context tax: sections + schemas + tokens + share, ranked.
|
|
65
|
+
- **Entry inspection** — `/xray/api/entry` returns any entry's live text with a chars/tokens ruler. Computed per request, never persisted.
|
|
66
|
+
- **Explained UI** — every view opens with a one-line "what am I looking at"; terms carry plain-language tooltips; the whole tab is localized (English / 中文) through the host locale service.
|
|
31
67
|
|
|
32
|
-
|
|
68
|
+
The same data flows through three surfaces: the **X-Ray tab** (native GUI), the standalone **`/xray` page** (works even when the client-module pipeline it diagnoses is broken), and the **CLI**.
|
|
33
69
|
|
|
34
70
|
---
|
|
35
71
|
|
|
@@ -49,6 +85,12 @@ npx dsh-xray shadow # services provided by multiple plugins
|
|
|
49
85
|
npx dsh-xray audit # static scan of out-of-tree plugins for sensitive touchpoints
|
|
50
86
|
```
|
|
51
87
|
|
|
88
|
+

|
|
89
|
+
|
|
90
|
+
`attribute`, `conflicts`, and `snapshot` are fully static — they work even when dsh cannot boot. All commands take `--profile <name>` (default `web`) and `--json`; `diff` and `health` exit `1` on drift/unhealth, so they slot into CI.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
52
94
|
<p align="center">
|
|
53
95
|
<img src="./assets/section-features.svg" width="100%" alt="Features">
|
|
54
96
|
</p>
|
|
@@ -61,56 +103,35 @@ npx dsh-xray audit # static scan of out-of-tree plugins for sensitive touc
|
|
|
61
103
|
Which layer introduced each active plugin: kernel bundle, profile dependency, `cordis.patch.yml` insert, or repository source.
|
|
62
104
|
|
|
63
105
|
### 📊 Declared vs. Actual Diff
|
|
64
|
-
Installed-but-inactive, uninstalled-but-lingering patch rows —
|
|
106
|
+
Installed-but-inactive, uninstalled-but-lingering patch rows — including patch rows targeting ids that don't exist (dsh skips them silently).
|
|
65
107
|
|
|
66
108
|
### ⚡ Conflict Detection
|
|
67
109
|
Plugins patching the same config row, and which one silently wins.
|
|
68
110
|
|
|
69
111
|
### 📸 Composition Snapshot
|
|
70
|
-
Export the effective composition as a lockfile; reproduce it elsewhere.
|
|
112
|
+
Export the effective composition as a lockfile; reproduce it elsewhere, diff against it later.
|
|
71
113
|
|
|
72
114
|
</td>
|
|
73
115
|
<td width="50%">
|
|
74
116
|
|
|
75
117
|
### 🌐 Service Dependency Graph
|
|
76
|
-
Who provides and consumes each service
|
|
118
|
+
Who provides and consumes each service — and the **disable-cascade**: exactly which dependents go down if you disable X.
|
|
77
119
|
|
|
78
120
|
### 💊 Runtime Health
|
|
79
|
-
Per-plugin fiber lifecycle state, startup failures, transition history.
|
|
80
|
-
|
|
81
|
-
### 🤖 Agent Self-Introspection
|
|
82
|
-
The `xray_composition` tool lets agents inspect their own capability set.
|
|
83
|
-
|
|
84
|
-
### 🖥️ Web Panel
|
|
85
|
-
Mounted in `dsh web`, the plugin serves a zero-dependency panel at **`/xray`** — summary, health, deps (with the disable-cascade table), cost, and shadow views, live from the running composition. JSON endpoints under `/xray/api/*` serve the same data.
|
|
121
|
+
Per-plugin fiber lifecycle state, startup failures, pending injects, transition history.
|
|
86
122
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-

|
|
90
|
-
|
|
91
|
-
What every request actually carries — prompt sections observed at assembly, blended with tool schemas:
|
|
92
|
-
|
|
93
|
-
```console
|
|
94
|
-
$ npx dsh-xray cost
|
|
95
|
-
~1625 tokens: 1 tool schema(s) ~121 + 19 prompt section(s) ~1504
|
|
96
|
-
|
|
97
|
-
# prompt sections (observed at last assembly):
|
|
98
|
-
app:web-surface ~248 15.3% ████████
|
|
99
|
-
tool:goal ~184 11.3% ██████
|
|
100
|
-
tool:ralph ~109 6.7% ███
|
|
101
|
-
harness:source ~94 5.8% ███
|
|
102
|
-
...
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
And when a patch row targets an id that doesn't exist (dsh skips it silently), `diff` catches it:
|
|
123
|
+
### 👥 Service Shadowing
|
|
124
|
+
Same-name registrations where a later writer silently wins — usually an intended override, occasionally a conflict.
|
|
106
125
|
|
|
107
126
|
### 🛡️ Capability Audit
|
|
108
|
-
Heuristic static scan: network egress, shell, filesystem, env, eval.
|
|
127
|
+
Heuristic static scan of out-of-tree plugins: network egress, shell, filesystem, env, eval.
|
|
109
128
|
|
|
110
129
|
</td>
|
|
111
130
|
</tr>
|
|
112
131
|
</table>
|
|
113
132
|
|
|
133
|
+
---
|
|
134
|
+
|
|
114
135
|
<p align="center">
|
|
115
136
|
<img src="./assets/section-agent.svg" width="100%" alt="Agent Tool">
|
|
116
137
|
</p>
|
|
@@ -131,7 +152,8 @@ Mounted in the tree, dsh-xray registers an `xray_composition` tool (`view: summa
|
|
|
131
152
|
|
|
132
153
|
- Loader `!!js` expressions in patch files are parsed as opaque markers and **never evaluated**
|
|
133
154
|
- The CLI **never executes** plugin code (`audit` is a pattern scan over source text)
|
|
134
|
-
- The mounted plugin writes only under `$DSH_HOME/xray/`
|
|
155
|
+
- The mounted plugin writes only under `$DSH_HOME/xray/` — entry text is served live, **never persisted**
|
|
156
|
+
- The entry endpoint returns composition-layer text only, **never session messages**
|
|
135
157
|
- See [SECURITY.md](./SECURITY.md)
|
|
136
158
|
|
|
137
159
|
---
|
|
@@ -148,7 +170,7 @@ Two ways to use it — they're independent:
|
|
|
148
170
|
npx dsh-xray attribute # requires Node >= 22
|
|
149
171
|
```
|
|
150
172
|
|
|
151
|
-
**2. Mount the plugin** (adds the runtime commands, the `/xray` panel, and the agent tool):
|
|
173
|
+
**2. Mount the plugin** (adds the runtime commands, the X-Ray tab, the `/xray` panel, and the agent tool):
|
|
152
174
|
|
|
153
175
|
```sh
|
|
154
176
|
dsh plugin --profile web add dsh-xray
|
|
@@ -160,13 +182,12 @@ Verify it took:
|
|
|
160
182
|
```sh
|
|
161
183
|
dsh --profile web --dump-config | grep dsh-xray # row present in the composed tree
|
|
162
184
|
npx dsh-xray health # reads the runtime snapshot
|
|
163
|
-
# then open
|
|
185
|
+
# then open any session and click the X-Ray tab,
|
|
186
|
+
# or http://localhost:3080/xray for the standalone panel
|
|
164
187
|
```
|
|
165
188
|
|
|
166
189
|
Uninstall: `dsh plugin --profile web remove dsh-xray`.
|
|
167
190
|
|
|
168
|
-
All commands take `--profile <name>` (default `web`) and `--json`.
|
|
169
|
-
|
|
170
191
|
| Command | Behavior |
|
|
171
192
|
| --- | --- |
|
|
172
193
|
| `diff` | Exits `1` when the trees disagree |
|
|
@@ -184,16 +205,16 @@ Diagnostic imaging for a running composition — complementary to [dsh-doctor](h
|
|
|
184
205
|
|
|
185
206
|
| Feature | Category |
|
|
186
207
|
| --- | --- |
|
|
208
|
+
| Context-tax attribution & entry inspection | 💰 Optimization |
|
|
187
209
|
| Layer attribution | 🔍 Inspection |
|
|
188
210
|
| Declared vs. actual diff | 🔍 Inspection |
|
|
189
211
|
| Conflict detection | 🔍 Inspection |
|
|
190
212
|
| Composition snapshot | 📦 Export |
|
|
191
213
|
| Service dependency graph | 🌐 Runtime |
|
|
192
214
|
| Runtime health | 🌐 Runtime |
|
|
215
|
+
| Service shadowing | 🌐 Runtime |
|
|
193
216
|
| Agent self-introspection | 🤖 AI |
|
|
194
217
|
| Capability audit | 🛡️ Security |
|
|
195
|
-
| Service shadowing | 🌐 Runtime |
|
|
196
|
-
| Context cost | 💰 Optimization |
|
|
197
218
|
|
|
198
219
|
---
|
|
199
220
|
|
package/README.zh.md
CHANGED
|
@@ -18,13 +18,59 @@
|
|
|
18
18
|
<a href="./README.md">English</a>
|
|
19
19
|
</p>
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
你挂载的每个插件都在悄悄向每次 LLM 请求收费:prompt sections、工具 schema、token。dsh-xray 以 **与 Chat / Trajectory 并列的 X 光标签页** 长在你运行中的 harness 里,把这份账单逐项摊开——按插件、按条目,直到具体到每一个字:
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
展开插件看它注册了什么;点击任何条目,读它注入每次请求的原文:
|
|
28
|
+
|
|
29
|
+
<table>
|
|
30
|
+
<tr>
|
|
31
|
+
<td width="50%">
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
</td>
|
|
36
|
+
<td width="50%">
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
</td>
|
|
41
|
+
</tr>
|
|
42
|
+
</table>
|
|
43
|
+
|
|
44
|
+
三次点击:插件总账 → 条目清单 → 实际文字。数字不再是你被迫相信的估算,而是你亲手核对过的事实。
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 问题
|
|
22
49
|
|
|
23
|
-
|
|
50
|
+
`dsh --dump-config` 只给你原始组合树,插件面板只给你平铺列表。它们都不回答:这个插件**为什么**在这、停用它会**连带瘫掉什么**、它在每次请求里**悄悄消耗什么**。
|
|
24
51
|
|
|
25
|
-
|
|
52
|
+
**dsh-xray 回答这些。** 当答案是"这个插件对每次请求收税、却没有任何东西依赖它"——`deps` 视图确认可以安全停用,一行 patch 移除它,`attribute` 验证生效。
|
|
26
53
|
|
|
27
|
-
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
<p align="center">
|
|
57
|
+
<img src="./assets/section-context-tax.svg" width="100%" alt="上下文税">
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
cost 视图回答一个别的工具都不问的问题:**这段上下文是谁放进来的、花掉我多少?**
|
|
61
|
+
|
|
62
|
+
- **来源归因** —— 每个 prompt section 和工具 schema 都关联到注册它的插件,实时从 registry 重建(无法唯一归因的条目诚实标注 `unattributed`,绝不猜测)。
|
|
63
|
+
- **按插件汇总** —— 每个插件的每请求上下文税:sections + schemas + tokens + 占比,排序呈现。
|
|
64
|
+
- **条目原文查看** —— `/xray/api/entry` 返回任意条目的实时文本,附字符/token 标尺。按请求现算,绝不落盘。
|
|
65
|
+
- **界面自解释** —— 每个视图开头一句"你在看什么";术语带白话提示;整个标签页通过宿主 locale 服务双语呈现(English / 中文)。
|
|
66
|
+
|
|
67
|
+
同一份数据流经三个界面:**X 光标签页**(原生 GUI)、独立 **`/xray` 页面**(连它所诊断的 client-module 加载链路挂了都能用)、以及 **CLI**。
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
<p align="center">
|
|
72
|
+
<img src="./assets/section-cli.svg" width="100%" alt="CLI 命令">
|
|
73
|
+
</p>
|
|
28
74
|
|
|
29
75
|
```sh
|
|
30
76
|
npx dsh-xray attribute # 每一行由哪层引入、之后被谁 patch 过
|
|
@@ -38,126 +84,139 @@ npx dsh-xray shadow # 被多个插件同时提供的服务
|
|
|
38
84
|
npx dsh-xray audit # 对 out-of-tree 插件做敏感触点静态扫描
|
|
39
85
|
```
|
|
40
86
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
## 长什么样
|
|
87
|
+

|
|
44
88
|
|
|
45
|
-
|
|
89
|
+
`attribute`、`conflicts`、`snapshot` 是纯静态的——dsh 起不来时照样能跑。所有命令支持 `--profile <name>`(默认 `web`)和 `--json`;`diff` 与 `health` 在漂移/不健康时退出码 `1`,可直接进 CI。
|
|
46
90
|
|
|
47
|
-
|
|
48
|
-
$ npx dsh-xray attribute
|
|
49
|
-
# 130 rows in profile "web"
|
|
91
|
+
---
|
|
50
92
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
session-query-sqlite @deepseek-ai/dsh-base ← patched by @deepseek-ai/dsh-web-app
|
|
55
|
-
...
|
|
56
|
-
```
|
|
93
|
+
<p align="center">
|
|
94
|
+
<img src="./assets/section-features.svg" width="100%" alt="能力">
|
|
95
|
+
</p>
|
|
57
96
|
|
|
58
|
-
|
|
97
|
+
<table>
|
|
98
|
+
<tr>
|
|
99
|
+
<td width="50%">
|
|
59
100
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# disable-cascade (transitive consumers of each provider):
|
|
63
|
-
Loader → 5 plugin(s): AgentPresets, ClientModuleRegistry, Hmr, Include, PluginInventoryGateway
|
|
64
|
-
TimerService → 1 plugin(s): Hmr
|
|
65
|
-
SessionProjectionRegistry → 1 plugin(s): SessionProjectionCache
|
|
66
|
-
```
|
|
101
|
+
### 🔍 层归因
|
|
102
|
+
每个活跃插件来自哪一层:内核 bundle / profile 依赖 / `cordis.patch.yml` insert / repository 源。
|
|
67
103
|
|
|
68
|
-
|
|
104
|
+
### 📊 声明 vs 实际 diff
|
|
105
|
+
装了但没生效、卸了但残留 patch 行——包括指向不存在 id 的 patch 行(dsh 会静默跳过)。
|
|
69
106
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
session-query-sqlite
|
|
73
|
-
.config: @deepseek-ai/dsh-base → @deepseek-ai/dsh-web-app (winner: @deepseek-ai/dsh-web-app)
|
|
74
|
-
tool-bash
|
|
75
|
-
.disabled: @deepseek-ai/dsh-base → @deepseek-ai/dsh-web-app (winner: @deepseek-ai/dsh-web-app)
|
|
76
|
-
```
|
|
107
|
+
### ⚡ 冲突检测
|
|
108
|
+
多个插件 patch 同一配置行时,谁静默赢了。
|
|
77
109
|
|
|
78
|
-
|
|
110
|
+
### 📸 组合快照
|
|
111
|
+
把当前生效组合导出为 lockfile,异地复现、事后对比。
|
|
79
112
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
~1625 tokens: 1 tool schema(s) ~121 + 19 prompt section(s) ~1504
|
|
113
|
+
</td>
|
|
114
|
+
<td width="50%">
|
|
83
115
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
tool:goal ~184 11.3% ██████
|
|
87
|
-
tool:ralph ~109 6.7% ███
|
|
88
|
-
harness:source ~94 5.8% ███
|
|
89
|
-
...
|
|
90
|
-
```
|
|
116
|
+
### 🌐 服务依赖图
|
|
117
|
+
每个服务谁提供、谁消费——以及**停用级联**:停用 X 会连带瘫掉哪些依赖方。
|
|
91
118
|
|
|
92
|
-
|
|
119
|
+
### 💊 运行时健康
|
|
120
|
+
每个插件的 fiber 生命周期状态、启动失败、等待中的注入、状态迁移史。
|
|
93
121
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
orphan overrides (silently skipped) (1)
|
|
97
|
-
no-such-row in ~/.dsh/profiles/web/cordis.patch.yml
|
|
98
|
-
```
|
|
122
|
+
### 👥 服务遮蔽
|
|
123
|
+
同名注册中后来者静默胜出——通常是有意覆盖,偶尔是冲突。
|
|
99
124
|
|
|
100
|
-
|
|
125
|
+
### 🛡️ 能力审计
|
|
126
|
+
对 out-of-tree 插件的启发式静态扫描:网络外发、shell、文件系统、环境变量、eval。
|
|
101
127
|
|
|
102
|
-
|
|
128
|
+
</td>
|
|
129
|
+
</tr>
|
|
130
|
+
</table>
|
|
103
131
|
|
|
104
|
-
|
|
132
|
+
---
|
|
105
133
|
|
|
106
|
-
|
|
134
|
+
<p align="center">
|
|
135
|
+
<img src="./assets/section-agent.svg" width="100%" alt="Agent 工具">
|
|
136
|
+
</p>
|
|
107
137
|
|
|
108
|
-
|
|
138
|
+
挂载进树后,dsh-xray 注册 `xray_composition` 工具(`view: summary | deps | health | cost | shadow`),agent 可以自答:
|
|
109
139
|
|
|
110
|
-
|
|
140
|
+
> *"我有哪些能力?" / "哪个插件提供 X?" / "为什么 Y 不可用?"*
|
|
111
141
|
|
|
112
|
-
|
|
142
|
+
——关于它自己。
|
|
113
143
|
|
|
114
|
-
|
|
144
|
+
---
|
|
115
145
|
|
|
116
|
-
|
|
146
|
+
<p align="center">
|
|
147
|
+
<img src="./assets/section-safety.svg" width="100%" alt="安全立场">
|
|
148
|
+
</p>
|
|
117
149
|
|
|
118
|
-
|
|
150
|
+
**dsh-xray 只读,不执行。**
|
|
119
151
|
|
|
120
|
-
|
|
152
|
+
- patch 文件里的 loader `!!js` 表达式解析为不透明标记,**绝不求值**
|
|
153
|
+
- CLI **从不执行**插件代码(`audit` 是对源码文本的模式扫描)
|
|
154
|
+
- 挂载的插件只写 `$DSH_HOME/xray/` 目录——条目原文实时返回,**绝不落盘**
|
|
155
|
+
- entry 端点只返回组合层文本,**绝不返回会话消息**
|
|
156
|
+
- 详见 [SECURITY.md](./SECURITY.md)
|
|
121
157
|
|
|
122
|
-
|
|
123
|
-
- **声明 vs 实际 diff** — 装了但没生效、卸了但残留 patch 行
|
|
124
|
-
- **冲突检测** — 多个插件 patch 同一配置行时,谁静默赢了
|
|
125
|
-
- **组合快照** — 把当前生效组合导出为 lockfile;`--against` 对比漂移
|
|
126
|
-
- **服务依赖图** — 每个服务谁提供、谁消费;停用 X 会级联影响什么(`deps`)
|
|
127
|
-
- **运行时健康** — 每个插件的 fiber 生命周期状态、启动失败、状态迁移史(`health`)
|
|
128
|
-
- **Agent 自省** — `xray_composition` 工具让 agent 检视自己的能力集
|
|
129
|
-
- **能力审计** — 对 out-of-tree 插件的启发式静态扫描:网络外发、shell、文件系统、环境变量、动态求值(`audit`)
|
|
130
|
-
- **服务重名检测** — 被多个插件同时提供的服务,及每插件工具/命令注册数(`shadow`)
|
|
131
|
-
- **上下文成本** — prompt sections(观测自 system-prompt/assemble)+ 工具 schema 的估算 token 占用(`cost`)
|
|
132
|
-
- **Web 面板** — `/xray` 五视图,零依赖零构建
|
|
158
|
+
---
|
|
133
159
|
|
|
134
|
-
|
|
160
|
+
<p align="center">
|
|
161
|
+
<img src="./assets/section-install.svg" width="100%" alt="安装">
|
|
162
|
+
</p>
|
|
135
163
|
|
|
136
|
-
|
|
164
|
+
两种用法,彼此独立:
|
|
137
165
|
|
|
138
|
-
**1. 只用静态 CLI**(不装进 dsh;dsh
|
|
166
|
+
**1. 只用静态 CLI**(不装进 dsh;dsh 起不来时照样能用):
|
|
139
167
|
|
|
140
168
|
```sh
|
|
141
169
|
npx dsh-xray attribute # 需要 Node >= 22
|
|
142
170
|
```
|
|
143
171
|
|
|
144
|
-
**2. 挂载插件**(
|
|
172
|
+
**2. 挂载插件**(解锁运行时命令、X 光标签页、`/xray` 面板和 agent 工具):
|
|
145
173
|
|
|
146
174
|
```sh
|
|
147
175
|
dsh plugin --profile web add dsh-xray
|
|
148
|
-
# bundle
|
|
176
|
+
# bundle 插件下次启动生效——重启 dsh web
|
|
149
177
|
```
|
|
150
178
|
|
|
151
|
-
|
|
179
|
+
验证生效:
|
|
152
180
|
|
|
153
181
|
```sh
|
|
154
|
-
dsh --profile web --dump-config | grep dsh-xray #
|
|
155
|
-
npx dsh-xray health #
|
|
156
|
-
#
|
|
182
|
+
dsh --profile web --dump-config | grep dsh-xray # 组合树中出现该行
|
|
183
|
+
npx dsh-xray health # 读取运行时快照
|
|
184
|
+
# 然后打开任意会话点 X 光标签页,
|
|
185
|
+
# 或访问 http://localhost:3080/xray 看独立面板
|
|
157
186
|
```
|
|
158
187
|
|
|
159
188
|
卸载:`dsh plugin --profile web remove dsh-xray`。
|
|
160
189
|
|
|
161
|
-
|
|
190
|
+
| 命令 | 行为 |
|
|
191
|
+
| --- | --- |
|
|
192
|
+
| `diff` | 两棵树不一致时退出码 `1` |
|
|
193
|
+
| `health` | 有插件不健康时退出码 `1` |
|
|
194
|
+
| `attribute`、`conflicts`、`snapshot` | 纯静态——dsh 起不来时照样能跑 |
|
|
195
|
+
| `deps`、`health` | 读取 `$DSH_HOME/xray/runtime.json` 运行时快照 |
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
<p align="center">
|
|
200
|
+
<img src="./assets/section-capabilities.svg" width="100%" alt="能力总表">
|
|
201
|
+
</p>
|
|
202
|
+
|
|
203
|
+
对运行中组合树的诊断成像——与 [dsh-doctor](https://www.npmjs.com/package/dsh-doctor)(救援与恢复)互补。
|
|
204
|
+
|
|
205
|
+
| 能力 | 类别 |
|
|
206
|
+
| --- | --- |
|
|
207
|
+
| 上下文税归因 & 条目原文查看 | 💰 优化 |
|
|
208
|
+
| 层归因 | 🔍 检视 |
|
|
209
|
+
| 声明 vs 实际 diff | 🔍 检视 |
|
|
210
|
+
| 冲突检测 | 🔍 检视 |
|
|
211
|
+
| 组合快照 | 📦 导出 |
|
|
212
|
+
| 服务依赖图 | 🌐 运行时 |
|
|
213
|
+
| 运行时健康 | 🌐 运行时 |
|
|
214
|
+
| 服务遮蔽 | 🌐 运行时 |
|
|
215
|
+
| Agent 自省 | 🤖 AI |
|
|
216
|
+
| 能力审计 | 🛡️ 安全 |
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## 许可
|
|
162
221
|
|
|
163
|
-
MIT
|
|
222
|
+
[MIT](./LICENSE)
|
package/assets/hero.svg
CHANGED
package/assets/section-agent.svg
CHANGED
package/assets/section-cli.svg
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="288" height="60" viewBox="0 0 288 60">
|
|
2
|
+
<title>Context Tax</title>
|
|
3
|
+
<defs>
|
|
4
|
+
<linearGradient id="line" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
5
|
+
<stop offset="0%" style="stop-color:#00d4aa;stop-opacity:0"/>
|
|
6
|
+
<stop offset="50%" style="stop-color:#00d4aa;stop-opacity:0.4"/>
|
|
7
|
+
<stop offset="100%" style="stop-color:#00d4aa;stop-opacity:0"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
</defs>
|
|
10
|
+
<line x1="0" y1="30" x2="288" y2="30" stroke="url(#line)" stroke-width="1"/>
|
|
11
|
+
<g transform="translate(144, 30)">
|
|
12
|
+
<rect x="-114" y="-16" width="228" height="32" rx="16" fill="#0a0e17" stroke="#1e293b" stroke-width="1"/>
|
|
13
|
+
<text x="0" y="5" text-anchor="middle" fill="#00d4aa" font-family="'SF Mono', 'Fira Code', monospace" font-size="14" font-weight="600" letter-spacing="1">▸ CONTEXT TAX</text>
|
|
14
|
+
</g>
|
|
15
|
+
</svg>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/docs/demo.svg
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="446" viewBox="0 0 720 446" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="12.5">
|
|
2
|
+
<title>dsh-xray CLI demo</title>
|
|
2
3
|
<rect width="720" height="446" rx="8" fill="#0d1117" stroke="#30363d"/>
|
|
3
4
|
<circle cx="20" cy="17" r="5.5" fill="#ff5f56"/><circle cx="40" cy="17" r="5.5" fill="#ffbd2e"/><circle cx="60" cy="17" r="5.5" fill="#27c93f"/>
|
|
4
5
|
<text x="360" y="21" fill="#8b949e" text-anchor="middle" font-size="11.5">dsh-xray — composition X-ray for DeepSeek Harness</text>
|
package/lib/client.js
CHANGED
|
@@ -47,6 +47,7 @@ window.__ModuleLoader__.load({
|
|
|
47
47
|
'.xray-entry-stats{color:var(--dsw-alias-label-tertiary);font-size:12px}',
|
|
48
48
|
'.xray-entry-close{margin-left:auto;font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:2px 10px;cursor:pointer}',
|
|
49
49
|
'.xray-entry-text{flex:1;min-height:0;overflow:auto;margin:0;padding:12px;background:var(--dsw-alias-bg-layer-3);border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word}',
|
|
50
|
+
'.xray-owner-entry{display:inline-block;padding-left:22px}',
|
|
50
51
|
'.xray-num{text-align:right}',
|
|
51
52
|
'.xray-warn{color:var(--dsw-alias-state-error-primary,#f85149)}',
|
|
52
53
|
'.xray-ok{color:var(--dsw-alias-state-success-primary,#3fb950)}',
|
|
@@ -67,11 +68,12 @@ window.__ModuleLoader__.load({
|
|
|
67
68
|
//#endregion
|
|
68
69
|
|
|
69
70
|
//#region tiny view primitives
|
|
70
|
-
const VIEWS = ['summary', 'health', 'deps', 'cost', 'shadow'];
|
|
71
|
+
const VIEWS = ['summary', 'health', 'deps', 'cost', 'shadow', 'skills'];
|
|
71
72
|
const NS = 'xray';
|
|
72
73
|
|
|
73
74
|
/** English messages (also the key vocabulary; zh mirrors every key). */
|
|
74
75
|
const en = {
|
|
76
|
+
'view.xray': 'X-Ray',
|
|
75
77
|
'panel.sub': 'composition X-ray — live from this harness',
|
|
76
78
|
'panel.loading': 'loading {view}…',
|
|
77
79
|
'panel.renderFailed': 'render failed: {message}',
|
|
@@ -148,16 +150,38 @@ window.__ModuleLoader__.load({
|
|
|
148
150
|
'cost.noAssembly': 'no prompt assembly observed yet — send one agent message first',
|
|
149
151
|
'cost.unattributed': 'unattributed',
|
|
150
152
|
'shadow.clean': 'no service is provided by more than one plugin',
|
|
153
|
+
'intro.skills':
|
|
154
|
+
'What each skill costs: its catalog line rides every request once any model-invocable skill exists; its body is billed only when loaded. Pricing only — to enable/disable skills, use a skill manager.',
|
|
155
|
+
'tip.catalog':
|
|
156
|
+
"Tokens of this skill's line in the durable session catalog, carried on every request",
|
|
157
|
+
'tip.body':
|
|
158
|
+
'Tokens of the full rendered skill body, billed per load and then resident in history',
|
|
159
|
+
'tip.invocable':
|
|
160
|
+
'Whether the model may load this skill itself; user-only skills stay out of the catalog',
|
|
161
|
+
'col.skill': 'skill',
|
|
162
|
+
'col.provider2': 'provider',
|
|
163
|
+
'col.catalog': 'catalog',
|
|
164
|
+
'col.body': 'body',
|
|
165
|
+
'col.invocable': 'invocable',
|
|
166
|
+
'skills.summary':
|
|
167
|
+
'{count} skill(s), {invocable} model-invocable · resident catalog ~{resident} tokens (~{entries} entries + ~{framing} framing) on every request',
|
|
168
|
+
'skills.none':
|
|
169
|
+
'no skill observation yet — the skills service is absent or discovery has not run',
|
|
170
|
+
'skills.yes': 'yes',
|
|
171
|
+
'skills.userOnly': 'user-only',
|
|
151
172
|
'entry.loading': 'loading entry…',
|
|
152
173
|
'entry.stats': '{chars} chars · ~{tokens} tokens ({estimator})',
|
|
153
174
|
'entry.close': 'close',
|
|
154
175
|
'tip.clickEntry': 'Click to view the exact text this entry puts into every request',
|
|
176
|
+
'tip.expandPlugin':
|
|
177
|
+
'A plugin has no single text — its cost is the sum of the entries it registered; click to unfold them',
|
|
155
178
|
'tip.contextTokens':
|
|
156
179
|
'Estimated tokens every request carries before your message — see the cost view for the full breakdown',
|
|
157
180
|
};
|
|
158
181
|
|
|
159
182
|
/** Simplified Chinese mirror of every en key. */
|
|
160
183
|
const zh = {
|
|
184
|
+
'view.xray': 'X 光',
|
|
161
185
|
'panel.sub': '组合 X 光——实时来自当前 harness',
|
|
162
186
|
'panel.loading': '正在加载 {view}…',
|
|
163
187
|
'panel.renderFailed': '渲染失败:{message}',
|
|
@@ -225,10 +249,26 @@ window.__ModuleLoader__.load({
|
|
|
225
249
|
'cost.noAssembly': '尚未观测到 prompt 装配——先发送一条 agent 消息',
|
|
226
250
|
'cost.unattributed': '未归因',
|
|
227
251
|
'shadow.clean': '没有服务被多个插件同时提供',
|
|
252
|
+
'intro.skills':
|
|
253
|
+
'每个 skill 的价格:只要存在模型可调用的 skill,它的 catalog 行就随每次请求发送;正文只在加载时计费。这里只计价——启用/禁用请使用 skill 管理器。',
|
|
254
|
+
'tip.catalog': '该 skill 在持久会话 catalog 中那一行的 token,每次请求都携带',
|
|
255
|
+
'tip.body': '完整渲染正文的 token,每次加载时计费,之后驻留在历史中',
|
|
256
|
+
'tip.invocable': '模型能否自行加载该 skill;仅用户可调用的 skill 不进入 catalog',
|
|
257
|
+
'col.skill': 'skill',
|
|
258
|
+
'col.provider2': '提供者',
|
|
259
|
+
'col.catalog': 'catalog 行',
|
|
260
|
+
'col.body': '正文',
|
|
261
|
+
'col.invocable': '可调用',
|
|
262
|
+
'skills.summary':
|
|
263
|
+
'{count} 个 skill,{invocable} 个模型可调用 · 常驻 catalog 约 {resident} tokens(条目约 {entries} + 框架约 {framing}),每次请求都携带',
|
|
264
|
+
'skills.none': '尚无 skill 观测——skills 服务缺失或发现未运行',
|
|
265
|
+
'skills.yes': '是',
|
|
266
|
+
'skills.userOnly': '仅用户',
|
|
228
267
|
'entry.loading': '正在加载条目…',
|
|
229
268
|
'entry.stats': '{chars} 字符 · 约 {tokens} tokens({estimator})',
|
|
230
269
|
'entry.close': '关闭',
|
|
231
270
|
'tip.clickEntry': '点击查看该条目每次请求实际注入的完整文本',
|
|
271
|
+
'tip.expandPlugin': '插件本身没有单一文本——它的成本是其注册的全部条目之和;点击展开这些条目',
|
|
232
272
|
'tip.contextTokens': '每次请求在你的消息之前携带的估算 tokens——完整明细见 cost 视图',
|
|
233
273
|
};
|
|
234
274
|
|
|
@@ -290,6 +330,69 @@ window.__ModuleLoader__.load({
|
|
|
290
330
|
);
|
|
291
331
|
}
|
|
292
332
|
|
|
333
|
+
/** By-plugin rollup with expandable rows: a plugin has no single text —
|
|
334
|
+
* its cost is the SUM of the entries it registered, so clicking a row
|
|
335
|
+
* unfolds those entries (each linking to its raw text). */
|
|
336
|
+
function OwnersTable({ owners, onInspect, t }) {
|
|
337
|
+
const [open, setOpen] = react.useState(() => new Set());
|
|
338
|
+
const toggle = (plugin) =>
|
|
339
|
+
setOpen((current) => {
|
|
340
|
+
const next = new Set(current);
|
|
341
|
+
if (next.has(plugin)) next.delete(plugin);
|
|
342
|
+
else next.add(plugin);
|
|
343
|
+
return next;
|
|
344
|
+
});
|
|
345
|
+
const rows = [];
|
|
346
|
+
for (const o of owners) {
|
|
347
|
+
const expanded = open.has(o.plugin);
|
|
348
|
+
rows.push(
|
|
349
|
+
Row(o.plugin, [
|
|
350
|
+
h(
|
|
351
|
+
'button',
|
|
352
|
+
{
|
|
353
|
+
className: 'xray-entry-link',
|
|
354
|
+
title: t('tip.expandPlugin'),
|
|
355
|
+
onClick: () => toggle(o.plugin),
|
|
356
|
+
},
|
|
357
|
+
`${expanded ? '▾' : '▸'} `,
|
|
358
|
+
o.plugin === 'unattributed'
|
|
359
|
+
? h(
|
|
360
|
+
'span',
|
|
361
|
+
{ className: 'xray-muted', title: t('tip.unattributed') },
|
|
362
|
+
t('cost.unattributed'),
|
|
363
|
+
)
|
|
364
|
+
: o.plugin,
|
|
365
|
+
),
|
|
366
|
+
{ cls: 'xray-num', text: String(o.sections) },
|
|
367
|
+
{ cls: 'xray-num', text: String(o.tools) },
|
|
368
|
+
{ cls: 'xray-num', text: `~${o.tokens}` },
|
|
369
|
+
{ cls: 'xray-num', text: `${o.share}%` },
|
|
370
|
+
h(Bar, { share: o.share }),
|
|
371
|
+
]),
|
|
372
|
+
);
|
|
373
|
+
if (expanded) {
|
|
374
|
+
for (const entry of o.entries) {
|
|
375
|
+
rows.push(
|
|
376
|
+
Row(`${o.plugin}/${entry.kind}/${entry.name}`, [
|
|
377
|
+
h(
|
|
378
|
+
'span',
|
|
379
|
+
{ className: 'xray-owner-entry' },
|
|
380
|
+
h('span', { className: 'xray-muted' }, `${entry.kind} · `),
|
|
381
|
+
h(EntryLink, { kind: entry.kind, name: entry.name, onInspect, t }),
|
|
382
|
+
),
|
|
383
|
+
'',
|
|
384
|
+
'',
|
|
385
|
+
{ cls: 'xray-num', text: `~${entry.tokens}` },
|
|
386
|
+
'',
|
|
387
|
+
'',
|
|
388
|
+
]),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return h(Table, { t, headers: ['plugin', 'sections', 'tools', 'tokens', 'share', ''], rows });
|
|
394
|
+
}
|
|
395
|
+
|
|
293
396
|
/** Raw-text inspector: fetches one entry's live text on open. Fetched per
|
|
294
397
|
* view, never cached — the text IS the audit artifact. */
|
|
295
398
|
function EntryModal({ target, onClose, t }) {
|
|
@@ -354,7 +457,7 @@ window.__ModuleLoader__.load({
|
|
|
354
457
|
|
|
355
458
|
//#region per-view renderers (mirror lib/panel.js, as components; all copy through t)
|
|
356
459
|
const renderers = {
|
|
357
|
-
summary: (d, t) =>
|
|
460
|
+
summary: (d, t, _onInspect, goToView) =>
|
|
358
461
|
h(
|
|
359
462
|
react.Fragment,
|
|
360
463
|
null,
|
|
@@ -372,7 +475,15 @@ window.__ModuleLoader__.load({
|
|
|
372
475
|
]),
|
|
373
476
|
Row('s', [t('row.services'), { cls: 'xray-num', text: String(d.services) }]),
|
|
374
477
|
Row('t', [
|
|
375
|
-
h(
|
|
478
|
+
h(
|
|
479
|
+
'button',
|
|
480
|
+
{
|
|
481
|
+
className: 'xray-entry-link',
|
|
482
|
+
title: t('tip.contextTokens'),
|
|
483
|
+
onClick: () => goToView('cost'),
|
|
484
|
+
},
|
|
485
|
+
t('row.contextTokens'),
|
|
486
|
+
),
|
|
376
487
|
{ cls: 'xray-num', text: `~${d.toolSchemaTokens}` },
|
|
377
488
|
]),
|
|
378
489
|
],
|
|
@@ -486,26 +597,7 @@ window.__ModuleLoader__.load({
|
|
|
486
597
|
react.Fragment,
|
|
487
598
|
null,
|
|
488
599
|
h('div', { className: 'xray-h3' }, t('h3.byPlugin')),
|
|
489
|
-
h(
|
|
490
|
-
t,
|
|
491
|
-
headers: ['plugin', 'sections', 'tools', 'tokens', 'share', ''],
|
|
492
|
-
rows: d.owners.map((o) =>
|
|
493
|
-
Row(o.plugin, [
|
|
494
|
-
o.plugin === 'unattributed'
|
|
495
|
-
? h(
|
|
496
|
-
'span',
|
|
497
|
-
{ className: 'xray-muted', title: t('tip.unattributed') },
|
|
498
|
-
t('cost.unattributed'),
|
|
499
|
-
)
|
|
500
|
-
: o.plugin,
|
|
501
|
-
{ cls: 'xray-num', text: String(o.sections) },
|
|
502
|
-
{ cls: 'xray-num', text: String(o.tools) },
|
|
503
|
-
{ cls: 'xray-num', text: `~${o.tokens}` },
|
|
504
|
-
{ cls: 'xray-num', text: `${o.share}%` },
|
|
505
|
-
h(Bar, { share: o.share }),
|
|
506
|
-
]),
|
|
507
|
-
),
|
|
508
|
-
}),
|
|
600
|
+
h(OwnersTable, { owners: d.owners, onInspect, t }),
|
|
509
601
|
)
|
|
510
602
|
: null,
|
|
511
603
|
d.sections.length
|
|
@@ -575,6 +667,40 @@ window.__ModuleLoader__.load({
|
|
|
575
667
|
)
|
|
576
668
|
: null,
|
|
577
669
|
),
|
|
670
|
+
|
|
671
|
+
skills: (d, t) =>
|
|
672
|
+
!d.available
|
|
673
|
+
? h('p', { className: 'xray-muted' }, t('skills.none'))
|
|
674
|
+
: h(
|
|
675
|
+
react.Fragment,
|
|
676
|
+
null,
|
|
677
|
+
h(
|
|
678
|
+
'p',
|
|
679
|
+
null,
|
|
680
|
+
fill(t('skills.summary'), {
|
|
681
|
+
count: d.totals.count,
|
|
682
|
+
invocable: d.totals.invocable,
|
|
683
|
+
resident: d.totals.residentTokens,
|
|
684
|
+
entries: d.totals.catalogEntryTokens,
|
|
685
|
+
framing: d.totals.catalogOverheadTokens,
|
|
686
|
+
}),
|
|
687
|
+
),
|
|
688
|
+
h(Table, {
|
|
689
|
+
t,
|
|
690
|
+
headers: ['skill', 'provider2', 'catalog', 'body', 'invocable'],
|
|
691
|
+
rows: d.skills.map((s) =>
|
|
692
|
+
Row(s.name, [
|
|
693
|
+
s.name,
|
|
694
|
+
{ cls: 'xray-muted', text: s.provider ?? '—' },
|
|
695
|
+
{ cls: 'xray-num', text: `~${s.catalogTokens}` },
|
|
696
|
+
{ cls: 'xray-num', text: s.bodyTokens === null ? '—' : `~${s.bodyTokens}` },
|
|
697
|
+
s.modelInvocable
|
|
698
|
+
? h('span', { className: 'xray-ok' }, t('skills.yes'))
|
|
699
|
+
: h('span', { className: 'xray-muted' }, t('skills.userOnly')),
|
|
700
|
+
]),
|
|
701
|
+
),
|
|
702
|
+
}),
|
|
703
|
+
),
|
|
578
704
|
};
|
|
579
705
|
//#endregion
|
|
580
706
|
|
|
@@ -624,7 +750,7 @@ window.__ModuleLoader__.load({
|
|
|
624
750
|
// A diagnostic surface must never take itself down on one bad
|
|
625
751
|
// payload: render the failure, keep the tab and its nav alive.
|
|
626
752
|
try {
|
|
627
|
-
body = renderers[state.for](state.data, t, setInspecting);
|
|
753
|
+
body = renderers[state.for](state.data, t, setInspecting, setView);
|
|
628
754
|
} catch (err) {
|
|
629
755
|
body = h(
|
|
630
756
|
'p',
|
|
@@ -661,12 +787,19 @@ window.__ModuleLoader__.load({
|
|
|
661
787
|
//#endregion
|
|
662
788
|
|
|
663
789
|
const inject = ['slots', 'locale'];
|
|
664
|
-
/** Mount the X-
|
|
790
|
+
/** Mount the X-Ray tab into the conversation view ring (beside Chat / Trajectory). */
|
|
665
791
|
function apply(ctx) {
|
|
666
792
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'xray: dictionaries');
|
|
793
|
+
const t = ctx.locale.bind(NS);
|
|
667
794
|
ctx.slots.inject('conversation.view', () =>
|
|
668
795
|
ctx.slots.register(
|
|
669
|
-
{
|
|
796
|
+
{
|
|
797
|
+
name: 'conversation.view',
|
|
798
|
+
id: 'xray',
|
|
799
|
+
order: 20,
|
|
800
|
+
label: () => t('view.xray'),
|
|
801
|
+
locale: NS,
|
|
802
|
+
},
|
|
670
803
|
XrayPanel,
|
|
671
804
|
),
|
|
672
805
|
);
|
package/lib/collect/runtime.js
CHANGED
|
@@ -207,10 +207,75 @@ function snapshotEntry(ctx, kind, name) {
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Estimate every skill's context cost from the live skills registry.
|
|
212
|
+
* Async (discovery may hit the filesystem); callers cache the result.
|
|
213
|
+
*
|
|
214
|
+
* Two prices per skill:
|
|
215
|
+
* - `catalogTokens` — its line in the durable session catalog
|
|
216
|
+
* (`- \`name\`: description`, description normalized/truncated the way
|
|
217
|
+
* dsh-tool-skill renders it). This line rides EVERY request once any
|
|
218
|
+
* model-invocable skill exists.
|
|
219
|
+
* - `bodyTokens` — the full rendered body, billed only when the skill is
|
|
220
|
+
* loaded (per call, then resident in history). Read via skills.get;
|
|
221
|
+
* unreadable bodies report null rather than a guess.
|
|
222
|
+
*
|
|
223
|
+
* `catalogOverheadTokens` prices the fixed catalog framing (the
|
|
224
|
+
* system-reminder prose around the entries).
|
|
225
|
+
*/
|
|
226
|
+
async function snapshotSkills(ctx, options = {}) {
|
|
227
|
+
const skills = ctx.get?.('skills');
|
|
228
|
+
if (!skills?.list) return null;
|
|
229
|
+
const maxLength = options.descriptionMaxLength ?? 120;
|
|
230
|
+
const catalogLine = (name, description) => {
|
|
231
|
+
const normalized = String(description ?? '')
|
|
232
|
+
.replaceAll(/\s+/g, ' ')
|
|
233
|
+
.trim();
|
|
234
|
+
const truncated =
|
|
235
|
+
normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`;
|
|
236
|
+
return `- \`${name}\`: ${truncated}`;
|
|
237
|
+
};
|
|
238
|
+
// The fixed framing dsh-tool-skill wraps around the entry lines.
|
|
239
|
+
const FRAMING =
|
|
240
|
+
"<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n</system-reminder>";
|
|
241
|
+
try {
|
|
242
|
+
const list = await skills.list(options.lookup ?? {});
|
|
243
|
+
const rows = [];
|
|
244
|
+
for (const summary of list) {
|
|
245
|
+
const line = catalogLine(summary.name, summary.description);
|
|
246
|
+
const row = {
|
|
247
|
+
name: summary.name,
|
|
248
|
+
provider: summary.provider ?? null,
|
|
249
|
+
source: summary.source ?? null,
|
|
250
|
+
modelInvocable: summary.invocation?.modelInvocable !== false,
|
|
251
|
+
catalogLine: line,
|
|
252
|
+
catalogTokens: estimateTokens(line),
|
|
253
|
+
bodyTokens: null,
|
|
254
|
+
};
|
|
255
|
+
try {
|
|
256
|
+
const full = await skills.get(summary.name, options.lookup ?? {});
|
|
257
|
+
if (full?.content !== undefined) row.bodyTokens = estimateTokens(String(full.content));
|
|
258
|
+
else if (typeof full?.body === 'string') row.bodyTokens = estimateTokens(full.body);
|
|
259
|
+
} catch {
|
|
260
|
+
/* body unreadable: null, never a guess */
|
|
261
|
+
}
|
|
262
|
+
rows.push(row);
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
capturedAt: new Date().toISOString(),
|
|
266
|
+
catalogOverheadTokens: estimateTokens(FRAMING),
|
|
267
|
+
skills: rows,
|
|
268
|
+
};
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
210
274
|
module.exports = {
|
|
211
275
|
snapshotRegistry,
|
|
212
276
|
snapshotTools,
|
|
213
277
|
snapshotEntry,
|
|
278
|
+
snapshotSkills,
|
|
214
279
|
injectNames,
|
|
215
280
|
provideNames,
|
|
216
281
|
stateName,
|
package/lib/index.js
CHANGED
|
@@ -4,11 +4,12 @@ const os = require('node:os');
|
|
|
4
4
|
const {
|
|
5
5
|
snapshotRegistry,
|
|
6
6
|
snapshotEntry,
|
|
7
|
+
snapshotSkills,
|
|
7
8
|
stateName,
|
|
8
9
|
estimateTokens,
|
|
9
10
|
} = require('./collect/runtime.js');
|
|
10
11
|
const { installSectionAttribution } = require('./collect/attribution.js');
|
|
11
|
-
const { serviceGraph, health, shadowing, contextCost } = require('./model.js');
|
|
12
|
+
const { serviceGraph, health, shadowing, contextCost, skillCost } = require('./model.js');
|
|
12
13
|
|
|
13
14
|
const name = 'dsh-xray';
|
|
14
15
|
|
|
@@ -34,6 +35,26 @@ function apply(ctx) {
|
|
|
34
35
|
const transitions = new Map(); // plugin name -> [{state, at}] ring buffer
|
|
35
36
|
let lastAssembly = null; // latest system-prompt assembly observation
|
|
36
37
|
let attribution = null; // section name -> plugin name (live table)
|
|
38
|
+
let skillCatalog = null; // latest skill-cost observation (async, throttled)
|
|
39
|
+
let skillRefreshing = false;
|
|
40
|
+
const refreshSkills = (lookup) => {
|
|
41
|
+
// Discovery hits the filesystem and is scope/cwd-sensitive: the
|
|
42
|
+
// filesystem provider resolves user/project roots from the viewing
|
|
43
|
+
// agent, so a bare-context list() sees nothing. We piggyback on the
|
|
44
|
+
// assemble waterfall, whose context carries the agent scope (and the
|
|
45
|
+
// session cwd through it). One refresh in flight; failures keep
|
|
46
|
+
// last-good state.
|
|
47
|
+
if (skillRefreshing) return;
|
|
48
|
+
skillRefreshing = true;
|
|
49
|
+
snapshotSkills(ctx, { lookup })
|
|
50
|
+
.then((observed) => {
|
|
51
|
+
if (observed) skillCatalog = observed;
|
|
52
|
+
})
|
|
53
|
+
.catch(() => {})
|
|
54
|
+
.finally(() => {
|
|
55
|
+
skillRefreshing = false;
|
|
56
|
+
});
|
|
57
|
+
};
|
|
37
58
|
|
|
38
59
|
let timer = null;
|
|
39
60
|
const writeSnapshot = () => {
|
|
@@ -44,6 +65,7 @@ function apply(ctx) {
|
|
|
44
65
|
snap.promptAssembly = lastAssembly;
|
|
45
66
|
snap.sectionOwners = attribution ? Object.fromEntries(attribution.table) : {};
|
|
46
67
|
snap.toolOwners = attribution ? Object.fromEntries(attribution.toolTable) : {};
|
|
68
|
+
snap.skillCatalog = skillCatalog;
|
|
47
69
|
fs.mkdirSync(dir, { recursive: true });
|
|
48
70
|
const tmp = `${file}.tmp`;
|
|
49
71
|
fs.writeFileSync(tmp, JSON.stringify(snap, null, 2));
|
|
@@ -73,7 +95,7 @@ function apply(ctx) {
|
|
|
73
95
|
// system-prompt/assemble is an expert waterfall: delegate via next(),
|
|
74
96
|
// then observe the final assembly. Purely observational — the assembly
|
|
75
97
|
// is returned unmodified.
|
|
76
|
-
const disposeAssemble = ctx.on('system-prompt/assemble', async (assembly,
|
|
98
|
+
const disposeAssemble = ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
77
99
|
const result = await next();
|
|
78
100
|
try {
|
|
79
101
|
lastAssembly = {
|
|
@@ -86,10 +108,20 @@ function apply(ctx) {
|
|
|
86
108
|
} catch {
|
|
87
109
|
/* observation must never break assembly */
|
|
88
110
|
}
|
|
111
|
+
try {
|
|
112
|
+
const agent = context?.agent ?? context?.scope;
|
|
113
|
+
refreshSkills({
|
|
114
|
+
...(context?.scope !== undefined ? { scope: context.scope } : {}),
|
|
115
|
+
...(agent?.session?.header?.cwd !== undefined ? { cwd: agent.session.header.cwd } : {}),
|
|
116
|
+
}); // piggyback: one throttled async catalog refresh per assembly, in the assembling agent's view
|
|
117
|
+
} catch {
|
|
118
|
+
/* skill observation must never break assembly */
|
|
119
|
+
}
|
|
89
120
|
schedule();
|
|
90
121
|
return result;
|
|
91
122
|
});
|
|
92
123
|
schedule(); // initial snapshot
|
|
124
|
+
refreshSkills(); // initial skill-cost observation
|
|
93
125
|
// Section attribution: diff-based name->plugin table over
|
|
94
126
|
// system-prompt/change (see collect/attribution.js for the strategy).
|
|
95
127
|
attribution = installSectionAttribution(ctx);
|
|
@@ -120,6 +152,7 @@ function apply(ctx) {
|
|
|
120
152
|
snap.promptAssembly = lastAssembly;
|
|
121
153
|
snap.sectionOwners = attribution ? Object.fromEntries(attribution.table) : {};
|
|
122
154
|
snap.toolOwners = attribution ? Object.fromEntries(attribution.toolTable) : {};
|
|
155
|
+
snap.skillCatalog = skillCatalog;
|
|
123
156
|
return snap;
|
|
124
157
|
};
|
|
125
158
|
wctx.effect(
|
|
@@ -141,6 +174,7 @@ function apply(ctx) {
|
|
|
141
174
|
health: () => health(freshSnap()),
|
|
142
175
|
cost: () => contextCost(freshSnap()),
|
|
143
176
|
shadow: () => shadowing(freshSnap()),
|
|
177
|
+
skills: () => skillCost(freshSnap()),
|
|
144
178
|
},
|
|
145
179
|
// Entry text is computed per request from the live registries and
|
|
146
180
|
// never persisted — the audit answer to "what exactly is ~N tokens?".
|
|
@@ -185,7 +219,7 @@ function apply(ctx) {
|
|
|
185
219
|
parameters: {
|
|
186
220
|
view: {
|
|
187
221
|
type: 'string',
|
|
188
|
-
description: 'summary | deps | health | cost | shadow (default summary)',
|
|
222
|
+
description: 'summary | deps | health | cost | shadow | skills (default summary)',
|
|
189
223
|
},
|
|
190
224
|
},
|
|
191
225
|
output: {
|
|
@@ -198,10 +232,12 @@ function apply(ctx) {
|
|
|
198
232
|
snap.promptAssembly = lastAssembly;
|
|
199
233
|
snap.sectionOwners = attribution ? Object.fromEntries(attribution.table) : {};
|
|
200
234
|
snap.toolOwners = attribution ? Object.fromEntries(attribution.toolTable) : {};
|
|
235
|
+
snap.skillCatalog = skillCatalog;
|
|
201
236
|
if (args.view === 'deps') return serviceGraph(snap);
|
|
202
237
|
if (args.view === 'health') return health(snap);
|
|
203
238
|
if (args.view === 'cost') return contextCost(snap);
|
|
204
239
|
if (args.view === 'shadow') return shadowing(snap);
|
|
240
|
+
if (args.view === 'skills') return skillCost(snap);
|
|
205
241
|
return {
|
|
206
242
|
plugins: snap.plugins.length,
|
|
207
243
|
unhealthy: health(snap).unhealthy.length,
|
package/lib/model.js
CHANGED
|
@@ -340,6 +340,43 @@ function contextCost(snap) {
|
|
|
340
340
|
};
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
/** F10: skill context cost — the catalog line every request carries per
|
|
344
|
+
* skill, plus each skill's on-load body size. Read-only companion to the
|
|
345
|
+
* ecosystem's skill managers: xray prices, it never toggles. */
|
|
346
|
+
function skillCost(snap) {
|
|
347
|
+
const observed = snap.skillCatalog;
|
|
348
|
+
if (!observed) return { available: false, skills: [], totals: null, capturedAt: snap.capturedAt };
|
|
349
|
+
const rows = observed.skills
|
|
350
|
+
.slice()
|
|
351
|
+
.sort((a, b) => b.catalogTokens - a.catalogTokens)
|
|
352
|
+
.map((s) => ({
|
|
353
|
+
name: s.name,
|
|
354
|
+
provider: s.provider,
|
|
355
|
+
source: s.source,
|
|
356
|
+
modelInvocable: s.modelInvocable,
|
|
357
|
+
catalogTokens: s.catalogTokens,
|
|
358
|
+
bodyTokens: s.bodyTokens,
|
|
359
|
+
}));
|
|
360
|
+
const invocable = rows.filter((s) => s.modelInvocable);
|
|
361
|
+
const catalogEntryTokens = invocable.reduce((sum, s) => sum + s.catalogTokens, 0);
|
|
362
|
+
return {
|
|
363
|
+
available: true,
|
|
364
|
+
skills: rows,
|
|
365
|
+
totals: {
|
|
366
|
+
count: rows.length,
|
|
367
|
+
invocable: invocable.length,
|
|
368
|
+
// Only model-invocable skills enter the durable catalog; its framing
|
|
369
|
+
// is a fixed one-off on top of the per-skill lines.
|
|
370
|
+
catalogEntryTokens,
|
|
371
|
+
catalogOverheadTokens: observed.catalogOverheadTokens,
|
|
372
|
+
residentTokens:
|
|
373
|
+
invocable.length > 0 ? catalogEntryTokens + observed.catalogOverheadTokens : 0,
|
|
374
|
+
},
|
|
375
|
+
observedAt: observed.capturedAt,
|
|
376
|
+
capturedAt: snap.capturedAt,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
343
380
|
module.exports = {
|
|
344
381
|
replayLayers,
|
|
345
382
|
attribute,
|
|
@@ -350,4 +387,5 @@ module.exports = {
|
|
|
350
387
|
health,
|
|
351
388
|
shadowing,
|
|
352
389
|
contextCost,
|
|
390
|
+
skillCost,
|
|
353
391
|
};
|
package/lib/panel.js
CHANGED
|
@@ -38,7 +38,7 @@ const PAGE = `<!doctype html>
|
|
|
38
38
|
<div id="status"></div>
|
|
39
39
|
<div id="content"></div>
|
|
40
40
|
<script>
|
|
41
|
-
const views = ['summary', 'health', 'deps', 'cost', 'shadow'];
|
|
41
|
+
const views = ['summary', 'health', 'deps', 'cost', 'shadow', 'skills'];
|
|
42
42
|
const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
|
43
43
|
const escAttr = (s) => esc(s).replace(/"/g, '"');
|
|
44
44
|
|
|
@@ -49,6 +49,7 @@ const INTRO = {
|
|
|
49
49
|
deps: 'Who provides and consumes each service. The disable-cascade table answers: if I disable this plugin, which dependents stop working with it?',
|
|
50
50
|
cost: 'What every LLM request carries before your message: prompt sections + tool schemas, attributed to the plugin that registered each. "By plugin" is each plugin\\'s per-request context tax.',
|
|
51
51
|
shadow: 'Same-name registrations. A service provided by two plugins means one silently wins — usually intended (an override), occasionally a conflict.',
|
|
52
|
+
skills: 'What each skill costs: its catalog line rides every request once any model-invocable skill exists; its body is billed only when loaded. Pricing only — to enable/disable skills, use a skill manager.',
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
// Hover glossary: term -> plain-language meaning (native title tooltips).
|
|
@@ -65,6 +66,9 @@ const TIPS = {
|
|
|
65
66
|
registrations: 'How many tools/commands this plugin registered on the shared registries',
|
|
66
67
|
sections: 'Prompt sections this plugin contributes to the system prompt',
|
|
67
68
|
tools: 'Tool schemas this plugin registers (each costs context on every request)',
|
|
69
|
+
catalog: 'Tokens of this skill's line in the durable session catalog, carried on every request',
|
|
70
|
+
body: 'Tokens of the full rendered skill body, billed per load and then resident in history',
|
|
71
|
+
invocable: 'Whether the model may load this skill itself; user-only skills stay out of the catalog',
|
|
68
72
|
};
|
|
69
73
|
const th = (h) => '<th' + (TIPS[h] ? ' title="' + escAttr(TIPS[h]) + '"' : '') + '>' + esc(h) + '</th>';
|
|
70
74
|
const intro = (v) => '<p class="muted" style="margin:0 0 10px">' + INTRO[v] + '</p>';
|
|
@@ -166,6 +170,17 @@ const renderers = {
|
|
|
166
170
|
}
|
|
167
171
|
return html;
|
|
168
172
|
},
|
|
173
|
+
skills(d) {
|
|
174
|
+
if (!d.available) return '<p class="muted">no skill observation yet — the skills service is absent or discovery has not run</p>';
|
|
175
|
+
let html = '<p>' + d.totals.count + ' skill(s), ' + d.totals.invocable + ' model-invocable · resident catalog ~'
|
|
176
|
+
+ d.totals.residentTokens + ' tokens (' + '~' + d.totals.catalogEntryTokens + ' entries + ~'
|
|
177
|
+
+ d.totals.catalogOverheadTokens + ' framing) on every request</p>';
|
|
178
|
+
html += table(['skill', 'provider', 'catalog', 'body', 'invocable'], d.skills.map((s) =>
|
|
179
|
+
'<tr><td>' + esc(s.name) + '</td><td class="muted">' + esc(s.provider ?? '—')
|
|
180
|
+
+ '</td><td class="num">~' + s.catalogTokens + '</td><td class="num">' + (s.bodyTokens === null ? '—' : '~' + s.bodyTokens)
|
|
181
|
+
+ '</td><td>' + (s.modelInvocable ? '<span class="ok">yes</span>' : '<span class="muted">user-only</span>') + '</td></tr>'));
|
|
182
|
+
return html;
|
|
183
|
+
},
|
|
169
184
|
};
|
|
170
185
|
|
|
171
186
|
async function render() {
|