dsh-auto-compaction 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
- # Temporary Holding Version
1
+ # dsh-auto-compaction
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
+ 引擎包。完整说明见仓库根目录 README:
4
+
5
+ https://github.com/1010231693/dsh-auto-compaction#readme
6
+
7
+ 在 Agent 预设里替换 `compaction-basic` 那一行即可使用;自带 `scripts/preset-scope.mjs` 做可逆切换。
package/lib/index.js ADDED
@@ -0,0 +1,376 @@
1
+ /**
2
+ * dsh-handoff-compaction
3
+ *
4
+ * Idle-time handoff compaction for deepseek-harness.
5
+ *
6
+ * At a configurable share of the model window (default 90%) this backend waits
7
+ * for the agent to go idle, then asks the routed model — replaying the current
8
+ * conversation as the prompt prefix so the provider's cache is reused — for a
9
+ * handoff checkpoint, and replaces the whole compactable surface with that one
10
+ * message. The next user turn therefore starts from system prompt + tools +
11
+ * handoff text, and nothing else.
12
+ *
13
+ * It subclasses the stock basic backend so selection, tool-pair balance,
14
+ * durable compaction markers, flushes and the idle admission reservation are
15
+ * the proven implementation; only the trigger point, the summarization
16
+ * template and the idle gate differ.
17
+ */
18
+ import z from "@deepseek-ai/schemastery";
19
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { BlockAssembler, LlmError, contentHasImage } from "@deepseek-ai/dsh-llm";
23
+ import BasicCompactionEngine from "@deepseek-ai/dsh-compaction-basic";
24
+ import { decideTrigger, shouldAttempt } from "./plan.js";
25
+
26
+ const LOG = "[handoff]";
27
+
28
+ const SETTINGS_DIR = join(homedir(), ".dsh-handoff-compaction");
29
+ const SETTINGS_FILE = join(SETTINGS_DIR, "settings.json");
30
+ const DEFAULT_TEMPLATE_FILE = join(SETTINGS_DIR, "template.md");
31
+
32
+ /**
33
+ * Live settings written by the companion UI package (dsh-handoff-compaction-ui).
34
+ * Read fresh on every decision, cached by mtime so a turn never blocks on disk.
35
+ */
36
+ let settingsCache = { mtimeMs: -1, value: undefined };
37
+ function readSettings() {
38
+ try {
39
+ const stat = statSync(SETTINGS_FILE);
40
+ if (stat.mtimeMs === settingsCache.mtimeMs) return settingsCache.value;
41
+ const parsed = JSON.parse(readFileSync(SETTINGS_FILE, "utf8"));
42
+ settingsCache = { mtimeMs: stat.mtimeMs, value: parsed && typeof parsed === "object" ? parsed : undefined };
43
+ return settingsCache.value;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ function templatePathFrom(configured) {
50
+ if (typeof configured === "string" && configured.trim().length > 0) return configured.trim();
51
+ const env = process.env.DSH_HANDOFF_TEMPLATE;
52
+ if (typeof env === "string" && env.trim().length > 0) return env.trim();
53
+ return DEFAULT_TEMPLATE_FILE;
54
+ }
55
+
56
+ /** Read the handoff template, falling back to the built-in text. */
57
+ function readTemplate(filePath, autoCreate) {
58
+ try {
59
+ if (!existsSync(filePath)) {
60
+ if (!autoCreate) return { text: undefined, source: "builtin (file missing)" };
61
+ mkdirSync(join(filePath, ".."), { recursive: true });
62
+ writeFileSync(filePath, BUILTIN_INSTRUCTION + "\n", "utf8");
63
+ return { text: BUILTIN_INSTRUCTION, source: "builtin (written to " + filePath + ")" };
64
+ }
65
+ const text = readFileSync(filePath, "utf8").replace(/^\uFEFF/, "");
66
+ if (text.trim().length < 20) return { text: undefined, source: "builtin (file empty)" };
67
+ return { text, source: filePath };
68
+ } catch (error) {
69
+ return { text: undefined, source: `builtin (read failed: ${error instanceof Error ? error.message : String(error)})` };
70
+ }
71
+ }
72
+
73
+
74
+ const BUILTIN_INSTRUCTION = [
75
+ "你现在要为本会话写一份交接文档:我会用它替换掉上面的全部对话记录,然后由你在一个空白的上下文里继续同一件事。写漏的东西将永久丢失。",
76
+ "",
77
+ "只输出 Markdown,严格按下面小节顺序,每节用短横线要点,不许省略任何小节;没有内容写「(无)」。",
78
+ "",
79
+ "## 用户目标与原始要求",
80
+ "- 用户最初想做什么、之后怎么改过口;关键原话逐字引用",
81
+ "",
82
+ "## 当前任务状态",
83
+ "- 在做什么、进行到哪一步、哪些已完成、哪些没完成",
84
+ "",
85
+ "## 涉及的文件与代码",
86
+ "- 精确路径:为什么重要、改了什么、还有哪里没改",
87
+ "",
88
+ "## 已验证的事实与命令",
89
+ "- 跑过的命令、测试结果、报错原文、环境约束(版本、端口、路径)",
90
+ "",
91
+ "## 关键决策与被放弃的方案",
92
+ "- 选了什么、为什么;试过但放弃的方案及放弃原因",
93
+ "",
94
+ "## 待办与下一步",
95
+ "- 用户明确要求但尚未完成的项;紧接着要执行的第一步",
96
+ "",
97
+ "## 风险与注意事项",
98
+ "- 容易踩的坑、用户的偏好与禁忌",
99
+ "",
100
+ "规则:",
101
+ "- 保留精确的路径、命令、错误串、标识符、数值、函数签名;不要概括成\"修改了相关文件\"这类废话。",
102
+ "- 如果对话里有用户粘贴的长文档或上一轮交接文档(例如包在 <compacted-summary> 标记里的那份),不要原样照抄:保留仍然生效的硬约束(路径、命令、版本、验收标准)并逐字引用其关键句,其余概括;过时的内容删掉,最终只输出一份同样结构的文档。",
103
+ "- 如果用户的早期消息给出了一份磁盘上的工程交接文档的绝对路径,必须逐字保留该路径并注明\"需要时重新读取该文件\";不要把它的内容当成必须在本文档里复述的东西。",
104
+ "- 不要提到这份交接文档本身,也不要说上下文被压缩或摘要。",
105
+ "- 只输出文档正文,不要调用任何工具。",
106
+ "",
107
+ "写入时还要记住下面两条,它们会被交给后续的你执行:",
108
+ "- 更早的对话消息不会留在你的上下文里,也没有任何工具或磁盘检索能把它们找回来。不要去搜索会话文件、日志或历史记录还原旧对话;文档里没写的,就当作不知道,直接说不知道。",
109
+ "- 用户问起\"之前说过什么\"时,以本文档为准;文档没有逐字保留的内容(例如旧回答的原文),直接说明已经被压缩掉,不要编造,也不要为了找它去翻文件。",
110
+ ].join("\n");
111
+
112
+ function finishError(finish) {
113
+ switch (finish.kind) {
114
+ case "error":
115
+ case "aborted":
116
+ return new LlmError(finish.failure.message, finish.failure.code, finish.failure);
117
+ case "max-tokens": {
118
+ const error = new Error("handoff summary truncated at the token cap (incomplete checkpoint)");
119
+ error.code = "MAX_TOKENS";
120
+ return error;
121
+ }
122
+ default:
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ export default class HandoffCompaction extends BasicCompactionEngine {
128
+ static inject = ["llm", "tokenMeter", "sessions"];
129
+
130
+ static Config = z.object({
131
+ thresholdRatio: z.number(),
132
+ headroomTokens: z.number(),
133
+ retainRatio: z.number(),
134
+ retainTokens: z.number(),
135
+ summarizationProvider: z.string(),
136
+ summarizationModel: z.string(),
137
+ maxTokens: z.number(),
138
+ compactionRetries: z.number(),
139
+ maxOverflowRetries: z.number(),
140
+ contextWindowOverride: z.number(),
141
+ reservedCompletionTokensOverride: z.number(),
142
+ idleDelayMs: z.number(),
143
+ minIntervalMs: z.number(),
144
+ minTokenGrowth: z.number(),
145
+ timeoutMs: z.number(),
146
+ dryRun: z.boolean(),
147
+ handoffInstruction: z.string(),
148
+ traceFile: z.string(),
149
+ traceConsole: z.boolean(),
150
+ templateFile: z.string(),
151
+ presetId: z.string(),
152
+ autoCreateTemplate: z.boolean(),
153
+ });
154
+
155
+ constructor(ctx, config = {}) {
156
+ const {
157
+ contextWindowOverride,
158
+ reservedCompletionTokensOverride,
159
+ idleDelayMs,
160
+ minIntervalMs,
161
+ minTokenGrowth,
162
+ timeoutMs,
163
+ dryRun,
164
+ handoffInstruction,
165
+ traceFile,
166
+ traceConsole,
167
+ templateFile,
168
+ presetId,
169
+ autoCreateTemplate,
170
+ ...base
171
+ } = config;
172
+ // auto:false keeps the stock between-step pressure trigger out of the way;
173
+ // this backend decides on idle instead.
174
+ super(ctx, { ...base, auto: false });
175
+ this.handoff = {
176
+ instruction: typeof handoffInstruction === "string" && handoffInstruction.trim().length > 0 ? handoffInstruction : undefined,
177
+ contextWindowOverride: contextWindowOverride ?? 0,
178
+ reservedOverride: reservedCompletionTokensOverride ?? -1,
179
+ idleDelayMs: idleDelayMs ?? 1_500,
180
+ minIntervalMs: minIntervalMs ?? 60_000,
181
+ minTokenGrowth: minTokenGrowth ?? 2_048,
182
+ timeoutMs: timeoutMs ?? 600_000,
183
+ dryRun: dryRun ?? false,
184
+ templateFile: templatePathFrom(templateFile ?? readSettings()?.templateFile),
185
+ presetId: typeof presetId === "string" ? presetId : "",
186
+ autoCreateTemplate: autoCreateTemplate !== false,
187
+ };
188
+ this.handoff.traceFile = typeof traceFile === "string" ? traceFile : "";
189
+ this.handoff.traceConsole = traceConsole === true;
190
+ // Materialise the template so the settings card can point at a real file.
191
+ try { readTemplate(this.handoff.templateFile, this.handoff.autoCreateTemplate); } catch { /* never block construction */ }
192
+ this.attempts = new WeakMap();
193
+ this.running = new WeakSet();
194
+ this.timers = new WeakMap();
195
+ this.agents = new WeakMap();
196
+ ctx.on("agent/status", ({ agent, status }) => {
197
+ if (agent?.session) this.agents.set(agent.session, agent);
198
+ if (status === "idle") this.scheduleCheck(agent, "agent/status idle");
199
+ });
200
+ // A finished turn is the gap the user described; agent/status is not the only
201
+ // place DSH reports it, so both paths are wired.
202
+ ctx.on("session/event", (session, event) => {
203
+ if (event?.type !== "turn/end") return;
204
+ const owner = this.agents.get(session);
205
+ if (owner !== undefined) this.scheduleCheck(owner, "turn/end");
206
+ });
207
+ this.log(`ready (threshold ${this.describeThreshold()}, dryRun=${this.handoff.dryRun}, trace=${this.handoff.traceFile || (this.handoff.traceConsole ? "console" : "off")})`);
208
+ }
209
+
210
+ describeThreshold() {
211
+ return `${Math.round((this.config.thresholdRatio ?? 0.9) * 100)}%`;
212
+ }
213
+
214
+ log(message, level = "info") {
215
+ const line = `${new Date().toISOString()} ${LOG} ${message}`;
216
+ if (this.handoff?.traceConsole === true) {
217
+ try { console.error(line); } catch { /* trace must never break the turn */ }
218
+ }
219
+ const file = this.handoff?.traceFile;
220
+ if (typeof file === "string" && file.length > 0) {
221
+ try { appendFileSync(file, line + "\n"); } catch { /* trace must never break the turn */ }
222
+ }
223
+ const write = this.ctx.logger?.[level] ?? this.ctx.logger?.info;
224
+ if (typeof write === "function") write.call(this.ctx.logger, `${LOG} ${message}`);
225
+ }
226
+
227
+ /** Debounce the idle signal so a turn that immediately resumes does not trigger work. */
228
+ scheduleCheck(agent, why = "idle") {
229
+ const previous = this.timers.get(agent);
230
+ if (previous !== undefined) clearTimeout(previous);
231
+ const timer = setTimeout(() => {
232
+ this.timers.delete(agent);
233
+ void this.checkAndHandoff(agent, why);
234
+ }, this.handoff.idleDelayMs);
235
+ if (typeof timer.unref === "function") timer.unref();
236
+ this.timers.set(agent, timer);
237
+ }
238
+
239
+ routedTarget(agent) {
240
+ const routed = agent.session?.requestHeader?.()?.config;
241
+ if (routed?.provider && routed?.model) return { provider: routed.provider, model: routed.model };
242
+ const options = agent.options ?? {};
243
+ if (options.provider && options.model) return { provider: options.provider, model: options.model };
244
+ return undefined;
245
+ }
246
+
247
+ async checkAndHandoff(agent, why = "idle") {
248
+ if (this.running.has(agent)) return;
249
+ try {
250
+ const session = agent.session;
251
+ if (!session) return;
252
+ const measurement = this.ctx.tokenMeter.measure(session);
253
+ const target = this.routedTarget(agent);
254
+ if (target === undefined) return this.log(`${why}: no routed provider/model yet; skipping`);
255
+
256
+ const info = await this.ctx.llm.resolveModelInfo(target.provider, target.model);
257
+ const headerMaxTokens = session.requestHeader?.()?.config?.maxTokens;
258
+ const reserved = this.handoff.reservedOverride >= 0
259
+ ? this.handoff.reservedOverride
260
+ : (headerMaxTokens ?? info.defaultMaxTokens ?? 0);
261
+ const contextWindow = this.handoff.contextWindowOverride > 0
262
+ ? this.handoff.contextWindowOverride
263
+ : info.context?.contextWindow;
264
+
265
+ const live = readSettings();
266
+ const scope = live?.scope === "preset" ? "preset" : "global";
267
+ const selectedPresets = Array.isArray(live?.presets) ? live.presets : [];
268
+ if (scope === "preset" && this.handoff.presetId !== "" && !selectedPresets.includes(this.handoff.presetId)) {
269
+ return this.log(`${why}: skipped by scope (preset "${this.handoff.presetId}" not selected)`);
270
+ }
271
+ const liveRatio = typeof live?.thresholdRatio === "number" && live.thresholdRatio > 0 && live.thresholdRatio <= 1
272
+ ? live.thresholdRatio
273
+ : undefined;
274
+
275
+ const decision = decideTrigger({
276
+ totalTokens: measurement.totalTokens,
277
+ contextWindow,
278
+ reservedCompletionTokens: reserved,
279
+ headroomTokens: this.config.headroomTokens ?? 0,
280
+ thresholdRatio: liveRatio ?? this.config.thresholdRatio ?? 0.9,
281
+ });
282
+ if (!decision.trigger) {
283
+ return this.log(`${why}: ${measurement.totalTokens}/${decision.thresholdTokens ?? "?"} tokens${decision.ok === false ? ` (${decision.reason})` : ""}`);
284
+ }
285
+
286
+ const last = this.attempts.get(agent);
287
+ if (!shouldAttempt({
288
+ now: Date.now(),
289
+ last,
290
+ tokens: measurement.totalTokens,
291
+ thresholdTokens: decision.thresholdTokens,
292
+ minIntervalMs: this.handoff.minIntervalMs,
293
+ minTokenGrowth: this.handoff.minTokenGrowth,
294
+ })) {
295
+ return this.log(`over threshold (${measurement.totalTokens} >= ${decision.thresholdTokens}) but holding: retry gate`);
296
+ }
297
+
298
+ this.attempts.set(agent, { at: Date.now(), tokens: measurement.totalTokens });
299
+ if (this.handoff.dryRun) {
300
+ return this.log(`DRY-RUN: would hand off now (${measurement.totalTokens} >= ${decision.thresholdTokens} of an assumed ${contextWindow}-token window)`);
301
+ }
302
+
303
+ this.running.add(agent);
304
+ try {
305
+ const result = await this.compactNow(agent, AbortSignal.timeout(this.handoff.timeoutMs));
306
+ if (result === null || result === undefined) {
307
+ this.log("idle: no compactable range; nothing replaced", "warn");
308
+ return;
309
+ }
310
+ this.log(`committed: replaced ${result.shadowedSeqs.length} node(s) (~${result.shadowedTokenCount} tokens) with the handoff checkpoint`);
311
+ } finally {
312
+ this.running.delete(agent);
313
+ }
314
+ } catch (error) {
315
+ const message = error instanceof Error ? error.message : String(error);
316
+ this.log(`failed: ${message}`, "warn");
317
+ }
318
+ }
319
+
320
+ /** Handoff template instead of the stock compaction instruction. */
321
+ async summarize(input, agent, signal) {
322
+ const latest = agent.session.requestHeader?.()?.config;
323
+ const configured = this.config.summarizationProvider && this.config.summarizationModel
324
+ ? { provider: this.config.summarizationProvider, model: this.config.summarizationModel }
325
+ : undefined;
326
+ const agentTarget = agent.options?.provider && agent.options?.model
327
+ ? { provider: agent.options.provider, model: agent.options.model }
328
+ : undefined;
329
+ const target = configured ?? latest ?? agentTarget;
330
+ if (target === undefined) throw new Error("no provider/model available for the handoff summary");
331
+
332
+ const resolved = this.handoff.instruction === undefined
333
+ ? readTemplate(this.handoff.templateFile, this.handoff.autoCreateTemplate)
334
+ : { text: this.handoff.instruction, source: "config handoffInstruction" };
335
+ const instruction = typeof resolved.text === "string" && resolved.text.trim().length > 0
336
+ ? resolved.text
337
+ : BUILTIN_INSTRUCTION;
338
+ this.log(`template: ${resolved.source}${resolved.text === undefined ? " (fallback)" : ""}`);
339
+
340
+ const assembler = new BlockAssembler();
341
+ const messages = [
342
+ ...input.messages,
343
+ Object.freeze({ role: "user", content: [{ type: "text", text: instruction }] }),
344
+ ];
345
+ const options = {
346
+ provider: target.provider,
347
+ model: target.model,
348
+ messages,
349
+ toolHistory: agent.session.toolHistory(),
350
+ ...(input.tools === undefined ? {} : { tools: [...input.tools] }),
351
+ maxTokens: this.config.maxTokens,
352
+ sessionId: agent.session.id,
353
+ purpose: "compaction",
354
+ ...(signal === undefined ? {} : { signal }),
355
+ };
356
+
357
+ for await (const chunk of this.ctx.llm.stream(options)) assembler.push(chunk);
358
+ const error = finishError(assembler.finish);
359
+ if (error !== undefined) throw error;
360
+
361
+ const rawOutput = assembler.blocks();
362
+ if (contentHasImage(rawOutput)) throw new LlmError("handoff summary cannot contain image output", "UNSUPPORTED_CONTENT");
363
+ const summary = rawOutput.filter((block) => block.type === "text");
364
+ if (!summary.some((block) => block.text.trim().length > 0)) throw new Error("handoff summary produced no text content");
365
+
366
+ return {
367
+ summary,
368
+ rawOutput,
369
+ llmStreamCall: true,
370
+ provider: target.provider,
371
+ model: target.model,
372
+ maxTokens: this.config.maxTokens,
373
+ ...(assembler.usage === undefined ? {} : { usage: assembler.usage }),
374
+ };
375
+ }
376
+ }
package/lib/plan.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Pure decision helpers for idle-time handoff compaction.
3
+ * Kept free of cordis/DSH imports so they can be unit-tested directly.
4
+ */
5
+
6
+ /**
7
+ * Threshold in tokens for a routed model.
8
+ *
9
+ * The ratio is applied to the *usable input budget* (window minus the output
10
+ * tokens the routed request reserves), then clamped by the headroom safety
11
+ * margin. That makes the whole 0-100% range monotonic: on a 250K window with a
12
+ * 20K reservation and 10K headroom the budget is 230K and the ceiling 220K, so
13
+ * 90% means 207K and 100% means 220K.
14
+ *
15
+ * @param {object} input
16
+ * @param {number} input.contextWindow model window in tokens
17
+ * @param {number} [input.reservedCompletionTokens] output tokens the routed request reserves
18
+ * @param {number} [input.headroomTokens] safety margin below the budget
19
+ * @param {number} [input.thresholdRatio] fraction of the usable budget, default 0.9
20
+ */
21
+ export function resolveThreshold({ contextWindow, reservedCompletionTokens = 0, headroomTokens = 0, thresholdRatio = 0.9 }) {
22
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) {
23
+ return { ok: false, reason: `contextWindow (${contextWindow}) must be a positive number` };
24
+ }
25
+ if (!Number.isFinite(reservedCompletionTokens) || reservedCompletionTokens < 0) {
26
+ return { ok: false, reason: `reservedCompletionTokens (${reservedCompletionTokens}) must be >= 0` };
27
+ }
28
+ if (!Number.isFinite(headroomTokens) || headroomTokens < 0) {
29
+ return { ok: false, reason: `headroomTokens (${headroomTokens}) must be >= 0` };
30
+ }
31
+ if (!(thresholdRatio > 0 && thresholdRatio <= 1)) {
32
+ return { ok: false, reason: `thresholdRatio (${thresholdRatio}) must be in (0, 1]` };
33
+ }
34
+ const budgetTokens = contextWindow - reservedCompletionTokens;
35
+ if (budgetTokens <= 0) {
36
+ return { ok: false, reason: `no input budget: window ${contextWindow} - reserved ${reservedCompletionTokens}` };
37
+ }
38
+ const ceilingTokens = budgetTokens - headroomTokens;
39
+ if (ceilingTokens <= 0) {
40
+ return { ok: false, reason: `headroom ${headroomTokens} consumes the input budget ${budgetTokens}` };
41
+ }
42
+ return {
43
+ ok: true,
44
+ thresholdTokens: Math.floor(Math.min(Math.round(budgetTokens * thresholdRatio), ceilingTokens)),
45
+ budgetTokens,
46
+ ceilingTokens,
47
+ };
48
+ }
49
+
50
+ /** Whether the measured session is at or above the handoff threshold. */
51
+ export function decideTrigger(input) {
52
+ const spec = resolveThreshold(input);
53
+ if (!spec.ok) return { trigger: false, ...spec };
54
+ return {
55
+ trigger: input.totalTokens >= spec.thresholdTokens,
56
+ totalTokens: input.totalTokens,
57
+ thresholdTokens: spec.thresholdTokens,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Retry gate: avoids hammering a session whose handoff keeps failing, or that
63
+ * shrank but is still reported above the threshold.
64
+ *
65
+ * @param {object} input
66
+ * @param {number} input.now epoch ms
67
+ * @param {{at:number,tokens:number}|undefined} input.last previous attempt record
68
+ * @param {number} input.tokens current measured tokens
69
+ * @param {number} input.thresholdTokens current threshold
70
+ * @param {number} [input.minIntervalMs] minimum wall-clock gap between attempts
71
+ * @param {number} [input.minTokenGrowth] minimum growth since the last attempt
72
+ */
73
+ export function shouldAttempt({ now, last, tokens, thresholdTokens, minIntervalMs = 60_000, minTokenGrowth = 2_048 }) {
74
+ if (tokens < thresholdTokens) return false;
75
+ if (last === undefined) return true;
76
+ if (now - last.at < minIntervalMs) return false;
77
+ if (tokens - last.tokens < minTokenGrowth) return false;
78
+ return true;
79
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "dsh-auto-compaction",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "lib/index.js",
6
+ "exports": {
7
+ ".": "./lib/index.js",
8
+ "./lib/*": "./lib/*",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "description": "Idle-time handoff compaction for DeepSeek Harness: at a context ratio, write a handoff checkpoint and replace the whole conversation with it.",
12
+ "engines": {
13
+ "node": "^22.19.0 || >=24.0.0"
14
+ },
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/1010231693/dsh-auto-compaction.git",
19
+ "directory": "packages/dsh-auto-compaction"
20
+ },
21
+ "homepage": "https://github.com/1010231693/dsh-auto-compaction#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/1010231693/dsh-auto-compaction/issues"
24
+ },
25
+ "keywords": [
26
+ "deepseek-harness",
27
+ "dsh",
28
+ "cordis",
29
+ "plugin",
30
+ "compaction",
31
+ "context",
32
+ "handoff",
33
+ "llm",
34
+ "local-llm"
35
+ ],
36
+ "files": [
37
+ "lib",
38
+ "scripts",
39
+ "README.md"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * preset-scope.mjs — switch an editable DSH preset between the handoff plugin and
3
+ * DSH's native compaction, reversibly.
4
+ *
5
+ * A preset context can only have ONE provider of `ctx.compaction`, so "native off"
6
+ * means "which row is active". The script replaces a marker-delimited slot in the
7
+ * preset's cordis.patch.yml with either row file, validates the resulting YAML, and
8
+ * keeps a .bak-scope copy. Disabling restores the stock row (`auto: true`), so a
9
+ * preset goes back to DSH native compaction the moment the plugin is switched off.
10
+ *
11
+ * Usage:
12
+ * node scripts/preset-scope.mjs status [--preset <cordis.patch.yml>] [--all-local]
13
+ * node scripts/preset-scope.mjs enable [--preset <cordis.patch.yml>] [--all-local]
14
+ * node scripts/preset-scope.mjs disable [--preset <cordis.patch.yml>] [--all-local]
15
+ * Restart DSH afterwards.
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { createRequire } from "node:module";
21
+
22
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
23
+ const DEFAULT_PRESET = "C:/Users/Me/.dsh-local-pkgs/dsh-preset-lightweight-local/cordis.patch.yml";
24
+ const LOCAL_PKGS = "C:/Users/Me/.dsh-local-pkgs";
25
+ const SLOT_BEGIN = "# >>> handoff:slot:begin";
26
+ const SLOT_END = "# <<< handoff:slot:end";
27
+ const ROW_INDENT = 14;
28
+
29
+ const argv = process.argv.slice(2);
30
+ const mode = (argv.find((a) => !a.startsWith("--")) ?? "status").toLowerCase();
31
+ const presetArg = argv.indexOf("--preset");
32
+ const allLocal = argv.includes("--all-local");
33
+
34
+ function targets() {
35
+ if (allLocal) {
36
+ return fs.readdirSync(LOCAL_PKGS)
37
+ .map((name) => path.join(LOCAL_PKGS, name, "cordis.patch.yml"))
38
+ .filter((file) => fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(SLOT_BEGIN));
39
+ }
40
+ return [presetArg >= 0 ? argv[presetArg + 1] : DEFAULT_PRESET];
41
+ }
42
+
43
+ function indentRow(text) {
44
+ return text.replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n")
45
+ .map((line) => (line.trim() === "" ? "" : " ".repeat(ROW_INDENT) + line));
46
+ }
47
+
48
+ function loadYaml() {
49
+ try {
50
+ return createRequire("C:/Users/Me/AppData/Roaming/dsh-desktop/harness/profiles/web/")("js-yaml");
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ function validate(text) {
57
+ const yaml = loadYaml();
58
+ if (yaml === undefined) return "skipped (js-yaml unavailable)";
59
+ const jsTag = new yaml.Type("tag:yaml.org,2002:js", { kind: "scalar", construct: (d) => d });
60
+ const doc = yaml.load(text, { schema: yaml.DEFAULT_SCHEMA.extend([jsTag]) });
61
+ const group = doc[0].insert[0].config.plugins.find((entry) => entry.id === "compaction");
62
+ const rows = group.config.filter((entry) => entry.id === "compaction-basic");
63
+ if (rows.length !== 1) throw new Error(`expected exactly one compaction-basic row, found ${rows.length}`);
64
+ return `ok -> ${rows[0].name}`;
65
+ }
66
+
67
+ function slotBounds(lines) {
68
+ const begin = lines.findIndex((line) => line.includes(SLOT_BEGIN));
69
+ const end = lines.findIndex((line) => line.includes(SLOT_END));
70
+ if (begin < 0 || end < 0 || end <= begin) return undefined;
71
+ return { begin, end };
72
+ }
73
+
74
+ function activeName(lines) {
75
+ const bounds = slotBounds(lines);
76
+ if (bounds === undefined) return "no slot";
77
+ for (let i = bounds.begin + 1; i < bounds.end; i += 1) {
78
+ const match = lines[i].match(/^\s*name:\s*"([^"]+)"/);
79
+ if (match) return match[1];
80
+ }
81
+ return "unknown";
82
+ }
83
+
84
+ for (const file of targets()) {
85
+ if (!fs.existsSync(file)) {
86
+ console.error(`skip (missing): ${file}`);
87
+ continue;
88
+ }
89
+ const original = fs.readFileSync(file, "utf8");
90
+ const lines = original.split(/\r?\n/);
91
+ const bounds = slotBounds(lines);
92
+ if (bounds === undefined) {
93
+ console.error(`skip (no slot markers): ${file}`);
94
+ continue;
95
+ }
96
+
97
+ if (mode === "status") {
98
+ const current = activeName(lines);
99
+ const kind = current === "dsh-handoff-compaction" ? "插件接管(原生关闭)"
100
+ : current === "@deepseek-ai/dsh-compaction-basic" ? "DSH 原生压缩"
101
+ : "未知";
102
+ console.log(`${file}\n 当前: ${current}\n 含义: ${kind}\n YAML: ${validate(original)}`);
103
+ continue;
104
+ }
105
+
106
+ if (mode !== "enable" && mode !== "disable") {
107
+ console.error("usage: preset-scope.mjs <status|enable|disable> [--preset <file>] [--all-local]");
108
+ process.exit(2);
109
+ }
110
+
111
+ const rowFile = path.join(HERE, mode === "enable" ? "row-plugin.yml" : "row-native.yml");
112
+ const row = indentRow(fs.readFileSync(rowFile, "utf8"));
113
+ const next = [
114
+ ...lines.slice(0, bounds.begin + 1),
115
+ ...row,
116
+ ...lines.slice(bounds.end),
117
+ ].join("\n");
118
+
119
+ const verdict = validate(next);
120
+ if (!verdict.startsWith("ok")) {
121
+ console.error(`aborted (validation): ${verdict}`);
122
+ process.exit(1);
123
+ }
124
+ fs.copyFileSync(file, `${file}.bak-scope`);
125
+ fs.writeFileSync(file, next);
126
+ console.log(`${file}\n -> ${activeName(next.split(/\r?\n/))} [${verdict}]`);
127
+ }
128
+
129
+ if (mode === "enable" || mode === "disable") console.log("重启 DSH 后生效。");
@@ -0,0 +1,6 @@
1
+ - id: compaction-basic
2
+ name: "@deepseek-ai/dsh-compaction-basic"
3
+ config:
4
+ auto: true
5
+ thresholdRatio: 0.9
6
+ headroomTokens: 20000
@@ -0,0 +1,10 @@
1
+ - id: compaction-basic
2
+ name: "dsh-auto-compaction"
3
+ config:
4
+ thresholdRatio: 0.9
5
+ headroomTokens: 10000
6
+ maxTokens: 8192
7
+ idleDelayMs: 1500
8
+ dryRun: false
9
+ presetId: lightweight-local
10
+ traceFile: "C:/Users/Me/.dsh-handoff-work/trace.log"