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.
@@ -0,0 +1,144 @@
1
+ // 媒体包:图片不再散在全局 media/ 里,跟着它所属的那一天走。
2
+ //
3
+ // 一天一个包:`days/<day>.media.zst`,追加式帧。一帧 = 一行表头 + 若干张图的原始字节,
4
+ // 表头记下每张图在自己这一帧明文里的偏移与长度,`days/<day>.media.idx` 再记下
5
+ // 「这一帧在包里从哪开始、多长」,于是取一张图只解那一帧、只切一段。
6
+ //
7
+ // 图片本身已经是压缩格式,用等级 1 —— 再往上压不动,白白费时间。
8
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync } from 'node:fs'
9
+ import { dirname } from 'node:path'
10
+ import { constants as C, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
11
+
12
+ import { mediaIdxFile, mediaPackFile } from './paths.mjs'
13
+
14
+ export const MEDIA_LEVEL = 1
15
+
16
+ function readLines(file) {
17
+ if (!existsSync(file)) return []
18
+ return readFileSync(file, 'utf8').split('\n').filter(Boolean)
19
+ }
20
+
21
+ function readRange(file, position, length) {
22
+ const fd = openSync(file, 'r')
23
+ try {
24
+ const buffer = Buffer.allocUnsafe(length)
25
+ readSync(fd, buffer, 0, length, position)
26
+ return buffer
27
+ } finally {
28
+ closeSync(fd)
29
+ }
30
+ }
31
+
32
+ /** 这一天的媒体索引:sha256 → (帧位置, 帧长, 帧内偏移, 字节数)。 */
33
+ export function mediaIndex(root, sessionId, day) {
34
+ const index = new Map()
35
+ for (const line of readLines(mediaIdxFile(root, sessionId, day))) {
36
+ try {
37
+ const item = JSON.parse(line)
38
+ if (typeof item?.s === 'string') index.set(item.s, item)
39
+ } catch {
40
+ // 坏行跳过:最坏结果是那张图要重新转存一次
41
+ }
42
+ }
43
+ return index
44
+ }
45
+
46
+ /** 把一批图追加进这一天的媒体包;已经在这一天里的直接跳过。 */
47
+ export function appendMedia(root, sessionId, day, items, { level = MEDIA_LEVEL } = {}) {
48
+ const pack = mediaPackFile(root, sessionId, day)
49
+ const known = mediaIndex(root, sessionId, day)
50
+ const fresh = []
51
+ const seen = new Set()
52
+ for (const item of items) {
53
+ const sha = item?.sha256
54
+ if (typeof sha !== 'string' || !Buffer.isBuffer(item?.bytes)) continue
55
+ if (known.has(sha) || seen.has(sha)) continue
56
+ seen.add(sha)
57
+ fresh.push(item)
58
+ }
59
+ if (fresh.length === 0) return { added: 0, skipped: items.length }
60
+
61
+ const header = []
62
+ let cursor = 0
63
+ for (const item of fresh) {
64
+ header.push({ s: item.sha256, o: cursor, l: item.bytes.length })
65
+ cursor += item.bytes.length
66
+ }
67
+ const head = Buffer.from(JSON.stringify(header) + '\n', 'utf8')
68
+ const payload = Buffer.concat([head, ...fresh.map((item) => item.bytes)])
69
+ const packed = zstdCompressSync(payload, { params: { [C.ZSTD_c_compressionLevel]: level } })
70
+
71
+ mkdirSync(dirname(pack), { recursive: true })
72
+ const at = existsSync(pack) ? statSync(pack).size : 0
73
+ appendFileSync(pack, packed)
74
+ const lines = fresh.map((item, i) => JSON.stringify({
75
+ s: item.sha256,
76
+ a: at,
77
+ z: packed.length,
78
+ o: head.length + header[i].o,
79
+ l: item.bytes.length,
80
+ e: item.ext ?? 'bin',
81
+ t: item.mediaType ?? 'application/octet-stream',
82
+ n: item.name ?? undefined,
83
+ }))
84
+ appendFileSync(mediaIdxFile(root, sessionId, day), lines.join('\n') + '\n')
85
+ return { added: fresh.length, skipped: items.length - fresh.length }
86
+ }
87
+
88
+ /** 取一张图的字节;这一天里没有就返回 null。 */
89
+ export function readMedia(root, sessionId, day, sha256) {
90
+ const entry = mediaIndex(root, sessionId, day).get(sha256)
91
+ if (!entry) return null
92
+ const whole = readRange(mediaPackFile(root, sessionId, day), entry.a, entry.z)
93
+ const payload = zstdDecompressSync(whole)
94
+ return payload.subarray(entry.o, entry.o + entry.l)
95
+ }
96
+
97
+ /** 这一天的媒体包在磁盘上占多少(包 + 索引)。 */
98
+ export function mediaPackBytes(root, sessionId, day) {
99
+ let total = 0
100
+ for (const file of [mediaPackFile(root, sessionId, day), mediaIdxFile(root, sessionId, day)]) {
101
+ try {
102
+ total += statSync(file).size
103
+ } catch {
104
+ // 没有媒体就是 0
105
+ }
106
+ }
107
+ return total
108
+ }
109
+
110
+
111
+ /** 这一天的每张图在磁盘上摊到多少字节:帧的压缩长度按原字节比例分给帧内的图。 */
112
+ export function mediaShares(root, sessionId, day) {
113
+ const shares = new Map()
114
+ const index = mediaIndex(root, sessionId, day)
115
+ if (index.size === 0) return shares
116
+
117
+ let idxBytes = 0
118
+ try {
119
+ idxBytes = statSync(mediaIdxFile(root, sessionId, day)).size
120
+ } catch {
121
+ idxBytes = 0
122
+ }
123
+
124
+ const byFrame = new Map()
125
+ let totalWeight = 0
126
+ for (const entry of index.values()) {
127
+ const weight = Number(entry.l) || 1
128
+ totalWeight += weight
129
+ const key = `${entry.a}|${entry.z}`
130
+ const list = byFrame.get(key) ?? { frame: Number(entry.z) || 0, rows: [] }
131
+ list.rows.push({ sha: entry.s, weight })
132
+ byFrame.set(key, list)
133
+ }
134
+
135
+ for (const { frame, rows } of byFrame.values()) {
136
+ const sum = rows.reduce((acc, item) => acc + item.weight, 0) || 1
137
+ for (const item of rows) {
138
+ const packed = (frame * item.weight) / sum
139
+ const meta = (idxBytes * item.weight) / (totalWeight || 1)
140
+ shares.set(item.sha, packed + meta)
141
+ }
142
+ }
143
+ return shares
144
+ }
package/src/pack.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // 冷日打包:过完的那一天整天重打成一条 zstd 流,等级高,能看见整天的上下文。
2
+ // 正在写的那一天不动——追加只压新的一段,代价低;整条流一重写就要重来看不见的甜头。
3
+ import { existsSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
4
+ import { constants as C, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
5
+
6
+ import { dayOf } from './day.mjs'
7
+ import { decompressFrame } from './frames.mjs'
8
+ import { dayFile, daysDir, framesFile, idxFile, sessionsDir } from './paths.mjs'
9
+
10
+ /** 冷日的压缩等级:只有落盘那一刻付一次,读的时候照样只解一条流。 */
11
+ export const COLD_LEVEL = 19
12
+
13
+ function readLines(file) {
14
+ if (!existsSync(file)) return []
15
+ return readFileSync(file, 'utf8').split('\n').filter(Boolean)
16
+ }
17
+
18
+ function writeAtomic(file, bytes) {
19
+ const tmp = `${file}.pack-${process.pid}`
20
+ writeFileSync(tmp, bytes)
21
+ renameSync(tmp, file)
22
+ }
23
+
24
+ /** 这一天是不是已经打成一条流了。 */
25
+ export function isPacked(root, sessionId, day, level = COLD_LEVEL) {
26
+ if (!existsSync(dayFile(root, sessionId, day))) return false
27
+ const frames = readLines(framesFile(root, sessionId, day)).map((line) => JSON.parse(line))
28
+ return frames.length === 1 && frames[0]?.lvl === level
29
+ }
30
+
31
+ /**
32
+ * 把一天的多帧重打成一条流:解开每一帧、拼回整天、整段压到一条。
33
+ * 偏移表里的 `o` 原本是「帧内偏移」,这里换算成「整天偏移」,`f` 一律归 0。
34
+ */
35
+ export function packDay(root, sessionId, day, { level = COLD_LEVEL } = {}) {
36
+ const data = dayFile(root, sessionId, day)
37
+ if (!existsSync(data)) return { day, skipped: 'missing' }
38
+ const frames = readLines(framesFile(root, sessionId, day)).map((line) => JSON.parse(line))
39
+ if (frames.length === 0) return { day, skipped: 'no-frames' }
40
+ if (frames.length === 1 && frames[0].lvl === level) return { day, skipped: 'packed' }
41
+
42
+ const before = statSync(data).size
43
+ const bytes = readFileSync(data)
44
+ const plains = frames.map((frame) => decompressFrame(bytes.subarray(frame.at, frame.at + frame.len)))
45
+ const whole = plains.join('')
46
+ const packed = zstdCompressSync(Buffer.from(whole, 'utf8'), {
47
+ params: { [C.ZSTD_c_compressionLevel]: level },
48
+ })
49
+ // 写完先自己解一遍:解出来必须和原文一字不差,才允许替换。
50
+ if (zstdDecompressSync(packed).toString('utf8') !== whole) throw new Error(`打包校验失败:${sessionId}/${day}`)
51
+
52
+ const offsets = []
53
+ let cursor = 0
54
+ for (const plain of plains) {
55
+ offsets.push(cursor)
56
+ cursor += Buffer.byteLength(plain)
57
+ }
58
+ const index = readLines(idxFile(root, sessionId, day)).map((line) => {
59
+ const entry = JSON.parse(line)
60
+ return JSON.stringify({ ...entry, f: 0, o: (offsets[entry.f] ?? 0) + entry.o })
61
+ })
62
+
63
+ writeAtomic(data, packed)
64
+ writeAtomic(
65
+ framesFile(root, sessionId, day),
66
+ Buffer.from(JSON.stringify({ n: 0, at: 0, len: packed.length, lvl: level }) + '\n', 'utf8'),
67
+ )
68
+ writeAtomic(idxFile(root, sessionId, day), Buffer.from(index.join('\n') + '\n', 'utf8'))
69
+ return { day, session: sessionId, before, after: packed.length }
70
+ }
71
+
72
+ /** 把「今天以外」的日子全部打包一遍;已经打过的直接跳过。 */
73
+ export function packColdDays(root, { level = COLD_LEVEL, today = dayOf(Date.now()), log } = {}) {
74
+ const sessions = existsSync(sessionsDir(root)) ? readdirSync(sessionsDir(root)) : []
75
+ const days = []
76
+ for (const sessionId of sessions) {
77
+ const dir = daysDir(root, sessionId)
78
+ if (!existsSync(dir)) continue
79
+ for (const name of readdirSync(dir)) {
80
+ if (!name.endsWith('.jsonl.zst')) continue
81
+ const day = name.replace('.jsonl.zst', '')
82
+ if (day === today) continue
83
+ try {
84
+ const result = packDay(root, sessionId, day, { level })
85
+ if (!result.skipped) days.push(result)
86
+ } catch (error) {
87
+ log?.(`[dsh-lpipe-memory] 冷日打包失败 ${sessionId}/${day}:${error?.message ?? error}`)
88
+ }
89
+ }
90
+ }
91
+ const before = days.reduce((sum, item) => sum + item.before, 0)
92
+ const after = days.reduce((sum, item) => sum + item.after, 0)
93
+ return { packed: days.length, before, after }
94
+ }
package/src/paths.mjs ADDED
@@ -0,0 +1,54 @@
1
+ // 路径规则:一条会话一个目录,目录里按天分卷。
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ /** 记忆根目录。DSH_HOME 与宿主一致,没设就退回 ~/.dsh。 */
6
+ export function memoryRoot(env = process.env) {
7
+ const base = typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() ? env.DSH_HOME.trim() : join(homedir(), '.dsh')
8
+ return join(base, 'life-memory')
9
+ }
10
+
11
+ export function sessionsDir(root) {
12
+ return join(root, 'sessions')
13
+ }
14
+
15
+ export function sessionDir(root, sessionId) {
16
+ return join(sessionsDir(root), sessionId)
17
+ }
18
+
19
+ export function daysDir(root, sessionId) {
20
+ return join(sessionDir(root, sessionId), 'days')
21
+ }
22
+
23
+ export function dayFile(root, sessionId, day) {
24
+ return join(daysDir(root, sessionId), `${day}.jsonl.zst`)
25
+ }
26
+
27
+ export function idxFile(root, sessionId, day) {
28
+ return join(daysDir(root, sessionId), `${day}.idx`)
29
+ }
30
+
31
+ export function framesFile(root, sessionId, day) {
32
+ return join(daysDir(root, sessionId), `${day}.frames`)
33
+ }
34
+
35
+ export function metaFile(root, sessionId) {
36
+ return join(sessionDir(root, sessionId), 'meta.json')
37
+ }
38
+
39
+ export function stateFile(root) {
40
+ return join(root, 'state.json')
41
+ }
42
+
43
+ export function mediaPackFile(root, sessionId, day) {
44
+ return join(daysDir(root, sessionId), `${day}.media.zst`)
45
+ }
46
+
47
+ export function mediaIdxFile(root, sessionId, day) {
48
+ return join(daysDir(root, sessionId), `${day}.media.idx`)
49
+ }
50
+
51
+ /** media 全局共享、内容寻址:同一份字节在两个会话里只存一次。 */
52
+ export function mediaFile(root, sha256, ext = 'bin') {
53
+ return join(root, 'media', sha256.slice(0, 2), sha256.slice(2, 4), `${sha256}.${ext}`)
54
+ }
@@ -0,0 +1,132 @@
1
+ // 投影:把宿主的会话事件压成记忆行。白名单之外一律不写。
2
+ const KIND = {
3
+ 'user/message': 'user/message',
4
+ 'assistant/message': 'assistant/message',
5
+ 'tool/call': 'tool/call',
6
+ 'tool/result': 'tool/result',
7
+ }
8
+
9
+ export const STORED_KINDS = new Set(Object.values(KIND))
10
+
11
+ /**
12
+ * 注入进来的上下文:不是说过的话,不写入。
13
+ *
14
+ * 除了运行期快照,还包括管线自己注入的东西——回忆、联想、想象。它们是程序拼给模型看的,
15
+ * 写进记忆就等于把管线的加工结果当成原始对话,越滚越多。
16
+ */
17
+ const INJECTED_SOURCES = new Set([
18
+ 'runtime-context',
19
+ 'dsh-lpipe-recall',
20
+ 'dsh-lpipe-assoc',
21
+ 'dsh-lpipe-imagine',
22
+ ])
23
+
24
+ /** 管线工具的命名约定:recall_* / assoc_* / imagine_*。 */
25
+ const PIPELINE_TOOL = /^(recall_|assoc_|imagine_)/
26
+
27
+ /** 记下管线工具的 callId,好把它们的「结果」也一并丢掉(结果行本身不带工具名)。 */
28
+ const pipelineCalls = new Set()
29
+
30
+ function markPipelineCall(callId) {
31
+ const id = String(callId ?? '')
32
+ if (!id) return
33
+ pipelineCalls.add(id)
34
+ // 只留最近的一批,避免长会话里越攒越多。
35
+ if (pipelineCalls.size > 512) {
36
+ const oldest = pipelineCalls.values().next().value
37
+ pipelineCalls.delete(oldest)
38
+ }
39
+ }
40
+
41
+ export function resetPipelineCalls() {
42
+ pipelineCalls.clear()
43
+ }
44
+
45
+ /** 把消息里的内容块拆成「给人看的文本」与「结构化块」。 */
46
+ function splitContent(content) {
47
+ if (typeof content === 'string') return { text: content, parts: [{ type: 'text', text: content }] }
48
+ if (!Array.isArray(content)) return { text: '', parts: [] }
49
+ const texts = []
50
+ for (const part of content) {
51
+ if (part?.type === 'text' && typeof part.text === 'string') texts.push(part.text)
52
+ }
53
+ return { text: texts.join('\n'), parts: content }
54
+ }
55
+
56
+ function reasoningOf(parts) {
57
+ const chunks = []
58
+ for (const part of parts ?? []) {
59
+ if (part?.type === 'reasoning' && typeof part.text === 'string') chunks.push(part.text)
60
+ }
61
+ return chunks.join('\n')
62
+ }
63
+
64
+ /**
65
+ * 只留搬不走的结构块:文本与思考已经单独存在 `text` / `reasoning` 里,
66
+ * 再存一份就是纯重复(历史上一度让 42% 的体积变成副本)。
67
+ * 图片、文件、工具结构化结果没有别的落点,必须留着。
68
+ */
69
+ export function carriedParts(parts) {
70
+ if (!Array.isArray(parts)) return []
71
+ return parts.filter((part) => part && part.type !== 'text' && part.type !== 'reasoning')
72
+ }
73
+
74
+ function payloadWith(parts, extra) {
75
+ const carried = carriedParts(parts)
76
+ return carried.length > 0 ? { ...extra, parts: carried } : extra
77
+ }
78
+
79
+ /** 一条事件要么投影成行,要么返回 null(不入档)。 */
80
+ export function project(event) {
81
+ const kind = KIND[event?.type]
82
+ if (!kind) return null
83
+ const data = event.data ?? {}
84
+ const turn = Number.isFinite(data.turn) ? data.turn : null
85
+ const step = Number.isFinite(data.step) ? data.step : null
86
+
87
+ if (kind === 'user/message') {
88
+ // 运行期注入的上下文不是「说过的话」,不进记忆层。
89
+ const source = data.source ?? null
90
+ if (INJECTED_SOURCES.has(source?.kind)) return null
91
+ const { text, parts } = splitContent(data.content)
92
+ return { kind, turn, step, payload: payloadWith(parts, { source, text }) }
93
+ }
94
+
95
+ if (kind === 'assistant/message') {
96
+ const { text, parts } = splitContent(data.message?.content)
97
+ return {
98
+ kind,
99
+ turn,
100
+ step,
101
+ payload: payloadWith(parts, {
102
+ text,
103
+ reasoning: reasoningOf(parts),
104
+ usage: data.usage ?? null,
105
+ interrupted: data.interrupted === true || undefined,
106
+ }),
107
+ }
108
+ }
109
+
110
+ if (kind === 'tool/call') {
111
+ // 管线工具的调用不是「对话里发生的事」,连调用带结果一起不要。
112
+ if (PIPELINE_TOOL.test(String(data.name ?? ''))) {
113
+ markPipelineCall(data.callId)
114
+ return null
115
+ }
116
+ return { kind, turn, step, payload: { callId: data.callId ?? null, name: data.name ?? '', arguments: data.arguments ?? '' } }
117
+ }
118
+
119
+ if (kind === 'tool/result' && pipelineCalls.has(String(data.callId ?? ''))) return null
120
+
121
+ const { text, parts } = splitContent(data.message?.content)
122
+ return {
123
+ kind,
124
+ turn,
125
+ step,
126
+ payload: payloadWith(parts, {
127
+ text,
128
+ isError: data.message?.isError === true || undefined,
129
+ error: data.error ? { name: data.error.name, code: data.error.code, reason: data.error.reason ?? '' } : undefined,
130
+ }),
131
+ }
132
+ }