dsh-retrace 0.4.18 → 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 +0 -0
- package/lib/adapter/dsh.js +115 -9
- package/lib/dynamic-host.js +15 -3
- package/lib/host-core.js +15 -3
- package/lib/http.js +3 -1
- package/lib/index.js +149 -59
- package/package.json +8 -10
package/bin/retrace.mjs
CHANGED
|
File without changes
|
package/lib/adapter/dsh.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -116,3 +139,86 @@ export const dshAdapter = {
|
|
|
116
139
|
return max
|
|
117
140
|
},
|
|
118
141
|
}
|
|
142
|
+
|
|
143
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
// 语义短码推导(2026-09-02)——工作区 createdAt 序号 + 父链,与修复线
|
|
145
|
+
// gen-session-codes.mjs 同规则(表的新鲜版,不冲突)。
|
|
146
|
+
// 短码 = 工作区2 + 序号3 + 父工作区2 + 父序号3;根父 = FF000。
|
|
147
|
+
// 只读会话文件帧1(header),全量 ~110 会话 ≈ 30ms,懒加载缓存。
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/** 工作区目录名 → 2 位缩写(与 gen-session-codes.mjs 同逻辑: --Users-maxwell-opena-- → op)。 */
|
|
151
|
+
function workspaceAbbr(workspace) {
|
|
152
|
+
const name = String(workspace).replace('--Users-maxwell-', '').replace(/--$/, '')
|
|
153
|
+
const parts = name.split('-').filter(Boolean)
|
|
154
|
+
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toLowerCase()
|
|
155
|
+
return name.slice(0, 2).toLowerCase()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 派生短码表:扫全部工作区会话 header,按 createdAt 排序编号,含父链。 */
|
|
159
|
+
export async function deriveBadgeTable() {
|
|
160
|
+
const { readSessionHeader } = await import('dsh-log-contract')
|
|
161
|
+
const root = join(homedir(), '.dsh', 'sessions')
|
|
162
|
+
const rows = [] // { id, ws, createdAt, parent }
|
|
163
|
+
for (const workspace of readdirSync(root)) {
|
|
164
|
+
const wsDir = join(root, workspace)
|
|
165
|
+
let sids
|
|
166
|
+
try { sids = readdirSync(wsDir) } catch { continue }
|
|
167
|
+
for (const sid of sids) {
|
|
168
|
+
const header = readSessionHeader(join(wsDir, sid, 'session.jsonl.zstd'))
|
|
169
|
+
if (!header || typeof header.id !== 'string') continue
|
|
170
|
+
rows.push({
|
|
171
|
+
id: header.id,
|
|
172
|
+
ws: workspaceAbbr(workspace),
|
|
173
|
+
createdAt: header.createdAt ?? 0,
|
|
174
|
+
parent: typeof header.parentSession === 'string' ? header.parentSession : null,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// 工作区内按 createdAt 排序 → 序号(与 gen-session-codes 一致)
|
|
179
|
+
const byWs = {}
|
|
180
|
+
for (const r of rows) (byWs[r.ws] ??= []).push(r)
|
|
181
|
+
const seqOf = new Map() // sessionId → { ws, seq }
|
|
182
|
+
for (const ws of Object.keys(byWs)) {
|
|
183
|
+
byWs[ws].sort((a, b) => a.createdAt - b.createdAt)
|
|
184
|
+
byWs[ws].forEach((r, i) => seqOf.set(r.id, { ws, seq: i + 1 }))
|
|
185
|
+
}
|
|
186
|
+
const WS = 2
|
|
187
|
+
const SEQ = 3
|
|
188
|
+
const pad = (n) => String(n).padStart(SEQ, '0')
|
|
189
|
+
const codes = {}
|
|
190
|
+
for (const r of rows) {
|
|
191
|
+
const self = seqOf.get(r.id)
|
|
192
|
+
if (!self) continue
|
|
193
|
+
const selfCode = `${self.ws}${pad(self.seq)}`
|
|
194
|
+
const parentInfo = r.parent ? seqOf.get(r.parent) : null
|
|
195
|
+
codes[r.id] = parentInfo
|
|
196
|
+
? `${selfCode}${parentInfo.ws}${pad(parentInfo.seq)}`
|
|
197
|
+
: `${selfCode}FF${'0'.repeat(SEQ)}`
|
|
198
|
+
}
|
|
199
|
+
return codes
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let deriveCache = null
|
|
203
|
+
let derivePromise = null
|
|
204
|
+
|
|
205
|
+
/** 语义短码(推导):懒加载缓存;会话不在表(如已删/新加入但未扫)返回 null。 */
|
|
206
|
+
export async function semanticBadgeOf(sessionId) {
|
|
207
|
+
const id = String(sessionId ?? '')
|
|
208
|
+
if (deriveCache !== null) return deriveCache[id] ?? null
|
|
209
|
+
derivePromise ??= deriveBadgeTable().then((codes) => {
|
|
210
|
+
deriveCache = codes
|
|
211
|
+
return codes
|
|
212
|
+
}).catch(() => {
|
|
213
|
+
deriveCache = {}
|
|
214
|
+
return deriveCache
|
|
215
|
+
})
|
|
216
|
+
const codes = await derivePromise
|
|
217
|
+
return codes[id] ?? null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 刷新推导缓存(新会话创建/删除后调用,后台)。 */
|
|
221
|
+
export function invalidateBadgeDerive() {
|
|
222
|
+
deriveCache = null
|
|
223
|
+
derivePromise = null
|
|
224
|
+
}
|
package/lib/dynamic-host.js
CHANGED
|
@@ -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
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
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
|
@@ -27,7 +27,7 @@ import { createMarkerGuard } from './prewrite-guard.js'
|
|
|
27
27
|
import { createWatchdog } from './watchdog.js'
|
|
28
28
|
import { attachExitWarning } from './interrupt-guard.js'
|
|
29
29
|
import { sessionBadge as fnvBadge } from './badge.js'
|
|
30
|
-
import { dshAdapter } from './adapter/dsh.js'
|
|
30
|
+
import { dshAdapter, semanticBadgeOf } from './adapter/dsh.js'
|
|
31
31
|
import { createDshMarkerWriter } from './adapter/dsh-writer.js'
|
|
32
32
|
import { readFileSync, readdirSync, accessSync } from 'node:fs'
|
|
33
33
|
import { join } from 'node:path'
|
|
@@ -43,7 +43,31 @@ let badgeTable = null
|
|
|
43
43
|
try {
|
|
44
44
|
badgeTable = JSON.parse(readFileSync(BADGE_TABLE_PATH, 'utf8')).codes ?? null
|
|
45
45
|
} catch { /* 表缺失时 FNV 兜底 */ }
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* 会话短码(2026-09-02 v2,三级)——解决"新 fork 会话不在短码表只有 FNV 兜底":
|
|
48
|
+
* ① archive 短码表(修复线维护,固定);
|
|
49
|
+
* ② 实时推导(工作区 createdAt 序号+父链,与 gen-session-codes.mjs 同规则,
|
|
50
|
+
* 表的新鲜超集——1f4d986e 这类新会话也能得 op071op065);
|
|
51
|
+
* ③ FNV-1a 确定性兜底(任何环境可复现,人机交互最低保障)。
|
|
52
|
+
* ②③ 是 async(推导需扫 header);同步调用点(forkmap 等)用 syncBadge。
|
|
53
|
+
*/
|
|
54
|
+
let deriveBooted = false
|
|
55
|
+
async function sessionBadge(sessionId) {
|
|
56
|
+
const semantic = badgeTable?.[sessionId]
|
|
57
|
+
if (typeof semantic === 'string' && semantic.length > 0) return semantic
|
|
58
|
+
if (!deriveBooted) {
|
|
59
|
+
deriveBooted = true
|
|
60
|
+
const derived = await semanticBadgeOf(sessionId)
|
|
61
|
+
if (derived) return derived
|
|
62
|
+
} else {
|
|
63
|
+
const derived = await semanticBadgeOf(sessionId)
|
|
64
|
+
if (derived) return derived
|
|
65
|
+
}
|
|
66
|
+
return fnvBadge(sessionId)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 同步短码(表 + FNV;推导留给异步调用点)。 */
|
|
70
|
+
function syncBadge(sessionId) {
|
|
47
71
|
const semantic = badgeTable?.[sessionId]
|
|
48
72
|
if (typeof semantic === 'string' && semantic.length > 0) return semantic
|
|
49
73
|
return fnvBadge(sessionId)
|
|
@@ -114,27 +138,46 @@ export function apply(ctx) {
|
|
|
114
138
|
* mode='round' 遮蔽目标轮;mode='tail' 遮蔽目标之后(编辑 fromScratch)。
|
|
115
139
|
*/
|
|
116
140
|
// spanFromFile 从 lib/file-span.js 引入(读文件全量算遮蔽)
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
141
|
+
// 标题固定 + 短码(2026-09-02 v2):任何 retrace 操作发生 = 会话活跃,
|
|
142
|
+
// 用**官方 sessionTitle.rename** 写 `[短码] 原标题`(user source title)→
|
|
143
|
+
// 官方自动改名被永久关闭(onUserMessage 见 user source 不再生成)+ 侧边栏/
|
|
144
|
+
// 标题栏投影立即刷新。不再用 session.append(append 不更新 title 投影,
|
|
145
|
+
// 且 0.4.17 前从未让用户看到短码——只依赖 ForkView 打开的旧路已弃)。
|
|
146
|
+
async function ensureBadgeTitle(sessionId) {
|
|
120
147
|
try {
|
|
121
|
-
const
|
|
148
|
+
const sid = String(sessionId ?? '')
|
|
149
|
+
const session = ctx.sessions?.get?.(sid)
|
|
122
150
|
if (!session) return
|
|
123
|
-
const badge = sessionBadge(
|
|
151
|
+
const badge = await sessionBadge(sid)
|
|
124
152
|
if (!badge) return
|
|
125
153
|
let currentTitle = ''
|
|
126
154
|
for (let i = session.events.length - 1; i >= 0; i--) {
|
|
127
155
|
if (session.events[i]?.type === 'session/title') { currentTitle = session.events[i].data?.title ?? ''; break }
|
|
128
156
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
157
|
+
// 无标题时用 cwd 基名(比 sessionId 前 16 位可读)
|
|
158
|
+
if (!currentTitle) {
|
|
159
|
+
const cwd = session.header?.cwd ?? ''
|
|
160
|
+
currentTitle = cwd.split('/').filter(Boolean).pop() || String(sid).slice(0, 16)
|
|
161
|
+
}
|
|
162
|
+
// 去掉旧版可能已拼的任意 [xxx] 前缀(只去 badge 本身避免误删内容)
|
|
163
|
+
const clean = currentTitle.replace(/^\[[a-z0-9]{6,}\]\s*/, '')
|
|
164
|
+
const tagged = `[${badge}] ${clean}`
|
|
165
|
+
if (currentTitle === tagged || currentTitle.startsWith(`[${badge}] `)) return
|
|
166
|
+
pinTitle(session, tagged)
|
|
133
167
|
} catch { /* 标题写入失败不影响操作 */ }
|
|
134
168
|
}
|
|
169
|
+
/** 官方 rename pin:user source title → 关闭自动改名 + 投影刷新。 */
|
|
170
|
+
function pinTitle(session, title) {
|
|
171
|
+
const titles = ctx.get?.('sessionTitle')
|
|
172
|
+
if (session && titles && typeof titles.rename === 'function') {
|
|
173
|
+
try { titles.rename(session, title); return true } catch { /* fallback append */ }
|
|
174
|
+
}
|
|
175
|
+
session?.append?.('session/title', { title, source: { kind: 'user' } })
|
|
176
|
+
return true
|
|
177
|
+
}
|
|
135
178
|
// 2026-09-01:操作前从文件算遮蔽(绕开 host 稀疏 session.events,用户方案)
|
|
136
179
|
const withFileSpan = (op, modeOf) => async (args) => {
|
|
137
|
-
ensureBadgeTitle(args?.sessionId)
|
|
180
|
+
await ensureBadgeTitle(args?.sessionId)
|
|
138
181
|
// 情形② step 号:writer 的 readMaxStep 已在装配时注入(index.js createDshMarkerWriter
|
|
139
182
|
// 闭包,从文件全量算)——不再经 args 传递(3e2262a 下沉后 writer 是唯一消费方)。
|
|
140
183
|
try {
|
|
@@ -149,24 +192,106 @@ export function apply(ctx) {
|
|
|
149
192
|
} catch (error) { log(`retrace: spanFromFile wrapper error: ${String(error).slice(0, 120)}`) }
|
|
150
193
|
return api[op](args)
|
|
151
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* 批量 pin 全部已驻留会话(2026-09-02 v2):用官方 rename 写 `[短码] 原标题`。
|
|
197
|
+
* 启动时/新会话驻留时调用——用户打开 DSH 侧边栏即可看到短码,不依赖
|
|
198
|
+
* 打开 ForkView 或先编辑。返回结果数组。
|
|
199
|
+
*/
|
|
200
|
+
async function pinAllResident() {
|
|
201
|
+
const results = []
|
|
202
|
+
const sessionStore = ctx.sessions
|
|
203
|
+
if (!sessionStore || (typeof sessionStore.list !== 'function' && typeof sessionStore.values !== 'function')) {
|
|
204
|
+
return { ok: false, error: { code: 'unavailable', message: 'session store not enumerable' } }
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const sessions = typeof sessionStore.list === 'function' ? sessionStore.list() : [...sessionStore.values()]
|
|
208
|
+
for (const session of sessions) {
|
|
209
|
+
const sessionId = session?.id ?? session?.sessionId
|
|
210
|
+
if (!sessionId) continue
|
|
211
|
+
try {
|
|
212
|
+
const badge = await sessionBadge(sessionId)
|
|
213
|
+
if (!badge) continue
|
|
214
|
+
let currentTitle = ''
|
|
215
|
+
for (let i = session.events.length - 1; i >= 0; i--) {
|
|
216
|
+
if (session.events[i]?.type === 'session/title') {
|
|
217
|
+
currentTitle = session.events[i].data?.title ?? ''
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!currentTitle) {
|
|
222
|
+
const cwd = session.header?.cwd ?? ''
|
|
223
|
+
currentTitle = cwd.split('/').filter(Boolean).pop() || sessionId.slice(0, 16)
|
|
224
|
+
}
|
|
225
|
+
const clean = currentTitle.replace(/^\[[a-z0-9]{6,}\]\s*/, '')
|
|
226
|
+
const tagged = `[${badge}] ${clean}`
|
|
227
|
+
if (currentTitle === tagged || currentTitle.startsWith(`[${badge}] `)) {
|
|
228
|
+
results.push({ sessionId, badge, alreadyTagged: true })
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
pinTitle(session, tagged)
|
|
232
|
+
results.push({ sessionId, badge, alreadyTagged: false })
|
|
233
|
+
} catch (error) {
|
|
234
|
+
results.push({ sessionId, badge: null, error: String(error) })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, value: { total: results.length, results } }
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return { ok: false, error: { code: 'internal', message: String(error) } }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
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
|
+
}
|
|
254
|
+
const sessionStore = ctx.sessions
|
|
255
|
+
const hasSessions = sessionStore && (
|
|
256
|
+
(typeof sessionStore.list === 'function' && sessionStore.list().length > 0) ||
|
|
257
|
+
(typeof sessionStore.values === 'function' && sessionStore.values().length > 0)
|
|
258
|
+
)
|
|
259
|
+
if (!hasSessions) {
|
|
260
|
+
schedule()
|
|
261
|
+
return
|
|
262
|
+
}
|
|
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
|
+
})
|
|
272
|
+
}
|
|
273
|
+
bootPin(0)
|
|
152
274
|
const disposers = [
|
|
153
|
-
|
|
275
|
+
// 2026-09-07:recall 语义 = 遮蔽目标轮及之后全部(编辑=从此处分叉,bfb965e4/
|
|
276
|
+
// 3f9e4f12 issue 要求)——不再只遮蔽目标轮(缺陷①断层);tail 在官方 foldSurface
|
|
277
|
+
// nodes 上按位置切(ISSUE-20260907113201 修复),大范围遮蔽由快照点守卫引导分支。
|
|
278
|
+
harness.handle('retrace.recall', withFileSpan('recall', () => 'tail')),
|
|
154
279
|
harness.handle('retrace.editAndResend', withFileSpan('editAndResend', (a) => (a?.fromScratch ? 'tail' : 'round'))),
|
|
155
280
|
harness.handle('retrace.regenerate', withFileSpan('regenerate', () => 'round')),
|
|
156
281
|
// 会话短码(2026-08-31):session id → 确定性 10 位短码,人机交互识别用。
|
|
157
|
-
harness.handle('retrace.sessionBadge', (args) => {
|
|
282
|
+
harness.handle('retrace.sessionBadge', async (args) => {
|
|
158
283
|
const sessionId = String(args?.sessionId ?? '')
|
|
159
|
-
return { ok: true, value: { sessionId, badge: sessionBadge(sessionId) } }
|
|
284
|
+
return { ok: true, value: { sessionId, badge: await sessionBadge(sessionId) } }
|
|
160
285
|
}),
|
|
161
286
|
// 会话短码写入标题(2026-09-01):给会话 title 组装「[短码]原标题」,
|
|
162
287
|
// 不改标题内容本身,只在标题前加短码标识。
|
|
163
|
-
harness.handle('retrace.setBadgeTitle', (args) => {
|
|
288
|
+
harness.handle('retrace.setBadgeTitle', async (args) => {
|
|
164
289
|
const sessionId = String(args?.sessionId ?? '')
|
|
165
290
|
if (!sessionId) return { ok: false, error: { code: 'bad-request', message: 'sessionId required' } }
|
|
166
291
|
const session = ctx.sessions?.get?.(sessionId)
|
|
167
292
|
if (!session) return { ok: false, error: { code: 'not-found', message: 'session not found' } }
|
|
168
293
|
try {
|
|
169
|
-
const badge = sessionBadge(sessionId)
|
|
294
|
+
const badge = await sessionBadge(sessionId)
|
|
170
295
|
// 读当前 title(最后一个 session/title 事件或 header.title)
|
|
171
296
|
let currentTitle = ''
|
|
172
297
|
for (let i = session.events.length - 1; i >= 0; i--) {
|
|
@@ -179,8 +304,9 @@ export function apply(ctx) {
|
|
|
179
304
|
// 检查是否已含短码(避免重复写入)
|
|
180
305
|
const tagged = `[${badge}] `
|
|
181
306
|
if (currentTitle.startsWith(tagged)) return { ok: true, value: { title: currentTitle, badge, alreadyTagged: true } }
|
|
182
|
-
const
|
|
183
|
-
|
|
307
|
+
const clean = currentTitle.replace(/^\[[a-z0-9]{6,}\]\s*/, '')
|
|
308
|
+
const newTitle = tagged + clean
|
|
309
|
+
pinTitle(session, newTitle)
|
|
184
310
|
return { ok: true, value: { title: newTitle, badge, alreadyTagged: false } }
|
|
185
311
|
} catch (error) {
|
|
186
312
|
return { ok: false, error: { code: 'internal', message: String(error) } }
|
|
@@ -188,7 +314,7 @@ export function apply(ctx) {
|
|
|
188
314
|
}),
|
|
189
315
|
// 用户自定义会话名称(2026-09-01):`[短码] 用户输入` 写入 user source title。
|
|
190
316
|
// 官方语义:user source title 固定会话名,后续自动生成(title-llm)被禁用。
|
|
191
|
-
harness.handle('retrace.setUserTitle', (args) => {
|
|
317
|
+
harness.handle('retrace.setUserTitle', async (args) => {
|
|
192
318
|
const sessionId = String(args?.sessionId ?? '')
|
|
193
319
|
const title = String(args?.title ?? '').trim()
|
|
194
320
|
if (!sessionId) return { ok: false, error: { code: 'bad-request', message: 'sessionId required' } }
|
|
@@ -196,52 +322,16 @@ export function apply(ctx) {
|
|
|
196
322
|
const session = ctx.sessions?.get?.(sessionId)
|
|
197
323
|
if (!session) return { ok: false, error: { code: 'not-found', message: 'session not found' } }
|
|
198
324
|
try {
|
|
199
|
-
const badge = sessionBadge(sessionId)
|
|
325
|
+
const badge = await sessionBadge(sessionId)
|
|
200
326
|
// 用户输入已含短码则直接用,否则拼接
|
|
201
327
|
const tagged = title.startsWith(`[${badge}]`) ? title : `[${badge}] ${title}`
|
|
202
|
-
|
|
328
|
+
pinTitle(session, tagged)
|
|
203
329
|
return { ok: true, value: { title: tagged, badge } }
|
|
204
330
|
} catch (error) {
|
|
205
331
|
return { ok: false, error: { code: 'internal', message: String(error) } }
|
|
206
332
|
}
|
|
207
333
|
}),
|
|
208
|
-
|
|
209
|
-
// 用户体感:侧边栏/对话标题直接显示 `[短码] 名称`,不用进分叉图才看到。
|
|
210
|
-
harness.handle('retrace.initBadgeTitles', () => {
|
|
211
|
-
const results = []
|
|
212
|
-
const sessionStore = ctx.sessions
|
|
213
|
-
if (!sessionStore || typeof sessionStore.list !== 'function' && typeof sessionStore.values !== 'function') {
|
|
214
|
-
return { ok: false, error: { code: 'unavailable', message: 'session store not enumerable' } }
|
|
215
|
-
}
|
|
216
|
-
try {
|
|
217
|
-
const sessions = typeof sessionStore.list === 'function' ? sessionStore.list() : [...sessionStore.values()]
|
|
218
|
-
for (const session of sessions) {
|
|
219
|
-
const sessionId = session?.id ?? session?.sessionId
|
|
220
|
-
if (!sessionId) continue
|
|
221
|
-
try {
|
|
222
|
-
const badge = sessionBadge(sessionId)
|
|
223
|
-
if (!badge) continue
|
|
224
|
-
let currentTitle = ''
|
|
225
|
-
for (let i = session.events.length - 1; i >= 0; i--) {
|
|
226
|
-
if (session.events[i]?.type === 'session/title') {
|
|
227
|
-
currentTitle = session.events[i].data?.title ?? ''
|
|
228
|
-
break
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
if (!currentTitle) currentTitle = sessionId.slice(0, 16)
|
|
232
|
-
const tagged = `[${badge}] `
|
|
233
|
-
if (currentTitle.startsWith(tagged)) { results.push({ sessionId, badge, alreadyTagged: true }); continue }
|
|
234
|
-
session.append('session/title', { title: tagged + currentTitle, source: { kind: 'user' } })
|
|
235
|
-
results.push({ sessionId, badge, alreadyTagged: false })
|
|
236
|
-
} catch (error) {
|
|
237
|
-
results.push({ sessionId, badge: null, error: String(error) })
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return { ok: true, value: { total: results.length, results } }
|
|
241
|
-
} catch (error) {
|
|
242
|
-
return { ok: false, error: { code: 'internal', message: String(error) } }
|
|
243
|
-
}
|
|
244
|
-
}),
|
|
334
|
+
harness.handle('retrace.initBadgeTitles', () => pinAllResident()),
|
|
245
335
|
]
|
|
246
336
|
return () => disposers.forEach((dispose) => dispose())
|
|
247
337
|
})()
|
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.
|
|
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"
|
|
@@ -85,7 +78,7 @@
|
|
|
85
78
|
"react": "^18.2.0"
|
|
86
79
|
},
|
|
87
80
|
"dependencies": {
|
|
88
|
-
"dsh-log-contract": "^0.3.
|
|
81
|
+
"dsh-log-contract": "^0.3.10",
|
|
89
82
|
"zod": "^4.4.3"
|
|
90
83
|
},
|
|
91
84
|
"repository": {
|
|
@@ -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
|
+
}
|