dsh-mini-remote 0.1.0 → 0.1.4

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,235 @@
1
+ /**
2
+ * 只读会话日志的**尾部**若干事件——手机端读历史的「上限」就落在这里。
3
+ *
4
+ * 为什么不能直接用 `sessionQuery.readSession()`:它会把整份日志解出来(还要做两次
5
+ * 全量克隆),而且 0.1.7-rc.2 起 v3 日志要走 v3→v4 迁移,迁移会把这个父会话的
6
+ * **每个直接子会话整份解码**。实测:310 个子会话 / 26MB 的父会话,默认约 4GB 堆
7
+ * 20 秒就 OOM(2026-09-25 事故)。那条路一走,代价就收不回来了。
8
+ *
9
+ * 所以这里自己读,代价只跟**窗口**有关、跟会话总量无关:
10
+ * ① 只读文件末尾 windowBytes 字节(不动前面);
11
+ * ② 在窗口里扫帧头(magic `28 b5 2f fd`),**只解完整的帧**——窗口起点切在半帧中间、
12
+ * 以及末尾正在写的残帧,都自然被跳过;
13
+ * ③ 窗里一个完整帧都没有(最后一帧比窗口还大)→ **放大窗口重来**(4×,直到上限),
14
+ * 而不是直接认输;
15
+ * ④ 拼出 JSONL 行→事件;只留最后 maxEvents 条,并**向前对齐到那一轮的 `turn/start`**
16
+ * (能对齐到就把 `turn/start` 也带上,重放才知道这是一轮的起点;对不齐才从
17
+ * `user/message` 起)。
18
+ *
19
+ * 拿不到就返回 null——**不猜、不编**。调用方据此决定怎么跟用户说。
20
+ */
21
+ import { closeSync, openSync, readSync, readdirSync, statSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { homedir } from 'node:os'
24
+ import { zstdDecompressSync } from 'node:zlib'
25
+
26
+ const ZSTD_MAGIC = 0xfd2fb528
27
+
28
+ function sessionsRoot() {
29
+ return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'sessions')
30
+ }
31
+
32
+ /**
33
+ * 找某个会话的日志文件。目录名两种口径都见过:`session-<uuid>` 与裸 `<uuid>`;
34
+ * 文件名按格式版本走(`session.v3.jsonl.zstd` / `v4` / …),所以只认前后缀。
35
+ * @returns {string|null} 绝对路径;找不到返回 null
36
+ */
37
+ export function findSessionLog(sessionId) {
38
+ if (!sessionId) return null
39
+ const root = sessionsRoot()
40
+ const bare = sessionId.replace(/^session-/, '')
41
+ try {
42
+ for (const ws of readdirSync(root, { withFileTypes: true })) {
43
+ if (!ws.isDirectory()) continue
44
+ const wsPath = join(root, ws.name)
45
+ let entries
46
+ try {
47
+ entries = readdirSync(wsPath, { withFileTypes: true })
48
+ } catch {
49
+ continue
50
+ }
51
+ for (const d of entries) {
52
+ if (!d.isDirectory()) continue
53
+ if (d.name !== sessionId && d.name !== bare && d.name !== `session-${bare}`) continue
54
+ const dir = join(wsPath, d.name)
55
+ let files
56
+ try {
57
+ files = readdirSync(dir)
58
+ } catch {
59
+ continue
60
+ }
61
+ for (const f of files) {
62
+ if (f.startsWith('session.') && f.endsWith('.jsonl.zstd')) return join(dir, f)
63
+ }
64
+ }
65
+ }
66
+ } catch {
67
+ /* 目录还不在、没权限——都当没有 */
68
+ }
69
+ return null
70
+ }
71
+
72
+ /**
73
+ * 扫出一个 buffer 里**完整**的 zstd 帧区间(不解压内容)。
74
+ *
75
+ * 帧头布局(RFC 8878):magic(4) → 描述符(1) → 可选字段 → 若干块(每块 3 字节头)。
76
+ * 只按结构走就能知道帧边界;起点/终点切在中间的帧会被丢掉(那正是我们要的:
77
+ * 宁可少一帧,也不拿半截数据去解)。
78
+ *
79
+ * @param {Buffer} buffer
80
+ * @returns {Array<{start:number, end:number}>}
81
+ */
82
+ export function frameRanges(buffer) {
83
+ const frames = []
84
+ let offset = 0
85
+ while (offset + 4 < buffer.length) {
86
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
87
+ offset += 1
88
+ continue
89
+ }
90
+ const start = offset
91
+ let cursor = offset + 4
92
+ const descriptor = buffer.readUInt8(cursor)
93
+ cursor += 1
94
+ if ((descriptor & 24) !== 0) {
95
+ offset += 1
96
+ continue
97
+ }
98
+ const contentSizeFlag = descriptor >>> 6
99
+ const singleSegment = (descriptor & 32) !== 0
100
+ const checksum = (descriptor & 4) !== 0
101
+ const dictionaryFlag = descriptor & 3
102
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
103
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
104
+ cursor += (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
105
+ let ok = cursor <= buffer.length
106
+ while (ok) {
107
+ if (cursor + 3 > buffer.length) {
108
+ ok = false
109
+ break
110
+ }
111
+ const blockHeader = buffer.readUIntLE(cursor, 3)
112
+ cursor += 3
113
+ const lastBlock = (blockHeader & 1) !== 0
114
+ const blockType = (blockHeader >>> 1) & 3
115
+ const blockSize = blockHeader >>> 3
116
+ if (blockType === 3) {
117
+ ok = false
118
+ break
119
+ }
120
+ cursor += blockType === 1 ? 1 : blockSize
121
+ if (cursor > buffer.length) {
122
+ ok = false
123
+ break
124
+ }
125
+ if (lastBlock) break
126
+ }
127
+ if (!ok) {
128
+ offset += 1
129
+ continue
130
+ }
131
+ if (checksum) cursor += 4
132
+ if (cursor <= buffer.length) {
133
+ frames.push({ start, end: cursor })
134
+ offset = cursor
135
+ } else {
136
+ offset += 1
137
+ }
138
+ }
139
+ return frames
140
+ }
141
+
142
+ /** 只读文件末尾 count 字节(文件比 count 短就全读)。 */
143
+ function readFileTail(file, count) {
144
+ const size = statSync(file).size
145
+ const start = Math.max(0, size - count)
146
+ const length = size - start
147
+ const buffer = Buffer.allocUnsafe(length)
148
+ const fd = openSync(file, 'r')
149
+ try {
150
+ readSync(fd, buffer, 0, length, start)
151
+ } finally {
152
+ closeSync(fd)
153
+ }
154
+ return { buffer, whole: start === 0 }
155
+ }
156
+
157
+ /** 读一个窗口并解出里面的完整帧 → 事件。读不了返回 null。 */
158
+ function eventsInWindow(file, windowBytes) {
159
+ let tail
160
+ try {
161
+ tail = readFileTail(file, windowBytes)
162
+ } catch {
163
+ return null
164
+ }
165
+ const events = []
166
+ for (const { start, end } of frameRanges(tail.buffer)) {
167
+ let text
168
+ try {
169
+ text = zstdDecompressSync(tail.buffer.subarray(start, end)).toString('utf8')
170
+ } catch {
171
+ // 校验不过的帧跳过:正在写的残帧,或校验位对不上的,都不该拖垮读取。
172
+ continue
173
+ }
174
+ for (const line of text.split('\n')) {
175
+ if (!line.trim()) continue
176
+ try {
177
+ events.push(JSON.parse(line))
178
+ } catch {
179
+ /* 半截行(正在写)跳过 */
180
+ }
181
+ }
182
+ }
183
+ return { events, whole: tail.whole }
184
+ }
185
+
186
+ /**
187
+ * 向前对齐到「那一轮的起点」:先找到保留区里第一条 user 消息,再看它前面是不是
188
+ * `turn/start`——是就把起点挪过去(重放需要它才知道这是一轮的开头)。
189
+ * 对不齐就从那条 user 消息起。
190
+ */
191
+ function alignToTurnStart(events) {
192
+ const firstUser = events.findIndex((e) => e?.type === 'user/message')
193
+ if (firstUser <= 0) return firstUser < 0 ? 0 : firstUser
194
+ for (let i = firstUser - 1; i >= 0; i--) {
195
+ if (events[i]?.type === 'turn/start') return i
196
+ if (events[i]?.type === 'assistant/message' || events[i]?.type === 'user/message') break
197
+ }
198
+ return firstUser
199
+ }
200
+
201
+ /**
202
+ * 读会话日志的尾部事件。
203
+ *
204
+ * @param {string} sessionId
205
+ * @param {{windowBytes?:number, maxWindowBytes?:number, maxEvents?:number}} [options]
206
+ * windowBytes:起始窗口(压缩字节,默认 4 MB)
207
+ * maxWindowBytes:窗口放大上限(默认 64 MB;最后一帧比窗口大时会放大重来)
208
+ * maxEvents:最多返回多少条事件(默认 2000;0 = 不限)
209
+ * @returns {{events:Array, truncated:boolean}|null} 拿不到日志 / 解不出任何事件 → null
210
+ */
211
+ export function readTailEvents(sessionId, options = {}) {
212
+ const maxWindowBytes = options.maxWindowBytes ?? 64 * 1024 * 1024
213
+ const maxEvents = options.maxEvents ?? 2000
214
+ const file = findSessionLog(sessionId)
215
+ if (!file) return null
216
+
217
+ let budget = Math.min(options.windowBytes ?? 4 * 1024 * 1024, maxWindowBytes)
218
+ for (;;) {
219
+ const read = eventsInWindow(file, budget)
220
+ if (read === null) return null
221
+ if (read.events.length) {
222
+ const droppedByCount = maxEvents > 0 && read.events.length > maxEvents
223
+ const kept = droppedByCount ? read.events.slice(-maxEvents) : read.events
224
+ const from = alignToTurnStart(kept)
225
+ return {
226
+ events: kept.slice(from),
227
+ // 「不是全部」有三种来路:本来就没从头读、条数被上限砍了、以及为了对齐起点丢了几条。
228
+ truncated: !read.whole || droppedByCount || from > 0,
229
+ }
230
+ }
231
+ // 窗里没有完整帧 —— 多半是「最后一帧比窗口大」。放大窗口重来,到上限才认输。
232
+ if (read.whole || budget >= maxWindowBytes) return null
233
+ budget = Math.min(budget * 4, maxWindowBytes)
234
+ }
235
+ }