dsh-xray 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,8 @@
13
13
 
14
14
  <p align="center">
15
15
  <strong>X-ray for your <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a></strong> — see what's actually loaded, why, and what it costs you.
16
+ <br>
17
+ <em>LLM context-cost observability: token attribution per plugin, prompt-section and tool-schema pricing, skill catalog tax, dependency cascades.</em>
16
18
  </p>
17
19
 
18
20
  <p align="center">
package/README.zh.md CHANGED
@@ -12,6 +12,8 @@
12
12
 
13
13
  <p align="center">
14
14
  <strong>给 <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> 拍 X 光</strong>——看清到底加载了什么、为什么在那、以及它悄悄花掉了你什么。
15
+ <br>
16
+ <em>LLM 上下文成本可观测:按插件归因 token、prompt section 与工具 schema 计价、skill catalog 常驻税、依赖停用级联。</em>
15
17
  </p>
16
18
 
17
19
  <p align="center">
package/lib/client.js CHANGED
@@ -68,7 +68,7 @@ window.__ModuleLoader__.load({
68
68
  //#endregion
69
69
 
70
70
  //#region tiny view primitives
71
- const VIEWS = ['summary', 'health', 'deps', 'cost', 'shadow', 'skills'];
71
+ const VIEWS = ['summary', 'health', 'deps', 'cost', 'shadow', 'skills', 'requests'];
72
72
  const NS = 'xray';
73
73
 
74
74
  /** English messages (also the key vocabulary; zh mirrors every key). */
@@ -169,6 +169,26 @@ window.__ModuleLoader__.load({
169
169
  'no skill observation yet — the skills service is absent or discovery has not run',
170
170
  'skills.yes': 'yes',
171
171
  'skills.userOnly': 'user-only',
172
+ 'intro.requests':
173
+ 'One bill per LLM call, newest first: system + tool schemas + history + tool results. ⚡ = the system/tools prefix matched the previous request (cache-friendly); ✂ = the prefix changed. Δ is the total against the previous request.',
174
+ 'col.seq': '#',
175
+ 'col.when': 'when',
176
+ 'col.purpose': 'purpose',
177
+ 'col.total': 'total',
178
+ 'col.system': 'system',
179
+ 'col.toolSchemas2': 'tools',
180
+ 'col.history': 'history',
181
+ 'col.results': 'results',
182
+ 'col.prefix': 'prefix',
183
+ 'col.delta': 'Δ',
184
+ 'tip.total': 'Estimated tokens of the whole request payload (~4 chars/token)',
185
+ 'tip.history': 'Conversation messages (user + assistant), excluding tool results',
186
+ 'tip.results': 'Tool-result messages, aggregated per tool (hover a cell for the top tools)',
187
+ 'tip.prefix':
188
+ 'Whether system prompt + tool schemas were byte-identical to the previous request in this session (⚡ cache-friendly, ✂ prefix broken)',
189
+ 'requests.none': 'no requests observed yet — send one agent message first',
190
+ 'requests.session': 'session {id} ({n} request(s))',
191
+ 'requests.chat': 'chat',
172
192
  'entry.loading': 'loading entry…',
173
193
  'entry.stats': '{chars} chars · ~{tokens} tokens ({estimator})',
174
194
  'entry.close': 'close',
@@ -264,6 +284,26 @@ window.__ModuleLoader__.load({
264
284
  'skills.none': '尚无 skill 观测——skills 服务缺失或发现未运行',
265
285
  'skills.yes': '是',
266
286
  'skills.userOnly': '仅用户',
287
+ 'intro.requests':
288
+ '每次 LLM 调用一张账单,最新在前:system + 工具 schema + 历史 + 工具结果。⚡ = system/工具前缀与上一请求逐字节一致(缓存友好);✂ = 前缀变了。Δ 为相对上一请求的总量增减。',
289
+ 'col.seq': '#',
290
+ 'col.when': '时间',
291
+ 'col.purpose': '用途',
292
+ 'col.total': '总量',
293
+ 'col.system': 'system',
294
+ 'col.toolSchemas2': '工具',
295
+ 'col.history': '历史',
296
+ 'col.results': '结果',
297
+ 'col.prefix': '前缀',
298
+ 'col.delta': 'Δ',
299
+ 'tip.total': '整个请求负载的估算 tokens(约 4 字符/token)',
300
+ 'tip.history': '对话消息(user + assistant),不含工具结果',
301
+ 'tip.results': '工具结果消息,按工具聚合(悬停单元格看 top 工具)',
302
+ 'tip.prefix':
303
+ 'system prompt + 工具 schema 是否与本会话上一请求逐字节一致(⚡ 缓存友好,✂ 前缀击穿)',
304
+ 'requests.none': '尚未观测到请求——先发送一条 agent 消息',
305
+ 'requests.session': '会话 {id}({n} 个请求)',
306
+ 'requests.chat': 'chat',
267
307
  'entry.loading': '正在加载条目…',
268
308
  'entry.stats': '{chars} 字符 · 约 {tokens} tokens({estimator})',
269
309
  'entry.close': '关闭',
@@ -701,6 +741,69 @@ window.__ModuleLoader__.load({
701
741
  ),
702
742
  }),
703
743
  ),
744
+
745
+ requests: (d, t) =>
746
+ !d.available
747
+ ? h('p', { className: 'xray-muted' }, t('requests.none'))
748
+ : h(
749
+ react.Fragment,
750
+ null,
751
+ d.sessions.map((session) =>
752
+ h(
753
+ react.Fragment,
754
+ { key: session.sessionId },
755
+ h(
756
+ 'div',
757
+ { className: 'xray-h3' },
758
+ fill(t('requests.session'), {
759
+ id: String(session.sessionId).slice(0, 12),
760
+ n: session.requests.length,
761
+ }),
762
+ ),
763
+ h(Table, {
764
+ t,
765
+ headers: [
766
+ 'seq',
767
+ 'when',
768
+ 'purpose',
769
+ 'total',
770
+ 'system',
771
+ 'toolSchemas2',
772
+ 'history',
773
+ 'results',
774
+ 'prefix',
775
+ 'delta',
776
+ ],
777
+ rows: session.requests.map((r) => {
778
+ const top = (r.toolResultRows ?? [])
779
+ .slice(0, 3)
780
+ .map((row) => `${row.name} ~${row.tokens}`)
781
+ .join(', ');
782
+ return Row(`${session.sessionId}/${r.seq}`, [
783
+ { cls: 'xray-num', text: String(r.seq) },
784
+ { cls: 'xray-muted', text: new Date(r.at).toTimeString().slice(0, 8) },
785
+ r.purpose
786
+ ? h('span', { className: 'xray-muted' }, r.purpose)
787
+ : t('requests.chat'),
788
+ { cls: 'xray-num', text: `~${r.total}` },
789
+ { cls: 'xray-num', text: String(r.system) },
790
+ { cls: 'xray-num', text: String(r.toolSchemas) },
791
+ { cls: 'xray-num', text: String(r.history) },
792
+ h('span', { className: 'xray-num', title: top }, String(r.toolResults)),
793
+ r.prefixStable === null ? '—' : r.prefixStable ? '⚡' : '✂',
794
+ {
795
+ cls: 'xray-num',
796
+ text:
797
+ r.deltaTotal === null
798
+ ? '—'
799
+ : `${r.deltaTotal >= 0 ? '+' : ''}${r.deltaTotal}`,
800
+ },
801
+ ]);
802
+ }),
803
+ }),
804
+ ),
805
+ ),
806
+ ),
704
807
  };
705
808
  //#endregion
706
809
 
@@ -0,0 +1,151 @@
1
+ // Request ledger: observe the `llm/stream` waterfall and record ONE
2
+ // classified bill per LLM call — system prompt, tool schemas, conversation
3
+ // history, tool results — plus prefix stability. Purely observational: the
4
+ // options object passes through `next()` untouched, and nothing but counts,
5
+ // names, and hashes is retained (never message text).
6
+ //
7
+ // Every request in the process flows through here: main sessions, subagents,
8
+ // compaction and title calls (tagged by `purpose`). Entries bucket per
9
+ // sessionId in a bounded ring so one fan-out cannot evict another session's
10
+ // history.
11
+
12
+ const crypto = require('node:crypto');
13
+
14
+ /** Rough token estimate mirroring collect/runtime.js (~4 chars/token). */
15
+ function estimateTokens(text) {
16
+ return Math.ceil(text.length / 4);
17
+ }
18
+
19
+ /** Serialize one content block to its model-facing text size, recursively. */
20
+ function blockChars(block) {
21
+ if (!block || typeof block !== 'object') return 0;
22
+ switch (block.type) {
23
+ case 'text':
24
+ case 'reasoning':
25
+ return String(block.text ?? '').length;
26
+ case 'image':
27
+ // Images are billed by the provider in provider units; approximate by
28
+ // the transported payload size so the ledger at least ranks them.
29
+ return String(block.data ?? block.url ?? '').length;
30
+ case 'tool-call':
31
+ return String(block.name ?? '').length + String(block.arguments ?? '').length;
32
+ case 'tool-result':
33
+ return (block.content ?? []).reduce((sum, inner) => sum + blockChars(inner), 0);
34
+ default:
35
+ try {
36
+ return JSON.stringify(block).length;
37
+ } catch {
38
+ return 0;
39
+ }
40
+ }
41
+ }
42
+
43
+ /** Short content hash for prefix-stability comparison (never stored text). */
44
+ function digest(text) {
45
+ return crypto.createHash('sha256').update(text).digest('hex').slice(0, 12);
46
+ }
47
+
48
+ /**
49
+ * Classify one GenerateOptions into the ledger entry. Tool results resolve
50
+ * their tool name through the preceding assistant tool-call blocks.
51
+ */
52
+ function classify(options) {
53
+ const system = String(options.system ?? '');
54
+ const toolsJson = JSON.stringify(options.tools ?? []);
55
+ const callNames = new Map(); // callId -> tool name
56
+ let historyChars = 0;
57
+ let historyMessages = 0;
58
+ let userMessages = 0;
59
+ let assistantMessages = 0;
60
+ const toolResults = new Map(); // tool name -> {count, chars}
61
+ for (const message of options.messages ?? []) {
62
+ const content = message.content ?? [];
63
+ for (const block of content) {
64
+ if (block?.type === 'tool-call') callNames.set(block.id, block.name);
65
+ }
66
+ const isToolResult =
67
+ message.source?.kind === 'tool' || content.some((b) => b?.type === 'tool-result');
68
+ const chars = content.reduce((sum, block) => sum + blockChars(block), 0);
69
+ if (isToolResult) {
70
+ let name = 'unknown';
71
+ for (const block of content) {
72
+ if (block?.type === 'tool-result') {
73
+ name = callNames.get(block.toolCallId) ?? 'unknown';
74
+ break;
75
+ }
76
+ }
77
+ const row = toolResults.get(name) ?? { count: 0, chars: 0 };
78
+ row.count += 1;
79
+ row.chars += chars;
80
+ toolResults.set(name, row);
81
+ } else {
82
+ historyChars += chars;
83
+ historyMessages += 1;
84
+ if (message.role === 'user') userMessages += 1;
85
+ else if (message.role === 'assistant') assistantMessages += 1;
86
+ }
87
+ }
88
+ const toolResultRows = [...toolResults.entries()]
89
+ .map(([name, row]) => ({
90
+ name,
91
+ count: row.count,
92
+ tokens: estimateTokens(' '.repeat(row.chars)),
93
+ }))
94
+ .sort((a, b) => b.tokens - a.tokens);
95
+ const toolResultTokens = toolResultRows.reduce((sum, r) => sum + r.tokens, 0);
96
+ const entry = {
97
+ at: Date.now(),
98
+ provider: options.provider ?? null,
99
+ model: options.model ?? null,
100
+ purpose: options.purpose ?? null,
101
+ system: estimateTokens(system),
102
+ toolSchemas: estimateTokens(toolsJson),
103
+ toolCount: (options.tools ?? []).length,
104
+ history: estimateTokens(' '.repeat(historyChars)),
105
+ historyMessages,
106
+ userMessages,
107
+ assistantMessages,
108
+ toolResults: toolResultTokens,
109
+ toolResultRows: toolResultRows.slice(0, 8),
110
+ prefixDigest: digest(system + '\u0000' + toolsJson),
111
+ };
112
+ entry.total = entry.system + entry.toolSchemas + entry.history + entry.toolResults;
113
+ return entry;
114
+ }
115
+
116
+ /**
117
+ * Install the ledger observer.
118
+ * @param ctx - the mounted plugin's context.
119
+ * @param options - { maxPerSession } ring bound (default 50).
120
+ * @returns { sessions, dispose } — sessions: Map<sessionId, entries[]>.
121
+ */
122
+ function installRequestLedger(ctx, options = {}) {
123
+ const maxPerSession = options.maxPerSession ?? 50;
124
+ const sessions = new Map(); // sessionId -> [entry, ...] (oldest first)
125
+ const dispose = ctx.on('llm/stream', (generateOptions, next) => {
126
+ try {
127
+ const key = String(generateOptions.sessionId ?? 'unscoped');
128
+ const entry = classify(generateOptions);
129
+ const ring = sessions.get(key) ?? [];
130
+ const prev = ring[ring.length - 1];
131
+ entry.prefixStable = prev === undefined ? null : prev.prefixDigest === entry.prefixDigest;
132
+ entry.deltaTotal = prev === undefined ? null : entry.total - prev.total;
133
+ ring.push(entry);
134
+ if (ring.length > maxPerSession) ring.shift();
135
+ sessions.set(key, ring);
136
+ // Bound the session table itself: keep the most recently active 20.
137
+ if (sessions.size > 20) {
138
+ const oldest = [...sessions.entries()].sort(
139
+ (a, b) => (a[1][a[1].length - 1]?.at ?? 0) - (b[1][b[1].length - 1]?.at ?? 0),
140
+ )[0];
141
+ if (oldest) sessions.delete(oldest[0]);
142
+ }
143
+ } catch {
144
+ /* the ledger must never break a model call */
145
+ }
146
+ return next();
147
+ });
148
+ return { sessions, dispose };
149
+ }
150
+
151
+ module.exports = { installRequestLedger, classify, blockChars };
package/lib/index.js CHANGED
@@ -9,7 +9,15 @@ const {
9
9
  estimateTokens,
10
10
  } = require('./collect/runtime.js');
11
11
  const { installSectionAttribution } = require('./collect/attribution.js');
12
- const { serviceGraph, health, shadowing, contextCost, skillCost } = require('./model.js');
12
+ const { installRequestLedger } = require('./collect/requests.js');
13
+ const {
14
+ serviceGraph,
15
+ health,
16
+ shadowing,
17
+ contextCost,
18
+ skillCost,
19
+ requestLedger,
20
+ } = require('./model.js');
13
21
 
14
22
  const name = 'dsh-xray';
15
23
 
@@ -36,6 +44,7 @@ function apply(ctx) {
36
44
  let lastAssembly = null; // latest system-prompt assembly observation
37
45
  let attribution = null; // section name -> plugin name (live table)
38
46
  let skillCatalog = null; // latest skill-cost observation (async, throttled)
47
+ let ledger = null; // per-session request ledger (llm/stream observer)
39
48
  let skillRefreshing = false;
40
49
  const refreshSkills = (lookup) => {
41
50
  // Discovery hits the filesystem and is scope/cwd-sensitive: the
@@ -66,6 +75,7 @@ function apply(ctx) {
66
75
  snap.sectionOwners = attribution ? Object.fromEntries(attribution.table) : {};
67
76
  snap.toolOwners = attribution ? Object.fromEntries(attribution.toolTable) : {};
68
77
  snap.skillCatalog = skillCatalog;
78
+ snap.requestLedger = ledger ? Object.fromEntries(ledger.sessions) : {};
69
79
  fs.mkdirSync(dir, { recursive: true });
70
80
  const tmp = `${file}.tmp`;
71
81
  fs.writeFileSync(tmp, JSON.stringify(snap, null, 2));
@@ -125,12 +135,15 @@ function apply(ctx) {
125
135
  // Section attribution: diff-based name->plugin table over
126
136
  // system-prompt/change (see collect/attribution.js for the strategy).
127
137
  attribution = installSectionAttribution(ctx);
138
+ ledger = installRequestLedger(ctx);
128
139
  return [
129
140
  disposeStatus,
130
141
  disposeAssemble,
131
142
  () => {
132
143
  attribution.dispose();
133
144
  attribution = null;
145
+ ledger.dispose();
146
+ ledger = null;
134
147
  },
135
148
  () => {
136
149
  clearTimeout(timer);
@@ -153,6 +166,7 @@ function apply(ctx) {
153
166
  snap.sectionOwners = attribution ? Object.fromEntries(attribution.table) : {};
154
167
  snap.toolOwners = attribution ? Object.fromEntries(attribution.toolTable) : {};
155
168
  snap.skillCatalog = skillCatalog;
169
+ snap.requestLedger = ledger ? Object.fromEntries(ledger.sessions) : {};
156
170
  return snap;
157
171
  };
158
172
  wctx.effect(
@@ -175,6 +189,7 @@ function apply(ctx) {
175
189
  cost: () => contextCost(freshSnap()),
176
190
  shadow: () => shadowing(freshSnap()),
177
191
  skills: () => skillCost(freshSnap()),
192
+ requests: () => requestLedger(freshSnap()),
178
193
  },
179
194
  // Entry text is computed per request from the live registries and
180
195
  // never persisted — the audit answer to "what exactly is ~N tokens?".
@@ -219,7 +234,8 @@ function apply(ctx) {
219
234
  parameters: {
220
235
  view: {
221
236
  type: 'string',
222
- description: 'summary | deps | health | cost | shadow | skills (default summary)',
237
+ description:
238
+ 'summary | deps | health | cost | shadow | skills | requests (default summary)',
223
239
  },
224
240
  },
225
241
  output: {
@@ -233,11 +249,13 @@ function apply(ctx) {
233
249
  snap.sectionOwners = attribution ? Object.fromEntries(attribution.table) : {};
234
250
  snap.toolOwners = attribution ? Object.fromEntries(attribution.toolTable) : {};
235
251
  snap.skillCatalog = skillCatalog;
252
+ snap.requestLedger = ledger ? Object.fromEntries(ledger.sessions) : {};
236
253
  if (args.view === 'deps') return serviceGraph(snap);
237
254
  if (args.view === 'health') return health(snap);
238
255
  if (args.view === 'cost') return contextCost(snap);
239
256
  if (args.view === 'shadow') return shadowing(snap);
240
257
  if (args.view === 'skills') return skillCost(snap);
258
+ if (args.view === 'requests') return requestLedger(snap);
241
259
  return {
242
260
  plugins: snap.plugins.length,
243
261
  unhealthy: health(snap).unhealthy.length,
package/lib/model.js CHANGED
@@ -377,6 +377,41 @@ function skillCost(snap) {
377
377
  };
378
378
  }
379
379
 
380
+ /** F11: per-request ledger — one classified bill per LLM call, newest
381
+ * first, with prefix-stability and delta markers. The time axis the static
382
+ * cost view lacks. */
383
+ function requestLedger(snap) {
384
+ const observed = snap.requestLedger;
385
+ if (!observed || Object.keys(observed).length === 0)
386
+ return { available: false, sessions: [], capturedAt: snap.capturedAt };
387
+ const sessions = Object.entries(observed)
388
+ .map(([sessionId, entries]) => ({
389
+ sessionId,
390
+ lastAt: entries[entries.length - 1]?.at ?? 0,
391
+ requests: entries
392
+ .slice()
393
+ .reverse()
394
+ .map((e, i, arr) => ({
395
+ seq: entries.length - i,
396
+ at: e.at,
397
+ purpose: e.purpose,
398
+ provider: e.provider,
399
+ model: e.model,
400
+ total: e.total,
401
+ system: e.system,
402
+ toolSchemas: e.toolSchemas,
403
+ history: e.history,
404
+ toolResults: e.toolResults,
405
+ toolResultRows: e.toolResultRows,
406
+ historyMessages: e.historyMessages,
407
+ prefixStable: e.prefixStable,
408
+ deltaTotal: e.deltaTotal,
409
+ })),
410
+ }))
411
+ .sort((a, b) => b.lastAt - a.lastAt);
412
+ return { available: true, sessions, capturedAt: snap.capturedAt };
413
+ }
414
+
380
415
  module.exports = {
381
416
  replayLayers,
382
417
  attribute,
@@ -388,4 +423,5 @@ module.exports = {
388
423
  shadowing,
389
424
  contextCost,
390
425
  skillCost,
426
+ requestLedger,
391
427
  };
package/lib/panel.js CHANGED
@@ -38,7 +38,7 @@ const PAGE = `<!doctype html>
38
38
  <div id="status"></div>
39
39
  <div id="content"></div>
40
40
  <script>
41
- const views = ['summary', 'health', 'deps', 'cost', 'shadow', 'skills'];
41
+ const views = ['summary', 'health', 'deps', 'cost', 'shadow', 'skills', 'requests'];
42
42
  const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
43
43
  const escAttr = (s) => esc(s).replace(/"/g, '&quot;');
44
44
 
@@ -50,6 +50,7 @@ const INTRO = {
50
50
  cost: 'What every LLM request carries before your message: prompt sections + tool schemas, attributed to the plugin that registered each. "By plugin" is each plugin\\'s per-request context tax.',
51
51
  shadow: 'Same-name registrations. A service provided by two plugins means one silently wins — usually intended (an override), occasionally a conflict.',
52
52
  skills: 'What each skill costs: its catalog line rides every request once any model-invocable skill exists; its body is billed only when loaded. Pricing only — to enable/disable skills, use a skill manager.',
53
+ requests: 'One bill per LLM call, newest first: system + tool schemas + history + tool results. \u26a1 = the system/tools prefix matched the previous request (cache-friendly); \u2702 = the prefix changed. \u0394 is the total against the previous request.',
53
54
  };
54
55
 
55
56
  // Hover glossary: term -> plain-language meaning (native title tooltips).
@@ -69,6 +70,10 @@ const TIPS = {
69
70
  catalog: 'Tokens of this skill's line in the durable session catalog, carried on every request',
70
71
  body: 'Tokens of the full rendered skill body, billed per load and then resident in history',
71
72
  invocable: 'Whether the model may load this skill itself; user-only skills stay out of the catalog',
73
+ total: 'Estimated tokens of the whole request payload (~4 chars/token)',
74
+ history: 'Conversation messages (user + assistant), excluding tool results',
75
+ results: 'Tool-result messages, aggregated per tool below each row',
76
+ prefix: 'Whether system prompt + tool schemas were byte-identical to the previous request in this session',
72
77
  };
73
78
  const th = (h) => '<th' + (TIPS[h] ? ' title="' + escAttr(TIPS[h]) + '"' : '') + '>' + esc(h) + '</th>';
74
79
  const intro = (v) => '<p class="muted" style="margin:0 0 10px">' + INTRO[v] + '</p>';
@@ -181,6 +186,30 @@ const renderers = {
181
186
  + '</td><td>' + (s.modelInvocable ? '<span class="ok">yes</span>' : '<span class="muted">user-only</span>') + '</td></tr>'));
182
187
  return html;
183
188
  },
189
+ requests(d) {
190
+ if (!d.available) return '<p class="muted">no requests observed yet — send one agent message first</p>';
191
+ let html = '';
192
+ for (const session of d.sessions) {
193
+ html += '<h3 style="margin:12px 0 4px">session ' + esc(String(session.sessionId).slice(0, 12))
194
+ + ' <span class="muted">(' + session.requests.length + ' request(s))</span></h3>';
195
+ html += table(['#', 'when', 'purpose', 'total', 'system', 'tools', 'history', 'results', 'prefix', 'Δ'],
196
+ session.requests.map((r) => {
197
+ const when = new Date(r.at).toTimeString().slice(0, 8);
198
+ const purpose = r.purpose ?? 'chat';
199
+ const prefix = r.prefixStable === null ? '—' : r.prefixStable ? '⚡' : '✂';
200
+ const delta = r.deltaTotal === null ? '—'
201
+ : (r.deltaTotal >= 0 ? '+' : '') + r.deltaTotal;
202
+ const top = (r.toolResultRows ?? []).slice(0, 3).map((t) => esc(t.name) + ' ~' + t.tokens).join(', ');
203
+ return '<tr><td class="num">' + r.seq + '</td><td class="muted">' + when
204
+ + '</td><td>' + (purpose === 'chat' ? 'chat' : '<span class="muted">' + esc(purpose) + '</span>')
205
+ + '</td><td class="num">~' + r.total + '</td><td class="num">' + r.system
206
+ + '</td><td class="num">' + r.toolSchemas + '</td><td class="num">' + r.history
207
+ + '</td><td class="num" title="' + escAttr(top) + '">' + r.toolResults
208
+ + '</td><td>' + prefix + '</td><td class="num">' + delta + '</td></tr>';
209
+ }));
210
+ }
211
+ return html;
212
+ },
184
213
  };
185
214
 
186
215
  async function render() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-xray",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",