dsh-retrace 0.4.17 → 0.4.19

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.
@@ -96,7 +96,12 @@ export function createDshMarkerWriter({ agents, validateMarker, readMaxStep, log
96
96
  ]
97
97
  wrappedAfter = [
98
98
  { type: 'step/end', data: { turn: markerTurn, step: 1 } },
99
- { type: 'turn/end', data: { turn: markerTurn } },
99
+ // 官方契约(逐字镜像 dsh-agent-loop/lib/index.js:620):turn/end 必须带
100
+ // reason.kind(completed|max-tokens|blocked|aborted|error|interrupted)——
101
+ // 缺失 = malformed → 官方 validation 拒绝 → 会话加载失败(1f4d986e 事故,
102
+ // 修复线 check-turn-end-reason.mjs 固化)。我们的信封 turn 立即完整关闭 →
103
+ // kind: 'completed'(与 agent-loop 正常完成一致)。
104
+ { type: 'turn/end', data: { turn: markerTurn, reason: { kind: 'completed' } } },
100
105
  ]
101
106
  }
102
107
  const data = {
@@ -116,3 +116,86 @@ export const dshAdapter = {
116
116
  return max
117
117
  },
118
118
  }
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ // 语义短码推导(2026-09-02)——工作区 createdAt 序号 + 父链,与修复线
122
+ // gen-session-codes.mjs 同规则(表的新鲜版,不冲突)。
123
+ // 短码 = 工作区2 + 序号3 + 父工作区2 + 父序号3;根父 = FF000。
124
+ // 只读会话文件帧1(header),全量 ~110 会话 ≈ 30ms,懒加载缓存。
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+
127
+ /** 工作区目录名 → 2 位缩写(与 gen-session-codes.mjs 同逻辑: --Users-maxwell-opena-- → op)。 */
128
+ function workspaceAbbr(workspace) {
129
+ const name = String(workspace).replace('--Users-maxwell-', '').replace(/--$/, '')
130
+ const parts = name.split('-').filter(Boolean)
131
+ if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toLowerCase()
132
+ return name.slice(0, 2).toLowerCase()
133
+ }
134
+
135
+ /** 派生短码表:扫全部工作区会话 header,按 createdAt 排序编号,含父链。 */
136
+ export async function deriveBadgeTable() {
137
+ const { readSessionHeader } = await import('dsh-log-contract')
138
+ const root = join(homedir(), '.dsh', 'sessions')
139
+ const rows = [] // { id, ws, createdAt, parent }
140
+ for (const workspace of readdirSync(root)) {
141
+ const wsDir = join(root, workspace)
142
+ let sids
143
+ try { sids = readdirSync(wsDir) } catch { continue }
144
+ for (const sid of sids) {
145
+ const header = readSessionHeader(join(wsDir, sid, 'session.jsonl.zstd'))
146
+ if (!header || typeof header.id !== 'string') continue
147
+ rows.push({
148
+ id: header.id,
149
+ ws: workspaceAbbr(workspace),
150
+ createdAt: header.createdAt ?? 0,
151
+ parent: typeof header.parentSession === 'string' ? header.parentSession : null,
152
+ })
153
+ }
154
+ }
155
+ // 工作区内按 createdAt 排序 → 序号(与 gen-session-codes 一致)
156
+ const byWs = {}
157
+ for (const r of rows) (byWs[r.ws] ??= []).push(r)
158
+ const seqOf = new Map() // sessionId → { ws, seq }
159
+ for (const ws of Object.keys(byWs)) {
160
+ byWs[ws].sort((a, b) => a.createdAt - b.createdAt)
161
+ byWs[ws].forEach((r, i) => seqOf.set(r.id, { ws, seq: i + 1 }))
162
+ }
163
+ const WS = 2
164
+ const SEQ = 3
165
+ const pad = (n) => String(n).padStart(SEQ, '0')
166
+ const codes = {}
167
+ for (const r of rows) {
168
+ const self = seqOf.get(r.id)
169
+ if (!self) continue
170
+ const selfCode = `${self.ws}${pad(self.seq)}`
171
+ const parentInfo = r.parent ? seqOf.get(r.parent) : null
172
+ codes[r.id] = parentInfo
173
+ ? `${selfCode}${parentInfo.ws}${pad(parentInfo.seq)}`
174
+ : `${selfCode}FF${'0'.repeat(SEQ)}`
175
+ }
176
+ return codes
177
+ }
178
+
179
+ let deriveCache = null
180
+ let derivePromise = null
181
+
182
+ /** 语义短码(推导):懒加载缓存;会话不在表(如已删/新加入但未扫)返回 null。 */
183
+ export async function semanticBadgeOf(sessionId) {
184
+ const id = String(sessionId ?? '')
185
+ if (deriveCache !== null) return deriveCache[id] ?? null
186
+ derivePromise ??= deriveBadgeTable().then((codes) => {
187
+ deriveCache = codes
188
+ return codes
189
+ }).catch(() => {
190
+ deriveCache = {}
191
+ return deriveCache
192
+ })
193
+ const codes = await derivePromise
194
+ return codes[id] ?? null
195
+ }
196
+
197
+ /** 刷新推导缓存(新会话创建/删除后调用,后台)。 */
198
+ export function invalidateBadgeDerive() {
199
+ deriveCache = null
200
+ derivePromise = null
201
+ }
@@ -544,7 +544,12 @@ return {
544
544
  ]
545
545
  wrappedAfter = [
546
546
  { type: 'step/end', data: { turn: markerTurn, step: 1 } },
547
- { type: 'turn/end', data: { turn: markerTurn } },
547
+ // 官方契约(逐字镜像 dsh-agent-loop/lib/index.js:620):turn/end 必须带
548
+ // reason.kind(completed|max-tokens|blocked|aborted|error|interrupted)——
549
+ // 缺失 = malformed → 官方 validation 拒绝 → 会话加载失败(1f4d986e 事故,
550
+ // 修复线 check-turn-end-reason.mjs 固化)。我们的信封 turn 立即完整关闭 →
551
+ // kind: 'completed'(与 agent-loop 正常完成一致)。
552
+ { type: 'turn/end', data: { turn: markerTurn, reason: { kind: 'completed' } } },
548
553
  ]
549
554
  }
550
555
  const data = {
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
- function sessionBadge(sessionId) {
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
- // 自动固定标题(2026-09-01):任何 retrace 操作发生 = 会话活跃,
118
- // 确保 title 带短码(用户体感:标题固定,不依赖打开 ForkView/RetraceView)。
119
- function ensureBadgeTitle(sessionId) {
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 session = ctx.sessions?.get?.(String(sessionId ?? ''))
148
+ const sid = String(sessionId ?? '')
149
+ const session = ctx.sessions?.get?.(sid)
122
150
  if (!session) return
123
- const badge = sessionBadge(String(sessionId))
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
- if (!currentTitle) currentTitle = String(sessionId).slice(0, 16)
130
- const tagged = `[${badge}] `
131
- if (currentTitle.startsWith(tagged)) return
132
- session.append('session/title', { title: tagged + currentTitle, source: { kind: 'user' } })
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,89 @@ 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
+ /** 启动批量 pin:DSH 会话渐进驻留,延迟+有限重试直到有驻留会话(避免启动空跑)。 */
243
+ function bootPin(retries = 0) {
244
+ if (retries >= 8) return
245
+ const sessionStore = ctx.sessions
246
+ const hasSessions = sessionStore && (
247
+ (typeof sessionStore.list === 'function' && sessionStore.list().length > 0) ||
248
+ (typeof sessionStore.values === 'function' && sessionStore.values().length > 0)
249
+ )
250
+ if (!hasSessions) {
251
+ setTimeout(() => bootPin(retries + 1), 3000)
252
+ return
253
+ }
254
+ pinAllResident().then((result) => {
255
+ log(`retrace: bootPin → ${result?.value?.total ?? 0} 个驻留会话标题已处理`)
256
+ }).catch(() => {})
257
+ setTimeout(() => bootPin(99), 30000) // 30s 后再跑一次,覆盖之后驻留的会话
258
+ }
259
+ bootPin(0)
152
260
  const disposers = [
153
261
  harness.handle('retrace.recall', withFileSpan('recall', () => 'round')),
154
262
  harness.handle('retrace.editAndResend', withFileSpan('editAndResend', (a) => (a?.fromScratch ? 'tail' : 'round'))),
155
263
  harness.handle('retrace.regenerate', withFileSpan('regenerate', () => 'round')),
156
264
  // 会话短码(2026-08-31):session id → 确定性 10 位短码,人机交互识别用。
157
- harness.handle('retrace.sessionBadge', (args) => {
265
+ harness.handle('retrace.sessionBadge', async (args) => {
158
266
  const sessionId = String(args?.sessionId ?? '')
159
- return { ok: true, value: { sessionId, badge: sessionBadge(sessionId) } }
267
+ return { ok: true, value: { sessionId, badge: await sessionBadge(sessionId) } }
160
268
  }),
161
269
  // 会话短码写入标题(2026-09-01):给会话 title 组装「[短码]原标题」,
162
270
  // 不改标题内容本身,只在标题前加短码标识。
163
- harness.handle('retrace.setBadgeTitle', (args) => {
271
+ harness.handle('retrace.setBadgeTitle', async (args) => {
164
272
  const sessionId = String(args?.sessionId ?? '')
165
273
  if (!sessionId) return { ok: false, error: { code: 'bad-request', message: 'sessionId required' } }
166
274
  const session = ctx.sessions?.get?.(sessionId)
167
275
  if (!session) return { ok: false, error: { code: 'not-found', message: 'session not found' } }
168
276
  try {
169
- const badge = sessionBadge(sessionId)
277
+ const badge = await sessionBadge(sessionId)
170
278
  // 读当前 title(最后一个 session/title 事件或 header.title)
171
279
  let currentTitle = ''
172
280
  for (let i = session.events.length - 1; i >= 0; i--) {
@@ -179,8 +287,9 @@ export function apply(ctx) {
179
287
  // 检查是否已含短码(避免重复写入)
180
288
  const tagged = `[${badge}] `
181
289
  if (currentTitle.startsWith(tagged)) return { ok: true, value: { title: currentTitle, badge, alreadyTagged: true } }
182
- const newTitle = tagged + currentTitle
183
- session.append('session/title', { title: newTitle, source: { kind: 'user' } })
290
+ const clean = currentTitle.replace(/^\[[a-z0-9]{6,}\]\s*/, '')
291
+ const newTitle = tagged + clean
292
+ pinTitle(session, newTitle)
184
293
  return { ok: true, value: { title: newTitle, badge, alreadyTagged: false } }
185
294
  } catch (error) {
186
295
  return { ok: false, error: { code: 'internal', message: String(error) } }
@@ -188,7 +297,7 @@ export function apply(ctx) {
188
297
  }),
189
298
  // 用户自定义会话名称(2026-09-01):`[短码] 用户输入` 写入 user source title。
190
299
  // 官方语义:user source title 固定会话名,后续自动生成(title-llm)被禁用。
191
- harness.handle('retrace.setUserTitle', (args) => {
300
+ harness.handle('retrace.setUserTitle', async (args) => {
192
301
  const sessionId = String(args?.sessionId ?? '')
193
302
  const title = String(args?.title ?? '').trim()
194
303
  if (!sessionId) return { ok: false, error: { code: 'bad-request', message: 'sessionId required' } }
@@ -196,52 +305,16 @@ export function apply(ctx) {
196
305
  const session = ctx.sessions?.get?.(sessionId)
197
306
  if (!session) return { ok: false, error: { code: 'not-found', message: 'session not found' } }
198
307
  try {
199
- const badge = sessionBadge(sessionId)
308
+ const badge = await sessionBadge(sessionId)
200
309
  // 用户输入已含短码则直接用,否则拼接
201
310
  const tagged = title.startsWith(`[${badge}]`) ? title : `[${badge}] ${title}`
202
- session.append('session/title', { title: tagged, source: { kind: 'user' } })
311
+ pinTitle(session, tagged)
203
312
  return { ok: true, value: { title: tagged, badge } }
204
313
  } catch (error) {
205
314
  return { ok: false, error: { code: 'internal', message: String(error) } }
206
315
  }
207
316
  }),
208
- // 批量初始化会话名称(2026-09-01):所有已驻留会话的 title 拼上短码。
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
- }),
317
+ harness.handle('retrace.initBadgeTitles', () => pinAllResident()),
245
318
  ]
246
319
  return () => disposers.forEach((dispose) => dispose())
247
320
  })()
package/package.json CHANGED
@@ -1,7 +1,7 @@
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.17",
4
+ "version": "0.4.19",
5
5
  "packageManager": "pnpm@11.7.0",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -85,7 +85,7 @@
85
85
  "react": "^18.2.0"
86
86
  },
87
87
  "dependencies": {
88
- "dsh-log-contract": "^0.3.7",
88
+ "dsh-log-contract": "^0.3.10",
89
89
  "zod": "^4.4.3"
90
90
  },
91
91
  "repository": {