dsh-xray 0.7.1 → 0.8.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/lib/client.js +377 -53
- package/lib/collect/attribution.js +239 -0
- package/lib/collect/runtime.js +85 -2
- package/lib/index.js +42 -16
- package/lib/model.js +43 -3
- package/lib/panel.js +75 -8
- package/package.json +2 -1
package/lib/client.js
CHANGED
|
@@ -17,7 +17,15 @@ window.__ModuleLoader__.load({
|
|
|
17
17
|
|
|
18
18
|
//#region styles (host design tokens; auto-claimed by client-modules)
|
|
19
19
|
const css = [
|
|
20
|
-
|
|
20
|
+
// The panel owns its scrolling: the tab fills the host view area
|
|
21
|
+
// (flex column, overflow on .xray-body) instead of growing inside the
|
|
22
|
+
// host scrollport. View switches then change only the inner scroll
|
|
23
|
+
// height — the page-level scroll position never jumps, however tall
|
|
24
|
+
// deps/cost render. .xray-stale dims outgoing content while the next
|
|
25
|
+
// payload is in flight.
|
|
26
|
+
'.xray-panel{flex:1;min-height:0;display:flex;flex-direction:column;max-width:920px;width:100%;margin:0 auto;padding:16px 20px;font-size:13px;color:var(--dsw-alias-label-primary)}',
|
|
27
|
+
'.xray-body{flex:1;min-height:0;overflow-y:auto}',
|
|
28
|
+
'.xray-stale{opacity:.45;transition:opacity .15s;pointer-events:none}',
|
|
21
29
|
'.xray-sub{color:var(--dsw-alias-label-tertiary);margin:0 0 14px;font-size:12px}',
|
|
22
30
|
'.xray-nav{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}',
|
|
23
31
|
'.xray-nav button{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:4px 10px;cursor:pointer}',
|
|
@@ -26,14 +34,19 @@ window.__ModuleLoader__.load({
|
|
|
26
34
|
// visible in both themes, unlike brand-primary which resolves to
|
|
27
35
|
// near-white in dark mode (white-on-white active buttons).
|
|
28
36
|
'.xray-nav button.active{color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-tertiary);border-color:var(--dsw-alias-state-business-primary);font-weight:600}',
|
|
29
|
-
// min-height pads the short views (summary is 4 rows, deps dozens) so
|
|
30
|
-
// switching between them moves the layout as little as possible;
|
|
31
|
-
// .xray-stale dims outgoing content while the next payload is in flight.
|
|
32
|
-
'.xray-body{min-height:320px}',
|
|
33
|
-
'.xray-stale{opacity:.45;transition:opacity .15s;pointer-events:none}',
|
|
34
37
|
'.xray-table{border-collapse:collapse;width:100%;margin-top:6px}',
|
|
35
38
|
'.xray-table th,.xray-table td{text-align:left;padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l2);vertical-align:top}',
|
|
36
39
|
'.xray-table th{color:var(--dsw-alias-label-tertiary);font-weight:normal}',
|
|
40
|
+
// entry inspection: clickable names + a centered modal with the raw text
|
|
41
|
+
'.xray-entry-link{font:inherit;color:var(--dsw-alias-state-business-primary);background:none;border:none;padding:0;cursor:pointer;text-align:left}',
|
|
42
|
+
'.xray-entry-link:hover{text-decoration:underline}',
|
|
43
|
+
'.xray-entry-overlay{position:fixed;inset:0;z-index:70;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center}',
|
|
44
|
+
'.xray-entry-modal{width:min(760px,90vw);max-height:80vh;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:16px 20px}',
|
|
45
|
+
'.xray-entry-head{display:flex;align-items:baseline;gap:10px;margin-bottom:8px}',
|
|
46
|
+
'.xray-entry-name{font-weight:600;word-break:break-all}',
|
|
47
|
+
'.xray-entry-stats{color:var(--dsw-alias-label-tertiary);font-size:12px}',
|
|
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
|
+
'.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}',
|
|
37
50
|
'.xray-num{text-align:right}',
|
|
38
51
|
'.xray-warn{color:var(--dsw-alias-state-error-primary,#f85149)}',
|
|
39
52
|
'.xray-ok{color:var(--dsw-alias-state-success-primary,#3fb950)}',
|
|
@@ -55,7 +68,177 @@ window.__ModuleLoader__.load({
|
|
|
55
68
|
|
|
56
69
|
//#region tiny view primitives
|
|
57
70
|
const VIEWS = ['summary', 'health', 'deps', 'cost', 'shadow'];
|
|
58
|
-
|
|
71
|
+
const NS = 'xray';
|
|
72
|
+
|
|
73
|
+
/** English messages (also the key vocabulary; zh mirrors every key). */
|
|
74
|
+
const en = {
|
|
75
|
+
'panel.sub': 'composition X-ray — live from this harness',
|
|
76
|
+
'panel.loading': 'loading {view}…',
|
|
77
|
+
'panel.renderFailed': 'render failed: {message}',
|
|
78
|
+
// one question per view: what am I looking at, what does trouble look like?
|
|
79
|
+
'intro.summary':
|
|
80
|
+
'Composition at a glance. A non-zero "unhealthy" count means some plugin failed to start — see the health view.',
|
|
81
|
+
'intro.health':
|
|
82
|
+
'Plugin lifecycle. "Waiting" plugins declared a dependency that no active plugin provides yet; "unhealthy" fibers failed to start and their features are absent.',
|
|
83
|
+
'intro.deps':
|
|
84
|
+
'Who provides and consumes each service. The disable-cascade table answers: if I disable this plugin, which dependents stop working with it?',
|
|
85
|
+
'intro.cost':
|
|
86
|
+
'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.',
|
|
87
|
+
'intro.shadow':
|
|
88
|
+
'Same-name registrations. A service provided by two plugins means one silently wins — usually intended (an override), occasionally a conflict.',
|
|
89
|
+
// hover glossary (native title tooltips), keyed by column header
|
|
90
|
+
'tip.share':
|
|
91
|
+
'Percentage of the total estimated context (sections + tool schemas) this row costs on every request',
|
|
92
|
+
'tip.tokens': 'Rough estimate: ~4 characters per token',
|
|
93
|
+
'tip.owner': 'The plugin whose registration put this entry into the context',
|
|
94
|
+
'tip.unattributed':
|
|
95
|
+
'Registered before dsh-xray mounted and not reconcilable to a single plugin — mount dsh-xray earlier in the profile to shrink this row',
|
|
96
|
+
'tip.wants':
|
|
97
|
+
'Services this plugin declared via inject that are not (yet) provided by any active plugin',
|
|
98
|
+
'tip.fiber': 'One mounted instance of the plugin (a Cordis fiber uid)',
|
|
99
|
+
'tip.state': 'Cordis lifecycle state: ACTIVE is healthy; FAILED means apply() threw',
|
|
100
|
+
'tip.affects': 'Transitive consumers: disabling the provider takes these down with it',
|
|
101
|
+
'tip.providers':
|
|
102
|
+
'Every plugin claiming this service name; the last one to load wins silently',
|
|
103
|
+
'tip.registrations':
|
|
104
|
+
'How many tools/commands this plugin registered on the shared registries',
|
|
105
|
+
'tip.sections': 'Prompt sections this plugin contributes to the system prompt',
|
|
106
|
+
'tip.tools': 'Tool schemas this plugin registers (each costs context on every request)',
|
|
107
|
+
// column headers / row labels
|
|
108
|
+
'col.metric': 'metric',
|
|
109
|
+
'col.value': 'value',
|
|
110
|
+
'col.plugin': 'plugin',
|
|
111
|
+
'col.fiber': 'fiber',
|
|
112
|
+
'col.state': 'state',
|
|
113
|
+
'col.error': 'error',
|
|
114
|
+
'col.waitingPlugin': 'waiting plugin',
|
|
115
|
+
'col.wants': 'wants',
|
|
116
|
+
'col.service': 'service',
|
|
117
|
+
'col.providedBy': 'provided by',
|
|
118
|
+
'col.consumedBy': 'consumed by',
|
|
119
|
+
'col.provider': 'provider',
|
|
120
|
+
'col.affects': 'affects',
|
|
121
|
+
'col.section': 'section',
|
|
122
|
+
'col.tool': 'tool',
|
|
123
|
+
'col.owner': 'owner',
|
|
124
|
+
'col.tokens': 'tokens',
|
|
125
|
+
'col.share': 'share',
|
|
126
|
+
'col.sections': 'sections',
|
|
127
|
+
'col.tools': 'tools',
|
|
128
|
+
'col.providers': 'providers',
|
|
129
|
+
'col.registrations': 'registrations',
|
|
130
|
+
'row.pluginsMounted': 'plugins mounted',
|
|
131
|
+
'row.unhealthy': 'unhealthy',
|
|
132
|
+
'row.services': 'services',
|
|
133
|
+
'row.contextTokens': 'context tokens (tools + sections)',
|
|
134
|
+
'summary.captured': 'captured {at}',
|
|
135
|
+
'health.healthy': '{n} healthy',
|
|
136
|
+
'health.waiting': '{n} waiting',
|
|
137
|
+
'health.unhealthy': '{n} unhealthy',
|
|
138
|
+
'deps.unsatisfied':
|
|
139
|
+
'{n} unsatisfied inject(s) — these plugins wait forever unless a provider is added',
|
|
140
|
+
'h3.disableCascade': 'disable-cascade',
|
|
141
|
+
'h3.services': 'services',
|
|
142
|
+
'h3.byPlugin': 'by plugin',
|
|
143
|
+
'h3.promptSections': 'prompt sections',
|
|
144
|
+
'h3.toolSchemas': 'tool schemas',
|
|
145
|
+
'h3.registrars': 'registrars',
|
|
146
|
+
'cost.headline':
|
|
147
|
+
'~{total} tokens: {toolCount} tool schema(s) ~{toolTokens} + {sectionCount} prompt section(s) ~{sectionTokens}',
|
|
148
|
+
'cost.noAssembly': 'no prompt assembly observed yet — send one agent message first',
|
|
149
|
+
'cost.unattributed': 'unattributed',
|
|
150
|
+
'shadow.clean': 'no service is provided by more than one plugin',
|
|
151
|
+
'entry.loading': 'loading entry…',
|
|
152
|
+
'entry.stats': '{chars} chars · ~{tokens} tokens ({estimator})',
|
|
153
|
+
'entry.close': 'close',
|
|
154
|
+
'tip.clickEntry': 'Click to view the exact text this entry puts into every request',
|
|
155
|
+
'tip.contextTokens':
|
|
156
|
+
'Estimated tokens every request carries before your message — see the cost view for the full breakdown',
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Simplified Chinese mirror of every en key. */
|
|
160
|
+
const zh = {
|
|
161
|
+
'panel.sub': '组合 X 光——实时来自当前 harness',
|
|
162
|
+
'panel.loading': '正在加载 {view}…',
|
|
163
|
+
'panel.renderFailed': '渲染失败:{message}',
|
|
164
|
+
'intro.summary': '组合总览。"unhealthy" 非零表示有插件启动失败——去 health 视图查看。',
|
|
165
|
+
'intro.health':
|
|
166
|
+
'插件生命周期。"waiting" 表示插件声明的依赖服务尚无活跃提供者;"unhealthy" 表示 fiber 启动失败,其功能缺失。',
|
|
167
|
+
'intro.deps':
|
|
168
|
+
'每个服务由谁提供、被谁消费。disable-cascade 表回答:禁用某插件后,哪些依赖它的插件会随之失效?',
|
|
169
|
+
'intro.cost':
|
|
170
|
+
'每次 LLM 请求在你的消息之前携带的内容:prompt sections + 工具 schema,并归因到注册它们的插件。"by plugin" 是每个插件的每请求上下文税。',
|
|
171
|
+
'intro.shadow':
|
|
172
|
+
'同名注册。一个服务被两个插件同时提供意味着有一方静默胜出——通常是有意覆盖,偶尔是冲突。',
|
|
173
|
+
'tip.share': '本行在每次请求中占估算总上下文(sections + 工具 schema)的百分比',
|
|
174
|
+
'tip.tokens': '粗略估算:约 4 个字符折合 1 token',
|
|
175
|
+
'tip.owner': '把这个条目注入上下文的插件',
|
|
176
|
+
'tip.unattributed':
|
|
177
|
+
'在 dsh-xray 挂载之前注册、无法唯一归因到某个插件——把 dsh-xray 在 profile 中提前挂载可缩小此行',
|
|
178
|
+
'tip.wants': '该插件通过 inject 声明、但当前没有任何活跃插件提供的服务',
|
|
179
|
+
'tip.fiber': '插件的一个挂载实例(Cordis fiber uid)',
|
|
180
|
+
'tip.state': 'Cordis 生命周期状态:ACTIVE 为健康;FAILED 表示 apply() 抛出了异常',
|
|
181
|
+
'tip.affects': '传递消费者:禁用该提供者会连带使这些插件失效',
|
|
182
|
+
'tip.providers': '声明提供此服务的全部插件;后加载者静默胜出',
|
|
183
|
+
'tip.registrations': '该插件在共享注册表上注册的工具/命令数量',
|
|
184
|
+
'tip.sections': '该插件贡献给 system prompt 的 sections',
|
|
185
|
+
'tip.tools': '该插件注册的工具 schema(每个都在每次请求中占用上下文)',
|
|
186
|
+
'col.metric': '指标',
|
|
187
|
+
'col.value': '值',
|
|
188
|
+
'col.plugin': '插件',
|
|
189
|
+
'col.fiber': 'fiber',
|
|
190
|
+
'col.state': '状态',
|
|
191
|
+
'col.error': '错误',
|
|
192
|
+
'col.waitingPlugin': '等待中的插件',
|
|
193
|
+
'col.wants': '等待的服务',
|
|
194
|
+
'col.service': '服务',
|
|
195
|
+
'col.providedBy': '提供者',
|
|
196
|
+
'col.consumedBy': '消费者',
|
|
197
|
+
'col.provider': '提供者',
|
|
198
|
+
'col.affects': '波及',
|
|
199
|
+
'col.section': 'section',
|
|
200
|
+
'col.tool': '工具',
|
|
201
|
+
'col.owner': '归属',
|
|
202
|
+
'col.tokens': 'tokens',
|
|
203
|
+
'col.share': '占比',
|
|
204
|
+
'col.sections': 'sections',
|
|
205
|
+
'col.tools': '工具',
|
|
206
|
+
'col.providers': '提供者',
|
|
207
|
+
'col.registrations': '注册数',
|
|
208
|
+
'row.pluginsMounted': '已挂载插件',
|
|
209
|
+
'row.unhealthy': '不健康',
|
|
210
|
+
'row.services': '服务',
|
|
211
|
+
'row.contextTokens': '上下文 tokens(工具 + sections)',
|
|
212
|
+
'summary.captured': '采集于 {at}',
|
|
213
|
+
'health.healthy': '{n} 个健康',
|
|
214
|
+
'health.waiting': '{n} 个等待中',
|
|
215
|
+
'health.unhealthy': '{n} 个不健康',
|
|
216
|
+
'deps.unsatisfied': '{n} 个未满足的 inject——除非补上提供者,这些插件将永远等待',
|
|
217
|
+
'h3.disableCascade': '停用级联',
|
|
218
|
+
'h3.services': '服务',
|
|
219
|
+
'h3.byPlugin': '按插件',
|
|
220
|
+
'h3.promptSections': 'prompt sections',
|
|
221
|
+
'h3.toolSchemas': '工具 schema',
|
|
222
|
+
'h3.registrars': '注册方',
|
|
223
|
+
'cost.headline':
|
|
224
|
+
'约 {total} tokens:{toolCount} 个工具 schema 约 {toolTokens} + {sectionCount} 个 prompt section 约 {sectionTokens}',
|
|
225
|
+
'cost.noAssembly': '尚未观测到 prompt 装配——先发送一条 agent 消息',
|
|
226
|
+
'cost.unattributed': '未归因',
|
|
227
|
+
'shadow.clean': '没有服务被多个插件同时提供',
|
|
228
|
+
'entry.loading': '正在加载条目…',
|
|
229
|
+
'entry.stats': '{chars} 字符 · 约 {tokens} tokens({estimator})',
|
|
230
|
+
'entry.close': '关闭',
|
|
231
|
+
'tip.clickEntry': '点击查看该条目每次请求实际注入的完整文本',
|
|
232
|
+
'tip.contextTokens': '每次请求在你的消息之前携带的估算 tokens——完整明细见 cost 视图',
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** Fill {placeholders}; the host t() resolves the key, we interpolate. */
|
|
236
|
+
const fill = (text, vars) =>
|
|
237
|
+
vars === undefined
|
|
238
|
+
? text
|
|
239
|
+
: text.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
|
|
240
|
+
|
|
241
|
+
function Table({ headers, rows, t }) {
|
|
59
242
|
return h(
|
|
60
243
|
'table',
|
|
61
244
|
{ className: 'xray-table' },
|
|
@@ -65,7 +248,13 @@ window.__ModuleLoader__.load({
|
|
|
65
248
|
h(
|
|
66
249
|
'tr',
|
|
67
250
|
null,
|
|
68
|
-
headers.map((head, i) =>
|
|
251
|
+
headers.map((head, i) =>
|
|
252
|
+
h(
|
|
253
|
+
'th',
|
|
254
|
+
{ key: i, title: head !== '' && en[`tip.${head}`] ? t(`tip.${head}`) : undefined },
|
|
255
|
+
head === '' ? '' : t(`col.${head}`),
|
|
256
|
+
),
|
|
257
|
+
),
|
|
69
258
|
),
|
|
70
259
|
),
|
|
71
260
|
h('tbody', null, rows),
|
|
@@ -87,55 +276,139 @@ window.__ModuleLoader__.load({
|
|
|
87
276
|
),
|
|
88
277
|
);
|
|
89
278
|
}
|
|
279
|
+
|
|
280
|
+
/** Clickable entry name: opens the raw-text inspector for one entry. */
|
|
281
|
+
function EntryLink({ kind, name, onInspect, t }) {
|
|
282
|
+
return h(
|
|
283
|
+
'button',
|
|
284
|
+
{
|
|
285
|
+
className: 'xray-entry-link',
|
|
286
|
+
title: t('tip.clickEntry'),
|
|
287
|
+
onClick: () => onInspect({ kind, name }),
|
|
288
|
+
},
|
|
289
|
+
name,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Raw-text inspector: fetches one entry's live text on open. Fetched per
|
|
294
|
+
* view, never cached — the text IS the audit artifact. */
|
|
295
|
+
function EntryModal({ target, onClose, t }) {
|
|
296
|
+
const [state, setState] = react.useState({ phase: 'loading' });
|
|
297
|
+
react.useEffect(() => {
|
|
298
|
+
let alive = true;
|
|
299
|
+
setState({ phase: 'loading' });
|
|
300
|
+
fetch(
|
|
301
|
+
`/xray/api/entry?kind=${encodeURIComponent(target.kind)}&name=${encodeURIComponent(target.name)}`,
|
|
302
|
+
)
|
|
303
|
+
.then(async (res) => {
|
|
304
|
+
if (!res.ok)
|
|
305
|
+
throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
|
306
|
+
return res.json();
|
|
307
|
+
})
|
|
308
|
+
.then((data) => {
|
|
309
|
+
if (alive) setState({ phase: 'ready', data });
|
|
310
|
+
})
|
|
311
|
+
.catch((err) => {
|
|
312
|
+
if (alive)
|
|
313
|
+
setState({
|
|
314
|
+
phase: 'error',
|
|
315
|
+
message: err instanceof Error ? err.message : String(err),
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
return () => {
|
|
319
|
+
alive = false;
|
|
320
|
+
};
|
|
321
|
+
}, [target]);
|
|
322
|
+
return h(
|
|
323
|
+
'div',
|
|
324
|
+
{ className: 'xray-entry-overlay', onClick: onClose },
|
|
325
|
+
h(
|
|
326
|
+
'div',
|
|
327
|
+
{ className: 'xray-entry-modal', onClick: (event) => event.stopPropagation() },
|
|
328
|
+
h(
|
|
329
|
+
'div',
|
|
330
|
+
{ className: 'xray-entry-head' },
|
|
331
|
+
h('span', { className: 'xray-entry-name' }, target.name),
|
|
332
|
+
state.phase === 'ready'
|
|
333
|
+
? h(
|
|
334
|
+
'span',
|
|
335
|
+
{ className: 'xray-entry-stats' },
|
|
336
|
+
fill(t('entry.stats'), {
|
|
337
|
+
chars: state.data.chars,
|
|
338
|
+
tokens: state.data.tokens,
|
|
339
|
+
estimator: state.data.estimator,
|
|
340
|
+
}),
|
|
341
|
+
)
|
|
342
|
+
: null,
|
|
343
|
+
h('button', { className: 'xray-entry-close', onClick: onClose }, t('entry.close')),
|
|
344
|
+
),
|
|
345
|
+
state.phase === 'loading'
|
|
346
|
+
? h('p', { className: 'xray-muted' }, t('entry.loading'))
|
|
347
|
+
: state.phase === 'error'
|
|
348
|
+
? h('p', { className: 'xray-warn' }, state.message)
|
|
349
|
+
: h('pre', { className: 'xray-entry-text' }, state.data.text),
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
90
353
|
//#endregion
|
|
91
354
|
|
|
92
|
-
//#region per-view renderers (mirror lib/panel.js, as components)
|
|
355
|
+
//#region per-view renderers (mirror lib/panel.js, as components; all copy through t)
|
|
93
356
|
const renderers = {
|
|
94
|
-
summary: (d) =>
|
|
357
|
+
summary: (d, t) =>
|
|
95
358
|
h(
|
|
96
359
|
react.Fragment,
|
|
97
360
|
null,
|
|
98
361
|
h(Table, {
|
|
362
|
+
t,
|
|
99
363
|
headers: ['metric', 'value'],
|
|
100
364
|
rows: [
|
|
101
|
-
Row('p', ['
|
|
365
|
+
Row('p', [t('row.pluginsMounted'), { cls: 'xray-num', text: String(d.plugins) }]),
|
|
102
366
|
Row('u', [
|
|
103
|
-
'unhealthy',
|
|
367
|
+
t('row.unhealthy'),
|
|
104
368
|
{
|
|
105
369
|
cls: `xray-num ${d.unhealthy ? 'xray-warn' : 'xray-ok'}`,
|
|
106
370
|
text: String(d.unhealthy),
|
|
107
371
|
},
|
|
108
372
|
]),
|
|
109
|
-
Row('s', ['services', { cls: 'xray-num', text: String(d.services) }]),
|
|
373
|
+
Row('s', [t('row.services'), { cls: 'xray-num', text: String(d.services) }]),
|
|
110
374
|
Row('t', [
|
|
111
|
-
'
|
|
375
|
+
h('span', { title: t('tip.contextTokens') }, t('row.contextTokens')),
|
|
112
376
|
{ cls: 'xray-num', text: `~${d.toolSchemaTokens}` },
|
|
113
377
|
]),
|
|
114
378
|
],
|
|
115
379
|
}),
|
|
116
|
-
h(
|
|
380
|
+
h(
|
|
381
|
+
'p',
|
|
382
|
+
{ className: 'xray-muted', style: { marginTop: 12 } },
|
|
383
|
+
fill(t('summary.captured'), { at: d.capturedAt }),
|
|
384
|
+
),
|
|
117
385
|
),
|
|
118
386
|
|
|
119
|
-
health: (d) =>
|
|
387
|
+
health: (d, t) =>
|
|
120
388
|
h(
|
|
121
389
|
react.Fragment,
|
|
122
390
|
null,
|
|
123
391
|
h(
|
|
124
392
|
'p',
|
|
125
393
|
null,
|
|
126
|
-
h('span', { className: 'xray-ok' },
|
|
127
|
-
d.waiting.length ? ` · ${d.waiting.length}
|
|
394
|
+
h('span', { className: 'xray-ok' }, fill(t('health.healthy'), { n: d.healthy.length })),
|
|
395
|
+
d.waiting.length ? ` · ${fill(t('health.waiting'), { n: d.waiting.length })}` : null,
|
|
128
396
|
d.unhealthy.length
|
|
129
397
|
? h(
|
|
130
398
|
react.Fragment,
|
|
131
399
|
null,
|
|
132
400
|
' · ',
|
|
133
|
-
h(
|
|
401
|
+
h(
|
|
402
|
+
'span',
|
|
403
|
+
{ className: 'xray-warn' },
|
|
404
|
+
fill(t('health.unhealthy'), { n: d.unhealthy.length }),
|
|
405
|
+
),
|
|
134
406
|
)
|
|
135
407
|
: null,
|
|
136
408
|
),
|
|
137
409
|
d.unhealthy.length
|
|
138
410
|
? h(Table, {
|
|
411
|
+
t,
|
|
139
412
|
headers: ['plugin', 'fiber', 'state', 'error'],
|
|
140
413
|
rows: d.unhealthy.flatMap((p) =>
|
|
141
414
|
p.fibers.map((f) =>
|
|
@@ -151,25 +424,31 @@ window.__ModuleLoader__.load({
|
|
|
151
424
|
: null,
|
|
152
425
|
d.waiting.length
|
|
153
426
|
? h(Table, {
|
|
154
|
-
|
|
427
|
+
t,
|
|
428
|
+
headers: ['waitingPlugin', 'wants'],
|
|
155
429
|
rows: d.waiting.map((p) => Row(p.name, [p.name, p.inject.join(', ')])),
|
|
156
430
|
})
|
|
157
431
|
: null,
|
|
158
432
|
),
|
|
159
433
|
|
|
160
|
-
deps: (d) =>
|
|
434
|
+
deps: (d, t) =>
|
|
161
435
|
h(
|
|
162
436
|
react.Fragment,
|
|
163
437
|
null,
|
|
164
438
|
d.unsatisfied.length
|
|
165
|
-
? h(
|
|
439
|
+
? h(
|
|
440
|
+
'p',
|
|
441
|
+
{ className: 'xray-warn', title: t('tip.wants') },
|
|
442
|
+
fill(t('deps.unsatisfied'), { n: d.unsatisfied.length }),
|
|
443
|
+
)
|
|
166
444
|
: null,
|
|
167
445
|
Object.keys(d.cascade).length
|
|
168
446
|
? h(
|
|
169
447
|
react.Fragment,
|
|
170
448
|
null,
|
|
171
|
-
h('div', { className: 'xray-h3' }, '
|
|
449
|
+
h('div', { className: 'xray-h3' }, t('h3.disableCascade')),
|
|
172
450
|
h(Table, {
|
|
451
|
+
t,
|
|
173
452
|
headers: ['provider', 'affects'],
|
|
174
453
|
rows: Object.entries(d.cascade).map(([provider, affected]) =>
|
|
175
454
|
Row(provider, [provider, affected.join(', ')]),
|
|
@@ -177,34 +456,70 @@ window.__ModuleLoader__.load({
|
|
|
177
456
|
}),
|
|
178
457
|
)
|
|
179
458
|
: null,
|
|
180
|
-
h('div', { className: 'xray-h3' }, 'services'),
|
|
459
|
+
h('div', { className: 'xray-h3' }, t('h3.services')),
|
|
181
460
|
h(Table, {
|
|
182
|
-
|
|
461
|
+
t,
|
|
462
|
+
headers: ['service', 'providedBy', 'consumedBy'],
|
|
183
463
|
rows: Object.entries(d.services).map(([name, node]) =>
|
|
184
464
|
Row(name, [name, node.providers.join(', ') || '—', node.consumers.join(', ') || '—']),
|
|
185
465
|
),
|
|
186
466
|
}),
|
|
187
467
|
),
|
|
188
468
|
|
|
189
|
-
cost: (d) =>
|
|
469
|
+
cost: (d, t, onInspect) =>
|
|
190
470
|
h(
|
|
191
471
|
react.Fragment,
|
|
192
472
|
null,
|
|
193
473
|
h(
|
|
194
474
|
'p',
|
|
195
475
|
null,
|
|
196
|
-
|
|
476
|
+
fill(t('cost.headline'), {
|
|
477
|
+
total: d.totalTokens,
|
|
478
|
+
toolCount: d.toolCount,
|
|
479
|
+
toolTokens: d.toolTokens,
|
|
480
|
+
sectionCount: d.sectionCount,
|
|
481
|
+
sectionTokens: d.sectionTokens,
|
|
482
|
+
}),
|
|
197
483
|
),
|
|
484
|
+
d.owners?.length
|
|
485
|
+
? h(
|
|
486
|
+
react.Fragment,
|
|
487
|
+
null,
|
|
488
|
+
h('div', { className: 'xray-h3' }, t('h3.byPlugin')),
|
|
489
|
+
h(Table, {
|
|
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
|
+
}),
|
|
509
|
+
)
|
|
510
|
+
: null,
|
|
198
511
|
d.sections.length
|
|
199
512
|
? h(
|
|
200
513
|
react.Fragment,
|
|
201
514
|
null,
|
|
202
|
-
h('div', { className: 'xray-h3' }, '
|
|
515
|
+
h('div', { className: 'xray-h3' }, t('h3.promptSections')),
|
|
203
516
|
h(Table, {
|
|
204
|
-
|
|
517
|
+
t,
|
|
518
|
+
headers: ['section', 'owner', 'tokens', 'share', ''],
|
|
205
519
|
rows: d.sections.map((s) =>
|
|
206
520
|
Row(s.name, [
|
|
207
|
-
s.name,
|
|
521
|
+
h(EntryLink, { kind: 'section', name: s.name, onInspect, t }),
|
|
522
|
+
{ cls: 'xray-muted', text: s.owner ?? '—' },
|
|
208
523
|
{ cls: 'xray-num', text: `~${s.tokens}` },
|
|
209
524
|
{ cls: 'xray-num', text: `${s.share}%` },
|
|
210
525
|
h(Bar, { share: s.share }),
|
|
@@ -212,31 +527,30 @@ window.__ModuleLoader__.load({
|
|
|
212
527
|
),
|
|
213
528
|
}),
|
|
214
529
|
)
|
|
215
|
-
: h(
|
|
216
|
-
|
|
217
|
-
{ className: 'xray-muted' },
|
|
218
|
-
'no prompt assembly observed yet — send one agent message first',
|
|
219
|
-
),
|
|
220
|
-
h('div', { className: 'xray-h3' }, 'tool schemas'),
|
|
530
|
+
: h('p', { className: 'xray-muted' }, t('cost.noAssembly')),
|
|
531
|
+
h('div', { className: 'xray-h3' }, t('h3.toolSchemas')),
|
|
221
532
|
h(Table, {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
{
|
|
227
|
-
{ cls: 'xray-
|
|
228
|
-
|
|
533
|
+
t,
|
|
534
|
+
headers: ['tool', 'owner', 'tokens', 'share', ''],
|
|
535
|
+
rows: d.tools.map((row) =>
|
|
536
|
+
Row(row.name, [
|
|
537
|
+
h(EntryLink, { kind: 'tool', name: row.name, onInspect, t }),
|
|
538
|
+
{ cls: 'xray-muted', text: row.owner ?? '—' },
|
|
539
|
+
{ cls: 'xray-num', text: `~${row.tokens}` },
|
|
540
|
+
{ cls: 'xray-num', text: `${row.share}%` },
|
|
541
|
+
h(Bar, { share: row.share }),
|
|
229
542
|
]),
|
|
230
543
|
),
|
|
231
544
|
}),
|
|
232
545
|
),
|
|
233
546
|
|
|
234
|
-
shadow: (d) =>
|
|
547
|
+
shadow: (d, t) =>
|
|
235
548
|
h(
|
|
236
549
|
react.Fragment,
|
|
237
550
|
null,
|
|
238
551
|
d.services.length
|
|
239
552
|
? h(Table, {
|
|
553
|
+
t,
|
|
240
554
|
headers: ['service', 'providers'],
|
|
241
555
|
rows: d.services.map((s) =>
|
|
242
556
|
Row(s.service, [
|
|
@@ -245,13 +559,14 @@ window.__ModuleLoader__.load({
|
|
|
245
559
|
]),
|
|
246
560
|
),
|
|
247
561
|
})
|
|
248
|
-
: h('p', { className: 'xray-ok' }, '
|
|
562
|
+
: h('p', { className: 'xray-ok' }, t('shadow.clean')),
|
|
249
563
|
d.registrars.length
|
|
250
564
|
? h(
|
|
251
565
|
react.Fragment,
|
|
252
566
|
null,
|
|
253
|
-
h('div', { className: 'xray-h3' }, 'registrars'),
|
|
567
|
+
h('div', { className: 'xray-h3' }, t('h3.registrars')),
|
|
254
568
|
h(Table, {
|
|
569
|
+
t,
|
|
255
570
|
headers: ['plugin', 'registrations'],
|
|
256
571
|
rows: d.registrars.map((r) =>
|
|
257
572
|
Row(r.plugin, [r.plugin, { cls: 'xray-num', text: String(r.registrations) }]),
|
|
@@ -264,7 +579,7 @@ window.__ModuleLoader__.load({
|
|
|
264
579
|
//#endregion
|
|
265
580
|
|
|
266
581
|
//#region panel (one conversation.view tab)
|
|
267
|
-
function XrayPanel() {
|
|
582
|
+
function XrayPanel({ t }) {
|
|
268
583
|
const [view, setView] = react.useState('summary');
|
|
269
584
|
// `for` stamps which view the payload belongs to: a click flips `view`
|
|
270
585
|
// synchronously while `state` still carries the previous view's data —
|
|
@@ -274,6 +589,8 @@ window.__ModuleLoader__.load({
|
|
|
274
589
|
// the layout changes once per click instead of collapsing to a
|
|
275
590
|
// one-line loading row and re-expanding.
|
|
276
591
|
const [state, setState] = react.useState({ phase: 'loading', for: 'summary' });
|
|
592
|
+
// Entry inspector: {kind, name} of the entry whose raw text is open.
|
|
593
|
+
const [inspecting, setInspecting] = react.useState(null);
|
|
277
594
|
react.useEffect(() => {
|
|
278
595
|
let alive = true;
|
|
279
596
|
fetch(`/xray/api/${view}`)
|
|
@@ -301,18 +618,20 @@ window.__ModuleLoader__.load({
|
|
|
301
618
|
// the fresh payload is in flight.
|
|
302
619
|
const settled = state.phase !== 'loading';
|
|
303
620
|
let body;
|
|
304
|
-
if (!settled) body = h('p', { className: 'xray-muted' },
|
|
621
|
+
if (!settled) body = h('p', { className: 'xray-muted' }, fill(t('panel.loading'), { view }));
|
|
305
622
|
else if (state.phase === 'error') body = h('p', { className: 'xray-warn' }, state.message);
|
|
306
623
|
else {
|
|
307
624
|
// A diagnostic surface must never take itself down on one bad
|
|
308
625
|
// payload: render the failure, keep the tab and its nav alive.
|
|
309
626
|
try {
|
|
310
|
-
body = renderers[state.for](state.data);
|
|
627
|
+
body = renderers[state.for](state.data, t, setInspecting);
|
|
311
628
|
} catch (err) {
|
|
312
629
|
body = h(
|
|
313
630
|
'p',
|
|
314
631
|
{ className: 'xray-warn' },
|
|
315
|
-
|
|
632
|
+
fill(t('panel.renderFailed'), {
|
|
633
|
+
message: err instanceof Error ? err.message : String(err),
|
|
634
|
+
}),
|
|
316
635
|
);
|
|
317
636
|
}
|
|
318
637
|
}
|
|
@@ -320,7 +639,7 @@ window.__ModuleLoader__.load({
|
|
|
320
639
|
return h(
|
|
321
640
|
'div',
|
|
322
641
|
{ className: 'xray-panel' },
|
|
323
|
-
h('p', { className: 'xray-sub' }, '
|
|
642
|
+
h('p', { className: 'xray-sub' }, t('panel.sub')),
|
|
324
643
|
h(
|
|
325
644
|
'nav',
|
|
326
645
|
{ className: 'xray-nav' },
|
|
@@ -332,17 +651,22 @@ window.__ModuleLoader__.load({
|
|
|
332
651
|
),
|
|
333
652
|
),
|
|
334
653
|
),
|
|
654
|
+
h('p', { className: 'xray-muted', style: { margin: '0 0 10px' } }, t(`intro.${view}`)),
|
|
335
655
|
h('div', { className: stale ? 'xray-body xray-stale' : 'xray-body' }, body),
|
|
656
|
+
inspecting
|
|
657
|
+
? h(EntryModal, { target: inspecting, onClose: () => setInspecting(null), t })
|
|
658
|
+
: null,
|
|
336
659
|
);
|
|
337
660
|
}
|
|
338
661
|
//#endregion
|
|
339
662
|
|
|
340
|
-
const inject = ['slots'];
|
|
663
|
+
const inject = ['slots', 'locale'];
|
|
341
664
|
/** Mount the X-ray tab into the conversation view ring (beside Chat / Trajectory). */
|
|
342
665
|
function apply(ctx) {
|
|
666
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'xray: dictionaries');
|
|
343
667
|
ctx.slots.inject('conversation.view', () =>
|
|
344
668
|
ctx.slots.register(
|
|
345
|
-
{ name: 'conversation.view', id: 'xray', order: 20, label: 'X-ray' },
|
|
669
|
+
{ name: 'conversation.view', id: 'xray', order: 20, label: 'X-ray', locale: NS },
|
|
346
670
|
XrayPanel,
|
|
347
671
|
),
|
|
348
672
|
);
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Registry attribution: attribute named entries (prompt sections, tools) to
|
|
2
|
+
// the plugin that registered them. Purely observational — nothing here
|
|
3
|
+
// mutates the registries.
|
|
4
|
+
//
|
|
5
|
+
// Both dsh-system-prompt and dsh-tools follow the same ScopedLayers pattern:
|
|
6
|
+
// `register()`/`section()` runs `layers.effect(CALLER ctx, ...)` with a fixed
|
|
7
|
+
// label ("systemPrompt.section()" / "tools.register()"), and every
|
|
8
|
+
// registration/disposal emits a change event ("system-prompt/change" /
|
|
9
|
+
// "tools/change"). The effect meta carries only the label, not the entry
|
|
10
|
+
// name (cordis EffectMeta is {label, children}), so the name->plugin join is
|
|
11
|
+
// reconstructed diff-wise: between two change events, the only fibers whose
|
|
12
|
+
// labeled-effect count grew are the registrants of the names that appeared.
|
|
13
|
+
// Verified against dsh-system-prompt + cordis in /tmp probes (2026-08-25).
|
|
14
|
+
//
|
|
15
|
+
// Baseline rule: entries present before observation starts are attributed by
|
|
16
|
+
// a one-shot scan only when exactly one already-mounted fiber carries the
|
|
17
|
+
// label — otherwise they stay null (`unattributed`) rather than guessing.
|
|
18
|
+
|
|
19
|
+
/** Count effects with the given label in one fiber's live effect tree. */
|
|
20
|
+
function labeledEffectCount(fiber, label) {
|
|
21
|
+
let count = 0;
|
|
22
|
+
const walk = (effect, depth) => {
|
|
23
|
+
if (!effect || depth > 4) return;
|
|
24
|
+
if (effect.label === label) count += 1;
|
|
25
|
+
for (const child of effect.children ?? []) walk(child, depth + 1);
|
|
26
|
+
};
|
|
27
|
+
try {
|
|
28
|
+
for (const effect of fiber.getEffects?.() ?? []) walk(effect, 0);
|
|
29
|
+
} catch {
|
|
30
|
+
/* disposed fiber mid-walk: count what we saw */
|
|
31
|
+
}
|
|
32
|
+
return count;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Best-effort plugin name for a fiber (entry name first, runtime name second). */
|
|
36
|
+
function fiberPluginName(fiber) {
|
|
37
|
+
try {
|
|
38
|
+
return fiber.entry?.options?.name ?? fiber.name ?? null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Normalize an identifier to comparable word stems: "tool-fs-search" ->
|
|
45
|
+
* ["tool","fs","search"], "tool:glob" -> ["tool","glob"], "get_goal" ->
|
|
46
|
+
* ["get","goal"]. Scope prefixes like @deepseek-ai/dsh- are shed first. */
|
|
47
|
+
function stems(identifier) {
|
|
48
|
+
return String(identifier)
|
|
49
|
+
.replace(/^@[^/]+\//, '')
|
|
50
|
+
.replace(/^dsh-/, '')
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.split(/[^a-z0-9]+/)
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Does the name plausibly belong to the fiber? True when they share any
|
|
57
|
+
* non-generic stem ("tool"/"get"/"list" alone prove nothing). */
|
|
58
|
+
const GENERIC_STEMS = new Set(['tool', 'tools', 'get', 'set', 'list', 'run', 'app', 'dsh']);
|
|
59
|
+
function affine(name, fiberName) {
|
|
60
|
+
const ns = stems(name);
|
|
61
|
+
const fibers = new Set(stems(fiberName));
|
|
62
|
+
return ns.some((s) => !GENERIC_STEMS.has(s) && fibers.has(s));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Attribute pre-existing names to carrier fibers by stem affinity, accepting
|
|
67
|
+
* a fiber's assignment only when its matched-name count reconciles exactly
|
|
68
|
+
* with its labeled-effect count (so a partial or over-broad match assigns
|
|
69
|
+
* nothing rather than something wrong).
|
|
70
|
+
* @returns Map<name, pluginName> for the names that reconciled.
|
|
71
|
+
*/
|
|
72
|
+
function baselineByAffinity(names, carriers, counts) {
|
|
73
|
+
const assigned = new Map();
|
|
74
|
+
const claims = new Map(); // fiber -> names it matches
|
|
75
|
+
for (const fiber of carriers) {
|
|
76
|
+
const fname = fiberPluginName(fiber);
|
|
77
|
+
if (!fname) continue;
|
|
78
|
+
const mine = [...names].filter((n) => !assigned.has(n) && affine(n, fname));
|
|
79
|
+
claims.set(fiber, mine);
|
|
80
|
+
}
|
|
81
|
+
// A name claimed by two fibers is ambiguous everywhere it appears: drop it.
|
|
82
|
+
const claimCount = new Map();
|
|
83
|
+
for (const mine of claims.values())
|
|
84
|
+
for (const n of mine) claimCount.set(n, (claimCount.get(n) ?? 0) + 1);
|
|
85
|
+
for (const [fiber, mine] of claims) {
|
|
86
|
+
const unambiguous = mine.filter((n) => claimCount.get(n) === 1);
|
|
87
|
+
if (unambiguous.length > 0 && unambiguous.length === (counts.get(fiber) ?? 0)) {
|
|
88
|
+
const fname = fiberPluginName(fiber);
|
|
89
|
+
for (const n of unambiguous) assigned.set(n, fname);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return assigned;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Install one diff-based attribution observer.
|
|
97
|
+
* @param ctx - the mounted plugin's context (reaches registry + events).
|
|
98
|
+
* @param spec - { event, effectLabel, names } where `names(ctx)` reads the
|
|
99
|
+
* registry's current global-layer name set.
|
|
100
|
+
* @returns { table, dispose } — `table` is a live Map<name, pluginName|null>.
|
|
101
|
+
*/
|
|
102
|
+
function installAttribution(ctx, spec) {
|
|
103
|
+
const table = new Map();
|
|
104
|
+
const fibers = new Set();
|
|
105
|
+
const counts = new Map(); // fiber -> last seen labeled-effect count
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
for (const runtime of ctx.registry.values()) {
|
|
109
|
+
for (const fiber of runtime.fibers) fibers.add(fiber);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
/* registry unreadable: event stream alone still works */
|
|
113
|
+
}
|
|
114
|
+
const disposePlugin = ctx.on('internal/plugin', (fiber) => {
|
|
115
|
+
try {
|
|
116
|
+
fibers.add(fiber);
|
|
117
|
+
} catch {
|
|
118
|
+
/* never break the host */
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Baseline: entries registered before observation started. The diff trick
|
|
123
|
+
// cannot see the past, but the effect COUNTS per fiber survive: a fiber
|
|
124
|
+
// carrying N labeled effects registered exactly N of the pre-existing
|
|
125
|
+
// names. Names follow strong conventions (fiber "tool-goal" registers
|
|
126
|
+
// section "tool:goal" and tools "get_goal"/"create_goal"/...), so match
|
|
127
|
+
// names to fibers by normalized-stem affinity — and accept a fiber's
|
|
128
|
+
// matches ONLY when their count equals that fiber's effect count exactly
|
|
129
|
+
// (per-fiber bookkeeping must reconcile). Anything left over stays null
|
|
130
|
+
// (`unattributed`) rather than guessed.
|
|
131
|
+
let prevNames = spec.names(ctx);
|
|
132
|
+
const carriers = [];
|
|
133
|
+
for (const fiber of fibers) {
|
|
134
|
+
const count = labeledEffectCount(fiber, spec.effectLabel);
|
|
135
|
+
counts.set(fiber, count);
|
|
136
|
+
if (count > 0) carriers.push(fiber);
|
|
137
|
+
}
|
|
138
|
+
if (carriers.length === 1) {
|
|
139
|
+
for (const name of prevNames) table.set(name, fiberPluginName(carriers[0]));
|
|
140
|
+
} else {
|
|
141
|
+
const assigned = baselineByAffinity(prevNames, carriers, counts);
|
|
142
|
+
for (const name of prevNames) table.set(name, assigned.get(name) ?? null);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const disposeChange = ctx.on(spec.event, () => {
|
|
146
|
+
try {
|
|
147
|
+
const names = spec.names(ctx);
|
|
148
|
+
const added = [...names].filter((n) => !prevNames.has(n));
|
|
149
|
+
const removed = [...prevNames].filter((n) => !names.has(n));
|
|
150
|
+
const registrants = [];
|
|
151
|
+
for (const fiber of fibers) {
|
|
152
|
+
const now = labeledEffectCount(fiber, spec.effectLabel);
|
|
153
|
+
const before = counts.get(fiber) ?? 0;
|
|
154
|
+
if (now > before) registrants.push(fiber);
|
|
155
|
+
counts.set(fiber, now);
|
|
156
|
+
}
|
|
157
|
+
// One grown fiber owns every added name in this tick (change fires per
|
|
158
|
+
// registration); multiple grown fibers between coalesced ticks would
|
|
159
|
+
// be ambiguous — attribute only the unambiguous case.
|
|
160
|
+
for (const name of added) {
|
|
161
|
+
table.set(name, registrants.length === 1 ? fiberPluginName(registrants[0]) : null);
|
|
162
|
+
}
|
|
163
|
+
for (const name of removed) table.delete(name);
|
|
164
|
+
prevNames = names;
|
|
165
|
+
} catch {
|
|
166
|
+
/* diagnostics must never break the host path */
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
table,
|
|
172
|
+
dispose: () => {
|
|
173
|
+
disposePlugin();
|
|
174
|
+
disposeChange();
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** All names across the global layer plus every scoped overlay. Tool plugins
|
|
180
|
+
* mount under each agent's scope (agent-presets refuses unscoped mounts), so
|
|
181
|
+
* the global layer alone misses every per-agent registration. */
|
|
182
|
+
function allLayerNames(layers, pick) {
|
|
183
|
+
const names = new Set();
|
|
184
|
+
if (!layers) return names;
|
|
185
|
+
try {
|
|
186
|
+
for (const key of pick(layers.global)?.keys() ?? []) names.add(key);
|
|
187
|
+
for (const layer of layers.scoped?.values() ?? []) {
|
|
188
|
+
for (const key of pick(layer)?.keys() ?? []) names.add(key);
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
/* layer shape drifted: return what we saw */
|
|
192
|
+
}
|
|
193
|
+
return names;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Prompt section names (global + every agent scope). */
|
|
197
|
+
function sectionNames(ctx) {
|
|
198
|
+
try {
|
|
199
|
+
return allLayerNames(ctx.get?.('systemPrompt')?.layers, (layer) => layer?.sections);
|
|
200
|
+
} catch {
|
|
201
|
+
return new Set();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Tool names (global + every agent scope). */
|
|
206
|
+
function toolNames(ctx) {
|
|
207
|
+
try {
|
|
208
|
+
return allLayerNames(ctx.get?.('tools')?.layers, (layer) => layer?.tools);
|
|
209
|
+
} catch {
|
|
210
|
+
return new Set();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Install both attribution observers (sections + tools).
|
|
216
|
+
* @returns { sections, tools, dispose } — two live Map<name, plugin|null>.
|
|
217
|
+
*/
|
|
218
|
+
function installSectionAttribution(ctx) {
|
|
219
|
+
const sections = installAttribution(ctx, {
|
|
220
|
+
event: 'system-prompt/change',
|
|
221
|
+
effectLabel: 'systemPrompt.section()',
|
|
222
|
+
names: sectionNames,
|
|
223
|
+
});
|
|
224
|
+
const tools = installAttribution(ctx, {
|
|
225
|
+
event: 'tools/change',
|
|
226
|
+
effectLabel: 'tools.register()',
|
|
227
|
+
names: toolNames,
|
|
228
|
+
});
|
|
229
|
+
return {
|
|
230
|
+
table: sections.table,
|
|
231
|
+
toolTable: tools.table,
|
|
232
|
+
dispose: () => {
|
|
233
|
+
sections.dispose();
|
|
234
|
+
tools.dispose();
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = { installSectionAttribution, installAttribution, labeledEffectCount };
|
package/lib/collect/runtime.js
CHANGED
|
@@ -60,11 +60,36 @@ function estimateTokens(text) {
|
|
|
60
60
|
return Math.ceil(text.length / 4);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** Capture the model-facing tool schemas (name/description/parameters).
|
|
63
|
+
/** Capture the model-facing tool schemas (name/description/parameters).
|
|
64
|
+
* Tool plugins mount under each agent's scope (agent-presets refuses
|
|
65
|
+
* unscoped mounts), so `schemas()` on the global view alone misses them:
|
|
66
|
+
* walk the global layer plus every scoped overlay and dedupe by name. */
|
|
64
67
|
function snapshotTools(ctx) {
|
|
65
68
|
try {
|
|
66
69
|
const tools = ctx.get?.('tools') ?? ctx.root?.tools;
|
|
67
|
-
|
|
70
|
+
if (!tools) return [];
|
|
71
|
+
const byName = new Map();
|
|
72
|
+
const harvest = (definitions) => {
|
|
73
|
+
for (const [name, definition] of definitions?.entries() ?? []) {
|
|
74
|
+
if (byName.has(name)) continue;
|
|
75
|
+
byName.set(name, {
|
|
76
|
+
name,
|
|
77
|
+
description: definition.description ?? '',
|
|
78
|
+
tokens: estimateTokens(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
name,
|
|
81
|
+
description: definition.description ?? '',
|
|
82
|
+
parameters: definition.parameters ?? {},
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
harvest(tools.layers?.global?.tools);
|
|
89
|
+
for (const layer of tools.layers?.scoped?.values() ?? []) harvest(layer.tools);
|
|
90
|
+
if (byName.size > 0) return [...byName.values()];
|
|
91
|
+
// Layer shape drifted: fall back to the public global-view projection.
|
|
92
|
+
const schemas = tools.schemas?.();
|
|
68
93
|
if (!Array.isArray(schemas)) return [];
|
|
69
94
|
return schemas.map((s) => ({
|
|
70
95
|
name: s.name,
|
|
@@ -125,9 +150,67 @@ function snapshotRegistry(ctx) {
|
|
|
125
150
|
};
|
|
126
151
|
}
|
|
127
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Read ONE entry's live text on demand (never persisted): the answer to
|
|
155
|
+
* "what exactly is this ~N tokens?". Sections resolve their text (static
|
|
156
|
+
* string or provider function called with an empty context); tools return
|
|
157
|
+
* the full model-facing schema. Reads the same layers attribution reads.
|
|
158
|
+
* @returns { kind, name, text, chars, tokens, estimator } or null when absent.
|
|
159
|
+
*/
|
|
160
|
+
function snapshotEntry(ctx, kind, name) {
|
|
161
|
+
const found = (text) => ({
|
|
162
|
+
kind,
|
|
163
|
+
name,
|
|
164
|
+
text,
|
|
165
|
+
chars: text.length,
|
|
166
|
+
tokens: estimateTokens(text),
|
|
167
|
+
estimator: '~4 chars/token',
|
|
168
|
+
});
|
|
169
|
+
const firstAcrossLayers = (layers, pick) => {
|
|
170
|
+
let value = pick(layers.global);
|
|
171
|
+
if (value !== undefined) return value;
|
|
172
|
+
for (const layer of layers.scoped?.values() ?? []) {
|
|
173
|
+
value = pick(layer);
|
|
174
|
+
if (value !== undefined) return value;
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
};
|
|
178
|
+
try {
|
|
179
|
+
if (kind === 'section') {
|
|
180
|
+
const layers = ctx.get?.('systemPrompt')?.layers;
|
|
181
|
+
if (!layers) return null;
|
|
182
|
+
const section = firstAcrossLayers(layers, (layer) => layer?.sections?.get?.(name));
|
|
183
|
+
if (!section) return null;
|
|
184
|
+
const text = typeof section.text === 'function' ? section.text({}) : section.text;
|
|
185
|
+
return found(String(text ?? ''));
|
|
186
|
+
}
|
|
187
|
+
if (kind === 'tool') {
|
|
188
|
+
const layers = ctx.get?.('tools')?.layers;
|
|
189
|
+
if (!layers) return null;
|
|
190
|
+
const definition = firstAcrossLayers(layers, (layer) => layer?.tools?.get?.(name));
|
|
191
|
+
if (!definition) return null;
|
|
192
|
+
return found(
|
|
193
|
+
JSON.stringify(
|
|
194
|
+
{
|
|
195
|
+
name: definition.name,
|
|
196
|
+
description: definition.description ?? '',
|
|
197
|
+
parameters: definition.parameters ?? {},
|
|
198
|
+
},
|
|
199
|
+
null,
|
|
200
|
+
2,
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
128
210
|
module.exports = {
|
|
129
211
|
snapshotRegistry,
|
|
130
212
|
snapshotTools,
|
|
213
|
+
snapshotEntry,
|
|
131
214
|
injectNames,
|
|
132
215
|
provideNames,
|
|
133
216
|
stateName,
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
const fs = require('node:fs');
|
|
2
2
|
const path = require('node:path');
|
|
3
3
|
const os = require('node:os');
|
|
4
|
-
const {
|
|
4
|
+
const {
|
|
5
|
+
snapshotRegistry,
|
|
6
|
+
snapshotEntry,
|
|
7
|
+
stateName,
|
|
8
|
+
estimateTokens,
|
|
9
|
+
} = require('./collect/runtime.js');
|
|
10
|
+
const { installSectionAttribution } = require('./collect/attribution.js');
|
|
5
11
|
const { serviceGraph, health, shadowing, contextCost } = require('./model.js');
|
|
6
12
|
|
|
7
13
|
const name = 'dsh-xray';
|
|
@@ -27,6 +33,7 @@ function apply(ctx) {
|
|
|
27
33
|
const file = path.join(dir, 'runtime.json');
|
|
28
34
|
const transitions = new Map(); // plugin name -> [{state, at}] ring buffer
|
|
29
35
|
let lastAssembly = null; // latest system-prompt assembly observation
|
|
36
|
+
let attribution = null; // section name -> plugin name (live table)
|
|
30
37
|
|
|
31
38
|
let timer = null;
|
|
32
39
|
const writeSnapshot = () => {
|
|
@@ -35,6 +42,8 @@ function apply(ctx) {
|
|
|
35
42
|
const snap = snapshotRegistry(ctx);
|
|
36
43
|
snap.transitions = Object.fromEntries(transitions);
|
|
37
44
|
snap.promptAssembly = lastAssembly;
|
|
45
|
+
snap.sectionOwners = attribution ? Object.fromEntries(attribution.table) : {};
|
|
46
|
+
snap.toolOwners = attribution ? Object.fromEntries(attribution.toolTable) : {};
|
|
38
47
|
fs.mkdirSync(dir, { recursive: true });
|
|
39
48
|
const tmp = `${file}.tmp`;
|
|
40
49
|
fs.writeFileSync(tmp, JSON.stringify(snap, null, 2));
|
|
@@ -81,9 +90,16 @@ function apply(ctx) {
|
|
|
81
90
|
return result;
|
|
82
91
|
});
|
|
83
92
|
schedule(); // initial snapshot
|
|
93
|
+
// Section attribution: diff-based name->plugin table over
|
|
94
|
+
// system-prompt/change (see collect/attribution.js for the strategy).
|
|
95
|
+
attribution = installSectionAttribution(ctx);
|
|
84
96
|
return [
|
|
85
97
|
disposeStatus,
|
|
86
98
|
disposeAssemble,
|
|
99
|
+
() => {
|
|
100
|
+
attribution.dispose();
|
|
101
|
+
attribution = null;
|
|
102
|
+
},
|
|
87
103
|
() => {
|
|
88
104
|
clearTimeout(timer);
|
|
89
105
|
writeSnapshot(); // final state on unload
|
|
@@ -102,26 +118,34 @@ function apply(ctx) {
|
|
|
102
118
|
const snap = snapshotRegistry(ctx);
|
|
103
119
|
snap.transitions = Object.fromEntries(transitions);
|
|
104
120
|
snap.promptAssembly = lastAssembly;
|
|
121
|
+
snap.sectionOwners = attribution ? Object.fromEntries(attribution.table) : {};
|
|
122
|
+
snap.toolOwners = attribution ? Object.fromEntries(attribution.toolTable) : {};
|
|
105
123
|
return snap;
|
|
106
124
|
};
|
|
107
125
|
wctx.effect(
|
|
108
126
|
() =>
|
|
109
|
-
mountPanel(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
127
|
+
mountPanel(
|
|
128
|
+
wctx.webServer,
|
|
129
|
+
{
|
|
130
|
+
summary: () => {
|
|
131
|
+
const snap = freshSnap();
|
|
132
|
+
return {
|
|
133
|
+
plugins: snap.plugins.length,
|
|
134
|
+
unhealthy: health(snap).unhealthy.length,
|
|
135
|
+
services: Object.keys(serviceGraph(snap).services).length,
|
|
136
|
+
toolSchemaTokens: contextCost(snap).totalTokens,
|
|
137
|
+
capturedAt: snap.capturedAt,
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
deps: () => serviceGraph(freshSnap()),
|
|
141
|
+
health: () => health(freshSnap()),
|
|
142
|
+
cost: () => contextCost(freshSnap()),
|
|
143
|
+
shadow: () => shadowing(freshSnap()),
|
|
119
144
|
},
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}),
|
|
145
|
+
// Entry text is computed per request from the live registries and
|
|
146
|
+
// never persisted — the audit answer to "what exactly is ~N tokens?".
|
|
147
|
+
(kind, entryName) => snapshotEntry(ctx, kind, entryName),
|
|
148
|
+
),
|
|
125
149
|
'xray-panel-routes',
|
|
126
150
|
);
|
|
127
151
|
logger.info('xray panel mounted at /xray');
|
|
@@ -172,6 +196,8 @@ function apply(ctx) {
|
|
|
172
196
|
const snap = snapshotRegistry(ctx);
|
|
173
197
|
snap.transitions = Object.fromEntries(transitions);
|
|
174
198
|
snap.promptAssembly = lastAssembly;
|
|
199
|
+
snap.sectionOwners = attribution ? Object.fromEntries(attribution.table) : {};
|
|
200
|
+
snap.toolOwners = attribution ? Object.fromEntries(attribution.toolTable) : {};
|
|
175
201
|
if (args.view === 'deps') return serviceGraph(snap);
|
|
176
202
|
if (args.view === 'health') return health(snap);
|
|
177
203
|
if (args.view === 'cost') return contextCost(snap);
|
package/lib/model.js
CHANGED
|
@@ -277,8 +277,13 @@ function shadowing(snap) {
|
|
|
277
277
|
return out;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
/** F8: estimated context cost — tool schemas plus prompt sections
|
|
280
|
+
/** F8: estimated context cost — tool schemas plus prompt sections, each
|
|
281
|
+
* attributed to the plugin that registered it (F9: owner join + per-plugin
|
|
282
|
+
* rollup). Owners come from the snapshot's diff-based attribution tables;
|
|
283
|
+
* a missing entry renders as null (`unattributed`), never a guess. */
|
|
281
284
|
function contextCost(snap) {
|
|
285
|
+
const sectionOwners = snap.sectionOwners ?? {};
|
|
286
|
+
const toolOwners = snap.toolOwners ?? {};
|
|
282
287
|
const tools = (snap.tools ?? []).slice().sort((a, b) => b.tokens - a.tokens);
|
|
283
288
|
const toolTokens = tools.reduce((sum, t) => sum + t.tokens, 0);
|
|
284
289
|
const sections = (snap.promptAssembly?.sections ?? [])
|
|
@@ -287,14 +292,49 @@ function contextCost(snap) {
|
|
|
287
292
|
const sectionTokens = sections.reduce((sum, s) => sum + s.tokens, 0);
|
|
288
293
|
const total = toolTokens + sectionTokens;
|
|
289
294
|
const share = (n) => (total ? Math.round((n / total) * 1000) / 10 : 0);
|
|
295
|
+
|
|
296
|
+
// Per-plugin rollup: what does each plugin cost per request, and through
|
|
297
|
+
// which entries? This is the context-budget view: sort by tokens, and the
|
|
298
|
+
// top rows are the plugins silently taxing every request.
|
|
299
|
+
const byOwner = new Map();
|
|
300
|
+
const add = (owner, kind, name, tokens) => {
|
|
301
|
+
const key = owner ?? 'unattributed';
|
|
302
|
+
if (!byOwner.has(key))
|
|
303
|
+
byOwner.set(key, { plugin: key, tokens: 0, sections: 0, tools: 0, entries: [] });
|
|
304
|
+
const row = byOwner.get(key);
|
|
305
|
+
row.tokens += tokens;
|
|
306
|
+
row[kind] += 1;
|
|
307
|
+
row.entries.push({ kind: kind === 'sections' ? 'section' : 'tool', name, tokens });
|
|
308
|
+
};
|
|
309
|
+
for (const s of sections) add(sectionOwners[s.name], 'sections', s.name, s.tokens);
|
|
310
|
+
for (const t of tools) add(toolOwners[t.name], 'tools', t.name, t.tokens);
|
|
311
|
+
const owners = [...byOwner.values()]
|
|
312
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
313
|
+
.map((row) => ({
|
|
314
|
+
...row,
|
|
315
|
+
share: share(row.tokens),
|
|
316
|
+
entries: row.entries.sort((a, b) => b.tokens - a.tokens),
|
|
317
|
+
}));
|
|
318
|
+
|
|
290
319
|
return {
|
|
291
320
|
totalTokens: total,
|
|
292
321
|
toolTokens,
|
|
293
322
|
sectionTokens,
|
|
294
323
|
toolCount: tools.length,
|
|
295
324
|
sectionCount: sections.length,
|
|
296
|
-
tools: tools.map((t) => ({
|
|
297
|
-
|
|
325
|
+
tools: tools.map((t) => ({
|
|
326
|
+
name: t.name,
|
|
327
|
+
tokens: t.tokens,
|
|
328
|
+
share: share(t.tokens),
|
|
329
|
+
owner: toolOwners[t.name] ?? null,
|
|
330
|
+
})),
|
|
331
|
+
sections: sections.map((s) => ({
|
|
332
|
+
name: s.name,
|
|
333
|
+
tokens: s.tokens,
|
|
334
|
+
share: share(s.tokens),
|
|
335
|
+
owner: sectionOwners[s.name] ?? null,
|
|
336
|
+
})),
|
|
337
|
+
owners,
|
|
298
338
|
promptObservedAt: snap.promptAssembly?.at ?? null,
|
|
299
339
|
capturedAt: snap.capturedAt,
|
|
300
340
|
};
|
package/lib/panel.js
CHANGED
|
@@ -40,6 +40,34 @@ const PAGE = `<!doctype html>
|
|
|
40
40
|
<script>
|
|
41
41
|
const views = ['summary', 'health', 'deps', 'cost', 'shadow'];
|
|
42
42
|
const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
|
43
|
+
const escAttr = (s) => esc(s).replace(/"/g, '"');
|
|
44
|
+
|
|
45
|
+
// One question per view: what am I looking at, and what does trouble look like?
|
|
46
|
+
const INTRO = {
|
|
47
|
+
summary: 'Composition at a glance. A non-zero "unhealthy" count means some plugin failed to start — see the health view.',
|
|
48
|
+
health: 'Plugin lifecycle. "Waiting" plugins declared a dependency that no active plugin provides yet; "unhealthy" fibers failed to start and their features are absent.',
|
|
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
|
+
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
|
+
shadow: 'Same-name registrations. A service provided by two plugins means one silently wins — usually intended (an override), occasionally a conflict.',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Hover glossary: term -> plain-language meaning (native title tooltips).
|
|
55
|
+
const TIPS = {
|
|
56
|
+
share: 'Percentage of the total estimated context (sections + tool schemas) this row costs on every request',
|
|
57
|
+
tokens: 'Rough estimate: ~4 characters per token',
|
|
58
|
+
owner: 'The plugin whose registration put this entry into the context',
|
|
59
|
+
unattributed: 'Registered before dsh-xray mounted and not reconcilable to a single plugin — mount dsh-xray earlier in the profile to shrink this row',
|
|
60
|
+
wants: 'Services this plugin declared via inject that are not (yet) provided by any active plugin',
|
|
61
|
+
fiber: 'One mounted instance of the plugin (a Cordis fiber uid)',
|
|
62
|
+
state: 'Cordis lifecycle state: ACTIVE is healthy; FAILED means apply() threw',
|
|
63
|
+
affects: 'Transitive consumers: disabling the provider takes these down with it',
|
|
64
|
+
providers: 'Every plugin claiming this service name; the last one to load wins silently',
|
|
65
|
+
registrations: 'How many tools/commands this plugin registered on the shared registries',
|
|
66
|
+
sections: 'Prompt sections this plugin contributes to the system prompt',
|
|
67
|
+
tools: 'Tool schemas this plugin registers (each costs context on every request)',
|
|
68
|
+
};
|
|
69
|
+
const th = (h) => '<th' + (TIPS[h] ? ' title="' + escAttr(TIPS[h]) + '"' : '') + '>' + esc(h) + '</th>';
|
|
70
|
+
const intro = (v) => '<p class="muted" style="margin:0 0 10px">' + INTRO[v] + '</p>';
|
|
43
71
|
const nav = document.getElementById('nav');
|
|
44
72
|
const content = document.getElementById('content');
|
|
45
73
|
const status = document.getElementById('status');
|
|
@@ -54,7 +82,7 @@ for (const v of views) {
|
|
|
54
82
|
}
|
|
55
83
|
|
|
56
84
|
function table(headers, rows) {
|
|
57
|
-
return '<table><tr>' + headers.map(
|
|
85
|
+
return '<table><tr>' + headers.map(th).join('') + '</tr>'
|
|
58
86
|
+ rows.join('') + '</table>';
|
|
59
87
|
}
|
|
60
88
|
function bar(share) {
|
|
@@ -98,24 +126,31 @@ const renderers = {
|
|
|
98
126
|
+ '<h3 style="margin:16px 0 8px">services</h3>' + html;
|
|
99
127
|
}
|
|
100
128
|
if (d.unsatisfied.length) {
|
|
101
|
-
html = '<p class="warn">' + d.unsatisfied.length + ' unsatisfied inject(s)</p>' + html;
|
|
129
|
+
html = '<p class="warn" title="' + escAttr(TIPS.wants) + '">' + d.unsatisfied.length + ' unsatisfied inject(s) — these plugins wait forever unless a provider is added</p>' + html;
|
|
102
130
|
}
|
|
103
131
|
return html;
|
|
104
132
|
},
|
|
105
133
|
cost(d) {
|
|
106
134
|
let html = '<p>~' + d.totalTokens + ' tokens: ' + d.toolCount + ' tool schema(s) ~' + d.toolTokens
|
|
107
135
|
+ ' + ' + d.sectionCount + ' prompt section(s) ~' + d.sectionTokens + '</p>';
|
|
136
|
+
if (d.owners && d.owners.length) {
|
|
137
|
+
html += '<h3 style="margin:12px 0 4px">by plugin</h3>'
|
|
138
|
+
+ table(['plugin', 'sections', 'tools', 'tokens', 'share', ''], d.owners.map((o) =>
|
|
139
|
+
'<tr><td>' + (o.plugin === 'unattributed' ? '<span class="muted" title="' + escAttr(TIPS.unattributed) + '">unattributed</span>' : esc(o.plugin))
|
|
140
|
+
+ '</td><td class="num">' + o.sections + '</td><td class="num">' + o.tools
|
|
141
|
+
+ '</td><td class="num">~' + o.tokens + '</td><td class="num">' + o.share + '%</td><td>' + bar(o.share) + '</td></tr>'));
|
|
142
|
+
}
|
|
108
143
|
if (d.sections.length) {
|
|
109
144
|
html += '<h3 style="margin:12px 0 4px">prompt sections</h3>'
|
|
110
|
-
+ table(['section', 'tokens', 'share', ''], d.sections.map((s) =>
|
|
111
|
-
'<tr><td>' + esc(s.name) + '</td><td class="num">~' + s.tokens + '</td><td class="num">'
|
|
145
|
+
+ table(['section', 'owner', 'tokens', 'share', ''], d.sections.map((s) =>
|
|
146
|
+
'<tr><td>' + esc(s.name) + '</td><td class="muted">' + esc(s.owner ?? '—') + '</td><td class="num">~' + s.tokens + '</td><td class="num">'
|
|
112
147
|
+ s.share + '%</td><td>' + bar(s.share) + '</td></tr>'));
|
|
113
148
|
} else {
|
|
114
149
|
html += '<p class="muted">no prompt assembly observed yet — send one agent message first</p>';
|
|
115
150
|
}
|
|
116
151
|
html += '<h3 style="margin:12px 0 4px">tool schemas</h3>'
|
|
117
|
-
+ table(['tool', 'tokens', 'share', ''], d.tools.map((t) =>
|
|
118
|
-
'<tr><td>' + esc(t.name) + '</td><td class="num">~' + t.tokens + '</td><td class="num">'
|
|
152
|
+
+ table(['tool', 'owner', 'tokens', 'share', ''], d.tools.map((t) =>
|
|
153
|
+
'<tr><td>' + esc(t.name) + '</td><td class="muted">' + esc(t.owner ?? '—') + '</td><td class="num">~' + t.tokens + '</td><td class="num">'
|
|
119
154
|
+ t.share + '%</td><td>' + bar(t.share) + '</td></tr>'));
|
|
120
155
|
return html;
|
|
121
156
|
},
|
|
@@ -140,7 +175,7 @@ async function render() {
|
|
|
140
175
|
const res = await fetch('/xray/api/' + active);
|
|
141
176
|
if (!res.ok) throw new Error(await res.text());
|
|
142
177
|
const data = await res.json();
|
|
143
|
-
content.innerHTML = renderers[active](data);
|
|
178
|
+
content.innerHTML = intro(active) + renderers[active](data);
|
|
144
179
|
status.textContent = '';
|
|
145
180
|
} catch (err) {
|
|
146
181
|
status.innerHTML = '<span class="warn">' + esc(err.message) + '</span>';
|
|
@@ -165,9 +200,11 @@ function sendJson(response, code, value) {
|
|
|
165
200
|
/**
|
|
166
201
|
* Mount the panel routes. `views` supplies fresh data per request:
|
|
167
202
|
* { summary, deps, health, cost, shadow } — each a () => object.
|
|
203
|
+
* `entry`, when supplied, answers /xray/api/entry?kind=section|tool&name=…
|
|
204
|
+
* with one entry's live text (computed per request, never persisted).
|
|
168
205
|
* Returns the disposers webServer.register produced.
|
|
169
206
|
*/
|
|
170
|
-
function mountPanel(webServer, views) {
|
|
207
|
+
function mountPanel(webServer, views, entry) {
|
|
171
208
|
const disposers = [];
|
|
172
209
|
disposers.push(
|
|
173
210
|
webServer.register({
|
|
@@ -207,6 +244,36 @@ function mountPanel(webServer, views) {
|
|
|
207
244
|
}),
|
|
208
245
|
);
|
|
209
246
|
}
|
|
247
|
+
if (entry) {
|
|
248
|
+
disposers.push(
|
|
249
|
+
webServer.register({
|
|
250
|
+
kind: 'exact',
|
|
251
|
+
path: '/xray/api/entry',
|
|
252
|
+
handler: (request, response) => {
|
|
253
|
+
if (request.method !== 'GET') {
|
|
254
|
+
response.writeHead(405, { allow: 'GET' });
|
|
255
|
+
response.end();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const params = new URL(request.url ?? '/', 'http://x').searchParams;
|
|
259
|
+
const kind = params.get('kind');
|
|
260
|
+
const name = params.get('name');
|
|
261
|
+
if ((kind !== 'section' && kind !== 'tool') || !name) {
|
|
262
|
+
sendJson(response, 400, { error: 'expected ?kind=section|tool&name=<entry name>' });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const value = entry(kind, name);
|
|
267
|
+
if (value === null)
|
|
268
|
+
sendJson(response, 404, { error: `no live ${kind} named "${name}"` });
|
|
269
|
+
else sendJson(response, 200, value);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
sendJson(response, 500, { error: err.message });
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
}
|
|
210
277
|
return disposers;
|
|
211
278
|
}
|
|
212
279
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-xray",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "X-ray for your DeepSeek Harness — see what's actually loaded, why, and what it costs you.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"platform": "web",
|
|
63
63
|
"inject": [
|
|
64
64
|
"@deepseek-ai/dsh-client-runtime",
|
|
65
|
+
"@deepseek-ai/dsh-client-locale",
|
|
65
66
|
"@deepseek-ai/dsh-client-ui-slots",
|
|
66
67
|
"@deepseek-ai/dsh-client-ui-conversation"
|
|
67
68
|
]
|