dsh-ebbing-context 0.0.0-stage → 0.2.6
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/CHANGELOG.md +0 -0
- package/LICENSE +21 -0
- package/README.md +91 -2
- package/client.js +369 -0
- package/dsh.bundle.patch.yml +28 -0
- package/icon.svg +11 -0
- package/index.js +1788 -0
- package/lib/blocks.js +1138 -0
- package/lib/callfold.js +288 -0
- package/lib/compress.js +1015 -0
- package/lib/config.js +195 -0
- package/lib/imageprice.js +96 -0
- package/lib/license.js +63 -0
- package/lib/log.js +135 -0
- package/lib/map.js +311 -0
- package/lib/messages.js +23 -0
- package/lib/state.js +183 -0
- package/lib/tokens.js +100 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +69 -4
package/lib/callfold.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// 把「这一次 compress 调用 + 它的返回」从表层折成一句短注。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要做:摘要里那份参数副本,在我们把原文折成块之后就成了纯粹的重复内容——
|
|
4
|
+
// 那段原文已经变成顶部的块,它却还留在本轮的调用参数里整份发一遍。
|
|
5
|
+
//
|
|
6
|
+
// 为什么不能在工具里当场做:工具返回的那一刻,宿主还没追加 tool/result。
|
|
7
|
+
// 只删调用会留下一条没妈的返回(下一次请求里就是"结果找不到对应调用")。
|
|
8
|
+
// 所以挪到下一次 agent/pre-step——它仍在同一个 turn 内,且发生在下一次请求之前,
|
|
9
|
+
// 于是那份参数副本根本不会被再发一次;模型看到的是下面这段短注。
|
|
10
|
+
//
|
|
11
|
+
// 为什么替换后必须是 user/message:宿主不允许把节点替换成 assistant/message
|
|
12
|
+
// (session-surface.ts:335-337:assistant/message embeds its source stream and cannot
|
|
13
|
+
// carry sourceEventSeqs),而只留一条 tool/result 又没有调用,会成孤儿。
|
|
14
|
+
// 两个节点一起换成一条用户消息,正好满足"来源列表覆盖所有被遮节点"的硬要求。
|
|
15
|
+
import { userMessage } from './messages.js'
|
|
16
|
+
|
|
17
|
+
const TOOL_NAME = 'compress'
|
|
18
|
+
const QUEUE_LIMIT = 4 // 同一会话最多挂几次待折叠,超了丢最老的,内存不至于随会话增长
|
|
19
|
+
|
|
20
|
+
export const FOLD_NOTE_HEAD = '〔压缩调用记录已折叠〕'
|
|
21
|
+
// 这条短注会以 user/message 的身份出现在表层(宿主不接受助手消息带 sourceEventSeqs),
|
|
22
|
+
// 所以第一行必须自报家门,免得模型或人把它当成用户说的话(回归修复)。
|
|
23
|
+
export const FOLD_NOTE_MARK = '〔Ebbing Context 插件注入〕'
|
|
24
|
+
// 自己的来源标记:宿主允许自定义 kind,用它就不必再借 'user'(那会让界面把它画成用户气泡,
|
|
25
|
+
// 也会让 pre-step 的"用户真的说话了"判定把它算进去)。
|
|
26
|
+
export const FOLD_KIND = 'ebbing-context-fold'
|
|
27
|
+
|
|
28
|
+
// 每次同 callId 的调用开始前先作废旧的待折项。
|
|
29
|
+
// 因为 callId 的重复只保证「同一条助手消息内不重号」,跨消息可以复用;
|
|
30
|
+
// 若上一次成功登记了却没折成、这一次又失败了,旧记录会把失败折成「已经成功」。
|
|
31
|
+
export function forgetFold(queue, callId) {
|
|
32
|
+
if (!callId) return
|
|
33
|
+
queue.delete(String(callId))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 这一次 compress 调用落在表层上的节点号。
|
|
37
|
+
// 工具执行的那一刻,宿主的 tool/result 还没追加,所以调用消息就是最后一个节点。
|
|
38
|
+
// 记下它,折叠时就不必假设"消息下标 = 节点下标"——会话里出现空节点(宿主会跳过渲染)
|
|
39
|
+
// 时两张表会错位,靠这个节点号仍能精确定位到那一对。
|
|
40
|
+
export function callNodeOf(session, callId = null) {
|
|
41
|
+
const nodes = session && session.surface ? session.surface.nodes : null
|
|
42
|
+
if (!nodes || !nodes.length) return null
|
|
43
|
+
const seq = nodes[nodes.length - 1]
|
|
44
|
+
// 只有"末节点确实是这一次调用的助手消息"才算数:顺序批(先 pwsh、后 compress)里
|
|
45
|
+
// 宿主逐条落结果,末节点可能是兄弟工具的返回。拿它当调用节点会让折叠范围剥掉兄弟结果、
|
|
46
|
+
// 留下一条没妈的调用——下一次请求直接 400。这里宁可返回 null,退回兜底扫描。
|
|
47
|
+
if (!callId) return seq
|
|
48
|
+
const message = messageAtNode(session, seq)
|
|
49
|
+
const calls = toolCallsOf(message)
|
|
50
|
+
if (calls.length !== 1 || calls[0].id !== String(callId)) return null
|
|
51
|
+
return seq
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 登记一次成功的压缩调用,等下一次 pre-step 来收。
|
|
55
|
+
export function rememberFold(queue, callId, lines, callSeq = null) {
|
|
56
|
+
if (!callId || !Array.isArray(lines) || lines.length === 0) return
|
|
57
|
+
const key = String(callId)
|
|
58
|
+
// 同一个 callId 出现两次就无法判断该折哪一条(Map 会被后者覆盖,
|
|
59
|
+
// 于是"成功后又有一次同 id 的失败调用"会被误折成成功)。整条放弃,宁可不折。
|
|
60
|
+
if (queue.has(key)) {
|
|
61
|
+
queue.delete(key)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
queue.set(key, { lines, callSeq })
|
|
65
|
+
while (queue.size > QUEUE_LIMIT) {
|
|
66
|
+
const oldest = queue.keys().next().value
|
|
67
|
+
queue.delete(oldest)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toolCallsOf(message) {
|
|
72
|
+
if (!message || message.role !== 'assistant' || !Array.isArray(message.content)) return []
|
|
73
|
+
const out = []
|
|
74
|
+
for (const block of message.content) {
|
|
75
|
+
if (!block || block.type !== 'tool-call') continue
|
|
76
|
+
const name = block.name || block.toolName
|
|
77
|
+
const id = block.id || block.toolCallId
|
|
78
|
+
if (name && id) out.push({ name: String(name), id: String(id) })
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toolResultCallId(message) {
|
|
84
|
+
if (!message || message.role !== 'tool') return null
|
|
85
|
+
if (message.toolCallId) return String(message.toolCallId)
|
|
86
|
+
if (Array.isArray(message.content)) {
|
|
87
|
+
for (const block of message.content) {
|
|
88
|
+
if (!block) continue
|
|
89
|
+
const id = block.toolCallId || block.id
|
|
90
|
+
if (id) return String(id)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildNote(lines) {
|
|
97
|
+
return [
|
|
98
|
+
FOLD_NOTE_HEAD + '(插件写的消息,不是你自己的话,也不是用户说的)',
|
|
99
|
+
FOLD_NOTE_MARK,
|
|
100
|
+
'你刚刚做过一次分层压缩,已经成功:',
|
|
101
|
+
// 单行的是回执要点,加 `- ` 列表化;多行的是整张地图(块表要对齐),原样贴,别加前缀。
|
|
102
|
+
...lines.map((line) => (String(line).includes('\n') ? line : '- ' + line)),
|
|
103
|
+
// 不要在这里提任何工具名(context_map 之类):模型见到"用 X 能看到"就会顺手去调一次,
|
|
104
|
+
// 而地图只给标题、不给摘要正文,那一趟纯属白花 token。
|
|
105
|
+
// 摘要正文就是各层块本身,本来就在它的上下文里。
|
|
106
|
+
'这次的工具调用与工具结果已经被折叠替换成本条消息;摘要正文就是各层的块本身,已经在你的上下文里。',
|
|
107
|
+
].join('\n')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 在派生消息的末尾附近找「一条只有一个 compress 调用的助手消息 + 紧邻它对得上的返回」,
|
|
111
|
+
// 再把它换算回表层节点下标。
|
|
112
|
+
//
|
|
113
|
+
// 注意"并行调用"的准确含义:**同一条助手消息里有两个及以上工具调用**(模型一次发多个),
|
|
114
|
+
// 不是"一轮里调用了多次工具"。一轮里连调三次工具是三个步骤、三条助手消息,每条只有一个调用,
|
|
115
|
+
// 这种情况完全不影响折叠——我们只看 compress 自己那一条消息。
|
|
116
|
+
//
|
|
117
|
+
// 只往末尾看几对:正常情况下 compress 的返回就是最后一个节点(pre-step 紧接着就触发),
|
|
118
|
+
// 留一点余量是为了容忍宿主在中间插了别的东西。
|
|
119
|
+
const SCAN_PAIRS = 3
|
|
120
|
+
|
|
121
|
+
// 精确路径最多回看多少个节点。正常情况返回就在末尾一两格,留余量是容忍宿主在中间插东西。
|
|
122
|
+
// 24 而不是 12:并发闸门(siblingCompressCount)靠这里找"含工具调用的助手消息",
|
|
123
|
+
// 末 12 格恰好全是长文本时会把两个 compress 数成一个(隔离实验提示的边界,用户同意加宽)。
|
|
124
|
+
const SCAN_NODES = 24
|
|
125
|
+
|
|
126
|
+
function messageAtNode(session, seq) {
|
|
127
|
+
if (!session || typeof session.deriveEventMessage !== 'function') return null
|
|
128
|
+
const log = session.log
|
|
129
|
+
if (!Array.isArray(log)) return null
|
|
130
|
+
const event = log[seq]
|
|
131
|
+
if (!event || typeof event !== 'object') return null
|
|
132
|
+
try {
|
|
133
|
+
return session.deriveEventMessage(event) || null
|
|
134
|
+
} catch {
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 精确找"我们登记过的那次调用 + 它自己的返回":
|
|
140
|
+
// 不看下标对齐、不假设一对相邻,只看 callId。同一条消息里带多个调用时返回 parallel。
|
|
141
|
+
function locateCall(session, queue) {
|
|
142
|
+
if (!queue || typeof queue.get !== 'function' || queue.size === 0) return null
|
|
143
|
+
const nodes = session.surface.nodes
|
|
144
|
+
const from = Math.max(0, nodes.length - SCAN_NODES)
|
|
145
|
+
for (let at = nodes.length - 1; at >= from; at -= 1) {
|
|
146
|
+
const calls = toolCallsOf(messageAtNode(session, nodes[at]))
|
|
147
|
+
if (!calls.length) continue
|
|
148
|
+
const mine = calls.find((c) => c.name === TOOL_NAME && queue.get(c.id))
|
|
149
|
+
if (!mine) continue
|
|
150
|
+
// 并行时返回可能乱序到达,所以在"这条消息调用数 + 余量"的窗口里按 callId 找。
|
|
151
|
+
const upto = Math.min(nodes.length - 1, at + calls.length + 2)
|
|
152
|
+
for (let j = at + 1; j <= upto; j += 1) {
|
|
153
|
+
if (toolResultCallId(messageAtNode(session, nodes[j])) !== mine.id) continue
|
|
154
|
+
// 结果可能是失败的(宿主或别的插件把它标成 isError)。
|
|
155
|
+
// 我们登记的是"成功过的那次调用",这时写"已经成功"就是假话——语义错不能靠信息量补。
|
|
156
|
+
// 精确路径以前漏了这条判据(回归修复):失败回执会被折成一句"已经成功",还带着完整地图。
|
|
157
|
+
const resultMessage = messageAtNode(session, nodes[j])
|
|
158
|
+
if (resultMessage && resultMessage.isError === true) return { reason: 'error-result', callId: mine.id }
|
|
159
|
+
return { callId: mine.id, callAt: at, resultAt: j, parallel: calls.length > 1 }
|
|
160
|
+
}
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
// 返回 { pair } 或 { reason }。
|
|
169
|
+
// 消息下标只用来"认"是哪一次调用(callId 是唯一的),**不用它去推节点下标**:
|
|
170
|
+
// 宿主会跳过内容为空的 system/developer/assistant 消息(dsh-session 的 deriveMessages
|
|
171
|
+
// 里 `if (msg) this.derived.push(msg)`),只要出现一个空节点,两张表就整体错位。
|
|
172
|
+
// 节点下标优先用 rememberFold 时记下的 callSeq,没有记录且数量相等时才退回按下标。
|
|
173
|
+
export function inspectTail(session, queue) {
|
|
174
|
+
// 先走精确路径:靠 session.log + deriveEventMessage 逐节点问"这个节点是哪条消息",
|
|
175
|
+
// 于是同一条助手消息里带了几个调用、返回的顺序有没有乱,都不影响定位。
|
|
176
|
+
const precise = locateCall(session, queue)
|
|
177
|
+
if (precise) {
|
|
178
|
+
// locateCall 也可能直接给出一个"不折"的原因(失败结果)。
|
|
179
|
+
if (precise.reason) return precise
|
|
180
|
+
// 并行调用:同一条助手消息里还有别的调用。整段替换会把兄弟调用一起带走、
|
|
181
|
+
// 让它们变成孤儿(宿主不清洗孤儿,之后每次请求都 400),所以这一条不折,
|
|
182
|
+
// 连返回正文也不改写(任何改写都会让已发出的请求前缀缓存作废)。
|
|
183
|
+
if (precise.parallel) return { reason: 'parallel-call', callId: precise.callId }
|
|
184
|
+
return { pair: { callId: precise.callId, callAt: precise.callAt, resultAt: precise.resultAt } }
|
|
185
|
+
}
|
|
186
|
+
const nodes = session.surface.nodes
|
|
187
|
+
if (nodes.length < 2) return { reason: 'no-nodes' }
|
|
188
|
+
const messages = session.deriveMessages()
|
|
189
|
+
const n = messages.length
|
|
190
|
+
if (n < 2) return { reason: 'no-messages' }
|
|
191
|
+
let fallback = null
|
|
192
|
+
for (let i = 0; i < SCAN_PAIRS; i += 1) {
|
|
193
|
+
// 成对只可能是 (偶数位调用, 奇数位返回)——宿主一步一条消息,调用与返回永远相邻。
|
|
194
|
+
const msgAt = n - 1 - 2 * i
|
|
195
|
+
const callMsgAt = msgAt - 1
|
|
196
|
+
if (callMsgAt < 0) break
|
|
197
|
+
const calls = toolCallsOf(messages[callMsgAt])
|
|
198
|
+
// 同一条消息里有多个调用就放弃:删掉它会把别的调用一起变成孤儿。
|
|
199
|
+
if (calls.length !== 1) continue
|
|
200
|
+
const only = calls[0]
|
|
201
|
+
if (only.name !== TOOL_NAME) continue
|
|
202
|
+
const result = messages[msgAt]
|
|
203
|
+
if (toolResultCallId(result) !== only.id) continue
|
|
204
|
+
// 结果可能是失败的:我们登记的是"成功过的那次调用",若宿主或别的插件
|
|
205
|
+
// (如 tools/post-execute block)把这条结果变成错误,短注里的"已经成功"就是假话。
|
|
206
|
+
// 语义错不能靠信息量补,直接不折,原样留着。
|
|
207
|
+
if (result && result.isError === true) return { reason: 'error-result', callId: only.id }
|
|
208
|
+
const entry = queue && typeof queue.get === 'function' ? queue.get(only.id) : null
|
|
209
|
+
let callAt = entry && entry.callSeq != null ? nodes.indexOf(entry.callSeq) : -1
|
|
210
|
+
if (callAt < 0 && n === nodes.length) callAt = callMsgAt
|
|
211
|
+
// 定位不到就不猜:宁可这一条不折,也不能错位替换别的节点。
|
|
212
|
+
if (callAt < 0) return { reason: 'node-unknown', callId: only.id }
|
|
213
|
+
const resultAt = callAt + 1
|
|
214
|
+
if (resultAt >= nodes.length) return { reason: 'node-unknown', callId: only.id }
|
|
215
|
+
const pair = { callId: only.id, callAt, resultAt }
|
|
216
|
+
// 优先返回"我们确实登记过的那一对":末尾可能是别的调用,
|
|
217
|
+
// 那时不该拿它去比队列(否则永远匹配不上,看起来像折叠失败)。
|
|
218
|
+
if (!queue || typeof queue.get !== 'function' || queue.get(only.id)) return { pair }
|
|
219
|
+
if (!fallback) fallback = pair
|
|
220
|
+
}
|
|
221
|
+
if (fallback) return { pair: fallback }
|
|
222
|
+
return { reason: 'no-tail-pair' }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 带原因的版本:胶水层要靠它区分"没什么可折"和"折不掉",后者必须被看见。
|
|
226
|
+
export function foldCallRecordEx(session, queue, config) {
|
|
227
|
+
if (!config || config.foldCallRecord === false) return { note: null, reason: 'disabled' }
|
|
228
|
+
if (!queue || queue.size === 0) return { note: null, reason: 'empty' }
|
|
229
|
+
const found = inspectTail(session, queue)
|
|
230
|
+
if (!found.pair) return { note: null, reason: found.reason, callId: found.callId }
|
|
231
|
+
const pending = queue.get(found.pair.callId)
|
|
232
|
+
if (!pending) return { note: null, reason: 'no-entry', callId: found.pair.callId }
|
|
233
|
+
const nodes = session.surface.nodes
|
|
234
|
+
// 只折"还没进过请求"的那一对:它必须是表层的最后两个节点。
|
|
235
|
+
// 只要后面又追加过节点,就说明含这一对的那次请求已经发出去了;这时再 replace,
|
|
236
|
+
// 从这一对往后的全部前缀缓存都会作废——省下的那点 token 远不够赔。
|
|
237
|
+
// 陈旧的待折项(异常重试、开关关着时登记、或者上一次没轮上)一律走到这里被丢掉。
|
|
238
|
+
// 中间夹了别的节点就不能按 [callAt..resultAt] 替换——那会把夹在中间的节点一起吞掉。
|
|
239
|
+
// 正常路径下调用与返回必然相邻(宿主紧跟着追加 tool/result);不相邻说明配对推断错了。
|
|
240
|
+
if (found.pair.resultAt !== found.pair.callAt + 1) {
|
|
241
|
+
return { note: null, reason: 'not-adjacent', callId: found.pair.callId }
|
|
242
|
+
}
|
|
243
|
+
if (found.pair.resultAt !== nodes.length - 1) {
|
|
244
|
+
return { note: null, reason: 'not-tail', callId: found.pair.callId }
|
|
245
|
+
}
|
|
246
|
+
const note = buildNote(pending.lines)
|
|
247
|
+
session.append(
|
|
248
|
+
'user/message',
|
|
249
|
+
userMessage({ content: [{ type: 'text', text: note }], source: { kind: FOLD_KIND } }),
|
|
250
|
+
{
|
|
251
|
+
surfaceOp: { op: 'replace', startSeq: nodes[found.pair.callAt], endSeq: nodes[found.pair.resultAt] },
|
|
252
|
+
sourceEventSeqs: [nodes[found.pair.callAt], nodes[found.pair.resultAt]],
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
queue.delete(found.pair.callId)
|
|
256
|
+
return { note, reason: null }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function foldCallRecord(session, queue, config) {
|
|
260
|
+
return foldCallRecordEx(session, queue, config).note
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 这一次 compress 调用是不是和别的 compress 挤在同一条助手消息里(并发边界)。
|
|
264
|
+
// 为什么必须拒:两个压缩是一次调用里并行的两个子事务,前者刚写完表层、后者手里的
|
|
265
|
+
// 区间就已经是旧的;宿主没有事务,我们也没有"按顺序等前一个"的能力。
|
|
266
|
+
// 与其赌运气写出半成品,不如立刻报错让模型一个一个来。
|
|
267
|
+
export function siblingCompressCount(session, log) {
|
|
268
|
+
try {
|
|
269
|
+
const nodes = session && session.surface ? session.surface.nodes : null
|
|
270
|
+
if (!nodes || !nodes.length) return 0
|
|
271
|
+
// 不能只看最后一个节点(回归修复):同一条助手消息里 [pwsh, compressA, compressB]
|
|
272
|
+
// 这种交错批,pwsh 的返回可能先落进表层,compressA 执行时末节点就是那条 tool 消息——
|
|
273
|
+
// 只看末节点会数成 0,闸门静默失效。所以往回找第一张"含工具调用的助手消息",
|
|
274
|
+
// 那一张才是本次并发调用的来源。
|
|
275
|
+
const from = Math.max(0, nodes.length - SCAN_NODES)
|
|
276
|
+
for (let i = nodes.length - 1; i >= from; i -= 1) {
|
|
277
|
+
const calls = toolCallsOf(messageAtNode(session, nodes[i]))
|
|
278
|
+
if (!calls.length) continue
|
|
279
|
+
return calls.filter((c) => c.name === TOOL_NAME).length
|
|
280
|
+
}
|
|
281
|
+
return 0
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// 数不出来就当 0(放行):并发闸门宁可漏拦一次,也不能把正常压缩永久拒掉。
|
|
284
|
+
// 但要留痕——否则闸门静默失效没人知道(第六轮审查 B)。
|
|
285
|
+
if (typeof log === 'function') log('sibling-count-failed', { error: String((error && error.message) || error) })
|
|
286
|
+
return 0
|
|
287
|
+
}
|
|
288
|
+
}
|