@weibaohui/context-razor 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.
@@ -0,0 +1,471 @@
1
+ /**
2
+ * dsh-plugin-razor — Browser half.
3
+ *
4
+ * Settings-section surface (same embedding as skills-management): pick a live
5
+ * session, see every model-visible context entry with its ≈token estimate,
6
+ * mark oversized ones in red over a threshold, multi-select and razor them
7
+ * away. All colors come from the ui-theme `--dsw-*` token layers; all copy
8
+ * comes from the locale registry (`zh`/`en`). No hardcoded colors, no ad-hoc
9
+ * copy. No class components — render errors land in globalThis.__rzErrors.
10
+ */
11
+
12
+ let __React = null
13
+ try { __React = require('react') } catch {}
14
+ if (!__React || typeof __React.createElement !== 'function') {
15
+ __React = {
16
+ createElement(type, props, ...kids) {
17
+ return { type, props: props || {}, kids: kids.flat(9).filter(k => k !== null && k !== undefined && k !== false && k !== true && typeof k !== 'string' || true) }
18
+ },
19
+ useState(init) { const v = [typeof init === 'function' ? init() : init]; return [v[0], x => { v[0] = typeof x === 'function' ? x(v[0]) : x }] },
20
+ useEffect() {}, useMemo(fn) { return fn() }, useRef(v = null) { return { current: v } },
21
+ }
22
+ }
23
+ const { createElement: h, useState, useEffect, useMemo, useRef } = __React
24
+
25
+ let P = null
26
+ try { P = require('@deepseek-ai/dsh-client-ui-primitives') } catch {}
27
+
28
+ /** Idempotent stylesheet injection (position-critical classes included). */
29
+ function ensureStyles() {
30
+ if (typeof document === 'undefined' || document.getElementById('rz-styles')) return
31
+ const holder = document.createElement('div')
32
+ holder.id = 'rz-styles'
33
+ holder.style.display = 'none'
34
+ holder.innerHTML = STYLE
35
+ document.head.appendChild(holder)
36
+ }
37
+
38
+ const prim = (name) => P && P[name]
39
+ ? P[name]
40
+ : function Shim(props) {
41
+ const { children, ...rest } = props
42
+ return h('button', { ...rest, 'data-p-shim': name }, children)
43
+ }
44
+
45
+ // ── Locale ───────────────────────────────────────────────────────────────
46
+
47
+ const NS = 'contextRazor'
48
+
49
+ const ZH = {
50
+ title: '上下文剃刀',
51
+ hint: '逐条列出会话上下文(≈token 为 cl100k_base 估算,与技能市场同词表)。删除 = 从模型视野移除(append-only 日志保留痕迹,不可恢复),不经 LLM 总结。',
52
+ sessionLabel: '会话',
53
+ pickSession: '选择会话…',
54
+ loading: '正在加载…',
55
+ refresh: '刷新',
56
+ busyTag: '运行中',
57
+ idleTag: '空闲',
58
+ thresholdLabel: '标红阈值',
59
+ thresholdUnit: 'token',
60
+ sortLabel: '排序',
61
+ sortOrder: '上下文顺序',
62
+ sortTokens: 'token 高→低',
63
+ overOnly: '只看超阈值',
64
+ selectOver: '选中超阈值',
65
+ clearSelect: '清空选择',
66
+ deleteSelected: '删除选中',
67
+ selectedStats: '已选 {n} 条 / ≈{tokens} token',
68
+ stats: '{nodes} 条 · 合计 ≈{tokens} token{mode}',
69
+ modeHeuristic: '(启发式估算:词表未加载)',
70
+ kindUser: '用户',
71
+ kindAssistant: '助手',
72
+ kindTool: '工具',
73
+ emptyContext: '该会话上下文为空',
74
+ showMore: '显示更多(剩余 {n})',
75
+ detailTitle: '条目详情',
76
+ detailTokens: '≈{tokens} token · {chars} 字符',
77
+ detailUsage: '真实用量:输入 {input} / 输出 {output}{cache}',
78
+ detailUsageCache: ' / 缓存读 {cache}',
79
+ close: '关闭',
80
+ deleteTitle: '确认裁剪',
81
+ deleteConfirm: '即将把 {n} 条消息(约 {tokens} token)从模型视野中移除,替换为一条标记消息。日志中保留痕迹,此操作不可恢复。继续?',
82
+ deleteBusyHint: '会话正在运行,等当前回合结束后再裁剪',
83
+ deleteOk: '执行',
84
+ cancel: '取消',
85
+ deletedToast: '已删除 {n} 条(约 {tokens} token)',
86
+ operationFailed: '操作失败',
87
+ seqLabel: 'seq {seq}',
88
+ }
89
+
90
+ const EN = {
91
+ title: 'Context Razor',
92
+ hint: 'Every model-visible context entry with a ≈token estimate (cl100k_base, same ranks as Skills Market). Delete removes entries from the model view (append-only log keeps traces, irreversible) — no LLM summarization involved.',
93
+ sessionLabel: 'Session',
94
+ pickSession: 'Pick a session…',
95
+ loading: 'Loading…',
96
+ refresh: 'Refresh',
97
+ busyTag: 'running',
98
+ idleTag: 'idle',
99
+ thresholdLabel: 'Red threshold',
100
+ thresholdUnit: 'tokens',
101
+ sortLabel: 'Sort',
102
+ sortOrder: 'Context order',
103
+ sortTokens: 'tokens high→low',
104
+ overOnly: 'Over threshold only',
105
+ selectOver: 'Select over threshold',
106
+ clearSelect: 'Clear selection',
107
+ deleteSelected: 'Delete selected',
108
+ selectedStats: '{n} selected / ≈{tokens} tokens',
109
+ stats: '{nodes} entries · total ≈{tokens} tokens{mode}',
110
+ modeHeuristic: ' (heuristic: ranks unavailable)',
111
+ kindUser: 'User',
112
+ kindAssistant: 'Assistant',
113
+ kindTool: 'Tool',
114
+ emptyContext: 'This session has no context entries',
115
+ showMore: 'Show more ({n} left)',
116
+ detailTitle: 'Entry detail',
117
+ detailTokens: '≈{tokens} tokens · {chars} chars',
118
+ detailUsage: 'Actual usage: in {input} / out {output}{cache}',
119
+ detailUsageCache: ' / cache-read {cache}',
120
+ close: 'Close',
121
+ deleteTitle: 'Confirm trim',
122
+ deleteConfirm: 'About to remove {n} messages (≈{tokens} tokens) from the model view, replaced by one marker message. Traces stay in the log; this cannot be undone. Continue?',
123
+ deleteBusyHint: 'Session is running — trim after the current turn finishes',
124
+ deleteOk: 'Delete',
125
+ cancel: 'Cancel',
126
+ deletedToast: 'Deleted {n} entries (≈{tokens} tokens)',
127
+ operationFailed: 'Operation failed',
128
+ seqLabel: 'seq {seq}',
129
+ }
130
+
131
+ // ── Pure helpers ────────────────────────────────────────────────────────
132
+
133
+ const API = '/context-razor/api'
134
+ const PAGE_SIZE = 150
135
+ const DEFAULT_THRESHOLD = 500
136
+ const THRESHOLD_KEY = 'context-razor-threshold'
137
+
138
+ const kindI18n = (key) => 'kind' + key[0].toUpperCase() + key.slice(1)
139
+
140
+ /** 行是否标红(仅 user/assistant/tool 有意义,阈值 <= 0 视为关闭)。 */
141
+ function overThreshold(entry, threshold) {
142
+ return threshold > 0 && typeof entry.tokens === 'number' && entry.tokens > threshold
143
+ }
144
+
145
+ /** 排序:order = 投影原序;tokens = 降序(并列按 seq,缺 token 沉底)。 */
146
+ function sortEntries(entries, sortBy) {
147
+ if (sortBy !== 'tokens') return entries
148
+ return [...entries].sort((a, b) => {
149
+ const va = a.tokens, vb = b.tokens
150
+ if (va === vb) return a.seq - b.seq
151
+ if (va === undefined || va === null) return 1
152
+ if (vb === undefined || vb === null) return -1
153
+ return vb - va
154
+ })
155
+ }
156
+
157
+ const formatNum = (n) => Number.isFinite(n) ? n.toLocaleString('en-US') : '-'
158
+
159
+ async function getJson(url) {
160
+ const r = await fetch(url)
161
+ if (!r.ok) {
162
+ const body = await r.json().catch(() => ({}))
163
+ throw new Error(body.error || 'HTTP ' + r.status)
164
+ }
165
+ return r.json()
166
+ }
167
+
168
+ // ── Token-based stylesheet (light/dark adaptive by construction) ────────
169
+
170
+ const STYLE = `<style>
171
+ .rz-page{position:relative;display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:var(--dsw-font-sm-14,14px)}
172
+ .rz-hint{color:var(--dsw-alias-label-secondary);font-size:12.5px;line-height:1.5}
173
+ .rz-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
174
+ .rz-spacer{flex:1}
175
+ .rz-label{color:var(--dsw-alias-label-secondary);font-size:12.5px;white-space:nowrap}
176
+ .rz-input{min-height:30px;padding:4px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-specific-input-major,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);font-size:13px;font-family:var(--dsw-font-family);outline:none}
177
+ .rz-input:focus{border-color:var(--dsw-alias-state-business-primary)}
178
+ .rz-select{min-height:30px;padding:4px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-specific-input-major,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);font-size:13px;font-family:var(--dsw-font-family);outline:none}
179
+ .rz-stats{display:flex;gap:12px;align-items:center;flex-wrap:wrap;color:var(--dsw-alias-label-secondary);font-size:12.5px}
180
+ .rz-badge{display:inline-flex;align-items:center;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11.5px;white-space:nowrap}
181
+ .rz-badge.over{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);font-weight:600}
182
+ .rz-chip{display:inline-flex;align-items:center;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:11.5px;white-space:nowrap;flex:none}
183
+ .rz-chip.user{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}
184
+ .rz-chip.assistant{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}
185
+ .rz-list{display:flex;flex-direction:column;gap:6px}
186
+ .rz-row{display:flex;gap:10px;align-items:center;padding:8px 12px;border-radius:10px;border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);cursor:pointer;text-align:left;width:100%}
187
+ .rz-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
188
+ .rz-row.over{border-left-color:var(--dsw-alias-state-error-primary)}
189
+ .rz-row.checked{border-color:var(--dsw-alias-state-business-primary)}
190
+ .rz-row-preview{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary);font-size:13px}
191
+ .rz-row.over .rz-row-preview{color:var(--dsw-alias-label-primary)}
192
+ .rz-row-meta{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none}
193
+ .rz-empty{border:1px dashed var(--dsw-alias-border-l2);border-radius:12px;padding:36px 20px;text-align:center;color:var(--dsw-alias-label-secondary)}
194
+ .rz-loading{padding:36px;text-align:center;color:var(--dsw-alias-label-secondary)}
195
+ .rz-footbtns{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
196
+ .rz-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:30px;padding:5px 14px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12.5px;font-weight:500;cursor:pointer;font-family:var(--dsw-font-family);white-space:nowrap}
197
+ .rz-btn:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}
198
+ .rz-btn:disabled{opacity:.45;cursor:not-allowed}
199
+ .rz-btn-danger{background:var(--dsw-alias-state-error-primary);border-color:transparent;color:var(--dsw-alias-label-primary-inverted,#fff)}
200
+ .rz-btn-danger:hover{filter:brightness(1.08);background:var(--dsw-alias-state-error-primary)}
201
+ .rz-dlg-backdrop{position:fixed;inset:0;z-index:30;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:24px}
202
+ .rz-dlg{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:14px;min-width:360px;max-width:720px;max-width:min(720px,92vw);max-height:82vh;overflow:auto;padding:18px;box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family)}
203
+ .rz-dlg h3{margin:0 0 12px;font-size:15px}
204
+ .rz-dlg-foot{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
205
+ .rz-dlg-text{white-space:pre-wrap;font-size:12.5px;line-height:1.55;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:12px;max-height:46vh;overflow:auto}
206
+ .rz-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:40;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:8px 18px;font-size:13px;box-shadow:var(--dsw-shadow-lv2)}
207
+ </style>`
208
+
209
+ // ── Small building blocks ────────────────────────────────────────────────
210
+
211
+ const Chip = ({ kind, label }) => h('span', { className: 'rz-chip ' + kind }, label)
212
+
213
+ const TokenBadge = ({ entry, threshold }) => {
214
+ const over = overThreshold(entry, threshold)
215
+ return h('span', { className: 'rz-badge' + (over ? ' over' : ''), title: over ? '≥ 阈值 ' + threshold : undefined },
216
+ '≈' + formatNum(entry.tokens))
217
+ }
218
+
219
+ /** 分页列表:先渲染 pageSize 行,按需增长(大会话一次挂几千行会卡)。 */
220
+ function PagedList({ items, render, t }) {
221
+ const [shown, setShown] = useState(PAGE_SIZE)
222
+ useEffect(() => { setShown(PAGE_SIZE) }, [items])
223
+ return [
224
+ ...items.slice(0, shown).map(render),
225
+ items.length > shown && h('div', { style: { textAlign: 'center', margin: '10px 0' } },
226
+ h('button', { className: 'rz-btn', onClick: () => setShown(n => n + PAGE_SIZE) }, t('showMore', { n: items.length - shown }))),
227
+ ]
228
+ }
229
+
230
+ /** 单条全文弹窗(正文经 /entry 异步补全)。 */
231
+ function DetailModal({ detail, threshold, t, onClose }) {
232
+ if (!detail) return null
233
+ return h('div', { className: 'rz-dlg-backdrop', onClick: onClose },
234
+ h('div', { className: 'rz-dlg', onClick: e => e.stopPropagation() },
235
+ h('h3', null, t('detailTitle') + ' · ' + t('seqLabel', { seq: detail.seq })),
236
+ h('div', { className: 'rz-stats', style: { marginBottom: 10 } },
237
+ h(Chip, { kind: detail.kind, label: t(kindI18n(detail.kind)) }),
238
+ h(TokenBadge, { entry: detail, threshold }),
239
+ h('span', { className: 'rz-label' }, t('detailTokens', { tokens: formatNum(detail.tokens), chars: formatNum(detail.chars) })),
240
+ detail.usage && h('span', { className: 'rz-label' }, t('detailUsage', {
241
+ input: formatNum(detail.usage.input), output: formatNum(detail.usage.output),
242
+ cache: detail.usage.cacheRead ? t('detailUsageCache', { cache: formatNum(detail.usage.cacheRead) }) : '' }))),
243
+ h('div', { className: 'rz-dlg-text' }, detail.text || ' '),
244
+ h('div', { className: 'rz-dlg-foot' },
245
+ h('button', { className: 'rz-btn', onClick: onClose }, t('close')))))
246
+ }
247
+
248
+ /** 裁剪确认弹窗。 */
249
+ function ConfirmDialog({ n, tokens, deleting, t, onCancel, onOk }) {
250
+ return h('div', { className: 'rz-dlg-backdrop', onClick: onCancel },
251
+ h('div', { className: 'rz-dlg', onClick: e => e.stopPropagation() },
252
+ h('h3', null, t('deleteTitle')),
253
+ h('div', { className: 'rz-hint' }, t('deleteConfirm', { n, tokens })),
254
+ h('div', { className: 'rz-dlg-foot' },
255
+ h('button', { className: 'rz-btn', onClick: onCancel }, t('cancel')),
256
+ h('button', { className: 'rz-btn rz-btn-danger', disabled: deleting, onClick: onOk }, t('deleteOk')))))
257
+ }
258
+
259
+ // ── Page ────────────────────────────────────────────────────────────────
260
+
261
+ function RazorPage({ t }) {
262
+ const [sessions, setSessions] = useState(null)
263
+ const [sessionId, setSessionId] = useState('')
264
+ const [context, setContext] = useState(null)
265
+ const [ctxLoading, setCtxLoading] = useState(false)
266
+ const [error, setError] = useState(null)
267
+ const [threshold, setThreshold] = useState(() => {
268
+ try { const v = Number(localStorage.getItem(THRESHOLD_KEY)); return Number.isFinite(v) && v > 0 ? v : DEFAULT_THRESHOLD } catch { return DEFAULT_THRESHOLD }
269
+ })
270
+ const [sortBy, setSortBy] = useState('order')
271
+ const [overOnly, setOverOnly] = useState(false)
272
+ const [selected, setSelected] = useState(() => new Set())
273
+ const [detail, setDetail] = useState(null)
274
+ const [confirming, setConfirming] = useState(false)
275
+ const [deleting, setDeleting] = useState(false)
276
+ const [toast, setToast] = useState(null)
277
+ const sessionRef = useRef('')
278
+
279
+ const showToast = (text) => { setToast(text); setTimeout(() => setToast(null), 3000) }
280
+
281
+ const loadSessions = () => getJson(API + '/sessions').then(d => setSessions(d.sessions || [])).catch(e => setError(e.message))
282
+ useEffect(() => { loadSessions() }, [])
283
+
284
+ const loadContext = (id) => {
285
+ if (!id) { setContext(null); return }
286
+ setCtxLoading(true)
287
+ setError(null)
288
+ getJson(API + '/context?session=' + encodeURIComponent(id))
289
+ .then(d => { setContext(d); setSelected(new Set()) })
290
+ .catch(e => setError(e.message))
291
+ .finally(() => setCtxLoading(false))
292
+ }
293
+ useEffect(() => { if (sessionId) loadContext(sessionId) }, [sessionId])
294
+
295
+ const refreshAll = () => {
296
+ loadSessions()
297
+ if (sessionRef.current) loadContext(sessionRef.current)
298
+ }
299
+
300
+ const pickSession = (id) => { sessionRef.current = id; setSessionId(id) }
301
+ const changeThreshold = (v) => {
302
+ const n = Number(v)
303
+ setThreshold(Number.isFinite(n) && n > 0 ? n : 0)
304
+ try { localStorage.setItem(THRESHOLD_KEY, String(n)) } catch {}
305
+ }
306
+
307
+ const visible = useMemo(() => {
308
+ if (!context) return []
309
+ let rows = context.entries
310
+ if (overOnly && threshold > 0) rows = rows.filter(e => overThreshold(e, threshold))
311
+ return sortEntries(rows, sortBy)
312
+ }, [context, overOnly, threshold, sortBy])
313
+
314
+ const overList = useMemo(() => (context ? context.entries.filter(e => overThreshold(e, threshold)) : []), [context, threshold])
315
+ const selectedTokens = useMemo(() => {
316
+ if (!context) return 0
317
+ const bySeq = new Map(context.entries.map(e => [e.seq, e]))
318
+ let sum = 0
319
+ for (const seq of selected) { const e = bySeq.get(seq); if (e) sum += e.tokens }
320
+ return sum
321
+ }, [context, selected])
322
+
323
+ const toggleRow = (seq) => setSelected(prev => {
324
+ const next = new Set(prev)
325
+ if (next.has(seq)) next.delete(seq)
326
+ else next.add(seq)
327
+ return next
328
+ })
329
+ const selectOver = () => setSelected(new Set(overList.map(e => e.seq)))
330
+
331
+ const openDetail = (entry) => {
332
+ setDetail(entry)
333
+ getJson(`${API}/entry?session=${encodeURIComponent(sessionId)}&seq=${entry.seq}`)
334
+ .then(d => setDetail(cur => (cur && cur.seq === entry.seq) ? { ...cur, text: d.text, tokens: d.tokens } : cur))
335
+ .catch(() => {})
336
+ }
337
+
338
+ const doDelete = async () => {
339
+ setDeleting(true)
340
+ try {
341
+ const r = await fetch(API + '/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session: sessionId, seqs: [...selected] }) })
342
+ const d = await r.json().catch(() => ({}))
343
+ if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
344
+ setConfirming(false)
345
+ setSelected(new Set())
346
+ showToast(t('deletedToast', { n: d.removed, tokens: formatNum(d.tokensRemoved) }))
347
+ refreshAll()
348
+ } catch (e) {
349
+ setConfirming(false)
350
+ setError(e.message)
351
+ } finally { setDeleting(false) }
352
+ }
353
+
354
+ const busy = !!(context && context.busy)
355
+ const canDelete = selected.size > 0 && !busy && !deleting
356
+
357
+ return h('div', { className: 'rz-page' },
358
+ h('div', { className: 'rz-hint' }, t('hint')),
359
+ h('div', { className: 'rz-toolbar' },
360
+ h('span', { className: 'rz-label' }, t('sessionLabel')),
361
+ h('select', { className: 'rz-select', value: sessionId, onChange: e => pickSession(e.target.value), style: { maxWidth: 380 } },
362
+ h('option', { value: '' }, t('pickSession')),
363
+ (sessions || []).map(s => h('option', { key: s.id, value: s.id },
364
+ (s.cwd ? s.cwd.split('/').pop() + ' · ' : '') + s.id.slice(0, 8) + ' · ' + s.nodes))),
365
+ h('button', { className: 'rz-btn', onClick: refreshAll, title: t('refresh') }, t('refresh')),
366
+ context && h('span', { className: 'rz-badge' }, context.busy ? t('busyTag') : t('idleTag'))),
367
+ error && h('div', { className: 'rz-hint', style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('operationFailed') + ': ' + error),
368
+ !sessionId && (sessions === null
369
+ ? h('div', { className: 'rz-loading' }, t('loading'))
370
+ : h('div', { className: 'rz-empty' }, t('pickSession'))),
371
+ sessionId && ctxLoading && h('div', { className: 'rz-loading' }, t('loading')),
372
+ sessionId && !ctxLoading && context && [
373
+ h('div', { key: 'stats', className: 'rz-stats' },
374
+ h('span', null, t('stats', { nodes: context.nodes, tokens: formatNum(context.totalTokens), mode: context.encoder === 'heuristic' ? t('modeHeuristic') : '' })),
375
+ h('span', { className: 'rz-spacer' }),
376
+ h('label', { className: 'rz-label', style: { display: 'inline-flex', gap: 6, alignItems: 'center' } },
377
+ t('thresholdLabel'),
378
+ h('input', { className: 'rz-input', type: 'number', min: 0, step: 50, value: threshold, onChange: e => changeThreshold(e.target.value), style: { width: 90 } }),
379
+ t('thresholdUnit')),
380
+ h('select', { className: 'rz-select', value: sortBy, onChange: e => setSortBy(e.target.value), title: t('sortLabel'), 'aria-label': t('sortLabel') },
381
+ h('option', { value: 'order' }, t('sortOrder')),
382
+ h('option', { value: 'tokens' }, t('sortTokens'))),
383
+ h('label', { className: 'rz-label', style: { display: 'inline-flex', gap: 6, alignItems: 'center' } },
384
+ h('input', { type: 'checkbox', checked: overOnly, onChange: e => setOverOnly(e.target.checked) }), t('overOnly'))),
385
+ h('div', { key: 'sel', className: 'rz-stats' },
386
+ h('span', null, selected.size > 0 ? t('selectedStats', { n: selected.size, tokens: formatNum(selectedTokens) }) : ' '),
387
+ h('span', { className: 'rz-spacer' }),
388
+ h('div', { className: 'rz-footbtns' },
389
+ h('button', { className: 'rz-btn', onClick: selectOver, disabled: overList.length === 0 || busy }, t('selectOver') + (overList.length ? ` (${overList.length})` : '')),
390
+ h('button', { className: 'rz-btn', onClick: () => setSelected(new Set()), disabled: selected.size === 0 }, t('clearSelect')),
391
+ h('button', { className: 'rz-btn rz-btn-danger', disabled: !canDelete,
392
+ title: busy ? t('deleteBusyHint') : undefined,
393
+ onClick: () => setConfirming(true) },
394
+ t('deleteSelected') + (selected.size ? ` (${selected.size})` : '')))),
395
+ visible.length === 0
396
+ ? h('div', { key: 'empty', className: 'rz-empty' }, overOnly ? t('overOnly') + ' · ' + t('emptyContext') : t('emptyContext'))
397
+ : h('div', { key: 'list', className: 'rz-list' },
398
+ h(PagedList, { items: visible, t, render: entry => {
399
+ const over = overThreshold(entry, threshold)
400
+ return h('div', { key: entry.seq, className: 'rz-row' + (over ? ' over' : '') + (selected.has(entry.seq) ? ' checked' : ''),
401
+ role: 'button', tabIndex: 0, onClick: () => toggleRow(entry.seq),
402
+ onKeyDown: e => e.key === 'Enter' && toggleRow(entry.seq) },
403
+ h('input', { type: 'checkbox', checked: selected.has(entry.seq), onClick: e => e.stopPropagation(), onChange: () => toggleRow(entry.seq) }),
404
+ h(Chip, { kind: entry.kind, label: t(kindI18n(entry.kind)) }),
405
+ h('span', { className: 'rz-row-preview', title: entry.preview }, entry.preview || ' '),
406
+ h(TokenBadge, { entry, threshold }),
407
+ h('button', { className: 'rz-btn', style: { minHeight: 24, padding: '2px 8px' },
408
+ onClick: e => { e.stopPropagation(); openDetail(entry) } }, '⋯'),
409
+ h('span', { className: 'rz-row-meta' }, 'seq ' + entry.seq))
410
+ } })),
411
+ detail && h(DetailModal, { detail, threshold, t, onClose: () => setDetail(null) }),
412
+ confirming && h(ConfirmDialog, { n: selected.size, tokens: formatNum(selectedTokens), deleting, t,
413
+ onCancel: () => setConfirming(false), onOk: doDelete }),
414
+ ],
415
+ toast && h('div', { className: 'rz-toast' }, toast))
416
+ }
417
+
418
+ // ── Plugin plane contract ────────────────────────────────────────────────
419
+
420
+ const CLIENT_NAME = '@weibaohui/context-razor'
421
+
422
+ module.exports = {
423
+ name: CLIENT_NAME,
424
+ inject: ['slots', 'locale'],
425
+ __internals: { NS, ZH, EN, overThreshold, sortEntries },
426
+ __boot(container, opts = {}) {
427
+ ensureStyles()
428
+ const t = opts.t || ((key, vars) => {
429
+ let out = EN[key] ?? key
430
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
431
+ return out
432
+ })
433
+ const root = require('react-dom/client').createRoot(container)
434
+ root.render(h(RazorPage, { t }))
435
+ return root
436
+ },
437
+ apply(ctx) {
438
+ let t = (key, vars) => {
439
+ let out = EN[key] ?? key
440
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
441
+ return out
442
+ }
443
+ try {
444
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
445
+ ctx.locale.register(NS, 'zh', ZH)
446
+ ctx.locale.register(NS, 'en', EN)
447
+ const bound = typeof ctx.locale.bind === 'function' ? ctx.locale.bind(NS) : null
448
+ if (bound) t = (key, vars) => {
449
+ let out = bound(key) || key
450
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
451
+ return out
452
+ }
453
+ }
454
+ } catch (e) { try { console.error('[context-razor] locale init:', e) } catch {} }
455
+ ctx.effect(() => {
456
+ try {
457
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
458
+ name: 'settings.section',
459
+ id: CLIENT_NAME,
460
+ order: 91,
461
+ locale: NS,
462
+ label: () => t('title'),
463
+ inject: () => ({}),
464
+ }, function RazorSectionSlot() {
465
+ useEffect(ensureStyles, [])
466
+ return h(RazorPage, { t })
467
+ }))
468
+ } catch (e) { (globalThis.__rzErrors = globalThis.__rzErrors || []).push('settings:' + (e && e.message)); throw e }
469
+ }, 'context-razor: settings section')
470
+ },
471
+ }
@@ -0,0 +1,11 @@
1
+ # dsh-plugin-context-razor bundle patch: contributes the plugin row into the host
2
+ # composition when this package is installed via `dsh plugin add`.
3
+ #
4
+ # The plugin is HOST-PLANE: it reads host session logs (ctx.sessions) and
5
+ # serves its HTTP API over a webServer route; its client half registers a
6
+ # settings section. It must therefore land in the host composition (this
7
+ # insert), never inside an agent preset.
8
+
9
+ - insert:
10
+ - id: context-razor
11
+ name: '@weibaohui/context-razor'
package/docs/demo.gif ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@weibaohui/context-razor",
3
+ "version": "0.1.0",
4
+ "description": "dsh 插件 · 上下文剃刀:把当前会话上下文逐条列出(角色/预览/≈token,cl100k 估算),超阈值标红,勾选后精确裁剪——不经 LLM 总结,删了什么一目了然。",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "dsh",
8
+ "deepseek-harness",
9
+ "cordis",
10
+ "plugin",
11
+ "context",
12
+ "token",
13
+ "trim",
14
+ "razor",
15
+ "dsh-plugin"
16
+ ],
17
+ "main": "src/index.js",
18
+ "exports": {
19
+ ".": "./src/index.js",
20
+ "./client": "./client/bundle.js",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "dsh": {
24
+ "bundle": {
25
+ "patch": "./cordis.patch.yml"
26
+ },
27
+ "client": {
28
+ "platform": "web"
29
+ }
30
+ },
31
+ "files": [
32
+ "src",
33
+ "client",
34
+ "docs",
35
+ "cordis.patch.yml"
36
+ ],
37
+ "scripts": {
38
+ "check": "node --check src/index.js && node --check client/index.js",
39
+ "test": "node --test test/*.test.mjs",
40
+ "build:client": "node scripts/build-client.mjs",
41
+ "prepublishOnly": "npm run build:client && npm run check && npm test"
42
+ },
43
+ "engines": {
44
+ "node": ">=22.5"
45
+ },
46
+ "dependencies": {
47
+ "js-tiktoken": "^1.0.21"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/weibaohui/context-razor.git"
52
+ },
53
+ "homepage": "https://github.com/weibaohui/context-razor#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/weibaohui/context-razor/issues"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }