@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.
package/src/index.js ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * dsh-plugin-razor — Host half.
3
+ *
4
+ * 上下文剃刀:把会话上下文的每条消息列出来(角色/预览/≈token),让用户
5
+ * 勾选后精确裁剪。删除不是物理删日志(append-only、深冻结、zstd 校验,
6
+ * 宿主根本不支持),而是走宿主的 surface replace 协议——与官方
7
+ * dsh-compaction-tool-result-pruner 同款:先追加一条 `compaction/prune`
8
+ * 计价事件,再追加一条带 `surfaceOp: {op:'replace'}` 的标记 user/message,
9
+ * `sourceEventSeqs` 覆盖全部被影子化的节点。被删节点从此不进
10
+ * `deriveMessages()`(模型视野消失),web UI 也随 mux fold 同步折叠。
11
+ * 全程不经 LLM 总结——删了什么、删了多少,用户逐条可见。
12
+ */
13
+
14
+ const { randomUUID } = require('node:crypto')
15
+ const { homedir } = require('node:os')
16
+ const { join } = require('node:path')
17
+
18
+ // ── token 计数(js-tiktoken cl100k_base,与 skills-management 同源同值)──
19
+ // 降级模式退回宿主 tokenMeter 同款 chars/4 启发式(token 计数是本插件
20
+ // 核心功能,不能像 skills-management 那样直接不显示)。
21
+ const RAZOR_PLUGIN_NAME = '@weibaohui/context-razor'
22
+ let usageEncoder = null
23
+ let usageEncoderFailed = false
24
+ function usageEncoderLazy() {
25
+ if (usageEncoderFailed) return null
26
+ if (usageEncoder === null) {
27
+ try {
28
+ const { Tiktoken } = require('js-tiktoken/lite')
29
+ usageEncoder = new Tiktoken(require('js-tiktoken/ranks/cl100k_base'))
30
+ } catch { usageEncoderFailed = true }
31
+ }
32
+ return usageEncoder
33
+ }
34
+ const USAGE_MEMO_CAP = 30000
35
+ const usageMemo = new Map()
36
+ /** cl100k_base 估算;词表不可用时退回 chars/4 启发式。返回 { tokens, mode }。 */
37
+ function countTokens(text) {
38
+ if (typeof text !== 'string' || text === '') return { tokens: 0, mode: 'cl100k' }
39
+ const enc = usageEncoderLazy()
40
+ if (enc !== null) {
41
+ let tokens = usageMemo.get(text)
42
+ if (tokens === undefined) {
43
+ tokens = enc.encode(text).length
44
+ if (usageMemo.size >= USAGE_MEMO_CAP) usageMemo.clear()
45
+ usageMemo.set(text, tokens)
46
+ }
47
+ return { tokens, mode: 'cl100k' }
48
+ }
49
+ return { tokens: Math.ceil(text.length / 4) + 4, mode: 'heuristic' }
50
+ }
51
+
52
+ // ── 事件 → 模型可见文本 ────────────────────────────────────────────────
53
+ // surface 上只有三类事件(dsh-session SURFACE_EVENT_TYPES);ContentBlock
54
+ // 是 merge-extensible 联合,已知块取字段,未知块按 JSON 长度保守计价。
55
+ function blockText(block) {
56
+ if (block === null || typeof block !== 'object') return ''
57
+ if (block.type === 'text' || block.type === 'reasoning') return typeof block.text === 'string' ? block.text : ''
58
+ if (block.type === 'tool-result') return (Array.isArray(block.content) ? block.content : []).map(blockText).join('\n')
59
+ return ''
60
+ }
61
+ function blocksText(blocks) {
62
+ return Array.isArray(blocks) ? blocks.map(blockText).filter(Boolean).join('\n') : ''
63
+ }
64
+ function entryText(event) {
65
+ const data = event.data || {}
66
+ if (event.type === 'user/message') return blocksText(data.content)
67
+ if (event.type === 'assistant/message') return blocksText(data.message && data.message.content)
68
+ if (event.type === 'tool/result') return blocksText(data.message && data.message.content)
69
+ return ''
70
+ }
71
+ function entryKindOf(type) {
72
+ if (type === 'user/message') return 'user'
73
+ if (type === 'assistant/message') return 'assistant'
74
+ if (type === 'tool/result') return 'tool'
75
+ return type
76
+ }
77
+
78
+ /** 会话是否有未收口的 turn(闲时才允许 surface replace,避免与运行中的 agent 竞态)。 */
79
+ function sessionBusy(session) {
80
+ let lastStart = -1
81
+ let lastEnd = -1
82
+ for (const event of session.events) {
83
+ if (event.type === 'turn/start') lastStart = event.seq
84
+ else if (event.type === 'turn/end') lastEnd = event.seq
85
+ }
86
+ return lastStart > lastEnd
87
+ }
88
+
89
+ /**
90
+ * 当前 surface 投影成条目列表:seq 有序(模型可见顺序)、逐条文本、
91
+ * token 估算。被此前 compaction/prune 影子化的节点本就不在 surface.nodes
92
+ * 里——列表即模型此刻真实可见的上下文。
93
+ */
94
+ function projectContext(session) {
95
+ const bySeq = new Map(session.events.map((event) => [event.seq, event]))
96
+ const entries = []
97
+ let totalTokens = 0
98
+ let mode = 'cl100k'
99
+ for (const seq of session.surface.nodes) {
100
+ const event = bySeq.get(seq)
101
+ if (event === undefined) continue
102
+ const text = entryText(event)
103
+ const { tokens, mode: m } = countTokens(text)
104
+ mode = m
105
+ const usage = event.type === 'assistant/message' ? event.data.usage : undefined
106
+ entries.push({
107
+ seq,
108
+ kind: entryKindOf(event.type),
109
+ time: event.time,
110
+ turn: typeof event.data.turn === 'number' ? event.data.turn : undefined,
111
+ chars: text.length,
112
+ tokens,
113
+ preview: text.length > 220 ? text.slice(0, 220) + '…' : text,
114
+ usage: usage && typeof usage === 'object'
115
+ ? { input: usage.inputTokens, output: usage.outputTokens, cacheRead: usage.cacheReadTokens }
116
+ : undefined,
117
+ })
118
+ totalTokens += tokens
119
+ }
120
+ return { entries, totalTokens, mode }
121
+ }
122
+
123
+ /** 选中 seq 按当前 surface 顺序分组成连续段(surface 上相邻才可一个 replace 覆盖)。 */
124
+ function groupRuns(surfaceNodes, wanted) {
125
+ const index = new Map(surfaceNodes.map((seq, i) => [seq, i]))
126
+ const unknown = wanted.filter((seq) => !index.has(seq))
127
+ if (unknown.length > 0) throw new Error(`seqs not on current surface: ${unknown.join(', ')}`)
128
+ const sorted = [...wanted].sort((a, b) => index.get(a) - index.get(b))
129
+ const runs = []
130
+ for (const seq of sorted) {
131
+ const last = runs[runs.length - 1]
132
+ if (last && index.get(seq) === index.get(last[last.length - 1]) + 1) last.push(seq)
133
+ else runs.push([seq])
134
+ }
135
+ return runs
136
+ }
137
+
138
+ /**
139
+ * 执行一次裁剪:每个连续段一对事件(prune 计价 + notice 替换),协议形状
140
+ * 与官方 dsh-compaction-tool-result-pruner 一致。append 抛错即整段回滚失败
141
+ * 信息(前段已成功的事件保留在日志里——append-only,不追求事务性)。
142
+ */
143
+ function deleteEntries(session, wantedSeqs) {
144
+ if (sessionBusy(session)) throw new Error('session is busy: wait for the running turn to finish')
145
+ const nodes = [...session.surface.nodes]
146
+ const runs = groupRuns(nodes, wantedSeqs)
147
+ const bySeq = new Map(session.events.map((event) => [event.seq, event]))
148
+ const done = []
149
+ let removed = 0
150
+ let tokensRemoved = 0
151
+ for (const run of runs) {
152
+ const start = run[0]
153
+ const end = run[run.length - 1]
154
+ let runTokens = 0
155
+ for (const seq of run) {
156
+ const event = bySeq.get(seq)
157
+ if (event !== undefined) runTokens += countTokens(entryText(event)).tokens
158
+ }
159
+ session.append('compaction/prune', {
160
+ shadowedRange: { start, end },
161
+ shadowedSeqs: run,
162
+ shadowedTokenCount: runTokens,
163
+ })
164
+ const summary = `已删除 ${run.length} 条历史(约 ${runTokens} token)`
165
+ const marker = createUserMessage({
166
+ content: [{ type: 'text', text: `[context-razor] 此处原有 ${run.length} 条历史消息(约 ${runTokens} token)已被用户删除以保持上下文聚焦;如后续对话需要被删部分的细节,请向用户确认。` }],
167
+ source: { kind: 'plugin', plugin: RAZOR_PLUGIN_NAME, form: 'notice', summary },
168
+ })
169
+ const replacement = session.append('user/message', marker, {
170
+ surfaceOp: { op: 'replace', start, end },
171
+ sourceEventSeqs: run,
172
+ })
173
+ done.push({ start, end, count: run.length, tokens: runTokens, replacement: replacement.seq })
174
+ removed += run.length
175
+ tokensRemoved += runTokens
176
+ }
177
+ return { removed, tokensRemoved, runs: done }
178
+ }
179
+
180
+ // ── 替换消息构造 ────────────────────────────────────────────────────────
181
+ // 优先用宿主 dsh-llm 的官方 createUserMessage(id/冻结与宿主一致)。宿主把
182
+ // 依赖装在自己 node_modules 里,从插件真实路径未必解析得到(pnpm 软链布局),
183
+ // 故先沿 dsh 全局安装探测(skills-management 的 schemastery 同款模式),再退
184
+ // 标准 require,最后手搓等价形状——append 侧自会做 JSON 合法性校验。
185
+ function loadCreateUserMessage() {
186
+ const { createRequire } = require('node:module')
187
+ for (const prefix of [process.env.DSH_GLOBAL_PREFIX, homedir()].filter(Boolean)) {
188
+ const hostCopy = join(prefix, 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-llm', 'lib', 'index.js')
189
+ try {
190
+ const mod = createRequire(hostCopy)(hostCopy)
191
+ if (typeof mod.createUserMessage === 'function') return mod.createUserMessage
192
+ } catch { /* 下一个来源 */ }
193
+ }
194
+ try {
195
+ const mod = require('@deepseek-ai/dsh-llm')
196
+ if (typeof mod.createUserMessage === 'function') return mod.createUserMessage
197
+ } catch { /* 手搓降级 */ }
198
+ return null
199
+ }
200
+ const createUserMessage = loadCreateUserMessage() ?? ((input) => ({ ...input, id: randomUUID(), role: 'user' }))
201
+
202
+ module.exports = {
203
+ name: 'context-razor',
204
+ inject: ['sessions', 'webServer'],
205
+ __internals: { countTokens, entryText, groupRuns, projectContext, sessionBusy, deleteEntries, usageMemo },
206
+
207
+ apply(ctx) {
208
+ ctx.effect(() => ctx.webServer.register({
209
+ kind: 'prefix',
210
+ path: '/context-razor/api',
211
+ handler: async (req, res) => {
212
+ try {
213
+ const url = new URL(req.url || '/', 'http://dsh.local')
214
+ const apiPath = url.pathname.replace(/\/+$/, '')
215
+ const query = url.searchParams
216
+
217
+ const sendJson = (status, payload) => {
218
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
219
+ res.end(JSON.stringify(payload))
220
+ }
221
+ const readJsonBody = () => new Promise((resolve, reject) => {
222
+ const chunks = []
223
+ req.on('data', (chunk) => chunks.push(chunk))
224
+ req.on('end', () => {
225
+ const raw = Buffer.concat(chunks).toString('utf8')
226
+ try { resolve(raw === '' ? {} : JSON.parse(raw)) } catch (e) { reject(e) }
227
+ })
228
+ req.on('error', reject)
229
+ })
230
+ const fail = (status, error) => sendJson(status, { error })
231
+
232
+ // GET /context-razor/api/sessions → 会话清单(轻量,不算 token)
233
+ if (req.method === 'GET' && apiPath.endsWith('/context-razor/api/sessions')) {
234
+ const sessions = (ctx.sessions.list() || [])
235
+ .map((session) => ({
236
+ id: session.id,
237
+ cwd: session.header && session.header.cwd,
238
+ createdAt: session.header && session.header.createdAt,
239
+ lastTime: session.events.length > 0 ? session.events[session.events.length - 1].time : undefined,
240
+ nodes: session.surface.nodes.length,
241
+ busy: sessionBusy(session),
242
+ }))
243
+ .sort((a, b) => String(b.lastTime || '').localeCompare(String(a.lastTime || '')))
244
+ sendJson(200, { sessions })
245
+ return
246
+ }
247
+
248
+ // GET /context-razor/api/context?session= → 逐条投影
249
+ if (req.method === 'GET' && apiPath.endsWith('/context-razor/api/context')) {
250
+ const session = ctx.sessions.get(query.get('session') || '')
251
+ if (session === undefined) { fail(404, 'session not found'); return }
252
+ const { entries, totalTokens, mode } = projectContext(session)
253
+ sendJson(200, {
254
+ id: session.id,
255
+ cwd: session.header && session.header.cwd,
256
+ busy: sessionBusy(session),
257
+ encoder: mode,
258
+ totalTokens,
259
+ nodes: session.surface.nodes.length,
260
+ entries,
261
+ })
262
+ return
263
+ }
264
+
265
+ // GET /context-razor/api/entry?session=&seq= → 单条全文
266
+ if (req.method === 'GET' && apiPath.endsWith('/context-razor/api/entry')) {
267
+ const session = ctx.sessions.get(query.get('session') || '')
268
+ if (session === undefined) { fail(404, 'session not found'); return }
269
+ const seq = Number(query.get('seq'))
270
+ const event = session.events.find((candidate) => candidate.seq === seq)
271
+ if (event === undefined) { fail(404, 'entry not found'); return }
272
+ const text = entryText(event)
273
+ sendJson(200, { seq, kind: entryKindOf(event.type), time: event.time, tokens: countTokens(text).tokens, text })
274
+ return
275
+ }
276
+
277
+ // POST /context-razor/api/delete { session, seqs } → 精确裁剪
278
+ if (req.method === 'POST' && apiPath.endsWith('/context-razor/api/delete')) {
279
+ const body = await readJsonBody()
280
+ const session = ctx.sessions.get(typeof body.session === 'string' ? body.session : '')
281
+ if (session === undefined) { fail(404, 'session not found'); return }
282
+ if (!Array.isArray(body.seqs) || body.seqs.length === 0 || !body.seqs.every((seq) => Number.isSafeInteger(seq) && seq >= 0)) {
283
+ fail(400, 'body must provide seqs: non-empty safe-integer array'); return
284
+ }
285
+ try {
286
+ const result = deleteEntries(session, body.seqs)
287
+ sendJson(200, result)
288
+ } catch (e) {
289
+ fail(409, (e && e.message) || 'delete failed')
290
+ }
291
+ return
292
+ }
293
+
294
+ fail(404, `no route for ${req.method} ${apiPath}`)
295
+ } catch (e) {
296
+ const payload = JSON.stringify({ error: (e && e.message) || 'internal error' })
297
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' })
298
+ res.end(payload)
299
+ }
300
+ },
301
+ }), 'context-razor: web api')
302
+ },
303
+ }