dsh-context 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 bowenliang123
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # dsh-context
2
+
3
+ A **Context insight panel** for [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness) (dsh): a plugin that adds a **Context** tab to the web UI — right beside **Chat** and **Trajectory** — so you can see what the model's context window is actually made of, and how it evolves across the conversation.
4
+
5
+ ![dsh-context screenshot](docs/screenshot.png)
6
+
7
+ ## Why
8
+
9
+ Every model request packs the same window from six sources: the system prompt, tool schemas, your messages, injected context (skills, AGENTS.md, runtime snapshots), assistant replies, and tool results. When a conversation degrades or gets compacted, *which part ate the budget* is usually invisible. dsh-context makes it observable:
10
+
11
+ - **Current composition** — a stacked bar of the six categories, scaled against the model's context window (the gray track is your remaining headroom), plus the top-5 most expensive tool schemas.
12
+ - **History** — one stacked bar per model request (finer than per-turn), with Y-axis ticks and gridlines. Click any bar for its full breakdown, including the **provider-reported** prompt/output tokens next to the estimate. ✂ marks where compaction/pruning happened — watch the bars drop.
13
+ - **Context events** — compactions, tool-output prunes, skill injections (`Skill injected (code-review)`), plugin context injections, model switches — each with its token delta and timestamp.
14
+ - **Messages** — the currently model-visible surface, message by message, with per-message token costs.
15
+
16
+ The UI is bilingual (中文/English) and follows the dsh locale automatically.
17
+
18
+ ## Install
19
+
20
+ dsh-context ships as a **dsh bundle**: an npm package with a `dsh.bundle` manifest (a `cordis.patch.yml` layer that inserts the plugin row) and a `dsh.client` manifest (the web UI half). No build step, no restart — install it into any profile:
21
+
22
+ ```sh
23
+ dsh plugin --profile <name> add dsh-context
24
+ dsh --profile <name> web
25
+ ```
26
+
27
+ That's it: the `dsh-context` loader row activates the host half, and the web app picks up the package's `./client` bundle and adds the **上下文 / Context** tab to every session view.
28
+
29
+ To install from this checkout instead (for development), from the repo root:
30
+
31
+ ```sh
32
+ dsh plugin --profile <name> add .
33
+ ```
34
+
35
+ If dsh is run from a source checkout, prefix the commands with `pnpm` (`pnpm dsh plugin ...`).
36
+
37
+ ## Usage
38
+
39
+ Open any session and click **上下文 / Context** (to the right of Chat and Trajectory). Data refreshes every 2 seconds while the tab is open; switching sessions switches the view to that session's log — including historical, persisted sessions.
40
+
41
+ - **Hover** a history bar for a quick tooltip; **click** it to pin the breakdown below the chart.
42
+ - The overview bar is scaled to the model's context window, so ~13% full means ~13% of the window is spoken for.
43
+ - Numbers are estimates using the *same fixed-density heuristic as dsh's built-in tokenMeter* (~4 chars ≈ 1 token), so they match the harness's own stats. Wherever the provider reported real usage, it's shown alongside as "actual".
44
+
45
+ ## How it works
46
+
47
+ - **Data source**: the session's durable event log. Live sessions are folded straight from the in-memory log (`sessions.get(id).events` — no clone, no disk parse); persisted sessions fall back to `sessionQuery.readSession`.
48
+ - **Transport**: host ↔ browser over a generic **Connection RPC channel** (`/dsh-context`, `ctx.connection.rpc` — the same channel mechanism the api gateway uses). The host half registers a `snapshot` endpoint; the client half calls it via `ctx.connection.rpc.call`.
49
+ - **Incremental fold**: per-session fold state lives in the Host half, so each poll only processes newly appended events — reopening the tab is instant.
50
+ - **Events decoded**: `request/header` (system prompt + tool schemas), surface events with `surfaceOp` (append/replace — compaction rewrites history in place), `compaction/summary|prune`, `assistant/message.usage` (real provider tokens), and message `source` metadata (`plugin` forms, `skill-invocation`) for injection events.
51
+ - **Architecture**: `src/host.js` is a plain ESM Cordis plugin (zero dependencies) loaded by the `dsh-context` loader row; `src/client.js` is the browser half, wrapped at build time into the web boot's closure-factory bundle (`window.__ModuleLoader__.load`). The client renders with bare `React.createElement` — theme-native via dsh CSS variables, bilingual via the client `locale` service.
52
+
53
+ ## Development
54
+
55
+ ```sh
56
+ node scripts/build.mjs # writes lib/index.js (host) + lib/client.js (client bundle)
57
+ ```
58
+
59
+ `build.mjs` also smoke-checks the outputs (both halves must parse; the host half must import with the `name`/`inject`/`apply` plugin shape).
60
+
61
+ ## Publishing
62
+
63
+ `scripts/publish.sh` builds and publishes to npm as the authenticated user:
64
+
65
+ ```sh
66
+ NPM_TOKEN=<your npmjs access token> ./scripts/publish.sh # patch bump if the current version is already published
67
+ NPM_TOKEN=<token> ./scripts/publish.sh minor # explicit bump level
68
+ ```
69
+
70
+ The token is read from the environment only and never written to disk. The script auto-bumps the version when the current one is already on the registry, so re-running it after a successful publish is a no-op.
71
+
72
+ ## Files
73
+
74
+ | File | Role |
75
+ | --- | --- |
76
+ | `src/host.js` | Host half: incremental log fold, category accounting, `/dsh-context` snapshot RPC |
77
+ | `src/client.js` | Client half: tab registration, bilingual chart UI |
78
+ | `package.json` | `dsh.bundle` (patch layer) + `dsh.client` (web UI) manifests |
79
+ | `cordis.patch.yml` | The bundle's patch layer: inserts the `dsh-context` row |
80
+ | `scripts/build.mjs` | Zero-dependency build of `lib/index.js` + `lib/client.js` |
81
+ | `scripts/publish.sh` | npm publish with `NPM_TOKEN` |
82
+ | `docs/screenshot.png` | The UI in action |
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,10 @@
1
+ # dsh-context — bundle patch layer.
2
+ #
3
+ # Installing this package into a profile (`dsh plugin --profile <name> add dsh-context`)
4
+ # appends it to `dsh.profile.bundles`; this patch is then applied as that
5
+ # bundle's layer. The row below loads the package's main entry as the host
6
+ # half (a plain Cordis plugin) and, because the package declares `dsh.client`,
7
+ # the web app also loads its `./client` bundle as the browser half.
8
+ - insert:
9
+ - id: dsh-context
10
+ name: dsh-context
package/lib/client.js ADDED
@@ -0,0 +1,521 @@
1
+ /* dsh-context client bundle — generated by scripts/build.mjs from src/client.js */
2
+ window.__ModuleLoader__.load({
3
+ id: "dsh-context",
4
+ factory: (require) => {
5
+ var module = { exports: {} };
6
+ var exports = module.exports;
7
+ /**
8
+ * dsh-context — Client half (installed package bundle).
9
+ *
10
+ * Registers a "上下文/Context" tab in the conversation view ring
11
+ * (`conversation.view` slot, beside Chat/Trajectory) and renders the
12
+ * context-composition timeline served by the Host half over the generic
13
+ * Connection RPC channel `/dsh-context`: current makeup, per-request
14
+ * stacked-bar history, context events, and the live message list.
15
+ *
16
+ * This file is the body of the package's `./client` bundle: build.mjs wraps
17
+ * it into the web boot handoff (`window.__ModuleLoader__.load({id, factory})`),
18
+ * so it runs inside the browser module table. React arrives via the injected
19
+ * `require` (a platform seed word), UI text is bilingual (zh/en) through the
20
+ * client `locale` service, and the Host sends structured event/node records
21
+ * which this half localizes.
22
+ */
23
+
24
+ var React = require('react')
25
+ var h = React.createElement
26
+
27
+ var DICT_ZH = {
28
+ 'tab': '上下文',
29
+ 'cat.system': '系统提示', 'cat.tools': '工具定义', 'cat.user': '用户消息',
30
+ 'cat.inject': '注入上下文', 'cat.assistant': '助手回复', 'cat.tool': '工具结果',
31
+ 'overview.title': '当前构成',
32
+ 'overview.ofWindow': 'tokens(约 {p}%)',
33
+ 'overview.estimate': 'tokens(估算)',
34
+ 'tools.top': '工具定义 Top:',
35
+ 'tools.more': '等 {n} 个',
36
+ 'trend.title': '历史趋势',
37
+ 'trend.hint': '每次模型请求一段;点击柱子查看详情,✂ 表示压缩/剪枝',
38
+ 'trend.empty': '发起一轮对话后,这里会展示每次模型请求的上下文构成',
39
+ 'detail.step': 'T{t} · 第 {s} 步',
40
+ 'detail.estTotal': '估算合计 ≈ {n}',
41
+ 'detail.actual': '实际 prompt {n}',
42
+ 'detail.output': '输出 {n}',
43
+ 'events.title': '上下文事件',
44
+ 'events.empty': '暂无上下文事件(压缩、注入、模型切换会出现在这里)',
45
+ 'nodes.title': '消息构成',
46
+ 'nodes.hint': '当前模型可见的消息,最新在前',
47
+ 'nodes.more': '… 更早的 {n} 条消息已省略',
48
+ 'nodes.empty': '当前没有模型可见的消息',
49
+ 'loading': '正在读取会话日志…',
50
+ 'error': '上下文数据读取失败:',
51
+ 'footer': '估算口径:与 dsh 内置 tokenMeter 相同的固定密度启发式(约 4 字符 ≈ 1 token);「实际」为供应商上报用量。',
52
+ 'tip.step': 'T{t} · 第{s}步',
53
+ 'tip.total': '合计 ≈ {n}',
54
+ 'tip.actual': '(实际 {n})',
55
+ 'ev.compaction': '压缩上下文(摘要替换 {n} 条消息)',
56
+ 'ev.prune': '剪枝工具输出',
57
+ 'ev.skill': 'Skill 注入({name})',
58
+ 'ev.model': '模型切换:{a} → {b}',
59
+ 'form.instructions': '指令注入', 'form.catalog': '目录更新', 'form.snapshot': '状态快照',
60
+ 'form.notice': '通知', 'form.relay': '代理转发', 'form.recall': '历史召回', 'form.context': '上下文注入',
61
+ 'node.toolResult': '工具结果',
62
+ 'node.calls': '调用 ',
63
+ 'node.empty': '(空回复)',
64
+ 'node.nonText': '(非文本消息)',
65
+ 'node.snapshot': '快照: ',
66
+ }
67
+
68
+ var DICT_EN = {
69
+ 'tab': 'Context',
70
+ 'cat.system': 'System', 'cat.tools': 'Tool schemas', 'cat.user': 'User',
71
+ 'cat.inject': 'Injected', 'cat.assistant': 'Assistant', 'cat.tool': 'Tool results',
72
+ 'overview.title': 'Current composition',
73
+ 'overview.ofWindow': 'tokens (~{p}%)',
74
+ 'overview.estimate': 'tokens (estimated)',
75
+ 'tools.top': 'Top tool schemas:',
76
+ 'tools.more': 'of {n}',
77
+ 'trend.title': 'History',
78
+ 'trend.hint': 'one bar per model request; click a bar for details, ✂ marks compaction/prune',
79
+ 'trend.empty': 'Send a message and each model request’s context makeup shows up here',
80
+ 'detail.step': 'T{t} · step {s}',
81
+ 'detail.estTotal': 'estimated ≈ {n}',
82
+ 'detail.actual': 'actual prompt {n}',
83
+ 'detail.output': 'output {n}',
84
+ 'events.title': 'Context events',
85
+ 'events.empty': 'No context events yet (compaction, injections, model switches appear here)',
86
+ 'nodes.title': 'Messages',
87
+ 'nodes.hint': 'currently model-visible, newest first',
88
+ 'nodes.more': '… {n} earlier messages omitted',
89
+ 'nodes.empty': 'No model-visible messages right now',
90
+ 'loading': 'Reading the session log…',
91
+ 'error': 'Failed to read context data: ',
92
+ 'footer': 'Estimate: same fixed-density heuristic as dsh’s built-in tokenMeter (~4 chars ≈ 1 token); “actual” is provider-reported usage.',
93
+ 'tip.step': 'T{t} · step {s}',
94
+ 'tip.total': 'total ≈ {n}',
95
+ 'tip.actual': ' (actual {n})',
96
+ 'ev.compaction': 'Context compacted (summary replaced {n} messages)',
97
+ 'ev.prune': 'Tool output pruned',
98
+ 'ev.skill': 'Skill injected ({name})',
99
+ 'ev.model': 'Model switched: {a} → {b}',
100
+ 'form.instructions': 'Instructions', 'form.catalog': 'Catalog update', 'form.snapshot': 'State snapshot',
101
+ 'form.notice': 'Notice', 'form.relay': 'Agent relay', 'form.recall': 'Recall', 'form.context': 'Context injection',
102
+ 'node.toolResult': 'Tool result',
103
+ 'node.calls': 'calls ',
104
+ 'node.empty': '(empty reply)',
105
+ 'node.nonText': '(non-text message)',
106
+ 'node.snapshot': 'snapshot: ',
107
+ }
108
+
109
+ var EVENT_ICONS = { compaction: '✂', prune: '✂', inject: '+', model: '⇄' }
110
+
111
+ function fmt(n) {
112
+ if (n === undefined || n === null || isNaN(n)) return '—'
113
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
114
+ return String(Math.round(n))
115
+ }
116
+
117
+ function fmtTime(t) {
118
+ var d = new Date(t)
119
+ function p(x) { return (x < 10 ? '0' : '') + x }
120
+ return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
121
+ }
122
+
123
+ function makeView(ctx, t) {
124
+ function tr(key, vars) {
125
+ return t(key, vars)
126
+ }
127
+
128
+ var CATS = [
129
+ { key: 'system', color: '#6366f1' },
130
+ { key: 'tools', color: '#f59e0b' },
131
+ { key: 'user', color: '#22c55e' },
132
+ { key: 'inject', color: '#a855f7' },
133
+ { key: 'assistant', color: '#3b82f6' },
134
+ { key: 'tool', color: '#14b8a6' },
135
+ ]
136
+
137
+ function catLabel(key) { return t('cat.' + key) }
138
+
139
+ function eventLabel(ev) {
140
+ if (ev.kind === 'compaction') return tr('ev.compaction', { n: ev.count || 0 })
141
+ if (ev.kind === 'prune') return t('ev.prune')
142
+ if (ev.kind === 'model') return tr('ev.model', { a: ev.from || '?', b: ev.to || '?' })
143
+ if (ev.kind === 'inject') {
144
+ if (ev.sub === 'skill') return tr('ev.skill', { name: ev.name || '?' })
145
+ var base = t('form.' + (ev.form || 'context'))
146
+ return ev.name ? base + ' · ' + ev.name : base
147
+ }
148
+ return ev.kind
149
+ }
150
+
151
+ function nodeText(n) {
152
+ if (n.cat === 'tool') {
153
+ return t('node.toolResult') + (n.tool ? ' ← ' + n.tool : '') + (n.err ? ' ⚠' : '')
154
+ }
155
+ if (n.skill) return 'Skill: ' + n.skill
156
+ if (n.calls) return t('node.calls') + n.calls.join(', ')
157
+ if (n.text) return n.form === 'snapshot' ? t('node.snapshot') + n.text : n.text
158
+ if (n.cat === 'assistant') return t('node.empty')
159
+ if (n.cat === 'inject') return t('form.' + (n.form || 'context'))
160
+ return t('node.nonText')
161
+ }
162
+
163
+ function StackedBar(props) {
164
+ // props.parts: [{key,color,value}]; optional props.max: when max exceeds
165
+ // the parts' total, the remainder shows as empty track.
166
+ var total = 0
167
+ for (var i = 0; i < props.parts.length; i++) total += props.parts[i].value
168
+ var scale = props.max !== undefined && props.max > total ? props.max : total
169
+ return h('div', { className: 'lc-stacked', style: { height: (props.height || 14) + 'px' } },
170
+ total <= 0
171
+ ? null
172
+ : props.parts.map(function (p) {
173
+ if (!p.value) return null
174
+ return h('div', {
175
+ key: p.key,
176
+ title: catLabel(p.key) + ' ' + fmt(p.value) + ' (' + Math.round(p.value / total * 100) + '%)',
177
+ style: { width: (p.value / scale * 100) + '%', background: p.color },
178
+ })
179
+ }))
180
+ }
181
+
182
+ function Legend(props) {
183
+ var total = 0
184
+ for (var i = 0; i < props.parts.length; i++) total += props.parts[i].value
185
+ return h('div', { className: 'lc-legend' },
186
+ props.parts.map(function (p) {
187
+ return h('span', { key: p.key, className: 'lc-chip' },
188
+ h('i', { style: { background: p.color } }),
189
+ catLabel(p.key) + ' ' + fmt(p.value),
190
+ total > 0 ? h('em', null, Math.round(p.value / total * 100) + '%') : null)
191
+ }))
192
+ }
193
+
194
+ function partsOf(breakdown) {
195
+ return CATS.map(function (c) {
196
+ return { key: c.key, color: c.color, value: breakdown[c.key] || 0 }
197
+ })
198
+ }
199
+
200
+ // Plot height in px (the marker lane above it is 18px).
201
+ var CHART_H = 112
202
+
203
+ function TrendChart(props) {
204
+ var requests = props.requests
205
+ var maxTotal = 1
206
+ for (var i = 0; i < requests.length; i++) if (requests[i].total > maxTotal) maxTotal = requests[i].total
207
+
208
+ // Compaction/prune markers: attach each to the first request logged after it.
209
+ var markers = {}
210
+ for (var m = 0; m < props.events.length; m++) {
211
+ var ev = props.events[m]
212
+ if (ev.kind !== 'compaction' && ev.kind !== 'prune') continue
213
+ for (var r = 0; r < requests.length; r++) {
214
+ if (requests[r].seq >= ev.seq) {
215
+ if (!markers[r]) markers[r] = ev
216
+ break
217
+ }
218
+ }
219
+ }
220
+
221
+ return h('div', { className: 'lc-chartrow' },
222
+ h('div', { className: 'lc-axis' },
223
+ h('span', { className: 'lc-axis-top' }, fmt(maxTotal)),
224
+ h('span', { className: 'lc-axis-mid' }, fmt(Math.round(maxTotal / 2))),
225
+ h('span', { className: 'lc-axis-bot' }, '0')),
226
+ h('div', { className: 'lc-chart' },
227
+ h('div', { className: 'lc-grid lc-grid-top' }),
228
+ h('div', { className: 'lc-grid lc-grid-mid' }),
229
+ requests.map(function (req, i) {
230
+ var selected = props.selectedSeq === req.seq
231
+ var tip = tr('tip.step', { t: req.turn, s: req.step }) + ' · ' + fmtTime(req.time) + '\n'
232
+ + tr('tip.total', { n: fmt(req.total) })
233
+ + (req.prompt !== undefined ? tr('tip.actual', { n: fmt(req.prompt) }) : '') + '\n'
234
+ + CATS.map(function (c) { return catLabel(c.key) + ' ' + fmt(req[c.key] || 0) }).join(' / ')
235
+ return h('div', {
236
+ key: req.seq,
237
+ className: 'lc-bar' + (selected ? ' lc-bar-selected' : ''),
238
+ title: tip,
239
+ onClick: function () { props.onSelect(selected ? null : req.seq) },
240
+ },
241
+ markers[i] ? h('span', { className: 'lc-bar-marker', title: eventLabel(markers[i]) }, '✂') : null,
242
+ h('div', { className: 'lc-bar-stack' },
243
+ CATS.map(function (c) {
244
+ var v = req[c.key] || 0
245
+ if (!v) return null
246
+ // px heights: the stack's height is content-driven, so
247
+ // percentage heights would collapse against an indefinite base.
248
+ return h('div', { key: c.key, style: { height: Math.max(1, Math.round(v / maxTotal * CHART_H)) + 'px', background: c.color } })
249
+ })))
250
+ })))
251
+ }
252
+
253
+ function RequestDetail(props) {
254
+ var req = props.request
255
+ if (!req) return null
256
+ return h('div', { className: 'lc-detail' },
257
+ h('div', { className: 'lc-detail-head' },
258
+ h('b', null, tr('detail.step', { t: req.turn, s: req.step })),
259
+ h('span', null, fmtTime(req.time)),
260
+ h('span', null, tr('detail.estTotal', { n: fmt(req.total) })),
261
+ req.prompt !== undefined ? h('span', { className: 'lc-actual' }, tr('detail.actual', { n: fmt(req.prompt) })) : null,
262
+ req.output !== undefined ? h('span', null, tr('detail.output', { n: fmt(req.output) })) : null),
263
+ h(StackedBar, { parts: partsOf(req), height: 10 }),
264
+ h('div', { className: 'lc-detail-rows' },
265
+ CATS.map(function (c) {
266
+ var v = req[c.key] || 0
267
+ return h('div', { key: c.key, className: 'lc-detail-row' },
268
+ h('i', { style: { background: c.color } }),
269
+ h('span', { className: 'lc-detail-label' }, catLabel(c.key)),
270
+ h('span', { className: 'lc-bar-track' },
271
+ h('span', { className: 'lc-bar-fill', style: { width: (req.total > 0 ? v / req.total * 100 : 0) + '%', background: c.color } })),
272
+ h('span', { className: 'lc-detail-num' }, fmt(v)),
273
+ h('span', { className: 'lc-detail-pct' }, req.total > 0 ? Math.round(v / req.total * 100) + '%' : ''))
274
+ })))
275
+ }
276
+
277
+ function EventList(props) {
278
+ if (props.events.length === 0) {
279
+ return h('div', { className: 'lc-empty' }, t('events.empty'))
280
+ }
281
+ var sorted = props.events.slice().reverse()
282
+ return h('div', { className: 'lc-events' },
283
+ sorted.map(function (ev, i) {
284
+ var label = eventLabel(ev)
285
+ return h('div', { key: ev.seq + '-' + i, className: 'lc-event' },
286
+ h('span', { className: 'lc-event-icon lc-event-' + ev.kind }, EVENT_ICONS[ev.kind] || '•'),
287
+ h('span', { className: 'lc-event-label', title: label }, label),
288
+ ev.tokens ? h('span', { className: 'lc-event-tokens' + (ev.kind === 'inject' ? ' lc-up' : ' lc-down') },
289
+ (ev.kind === 'inject' ? '+' : '−') + fmt(ev.tokens)) : null,
290
+ h('span', { className: 'lc-event-time' }, fmtTime(ev.time)))
291
+ }))
292
+ }
293
+
294
+ function NodeList(props) {
295
+ if (props.nodes.length === 0) {
296
+ return h('div', { className: 'lc-empty' }, t('nodes.empty'))
297
+ }
298
+ var catColor = {}
299
+ CATS.forEach(function (c) { catColor[c.key] = c.color })
300
+ var rows = props.nodes.slice().reverse()
301
+ return h('div', { className: 'lc-nodes' },
302
+ props.dropped > 0 ? h('div', { className: 'lc-nodes-more' }, tr('nodes.more', { n: props.dropped })) : null,
303
+ rows.map(function (n) {
304
+ var text = nodeText(n)
305
+ return h('div', { key: n.seq, className: 'lc-node' },
306
+ h('i', { style: { background: catColor[n.cat] || '#999' } }),
307
+ h('span', { className: 'lc-node-preview', title: text }, text),
308
+ h('span', { className: 'lc-node-tokens' }, fmt(n.tokens)))
309
+ }))
310
+ }
311
+
312
+ function ContextView(props) {
313
+ var sessionId = props.sessionId
314
+ var state = React.useState(null)
315
+ var data = state[0]
316
+ var setData = state[1]
317
+ var errState = React.useState(null)
318
+ var error = errState[0]
319
+ var setError = errState[1]
320
+ var selState = React.useState(null)
321
+ var selectedSeq = selState[0]
322
+ var setSelectedSeq = selState[1]
323
+ var tickState = React.useState(0)
324
+ var setTick = tickState[1]
325
+
326
+ React.useEffect(function () {
327
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
328
+ var alive = true
329
+ var load = function () {
330
+ // Generic Connection RPC channel served by the Host half.
331
+ ctx.connection.rpc.call('/dsh-context', 'snapshot', { sessionId: sessionId }).then(function (res) {
332
+ if (!alive) return
333
+ if (res && res.ok) { setData(res.value); setError(null) }
334
+ else setError(res && res.error ? String(res.error.message || res.error.code) : 'failed')
335
+ }, function (err) {
336
+ if (alive) setError(String(err && err.message ? err.message : err))
337
+ })
338
+ }
339
+ load()
340
+ var timerId = setInterval(load, 2000)
341
+ return function () { alive = false; clearInterval(timerId) }
342
+ }, [sessionId])
343
+
344
+ // Re-render on locale switch.
345
+ React.useEffect(function () {
346
+ var localeSvc = ctx.get('locale')
347
+ if (!localeSvc) return undefined
348
+ return localeSvc.subscribe(function () { setTick(function (x) { return x + 1 }) })
349
+ }, [])
350
+
351
+ if (error) {
352
+ return h('div', { className: 'lc-root' }, h('div', { className: 'lc-empty' }, t('error') + error))
353
+ }
354
+ if (!data) {
355
+ return h('div', { className: 'lc-root' }, h('div', { className: 'lc-empty' }, t('loading')))
356
+ }
357
+
358
+ var current = data.current
359
+ var requests = data.requests || []
360
+ var events = data.events || []
361
+ var nodes = data.nodes || []
362
+
363
+ var selReq = null
364
+ for (var i = 0; i < requests.length; i++) if (requests[i].seq === selectedSeq) selReq = requests[i]
365
+ if (!selReq && requests.length > 0) selReq = requests[requests.length - 1]
366
+
367
+ var windowPct = data.contextWindow ? Math.min(100, Math.round(current.total / data.contextWindow * 100)) : null
368
+
369
+ return h('div', { className: 'lc-root' },
370
+
371
+ // ---- overview ----
372
+ h('div', { className: 'lc-card' },
373
+ h('div', { className: 'lc-card-title' },
374
+ t('overview.title'),
375
+ h('span', { className: 'lc-card-sub' },
376
+ (data.model ? data.model : '') + (data.provider ? ' · ' + data.provider : ''))),
377
+ h('div', { className: 'lc-overview-num' },
378
+ h('b', null, fmt(current.total)),
379
+ h('span', null, data.contextWindow
380
+ ? ' / ' + fmt(data.contextWindow) + ' ' + tr('overview.ofWindow', { p: windowPct })
381
+ : ' ' + t('overview.estimate'))),
382
+ h(StackedBar, { parts: partsOf(current), height: 16, max: data.contextWindow }),
383
+ h(Legend, { parts: partsOf(current) }),
384
+ (data.toolList && data.toolList.length > 0) ? h('div', { className: 'lc-tools' },
385
+ t('tools.top'),
386
+ data.toolList.slice().sort(function (a, b) { return b.tokens - a.tokens }).slice(0, 5).map(function (tool) {
387
+ return h('span', { key: tool.name, className: 'lc-tool-chip' }, tool.name + ' ' + fmt(tool.tokens))
388
+ }),
389
+ data.toolList.length > 5 ? h('span', { className: 'lc-card-sub' }, ' ' + tr('tools.more', { n: data.toolList.length })) : null) : null),
390
+
391
+ // ---- trend ----
392
+ h('div', { className: 'lc-card' },
393
+ h('div', { className: 'lc-card-title' },
394
+ t('trend.title'),
395
+ h('span', { className: 'lc-card-sub' }, t('trend.hint'))),
396
+ requests.length === 0
397
+ ? h('div', { className: 'lc-empty' }, t('trend.empty'))
398
+ : h('div', null,
399
+ h(TrendChart, { requests: requests.slice(-80), events: events, selectedSeq: selReq ? selReq.seq : null, onSelect: setSelectedSeq }),
400
+ h(RequestDetail, { request: selReq }))),
401
+
402
+ // ---- events + messages ----
403
+ h('div', { className: 'lc-cols' },
404
+ h('div', { className: 'lc-card lc-col' },
405
+ h('div', { className: 'lc-card-title' }, t('events.title')),
406
+ h(EventList, { events: events })),
407
+ h('div', { className: 'lc-card lc-col' },
408
+ h('div', { className: 'lc-card-title' },
409
+ t('nodes.title'),
410
+ h('span', { className: 'lc-card-sub' }, t('nodes.hint'))),
411
+ h(NodeList, { nodes: nodes, dropped: data.droppedNodes || 0 }))),
412
+
413
+ h('div', { className: 'lc-foot' }, t('footer')))
414
+ }
415
+
416
+ return ContextView
417
+ }
418
+
419
+ var STYLES = [
420
+ '.lc-root { padding: 16px 20px 32px; overflow-y: auto; height: 100%; box-sizing: border-box; color: var(--dsw-alias-label-primary); font-size: 13px; }',
421
+ '.lc-card { background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; padding: 14px 16px; margin-bottom: 14px; }',
422
+ '.lc-card-title { font-weight: 600; margin-bottom: 10px; display: flex; align-items: baseline; gap: 8px; }',
423
+ '.lc-card-sub { font-weight: 400; color: var(--dsw-alias-label-secondary); font-size: 12px; }',
424
+ '.lc-overview-num { margin-bottom: 8px; }',
425
+ '.lc-overview-num b { font-size: 20px; }',
426
+ '.lc-overview-num span { color: var(--dsw-alias-label-secondary); }',
427
+ '.lc-stacked { display: flex; width: 100%; border-radius: 5px; overflow: hidden; background: rgba(128,128,128,0.18); }',
428
+ '.lc-stacked > div { height: 100%; }',
429
+ '.lc-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 10px; }',
430
+ '.lc-chip { display: inline-flex; align-items: center; gap: 5px; color: var(--dsw-alias-label-primary); }',
431
+ '.lc-chip i, .lc-detail-row i, .lc-node i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }',
432
+ '.lc-chip em { font-style: normal; color: var(--dsw-alias-label-secondary); }',
433
+ '.lc-tools { margin-top: 10px; color: var(--dsw-alias-label-secondary); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }',
434
+ '.lc-tool-chip { background: var(--dsw-alias-bg-layer-2); border-radius: 4px; padding: 1px 7px; font-size: 12px; color: var(--dsw-alias-label-primary); }',
435
+ '.lc-chartrow { display: flex; gap: 6px; align-items: stretch; }',
436
+ '.lc-axis { position: relative; width: 40px; height: 130px; padding-top: 18px; box-sizing: border-box; color: var(--dsw-alias-label-secondary); font-size: 11px; }',
437
+ '.lc-axis span { position: absolute; right: 0; line-height: 1; }',
438
+ '.lc-axis-top { top: 13px; }',
439
+ '.lc-axis-mid { top: 69px; }',
440
+ '.lc-axis-bot { top: 125px; }',
441
+ '.lc-chart { position: relative; flex: 1; display: flex; align-items: flex-end; gap: 2px; height: 130px; padding-top: 18px; box-sizing: border-box; }',
442
+ '.lc-grid { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--dsw-alias-border-l1); pointer-events: none; }',
443
+ '.lc-grid-top { top: 18px; }',
444
+ '.lc-grid-mid { top: 74px; }',
445
+ '.lc-bar { position: relative; flex: 1; min-width: 5px; height: 100%; display: flex; align-items: flex-end; cursor: pointer; border-radius: 2px; }',
446
+ '.lc-bar:hover { background: var(--dsw-alias-bg-layer-2); }',
447
+ '.lc-bar-selected { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }',
448
+ '.lc-bar-stack { display: flex; flex-direction: column-reverse; width: 100%; }',
449
+ '.lc-bar-stack > div { width: 100%; }',
450
+ '.lc-bar-marker { position: absolute; top: -16px; left: 50%; transform: translateX(-50%); font-size: 11px; color: var(--dsw-alias-state-warn-primary); }',
451
+ '.lc-detail { margin-top: 12px; border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 12px; }',
452
+ '.lc-detail-head { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-bottom: 8px; color: var(--dsw-alias-label-secondary); }',
453
+ '.lc-detail-head b { color: var(--dsw-alias-label-primary); }',
454
+ '.lc-detail-head .lc-actual { color: var(--dsw-alias-state-success-primary); }',
455
+ '.lc-detail-rows { margin-top: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; }',
456
+ '.lc-detail-row { display: flex; align-items: center; gap: 8px; }',
457
+ '.lc-detail-label { min-width: 70px; white-space: nowrap; color: var(--dsw-alias-label-secondary); }',
458
+ '.lc-bar-track { flex: 1; height: 5px; border-radius: 3px; background: rgba(128,128,128,0.18); overflow: hidden; display: block; }',
459
+ '.lc-bar-fill { display: block; height: 100%; border-radius: 3px; }',
460
+ '.lc-detail-num { width: 44px; text-align: right; }',
461
+ '.lc-detail-pct { width: 34px; text-align: right; color: var(--dsw-alias-label-secondary); }',
462
+ '.lc-cols { display: flex; gap: 14px; flex-wrap: wrap; }',
463
+ '.lc-col { flex: 1; min-width: 280px; }',
464
+ '.lc-events, .lc-nodes { display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow-y: auto; }',
465
+ '.lc-event { display: flex; align-items: center; gap: 8px; padding: 3px 0; }',
466
+ '.lc-event-icon { width: 18px; text-align: center; color: var(--dsw-alias-state-warn-primary); }',
467
+ '.lc-event-icon.lc-event-inject { color: #a855f7; }',
468
+ '.lc-event-icon.lc-event-model { color: var(--dsw-alias-brand-primary); }',
469
+ '.lc-event-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
470
+ '.lc-event-tokens { color: var(--dsw-alias-state-success-primary); }',
471
+ '.lc-event-tokens.lc-up { color: var(--dsw-alias-state-warn-primary); }',
472
+ '.lc-event-time { color: var(--dsw-alias-label-secondary); font-size: 12px; }',
473
+ '.lc-node { display: flex; align-items: center; gap: 8px; padding: 3px 0; }',
474
+ '.lc-node-preview { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); }',
475
+ '.lc-node-tokens { color: var(--dsw-alias-label-secondary); }',
476
+ '.lc-nodes-more { color: var(--dsw-alias-label-secondary); padding: 3px 0; }',
477
+ '.lc-empty { color: var(--dsw-alias-label-secondary); padding: 18px 0; text-align: center; }',
478
+ '.lc-foot { color: var(--dsw-alias-label-secondary); font-size: 12px; margin-top: 4px; }',
479
+ ].join('\n')
480
+
481
+ function apply(ctx) {
482
+ // Bilingual dictionaries; the tab label thunk and all UI text follow the
483
+ // active locale through the bound translate (missing keys fall back to
484
+ // zh, then the key itself). The registration rides ctx.effect, so a stop
485
+ // or HMR reload disposes it.
486
+ ctx.effect(function () {
487
+ return ctx.locale.register('dsh-context', { zh: DICT_ZH, en: DICT_EN })
488
+ }, 'dsh-context: dictionaries')
489
+ var t = ctx.locale.bind('dsh-context')
490
+
491
+ // Theme-native styles, injected as a plugin-owned <style> tag (the web
492
+ // boot loader claims and removes tags carrying data-plugin on unload).
493
+ ctx.effect(function () {
494
+ var tag = document.createElement('style')
495
+ tag.setAttribute('data-plugin', 'dsh-context')
496
+ tag.textContent = STYLES
497
+ document.head.appendChild(tag)
498
+ return function () {
499
+ if (tag.parentNode !== null) tag.parentNode.removeChild(tag)
500
+ }
501
+ }, 'dsh-context: styles')
502
+
503
+ var ContextView = makeView(ctx, t)
504
+ ctx.slots.inject('conversation.view', function () {
505
+ return ctx.slots.register(
506
+ // order 20 renders right of Chat (0) and Trajectory (10).
507
+ { name: 'conversation.view', id: 'context', order: 20, label: function () { return t('tab') } },
508
+ function (props) { return h(ContextView, props) },
509
+ )
510
+ })
511
+ }
512
+
513
+ module.exports = {
514
+ name: 'dsh-context',
515
+ inject: ['connection', 'slots', 'locale'],
516
+ apply: apply,
517
+ }
518
+
519
+ return module.exports;
520
+ },
521
+ });
package/lib/index.js ADDED
@@ -0,0 +1,377 @@
1
+ /**
2
+ * dsh-context — Host half (installed package entry).
3
+ *
4
+ * A plain Cordis plugin module (ESM, zero dependencies) loaded by the harness
5
+ * as the `dsh-context` loader row. It replays a session's durable event log
6
+ * into a per-request context-composition timeline and serves it to the
7
+ * Client half over a generic Connection RPC channel (`/dsh-context`).
8
+ *
9
+ * Performance: live sessions are folded straight from the in-memory log
10
+ * (`sessions.get(id).events` — no clone, no parse) and the fold is
11
+ * INCREMENTAL: per-session state advances only over newly appended events.
12
+ * Cold (persisted, not live) sessions fall back to `sessionQuery` and are
13
+ * served from cache once folded, since their logs never grow.
14
+ *
15
+ * Token figures use the same fixed-density heuristic as the harness's own
16
+ * token-meter (4 chars ≈ 1 token, +4 per content block, +4 role framing).
17
+ * Labels are sent structured (kind/form/name/count) so the Client localizes.
18
+ */
19
+
20
+ export const name = 'dsh-context'
21
+
22
+ /** Required services: the generic Connection RPC registry (host half). */
23
+ export const inject = ['connection']
24
+
25
+ // ---- harness token-meter heuristic (mirrors dsh-token-meter/estimate.ts) ----
26
+ var CHARS_PER_TOKEN = 4
27
+ var BLOCK_OVERHEAD = 4
28
+ var ROLE_OVERHEAD = 4
29
+
30
+ function estimateBlocks(blocks) {
31
+ var tokens = 0
32
+ if (!Array.isArray(blocks)) return 0
33
+ for (var i = 0; i < blocks.length; i++) {
34
+ var block = blocks[i]
35
+ if (block === null || typeof block !== 'object') continue
36
+ switch (block.type) {
37
+ case 'text':
38
+ case 'reasoning':
39
+ tokens += Math.ceil(String(block.text || '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
40
+ break
41
+ case 'tool-call':
42
+ tokens += Math.ceil(String(block.name || '').length / CHARS_PER_TOKEN)
43
+ + Math.ceil(String(block.arguments || '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
44
+ break
45
+ case 'tool-result':
46
+ tokens += estimateBlocks(block.content) + BLOCK_OVERHEAD
47
+ break
48
+ default:
49
+ tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
50
+ }
51
+ }
52
+ return tokens
53
+ }
54
+
55
+ function estimateMessage(message) {
56
+ return estimateBlocks(message && message.content) + ROLE_OVERHEAD
57
+ }
58
+
59
+ function estimateSystem(text) {
60
+ if (typeof text !== 'string' || text.length === 0) return 0
61
+ return Math.ceil(text.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
62
+ }
63
+
64
+ function estimateToolSchema(tool) {
65
+ return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
66
+ }
67
+
68
+ // ---- content extraction -----------------------------------------------------
69
+
70
+ function firstText(blocks) {
71
+ if (!Array.isArray(blocks)) return ''
72
+ for (var i = 0; i < blocks.length; i++) {
73
+ var b = blocks[i]
74
+ if (b && b.type === 'text' && typeof b.text === 'string' && b.text.trim() !== '') {
75
+ return b.text.replace(/\s+/g, ' ').trim().slice(0, 80)
76
+ }
77
+ }
78
+ return ''
79
+ }
80
+
81
+ function toolCallNames(blocks) {
82
+ var names = []
83
+ if (!Array.isArray(blocks)) return names
84
+ for (var i = 0; i < blocks.length; i++) {
85
+ var b = blocks[i]
86
+ if (b && b.type === 'tool-call' && typeof b.name === 'string') names.push(b.name)
87
+ }
88
+ return names
89
+ }
90
+
91
+ function isInjection(source) {
92
+ // plugin context (AGENTS.md, snapshots, notices, …) and user-explicit skill
93
+ // invocations both ride user-role messages with a declared form.
94
+ return source !== null && typeof source === 'object'
95
+ && (source.kind === 'plugin' || source.kind === 'skill-invocation' || typeof source.form === 'string')
96
+ }
97
+
98
+ // ---- the incremental fold -----------------------------------------------------
99
+
100
+ function createFold() {
101
+ return {
102
+ n: 0, // number of log events already folded
103
+ surface: [], // { seq, cat, tokens, form?, text?, tool?, err?, skill?, calls? }
104
+ sums: { user: 0, inject: 0, assistant: 0, tool: 0 },
105
+ systemTokens: 0,
106
+ toolsTokens: 0,
107
+ toolList: [], // { name, tokens }
108
+ model: undefined,
109
+ provider: undefined,
110
+ lastModel: undefined,
111
+ contextWindow: undefined,
112
+ requests: [], // one entry per answered model call
113
+ events: [], // notable context events (structured; the Client labels them)
114
+ callNames: {}, // callId -> tool name
115
+ }
116
+ }
117
+
118
+ function categoryOf(type, message) {
119
+ if (type === 'assistant/message') return 'assistant'
120
+ if (type === 'tool/result') return 'tool'
121
+ if (isInjection(message && message.source)) return 'inject'
122
+ return 'user'
123
+ }
124
+
125
+ function applySurface(st, ev, type, data, message) {
126
+ var cat = categoryOf(type, message)
127
+ var node = { seq: ev.seq, cat: cat, tokens: estimateMessage(message) }
128
+ var source = message && message.source
129
+ var form = source && source.form
130
+ if (typeof form === 'string') node.form = form
131
+ if (type === 'assistant/message') {
132
+ var text = firstText(message && message.content)
133
+ if (text !== '') node.text = text
134
+ else {
135
+ var names = toolCallNames(message && message.content)
136
+ if (names.length > 0) node.calls = names.slice(0, 3)
137
+ }
138
+ } else if (type === 'tool/result') {
139
+ var block = message && message.content && message.content[0]
140
+ var tname = block && block.callId !== undefined ? st.callNames[block.callId] : undefined
141
+ if (tname) node.tool = tname
142
+ if (data && data.error) node.err = true
143
+ } else if (source && source.kind === 'skill-invocation') {
144
+ node.skill = typeof source.name === 'string' ? source.name : '?'
145
+ } else if (source && source.kind === 'plugin') {
146
+ if (source.form === 'notice' && typeof source.summary === 'string') node.text = source.summary
147
+ else if (source.form === 'snapshot' && Array.isArray(source.sections)) {
148
+ node.text = source.sections.map(function (s) { return s && s.name }).filter(Boolean).join(', ').slice(0, 80)
149
+ } else {
150
+ var ptext = firstText(message && message.content)
151
+ if (ptext !== '') node.text = ptext
152
+ }
153
+ } else {
154
+ var utext = firstText(message && message.content)
155
+ if (utext !== '') node.text = utext
156
+ }
157
+
158
+ var op = ev.surfaceOp
159
+ if (op !== null && typeof op === 'object' && op.op === 'replace') {
160
+ var si = -1
161
+ var ei = -1
162
+ for (var i = 0; i < st.surface.length; i++) {
163
+ if (si < 0 && st.surface[i].seq === op.start) si = i
164
+ if (st.surface[i].seq === op.end) { ei = i; break }
165
+ }
166
+ if (si >= 0 && ei >= si) {
167
+ var removed = st.surface.splice(si, ei - si + 1, node)
168
+ for (var r = 0; r < removed.length; r++) st.sums[removed[r].cat] -= removed[r].tokens
169
+ st.sums[cat] += node.tokens
170
+ return node
171
+ }
172
+ }
173
+ st.surface.push(node)
174
+ st.sums[cat] += node.tokens
175
+ return node
176
+ }
177
+
178
+ function foldInto(st, events) {
179
+ for (var e = st.n; e < events.length; e++) {
180
+ var ev = events[e]
181
+ if (ev === null || typeof ev !== 'object') continue
182
+ var data = ev.data
183
+ switch (ev.type) {
184
+ case 'request/header': {
185
+ var header = data && data.header ? data.header : {}
186
+ var tools = Array.isArray(header.tools) ? header.tools : []
187
+ st.toolList = tools.map(function (t) {
188
+ return { name: typeof t.name === 'string' ? t.name : '?', tokens: estimateToolSchema(t) }
189
+ })
190
+ st.toolsTokens = st.toolList.reduce(function (a, t) { return a + t.tokens }, 0)
191
+ if (tools.length > 0) st.toolsTokens += BLOCK_OVERHEAD
192
+ st.systemTokens = estimateSystem(header.system)
193
+ if (header.config && typeof header.config.model === 'string') st.model = header.config.model
194
+ if (header.config && typeof header.config.provider === 'string') st.provider = header.config.provider
195
+ if (data && data.reason === 'change' && st.model && st.lastModel && st.model !== st.lastModel) {
196
+ st.events.push({ seq: ev.seq, time: ev.time, kind: 'model', from: st.lastModel, to: st.model })
197
+ }
198
+ if (st.model) st.lastModel = st.model
199
+ break
200
+ }
201
+ case 'request/context':
202
+ if (data && typeof data.contextWindow === 'number') st.contextWindow = data.contextWindow
203
+ if (data && typeof data.model === 'string') st.model = data.model
204
+ if (data && typeof data.provider === 'string') st.provider = data.provider
205
+ break
206
+ case 'tool/call':
207
+ if (data && data.callId !== undefined && typeof data.name === 'string') st.callNames[data.callId] = data.name
208
+ break
209
+ case 'user/message': {
210
+ var node = applySurface(st, ev, ev.type, data, data)
211
+ var source = data && data.source
212
+ if (isInjection(source)) {
213
+ var rec = { seq: ev.seq, time: ev.time, kind: 'inject', form: source.form || 'context', tokens: node.tokens }
214
+ if (source.kind === 'skill-invocation') {
215
+ rec.sub = 'skill'
216
+ rec.name = typeof source.name === 'string' ? source.name : '?'
217
+ } else if (typeof source.plugin === 'string' && source.plugin !== '') {
218
+ rec.name = source.plugin
219
+ }
220
+ st.events.push(rec)
221
+ }
222
+ break
223
+ }
224
+ case 'tool/result':
225
+ applySurface(st, ev, ev.type, data, data && data.message)
226
+ break
227
+ case 'assistant/message': {
228
+ // Snapshot the request exactly as dispatched: current surface + header,
229
+ // before this response joins the surface.
230
+ var usage = data && data.usage
231
+ var record = {
232
+ turn: data && data.turn, step: data && data.step, time: ev.time, seq: ev.seq,
233
+ system: st.systemTokens,
234
+ tools: st.toolsTokens,
235
+ user: st.sums.user,
236
+ inject: st.sums.inject,
237
+ assistant: st.sums.assistant,
238
+ tool: st.sums.tool,
239
+ }
240
+ record.total = record.system + record.tools + record.user + record.inject + record.assistant + record.tool
241
+ if (usage && typeof usage.inputTokens === 'number') {
242
+ record.prompt = usage.inputTokens + (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0)
243
+ if (typeof usage.outputTokens === 'number') record.output = usage.outputTokens
244
+ }
245
+ st.requests.push(record)
246
+ applySurface(st, ev, ev.type, data, data && data.message)
247
+ break
248
+ }
249
+ case 'compaction/summary':
250
+ st.events.push({
251
+ seq: ev.seq, time: ev.time, kind: 'compaction',
252
+ tokens: data && typeof data.shadowedTokenCount === 'number' ? data.shadowedTokenCount : 0,
253
+ count: data && Array.isArray(data.shadowedSeqs) ? data.shadowedSeqs.length : 0,
254
+ })
255
+ break
256
+ case 'compaction/prune':
257
+ st.events.push({
258
+ seq: ev.seq, time: ev.time, kind: 'prune',
259
+ tokens: data && typeof data.shadowedTokenCount === 'number' ? data.shadowedTokenCount : 0,
260
+ })
261
+ break
262
+ default:
263
+ break
264
+ }
265
+ }
266
+ st.n = events.length
267
+ if (st.requests.length > 160) st.requests = st.requests.slice(-160)
268
+ if (st.events.length > 150) st.events = st.events.slice(-150)
269
+ }
270
+
271
+ function buildResult(st) {
272
+ var surfaceTotal = st.sums.user + st.sums.inject + st.sums.assistant + st.sums.tool
273
+ var result = {
274
+ ok: true,
275
+ model: st.model,
276
+ provider: st.provider,
277
+ contextWindow: st.contextWindow,
278
+ current: {
279
+ system: st.systemTokens,
280
+ tools: st.toolsTokens,
281
+ user: st.sums.user,
282
+ inject: st.sums.inject,
283
+ assistant: st.sums.assistant,
284
+ tool: st.sums.tool,
285
+ total: surfaceTotal + st.systemTokens + st.toolsTokens,
286
+ },
287
+ toolList: st.toolList,
288
+ requests: st.requests,
289
+ events: st.events,
290
+ }
291
+ // Bound the payload: the newest surface nodes carry the most signal.
292
+ var MAX_NODES = 200
293
+ result.droppedNodes = Math.max(0, st.surface.length - MAX_NODES)
294
+ result.nodes = st.surface.slice(-MAX_NODES)
295
+ return result
296
+ }
297
+
298
+ // ---- RPC endpoint: /dsh-context snapshot -------------------------------------
299
+ //
300
+ // The generic Connection RPC channel replaces the dynamic-runner
301
+ // `harness.handle` seat: installed packages register a channel on the host
302
+ // half and call it from the browser half through `ctx.connection.rpc.call`.
303
+ // Responses use the harness RpcResult envelope ({ok:true,value} | {ok:false,error}).
304
+
305
+ async function computeSnapshot(ctx, states, sessionId) {
306
+ var st = states.get(sessionId)
307
+ if (st === undefined) {
308
+ st = { fold: createFold(), count: -1, result: null }
309
+ states.set(sessionId, st)
310
+ }
311
+
312
+ // Resolve the log sources lazily per call: `sessions` / `sessionQuery` may
313
+ // be provided after this plugin applies, and a replaced service must not
314
+ // leave us holding a stale instance.
315
+ var sessions = ctx.get('sessions')
316
+ var sessionQuery = ctx.get('sessionQuery')
317
+
318
+ // Live sessions fold from the in-memory log — no clone, no disk parse.
319
+ var live = sessions !== undefined ? sessions.get(sessionId) : undefined
320
+ var events
321
+ if (live !== undefined) {
322
+ events = live.events
323
+ } else {
324
+ if (sessionQuery === undefined) throw new Error('session is not live and sessionQuery is unavailable')
325
+ if (st.result !== null && st.count >= 0) {
326
+ // Cold logs never grow: probe the lightweight record count only.
327
+ var records = await sessionQuery.listEvents(sessionId)
328
+ if (records.length === st.count) return st.result
329
+ }
330
+ var snapshot = await sessionQuery.readSession(sessionId)
331
+ events = snapshot && Array.isArray(snapshot.events) ? snapshot.events : []
332
+ }
333
+
334
+ if (events.length === st.count && st.result !== null) return st.result
335
+ if (events.length < st.fold.n) st.fold = createFold() // defensive: log replaced
336
+ foldInto(st.fold, events)
337
+ st.count = events.length
338
+ st.result = buildResult(st.fold)
339
+ return st.result
340
+ }
341
+
342
+ export function apply(ctx) {
343
+ // sessionId -> { fold state + last built result + the count it reflects }.
344
+ var states = new Map()
345
+
346
+ ctx.effect(function () {
347
+ return ctx.connection.rpc.handle(
348
+ '/dsh-context',
349
+ async function (endpoint, payload) {
350
+ try {
351
+ if (endpoint !== 'snapshot') {
352
+ return {
353
+ ok: false,
354
+ error: { code: 'internal', message: 'unknown endpoint: ' + endpoint, details: {} },
355
+ }
356
+ }
357
+ var sessionId = payload !== null && typeof payload === 'object' ? payload.sessionId : undefined
358
+ if (typeof sessionId !== 'string' || sessionId === '') {
359
+ return { ok: false, error: { code: 'internal', message: 'missing sessionId', details: {} } }
360
+ }
361
+ var value = await computeSnapshot(ctx, states, sessionId)
362
+ return { ok: true, value: value }
363
+ } catch (err) {
364
+ return {
365
+ ok: false,
366
+ error: {
367
+ code: 'internal',
368
+ message: String(err && err.message ? err.message : err),
369
+ details: {},
370
+ },
371
+ }
372
+ }
373
+ },
374
+ { authority: 'trusted-host' },
375
+ )
376
+ }, 'dsh-context: rpc channel')
377
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "dsh-context",
3
+ "version": "0.1.0",
4
+ "description": "Context insight panel for DeepSeek Harness: a Context tab (beside Chat/Trajectory) showing what the model's context window is made of and how it evolves — composition, per-request history, compactions, injections, and model switches.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "node scripts/build.mjs",
20
+ "test": "node scripts/test-host.mjs && node scripts/test-client.mjs",
21
+ "release": "bash scripts/publish.sh"
22
+ },
23
+ "dsh": {
24
+ "bundle": {
25
+ "patch": "./cordis.patch.yml"
26
+ },
27
+ "client": {
28
+ "inject": [
29
+ "@deepseek-ai/dsh-client-connection",
30
+ "@deepseek-ai/dsh-client-locale",
31
+ "@deepseek-ai/dsh-client-runtime",
32
+ "@deepseek-ai/dsh-client-ui-conversation"
33
+ ],
34
+ "platform": "web"
35
+ }
36
+ },
37
+ "keywords": [
38
+ "deepseek-harness",
39
+ "dsh",
40
+ "cordis",
41
+ "plugin",
42
+ "context",
43
+ "token",
44
+ "ui"
45
+ ],
46
+ "license": "MIT"
47
+ }