init version
This commit is contained in:
1 parent
517dab8336
commit
868cb4478a
4 files changed
+919
No files matched your search
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -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 |
@@ -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) }
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user