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.
- package/CHANGELOG.md +82 -0
- package/README.en.md +160 -0
- package/README.md +81 -59
- package/client/client.js +148 -1
- package/lib/art/work-9-talking.webp +0 -0
- package/lib/browse.js +216 -0
- package/lib/commands.js +166 -0
- package/lib/events.js +52 -0
- package/lib/history.js +72 -0
- package/lib/index.js +801 -12
- package/lib/log-tail.js +235 -0
- package/lib/page.html +1699 -36
- package/lib/page.js +21 -0
- package/lib/pairing.js +110 -14
- package/lib/permissions.js +165 -0
- package/lib/serve.js +365 -0
- package/lib/server.js +465 -6
- package/lib/store.js +191 -8
- package/lib/sw.js +39 -0
- package/lib/tree.js +145 -3
- package/package.json +15 -6
- package/README.zh.md +0 -138
- package/client/verify.mjs +0 -514
package/lib/log-tail.js
ADDED
|
@@ -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
|
+
}
|