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.
- package/lib/client.js +434 -48
- 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
|
@@ -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.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
|
]
|