dsh-manual-compact-plugin 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 +3 -0
- package/lib/client.js +48 -10
- package/lib/index.js +134 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
- **处理方式**:
|
|
11
11
|
- 完成一批后停止
|
|
12
12
|
- 分批处理直到完成
|
|
13
|
+
- **取消压缩**:压缩过程中可随时取消
|
|
14
|
+
- **压缩进度**:分批模式下实时显示进度条(已处理 / 总数)
|
|
15
|
+
- **可读结果文件**:每次压缩自动追加到 `~/.dsh/manual-compact/<会话>.md`,含时间 / 保留数 / 压缩条数 / 摘要,可用任意文本编辑器打开
|
|
13
16
|
- **压缩存档**:每次压缩显示时间、条数、约计 tokens,点击展开完整摘要
|
|
14
17
|
- 调用 DSH 官方 compaction API(`compaction.compactRegion`):不拆开工具调用对、会话忙时自动拒绝、失败不改动会话
|
|
15
18
|
|
package/lib/client.js
CHANGED
|
@@ -39,6 +39,10 @@ window.__ModuleLoader__.load({
|
|
|
39
39
|
".dsmc-actions button{border:0;border-radius:6px;padding:5px 10px;cursor:pointer;font-size:12px}",
|
|
40
40
|
".dsmc-actions button:disabled{cursor:default;opacity:.6}",
|
|
41
41
|
".dsmc-confirm{background:var(--dsw-alias-interactive-bg-primary);color:var(--dsw-alias-label-on-color)}",
|
|
42
|
+
".dsmc-cancel{background:transparent;color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)}",
|
|
43
|
+
".dsmc-progress{height:6px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);overflow:hidden;margin-top:8px;width:100%}",
|
|
44
|
+
".dsmc-progress-fill{height:100%;border-radius:999px;background:var(--dsw-alias-interactive-bg-primary);transition:width .2s ease}",
|
|
45
|
+
".dsmc-progress-txt{margin-top:4px;font-size:11px;color:var(--dsw-alias-label-tertiary)}",
|
|
42
46
|
".dsmc-msg{margin-top:6px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary)}",
|
|
43
47
|
".dsmc-msg.dsmc-err{color:var(--dsw-alias-state-error-primary)}",
|
|
44
48
|
".dsmc-archive{max-height:160px;overflow:auto}",
|
|
@@ -101,10 +105,18 @@ window.__ModuleLoader__.load({
|
|
|
101
105
|
var [mode, setMode] = useState("stop");
|
|
102
106
|
var [localError, setLocalError] = useState("");
|
|
103
107
|
var [expandedSeq, setExpandedSeq] = useState(null);
|
|
108
|
+
var [cancelling, setCancelling] = useState(false);
|
|
104
109
|
|
|
105
110
|
var request = value.request;
|
|
106
111
|
var busy = request !== null && request !== undefined;
|
|
107
112
|
var lastRun = value.lastRun;
|
|
113
|
+
var progress = value.progress;
|
|
114
|
+
var progVisible = busy && progress && progress.nonce === (request && request.nonce) && progress.total > 0;
|
|
115
|
+
|
|
116
|
+
// Once the request clears, reset the "cancelling" local flag.
|
|
117
|
+
useEffect(function () {
|
|
118
|
+
if (!busy) setCancelling(false);
|
|
119
|
+
}, [busy]);
|
|
108
120
|
|
|
109
121
|
var legacy = props.session && props.session.chat && props.session.chat.legacy;
|
|
110
122
|
var nodes = legacy && Array.isArray(legacy.nodes) ? legacy.nodes : [];
|
|
@@ -114,6 +126,14 @@ window.__ModuleLoader__.load({
|
|
|
114
126
|
if (n && n.kind === "compaction") entries.push(n);
|
|
115
127
|
}
|
|
116
128
|
|
|
129
|
+
function newNonce() { return Date.now() * 1000 + Math.floor(Math.random() * 1000); }
|
|
130
|
+
function sendRequest(req) {
|
|
131
|
+
props.scope.set("request", req).catch(function (error) {
|
|
132
|
+
console.error("[dsh-manual-compact-plugin] request failed:", error);
|
|
133
|
+
setLocalError("请求发送失败,请稍后重试。");
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
var effectiveKeep = keep;
|
|
118
138
|
function submit() {
|
|
119
139
|
var n = Number(effectiveKeep);
|
|
@@ -126,15 +146,12 @@ window.__ModuleLoader__.load({
|
|
|
126
146
|
setLocalError("当前没有可压缩的会话。");
|
|
127
147
|
return;
|
|
128
148
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
console.error("[dsh-manual-compact-plugin] submit failed:", error);
|
|
136
|
-
setLocalError("请求发送失败,请稍后重试。");
|
|
137
|
-
});
|
|
149
|
+
sendRequest({ action: "compact", nonce: newNonce(), sessionId: props.sessionId, keep: n, mode: mode });
|
|
150
|
+
}
|
|
151
|
+
function cancelCurrent() {
|
|
152
|
+
if (props.sessionId === undefined || props.sessionId === null) return;
|
|
153
|
+
setCancelling(true);
|
|
154
|
+
sendRequest({ action: "cancel", nonce: newNonce(), sessionId: props.sessionId, keep: Number(effectiveKeep) || 4, mode: mode });
|
|
138
155
|
}
|
|
139
156
|
|
|
140
157
|
return react.createElement(
|
|
@@ -175,8 +192,29 @@ window.__ModuleLoader__.load({
|
|
|
175
192
|
"button",
|
|
176
193
|
{ type: "button", className: "dsmc-confirm", disabled: busy, onClick: submit },
|
|
177
194
|
busy ? "处理中…" : "开始压缩"
|
|
178
|
-
)
|
|
195
|
+
),
|
|
196
|
+
busy
|
|
197
|
+
? react.createElement(
|
|
198
|
+
"button",
|
|
199
|
+
{ type: "button", className: "dsmc-cancel", disabled: cancelling, onClick: cancelCurrent },
|
|
200
|
+
cancelling ? "正在取消…" : "取消压缩"
|
|
201
|
+
)
|
|
202
|
+
: null
|
|
179
203
|
),
|
|
204
|
+
progVisible
|
|
205
|
+
? react.createElement(
|
|
206
|
+
"div",
|
|
207
|
+
null,
|
|
208
|
+
react.createElement(
|
|
209
|
+
"div",
|
|
210
|
+
{ className: "dsmc-progress" },
|
|
211
|
+
react.createElement("div", { className: "dsmc-progress-fill", style: { width: Math.round((progress.done / progress.total) * 100) + "%" } })
|
|
212
|
+
),
|
|
213
|
+
react.createElement("div", { className: "dsmc-progress-txt" },
|
|
214
|
+
"压缩进度 " + progress.done + " / " + progress.total
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
: null,
|
|
180
218
|
localError
|
|
181
219
|
? react.createElement("div", { className: "dsmc-msg dsmc-err" }, localError)
|
|
182
220
|
: lastRun
|
package/lib/index.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-manual-compact — host face.
|
|
2
|
+
* dsh-manual-compact-plugin — host face.
|
|
3
3
|
*
|
|
4
4
|
* Registers the `manual-compact` settings namespace as the Client<->Host
|
|
5
5
|
* transport (the same pattern dsh-plugin-proxy uses). The browser half writes
|
|
6
|
-
* a `request` object
|
|
7
|
-
* agent's official compaction seam
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* a `request` object with an `action`; this half executes the compaction
|
|
7
|
+
* through the current agent's official compaction seam
|
|
8
|
+
* (`compaction.compactRegion(..., signal)`), supports cancellation via an
|
|
9
|
+
* AbortController, publishes live progress, and appends a readable
|
|
10
|
+
* per-session markdown archive to `$DSH_HOME/manual-compact/<session>.md`.
|
|
11
|
+
* The outcome is published into `lastRun` and the request is cleared.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import z from '@deepseek-ai/schemastery'
|
|
13
15
|
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
|
|
16
|
+
import { mkdir, appendFile } from 'node:fs/promises'
|
|
17
|
+
import os from 'node:os'
|
|
14
18
|
|
|
15
19
|
const name = 'dsh-manual-compact-plugin'
|
|
16
20
|
const inject = ['settings', 'agents']
|
|
@@ -23,6 +27,12 @@ const Config = z.object({
|
|
|
23
27
|
sessionId: z.string(),
|
|
24
28
|
keep: z.number(),
|
|
25
29
|
mode: z.union([z.const('stop'), z.const('batch')]),
|
|
30
|
+
action: z.union([z.const('compact'), z.const('cancel')]).default('compact'),
|
|
31
|
+
})]).default(null),
|
|
32
|
+
progress: z.union([z.const(null), z.object({
|
|
33
|
+
nonce: z.number(),
|
|
34
|
+
done: z.number(),
|
|
35
|
+
total: z.number(),
|
|
26
36
|
})]).default(null),
|
|
27
37
|
lastRun: z.union([z.const(null), z.object({
|
|
28
38
|
at: z.number(),
|
|
@@ -31,49 +41,107 @@ const Config = z.object({
|
|
|
31
41
|
ok: z.boolean(),
|
|
32
42
|
shadowed: z.number(),
|
|
33
43
|
message: z.string(),
|
|
44
|
+
cancelled: z.boolean().default(false),
|
|
45
|
+
file: z.string().default(''),
|
|
34
46
|
})]).default(null),
|
|
35
47
|
})
|
|
36
48
|
|
|
49
|
+
/** Best-effort JSON-friendly timestamp. */
|
|
50
|
+
function fmtTime(ms) {
|
|
51
|
+
const d = new Date(ms)
|
|
52
|
+
const pad = (v) => String(v).padStart(2, '0')
|
|
53
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Locate the newest landed compaction node, if any. */
|
|
57
|
+
function newestCompactionNode(snapshot) {
|
|
58
|
+
const nodes = snapshot && snapshot.surface && snapshot.surface.nodes
|
|
59
|
+
if (!Array.isArray(nodes)) return null
|
|
60
|
+
let newest = null
|
|
61
|
+
for (const n of nodes) {
|
|
62
|
+
if (n && n.kind === 'compaction') newest = n
|
|
63
|
+
}
|
|
64
|
+
return newest
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Append a readable markdown record for every completed region. */
|
|
68
|
+
async function writeArchiveFile(sessionId, keep, mode, records, shadowed) {
|
|
69
|
+
if (!records.length) return ''
|
|
70
|
+
const dir = path(process.env.DSH_HOME || homeDotDsh(), 'manual-compact')
|
|
71
|
+
try { await mkdir(dir, { recursive: true }) } catch { /* best effort */ }
|
|
72
|
+
const file = path(dir, sessionId + '.md')
|
|
73
|
+
const chunks = records.map((r) => [
|
|
74
|
+
`## 压缩记录 · ${fmtTime(r.time)}`,
|
|
75
|
+
`- 保留最近:${keep} 条`,
|
|
76
|
+
`- 压缩:${shadowed} 条 · ~${fmtTokens(r.shadowedTokenCount)} tokens`,
|
|
77
|
+
`- 方式:${mode === 'batch' ? '分批处理直到完成' : '完成一批后停止'}`,
|
|
78
|
+
'',
|
|
79
|
+
r.summary || '',
|
|
80
|
+
'',
|
|
81
|
+
'---',
|
|
82
|
+
'',
|
|
83
|
+
].join('\n'))
|
|
84
|
+
try { await appendFile(file, chunks.join('\n') + '\n', 'utf8') } catch { /* best effort */ }
|
|
85
|
+
return file
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function homeDotDsh() {
|
|
89
|
+
return os.homedir() + (process.platform === 'win32' ? '\\.dsh' : '/.dsh')
|
|
90
|
+
}
|
|
91
|
+
function path(...parts) { return parts.join(process.platform === 'win32' ? '\\' : '/') }
|
|
92
|
+
function fmtTokens(v) { return typeof v === 'number' && isFinite(v) ? (v >= 1000 ? Math.round(v / 1000) + 'k' : String(v)) : '?' }
|
|
93
|
+
|
|
94
|
+
const ERROR_MESSAGES = {
|
|
95
|
+
busy: '当前正在压缩或模型仍在工作,请稍后再试。',
|
|
96
|
+
changed: '会话发生变化,本次压缩已停止。',
|
|
97
|
+
summary: '无法生成有效摘要,本次压缩已停止。',
|
|
98
|
+
commit: '压缩提交未完成。',
|
|
99
|
+
persistence: '摘要生成完成,但保存会话失败。',
|
|
100
|
+
}
|
|
101
|
+
|
|
37
102
|
/**
|
|
38
103
|
* Run one manual compaction request against the requesting session.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* @param request - validated request payload.
|
|
42
|
-
* @returns the outcome: ok, shadowed item count, and a human message.
|
|
104
|
+
* Supports cancellation through `signal` and reports progress through
|
|
105
|
+
* `onProgress`. Returns the outcome including a readable file path.
|
|
43
106
|
*/
|
|
44
|
-
async function executeCompact(ctx, agents, request) {
|
|
107
|
+
async function executeCompact(ctx, agents, request, signal, onProgress) {
|
|
45
108
|
const agent = agents.get(request.sessionId)
|
|
46
109
|
const compaction = agent && agent.ctx && agent.ctx.get('compaction')
|
|
47
110
|
if (!agent || !compaction) {
|
|
48
|
-
return { ok: false, shadowed: 0, message: '当前会话暂时没有可用的压缩服务。' }
|
|
111
|
+
return { ok: false, shadowed: 0, message: '当前会话暂时没有可用的压缩服务。', cancelled: false, file: '' }
|
|
49
112
|
}
|
|
113
|
+
const records = []
|
|
50
114
|
try {
|
|
115
|
+
let done = 0
|
|
51
116
|
let total = 0
|
|
52
117
|
while (true) {
|
|
118
|
+
if (signal && signal.aborted) throw { name: 'AbortError' }
|
|
53
119
|
const nodes = agent.session.surface.nodes
|
|
54
120
|
if (!Array.isArray(nodes) || nodes.length <= request.keep) break
|
|
55
|
-
const
|
|
121
|
+
const toCompress = nodes.length - request.keep
|
|
122
|
+
if (total === 0) total = toCompress
|
|
123
|
+
const endIndex = Math.min(toCompress - 1, request.mode === 'batch' ? 19 : toCompress - 1)
|
|
56
124
|
if (endIndex < 0) break
|
|
57
|
-
const result = await compaction.compactRegion(nodes[0], nodes[endIndex], agent)
|
|
58
|
-
|
|
125
|
+
const result = await compaction.compactRegion(nodes[0], nodes[endIndex], agent, signal)
|
|
126
|
+
done += Array.isArray(result.shadowedSeqs) ? result.shadowedSeqs.length : 0
|
|
127
|
+
const rec = newestCompactionNode(agent.session.surface.nodes)
|
|
128
|
+
if (rec) {
|
|
129
|
+
records.push({ time: rec.time, shadowedItemCount: rec.shadowedItemCount, shadowedTokenCount: rec.shadowedTokenCount, summary: rec.summary })
|
|
130
|
+
}
|
|
131
|
+
if (onProgress) await onProgress({ nonce: request.nonce, done, total })
|
|
59
132
|
if (request.mode !== 'batch') break
|
|
60
133
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
: `没有可压缩的历史,已保留最近 ${request.keep} 条。`,
|
|
67
|
-
}
|
|
134
|
+
const file = await writeArchiveFile(request.sessionId, request.keep, request.mode, records, done)
|
|
135
|
+
const message = done
|
|
136
|
+
? `已压缩 ${done} 条历史,保留最近 ${request.keep} 条${done > 0 && file ? `;结果已保存到 ${file}` : ''}。`
|
|
137
|
+
: `没有可压缩的历史,已保留最近 ${request.keep} 条。`
|
|
138
|
+
return { ok: true, shadowed: done, message, cancelled: false, file }
|
|
68
139
|
} catch (error) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
persistence: '摘要生成完成,但保存会话失败。',
|
|
75
|
-
}
|
|
76
|
-
return { ok: false, shadowed: 0, message: messages[error && error.code] || `压缩失败:${error && error.message ? error.message : String(error)}` }
|
|
140
|
+
const cancelled = !!(signal && signal.aborted) || (error && error.name === 'AbortError')
|
|
141
|
+
const reason = cancelled
|
|
142
|
+
? '已取消压缩。'
|
|
143
|
+
: ERROR_MESSAGES[error && error.code] || `压缩失败:${error && error.message ? error.message : String(error)}`
|
|
144
|
+
return { ok: false, shadowed: 0, message: reason, cancelled, file: '' }
|
|
77
145
|
}
|
|
78
146
|
}
|
|
79
147
|
|
|
@@ -86,17 +154,38 @@ function apply(ctx, config) {
|
|
|
86
154
|
let getter = () => config
|
|
87
155
|
let lastHandledNonce = null
|
|
88
156
|
let handling = null
|
|
157
|
+
const controllers = new Map() // sessionId -> AbortController
|
|
158
|
+
|
|
89
159
|
const handle = () => {
|
|
160
|
+
const current = getter()
|
|
161
|
+
const request = current && current.request
|
|
162
|
+
if (request === null || request === undefined) return
|
|
163
|
+
|
|
164
|
+
// Cancel: abort any active compaction for this session.
|
|
165
|
+
if (request.action === 'cancel') {
|
|
166
|
+
const controller = controllers.get(request.sessionId)
|
|
167
|
+
if (!controller) {
|
|
168
|
+
void ctx.settings.update(NS, { request: null }).catch(() => {})
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
controller.abort()
|
|
172
|
+
return // the running task publishes the cancelled lastRun and clears request
|
|
173
|
+
}
|
|
174
|
+
|
|
90
175
|
if (handling !== null) return
|
|
176
|
+
if (request.nonce === lastHandledNonce) return
|
|
177
|
+
lastHandledNonce = request.nonce
|
|
91
178
|
handling = (async () => {
|
|
179
|
+
const controller = new AbortController()
|
|
180
|
+
controllers.set(request.sessionId, controller)
|
|
92
181
|
try {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const result = await executeCompact(ctx, ctx.agents, request)
|
|
182
|
+
const onProgress = async (p) => {
|
|
183
|
+
try { await ctx.settings.update(NS, { progress: p }) } catch {}
|
|
184
|
+
}
|
|
185
|
+
const result = await executeCompact(ctx, ctx.agents, request, controller.signal, onProgress)
|
|
98
186
|
await ctx.settings.update(NS, {
|
|
99
187
|
request: null,
|
|
188
|
+
progress: null,
|
|
100
189
|
lastRun: {
|
|
101
190
|
at: Date.now(),
|
|
102
191
|
keep: request.keep,
|
|
@@ -104,15 +193,26 @@ function apply(ctx, config) {
|
|
|
104
193
|
ok: result.ok,
|
|
105
194
|
shadowed: result.shadowed,
|
|
106
195
|
message: result.message,
|
|
196
|
+
cancelled: !!result.cancelled,
|
|
197
|
+
file: result.file || '',
|
|
107
198
|
},
|
|
108
199
|
})
|
|
109
200
|
} catch (error) {
|
|
110
201
|
ctx.logger.warn('manual-compact: handling a request failed: %s', String(error))
|
|
202
|
+
try {
|
|
203
|
+
await ctx.settings.update(NS, {
|
|
204
|
+
request: null,
|
|
205
|
+
progress: null,
|
|
206
|
+
lastRun: { at: Date.now(), keep: request.keep, mode: request.mode, ok: false, shadowed: 0, message: String(error), cancelled: (controller.signal && controller.signal.aborted), file: '' },
|
|
207
|
+
})
|
|
208
|
+
} catch {}
|
|
111
209
|
} finally {
|
|
210
|
+
controllers.delete(request.sessionId)
|
|
112
211
|
handling = null
|
|
113
212
|
}
|
|
114
213
|
})()
|
|
115
214
|
}
|
|
215
|
+
|
|
116
216
|
installSettingsSection(ctx, NS, Config, config, {
|
|
117
217
|
setSource: (current) => {
|
|
118
218
|
getter = current
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-manual-compact-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Manual context compaction for DeepSeek Harness: keep the latest N conversation items with stop or batch mode and a per-session compaction archive — embedded in the context meter popup when the meter panel slot exists, or a composer button otherwise.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|