dsh-context-domains 0.3.6 → 0.4.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/DESIGN.md +122 -4
- package/README.md +30 -4
- package/SPEC.md +10 -0
- package/client.js +583 -132
- package/lib/compaction.js +262 -0
- package/lib/http.js +30 -2
- package/lib/runtime.js +12 -5
- package/lib/store.js +10 -1
- package/package.json +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// 上下文压缩阈值(compaction-basic 的压力线)的运行期接管。
|
|
2
|
+
//
|
|
3
|
+
// 为什么不是「改配置」:compaction-basic 的 config 在装载时被 deepFreeze,而且它挂在
|
|
4
|
+
// preset(@deepseek-ai/dsh-agent-preset)的 isolate 组里 —— profile 的 cordis.patch.yml
|
|
5
|
+
// 只能按 id 命中顶层/group 节点,够不到 preset 内部子节点(详见 ~/.dsh/compaction-threshold.py
|
|
6
|
+
// 的说明)。所以静态改阈值只有两条路:改 preset 文件(要重启)或该脚本(要重启)。
|
|
7
|
+
//
|
|
8
|
+
// 真正决定「压不压」的是 resolveCompactSpec() 算出的 thresholdTokens:
|
|
9
|
+
// thresholdTokens = floor(min(window * thresholdRatio, window - reservedOutput - headroomTokens))
|
|
10
|
+
// 判定是 `measurement.totalTokens >= thresholdTokens` 就压。于是只要给每个
|
|
11
|
+
// (provider, model) 造一条精确的 modelPolicies 覆盖:
|
|
12
|
+
// thresholdRatio = 1、headroomTokens = (window - reservedOutput) - budget
|
|
13
|
+
// 解出来就是 min(window, budget) —— 阈值被钉死在用户给的预算上。modelPolicies 在每次
|
|
14
|
+
// compactPressure 里现查(resolveTargetPolicy),所以运行期重写 engine.config 立刻生效:
|
|
15
|
+
// 不重启 dsh、不动 preset 文件、不依赖模型。
|
|
16
|
+
//
|
|
17
|
+
// 预算存在自身 state.json(用户数据,不是部署配置),counter 在会话顶栏最右边。
|
|
18
|
+
const MIN_TOKENS = 200_000
|
|
19
|
+
const MAX_TOKENS = 10_000_000
|
|
20
|
+
const STEP_TOKENS = 100_000
|
|
21
|
+
const DEFAULT_TOKENS = 500_000
|
|
22
|
+
/** 压缩后保留的原文尾巴占阈值的比例(太小等于每轮全summarize,太大压不动)。 */
|
|
23
|
+
const RETAIN_SHARE = 0.25
|
|
24
|
+
/** 引擎扫描周期:preset 是懒挂载的,新会话选到新 preset 时才会出现新引擎。 */
|
|
25
|
+
const SCAN_INTERVAL_MS = 4000
|
|
26
|
+
/** cordis fiber 的「已激活」状态值(与 cordis 内部 _getImpl(strict) 同一判据)。 */
|
|
27
|
+
const FIBER_ACTIVE = 2
|
|
28
|
+
|
|
29
|
+
export { MIN_TOKENS, MAX_TOKENS, STEP_TOKENS, DEFAULT_TOKENS }
|
|
30
|
+
|
|
31
|
+
/** 收进 [20 万, 1000 万],并按千位取整(UI 只以 10 万为步进)。 */
|
|
32
|
+
export function clampBudgetTokens(value) {
|
|
33
|
+
if (!Number.isFinite(value)) return DEFAULT_TOKENS
|
|
34
|
+
const rounded = Math.round(value / 1000) * 1000
|
|
35
|
+
return Math.min(MAX_TOKENS, Math.max(MIN_TOKENS, rounded))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 文档里的预算;没写过就是默认值(不静默关掉接管)。 */
|
|
39
|
+
export function budgetOf(doc) {
|
|
40
|
+
const raw = doc?.compaction?.budgetTokens
|
|
41
|
+
return Number.isFinite(raw) ? clampBudgetTokens(raw) : DEFAULT_TOKENS
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function keyOf(provider, model) {
|
|
45
|
+
return `${provider}\u0000${model}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createCompactionBudget({ ctx, store, logger }) {
|
|
49
|
+
const log = (level, message) => {
|
|
50
|
+
const fn = logger?.[level]
|
|
51
|
+
if (typeof fn === 'function') fn.call(logger, `[context-domains] ${message}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** engine -> 接管前的原生 config(deepFrozen,只读参照)。 */
|
|
55
|
+
const patched = new Map()
|
|
56
|
+
/** "provider\0model" -> {window, reserved} | {error} */
|
|
57
|
+
const targets = new Map()
|
|
58
|
+
/** engine -> 上次写进去的签名(预算 + policy 表没变就不重写,避免每 4s 换一次 config 对象) */
|
|
59
|
+
const signatures = new WeakMap()
|
|
60
|
+
let timer
|
|
61
|
+
|
|
62
|
+
const llm = () => {
|
|
63
|
+
try {
|
|
64
|
+
return ctx.get('llm')
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 服务表里所有活着的 compaction 引擎(跨 isolate realm,所以按符号扫 store)。 */
|
|
71
|
+
function liveEngines() {
|
|
72
|
+
const bag = ctx.reflect?.store
|
|
73
|
+
const live = new Set()
|
|
74
|
+
if (bag === undefined || bag === null) return live
|
|
75
|
+
for (const key of Object.getOwnPropertySymbols(bag)) {
|
|
76
|
+
const impl = bag[key]
|
|
77
|
+
if (impl === undefined || impl.name !== 'compaction') continue
|
|
78
|
+
if (impl.ctx?.fiber?.state !== FIBER_ACTIVE) continue
|
|
79
|
+
const value = impl.value
|
|
80
|
+
if (value === undefined || typeof value.compactPressure !== 'function') continue
|
|
81
|
+
live.add(value)
|
|
82
|
+
}
|
|
83
|
+
return live
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 把预算翻译成该模型的一条精确 policy。 */
|
|
87
|
+
function policyFor(key, entry, budget) {
|
|
88
|
+
if (entry.error !== undefined) return undefined
|
|
89
|
+
const capacity = entry.window - entry.reserved
|
|
90
|
+
if (!(capacity > 0)) return undefined
|
|
91
|
+
const [provider, model] = key.split('\u0000')
|
|
92
|
+
const threshold = Math.min(budget, capacity)
|
|
93
|
+
const retain = Math.max(1, Math.floor(threshold * RETAIN_SHARE))
|
|
94
|
+
return {
|
|
95
|
+
provider,
|
|
96
|
+
model,
|
|
97
|
+
thresholdRatio: 1,
|
|
98
|
+
headroomTokens: Math.max(0, capacity - budget),
|
|
99
|
+
retainTokens: Math.min(retain, Math.max(1, threshold - 1)),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function apply(engine) {
|
|
104
|
+
const base = patched.get(engine)
|
|
105
|
+
if (base === undefined) return
|
|
106
|
+
const budget = budgetOf(store.document())
|
|
107
|
+
const policies = []
|
|
108
|
+
for (const [key, entry] of targets) {
|
|
109
|
+
const policy = policyFor(key, entry, budget)
|
|
110
|
+
if (policy !== undefined) policies.push(policy)
|
|
111
|
+
}
|
|
112
|
+
const signature = `${budget}|${JSON.stringify(policies)}`
|
|
113
|
+
if (signatures.get(engine) === signature) return
|
|
114
|
+
signatures.set(engine, signature)
|
|
115
|
+
const ours = new Set(policies.map((policy) => keyOf(policy.provider, policy.model)))
|
|
116
|
+
const inherited = Array.isArray(base.modelPolicies)
|
|
117
|
+
? base.modelPolicies.filter((policy) => !ours.has(keyOf(policy.provider, policy.model)))
|
|
118
|
+
: []
|
|
119
|
+
engine.config = { ...base, modelPolicies: [...inherited, ...policies] }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function applyAll() {
|
|
123
|
+
for (const engine of patched.keys()) apply(engine)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 学一条 (provider, model) 的窗口;学不到就记下原因,降级回该模型的原生阈值。 */
|
|
127
|
+
async function learn(target) {
|
|
128
|
+
if (target === null || typeof target !== 'object') return
|
|
129
|
+
const provider = target.provider
|
|
130
|
+
const model = target.model
|
|
131
|
+
if (typeof provider !== 'string' || provider === '' || typeof model !== 'string' || model === '') return
|
|
132
|
+
const key = keyOf(provider, model)
|
|
133
|
+
const known = targets.get(key)
|
|
134
|
+
// 失败过的目标给 30s 冷却后重试(适配器可能只是暂时没就绪)
|
|
135
|
+
if (known !== undefined) {
|
|
136
|
+
if (known.error === undefined) return
|
|
137
|
+
if (Date.now() - (known.at ?? 0) < 30_000) return
|
|
138
|
+
targets.delete(key)
|
|
139
|
+
}
|
|
140
|
+
const service = llm()
|
|
141
|
+
if (service === undefined) {
|
|
142
|
+
targets.set(key, { error: 'llm 服务不可见,无法解析模型窗口', at: Date.now() })
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const info = await service.resolveModelInfo(provider, model)
|
|
147
|
+
const window = info?.context?.contextWindow
|
|
148
|
+
if (!Number.isInteger(window) || window <= 0) {
|
|
149
|
+
targets.set(key, { error: '模型没有声明 contextWindow', at: Date.now() })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
targets.set(key, { window, reserved: Number.isFinite(info.defaultMaxTokens) ? info.defaultMaxTokens : 0 })
|
|
153
|
+
applyAll()
|
|
154
|
+
} catch (error) {
|
|
155
|
+
targets.set(key, { error: error?.message ?? String(error), at: Date.now() })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 装上包装,一次就够。
|
|
161
|
+
*
|
|
162
|
+
* 包 compactPressure 而不是换个引擎实现:它同时被自动压缩(isBeforeStep)、模型切换
|
|
163
|
+
* 和 compactIfNeeded 三处调用,包一层就全覆盖;而且这里是唯一能拿到 header.config
|
|
164
|
+
* 的地方 —— 模型路由只有走到这一步才知道,窗口也因此才能按需解析。
|
|
165
|
+
*/
|
|
166
|
+
function patch(engine) {
|
|
167
|
+
if (patched.has(engine)) return false
|
|
168
|
+
const original = engine.compactPressure
|
|
169
|
+
if (typeof original !== 'function') return false
|
|
170
|
+
patched.set(engine, engine.config)
|
|
171
|
+
engine.compactPressure = async function compactPressureWithBudget(agent, header, signal, requireBelowThreshold) {
|
|
172
|
+
try {
|
|
173
|
+
await learn(header?.config)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
log('warn', `compaction budget: model info lookup failed: ${error?.message ?? error}`)
|
|
176
|
+
}
|
|
177
|
+
return original.call(engine, agent, header, signal, requireBelowThreshold)
|
|
178
|
+
}
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 扫一遍:摘掉死引擎、装上新引擎、按当前预算重写所有引擎的 policy 表。 */
|
|
183
|
+
function refresh() {
|
|
184
|
+
const live = liveEngines()
|
|
185
|
+
for (const engine of [...patched.keys()]) if (!live.has(engine)) patched.delete(engine)
|
|
186
|
+
let added = 0
|
|
187
|
+
for (const engine of live) if (patch(engine)) added += 1
|
|
188
|
+
applyAll()
|
|
189
|
+
if (added > 0) log('info', `compaction budget: installed on ${patched.size} engine(s)`)
|
|
190
|
+
return status()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function status() {
|
|
194
|
+
const budget = budgetOf(store.document())
|
|
195
|
+
const rows = []
|
|
196
|
+
const degraded = []
|
|
197
|
+
for (const [key, entry] of targets) {
|
|
198
|
+
const [provider, model] = key.split('\u0000')
|
|
199
|
+
if (entry.error !== undefined) {
|
|
200
|
+
degraded.push({ provider, model, reason: entry.error })
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
const capacity = entry.window - entry.reserved
|
|
204
|
+
rows.push({
|
|
205
|
+
provider,
|
|
206
|
+
model,
|
|
207
|
+
contextWindow: entry.window,
|
|
208
|
+
reservedTokens: entry.reserved,
|
|
209
|
+
thresholdTokens: Math.min(budget, Math.max(0, capacity)),
|
|
210
|
+
clamped: budget > capacity,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
if (patched.size === 0) degraded.push({ reason: '没有在线的 compaction 引擎(preset 还没挂载)' })
|
|
214
|
+
if (llm() === undefined) degraded.push({ reason: 'llm 服务不可见,预算未接管' })
|
|
215
|
+
return {
|
|
216
|
+
budgetTokens: budget,
|
|
217
|
+
tokens: Math.round(budget / STEP_TOKENS),
|
|
218
|
+
stepTokens: STEP_TOKENS,
|
|
219
|
+
minTokens: MIN_TOKENS,
|
|
220
|
+
maxTokens: MAX_TOKENS,
|
|
221
|
+
engines: patched.size,
|
|
222
|
+
targets: rows,
|
|
223
|
+
degraded,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
status,
|
|
229
|
+
refresh,
|
|
230
|
+
/** 会话起来就重扫一次:新 preset 的引擎是随会话挂载的。 */
|
|
231
|
+
kick() {
|
|
232
|
+
try {
|
|
233
|
+
refresh()
|
|
234
|
+
} catch (error) {
|
|
235
|
+
log('warn', `compaction budget refresh failed: ${error?.message ?? error}`)
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
start() {
|
|
239
|
+
refresh()
|
|
240
|
+
timer = setInterval(() => {
|
|
241
|
+
try {
|
|
242
|
+
refresh()
|
|
243
|
+
} catch (error) {
|
|
244
|
+
log('warn', `compaction budget scan failed: ${error?.message ?? error}`)
|
|
245
|
+
}
|
|
246
|
+
}, SCAN_INTERVAL_MS)
|
|
247
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
248
|
+
return () => {
|
|
249
|
+
if (timer !== undefined) clearInterval(timer)
|
|
250
|
+
// 还回原生 config:插件被卸载/热更时不留一个「预算已接管」的假象
|
|
251
|
+
for (const [engine, base] of patched) {
|
|
252
|
+
try {
|
|
253
|
+
engine.config = base
|
|
254
|
+
} catch {
|
|
255
|
+
/* 引擎可能已经跟着 preset 一起没了 */
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
patched.clear()
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
}
|
package/lib/http.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// HTTP API:Client 半边没有 typert 代码生成,所以走同源 fetch + 前缀路由
|
|
2
2
|
// (dsh-file-activity / horon-web 同款)。全部端点前置信任栅栏。
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { clampBudgetTokens, MAX_TOKENS, MIN_TOKENS, STEP_TOKENS } from './compaction.js'
|
|
4
5
|
import { domainCounts, requestedMembers, selectionFor, unassigned } from './resolve.js'
|
|
5
6
|
import { estimateTokens } from './config.js'
|
|
6
7
|
import { VERSION } from './version.js'
|
|
@@ -54,7 +55,7 @@ function normalizeMembers(raw) {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export function registerHttp(ctx, deps) {
|
|
57
|
-
const { store, config, applier, logger } = deps
|
|
58
|
+
const { store, config, applier, budget, logger } = deps
|
|
58
59
|
const log = (level, message) => {
|
|
59
60
|
const fn = logger?.[level]
|
|
60
61
|
if (typeof fn === 'function') fn.call(logger, `[context-domains] ${message}`)
|
|
@@ -136,6 +137,8 @@ export function registerHttp(ctx, deps) {
|
|
|
136
137
|
skills: live.skills.items.filter((item) => !managedSkillSet.has(item.name)).length,
|
|
137
138
|
tools: live.tools.items.filter((item) => !managedToolSet.has(item.name)).length,
|
|
138
139
|
},
|
|
140
|
+
// 压缩阈值:counter 在顶栏最右边,值来自这里(全局,不是会话级)
|
|
141
|
+
compaction: budget?.status?.() ?? null,
|
|
139
142
|
discoverComplete: live.skills.complete === true,
|
|
140
143
|
degraded: status.degraded ?? [],
|
|
141
144
|
}
|
|
@@ -187,6 +190,7 @@ export function registerHttp(ctx, deps) {
|
|
|
187
190
|
domains: store.document().domains.length,
|
|
188
191
|
sessions: Object.keys(store.document().sessions).length,
|
|
189
192
|
agents: applier.entries.size,
|
|
193
|
+
compaction: budget?.status?.() ?? null,
|
|
190
194
|
})
|
|
191
195
|
},
|
|
192
196
|
|
|
@@ -252,6 +256,29 @@ export function registerHttp(ctx, deps) {
|
|
|
252
256
|
ok(res, out)
|
|
253
257
|
},
|
|
254
258
|
|
|
259
|
+
/**
|
|
260
|
+
* 压缩阈值:客户端顶栏 counter 写这里。
|
|
261
|
+
* tokens 是「单位 10 万」的整数(最低 2 = 20 万),budgetTokens 是绝对值;两者都可。
|
|
262
|
+
*/
|
|
263
|
+
async 'POST /compaction'(_req, res, _url, body) {
|
|
264
|
+
const fromTokens = Number.isFinite(body.tokens) ? body.tokens * STEP_TOKENS : undefined
|
|
265
|
+
const requested = Number.isFinite(body.budgetTokens) ? body.budgetTokens : fromTokens
|
|
266
|
+
if (requested === undefined) return fail(res, 400, 'bad-request', 'tokens or budgetTokens is required')
|
|
267
|
+
const budgetTokens = clampBudgetTokens(requested)
|
|
268
|
+
await store.mutate((doc) => {
|
|
269
|
+
doc.compaction = { budgetTokens, at: Date.now() }
|
|
270
|
+
}, 'compaction')
|
|
271
|
+
budget?.refresh?.()
|
|
272
|
+
ok(res, {
|
|
273
|
+
budgetTokens,
|
|
274
|
+
tokens: Math.round(budgetTokens / STEP_TOKENS),
|
|
275
|
+
stepTokens: STEP_TOKENS,
|
|
276
|
+
minTokens: MIN_TOKENS,
|
|
277
|
+
maxTokens: MAX_TOKENS,
|
|
278
|
+
status: budget?.status?.() ?? null,
|
|
279
|
+
})
|
|
280
|
+
},
|
|
281
|
+
|
|
255
282
|
async 'GET /domains'(_req, res) {
|
|
256
283
|
const doc = store.document()
|
|
257
284
|
const merged = await readCatalogAll(ctx)
|
|
@@ -318,7 +345,8 @@ export function registerHttp(ctx, deps) {
|
|
|
318
345
|
icon: typeof body.icon === 'string' ? body.icon : undefined,
|
|
319
346
|
description: typeof body.description === 'string' ? body.description : undefined,
|
|
320
347
|
members,
|
|
321
|
-
|
|
348
|
+
// 新建默认启用:建域的意图就是「要用它」,默认值别再让人多勾一次
|
|
349
|
+
defaultOn: typeof body.defaultOn === 'boolean' ? body.defaultOn : true,
|
|
322
350
|
order: doc.domains.length,
|
|
323
351
|
}
|
|
324
352
|
doc.domains.push(domain)
|
package/lib/runtime.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// 组装:store ↔ 目录 ↔ 施加层 ↔ HTTP。这里是唯一知道所有部件的地方。
|
|
1
|
+
// 组装:store ↔ 目录 ↔ 施加层 ↔ 压缩预算 ↔ HTTP。这里是唯一知道所有部件的地方。
|
|
2
2
|
import { normalizeConfig } from './config.js'
|
|
3
3
|
import { createStore } from './store.js'
|
|
4
4
|
import { createApplier } from './apply.js'
|
|
5
|
+
import { createCompactionBudget } from './compaction.js'
|
|
5
6
|
import { registerHttp } from './http.js'
|
|
6
7
|
import { stateFile, dataDir } from './paths.js'
|
|
7
8
|
|
|
@@ -10,18 +11,24 @@ export async function createRuntime(ctx, rawConfig) {
|
|
|
10
11
|
const store = createStore({ file: stateFile(), logger: ctx.logger })
|
|
11
12
|
await store.load()
|
|
12
13
|
|
|
14
|
+
const budget = createCompactionBudget({ ctx, store, logger: ctx.logger })
|
|
13
15
|
const applier = createApplier({ ctx, config, store, logger: ctx.logger })
|
|
14
|
-
const http = registerHttp(ctx, { store, config, applier, logger: ctx.logger })
|
|
16
|
+
const http = registerHttp(ctx, { store, config, applier, budget, logger: ctx.logger })
|
|
17
|
+
|
|
18
|
+
// 压缩阈值接管:引擎随 preset 懒挂载,所以启动 + 每 4s 扫一次(见 lib/compaction.js)
|
|
19
|
+
ctx.effect(() => budget.start(), 'context-domains: compaction budget')
|
|
15
20
|
|
|
16
21
|
// 会话生命周期:agent 出现就装,消失就撤(插件自己的 effect 也持有它,双保险)
|
|
17
22
|
ctx.on('agent/created', async ({ agent }) => {
|
|
23
|
+
budget.kick()
|
|
18
24
|
await applier.attach(agent)
|
|
19
25
|
ctx.effect(() => () => applier.detach(agent.id))
|
|
20
26
|
})
|
|
21
27
|
ctx.on('agent/disposed', ({ agent }) => applier.detach(agent.id))
|
|
22
28
|
|
|
23
|
-
// 领域/选择变更 →
|
|
24
|
-
store.onChange(() => {
|
|
29
|
+
// 领域/选择变更 → 所有在线会话重算(压缩预算写入不需要重装工具/skill,跳过)
|
|
30
|
+
store.onChange((_doc, reason) => {
|
|
31
|
+
if (reason === 'compaction') return
|
|
25
32
|
void applier.reapplyAll()
|
|
26
33
|
})
|
|
27
34
|
|
|
@@ -34,5 +41,5 @@ export async function createRuntime(ctx, rawConfig) {
|
|
|
34
41
|
|
|
35
42
|
ctx.logger?.info?.(`[context-domains] ready · state=${stateFile()} · dir=${dataDir()}`)
|
|
36
43
|
|
|
37
|
-
return { store, applier, http, config }
|
|
44
|
+
return { store, applier, http, budget, config }
|
|
38
45
|
}
|
package/lib/store.js
CHANGED
|
@@ -10,7 +10,8 @@ const VERSION = 1
|
|
|
10
10
|
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000 // 会话覆盖保留 30 天
|
|
11
11
|
|
|
12
12
|
export function emptyDocument() {
|
|
13
|
-
|
|
13
|
+
// compaction:压缩阈值预算(用户数据,与领域同库;没写过就是 {},读侧回落默认值)
|
|
14
|
+
return { version: VERSION, domains: [], sessions: {}, compaction: {}, updatedAt: 0 }
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
function asStringArray(value) {
|
|
@@ -87,6 +88,14 @@ export function normalizeDocument(raw) {
|
|
|
87
88
|
const normalized = normalizeSession(value)
|
|
88
89
|
if (normalized !== undefined) doc.sessions[sessionId] = normalized
|
|
89
90
|
}
|
|
91
|
+
// v0.4 起:压缩阈值预算。缺失/非法一律回落 {}(读侧给默认值),不阻止插件启动
|
|
92
|
+
const compaction = raw.compaction !== null && typeof raw.compaction === 'object' ? raw.compaction : {}
|
|
93
|
+
if (Number.isFinite(compaction.budgetTokens)) {
|
|
94
|
+
doc.compaction = {
|
|
95
|
+
budgetTokens: Math.round(compaction.budgetTokens),
|
|
96
|
+
at: Number.isFinite(compaction.at) ? compaction.at : 0,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
90
99
|
doc.updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0
|
|
91
100
|
return doc
|
|
92
101
|
}
|