rcs-context 0.0.0-stage → 0.1.39

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/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ScreamingMaggot
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,195 @@
1
- # Temporary Holding Version
1
+ # RCS — 上下文折叠 · 外置推理 · 转录日志 · 外部记忆
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
+ [![dshfind](https://dshfind.com/api/badge/ScreamingMaggot/rcs-context?lang=zh)](https://dshfind.com/zh/plugins/ScreamingMaggot/rcs-context?ref=badge)
4
+
5
+ **给 DeepSeek Harness (DSH) 的插件包。** 长会话里工具输出和模型回复很快把上下文吃满,RCS 把它们压成可随时展开的一行摘要,把会话事实沉淀成带证据的外部记忆。
6
+
7
+ > 同样的窗口跑更多轮,同样的轮数花更少 token;折叠是**可逆**的——原文永远能展开,证据不丢。
8
+
9
+ ## 它解决什么问题
10
+
11
+ 一个真实的 DSH 长会话里,**工具输出 + 模型回复**往往比用户输入大两个数量级。几百步之后会出现三件事:
12
+
13
+ 1. **窗口被吃满** —— 1M token 的窗口也会被工具日志填到 40% 以上;
14
+ 2. **宿主原生压缩反复触发** —— 每次都要把整段对话交给主模型重新总结,**贵**,而且**不可逆**:细节一旦被总结就找不回来;
15
+ 3. **记忆无处安放** —— 上个会话的结论、约束、文件位置,下个会话要从零再说一遍。
16
+
17
+ RCS 用三件事同时处理它们:**折叠**(省窗口)、**外置推理**(省主模型轮次)、**外部记忆**(跨会话)。四个插件可独立启停,互不依赖。
18
+
19
+ ## 实测:折叠到底省了多少
20
+
21
+ 本机真实会话档案,DSH 0.2.2-rc.1 + `deepseek-v4.1-flash`(1,048,576 token 窗口),折叠档位 = 单压缩,2026-11。
22
+
23
+ | 会话 | 步数 | 累计工具输出+回复 | 折叠后驻留 | 压缩比 | 上下文占用 中位 / P90 | 峰值 | 原生压缩 |
24
+ |---|---|---|---|---|---|---|---|
25
+ | `c76c469f` | 1,482 | 24.54 MB | 0.68 MB | **36×** | 15.0% / 18.5% | 59.6% | 22 次 |
26
+ | `6ff460cb` | 416 | 8.71 MB | 0.14 MB | **61×** | 15.1% / 18.2% | 39.8% | 9 次 |
27
+ | `fde06aba` | 333 | 8.36 MB | 0.03 MB | **263×** | 12.3% / 17.9% | 42.0% | 3 次 |
28
+ | `8f473b7d` | 346 | 4.86 MB | 0.08 MB | **63×** | 10.2% / 15.6% | 24.0% | 6 次 |
29
+
30
+ **合计 12 个有折叠记录的单压缩档会话、2,869 步**:累计 50.18 MB 的工具输出与模型回复,折叠后实际驻留 0.96 MB(**52×**)。按中英混排约 3.5 字节/token 折算,50 MB ≈ 1,500 万 token,是 1M 窗口的 **14.3 倍**——不折叠的话这段内容要反复触发原生压缩;折叠后只占 **0.28 个窗口**。
31
+
32
+ 口径说明:
33
+ - **累计工具输出+回复** = 该会话全部 `tool/result` + `assistant/message` + `user/message` 的原始字节,取自已归档的 session 事件流;
34
+ - **折叠后驻留** = 插件最后一份 `last-fold` 记录的 `transcriptBytes`,即模型真正携带的折叠版 transcript;
35
+ - **上下文占用** = 每个模型步的 `inputTokens + cacheReadTokens` ÷ 1,048,576,取全部步的中位 / P90;**峰值**含原生压缩那次请求(它要读整段对话,所以偏高)。
36
+
37
+ 可自行复算:`node tools/session-econ.mjs --dsh-home "$DSH_HOME" --mode single`(读本机会话档案,不上传任何数据)。
38
+
39
+ ## 四个插件
40
+
41
+ | 插件 | 作用 | 默认 | 解决的问题 |
42
+ |---|---|---|---|
43
+ | **CONTEXTinjector** | 工具结果折叠、语义压缩、值保真提取 | 关(按会话启用) | 窗口被工具日志吃满 |
44
+ | **EXTREASON** | 只读子代理先核实(记忆/转录/文件/网页),主模型再动手 | 开 | 主模型空转、返工 |
45
+ | **LOGcompiler** | 转录日志,完整记录会话事件流 | 开 | 事后复盘与证据回溯 |
46
+ | **memory.mjs** | 外部记忆:SQLite + 中文检索 + 分层加载 + 生命周期 + 受控写入 | 关 | 跨会话失忆、重复交代 |
47
+
48
+ ## 适用 / 不适用
49
+
50
+ **适合**
51
+ - 单会话几百步以上的长任务(重构、调研、排障、论文/工程迭代);
52
+ - 工具输出重、日志多的场景(`pwsh`、编译、测试、爬取结果);
53
+ - 需要跨会话延续的项目(约定、约束、文件位置、决策)。
54
+
55
+ **不太需要**
56
+ - 一问一答的短会话(折叠与记忆都有固定开销,收益抵不过);
57
+ - 完全不让模型跑工具的纯对话;
58
+ - 对“每一字节原文都必须留在上下文里”的合规场景——折叠虽可展开,但驻留的是摘要。
59
+
60
+ **安全边界**:记忆写入受控(用户明说或事实有原话支撑才收);注入内容一律标记为**不可信背景**,模型不得执行其中的指令;记忆文件只在本机 `$DSH_HOME` 下,不联网。
61
+
62
+ ## 快速开始
63
+
64
+ **方式一:插件包(推荐)**
65
+
66
+ ```bash
67
+ dsh plugin --profile web add github:ScreamingMaggot/rcs-context
68
+ ```
69
+
70
+ 然后在 `<DSH_HOME>/profiles/web/package.json` 的 `dsh.profile.bundles` 里加入 `"rcs-context"`,重启 `dsh web`。
71
+
72
+ **方式二:脚本直装**(离线 / 无 pnpm 时)
73
+
74
+ ```bash
75
+ git clone https://github.com/ScreamingMaggot/rcs-context.git
76
+ cd rcs-context
77
+ powershell -ExecutionPolicy Bypass -File ./install.ps1
78
+ ```
79
+
80
+ 脚本会备份现网、落位插件、追加补丁层并自校验;重复运行安全。
81
+
82
+ 装完 **重启 `dsh web`**(客户端插件按名缓存,刷新页面不够),会话侧边栏会出现「注入」和「记忆」两个 tab。
83
+
84
+ ## 折叠档位与成本
85
+
86
+ 在会话输入框上方的 dock 或「注入」tab 里按会话选档:
87
+
88
+ | 档位 | 做什么 | 模型调用 | 适用 |
89
+ |---|---|---|---|
90
+ | **工具折叠**(无损) | 纯规则:`[T]` 工具结果压成一行,`[V]` 抽取值 | 0 次 | 默认,零成本零风险 |
91
+ | **单压缩** | 每轮把本轮的回复与工具输出交给轻模型压一遍 | 1 次/轮 | 绝大多数长会话(**推荐**) |
92
+ | **双压缩** | 两遍压缩,更短、更慢、更贵 | 2 次/轮 | 极长会话、窗口特别紧 |
93
+
94
+ 压缩模型建议指向**廉价或本地**模型,别用主模型:
95
+
96
+ ```json
97
+ // $DSH_HOME/state-compiler/control.json
98
+ { "condenseProvider": "ollama", "condenseModel": "qwen3:4b-instruct-2507-q4_K_M" }
99
+ ```
100
+
101
+ 本机实测用的就是 `opencode-go / deepseek-v4.1-flash`(快而便宜)。弱模型压缩率略低、单次 7–15 s,**失败一律无损回退**,不影响档案完整性。
102
+
103
+ > 折叠在 `agent/pre-step` 阻塞 join(3 s 超时闸),保证每轮都吃到压缩收益;超时则放行、后台继续。
104
+
105
+ ## 转录行与回取(`[T]` / `transcript_expand`)
106
+
107
+ 折叠之后,模型看到的工具痕迹是**一行**一条:
108
+
109
+ ```
110
+ [T] read | D:/proj/a.mjs | OK | raw=167
111
+ [T] memory_correct | - | FAIL(need-explicit-user-intent) | {"ok":false,"error":"need-explicit-user-intent"}
112
+ [T] transcript_expand | - | PENDING
113
+ ```
114
+
115
+ - **判定列是业务语义,不是传输语义**:回执 `{"ok":false,"error":"x"}` 记 `FAIL(x)`(工具跑通了、业务拒绝了),传输层报错记 `ERR`,其余 `OK`。只看传输层会让"同一件事连错三次"在转录里全印成 OK——真机上就是这样连错三回合的。
116
+ - **指针有体积门槛(缺省 1024 B)**:正文够大才挂 `raw=<seq>`;小结果把有界预览(≤160 字)内联在同一行。回取是一次工具往返,为几百字节换一次调用不划算。`control.rawPtrMinBytes` 可热调,`0` 退回"永远挂指针"的旧行为。
117
+ - **未落盘 ≠ 失败**:raw 落盘是异步的,行渲染时该 seq 可能还没 flush,回执为 `seq-not-in-raw` 且带 `pending:true`;这类行记中性状态 `PENDING`,稍后重取即可,不该与业务失败同权重。
118
+ - **回取**:`transcript_expand(seq)` 从 `raw/<sid>.jsonl` 取回该 seq 的原文(只读、按 session 隔离)。工具注册走 generator 持有器——`ctx.effect(() => dispose())` 会**立即执行**并当成 disposer,等于"注册完立刻注销",这是"register 不抛错、工具却始终不在模型可见表里"的真正原因。
119
+
120
+ `[T]` 行契约由 `tests/transcript-row-contract.test.mjs`、`tests/transcript-row-replay.test.mjs`(含"旧版 vs 新版"的同一份存证回放)、`tests/transcript-pending-row.test.mjs`、`tests/tool-registration-visible.test.mjs` 钉住。
121
+
122
+ ## 外部记忆
123
+
124
+ 开启:`control.json` 写 `"memoryEnabled": true`(可再开 `"memoryLlmEnabled": true` 让轻模型把事件编译成事实),重启后看「记忆」tab。
125
+
126
+ 给模型的工具:`memory_catalog` / `memory_search` / `memory_get` / `memory_ls`(只读)、`memory_remember` / `memory_forget` / `memory_correct`(受控写入)。
127
+
128
+ 亮点一句话版:
129
+
130
+ - **`rcs://` 寻址**:记忆像文件系统一样浏览,先看目录再取正文;
131
+ - **L0/L1/L2 分层**:目录只带 ≤50 字摘要,需要细节才升级读取,700 字预算能塞 8 条事实;
132
+ - **中文检索**:trigram 精确子串 + 2-gram bm25,自然问句也能命中(真实语料 recall@5 17/24 → 23/24);
133
+ - **事实有生命周期**:`state / constraint / task / history`,冲突不露面,删除后不再复述被删内容;
134
+ - **证据链**:每条事实可回查来源会话、seq、原文。
135
+
136
+ 完整机制(检索、分层、生命周期、整理、巡检、向量、轨迹、面板)见 [docs/MEMORY.md](docs/MEMORY.md)。
137
+
138
+ ## 兼容性
139
+
140
+ | DSH 版本 | 状态 |
141
+ |---|---|
142
+ | 0.2.2-rc.2 | 🟡 静态差异比对通过(runtime 待验) |
143
+ | 0.2.2-rc.1 | ✅ 已验证(live 实例四插件装载、`turn/end` 折叠提交、转录写入、状态接口 200;`install.ps1` 已适配 npm 扁平布局) |
144
+ | 0.2.1-rc.5 | 🟡 静态差异比对通过(市场索引构建的钉定版本) |
145
+ | 0.2.1-rc.3 | ✅ 已验证(五插件 + ledger-online,隔离实例与 live 实测) |
146
+ | 0.2.1-rc.2 | ✅ 已验证 |
147
+ | 0.2.0-rc.2 | ✅ 已验证(14 个关键宿主包中 10 个逐字节相同,16 条接缝 0 消失 0 改名) |
148
+ | 0.1.7-rc.2 | ✅ 已验证(session-format v4 全链路) |
149
+ | 0.1.5-rc.2 | ✅ 兼容 |
150
+
151
+ `package.json` 的 `engines.dsh` / `peerDependencies` 用显式预发布分支覆盖上述全部版本:
152
+
153
+ ```
154
+ >=0.1.5-rc.2 <0.1.6-0 || >=0.1.7-rc.2 <0.2.0-0 || >=0.2.0-rc.2 <0.2.1-0 || >=0.2.1-rc.2 <0.2.2-0 || >=0.2.2-rc.1 <0.3.0-0
155
+ ```
156
+
157
+ ⚠️ node-semver 只在范围里**某个比较符与该版本 `major.minor.patch` 元组完全一致、且自身带预发布标签**时才放行预发布版本。因此早先的 `>=0.1.5-rc.2` 看似宽松,实际只匹配 `0.1.5-rc.2` 与 `0.2.0`——`0.2.1-rc.5`、`0.2.2-rc.1` 等预发布宿主全被静默排除(插件市场会据此直接丢弃整个条目)。上面这种逐元组列出的写法才是对的。
158
+
159
+ 同一份代码内含多代兼容层(0.2.2 / 0.2.1 / 0.2.0 / 0.1.7 / 0.1.5),无需按宿主版本换分支。
160
+
161
+ ## 目录结构
162
+
163
+ ```
164
+ web/ 插件本体(折叠 / 转录 / 外置推理 / 记忆)
165
+ condense/ 共享压缩器与分型
166
+ toolfold/ 结构化工具折叠(A 档)
167
+ contextinjector-webui/ WebUI 面板(注入 / 记忆两 tab)
168
+ tests/ 回归测试(76 套 / 2257 断言)
169
+ tools/ 辅助脚本(会话经济性复算 session-econ.mjs、成本标定、提示面测量)
170
+ docs/ 机制说明、性能基线、面板差距
171
+ install.ps1 uninstall.ps1 install.cmd 安装与卸载
172
+ cordis.patch.yml bundle 补丁层
173
+ version.json 版本与构建信息
174
+ ```
175
+
176
+ ## 数据与卸载
177
+
178
+ - 运行时:`$DSH_HOME/state-compiler/`(`control.json`、`transcripts/`、`.ctxinjector/`)
179
+ - 记忆库:`$DSH_HOME/state-compiler/memory/memory.sqlite`(破坏性操作前自动备份,默认留 5 份)
180
+ - 安装备份:`$DSH_HOME/.statecompiler-backup-<时间戳>/`
181
+
182
+ 卸载:`powershell -ExecutionPolicy Bypass -File ./uninstall.ps1`,回滚备份并移除补丁项,重启生效。
183
+
184
+ ## 深入
185
+
186
+ | 文档 | 内容 |
187
+ |---|---|
188
+ | [docs/MEMORY.md](docs/MEMORY.md) | 记忆层全部机制:检索、URI、分层、生命周期、整理、巡检、向量、轨迹、面板 |
189
+ | [docs/PERFORMANCE-BASELINE.md](docs/PERFORMANCE-BASELINE.md) | 内存/折叠/检索性能基线 |
190
+ | [docs/MEMORY-UI-GAPS.md](docs/MEMORY-UI-GAPS.md) | 记忆面板与前沿产品的差距与验收口径 |
191
+ | [contextinjector-webui/README.md](contextinjector-webui/README.md) | 面板与插件侧的档位/同步约定 |
192
+
193
+ ## 许可证
194
+
195
+ MIT
@@ -0,0 +1,258 @@
1
+ // Copyright (c) 2026 ScreamingMaggot. This source code is licensed under the MIT License.
2
+ // chunk.mjs — Phase A 纯函数模块(无 DSH 运行时依赖,可离线单测)
3
+ // 1. chunkForCompressor 按语义断点切分超窗口文本(§5.2 / §5.3)
4
+ // 2. detectMilestone 不依赖 LLM 的里程碑语块检测(§5.4,仅检测信号)
5
+ // 3. classifyCondenseError 压缩失败分型(§6.1)
6
+ // 4. CONDENSE_BUDGET 压缩预算表(§5.6,v1.1 已决)
7
+ // 设计依据:docs/v2-优化设计.md
8
+
9
+ // ============ 1. 分块压缩(§5.2 / §5.3) ============
10
+ export const CHUNK_DEFAULT_WINDOW_LINES = 24 // 回找上限(行)
11
+ // 切点优先级(§5.3):段落空行 > 换行 > 句末标点(不含 '.',避免劈开 obj.method()/URL/小数)
12
+ const SENT_END_RE = /[。!?!?]/
13
+
14
+ // 在 [start, hardEnd) 区间内,从截断处往回找最佳语义断点;找不到且 allowHardCut ⇒ 硬切
15
+ function findCut(src, start, maxBytes, windowBytes, allowHardCut) {
16
+ const hardEnd = Math.min(start + maxBytes, src.length)
17
+ if (hardEnd >= src.length) return src.length // 末尾,整段为一块
18
+ const lookStart = Math.max(start, hardEnd - windowBytes)
19
+ const seg = src.slice(lookStart, hardEnd)
20
+ // 优先:段落空行(\n[ \t]*\n)
21
+ let idx = seg.lastIndexOf('\n\n')
22
+ if (idx < 0) idx = seg.lastIndexOf('\n \n')
23
+ if (idx >= 0) return lookStart + idx + 2 // 含空行,下块从空行后起
24
+ // 次:换行(按行切对代码类工具结果最安全)
25
+ const nl = seg.lastIndexOf('\n')
26
+ if (nl >= 0) return lookStart + nl + 1
27
+ // 再次:句末标点(中/英,不含孤立 '.')
28
+ for (let i = seg.length - 1; i >= 0; i--) {
29
+ if (SENT_END_RE.test(seg[i])) return lookStart + i + 1
30
+ }
31
+ // 兜底:硬切(allowHardCut)或整段作单块(交由上层)
32
+ return allowHardCut ? hardEnd : src.length
33
+ }
34
+
35
+ export function chunkForCompressor(text, maxBytes, opts = {}) {
36
+ const windowLines = opts.windowLines ?? CHUNK_DEFAULT_WINDOW_LINES
37
+ const allowHardCut = opts.allowHardCut !== false
38
+ const windowBytes = windowLines * 120 // 行→字节量级近似
39
+ const src = String(text)
40
+ const out = []
41
+ let start = 0
42
+ while (start < src.length) {
43
+ const cut = findCut(src, start, maxBytes, windowBytes, allowHardCut)
44
+ if (cut <= start) { out.push(src.slice(start)); break } // 兜底防死循环
45
+ out.push(src.slice(start, cut))
46
+ start = cut
47
+ }
48
+ return out
49
+ }
50
+
51
+ // ============ 2. 里程碑检测(§5.4,仅检测信号) ============
52
+ export const MILESTONE_THRESHOLD_K_DEFAULT = 3 // 默认 3× 起步(评审建议值 2026-09-08)
53
+ export const MILESTONE_MIN_BYTES = 512 // 绝对下限 512B(评审建议值)
54
+ // 描述性/总结性启发式词(命中才视作"阶段总结"而非普通工具输出)
55
+ const MILESTONE_DESCRIPTIVE_RE = /(已完成|总结|发现|关键|阶段性|本轮|结论|成果|重大|进展|已解决|已确认|成功|失败|注意)/
56
+
57
+ export function detectMilestone(text, stat = {}) {
58
+ const minBytes = stat.minBytes ?? MILESTONE_MIN_BYTES
59
+ const k = stat.thresholdK ?? MILESTONE_THRESHOLD_K_DEFAULT
60
+ const mean = stat.meanBlockBytes && stat.meanBlockBytes > 0 ? stat.meanBlockBytes : minBytes
61
+ const len = String(text).length
62
+ if (len < minBytes) return false
63
+ if (!MILESTONE_DESCRIPTIVE_RE.test(text)) return false // 非描述性/总结性 ⇒ 不算里程碑
64
+ return len > k * mean
65
+ }
66
+
67
+ // ============ 3. 压缩失败分型(§6.1) ============
68
+ // 把自由文本失败原因归一到 enum 值;disposal 由调用方按处置路径填(见 §6.3)
69
+ export function classifyCondenseError(reason) {
70
+ const r = String(reason ?? '')
71
+ if (/llm service unavailable|llm 服务缺失|未提供 llm/.test(r)) return 'llm-unavailable'
72
+ if (/condense route missing|no condense route|无浓缩路由/.test(r)) return 'route-missing'
73
+ if (/stream error|流错误|流式异常/i.test(r)) return 'stream-error'
74
+ if (/no finish chunk|无 finish/.test(r)) return 'no-finish'
75
+ if (/finish=/.test(r)) {
76
+ const m = r.match(/finish=(\w+)/)
77
+ return m ? 'finish-' + m[1] : 'finish-other'
78
+ }
79
+ if (/parse|解析失败|行协议|不合规/.test(r)) return 'parse-error'
80
+ if (/payload|值校验|保真|载荷未过/.test(r)) return 'fidelity-blocked'
81
+ if (/timeout|超时|abort/i.test(r)) return 'timeout'
82
+ if (/ollama/i.test(r)) return 'ollama-error'
83
+ if (/empty|空输出|返回空/.test(r)) return 'empty-output'
84
+ if (/未短|not shortened|复读|未缩短/.test(r)) return 'not-shortened'
85
+ return 'other'
86
+ }
87
+
88
+ // ============ 4. 压缩预算表(§5.6,v1.1 已决) ============
89
+ // 预算 = 该信息类型的保真权重(浓缩强度上限,1=全保真、0=可全丢);与档位(single/double)正交
90
+ export const CONDENSE_BUDGET = {
91
+ user_instruction: 0.9, // 用户指令:高保真
92
+ tool_error: 0.6, // 工具错误:保留诊断
93
+ ai_summary: 0.15, // AI 阶段性总结:最可压缩
94
+ exploration: 0.05, // 探索/试错往返:最低保真
95
+ }
96
+ // 按 [A] 块上下文推断信息类型(启发式;与相邻 [T] ERR / 里程碑 / 普通轮流):
97
+ // - 附近 [T] 带 ERR ⇒ tool_error
98
+ // - 命中里程碑(detectMilestone) ⇒ ai_summary
99
+ // - 否则普通 AI 轮 ⇒ exploration
100
+ export function inferBlockType(text, opts = {}) {
101
+ if (opts.nearToolError) return 'tool_error'
102
+ if (opts.milestone) return 'ai_summary'
103
+ return 'exploration'
104
+ }
105
+
106
+ // ============ 5. 消息来源分型(§13,2026-09-08 实测) ============
107
+ // 背景:DSH 把系统注入(system-reminder / background job 通知 / 审批策略变更 / subagent
108
+ // 回传)以 role:'user' 写进会话历史,旧判定只看 role ⇒ ROUND 被灌水 4.8×(实测 29 轮仅 6 轮真实)。
109
+ //
110
+ // 事件/消息结构(关键):
111
+ // user/message 事件:data = { content, source, role, id }
112
+ // —— source 在 **data.source**;不存在 data.message 这一层。
113
+ // (旧 logcompiler 误取 data.message?.source,导致 source 检查恒为 undefined、从未生效)
114
+ // deriveMessages() 的消息:m.source(同一 MessageSource 形状)
115
+ //
116
+ // source.kind 是 merge-extensible 联合类型,除文档四选一 user/plugin/model/tool 外,
117
+ // 实测另有 skill-catalog / subagent-settled / subagent-report ⇒
118
+ // **只能用 kind === 'user' 判真实用户轮**,不可做"非 user 即插件"的二分假设。
119
+ export const SELF_PLUGIN_NAME = 'CONTEXTinjector'
120
+ // 已知 notice 类插件名 → 子类(用于 [N] 行细分)
121
+ const NOTICE_SUB_BY_PLUGIN = { 'tool-jobs': 'job', 'user-approval': 'approval' }
122
+
123
+ // 【#2 修复(取自 RCS-0.1.0 返回包)】注入类 user 消息的文本前缀集 —— **单一来源**。
124
+ // 背景:注入器与 logcompiler 在 source 缺失(DSH 规定必填,缺失属异常)时的回退分支
125
+ // 此前各写一套前缀,同一事件可能一侧跳过、一侧计入 ROUND → 审计轮号与注入侧分叉。
126
+ // 此处提为唯一来源,两处均从此导入,杜绝再次漂移。
127
+ export const INJECTED_USER_PREFIXES = [
128
+ 'Current runtime context',
129
+ // DSH 0.2.2-rc.2 wraps runtime-context in <system-reminder> and opens with this
130
+ // sentence instead of "Current runtime context:"; callers strip the wrapper first.
131
+ 'The following information describes your current session',
132
+ 'Current DSH file policy',
133
+ 'Current DSH permission mode',
134
+ 'sandbox:',
135
+ '[ROUND]',
136
+ ]
137
+
138
+ // 【v1.10.27 · DSH 0.1.6+ session-format v4 兼容】
139
+ // v4 退役了 {kind:'plugin', plugin:'X'} 包装(持久化层对 kind==='plugin' 直接抛
140
+ // "format v4 message requires a producer-owned source kind",且在编码路径上**打死宿主进程**),
141
+ // 官方 v3→v4 迁移器把老数据重写为 {kind:'plugin:X'}。故写侧一律发新形态(v3 持久化对 kind
142
+ // 无枚举校验,同一形态两类宿主通吃);读侧必须同时认两种形态——老宿主给旧形态,
143
+ // 迁移后的会话数据给新形态。
144
+ export function pluginIdOf(source) {
145
+ const k = source?.kind
146
+ if (typeof k !== 'string') return null
147
+ if (k === 'plugin') return typeof source.plugin === 'string' && source.plugin.length > 0 ? source.plugin : null
148
+ if (k.startsWith('plugin:')) return k.slice(7)
149
+ // 压缩检查点:宿主只认 kind='compact-checkpoint'。插件自己的检查点额外带 plugin 字段,
150
+ // 读侧据此仍能识别为自身转录(宿主原生 compaction 不带该字段)。
151
+ if (k === 'compact-checkpoint') return typeof source.plugin === 'string' && source.plugin.length > 0 ? source.plugin : null
152
+ return null
153
+ }
154
+
155
+ export function classifyMessageSource(source, opts = {}) {
156
+ const self = opts.selfPlugin ?? SELF_PLUGIN_NAME
157
+ const src = source ?? null
158
+ if (!src || typeof src !== 'object') return { cls: 'opaque', kind: undefined }
159
+ const kind = src.kind
160
+ const form = src.form
161
+ const plugin = pluginIdOf(src) ?? (typeof src.plugin === 'string' ? src.plugin : undefined)
162
+ if (kind === 'user') return { cls: 'user' }
163
+ if (plugin === self) return { cls: 'self' }
164
+ // 【extreason 接入(取自 RCS-0.1.0)】外置推理简报:文本本身已是 `[R] …` 行 ⇒ 单独一类,
165
+ // 两个转录器都按行保留、不计 ROUND。它是 append-only 日志的一部分(会被折进转录并永久保留),
166
+ // 不是易失注入。位置必须在 opaque 兜底之前,否则会落 opaque ⇒ 两转录器都不转录(静默丢内容)。
167
+ if (plugin === 'EXTREASON') return { cls: 'extreason' }
168
+ // subagent 报告:内容需主模型看到,但语义接近工具结果 ⇒ 走 [T]/[V],不计 ROUND
169
+ if (kind === 'subagent-report' || form === 'relay') return { cls: 'relay', agentId: opts.agentId ?? null }
170
+ if (kind === 'subagent-settled') return { cls: 'notice', sub: 'subagent' }
171
+ // 无 form 的 notice 类插件(实测 user-approval 不带 form)
172
+ if (plugin !== null && plugin !== undefined && NOTICE_SUB_BY_PLUGIN[plugin]) return { cls: 'notice', sub: NOTICE_SUB_BY_PLUGIN[plugin] }
173
+ if (form === 'snapshot') return { cls: 'snapshot', sections: Array.isArray(src.sections) ? src.sections : [] }
174
+ if (form === 'catalog' || kind === 'skill-catalog') return { cls: 'catalog' }
175
+ if (form === 'notice') return { cls: 'notice', sub: NOTICE_SUB_BY_PLUGIN[plugin] ?? 'notice' }
176
+ // 未知 kind(含未来新增)⇒ 保守跳过并回传 kind,便于调用方计数/上报
177
+ return { cls: 'opaque', kind }
178
+ }
179
+
180
+ // 事件适配器:读 data.source(**不是** data.message.source)
181
+ export function classifyUserMessage(ev, opts = {}) {
182
+ return classifyMessageSource(ev?.data?.source, opts)
183
+ }
184
+
185
+ const SUBAGENT_ID_RE = /subagent\s+([0-9a-fA-F][0-9a-fA-F-]{7,})/
186
+ export function subagentIdOf(text) {
187
+ const m = SUBAGENT_ID_RE.exec(String(text ?? ''))
188
+ return m ? m[1] : null
189
+ }
190
+
191
+ // 快照摘要:只留 sections 名。runtime 快照每 step 重注入(实测 14 次),
192
+ // 全量转录会显著膨胀,故 [S] 行只存摘要。
193
+ export function snapshotSummary(sections) {
194
+ if (!Array.isArray(sections) || !sections.length) return 'runtime snapshot'
195
+ const names = sections.map((s) => s && s.name).filter(Boolean)
196
+ return names.length ? 'runtime snapshot: ' + names.join(', ') : 'runtime snapshot'
197
+ }
198
+
199
+ // ============ 6. 单块压缩 token 预算(§4.2 纯算术部分) ============
200
+ // 说明:§4 的完整 resolveCondenseMaxTokens 需异步取 ctx.llm.resolveModelInfo(真机侧),
201
+ // 此处仅落**纯算术**分支(§4.2):给定已解析的 ctxWindow / defaultMaxTokens / 硬覆盖,
202
+ // 折算单块压缩 token 预算。真机接线在拿到窗口值后调用本函数即可,默认回退内置 700,
203
+ // 无回退风险、不触碰折叠主链路。建议值(评审 2026-09-08):min(defaultMaxTokens,
204
+ // ctxWindow − inputBudget − 20%余量),不必 ×0.5;预算下限 256。
205
+ // MAX_TOKEN_FLOOR = 256(语义:预算过小会退回无损,故设下限防无意义压缩)
206
+ export const MAX_TOKEN_FLOOR = 256
207
+ export function resolveMaxTokenBudget({ hardOverride, ctxWindow, defaultMaxTokens, inputBudget = 0, fallback = 700, floor = MAX_TOKEN_FLOOR } = {}) {
208
+ const b = Math.max(0, Number(inputBudget) || 0)
209
+ if (hardOverride != null && Number.isFinite(hardOverride)) return Math.max(floor, Math.floor(hardOverride))
210
+ if (ctxWindow != null && defaultMaxTokens != null && ctxWindow > 0 && defaultMaxTokens > 0) {
211
+ const ctxW = Math.floor(ctxWindow)
212
+ const headroom = Math.max(0, ctxW - b - Math.floor(ctxW * 0.2))
213
+ return Math.max(floor, Math.min(defaultMaxTokens, headroom))
214
+ }
215
+ if (defaultMaxTokens != null && defaultMaxTokens > 0) return Math.max(floor, Math.floor(defaultMaxTokens))
216
+ if (ctxWindow != null && ctxWindow > 0) {
217
+ const ctxW = Math.floor(ctxWindow)
218
+ return Math.max(floor, Math.floor(ctxW * 0.8 - b))
219
+ }
220
+ return Math.max(floor, Math.floor(fallback))
221
+ }
222
+
223
+ // ============ 路由可用性(折叠与记忆共用) ============
224
+ // 上游会整条下线模型(真机:minimax-m3.1-flash-free 从免费名单消失),宿主也可能根本没配
225
+ // 某个 id(UNKNOWN_MODEL)。这两种都不是"重试能好"的错,只有换路由才有产出。
226
+ const AUX_ROUTE_DEAD_RE = /Model is unavailable|no configured model|UNKNOWN_MODEL|model.{0,16}(not found|unavailable|does not exist)|unsupported model|no condense route|route missing/i
227
+
228
+ export function parseRouteSpec(spec) {
229
+ const t = String(spec ?? '').trim()
230
+ const i = t.indexOf('/')
231
+ if (i <= 0 || i === t.length - 1) return null
232
+ return { provider: t.slice(0, i), model: t.slice(i + 1) }
233
+ }
234
+
235
+ export function auxFallbackRoutes(control = {}, env = process.env) {
236
+ const raw = []
237
+ // 首选回退:压缩路由(同一 provider 的另一个模型通常最先可用,也无需额外配置)
238
+ if (control?.condenseProvider && control?.condenseModel) raw.push(control.condenseProvider + '/' + control.condenseModel)
239
+ if (Array.isArray(control?.auxFallbackRoutes)) raw.push(...control.auxFallbackRoutes)
240
+ if (env.DSH_AUX_FALLBACK_MODELS) raw.push(...String(env.DSH_AUX_FALLBACK_MODELS).split(','))
241
+ // 本机模型恒列最后兜底:它由宿主配置直接可用,且不花外部额度。
242
+ raw.push('ollama-local/qwen3.5:4b', 'ollama-local/qwen3-8b-q4:latest')
243
+ const out = []
244
+ const seen = new Set()
245
+ for (const s of raw) {
246
+ const r = parseRouteSpec(s)
247
+ if (!r) continue
248
+ const k = r.provider + '/' + r.model
249
+ if (seen.has(k)) continue
250
+ seen.add(k)
251
+ out.push(r)
252
+ }
253
+ return out
254
+ }
255
+
256
+ export function isRouteDead(error) {
257
+ return AUX_ROUTE_DEAD_RE.test(String(error ?? ''))
258
+ }
@@ -0,0 +1,104 @@
1
+ // Copyright (c) 2026 ScreamingMaggot. This source code is licensed under the MIT License.
2
+ // ivr.mjs — 「压缩器IO比」口径计量 + 错误时间线(§2 / §6.4)
3
+ // 纯簿记模块,依赖 node:fs,不依赖 DSH 运行时;与 contextinjector.mjs 同目录。
4
+ // 读法:ratio = inBytes / outBytes(输入 ÷ 输出)
5
+ // ratio ≈ 1 属正常 = 主流程已无冗余可压;> 1 = 压掉的量更大;< 1 = 输出比输入还大。
6
+ // 只作**回归哨兵**:比值明显变大=冗余回流,< 1=有内容被重复注入。
7
+ // 设计依据:docs/v2-优化设计.md
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ // 文件名用"裸 sid"(去 session- 前缀),与 contextinjector per-sid 落盘 & host /status 读取一致
12
+ const normSid = (s) => String(s ?? 'unknown').replace(/^session-?/i, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
13
+
14
+ // ============ 压缩器IO比(§2) ============
15
+ // 【2026-09-09(用户定名:"压缩器IO比")】
16
+ // 每轮记两个数,再给一个比值,不掺 S(系统提示字节)、不叫折扣:
17
+ // inBytes = 本轮喂进压缩器的原始内容 = shadowedBytes − 上一轮转录字节
18
+ // outBytes = 本轮压缩器产出的转录增量 = transcriptBytes − 上一轮转录字节
19
+ // ratio = inBytes / outBytes (输入/输出:值越大=压掉的量越大;≈1=没冗余可压;<1=输出比输入还大)
20
+ // 累计:cumIn/cumOut/cumRatio = cumIn / cumOut
21
+ // 读法:RCS 架构下主流程已无冗余可压(reasoning 外置、工具结果折成 [T] 行、DSH 管线噪音被清),
22
+ // ratio≈1 属正常,只作**回归哨兵**(比值明显变大=冗余回流;<1=有内容被重复注入)。
23
+
24
+ export function ivrLoad(stateDir, sid) {
25
+ try {
26
+ const fp = join(stateDir, 'ivr', normSid(sid) + '.json')
27
+ if (!existsSync(fp)) return null
28
+ return JSON.parse(readFileSync(fp, 'utf8'))
29
+ } catch { return null }
30
+ }
31
+
32
+ export function ivrEnsure(stateDir, sid, S) {
33
+ mkdirSync(join(stateDir, 'ivr'), { recursive: true })
34
+ const fp = join(stateDir, 'ivr', normSid(sid) + '.json')
35
+ let st = null
36
+ try { st = existsSync(fp) ? JSON.parse(readFileSync(fp, 'utf8')) : null } catch { st = null }
37
+ // 口径标记:旧文件(v1-role / v2-source)不与新口径混算 —— 旧轮次数据**整体重建丢弃**。
38
+ // ⚠ 破坏性行为(用户已知并接受):旧 rounds 会被清空,老会话的历史 IO 数据不再保留、也不可比。
39
+ // 口径 v3(io2):旧行把**单折** shadowedBytes 当累计量去减上一轮转录,单折量小于累计时被钳成 0,
40
+ // 面板于是出现大量「输入 0B、比值 0」,累计比值也被带偏。旧行不再混算:保留一份备份后重建。
41
+ if (!st || st.roundSemantics !== 'io2') {
42
+ if (st && Array.isArray(st.rounds) && st.rounds.length) {
43
+ try { writeFileSync(fp + '.bak-' + (st.roundSemantics || 'io') + '-' + Date.now(), JSON.stringify(st, null, 2)) } catch { /* ignore */ }
44
+ }
45
+ st = { sessionId: sid, lastTranscriptBytes: 0, rounds: [], roundSemantics: 'io2' }
46
+ }
47
+ if (!Array.isArray(st.rounds)) st.rounds = []
48
+ void S // S(系统提示字节)在新口径中不再参与计算,仅为兼容旧调用签名而保留
49
+ writeFileSync(fp, JSON.stringify(st, null, 2), 'utf8')
50
+ return st
51
+ }
52
+
53
+ export function ivrRecord(stateDir, sid, fold) {
54
+ const st = ivrEnsure(stateDir, sid)
55
+ const prev = st.lastTranscriptBytes || 0
56
+ // inBytes = 本折真正喂进压缩器的原始内容(单折量,不减去累计转录)。
57
+ // fold.inBytes 由调用方给出(= incrementalRawBytes);缺省回落到单折 shadowedBytes 以兼容旧调用。
58
+ const rawIn = Number.isFinite(fold.inBytes) ? fold.inBytes : (Number.isFinite(fold.incrementalRawBytes) ? fold.incrementalRawBytes : fold.shadowedBytes)
59
+ const inBytes = Math.max(0, Math.floor(Number(rawIn) || 0))
60
+ const outBytes = Math.max(0, fold.transcriptBytes - prev)
61
+ const last = st.rounds[st.rounds.length - 1] || { cumIn: 0, cumOut: 0 }
62
+ const cumIn = last.cumIn + inBytes
63
+ const cumOut = last.cumOut + outBytes
64
+ const ratio = outBytes > 0 ? inBytes / outBytes : 1
65
+ const cumRatio = cumOut > 0 ? cumIn / cumOut : 1
66
+ const round = {
67
+ n: fold.n, step: fold.step, ts: fold.ts ?? Date.now(),
68
+ inBytes, outBytes,
69
+ ratio: Number(ratio.toFixed(4)),
70
+ cumIn, cumOut,
71
+ cumRatio: Number(cumRatio.toFixed(4)),
72
+ shadowedBytes: fold.shadowedBytes, transcriptBytes: fold.transcriptBytes,
73
+ stageAnchor: fold.stageAnchor === true, // §8 里程碑锚点
74
+ }
75
+ st.rounds.push(round)
76
+ st.lastTranscriptBytes = fold.transcriptBytes
77
+ st.cumRatio = round.cumRatio
78
+ writeFileSync(join(stateDir, 'ivr', normSid(sid) + '.json'), JSON.stringify(st, null, 2), 'utf8')
79
+ return round
80
+ }
81
+
82
+ export function ivrSeries(stateDir, sid) {
83
+ const st = ivrLoad(stateDir, sid)
84
+ return st ? st.rounds : []
85
+ }
86
+
87
+ export function ivrExport(stateDir, sid, format = 'csv') {
88
+ const st = ivrLoad(stateDir, sid)
89
+ if (!st) return ''
90
+ if (format === 'json') return JSON.stringify(st, null, 2)
91
+ const head = 'n,step,inBytes,outBytes,ratio,cumIn,cumOut,cumRatio,ts'
92
+ const rows = st.rounds.map((r) => [r.n, r.step, r.inBytes, r.outBytes, r.ratio, r.cumIn, r.cumOut, r.cumRatio, r.ts].join(','))
93
+ return [head, ...rows].join('\n')
94
+ }
95
+
96
+ // ============ 错误时间线(§6.4,append-only,对称 IVR) ============
97
+ // $STATE_DIR/errors/<sid>.jsonl —— 与 IVR 共用同一 session 时间线(同 sid、同轮号)
98
+ export function errAppend(stateDir, sid, entry) {
99
+ try {
100
+ mkdirSync(join(stateDir, 'errors'), { recursive: true })
101
+ const fp = join(stateDir, 'errors', normSid(sid) + '.jsonl')
102
+ appendFileSync(fp, JSON.stringify({ ts: Date.now(), ...entry }) + '\n', 'utf8')
103
+ } catch { /* best-effort */ }
104
+ }
@@ -0,0 +1,43 @@
1
+ # contextinjector-webui — 「注入」面板(仓库权威副本)
2
+
3
+ > 与 `../contextinjector.mjs`(折叠插件,Host 单文件)配对。本目录是 **npm 包形态** 的 WebUI 面板
4
+ > (conversation.view「注入」tab + 自注册 `/api/context-inject/{status,config}`)。
5
+
6
+ ## 结构
7
+ ```
8
+ webui/
9
+ ├── package.json # main=host(lib/index.mjs);exports["./client"] + dsh.client{platform:'web'}
10
+ ├── lib/index.mjs # Host 半:ctx.webServer.register exact /api/context-inject/{status,config,session}
11
+ │ # 读 state-compiler/{control,runtime,last-fold}.json;本机 origin 校验写
12
+ └── lib/client.js # Client 半:lazy-CJS(__ModuleLoader__),两处注入
13
+ # ① conversation.view「注入」tab:启用开关 + workspace 分组白名单(标题/归档过滤)
14
+ # + 全局默认压缩档 + 折叠时间线(ErrorBoundary)
15
+ # ② conversation.input.dock(v1.7):composer 上方「本会话压缩模式」四态选择器
16
+ # —— 空白会话(hero)同样渲染,首轮发送前即可定档
17
+ ```
18
+
19
+ ## 装载(正式区)
20
+ 1. 把本目录内容同步到 `$DSH_HOME/profiles/node_modules/contextinjector-webui/`(目录须在 node_modules,
21
+ 包名 include 才被 client 收集——路径型 insert 不收集 client half);
22
+ 2. web profile 的 `cordis.patch.yml` insert:`name: 'contextinjector-webui'`(包名,非路径)。
23
+ 折叠插件本尊:`contextinjector.mjs`(路径型 insert,Host 单文件)。
24
+
25
+ ## 端点与数据
26
+ - `GET /api/context-inject/status` → control/runtime/lastFold/会话索引(title 来自 projcache,同左侧栏)
27
+ - `POST /api/context-inject/config` → `{enabled, sessions[], condense}`(白名单=session id 数组;condense=**全局默认档**)
28
+ - `POST /api/context-inject/session`(v1.7)→ `{sessionId, mode}`,mode=`off|single|double|none`
29
+ → 一次动作 = 启用 + 该会话入白名单 + 写 `sessionModes[<sid>]`(none=移出白名单)。
30
+ 原子读改写(展开 cur 保留未知字段,防 keepInject 被剥);会话 id 前缀归一,键形式沿用既有记录。
31
+ - 状态目录:`$DSH_HOME/state-compiler/`(CONTEXTinjector 插件写入;webui 只读+改 control)
32
+ - 档位优先级(插件侧,v1.9):env `DSH_CONTEXTINJECTOR_LOSSY` > `sessionModes[本会话]`
33
+ > (test/env 直通: off 兜底 | control 门控无档: **不折叠**)。**全局默认档已移除**——
34
+ 顶部「注入」tab 无「AI 输出压缩档」chips;composer dock 是唯一选档入口,tab 勾选会话即写无损 off(取消= none)。
35
+
36
+ ## 同步约定
37
+ - **本目录 = 源**;`$DSH_HOME/profiles/web/` 下的是装载副本;
38
+ - 改 host 端点(index.mjs)或面板(client.js)后:同步副本 + **重启 web**(client bundle sha1 rev 变化;
39
+ 装载器元数据按名缓存,重启前不失效);
40
+ - 回滚:删 cordis.patch 中 insert 与 node_modules 包目录即可。
41
+
42
+ ## 历史残留说明
43
+ 早期误置的 `profiles/web/contextinjector-webui/`(harness 版实验目录)已不被引用,若在可手动删除。