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.
- package/ACCEPTANCE.md +3 -0
- package/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/README.md +239 -2
- package/assets/screenshots/01-title-card.png +0 -0
- package/assets/screenshots/06-status-table.png +0 -0
- package/assets/screenshots/07-code-block.png +0 -0
- package/assets/screenshots/08-narrow.png +0 -0
- package/cordis.patch.yml +20 -0
- package/docs/CARD-SYSTEM.md +62 -0
- package/docs/DESIGN-REFERENCES.md +51 -0
- package/docs/METRICS.md +89 -0
- package/docs/RISK-ROLLBACK.md +129 -0
- package/docs/ROADMAP.md +85 -0
- package/docs/TROUBLESHOOT-VISUAL.md +78 -0
- package/docs/VISUAL-ACCEPTANCE.md +224 -0
- package/docs/VISUAL-CRITIQUE.md +57 -0
- package/docs/VISUAL-PLAN.md +133 -0
- package/icon.svg +10 -0
- package/lib/client.js +467 -0
- package/lib/index.js +483 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +83 -4
- package/screenshots.json +6 -0
- package/scripts/build.mjs +22 -0
- package/scripts/desktop-check.mjs +129 -0
- package/scripts/lib/session-log.mjs +176 -0
- package/scripts/measure-prose.mjs +227 -0
- package/scripts/session-inspect.mjs +123 -0
- package/scripts/uninstall.mjs +53 -0
- package/src/client.js +465 -0
- package/src/index.js +481 -0
- package/test/structured.test.mjs +537 -0
|
@@ -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
|
+
}
|