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 +21 -0
- package/README.md +86 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +521 -0
- package/lib/index.js +377 -0
- package/package.json +47 -0
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
|
+

|
|
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
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|