dsh-ctx-trim 0.0.0-stage → 1.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AlexSh1ne
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,82 @@
1
- # Temporary Holding Version
1
+ # dsh-ctx-trim
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
+ 给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的手动上下文压缩:**会话头部一个「上下文」按钮,两档压缩,本地摘要不调模型。**
4
+
5
+ 长会话涨到二十几万 token 以后,自动压缩只会把最后那条消息留下,前面全丢;你想自己决定「切到哪、留多少」,又没有入口。这个插件补的就是那个入口。
6
+
7
+ ![两档](icon.svg)
8
+
9
+ ## 装
10
+
11
+ ```bash
12
+ dsh plugin --profile <你的profile> add dsh-ctx-trim
13
+ ```
14
+
15
+ 装完刷新页面,会话标题右边多一个「上下文」按钮。
16
+
17
+ ## 用
18
+
19
+ 点按钮 → 面板里两行数字 → 点一档 → 等它跑完。
20
+
21
+ | 档位 | 保留 | 下限 | 多余的动作 |
22
+ | --- | --- | --- | --- |
23
+ | 正常压缩 | 最近 30% token 的原文 | 至少 6k | — |
24
+ | 深度压缩 | 最近 10% token 的原文 | 至少 2.5k | 切下的原文打包进 `~/.dsh/im/ctxpacks/` |
25
+
26
+ 切点两端都收在结构边界上(不切在工具调用中间、不切在压缩标记中间),所以压完的会话还是合法的对话。
27
+
28
+ ## 摘要从哪来
29
+
30
+ - 优先用同 profile 里 `dsh-live-compact/plan.js` 的本地摘要器(有这个插件就用,没有就跳过);
31
+ - 都没有时用内置摘录:每条留开头、保留硬性条款行;
32
+ - **全程不调模型**,所以不花钱、不等生成。
33
+
34
+ 摘要如果没比切掉的原文更小,这次直接放弃不落盘——宁可不动,不给你写一条更长的上下文。
35
+
36
+ ## 它怎么做到「空闲才能压」
37
+
38
+ 走的是 DSH 公开的压缩事务:
39
+
40
+ ```
41
+ compaction/start → compaction/summary → user/message(replace) → compaction/end → flush
42
+ ```
43
+
44
+ 外层套 `agent.runMaintenance`,跟正在跑的回合互斥。所以点的时候如果这一轮还没结束,会明确告诉你「这个会话正在跑…等这一轮结束再压」,不会把日志写坏。
45
+
46
+ ## 限制
47
+
48
+ - 只在会话**空闲**时能压;回合进行中会被拒绝(这是故意的)。
49
+ - 没有撤销按钮。被折叠的原文还在 session log 里,但界面上看不见了。
50
+ - 只有 Web 端有按钮;保留比例是写死的两档,暂不支持自定义。
51
+ - 不用 `ctx.compaction.compactRegion`:它写死 `owner:'current-turn'`,空闲会话上必抛 `no open turn`。
52
+
53
+ ## 接口
54
+
55
+ 宿主挂了两个路由(客户端用它,你也可以自己 curl):
56
+
57
+ ```bash
58
+ # 看一眼这个会话现在多少条、两档各会切多少
59
+ curl "http://<host>:<port>/dsh-ctx-trim/status?sessionId=session-xxx"
60
+
61
+ # 试跑(不落盘),看会切多少、摘要多大
62
+ curl -X POST http://<host>:<port>/dsh-ctx-trim/run \
63
+ -H 'content-type: application/json' \
64
+ -d '{"sessionId":"session-xxx","mode":"normal","dry":true}'
65
+
66
+ # 真压
67
+ curl -X POST http://<host>:<port>/dsh-ctx-trim/run \
68
+ -H 'content-type: application/json' \
69
+ -d '{"sessionId":"session-xxx","mode":"deep"}'
70
+ ```
71
+
72
+ 只收 `application/json`,浏览器从别的站发来的跨站请求(`sec-fetch-site: cross-site`)直接 403。
73
+
74
+ ## 兼容
75
+
76
+ - DSH `>=0.2.0-rc.2 <0.3.0-0`
77
+ - Node `>=22.13.0`
78
+ - 已实测:DSH 0.2.1-rc.5 + Web profile,真跑一次把 353 条 / 263k token 压到 194 条 / 127.8k token。
79
+
80
+ ## 许可
81
+
82
+ MIT
package/client.js ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * dsh-ctx-trim — 客户端半边。
3
+ *
4
+ * 会话头部一个「上下文」按钮,点开是两档手动压缩:正常 / 深度。
5
+ * 客户端这边不碰任何宿主服务(服务表里没有 commands / remote),
6
+ * 只做两件事:fetch 宿主路由 + 把返回的文字显示出来。
7
+ */
8
+ window.__ModuleLoader__.load({
9
+ id: "dsh-ctx-trim",
10
+ factory(require) {
11
+ const React = require("react");
12
+ const h = React.createElement;
13
+ const NS = "dsh-ctx-trim";
14
+
15
+ const zh = {
16
+ button: "上下文",
17
+ title: "手动压缩",
18
+ sub: "本地摘要,不调模型",
19
+ loading: "读取中…",
20
+ now: "现在",
21
+ items: "条",
22
+ running: "这个会话正在跑,等这一轮结束再压。",
23
+ normal: "正常压缩",
24
+ normalDesc: "切掉较早一段,保留最近三成近文;切点落在结构边界上。",
25
+ deep: "深度压缩",
26
+ deepDesc: "切更大范围,只留最近一成;切下的原文打包进 im/ctxpacks/。",
27
+ working: "处理中…",
28
+ close: "关闭",
29
+ hint: "压缩不可撤销;会话里会留下一段压缩标记。",
30
+ unavailable: "读不到这个会话的状态。",
31
+ };
32
+ const en = {
33
+ button: "Context",
34
+ title: "Manual compaction",
35
+ sub: "Local summary, no model call",
36
+ loading: "Loading…",
37
+ now: "Now",
38
+ items: "items",
39
+ running: "This session is running — trim after the turn ends.",
40
+ normal: "Normal",
41
+ normalDesc: "Cut the older span, keep the recent third; cuts land on structural boundaries.",
42
+ deep: "Deep",
43
+ deepDesc: "Cut more, keep the last tenth; the cut text is packed into im/ctxpacks/.",
44
+ working: "Working…",
45
+ close: "Close",
46
+ hint: "Compaction cannot be undone; it leaves a checkpoint in the session.",
47
+ unavailable: "Cannot read this session's state.",
48
+ };
49
+
50
+ const T = {
51
+ wrap: { position: "relative", display: "inline-flex" },
52
+ trigger: {
53
+ display: "inline-flex",
54
+ alignItems: "center",
55
+ gap: 6,
56
+ height: 26,
57
+ padding: "0 10px",
58
+ borderRadius: 8,
59
+ border: "1px solid var(--dsw-alias-border-l2)",
60
+ background: "transparent",
61
+ color: "var(--dsw-alias-label-secondary)",
62
+ fontSize: 12,
63
+ lineHeight: 1,
64
+ cursor: "pointer",
65
+ },
66
+ dot: {
67
+ width: 6,
68
+ height: 6,
69
+ borderRadius: 999,
70
+ background: "var(--dsw-alias-state-business-primary, #3964fe)",
71
+ },
72
+ panel: {
73
+ position: "absolute",
74
+ left: 0,
75
+ top: "calc(100% + 8px)",
76
+ zIndex: 60,
77
+ width: 340,
78
+ padding: 16,
79
+ borderRadius: 14,
80
+ border: "1px solid var(--dsw-alias-border-l2)",
81
+ background: "var(--dsw-alias-bg-layer-2, #1c1c1f)",
82
+ boxShadow: "0 18px 48px rgba(0,0,0,.38)",
83
+ display: "flex",
84
+ flexDirection: "column",
85
+ gap: 10,
86
+ textAlign: "left",
87
+ },
88
+ head: { display: "flex", alignItems: "baseline", gap: 8 },
89
+ title: { fontSize: 14, fontWeight: 650, color: "var(--dsw-alias-label-primary)" },
90
+ sub: { fontSize: 11, color: "var(--dsw-alias-label-tertiary)" },
91
+ meter: { fontSize: 11.5, color: "var(--dsw-alias-label-secondary)" },
92
+ card: {
93
+ textAlign: "left",
94
+ border: "1px solid var(--dsw-alias-border-l2)",
95
+ background: "transparent",
96
+ borderRadius: 10,
97
+ padding: "10px 12px",
98
+ display: "flex",
99
+ flexDirection: "column",
100
+ gap: 3,
101
+ cursor: "pointer",
102
+ },
103
+ cardName: { fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary)" },
104
+ cardDesc: { fontSize: 11.5, lineHeight: 1.55, color: "var(--dsw-alias-label-tertiary)" },
105
+ ok: { fontSize: 11.5, lineHeight: 1.5, color: "var(--dsw-alias-state-success-primary, #2ea043)" },
106
+ err: { fontSize: 11.5, lineHeight: 1.5, color: "var(--dsw-alias-state-error-primary, #f85149)" },
107
+ note: { fontSize: 11, lineHeight: 1.5, color: "var(--dsw-alias-label-tertiary)" },
108
+ foot: { display: "flex", justifyContent: "flex-end" },
109
+ close: {
110
+ border: "1px solid var(--dsw-alias-border-l2)",
111
+ background: "transparent",
112
+ color: "var(--dsw-alias-label-secondary)",
113
+ borderRadius: 8,
114
+ padding: "5px 14px",
115
+ fontSize: 12,
116
+ cursor: "pointer",
117
+ },
118
+ };
119
+
120
+ function formatTokens(value) {
121
+ if (typeof value !== "number" || !Number.isFinite(value)) return "?";
122
+ if (value >= 1000) return (value / 1000).toFixed(1) + "k";
123
+ return String(value);
124
+ }
125
+
126
+ function makeButton(t) {
127
+ return function TrimButton(props) {
128
+ const [open, setOpen] = React.useState(false);
129
+ const [info, setInfo] = React.useState(null);
130
+ const [loadError, setLoadError] = React.useState("");
131
+ const [busy, setBusy] = React.useState("");
132
+ const [message, setMessage] = React.useState(null);
133
+ const [tick, setTick] = React.useState(0);
134
+ const wrapRef = React.useRef(null);
135
+ const sessionId = props.sessionId;
136
+
137
+ React.useEffect(() => {
138
+ if (!open) return undefined;
139
+ function onDown(event) {
140
+ if (wrapRef.current && !wrapRef.current.contains(event.target)) setOpen(false);
141
+ }
142
+ document.addEventListener("mousedown", onDown);
143
+ return () => document.removeEventListener("mousedown", onDown);
144
+ }, [open]);
145
+
146
+ React.useEffect(() => {
147
+ if (!open || !sessionId) return undefined;
148
+ let cancelled = false;
149
+ setLoadError("");
150
+ fetch(`/dsh-ctx-trim/status?sessionId=${encodeURIComponent(sessionId)}`)
151
+ .then((response) => response.json())
152
+ .then((data) => {
153
+ if (cancelled) return;
154
+ setInfo(data);
155
+ if (!data || data.ok === false) setLoadError((data && data.message) || t("unavailable"));
156
+ })
157
+ .catch((error) => {
158
+ if (!cancelled) setLoadError(String((error && error.message) || error));
159
+ });
160
+ return () => {
161
+ cancelled = true;
162
+ };
163
+ }, [open, sessionId, tick]);
164
+
165
+ function run(mode) {
166
+ if (busy || !sessionId) return;
167
+ setBusy(mode);
168
+ setMessage(null);
169
+ fetch("/dsh-ctx-trim/run", {
170
+ method: "POST",
171
+ headers: { "content-type": "application/json" },
172
+ body: JSON.stringify({ sessionId, mode }),
173
+ })
174
+ .then((response) => response.json())
175
+ .then((data) => {
176
+ setBusy("");
177
+ if (data && data.ok) {
178
+ setMessage({ kind: "ok", text: data.message || t("done") });
179
+ setInfo(null);
180
+ setLoadError("");
181
+ // 压完再读一次状态,数字要对得上。
182
+ setTick((value) => value + 1);
183
+ return;
184
+ }
185
+ setMessage({ kind: "err", text: (data && data.message) || t("failed") });
186
+ })
187
+ .catch((error) => {
188
+ setBusy("");
189
+ setMessage({ kind: "err", text: String((error && error.message) || error) });
190
+ });
191
+ }
192
+
193
+ function option(key, mode) {
194
+ const plan = info && info.modes && info.modes[mode];
195
+ const line =
196
+ plan && !plan.error
197
+ ? `切掉 ${plan.cutNodes} 条 · 约 ${formatTokens(plan.cutTokens)} token`
198
+ : plan && plan.error
199
+ ? plan.error
200
+ : "";
201
+ return h(
202
+ "button",
203
+ {
204
+ type: "button",
205
+ style: T.card,
206
+ disabled: Boolean(busy),
207
+ onClick: () => run(mode),
208
+ },
209
+ h("span", { style: T.cardName }, busy === mode ? `${t(key)} ${t("working")}` : t(key)),
210
+ h("span", { style: T.cardDesc }, t(key + "Desc")),
211
+ line ? h("span", { style: T.meter }, line) : null,
212
+ );
213
+ }
214
+
215
+ const meter =
216
+ info && info.ok
217
+ ? `${t("now")} ${info.nodes} ${t("items")} · ${formatTokens(info.tokens)} token`
218
+ : loadError || t("loading");
219
+
220
+ return h(
221
+ "span",
222
+ { ref: wrapRef, style: T.wrap },
223
+ h(
224
+ "button",
225
+ {
226
+ type: "button",
227
+ style: T.trigger,
228
+ title: t("title"),
229
+ onClick: () => setOpen(!open),
230
+ },
231
+ h("span", { style: T.dot }),
232
+ t("button"),
233
+ ),
234
+ open
235
+ ? h(
236
+ "div",
237
+ { style: T.panel },
238
+ h("div", { style: T.head }, h("span", { style: T.title }, t("title")), h("span", { style: T.sub }, t("sub"))),
239
+ h("div", { style: T.meter }, meter),
240
+ option("normal", "normal"),
241
+ option("deep", "deep"),
242
+ message ? h("div", { style: message.kind === "ok" ? T.ok : T.err }, message.text) : null,
243
+ h("div", { style: T.note }, t("hint")),
244
+ h("div", { style: T.foot }, h("button", { type: "button", style: T.close, onClick: () => setOpen(false) }, t("close"))),
245
+ )
246
+ : null,
247
+ );
248
+ };
249
+ }
250
+
251
+ return {
252
+ inject: ["slots", "locale"],
253
+ apply(ctx) {
254
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "ctx-trim: 词典");
255
+ const t = ctx.locale.bind(NS);
256
+ const TrimButton = makeButton(t);
257
+ const render = (props) => h(TrimButton, { sessionId: props.sessionId, key: props.sessionId });
258
+ ctx.slots.inject("conversation.session.header.actions", () =>
259
+ ctx.slots.register({ name: "conversation.session.header.actions", id: "ctx-trim", order: 45 }, render),
260
+ );
261
+ },
262
+ };
263
+ },
264
+ });
@@ -0,0 +1,4 @@
1
+ # dsh-ctx-trim 只插入一个宿主行;客户端半边由 dsh.client 声明,不写在这个文件里。
2
+ - insert:
3
+ - id: dsh-ctx-trim
4
+ name: 'dsh-ctx-trim'
package/icon.svg ADDED
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Context compaction">
2
+ <rect width="64" height="64" rx="14" fill="#1b2a4a"/>
3
+ <g fill="none" stroke="#7fb2ff" stroke-width="3" stroke-linecap="round">
4
+ <path d="M14 22h36"/>
5
+ <path d="M14 32h26"/>
6
+ <path d="M14 42h16"/>
7
+ </g>
8
+ <g stroke="#3964fe" stroke-width="4" stroke-linecap="round">
9
+ <path d="M40 38l6 6"/>
10
+ <path d="M46 38l-6 6"/>
11
+ </g>
12
+ <circle cx="52" cy="22" r="4" fill="#9ad0ff"/>
13
+ </svg>
package/index.js ADDED
@@ -0,0 +1,540 @@
1
+ /**
2
+ * dsh-ctx-trim — 宿主半边(v0.2,HTTP 路线)。
3
+ *
4
+ * 为什么走 HTTP 路由:客户端半边拿不到宿主服务(客户端服务表里只有
5
+ * slots / locale / theme / sessions / layout / uiWorkspace / workspaces / timer),
6
+ * 所以按钮只能 fetch 打一条宿主路由。这条路 dsh-meme 已经跑通。
7
+ *
8
+ * 为什么不用 ctx.compaction.compactRegion:
9
+ * BasicCompactionEngine 里它写死 owner: 'current-turn',会话没有开着的 turn 时直接抛
10
+ * "compactRegion: no open turn — automatic compaction events must be enclosed in a turn"
11
+ * 我们是空闲时按按钮,永远没有 open turn;它还要整张 surface 在摘要期间不变。
12
+ * 官方空闲手动压缩只有 compactNow,但它自己选区间(retain 0,只留最后一条),
13
+ * 给不了「保留最近 N 条」的两档控制。
14
+ * 所以按 live-compact 验证过的写法自己走一遍公开事务:
15
+ * compaction/start → compaction/summary → user/message(replace) → compaction/end → flush
16
+ * 外层用 agent.runMaintenance 占住空闲,跟 driver 的回合互斥。
17
+ *
18
+ * 摘要不调模型:优先借用同 profile 的 dsh-live-compact/plan.js(profile 在用的本地
19
+ * 摘要器),拿不到就退回自己那版摘录。摘要若不比切掉的原文小,直接放弃不写。
20
+ */
21
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { randomUUID } from "node:crypto";
25
+ import {
26
+ compactCheckpointSource,
27
+ toolPairingBalancedAfter,
28
+ toolPairingBalancedBefore,
29
+ } from "@deepseek-ai/dsh-compaction";
30
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
31
+
32
+ export const name = "ctx-trim";
33
+
34
+ /** 只把 webServer 当硬依赖;其余服务在用的时候用 ctx.get 取,取不到就报人话。 */
35
+ export const inject = ["webServer"];
36
+
37
+ // 前缀路由的匹配规则是「相等,或以 `${prefix}/` 开头」,所以这里不能带尾斜杠:
38
+ // 带上之后 /dsh-ctx-trim/status 永远匹配不到(踩过,404)。
39
+ const ROUTE_PREFIX = "/dsh-ctx-trim";
40
+ const MAX_BODY = 64 * 1024;
41
+ const SUMMARY_OPEN_TAG = "<compacted-summary>";
42
+ const SUMMARY_CLOSE_TAG = "</compacted-summary>";
43
+ const CHECKPOINT_PREAMBLE =
44
+ "This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. " +
45
+ "Treat the captured context as established background and build on it without restating it. " +
46
+ "Continue the task directly from the messages that follow, without acknowledging this checkpoint.";
47
+
48
+ /** 两档:保留多少比例的现价 token;floor 防止小会话一刀切光。 */
49
+ const MODES = {
50
+ normal: { keepRatio: 0.3, floorTokens: 6000, pack: false, label: "正常压缩" },
51
+ deep: { keepRatio: 0.1, floorTokens: 2500, pack: true, label: "深度压缩" },
52
+ };
53
+
54
+ function dshHome() {
55
+ return process.env.DSH_HOME || join(homedir(), ".dsh");
56
+ }
57
+
58
+ function packsDir() {
59
+ const dir = join(dshHome(), "im", "ctxpacks");
60
+ mkdirSync(dir, { recursive: true });
61
+ return dir;
62
+ }
63
+
64
+ function logLine(line) {
65
+ try {
66
+ appendFileSync(join(packsDir(), "pending.log"), `[${new Date().toISOString()}] ${line}\n`, "utf8");
67
+ } catch {
68
+ /* 记日志失败不影响压缩本身 */
69
+ }
70
+ }
71
+
72
+ function recentLog(limit = 6) {
73
+ try {
74
+ const text = readFileSync(join(packsDir(), "pending.log"), "utf8");
75
+ return text.split("\n").filter(Boolean).slice(-limit);
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ function service(ctx, key) {
82
+ try {
83
+ return ctx.get(key);
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ function errorMessage(error) {
90
+ if (!error) return "未知原因";
91
+ return error instanceof Error ? error.message : String(error);
92
+ }
93
+
94
+ function messageTextOf(value) {
95
+ const content = value?.content ?? value?.message?.content;
96
+ if (typeof content === "string") return content;
97
+ if (!Array.isArray(content)) return "";
98
+ return content
99
+ .map((block) => (typeof block?.text === "string" ? block.text : ""))
100
+ .filter(Boolean)
101
+ .join("\n");
102
+ }
103
+
104
+ function eventText(session, seq) {
105
+ const event = session.eventAt(seq);
106
+ if (!event) return "";
107
+ if (event.type === "user/message") return messageTextOf(event.data);
108
+ if (event.type === "assistant/message") return messageTextOf(event.data?.message);
109
+ if (event.type === "tool/result") return messageTextOf(event.data?.message);
110
+ if (event.type === "system/message") return messageTextOf(event.data?.message);
111
+ if (event.type === "developer/message") return messageTextOf(event.data?.message);
112
+ return messageTextOf(event.data);
113
+ }
114
+
115
+ function roleOf(session, seq) {
116
+ const type = session.eventAt(seq)?.type;
117
+ if (type === "assistant/message") return "assistant";
118
+ if (type === "tool/result") return "tool";
119
+ return "user";
120
+ }
121
+
122
+ function isCheckpoint(session, seq) {
123
+ const event = session.eventAt(seq);
124
+ if (!event) return false;
125
+ if (event.data?.source?.kind === "compact-checkpoint") return true;
126
+ return eventText(session, seq).includes("Spine extract.");
127
+ }
128
+
129
+ /** 有没有一次没收尾的 compaction(别的压缩正在跑就不许再下刀)。 */
130
+ function compactionActive(session) {
131
+ let open = false;
132
+ try {
133
+ for (const event of session.snapshotEvents()) {
134
+ if (event.type === "compaction/start") open = true;
135
+ else if (event.type === "compaction/end") open = false;
136
+ }
137
+ } catch {
138
+ return false;
139
+ }
140
+ return open;
141
+ }
142
+
143
+ function safe(fn, fallback) {
144
+ try {
145
+ return fn();
146
+ } catch {
147
+ return fallback;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * 选区间:从尾部往前攒 token,攒够 keepTokens 就停;两端都收到结构边界上。
153
+ */
154
+ function plan(session, meter, modeName) {
155
+ const mode = MODES[modeName] ?? MODES.normal;
156
+ const nodes = session.surface.nodes;
157
+ const measurement = meter.measure(session);
158
+ const priced = measurement.nodes;
159
+ if (nodes.length !== priced.length) {
160
+ return { ok: false, message: "拿不到逐条 token 计量,先不压(surface 和计量对不上)。" };
161
+ }
162
+ const first = safe(() => session.eventAt(nodes[0])?.type === "system/message", false) ? 1 : 0;
163
+ if (nodes.length - first < 4) return { ok: false, message: `消息太少(${nodes.length} 条),不值得压。` };
164
+
165
+ const total = measurement.totalTokens;
166
+ const keepTokens = Math.max(mode.floorTokens, Math.floor(total * mode.keepRatio));
167
+
168
+ let keepFrom = nodes.length;
169
+ let accumulated = 0;
170
+ for (let index = nodes.length - 1; index >= first; index -= 1) {
171
+ accumulated += priced[index].tokens;
172
+ keepFrom = index;
173
+ if (accumulated >= keepTokens) break;
174
+ }
175
+
176
+ let endIdx = keepFrom - 1;
177
+ let guard = 0;
178
+ while (endIdx >= first && guard <= nodes.length) {
179
+ guard += 1;
180
+ if (isCheckpoint(session, nodes[endIdx])) {
181
+ endIdx -= 1;
182
+ continue;
183
+ }
184
+ if (!safe(() => toolPairingBalancedAfter(session, nodes[endIdx]), true)) {
185
+ endIdx -= 1;
186
+ continue;
187
+ }
188
+ break;
189
+ }
190
+
191
+ let startIdx = first;
192
+ guard = 0;
193
+ while (startIdx <= endIdx && guard <= nodes.length) {
194
+ guard += 1;
195
+ if (isCheckpoint(session, nodes[startIdx])) {
196
+ startIdx += 1;
197
+ continue;
198
+ }
199
+ if (!safe(() => toolPairingBalancedBefore(session, nodes[startIdx]), true)) {
200
+ startIdx += 1;
201
+ continue;
202
+ }
203
+ break;
204
+ }
205
+
206
+ if (startIdx > endIdx) {
207
+ return { ok: false, message: "没找到能安全下刀的区间(不是切在工具调用中间,就是撞上压缩标记)。" };
208
+ }
209
+
210
+ const cutNodes = priced.slice(startIdx, endIdx + 1);
211
+ const cutSeqs = nodes.slice(startIdx, endIdx + 1);
212
+ const cutTokens = cutNodes.reduce((sum, node) => sum + (node?.tokens ?? 0), 0);
213
+ const cutHeuristic = cutNodes.reduce((sum, node) => sum + (node?.heuristicTokens ?? node?.tokens ?? 0), 0);
214
+
215
+ return {
216
+ ok: true,
217
+ mode,
218
+ nodes,
219
+ total,
220
+ startSeq: nodes[startIdx],
221
+ endSeq: nodes[endIdx],
222
+ cutSeqs,
223
+ cutTokens,
224
+ cutHeuristic,
225
+ keepNodes: nodes.length - cutSeqs.length,
226
+ };
227
+ }
228
+
229
+ function packSpan(sessionId, selected, spanText) {
230
+ if (!selected.mode.pack) return null;
231
+ try {
232
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
233
+ const file = join(packsDir(), `${String(sessionId).slice(-12)}-${stamp}.md`);
234
+ writeFileSync(
235
+ file,
236
+ `# 深度压缩:切下的原文\n\n- 会话:${sessionId}\n- 时间:${new Date().toISOString()}\n` +
237
+ `- 区间:${selected.startSeq} → ${selected.endSeq}(${selected.cutSeqs.length} 条,约 ${selected.cutTokens} token)\n\n---\n\n${spanText}\n`,
238
+ "utf8",
239
+ );
240
+ return file;
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ let digestModule;
247
+ async function localDigest(messages, spanText) {
248
+ if (digestModule === undefined) {
249
+ try {
250
+ const module = await import("dsh-live-compact/plan.js");
251
+ digestModule = typeof module.planMessages === "function" ? module.planMessages : null;
252
+ } catch {
253
+ digestModule = null;
254
+ }
255
+ }
256
+ if (digestModule) {
257
+ try {
258
+ const card = digestModule(messages);
259
+ if (card && typeof card.text === "string" && card.text.length > 0) return card.text;
260
+ } catch {
261
+ /* 退到自己的摘录 */
262
+ }
263
+ }
264
+ return fallbackDigest(messages, spanText);
265
+ }
266
+
267
+ function fallbackDigest(messages, spanText) {
268
+ const lines = [];
269
+ for (const message of messages) {
270
+ const text = String(message.content || "").replace(/\s+/g, " ").trim();
271
+ if (!text) continue;
272
+ lines.push(`- [${message.role}] ${text.slice(0, 220)}`);
273
+ if (lines.length >= 60) break;
274
+ }
275
+ return [
276
+ "Local extract (no model).",
277
+ `Shadowed characters: ${String(spanText || "").length}.`,
278
+ "Excerpt of the shadowed span:",
279
+ ...(lines.length ? lines : ["- (empty)"]),
280
+ ].join("\n");
281
+ }
282
+
283
+ function frameSummary(digest) {
284
+ return [
285
+ { type: "text", text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
286
+ { type: "text", text: digest },
287
+ { type: "text", text: SUMMARY_CLOSE_TAG },
288
+ ];
289
+ }
290
+
291
+ /** 真下刀:整段 append 不 await,保证 start/summary/replace/end 紧挨着。 */
292
+ function commit(session, selected, compactionId, checkpointMessage) {
293
+ const lifecycle = { compactionId, turn: null };
294
+ const startEvent = session.append("compaction/start", lifecycle);
295
+ const summaryEvent = session.append("compaction/summary", {
296
+ compactionId,
297
+ summary: checkpointMessage.content,
298
+ shadowedRange: { start: selected.startSeq, end: selected.endSeq },
299
+ shadowedSeqs: [...selected.cutSeqs],
300
+ shadowedTokenCount: selected.cutHeuristic,
301
+ provider: "local",
302
+ model: "ctx-trim-extract",
303
+ });
304
+ session.append("user/message", checkpointMessage, {
305
+ surfaceOp: { op: "replace", startSeq: selected.startSeq, endSeq: selected.endSeq },
306
+ sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...selected.cutSeqs],
307
+ });
308
+ session.append("compaction/end", lifecycle);
309
+ }
310
+
311
+ async function runTrim(ctx, sessionId, modeName, dry) {
312
+ const agents = service(ctx, "agents");
313
+ const sessions = service(ctx, "sessions");
314
+ const meter = service(ctx, "tokenMeter");
315
+ if (!agents || typeof agents.get !== "function") return { ok: false, message: "宿主没有 agents 服务,压不了。" };
316
+ if (!meter || typeof meter.measure !== "function") return { ok: false, message: "宿主没有 tokenMeter 服务,压不了。" };
317
+
318
+ const agent = agents.get(sessionId);
319
+ if (!agent) return { ok: false, message: "这个会话不在运行(可能已归档、或由别的进程持有),压不了。" };
320
+ const session = agent.session;
321
+ if (!session) return { ok: false, message: "拿不到会话对象。" };
322
+ if (typeof agent.runMaintenance !== "function") return { ok: false, message: "这个 agent 不支持空闲维护,压不了。" };
323
+ if (compactionActive(session)) return { ok: false, message: "已经有一次压缩没收尾,先等它结束。" };
324
+
325
+ const mode = MODES[modeName] ? modeName : "normal";
326
+ const selected = plan(session, meter, mode);
327
+ if (!selected.ok) return { ok: false, message: selected.message };
328
+
329
+ // 摘要必须在事务开始前算好:事务里不能有 await。
330
+ const messages = selected.cutSeqs.map((seq) => ({ role: roleOf(session, seq), content: eventText(session, seq) }));
331
+ const spanText = messages.map((message) => message.content).join("\n\n");
332
+ const packFile = packSpan(sessionId, selected, spanText);
333
+ let digest = await localDigest(messages, spanText);
334
+ if (packFile) digest += `\nFull shadowed text saved to: ${packFile}`;
335
+
336
+ const compactionId = randomUUID();
337
+ const checkpointMessage = createUserMessage({
338
+ content: frameSummary(digest),
339
+ source: compactCheckpointSource(compactionId),
340
+ });
341
+
342
+ const framed = safe(() => meter.estimateMessage(checkpointMessage), 0);
343
+ if (framed > 0 && framed >= selected.cutTokens) {
344
+ return {
345
+ ok: false,
346
+ message: `摘要没比切掉的原文小(${framed} ≥ ${selected.cutTokens} token),这一刀不划算,放弃。`,
347
+ };
348
+ }
349
+
350
+ const before = selected.total;
351
+ // 试跑:把选区间、取原文、做摘要、估大小全走一遍,只是不落盘。
352
+ if (dry) {
353
+ return {
354
+ ok: true,
355
+ dry: true,
356
+ mode,
357
+ cutNodes: selected.cutSeqs.length,
358
+ cutTokens: selected.cutTokens,
359
+ keepNodes: selected.keepNodes,
360
+ before,
361
+ framed,
362
+ digestChars: digest.length,
363
+ digestHead: digest.slice(0, 400),
364
+ message: `试跑:会切掉 ${selected.cutSeqs.length} 条(约 ${selected.cutTokens} token),摘要 ${digest.length} 字 / 约 ${framed} token,保留最近 ${selected.keepNodes} 条。`,
365
+ };
366
+ }
367
+
368
+ let task;
369
+ try {
370
+ // runMaintenance 同步抛错 = agent 正忙,这一轮还在跑。
371
+ task = agent.runMaintenance(async (signal) => {
372
+ signal?.throwIfAborted?.();
373
+ if (compactionActive(session)) throw new Error("已经有一次压缩没收尾");
374
+ commit(session, selected, compactionId, checkpointMessage);
375
+ if (sessions && typeof sessions.flush === "function") await sessions.flush(session);
376
+ return true;
377
+ });
378
+ } catch (error) {
379
+ return { ok: false, message: `这个会话正在跑(${errorMessage(error)}),等这一轮结束再压。` };
380
+ }
381
+
382
+ try {
383
+ await task;
384
+ } catch (error) {
385
+ return { ok: false, message: `压缩失败:${errorMessage(error)}` };
386
+ }
387
+
388
+ const after = safe(() => meter.measure(session).totalTokens, null);
389
+ const message =
390
+ `切掉 ${selected.cutSeqs.length} 条(约 ${selected.cutTokens} token),保留最近 ${selected.keepNodes} 条。` +
391
+ (before != null && after != null ? `上下文 token:${before} → ${after}` : "") +
392
+ (packFile ? `;原文已存 ${packFile}` : "");
393
+ logLine(`${mode} 成功:${message}`);
394
+ return {
395
+ ok: true,
396
+ mode,
397
+ cutNodes: selected.cutSeqs.length,
398
+ cutTokens: selected.cutTokens,
399
+ before,
400
+ after,
401
+ packFile,
402
+ message,
403
+ };
404
+ }
405
+
406
+ function status(ctx, sessionId) {
407
+ const agents = service(ctx, "agents");
408
+ const meter = service(ctx, "tokenMeter");
409
+ const base = { ok: true, sessionId: sessionId ?? null, recent: recentLog() };
410
+ if (!agents || typeof agents.get !== "function" || !meter) {
411
+ return { ...base, ok: false, message: "宿主服务不全(agents / tokenMeter)" };
412
+ }
413
+ if (!sessionId) return { ...base, ok: false, message: "缺 sessionId" };
414
+ const agent = agents.get(sessionId);
415
+ if (!agent) return { ...base, ok: false, message: "这个会话不在运行" };
416
+ const session = agent.session;
417
+ const modes = {};
418
+ for (const key of Object.keys(MODES)) {
419
+ const value = plan(session, meter, key);
420
+ modes[key] = value.ok
421
+ ? { label: value.mode.label, cutNodes: value.cutSeqs.length, cutTokens: value.cutTokens, keepNodes: value.keepNodes }
422
+ : { label: MODES[key].label, error: value.message };
423
+ }
424
+ return {
425
+ ...base,
426
+ ok: true,
427
+ nodes: session.surface.nodes.length,
428
+ tokens: safe(() => meter.measure(session).totalTokens, null),
429
+ compacting: compactionActive(session),
430
+ modes,
431
+ };
432
+ }
433
+
434
+ function sendJson(res, statusCode, payload) {
435
+ const body = Buffer.from(JSON.stringify(payload), "utf8");
436
+ res.writeHead(statusCode, {
437
+ "content-type": "application/json; charset=utf-8",
438
+ "content-length": String(body.length),
439
+ "cache-control": "no-store",
440
+ });
441
+ res.end(body);
442
+ }
443
+
444
+ async function readJson(req) {
445
+ const chunks = [];
446
+ let size = 0;
447
+ for await (const chunk of req) {
448
+ size += chunk.length;
449
+ if (size > MAX_BODY) throw new Error("请求体太大");
450
+ chunks.push(chunk);
451
+ }
452
+ if (chunks.length === 0) return {};
453
+ const text = Buffer.concat(chunks).toString("utf8").trim();
454
+ return text.length === 0 ? {} : JSON.parse(text);
455
+ }
456
+
457
+ export function apply(ctx) {
458
+ const webServer = ctx.webServer ?? service(ctx, "webServer");
459
+ // 留一行落地证据:apply 到底有没有跑、跑的时候看得到哪些服务。
460
+ logLine(
461
+ `apply: webServer=${typeof webServer?.register === "function"} agents=${typeof service(ctx, "agents")?.get === "function"} ` +
462
+ `sessions=${typeof service(ctx, "sessions")?.flush === "function"} meter=${typeof service(ctx, "tokenMeter")?.measure === "function"}`,
463
+ );
464
+ if (!webServer || typeof webServer.register !== "function") {
465
+ ctx.logger?.warn?.("ctx-trim: 没有 webServer,路由没挂上");
466
+ return;
467
+ }
468
+
469
+ const handler = async (req, res) => {
470
+ let url;
471
+ try {
472
+ url = new URL(req.url || "/", "http://127.0.0.1");
473
+ } catch {
474
+ sendJson(res, 400, { ok: false, message: "请求地址解析失败" });
475
+ return;
476
+ }
477
+
478
+ // 这两个接口能改会话,所以挡掉「别的网页发起的跨站请求」;
479
+ // 浏览器同源是 same-origin,直接 curl 没有这个头(本机调试要用)。
480
+ const site = String(req.headers["sec-fetch-site"] || "").toLowerCase();
481
+ if (site && site !== "same-origin" && site !== "none") {
482
+ sendJson(res, 403, { ok: false, message: "只接受同源请求" });
483
+ return;
484
+ }
485
+
486
+ if (url.pathname === `${ROUTE_PREFIX}/status`) {
487
+ if (req.method !== "GET") {
488
+ res.writeHead(405, { allow: "GET" });
489
+ res.end();
490
+ return;
491
+ }
492
+ try {
493
+ sendJson(res, 200, status(ctx, url.searchParams.get("sessionId") || undefined));
494
+ } catch (error) {
495
+ sendJson(res, 500, { ok: false, message: errorMessage(error) });
496
+ }
497
+ return;
498
+ }
499
+
500
+ if (url.pathname === `${ROUTE_PREFIX}/run`) {
501
+ if (req.method !== "POST") {
502
+ res.writeHead(405, { allow: "POST" });
503
+ res.end();
504
+ return;
505
+ }
506
+ // 只收 JSON:跨站表单发不出这个 content-type,顺手挡掉一半 CSRF。
507
+ const contentType = String(req.headers["content-type"] || "").toLowerCase();
508
+ if (!contentType.startsWith("application/json")) {
509
+ sendJson(res, 415, { ok: false, message: "只收 application/json" });
510
+ return;
511
+ }
512
+ try {
513
+ const body = await readJson(req);
514
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId : "";
515
+ const mode = typeof body.mode === "string" ? body.mode : "normal";
516
+ if (!sessionId) {
517
+ sendJson(res, 400, { ok: false, message: "缺 sessionId" });
518
+ return;
519
+ }
520
+ const result = await runTrim(ctx, sessionId, mode, body.dry === true);
521
+ sendJson(res, result.ok ? 200 : 409, result);
522
+ } catch (error) {
523
+ sendJson(res, 400, { ok: false, message: errorMessage(error) });
524
+ }
525
+ return;
526
+ }
527
+
528
+ sendJson(res, 404, { ok: false, message: "没有这个接口" });
529
+ };
530
+
531
+ ctx.effect(
532
+ () =>
533
+ webServer.register({
534
+ kind: "prefix",
535
+ path: ROUTE_PREFIX,
536
+ handler,
537
+ }),
538
+ "ctx-trim: /dsh-ctx-trim 路由",
539
+ );
540
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Manual Context Compaction",
4
+ "description": "Adds a Context button to the session header: two levels of manual compaction with a local summary, no model call."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "手动上下文压缩",
4
+ "description": "会话头部加一个「上下文」按钮:两档手动压缩,本地摘要不调模型。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "dsh-ctx-trim",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "会话头部一键手动压缩上下文:正常 / 深度两档,本地摘要不调模型,切点落在结构边界上。Manual context compaction for DeepSeek Harness with two levels and a local summary.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./locale/*.json": "./locale/*.json",
11
+ "./icon": "./icon.svg"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "client.js",
16
+ "cordis.patch.yml",
17
+ "locale/*.json",
18
+ "icon.svg",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "engines": {
23
+ "dsh": ">=0.2.0-rc.2 <0.3.0-0",
24
+ "node": ">=22.13.0"
25
+ },
26
+ "peerDependencies": {
27
+ "@deepseek-ai/dsh-compaction": ">=0.2.0-rc.2 <0.3.0-0",
28
+ "@deepseek-ai/dsh-llm": ">=0.1.0-rc.2 <0.3.0-0"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "@deepseek-ai/dsh-compaction": {
32
+ "optional": true
33
+ },
34
+ "@deepseek-ai/dsh-llm": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "dsh": {
39
+ "bundle": {
40
+ "patch": "./cordis.patch.yml"
41
+ },
42
+ "client": {
43
+ "platform": "web",
44
+ "immediately": true,
45
+ "inject": [
46
+ "@deepseek-ai/dsh-client-ui-conversation"
47
+ ]
48
+ }
49
+ },
50
+ "keywords": [
51
+ "dsh-plugin",
52
+ "deepseek-harness",
53
+ "dsh",
54
+ "context",
55
+ "compaction",
56
+ "context-window",
57
+ "token",
58
+ "compress"
59
+ ],
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/AlexSh1ne/dsh-ctx-trim.git"
63
+ },
64
+ "homepage": "https://github.com/AlexSh1ne/dsh-ctx-trim#readme",
65
+ "bugs": {
66
+ "url": "https://github.com/AlexSh1ne/dsh-ctx-trim/issues"
67
+ },
68
+ "author": "AlexSh1ne",
69
+ "license": "MIT"
70
+ }