dsh-auto-compaction 0.1.3 → 0.2.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 +2 -2
- package/cordis.patch.yml +10 -0
- package/lib/client.js +880 -0
- package/lib/index.js +234 -600
- package/package.json +18 -9
- package/lib/plan.js +0 -79
- package/lib/status.js +0 -52
- package/lib/summary.js +0 -147
- package/scripts/preset-scope.mjs +0 -409
- package/scripts/row-native.yml +0 -6
- package/scripts/row-plugin.yml +0 -10
package/package.json
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-auto-compaction",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./lib/index.js",
|
|
8
|
-
"./
|
|
8
|
+
"./client": "./lib/client.js",
|
|
9
9
|
"./package.json": "./package.json"
|
|
10
10
|
},
|
|
11
|
-
"description": "A session auto-compaction plugin that is friendly to local models: compaction never makes the local engine re-read history, saving a lot of time; the trigger point and the saved content of the compacted session are user-configurable.",
|
|
11
|
+
"description": "Settings page (设置 → 会话压缩) for A session auto-compaction plugin that is friendly to local models: compaction never makes the local engine re-read history, saving a lot of time; the trigger point and the saved content of the compacted session are user-configurable.",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": "^22.19.0 || >=24.0.0"
|
|
14
14
|
},
|
|
15
|
+
"dsh": {
|
|
16
|
+
"client": {
|
|
17
|
+
"inject": [
|
|
18
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
19
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
20
|
+
],
|
|
21
|
+
"platform": "web"
|
|
22
|
+
},
|
|
23
|
+
"bundle": {
|
|
24
|
+
"patch": "./cordis.patch.yml"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
15
27
|
"license": "MIT",
|
|
16
28
|
"repository": {
|
|
17
29
|
"type": "git",
|
|
@@ -28,20 +40,17 @@
|
|
|
28
40
|
"cordis",
|
|
29
41
|
"plugin",
|
|
30
42
|
"compaction",
|
|
31
|
-
"
|
|
32
|
-
"handoff",
|
|
33
|
-
"llm",
|
|
34
|
-
"local-llm"
|
|
43
|
+
"settings-ui"
|
|
35
44
|
],
|
|
36
45
|
"files": [
|
|
37
46
|
"lib",
|
|
38
|
-
"
|
|
47
|
+
"cordis.patch.yml",
|
|
39
48
|
"README.md"
|
|
40
49
|
],
|
|
41
50
|
"publishConfig": {
|
|
42
51
|
"access": "public"
|
|
43
52
|
},
|
|
44
53
|
"dependencies": {
|
|
45
|
-
"
|
|
54
|
+
"dsh-auto-compaction-engine": "^0.2.0"
|
|
46
55
|
}
|
|
47
56
|
}
|
package/lib/plan.js
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
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/lib/status.js
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-session compaction status sidecar.
|
|
3
|
-
*
|
|
4
|
-
* The engine writes one small JSON file while it prepares, summarizes and
|
|
5
|
-
* commits a handoff; the UI dock polls it through the companion package's RPC
|
|
6
|
-
* channel. Kept free of DSH imports so the UI's node half can reuse it.
|
|
7
|
-
*/
|
|
8
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { homedir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
|
|
12
|
-
const STATUS_DIR = process.env.DSH_HANDOFF_STATUS_DIR || join(homedir(), ".dsh-handoff-compaction", "status");
|
|
13
|
-
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
14
|
-
|
|
15
|
-
/** Absolute status file for a session id, or undefined when the id is unsafe. */
|
|
16
|
-
export function statusFileFor(sessionId) {
|
|
17
|
-
if (typeof sessionId !== "string" || !SESSION_ID.test(sessionId)) return undefined;
|
|
18
|
-
return join(STATUS_DIR, `${sessionId}.json`);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Latest status for a session, or undefined when absent/unreadable. */
|
|
22
|
-
export function readStatus(sessionId) {
|
|
23
|
-
const file = statusFileFor(sessionId);
|
|
24
|
-
if (file === undefined) return undefined;
|
|
25
|
-
try {
|
|
26
|
-
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
27
|
-
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
28
|
-
} catch {
|
|
29
|
-
return undefined;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Merge one patch into the session status and replace the file atomically.
|
|
35
|
-
* Returns false when the session id is unsafe or the write failed; status
|
|
36
|
-
* writes must never break a compaction.
|
|
37
|
-
*/
|
|
38
|
-
export function writeStatus(sessionId, patch) {
|
|
39
|
-
const file = statusFileFor(sessionId);
|
|
40
|
-
if (file === undefined || patch === undefined || patch === null) return false;
|
|
41
|
-
try {
|
|
42
|
-
mkdirSync(STATUS_DIR, { recursive: true });
|
|
43
|
-
const previous = readStatus(sessionId) ?? {};
|
|
44
|
-
const next = { ...previous, ...patch, sessionId, updatedAt: Date.now() };
|
|
45
|
-
const temp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
46
|
-
writeFileSync(temp, JSON.stringify(next), "utf8");
|
|
47
|
-
renameSync(temp, file);
|
|
48
|
-
return true;
|
|
49
|
-
} catch {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
}
|
package/lib/summary.js
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure completeness checks for the generated handoff document.
|
|
3
|
-
*
|
|
4
|
-
* The local models used with DSH sometimes stop early and append a literal
|
|
5
|
-
* "(截断)" marker (or stop right after a heading) instead of failing the
|
|
6
|
-
* request. These helpers let the engine treat such output as incomplete,
|
|
7
|
-
* continue it, or rewrite it before the checkpoint is committed.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const FENCE = /^\s*(```|~~~)/;
|
|
11
|
-
const HEADING = /^\s*(#{2,3})\s+(.+?)\s*$/;
|
|
12
|
-
|
|
13
|
-
function normalizeHeading(text) {
|
|
14
|
-
return String(text ?? "")
|
|
15
|
-
.replace(/^#{2,3}\s*/, "")
|
|
16
|
-
.replace(/^\s*\d+[.、)]\s*/, "")
|
|
17
|
-
.replace(/[*_`]/g, "")
|
|
18
|
-
.replace(/[\s::]+$/g, "")
|
|
19
|
-
.trim();
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function headingsIn(text) {
|
|
23
|
-
const out = [];
|
|
24
|
-
let fenced = false;
|
|
25
|
-
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
26
|
-
if (FENCE.test(line)) {
|
|
27
|
-
fenced = !fenced;
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
if (fenced) continue;
|
|
31
|
-
const match = HEADING.exec(line);
|
|
32
|
-
if (match === null) continue;
|
|
33
|
-
const heading = normalizeHeading(match[2]);
|
|
34
|
-
if (heading.length > 0 && !out.includes(heading)) out.push(heading);
|
|
35
|
-
}
|
|
36
|
-
return out;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Section headings the template asks the model to produce. */
|
|
40
|
-
export function expectedSections(templateText) {
|
|
41
|
-
return headingsIn(templateText);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Section headings actually present in a generated summary. */
|
|
45
|
-
export function summarySections(summaryText) {
|
|
46
|
-
return headingsIn(summaryText);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Whether the text ends with a model-generated truncation marker. */
|
|
50
|
-
export function looksTruncated(text) {
|
|
51
|
-
const tail = String(text ?? "").trimEnd();
|
|
52
|
-
if (tail.length === 0) return false;
|
|
53
|
-
if (/[((]\s*截断\s*[))]\s*$/.test(tail)) return true;
|
|
54
|
-
if (/[\[【]\s*截断\s*[\]】]\s*$/.test(tail)) return true;
|
|
55
|
-
const last = tail.slice(-48);
|
|
56
|
-
return /(?:输出|回答|摘要|文档|内容|响应)\s*(?:已|被)?\s*截断\s*$/.test(last);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Remove literal truncation markers the model wrote about its own output. */
|
|
60
|
-
export function stripTruncationMarkers(text) {
|
|
61
|
-
return String(text ?? "")
|
|
62
|
-
.replace(/[…\.]{0,3}\s*[((]\s*截断\s*[))]/g, "")
|
|
63
|
-
.replace(/[…\.]{0,3}\s*[\[【]\s*截断\s*[\]】]/g, "")
|
|
64
|
-
.trimEnd();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Validate one generated handoff document against the template sections.
|
|
69
|
-
* `ok` is false when a section is missing, the text ends on a bare heading,
|
|
70
|
-
* or the model marked its own output as truncated.
|
|
71
|
-
*/
|
|
72
|
-
export function validateSummary(text, expected = []) {
|
|
73
|
-
const headings = headingsIn(text);
|
|
74
|
-
const missing = expected.filter(
|
|
75
|
-
(section) => !headings.some((heading) => heading.includes(section) || section.includes(heading)),
|
|
76
|
-
);
|
|
77
|
-
const truncated = looksTruncated(text);
|
|
78
|
-
const endsWithHeading = /(?:^|\n)\s*#{2,3}\s+[^\n]*\s*$/.test(String(text ?? "").trimEnd());
|
|
79
|
-
const reasons = [];
|
|
80
|
-
if (missing.length > 0) reasons.push(`missing sections: ${missing.join(", ")}`);
|
|
81
|
-
if (truncated) reasons.push("truncation marker");
|
|
82
|
-
if (endsWithHeading) reasons.push("ends on a heading");
|
|
83
|
-
return {
|
|
84
|
-
ok: missing.length === 0 && !truncated && !endsWithHeading,
|
|
85
|
-
missing,
|
|
86
|
-
truncated,
|
|
87
|
-
endsWithHeading,
|
|
88
|
-
reason: reasons.join("; "),
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function lastHeading(text) {
|
|
93
|
-
const headings = headingsIn(text);
|
|
94
|
-
return headings.length === 0 ? undefined : headings[headings.length - 1];
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function startsWithHeading(text) {
|
|
98
|
-
const first = String(text ?? "").trimStart().split(/\r?\n/, 1)[0] ?? "";
|
|
99
|
-
const match = HEADING.exec(first);
|
|
100
|
-
return match === null ? undefined : normalizeHeading(match[2]);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Join a partial summary and a continuation without duplicating its heading. */
|
|
104
|
-
export function mergeSummary(partial, tail) {
|
|
105
|
-
const head = String(partial ?? "").trimEnd();
|
|
106
|
-
const rest = String(tail ?? "").trim();
|
|
107
|
-
if (head.length === 0) return rest;
|
|
108
|
-
if (rest.length === 0) return head;
|
|
109
|
-
const repeated = lastHeading(head);
|
|
110
|
-
const first = startsWithHeading(rest);
|
|
111
|
-
if (repeated !== undefined && first !== undefined && repeated === first) {
|
|
112
|
-
const newline = rest.indexOf("\n");
|
|
113
|
-
const body = newline === -1 ? "" : rest.slice(newline + 1).trim();
|
|
114
|
-
return body.length === 0 ? head : `${head}\n${body}`;
|
|
115
|
-
}
|
|
116
|
-
if (first !== undefined) return `${head}\n\n${rest}`;
|
|
117
|
-
const safeBreak = /[。!?.!?;;::)\])】]$/.test(head);
|
|
118
|
-
return safeBreak ? `${head}\n\n${rest}` : `${head}${rest}`;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** User turn appended after the partial assistant document. */
|
|
122
|
-
export function continuationInstruction(validation) {
|
|
123
|
-
const missing = validation?.missing ?? [];
|
|
124
|
-
const lines = [
|
|
125
|
-
"你刚才写到一半中断了。请从断点继续这份交接文档:",
|
|
126
|
-
"- 先补完断点处的句子,不要重复已经写过的内容或标题;",
|
|
127
|
-
];
|
|
128
|
-
if (missing.length > 0) {
|
|
129
|
-
lines.push(`- 然后按模板顺序补完这些缺失小节:${missing.join("、")};`);
|
|
130
|
-
} else {
|
|
131
|
-
lines.push("- 并完成文档剩余部分;");
|
|
132
|
-
}
|
|
133
|
-
lines.push("- 只输出 Markdown 正文,不要解释,不要写「(截断)」。");
|
|
134
|
-
return lines.join("\n");
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** User turn appended when a continuation still does not pass validation. */
|
|
138
|
-
export function rewriteInstruction(validation, maxTokens) {
|
|
139
|
-
const missing = validation?.missing ?? [];
|
|
140
|
-
const why = missing.length > 0 ? `缺少这些小节:${missing.join("、")}` : "输出在末尾被截断";
|
|
141
|
-
return [
|
|
142
|
-
`上一次的交接文档不完整(${why})。`,
|
|
143
|
-
"请重新完整输出整份交接文档,必须包含模板要求的全部小节;",
|
|
144
|
-
`不要因为长度自行截断,不要写「(截断)」;本任务允许输出到 ${String(maxTokens)} tokens。`,
|
|
145
|
-
"只输出 Markdown 正文。",
|
|
146
|
-
].join("\n");
|
|
147
|
-
}
|