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 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
- props.scope.set("request", {
130
- nonce: Date.now() * 1000 + Math.floor(Math.random() * 1000),
131
- sessionId: props.sessionId,
132
- keep: n,
133
- mode: mode,
134
- }).catch(function (error) {
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; this half executes the compaction through the current
7
- * agent's official compaction seam (`compaction.compactRegion`) and writes the
8
- * outcome into `lastRun`, clearing the request. Safe boundaries (tool-call
9
- * pairing, busy session, cancellation) are enforced by the compaction seam.
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
- * @param ctx - registrant context (logger only).
40
- * @param agents - live agent registry; resolves the session's agent.
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 endIndex = Math.min(nodes.length - request.keep - 1, request.mode === 'batch' ? 19 : nodes.length - request.keep - 1)
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
- total += result.shadowedSeqs.length
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
- return {
62
- ok: true,
63
- shadowed: total,
64
- message: total
65
- ? `已压缩 ${total} 条历史,保留最近 ${request.keep} 条。`
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 messages = {
70
- busy: '当前正在压缩或模型仍在工作,请稍后再试。',
71
- changed: '会话发生变化,本次压缩已停止。',
72
- summary: '无法生成有效摘要,本次压缩已停止。',
73
- commit: '压缩提交未完成。',
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 current = getter()
94
- const request = current && current.request
95
- if (request === null || request === undefined || request.nonce === lastHandledNonce) return
96
- lastHandledNonce = request.nonce
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.1.3",
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",