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.
@@ -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
- defaultOn: body.defaultOn === true,
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
- return { version: VERSION, domains: [], sessions: {}, updatedAt: 0 }
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-context-domains",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "DSH 上下文领域:把一个领域里的 skill 与 tool 归成一块,在会话顶部一键整块开关,设置页里维护领域成员",
5
5
  "type": "module",
6
6
  "main": "index.js",