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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 离线扫描:用插件同一份检测器复跑 DSH 会话文件,回答「这些消息会不会被切、切在哪」。
|
|
4
|
+
* 用于调阈值:先扫一遍自己的历史会话,看命中是不是都是真循环。
|
|
5
|
+
*
|
|
6
|
+
* 用法:
|
|
7
|
+
* node tools/scan-session.mjs <会话文件或目录> [--json] [--all]
|
|
8
|
+
*
|
|
9
|
+
* - 支持纯 jsonl 和 DSH 的 zstd 压缩会话(按内容识别,需要 Node 22.15+);
|
|
10
|
+
* - 目录会递归扫描其中的 *.jsonl / *.zst / *.zstd;
|
|
11
|
+
* - 只读 `assistant/message` 的已提交内容(即模型下一轮会看到的内容);
|
|
12
|
+
* - `--all` 连没命中的消息也输出统计;`--json` 输出机器可读结果。
|
|
13
|
+
*
|
|
14
|
+
* 本脚本只导入 ../src/detector.js,不依赖 @deepseek-ai/*,普通 node 即可运行。
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import * as zlib from 'node:zlib'
|
|
19
|
+
import { detectInText } from '../src/detector.js'
|
|
20
|
+
import { decodeLog } from './zstd-log.mjs'
|
|
21
|
+
|
|
22
|
+
const args = process.argv.slice(2)
|
|
23
|
+
const asJson = args.includes('--json')
|
|
24
|
+
const showAll = args.includes('--all')
|
|
25
|
+
const targets = args.filter((a) => !a.startsWith('--'))
|
|
26
|
+
if (targets.length === 0) {
|
|
27
|
+
console.error('用法: node tools/scan-session.mjs <会话文件或目录> [--json] [--all]')
|
|
28
|
+
process.exit(2)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @param {string} p */
|
|
32
|
+
function* files(p) {
|
|
33
|
+
const st = statSync(p)
|
|
34
|
+
if (st.isDirectory()) {
|
|
35
|
+
for (const e of readdirSync(p)) yield* files(join(p, e))
|
|
36
|
+
} else if (/\.(jsonl|zst|zstd)$/.test(p)) {
|
|
37
|
+
yield p
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {string} path */
|
|
42
|
+
function readText(path) {
|
|
43
|
+
return decodeLog(readFileSync(path))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const results = []
|
|
47
|
+
let scanned = 0
|
|
48
|
+
for (const target of targets) {
|
|
49
|
+
for (const path of files(target)) {
|
|
50
|
+
let text
|
|
51
|
+
try {
|
|
52
|
+
text = readText(path)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(`跳过 ${path}: ${error.message}`)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
for (const line of text.split('\n')) {
|
|
58
|
+
if (!line.trim()) continue
|
|
59
|
+
let ev
|
|
60
|
+
try { ev = JSON.parse(line) } catch { continue }
|
|
61
|
+
if (ev?.type !== 'assistant/message') continue
|
|
62
|
+
const blocks = ev.data?.message?.content ?? []
|
|
63
|
+
for (const block of blocks) {
|
|
64
|
+
if (block.type !== 'reasoning' && block.type !== 'text') continue
|
|
65
|
+
scanned++
|
|
66
|
+
const hit = detectInText(block.type, block.text ?? '')
|
|
67
|
+
if (hit === undefined && !showAll) continue
|
|
68
|
+
results.push({
|
|
69
|
+
file: path,
|
|
70
|
+
seq: ev.seq,
|
|
71
|
+
turn: ev.data.turn,
|
|
72
|
+
step: ev.data.step,
|
|
73
|
+
kind: block.type,
|
|
74
|
+
chars: (block.text ?? '').length,
|
|
75
|
+
rule: hit?.rule ?? null,
|
|
76
|
+
repeatedChars: hit ? (block.text ?? '').length - hit.cut : 0,
|
|
77
|
+
cut: hit?.cut ?? null,
|
|
78
|
+
preview: hit ? (block.text ?? '').slice(hit.cut, hit.cut + 80).replace(/\s+/g, ' ') : '',
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (asJson) {
|
|
86
|
+
console.log(JSON.stringify({ scanned, results }, null, 2))
|
|
87
|
+
} else {
|
|
88
|
+
const hits = results.filter((r) => r.rule)
|
|
89
|
+
console.log(`扫描 ${scanned} 个 block,命中 ${hits.length} 个\n`)
|
|
90
|
+
for (const r of results) {
|
|
91
|
+
const tag = r.rule ? `${r.rule.padEnd(5)} 剪 ${r.repeatedChars}/${r.chars}` : `未命中 ${r.chars}`
|
|
92
|
+
console.log(`${r.file} seq=${r.seq} t${r.turn}s${r.step} ${r.kind.padEnd(9)} ${tag}`)
|
|
93
|
+
if (r.preview) console.log(` 从这里开始剪: ${r.preview}`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH 会话日志的 zstd 读写。
|
|
3
|
+
*
|
|
4
|
+
* session.v4.jsonl.zstd 是多帧文件:头部一帧,之后每次追加一帧。
|
|
5
|
+
* - zlib.zstdDecompressSync 只解第一帧 —— 直接用它只能读到头部一行,必须逐帧解;
|
|
6
|
+
* - DSH 列会话时只解第一帧并要求它「恰好一行头部」,否则把会话当损坏、静默隐藏 ——
|
|
7
|
+
* 写回时头部必须单独成帧。
|
|
8
|
+
*/
|
|
9
|
+
import * as zlib from 'node:zlib'
|
|
10
|
+
|
|
11
|
+
const MAGIC = 0xfd2fb528
|
|
12
|
+
const OPTS = () => ({ params: { [zlib.constants.ZSTD_c_checksumFlag]: 1 } })
|
|
13
|
+
|
|
14
|
+
/** RFC 8878 帧边界:返回从 start 开始那一帧的结束偏移。 */
|
|
15
|
+
export function frameEnd(buf, start = 0) {
|
|
16
|
+
if (buf.readUInt32LE(start) !== MAGIC) return undefined
|
|
17
|
+
const fhd = buf[start + 4]
|
|
18
|
+
const fcsFlag = fhd >> 6; const single = (fhd >> 5) & 1; const checksum = (fhd >> 2) & 1; const dictFlag = fhd & 3
|
|
19
|
+
let o = start + 5 + (single ? 0 : 1) + [0, 1, 2, 4][dictFlag] + [single ? 1 : 0, 2, 4, 8][fcsFlag]
|
|
20
|
+
for (;;) {
|
|
21
|
+
if (o + 3 > buf.length) return undefined
|
|
22
|
+
const h = buf[o] | (buf[o + 1] << 8) | (buf[o + 2] << 16)
|
|
23
|
+
o += 3 + (((h >> 1) & 3) === 1 ? 1 : h >>> 3)
|
|
24
|
+
if (h & 1) break
|
|
25
|
+
}
|
|
26
|
+
o += checksum ? 4 : 0
|
|
27
|
+
return o <= buf.length ? o : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function decodeLog(buf) {
|
|
31
|
+
if (buf.length < 4 || buf.readUInt32LE(0) !== MAGIC) return buf.toString('utf8')
|
|
32
|
+
if (typeof zlib.zstdDecompressSync !== 'function') throw new Error('需要 Node 22.15+(内置 zstd)。')
|
|
33
|
+
const parts = []
|
|
34
|
+
let o = 0
|
|
35
|
+
while (o < buf.length) {
|
|
36
|
+
const magic = buf.readUInt32LE(o)
|
|
37
|
+
if ((magic & 0xfffffff0) === 0x184d2a50) { o += 8 + buf.readUInt32LE(o + 4); continue }
|
|
38
|
+
const end = frameEnd(buf, o)
|
|
39
|
+
if (end === undefined) throw new Error(`zstd 帧不完整(偏移 ${o})`)
|
|
40
|
+
parts.push(zlib.zstdDecompressSync(buf.subarray(o, end)))
|
|
41
|
+
o = end
|
|
42
|
+
}
|
|
43
|
+
return Buffer.concat(parts).toString('utf8')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function encodeLog(text, compressed = true) {
|
|
47
|
+
if (!compressed) return Buffer.from(text, 'utf8')
|
|
48
|
+
const nl = text.indexOf('\n')
|
|
49
|
+
const head = nl < 0 ? `${text}\n` : text.slice(0, nl + 1)
|
|
50
|
+
const rest = nl < 0 ? '' : text.slice(nl + 1)
|
|
51
|
+
const frames = [zlib.zstdCompressSync(Buffer.from(head, 'utf8'), OPTS())]
|
|
52
|
+
if (rest.length) frames.push(zlib.zstdCompressSync(Buffer.from(rest, 'utf8'), OPTS()))
|
|
53
|
+
return Buffer.concat(frames)
|
|
54
|
+
}
|