dsh-structured 0.0.0-stage → 0.7.4

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.
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Desktop 兼容性预检(只读)。
4
+ *
5
+ * 目标 profile 默认就是 `desktop`。这个脚本在安装前检查:
6
+ * - 插件只依赖宿主的 systemPrompt 服务(commands 可选),不使用 Electron/headless 专有 API;
7
+ * - bundle 清单、files、main/exports 能让 desktop 的 boot 层正确装配;
8
+ * - 默认开启、状态落在 $DSH_HOME,不写死 profile 名或盘符;
9
+ * - desktop profile 的 bundle 栈里确实有提供 systemPrompt/commands 的 dsh-base。
10
+ *
11
+ * 用法:
12
+ * node scripts/desktop-check.mjs
13
+ * node scripts/desktop-check.mjs --entry "C:\...\profiles\desktop\node_modules\dsh-structured"
14
+ */
15
+
16
+ import { existsSync, readFileSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { isAbsolute, join, resolve } from 'node:path'
19
+
20
+ const args = process.argv.slice(2)
21
+ let entry = process.cwd()
22
+ for (let i = 0; i < args.length; i++) {
23
+ if (args[i] === '--entry') entry = resolve(args[++i])
24
+ else if (args[i] === '--help' || args[i] === '-h') {
25
+ process.stdout.write('用法: node scripts/desktop-check.mjs [--entry <插件目录>]\n')
26
+ process.exit(0)
27
+ } else {
28
+ process.stderr.write('desktop-check: 未知参数 ' + args[i] + '\n')
29
+ process.exit(2)
30
+ }
31
+ }
32
+
33
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
34
+ const profileDir = join(home, 'profiles', 'desktop')
35
+ const rows = []
36
+ const check = (name, ok, detail = '') => rows.push({ name, ok, detail })
37
+
38
+ function readJson(file) {
39
+ return JSON.parse(readFileSync(file, 'utf8'))
40
+ }
41
+
42
+ // 1. desktop profile 的 bundle 栈
43
+ const profileManifestFile = join(profileDir, 'package.json')
44
+ let profileManifest = null
45
+ try {
46
+ profileManifest = readJson(profileManifestFile)
47
+ const bundles = profileManifest?.dsh?.profile?.bundles ?? []
48
+ check('desktop profile 已初始化', true, profileManifestFile)
49
+ check('desktop bundle 栈含 dsh-base', bundles.includes('@deepseek-ai/dsh-base'), 'dsh-base 提供 systemPrompt / commands')
50
+ check('desktop bundle 栈含 dsh-web-app', bundles.includes('@deepseek-ai/dsh-web-app'), 'desktop 的 Web 表层')
51
+ } catch {
52
+ rows.push({ name: 'desktop profile 已初始化', ok: null, detail: '未找到 ' + profileManifestFile + '(先启动一次 Desktop)' })
53
+ }
54
+
55
+ // 2. 插件清单
56
+ let pkg = null
57
+ try {
58
+ pkg = readJson(join(entry, 'package.json'))
59
+ check('插件 package.json 可读', true, entry)
60
+ } catch (error) {
61
+ check('插件 package.json 可读', false, String(error))
62
+ report()
63
+ process.exit(1)
64
+ }
65
+
66
+ const patchRel = pkg?.dsh?.bundle?.patch
67
+ check('声明 dsh.bundle.patch', typeof patchRel === 'string', patchRel ?? '缺失')
68
+ const patchFile = patchRel && isAbsolute(patchRel) ? patchRel : join(entry, patchRel ?? '')
69
+ check('cordis.patch.yml 存在', Boolean(patchRel) && existsSync(patchFile), patchFile)
70
+ const files = Array.isArray(pkg.files) ? pkg.files : []
71
+ check('files 含 cordis.patch.yml', files.includes('cordis.patch.yml'), 'npm pack 不会漏掉装配清单')
72
+ check('files 含 lib / src', files.includes('lib') && files.includes('src'), files.join(', '))
73
+ check('main 指向 lib/index.js', pkg.main === 'lib/index.js', pkg.main ?? '缺失')
74
+ check('exports . 指向 lib/index.js', pkg.exports?.['.'] === './lib/index.js', String(pkg.exports?.['.'] ?? '缺失'))
75
+ check('桌面 client 半区已声明', pkg?.dsh?.client?.platform === 'web', 'dsh.client.platform = web')
76
+ check('exports ./client 指向 lib/client.js', pkg.exports?.['./client'] === './lib/client.js', String(pkg.exports?.['./client'] ?? '缺失'))
77
+ check('engines.dsh 声明兼容范围', typeof pkg?.engines?.dsh === 'string', pkg?.engines?.dsh ?? '缺失')
78
+ check('dsh.compatibility 声明兼容版本', Boolean(pkg?.dsh?.compatibility?.dshReleases), JSON.stringify(pkg?.dsh?.compatibility?.dshReleases ?? {}))
79
+
80
+ // 3. patch 内容
81
+ let patchText = ''
82
+ try {
83
+ patchText = readFileSync(patchFile, 'utf8')
84
+ } catch {}
85
+ check('patch 含 dsh-structured 行', /id:\s*dsh-structured/.test(patchText), '')
86
+ check('patch 默认 defaultEnabled: true', /defaultEnabled:\s*true/.test(patchText), '')
87
+
88
+ // 4. 运行时入口:只允许 node: 内置模块 + 无 profile 分支
89
+ const libFile = join(entry, 'lib', 'index.js')
90
+ let libText = ''
91
+ try {
92
+ libText = readFileSync(libFile, 'utf8')
93
+ check('lib/index.js 存在', true, libFile)
94
+ } catch {
95
+ check('lib/index.js 存在', false, libFile)
96
+ }
97
+ const clientFile = join(entry, 'lib', 'client.js')
98
+ let clientText = ''
99
+ try {
100
+ clientText = readFileSync(clientFile, 'utf8')
101
+ check('lib/client.js 存在', true, clientFile)
102
+ } catch {
103
+ check('lib/client.js 存在', false, clientFile)
104
+ }
105
+ check('client 模块 id 与包名一致', /id:\s*'dsh-structured'/.test(clientText), '')
106
+ check('client 模块是纯 DOM 增强(无 React 强依赖)', !/require\(['"]react['"]\)/.test(clientText), '')
107
+ const imports = [...libText.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((match) => match[1])
108
+ const nonNodeImports = imports.filter((spec) => !spec.startsWith('node:'))
109
+ check('运行时只 import node: 内置模块', nonNodeImports.length === 0, nonNodeImports.join(', ') || 'ok')
110
+ check('inject 只硬依赖 systemPrompt', /export const inject = \['systemPrompt'\]/.test(libText), 'commands 缺失时只降级命令')
111
+ check('没有 desktop/structured-test 分支', !/['"](?:desktop|structured-test)['"]/.test(libText), '')
112
+ check('状态目录走 DSH_HOME', /process\.env\.DSH_HOME/.test(libText), '')
113
+ check('没有 Electron 专有 API', !/electron|process\.versions\.electron/.test(libText), '')
114
+
115
+ // 5. 版本与运行时
116
+ const nodeMajor = Number(process.versions.node.split('.')[0])
117
+ check('Node >= 22', nodeMajor >= 22, process.versions.node)
118
+
119
+ function report() {
120
+ for (const row of rows) {
121
+ const tag = row.ok === true ? 'PASS' : row.ok === false ? 'FAIL' : 'WARN'
122
+ process.stdout.write(tag.padEnd(5) + ' ' + row.name + (row.detail ? ' — ' + row.detail : '') + '\n')
123
+ }
124
+ const failed = rows.filter((row) => row.ok === false).length
125
+ process.stdout.write('\n' + (failed === 0 ? 'desktop 兼容性预检通过' : failed + ' 项失败') + '\n')
126
+ }
127
+
128
+ report()
129
+ if (rows.some((row) => row.ok === false)) process.exitCode = 1
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * DSH v4 会话日志读取器(无依赖)。
4
+ *
5
+ * 物理格式:独立 Zstandard 帧的标准拼接——一个只含 header 行的校验和帧,之后每个 append 批次一个帧。
6
+ * Node 的 zstdDecompressSync 只解第一帧,所以这里先按帧头 + block header 精确算出帧长,再逐帧解压。
7
+ */
8
+
9
+ import fs from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import zlib from 'node:zlib'
12
+
13
+ const ZSTD_MAGIC = 0xfd2fb528
14
+
15
+ /**
16
+ * 计算 [offset, offset+frameLength) 这一帧的字节长度。
17
+ * @param {Buffer} buf
18
+ * @param {number} offset
19
+ * @returns {number}
20
+ */
21
+ export function frameLength(buf, offset) {
22
+ if (buf.readUInt32LE(offset) !== ZSTD_MAGIC) throw new Error('bad zstd magic at ' + offset)
23
+ let p = offset + 4
24
+ const descriptor = buf[p++]
25
+ const contentSizeFlag = descriptor >> 6
26
+ const singleSegment = (descriptor >> 5) & 1
27
+ const checksumFlag = (descriptor >> 2) & 1
28
+ const dictionaryFlag = descriptor & 3
29
+ if (!singleSegment) p += 1 // Window descriptor
30
+ if (dictionaryFlag === 3) p += 4
31
+ else if (dictionaryFlag === 2) p += 2
32
+ else if (dictionaryFlag === 1) p += 1
33
+ if (contentSizeFlag === 0) {
34
+ if (singleSegment) p += 1
35
+ } else if (contentSizeFlag === 1) p += 2
36
+ else if (contentSizeFlag === 2) p += 4
37
+ else p += 8
38
+
39
+ for (;;) {
40
+ if (p + 3 > buf.length) throw new Error('truncated zstd block header')
41
+ const header = buf[p] | (buf[p + 1] << 8) | (buf[p + 2] << 16)
42
+ p += 3
43
+ const last = header & 1
44
+ const type = (header >> 1) & 3
45
+ const size = header >>> 3
46
+ if (type === 0 || type === 2) p += size
47
+ else if (type === 1) p += 1
48
+ else throw new Error('reserved zstd block type')
49
+ if (p > buf.length) throw new Error('truncated zstd block body')
50
+ if (last) break
51
+ }
52
+ if (checksumFlag) p += 4
53
+ return p - offset
54
+ }
55
+
56
+ /**
57
+ * 读取一个会话日志,返回全部 JSONL 事件(header 事件 + 持久事件)。
58
+ * @param {string} file
59
+ * @returns {any[]}
60
+ */
61
+ export function readSessionLog(file) {
62
+ const buf = fs.readFileSync(file)
63
+ const events = []
64
+ let offset = 0
65
+ while (offset < buf.length) {
66
+ const length = frameLength(buf, offset)
67
+ const text = zlib.zstdDecompressSync(buf.subarray(offset, offset + length)).toString('utf8')
68
+ for (const line of text.split('\n')) {
69
+ if (line.trim() === '') continue
70
+ events.push(JSON.parse(line))
71
+ }
72
+ offset += length
73
+ }
74
+ return events
75
+ }
76
+
77
+ /**
78
+ * 在 sessions 根目录下递归找某个 session id 的日志,取最高版本。
79
+ * @param {string} sessionsRoot
80
+ * @param {string} sessionId
81
+ * @returns {string | null}
82
+ */
83
+ export function findSessionLog(sessionsRoot, sessionId) {
84
+ if (!fs.existsSync(sessionsRoot)) return null
85
+ for (const group of fs.readdirSync(sessionsRoot, { withFileTypes: true })) {
86
+ if (!group.isDirectory()) continue
87
+ const dir = join(sessionsRoot, group.name, sessionId)
88
+ if (!fs.existsSync(dir)) continue
89
+ const candidates = fs
90
+ .readdirSync(dir)
91
+ .filter((name) => /^session(?:\.v\d+)?\.jsonl(?:\.zstd)?$/.test(name))
92
+ .sort((a, b) => versionOf(b) - versionOf(a))
93
+ if (candidates.length > 0) return join(dir, candidates[0])
94
+ }
95
+ return null
96
+ }
97
+
98
+ function versionOf(name) {
99
+ const match = /^session(?:\.v(\d+))?\.jsonl(?:\.zstd)?$/.exec(name)
100
+ return match?.[1] === undefined ? 0 : Number(match[1])
101
+ }
102
+
103
+ /**
104
+ * 列出 sessions 根目录下全部会话日志(可选时间窗)。
105
+ * @param {string} sessionsRoot
106
+ * @param {{ sinceDays?: number }} [options]
107
+ * @returns {Array<{ file: string, sessionId: string, group: string, mtimeMs: number }>}
108
+ */
109
+ export function listSessionLogs(sessionsRoot, options = {}) {
110
+ if (!fs.existsSync(sessionsRoot)) return []
111
+ const cutoff = options.sinceDays ? Date.now() - options.sinceDays * 86_400_000 : 0
112
+ const out = []
113
+ for (const group of fs.readdirSync(sessionsRoot, { withFileTypes: true })) {
114
+ if (!group.isDirectory()) continue
115
+ const groupDir = join(sessionsRoot, group.name)
116
+ for (const session of fs.readdirSync(groupDir, { withFileTypes: true })) {
117
+ if (!session.isDirectory()) continue
118
+ const dir = join(groupDir, session.name)
119
+ const candidates = fs
120
+ .readdirSync(dir)
121
+ .filter((name) => /^session(?:\.v\d+)?\.jsonl(?:\.zstd)?$/.test(name))
122
+ .sort((a, b) => versionOf(b) - versionOf(a))
123
+ if (candidates.length === 0) continue
124
+ const file = join(dir, candidates[0])
125
+ const mtimeMs = fs.statSync(file).mtimeMs
126
+ if (mtimeMs < cutoff) continue
127
+ out.push({ file, sessionId: session.name, group: group.name, mtimeMs })
128
+ }
129
+ }
130
+ return out.sort((a, b) => a.mtimeMs - b.mtimeMs)
131
+ }
132
+
133
+ /**
134
+ * 逐轮提取「用户可见最终回复」:每轮最后一条含文本、且不含 tool-call 的 assistant 消息。
135
+ * @param {any[]} events
136
+ * @returns {Array<{ turn: number, text: string }>}
137
+ */
138
+ export function finalReplies(events) {
139
+ const byTurn = new Map()
140
+ let turn = 0
141
+ for (const event of events) {
142
+ if (event.type === 'turn/start') turn = event.data?.turn ?? turn + 1
143
+ if (event.type !== 'assistant/message') continue
144
+ const message = event.data?.message
145
+ const content = Array.isArray(message?.content) ? message.content : []
146
+ const hasToolCall = content.some((block) =>
147
+ ['tool-call', 'tool-call-delta', 'tool_use', 'toolCalls'].includes(block?.type),
148
+ )
149
+ const text = content
150
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
151
+ .map((block) => block.text)
152
+ .join('')
153
+ if (hasToolCall || text.trim() === '') continue
154
+ byTurn.set(turn, { turn, text })
155
+ }
156
+ return [...byTurn.values()].sort((a, b) => a.turn - b.turn)
157
+ }
158
+
159
+ /**
160
+ * 会话日志里所有 system/message 的文本。
161
+ * @param {any[]} events
162
+ * @returns {string[]}
163
+ */
164
+ export function systemTexts(events) {
165
+ return events
166
+ .filter((event) => event.type === 'system/message')
167
+ .map((event) => {
168
+ const content = event.data?.message?.content
169
+ if (!Array.isArray(content)) return ''
170
+ return content
171
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
172
+ .map((block) => block.text)
173
+ .join('')
174
+ })
175
+ .filter((text) => text.length > 0)
176
+ }
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 散文率统计:实质回复里「该结构化却写成散文」的比例。
4
+ * 口径固定、可复跑,README 与 docs/METRICS.md 有完整说明。
5
+ *
6
+ * 判定:
7
+ * 实质回复 = 去掉代码块后可见字符 >= 120,且句子数 >= 2
8
+ * 首屏结构化 = 回复前 300 个字符内出现 >=2 行表格 | >=2 个清单项 | >=2 个标题 | >=1 个 dsh-ui 块
9
+ * 散文违规 = 实质回复 && 首屏不结构化 && 句子数 >= 3
10
+ * 散文率 = 散文违规数 / 实质回复数(主口径)
11
+ *
12
+ * 另给整篇口径作对照:整篇任意位置出现结构即算结构化。两个口径都输出,避免只挑好看的数。
13
+ *
14
+ * 用法:
15
+ * node scripts/measure-prose.mjs --since-days 14 --verbose
16
+ * node scripts/measure-prose.mjs --sessions session-a,session-b --json
17
+ * node scripts/measure-prose.mjs --first-screen 500
18
+ * node scripts/measure-prose.mjs --whole
19
+ */
20
+
21
+ import { homedir } from 'node:os'
22
+ import { join } from 'node:path'
23
+ import { finalReplies, readSessionLog, findSessionLog, listSessionLogs } from './lib/session-log.mjs'
24
+
25
+ const SUBSTANTIVE_CHARS = 120
26
+ const MIN_SENTENCES = 2
27
+ const PROSE_SENTENCES = 3
28
+ const DEFAULT_FIRST_SCREEN = 300
29
+
30
+ function parseArgs(argv) {
31
+ const args = {
32
+ dir: process.env.DSH_HOME ? join(process.env.DSH_HOME, 'sessions') : join(homedir(), '.dsh', 'sessions'),
33
+ firstScreen: DEFAULT_FIRST_SCREEN,
34
+ whole: false,
35
+ }
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const arg = argv[i]
38
+ if (arg === '--dir') args.dir = argv[++i]
39
+ else if (arg === '--sessions') args.sessions = String(argv[++i] ?? '').split(',').map((s) => s.trim()).filter(Boolean)
40
+ else if (arg === '--since-days') args.sinceDays = Number(argv[++i])
41
+ else if (arg === '--before') args.before = argv[++i]
42
+ else if (arg === '--limit') args.limit = Number(argv[++i])
43
+ else if (arg === '--first-screen') args.firstScreen = Number(argv[++i])
44
+ else if (arg === '--whole') args.whole = true
45
+ else if (arg === '--json') args.json = true
46
+ else if (arg === '--verbose') args.verbose = true
47
+ else if (arg === '--help' || arg === '-h') args.help = true
48
+ else throw new Error('unknown argument: ' + arg)
49
+ }
50
+ if (!Number.isFinite(args.firstScreen) || args.firstScreen < 40) args.firstScreen = DEFAULT_FIRST_SCREEN
51
+ return args
52
+ }
53
+
54
+ const HELP = `用法: node scripts/measure-prose.mjs [选项]
55
+
56
+ --dir <path> sessions 根目录(默认 $DSH_HOME/sessions)
57
+ --sessions <ids> 只统计指定会话,逗号分隔
58
+ --since-days <n> 只统计最近 n 天有写入的会话
59
+ --before <iso> 只统计写入时间早于该时刻的会话(做干净的改前基线)
60
+ --first-screen <n> 首屏口径的字符数(默认 300)
61
+ --whole 改用整篇口径(任意位置有结构即算结构化)
62
+ --limit <n> 最多统计 n 条实质回复
63
+ --json 输出机器可读结果
64
+ --verbose 列出每条违规回复
65
+ -h, --help 显示本帮助
66
+ `
67
+
68
+ /**
69
+ * 解析回复的可见结构信号。
70
+ * @param {string} text
71
+ * @returns {{ chars: number, sentences: number, tableRows: number, listItems: number, headings: number, dshUiBlocks: number, withoutCode: string }}
72
+ */
73
+ export function analysisText(text) {
74
+ const dshUiBlocks = (text.match(/```dsh-ui/g) ?? []).length
75
+ const withoutCode = text.replace(/```[\s\S]*?```/g, '\n')
76
+ const lines = withoutCode.split('\n')
77
+ const visible = withoutCode.replace(/\s+/g, '')
78
+ const tableRows = lines.filter((line) => /^\s*\|.*\|\s*$/.test(line)).length
79
+ const listItems = lines.filter((line) => /^\s*(?:[-*+]|\d+[.)])\s+\S/.test(line)).length
80
+ const headings = lines.filter((line) => /^\s*#{1,6}\s+\S/.test(line)).length
81
+ const cardLines = lines.filter(
82
+ (line) =>
83
+ /^\s*>\s*\*\*[^*]+\*\*\s*$/.test(line) ||
84
+ /^\s*>\s*\*\*(?:结论|判断|决策|指标|重点|风险|注意|下一步|行动|验收|通过)\*\*/.test(line),
85
+ ).length
86
+ const sentences = withoutCode
87
+ .split(/[。!?!?;;\n]+|\.\s+/)
88
+ .map((part) => part.trim())
89
+ .filter((part) => part.length >= 6).length
90
+ return { chars: visible.length, sentences, tableRows, listItems, headings, cardLines, dshUiBlocks, withoutCode }
91
+ }
92
+
93
+ /**
94
+ * 首屏是否出现结构。按字符数截断而不是按行数,避免长段落把结构推到屏幕外。
95
+ * dsh-ui 组件块在原文里识别;普通代码块不算结构(先剥掉,避免代码里的 | / - 被误判)。
96
+ * @param {string} text
97
+ * @param {number} limit
98
+ * @returns {boolean}
99
+ */
100
+ export function firstScreenStructured(text, limit = DEFAULT_FIRST_SCREEN) {
101
+ const head = text.slice(0, limit)
102
+ if (head.includes('```dsh-ui')) return true
103
+ // 非 dsh-ui 的 fenced block:从开栅栏起整段丢弃(含未闭合到首屏末尾的情况)。
104
+ const cleaned = head.replace(/```(?!dsh-ui)[^\n]*\n[\s\S]*?(?:```|$)/g, '\n')
105
+ const lines = cleaned.split('\n')
106
+ const tableRows = lines.filter((line) => /^\s*\|.*\|\s*$/.test(line)).length
107
+ const listItems = lines.filter((line) => /^\s*(?:[-*+]|\d+[.)])\s+\S/.test(line)).length
108
+ const headings = lines.filter((line) => /^\s*#{1,6}\s+\S/.test(line)).length
109
+ const cardLines = lines.filter(
110
+ (line) =>
111
+ /^\s*>\s*\*\*[^*]+\*\*\s*$/.test(line) ||
112
+ /^\s*>\s*\*\*(?:结论|判断|决策|指标|重点|风险|注意|下一步|行动|验收|通过)\*\*/.test(line),
113
+ ).length
114
+ return cardLines >= 1 || tableRows >= 2 || listItems >= 2 || headings >= 2
115
+ }
116
+
117
+ /**
118
+ * 单条回复的判定。
119
+ * @param {string} text
120
+ * @param {{ firstScreen?: number, whole?: boolean }} [options]
121
+ * @returns {ReturnType<typeof analysisText> & { substantive: boolean, structured: boolean, structuredWhole: boolean, prose: boolean }}
122
+ */
123
+ export function classify(text, options = {}) {
124
+ const a = analysisText(text)
125
+ const substantive = a.chars >= SUBSTANTIVE_CHARS && a.sentences >= MIN_SENTENCES
126
+ const structuredWhole = a.tableRows >= 2 || a.listItems >= 2 || a.headings >= 2 || a.cardLines >= 1 || a.dshUiBlocks >= 1
127
+ const structured = options.whole
128
+ ? structuredWhole
129
+ : firstScreenStructured(text, options.firstScreen ?? DEFAULT_FIRST_SCREEN)
130
+ const prose = substantive && !structured && a.sentences >= PROSE_SENTENCES
131
+ return { ...a, substantive, structured, structuredWhole, prose }
132
+ }
133
+
134
+ function main() {
135
+ const args = parseArgs(process.argv.slice(2))
136
+ if (args.help) {
137
+ process.stdout.write(HELP)
138
+ return
139
+ }
140
+
141
+ let logs
142
+ if (args.sessions) {
143
+ logs = args.sessions.map((id) => {
144
+ const file = findSessionLog(args.dir, id)
145
+ if (!file) throw new Error('找不到会话:' + id)
146
+ return { file, sessionId: id }
147
+ })
148
+ } else {
149
+ logs = listSessionLogs(args.dir, { sinceDays: args.sinceDays })
150
+ if (args.before) {
151
+ const cutoff = Date.parse(args.before)
152
+ if (!Number.isFinite(cutoff)) throw new Error('无法解析 --before:' + args.before)
153
+ logs = logs.filter((log) => log.mtimeMs <= cutoff)
154
+ }
155
+ }
156
+
157
+ const rows = []
158
+ for (const log of logs) {
159
+ const events = readSessionLog(log.file)
160
+ for (const reply of finalReplies(events)) {
161
+ const verdict = classify(reply.text, { firstScreen: args.firstScreen, whole: args.whole })
162
+ rows.push({ sessionId: log.sessionId, turn: reply.turn, ...verdict })
163
+ }
164
+ }
165
+
166
+ const substantive = rows.filter((row) => row.substantive)
167
+ const selected = args.limit ? substantive.slice(-args.limit) : substantive
168
+ const selectedProse = selected.filter((row) => row.prose)
169
+ const wholeProse = selected.filter((row) => !row.structuredWhole && row.sentences >= PROSE_SENTENCES)
170
+ const rate = (value) => (selected.length === 0 ? 0 : Number(((value / selected.length) * 100).toFixed(1)))
171
+
172
+ const summary = {
173
+ dir: args.dir,
174
+ sessions: logs.length,
175
+ replies: rows.length,
176
+ substantive: selected.length,
177
+ structured: selected.filter((row) => row.structured).length,
178
+ prose: selectedProse.length,
179
+ proseRate: rate(selectedProse.length),
180
+ wholeProse: wholeProse.length,
181
+ wholeProseRate: rate(wholeProse.length),
182
+ metric: {
183
+ mode: args.whole ? 'whole' : 'first-screen',
184
+ firstScreenChars: args.whole ? null : args.firstScreen,
185
+ substantiveChars: SUBSTANTIVE_CHARS,
186
+ proseSentences: PROSE_SENTENCES,
187
+ },
188
+ violations: selectedProse.map((row) => ({
189
+ sessionId: row.sessionId,
190
+ turn: row.turn,
191
+ chars: row.chars,
192
+ sentences: row.sentences,
193
+ firstLine: row.withoutCode.trim().split('\n').find((line) => line.trim() !== '')?.slice(0, 120) ?? '',
194
+ })),
195
+ }
196
+
197
+ if (args.json) {
198
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n')
199
+ return
200
+ }
201
+
202
+ process.stdout.write(
203
+ 'sessions=' + summary.sessions +
204
+ ' replies=' + summary.replies +
205
+ ' substantive=' + summary.substantive +
206
+ ' structured=' + summary.structured +
207
+ ' prose=' + summary.prose +
208
+ ' proseRate=' + summary.proseRate + '%' +
209
+ ' wholeProseRate=' + summary.wholeProseRate + '%' +
210
+ ' mode=' + summary.metric.mode + '\n',
211
+ )
212
+ if (args.verbose && summary.violations.length > 0) {
213
+ process.stdout.write('违规回复:\n')
214
+ for (const row of summary.violations) {
215
+ process.stdout.write(' ' + row.sessionId + ' turn ' + row.turn + ' · ' + row.chars + ' 字符 / ' + row.sentences + ' 句 · ' + row.firstLine + '\n')
216
+ }
217
+ }
218
+ }
219
+
220
+ if (process.argv[1] && process.argv[1].endsWith('measure-prose.mjs')) {
221
+ try {
222
+ main()
223
+ } catch (error) {
224
+ process.stderr.write('measure-prose: ' + String(error?.message ?? error) + '\n')
225
+ process.exitCode = 2
226
+ }
227
+ }
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 读一个 DSH 会话日志,用来验证 system prompt 注入是否生效。
4
+ *
5
+ * 用法:
6
+ * node scripts/session-inspect.mjs --session session-xxxx --grep "结构化输出(本会话已启用"
7
+ * node scripts/session-inspect.mjs --session session-xxxx --grep "结构化输出" --json
8
+ * node scripts/session-inspect.mjs # 默认看最近一个会话
9
+ *
10
+ * 退出码:0 = 正常读取(--grep 的命中数在输出里);2 = 参数或读取错误。
11
+ */
12
+
13
+ import { homedir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import {
16
+ finalReplies,
17
+ findSessionLog,
18
+ listSessionLogs,
19
+ readSessionLog,
20
+ systemTexts,
21
+ } from './lib/session-log.mjs'
22
+
23
+ function parseArgs(argv) {
24
+ const args = { dir: process.env.DSH_HOME ? join(process.env.DSH_HOME, 'sessions') : join(homedir(), '.dsh', 'sessions') }
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const arg = argv[i]
27
+ if (arg === '--session') args.session = argv[++i]
28
+ else if (arg === '--dir') args.dir = argv[++i]
29
+ else if (arg === '--grep') args.grep = argv[++i]
30
+ else if (arg === '--json') args.json = true
31
+ else if (arg === '--replies') args.replies = true
32
+ else if (arg === '--last-system') args.lastSystem = true
33
+ else if (arg === '--help' || arg === '-h') args.help = true
34
+ else throw new Error('unknown argument: ' + arg)
35
+ }
36
+ return args
37
+ }
38
+
39
+ const HELP = `用法: node scripts/session-inspect.mjs [选项]
40
+
41
+ --session <id> 指定会话 id;省略时取最近写入的会话
42
+ --dir <path> sessions 根目录(默认 $DSH_HOME/sessions)
43
+ --grep <text> 在 system/message 里搜这段文本,输出命中数与位置
44
+ --last-system 只检查最新一条 system/message(用于验证「关掉后下一轮 0 命中」)
45
+ --replies 同时列出每轮最终回复的首行与长度
46
+ --json 输出机器可读 JSON
47
+ -h, --help 显示本帮助
48
+ `
49
+
50
+ function main() {
51
+ const args = parseArgs(process.argv.slice(2))
52
+ if (args.help) {
53
+ process.stdout.write(HELP)
54
+ return
55
+ }
56
+
57
+ let file
58
+ if (args.session) {
59
+ file = findSessionLog(args.dir, args.session)
60
+ if (!file) throw new Error('找不到会话日志:' + args.session + '(根目录 ' + args.dir + ')')
61
+ } else {
62
+ const latest = listSessionLogs(args.dir).at(-1)
63
+ if (!latest) throw new Error('目录下没有会话日志:' + args.dir)
64
+ file = latest.file
65
+ }
66
+
67
+ const events = readSessionLog(file)
68
+ const header = events.find((event) => event.type === 'session') ?? {}
69
+ const sessionId = header.id ?? args.session ?? '(unknown)'
70
+ const systems = systemTexts(events)
71
+ const scopedSystems = args.lastSystem ? systems.slice(-1) : systems
72
+ const hits = args.grep ? scopedSystems.filter((text) => text.includes(args.grep)) : []
73
+ const replies = args.replies ? finalReplies(events) : []
74
+
75
+ if (args.json) {
76
+ process.stdout.write(
77
+ JSON.stringify(
78
+ {
79
+ file,
80
+ sessionId,
81
+ events: events.length,
82
+ systemMessages: systems.length,
83
+ systemChars: systems.reduce((sum, text) => sum + text.length, 0),
84
+ scopedToLastSystem: args.lastSystem === true,
85
+ grep: args.grep ?? null,
86
+ hits: hits.length,
87
+ replies: replies.map((reply) => ({
88
+ turn: reply.turn,
89
+ chars: reply.text.length,
90
+ firstLine: reply.text.trim().split('\n')[0].slice(0, 120),
91
+ })),
92
+ },
93
+ null,
94
+ 2,
95
+ ) + '\n',
96
+ )
97
+ return
98
+ }
99
+
100
+ process.stdout.write('会话:' + sessionId + '\n')
101
+ process.stdout.write('日志:' + file + '\n')
102
+ process.stdout.write('事件:' + events.length + ' 条;system/message:' + systems.length + ' 条,共 ' + systems.reduce((sum, text) => sum + text.length, 0) + ' 字符\n')
103
+ if (args.grep) {
104
+ process.stdout.write('搜索:' + JSON.stringify(args.grep) + ' → 命中 ' + hits.length + ' 条 system/message\n')
105
+ for (const text of hits) {
106
+ const index = text.indexOf(args.grep)
107
+ process.stdout.write(' …' + text.slice(Math.max(0, index - 40), index + args.grep.length + 60).replace(/\n/g, ' ⏎ ') + '…\n')
108
+ }
109
+ }
110
+ if (args.replies) {
111
+ process.stdout.write('最终回复:' + replies.length + ' 轮\n')
112
+ for (const reply of replies) {
113
+ process.stdout.write(' turn ' + reply.turn + ' · ' + reply.text.length + ' 字符 · ' + reply.text.trim().split('\n')[0].slice(0, 80) + '\n')
114
+ }
115
+ }
116
+ }
117
+
118
+ try {
119
+ main()
120
+ } catch (error) {
121
+ process.stderr.write('session-inspect: ' + String(error?.message ?? error) + '\n')
122
+ process.exitCode = 2
123
+ }