dsh-xray 0.7.2 → 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 +368 -47
- 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
|
@@ -37,6 +37,16 @@ window.__ModuleLoader__.load({
|
|
|
37
37
|
'.xray-table{border-collapse:collapse;width:100%;margin-top:6px}',
|
|
38
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}',
|
|
39
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}',
|
|
40
50
|
'.xray-num{text-align:right}',
|
|
41
51
|
'.xray-warn{color:var(--dsw-alias-state-error-primary,#f85149)}',
|
|
42
52
|
'.xray-ok{color:var(--dsw-alias-state-success-primary,#3fb950)}',
|
|
@@ -58,7 +68,177 @@ window.__ModuleLoader__.load({
|
|
|
58
68
|
|
|
59
69
|
//#region tiny view primitives
|
|
60
70
|
const VIEWS = ['summary', 'health', 'deps', 'cost', 'shadow'];
|
|
61
|
-
|
|
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 }) {
|
|
62
242
|
return h(
|
|
63
243
|
'table',
|
|
64
244
|
{ className: 'xray-table' },
|
|
@@ -68,7 +248,13 @@ window.__ModuleLoader__.load({
|
|
|
68
248
|
h(
|
|
69
249
|
'tr',
|
|
70
250
|
null,
|
|
71
|
-
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
|
+
),
|
|
72
258
|
),
|
|
73
259
|
),
|
|
74
260
|
h('tbody', null, rows),
|
|
@@ -90,55 +276,139 @@ window.__ModuleLoader__.load({
|
|
|
90
276
|
),
|
|
91
277
|
);
|
|
92
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
|
+
}
|
|
93
353
|
//#endregion
|
|
94
354
|
|
|
95
|
-
//#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)
|
|
96
356
|
const renderers = {
|
|
97
|
-
summary: (d) =>
|
|
357
|
+
summary: (d, t) =>
|
|
98
358
|
h(
|
|
99
359
|
react.Fragment,
|
|
100
360
|
null,
|
|
101
361
|
h(Table, {
|
|
362
|
+
t,
|
|
102
363
|
headers: ['metric', 'value'],
|
|
103
364
|
rows: [
|
|
104
|
-
Row('p', ['
|
|
365
|
+
Row('p', [t('row.pluginsMounted'), { cls: 'xray-num', text: String(d.plugins) }]),
|
|
105
366
|
Row('u', [
|
|
106
|
-
'unhealthy',
|
|
367
|
+
t('row.unhealthy'),
|
|
107
368
|
{
|
|
108
369
|
cls: `xray-num ${d.unhealthy ? 'xray-warn' : 'xray-ok'}`,
|
|
109
370
|
text: String(d.unhealthy),
|
|
110
371
|
},
|
|
111
372
|
]),
|
|
112
|
-
Row('s', ['services', { cls: 'xray-num', text: String(d.services) }]),
|
|
373
|
+
Row('s', [t('row.services'), { cls: 'xray-num', text: String(d.services) }]),
|
|
113
374
|
Row('t', [
|
|
114
|
-
'
|
|
375
|
+
h('span', { title: t('tip.contextTokens') }, t('row.contextTokens')),
|
|
115
376
|
{ cls: 'xray-num', text: `~${d.toolSchemaTokens}` },
|
|
116
377
|
]),
|
|
117
378
|
],
|
|
118
379
|
}),
|
|
119
|
-
h(
|
|
380
|
+
h(
|
|
381
|
+
'p',
|
|
382
|
+
{ className: 'xray-muted', style: { marginTop: 12 } },
|
|
383
|
+
fill(t('summary.captured'), { at: d.capturedAt }),
|
|
384
|
+
),
|
|
120
385
|
),
|
|
121
386
|
|
|
122
|
-
health: (d) =>
|
|
387
|
+
health: (d, t) =>
|
|
123
388
|
h(
|
|
124
389
|
react.Fragment,
|
|
125
390
|
null,
|
|
126
391
|
h(
|
|
127
392
|
'p',
|
|
128
393
|
null,
|
|
129
|
-
h('span', { className: 'xray-ok' },
|
|
130
|
-
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,
|
|
131
396
|
d.unhealthy.length
|
|
132
397
|
? h(
|
|
133
398
|
react.Fragment,
|
|
134
399
|
null,
|
|
135
400
|
' · ',
|
|
136
|
-
h(
|
|
401
|
+
h(
|
|
402
|
+
'span',
|
|
403
|
+
{ className: 'xray-warn' },
|
|
404
|
+
fill(t('health.unhealthy'), { n: d.unhealthy.length }),
|
|
405
|
+
),
|
|
137
406
|
)
|
|
138
407
|
: null,
|
|
139
408
|
),
|
|
140
409
|
d.unhealthy.length
|
|
141
410
|
? h(Table, {
|
|
411
|
+
t,
|
|
142
412
|
headers: ['plugin', 'fiber', 'state', 'error'],
|
|
143
413
|
rows: d.unhealthy.flatMap((p) =>
|
|
144
414
|
p.fibers.map((f) =>
|
|
@@ -154,25 +424,31 @@ window.__ModuleLoader__.load({
|
|
|
154
424
|
: null,
|
|
155
425
|
d.waiting.length
|
|
156
426
|
? h(Table, {
|
|
157
|
-
|
|
427
|
+
t,
|
|
428
|
+
headers: ['waitingPlugin', 'wants'],
|
|
158
429
|
rows: d.waiting.map((p) => Row(p.name, [p.name, p.inject.join(', ')])),
|
|
159
430
|
})
|
|
160
431
|
: null,
|
|
161
432
|
),
|
|
162
433
|
|
|
163
|
-
deps: (d) =>
|
|
434
|
+
deps: (d, t) =>
|
|
164
435
|
h(
|
|
165
436
|
react.Fragment,
|
|
166
437
|
null,
|
|
167
438
|
d.unsatisfied.length
|
|
168
|
-
? h(
|
|
439
|
+
? h(
|
|
440
|
+
'p',
|
|
441
|
+
{ className: 'xray-warn', title: t('tip.wants') },
|
|
442
|
+
fill(t('deps.unsatisfied'), { n: d.unsatisfied.length }),
|
|
443
|
+
)
|
|
169
444
|
: null,
|
|
170
445
|
Object.keys(d.cascade).length
|
|
171
446
|
? h(
|
|
172
447
|
react.Fragment,
|
|
173
448
|
null,
|
|
174
|
-
h('div', { className: 'xray-h3' }, '
|
|
449
|
+
h('div', { className: 'xray-h3' }, t('h3.disableCascade')),
|
|
175
450
|
h(Table, {
|
|
451
|
+
t,
|
|
176
452
|
headers: ['provider', 'affects'],
|
|
177
453
|
rows: Object.entries(d.cascade).map(([provider, affected]) =>
|
|
178
454
|
Row(provider, [provider, affected.join(', ')]),
|
|
@@ -180,34 +456,70 @@ window.__ModuleLoader__.load({
|
|
|
180
456
|
}),
|
|
181
457
|
)
|
|
182
458
|
: null,
|
|
183
|
-
h('div', { className: 'xray-h3' }, 'services'),
|
|
459
|
+
h('div', { className: 'xray-h3' }, t('h3.services')),
|
|
184
460
|
h(Table, {
|
|
185
|
-
|
|
461
|
+
t,
|
|
462
|
+
headers: ['service', 'providedBy', 'consumedBy'],
|
|
186
463
|
rows: Object.entries(d.services).map(([name, node]) =>
|
|
187
464
|
Row(name, [name, node.providers.join(', ') || '—', node.consumers.join(', ') || '—']),
|
|
188
465
|
),
|
|
189
466
|
}),
|
|
190
467
|
),
|
|
191
468
|
|
|
192
|
-
cost: (d) =>
|
|
469
|
+
cost: (d, t, onInspect) =>
|
|
193
470
|
h(
|
|
194
471
|
react.Fragment,
|
|
195
472
|
null,
|
|
196
473
|
h(
|
|
197
474
|
'p',
|
|
198
475
|
null,
|
|
199
|
-
|
|
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
|
+
}),
|
|
200
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,
|
|
201
511
|
d.sections.length
|
|
202
512
|
? h(
|
|
203
513
|
react.Fragment,
|
|
204
514
|
null,
|
|
205
|
-
h('div', { className: 'xray-h3' }, '
|
|
515
|
+
h('div', { className: 'xray-h3' }, t('h3.promptSections')),
|
|
206
516
|
h(Table, {
|
|
207
|
-
|
|
517
|
+
t,
|
|
518
|
+
headers: ['section', 'owner', 'tokens', 'share', ''],
|
|
208
519
|
rows: d.sections.map((s) =>
|
|
209
520
|
Row(s.name, [
|
|
210
|
-
s.name,
|
|
521
|
+
h(EntryLink, { kind: 'section', name: s.name, onInspect, t }),
|
|
522
|
+
{ cls: 'xray-muted', text: s.owner ?? '—' },
|
|
211
523
|
{ cls: 'xray-num', text: `~${s.tokens}` },
|
|
212
524
|
{ cls: 'xray-num', text: `${s.share}%` },
|
|
213
525
|
h(Bar, { share: s.share }),
|
|
@@ -215,31 +527,30 @@ window.__ModuleLoader__.load({
|
|
|
215
527
|
),
|
|
216
528
|
}),
|
|
217
529
|
)
|
|
218
|
-
: h(
|
|
219
|
-
|
|
220
|
-
{ className: 'xray-muted' },
|
|
221
|
-
'no prompt assembly observed yet — send one agent message first',
|
|
222
|
-
),
|
|
223
|
-
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')),
|
|
224
532
|
h(Table, {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
{
|
|
230
|
-
{ cls: 'xray-
|
|
231
|
-
|
|
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 }),
|
|
232
542
|
]),
|
|
233
543
|
),
|
|
234
544
|
}),
|
|
235
545
|
),
|
|
236
546
|
|
|
237
|
-
shadow: (d) =>
|
|
547
|
+
shadow: (d, t) =>
|
|
238
548
|
h(
|
|
239
549
|
react.Fragment,
|
|
240
550
|
null,
|
|
241
551
|
d.services.length
|
|
242
552
|
? h(Table, {
|
|
553
|
+
t,
|
|
243
554
|
headers: ['service', 'providers'],
|
|
244
555
|
rows: d.services.map((s) =>
|
|
245
556
|
Row(s.service, [
|
|
@@ -248,13 +559,14 @@ window.__ModuleLoader__.load({
|
|
|
248
559
|
]),
|
|
249
560
|
),
|
|
250
561
|
})
|
|
251
|
-
: h('p', { className: 'xray-ok' }, '
|
|
562
|
+
: h('p', { className: 'xray-ok' }, t('shadow.clean')),
|
|
252
563
|
d.registrars.length
|
|
253
564
|
? h(
|
|
254
565
|
react.Fragment,
|
|
255
566
|
null,
|
|
256
|
-
h('div', { className: 'xray-h3' }, 'registrars'),
|
|
567
|
+
h('div', { className: 'xray-h3' }, t('h3.registrars')),
|
|
257
568
|
h(Table, {
|
|
569
|
+
t,
|
|
258
570
|
headers: ['plugin', 'registrations'],
|
|
259
571
|
rows: d.registrars.map((r) =>
|
|
260
572
|
Row(r.plugin, [r.plugin, { cls: 'xray-num', text: String(r.registrations) }]),
|
|
@@ -267,7 +579,7 @@ window.__ModuleLoader__.load({
|
|
|
267
579
|
//#endregion
|
|
268
580
|
|
|
269
581
|
//#region panel (one conversation.view tab)
|
|
270
|
-
function XrayPanel() {
|
|
582
|
+
function XrayPanel({ t }) {
|
|
271
583
|
const [view, setView] = react.useState('summary');
|
|
272
584
|
// `for` stamps which view the payload belongs to: a click flips `view`
|
|
273
585
|
// synchronously while `state` still carries the previous view's data —
|
|
@@ -277,6 +589,8 @@ window.__ModuleLoader__.load({
|
|
|
277
589
|
// the layout changes once per click instead of collapsing to a
|
|
278
590
|
// one-line loading row and re-expanding.
|
|
279
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);
|
|
280
594
|
react.useEffect(() => {
|
|
281
595
|
let alive = true;
|
|
282
596
|
fetch(`/xray/api/${view}`)
|
|
@@ -304,18 +618,20 @@ window.__ModuleLoader__.load({
|
|
|
304
618
|
// the fresh payload is in flight.
|
|
305
619
|
const settled = state.phase !== 'loading';
|
|
306
620
|
let body;
|
|
307
|
-
if (!settled) body = h('p', { className: 'xray-muted' },
|
|
621
|
+
if (!settled) body = h('p', { className: 'xray-muted' }, fill(t('panel.loading'), { view }));
|
|
308
622
|
else if (state.phase === 'error') body = h('p', { className: 'xray-warn' }, state.message);
|
|
309
623
|
else {
|
|
310
624
|
// A diagnostic surface must never take itself down on one bad
|
|
311
625
|
// payload: render the failure, keep the tab and its nav alive.
|
|
312
626
|
try {
|
|
313
|
-
body = renderers[state.for](state.data);
|
|
627
|
+
body = renderers[state.for](state.data, t, setInspecting);
|
|
314
628
|
} catch (err) {
|
|
315
629
|
body = h(
|
|
316
630
|
'p',
|
|
317
631
|
{ className: 'xray-warn' },
|
|
318
|
-
|
|
632
|
+
fill(t('panel.renderFailed'), {
|
|
633
|
+
message: err instanceof Error ? err.message : String(err),
|
|
634
|
+
}),
|
|
319
635
|
);
|
|
320
636
|
}
|
|
321
637
|
}
|
|
@@ -323,7 +639,7 @@ window.__ModuleLoader__.load({
|
|
|
323
639
|
return h(
|
|
324
640
|
'div',
|
|
325
641
|
{ className: 'xray-panel' },
|
|
326
|
-
h('p', { className: 'xray-sub' }, '
|
|
642
|
+
h('p', { className: 'xray-sub' }, t('panel.sub')),
|
|
327
643
|
h(
|
|
328
644
|
'nav',
|
|
329
645
|
{ className: 'xray-nav' },
|
|
@@ -335,17 +651,22 @@ window.__ModuleLoader__.load({
|
|
|
335
651
|
),
|
|
336
652
|
),
|
|
337
653
|
),
|
|
654
|
+
h('p', { className: 'xray-muted', style: { margin: '0 0 10px' } }, t(`intro.${view}`)),
|
|
338
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,
|
|
339
659
|
);
|
|
340
660
|
}
|
|
341
661
|
//#endregion
|
|
342
662
|
|
|
343
|
-
const inject = ['slots'];
|
|
663
|
+
const inject = ['slots', 'locale'];
|
|
344
664
|
/** Mount the X-ray tab into the conversation view ring (beside Chat / Trajectory). */
|
|
345
665
|
function apply(ctx) {
|
|
666
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'xray: dictionaries');
|
|
346
667
|
ctx.slots.inject('conversation.view', () =>
|
|
347
668
|
ctx.slots.register(
|
|
348
|
-
{ name: 'conversation.view', id: 'xray', order: 20, label: 'X-ray' },
|
|
669
|
+
{ name: 'conversation.view', id: 'xray', order: 20, label: 'X-ray', locale: NS },
|
|
349
670
|
XrayPanel,
|
|
350
671
|
),
|
|
351
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
|
]
|