dsh-ebbing-context 0.0.0-stage → 0.2.7
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 +97 -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/config.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// 配置:长度一律按 token,只有标题按字符。可被 profile 的 bundle patch 行 config 覆盖。
|
|
2
|
+
// 纪律:配置项名字只在这里定义一次,别处一律引用,不许硬编码数字。
|
|
3
|
+
|
|
4
|
+
export const DEFAULTS = {
|
|
5
|
+
// 各区总长上限(占可用输入预算的比例:窗口减输出预留,且不低于窗口的 10%)。
|
|
6
|
+
// 超了只在提醒里报数字,不做任何机械处置。
|
|
7
|
+
budget: { l1: 0.10, l2: 0.06, l3: 0.03 },
|
|
8
|
+
|
|
9
|
+
// 单块正文上限,单位是 token(不是字数)。用插件自带估算器算(中文 1 字≈0.6 token、
|
|
10
|
+
// 英文 1 字≈0.3 token,按真实用量校准过),不用宿主那套 4 字 1 token 的口径,
|
|
11
|
+
// 否则中英文会被同一个数字卡出两种结果。超了直接拒绝写入,把实测值与上限回给模型。
|
|
12
|
+
segment: { l1: 5000, l2: 2000, l3: 5000 },
|
|
13
|
+
|
|
14
|
+
// 压缩比不设门槛、也不在提示里提。原来那套"至少 25 倍/8 倍"等于在鼓励模型
|
|
15
|
+
// 往死里压;而压缩比只有下限没有上限,真压成一句话它也照样通过。
|
|
16
|
+
// 现在只保留一条硬约束:压完必须比原来小(那是宿主的硬要求,不达标整批不落盘)。
|
|
17
|
+
|
|
18
|
+
// 标题上限:用"等效 token"算(中文 1 字 = 0.6,英文 1 字 = 0.3)。
|
|
19
|
+
// 不用宿主的估算器——它按 CHARS_PER_TOKEN=4 直接除 text.length,
|
|
20
|
+
// 中文会被低估约 4 倍(4 个汉字才算 1 token),拿来当长度尺子会松得离谱。
|
|
21
|
+
title: { maxTokens: 50 },
|
|
22
|
+
|
|
23
|
+
// 剪掉超大的工具返回(原位改写,原文仍在日志,可逆性比我们好)。
|
|
24
|
+
// 宿主的 toolResultPruner 是全局服务:任何工具返回超过 thresholdChars(默认 8192 字符)
|
|
25
|
+
// 就把中间挖掉、留 head 4096 + tail 1024;它只碰 tool/result,碰不到 assistant/message
|
|
26
|
+
// 与 user/message——所以压缩块和摘要剪不掉。
|
|
27
|
+
// 宿主只在它自己的自动压缩两条路上调它(compaction-basic 的 context-overflow / pressure)——
|
|
28
|
+
// 宿主把它当"压缩的前置步骤"。我们与它同构:只在 compress 里叫一次(见 compress.js),
|
|
29
|
+
// 不压缩就不动表层。开关关掉就完全不剪(宿主的 auto 关着时也就没人剪了)。
|
|
30
|
+
pruneFirst: true,
|
|
31
|
+
|
|
32
|
+
// 压缩成功后,把「这次 compress 调用 + 它的返回」从表层折成一句短注。
|
|
33
|
+
// 摘要里那份参数副本在被折成块之后就是纯重复:原文已经是顶部的块,它却还整份发一遍。
|
|
34
|
+
// 必须在工具返回后的下一次 pre-step 做——那一刻工具结果才存在;只删调用会留下孤儿返回。
|
|
35
|
+
// 代价:模型看不到自己那次调用的完整返回,只剩一句"成功了、压了什么";摘要正文仍在块里。
|
|
36
|
+
foldCallRecord: true,
|
|
37
|
+
|
|
38
|
+
// 到线提醒:占用量达到 triggerPct 时每一轮追加一条;掉到线下就不再发到线提醒(没有滞回)。
|
|
39
|
+
notice: {
|
|
40
|
+
enabled: true,
|
|
41
|
+
// 另一个独立触发条件:自上次提醒后上下文又长了多少 token,就再提醒一次
|
|
42
|
+
// (总量还没到线、但一直在稳步变长时,这条会先叫)。
|
|
43
|
+
// 长到这个数却一层都压不动时,额度自动砍到它的一半、基线前移,再长半个额度才重新判
|
|
44
|
+
// (用户要求 第 2 条:不是静默丢一次,而是把门槛降下来继续等)。
|
|
45
|
+
growthTokens: 65000,
|
|
46
|
+
// 增长提醒发出、模型这一轮选择不压之后,门槛按原额的百分之多少重设。
|
|
47
|
+
// 40 = 原额 65000 的话,下一轮再长 26000 就重新触发(设计约束)。
|
|
48
|
+
// 100 = 全额重攒;0 = 下一轮立刻再触发。只影响增长这条触发。
|
|
49
|
+
growthResetPct: 40,
|
|
50
|
+
// 子代理(宿主用 `agents.enter(agent, owner)` 建出来的非根 agent)要不要也参与:
|
|
51
|
+
// 默认 true(用户要求 定)——子代理也会碰到已压缩的表层,没有轮次标记它就看不出
|
|
52
|
+
// 「这一块盖住了哪几轮」,等于压过的信息在它眼里丢了。所以标记、提醒、判据全都照做。
|
|
53
|
+
// 想安静时可以在面板关掉:关掉后子代理不叫醒、不写标记、不报红字。
|
|
54
|
+
notifySubagents: true,
|
|
55
|
+
},
|
|
56
|
+
reservedTokens: 0, // 输出预留读不到时的兜底值(0 = 不扣)
|
|
57
|
+
triggerPct: 0.75,
|
|
58
|
+
|
|
59
|
+
// 原文区(l0)必须留住的最后 N 轮:`to` 最多只写到 currentTurn − keepTurns。
|
|
60
|
+
// 当前轮天然压不了;这条给"最近几轮还在用、压了就要立刻取回原文"留缓冲。
|
|
61
|
+
keepTurns: 2,
|
|
62
|
+
|
|
63
|
+
// 取回:依赖宿主「会话搜索」实验性插件。缺失时每次调用这两个工具都直接报错,
|
|
64
|
+
// 不静默降级——否则压缩不可逆,而模型不知道自己已经取不回原文了。
|
|
65
|
+
requireSessionQuery: true,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const NAME = 'dsh-ebbing-context'
|
|
69
|
+
export const LOG_FILE = 'ebbing-context.log'
|
|
70
|
+
export const STATUS_COMMAND = 'context-status'
|
|
71
|
+
|
|
72
|
+
// 块正文第一行的标题行格式:〔L1|T16–T19|ev 4121–4390〕标题
|
|
73
|
+
export const TITLE_OPEN = '〔'
|
|
74
|
+
export const TITLE_CLOSE = '〕'
|
|
75
|
+
|
|
76
|
+
// 与原生成检查点逐字节对齐的前导(宿主未导出该常量,只能自建)
|
|
77
|
+
export const CHECKPOINT_PREAMBLE =
|
|
78
|
+
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. ' +
|
|
79
|
+
'Treat the captured context as established background and build on it without restating it. ' +
|
|
80
|
+
'Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
|
|
81
|
+
|
|
82
|
+
export const SUMMARY_OPEN_TAG = '<compacted-summary>'
|
|
83
|
+
export const SUMMARY_CLOSE_TAG = '</compacted-summary>'
|
|
84
|
+
|
|
85
|
+
// 配置自检:不依赖宿主的 schema 机制,自己也能把写错的配置挡下来。
|
|
86
|
+
// 原来只靠 schemastery,加载不到就静默跳过校验——用户以为设上了,其实走的是默认值。
|
|
87
|
+
// 老配置项的迁移:`notice.forgivePct`(旧语义「不压时退回多少额度」,默认 100)
|
|
88
|
+
// 在 0.1.9 之后改名成 `notice.growthResetPct`(新语义「门槛重设到原额的百分之多少」,默认 40)。
|
|
89
|
+
// 不做迁移的话,老 profile 里那一行要么被宿主 schema 丢掉、静默按 40 跑(语义反了),
|
|
90
|
+
// 要么留在对象里被下面的 knownSub 判成"不认识的配置项",直接拒绝启动(回归修复)。
|
|
91
|
+
// 这里就地改名,并保留"用户原来写的那个数",不替他猜语义。
|
|
92
|
+
export function migrateConfig(raw) {
|
|
93
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw
|
|
94
|
+
// 默认就地改(宿主保存配置时只看这个对象,改就地最不容易漏)。
|
|
95
|
+
// 万一宿主将来把配置对象深冻结,就地写会抛 TypeError、整插件加载失败:
|
|
96
|
+
// 那时退回"浅拷贝再改",返回拷贝给调用方即可。
|
|
97
|
+
try {
|
|
98
|
+
return migrateInPlace(raw)
|
|
99
|
+
} catch {
|
|
100
|
+
const copy = { ...raw }
|
|
101
|
+
const notice = copy.notice
|
|
102
|
+
if (notice && typeof notice === 'object' && !Array.isArray(notice)) copy.notice = { ...notice }
|
|
103
|
+
return migrateInPlace(copy)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function migrateInPlace(raw) {
|
|
108
|
+
// 0.1.11 的 patch 模板把 notifySubagents 写错了一层(落到了根上),装过那版的人
|
|
109
|
+
// 配置里就有个根级键;这里把它挪进 notice,别让它变成"启动即拒绝"。
|
|
110
|
+
// 判据只看「旧键在不在」,**不要**看新键是不是 undefined:宿主解析 schema 时会把
|
|
111
|
+
// volatile 叶子按默认值预置好(schemastery 先处理 volatile、再填 default),
|
|
112
|
+
// 于是新键永远有值,`=== undefined` 恒假 —— 那正是"旧值被静默丢弃"的坑(第四轮审查 D1)。
|
|
113
|
+
if (raw.notifySubagents !== undefined) {
|
|
114
|
+
if (!raw.notice || typeof raw.notice !== 'object' || Array.isArray(raw.notice)) raw.notice = {}
|
|
115
|
+
// 根级键是 0.1.11 模板写错层的产物,只有它还等于旧默认值时才认为"用户没在新位置设过"。
|
|
116
|
+
const current = raw.notice.notifySubagents
|
|
117
|
+
const sameAsDefault = current === DEFAULTS.notice.notifySubagents
|
|
118
|
+
if (current === undefined || sameAsDefault) raw.notice.notifySubagents = raw.notifySubagents
|
|
119
|
+
delete raw.notifySubagents
|
|
120
|
+
}
|
|
121
|
+
const notice = raw.notice
|
|
122
|
+
if (notice && typeof notice === 'object' && !Array.isArray(notice)) {
|
|
123
|
+
if (notice.forgivePct !== undefined) {
|
|
124
|
+
notice.growthResetPct = notice.forgivePct
|
|
125
|
+
delete notice.forgivePct
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return raw
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function validateConfig(input) {
|
|
132
|
+
// 迁移可能返回拷贝(配置被冻结时),校验必须看同一份。
|
|
133
|
+
const raw = migrateConfig(input)
|
|
134
|
+
const bad = []
|
|
135
|
+
const num = (value, path, min, max) => {
|
|
136
|
+
if (value === undefined) return
|
|
137
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return bad.push(`${path} 应该是数字,现在是 ${JSON.stringify(value)}`)
|
|
138
|
+
if (min !== undefined && value < min) bad.push(`${path} 不能小于 ${min},现在是 ${value}`)
|
|
139
|
+
if (max !== undefined && value > max) bad.push(`${path} 不能大于 ${max},现在是 ${value}`)
|
|
140
|
+
}
|
|
141
|
+
const bool = (value, path) => {
|
|
142
|
+
if (value === undefined) return
|
|
143
|
+
if (typeof value !== 'boolean') bad.push(`${path} 应该是 true 或 false,现在是 ${JSON.stringify(value)}`)
|
|
144
|
+
}
|
|
145
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
146
|
+
throw new Error(`${NAME}: 配置必须是一个对象,现在是 ${JSON.stringify(raw)}`)
|
|
147
|
+
}
|
|
148
|
+
for (const group of ['budget', 'segment', 'title', 'notice']) {
|
|
149
|
+
const value = raw[group]
|
|
150
|
+
if (value === undefined) continue
|
|
151
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
152
|
+
bad.push(`${group} 应该是对象(形如 ${group}: { ... }),现在是 ${JSON.stringify(value)}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const budget = raw.budget || {}
|
|
156
|
+
for (const k of ['l1', 'l2', 'l3']) num(budget[k], `budget.${k}`, 0, 1)
|
|
157
|
+
const segment = raw.segment || {}
|
|
158
|
+
for (const k of ['l1', 'l2', 'l3']) num(segment[k], `segment.${k}`, 64)
|
|
159
|
+
const title = raw.title || {}
|
|
160
|
+
num(title.maxTokens, 'title.maxTokens', 4)
|
|
161
|
+
const notice = raw.notice || {}
|
|
162
|
+
bool(notice.enabled, 'notice.enabled')
|
|
163
|
+
num(notice.growthTokens, 'notice.growthTokens', 0)
|
|
164
|
+
num(notice.growthResetPct, 'notice.growthResetPct', 0, 100)
|
|
165
|
+
bool(notice.notifySubagents, 'notice.notifySubagents')
|
|
166
|
+
bool(raw.pruneFirst, 'pruneFirst')
|
|
167
|
+
bool(raw.foldCallRecord, 'foldCallRecord')
|
|
168
|
+
bool(raw.requireSessionQuery, 'requireSessionQuery')
|
|
169
|
+
num(raw.reservedTokens, 'reservedTokens', 0)
|
|
170
|
+
num(raw.triggerPct, 'triggerPct', 0.1, 0.99)
|
|
171
|
+
// keepTurns:面板 min=1 拦正常输入;校验副本上也兜底夹到 1..50(SPEC §4.2:必须是 ≥1 的整数)。
|
|
172
|
+
// 不再报红、不再停轮——以前放 0 进来靠 pre-step 每轮抛错,会把整段对话打死(用户 2026-11-05)。
|
|
173
|
+
if (raw.keepTurns !== undefined) {
|
|
174
|
+
if (typeof raw.keepTurns !== 'number' || !Number.isFinite(raw.keepTurns)) {
|
|
175
|
+
bad.push(`keepTurns 应该是数字,现在是 ${JSON.stringify(raw.keepTurns)}`)
|
|
176
|
+
} else {
|
|
177
|
+
raw.keepTurns = Math.min(50, Math.max(1, Math.trunc(raw.keepTurns)))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const known = ['budget','segment','title','notice','pruneFirst','foldCallRecord','requireSessionQuery','reservedTokens','triggerPct','keepTurns']
|
|
181
|
+
for (const key of Object.keys(raw)) {
|
|
182
|
+
if (!known.includes(key)) bad.push(`不认识的配置项 ${key}(拼错了?可用的是 ${known.join(' / ')})`)
|
|
183
|
+
}
|
|
184
|
+
const knownSub = { budget: ['l1','l2','l3'], segment: ['l1','l2','l3'], title: ['maxTokens'], notice: ['enabled','growthTokens','growthResetPct','notifySubagents'] }
|
|
185
|
+
for (const [group, keys] of Object.entries(knownSub)) {
|
|
186
|
+
const value = raw[group]
|
|
187
|
+
if (value === undefined || value === null || typeof value !== 'object') continue
|
|
188
|
+
for (const key of Object.keys(value)) {
|
|
189
|
+
if (!keys.includes(key)) bad.push(`${group}.${key} 不认识(可用的是 ${keys.join(' / ')})`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (bad.length) {
|
|
193
|
+
throw new Error(`${NAME}: 配置有 ${bad.length} 处不对,插件拒绝启动(以免你以为设上了、实际用的是默认值):\n - ${bad.join('\n - ')}`)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// 图片计价(设计约束):宿主按路由声明的图片价是唯一与账单同源的口径,
|
|
2
|
+
// 拿不到就退回固定 800——宁可粗,也不能让计价问题冒到压缩流程里。
|
|
3
|
+
//
|
|
4
|
+
// 宿主口径(`ref/pkg-0.2.2-alpha.2/tok/lib/types/route-pricing.js:28-30`):
|
|
5
|
+
// const pricing = llm.imageRequestPricing(provider, model)
|
|
6
|
+
// const prices = pricing.priceImages(images) // 每张图(按出现顺序)一个 token 数
|
|
7
|
+
// 张数对不上会抛错。我们全部包在 try/catch 里,任何异常都返回 null(调用方退回 800)。
|
|
8
|
+
//
|
|
9
|
+
// 缓存:按 (provider, model) 缓存 pricing 对象。频繁切模型时最坏情况只是多个键、
|
|
10
|
+
// 每次都退回固定价,不会报错、不会中断压缩。
|
|
11
|
+
import { IMAGE_TOKENS, estimateTokens } from './tokens.js'
|
|
12
|
+
|
|
13
|
+
// 宿主 `priceImages()` 返回的元素**不是数字**,而是 `{ visualTokens, text }`:
|
|
14
|
+
// 宿主自己的账是 `tokens += price.visualTokens + estimateContent([{type:'text',text:price.text}])`
|
|
15
|
+
// (`ref/pkg-0.2.2-alpha.2/tok/lib/index.js:754-757`)。只认数字的话每张图都会落回 800,
|
|
16
|
+
// 于是"用真实价"这层壳在真机上等于没装(回归修复)。
|
|
17
|
+
// 两种形状都认:数字(旧版/第三方路由)与对象(当前宿主)。
|
|
18
|
+
function normalizePrice(price) {
|
|
19
|
+
if (typeof price === 'number' && Number.isFinite(price) && price > 0) return price
|
|
20
|
+
if (price && typeof price === 'object') {
|
|
21
|
+
const visual = typeof price.visualTokens === 'number' && Number.isFinite(price.visualTokens) ? price.visualTokens : null
|
|
22
|
+
const text = typeof price.text === 'string' && price.text.length ? estimateTokens(price.text) : 0
|
|
23
|
+
if (visual !== null && visual >= 0) return Math.max(1, Math.round(visual) + text)
|
|
24
|
+
}
|
|
25
|
+
return IMAGE_TOKENS
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const seen = new WeakMap() // ctx -> Map<key, pricing|null>
|
|
29
|
+
|
|
30
|
+
function cacheFor(ctx) {
|
|
31
|
+
// WeakMap 的键必须是对象:宿主恒传对象,但假宿主/探针可能传 null、数字、字符串,
|
|
32
|
+
// 那会在 set 处抛「Invalid value used as weak map key」。拿不到可用的键就不缓存。
|
|
33
|
+
if (!ctx || (typeof ctx !== 'object' && typeof ctx !== 'function')) return null
|
|
34
|
+
let byKey = seen.get(ctx)
|
|
35
|
+
if (!byKey) {
|
|
36
|
+
byKey = new Map()
|
|
37
|
+
seen.set(ctx, byKey)
|
|
38
|
+
}
|
|
39
|
+
return byKey
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pricingOf(ctx, route) {
|
|
43
|
+
const provider = (route && route.provider) || ''
|
|
44
|
+
const model = (route && route.model) || ''
|
|
45
|
+
const key = provider + '\u0000' + model
|
|
46
|
+
const byKey = cacheFor(ctx)
|
|
47
|
+
if (!byKey) return null
|
|
48
|
+
if (byKey.has(key)) return byKey.get(key)
|
|
49
|
+
let value = null
|
|
50
|
+
// 只缓存"确定没有图片价"这个结论。llm 服务还没装配、imageRequestPricing 抛错这类
|
|
51
|
+
// 都是暂时状态(宿主晚装配、适配器刚注册),把它们钉死在缓存里会整个进程都退回 800,
|
|
52
|
+
// 用户切模型时还会再踩一次(回归修复)。暂时性失败不写缓存,下次调用重试。
|
|
53
|
+
let cacheable = false
|
|
54
|
+
try {
|
|
55
|
+
const llm = ctx && typeof ctx.get === 'function' ? ctx.get('llm') : null
|
|
56
|
+
if (llm && typeof llm.imageRequestPricing === 'function') {
|
|
57
|
+
const got = llm.imageRequestPricing(provider, model)
|
|
58
|
+
if (got && typeof got.priceImages === 'function') value = got
|
|
59
|
+
// 方法存在、明确没给价(undefined / 缺 priceImages):这条路由就是不供应图片价,
|
|
60
|
+
// 属于确定性结论,可以缓存。
|
|
61
|
+
cacheable = true
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
value = null
|
|
65
|
+
cacheable = false
|
|
66
|
+
}
|
|
67
|
+
// 只缓存"拿到了价格对象"这一种结果:适配器未装配、路由暂不可用时 `imageRequestPricing`
|
|
68
|
+
// 也会返回 undefined,把它永久缓存会让这个进程后续都退回 800(回归修复)。
|
|
69
|
+
if (cacheable && value) byKey.set(key, value)
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 给一批图片出现位置定价。返回 null 表示"拿不到按路由的真实价,请退回固定价"。
|
|
75
|
+
* @param {any} ctx 插件上下文(只用于 ctx.get('llm'))
|
|
76
|
+
* @param {any} route 当前路由(agent.options 里的 provider/model)
|
|
77
|
+
* @param {any[]} images 按出现顺序排列的图片块
|
|
78
|
+
* @param {(event: string, data?: any) => void} [log]
|
|
79
|
+
* @returns {number[]|null}
|
|
80
|
+
*/
|
|
81
|
+
export function imagePrices(ctx, route, images, log) {
|
|
82
|
+
if (!Array.isArray(images) || images.length === 0) return []
|
|
83
|
+
const pricing = pricingOf(ctx, route)
|
|
84
|
+
if (!pricing) return null
|
|
85
|
+
try {
|
|
86
|
+
const prices = pricing.priceImages(images)
|
|
87
|
+
if (!Array.isArray(prices) || prices.length !== images.length) {
|
|
88
|
+
if (log) log('image-pricing-mismatch', { want: images.length, got: Array.isArray(prices) ? prices.length : -1 })
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
return prices.map(normalizePrice)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (log) log('image-pricing-failed', { error: String((error && error.message) || error) })
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
package/lib/license.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// 压缩许可证:地图是一次"看地形"的凭证,看一次只能用一次。
|
|
2
|
+
// 为什么需要它:模型只能按"区内序号"点名,序号是它看地图那一刻的。
|
|
3
|
+
// 如果中间表层被替换过(上一次压缩、或者别的插件),同一个序号可能指到隔壁的块。
|
|
4
|
+
// 所以:取地图发证,压缩成功收证;想再压必须重新看一遍地图。
|
|
5
|
+
//
|
|
6
|
+
// 判据不用宿主的 `replaceGeneration`(契约收紧):那个计数会被**任何**替换 +1,
|
|
7
|
+
// 包括宿主自己把提示词重写一遍、图片离线化、我们自己的折叠短注——
|
|
8
|
+
// 它们一个都没动块的位置,却会把刚发出去的地图打废,于是每次压完想再压一层都被假报过期。
|
|
9
|
+
// 现在改成对"地图里真正出现的东西"取指纹:三层的块 seq 表 + 表头节点。
|
|
10
|
+
// 只要这些没变,地图里写的序号就仍然指同一个块;变了就一定是真的过期。
|
|
11
|
+
const seen = new Map()
|
|
12
|
+
|
|
13
|
+
function fingerprint(zones) {
|
|
14
|
+
if (!zones || !zones.zones) return null
|
|
15
|
+
const seqs = (list) => (Array.isArray(list) ? list : []).map((b) => (b && b.seq !== undefined && b.seq !== null ? String(b.seq) : '')).join(',')
|
|
16
|
+
// 只算三层块的 seq 表,**不含表头节点 0**(回归修复):节点 0 不在任何地图里展示,
|
|
17
|
+
// 而宿主重写提示词(渲染文本变化)会把它换成新 seq——带上它就会出现"块一个没动、
|
|
18
|
+
// 却报这张地图过期"的假报警。地图给模型看的正是这三层,指纹就只盯这三层。
|
|
19
|
+
return [
|
|
20
|
+
seqs(zones.zones.l3),
|
|
21
|
+
seqs(zones.zones.l2),
|
|
22
|
+
seqs(zones.zones.l1),
|
|
23
|
+
].join('|')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
// 条目上限(许可证本轮要求):清理只挂在 agent/disposed 上,而插件被禁用时监听器
|
|
28
|
+
// 先卸掉了,之后销毁的代理没人 forget,长跑进程里会缓慢累积。按插入顺序淘汰最老的,
|
|
29
|
+
// 数量封顶即可——条目本身只有指纹,误淘汰最多让模型重看一次地图。
|
|
30
|
+
const SEEN_LIMIT = 256
|
|
31
|
+
|
|
32
|
+
export function issue(agentId, session, zones) {
|
|
33
|
+
seen.set(agentId, { fp: fingerprint(zones) })
|
|
34
|
+
while (seen.size > SEEN_LIMIT) {
|
|
35
|
+
const oldest = seen.keys().next().value
|
|
36
|
+
if (oldest === agentId) break
|
|
37
|
+
seen.delete(oldest)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function check(agentId, session, zones) {
|
|
42
|
+
const rec = seen.get(agentId)
|
|
43
|
+
if (!rec) {
|
|
44
|
+
return '还没看过地图:这条提醒或上一次工具结果里附的地图就能用;手边没有时再调一次 context_map。'
|
|
45
|
+
}
|
|
46
|
+
// 拿不到指纹(调用方没给 zones)时不拦:宁可按"没过期"放行,也不要凭空造一次拒绝。
|
|
47
|
+
// 真正的保护仍在 compress 里——它按实时表层重新算区间,区间对不上就整批拒绝。
|
|
48
|
+
if (rec.fp === null || !zones) return null
|
|
49
|
+
if (fingerprint(zones) !== rec.fp) {
|
|
50
|
+
return '这张地图已经过期了(这几层区里的块在这之后变过)——用最近一次结果里附的新地图,照它再压。'
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function consume(agentId) {
|
|
56
|
+
seen.delete(agentId)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
export function forget(agentId) {
|
|
62
|
+
seen.delete(agentId)
|
|
63
|
+
}
|
package/lib/log.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// 运行日志:只记"发生了什么判定",不记任何消息正文。
|
|
2
|
+
// 纪律:按大小滚动(默认 2 MiB × 2 份),单条超过 4 KiB 截断,写失败静默——
|
|
3
|
+
// 诊断面以 /context-status 为主,这个文件只是给"为什么会这样"留痕。
|
|
4
|
+
//
|
|
5
|
+
// 两条底线(2026-11-05 改):
|
|
6
|
+
// 1. **建目录失败不等于永久关闭**:以前一次失败就把 logger 换成空函数,整个进程再无痕迹;
|
|
7
|
+
// 现在每次写之前允许重试(失败后 30 秒冷却,避免每步都去撞同一个权限错误)。
|
|
8
|
+
// 2. **`DSH_HOME/logs` 建不了就退到系统临时目录**,宁可位置难看,也别让诊断线自己先断。
|
|
9
|
+
|
|
10
|
+
import { appendFileSync, mkdirSync, renameSync, statSync, rmSync } from 'node:fs'
|
|
11
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
|
|
14
|
+
const MAX_BYTES = 2 * 1024 * 1024
|
|
15
|
+
const KEEP_FILES = 2
|
|
16
|
+
const MAX_LINE = 4 * 1024
|
|
17
|
+
const RETRY_MS = 30 * 1000
|
|
18
|
+
|
|
19
|
+
function homeDir() {
|
|
20
|
+
return process.env.DSH_HOME || process.env.HOME || process.env.USERPROFILE || '.'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 解析成功后的文件路径;空串表示"此刻建不出来"(30 秒后会再试)。
|
|
24
|
+
let target = null
|
|
25
|
+
let targetOrigin = ''
|
|
26
|
+
let lastTry = 0
|
|
27
|
+
|
|
28
|
+
function logDirs(dir) {
|
|
29
|
+
const list = [dir]
|
|
30
|
+
if (process.platform === 'win32' && !dir.startsWith('\\\\?\\')) {
|
|
31
|
+
const abs = isAbsolute(dir) ? dir : resolve(dir)
|
|
32
|
+
list.push(`\\\\?\\${abs.replace(/\//g, '\\')}`)
|
|
33
|
+
}
|
|
34
|
+
return list
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function candidates() {
|
|
38
|
+
const list = []
|
|
39
|
+
for (const [dir, origin] of [
|
|
40
|
+
[join(homeDir(), 'logs'), 'home'],
|
|
41
|
+
[join(tmpdir(), 'ebbing-context'), 'temp'],
|
|
42
|
+
]) {
|
|
43
|
+
for (const candidate of logDirs(dir)) list.push({ dir: candidate, origin })
|
|
44
|
+
}
|
|
45
|
+
return list
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Windows 上超过 MAX_PATH(260) 的路径会让 mkdir 直接失败(ENAMETOOLONG),
|
|
49
|
+
// 那时退回长路径前缀(`\\?\` + 绝对路径)再试一次;还不行就换临时目录。
|
|
50
|
+
function tryResolve() {
|
|
51
|
+
for (const { dir, origin } of candidates()) {
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(dir, { recursive: true })
|
|
54
|
+
return { path: join(dir, 'ebbing-context.log'), origin }
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureTarget() {
|
|
61
|
+
if (target) return target
|
|
62
|
+
const now = Date.now()
|
|
63
|
+
if (now - lastTry < RETRY_MS) return ''
|
|
64
|
+
lastTry = now
|
|
65
|
+
const got = tryResolve()
|
|
66
|
+
if (!got) {
|
|
67
|
+
target = ''
|
|
68
|
+
targetOrigin = ''
|
|
69
|
+
return ''
|
|
70
|
+
}
|
|
71
|
+
target = got.path
|
|
72
|
+
targetOrigin = got.origin
|
|
73
|
+
return target
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 给 /context-status 用:日志到底写在哪、是不是退到了临时目录。
|
|
77
|
+
export function logStatus() {
|
|
78
|
+
const path = ensureTarget()
|
|
79
|
+
return { path, origin: targetOrigin, writable: Boolean(path) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rotate(path) {
|
|
83
|
+
try {
|
|
84
|
+
if (statSync(path).size < MAX_BYTES) return
|
|
85
|
+
} catch {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
// 每一步单独兜住:第一次滚动时 .1 还不存在,整体 try 会在第一步就抛出,
|
|
89
|
+
// 结果主日志永远轮不掉、无限增长。这里必须逐次容错。
|
|
90
|
+
for (let i = KEEP_FILES - 1; i >= 1; i--) {
|
|
91
|
+
try {
|
|
92
|
+
renameSync(`${path}.${i}`, `${path}.${i + 1}`)
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
rmSync(`${path}.${KEEP_FILES}`, { force: true })
|
|
97
|
+
} catch {}
|
|
98
|
+
try {
|
|
99
|
+
renameSync(path, `${path}.1`)
|
|
100
|
+
} catch {
|
|
101
|
+
// 滚动失败就不滚,宁可日志大一点也不能让插件写崩
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 同一个 scope 只发一个写入口:多个模块各建一个也能写,但每个实例各记一份"已经滚到哪个大小",
|
|
106
|
+
// 轮到自己判断时容易在别人刚滚完之后再滚一次;共享一个入口就不会有这个歧义。
|
|
107
|
+
const byScope = new Map()
|
|
108
|
+
|
|
109
|
+
export function makeLogger(scope) {
|
|
110
|
+
const cached = byScope.get(scope)
|
|
111
|
+
if (cached) return cached
|
|
112
|
+
const write = (event, detail) => {
|
|
113
|
+
const path = ensureTarget()
|
|
114
|
+
if (!path) return
|
|
115
|
+
try {
|
|
116
|
+
let line = `${new Date().toISOString()} [${scope}] ${event}`
|
|
117
|
+
if (detail !== undefined) {
|
|
118
|
+
let text
|
|
119
|
+
try {
|
|
120
|
+
text = typeof detail === 'string' ? detail : JSON.stringify(detail)
|
|
121
|
+
} catch {
|
|
122
|
+
text = String(detail)
|
|
123
|
+
}
|
|
124
|
+
if (text && text.length > MAX_LINE) text = `${text.slice(0, MAX_LINE)}…`
|
|
125
|
+
if (text) line += ` ${text}`
|
|
126
|
+
}
|
|
127
|
+
rotate(path)
|
|
128
|
+
appendFileSync(path, `${line}\n`)
|
|
129
|
+
} catch {
|
|
130
|
+
// 写日志永远不能影响主流程;下次调用会照常重试(不再永久关闭)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
byScope.set(scope, write)
|
|
134
|
+
return write
|
|
135
|
+
}
|