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/map.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// 地图:给模型看当前表层的结构与占用。压缩前必调。
|
|
2
|
+
// 原则:地图只给模型自己算不出来的东西(位置、配额、结构),不给正文内容。
|
|
3
|
+
|
|
4
|
+
import { MIN_L0_COST, costOfMessage, costOfTurns, productFloor, worthCompressing, l0CostOf, lastCompressibleTurn, readZones, writableTo, zoneRangeCost } from './blocks.js'
|
|
5
|
+
import { estimateTokens, messageTokens } from './tokens.js'
|
|
6
|
+
import { makeLogger } from './log.js'
|
|
7
|
+
|
|
8
|
+
// 地图自己拿一个写日志的入口:renderMap 可能在 apply() 的作用域之外被调用,
|
|
9
|
+
// 拿不到那边的 log(scope 测试会拦这种写法)。延迟到第一次渲染地图时再建,
|
|
10
|
+
// 免得"只是 import 了地图"就顺手解析日志路径。
|
|
11
|
+
let mapLog = null
|
|
12
|
+
function logger() {
|
|
13
|
+
if (!mapLog) mapLog = makeLogger('ebbing-context')
|
|
14
|
+
return mapLog
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ZONE_LABEL = { l3: 'L3', l2: 'L2', l1: 'L1', l0: '原文' }
|
|
18
|
+
|
|
19
|
+
export function renderMap(ctx, agent, config, usage = null) {
|
|
20
|
+
const session = ctx.sessions.get(agent.id)
|
|
21
|
+
if (!session) return { text: '拿不到当前会话。' }
|
|
22
|
+
|
|
23
|
+
const zones = readZones(ctx, session, agent, logger())
|
|
24
|
+
// 分母与提醒线必须是同一个数(口径一致):调用方已经算好了就照用,
|
|
25
|
+
// 不要在这里用另一套实现重新推一遍——两处一旦漂移,地图和提醒会互相打架。
|
|
26
|
+
const window = (usage && typeof usage.budget === 'number') ? usage.budget : readWindow(ctx, session, config)
|
|
27
|
+
const prices = ownPrices(zones)
|
|
28
|
+
|
|
29
|
+
const used = { l3: 0, l2: 0, l1: 0, l0: 0 }
|
|
30
|
+
for (const name of ['l3', 'l2', 'l1']) {
|
|
31
|
+
for (const b of zones.zones[name]) used[name] += prices.get(b.seq) || 0
|
|
32
|
+
}
|
|
33
|
+
for (const n of zones.zones.l0) used.l0 += prices.get(n.seq) || 0
|
|
34
|
+
const sysTokens = zones.sys.length ? prices.get(zones.sys[0]) || 0 : 0
|
|
35
|
+
const total = sysTokens + used.l3 + used.l2 + used.l1 + used.l0
|
|
36
|
+
const pct = window ? Math.round((total / window) * 1000) / 10 : null
|
|
37
|
+
|
|
38
|
+
const lines = []
|
|
39
|
+
// 总量并排报两个数:宿主的账单口径(上次请求真实用量 + 之后的估算增量,和提醒线同源)
|
|
40
|
+
// 与插件自估(分层占比的来源)。压完那一刻宿主的数还带过渡估算,用户会看到两个数差得远,
|
|
41
|
+
// 所以不再只报一个——两个口径同时摆出来,谁都能互相核对。
|
|
42
|
+
if (usage && typeof usage.used === 'number') {
|
|
43
|
+
// Number.isFinite 一并挡掉 null / undefined / NaN / Infinity——守卫写 `!= null` 的话
|
|
44
|
+
// 合成的 NaN、Infinity 会渲染成「(NaN%)」;生产路径出不了这些值,但展示层不该有洞。
|
|
45
|
+
const hostPct = Number.isFinite(usage.ratio) ? Math.round(usage.ratio * 1000) / 10 : null
|
|
46
|
+
const selfPart = pct === null ? `自估 ${fmt(total)}` : `自估 ${fmt(total)}(${pct}%)`
|
|
47
|
+
lines.push(`上下文 ${fmt(usage.used)} token(宿主口径)/ ${selfPart}${window ? ` / 可用输入 ${fmt(window)}${hostPct != null ? `(${hostPct}%)` : ''}` : ''}`)
|
|
48
|
+
lines.push('下面是分层占用,用插件自估(中文 1 字≈0.6 token、英文 1 字≈0.3 token,按真实用量校准)')
|
|
49
|
+
} else {
|
|
50
|
+
lines.push(`上下文 ${fmt(total)} token(插件自估口径;取不到宿主账单时才会走到这里)${window ? ` / 可用输入 ${fmt(window)}(${pct}%)` : ''}`)
|
|
51
|
+
}
|
|
52
|
+
lines.push('层序:l3 最深 ← l2 ← l1 ← l0 原文(最浅);压某层时,比它浅且还能压的层要同批一起给')
|
|
53
|
+
lines.push('')
|
|
54
|
+
lines.push('区域 已用(占可用输入) 区上限(参考) 单块上限')
|
|
55
|
+
const share = (n) => (window && window > 0 ? `(${(Math.round((n / window) * 1000) / 10).toFixed(1)}%)` : '')
|
|
56
|
+
for (const name of ['l3', 'l2', 'l1']) {
|
|
57
|
+
// 取整方式要和提醒判定(index.js 的 readZoneOver 用 Math.round)一致,
|
|
58
|
+
// 否则同一条线在地图上显示"已超"、在提醒里却不触发,最多差 1 token。
|
|
59
|
+
const cap = window ? Math.round(window * config.budget[name]) : null
|
|
60
|
+
const over = cap !== null && used[name] > cap ? ' ⚠已超设计容量(只影响提醒,不阻止压缩)' : ''
|
|
61
|
+
lines.push(`${ZONE_LABEL[name].padEnd(6)}${(fmt(used[name]) + share(used[name])).padEnd(20)}${(cap === null ? '—' : fmt(cap)).padEnd(16)}${config.segment[name]} token${over}`)
|
|
62
|
+
}
|
|
63
|
+
lines.push(`原文 ${(fmt(used.l0) + share(used.l0)).padEnd(20)}— —`)
|
|
64
|
+
|
|
65
|
+
// 宿主的 deriveMessages() 会跳过派生为空的节点(截断的空助手回复、空注入)。
|
|
66
|
+
// 这不影响压缩:插件按节点逐个投影(`blocks.js` 的 `deriveExactly`),空节点只是被跳过。
|
|
67
|
+
// 但要在图上说明,否则"节点数≠消息数"看起来像 bug。
|
|
68
|
+
const emptyNodes = zones.messages ? zones.nodes.length - zones.messages.length : 0
|
|
69
|
+
if (emptyNodes > 0) {
|
|
70
|
+
// 这些节点没有消息,但它们仍然占着表层位置。插件按节点逐个投影(见 blocks.js 的
|
|
71
|
+
// `deriveExactly`),所以它们只是被跳过,不影响判断块在哪、也不影响区间划分。
|
|
72
|
+
lines.push(`注意 表层 ${zones.nodes.length} 个节点里有 ${emptyNodes} 个空节点(宿主写的空消息,不产生请求内容):它们会被跳过,不影响压缩。`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 块的序号按区各自从 1 起:`to` 就是这个序号,别混着用。
|
|
76
|
+
const order = []
|
|
77
|
+
for (const name of ['l3', 'l2', 'l1']) {
|
|
78
|
+
const list = zones.zones[name]
|
|
79
|
+
const ordered = name === 'l3' ? list : list.slice()
|
|
80
|
+
ordered.forEach((b, i) => order.push({ ...b, zone: name, no: i + 1 }))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (order.length) {
|
|
84
|
+
// 只有"宿主压缩留下、前面没有任何内容"的检查点会进 L3(见 blocks.js);其余外来检查点
|
|
85
|
+
// 已经按原文处理,不出现在块表里(回归修复)。
|
|
86
|
+
const foreign = order.filter((b) => b.foreign).length
|
|
87
|
+
if (foreign) {
|
|
88
|
+
lines.push(`(其中 ${foreign} 块是宿主压缩留下来的、没有我们的块头:按 L3 收纳,l3 可以把它再折一次)`)
|
|
89
|
+
}
|
|
90
|
+
lines.push('')
|
|
91
|
+
lines.push('块(序号按区各自从 1 起,1 = 该区最老,新压出的块追加在本区末尾)')
|
|
92
|
+
lines.push('序 区 覆盖轮次 token 标题')
|
|
93
|
+
order.forEach((b) => {
|
|
94
|
+
// `+` 表示"覆盖到 T19 之后还没封口":不显示它,模型会以为这块只盖到 19。
|
|
95
|
+
// 下界未知、上界已知:写 `轮 ?–2`,别只写 `—`(那会让模型以为这块没有轮次信息)。
|
|
96
|
+
const turns = b.turns[0] === null
|
|
97
|
+
? (typeof b.turns[1] === 'number' ? `轮 ?–${b.turns[1]}${b.turnsOpen ? '+' : ''}` : '—')
|
|
98
|
+
: `轮 ${b.turns[0]}–${b.turns[1]}${b.turnsOpen ? '+' : ''}`
|
|
99
|
+
lines.push(`${String(b.no).padEnd(4)}${ZONE_LABEL[b.zone].padEnd(6)}${turns.padEnd(12)}${String(prices.get(b.seq) || 0).padStart(6)} token ${b.title || '(无标题)'}`)
|
|
100
|
+
})
|
|
101
|
+
} else {
|
|
102
|
+
lines.push('')
|
|
103
|
+
lines.push('块:还没有任何压缩块。')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
lines.push('')
|
|
107
|
+
lines.push('可压位置(to 就从这里抄,别自己数)')
|
|
108
|
+
if (zones.ambiguous) {
|
|
109
|
+
lines.push(
|
|
110
|
+
'这张地图只供查看:表层里有内容为空的步骤(宿主把它们跳过了),而表层上已经有压缩块,' +
|
|
111
|
+
'块的位置推不出来。这一版整批拒绝压缩,免得把旧摘要再压成一份新的。别用宿主自带的压缩顶替(那会把分层拍平)'
|
|
112
|
+
)
|
|
113
|
+
// 下面每一行都是"可写 to=…",在这个状态下它们全是假话(compress 会整批拒绝),
|
|
114
|
+
// 同一张地图里既说"压不了"又说"可写 to=T16"只会把模型骗去撞一次拒绝。直接收尾。
|
|
115
|
+
return { text: lines.join('\n'), zones }
|
|
116
|
+
}
|
|
117
|
+
const keepTurns = Math.max(0, Number(config.keepTurns) || 0)
|
|
118
|
+
const cutoff = lastCompressibleTurn(zones, keepTurns)
|
|
119
|
+
// 给模型看的数字必须真能写:物理切点落在缺轮上时,可写的 to 要往回退一格(见 writableTo)。
|
|
120
|
+
const writable = writableTo(zones, keepTurns)
|
|
121
|
+
const rawTurns = zones.zones.l0
|
|
122
|
+
.filter((n) => typeof n.turn === 'number' && cutoff !== null && n.turn <= cutoff)
|
|
123
|
+
.map((n) => n.turn)
|
|
124
|
+
const rawTurnList = Array.from(new Set(rawTurns)).sort((a, b) => a - b)
|
|
125
|
+
// 只列真实还有原文、且不在 keepTurns 保护圈里的轮号:早被压进摘要块的轮在表层上已经不存在。
|
|
126
|
+
const rawTurnShown = rawTurnList.slice(0, 14).map((t) => `T${t}`).join(' ')
|
|
127
|
+
const rawTurnMore = rawTurnList.length > 14 ? ` …(共 ${rawTurnList.length} 个)` : ''
|
|
128
|
+
const kept = keepTurns > 0 ? `、最后 ${keepTurns} 轮要留着` : ''
|
|
129
|
+
const keepEatsAll = zones.turnStarts !== null && cutoff === null && keepTurns > 0
|
|
130
|
+
const oldest = rawTurnList.length ? rawTurnList[0] : null
|
|
131
|
+
// 「只写到最早那轮」是否值得压:那一轮自己太小的话,压完不会更小,compress 必拒。
|
|
132
|
+
const oldestCost = oldest === null ? null : costOfTurns(zones, [oldest])
|
|
133
|
+
// 整段可压原文的合计(与 compress 的收益校验同一口径):用来陈述"这一段一共多少 token"。
|
|
134
|
+
const totalL0Cost = l0CostOf(zones, keepTurns).cost
|
|
135
|
+
// 缺轮不是故障,但必须说出来:不然模型只看到"最多压到 T1"却不知道为什么(设计约束)。
|
|
136
|
+
const missing = (Array.isArray(zones.turnNotes) ? zones.turnNotes : [])
|
|
137
|
+
.filter((n) => n && n.reason === 'missing')
|
|
138
|
+
.map((n) => n.turn)
|
|
139
|
+
// 原文区里混进过来历不明的压缩摘要(查不到压缩事务):按原文算,会跟着同一段一起被压成摘要块。
|
|
140
|
+
const foreignRaw = (zones.zones && Array.isArray(zones.zones.l0) ? zones.zones.l0 : []).filter((n) => n && n.foreign).length
|
|
141
|
+
// 首界标之前的无主原文(回归修复):没有轮号可归,地图必须点出来,否则模型按轮号索引
|
|
142
|
+
// 会以为那段内容不存在(这些内容会被一起压进第一块)。
|
|
143
|
+
const orphan = zones.orphan
|
|
144
|
+
const orphanNote = orphan
|
|
145
|
+
? `(原文区最早还有 ${orphan.count} 条没有轮号的消息(事件号 ${orphan.firstSeq === orphan.lastSeq ? String(orphan.firstSeq) : `${orphan.firstSeq}–${orphan.lastSeq}`})——最早的轮次记录丢了;这些内容会被一起压进第一块,块头会标"T?–")`
|
|
146
|
+
: ''
|
|
147
|
+
const foreignRawNote = foreignRaw
|
|
148
|
+
? `(原文区里有 ${foreignRaw} 段来历不明的压缩摘要,已按普通原文算:它没有可核对的边界,会跟着同一段一起压进摘要块)`
|
|
149
|
+
: ''
|
|
150
|
+
// 可写区间内、却又写不得的轮号(缺轮):单独列出来,别让模型照着主句的区间照抄(审查 A#2)。
|
|
151
|
+
const missingInRange = missing
|
|
152
|
+
.filter((t) => typeof t === 'number' && oldest !== null && t >= oldest && t <= cutoff)
|
|
153
|
+
.sort((a, b) => a - b)
|
|
154
|
+
const missingNote = missing.length
|
|
155
|
+
? `(轮次记录里缺了${missing.length > 2 ? `第 ${missing[0]} 轮等 ${missing.length} 个轮次` : `第 ${missing.join('、')} 轮`};它没有自己的边界,压到它前一轮时会把它的内容一起压进块里——标签会粗一档,切点仍然是真边界)`
|
|
156
|
+
: ''
|
|
157
|
+
const l0Base = zones.outlineFault
|
|
158
|
+
? `暂不可用:轮次大纲损坏(${zones.outlineFault === 'gap' ? '缺轮' : '乱序'}),判不准每一轮到哪儿结束。l1 / l2 的 to 不受影响`
|
|
159
|
+
: zones.turnStarts === null
|
|
160
|
+
? '暂不可用:拿不到轮号(宿主的轮次大纲投影没挂载)。这不是"没有原文",是这一版判不了轮的边界'
|
|
161
|
+
: keepEatsAll
|
|
162
|
+
? `暂不可用:keepTurns=${keepTurns} 把现有轮次全保住了(当前才第 ${zones.currentTurn ?? '?'} 轮),可压范围是空的;把 keepTurns 调小或等轮次多了再压`
|
|
163
|
+
: rawTurnList.length
|
|
164
|
+
? (worthCompressing(zones, 'l0', keepTurns)
|
|
165
|
+
? `可写 to=${writable === oldest ? `T${oldest}` : `T${oldest}–T${writable}`}(原文区最老的是 T${oldest},当前第 ${zones.currentTurn ?? '?'} 轮${kept};只压已经收尾、结果已落地的内容,还在推进或接下来还要用的留着);` +
|
|
166
|
+
`落在表层上的轮:${rawTurnShown}${rawTurnMore}。` +
|
|
167
|
+
(missingInRange.length
|
|
168
|
+
? `缺的轮号 ${missingInRange.map((t) => `T${t}`).join('、')} 写不得——它们没有 ` +
|
|
169
|
+
'turn/start,边界不可知,写了必被拒;写到缺轮前一轮时会把它们的内容一起带走(块头会标 +)。'
|
|
170
|
+
: '') +
|
|
171
|
+
(writable !== null && writable > oldest
|
|
172
|
+
? (Number.isFinite(oldestCost) && oldestCost < MIN_L0_COST
|
|
173
|
+
// 只给两端的事实:最早那一轮自己压不动,是因为它连块头开销都盖不住。
|
|
174
|
+
// 不写"该并到哪一轮"——那等于替模型选区间(SPEC §6:只陈述事实)。
|
|
175
|
+
? `最早那一轮(T${oldest})单独是 ${oldestCost} token(${MIN_L0_COST} 是整段的叫醒门槛,不管单轮;能不能压要看地板 ${productFloor('l0', config)});T${oldest}–T${writable} 整段合计 ${Number.isFinite(totalL0Cost) ? totalL0Cost : '?'} token`
|
|
176
|
+
: `T${oldest} 单独是 ${oldestCost} token;T${oldest}–T${writable} 整段合计 ${Number.isFinite(totalL0Cost) ? totalL0Cost : '?'} token`)
|
|
177
|
+
: `只写 T${oldest}:被压 ${oldestCost === null ? '?' : oldestCost} token`)
|
|
178
|
+
// 压完必须比被压内容小(§4.7):剩下这一小段连块头开销都盖不住时,
|
|
179
|
+
// 地图先说"压不了",别把模型叫来压一次然后被拒(口径一致)。
|
|
180
|
+
: (Number.isFinite(l0CostOf(zones, keepTurns).cost)
|
|
181
|
+
? `未列入提醒范围:可压的原文合计低于叫醒门槛(${MIN_L0_COST} token)。这是"值不值得叫醒"的门槛,不是压不动的硬边界(地板是 ${productFloor('l0', config)});这么短的段落压完通常省不了多少`
|
|
182
|
+
: `暂不可用:可压原文的成本量不出来(有正文取不回),别按'等它再长'理解;真的要压的时候会自己再判一次`))
|
|
183
|
+
: `暂不可用:没有可压的原文(当前是第 ${zones.currentTurn ?? '?'} 轮${kept})`
|
|
184
|
+
// orphanNote 必须无条件带上:无主原文最常见的情形恰恰是"算不出可压轮次"那几条分支,
|
|
185
|
+
// 只在可压分支里说,等于最需要说的时候不说(回归修复)。
|
|
186
|
+
lines.push(`l0 压原文区最旧的一段 -> ` + l0Base + orphanNote + missingNote + foreignRawNote)
|
|
187
|
+
// 轮次标记:模型在写 to 时唯一能把"轮号"落到"表层里那一段"的锚。
|
|
188
|
+
// 装插件之前的老轮次没有标记,所以还得回到"按地图给的轮号认"这条老路。
|
|
189
|
+
if (zones.zones.l0.length) {
|
|
190
|
+
lines.push(' (原文区里每条〔轮次标记〕后面紧跟的就是它写的那一轮;装插件之前的老轮次没有标记,按上面给的轮号认)')
|
|
191
|
+
}
|
|
192
|
+
const maxL1 = zones.zones.l1.length - 1
|
|
193
|
+
const maxL2 = zones.zones.l2.length - 1
|
|
194
|
+
// 成本判据:可压区间连改写开销都盖不住时,compress 一定拒——地图先说清楚,
|
|
195
|
+
// 别让模型照着"可写"去撞一次(与 compress 的收益校验同一口径)。
|
|
196
|
+
const floorTok = (key) => productFloor(key, config)
|
|
197
|
+
const costL1 = zoneRangeCost(zones, 'l1')
|
|
198
|
+
const costL2 = zoneRangeCost(zones, 'l2')
|
|
199
|
+
const costL3 = zoneRangeCost(zones, 'l3')
|
|
200
|
+
// 两边的事实:可压段一共多少 token、以及留给"摘要+标题"的剩余预算。
|
|
201
|
+
// 后者 = 可压段 − 一块的最小产物成本(摘要≈0 时的开销),所以模型写得比它长就过不了收益校验。
|
|
202
|
+
const budgetOf = (cost, key) => Math.max(0, cost - floorTok(key))
|
|
203
|
+
const tooSmall = (cost, key) =>
|
|
204
|
+
`暂不可用:可压的块合计 ${cost} token,盖不住一块的最小产物成本(约 ${floorTok(key)} token),压完反而更大`
|
|
205
|
+
const budgetNote = (cost, key) => `这一段合计 ${cost} token;摘要+标题合计不超过 ${budgetOf(cost, key)} token 才过收益校验`
|
|
206
|
+
// 只并第一块时的预算:整段的数字对 to=1 是虚构的(97 的块配不下 119 的预算)。
|
|
207
|
+
const firstOnly = (key) => {
|
|
208
|
+
const list = zones.zones[key] || []
|
|
209
|
+
const first = list.length > 0 ? zones.messageFor(list[0].seq) : null
|
|
210
|
+
return first ? costOfMessage(zones, first) : 0
|
|
211
|
+
}
|
|
212
|
+
const firstOk = (key) => { const cost = firstOnly(key); return !cost || cost >= floorTok(key) }
|
|
213
|
+
const firstNote = (key) => {
|
|
214
|
+
const cost = firstOnly(key)
|
|
215
|
+
if (!cost) return ''
|
|
216
|
+
return cost >= floorTok(key)
|
|
217
|
+
? `to=1 只并最老的一块(${cost} token,够改写开销)`
|
|
218
|
+
: `to=1 只并最老的一块(${cost} token,单独写必被拒)`
|
|
219
|
+
}
|
|
220
|
+
lines.push(
|
|
221
|
+
// 标题里直接写真实可写范围:写「前 N 块」会让模型以为 N 是个要它自己算的占位符,
|
|
222
|
+
// 而压不动的时候那句话又变成半截话("前 N 块 -> 暂不可用")。
|
|
223
|
+
`l1 压 L1 区最老的${maxL1 >= 1 ? ` 1–${maxL1} 块` : '一段'} -> ` +
|
|
224
|
+
(maxL1 >= 1
|
|
225
|
+
? (costL1 >= floorTok('l1')
|
|
226
|
+
? `${firstOk('l1') || maxL1 < 2 ? `可写 to=1–${maxL1}` : `可写 to=2–${maxL1}(to=1 单独不可行)`}(该区共 ${zones.zones.l1.length} 块,1 最老;最后一块不能压)。` +
|
|
227
|
+
`${firstNote('l1')};to=${maxL1} 一次并 ${maxL1} 块。${budgetNote(costL1, 'l1')}`
|
|
228
|
+
: tooSmall(costL1, 'l1'))
|
|
229
|
+
: '暂不可用:L1 区不足 2 块(最后一块不能压)')
|
|
230
|
+
)
|
|
231
|
+
lines.push(
|
|
232
|
+
`l2 压 L2 区最老的${maxL2 >= 1 ? ` 1–${maxL2} 块` : '一段'}(产物是新的 L3 块,旧 L3 不动)-> ` +
|
|
233
|
+
(maxL2 >= 1
|
|
234
|
+
? (costL2 >= floorTok('l2')
|
|
235
|
+
? `${firstOk('l2') || maxL2 < 2 ? `可写 to=1–${maxL2}` : `可写 to=2–${maxL2}(to=1 单独不可行)`}(该区共 ${zones.zones.l2.length} 块,1 最老;最后一块不能压)。` +
|
|
236
|
+
`${firstNote('l2')};to=${maxL2} 一次并 ${maxL2} 块。${budgetNote(costL2, 'l2')}`
|
|
237
|
+
: tooSmall(costL2, 'l2'))
|
|
238
|
+
: '暂不可用:L2 区不足 2 块(最后一块不能压)')
|
|
239
|
+
)
|
|
240
|
+
lines.push(`l3 把整个 L3 区压成一块(不写 to)-> ${
|
|
241
|
+
zones.zones.l3.length
|
|
242
|
+
? (costL3 >= floorTok('l3') ? `现在有 ${zones.zones.l3.length} 块,可压。${budgetNote(costL3, 'l3')}` : tooSmall(costL3, 'l3'))
|
|
243
|
+
: '暂不可用:L3 区为空'}`)
|
|
244
|
+
|
|
245
|
+
lines.push('')
|
|
246
|
+
lines.push('压缩须知')
|
|
247
|
+
lines.push('· 只压已经结束、结果已经落地的轮次;当前轮和还需要的轮绝对不可压——压掉它们等于把正在做的工作从上下文里删掉。不要一压到底:压到第一个还没收尾的轮次之前就停,拿不准是否收尾就按没收尾处理、留着。')
|
|
248
|
+
lines.push('· 压缩专用轮:压完就收手——不要越过上一轮还没收尾的未决项,也不要在压缩成功后转做其它任务;未决的事原样留着。')
|
|
249
|
+
lines.push('· 级联:压 l1/l2/l3 时,比它浅、且还能压的层要同批一起给,只给深层会整批被拒。')
|
|
250
|
+
lines.push('· 压完必须比被压掉的内容小,不达标整批不写并返回两侧实测值;区间取多大由你权衡(越大省得越多、细节留得越少)。')
|
|
251
|
+
|
|
252
|
+
return { text: lines.join('\n'), zones }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 用插件自己的估算器给每个节点计价:中文 0.6 token/字、英文 0.3 token/字,
|
|
256
|
+
// 和真实账单只差几个百分点(宿主按 4 字 1 token,对中文低估约 2.4 倍)。
|
|
257
|
+
// 原文区(含系统头)走 costOfMessage:本步有路由图片价时按真实价,否则退回 800。
|
|
258
|
+
// 判定链(叫醒/MIN_L0_COST/收益校验)用的是同一把尺子,这里不许另开一套。
|
|
259
|
+
function ownPrices(zones) {
|
|
260
|
+
const prices = new Map()
|
|
261
|
+
for (const name of ['l1', 'l2', 'l3']) {
|
|
262
|
+
for (const b of zones.zones[name]) prices.set(b.seq, estimateTokens(b.text || ''))
|
|
263
|
+
}
|
|
264
|
+
for (const n of zones.zones.l0) {
|
|
265
|
+
prices.set(n.seq, costOfMessage(zones, zones.messageFor(n.seq)))
|
|
266
|
+
}
|
|
267
|
+
for (const seq of zones.sys) {
|
|
268
|
+
prices.set(seq, costOfMessage(zones, zones.messageFor(seq)))
|
|
269
|
+
}
|
|
270
|
+
return prices
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 可用输入预算 = 总窗口 − 输出预留(与提醒线同一口径)。
|
|
274
|
+
// 输出预留:优先读这条路由真实用的 maxTokens,读不到用配置兜底。
|
|
275
|
+
function reserveOf(ctx, session, config) {
|
|
276
|
+
try {
|
|
277
|
+
const h = session && session.requestHeader ? session.requestHeader() : undefined
|
|
278
|
+
const mt = h && h.config && h.config.maxTokens
|
|
279
|
+
if (typeof mt === "number" && mt > 0) return mt
|
|
280
|
+
} catch {}
|
|
281
|
+
const f = config && config.reservedTokens
|
|
282
|
+
return typeof f === "number" && f > 0 ? f : 0
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readWindow(ctx, session, config) {
|
|
286
|
+
// 分母必须和 readUsage 用的"可用输入预算"是同一个数,否则会出现
|
|
287
|
+
// "占用 40% 却显示 分母 5000" 这种自相矛盾的展示(reservedTokens 配得离谱时)。
|
|
288
|
+
const budgetOf = (win) => Math.max(Math.floor(win * 0.1), win - reserveOf(ctx, session, config))
|
|
289
|
+
try {
|
|
290
|
+
const rc = session.requestContext && session.requestContext()
|
|
291
|
+
if (rc && typeof rc.contextWindow === 'number') return budgetOf(rc.contextWindow)
|
|
292
|
+
} catch {}
|
|
293
|
+
// 0.2.1 的 tokenMeter 没有 contextPressure(),只有 0.2.2 才有;先试服务方法,再退回投影。
|
|
294
|
+
try {
|
|
295
|
+
const tm = ctx.tokenMeter
|
|
296
|
+
if (tm && typeof tm.contextPressure === 'function') {
|
|
297
|
+
const p = tm.contextPressure(session)
|
|
298
|
+
if (p && typeof p.contextWindow === 'number') return budgetOf(p.contextWindow)
|
|
299
|
+
}
|
|
300
|
+
} catch {}
|
|
301
|
+
try {
|
|
302
|
+
const state = ctx.sessionProjections?.stateOf?.(session, 'contextPressure')
|
|
303
|
+
if (state && typeof state.contextWindow === 'number') return budgetOf(state.contextWindow)
|
|
304
|
+
} catch {}
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function fmt(n) {
|
|
309
|
+
if (typeof n !== 'number') return '—'
|
|
310
|
+
return n.toLocaleString('en-US')
|
|
311
|
+
}
|
package/lib/messages.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// 造一条「用户消息」。优先用宿主的构造器(它负责 id / 时间戳 / 默认字段)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要包一层:第三方插件能不能解析到宿主的包,是装载方式决定的,不是代码决定的。
|
|
4
|
+
// 解析不到时若直接 import,插件会在启动阶段整片崩掉;包一层之后最坏结果只是消息少两个
|
|
5
|
+
// 可选字段,压缩照样能落盘。这条降级路径只会在宿主包真的缺失时走到。
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
|
|
8
|
+
let hostFactory = null
|
|
9
|
+
try {
|
|
10
|
+
const mod = await import('@deepseek-ai/dsh-llm')
|
|
11
|
+
if (typeof mod.createUserMessage === 'function') hostFactory = mod.createUserMessage
|
|
12
|
+
} catch {
|
|
13
|
+
hostFactory = null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function userMessage({ content, source }) {
|
|
17
|
+
if (hostFactory) {
|
|
18
|
+
try {
|
|
19
|
+
return hostFactory({ content, source })
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
return { id: randomUUID(), role: 'user', createdAt: Date.now(), content, source }
|
|
23
|
+
}
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// 增长累计的落盘状态:关掉 DSH 再打开,基线接着算,不回到零。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要:插件把增长状态放在进程内存里(index.js 的 state Map),重启进程就全丢,
|
|
4
|
+
// 下一次 pre-step 只能把基线重设成"当时的用量"——用户看到的就是"累计清零"。
|
|
5
|
+
// 这里只存增长线的三个数:基线用量(tokens)、当前门槛(growthQuota,没压成时被砍半)、
|
|
6
|
+
// 到线指示灯(lineOn,重启后不重复报到线)。轮内的东西(growthPending、autoTurn)不存:
|
|
7
|
+
// 它们只对当前这轮有意义。
|
|
8
|
+
//
|
|
9
|
+
// 位置与日志同源:`DSH_HOME/logs/ebbing-context-state/<sessionId>.json`;
|
|
10
|
+
// 主目录建不出来/写不进去就退系统临时目录(宁可位置难看,也别让累计凭空消失)。
|
|
11
|
+
// 读的时候两处都读、按 `updatedAt` 取**最新**那份(并列时再比文件 mtime,两个都并列才主目录优先):
|
|
12
|
+
// 主目录写失败退到临时目录后,主目录里那份是旧的,不能把新的盖回去(审查 0883a196 回归修复 1)。
|
|
13
|
+
// 主目录写成功时顺手删掉临时目录里可能残留的旧副本。
|
|
14
|
+
// 以 session.id 为键:agent.id 在重启后可能变,session.id 是稳定的(也随会话文件走)。
|
|
15
|
+
// 读写失败不抛错:读不到只是退回"按当前用量起步",绝不影响主流程;写失败由
|
|
16
|
+
// persistGrowth 记日志(全部失败记 growth-state-write-failed,退到临时目录记
|
|
17
|
+
// growth-state-written-to-tmp),排查时不用靠猜。
|
|
18
|
+
import { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
20
|
+
import { tmpdir } from 'node:os'
|
|
21
|
+
|
|
22
|
+
const VERSION = 1
|
|
23
|
+
|
|
24
|
+
let lastFailure = ''
|
|
25
|
+
let lastFallback = { fellBack: false, reason: '' }
|
|
26
|
+
|
|
27
|
+
// 最近一次读写失败的原因(成功一次就清空)。只给 persistGrowth 记日志用,不往外抛。
|
|
28
|
+
export function lastStateFailure() {
|
|
29
|
+
return lastFailure
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 最近一次写入是否退到了临时目录(主目录写失败)。persistGrowth 据此记一条日志。
|
|
33
|
+
export function lastStateFallback() {
|
|
34
|
+
return { ...lastFallback }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function homeDir() {
|
|
38
|
+
return process.env.DSH_HOME || process.env.HOME || process.env.USERPROFILE || ''
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withLongPath(dir) {
|
|
42
|
+
const list = [dir]
|
|
43
|
+
if (process.platform === 'win32' && !dir.startsWith('\\\\?\\')) {
|
|
44
|
+
const abs = isAbsolute(dir) ? dir : resolve(dir)
|
|
45
|
+
list.push(`\\\\?\\${abs.replace(/\//g, '\\')}`)
|
|
46
|
+
}
|
|
47
|
+
return list
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 候选目录,按优先级:DSH_HOME 下(与日志同源)→ 系统临时目录。
|
|
51
|
+
// 写的时候先试主目录,失败才退临时目录;读的时候两处都读,取最新的一份(不是先找到先用)。
|
|
52
|
+
function stateDirs() {
|
|
53
|
+
const home = homeDir()
|
|
54
|
+
const list = []
|
|
55
|
+
if (home) list.push(join(home, 'logs', 'ebbing-context-state'))
|
|
56
|
+
list.push(join(tmpdir(), 'ebbing-context-state'))
|
|
57
|
+
return list
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 会话 id 直接进文件名:只留文件名安全字符,其余换成下划线(`..` 拼不出目录穿越)。
|
|
61
|
+
function safeId(sessionId) {
|
|
62
|
+
const id = String(sessionId || '').trim()
|
|
63
|
+
if (!id) return ''
|
|
64
|
+
const safe = id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
|
|
65
|
+
return safe === '' || safe === '.' || safe === '..' ? '' : safe
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseRecord(text) {
|
|
69
|
+
try {
|
|
70
|
+
const raw = JSON.parse(text)
|
|
71
|
+
if (!raw || raw.v !== VERSION) return null
|
|
72
|
+
const tokens = typeof raw.tokens === 'number' && Number.isFinite(raw.tokens) && raw.tokens >= 0 ? raw.tokens : null
|
|
73
|
+
if (tokens === null) return null
|
|
74
|
+
const quota = typeof raw.growthQuota === 'number' && Number.isFinite(raw.growthQuota) && raw.growthQuota > 0
|
|
75
|
+
? raw.growthQuota
|
|
76
|
+
: null
|
|
77
|
+
const stamp = typeof raw.updatedAt === 'string' ? Date.parse(raw.updatedAt) : NaN
|
|
78
|
+
return { tokens, growthQuota: quota, lineOn: raw.lineOn === true, updatedAt: Number.isFinite(stamp) ? stamp : 0 }
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 读一次增长状态:两处候选都读,在**有效**记录里取 `updatedAt` 最新的那份;
|
|
85
|
+
// 并列时再用文件 mtime 分先后,两个都并列才按候选顺序(主目录优先)。
|
|
86
|
+
// 都读不到(缺失/坏了/版本不对/字段不合法)返回 null(退回"按当前用量起步")。
|
|
87
|
+
export function readGrowth(sessionId) {
|
|
88
|
+
const id = safeId(sessionId)
|
|
89
|
+
if (!id) return null
|
|
90
|
+
let best = null
|
|
91
|
+
for (const dir of stateDirs()) {
|
|
92
|
+
for (const candidate of withLongPath(dir)) {
|
|
93
|
+
const path = join(candidate, `${id}.json`)
|
|
94
|
+
let text
|
|
95
|
+
try {
|
|
96
|
+
text = readFileSync(path, 'utf8')
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// 这个候选没有/读不了就继续试下一个(主目录被日志清理带走、或权限暂时不对)。
|
|
99
|
+
if (error && error.code !== 'ENOENT') lastFailure = `read:${error.code || error.message || error}`
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
const record = parseRecord(text)
|
|
103
|
+
if (!record) {
|
|
104
|
+
// 坏了/版本不对的那份按"不存在"处理,但不影响另一处的有效副本(下面继续读)。
|
|
105
|
+
lastFailure = 'read:invalid'
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
// 先比 `updatedAt`(严格大于才替换);并列时比文件 mtime,后写的那份不会更早
|
|
109
|
+
// (`updatedAt` 只到毫秒,同一毫秒里连续写两次会并列;ext4/NTFS 的 mtime 精度都够)。
|
|
110
|
+
// 两个都并列才保留先读到的(主目录)。
|
|
111
|
+
let mtimeMs = 0
|
|
112
|
+
try {
|
|
113
|
+
mtimeMs = statSync(path).mtimeMs || 0
|
|
114
|
+
} catch {}
|
|
115
|
+
const newer = !best ||
|
|
116
|
+
record.updatedAt > best.updatedAt ||
|
|
117
|
+
(record.updatedAt === best.updatedAt && mtimeMs > best.mtimeMs)
|
|
118
|
+
if (newer) best = { ...record, mtimeMs }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!best) return null
|
|
122
|
+
lastFailure = ''
|
|
123
|
+
return { tokens: best.tokens, growthQuota: best.growthQuota, lineOn: best.lineOn }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 写一次增长状态;返回是否真的落盘。先写临时文件再改名,半截文件不会被下一次读到。
|
|
127
|
+
// 临时文件名带 pid 与序号:两个进程同时写同一条会话时不会互相踩 .tmp。
|
|
128
|
+
let tmpCounter = 0
|
|
129
|
+
|
|
130
|
+
export function writeGrowth(sessionId, data) {
|
|
131
|
+
const id = safeId(sessionId)
|
|
132
|
+
if (!id) return false
|
|
133
|
+
const tokens = data && typeof data.tokens === 'number' && Number.isFinite(data.tokens) ? data.tokens : null
|
|
134
|
+
if (tokens === null) return false
|
|
135
|
+
const quota = data && typeof data.growthQuota === 'number' && Number.isFinite(data.growthQuota) && data.growthQuota > 0
|
|
136
|
+
? Math.round(data.growthQuota)
|
|
137
|
+
: null
|
|
138
|
+
const payload = {
|
|
139
|
+
v: VERSION,
|
|
140
|
+
tokens: Math.round(tokens),
|
|
141
|
+
growthQuota: quota,
|
|
142
|
+
lineOn: data && data.lineOn === true,
|
|
143
|
+
updatedAt: new Date().toISOString(),
|
|
144
|
+
}
|
|
145
|
+
const text = JSON.stringify(payload)
|
|
146
|
+
const dirs = stateDirs()
|
|
147
|
+
const primary = dirs[0]
|
|
148
|
+
const spillover = dirs.length > 1 ? dirs[dirs.length - 1] : null
|
|
149
|
+
let failure = ''
|
|
150
|
+
lastFallback = { fellBack: false, reason: '' }
|
|
151
|
+
for (const dir of dirs) {
|
|
152
|
+
for (const candidate of withLongPath(dir)) {
|
|
153
|
+
const path = join(candidate, `${id}.json`)
|
|
154
|
+
const tmp = `${path}.${process.pid}.${tmpCounter++}.tmp`
|
|
155
|
+
try {
|
|
156
|
+
mkdirSync(candidate, { recursive: true })
|
|
157
|
+
writeFileSync(tmp, text, 'utf8')
|
|
158
|
+
renameSync(tmp, path)
|
|
159
|
+
lastFailure = ''
|
|
160
|
+
if (dir !== primary) {
|
|
161
|
+
// 主目录写失败、退到了临时目录:调用方要能记一条,用户排查"为什么盘上还是旧值"时有据可查。
|
|
162
|
+
lastFallback = { fellBack: true, reason: failure || 'write:primary-failed' }
|
|
163
|
+
} else if (spillover) {
|
|
164
|
+
// 主目录写成功:把临时目录里可能残留的旧副本尽力删掉,避免以后误读到更旧的状态。
|
|
165
|
+
for (const other of withLongPath(spillover)) {
|
|
166
|
+
try {
|
|
167
|
+
unlinkSync(join(other, `${id}.json`))
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return true
|
|
172
|
+
} catch (error) {
|
|
173
|
+
failure = `write:${error.code || error.message || error}`
|
|
174
|
+
lastFailure = failure
|
|
175
|
+
try {
|
|
176
|
+
unlinkSync(tmp)
|
|
177
|
+
} catch {}
|
|
178
|
+
// 这个候选写不进去(只读、被占用、路径太深)就试下一个,最后才放弃。
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return false
|
|
183
|
+
}
|
package/lib/tokens.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// 自己的 token 估算器:不依赖宿主的度量服务。
|
|
2
|
+
//
|
|
3
|
+
// 为什么不用宿主的:`@deepseek-ai/dsh-token-meter` 的 estimateMessage 用的是
|
|
4
|
+
// 「固定密度」启发式——`ceil(text.length / 4)`。中文一个字的 `.length` 是 1,
|
|
5
|
+
// 于是四个汉字才算 1 token;真实分词器远不止,拿它当长度尺子,"5000 token" 实际能写到两万多字。
|
|
6
|
+
//
|
|
7
|
+
// 密度按真实账单调过(2026-11-06,一次 l0 压缩的完整校验:227 条消息、非空 225 条,
|
|
8
|
+
// 文本 19.3 万字符、reasoning 26 万字符、工具参数 3.7 万字符;按下面的密度算出 168,299,
|
|
9
|
+
// 而压缩前后两次真实请求的 prompt 差是 171,464,误差约 2%):
|
|
10
|
+
// · 中日韩字符、全角标点、假名、注音:1 字 = 0.6 token
|
|
11
|
+
// · 其他非 ASCII(emoji、西里尔、泰文…):1 字 = 0.6 token
|
|
12
|
+
// · ASCII 数字:3 个 = 1 token(数字分词通常 1~3 位一档)
|
|
13
|
+
// · ASCII 其他(字母、标点、空格、换行):1 字 = 0.3 token
|
|
14
|
+
// 最后整体向上取整。
|
|
15
|
+
//
|
|
16
|
+
// 校准依据(经验值,量级正确即可):
|
|
17
|
+
// · 纯中文 100 字 ≈ 60 token(真实 55~70)
|
|
18
|
+
// · 纯英文 400 字符 ≈ 120 token(真实 100~140)
|
|
19
|
+
// · 中文为主的技术文档混合文本 ≈ 每 token 1.6~1.7 字
|
|
20
|
+
//
|
|
21
|
+
// 用途:单块正文上限、标题上限、地图自估、压缩收益校验的"自估"一侧;
|
|
22
|
+
// 都要求量级正确,差 10%~20% 不影响判断;差 4 倍会让闸门失效。
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 估一段文本的 token 数。
|
|
26
|
+
* @param {string} text
|
|
27
|
+
* @returns {number} 向上取整的 token 估计
|
|
28
|
+
*/
|
|
29
|
+
export function estimateTokens(text) {
|
|
30
|
+
if (typeof text !== 'string' || text.length === 0) return 0
|
|
31
|
+
|
|
32
|
+
let wide = 0 // 中日韩等宽字符 + 非 ASCII 其他
|
|
33
|
+
let digits = 0 // ASCII 数字
|
|
34
|
+
let narrow = 0 // ASCII 字母、标点、空白
|
|
35
|
+
|
|
36
|
+
for (const ch of text) {
|
|
37
|
+
const code = ch.codePointAt(0)
|
|
38
|
+
if (code > 0x7f) {
|
|
39
|
+
wide += 1
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (code >= 0x30 && code <= 0x39) {
|
|
43
|
+
digits += 1
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
narrow += 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 逐类密度见文件头:中文 0.6/字、ASCII 0.3/字、数字 1/3。
|
|
50
|
+
// 极端情况保底:非空文本至少 1 token
|
|
51
|
+
return Math.max(1, Math.ceil(wide * 0.6 + digits / 3 + narrow * 0.3))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 宿主自己的口径:`@deepseek-ai/dsh-token-meter` 的 estimateMessage 用 `ceil(text.length / 4)`。
|
|
55
|
+
// 中文会被低估约 4 倍,所以**只在"两侧同时退回宿主口径"这一处**使用(见 compress.js 的收益校验)。
|
|
56
|
+
// 别的地方一律用 estimateTokens / messageTokens,避免同一张图两处算出两个数。
|
|
57
|
+
export function hostTokens(text) {
|
|
58
|
+
if (typeof text !== 'string') return 0
|
|
59
|
+
return Math.ceil([...text].length / 4)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 一条消息占多少 token:文本、图片、工具调用的参数,一处算清。
|
|
63
|
+
// 地图、压缩收益、长度校验全部调这里——只留一把尺子,避免"同一张图两处算出两个数"。
|
|
64
|
+
export const IMAGE_TOKENS = 800
|
|
65
|
+
|
|
66
|
+
export function messageTokens(message, imageTokens = IMAGE_TOKENS) {
|
|
67
|
+
const content = message && message.content
|
|
68
|
+
if (!Array.isArray(content)) return 0
|
|
69
|
+
let total = 0
|
|
70
|
+
for (const block of content) {
|
|
71
|
+
if (!block || typeof block !== 'object') continue
|
|
72
|
+
if (block.type === 'image') {
|
|
73
|
+
// 计价口径可以换(A4):压缩收益校验用宿主按路由声明的真实图片价,
|
|
74
|
+
// 地图展示与其它粗算仍用固定的 IMAGE_TOKENS。传 0 表示"这一块不算图片",
|
|
75
|
+
// 由调用方把真实的图片价单独加上去,避免同一张图被算两次。
|
|
76
|
+
total += typeof imageTokens === 'function' ? imageTokens(block) : imageTokens
|
|
77
|
+
} else if (block.type === 'tool-call') {
|
|
78
|
+
// 工具调用的参数是独立的一串 JSON,不在文本块里;不单独算,读文件/写代码类调用会被算成 0。
|
|
79
|
+
const name = typeof block.name === 'string' ? block.name : ''
|
|
80
|
+
const args = block.arguments
|
|
81
|
+
let raw = ''
|
|
82
|
+
try {
|
|
83
|
+
raw = typeof args === 'string' ? args : (JSON.stringify(args) ?? '')
|
|
84
|
+
} catch {
|
|
85
|
+
raw = ''
|
|
86
|
+
}
|
|
87
|
+
total += estimateTokens(name) + estimateTokens(raw) + 8
|
|
88
|
+
} else if (typeof block.text === 'string') {
|
|
89
|
+
// text 与 reasoning 都是真正进请求的文本块:thinking 模式的 API 要求把 reasoning
|
|
90
|
+
// 原样回传,丢掉会被拒(实测返回 400:The reasoning_content in the thinking mode
|
|
91
|
+
// must be passed back)。以前只认 text,reasoning 整块记 0 —— 一次 227 条的区间里
|
|
92
|
+
// 它有 26 万字符,自估因此少一半(回归修复)。
|
|
93
|
+
// 以后宿主再加新的文本类块,也走这一支,不会再静默记 0。
|
|
94
|
+
// 边界:非文本块(file 等)不在这里计价,自估对它们偏保守;以后若出现带 text 展示
|
|
95
|
+
// 字段的非文本块,要在这里按类型排除,别把展示文案算成请求内容。
|
|
96
|
+
total += estimateTokens(block.text)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return total
|
|
100
|
+
}
|
package/locale/en.json
ADDED