dsh-retrace 0.4.19 → 0.4.20

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/bin/retrace.mjs CHANGED
File without changes
@@ -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) {
@@ -49,12 +53,22 @@ async function readEventsFromFile(filePath) {
49
53
 
50
54
  /**
51
55
  * 从全量事件计算遮蔽范围(业务逻辑,基于通用事件,与 DSH 无关)。
56
+ *
57
+ * 2026-09-07 修复(ISSUE-20260907113201-3f9e4f12):nodes 不再从 events 顺序收集
58
+ * (seq 递增的虚拟 nodes),改为**官方 foldSurface 重放得真实 surface nodes**——
59
+ * 官方 replace 会把 marker(新 seq)插入遮蔽范围开头、移除被遮蔽节点 → nodes 非
60
+ * seq 单调。旧算法按 seq 递增假设算 span,写入时官方 nodes 里 indexOf(start/end)
61
+ * 可能 not found(目标已被遮蔽)或倒置(startIdx>endIdx,marker 插入) → S4/S8 拒 →
62
+ * 撤回死锁(evidence 快照实测:target 710693 span[710693..711448] → index 442>441)。
63
+ * 新算法 nodes = 官方 foldSurface 结果 → span 的 start/end 与写入端完全一致,
64
+ * not found/倒置不可能;target 已被遮蔽(不在 nodes)= 返回 null(target-shadowed)。
65
+ *
52
66
  * @param {Array} events - 全量事件(通用格式)。
53
67
  * @param {number|string} target - seq 或 messageId。
54
- * @param {'round'|'tail'} [mode] - round=遮蔽目标轮;tail=遮蔽目标之后(编辑 fromScratch)。
68
+ * @param {'round'|'tail'} [mode] - round=遮蔽目标轮;tail=遮蔽目标位置之后全部(recall/fromScratch)。
55
69
  */
56
70
  export function computeSpan(events, target, mode = 'round') {
57
- if (!Array.isArray(events)) return null
71
+ if (!Array.isArray(events) || events.length === 0) return null
58
72
  let seq = typeof target === 'number' ? target : -1
59
73
  if (seq === -1) {
60
74
  for (let i = events.length - 1; i >= 0; i--) {
@@ -64,18 +78,27 @@ export function computeSpan(events, target, mode = 'round') {
64
78
  }
65
79
  }
66
80
  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)
81
+ // 官方 foldSurface 重放 = 与写入端一致的当前 surface(含 marker 插入效应、排除被遮蔽节点)
82
+ let nodes
83
+ try {
84
+ const folded = foldSurface(events)
85
+ nodes = folded?.nodes
86
+ } catch {
87
+ return null
71
88
  }
89
+ if (!Array.isArray(nodes) || nodes.length === 0) return null
90
+ const index = nodes.indexOf(seq)
91
+ if (index === -1) return null // 目标已被遮蔽/不在 surface → 调用方报 target-shadowed
72
92
  if (mode === 'tail') {
73
- const shadowedSeqs = nodes.filter((s) => s >= seq)
93
+ // tail:从目标所在轮首遮蔽到 surface 尾(撤回 = 移除整轮 input+output + 其后全部,
94
+ // R2 语义;目标若是轮内 assistant/tool 则回退到轮首 user,防孤立输入)。
95
+ let startPos = index
96
+ for (let i = index; i >= 0; i--) { if (isRoundBoundary(events[nodes[i]])) { startPos = i; break } }
97
+ const shadowedSeqs = nodes.slice(startPos)
74
98
  if (shadowedSeqs.length === 0) return null
75
99
  return { start: shadowedSeqs[0], end: shadowedSeqs[shadowedSeqs.length - 1], shadowedSeqs }
76
100
  }
77
- const index = nodes.indexOf(seq)
78
- if (index === -1) return null
101
+ // round:目标轮(在官方 nodes 位置序上找轮边界;marker 是 assistant/message 非轮边界)
79
102
  let startIdx = index
80
103
  for (let i = index; i >= 0; i--) { if (isRoundBoundary(events[nodes[i]])) { startIdx = i; break } }
81
104
  let endIdx = nodes.length - 1
@@ -206,8 +206,17 @@ return {
206
206
  if (span.length === 0) return null
207
207
  return { start: span[0], end: span[span.length - 1], shadowedSeqs: span }
208
208
  }
209
- // tail 模式:遮蔽 startSeq 之后所有 surface 节点(不依赖稀疏 events)
210
- const shadowedSeqs = nodes.filter((s) => s >= startSeq)
209
+ // tail 模式:从目标所在轮首遮蔽到 surface 尾(撤回 = 移除整轮 input+output + 其后
210
+ // 全部;目标若是轮内 assistant/tool 则回退到轮首 user,防孤立输入)。位置连续段——
211
+ // marker 插入使 nodes 非 seq 单调,filter(s>=startSeq) 会漏/错;host 视图尽力,
212
+ // 主路径 = index.js 注入 spanFromFile 官方 foldSurface nodes,ISSUE-20260907113201。
213
+ const index = nodes.indexOf(startSeq)
214
+ if (index === -1) return null // 已被遮蔽/不在 surface → target-shadowed
215
+ let startPos = index
216
+ for (let i = index; i >= 0; i--) {
217
+ if (isRoundBoundary(events[nodes[i]])) { startPos = i; break }
218
+ }
219
+ const shadowedSeqs = nodes.slice(startPos)
211
220
  if (shadowedSeqs.length === 0) return null
212
221
  return {
213
222
  start: shadowedSeqs[0],
@@ -321,7 +330,10 @@ return {
321
330
  await ensureIdle(agents.get(sessionId))
322
331
  const seq = findMessageSeq(session, messageId)
323
332
  if (seq === -1) throw editorError('message-not-found', 'Message not found in this session.')
324
- const span = args?.span ?? roundSpanFrom(session, seq)
333
+ // 2026-09-07:撤回语义 = 遮蔽目标轮及之后全部(编辑=从此处分叉,bfb965e4/3f9e4f12
334
+ // issue)——fallback 用 tail;主路径由 index.js 注入 spanFromFile(官方 foldSurface nodes)。
335
+ // 大范围遮蔽由快照点守卫引导分支(太旧消息的正确出口)。
336
+ const span = args?.span ?? shadowSpanFrom(session, seq, { mode: 'tail' })
325
337
  if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
326
338
  const markerEvent = await writeMarker(session, span, 'recall', seq, messageTextOf(session, seq))
327
339
  await flushSafely(session)
package/lib/host-core.js CHANGED
@@ -196,8 +196,17 @@ export function createEditorApi(ctx, sessions, agents, log = () => {}, hooks = {
196
196
  if (span.length === 0) return null
197
197
  return { start: span[0], end: span[span.length - 1], shadowedSeqs: span }
198
198
  }
199
- // tail 模式:遮蔽 startSeq 之后所有 surface 节点(不依赖稀疏 events)
200
- const shadowedSeqs = nodes.filter((s) => s >= startSeq)
199
+ // tail 模式:从目标所在轮首遮蔽到 surface 尾(撤回 = 移除整轮 input+output + 其后
200
+ // 全部;目标若是轮内 assistant/tool 则回退到轮首 user,防孤立输入)。位置连续段——
201
+ // marker 插入使 nodes 非 seq 单调,filter(s>=startSeq) 会漏/错;host 视图尽力,
202
+ // 主路径 = index.js 注入 spanFromFile 官方 foldSurface nodes,ISSUE-20260907113201。
203
+ const index = nodes.indexOf(startSeq)
204
+ if (index === -1) return null // 已被遮蔽/不在 surface → target-shadowed
205
+ let startPos = index
206
+ for (let i = index; i >= 0; i--) {
207
+ if (isRoundBoundary(events[nodes[i]])) { startPos = i; break }
208
+ }
209
+ const shadowedSeqs = nodes.slice(startPos)
201
210
  if (shadowedSeqs.length === 0) return null
202
211
  return {
203
212
  start: shadowedSeqs[0],
@@ -311,7 +320,10 @@ export function createEditorApi(ctx, sessions, agents, log = () => {}, hooks = {
311
320
  await ensureIdle(agents.get(sessionId))
312
321
  const seq = findMessageSeq(session, messageId)
313
322
  if (seq === -1) throw editorError('message-not-found', 'Message not found in this session.')
314
- const span = args?.span ?? roundSpanFrom(session, seq)
323
+ // 2026-09-07:撤回语义 = 遮蔽目标轮及之后全部(编辑=从此处分叉,bfb965e4/3f9e4f12
324
+ // issue)——fallback 用 tail;主路径由 index.js 注入 spanFromFile(官方 foldSurface nodes)。
325
+ // 大范围遮蔽由快照点守卫引导分支(太旧消息的正确出口)。
326
+ const span = args?.span ?? shadowSpanFrom(session, seq, { mode: 'tail' })
315
327
  if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
316
328
  const markerEvent = await writeMarker(session, span, 'recall', seq, messageTextOf(session, seq))
317
329
  await flushSafely(session)
package/lib/http.js CHANGED
@@ -254,11 +254,13 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
254
254
  }
255
255
  // 情形② step 号:writer 的 readMaxStep 已在 index.js 装配时注入(文件全量算)。
256
256
  // 2026-09-01:编辑操作前从文件算遮蔽(绕开 host 稀疏 session.events,用户方案)
257
+ // 2026-09-09:recall mode 改 tail(遮蔽目标轮及之后全部)——与 index.js harness 入口
258
+ // 同语义(独立审查 ❌-1:HTTP 入口此前漏改仍 round,两入口分叉违反"编辑走哪个入口都生效")
257
259
  if ((op === 'recall' || op === 'editAndResend' || op === 'regenerate') && !args?.span) {
258
260
  try {
259
261
  const target = typeof args?.seq === 'number' ? args.seq : args?.messageId
260
262
  if (target !== undefined && target !== null) {
261
- const mode = op === 'editAndResend' && args?.fromScratch ? 'tail' : 'round'
263
+ const mode = op === 'recall' || (op === 'editAndResend' && args?.fromScratch) ? 'tail' : 'round'
262
264
  const span = await dshAdapter.spanFromFile(sessionId, target, mode)
263
265
  if (span) args = { ...args, span }
264
266
  }
package/lib/index.js CHANGED
@@ -239,26 +239,43 @@ export function apply(ctx) {
239
239
  return { ok: false, error: { code: 'internal', message: String(error) } }
240
240
  }
241
241
  }
242
- /** 启动批量 pin:DSH 会话渐进驻留,延迟+有限重试直到有驻留会话(避免启动空跑)。 */
243
- function bootPin(retries = 0) {
244
- if (retries >= 8) return
242
+ /**
243
+ * 启动批量 pin:DSH 会话渐进驻留,定期重试直到覆盖(幂等可重复,alreadyTagged 跳过)。
244
+ * 0.4.19 缺陷(用户实测前发现):`bootPin(99)` 被 `retries>=8` 挡住 → 30s 重跑永不执行;
245
+ * 启动 24s 窗口内会话未驻留 → 永久放弃 → 短码永不显示(全天 0 条 bootPin 日志)。
246
+ * 修法:每次(成功或失败)都安排下一次,共 MAX_ATTEMPTS 次(30s×20 ≈ 启动后 10 分钟
247
+ * 窗口,覆盖渐进驻留);错误打日志不静默。
248
+ */
249
+ function bootPin(attempts = 0) {
250
+ const MAX_ATTEMPTS = 20
251
+ const schedule = () => {
252
+ if (attempts < MAX_ATTEMPTS) setTimeout(() => bootPin(attempts + 1), 30000)
253
+ }
245
254
  const sessionStore = ctx.sessions
246
255
  const hasSessions = sessionStore && (
247
256
  (typeof sessionStore.list === 'function' && sessionStore.list().length > 0) ||
248
257
  (typeof sessionStore.values === 'function' && sessionStore.values().length > 0)
249
258
  )
250
259
  if (!hasSessions) {
251
- setTimeout(() => bootPin(retries + 1), 3000)
260
+ schedule()
252
261
  return
253
262
  }
254
- pinAllResident().then((result) => {
255
- log(`retrace: bootPin → ${result?.value?.total ?? 0} 个驻留会话标题已处理`)
256
- }).catch(() => {})
257
- setTimeout(() => bootPin(99), 30000) // 30s 后再跑一次,覆盖之后驻留的会话
263
+ pinAllResident()
264
+ .then((result) => {
265
+ log(`retrace: bootPin(${attempts}) → ${result?.value?.total ?? 0} 个驻留会话标题已处理`)
266
+ schedule()
267
+ })
268
+ .catch((error) => {
269
+ log(`retrace: bootPin error: ${String(error).slice(0, 160)}`)
270
+ schedule()
271
+ })
258
272
  }
259
273
  bootPin(0)
260
274
  const disposers = [
261
- harness.handle('retrace.recall', withFileSpan('recall', () => 'round')),
275
+ // 2026-09-07:recall 语义 = 遮蔽目标轮及之后全部(编辑=从此处分叉,bfb965e4/
276
+ // 3f9e4f12 issue 要求)——不再只遮蔽目标轮(缺陷①断层);tail 在官方 foldSurface
277
+ // nodes 上按位置切(ISSUE-20260907113201 修复),大范围遮蔽由快照点守卫引导分支。
278
+ harness.handle('retrace.recall', withFileSpan('recall', () => 'tail')),
262
279
  harness.handle('retrace.editAndResend', withFileSpan('editAndResend', (a) => (a?.fromScratch ? 'tail' : 'round'))),
263
280
  harness.handle('retrace.regenerate', withFileSpan('regenerate', () => 'round')),
264
281
  // 会话短码(2026-08-31):session id → 确定性 10 位短码,人机交互识别用。
package/package.json CHANGED
@@ -1,17 +1,10 @@
1
1
  {
2
2
  "name": "dsh-retrace",
3
3
  "description": "Retrace · 回溯 — Recall, edit-and-resend, regenerate, and conversation/artifact versioning (timeline, rollback, fork map) for DeepSeek Harness — Web and Desktop",
4
- "version": "0.4.19",
5
- "packageManager": "pnpm@11.7.0",
4
+ "version": "0.4.20",
6
5
  "type": "module",
7
6
  "main": "lib/index.js",
8
7
  "types": "lib/types/index.d.ts",
9
- "scripts": {
10
- "build": "node scripts/generate-dynamic.mjs && node scripts/build-client.mjs",
11
- "check": "node scripts/check-syntax.mjs && node scripts/check-dynamic.mjs",
12
- "test": "vitest run",
13
- "prepublishOnly": "pnpm check && pnpm build && pnpm test"
14
- },
15
8
  "exports": {
16
9
  ".": {
17
10
  "default": "./lib/index.js"
@@ -102,5 +95,10 @@
102
95
  },
103
96
  "bin": {
104
97
  "retrace": "bin/retrace.mjs"
98
+ },
99
+ "scripts": {
100
+ "build": "node scripts/generate-dynamic.mjs && node scripts/build-client.mjs",
101
+ "check": "node scripts/check-syntax.mjs && node scripts/check-dynamic.mjs",
102
+ "test": "vitest run"
105
103
  }
106
- }
104
+ }