init version

This commit is contained in:
bowenliang123 committed 2026-08-14 16:08:55 +08:00
1 parent 517dab8336
commit 868cb4478a
4 files changed
+919

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
# last-context
A [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness) (dsh) 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.
![last-context screenshot](docs/screenshot.png)
## Why
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. last-context makes it observable:
- **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.
- **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.
- **Context events** — compactions, tool-output prunes, skill injections (`Skill injected (code-review)`), plugin context injections, model switches — each with its token delta and timestamp.
- **Messages** — the currently model-visible surface, message by message, with per-message token costs.
The UI is bilingual (中文/English) and follows the dsh locale automatically.
## Install
last-context is a **dynamic Cordis plugin**: two plain-JavaScript halves (`host.js`, `client.js`) loaded into a running dsh process — no build step, no restart.
### Option 1: ask the agent (easiest)
In any dsh session with the Cordis tools available, say:
> 加载 ~/dev/last-context 里的 dsh 插件(host.js + client.js),用 cordis_define 和 cordis_run 运行
The agent will define the package and run it; approve the client bundle when prompted, and the **Context** tab appears in every session view.
### Option 2: load it yourself
Call `cordis_define` with the file contents as `code.host` / `code.client`, then `cordis_run`:
```js
// code.host = contents of host.js
// code.client = contents of client.js
cordis_define({ plugin: { kind: 'new', idPrefix: 'lctx' }, name: 'last-context', purpose: '…', code })
cordis_run({ pluginId, packageId, mode: 'run' })
```
> Dynamic plugins are process-local: they disappear when dsh restarts. To make it permanent, wrap the two halves as a plugin package (a `package.json` with a `dsh` field, like [`@deepseek-ai/dsh-client-ui-trajectory`](https://github.com/deepseek-ai/DeepSeek-Harness/tree/master/packages/client/ui-trajectory)) and add a row to your agent preset's `cordis.yml` — see the [dsh plugin docs](https://deepseek-harness.github.io/deepseek-harness/develop/).
## Usage
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.
- **Hover** a history bar for a quick tooltip; **click** it to pin the breakdown below the chart.
- The overview bar is scaled to the model's context window, so ~13% full means ~13% of the window is spoken for.
- 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".
## How it works
- **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`.
- **Incremental fold**: per-session fold state lives in the Host half, so each poll only processes newly appended events — reopening the tab is instant.
- **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.
- **Architecture**: `host.js` folds the log and serves a package-private `snapshot` RPC; `client.js` registers the tab in the `conversation.view` slot and renders with bare `React.createElement` — zero dependencies, theme-native via dsh CSS variables.
## Files
| File | Role |
| --- | --- |
| `host.js` | Host half: incremental log fold, category accounting, `snapshot` RPC |
| `client.js` | Client half: tab registration, bilingual chart UI |
| `docs/screenshot.png` | The UI in action |
## License
MIT
+509
View File
@@ -0,0 +1,509 @@
/**
* last-context — Client half.
*
* Registers a "上下文/Context" tab in the conversation view ring
* (`conversation.view` slot, beside Chat/Trajectory) and renders the
* context-composition timeline served by the Host half: current makeup,
* per-request stacked-bar history, context events, and the live message list.
*
* UI text is bilingual (zh/en) through the client `locale` service; the Host
* sends structured event/node records and this half localizes the labels.
*/
var h = React.createElement
var DICT_ZH = {
'tab': '上下文',
'cat.system': '系统提示', 'cat.tools': '工具定义', 'cat.user': '用户消息',
'cat.inject': '注入上下文', 'cat.assistant': '助手回复', 'cat.tool': '工具结果',
'overview.title': '当前构成',
'overview.ofWindow': 'tokens(约 {p}%)',
'overview.estimate': 'tokens(估算)',
'tools.top': '工具定义 Top:',
'tools.more': '等 {n} 个',
'trend.title': '历史趋势',
'trend.hint': '每次模型请求一段;点击柱子查看详情,✂ 表示压缩/剪枝',
'trend.empty': '发起一轮对话后,这里会展示每次模型请求的上下文构成',
'detail.step': 'T{t} · 第 {s} 步',
'detail.estTotal': '估算合计 ≈ {n}',
'detail.actual': '实际 prompt {n}',
'detail.output': '输出 {n}',
'events.title': '上下文事件',
'events.empty': '暂无上下文事件(压缩、注入、模型切换会出现在这里)',
'nodes.title': '消息构成',
'nodes.hint': '当前模型可见的消息,最新在前',
'nodes.more': '… 更早的 {n} 条消息已省略',
'nodes.empty': '当前没有模型可见的消息',
'loading': '正在读取会话日志…',
'error': '上下文数据读取失败:',
'footer': '估算口径:与 dsh 内置 tokenMeter 相同的固定密度启发式(约 4 字符 ≈ 1 token);「实际」为供应商上报用量。',
'tip.step': 'T{t} · 第{s}步',
'tip.total': '合计 ≈ {n}',
'tip.actual': '(实际 {n})',
'ev.compaction': '压缩上下文(摘要替换 {n} 条消息)',
'ev.prune': '剪枝工具输出',
'ev.skill': 'Skill 注入({name})',
'ev.model': '模型切换:{a} → {b}',
'form.instructions': '指令注入', 'form.catalog': '目录更新', 'form.snapshot': '状态快照',
'form.notice': '通知', 'form.relay': '代理转发', 'form.recall': '历史召回', 'form.context': '上下文注入',
'node.toolResult': '工具结果',
'node.calls': '调用 ',
'node.empty': '(空回复)',
'node.nonText': '(非文本消息)',
'node.snapshot': '快照: ',
}
var DICT_EN = {
'tab': 'Context',
'cat.system': 'System', 'cat.tools': 'Tool schemas', 'cat.user': 'User',
'cat.inject': 'Injected', 'cat.assistant': 'Assistant', 'cat.tool': 'Tool results',
'overview.title': 'Current composition',
'overview.ofWindow': 'tokens (~{p}%)',
'overview.estimate': 'tokens (estimated)',
'tools.top': 'Top tool schemas:',
'tools.more': 'of {n}',
'trend.title': 'History',
'trend.hint': 'one bar per model request; click a bar for details, ✂ marks compaction/prune',
'trend.empty': 'Send a message and each model request’s context makeup shows up here',
'detail.step': 'T{t} · step {s}',
'detail.estTotal': 'estimated ≈ {n}',
'detail.actual': 'actual prompt {n}',
'detail.output': 'output {n}',
'events.title': 'Context events',
'events.empty': 'No context events yet (compaction, injections, model switches appear here)',
'nodes.title': 'Messages',
'nodes.hint': 'currently model-visible, newest first',
'nodes.more': '… {n} earlier messages omitted',
'nodes.empty': 'No model-visible messages right now',
'loading': 'Reading the session log…',
'error': 'Failed to read context data: ',
'footer': 'Estimate: same fixed-density heuristic as dsh’s built-in tokenMeter (~4 chars ≈ 1 token); “actual” is provider-reported usage.',
'tip.step': 'T{t} · step {s}',
'tip.total': 'total ≈ {n}',
'tip.actual': ' (actual {n})',
'ev.compaction': 'Context compacted (summary replaced {n} messages)',
'ev.prune': 'Tool output pruned',
'ev.skill': 'Skill injected ({name})',
'ev.model': 'Model switched: {a} → {b}',
'form.instructions': 'Instructions', 'form.catalog': 'Catalog update', 'form.snapshot': 'State snapshot',
'form.notice': 'Notice', 'form.relay': 'Agent relay', 'form.recall': 'Recall', 'form.context': 'Context injection',
'node.toolResult': 'Tool result',
'node.calls': 'calls ',
'node.empty': '(empty reply)',
'node.nonText': '(non-text message)',
'node.snapshot': 'snapshot: ',
}
var EVENT_ICONS = { compaction: '✂', prune: '✂', inject: '+', model: '⇄' }
function fmt(n) {
if (n === undefined || n === null || isNaN(n)) return '—'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return String(Math.round(n))
}
function fmtTime(t) {
var d = new Date(t)
function p(x) { return (x < 10 ? '0' : '') + x }
return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
}
function makeView(ctx, t, localeSvc) {
function tr(key, vars) {
var s = t(key)
if (vars) for (var k in vars) s = s.replace('{' + k + '}', String(vars[k]))
return s
}
var CATS = [
{ key: 'system', color: '#6366f1' },
{ key: 'tools', color: '#f59e0b' },
{ key: 'user', color: '#22c55e' },
{ key: 'inject', color: '#a855f7' },
{ key: 'assistant', color: '#3b82f6' },
{ key: 'tool', color: '#14b8a6' },
]
function catLabel(key) { return t('cat.' + key) }
function eventLabel(ev) {
if (ev.kind === 'compaction') return tr('ev.compaction', { n: ev.count || 0 })
if (ev.kind === 'prune') return t('ev.prune')
if (ev.kind === 'model') return tr('ev.model', { a: ev.from || '?', b: ev.to || '?' })
if (ev.kind === 'inject') {
if (ev.sub === 'skill') return tr('ev.skill', { name: ev.name || '?' })
var base = t('form.' + (ev.form || 'context'))
return ev.name ? base + ' · ' + ev.name : base
}
return ev.kind
}
function nodeText(n) {
if (n.cat === 'tool') {
return t('node.toolResult') + (n.tool ? ' ← ' + n.tool : '') + (n.err ? ' ⚠' : '')
}
if (n.skill) return 'Skill: ' + n.skill
if (n.calls) return t('node.calls') + n.calls.join(', ')
if (n.text) return n.form === 'snapshot' ? t('node.snapshot') + n.text : n.text
if (n.cat === 'assistant') return t('node.empty')
if (n.cat === 'inject') return t('form.' + (n.form || 'context'))
return t('node.nonText')
}
function StackedBar(props) {
// props.parts: [{key,color,value}]; optional props.max: when max exceeds
// the parts' total, the remainder shows as empty track.
var total = 0
for (var i = 0; i < props.parts.length; i++) total += props.parts[i].value
var scale = props.max !== undefined && props.max > total ? props.max : total
return h('div', { className: 'lc-stacked', style: { height: (props.height || 14) + 'px' } },
total <= 0
? null
: props.parts.map(function (p) {
if (!p.value) return null
return h('div', {
key: p.key,
title: catLabel(p.key) + ' ' + fmt(p.value) + ' (' + Math.round(p.value / total * 100) + '%)',
style: { width: (p.value / scale * 100) + '%', background: p.color },
})
}))
}
function Legend(props) {
var total = 0
for (var i = 0; i < props.parts.length; i++) total += props.parts[i].value
return h('div', { className: 'lc-legend' },
props.parts.map(function (p) {
return h('span', { key: p.key, className: 'lc-chip' },
h('i', { style: { background: p.color } }),
catLabel(p.key) + ' ' + fmt(p.value),
total > 0 ? h('em', null, Math.round(p.value / total * 100) + '%') : null)
}))
}
function partsOf(breakdown) {
return CATS.map(function (c) {
return { key: c.key, color: c.color, value: breakdown[c.key] || 0 }
})
}
// Plot height in px (the marker lane above it is 18px).
var CHART_H = 112
function TrendChart(props) {
var requests = props.requests
var maxTotal = 1
for (var i = 0; i < requests.length; i++) if (requests[i].total > maxTotal) maxTotal = requests[i].total
// Compaction/prune markers: attach each to the first request logged after it.
var markers = {}
for (var m = 0; m < props.events.length; m++) {
var ev = props.events[m]
if (ev.kind !== 'compaction' && ev.kind !== 'prune') continue
for (var r = 0; r < requests.length; r++) {
if (requests[r].seq >= ev.seq) {
if (!markers[r]) markers[r] = ev
break
}
}
}
return h('div', { className: 'lc-chartrow' },
h('div', { className: 'lc-axis' },
h('span', { className: 'lc-axis-top' }, fmt(maxTotal)),
h('span', { className: 'lc-axis-mid' }, fmt(Math.round(maxTotal / 2))),
h('span', { className: 'lc-axis-bot' }, '0')),
h('div', { className: 'lc-chart' },
h('div', { className: 'lc-grid lc-grid-top' }),
h('div', { className: 'lc-grid lc-grid-mid' }),
requests.map(function (req, i) {
var selected = props.selectedSeq === req.seq
var tip = tr('tip.step', { t: req.turn, s: req.step }) + ' · ' + fmtTime(req.time) + '\n'
+ tr('tip.total', { n: fmt(req.total) })
+ (req.prompt !== undefined ? tr('tip.actual', { n: fmt(req.prompt) }) : '') + '\n'
+ CATS.map(function (c) { return catLabel(c.key) + ' ' + fmt(req[c.key] || 0) }).join(' / ')
return h('div', {
key: req.seq,
className: 'lc-bar' + (selected ? ' lc-bar-selected' : ''),
title: tip,
onClick: function () { props.onSelect(selected ? null : req.seq) },
},
markers[i] ? h('span', { className: 'lc-bar-marker', title: eventLabel(markers[i]) }, '✂') : null,
h('div', { className: 'lc-bar-stack' },
CATS.map(function (c) {
var v = req[c.key] || 0
if (!v) return null
// px heights: the stack's height is content-driven, so
// percentage heights would collapse against an indefinite base.
return h('div', { key: c.key, style: { height: Math.max(1, Math.round(v / maxTotal * CHART_H)) + 'px', background: c.color } })
})))
})))
}
function RequestDetail(props) {
var req = props.request
if (!req) return null
return h('div', { className: 'lc-detail' },
h('div', { className: 'lc-detail-head' },
h('b', null, tr('detail.step', { t: req.turn, s: req.step })),
h('span', null, fmtTime(req.time)),
h('span', null, tr('detail.estTotal', { n: fmt(req.total) })),
req.prompt !== undefined ? h('span', { className: 'lc-actual' }, tr('detail.actual', { n: fmt(req.prompt) })) : null,
req.output !== undefined ? h('span', null, tr('detail.output', { n: fmt(req.output) })) : null),
h(StackedBar, { parts: partsOf(req), height: 10 }),
h('div', { className: 'lc-detail-rows' },
CATS.map(function (c) {
var v = req[c.key] || 0
return h('div', { key: c.key, className: 'lc-detail-row' },
h('i', { style: { background: c.color } }),
h('span', { className: 'lc-detail-label' }, catLabel(c.key)),
h('span', { className: 'lc-bar-track' },
h('span', { className: 'lc-bar-fill', style: { width: (req.total > 0 ? v / req.total * 100 : 0) + '%', background: c.color } })),
h('span', { className: 'lc-detail-num' }, fmt(v)),
h('span', { className: 'lc-detail-pct' }, req.total > 0 ? Math.round(v / req.total * 100) + '%' : ''))
})))
}
function EventList(props) {
if (props.events.length === 0) {
return h('div', { className: 'lc-empty' }, t('events.empty'))
}
var sorted = props.events.slice().reverse()
return h('div', { className: 'lc-events' },
sorted.map(function (ev, i) {
var label = eventLabel(ev)
return h('div', { key: ev.seq + '-' + i, className: 'lc-event' },
h('span', { className: 'lc-event-icon lc-event-' + ev.kind }, EVENT_ICONS[ev.kind] || '•'),
h('span', { className: 'lc-event-label', title: label }, label),
ev.tokens ? h('span', { className: 'lc-event-tokens' + (ev.kind === 'inject' ? ' lc-up' : ' lc-down') },
(ev.kind === 'inject' ? '+' : '−') + fmt(ev.tokens)) : null,
h('span', { className: 'lc-event-time' }, fmtTime(ev.time)))
}))
}
function NodeList(props) {
if (props.nodes.length === 0) {
return h('div', { className: 'lc-empty' }, t('nodes.empty'))
}
var catColor = {}
CATS.forEach(function (c) { catColor[c.key] = c.color })
var rows = props.nodes.slice().reverse()
return h('div', { className: 'lc-nodes' },
props.dropped > 0 ? h('div', { className: 'lc-nodes-more' }, tr('nodes.more', { n: props.dropped })) : null,
rows.map(function (n) {
var text = nodeText(n)
return h('div', { key: n.seq, className: 'lc-node' },
h('i', { style: { background: catColor[n.cat] || '#999' } }),
h('span', { className: 'lc-node-preview', title: text }, text),
h('span', { className: 'lc-node-tokens' }, fmt(n.tokens)))
}))
}
function ContextView(props) {
var sessionId = props.sessionId
var state = React.useState(null)
var data = state[0]
var setData = state[1]
var errState = React.useState(null)
var error = errState[0]
var setError = errState[1]
var selState = React.useState(null)
var selectedSeq = selState[0]
var setSelectedSeq = selState[1]
var tickState = React.useState(0)
var setTick = tickState[1]
React.useEffect(function () {
if (typeof sessionId !== 'string' || sessionId === '') return undefined
var alive = true
var load = function () {
host.call('snapshot', { sessionId: sessionId }).then(function (res) {
if (!alive) return
if (res && res.ok) { setData(res); setError(null) }
else setError(res && res.error ? String(res.error) : 'failed')
}, function (err) {
if (alive) setError(String(err && err.message ? err.message : err))
})
}
load()
var dispose = ctx.interval(load, 2000)
return function () { alive = false; dispose() }
}, [sessionId])
// Re-render on locale switch.
React.useEffect(function () {
if (!localeSvc) return undefined
return localeSvc.subscribe(function () { setTick(function (x) { return x + 1 }) })
}, [])
if (error) {
return h('div', { className: 'lc-root' }, h('div', { className: 'lc-empty' }, t('error') + error))
}
if (!data) {
return h('div', { className: 'lc-root' }, h('div', { className: 'lc-empty' }, t('loading')))
}
var current = data.current
var requests = data.requests || []
var events = data.events || []
var nodes = data.nodes || []
var selReq = null
for (var i = 0; i < requests.length; i++) if (requests[i].seq === selectedSeq) selReq = requests[i]
if (!selReq && requests.length > 0) selReq = requests[requests.length - 1]
var windowPct = data.contextWindow ? Math.min(100, Math.round(current.total / data.contextWindow * 100)) : null
return h('div', { className: 'lc-root' },
// ---- overview ----
h('div', { className: 'lc-card' },
h('div', { className: 'lc-card-title' },
t('overview.title'),
h('span', { className: 'lc-card-sub' },
(data.model ? data.model : '') + (data.provider ? ' · ' + data.provider : ''))),
h('div', { className: 'lc-overview-num' },
h('b', null, fmt(current.total)),
h('span', null, data.contextWindow
? ' / ' + fmt(data.contextWindow) + ' ' + tr('overview.ofWindow', { p: windowPct })
: ' ' + t('overview.estimate'))),
h(StackedBar, { parts: partsOf(current), height: 16, max: data.contextWindow }),
h(Legend, { parts: partsOf(current) }),
(data.toolList && data.toolList.length > 0) ? h('div', { className: 'lc-tools' },
t('tools.top'),
data.toolList.slice().sort(function (a, b) { return b.tokens - a.tokens }).slice(0, 5).map(function (tool) {
return h('span', { key: tool.name, className: 'lc-tool-chip' }, tool.name + ' ' + fmt(tool.tokens))
}),
data.toolList.length > 5 ? h('span', { className: 'lc-card-sub' }, ' ' + tr('tools.more', { n: data.toolList.length })) : null) : null),
// ---- trend ----
h('div', { className: 'lc-card' },
h('div', { className: 'lc-card-title' },
t('trend.title'),
h('span', { className: 'lc-card-sub' }, t('trend.hint'))),
requests.length === 0
? h('div', { className: 'lc-empty' }, t('trend.empty'))
: h('div', null,
h(TrendChart, { requests: requests.slice(-80), events: events, selectedSeq: selReq ? selReq.seq : null, onSelect: setSelectedSeq }),
h(RequestDetail, { request: selReq }))),
// ---- events + messages ----
h('div', { className: 'lc-cols' },
h('div', { className: 'lc-card lc-col' },
h('div', { className: 'lc-card-title' }, t('events.title')),
h(EventList, { events: events })),
h('div', { className: 'lc-card lc-col' },
h('div', { className: 'lc-card-title' },
t('nodes.title'),
h('span', { className: 'lc-card-sub' }, t('nodes.hint'))),
h(NodeList, { nodes: nodes, dropped: data.droppedNodes || 0 }))),
h('div', { className: 'lc-foot' }, t('footer')))
}
return ContextView
}
return {
inject: ['timer'],
apply(ctx) {
var slots = ctx.get('slots')
if (slots === undefined) return
// Bilingual dictionaries; the tab label thunk and all UI text follow the
// active locale (missing keys fall back to zh, then the key itself).
// Registrations ride ctx.effect so a stop/update disposes them; the catch
// tolerates dictionaries leaked by an earlier run of this same plugin
// (re-registering a live (ns, locale) pair throws).
var localeSvc = ctx.get('locale')
var t
if (localeSvc !== undefined) {
try {
ctx.effect(function () {
var d1 = localeSvc.register('last-context', 'zh', DICT_ZH)
var d2 = localeSvc.register('last-context', 'en', DICT_EN)
return function () { d1(); d2() }
}, 'locale-dicts')
} catch (e) { /* dictionaries from an earlier run are still live; reuse them */ }
t = localeSvc.bind('last-context')
} else {
t = function (key) { return DICT_ZH[key] !== undefined ? DICT_ZH[key] : key }
}
styles.insert([
'.lc-root { padding: 16px 20px 32px; overflow-y: auto; height: 100%; box-sizing: border-box; color: var(--dsw-alias-label-primary); font-size: 13px; }',
'.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; }',
'.lc-card-title { font-weight: 600; margin-bottom: 10px; display: flex; align-items: baseline; gap: 8px; }',
'.lc-card-sub { font-weight: 400; color: var(--dsw-alias-label-secondary); font-size: 12px; }',
'.lc-overview-num { margin-bottom: 8px; }',
'.lc-overview-num b { font-size: 20px; }',
'.lc-overview-num span { color: var(--dsw-alias-label-secondary); }',
'.lc-stacked { display: flex; width: 100%; border-radius: 5px; overflow: hidden; background: rgba(128,128,128,0.18); }',
'.lc-stacked > div { height: 100%; }',
'.lc-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 10px; }',
'.lc-chip { display: inline-flex; align-items: center; gap: 5px; color: var(--dsw-alias-label-primary); }',
'.lc-chip i, .lc-detail-row i, .lc-node i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }',
'.lc-chip em { font-style: normal; color: var(--dsw-alias-label-secondary); }',
'.lc-tools { margin-top: 10px; color: var(--dsw-alias-label-secondary); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }',
'.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); }',
'.lc-chartrow { display: flex; gap: 6px; align-items: stretch; }',
'.lc-axis { position: relative; width: 40px; height: 130px; padding-top: 18px; box-sizing: border-box; color: var(--dsw-alias-label-secondary); font-size: 11px; }',
'.lc-axis span { position: absolute; right: 0; line-height: 1; }',
'.lc-axis-top { top: 13px; }',
'.lc-axis-mid { top: 69px; }',
'.lc-axis-bot { top: 125px; }',
'.lc-chart { position: relative; flex: 1; display: flex; align-items: flex-end; gap: 2px; height: 130px; padding-top: 18px; box-sizing: border-box; }',
'.lc-grid { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--dsw-alias-border-l1); pointer-events: none; }',
'.lc-grid-top { top: 18px; }',
'.lc-grid-mid { top: 74px; }',
'.lc-bar { position: relative; flex: 1; min-width: 5px; height: 100%; display: flex; align-items: flex-end; cursor: pointer; border-radius: 2px; }',
'.lc-bar:hover { background: var(--dsw-alias-bg-layer-2); }',
'.lc-bar-selected { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }',
'.lc-bar-stack { display: flex; flex-direction: column-reverse; width: 100%; }',
'.lc-bar-stack > div { width: 100%; }',
'.lc-bar-marker { position: absolute; top: -16px; left: 50%; transform: translateX(-50%); font-size: 11px; color: var(--dsw-alias-state-warn-primary); }',
'.lc-detail { margin-top: 12px; border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 12px; }',
'.lc-detail-head { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-bottom: 8px; color: var(--dsw-alias-label-secondary); }',
'.lc-detail-head b { color: var(--dsw-alias-label-primary); }',
'.lc-detail-head .lc-actual { color: var(--dsw-alias-state-success-primary); }',
'.lc-detail-rows { margin-top: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; }',
'.lc-detail-row { display: flex; align-items: center; gap: 8px; }',
'.lc-detail-label { min-width: 70px; white-space: nowrap; color: var(--dsw-alias-label-secondary); }',
'.lc-bar-track { flex: 1; height: 5px; border-radius: 3px; background: rgba(128,128,128,0.18); overflow: hidden; display: block; }',
'.lc-bar-fill { display: block; height: 100%; border-radius: 3px; }',
'.lc-detail-num { width: 44px; text-align: right; }',
'.lc-detail-pct { width: 34px; text-align: right; color: var(--dsw-alias-label-secondary); }',
'.lc-cols { display: flex; gap: 14px; flex-wrap: wrap; }',
'.lc-col { flex: 1; min-width: 280px; }',
'.lc-events, .lc-nodes { display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow-y: auto; }',
'.lc-event { display: flex; align-items: center; gap: 8px; padding: 3px 0; }',
'.lc-event-icon { width: 18px; text-align: center; color: var(--dsw-alias-state-warn-primary); }',
'.lc-event-icon.lc-event-inject { color: #a855f7; }',
'.lc-event-icon.lc-event-model { color: var(--dsw-alias-brand-primary); }',
'.lc-event-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'.lc-event-tokens { color: var(--dsw-alias-state-success-primary); }',
'.lc-event-tokens.lc-up { color: var(--dsw-alias-state-warn-primary); }',
'.lc-event-time { color: var(--dsw-alias-label-secondary); font-size: 12px; }',
'.lc-node { display: flex; align-items: center; gap: 8px; padding: 3px 0; }',
'.lc-node-preview { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); }',
'.lc-node-tokens { color: var(--dsw-alias-label-secondary); }',
'.lc-nodes-more { color: var(--dsw-alias-label-secondary); padding: 3px 0; }',
'.lc-empty { color: var(--dsw-alias-label-secondary); padding: 18px 0; text-align: center; }',
'.lc-foot { color: var(--dsw-alias-label-secondary); font-size: 12px; margin-top: 4px; }',
].join('\n'))
var ContextView = makeView(ctx, t, localeSvc)
slots.inject('conversation.view', function () {
return slots.register(
// NOTE: for dynamic packages the client Guard overrides `priority`
// with its own page-local rank (negative), so dynamic tabs always sort
// BEFORE the shipped priority-0 tabs — the tab currently renders
// leftmost by sandbox design. The priority/order pair below takes
// effect (right of Trajectory) once the plugin is installed as a real
// package in a preset composition instead of dynamically.
{ name: 'conversation.view', id: 'context', order: 20, priority: 10, label: function () { return t('tab') } },
function (props) { return h(ContextView, props) },
)
})
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

+342
View File
@@ -0,0 +1,342 @@
/**
* last-context — Host half.
*
* Replays a session's durable event log into a per-request context-composition
* timeline, and serves it to the Client half over a Package-private
* `snapshot` RPC.
*
* Performance: live sessions are folded straight from the in-memory log
* (`sessions.get(id).events` — no clone, no parse) and the fold is
* INCREMENTAL: per-session state advances only over newly appended events.
* Cold (persisted, not live) sessions fall back to `sessionQuery` and are
* served from cache once folded, since their logs never grow.
*
* Token figures use the same fixed-density heuristic as the harness's own
* token-meter (4 chars ≈ 1 token, +4 per content block, +4 role framing).
* Labels are sent structured (kind/form/name/count) so the Client localizes.
*/
// ---- harness token-meter heuristic (mirrors dsh-token-meter/estimate.ts) ----
var CHARS_PER_TOKEN = 4
var BLOCK_OVERHEAD = 4
var ROLE_OVERHEAD = 4
function estimateBlocks(blocks) {
var tokens = 0
if (!Array.isArray(blocks)) return 0
for (var i = 0; i < blocks.length; i++) {
var block = blocks[i]
if (block === null || typeof block !== 'object') continue
switch (block.type) {
case 'text':
case 'reasoning':
tokens += Math.ceil(String(block.text || '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
break
case 'tool-call':
tokens += Math.ceil(String(block.name || '').length / CHARS_PER_TOKEN)
+ Math.ceil(String(block.arguments || '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
break
case 'tool-result':
tokens += estimateBlocks(block.content) + BLOCK_OVERHEAD
break
default:
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
}
}
return tokens
}
function estimateMessage(message) {
return estimateBlocks(message && message.content) + ROLE_OVERHEAD
}
function estimateSystem(text) {
if (typeof text !== 'string' || text.length === 0) return 0
return Math.ceil(text.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
}
function estimateToolSchema(tool) {
return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
}
// ---- content extraction -----------------------------------------------------
function firstText(blocks) {
if (!Array.isArray(blocks)) return ''
for (var i = 0; i < blocks.length; i++) {
var b = blocks[i]
if (b && b.type === 'text' && typeof b.text === 'string' && b.text.trim() !== '') {
return b.text.replace(/\s+/g, ' ').trim().slice(0, 80)
}
}
return ''
}
function toolCallNames(blocks) {
var names = []
if (!Array.isArray(blocks)) return names
for (var i = 0; i < blocks.length; i++) {
var b = blocks[i]
if (b && b.type === 'tool-call' && typeof b.name === 'string') names.push(b.name)
}
return names
}
function isInjection(source) {
// plugin context (AGENTS.md, snapshots, notices, …) and user-explicit skill
// invocations both ride user-role messages with a declared form.
return source !== null && typeof source === 'object'
&& (source.kind === 'plugin' || source.kind === 'skill-invocation' || typeof source.form === 'string')
}
// ---- the incremental fold -----------------------------------------------------
function createFold() {
return {
n: 0, // number of log events already folded
surface: [], // { seq, cat, tokens, form?, text?, tool?, err?, skill?, calls? }
sums: { user: 0, inject: 0, assistant: 0, tool: 0 },
systemTokens: 0,
toolsTokens: 0,
toolList: [], // { name, tokens }
model: undefined,
provider: undefined,
lastModel: undefined,
contextWindow: undefined,
requests: [], // one entry per answered model call
events: [], // notable context events (structured; the Client labels them)
callNames: {}, // callId -> tool name
}
}
function categoryOf(type, message) {
if (type === 'assistant/message') return 'assistant'
if (type === 'tool/result') return 'tool'
if (isInjection(message && message.source)) return 'inject'
return 'user'
}
function applySurface(st, ev, type, data, message) {
var cat = categoryOf(type, message)
var node = { seq: ev.seq, cat: cat, tokens: estimateMessage(message) }
var source = message && message.source
var form = source && source.form
if (typeof form === 'string') node.form = form
if (type === 'assistant/message') {
var text = firstText(message && message.content)
if (text !== '') node.text = text
else {
var names = toolCallNames(message && message.content)
if (names.length > 0) node.calls = names.slice(0, 3)
}
} else if (type === 'tool/result') {
var block = message && message.content && message.content[0]
var name = block && block.callId !== undefined ? st.callNames[block.callId] : undefined
if (name) node.tool = name
if (data && data.error) node.err = true
} else if (source && source.kind === 'skill-invocation') {
node.skill = typeof source.name === 'string' ? source.name : '?'
} else if (source && source.kind === 'plugin') {
if (source.form === 'notice' && typeof source.summary === 'string') node.text = source.summary
else if (source.form === 'snapshot' && Array.isArray(source.sections)) {
node.text = source.sections.map(function (s) { return s && s.name }).filter(Boolean).join(', ').slice(0, 80)
} else {
var ptext = firstText(message && message.content)
if (ptext !== '') node.text = ptext
}
} else {
var utext = firstText(message && message.content)
if (utext !== '') node.text = utext
}
var op = ev.surfaceOp
if (op !== null && typeof op === 'object' && op.op === 'replace') {
var si = -1
var ei = -1
for (var i = 0; i < st.surface.length; i++) {
if (si < 0 && st.surface[i].seq === op.start) si = i
if (st.surface[i].seq === op.end) { ei = i; break }
}
if (si >= 0 && ei >= si) {
var removed = st.surface.splice(si, ei - si + 1, node)
for (var r = 0; r < removed.length; r++) st.sums[removed[r].cat] -= removed[r].tokens
st.sums[cat] += node.tokens
return node
}
}
st.surface.push(node)
st.sums[cat] += node.tokens
return node
}
function foldInto(st, events) {
for (var e = st.n; e < events.length; e++) {
var ev = events[e]
if (ev === null || typeof ev !== 'object') continue
var data = ev.data
switch (ev.type) {
case 'request/header': {
var header = data && data.header ? data.header : {}
var tools = Array.isArray(header.tools) ? header.tools : []
st.toolList = tools.map(function (t) {
return { name: typeof t.name === 'string' ? t.name : '?', tokens: estimateToolSchema(t) }
})
st.toolsTokens = st.toolList.reduce(function (a, t) { return a + t.tokens }, 0)
if (tools.length > 0) st.toolsTokens += BLOCK_OVERHEAD
st.systemTokens = estimateSystem(header.system)
if (header.config && typeof header.config.model === 'string') st.model = header.config.model
if (header.config && typeof header.config.provider === 'string') st.provider = header.config.provider
if (data && data.reason === 'change' && st.model && st.lastModel && st.model !== st.lastModel) {
st.events.push({ seq: ev.seq, time: ev.time, kind: 'model', from: st.lastModel, to: st.model })
}
if (st.model) st.lastModel = st.model
break
}
case 'request/context':
if (data && typeof data.contextWindow === 'number') st.contextWindow = data.contextWindow
if (data && typeof data.model === 'string') st.model = data.model
if (data && typeof data.provider === 'string') st.provider = data.provider
break
case 'tool/call':
if (data && data.callId !== undefined && typeof data.name === 'string') st.callNames[data.callId] = data.name
break
case 'user/message': {
var node = applySurface(st, ev, ev.type, data, data)
var source = data && data.source
if (isInjection(source)) {
var rec = { seq: ev.seq, time: ev.time, kind: 'inject', form: source.form || 'context', tokens: node.tokens }
if (source.kind === 'skill-invocation') {
rec.sub = 'skill'
rec.name = typeof source.name === 'string' ? source.name : '?'
} else if (typeof source.plugin === 'string' && source.plugin !== '') {
rec.name = source.plugin
}
st.events.push(rec)
}
break
}
case 'tool/result':
applySurface(st, ev, ev.type, data, data && data.message)
break
case 'assistant/message': {
// Snapshot the request exactly as dispatched: current surface + header,
// before this response joins the surface.
var usage = data && data.usage
var record = {
turn: data && data.turn, step: data && data.step, time: ev.time, seq: ev.seq,
system: st.systemTokens,
tools: st.toolsTokens,
user: st.sums.user,
inject: st.sums.inject,
assistant: st.sums.assistant,
tool: st.sums.tool,
}
record.total = record.system + record.tools + record.user + record.inject + record.assistant + record.tool
if (usage && typeof usage.inputTokens === 'number') {
record.prompt = usage.inputTokens + (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0)
if (typeof usage.outputTokens === 'number') record.output = usage.outputTokens
}
st.requests.push(record)
applySurface(st, ev, ev.type, data, data && data.message)
break
}
case 'compaction/summary':
st.events.push({
seq: ev.seq, time: ev.time, kind: 'compaction',
tokens: data && typeof data.shadowedTokenCount === 'number' ? data.shadowedTokenCount : 0,
count: data && Array.isArray(data.shadowedSeqs) ? data.shadowedSeqs.length : 0,
})
break
case 'compaction/prune':
st.events.push({
seq: ev.seq, time: ev.time, kind: 'prune',
tokens: data && typeof data.shadowedTokenCount === 'number' ? data.shadowedTokenCount : 0,
})
break
default:
break
}
}
st.n = events.length
if (st.requests.length > 160) st.requests = st.requests.slice(-160)
if (st.events.length > 150) st.events = st.events.slice(-150)
}
function buildResult(st) {
var surfaceTotal = st.sums.user + st.sums.inject + st.sums.assistant + st.sums.tool
var result = {
ok: true,
model: st.model,
provider: st.provider,
contextWindow: st.contextWindow,
current: {
system: st.systemTokens,
tools: st.toolsTokens,
user: st.sums.user,
inject: st.sums.inject,
assistant: st.sums.assistant,
tool: st.sums.tool,
total: surfaceTotal + st.systemTokens + st.toolsTokens,
},
toolList: st.toolList,
requests: st.requests,
events: st.events,
}
// Bound the payload: the newest surface nodes carry the most signal.
var MAX_NODES = 200
result.droppedNodes = Math.max(0, st.surface.length - MAX_NODES)
result.nodes = st.surface.slice(-MAX_NODES)
return result
}
return {
apply(ctx) {
var sessionQuery = ctx.get('sessionQuery')
var sessions = ctx.get('sessions')
if (sessionQuery === undefined && sessions === undefined) {
console.error('last-context: neither sessions nor sessionQuery is available; snapshot RPC disabled')
return
}
// sessionId -> { fold state + last built result + the count it reflects }.
var states = new Map()
harness.handle('snapshot', async function (args) {
try {
var sessionId = args !== null && typeof args === 'object' ? args.sessionId : undefined
if (typeof sessionId !== 'string' || sessionId === '') return { ok: false, error: 'missing sessionId' }
var st = states.get(sessionId)
if (st === undefined) {
st = { fold: createFold(), count: -1, result: null }
states.set(sessionId, st)
}
// Live sessions fold from the in-memory log — no clone, no disk parse.
var live = sessions !== undefined ? sessions.get(sessionId) : undefined
var events
if (live !== undefined) {
events = live.events
} else {
if (sessionQuery === undefined) return { ok: false, error: 'session is not live and sessionQuery is unavailable' }
if (st.result !== null && st.count >= 0) {
// Cold logs never grow: probe the lightweight record count only.
var records = await sessionQuery.listEvents(sessionId)
if (records.length === st.count) return st.result
}
var snapshot = await sessionQuery.readSession(sessionId)
events = snapshot && Array.isArray(snapshot.events) ? snapshot.events : []
}
if (events.length === st.count && st.result !== null) return st.result
if (events.length < st.fold.n) st.fold = createFold() // defensive: log replaced
foldInto(st.fold, events)
st.count = events.length
st.result = buildResult(st.fold)
return st.result
} catch (err) {
return { ok: false, error: String(err && err.message ? err.message : err) }
}
})
},
}