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/src/reader.mjs ADDED
@@ -0,0 +1,284 @@
1
+ // 读取:按 seq 只解一帧;时间线跨会话合并;搜索走全扫(冷路径,调用方应放到 worker)。
2
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync } from 'node:fs'
3
+
4
+ import { GENESIS, checkRow } from './chain.mjs'
5
+ import { decompressFrame } from './frames.mjs'
6
+ import { readMedia } from './mediapack.mjs'
7
+ import { dayFile, daysDir, framesFile, idxFile, memoryRoot, stateFile } from './paths.mjs'
8
+
9
+ function readJson(file, fallback) {
10
+ try {
11
+ return JSON.parse(readFileSync(file, 'utf8'))
12
+ } catch {
13
+ return fallback
14
+ }
15
+ }
16
+
17
+ function readLines(file) {
18
+ if (!existsSync(file)) return []
19
+ return readFileSync(file, 'utf8').split('\n').filter(Boolean)
20
+ }
21
+
22
+ function readRange(file, position, length) {
23
+ const fd = openSync(file, 'r')
24
+ try {
25
+ const buffer = Buffer.allocUnsafe(length)
26
+ readSync(fd, buffer, 0, length, position)
27
+ return buffer
28
+ } finally {
29
+ closeSync(fd)
30
+ }
31
+ }
32
+
33
+ export class MemoryReader {
34
+ constructor({ root = memoryRoot(), cacheFrames = 512 } = {}) {
35
+ this.root = root
36
+ this.cacheLimit = cacheFrames
37
+ this.frameCache = new Map()
38
+ // 帧缓存按字节封顶:一天的整包解压后有七八兆,按“帧数”记会把内存吃光。
39
+ this.frameBytes = 0
40
+ this.cacheBytes = 64 * 1024 * 1024
41
+ this.idxCache = new Map()
42
+ this.rowCache = new Map()
43
+ this.dayCache = new Map()
44
+ this.turnCache = new Map()
45
+ this.state = readJson(stateFile(root), { version: 1, seq: -1, hash: GENESIS, sessions: {} })
46
+ }
47
+
48
+ reload() {
49
+ this.state = readJson(stateFile(this.root), this.state)
50
+ this.frameCache.clear()
51
+ this.frameBytes = 0
52
+ this.idxCache.clear()
53
+ this.rowCache.clear()
54
+ this.dayCache.clear()
55
+ this.turnCache.clear()
56
+ return this
57
+ }
58
+
59
+ get size() {
60
+ return Object.keys(this.state.sessions ?? {}).length
61
+ }
62
+
63
+ /** 哪个会话拥有这个 seq。 */
64
+ sessionOf(seq) {
65
+ for (const [sessionId, info] of Object.entries(this.state.sessions ?? {})) {
66
+ if (seq >= info.firstSeq && seq <= info.lastSeq) return sessionId
67
+ }
68
+ return null
69
+ }
70
+
71
+ sessions() {
72
+ return Object.entries(this.state.sessions ?? {})
73
+ .map(([sessionId, info]) => ({ sessionId, firstSeq: info.firstSeq, lastSeq: info.lastSeq, rows: info.rows, lastAt: info.lastAt }))
74
+ .sort((a, b) => a.firstSeq - b.firstSeq)
75
+ }
76
+
77
+ daysOf(sessionId) {
78
+ if (this.dayCache.has(sessionId)) return this.dayCache.get(sessionId)
79
+ const dir = daysDir(this.root, sessionId)
80
+ const days = existsSync(dir)
81
+ ? readdirSync(dir)
82
+ .filter((name) => name.endsWith('.jsonl.zst'))
83
+ .map((name) => name.replace('.jsonl.zst', ''))
84
+ .sort()
85
+ : []
86
+ this.dayCache.set(sessionId, days)
87
+ return days
88
+ }
89
+
90
+ /** 取一行原文。命中帧缓存时零解压。 */
91
+ read(seq) {
92
+ const row = this.raw(seq)
93
+ if (!row || Number.isInteger(row.turn)) return row
94
+ // 早先落盘的 user/message 没有轮号(事件本身不带 turn)。
95
+ // 与其改写整条哈希链去补它,不如读的时候补:同一会话里之后最近一个有轮号的行。
96
+ const hint = this.#turnHints(this.sessionOf(seq)).get(seq)
97
+ return hint === undefined ? row : { ...row, turn: hint }
98
+ }
99
+
100
+ /** 原样读一行,不做任何补全。verify 要的是磁盘上的字节。 */
101
+ raw(seq) {
102
+ if (!Number.isInteger(seq)) throw new TypeError('read 需要整数 seq')
103
+ const sessionId = this.sessionOf(seq)
104
+ if (!sessionId) return null
105
+ const day = this.dayOf(seq)
106
+ if (!day) return null
107
+ const entry = this.#index(sessionId, day).get(seq)
108
+ const frame = this.#frameText(sessionId, day, entry.f)
109
+ return JSON.parse(frame.subarray(entry.o, entry.o + entry.l).toString('utf8'))
110
+ }
111
+
112
+ /** seq → 应当继承的轮号;一个会话只扫一遍。 */
113
+ #turnHints(sessionId) {
114
+ if (this.turnCache.has(sessionId)) return this.turnCache.get(sessionId)
115
+ const hints = new Map()
116
+ const info = this.state.sessions?.[sessionId]
117
+ if (info) {
118
+ let next = null
119
+ for (let seq = info.lastSeq; seq >= info.firstSeq; seq -= 1) {
120
+ const row = this.raw(seq)
121
+ if (!row) continue
122
+ if (Number.isInteger(row.turn)) next = row.turn
123
+ else if (next !== null) hints.set(seq, next)
124
+ }
125
+ }
126
+ this.turnCache.set(sessionId, hints)
127
+ return hints
128
+ }
129
+
130
+ /** 这一行实际躺在哪一天的文件里。行上的 `day` 字段按写入时刻算,换天时可能偏一格。 */
131
+ dayOf(seq) {
132
+ const sessionId = this.sessionOf(seq)
133
+ if (!sessionId) return null
134
+ return this.daysOf(sessionId).find((name) => this.#index(sessionId, name).has(seq)) ?? null
135
+ }
136
+
137
+ /** 取某一行带的那张图;字节在它所属那一天的媒体包里。 */
138
+ media(seq, sha256) {
139
+ const sessionId = this.sessionOf(seq)
140
+ if (!sessionId) return null
141
+ const day = this.dayOf(seq)
142
+ if (!day) return null
143
+ return readMedia(this.root, sessionId, day, sha256)
144
+ }
145
+
146
+ /**
147
+ * 一个会话的全部行(已补好轮号),按 seq 排序,解析一次就缓存。
148
+ * 统计与抽星都要整段原文,逐 seq read 会把同一帧反复解压。
149
+ */
150
+ rows(sessionId = null) {
151
+ const out = []
152
+ for (const info of this.sessions()) {
153
+ if (sessionId && info.sessionId !== sessionId) continue
154
+ const hints = this.#turnHints(info.sessionId)
155
+ for (const row of this.#rows(info.sessionId)) {
156
+ const hint = hints.get(row.seq)
157
+ out.push(hint === undefined || Number.isInteger(row.turn) ? row : { ...row, turn: hint })
158
+ }
159
+ }
160
+ out.sort((a, b) => a.seq - b.seq)
161
+ return out
162
+ }
163
+
164
+ /** 按时间回放;给了 session 就只回放那一个对话。 */
165
+ timeline({ from = 0, to = Number.MAX_SAFE_INTEGER, limit = 50, session = null } = {}) {
166
+ const out = []
167
+ for (const { sessionId } of this.sessions()) {
168
+ if (session && sessionId !== session) continue
169
+ for (const row of this.#rows(sessionId)) {
170
+ if (row.at < from || row.at > to) continue
171
+ out.push({
172
+ seq: row.seq,
173
+ at: row.at,
174
+ day: row.day,
175
+ session: sessionId,
176
+ kind: row.kind,
177
+ turn: row.turn,
178
+ step: row.step,
179
+ text: (row.text ?? row.arguments ?? '').slice(0, 160),
180
+ })
181
+ }
182
+ }
183
+ out.sort((a, b) => a.seq - b.seq)
184
+ return out.slice(0, limit)
185
+ }
186
+
187
+ /** 子串搜索。冷路径:调用方应放进 worker,别卡住宿主。 */
188
+ search({ text, session = null, limit = 50 } = {}) {
189
+ if (!text) return []
190
+ const hits = []
191
+ for (const { sessionId } of this.sessions()) {
192
+ if (session && sessionId !== session) continue
193
+ for (const row of this.#rows(sessionId)) {
194
+ const haystack = [row.text, row.arguments, row.reasoning, row.message].filter(Boolean).join('\n')
195
+ const at = haystack.indexOf(text)
196
+ if (at < 0) continue
197
+ hits.push({
198
+ seq: row.seq,
199
+ at: row.at,
200
+ day: row.day,
201
+ session: sessionId,
202
+ kind: row.kind,
203
+ snippet: haystack.slice(Math.max(0, at - 40), at + text.length + 40),
204
+ })
205
+ if (hits.length >= limit) return hits
206
+ }
207
+ }
208
+ return hits
209
+ }
210
+
211
+ /** 沿哈希链校验。返回问题清单,空数组即完好。 */
212
+ verify({ from = 0, to = Number.MAX_SAFE_INTEGER } = {}) {
213
+ const rows = []
214
+ for (const { sessionId } of this.sessions()) rows.push(...this.#rows(sessionId))
215
+ rows.sort((a, b) => a.seq - b.seq)
216
+ const problems = []
217
+ let prev = GENESIS
218
+ let expectSeq = rows.length ? rows[0].seq : 0
219
+ for (const row of rows) {
220
+ if (row.seq !== expectSeq) problems.push({ seq: row.seq, reason: `seq 不连续:期望 ${expectSeq}` })
221
+ expectSeq = row.seq + 1
222
+ const reason = checkRow(row, prev)
223
+ if (reason && row.seq >= from && row.seq <= to) problems.push({ seq: row.seq, reason })
224
+ prev = row.hash
225
+ }
226
+ return problems
227
+ }
228
+
229
+ // ---- 内部 ----
230
+
231
+ #index(sessionId, day) {
232
+ const key = `${sessionId}|${day}`
233
+ if (this.idxCache.has(key)) return this.idxCache.get(key)
234
+ const index = new Map()
235
+ for (const line of readLines(idxFile(this.root, sessionId, day))) {
236
+ const entry = JSON.parse(line)
237
+ index.set(entry.s, entry)
238
+ }
239
+ this.idxCache.set(key, index)
240
+ return index
241
+ }
242
+
243
+ #frameText(sessionId, day, frameNo) {
244
+ const key = `${sessionId}|${day}|${frameNo}`
245
+ const hit = this.frameCache.get(key)
246
+ if (hit) {
247
+ // 命中就挪到队尾,LRU 才有意义。
248
+ this.frameCache.delete(key)
249
+ this.frameCache.set(key, hit)
250
+ return hit
251
+ }
252
+ const meta = readLines(framesFile(this.root, sessionId, day))
253
+ .map((line) => JSON.parse(line))
254
+ .find((item) => item.n === frameNo)
255
+ if (!meta) throw new Error(`帧 ${frameNo} 不在索引里:${sessionId}/${day}`)
256
+ const buffer = Buffer.from(decompressFrame(readRange(dayFile(this.root, sessionId, day), meta.at, meta.len)), 'utf8')
257
+ this.frameCache.set(key, buffer)
258
+ this.frameBytes += buffer.length
259
+ while (this.frameCache.size > this.cacheLimit || this.frameBytes > this.cacheBytes) {
260
+ const oldest = this.frameCache.keys().next().value
261
+ if (oldest === key) break
262
+ const dropped = this.frameCache.get(oldest)
263
+ this.frameCache.delete(oldest)
264
+ this.frameBytes -= dropped?.length ?? 0
265
+ }
266
+ return buffer
267
+ }
268
+
269
+ #rows(sessionId) {
270
+ if (this.rowCache.has(sessionId)) return this.rowCache.get(sessionId)
271
+ const rows = []
272
+ for (const day of this.daysOf(sessionId)) {
273
+ const file = dayFile(this.root, sessionId, day)
274
+ for (const frame of readLines(framesFile(this.root, sessionId, day)).map((line) => JSON.parse(line))) {
275
+ for (const line of decompressFrame(readRange(file, frame.at, frame.len)).split('\n')) {
276
+ if (line) rows.push(JSON.parse(line))
277
+ }
278
+ }
279
+ }
280
+ rows.sort((a, b) => a.seq - b.seq)
281
+ this.rowCache.set(sessionId, rows)
282
+ return rows
283
+ }
284
+ }
package/src/rpc.mjs ADDED
@@ -0,0 +1,77 @@
1
+ // 记忆管线的 RPC:只读,给设置页看大小曲线用。
2
+ export const MEMORY_RPC_ENDPOINT = 'dsh-lpipe-memory'
3
+
4
+ const METHODS = new Set(['memory.stats'])
5
+
6
+ function failure(code, message) {
7
+ return { ok: false, error: { code, message, details: {} } }
8
+ }
9
+
10
+ function isRecord(value) {
11
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
12
+ }
13
+
14
+ function clampTurns(value) {
15
+ const turns = Number(value)
16
+ if (!Number.isFinite(turns)) return 30
17
+ return Math.min(120, Math.max(1, Math.round(turns)))
18
+ }
19
+
20
+ export function createMemoryRpcHandler({ stats }) {
21
+ return async function handle(method, payload) {
22
+ if (!METHODS.has(method)) return failure('memory/unknown-method', `Unknown method: ${method}`)
23
+ try {
24
+ if (method === 'memory.stats') {
25
+ return { ok: true, value: stats({ turns: clampTurns(payload?.turns) }) }
26
+ }
27
+ return failure('memory/unknown-method', `Unknown method: ${method}`)
28
+ } catch (error) {
29
+ return failure('memory/failed', String(error?.message ?? error))
30
+ }
31
+ }
32
+ }
33
+
34
+ /** 挂在宿主自己的 /api 载体上,鉴权与来源校验由宿主先做掉。 */
35
+ export function registerMemoryRpc(ctx, handle) {
36
+ const registry = ctx?.connection?.fetch
37
+ if (typeof registry?.register !== 'function') {
38
+ throw new TypeError('dsh-lpipe-memory needs the Host Connection Fetch registry')
39
+ }
40
+ return registry.register({
41
+ path: `/api/${MEMORY_RPC_ENDPOINT}`,
42
+ methods: ['POST'],
43
+ requestBody: 'buffered',
44
+ async fetch(request) {
45
+ if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })
46
+ if (request.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
47
+ return new Response('content type must be application/json', { status: 415 })
48
+ }
49
+ let message
50
+ try {
51
+ message = await request.json()
52
+ } catch {
53
+ return new Response('body is not JSON', { status: 400 })
54
+ }
55
+ const rpcId = typeof message?.rpcId === 'string' ? message.rpcId : 'invalid-request'
56
+ const call = message?.payload
57
+ if (
58
+ !isRecord(message) ||
59
+ message.type !== 'client-request' ||
60
+ typeof message.rpcId !== 'string' ||
61
+ message.method !== MEMORY_RPC_ENDPOINT ||
62
+ !isRecord(call) ||
63
+ typeof call.method !== 'string' ||
64
+ !Object.hasOwn(call, 'payload')
65
+ ) {
66
+ return Response.json({ type: 'server-response', rpcId, result: failure('memory/bad-request', 'Invalid request.') })
67
+ }
68
+ let result
69
+ try {
70
+ result = await handle(call.method, call.payload, request.signal)
71
+ } catch (error) {
72
+ result = failure('memory/failed', String(error?.message ?? error))
73
+ }
74
+ return Response.json({ type: 'server-response', rpcId, result })
75
+ },
76
+ })
77
+ }
package/src/stats.mjs ADDED
@@ -0,0 +1,286 @@
1
+ // 记忆大小的统计:按轮累计「原文」与「落盘」两条曲线,供设置页画图用。
2
+ //
3
+ // 原文 = 每行 JSON 的字节 + 它第一次带来的 media 字节,未压缩。
4
+ // 落盘 = 同样这些行在磁盘上真实占的字节:帧的压缩长度按行字节摊到每一行,
5
+ // 再加上这一天的索引表、帧表,以及 media。
6
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ import { mediaShares } from './mediapack.mjs'
10
+ import { daysDir, framesFile, idxFile, memoryRoot, sessionsDir } from './paths.mjs'
11
+
12
+ /** media 目录树加起来有多大(内容寻址,天然去重)。 */
13
+ function directoryBytes(dir) {
14
+ let total = 0
15
+ let entries
16
+ try {
17
+ entries = readdirSync(dir, { withFileTypes: true })
18
+ } catch {
19
+ return 0
20
+ }
21
+ for (const entry of entries) {
22
+ const full = join(dir, entry.name)
23
+ if (entry.isDirectory()) total += directoryBytes(full)
24
+ else if (entry.isFile()) {
25
+ try {
26
+ total += statSync(full).size
27
+ } catch {
28
+ // 文件刚被挪走,跳过
29
+ }
30
+ }
31
+ }
32
+ return total
33
+ }
34
+
35
+ function fileBytes(file) {
36
+ try {
37
+ return statSync(file).size
38
+ } catch {
39
+ return 0
40
+ }
41
+ }
42
+
43
+ function readLines(file) {
44
+ try {
45
+ return readFileSync(file, 'utf8').split('\n').filter(Boolean)
46
+ } catch {
47
+ return []
48
+ }
49
+ }
50
+
51
+ /** 磁盘上到底占了多少:每天的分卷 + 帧表 + 偏移表 + media。 */
52
+ export function diskUsage(root = memoryRoot()) {
53
+ let bytes = 0
54
+ let sessions = []
55
+ try {
56
+ sessions = readdirSync(sessionsDir(root), { withFileTypes: true })
57
+ .filter((entry) => entry.isDirectory())
58
+ .map((entry) => entry.name)
59
+ } catch {
60
+ return 0
61
+ }
62
+ for (const sessionId of sessions) {
63
+ bytes += directoryBytes(join(daysDir(root, sessionId)))
64
+ }
65
+ bytes += directoryBytes(join(root, 'media'))
66
+ return bytes
67
+ }
68
+
69
+ /**
70
+ * 一天的分卷里,每一行摊到多少磁盘字节。
71
+ *
72
+ * 帧是压缩单位,一行自己压出来多少没法量;所以按行 JSON 长度把帧的压缩长度分下去——
73
+ * 同一帧里长的行分得多。这天的 `.idx` 与 `.frames` 也按同样比例摊进每一行,
74
+ * 否则曲线末尾会比真实磁盘少掉几万个字节。
75
+ */
76
+ function dayShares(root, sessionId, day) {
77
+ const shares = new Map()
78
+ const idx = readLines(idxFile(root, sessionId, day))
79
+ .map((line) => JSON.parse(line))
80
+ .filter((item) => Number.isFinite(item?.s))
81
+ if (idx.length === 0) return shares
82
+
83
+ const frames = new Map()
84
+ let metaBytes = fileBytes(idxFile(root, sessionId, day)) + fileBytes(framesFile(root, sessionId, day))
85
+ for (const line of readLines(framesFile(root, sessionId, day))) {
86
+ const item = JSON.parse(line)
87
+ if (Number.isFinite(item?.n) && Number.isFinite(item?.len)) frames.set(item.n, item.len)
88
+ }
89
+
90
+ const byFrame = new Map()
91
+ for (const item of idx) {
92
+ const list = byFrame.get(item.f) ?? []
93
+ list.push(item)
94
+ byFrame.set(item.f, list)
95
+ }
96
+ const totalWeight = idx.reduce((sum, item) => sum + (Number(item.l) || 1), 0) || 1
97
+
98
+ for (const [frameNo, rows] of byFrame) {
99
+ const weight = rows.reduce((sum, item) => sum + (Number(item.l) || 1), 0) || 1
100
+ const packed = frames.get(frameNo) ?? 0
101
+ for (const item of rows) {
102
+ const l = Number(item.l) || 1
103
+ const compressed = (packed * l) / weight
104
+ const meta = (metaBytes * l) / totalWeight
105
+ shares.set(item.s, compressed + meta)
106
+ }
107
+ }
108
+ return shares
109
+ }
110
+
111
+ /**
112
+ * 全库的「一行摊到多少磁盘字节」。
113
+ *
114
+ * 不按行上的 `day` 去找文件——那个字段是写入时按 `at − 4h` 算的,跨零点时会和
115
+ * 它实际躺在哪个天文件里对不上,查错了文件就摊到 0,缺口最后全堆到最后一轮上。
116
+ * 所以直接扫盘:把每个会话每一天的 `.idx` / `.frames` 读一遍,按 seq 建表。
117
+ */
118
+ /** 全库的「一张图摊到多少磁盘字节」。 */
119
+ function diskMediaShares(root) {
120
+ const shares = new Map()
121
+ let sessions = []
122
+ try {
123
+ sessions = readdirSync(sessionsDir(root), { withFileTypes: true })
124
+ .filter((entry) => entry.isDirectory())
125
+ .map((entry) => entry.name)
126
+ } catch {
127
+ return shares
128
+ }
129
+ for (const sessionId of sessions) {
130
+ let days = []
131
+ try {
132
+ days = readdirSync(daysDir(root, sessionId))
133
+ .filter((name) => name.endsWith('.media.zst'))
134
+ .map((name) => name.slice(0, -'.media.zst'.length))
135
+ } catch {
136
+ continue
137
+ }
138
+ for (const day of days) {
139
+ for (const [sha, bytes] of mediaShares(root, sessionId, day)) shares.set(sha, bytes)
140
+ }
141
+ }
142
+ return shares
143
+ }
144
+
145
+ function diskShares(root) {
146
+ const shares = new Map()
147
+ let sessions = []
148
+ try {
149
+ sessions = readdirSync(sessionsDir(root), { withFileTypes: true })
150
+ .filter((entry) => entry.isDirectory())
151
+ .map((entry) => entry.name)
152
+ } catch {
153
+ return shares
154
+ }
155
+ for (const sessionId of sessions) {
156
+ let days = []
157
+ try {
158
+ days = readdirSync(daysDir(root, sessionId))
159
+ .filter((name) => name.endsWith('.jsonl.zst'))
160
+ .map((name) => name.slice(0, -'.jsonl.zst'.length))
161
+ } catch {
162
+ continue
163
+ }
164
+ for (const day of days) {
165
+ for (const [seq, bytes] of dayShares(root, sessionId, day)) shares.set(seq, bytes)
166
+ }
167
+ }
168
+ return shares
169
+ }
170
+
171
+ /**
172
+ * 逐轮汇总:每一轮新增多少字节、累计到多少。原文与落盘两条线同起点、同刻度。
173
+ * 一轮的原文按这一轮写下的行(JSON 字节)加它新带来的 media 字节算;
174
+ * 落盘按同样的行在磁盘上的摊分字节算。
175
+ */
176
+ export function computeStats(reader, { turns = 30, root = memoryRoot() } = {}) {
177
+ const state = reader?.state ?? {}
178
+ const lastSeq = Number.isFinite(state.seq) ? state.seq : -1
179
+ const groups = new Map()
180
+ const seenMedia = new Set()
181
+ const shares = diskShares(root)
182
+ const mediaShares_ = diskMediaShares(root)
183
+ let total = 0
184
+ let diskTotal = 0
185
+ let rows = 0
186
+
187
+ let all = []
188
+ try {
189
+ all = typeof reader.rows === 'function' ? reader.rows() : []
190
+ } catch {
191
+ all = []
192
+ }
193
+ if (all.length === 0) {
194
+ // 老版本 reader 没有 rows():退回逐 seq,慢一点但不至于没有数。
195
+ for (let seq = 0; seq <= lastSeq; seq += 1) {
196
+ try {
197
+ const row = reader.read(seq)
198
+ if (row) all.push(row)
199
+ } catch {
200
+ // 坏行跳过
201
+ }
202
+ }
203
+ }
204
+
205
+ for (const row of all) {
206
+ const bytes = Buffer.byteLength(JSON.stringify(row)) + 1
207
+ rows += 1
208
+ total += bytes
209
+
210
+ const diskRow = shares.get(row.seq) ?? 0
211
+ diskTotal += diskRow
212
+
213
+ let mediaBytes = 0
214
+ let mediaDisk = 0
215
+ for (const item of row.media ?? []) {
216
+ const sha = item?.sha256
217
+ if (!sha || seenMedia.has(sha)) continue
218
+ seenMedia.add(sha)
219
+ mediaBytes += Number(item.bytes) || 0
220
+ mediaDisk += mediaShares_.get(sha) ?? 0
221
+ }
222
+ total += mediaBytes
223
+
224
+ const key = `${row.session}|${row.turn}`
225
+ const entry = groups.get(key) ?? {
226
+ session: row.session, turn: row.turn, at: row.at, added: 0, total: 0, diskAdded: 0, diskTotal: 0,
227
+ }
228
+ entry.added += bytes + mediaBytes
229
+ entry.diskAdded += diskRow + mediaDisk
230
+ entry.at = Math.max(entry.at ?? 0, row.at ?? 0)
231
+ groups.set(key, entry)
232
+ }
233
+
234
+ const series = [...groups.values()]
235
+ let runningTotal = 0
236
+ let runningDisk = 0
237
+ for (const entry of series) {
238
+ runningTotal += entry.added
239
+ runningDisk += entry.diskAdded
240
+ entry.total = runningTotal
241
+ entry.diskTotal = runningDisk
242
+ }
243
+
244
+ // state.json、meta.json、没有行引用到的 media 这些零头没落到任何一行上。
245
+ // 按各轮已有的磁盘增量等比摊开,而不是一股脑塞进最后一轮——否则界面上会看到
246
+ // 「最近一轮 +434 KB」这种假暴涨,而那一轮其实只写了几 KB。
247
+ const actual = diskUsage(root)
248
+ const gap = actual - runningDisk
249
+ if (gap > 0 && runningDisk > 0) {
250
+ const scale = 1 + gap / runningDisk
251
+ for (const entry of series) entry.diskAdded *= scale
252
+ runningDisk = 0
253
+ for (const entry of series) {
254
+ runningDisk += entry.diskAdded
255
+ entry.diskTotal = runningDisk
256
+ }
257
+ }
258
+
259
+ return {
260
+ total,
261
+ disk: actual,
262
+ rows,
263
+ sessions: Object.keys(state.sessions ?? {}).length,
264
+ generatedAt: Date.now(),
265
+ turns: series.slice(-Math.max(1, turns)),
266
+ }
267
+ }
268
+
269
+ /** 按 state.seq 缓存:库没变就不重算,设置页每几秒问一次也不心疼。 */
270
+ export function createStatsCache({ reader, root = memoryRoot() } = {}) {
271
+ let cachedSeq = null
272
+ let cached = null
273
+ return {
274
+ get({ turns = 30 } = {}) {
275
+ const seq = reader?.state?.seq ?? -1
276
+ if (cached && cachedSeq === seq) return { ...cached, turns: cached.turns.slice(-Math.max(1, turns)) }
277
+ cached = computeStats(reader, { turns: 60, root })
278
+ cachedSeq = seq
279
+ return { ...cached, turns: cached.turns.slice(-Math.max(1, turns)) }
280
+ },
281
+ invalidate() {
282
+ cachedSeq = null
283
+ cached = null
284
+ },
285
+ }
286
+ }