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/LICENSE +21 -0
- package/README.md +149 -2
- package/README.zh-CN.md +150 -0
- package/cordis.patch.yml +15 -0
- package/package.json +59 -4
- package/src/detector.js +255 -0
- package/src/i18n.js +64 -0
- package/src/index.js +259 -0
- package/tools/clean-session.cmd +4 -0
- package/tools/clean-session.mjs +293 -0
- package/tools/scan-session.mjs +95 -0
- package/tools/zstd-log.mjs +54 -0
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,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)))
|