dsh-xray 0.7.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 };
@@ -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
- const schemas = tools?.schemas?.();
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 { snapshotRegistry, stateName, estimateTokens } = require('./collect/runtime.js');
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(wctx.webServer, {
110
- summary: () => {
111
- const snap = freshSnap();
112
- return {
113
- plugins: snap.plugins.length,
114
- unhealthy: health(snap).unhealthy.length,
115
- services: Object.keys(serviceGraph(snap).services).length,
116
- toolSchemaTokens: contextCost(snap).totalTokens,
117
- capturedAt: snap.capturedAt,
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
- deps: () => serviceGraph(freshSnap()),
121
- health: () => health(freshSnap()),
122
- cost: () => contextCost(freshSnap()),
123
- shadow: () => shadowing(freshSnap()),
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) => ({ name: t.name, tokens: t.tokens, share: share(t.tokens) })),
297
- sections: sections.map((s) => ({ name: s.name, tokens: s.tokens, share: share(s.tokens) })),
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) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
43
+ const escAttr = (s) => esc(s).replace(/"/g, '&quot;');
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((h) => '<th>' + esc(h) + '</th>').join('') + '</tr>'
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.7.2",
3
+ "version": "0.8.1",
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
  ]