dsh-loop-rescue 0.0.0-stage → 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/src/i18n.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 面向模型与用户的文案。默认中文;只有显式 `en` 才用英文。
3
+ * @module dsh-loop-rescue/i18n
4
+ */
5
+
6
+ const ZH = {
7
+ blockName: (kind) => (kind === 'reasoning' ? '思考' : '正文'),
8
+ marker: (kind, chars) => `\n〔已删除 ${chars} 字重复${kind === 'reasoning' ? '思考' : '输出'}〕`,
9
+ // 续跑提示只把模型送回原任务,不要求它「总结收尾」,否则它会放弃进行中的工作。
10
+ resume: (kind, chars) =>
11
+ `上一步的${kind === 'reasoning' ? '思考' : '输出'}陷入了重复(约 ${chars} 字),已被截断并从上下文中删除。`
12
+ + '不要复述刚才的内容,也不要解释发生了什么。直接从上一个有效进展继续你原本的任务,下一步就是具体行动。',
13
+ resumeSummary: (kind, chars) => `已切除 ${chars} 字重复${kind === 'reasoning' ? '思考' : '输出'}并续跑`,
14
+ stall: (n) =>
15
+ `你已连续 ${n} 次只思考、没有任何输出或工具调用。停止空转:直接执行下一步具体操作。`,
16
+ stallSummary: (n) => `连续 ${n} 次仅思考无产出,已提示继续`,
17
+ exhausted: (n) =>
18
+ `循环救援已连续触发 ${n} 次,自动续跑已暂停。请检查任务是否卡在同一处,或换个思路再继续。`,
19
+ exhaustedSummary: (n) => `连续 ${n} 次截断循环,已暂停自动续跑`,
20
+ }
21
+
22
+ const EN = {
23
+ blockName: (kind) => (kind === 'reasoning' ? 'reasoning' : 'output'),
24
+ marker: (kind, chars) => `\n[${chars} chars of repeated ${kind === 'reasoning' ? 'reasoning' : 'output'} removed]`,
25
+ resume: (kind, chars) =>
26
+ `Your previous ${kind === 'reasoning' ? 'reasoning' : 'output'} fell into repetition (~${chars} chars); `
27
+ + 'it was cut off and removed from the context. Do not restate it or explain what happened. '
28
+ + 'Continue the original task from the last real progress; your next move is a concrete action.',
29
+ resumeSummary: (kind, chars) => `removed ${chars} chars of repeated ${kind} and resumed`,
30
+ stall: (n) =>
31
+ `You have made ${n} consecutive calls that only reasoned, with no output or tool call. `
32
+ + 'Stop deliberating and take the next concrete step.',
33
+ stallSummary: (n) => `${n} reasoning-only calls in a row; nudged to continue`,
34
+ exhausted: (n) =>
35
+ `Loop rescue fired ${n} times in a row, so automatic resume is paused. `
36
+ + 'Check whether the task is stuck at the same point, or try a different approach.',
37
+ exhaustedSummary: (n) => `${n} loop cuts in a row; auto-resume paused`,
38
+ }
39
+
40
+ /**
41
+ * @param {'zh' | 'en'} lang
42
+ * @returns {typeof ZH}
43
+ */
44
+ export function strings(lang) {
45
+ return lang === 'en' ? EN : ZH
46
+ }
47
+
48
+ /**
49
+ * 读取界面语言。设置服务可选:用 `ctx.get` 读取,缺失或出错一律回退中文。
50
+ * @param {any} ctx
51
+ * @param {'auto' | 'zh' | 'en'} preference 配置里的语言
52
+ * @returns {'zh' | 'en'}
53
+ */
54
+ export function resolveLanguage(ctx, preference) {
55
+ if (preference === 'zh' || preference === 'en') return preference
56
+ try {
57
+ const settings = ctx.get?.('settings')
58
+ const section = settings?.get?.('locale')
59
+ const pref = section?.preference
60
+ return typeof pref === 'string' && pref.toLowerCase().startsWith('en') ? 'en' : 'zh'
61
+ } catch {
62
+ return 'zh'
63
+ }
64
+ }
package/src/index.js ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * dsh-loop-rescue:在模型陷入重复时切断流,把循环部分从落盘内容里剪掉,再自动续跑。
3
+ *
4
+ * ## 为什么能「剪掉」而不只是「截断」
5
+ *
6
+ * agent-loop 组装 `assistant/message` 时,每个 block 的最终内容取自 `block-end`
7
+ * 的载荷,而不是之前累计的 delta。所以在 `llm/stream` 瀑布里切断时,只要给出
8
+ * 剪过的 `block-end`,落盘的消息(下一轮发给模型的上下文)里就不会有循环部分。
9
+ * 不需要事后改写会话,也不会和会话面的 replace 规则冲突
10
+ * (`assistant/message` 不能携带 `sourceEventSeqs`,因此无法用 replace 改写)。
11
+ * 原始流仍完整保存在事件的 `stream` 记录里,轨迹视图可以看到全部内容。
12
+ *
13
+ * ## 流程
14
+ *
15
+ * 1. 包装每次 agent-loop 发起的模型调用,对每个推理/正文 block 单独跑检测器;
16
+ * 2. 命中后:闭合所有打开的 block(循环所在 block 换成剪过的内容),以 `stop` 结束流;
17
+ * 3. 在 yield 终止 chunk 之前同步拿到 `whenIdle()`,等回合真正结束后用
18
+ * `followup()` 发一条带纠正语的续跑消息(运行中发出的唤醒会被宿主丢弃);
19
+ * 4. 续跑的前 N 次请求可选地改用另一组采样参数(temperature / 推理强度);
20
+ * 5. 连续救援达到上限就停止续跑,留一条提示等用户处理。
21
+ *
22
+ * 打开的 block 里有工具调用时不切:闭合半截工具调用会让宿主执行参数不完整的调用。
23
+ *
24
+ * @module dsh-loop-rescue
25
+ */
26
+
27
+ import z from '@deepseek-ai/schemastery'
28
+ import { createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
29
+ import { DEFAULTS, StreamLoopDetector } from './detector.js'
30
+ import { resolveLanguage, strings } from './i18n.js'
31
+
32
+ export { DEFAULTS, StreamLoopDetector, detectInText, normalizeUnit } from './detector.js'
33
+
34
+ export const name = 'loop-rescue'
35
+
36
+ /** 通过 `ctx.agents.get(sessionId)` 找到会话的 Agent,必须声明,否则激活即失败。 */
37
+ export const inject = ['agents']
38
+
39
+ const detectorSchema = z.object({
40
+ poolWindow: z.natural().min(8).default(DEFAULTS.poolWindow).description('短句池:参与判定的最近句子数'),
41
+ poolMaxMeanChars: z.natural().min(8).default(DEFAULTS.poolMaxMeanChars).description('短句池:句子平均长度上限(汉字按 3 计)'),
42
+ poolMinChars: z.natural().default(DEFAULTS.poolMinChars).description('短句池:窗口总长度下限'),
43
+ poolMinRepeats: z.natural().min(2).default(DEFAULTS.poolMinRepeats).description('短句池:出现几次算反复'),
44
+ poolMinCoverage: z.number().min(0).max(1).default(DEFAULTS.poolMinCoverage).description('短句池:反复句子的占比下限'),
45
+ poolGapAllowance: z.natural().default(DEFAULTS.poolGapAllowance).description('回溯循环起点时允许夹杂的新句子数'),
46
+ cycleMinPeriod: z.natural().min(4).default(DEFAULTS.cycleMinPeriod).description('逐字周期:最短周期'),
47
+ cycleMaxPeriod: z.natural().min(16).default(DEFAULTS.cycleMaxPeriod).description('逐字周期:最长周期'),
48
+ cycleMinChars: z.natural().min(64).default(DEFAULTS.cycleMinChars).description('逐字周期:重复部分总长下限'),
49
+ cycleMinReps: z.natural().min(2).default(DEFAULTS.cycleMinReps).description('逐字周期:至少重复几遍'),
50
+ cycleCheckEvery: z.natural().min(16).default(DEFAULTS.cycleCheckEvery).description('逐字周期:检查间隔(字符)'),
51
+ })
52
+
53
+ export const Config = z.object({
54
+ mode: z.union(['rescue', 'cut', 'observe']).default('rescue')
55
+ .description('rescue:切断+剪除+自动续跑;cut:切断+剪除,不续跑;observe:只记日志'),
56
+ watch: z.union(['both', 'reasoning', 'text']).default('both').description('检测哪类内容'),
57
+ marker: z.boolean().default(true).description('在剪除处留一行标记,让模型知道这里删过东西'),
58
+ maxConsecutiveRescues: z.natural().min(1).default(3)
59
+ .description('连续救援上限;中间出现一次正常调用就清零'),
60
+ stallCalls: z.natural().default(3)
61
+ .description('连续多少次「只思考无产出」的调用后提示继续;0 关闭'),
62
+ stallMinReasoningChars: z.natural().default(1024).description('计入空转的单次推理最小长度'),
63
+ retryTemperature: z.number().min(0).max(2).description('续跑请求改用的 temperature;不填则不改'),
64
+ retryReasoningEffort: z.string().description('续跑请求改用的推理强度 ID(如 low);不填则不改'),
65
+ retryRequests: z.natural().default(1).description('续跑后多少次请求使用上面的参数'),
66
+ language: z.union(['auto', 'zh', 'en']).default('auto').description('提示语言;auto 跟随界面'),
67
+ detector: detectorSchema.default({}),
68
+ })
69
+
70
+ const SOURCE = { kind: 'loop-rescue' }
71
+
72
+ /**
73
+ * 构造一条插件来源的 notice 消息。
74
+ * @param {string} text
75
+ * @param {string} summary 聊天区折叠行显示的一句话(宿主限 120 字)
76
+ */
77
+ function notice(text, summary) {
78
+ return createUserMessage({
79
+ content: [{ type: 'text', text }],
80
+ source: { ...SOURCE, form: 'notice', summary: summary.slice(0, 120) },
81
+ })
82
+ }
83
+
84
+ /**
85
+ * @typedef {object} RescueState
86
+ * @property {number} consecutive 连续救援次数
87
+ * @property {number} stall 连续空转调用次数
88
+ * @property {number} overrides 剩余需要改采样参数的请求数
89
+ * @property {number} generation 每次救援自增;续跑前比较,判断期间是否已有新调用
90
+ */
91
+
92
+ /**
93
+ * @param {any} ctx
94
+ * @param {any} config
95
+ */
96
+ export function apply(ctx, config) {
97
+ /** @type {WeakMap<object, RescueState>} */
98
+ const states = new WeakMap()
99
+ const stateOf = (agent) => {
100
+ let s = states.get(agent)
101
+ if (s === undefined) {
102
+ s = { consecutive: 0, stall: 0, overrides: 0, generation: 0 }
103
+ states.set(agent, s)
104
+ }
105
+ return s
106
+ }
107
+ const lang = () => resolveLanguage(ctx, config.language)
108
+ const watches = (kind) => config.watch === 'both' || config.watch === kind
109
+
110
+ /**
111
+ * 回合结束后续跑。必须在 yield 终止 chunk 之前调用,拿到的 `whenIdle()` 才对应本回合。
112
+ * 期间若已有新的模型调用(用户发话、别的插件唤醒),本次续跑作废。
113
+ */
114
+ function scheduleFollowup(agent, state, message) {
115
+ if (typeof agent.whenIdle !== 'function' || typeof agent.followup !== 'function') return
116
+ const generation = state.generation
117
+ const idle = agent.whenIdle()
118
+ void Promise.resolve(idle).then(() => {
119
+ if (state.generation !== generation) return
120
+ if (agent.inbox?.hasPending) return
121
+ agent.followup(message)
122
+ ctx.logger.debug('loop-rescue: resumed after cut')
123
+ }).catch((error) => {
124
+ ctx.logger.warn(`loop-rescue: resume failed: ${String(error)}`)
125
+ })
126
+ }
127
+
128
+ ctx.on('llm/stream', (options, next) => {
129
+ if (!isAgentLoopRequest(options) || options.sessionId === undefined) return next()
130
+ const agent = ctx.agents.get(options.sessionId)
131
+ if (agent === undefined) return next()
132
+ const state = stateOf(agent)
133
+ // 新调用开始:之前排队的续跑作废。
134
+ state.generation++
135
+
136
+ return (async function* rescued() {
137
+ /** @type {Map<number, {blockType: string, text: string, detector?: StreamLoopDetector}>} */
138
+ const open = new Map()
139
+ let reasoningChars = 0
140
+ let hasOutput = false
141
+ let finished = false
142
+
143
+ for await (const chunk of next()) {
144
+ if (chunk.type === 'block-start') {
145
+ const kind = chunk.blockType
146
+ const detector = (kind === 'reasoning' || kind === 'text') && watches(kind)
147
+ ? new StreamLoopDetector(kind, config.detector)
148
+ : undefined
149
+ open.set(chunk.index, { blockType: kind, text: '', detector })
150
+ } else if (chunk.type === 'block-end') {
151
+ open.delete(chunk.index)
152
+ } else if (chunk.type === 'tool-call-delta') {
153
+ hasOutput = true
154
+ } else if (chunk.type === 'finish') {
155
+ finished = true
156
+ }
157
+
158
+ let hit
159
+ let hitBlock
160
+ if (chunk.type === 'reasoning-delta' || chunk.type === 'text-delta') {
161
+ if (chunk.type === 'text-delta') hasOutput = true
162
+ else reasoningChars += chunk.text.length
163
+ const block = open.get(chunk.index)
164
+ if (block !== undefined) {
165
+ block.text += chunk.text
166
+ hit = block.detector?.push(chunk.text)
167
+ if (hit !== undefined) hitBlock = { index: chunk.index, ...block }
168
+ }
169
+ }
170
+
171
+ if (hit === undefined) {
172
+ yield chunk
173
+ continue
174
+ }
175
+
176
+ const kind = hitBlock.blockType
177
+ ctx.logger.warn(`loop-rescue: ${kind} loop detected (rule ${hit.rule}, ${hit.repeatedChars} chars)`)
178
+ if (config.mode === 'observe') {
179
+ hitBlock.detector = undefined
180
+ const live = open.get(hitBlock.index)
181
+ if (live) live.detector = undefined
182
+ yield chunk
183
+ continue
184
+ }
185
+ // 半截工具调用不能闭合,放弃本次切断,继续透传。
186
+ if ([...open.values()].some((b) => b.blockType !== 'text' && b.blockType !== 'reasoning')) {
187
+ ctx.logger.warn('loop-rescue: a non-text block is open; not cutting')
188
+ const live = open.get(hitBlock.index)
189
+ if (live) live.detector = undefined
190
+ yield chunk
191
+ continue
192
+ }
193
+
194
+ const s = strings(lang())
195
+ state.consecutive++
196
+ state.stall = 0
197
+ // 先不 yield 当前 chunk:它的内容已在 block.text 里,剪切按累计文本进行。
198
+ const tail = []
199
+ for (const [index, block] of open) {
200
+ let text = block.text
201
+ if (index === hitBlock.index) {
202
+ text = text.slice(0, hit.cut).replace(/\s+$/, '')
203
+ if (config.marker) text += s.marker(kind, hit.repeatedChars)
204
+ }
205
+ tail.push({ type: 'block-end', index, block: { type: block.blockType, text } })
206
+ }
207
+ tail.push({ type: 'finish', reason: { kind: 'stop' } })
208
+
209
+ if (config.mode === 'rescue') {
210
+ if (state.consecutive > config.maxConsecutiveRescues) {
211
+ ctx.logger.warn(`loop-rescue: ${state.consecutive} consecutive cuts; auto-resume paused`)
212
+ agent.inject?.(notice(s.exhausted(state.consecutive), s.exhaustedSummary(state.consecutive)))
213
+ } else {
214
+ state.overrides = config.retryRequests
215
+ scheduleFollowup(agent, state,
216
+ notice(s.resume(kind, hit.repeatedChars), s.resumeSummary(kind, hit.repeatedChars)))
217
+ }
218
+ }
219
+ for (const c of tail) yield c
220
+ return
221
+ }
222
+
223
+ // 流自然结束。
224
+ if (!finished) return
225
+ if (hasOutput) {
226
+ state.consecutive = 0
227
+ state.stall = 0
228
+ return
229
+ }
230
+ // 只有推理、没有正文和工具调用:空转计数。
231
+ if (config.stallCalls > 0 && reasoningChars >= config.stallMinReasoningChars && config.mode === 'rescue') {
232
+ state.stall++
233
+ if (state.stall >= config.stallCalls) {
234
+ const s = strings(lang())
235
+ const n = state.stall
236
+ state.stall = 0
237
+ ctx.logger.warn(`loop-rescue: ${n} reasoning-only calls in a row`)
238
+ scheduleFollowup(agent, state, notice(s.stall(n), s.stallSummary(n)))
239
+ }
240
+ }
241
+ })()
242
+ })
243
+
244
+ // 续跑请求可选地换一组采样参数,打破同一前缀下的确定性复读。
245
+ ctx.on('agent/request', async (payload, next) => {
246
+ const resolved = await next()
247
+ const agent = payload?.agent
248
+ if (agent === undefined) return resolved
249
+ const state = states.get(agent)
250
+ if (state === undefined || state.overrides <= 0) return resolved
251
+ state.overrides--
252
+ const patch = {}
253
+ if (config.retryTemperature !== undefined) patch.temperature = config.retryTemperature
254
+ if (config.retryReasoningEffort) patch.reasoningEffort = config.retryReasoningEffort
255
+ return Object.keys(patch).length === 0 ? resolved : { ...resolved, ...patch }
256
+ })
257
+ }
258
+
259
+ export default { name, inject, Config, apply }
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ rem Double-click to run, or drag a session folder onto this file
3
+ node "%~dp0clean-session.mjs" %*
4
+ pause
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 离线清洗:把 DSH 会话历史里已经提交的循环截掉,让「已经嗝屁」的会话重新可用。
4
+ *
5
+ * 为什么是离线脚本:DSH 发给模型的请求是会话日志的纯投影且被冻结,插件只能读不能改;
6
+ * 所以只能在 DSH 关闭时直接改会话文件。检测规则与插件同一份(../src/detector.js),
7
+ * 只看已提交文本,没装过插件的老会话同样适用。
8
+ *
9
+ * 用法(不需要会话 ID):
10
+ * node tools/clean-session.mjs 自动找 DSH 数据目录,列出有循环的会话,按序号选
11
+ * node tools/clean-session.mjs --all 列表里也显示没有循环的会话
12
+ * node tools/clean-session.mjs --data D:\dsh-desktop\data
13
+ * node tools/clean-session.mjs <会话文件夹或文件> 直接处理这一个(可把文件夹拖到命令行)
14
+ * node tools/clean-session.mjs --undo 从备份恢复(同样按序号选)
15
+ * 选项:--dry-run 只看不改;--yes 不再确认;--force 检测到 DSH 在运行也继续(不推荐)
16
+ *
17
+ * 改动内容(只动命中的 assistant/message 事件,其余行逐字节保留):
18
+ * - message.content 里命中的 text/reasoning 块截到循环起点;
19
+ * - data.stream 里同一块的增量记录与 block-end 同步截断;
20
+ * - 删掉该消息的 source.replayState(里面存着原文副本,留着会被原样重放)。
21
+ * 原文件先备份到 <data>/sessions-loop-rescue-backup/,再以带校验的 zstd 原子写回。
22
+ *
23
+ * 需要 Node 22.15+(内置 zstd)。不依赖 @deepseek-ai/*。
24
+ */
25
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync, renameSync, copyFileSync } from 'node:fs'
26
+ import { join, resolve, dirname, basename, relative } from 'node:path'
27
+ import { homedir } from 'node:os'
28
+ import { execFileSync } from 'node:child_process'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { createInterface } from 'node:readline/promises'
31
+ import * as zlib from 'node:zlib'
32
+ import { detectInText } from '../src/detector.js'
33
+ import { decodeLog, encodeLog } from './zstd-log.mjs'
34
+
35
+ const argv = process.argv.slice(2)
36
+ const flag = (n) => argv.includes(n)
37
+ const opt = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined }
38
+ const positional = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--data')
39
+ const DRY = flag('--dry-run'), YES = flag('--yes'), FORCE = flag('--force'), ALL = flag('--all'), UNDO = flag('--undo')
40
+ const GEN = /^session\.v(\d+)\.jsonl(\.zstd)?$/
41
+ const BACKUP_DIR = 'sessions-loop-rescue-backup'
42
+
43
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
44
+ const ask = async (q) => (await rl.question(q)).trim()
45
+ const die = (m) => { console.error(m); rl.close(); process.exit(1) }
46
+
47
+ // ---------- 读写 ----------
48
+ function readSession(path) {
49
+ try { return decodeLog(readFileSync(path)) } catch (e) { die(e.message) }
50
+ }
51
+ function writeSession(path, text) {
52
+ const out = encodeLog(text, path.endsWith('.zstd'))
53
+ const tmp = `${path}.loop-rescue-tmp`
54
+ writeFileSync(tmp, out)
55
+ renameSync(tmp, path)
56
+ }
57
+ /** 会话文件夹里版本号最高的那一代日志。 */
58
+ function latestGeneration(dir) {
59
+ let best
60
+ for (const f of readdirSync(dir)) {
61
+ const m = GEN.exec(f)
62
+ if (m && (!best || Number(m[1]) > best.v)) best = { v: Number(m[1]), path: join(dir, f) }
63
+ }
64
+ return best
65
+ }
66
+
67
+ // ---------- 找数据目录 ----------
68
+ function findDataRoots() {
69
+ const given = opt('--data') ?? process.env.DSH_DATA
70
+ if (given) return [resolve(given)]
71
+ const roots = new Set()
72
+ // 1) 脚本放在数据目录里面时,向上找
73
+ for (let d = dirname(fileURLToPath(import.meta.url)); ; d = dirname(d)) {
74
+ if (existsSync(join(d, 'sessions'))) roots.add(d)
75
+ if (existsSync(join(d, 'data', 'sessions'))) roots.add(join(d, 'data'))
76
+ if (dirname(d) === d) break
77
+ }
78
+ // 2) 常见位置
79
+ const cands = []
80
+ if (process.platform === 'win32') {
81
+ for (const drive of 'CDEFG') for (const n of ['dsh-desktop', 'dsh-portable', 'dsh']) cands.push(`${drive}:\\${n}\\data`)
82
+ if (process.env.APPDATA) cands.push(join(process.env.APPDATA, 'dsh'), join(process.env.APPDATA, 'dsh-desktop'))
83
+ if (process.env.LOCALAPPDATA) cands.push(join(process.env.LOCALAPPDATA, 'dsh'), join(process.env.LOCALAPPDATA, 'dsh-desktop'))
84
+ }
85
+ cands.push(join(homedir(), '.dsh'), join(homedir(), '.config', 'dsh'), join(homedir(), '.local', 'share', 'dsh'))
86
+ for (const c of cands) if (existsSync(join(c, 'sessions'))) roots.add(resolve(c))
87
+ return [...roots]
88
+ }
89
+
90
+ // ---------- DSH 是否在运行 ----------
91
+ function dshRunning() {
92
+ try {
93
+ const out = process.platform === 'win32'
94
+ ? execFileSync('tasklist', ['/fo', 'csv', '/nh'], { encoding: 'utf8', timeout: 10000 })
95
+ : execFileSync('ps', ['-eo', 'comm='], { encoding: 'utf8', timeout: 10000 })
96
+ return out.split(/\r?\n/).map((l) => l.replace(/^"|".*$/g, '')).filter((n) => /\bdsh/i.test(n) && !/loop-rescue/i.test(n))
97
+ } catch { return [] }
98
+ }
99
+
100
+ // ---------- 分析一个会话 ----------
101
+ function analyze(path) {
102
+ const text = readSession(path)
103
+ const lines = text.split('\n')
104
+ let header, title, last = 0, loops = 0
105
+ const hits = [] // {line, blocks:[{idx, cut}]}
106
+ lines.forEach((line, li) => {
107
+ if (!line.trim()) return
108
+ let ev
109
+ try { ev = JSON.parse(line) } catch { return }
110
+ if (ev.type === 'session' && header === undefined) header = ev
111
+ if (typeof ev.time === 'number') last = Math.max(last, ev.time)
112
+ if (ev.type === 'session/title') title = ev.data?.title
113
+ if (ev.type !== 'assistant/message') return
114
+ const blocks = []
115
+ ;(ev.data?.message?.content ?? []).forEach((b, idx) => {
116
+ if (b.type !== 'text' && b.type !== 'reasoning') return
117
+ const d = detectInText(b.type, b.text ?? '')
118
+ if (d) blocks.push({ idx, cut: d.cut, removed: (b.text ?? '').length - d.cut })
119
+ })
120
+ if (blocks.length) { hits.push({ line: li, blocks }); loops += blocks.length }
121
+ })
122
+ return { path, text, lines, header, title, last, loops, hits }
123
+ }
124
+
125
+ /** 截断一条消息事件,返回新事件。 */
126
+ function trimEvent(ev, blocks) {
127
+ const content = ev.data.message.content
128
+ const cutByIndex = new Map()
129
+ for (const { idx, cut } of blocks) {
130
+ const b = content[idx]
131
+ b.text = b.text.slice(0, cut).replace(/\s+$/, '')
132
+ cutByIndex.set(idx, b.text)
133
+ }
134
+ // stream:截断对应块的增量与 block-end
135
+ if (Array.isArray(ev.data.stream)) {
136
+ const posByIdx = new Map()
137
+ // content 下标与流里的 block index 一一对应(同序),按出现顺序映射
138
+ const streamIdx = []
139
+ for (const r of ev.data.stream) {
140
+ if (r.type === 'chunk' && r.chunk?.type === 'block-start') streamIdx.push(r.chunk.index)
141
+ }
142
+ const map = new Map() // stream index -> kept text
143
+ for (const [ci, kept] of cutByIndex) if (streamIdx[ci] !== undefined) map.set(streamIdx[ci], kept)
144
+ ev.data.stream = ev.data.stream.map((r) => {
145
+ if ((r.type === 'text-chunks' || r.type === 'reasoning-chunks') && map.has(r.index)) {
146
+ const kept = map.get(r.index)
147
+ let used = posByIdx.get(r.index) ?? 0
148
+ const texts = [], dt = []
149
+ for (let i = 0; i < r.texts.length && used < kept.length; i++) {
150
+ const piece = r.texts[i].slice(0, kept.length - used)
151
+ texts.push(piece); used += piece.length
152
+ if (i > 0) dt.push(r.dt[i - 1])
153
+ }
154
+ posByIdx.set(r.index, used)
155
+ if (texts.length === 0) return null
156
+ return { ...r, texts, dt }
157
+ }
158
+ if (r.type === 'chunk' && r.chunk?.type === 'block-end' && map.has(r.chunk.index)) {
159
+ return { ...r, chunk: { ...r.chunk, block: { ...r.chunk.block, text: map.get(r.chunk.index) } } }
160
+ }
161
+ return r
162
+ }).filter(Boolean)
163
+ }
164
+ const src = ev.data.message.source
165
+ if (src && src.replayState !== undefined) {
166
+ const { replayState, ...rest } = src
167
+ ev.data.message.source = rest
168
+ }
169
+ return ev
170
+ }
171
+
172
+ // ---------- 列表与选择 ----------
173
+ function listSessions(roots) {
174
+ const out = []
175
+ for (const root of roots) {
176
+ const sdir = join(root, 'sessions')
177
+ for (const proj of safeDir(sdir)) {
178
+ const pdir = join(sdir, proj)
179
+ if (!statSync(pdir).isDirectory()) continue
180
+ for (const s of safeDir(pdir)) {
181
+ const dir = join(pdir, s)
182
+ if (!statSync(dir).isDirectory()) continue
183
+ const g = latestGeneration(dir)
184
+ if (!g) continue
185
+ try { out.push({ root, gen: g, ...analyze(g.path) }) } catch (e) { console.error(`跳过 ${dir}:${e.message}`) }
186
+ }
187
+ }
188
+ }
189
+ return out.sort((a, b) => b.last - a.last)
190
+ }
191
+ function safeDir(d) { try { return readdirSync(d) } catch { return [] } }
192
+ const fmtTime = (t) => (t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '?')
193
+
194
+ async function pick(items, render, prompt) {
195
+ items.forEach((it, i) => console.log(`${String(i + 1).padStart(3)}. ${render(it)}`))
196
+ const a = await ask(prompt)
197
+ if (a === '') return items[0]
198
+ const n = Number(a)
199
+ if (!Number.isInteger(n) || n < 1 || n > items.length) die('没有这个序号,已退出。')
200
+ return items[n - 1]
201
+ }
202
+
203
+ // ---------- 主流程 ----------
204
+ async function main() {
205
+ // 撤销
206
+ if (UNDO) {
207
+ const roots = findDataRoots()
208
+ const backups = []
209
+ for (const root of roots) collectBackups(join(root, BACKUP_DIR), root, backups)
210
+ if (!backups.length) die('没有找到备份。')
211
+ backups.sort((a, b) => b.mtime - a.mtime)
212
+ const b = await pick(backups, (x) => `${fmtTime(x.mtime)} ${x.target}`, '恢复哪个?(回车=最新)')
213
+ guardRunning()
214
+ if (!YES && (await ask(`用备份覆盖 ${b.target}?[y/N] `)).toLowerCase() !== 'y') die('已取消。')
215
+ copyFileSync(b.path, b.target)
216
+ console.log('已恢复。')
217
+ return rl.close()
218
+ }
219
+
220
+ let session
221
+ if (positional[0]) {
222
+ const p = resolve(positional[0])
223
+ const file = statSync(p).isDirectory() ? latestGeneration(p)?.path : p
224
+ if (!file) die(`${p} 里没有会话日志。`)
225
+ // 推断数据目录:…/data/sessions/<项目>/<会话>/file
226
+ const root = dirname(dirname(dirname(dirname(file))))
227
+ session = { root, gen: { path: file, v: Number(GEN.exec(basename(file))?.[1] ?? 0) }, ...analyze(file) }
228
+ } else {
229
+ const roots = findDataRoots()
230
+ if (!roots.length) die('没找到 DSH 数据目录。用 --data 指定,例如:--data D:\\dsh-desktop\\data')
231
+ console.log(`扫描:${roots.map((r) => join(r, 'sessions')).join(',')}\n`)
232
+ let list = listSessions(roots)
233
+ if (!ALL) list = list.filter((s) => s.loops > 0)
234
+ if (!list.length) { console.log(ALL ? '没有会话。' : '没有发现带循环的会话。加 --all 可看全部。'); return rl.close() }
235
+ session = await pick(list, (s) => {
236
+ const sub = s.header?.origin === 'subagent' ? '[子] ' : ''
237
+ return `${fmtTime(s.last)} ${sub}${s.title ?? '(无标题)'} — ${s.header?.cwd ?? '?'} 循环 ${s.loops} 段`
238
+ }, '\n选哪个?(回车=第一个)')
239
+ }
240
+
241
+ if (session.loops === 0) { console.log('这个会话里没有检测到循环。'); return rl.close() }
242
+ if (session.gen.v !== 4) die(`目前只支持 v4 会话,这个是 v${session.gen.v}。`)
243
+
244
+ const removed = session.hits.reduce((s, h) => s + h.blocks.reduce((t, b) => t + b.removed, 0), 0)
245
+ console.log(`\n${session.title ?? ''}\n 文件:${session.path}\n 将截断 ${session.loops} 段循环(${session.hits.length} 条消息),删除约 ${removed} 字。`)
246
+ for (const h of session.hits.slice(0, 3)) {
247
+ const ev = JSON.parse(session.lines[h.line])
248
+ const b = ev.data.message.content[h.blocks[0].idx]
249
+ console.log(` · 第 ${ev.data.turn} 轮:…${b.text.slice(Math.max(0, h.blocks[0].cut - 60), h.blocks[0].cut).replace(/\s+/g, ' ')} ✂ ${b.text.slice(h.blocks[0].cut, h.blocks[0].cut + 40).replace(/\s+/g, ' ')}…`)
250
+ }
251
+ if (DRY) { console.log('\n--dry-run:未做任何修改。'); return rl.close() }
252
+
253
+ guardRunning()
254
+ if (!YES && (await ask('\n确认修改?会先自动备份。[y/N] ')).toLowerCase() !== 'y') die('已取消。')
255
+
256
+ // 备份
257
+ const rel = relative(join(session.root, 'sessions'), session.path)
258
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
259
+ const bak = rel.startsWith('..') ? `${session.path}.${stamp}.bak` : join(session.root, BACKUP_DIR, `${rel}.${stamp}`)
260
+ mkdirSync(dirname(bak), { recursive: true })
261
+ copyFileSync(session.path, bak)
262
+
263
+ // 改写
264
+ const lines = session.lines.slice()
265
+ for (const h of session.hits) lines[h.line] = JSON.stringify(trimEvent(JSON.parse(lines[h.line]), h.blocks))
266
+ const text = lines.join('\n')
267
+ // 自检:重新读回不应再有循环、事件数不变
268
+ const check = text.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l))
269
+ if (check.length !== session.lines.filter((l) => l.trim()).length) die('自检失败:事件数变化,未写入。')
270
+ writeSession(session.path, text)
271
+ const after = analyze(session.path)
272
+ console.log(`\n完成。剩余循环 ${after.loops} 段。备份:${bak}\n撤销:node tools/clean-session.mjs --undo`)
273
+ rl.close()
274
+ }
275
+
276
+ function collectBackups(dir, root, out) {
277
+ for (const e of safeDir(dir)) {
278
+ const p = join(dir, e)
279
+ const st = statSync(p)
280
+ if (st.isDirectory()) collectBackups(p, root, out)
281
+ else {
282
+ const m = /^(.*)\.\d{4}-\d{2}-\d{2}T[\d-]+Z$/.exec(p)
283
+ if (m) out.push({ path: p, mtime: st.mtimeMs, target: join(root, 'sessions', relative(join(root, BACKUP_DIR), m[1])) })
284
+ }
285
+ }
286
+ }
287
+
288
+ function guardRunning() {
289
+ const procs = dshRunning()
290
+ if (procs.length && !FORCE) die(`检测到 DSH 正在运行(${[...new Set(procs)].join('、')})。请先完全退出 DSH 再运行;确定无关可加 --force。`)
291
+ }
292
+
293
+ main().catch((e) => die(e.stack ?? String(e)))