@kira905/dsh-diagnostics-extra 0.0.0-stage → 0.1.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.
@@ -0,0 +1,224 @@
1
+ // ============================================================
2
+ // attach-refs.mjs — 会话附件引用 / 附件对象 共享库(卡 AT1 · 2026-09-16)
3
+ // ------------------------------------------------------------
4
+ // 为什么需要:`attachments-audit.mjs`(只读对账)与 `attachments-fetch.mjs`(按需取回)
5
+ // 必须用**同一套口径**解析「会话引用了哪些 sha256」「对象该落在哪」——两套实现必然漂移。
6
+ //
7
+ // 口径真源(全部来自主包源码,改前先复核):
8
+ // · 实体路径 `<DSH_HOME>/attachments/v1/objects/<sha256前2位>/<sha256>`
9
+ // (@deepseek-ai/dsh-attachment-local/lib/index.js 的 objectPath())
10
+ // · sha 形态 `^sha256:[a-f0-9]{64}$`(同文件 ID_PATTERN,强制小写十六进制)
11
+ // · 会话文件 `<DSH_HOME>/sessions/**_/session[.vN].jsonl.zstd`(0.1.5 起为
12
+ // `session.v3.jsonl.zstd`;判据真源 `lib/session-file-name.mjs`,**认全世代**;
13
+ // 多帧 zstd 拼接,append-only,
14
+ // 逐帧解压 —— 只解第一帧会漏事件:与 dsh-session-probe.mjs 同判据)
15
+ // · 引用形态 事件 JSON 里 `{type:"image", attachment:{attachmentId:"sha256:…",…}}`,
16
+ // 且 tool-result 内容里也嵌图 ⇒ 递归收集,不能只看第一层
17
+ //
18
+ // 只读库:本文件不做任何写操作。
19
+ // ============================================================
20
+ import fs from 'node:fs'
21
+ import path from 'node:path'
22
+ import crypto from 'node:crypto'
23
+ import zlib from 'node:zlib'
24
+ // 会话日志文件名判据**唯一真源**(卡「修·V3判据③」2026-09-24):
25
+ // 0.1.5 起落盘名 `session.jsonl.zstd` → `session.v3.jsonl.zstd`,读取侧必须认全世代。
26
+ import { isSessionFileName, SESSION_FILE_NAME_LEGACY } from './session-file-name.mjs'
27
+
28
+ /** zstd 帧魔数(多帧拼接文件的切帧依据)。 */
29
+ export const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
30
+
31
+ /**
32
+ * @deprecated 会话日志文件名**已世代化**(0.1.5 起 `session.jsonl.zstd` → `session.v3.jsonl.zstd`):
33
+ * 读取侧一律用 `isSessionFileName()`(认全世代),**禁拿本常量做文件名比较**。
34
+ * 保留导出只为历史消费者不炸(与 `pitfall-scan.mjs` 同款处置)。
35
+ */
36
+ export const SESSION_FILE_NAME = SESSION_FILE_NAME_LEGACY
37
+
38
+ /** 附件 id 严格形态(与主包 ID_PATTERN 同源)。 */
39
+ const ATTACHMENT_ID_RE = /^sha256:([a-f0-9]{64})$/
40
+
41
+ /** 宽松扫描用(日志/文本里找 sha 引用;文件名与目录名都是小写)。 */
42
+ const SHA_IN_TEXT_RE = /sha256:([a-f0-9]{64})/g
43
+
44
+ /** 是否为合法 sha256 十六进制(小写)。 */
45
+ export function isSha256(v) {
46
+ return typeof v === 'string' && /^[a-f0-9]{64}$/.test(v)
47
+ }
48
+
49
+ /** 归一化用户输入的 hash:接受裸 sha / `sha256:<sha>` / 大写。返回 null 表示非法。 */
50
+ export function normalizeSha(input) {
51
+ if (typeof input !== 'string') return null
52
+ const s = input.trim().toLowerCase().replace(/^sha256:/, '')
53
+ return isSha256(s) ? s : null
54
+ }
55
+
56
+ /** 附件实体路径(唯一口径,别在调用方另拼)。 */
57
+ export function objectPathFor(objectsRoot, sha) {
58
+ return path.join(objectsRoot, sha.slice(0, 2), sha)
59
+ }
60
+
61
+ /** 附件对象根(由 DSH_HOME 推出)。 */
62
+ export function objectsRootFor(home) {
63
+ return path.join(home, 'attachments', 'v1', 'objects')
64
+ }
65
+
66
+ /** 附件根(request-images / tmp 的同级)。 */
67
+ export function attachmentsRootFor(home) {
68
+ return path.join(home, 'attachments', 'v1')
69
+ }
70
+
71
+ /** 文件 sha256(小写十六进制)。 */
72
+ export function sha256File(file) {
73
+ const buf = fs.readFileSync(file)
74
+ return crypto.createHash('sha256').update(buf).digest('hex')
75
+ }
76
+
77
+ /** 字节 sha256。 */
78
+ export function sha256Bytes(buf) {
79
+ return crypto.createHash('sha256').update(buf).digest('hex')
80
+ }
81
+
82
+ // ---------------------------------------------------------------- 会话帧解析
83
+
84
+ /**
85
+ * 把多帧 zstd 拼接缓冲切成帧(与 dsh-session-probe.mjs 同实现)。
86
+ * @param {Buffer} buf
87
+ * @returns {Buffer[]}
88
+ */
89
+ export function splitFrames(buf) {
90
+ const starts = []
91
+ let i = 0
92
+ while (i >= 0 && i < buf.length) {
93
+ const j = buf.indexOf(ZSTD_MAGIC, i)
94
+ if (j < 0) break
95
+ starts.push(j)
96
+ i = j + 4
97
+ }
98
+ return starts.map((s, k) => buf.subarray(s, k + 1 < starts.length ? starts[k + 1] : buf.length))
99
+ }
100
+
101
+ /**
102
+ * 逐帧解压会话缓冲。
103
+ * ⚠️ 只解第一帧会把完整会话误判成空会话(仓2 空壳判据的历史教训)。
104
+ * @param {Buffer} buf
105
+ * @returns {{text:string, frames:number, badFrames:number, bytes:number}}
106
+ */
107
+ export function decodeSessionBuffer(buf) {
108
+ const frames = splitFrames(buf)
109
+ const parts = []
110
+ let badFrames = 0
111
+ for (const f of frames) {
112
+ try { parts.push(zlib.zstdDecompressSync(f)) } catch { badFrames++ }
113
+ }
114
+ return { text: Buffer.concat(parts).toString('utf8'), frames: frames.length, badFrames, bytes: buf.length }
115
+ }
116
+
117
+ /** 读文件并解码(IO 版)。 */
118
+ export function decodeSessionFile(file) {
119
+ return decodeSessionBuffer(fs.readFileSync(file))
120
+ }
121
+
122
+ /**
123
+ * 递归收集一个会话事件里的附件引用(`attachmentId` 字段,任意深度;tool-result 内容里的图也在内)。
124
+ * @param {unknown} node 已 JSON.parse 的事件
125
+ * @param {Array<{sha:string, ref:object}>} out
126
+ */
127
+ export function collectRefsFromEvent(node, out) {
128
+ if (node === null || typeof node !== 'object') return
129
+ if (Array.isArray(node)) {
130
+ for (const v of node) collectRefsFromEvent(v, out)
131
+ return
132
+ }
133
+ const id = node.attachmentId
134
+ if (typeof id === 'string') {
135
+ const m = ATTACHMENT_ID_RE.exec(id)
136
+ if (m) out.push({ sha: m[1], ref: node })
137
+ }
138
+ for (const v of Object.values(node)) collectRefsFromEvent(v, out)
139
+ }
140
+
141
+ /**
142
+ * 从会话文本(多行 JSONL)收集全部附件引用(去重后按 sha 归并)。
143
+ * @param {string} text
144
+ * @returns {{refs: Map<string, {sha:string, count:number, sample:object}>, badLines:number, events:number}}
145
+ */
146
+ export function collectSessionRefs(text) {
147
+ const refs = new Map()
148
+ let badLines = 0
149
+ let events = 0
150
+ for (const line of text.split('\n')) {
151
+ if (line.trim().length === 0) continue
152
+ let obj
153
+ try { obj = JSON.parse(line) } catch { badLines++; continue }
154
+ events++
155
+ const found = []
156
+ collectRefsFromEvent(obj, found)
157
+ for (const { sha, ref } of found) {
158
+ let rec = refs.get(sha)
159
+ if (!rec) { rec = { sha, count: 0, sample: ref }; refs.set(sha, rec) }
160
+ rec.count++
161
+ }
162
+ }
163
+ return { refs, badLines, events }
164
+ }
165
+
166
+ /** 文本里出现的 sha256(宽松扫描,用于报告里的 `sha256:` 摘录)。 */
167
+ export function scanShaInText(text) {
168
+ const out = new Set()
169
+ SHA_IN_TEXT_RE.lastIndex = 0
170
+ let m
171
+ while ((m = SHA_IN_TEXT_RE.exec(text)) !== null) out.add(m[1])
172
+ return out
173
+ }
174
+
175
+ // ---------------------------------------------------------------- 会话文件枚举
176
+
177
+ /**
178
+ * 枚举一棵会话树下的会话文件(不跟随链接/重解析点 —— 同仓2 采集口径 /XJ)。
179
+ * ⚠️ 认全世代(`isSessionFileName`):归档里的旧名 `session.jsonl.zstd` 与 0.1.5 起的
180
+ * `session.v3.jsonl.zstd` 都收(**禁精确名比较** —— 卡「修·V3判据③」2026-09-24)。
181
+ * @param {string} dir
182
+ * @returns {string[]} 绝对路径
183
+ */
184
+ export function listSessionFiles(dir) {
185
+ const out = []
186
+ const stack = [dir]
187
+ while (stack.length > 0) {
188
+ const cur = stack.pop()
189
+ let ents
190
+ try { ents = fs.readdirSync(cur, { withFileTypes: true }) } catch { continue }
191
+ for (const e of ents) {
192
+ const p = path.join(cur, e.name)
193
+ let st
194
+ try { st = fs.lstatSync(p) } catch { continue }
195
+ if (st.isSymbolicLink()) continue
196
+ if (st.isDirectory()) { stack.push(p); continue }
197
+ if (st.isFile() && isSessionFileName(e.name)) out.push(p)
198
+ }
199
+ }
200
+ return out.sort()
201
+ }
202
+
203
+ /**
204
+ * 枚举本机全部会话树(现役 + 物理归档)。归档里的图**同样会被历史回合带上**,
205
+ * 所以对账必须两棵树一起扫(只扫 sessions 会漏一半 —— 实测 14 个受影响会话里
206
+ * 11 个在 `_archived-sessions`)。
207
+ * @param {string} home
208
+ * @returns {Array<{name:string, dir:string}>}
209
+ */
210
+ export function sessionTrees(home) {
211
+ return ['sessions', '_archived-sessions']
212
+ .map((name) => ({ name, dir: path.join(home, name) }))
213
+ .filter((t) => fs.existsSync(t.dir))
214
+ }
215
+
216
+ /** 会话目录名/路径 → 归一会话 id(兼容 `session-<guid>` 与裸 `<guid>`)。 */
217
+ export function normalizeSessionId(value) {
218
+ if (typeof value !== 'string') return null
219
+ let n = path.basename(value.trim())
220
+ if (n.startsWith('session-')) n = n.slice('session-'.length)
221
+ return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(n)
222
+ ? n.toLowerCase()
223
+ : null
224
+ }
@@ -0,0 +1,95 @@
1
+ // ============================================================
2
+ // dsh-home.mjs — DSH home 解析(测试环境支持,2026-09-11)
3
+ // ------------------------------------------------------------
4
+ // 与主包 @deepseek-ai/dsh-home-paths 同源语义,优先级(2026-09-14 补齐 BUTLER_DSH_HOME):
5
+ // 显式参数 > $BUTLER_DSH_HOME > $DSH_HOME > ~/.dsh
6
+ // (空 / 纯空白的取值视为未设置,避免解析到 cwd)
7
+ //
8
+ // ⚠️ 与 dsh-butler/lib/paths.mjs 的 dshHome() 同一口径(那边是 BUTLER_DSH_HOME 优先、
9
+ // 依次回落到 DSH_HOME 再回落到 ~/.dsh);本文件此前只认 DSH_HOME,对既有调用方
10
+ // 是"行为不变 + 多认一个环境变量",不会改变任何默认路径。
11
+ //
12
+ // 用途:让部署/恢复脚本与运行期组件能被指向隔离的测试环境
13
+ // (`DSH_HOME = <某目录>/.dsh`),先把插件部署到测试
14
+ // profile 验证通过,再部署到生产。
15
+ //
16
+ // 兼容性铁律:不设置任何环境变量时,返回的路径与改造前完全一致
17
+ // (~/.dsh),因此对既有生产流程是零行为变化。
18
+ // ============================================================
19
+ import os from 'node:os'
20
+ import path from 'node:path'
21
+
22
+ /** 解析 DSH home 绝对路径。 */
23
+ export function resolveDshHome(configured) {
24
+ if (configured) return path.resolve(configured)
25
+ // 逐变量判定(2026-09-14 补):空 / 纯空白取值一律视为「未设置」,逐级回落
26
+ // BUTLER_DSH_HOME → DSH_HOME → ~/.dsh(复用本文件 envValue(),不另造判定)。
27
+ // 修的就是这个缺口:`process.env.A || process.env.B` 只挡得住**空串**——
28
+ // 纯空白(' ')是 truthy 会被当成"已设置",而 `' '.trim()` 又是空串 ⇒
29
+ // 既不采信 BUTLER_DSH_HOME、也**跳过 DSH_HOME**,直接落回 `~/.dsh`(生产 home)。
30
+ // 实测后果:测试/金丝雀场景里只要残留一个空白值,脚本就静默读写**生产**,
31
+ // 而调用方以为自己在沙箱里(2026-09-14 实测:want 沙箱 / got <HOME>\.dsh)。
32
+ const override = envValue('BUTLER_DSH_HOME') ?? envValue('DSH_HOME')
33
+ if (override) return path.resolve(override)
34
+ return path.join(os.homedir(), '.dsh')
35
+ }
36
+
37
+ /** web profile 目录(插件实体、package.json、.package-map.json 所在处)。 */
38
+ export function webProfileDir(configuredHome) {
39
+ return path.join(resolveDshHome(configuredHome), 'profiles', 'web')
40
+ }
41
+
42
+ /**
43
+ * 标记当前目标是不是测试环境(仅用于日志提示与安全自检)。
44
+ * 判定:home 路径不等于默认的 ~/.dsh。
45
+ */
46
+ export function isNonDefaultHome(configuredHome) {
47
+ const def = path.join(os.homedir(), '.dsh')
48
+ return path.resolve(resolveDshHome(configuredHome)) !== path.resolve(def)
49
+ }
50
+
51
+ /** 生产默认工作区(DSH 代码/文档树,2026-09-14 起可被环境变量改道)。 */
52
+ export const DEFAULT_DSH_WORKSPACE = process.env.DSH_ROOT || path.join(os.homedir(), 'DSH')
53
+
54
+ /** 任意环境变量的非空取值(空串/纯空白视为未设置)。 */
55
+ function envValue(name) {
56
+ const v = process.env[name]
57
+ return v && v.trim() ? v.trim() : null
58
+ }
59
+
60
+ /**
61
+ * 解析 DSH 工作区根(放 docs\ 与管家报告的那棵树)。
62
+ * 优先级:显式参数 > $DSH_WORKSPACE > 由 home 推导(**仅当 home 被环境变量重定向到
63
+ * <某目录>\.dsh 这种"测试实例"形态时**)> <DSH_ROOT>。
64
+ * ⚠️ 判据必须是「env 显式指向了非默认 home」而不是"路径层数够"——生产 home 也是
65
+ * `<HOME>\.dsh`(3 层),按层数判会把 workspace 错推成 `<HOME>`。
66
+ */
67
+ export function resolveDshWorkspace(configured) {
68
+ if (configured) return path.resolve(configured)
69
+ const env = envValue('DSH_WORKSPACE')
70
+ if (env) return path.resolve(env)
71
+ const defaultHome = path.join(os.homedir(), '.dsh')
72
+ const override = envValue('BUTLER_DSH_HOME') || envValue('DSH_HOME')
73
+ if (override) {
74
+ const home = path.resolve(override)
75
+ if (home.toLowerCase() !== path.resolve(defaultHome).toLowerCase()
76
+ && path.basename(home).toLowerCase() === '.dsh') {
77
+ return path.resolve(path.dirname(home))
78
+ }
79
+ }
80
+ return DEFAULT_DSH_WORKSPACE
81
+ }
82
+
83
+ /** 管家日报/周报输出目录($BUTLER_REPORT_DIR 已有先例,2026-09-12 起用于端到端夹具)。 */
84
+ export function resolveReportDir(configured) {
85
+ if (configured) return path.resolve(configured)
86
+ const env = envValue('BUTLER_REPORT_DIR')
87
+ if (env) return path.resolve(env)
88
+ return path.join(resolveDshWorkspace(), 'docs', '管家报告')
89
+ }
90
+
91
+ /** 变更流水目录(<workspace>\docs\变更流水)。 */
92
+ export function resolveChangelogDir(configured) {
93
+ if (configured) return path.resolve(configured)
94
+ return path.join(resolveDshWorkspace(), 'docs', '变更流水')
95
+ }