dsh-lpipe-memory 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.
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "dsh-lpipe-memory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "把会话原文逐字投影到长期文件:按会话分文件夹,按天分卷,哈希链可校验。",
6
+ "license": "AGPL-3.0-or-later",
7
+ "main": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./reader": "./src/reader.mjs",
11
+ "./paths": "./src/paths.mjs",
12
+ "./client": "./lib/client.js",
13
+ "./locale/en.json": "./locale/en.json",
14
+ "./locale/zh.json": "./locale/zh.json",
15
+ "./package.json": "./package.json",
16
+ "./icon": "./assets/icon.png"
17
+ },
18
+ "dependencies": {
19
+ "dsh-life-pipline": "^0.7.1"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test \"test/*.test.mjs\"",
23
+ "build-client": "node scripts/build-client.mjs"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "lib",
28
+ "client",
29
+ "assets",
30
+ "locale",
31
+ "cordis.patch.yml",
32
+ "LICENSE",
33
+ "README.md"
34
+ ],
35
+ "keywords": [
36
+ "dsh",
37
+ "dsh-plugin",
38
+ "deepseek-harness",
39
+ "memory"
40
+ ],
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/Kaczev/dsh-life-pipline.git",
44
+ "directory": "components/dsh-lpipe-memory"
45
+ },
46
+ "dsh": {
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ },
50
+ "client": {
51
+ "inject": [
52
+ "@deepseek-ai/dsh-client-connection",
53
+ "@deepseek-ai/dsh-client-ui-slots"
54
+ ],
55
+ "platform": "web"
56
+ }
57
+ },
58
+ "icon": "assets/icon.png"
59
+ }
package/src/chain.mjs ADDED
@@ -0,0 +1,28 @@
1
+ // 哈希链:每一行的 hash 由上一行的 hash 与本行正文算出,跨文件、跨天相连。
2
+ import { createHash } from 'node:crypto'
3
+
4
+ export const GENESIS = 'sha256:genesis'
5
+
6
+ /** 正文:一行除了 `prev` 与 `hash` 之外的全部字段,按插入顺序序列化。 */
7
+ export function bodyOf(row) {
8
+ const { prev: _prev, hash: _hash, ...rest } = row
9
+ return JSON.stringify(rest)
10
+ }
11
+
12
+ export function hashOf(prev, body) {
13
+ return 'sha256:' + createHash('sha256').update(`${prev}\n${body}`).digest('hex')
14
+ }
15
+
16
+ /** 给一行接上链:写入前调用,`prev` 是上一行的 hash。 */
17
+ export function seal(row, prev) {
18
+ const body = bodyOf(row)
19
+ return { ...row, prev, hash: hashOf(prev, body) }
20
+ }
21
+
22
+ /** 校验一行:返回 null 表示完好,否则返回人话说的原因。 */
23
+ export function checkRow(row, prev) {
24
+ if (typeof row?.hash !== 'string' || typeof row?.prev !== 'string') return '缺少 prev/hash'
25
+ if (row.prev !== prev) return `prev 对不上:期望 ${prev},实际 ${row.prev}`
26
+ const want = hashOf(row.prev, bodyOf(row))
27
+ return want === row.hash ? null : 'hash 与正文不符'
28
+ }
@@ -0,0 +1,167 @@
1
+ // 迁移:把旧的记忆库重建成「不再重复存 parts」的格式,哈希链整条重算。
2
+ // 只在库的 version 落后时跑一次;跑完把旧目录改名留作备份。
3
+ import { cpSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ import { GENESIS } from './chain.mjs'
7
+ import { MemoryReader } from './reader.mjs'
8
+ import { metaFile, stateFile } from './paths.mjs'
9
+ import { carriedParts } from './project.mjs'
10
+ import { MemoryWriter } from './writer.mjs'
11
+
12
+ /**
13
+ * 2 = 行里不再存 text/reasoning 的副本。
14
+ * 3 = 每条行都带轮号:早先 `user/message` 事件本身不带 turn,那些行是 null,
15
+ * 星图按轮分组时会把它们整条跳过,于是你自己的话从来没有参与抽星。
16
+ */
17
+ export const FORMAT_VERSION = 2
18
+
19
+ const STRUCTURAL = new Set(['seq', 'day', 'at', 'session', 'turn', 'step', 'kind', 'prev', 'hash'])
20
+
21
+ function readJson(file, fallback) {
22
+ try {
23
+ return JSON.parse(readFileSync(file, 'utf8'))
24
+ } catch {
25
+ return fallback
26
+ }
27
+ }
28
+
29
+ function writeJson(file, value) {
30
+ writeFileSync(file, JSON.stringify(value, null, 2) + '\n', 'utf8')
31
+ }
32
+
33
+ /** 这一条旧行还带着重复的 parts 副本吗? */
34
+ export function hasDuplicateParts(row) {
35
+ const parts = row?.parts
36
+ if (!Array.isArray(parts)) return false
37
+ return parts.some((part) => part?.type === 'text' || part?.type === 'reasoning')
38
+ }
39
+
40
+ function payloadOf(row) {
41
+ const payload = {}
42
+ for (const [key, value] of Object.entries(row)) {
43
+ if (STRUCTURAL.has(key)) continue
44
+ payload[key] = value
45
+ }
46
+ if (Array.isArray(payload.parts)) {
47
+ const carried = carriedParts(payload.parts)
48
+ if (carried.length > 0) payload.parts = carried
49
+ else delete payload.parts
50
+ }
51
+ return payload
52
+ }
53
+
54
+ export function needsMigration(root) {
55
+ const state = readJson(stateFile(root), null)
56
+ if (!state) return false
57
+ return (state.version ?? 1) < FORMAT_VERSION
58
+ }
59
+
60
+ /**
61
+ * 整库重建:行照旧、seq 照旧、顺序照旧,只去掉重复的 parts,并重新焊哈希链。
62
+ * 旧目录改名成 `<root>.bak-<时间>`,不删除。
63
+ */
64
+ export function compact({ root, log = () => {}, now = () => Date.now() } = {}) {
65
+ const oldState = readJson(stateFile(root), { version: 1, sessions: {} })
66
+ const oldMetas = new Map()
67
+ for (const sessionId of Object.keys(oldState.sessions ?? {})) {
68
+ const meta = readJson(metaFile(root, sessionId), null)
69
+ if (meta) oldMetas.set(sessionId, meta)
70
+ }
71
+
72
+ const reader = new MemoryReader({ root }).reload()
73
+ const next = `${root}.next`
74
+ rmSync(next, { recursive: true, force: true })
75
+ const writer = new MemoryWriter({ root: next, now })
76
+
77
+ let maxSeq = -1
78
+ for (const session of reader.sessions()) maxSeq = Math.max(maxSeq, session.lastSeq)
79
+ const all = []
80
+ for (let seq = 0; seq <= maxSeq; seq += 1) {
81
+ const row = reader.read(seq)
82
+ if (row) all.push(row)
83
+ }
84
+ // 缺轮号的行向后取最近一个有轮号的行:一是这段行为只追加,
85
+ // 二是「一轮的第一条 user/message」后面紧跟的正是同一轮的行。
86
+ const ahead = new Map()
87
+ const turns = new Array(all.length).fill(null)
88
+ for (let index = all.length - 1; index >= 0; index -= 1) {
89
+ const row = all[index]
90
+ if (Number.isInteger(row.turn)) ahead.set(row.session, row.turn)
91
+ turns[index] = Number.isInteger(row.turn) ? row.turn : (ahead.get(row.session) ?? null)
92
+ }
93
+ let rows = 0
94
+ for (const [index, row] of all.entries()) {
95
+ writer.append({
96
+ sessionId: row.session,
97
+ at: row.at,
98
+ kind: row.kind,
99
+ turn: turns[index],
100
+ step: row.step ?? null,
101
+ payload: payloadOf(row),
102
+ })
103
+ rows += 1
104
+ }
105
+ writer.close()
106
+
107
+ // 会话的事件游标与标题不能丢:前者决定幂等,后者是目录。
108
+ const state = readJson(stateFile(next), { version: 1, seq: -1, hash: GENESIS, sessions: {} })
109
+ for (const [sessionId, info] of Object.entries(oldState.sessions ?? {})) {
110
+ const target = state.sessions?.[sessionId]
111
+ if (!target) continue
112
+ if (Number.isFinite(info.lastEventSeq)) target.lastEventSeq = info.lastEventSeq
113
+ }
114
+ state.version = FORMAT_VERSION
115
+ writeJson(stateFile(next), state)
116
+ for (const [sessionId, meta] of oldMetas) {
117
+ const file = metaFile(next, sessionId)
118
+ const nextMeta = readJson(file, null)
119
+ if (!nextMeta) continue
120
+ if (meta.title) nextMeta.title = meta.title
121
+ writeJson(file, nextMeta)
122
+ }
123
+
124
+ const mediaDir = join(root, 'media')
125
+ if (existsSync(mediaDir)) cpSync(mediaDir, join(next, 'media'), { recursive: true, force: true })
126
+
127
+ const backup = `${root}.bak-${new Date(now()).toISOString().replace(/[:.]/g, '-')}`
128
+ // Windows 上目录里只要有一个文件被别人打开着,改名就会被拒(EPERM/EBUSY)。
129
+ // 启动时星图那边可能正在读记忆,所以这里重试几次,别让整次迁移白做。
130
+ swapWithRetry(root, backup, log)
131
+ log(`[dsh-lpipe-memory] 已重建 ${rows} 行(旧库备份在 ${backup})`)
132
+ return { rows, backup }
133
+ }
134
+
135
+ /** 把旧目录挪开、新目录就位;被占用时等一会儿再试。 */
136
+ function swapWithRetry(root, backup, log, attempts = 8, waitMs = 400) {
137
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
138
+ try {
139
+ renameSync(root, backup)
140
+ renameSync(`${root}.next`, root)
141
+ return true
142
+ } catch (error) {
143
+ const code = error?.code ?? ''
144
+ const transient = code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'
145
+ // 第一次改名成了、第二次没成:把旧目录放回去,下次再来。
146
+ try {
147
+ if (existsSync(backup) && !existsSync(root)) renameSync(backup, root)
148
+ } catch {}
149
+ if (!transient || attempt === attempts) throw error
150
+ log(`[dsh-lpipe-memory] 迁移第 ${attempt} 次改名被占用(${code}),稍后重试`)
151
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs)
152
+ }
153
+ }
154
+ return false
155
+ }
156
+
157
+ /** 启动时按需迁移;失败只报一句,绝不挡住写入。 */
158
+ export function migrateIfNeeded({ root, log = () => {} } = {}) {
159
+ if (!existsSync(stateFile(root))) return null
160
+ if (!needsMigration(root)) return null
161
+ try {
162
+ return compact({ root, log })
163
+ } catch (error) {
164
+ log(`[dsh-lpipe-memory] 迁移失败,继续用旧格式:${error?.message ?? error}`)
165
+ return null
166
+ }
167
+ }
package/src/day.mjs ADDED
@@ -0,0 +1,15 @@
1
+ // 天界:凌晨 4 点换天,按本地时间算。
2
+ export const DAY_BOUNDARY_HOURS = 4
3
+
4
+ /**
5
+ * `at` 落在哪一天。先把时间往回拨 4 小时,再取本地日期——
6
+ * 于是 04:00 到次日 03:59 之间发生的一切都归到同一天。
7
+ */
8
+ export function dayOf(at) {
9
+ if (!Number.isFinite(at)) throw new TypeError('dayOf 需要一个毫秒时间戳')
10
+ const shifted = new Date(at - DAY_BOUNDARY_HOURS * 3600_000)
11
+ const year = shifted.getFullYear()
12
+ const month = String(shifted.getMonth() + 1).padStart(2, '0')
13
+ const day = String(shifted.getDate()).padStart(2, '0')
14
+ return `${year}-${month}-${day}`
15
+ }
package/src/frames.mjs ADDED
@@ -0,0 +1,12 @@
1
+ // 帧:写盘与读回都按 8 KB 一帧压缩,每帧独立可解。
2
+ import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
3
+
4
+ export const FRAME_BYTES = 8 * 1024
5
+
6
+ export function compressFrame(text) {
7
+ return zstdCompressSync(Buffer.from(text, 'utf8'))
8
+ }
9
+
10
+ export function decompressFrame(bytes) {
11
+ return zstdDecompressSync(bytes).toString('utf8')
12
+ }
package/src/index.js ADDED
@@ -0,0 +1,183 @@
1
+ // 宿主半边:订阅 session/event,把白名单事件逐条追加进记忆。
2
+ import { readSessionTitle } from 'dsh-life-pipline/session-title'
3
+
4
+ import { captureMedia } from './media.mjs'
5
+ import { migrateIfNeeded } from './compact.mjs'
6
+ import { packColdDays } from './pack.mjs'
7
+ import { memoryRoot } from './paths.mjs'
8
+ import { project } from './project.mjs'
9
+ import { MemoryReader } from './reader.mjs'
10
+ import { createMemoryRpcHandler, registerMemoryRpc } from './rpc.mjs'
11
+ import { createStatsCache } from './stats.mjs'
12
+ import { MemoryWriter } from './writer.mjs'
13
+
14
+ export const name = 'dsh-lpipe-memory'
15
+
16
+ export const inject = []
17
+
18
+ /**
19
+ * 一轮结束才落盘:一轮之内的行先攒在内存里,收到 turn/end 才一次性写进去。
20
+ * 盘上不会出现半截的轮次;没说完的轮次宁可不存,也不存一半。
21
+ */
22
+ const TURN_END = 'turn/end'
23
+
24
+ export function apply(ctx, options = {}) {
25
+ const root = options.root ?? memoryRoot()
26
+ // 宿主没给 logger 时别把失败吞掉:迁移、落盘出了问题要能看见。
27
+ const warn = (message) => {
28
+ if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(message)
29
+ else console.warn(message)
30
+ }
31
+ // 旧格式(行里重复存 parts)在开写之前先重建,哈希链整条重算;失败就继续用旧格式。
32
+ if (!options.writer) migrateIfNeeded({ root, log: warn })
33
+ const writer = options.writer ?? new MemoryWriter({ root })
34
+ const reader = new MemoryReader({ root })
35
+ const stats = createStatsCache({ reader, root })
36
+ let attachments = null
37
+ /** sessionId -> 这一轮已经攒下的行 */
38
+ const buffered = new Map()
39
+
40
+ /** 把某个会话正在攒的轮次落盘;没有就什么也不做。 */
41
+ const commit = (sessionId, turn = null) => {
42
+ const rows = buffered.get(sessionId)
43
+ buffered.delete(sessionId)
44
+ if (!rows || rows.length === 0) return 0
45
+ // user/message 事件本身不带轮号:用 turn/end 递过来的那个补上,
46
+ // 否则这一行在星图里会被当成「没有轮次」整条跳过。
47
+ const known = Number.isInteger(turn)
48
+ ? turn
49
+ : (rows.find((row) => Number.isInteger(row.turn))?.turn ?? null)
50
+ const filled = known === null
51
+ ? rows
52
+ : rows.map((row) => (Number.isInteger(row.turn) ? row : { ...row, turn: known }))
53
+ try {
54
+ for (const row of filled) writer.append(row)
55
+ writer.flush()
56
+ refresh()
57
+ return rows.length
58
+ } catch (error) {
59
+ warn(`[dsh-lpipe-memory] 落盘失败:${error?.message ?? error}`)
60
+ return 0
61
+ }
62
+ }
63
+
64
+ // 落盘之后游标变了:读端与统计都得重新读一遍,页面才会看见这一轮。
65
+ const refresh = () => {
66
+ try {
67
+ reader.reload()
68
+ } catch (error) {
69
+ warn(`[dsh-lpipe-memory] 重读失败:${error?.message ?? error}`)
70
+ }
71
+ stats.invalidate()
72
+ }
73
+
74
+ ctx.effect(
75
+ () => () => {
76
+ // 卸载时还没说完的轮次不落盘,只把已落盘的部分收尾。
77
+ buffered.clear()
78
+ writer.close()
79
+ },
80
+ 'dsh-lpipe-memory: close on teardown',
81
+ )
82
+
83
+ ctx.inject(['connection'], (connectionCtx) => {
84
+ try {
85
+ registerMemoryRpc(
86
+ connectionCtx,
87
+ createMemoryRpcHandler({ stats: (query) => stats.get(query) }),
88
+ )
89
+ } catch (error) {
90
+ warn(`[dsh-lpipe-memory] RPC 注册失败:${error?.message ?? error}`)
91
+ }
92
+ })
93
+
94
+ ctx.inject(['attachments'], (attachmentCtx) => {
95
+ attachments = attachmentCtx.attachments ?? null
96
+ })
97
+
98
+ // 冷日打包:启动后补做一次,之后每 6 小时一次;只碰「今天以外」的日子,打过的直接跳过。
99
+ if (options.pack !== false) {
100
+ // 必须写成 ctx.logger.info():Cordis 的 logger 是可调用对象,方法取出来再调用会丢 this。
101
+ // 日志本身再出错也不能往外抛——宿主会把插件的异常当成致命加载失败。
102
+ const note = (message) => {
103
+ try {
104
+ if (typeof ctx.logger?.info === 'function') ctx.logger.info(message)
105
+ else if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(message)
106
+ } catch {
107
+ // 日志写不进去就算了,打包已经完成
108
+ }
109
+ }
110
+ const sweep = () => {
111
+ try {
112
+ const result = packColdDays(root, { log: warn })
113
+ if (result.packed > 0) {
114
+ stats.invalidate()
115
+ note(`[dsh-lpipe-memory] 冷日打包 ${result.packed} 天:${result.before} → ${result.after} 字节`)
116
+ }
117
+ } catch (error) {
118
+ warn(`[dsh-lpipe-memory] 冷日打包失败:${error?.message ?? error}`)
119
+ }
120
+ }
121
+ const first = setTimeout(sweep, Number.isFinite(options.packDelay) ? options.packDelay : 5000)
122
+ const repeat = setInterval(sweep, 6 * 60 * 60 * 1000)
123
+ first.unref?.()
124
+ repeat.unref?.()
125
+ ctx.effect(
126
+ () => () => {
127
+ clearTimeout(first)
128
+ clearInterval(repeat)
129
+ },
130
+ 'dsh-lpipe-memory: settle cold days',
131
+ )
132
+ }
133
+
134
+ ctx.inject(['sessions'], (sessionsCtx) => {
135
+ sessionsCtx.on('session/event', (session, event) => {
136
+ const sessionId = session?.id ?? ''
137
+ try {
138
+ // 轮次结束本身不入档,但它是这一轮完成的信号。
139
+ if (event?.type === TURN_END) {
140
+ commit(sessionId, event?.data?.turn ?? event?.turn ?? null)
141
+ return
142
+ }
143
+ const projected = project(event)
144
+ if (!projected) return
145
+ let payload = projected.payload
146
+ if (Array.isArray(payload?.parts) && payload.parts.some((part) => part?.attachment)) {
147
+ const captured = captureMedia({ attachments, parts: payload.parts, root, log: warn, sessionId, at: event?.time })
148
+ payload = { ...payload, parts: captured.parts, media: captured.media }
149
+ }
150
+ const rows = buffered.get(sessionId) ?? []
151
+ // 新一轮开始了:上一轮若没收到 turn/end,先把它收掉,别两轮混在一起。
152
+ if (
153
+ projected.kind === 'user/message' &&
154
+ rows.length > 0 &&
155
+ projected.turn !== null &&
156
+ rows[0].turn !== null &&
157
+ projected.turn !== rows[0].turn
158
+ ) {
159
+ commit(sessionId, projected.turn)
160
+ }
161
+ const target = buffered.get(sessionId) ?? []
162
+ target.push({
163
+ sessionId,
164
+ eventSeq: event?.seq,
165
+ at: event?.time,
166
+ kind: projected.kind,
167
+ turn: projected.turn,
168
+ step: projected.step,
169
+ payload,
170
+ })
171
+ buffered.set(sessionId, target)
172
+ if (projected.kind === 'user/message' && event?.seq === 0) {
173
+ const title = readSessionTitle?.(sessionsCtx.sessions, sessionId)
174
+ if (title?.title) writer.setTitle(sessionId, title.title)
175
+ }
176
+ } catch (error) {
177
+ warn(`[dsh-lpipe-memory] 写入失败:${error?.message ?? error}`)
178
+ }
179
+ })
180
+ })
181
+
182
+ return { writer, reader, stats }
183
+ }
package/src/media.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // media 转存:把图片/附件的字节搬进「它所属的那一天」的媒体包,行里只留坐标。
2
+ import { createHash } from 'node:crypto'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ import { dayOf } from './day.mjs'
6
+ import { appendMedia } from './mediapack.mjs'
7
+
8
+ const MAGIC = [
9
+ { ext: 'png', mediaType: 'image/png', test: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
10
+ { ext: 'jpg', mediaType: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
11
+ { ext: 'gif', mediaType: 'image/gif', test: (b) => b.slice(0, 4).toString('latin1') === 'GIF8' },
12
+ {
13
+ ext: 'webp',
14
+ mediaType: 'image/webp',
15
+ test: (b) => b.slice(0, 4).toString('latin1') === 'RIFF' && b.slice(8, 12).toString('latin1') === 'WEBP',
16
+ },
17
+ ]
18
+
19
+ /** 认格式只看魔数,不信声明。认不出就当二进制存。 */
20
+ export function sniff(bytes) {
21
+ for (const entry of MAGIC) {
22
+ if (bytes.length >= 12 && entry.test(bytes)) return { ext: entry.ext, mediaType: entry.mediaType }
23
+ }
24
+ return { ext: 'bin', mediaType: 'application/octet-stream' }
25
+ }
26
+
27
+ function hostPathOf(attachments, ref) {
28
+ if (typeof ref !== 'object' || ref === null) return undefined
29
+ if (typeof ref.attachmentId === 'string' && ref.attachmentId.length === 0) return undefined
30
+ if (typeof attachments?.imageHostPath === 'function') {
31
+ try {
32
+ const path = attachments.imageHostPath(ref)
33
+ if (path) return path
34
+ } catch {
35
+ // 不是图片引用,接着试文件
36
+ }
37
+ }
38
+ if (typeof attachments?.fileHostPath === 'function') {
39
+ try {
40
+ return attachments.fileHostPath(ref) ?? undefined
41
+ } catch {
42
+ return undefined
43
+ }
44
+ }
45
+ return undefined
46
+ }
47
+
48
+ /**
49
+ * 把内容块里的附件字节搬进当天那一个媒体包。
50
+ * 返回新的 parts(含 sha256)与这一行要记的 media 清单;字节本身绝不进日志。
51
+ */
52
+ export function captureMedia({ attachments, parts, root, log, sessionId, at }) {
53
+ if (!Array.isArray(parts) || parts.length === 0) return { parts, media: [] }
54
+ const media = []
55
+ const items = []
56
+ const next = []
57
+ for (const part of parts) {
58
+ const ref = part?.attachment
59
+ if ((part?.type !== 'image' && part?.type !== 'file') || !ref) {
60
+ next.push(part)
61
+ continue
62
+ }
63
+ try {
64
+ const path = hostPathOf(attachments, ref)
65
+ if (!path) throw new Error('宿主没给这个附件的落盘路径')
66
+ const bytes = readFileSync(path)
67
+ const sha256 = createHash('sha256').update(bytes).digest('hex')
68
+ const declared = typeof ref.mediaType === 'string' ? ref.mediaType : ''
69
+ const sniffed = sniff(bytes)
70
+ // 声明的类型只在它比魔数更具体时才算数:文件附件常带 application/octet-stream。
71
+ const trusted = declared !== '' && declared !== 'application/octet-stream'
72
+ const mediaType = trusted ? declared : sniffed.mediaType
73
+ const ext = trusted ? declared.split('/')[1]?.replace('jpeg', 'jpg') || sniffed.ext : sniffed.ext
74
+ items.push({ sha256, ext, mediaType, bytes, name: ref.name ?? undefined })
75
+ const record = { sha256, mediaType, bytes: bytes.length, name: ref.name ?? undefined }
76
+ media.push(record)
77
+ next.push({ ...part, sha256, mediaType, bytes: bytes.length })
78
+ } catch (error) {
79
+ const reason = String(error?.message ?? error).split('\n')[0]
80
+ log?.(`[dsh-lpipe-memory] 附件没转存:${reason}`)
81
+ next.push({ ...part, mediaError: reason })
82
+ }
83
+ }
84
+ if (items.length > 0) {
85
+ if (!sessionId) {
86
+ log?.('[dsh-lpipe-memory] 附件没转存:不知道属于哪个会话')
87
+ } else {
88
+ try {
89
+ appendMedia(root, sessionId, dayOf(Number.isFinite(at) ? at : Date.now()), items)
90
+ } catch (error) {
91
+ const reason = String(error?.message ?? error).split('\n')[0]
92
+ log?.(`[dsh-lpipe-memory] 媒体包写入失败:${reason}`)
93
+ }
94
+ }
95
+ }
96
+ return { parts: next, media }
97
+ }