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 ADDED
Binary file
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Light-EI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,92 @@
1
- # Temporary Holding Version
1
+ # Ebbing Context
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![test](https://github.com/Light-EI/dsh-ebbing-context/actions/workflows/test.yml/badge.svg)](https://github.com/Light-EI/dsh-ebbing-context/actions/workflows/test.yml)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ **模拟记忆衰退的 DSH 分层上下文压缩插件。**
7
+
8
+ 模型自己决定压哪里、自己写摘要;插件只写宿主本来就认识的会话事件,把旧内容一层层降成更粗的表示:
9
+
10
+ ```
11
+ 原文(逐字) → L1 块(一段的结论与数据) → L2 块 → L3 块(只剩关键词句)
12
+ ```
13
+
14
+ 越旧的越粗,但**从来没有被删掉**——原文仍在会话日志里,装了「会话搜索」插件就能按事件号取回。
15
+
16
+ `ebbing` 是"渐渐消退":降不降、降多少,由模型看着上下文压力自己决定,不是定时任务。
17
+
18
+ > **状态**:功能完整、测试全绿,已在真机上跑通过压缩。没留下的是**可复现的验证记录**——当时的地图、日志与压缩前后对比都没归档。目标宿主 DSH 0.2.1-rc.3 ~ 0.2.2-alpha.2。试的时候请先备份 profile、用新会话试。
19
+ > 逐版变更见 [CHANGELOG](CHANGELOG.md)。
20
+
21
+ ---
22
+
23
+ ## 它怎么工作
24
+
25
+ - **两个工具 + 一条命令**:`context_map`(分层结构与占用)、`compress`(执行一次压缩)、`/context-status`(只读,同地图)。
26
+ - **键名 = 要压的那一层**:`l0` 压原文 → L1 块;`l1` 压 L1 → L2;`l2` 压 L2 → L3;`l3` 把整个 L3 区压成一块(唯一不写 `to` 的键)。
27
+ - **`to` 只能从地图上抄**:`l0` 写轮号(`T19`),`l1`/`l2` 写该区从 1 起的块序号。写错就被拒,拒绝回执里带刷新后的地图。
28
+ - **级联**:压深层时,比它浅且还能压的层必须同批给完,否则整批拒绝(只断一次前缀缓存)。
29
+ - **零台账**:不建库、不写状态文件;层级每轮从会话表层现算,块的身份写在正文第一行 `〔L1|T16–T19|ev 4121–4390〕标题`。
30
+ - **提醒但不代劳**:占用到线、又长了、某层超配额时,在本轮收尾把模型叫醒并附一张新地图。从不自动压缩,也不催 `/compact`。
31
+
32
+ 完整设计(分层模型、级联、保护边界、配置、宿主约定、限制)见 [docs/SPEC.md](https://github.com/Light-EI/dsh-ebbing-context/blob/main/docs/SPEC.md)。
33
+
34
+ ---
35
+
36
+ ## 安装
37
+
38
+ > npm 暂不上架(名字已占位 `dsh-ebbing-context`);下面两种方式在真机上装起来过。
39
+
40
+ **方式一:从源码目录装(推荐)**
41
+
42
+ ```bash
43
+ git clone https://github.com/Light-EI/dsh-ebbing-context.git
44
+ dsh plugin add ./dsh-ebbing-context
45
+ ```
46
+
47
+ **方式二:手动挂到 profile**
48
+
49
+ 1. 把仓库内容放进 profile 的 `node_modules/dsh-ebbing-context/`
50
+ 2. 在 profile 的 `cordis.patch.yml` 里加一行 `dsh-ebbing-context:`(路径写法见 [docs/SPEC.md](https://github.com/Light-EI/dsh-ebbing-context/blob/main/docs/SPEC.md) 的"目录与开发")
51
+ 3. 重启 DSH
52
+
53
+ 装完确认三件事:插件卡片显示**运行中**、`context_map` / `compress` 两个工具都在、`/context-status` 能出地图。
54
+
55
+ **卸载**(`dsh plugin remove dsh-ebbing-context` 之后还要做两步):
56
+
57
+ 1. 打开 profile 的 `cordis.patch.yml`,删掉 `dsh-ebbing-context` 那一节(包括面板里改过的 `config`)。不删的话,下一次启动会因为"patch 指向的条目不存在"在 stderr 刷一条警告——不会坏,但会一直刷。
58
+ 2. 日志在 `$DSH_HOME/logs/ebbing-context.log`(滚动时还有 `.1` / `.2`),卸载不会自动删,确认不需要排查后手动删掉即可。
59
+ **建议关掉宿主自带的自动压缩**(`compaction-basic` 的 `auto`,默认 80% 触发)。它一旦触发会把多层摘要重新拍平,细节永久丢失。插件照样能压,只是效果打折。关的位置取决于你的安装方式与预设。
60
+
61
+ ---
62
+
63
+ ## 配置
64
+
65
+ 共 17 项(`triggerPct`、`keepTurns`、`pruneFirst`、`foldCallRecord`、`requireSessionQuery`、`reservedTokens`、`budget.*`、`segment.*`、`title.maxTokens`、`notice.*`),在插件面板里改,改完点「保存」才生效。
66
+
67
+ 默认值、含义与拒绝规则见 [docs/SPEC.md 第 6 节](https://github.com/Light-EI/dsh-ebbing-context/blob/main/docs/SPEC.md#6-配置17-项)。
68
+
69
+ ---
70
+
71
+ ## 依赖与边界
72
+
73
+ - 需要宿主提供 `agent/pre-step`、`agent/turn-stopping`、`agent/followup` 与会话表层 API。
74
+ - 默认要求装「会话搜索」(`session_event_read` / `session_event_search`),取不回原文就不让压。
75
+ - 只做"成对替换"或"只改 `tool/result` 正文",从不单点删节点。
76
+ - 不联网、不读文件、不收集遥测。
77
+
78
+ ## 已知限制
79
+
80
+ - 并行调用时不折叠(也不改写返回),摘要正文会以参数形式留在上下文里(插件会提醒以后单独发)。
81
+ - 折叠只在下一次请求发出之前做;错过这一步的那条就不再折,调用记录原样留着。
82
+ - 图片无法取回,只按张数记账。
83
+ - 轮次大纲损坏(日志缺 `turn/start` 或乱序)时,该轮报红色错误并停用。
84
+ - 区占用由插件自估(中文约 0.6 token/字、英文约 0.3 token/字),与宿主计价器接近但仍会有几个百分点的差异。
85
+
86
+ ## 许可
87
+
88
+ MIT。
89
+
90
+ ## 参与
91
+
92
+ Issue / PR 都欢迎。动手前请先看 [docs/SPEC.md](https://github.com/Light-EI/dsh-ebbing-context/blob/main/docs/SPEC.md) 的"依赖的宿主约定"一节;那是正确性的前提。
package/client.js ADDED
@@ -0,0 +1,369 @@
1
+ // 浏览器侧:在「插件」面板的这个 bundle 页里画配置表单。
2
+ // 保存走宿主给的 form.mutate(一次提交 = 一个 revision = 一次校验),写回 profile 的 cordis.patch.yml。
3
+ // 这里不做任何读文件、不调用模型;只负责把可设置项画出来。
4
+
5
+ window.__ModuleLoader__.load({
6
+ id: 'dsh-ebbing-context',
7
+ factory(require) {
8
+ const React = require('react')
9
+ const h = React.createElement
10
+ const { useState, useEffect } = React
11
+
12
+ const TEXT = {
13
+ title: '上下文压缩',
14
+ hint: '改完点最下面的「保存」;离开页面不保存。',
15
+ loading: '正在读取配置…',
16
+ unavailable: '当前客户端读不到这个插件的配置。',
17
+ readonly: '当前 profile 不允许写入配置。',
18
+ noForm: '这个插件没有声明可编辑的配置。',
19
+ // 宿主侧插件没启动时,面板拿不到 form,页面会永远停在「正在读取配置…」。
20
+ // 那种情况下必须有一句能指向原因的话,否则用户只看到一片空白。
21
+ late: '一直停在这里,说明宿主侧的插件没有启动起来。DSH 日志里会有一条加载错误(搜 import),插件卡片的状态也会停在「未运行」而不是「运行中」。',
22
+ save: '保存',
23
+ saving: '保存中…',
24
+ discard: '放弃修改',
25
+ detail: '详情',
26
+ hide: '收起',
27
+ saved: '已保存。',
28
+ nothing: '没有改动。',
29
+ refused: '宿主拒绝了这次保存,改动没有生效。可能是值超出允许范围,或该字段不允许这样设置。',
30
+ failed: '保存失败:',
31
+ invalid: '这些值不合法,没有提交:',
32
+ // 使用注意:宿主自带的自动压缩一旦触发,会把这里的分层摘要拍平。
33
+ // 放在页面最上方、不折叠——它比任何一个配置项都更容易把人坑到。
34
+ // 不写死"改哪个文件"(配置入口随安装方式与预设而变),但要说清"关的是什么",
35
+ // 并直接给一句可以复制给助手/模型的话——用户不必自己去找配置文件。
36
+ note: '使用注意:如果这套 DSH 开着自带的自动压缩(compaction-basic 的自动压缩,默认在上下文用到约 80% 时自己触发),建议关掉它。它一旦触发,会把这里的多层摘要重新拍平成一段总摘要,分层结构就没了——详细内容要从会话日志里按事件号翻回来,模型不会再看到这些块;插件本身不受影响、照常能压,但效果会打折扣。要关的是「自动压缩」这个行为(通常是一个 auto 之类的开关,或直接停用那个插件),不关也行、只是效果打折。具体在哪一行、怎么改取决于你的安装方式与预设,插件不会替你改宿主配置。不想自己找的话,可以把这句发给你的助手或模型:「帮我关掉 DeepSeek Harness 自带的自动压缩(compaction-basic 的 auto),改完告诉我你动了哪个文件、怎么改回去」,让它去查;也可以先问问它这个功能是做什么的再决定。',
37
+ }
38
+
39
+ // 每一项:分组、标签、路径、类型、范围、详情
40
+ const FIELDS = [
41
+ {
42
+ group: '配额', label: 'L1 占比', path: ['budget', 'l1'], kind: 'number', step: 0.01, min: 0, max: 1,
43
+ detail: '第一层把「原文」折成块,整层合计最多占可用输入的比例。0.10 表示最多占 10%。三层合计默认 0.19(10% + 6% + 3%);调大后留给原文的空间会变小。',
44
+ },
45
+ {
46
+ group: '配额', label: 'L2 占比', path: ['budget', 'l2'], kind: 'number', step: 0.01, min: 0, max: 1,
47
+ detail: '第二层把「L1 块」再折一次,整层合计最多占可用输入的比例。',
48
+ },
49
+ {
50
+ group: '配额', label: 'L3 占比', path: ['budget', 'l3'], kind: 'number', step: 0.01, min: 0, max: 1,
51
+ detail: '第三层只放关键词句,用来保住任务目标、约束和结论。整层合计最多占可用输入的比例。',
52
+ },
53
+ {
54
+ group: '单块上限', label: 'L1 单块上限', path: ['segment', 'l1'], kind: 'number', step: 100, min: 64,
55
+ detail: '一个 L1 块正文最多多少 token(按等效 token 算:中文 1 字约 0.6、英文 1 字约 0.3)。超了这次调用会被整批拒绝,拒绝里给出实际值与上限;怎么调整由模型自己判断(换一段更小的范围再压),不要为了塞进上限删内容。',
56
+ },
57
+ {
58
+ group: '单块上限', label: 'L2 单块上限', path: ['segment', 'l2'], kind: 'number', step: 100, min: 64,
59
+ detail: '一个 L2 块正文最多多少 token。超过同样整批拒绝,并给出实际值与上限。',
60
+ },
61
+ {
62
+ group: '单块上限', label: 'L3 单块上限', path: ['segment', 'l3'], kind: 'number', step: 100, min: 64,
63
+ detail: '一个 L3 块正文最多多少 token。压 l2 时新块追加在 L3 区末尾、旧 L3 块不动;压 l3 时整个 L3 区并成一块,旧块一起并进去——所以这一块的上限也决定了 L3 区最后一次压缩能留下多少。',
64
+ },
65
+ {
66
+ group: '单块上限', label: '块标题上限', path: ['title', 'maxTokens'], kind: 'number', step: 5, min: 4,
67
+ detail: '每个块正文首行那个标题最多多少 token。标题是模型之后认块用的,太短会认错,太长浪费。',
68
+ },
69
+ {
70
+ group: '提醒', label: '开启提醒', path: ['notice', 'enabled'], kind: 'boolean',
71
+ detail: '提醒的总开关。开着时,插件在每一轮收尾(该叫醒时把模型叫醒)递一条提醒并附上当前地图,让模型自己决定压不压——它从不替模型压缩。关掉就完全不提醒,压缩本身不受影响。',
72
+ },
73
+ {
74
+ group: '提醒', label: '增长提醒阈值', path: ['notice', 'growthTokens'], kind: 'number', step: 1000, min: 0,
75
+ detail: '到线之前的另一条路:自上次提醒后又长了这么多 token 就提醒一次,即使总量还没到线。写 0 只关掉这一条(到线与某层超出设计占比仍会提醒)。长到这个数却一层都压不动时,门槛会自动砍到一半、基线前移,再长半个门槛就重新判一次。',
76
+ },
77
+ {
78
+ group: '提醒', label: '不压之后门槛重设到原额的百分比', path: ['notice', 'growthResetPct'], kind: 'number', step: 10, min: 0, max: 100,
79
+ detail: '增长提醒发出去之后,模型如果决定这一轮不压(内容还没做完、或者比较重要),下一个门槛就按原额的百分之多少重设。40(默认)= 原额 65000 的话,再长 26000 才重新触发;100 = 全额重攒;0 = 门槛降到 1 token,下一轮只要再长一点就重新触发。只影响「又长了」这条触发,不影响到线提醒。增长到量但一层都压不动时走另一条路:门槛固定降到一半、基线前移,再长半个门槛重新判一次,直到某次真压得动为止。',
80
+ },
81
+ {
82
+ group: '提醒', label: '子代理也参与', path: ['notice', 'notifySubagents'], kind: 'boolean',
83
+ detail: '开(默认):子代理按同一套规则被标记与叫醒。它也会碰到已压缩的表层,没有轮次标记就看不出「这块盖住了哪几轮」,压过的信息在它眼里等于丢了。关:子代理不叫醒、不写轮次标记、不报红字。',
84
+ },
85
+ {
86
+ group: '提醒', label: '提醒触发占比', path: ['triggerPct'], kind: 'number', step: 0.01, min: 0.1, max: 0.99,
87
+ detail: '那条线画在哪。0.75 表示用到「可用输入」的 75% 开始提醒。可用输入 = 上下文窗口 − 输出预留,不是裸窗口大小。只在越过这条线时提醒一次,停在线上不会每轮重复催;掉到线下(比如压完之后)再越线才会再提醒一次。',
88
+ },
89
+ {
90
+ group: '行为', label: '原文区留住最后几轮', path: ['keepTurns'], kind: 'number', step: 1, min: 1, max: 50,
91
+ detail: '原文区必须留住的最后 N 轮:compress 的 l0 最多只写到「当前轮 − 1 − N」。当前轮本来就压不了;这条是给最近几轮留缓冲,免得刚压完就要按事件号取回原文。',
92
+ },
93
+ {
94
+ group: '行为', label: '剪大工具返回', path: ['pruneFirst'], kind: 'boolean',
95
+ detail: '把超大的工具返回剪短,别让它们白占上下文。宿主的 toolResultPruner 是全局服务:任何工具返回只要超过 8192 字符,就把中间挖掉、只留开头 4096 与结尾 1024,并插一句「[... tool result middle pruned ...]」,对所有工具一视同仁(本插件 compress 自己那条返回也一样)。它只动工具返回,不动模型写的摘要、也不动压缩块——块是 user/message,不是 tool/result,所以摘要剪不到;被剪的中间那段的原文仍在会话日志里,可按事件号取回。宿主原本把它当「压缩的前置步骤」:只有它的自动压缩要动手时才叫。关掉宿主自动压缩后,改由本插件在每次压缩前叫一次——不压缩就不剪,模型正在用的内容不会被提前改写。关掉这个开关就完全不剪,原文原样留着。',
96
+ },
97
+ {
98
+ group: '行为', label: '折叠压缩调用记录', path: ['foldCallRecord'], kind: 'boolean',
99
+ detail: '压缩成功后,把「这次调用 compress + 它的返回」一起折成一句短注(说明调过、成功了、压了什么)。摘要正文本来就已经写成顶部的块,留在调用参数里的那份是纯重复。宿主不允许改写助手消息本身,只删调用又会留下孤儿返回,所以这一对只能在下一次请求之前整体换成一句用户消息——代价是你看不到那次调用的完整返回。',
100
+ },
101
+ {
102
+ group: '行为', label: '要求可取回原文', path: ['requireSessionQuery'], kind: 'boolean',
103
+ detail: '它不改变压缩本身:开或关,compress 都照常工作。它只决定「没有取回工具时要不要停用」——开着时,一旦发现没装「会话搜索」,两个工具就停用并说明原因,提醒也不再叫醒,宁可上下文大着也不做取不回的事;关掉时照常压;能不能按事件号查回来,取决于这台机器装没装会话搜索。',
104
+ },
105
+ {
106
+ group: '行为', label: '输出预留兜底', path: ['reservedTokens'], kind: 'number', step: 1000, min: 0,
107
+ detail: '读不到宿主的输出预留值时,用它扣减可用窗口。0 表示不扣。注意:宿主能给出预留值时这一项不生效。',
108
+ },
109
+ ]
110
+
111
+ function readPath(obj, path) {
112
+ let cur = obj
113
+ for (const k of path) {
114
+ if (cur === null || cur === undefined || typeof cur !== 'object') return undefined
115
+ cur = cur[k]
116
+ }
117
+ return cur
118
+ }
119
+
120
+ const keyOf = (item) => item.path.join('.')
121
+
122
+ const rowStyle = {
123
+ display: 'flex',
124
+ alignItems: 'flex-start',
125
+ justifyContent: 'space-between',
126
+ gap: '16px',
127
+ padding: '10px 0',
128
+ borderTop: '1px solid color-mix(in srgb, currentColor 12%, transparent)',
129
+ }
130
+ const labelStyle = { fontSize: '13px', fontWeight: 600, lineHeight: '20px' }
131
+ const detailStyle = { fontSize: '12px', opacity: 0.7, marginTop: '6px', lineHeight: '18px', maxWidth: '640px' }
132
+ const inputStyle = {
133
+ width: '104px',
134
+ padding: '4px 8px',
135
+ fontSize: '13px',
136
+ textAlign: 'right',
137
+ color: 'inherit',
138
+ background: 'color-mix(in srgb, currentColor 6%, transparent)',
139
+ border: '1px solid color-mix(in srgb, currentColor 18%, transparent)',
140
+ borderRadius: '6px',
141
+ outline: 'none',
142
+ }
143
+ const groupStyle = { fontSize: '12px', fontWeight: 600, opacity: 0.55, marginTop: '18px', marginBottom: '2px' }
144
+ // 使用注意:常驻、不折叠、不和错误横幅抢位置——它讲的是"会让插件效果打折"的既有事实。
145
+ const noteStyle = {
146
+ fontSize: '12px',
147
+ lineHeight: '19px',
148
+ maxWidth: '640px',
149
+ margin: '10px 0 4px',
150
+ padding: '8px 10px',
151
+ borderRadius: '6px',
152
+ borderLeft: '3px solid color-mix(in srgb, currentColor 30%, transparent)',
153
+ background: 'color-mix(in srgb, currentColor 5%, transparent)',
154
+ opacity: 0.85,
155
+ }
156
+ const linkStyle = {
157
+ fontSize: '12px', opacity: 0.7, background: 'none', border: 'none',
158
+ padding: 0, marginLeft: '8px', cursor: 'pointer', color: 'inherit', textDecoration: 'underline',
159
+ }
160
+ const buttonStyle = (primary, disabled) => ({
161
+ padding: '6px 16px',
162
+ fontSize: '13px',
163
+ fontWeight: 600,
164
+ color: primary ? '#fff' : 'inherit',
165
+ background: primary ? '#247bbf' : 'transparent',
166
+ border: primary ? '1px solid #247bbf' : '1px solid color-mix(in srgb, currentColor 22%, transparent)',
167
+ borderRadius: '8px',
168
+ cursor: disabled ? 'not-allowed' : 'pointer',
169
+ opacity: disabled ? 0.5 : 1,
170
+ })
171
+ const bannerStyle = (kind) => ({
172
+ fontSize: '12px',
173
+ lineHeight: '18px',
174
+ padding: '8px 10px',
175
+ marginBottom: '10px',
176
+ borderRadius: '6px',
177
+ border: '1px solid ' + (kind === 'error' ? 'color-mix(in srgb, #d33 45%, transparent)' : 'color-mix(in srgb, currentColor 18%, transparent)'),
178
+ background: kind === 'error' ? 'color-mix(in srgb, #d33 10%, transparent)' : 'color-mix(in srgb, currentColor 5%, transparent)',
179
+ })
180
+
181
+ function ConfigPage(props) {
182
+ const { view, form } = props
183
+ const [draft, setDraft] = useState({})
184
+ const [open, setOpen] = useState({})
185
+ const [banner, setBanner] = useState(null)
186
+ const [busy, setBusy] = useState(false)
187
+ // 「宿主侧根本没起来」和「配置还在路上」在界面上长得一样,靠时间把它们分开。
188
+ const [late, setLate] = useState(false)
189
+
190
+ useEffect(() => {
191
+ if (form) return undefined
192
+ const timer = setTimeout(() => setLate(true), 4000)
193
+ return () => clearTimeout(timer)
194
+ }, [form])
195
+
196
+ const st = (form && form.state) || {}
197
+ const revision = st.revision
198
+
199
+ useEffect(() => { setDraft({}); setBanner(null) }, [revision])
200
+
201
+ if (view !== 'page') return null
202
+ // form 由「插件」面板的宿主侧拼出来({ state, mutate }),插件条目还没进配置目录时它是 undefined:
203
+ // 那是"还没读到",不是"没有配置项",别骗用户说没声明。
204
+ if (!form) {
205
+ return h(
206
+ 'div',
207
+ { style: { fontSize: '13px', opacity: 0.7, lineHeight: 1.6 } },
208
+ late ? TEXT.late : TEXT.loading,
209
+ )
210
+ }
211
+ if (st.status === 'loading') return h('div', { style: { fontSize: '13px', opacity: 0.7 } }, TEXT.loading)
212
+ if (st.status === 'unavailable') return h('div', { style: { fontSize: '13px', opacity: 0.7 } }, TEXT.unavailable)
213
+
214
+ const value = st.value || {}
215
+ const readonly = st.writable === false
216
+
217
+ function effective(item) {
218
+ const k = keyOf(item)
219
+ if (Object.prototype.hasOwnProperty.call(draft, k)) return draft[k]
220
+ return readPath(value, item.path)
221
+ }
222
+
223
+ function setField(item, next) {
224
+ setBanner(null)
225
+ setDraft((prev) => ({ ...prev, [keyOf(item)]: next }))
226
+ }
227
+
228
+ const changed = FIELDS.filter((item) => {
229
+ const k = keyOf(item)
230
+ if (!Object.prototype.hasOwnProperty.call(draft, k)) return false
231
+ const now = readPath(value, item.path)
232
+ // 数字按数值比,不按字符串比:把 0.1 写成 0.10 / 00.1 / 1e-1 提交的还是同一个数,
233
+ // 不该算成"改了一项"。字符串比较只留给空值与非数字种类。
234
+ if (item.kind === 'number') {
235
+ if (now === undefined || now === null) return draft[k] !== ''
236
+ const num = Number(draft[k])
237
+ if (draft[k] === '' || !Number.isFinite(num)) return true
238
+ return num !== Number(now)
239
+ }
240
+ if (now === undefined) return draft[k] !== ''
241
+ return String(draft[k]) !== String(now)
242
+ })
243
+
244
+ async function save() {
245
+ if (busy || readonly) return
246
+ if (changed.length === 0) { setBanner({ kind: 'ok', text: TEXT.nothing }); return }
247
+
248
+ const bad = []
249
+ const ops = []
250
+ for (const item of changed) {
251
+ const raw = draft[keyOf(item)]
252
+ if (item.kind === 'boolean') {
253
+ ops.push({ op: 'set', path: item.path, value: raw === true })
254
+ continue
255
+ }
256
+ const num = Number(raw)
257
+ if (raw === '' || !Number.isFinite(num)) { bad.push(item.label + '(不是数字)'); continue }
258
+ if (item.min !== undefined && num < item.min) { bad.push(item.label + '(不能小于 ' + item.min + ')'); continue }
259
+ if (item.max !== undefined && num > item.max) { bad.push(item.label + '(不能大于 ' + item.max + ')'); continue }
260
+ ops.push({ op: 'set', path: item.path, value: num })
261
+ }
262
+
263
+ if (bad.length > 0) {
264
+ setBanner({ kind: 'error', text: TEXT.invalid + bad.join(';') })
265
+ return
266
+ }
267
+
268
+ setBusy(true)
269
+ try {
270
+ const ok = await form.mutate(ops, revision)
271
+ if (ok) {
272
+ setDraft({})
273
+ setBanner({ kind: 'ok', text: TEXT.saved })
274
+ } else {
275
+ setBanner({ kind: 'error', text: TEXT.refused })
276
+ }
277
+ } catch (err) {
278
+ setBanner({ kind: 'error', text: TEXT.failed + (err && err.message ? err.message : String(err)) })
279
+ } finally {
280
+ setBusy(false)
281
+ }
282
+ }
283
+
284
+ const control = (item) => {
285
+ const v = effective(item)
286
+ if (item.kind === 'boolean') {
287
+ return h('input', {
288
+ type: 'checkbox',
289
+ checked: v === true,
290
+ disabled: readonly || busy,
291
+ onChange: (e) => setField(item, e.target.checked),
292
+ style: { width: '16px', height: '16px', marginTop: '2px', cursor: readonly ? 'not-allowed' : 'pointer' },
293
+ })
294
+ }
295
+ return h('input', {
296
+ type: 'number',
297
+ value: v === undefined ? '' : String(v),
298
+ disabled: readonly || busy,
299
+ step: item.step,
300
+ min: item.min,
301
+ max: item.max,
302
+ onChange: (e) => setField(item, e.target.value),
303
+ style: inputStyle,
304
+ })
305
+ }
306
+
307
+ const groups = []
308
+ for (const item of FIELDS) {
309
+ if (groups.length === 0 || groups[groups.length - 1].name !== item.group) {
310
+ groups.push({ name: item.group, items: [] })
311
+ }
312
+ groups[groups.length - 1].items.push(item)
313
+ }
314
+
315
+ return h('div', { style: { fontSize: '13px', color: 'inherit' } },
316
+ h('div', { style: { fontSize: '14px', fontWeight: 600 } }, TEXT.title),
317
+ h('div', { style: { ...detailStyle, marginTop: '2px', marginBottom: '8px' } }, readonly ? TEXT.readonly : TEXT.hint),
318
+ h('div', { style: noteStyle }, TEXT.note),
319
+ banner ? h('div', { style: bannerStyle(banner.kind) }, banner.text) : null,
320
+ groups.map((g) => h('div', { key: g.name },
321
+ h('div', { style: groupStyle }, g.name),
322
+ g.items.map((item) => {
323
+ const k = keyOf(item)
324
+ return h('div', { key: k },
325
+ h('div', { style: rowStyle },
326
+ h('div', null,
327
+ h('span', { style: labelStyle }, item.label),
328
+ h('button', {
329
+ type: 'button',
330
+ style: linkStyle,
331
+ onClick: () => setOpen((prev) => ({ ...prev, [k]: !prev[k] })),
332
+ }, open[k] ? TEXT.hide : TEXT.detail),
333
+ open[k] ? h('div', { style: detailStyle }, item.detail) : null,
334
+ ),
335
+ control(item),
336
+ ),
337
+ )
338
+ }),
339
+ )),
340
+ h('div', { style: { display: 'flex', gap: '10px', marginTop: '18px', paddingTop: '14px', borderTop: '1px solid color-mix(in srgb, currentColor 12%, transparent)' } },
341
+ h('button', {
342
+ type: 'button',
343
+ style: buttonStyle(true, readonly || busy),
344
+ disabled: readonly || busy,
345
+ onClick: save,
346
+ }, busy ? TEXT.saving : TEXT.save),
347
+ h('button', {
348
+ type: 'button',
349
+ style: buttonStyle(false, readonly || busy || changed.length === 0),
350
+ disabled: readonly || busy || changed.length === 0,
351
+ onClick: () => { setDraft({}); setBanner(null) },
352
+ }, TEXT.discard),
353
+ h('span', { style: { fontSize: '12px', opacity: 0.6, alignSelf: 'center' } },
354
+ changed.length > 0 ? '待保存 ' + changed.length + ' 项' : ''),
355
+ ),
356
+ )
357
+ }
358
+
359
+ return {
360
+ inject: ['slots'],
361
+ apply(ctx) {
362
+ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
363
+ name: 'plugins.row.config',
364
+ key: 'dsh-ebbing-context#dsh-ebbing-context',
365
+ }, ConfigPage))
366
+ },
367
+ }
368
+ },
369
+ })
@@ -0,0 +1,28 @@
1
+ # 本层只干一件事:把插件插进 profile 的 Loader 行列表。
2
+ # 注意:profile 层覆盖该行时 config 会被整份替换(不深合并),改参数要写全。
3
+
4
+ - insert:
5
+ - id: dsh-ebbing-context
6
+ name: dsh-ebbing-context
7
+ config:
8
+ budget:
9
+ l1: 0.10
10
+ l2: 0.06
11
+ l3: 0.03
12
+ segment:
13
+ l1: 5000
14
+ l2: 2000
15
+ l3: 5000
16
+ triggerPct: 0.75
17
+ keepTurns: 2
18
+ notice:
19
+ enabled: true
20
+ growthTokens: 65000
21
+ growthResetPct: 40
22
+ notifySubagents: true
23
+ title:
24
+ maxTokens: 50
25
+ requireSessionQuery: true
26
+ pruneFirst: true
27
+ foldCallRecord: true
28
+ reservedTokens: 0
package/icon.svg ADDED
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Infinity Context">
2
+ <rect x="1" y="1" width="62" height="62" rx="14" fill="#12161f"/>
3
+ <g fill="none" stroke-linecap="round" stroke-linejoin="round">
4
+ <!-- three stacked layers: top narrowest (oldest, most compressed) -->
5
+ <path d="M20 18h24" stroke="#7cc7ff" stroke-width="6"/>
6
+ <path d="M16 30h32" stroke="#4a9fe0" stroke-width="6"/>
7
+ <path d="M12 42h40" stroke="#2f6f9e" stroke-width="6"/>
8
+ <!-- a bar dropping out of the bottom layer: the raw tail stays untouched -->
9
+ <path d="M12 52h28" stroke="#9aa7b8" stroke-width="4" stroke-dasharray="4 6"/>
10
+ </g>
11
+ </svg>