dsh-retrace 0.4.19 → 0.4.25

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
@@ -77,6 +77,21 @@ Full steps in [📦 Installation](#-installation).
77
77
  | 🧭 | **Jump-to-conversation** | one click from a version to that point in the conversation (auto-loads history, anchor highlight) |
78
78
  | 🧹 | **Bounded storage** | snapshots keep the most recent N versions (default 50); throttled background sweep prunes truncated ones |
79
79
 
80
+ **Close guard (don't lose work by accident)** — before you exit or reload, know what is still running:
81
+
82
+ | | What | |
83
+ |---|---|---|
84
+ | 🛡️ | **Running-work detection** | every session is scanned for live work: agent running, queued inbox items, background jobs, unclosed turns |
85
+ | 📋 | **Running banner** | sessions with live work show a persistent in-page banner (short session code + reasons), so you can see it before quitting |
86
+ | ⚠️ | **Exit prompt** | on plugin dispose (app exit / reload) a Chinese notice lists each running session and why it is considered busy — it only warns, it never cancels your running agent |
87
+ | 🔒 | **Page-close interception (Web)** | `beforeunload` interception: a strong confirm when work is running (details modal, `[仍关闭]` = confirm-and-go), a light confirm otherwise |
88
+ | 🔎 | **Query surface** | `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP) — same shape on both transports |
89
+
90
+ > Desktop note: the Electron shell destroys the window on quit, so the page-level
91
+ > `beforeunload` hook cannot fire there and the host exposes no plugin quit-veto seam —
92
+ > Desktop is covered by the running banner plus the dispose notice; Web gets the full
93
+ > interception.
94
+
80
95
  **Why it's different** (the interaction layer — the guarantees above are the storage layer):
81
96
 
82
97
  - 🎯 **Whole-round recall** — removes the input *and* its output (tool rows included), not just a single bubble.
package/README.zh.md CHANGED
@@ -233,6 +233,21 @@ Client 半区会依据包内 `dsh.client` 元数据被自动打包进 Web 客户
233
233
 
234
234
  ---
235
235
 
236
+ **关闭守卫(防误关丢进度)** —— 退出/重载前先看清还有什么在跑:
237
+
238
+ | | 是什么 | |
239
+ |---|---|---|
240
+ | 🛡️ | **运行中检测** | 逐会话扫描运行中工作:agent 正在跑 / inbox 排队 / 后台 jobs / 未闭合轮 |
241
+ | 📋 | **运行中横幅** | 有运行中工作的会话显示页面常驻横幅(会话短码 + 原因),退出前可见 |
242
+ | ⚠️ | **退出提示** | 插件 dispose(应用退出/重载)时中文提示列出每个运行中会话与原因——只提示,绝不代你取消 agent |
243
+ | 🔒 | **页面关闭拦截(Web)** | `beforeunload` 拦截:有运行中任务强确认(明细模态,`[仍关闭]` 即确认离开),无任务轻确认 |
244
+ | 🔎 | **查询面** | `retrace.runningState`(host RPC)+ `GET|POST /api/plugins/retrace/runningState`(HTTP),两入口同形状 |
245
+
246
+ > 桌面说明:Electron 宿主退出时销毁窗口,页面 `beforeunload` 不会触发,宿主也未暴露
247
+ > 插件可用的退出否决点——桌面侧由运行中横幅 + dispose 提示覆盖;Web 端拦截完整生效。
248
+
249
+ ---
250
+
236
251
  ## 🗺️ 路线图
237
252
 
238
253
  **当前已具备(0.4.x):**
@@ -12,6 +12,10 @@
12
12
  import { readdirSync, accessSync } from 'node:fs'
13
13
  import { join } from 'node:path'
14
14
  import { homedir } from 'node:os'
15
+ // 官方 foldSurface:重放得与写入端完全一致的 surface nodes(replace 插 marker、
16
+ // 遮蔽移除节点 → nodes 非 seq 单调;span 计算必须用它,否则 start/end indexOf
17
+ // 会 not found/倒置 → S4/S8 拒 → 撤回死锁,ISSUE-20260907113201)。peerDep 提供。
18
+ import { foldSurface } from '@deepseek-ai/dsh-session'
15
19
 
16
20
  /** 找 DSH 会话文件路径(遍历 ~/.dsh/sessions 各工作区)。 */
17
21
  export function sessionFilePath(sessionId) {
@@ -37,24 +41,58 @@ async function readEvents(sessionId) {
37
41
  return readEventsFromFile(filePath)
38
42
  }
39
43
 
40
- /** 从指定文件路径读全量事件(测试可注入路径;生产走 readEvents 找 ~/.dsh)。 */
44
+ /** 从指定文件路径读全量事件(测试可注入路径;生产走 readEvents 找 ~/.dsh)。
45
+ * P0-6(代码 M-5):单次编辑/查询操作会连读多次(span+文件快照事实),每次全量
46
+ * zstd 解压+JSON+foldSurface(1.37M 事件秒级)→ 加**进程级短缓存**(同批次
47
+ * 内共享一次读取;以 mtime+size 校验文件未变,变了即失效,防与写入撕裂)。
48
+ * TTL 很短(300ms)只覆盖单次命令的连续读取,不缓存跨命令的陈旧数据。 */
49
+ const readCache = new Map() // filePath → { sig, events, at }
50
+ const READ_CACHE_TTL_MS = 300
41
51
  async function readEventsFromFile(filePath) {
42
52
  try {
43
53
  if (!filePath) return null
54
+ const { statSync } = await import('node:fs')
55
+ // 文件签名(mtimeMs+size):写入会改 mtime/size → 缓存自动失效
56
+ let sig = null
57
+ try {
58
+ const st = statSync(filePath)
59
+ sig = `${st.mtimeMs}:${st.size}`
60
+ } catch { sig = 'unreadable' }
61
+ const hit = readCache.get(filePath)
62
+ if (hit && hit.sig === sig && Date.now() - hit.at < READ_CACHE_TTL_MS) {
63
+ return hit.events
64
+ }
44
65
  const { loadSessionLog } = await import('dsh-log-contract')
45
66
  const log = loadSessionLog(filePath)
46
- return log.events.map((r) => r.event)
67
+ const events = log.events.map((r) => r.event)
68
+ // 只缓存有效读取(失败不缓存,下次重试)
69
+ if (Array.isArray(events) && events.length > 0) {
70
+ readCache.set(filePath, { sig, events, at: Date.now() })
71
+ // 防膨胀:超过 32 个文件清空(单命令最多几个会话文件)
72
+ if (readCache.size > 32) readCache.clear()
73
+ }
74
+ return events
47
75
  } catch { return null }
48
76
  }
49
77
 
50
78
  /**
51
79
  * 从全量事件计算遮蔽范围(业务逻辑,基于通用事件,与 DSH 无关)。
80
+ *
81
+ * 2026-09-07 修复(ISSUE-20260907113201-3f9e4f12):nodes 不再从 events 顺序收集
82
+ * (seq 递增的虚拟 nodes),改为**官方 foldSurface 重放得真实 surface nodes**——
83
+ * 官方 replace 会把 marker(新 seq)插入遮蔽范围开头、移除被遮蔽节点 → nodes 非
84
+ * seq 单调。旧算法按 seq 递增假设算 span,写入时官方 nodes 里 indexOf(start/end)
85
+ * 可能 not found(目标已被遮蔽)或倒置(startIdx>endIdx,marker 插入) → S4/S8 拒 →
86
+ * 撤回死锁(evidence 快照实测:target 710693 span[710693..711448] → index 442>441)。
87
+ * 新算法 nodes = 官方 foldSurface 结果 → span 的 start/end 与写入端完全一致,
88
+ * not found/倒置不可能;target 已被遮蔽(不在 nodes)= 返回 null(target-shadowed)。
89
+ *
52
90
  * @param {Array} events - 全量事件(通用格式)。
53
91
  * @param {number|string} target - seq 或 messageId。
54
- * @param {'round'|'tail'} [mode] - round=遮蔽目标轮;tail=遮蔽目标之后(编辑 fromScratch)。
92
+ * @param {'round'|'tail'} [mode] - round=遮蔽目标轮;tail=遮蔽目标位置之后全部(recall/fromScratch)。
55
93
  */
56
94
  export function computeSpan(events, target, mode = 'round') {
57
- if (!Array.isArray(events)) return null
95
+ if (!Array.isArray(events) || events.length === 0) return null
58
96
  let seq = typeof target === 'number' ? target : -1
59
97
  if (seq === -1) {
60
98
  for (let i = events.length - 1; i >= 0; i--) {
@@ -64,18 +102,27 @@ export function computeSpan(events, target, mode = 'round') {
64
102
  }
65
103
  }
66
104
  if (seq === -1 || !events[seq]) return null
67
- // surface 节点 = user/assistant/tool 消息(近似 surface 节点,与官方 foldSurface 一致)
68
- const nodes = []
69
- for (const ev of events) {
70
- if (ev && (ev.type === 'user/message' || ev.type === 'assistant/message' || ev.type === 'tool/result')) nodes.push(ev.seq)
105
+ // 官方 foldSurface 重放 = 与写入端一致的当前 surface(含 marker 插入效应、排除被遮蔽节点)
106
+ let nodes
107
+ try {
108
+ const folded = foldSurface(events)
109
+ nodes = folded?.nodes
110
+ } catch {
111
+ return null
71
112
  }
113
+ if (!Array.isArray(nodes) || nodes.length === 0) return null
114
+ const index = nodes.indexOf(seq)
115
+ if (index === -1) return null // 目标已被遮蔽/不在 surface → 调用方报 target-shadowed
72
116
  if (mode === 'tail') {
73
- const shadowedSeqs = nodes.filter((s) => s >= seq)
117
+ // tail:从目标所在轮首遮蔽到 surface 尾(撤回 = 移除整轮 input+output + 其后全部,
118
+ // R2 语义;目标若是轮内 assistant/tool 则回退到轮首 user,防孤立输入)。
119
+ let startPos = index
120
+ for (let i = index; i >= 0; i--) { if (isRoundBoundary(events[nodes[i]])) { startPos = i; break } }
121
+ const shadowedSeqs = nodes.slice(startPos)
74
122
  if (shadowedSeqs.length === 0) return null
75
123
  return { start: shadowedSeqs[0], end: shadowedSeqs[shadowedSeqs.length - 1], shadowedSeqs }
76
124
  }
77
- const index = nodes.indexOf(seq)
78
- if (index === -1) return null
125
+ // round:目标轮(在官方 nodes 位置序上找轮边界;marker 是 assistant/message 非轮边界)
79
126
  let startIdx = index
80
127
  for (let i = index; i >= 0; i--) { if (isRoundBoundary(events[nodes[i]])) { startIdx = i; break } }
81
128
  let endIdx = nodes.length - 1
@@ -85,6 +132,67 @@ export function computeSpan(events, target, mode = 'round') {
85
132
  return { start: span[0], end: span[span.length - 1], shadowedSeqs: span }
86
133
  }
87
134
 
135
+ /**
136
+ * 文件侧「目标同一轮的前置 user 原文」(M-1,独立审查 74e580d 后续)——regenerate
137
+ * 重发文本的唯一可靠来源。
138
+ *
139
+ * 为什么必须算在文件侧:host 内存 session.events 是窗口化/稀疏视图(带 undefined 洞),
140
+ * regenerate 在内存里「向前找最近的前置 user」会越过洞(洞里正是该轮 user)或越过被
141
+ * 遮蔽区间,选到**更早轮**的 user → 重发错文本 + marker targetSeq 指向错轮。round span
142
+ * 的起点在文件全量 events + 官方 foldSurface nodes 上就是目标同一轮的轮首 user
143
+ * (computeSpan round 模式向前找 isRoundBoundary 的落点),从该点单点取原文不可能跨轮。
144
+ *
145
+ * 纯函数(零平台 import)。
146
+ * @returns {{seq: number, text: string}|null} span 起点非轮边界 user(孤儿回复:向前
147
+ * 找不到 user;或非 round 模式)→ null(调用方保守处理:绝不重发更早轮的文本)。
148
+ */
149
+ export function roundPromptOf(events, span, mode = 'round') {
150
+ if (mode !== 'round' || !span || !Array.isArray(events)) return null
151
+ const seq = span.start
152
+ if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0) return null
153
+ const event = events[seq]
154
+ if (!isRoundBoundary(event)) return null
155
+ const content = event.data?.content
156
+ const text = Array.isArray(content)
157
+ ? content.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('')
158
+ : ''
159
+ return { seq, text }
160
+ }
161
+
162
+ /**
163
+ * computeSpan + 文件快照事实(comm-200):一次遍历同时给出遮蔽 span 与
164
+ * 「文件快照已知最大 seq / 目标在快照里的 seq」。span 为 null 时,调用方
165
+ * (withFileSpan/http.js)把这些事实注入 args.spanFacts,host-core 据此区分:
166
+ * - 目标 seq > 文件快照最大 seq → 文件尚未 flush 该消息(提交中)→ message-pending;
167
+ * - 快照已覆盖目标位置却不在其 surface(span null)→ 真被遮蔽 → target-shadowed。
168
+ * 纯函数(与 computeSpan 同构,平台无关;零平台 import)。
169
+ *
170
+ * M-1(独立审查 74e580d 后续):同时带回 prompt = round span 起点 user 的原文
171
+ * (roundPromptOf)。span 命中时调用方把它注入 args.regeneratePrompt —— regenerate
172
+ * 的重发文本/ marker targetSeq 一律取自文件侧,不再直扫 host 稀疏 events
173
+ * (直扫会越过洞选中更早轮 user,重发错文本)。
174
+ *
175
+ * @returns {{span: object|null, facts: {fileMaxSeq: number, targetSeq: number},
176
+ * prompt: {seq: number, text: string}|null}}
177
+ */
178
+ export function computeSpanProbe(events, target, mode = 'round', opts = {}) {
179
+ const span = computeSpan(events, target, mode, opts)
180
+ let targetSeq = typeof target === 'number' ? target : -1
181
+ let fileMaxSeq = -1
182
+ if (Array.isArray(events)) {
183
+ for (let i = events.length - 1; i >= 0; i--) {
184
+ const ev = events[i]
185
+ if (!ev) continue
186
+ if (typeof ev.seq === 'number' && ev.seq > fileMaxSeq) fileMaxSeq = ev.seq
187
+ if (targetSeq === -1) {
188
+ const id = ev?.type === 'user/message' ? ev.data?.id : ev?.type === 'assistant/message' ? ev.data?.message?.id : undefined
189
+ if (typeof id === 'string' && id === target) targetSeq = ev.seq
190
+ }
191
+ }
192
+ }
193
+ return { span, facts: { fileMaxSeq, targetSeq: targetSeq >= 0 ? targetSeq : -1 }, prompt: roundPromptOf(events, span, mode) }
194
+ }
195
+
88
196
  /**
89
197
  * DSH 适配器:EventReader 实现。
90
198
  * 从文件读全量事件(可靠事实),并提供基于它的遮蔽计算。
@@ -96,6 +204,17 @@ export const dshAdapter = {
96
204
  const events = await readEvents(sessionId)
97
205
  return computeSpan(events, target, mode)
98
206
  },
207
+ /**
208
+ * 便捷:读事件一次,同时给出 span 与文件快照事实(comm-200)。span 为 null 时
209
+ * 调用方(withFileSpan/http.js)注入 facts → host-core 区分「提交中(message-pending)」
210
+ * 与「真被遮蔽(target-shadowed)」——文件 flush 滞后不再误报「已不在活跃对话」。
211
+ * M-1(独立审查 74e580d 后续):probe.prompt = round span 起点 user 的**文件侧原文**
212
+ * ——regenerate 的重发文本/ marker targetSeq 取自这里,不直扫 host 稀疏 events。
213
+ */
214
+ async spanProbeFromFile(sessionId, target, mode = 'round', opts = {}) {
215
+ const events = await readEvents(sessionId)
216
+ return computeSpanProbe(events, target, mode, opts)
217
+ },
99
218
  /**
100
219
  * 从文件全量事件算某 turn 内的最大 step 号(情形② marker step 分配用)。
101
220
  * 绕开 host 窗口化 session.events(稀疏内存视图可能看不到 turn 内全部 step,