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/LICENSE +661 -0
- package/README.md +82 -2
- package/assets/icon.png +0 -0
- package/client/panel.js +254 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +263 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -4
- package/src/chain.mjs +28 -0
- package/src/compact.mjs +167 -0
- package/src/day.mjs +15 -0
- package/src/frames.mjs +12 -0
- package/src/index.js +183 -0
- package/src/media.mjs +97 -0
- package/src/mediapack.mjs +144 -0
- package/src/pack.mjs +94 -0
- package/src/paths.mjs +54 -0
- package/src/project.mjs +132 -0
- package/src/reader.mjs +284 -0
- package/src/rpc.mjs +77 -0
- package/src/stats.mjs +286 -0
- package/src/writer.mjs +155 -0
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
|
+
}
|