dsh-mobile-plugin 0.5.1 → 0.5.3

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.
@@ -1,25 +1,31 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 正文图片内联:把消息文本里指向本机文件的图片语法(`![](/abs/path.png)`)换成 data URI,
5
- * 让手机端能直接画出来。
4
+ * 正文里本机图片 → 一个短编号(`![alt](dsh-local-image:<id>?w=..&h=..)`)。
6
5
  *
7
- * 手机拿到的只是文本,`/Users/…/a.png` 既不是手机上的文件,移动 API 也没有「按路径读文件」
8
- * 的能力面,原样过去只会显示成一串字面 markdown。这里把「正文里写的本机图片」拉齐到附件那种
9
- * 内联形状,App 不需要新增通道。
6
+ * 为什么不内联 base64(2026-10-29 改):整张图的字节塞进消息正文有三个代价,全落在用户身上 ——
7
+ * ① 正文里没有宽高,手机只能先按固定尺寸占位、等解码完再改;翻页时那一行高度晚到,内容被顶;
8
+ * ② 体积:一张 135KB 的图进正文就是 180KB 字符,而正文整段会进本地缓存(1MiB 预算),
9
+ * 六七张图就把缓存吃穿 —— 于是「打开过很多次还是要重新加载」;
10
+ * ③ 每次重拉历史都要重传一遍。
11
+ * 业界(WhatsApp / Telegram / Slack / 各家大模型客户端)都是「消息里只放引用 + 宽高」,
12
+ * 这里对齐同一条口径:正文只留编号,字节按编号单张取(`/session/local-image`)。
13
+ *
14
+ * 安全边界不变:**不是「按路径读文件」**。编号只由本模块在投影历史时登记,取图口只认
15
+ * 登记表里的编号 —— 构造一个编号读不到任何别的文件。登记表活在进程内;插件重启后老编号
16
+ * 取不到就如实 404,App 走「失败 + 重试」那条现成的路。
10
17
  *
11
18
  * 保守取舍——宁可保留字面 markdown,也不给半截东西:
12
19
  * - 本机路径认三种:绝对(`/x/y.png`、`file:///x/y.png`)、`~/y.png`、相对(`deliverables/a.png`,
13
20
  * 按调用方给的工作区目录解析,与桌面端同口径;没给目录就当成不是本机图);http(s)、`data:` 不碰;
14
21
  * - 只认 png/jpg/jpeg/webp/gif,并核对魔术字节,不按扩展名撒谎;
15
- * - 三级体积预算(单图 / 单段 / 单次响应),超预算就保持字面;
16
- * - 读不到、不是普通文件、读取失败一律原样保留;
22
+ * - 宽高只读文件头(最多 64KB);读不出来就不带宽高,App 那边照旧现量;
17
23
  * - 围栏代码块与行内代码里的图片语法不碰,那是写法示例。
18
24
  */
19
-
20
25
  const fs = require('node:fs/promises');
21
26
  const os = require('node:os');
22
27
  const path = require('node:path');
28
+ const crypto = require('node:crypto');
23
29
 
24
30
  /**
25
31
  * `![alt](dest)`。dest 两种写法都认:裸路径(不含空白与右括号)与 `<>` 包裹(里面可以有空格,
@@ -38,17 +44,21 @@ const MEDIA_TYPES = {
38
44
  '.gif': 'image/gif',
39
45
  };
40
46
 
41
- /** 单张原图上限(base64 后约 1.33 倍,见下面的字符预算)。 */
47
+ /** 单张图上限:再大的东西不该整包回给手机(超出如实保持字面 markdown)。 */
42
48
  const MAX_IMAGE_BYTES = 2.5 * 1024 * 1024;
43
- /** 一段文本里内联进去的 data URI 总字符上限。 */
44
- const MAX_TEXT_CHARS = 3 * 1024 * 1024;
45
- /** 一次响应(一页历史 / 一帧 SSE / 一次镜像快照)共用的字符上限。 */
46
- const MAX_RESPONSE_CHARS = 5 * 1024 * 1024;
47
- /** data URI 复用条数:同一张图常在同一轮里出现多次(历史 + 流式 + 镜像)。 */
48
- const MAX_CACHE_ENTRIES = 8;
49
+ /** 读宽高只需要文件头:JPEG 的 SOF 段可能在几百字节之后,64KB 足够覆盖绝大多实拍图。 */
50
+ const HEAD_BYTES = 64 * 1024;
51
+ /** 登记表上限:翻历史时同一张图反复出现也只登记一次,512 条够一两百个会话轮着用。 */
52
+ const MAX_REGISTRY_ENTRIES = 512;
53
+ /** 一张响应(一页历史 / 一帧 SSE / 一次镜像快照)里最多换几张:防一段文字里几十上百个图片语法。 */
54
+ const MAX_TEXT_IMAGES = 64;
49
55
 
50
- /** path+mtime+size → data URI。会话里同一张图会被反复解析,但文件内容只读一次。 */
51
- const cache = new Map();
56
+ /**
57
+ * 编号 → { filePath, mediaType, width?, height? }。
58
+ *
59
+ * 只由本模块登记,取图口只认这里面的编号 —— 这是「不扩能力面」的全部依据(见文件头)。
60
+ */
61
+ const registry = new Map();
52
62
 
53
63
  function mediaTypeFor(filePath) {
54
64
  return MEDIA_TYPES[path.extname(filePath).toLowerCase()] || null;
@@ -119,40 +129,103 @@ function hasRelativeImage(text) {
119
129
  * `cwd` 由调用方按需补上(相对地址的解析基准,见 localImagePathOf):`undefined` 表示
120
130
  * 「还没查过工作区目录」,空串表示「查过了,这条会话没有」。
121
131
  */
122
- function createTextImageBudget(limit = MAX_RESPONSE_CHARS) {
132
+ function createTextImageBudget(limit = MAX_TEXT_IMAGES) {
123
133
  return { remaining: limit };
124
134
  }
125
135
 
126
- /** 读文件 → data URI(带缓存);不满足条件一律 null。 */
127
- async function dataUriFor(filePath) {
128
- let stat;
136
+ /** 从文件头解析宽高;认不出来返回 null(不是错误:App 那边照旧现量)。 */
137
+ function imageSizeOf(buf) {
138
+ if (buf.length >= 24 && buf[0] === 0x89 && buf.toString('latin1', 1, 4) === 'PNG') {
139
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
140
+ }
141
+ if (buf.length >= 10 && buf.toString('latin1', 0, 3) === 'GIF') {
142
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
143
+ }
144
+ if (buf.length >= 30 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') {
145
+ const kind = buf.toString('latin1', 12, 16);
146
+ if (kind === 'VP8X') return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
147
+ if (kind === 'VP8 ') return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
148
+ if (kind === 'VP8L' && buf.length >= 25) {
149
+ const bits = buf.readUInt32LE(21);
150
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
151
+ }
152
+ return null;
153
+ }
154
+ if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
155
+ let at = 2;
156
+ while (at + 9 < buf.length) {
157
+ if (buf[at] !== 0xff) { at += 1; continue; }
158
+ const marker = buf[at + 1];
159
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { at += 2; continue; }
160
+ const length = buf.readUInt16BE(at + 2);
161
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
162
+ return { width: buf.readUInt16BE(at + 7), height: buf.readUInt16BE(at + 5) };
163
+ }
164
+ if (length < 2) return null;
165
+ at += 2 + length;
166
+ }
167
+ return null;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /** 读文件头(最多 HEAD_BYTES);打不开就 null。 */
173
+ async function readHead(filePath, size) {
174
+ let handle;
129
175
  try {
130
- stat = await fs.stat(filePath);
176
+ handle = await fs.open(filePath, 'r');
177
+ const length = Math.min(size, HEAD_BYTES);
178
+ const buf = Buffer.alloc(length);
179
+ await handle.read(buf, 0, length, 0);
180
+ return buf;
131
181
  } catch {
132
182
  return null;
183
+ } finally {
184
+ if (handle) await handle.close().catch(() => {});
133
185
  }
134
- if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_IMAGE_BYTES) return null;
135
- const key = `${filePath}:${stat.size}:${stat.mtimeMs}`;
136
- const hit = cache.get(key);
137
- if (hit) {
138
- cache.delete(key);
139
- cache.set(key, hit);
140
- return hit;
141
- }
142
- let buf;
186
+ }
187
+
188
+ /**
189
+ * 登记一张本机图片,返回它的编号与宽高。
190
+ *
191
+ * 编号取「路径 + 大小 + 修改时间」的短哈希:同一张图在同一轮历史里会被反复解析,
192
+ * 这个组合稳定,且改过之后自然换号(图片内容变了本来就该换)。
193
+ */
194
+ async function registerLocalImage(filePath) {
195
+ let stat;
143
196
  try {
144
- buf = await fs.readFile(filePath);
197
+ stat = await fs.stat(filePath);
145
198
  } catch {
146
199
  return null;
147
200
  }
148
- const mediaType = sniffMediaType(buf);
201
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_IMAGE_BYTES) return null;
202
+ const id = crypto.createHash('sha1').update(`${filePath}:${stat.size}:${stat.mtimeMs}`).digest('hex').slice(0, 16);
203
+ const known = registry.get(id);
204
+ if (known) return { id, ...known };
205
+ const head = await readHead(filePath, stat.size);
206
+ if (!head) return null;
207
+ const mediaType = sniffMediaType(head);
149
208
  if (!mediaType) return null;
150
- const uri = `data:${mediaType};base64,${buf.toString('base64')}`;
151
- cache.set(key, uri);
152
- while (cache.size > MAX_CACHE_ENTRIES) cache.delete(cache.keys().next().value);
153
- return uri;
209
+ const parsed = imageSizeOf(head);
210
+ const sane = parsed && parsed.width > 0 && parsed.height > 0
211
+ && parsed.width <= 30000 && parsed.height <= 30000;
212
+ const size = sane ? parsed : {};
213
+ const entry = { filePath, mediaType, ...size };
214
+ registry.set(id, entry);
215
+ while (registry.size > MAX_REGISTRY_ENTRIES) registry.delete(registry.keys().next().value);
216
+ return { id, ...entry };
217
+ }
218
+
219
+ /** 取图口用:只认登记表里的编号(构造不出来的编号一律 null → 404)。 */
220
+ function localImageOf(id) {
221
+ if (typeof id !== 'string' || !id) return null;
222
+ return registry.get(id) || null;
154
223
  }
155
224
 
225
+ /** 清空登记表(测试用)。 */
226
+ function clearLocalImages() {
227
+ registry.clear();
228
+ }
156
229
  /**
157
230
  * Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
158
231
  *
@@ -232,8 +305,10 @@ function inRanges(index, ranges) {
232
305
  }
233
306
 
234
307
  /**
235
- * 把一段文本里的本机图片语法换成 data URI。返回原字符串本身(`===`)表示一个字没动,
236
- * 调用方据此免掉不必要的对象拷贝。
308
+ * 把一段文本里的本机图片语法换成**短编号**(带宽高):`![alt](dsh-local-image:<id>?w=..&h=..)`。
309
+ *
310
+ * 返回原字符串本身(`===`)表示一个字没动,调用方据此免掉不必要的对象拷贝。
311
+ * `budget` 只为兼容调用点保留:标识只有几十字节,不再有字符预算这回事。
237
312
  */
238
313
  async function inlineTextImages(text, budget) {
239
314
  if (typeof text !== 'string' || text.indexOf('![') < 0) return text;
@@ -241,21 +316,99 @@ async function inlineTextImages(text, budget) {
241
316
  const matches = [...text.matchAll(IMAGE_RE)].filter((m) => !inRanges(m.index, ranges));
242
317
  if (!matches.length) return text;
243
318
  const pool = budget || createTextImageBudget();
244
- let textBudget = MAX_TEXT_CHARS;
319
+ let count = pool.remaining;
245
320
  let out = '';
246
321
  let cursor = 0;
247
322
  for (const m of matches) {
248
- if (textBudget <= 0 || pool.remaining <= 0) break;
323
+ if (count <= 0) break;
249
324
  const filePath = localImagePathOf(m[2], pool.cwd);
250
325
  if (!filePath) continue;
251
- const uri = await dataUriFor(filePath);
252
- if (!uri || uri.length > textBudget || uri.length > pool.remaining) continue;
253
- textBudget -= uri.length;
254
- pool.remaining -= uri.length;
255
- out += text.slice(cursor, m.index) + `![${m[1]}](${uri})`;
326
+ const entry = await registerLocalImage(filePath);
327
+ if (!entry) continue;
328
+ count -= 1;
329
+ pool.remaining = count;
330
+ const query = entry.width && entry.height ? `?w=${entry.width}&h=${entry.height}` : '';
331
+ out += text.slice(cursor, m.index) + `![${m[1]}](dsh-local-image:${entry.id}${query})`;
256
332
  cursor = m.index + m[0].length;
257
333
  }
258
334
  return cursor === 0 ? text : out + text.slice(cursor);
259
335
  }
336
+ /**
337
+ * Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
338
+ *
339
+ * 代码块里的 `![](/tmp/a.png)` 是**写法示例**而不是要画的图。旧实现整段正则替换,
340
+ * 会把三行代码换成几十万字符的 base64——App 把这段按代码文本铺开,就成了很长很长
341
+ * 的一块(真机现象:图片语法那块刷出几十屏,要划很久)。围栏判定与 App 的
342
+ * `utils/markdown/parser.ts` 同口径(两边都 trim 后看 3 个以上 ` / ~)。
343
+ */
344
+ function literalRanges(text) {
345
+ const ranges = [];
346
+ const lines = text.split('\n');
347
+ const lineStarts = [];
348
+ let offset = 0;
349
+ for (const line of lines) {
350
+ lineStarts.push(offset);
351
+ offset += line.length + 1;
352
+ }
353
+ let fence = null;
354
+ let fenceStart = -1;
355
+ for (let index = 0; index < lines.length; index += 1) {
356
+ const line = lines[index];
357
+ if (fence) {
358
+ const close = /^(`{3,}|~{3,})[ \t]*$/.exec(line.trim());
359
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length) {
360
+ ranges.push([fenceStart, lineStarts[index] + line.length]);
361
+ fence = null;
362
+ }
363
+ continue;
364
+ }
365
+ const open = /^(`{3,}|~{3,})/.exec(line.trim());
366
+ if (!open) continue;
367
+ fence = { char: open[1][0], length: open[1].length };
368
+ fenceStart = lineStarts[index];
369
+ }
370
+ // 没闭合的围栏按 App 的解析口径一直吃到文末。
371
+ if (fence) ranges.push([fenceStart, text.length]);
372
+
373
+ // 行内代码:同长度的反引号成对(`code`)。围栏内部的反引号已在上面标过。
374
+ let cursor = 0;
375
+ while (cursor < text.length) {
376
+ if (text[cursor] !== '`' || inRanges(cursor, ranges)) {
377
+ cursor += 1;
378
+ continue;
379
+ }
380
+ let ticks = 1;
381
+ while (text[cursor + ticks] === '`') ticks += 1;
382
+ let scan = cursor + ticks;
383
+ let closed = -1;
384
+ while (scan < text.length) {
385
+ if (text[scan] !== '`' || inRanges(scan, ranges)) {
386
+ scan += 1;
387
+ continue;
388
+ }
389
+ let run = 1;
390
+ while (text[scan + run] === '`') run += 1;
391
+ if (run === ticks) {
392
+ closed = scan + run;
393
+ break;
394
+ }
395
+ scan += run;
396
+ }
397
+ if (closed < 0) {
398
+ cursor += ticks;
399
+ continue;
400
+ }
401
+ ranges.push([cursor, closed]);
402
+ cursor = closed;
403
+ }
404
+ return ranges;
405
+ }
406
+
407
+ function inRanges(index, ranges) {
408
+ for (const [start, end] of ranges) {
409
+ if (index >= start && index < end) return true;
410
+ }
411
+ return false;
412
+ }
260
413
 
261
- module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage };
414
+ module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, clearLocalImages };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 正文图的字节(POST /api/mobile/v1/session/local-image)。
5
+ *
6
+ * 正文里那张图在历史投影时已经被登记成一个短编号(见 `local-images.js`),手机画到它时
7
+ * 拿着编号来取字节。**这不是「按路径读文件」**:只认登记表里的编号,构造一个编号读不到
8
+ * 任何别的文件 —— 能力面与附件那条 `session/attachment` 同一个口径。
9
+ *
10
+ * 只认 `id`、不认 sessionId:授权依据是「这张图在投影这条会话时被登记过」,而不是调用方
11
+ * 自报的会话号。登记表是进程级的,同一个编号从哪条会话取都一样(本来就是同一份文件)。
12
+ *
13
+ * 回 base64 JSON 而不是流:正文图都是单张位图、上限 2.5MB,与附件那条一致;大文件
14
+ * (视频 / 交付物)走 `session/file` 的流式那条,别混。
15
+ */
16
+
17
+ const fs = require('node:fs/promises');
18
+ const { sendJson, readJsonBody } = require('../shared/http-util');
19
+ const { localImageOf } = require('./local-images.js');
20
+
21
+ async function handleSessionLocalImage(ctx, req, res) {
22
+ const body = await readJsonBody(req);
23
+ if (!body || typeof body.id !== 'string' || !body.id) {
24
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { id }' } });
25
+ }
26
+ const entry = localImageOf(body.id);
27
+ // 插件重启、或那条记录被挤出登记表:如实 404,让 App 把重试按钮的失败说清楚。
28
+ if (!entry) {
29
+ return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '这张图没有登记,或登记已失效' } });
30
+ }
31
+ let data;
32
+ try {
33
+ data = await fs.readFile(entry.filePath);
34
+ } catch {
35
+ return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '图片文件已经不在了' } });
36
+ }
37
+ sendJson(res, 200, {
38
+ ok: true,
39
+ mediaType: entry.mediaType,
40
+ data: data.toString('base64'),
41
+ ...(entry.width && entry.height ? { width: entry.width, height: entry.height } : {}),
42
+ });
43
+ }
44
+
45
+ module.exports = { handleSessionLocalImage };
@@ -8,7 +8,8 @@
8
8
  * `updatedAt:running:limit` 指纹判鲜,2 分钟 TTL,上限 256 条按插入序淘汰。
9
9
  */
10
10
 
11
- const { buildMessagesSnapshot, buildStatsSnapshot } = require('./session-snapshot.js');
11
+ const { buildMessagesSnapshot } = require('./session-snapshot.js');
12
+ const { buildStatsSnapshot } = require('./session-stats.js');
12
13
  const { mirrorHistoryStateOf } = require('./session-cache.js');
13
14
 
14
15
  const MIRROR_HISTORY_CACHE_MS = 120000; // 2 min TTL, reduces redundant rebuilds
@@ -25,6 +25,7 @@ const { promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive
25
25
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
26
26
  const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
27
27
  const { recordSessionRead } = require('./session-last-turn.js');
28
+ const { imageSizeOf } = require('./local-images.js');
28
29
 
29
30
  /**
30
31
  * `/send` 的请求体上限(**明文**字节,图片 base64)。
@@ -285,9 +286,20 @@ async function handleSessionAttachment(ctx, req, res) {
285
286
  // 让 App 那边把重试按钮的失败说清楚,而不是转圈到超时。
286
287
  if (!data) return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '附件已不可读' } });
287
288
  const attachment = (value && value.attachment) || {};
289
+ const mediaType = typeof attachment.mediaType === 'string' && attachment.mediaType ? attachment.mediaType : 'image/png';
290
+ // `dims: true` = 只要原始宽高,不要字节。老会话里宿主没存附件的宽高,正文下发时就带不上,
291
+ // 手机侧只能按 240 方盒先占位、等字节到了再变高(那一行一变高,屏幕上就是「跳」)。这一档
292
+ // 让手机在挂载那一帧就能按真比例占好:回给它的只有两格数字,整张图的 base64 不出这台电脑。
293
+ if (body.dims === true) {
294
+ const size = imageSizeOf(Buffer.from(data, 'base64'));
295
+ if (!size || !(size.width > 0) || !(size.height > 0)) {
296
+ return sendJson(res, 404, { ok: false, error: { code: 'no-dims', message: '这张图的文件头里量不出宽高' } });
297
+ }
298
+ return sendJson(res, 200, { ok: true, mediaType, width: size.width, height: size.height });
299
+ }
288
300
  sendJson(res, 200, {
289
301
  ok: true,
290
- mediaType: typeof attachment.mediaType === 'string' && attachment.mediaType ? attachment.mediaType : 'image/png',
302
+ mediaType,
291
303
  data,
292
304
  ...(typeof attachment.name === 'string' ? { name: attachment.name } : {}),
293
305
  });
@@ -1,11 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 消息与统计快照:镜像推送与 /messages 分页共用的两条折叠路径。
4
+ * 消息快照:镜像推送与 /messages 分页共用的折叠路径。
5
5
  *
6
6
  * buildMessagesSnapshot(不解析 query)与 handleMessages(解析 beforeSeq/afterSeq/limit)
7
7
  * 共用消息折叠逻辑。普通会话走 sessions.history;子代理必须携带 durable parent address,
8
- * 改走 subagents.history,不能把 child sessionId 当普通会话读。
8
+ * 改走 subagents.history,不能把 child sessionId 当普通会话读。统计投影不在这里,
9
+ * 在 session-stats.js(两条路径的取数口径不同,别混回一份)。
9
10
  *
10
11
  * 上游历史接口不认 afterSeq(只认 beforeSeq/maxMessages),所以增量轮询在插件内用较大的
11
12
  * 窗口(Math.max(limit, 200))拉最近若干事件后再按 seq 过滤;缺口超过窗口时会漏,此时
@@ -20,7 +21,7 @@ const { resolveEventAttachments } = require('./attachments.js');
20
21
  const { createTextImageBudget } = require('./local-images.js');
21
22
  const { entryWindowPayload } = require('./entry-window.js');
22
23
 
23
- const MESSAGES_DEFAULT_LIMIT = 50; // /messages 默认条数
24
+ const MESSAGES_DEFAULT_LIMIT = 50, TOOL_LOOKBACK_EVENTS = 120; // 默认条数 / 倒着翻页时多看几条(只为认出这一轮的工具结果)
24
25
 
25
26
  function historyHttpStatus(error) {
26
27
  switch (error && error.code) {
@@ -55,58 +56,19 @@ function eventSeqOf(entry) {
55
56
  async function buildMessagesSnapshot(ctx, sessionId, limit = MESSAGES_DEFAULT_LIMIT) {
56
57
  const { value, error } = unwrap(await callSessions(ctx, 'history', { sessionId, maxMessages: limit }));
57
58
  if (error) return { ok: false, error };
58
- const events = (value && value.events) || [];
59
+ const allEvents = (value && value.events) || [];
60
+ // 这一路是整窗快照,不带分页参数(宿主已按 maxMessages 截过):多给了就只留尾部那一窗。
61
+ const events = allEvents.length > limit ? allEvents.slice(-limit) : allEvents;
59
62
  // 引用模式:图片只下发 attachmentId,不取字节(见 attachments.js)。正文里本机图片的
60
63
  // 内联仍走 budget,与之前一致。
61
64
  const budget = createTextImageBudget();
62
65
  const resolvedEvents = await Promise.all(events.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget, referencesOnly: true }).then((event) => entry.event ? { ...entry, event } : event)));
63
- const messages = deriveMessages(resolvedEvents);
66
+ const messages = deriveMessages(resolvedEvents, sessionId);
64
67
  const lastSeq = events.length ? (eventSeqOf(events[events.length - 1]) ?? 0) : 0;
65
68
  const hasMore = !!(value && value.hasMore);
66
69
  return { ok: true, items: messages, lastSeq, hasMore };
67
70
  }
68
71
 
69
- /**
70
- * 镜像快照用的统计投影(与 handleStats 尾部投影读取保持一致):取整帧 projections,
71
- * 并把 plan 归一化为 planMode,供 pushMirror 为活跃会话生成统计快照。
72
- */
73
- async function buildStatsSnapshot(ctx, sessionId) {
74
- return statsProjections(ctx, sessionId);
75
- }
76
-
77
- /**
78
- * 统计投影快照(handleStats 与 buildStatsSnapshot 共用)。
79
- *
80
- * 0.2.0 把整帧投影从 history/page 的返回值里拆走了,「拉一条历史顺带取投影」这条路断了;
81
- * 改走 `apiProxy.sessions.projections({ request: { sessionId } })`,返回值是
82
- * `{ asOfSeq, values }`。顺带把 plan 归一化成 planMode(排队条与计划条读它)。
83
- * 历史轮询是每 15 秒一次的热路径,多背一次投影 RPC 划不来,所以统计单独走投影端点。
84
- */
85
- async function statsProjections(ctx, sessionId) {
86
- // 显式给 signal:宿主部分方法内部直接读 signal.aborted,不做 undefined 兜底。
87
- const controller = new AbortController();
88
- let value;
89
- let error;
90
- try {
91
- ({ value, error } = unwrap(await callSessions(ctx, 'projections', { sessionId }, 'mobile', controller.signal)));
92
- } catch (thrown) {
93
- // 投影读不出来不该变成一个从路由层逃出去的 rejection:handleStats 要回 400,
94
- // pushMirror 那边更是一条都不能抛(抛了整帧快照就丢了)。收敛成正常错误态。
95
- error = thrown && typeof thrown === 'object' && typeof thrown.code === 'string'
96
- ? { code: thrown.code, message: typeof thrown.message === 'string' ? thrown.message : '读会话投影失败' }
97
- : { code: 'host-call-failed', message: thrown && thrown.message ? String(thrown.message) : '读会话投影失败' };
98
- }
99
- if (error) return { ok: false, error };
100
- const projections = value && value.values && typeof value.values === 'object' ? value.values : {};
101
- const plan = projections.plan;
102
- return {
103
- ok: true,
104
- projections: plan && typeof plan === 'object' && typeof plan.active === 'boolean'
105
- ? { ...projections, planMode: { active: plan.active, ...(typeof plan.pending === 'boolean' ? { pending: plan.pending } : {}) } }
106
- : projections,
107
- };
108
- }
109
-
110
72
  /** GET /api/mobile/v1/session/messages?sessionId=…&beforeSeq=…&afterSeq=…&limit=…(token) */
111
73
  async function handleMessages(ctx, req, res) {
112
74
  const url = new URL(req.url, 'http://x');
@@ -135,7 +97,7 @@ async function handleMessages(ctx, req, res) {
135
97
  if (Number.isSafeInteger(parsed)) afterSeq = parsed;
136
98
  }
137
99
  const payload = { sessionId, maxMessages: limit };
138
- if (beforeSeq !== null) payload.beforeSeq = beforeSeq;
100
+ if (beforeSeq !== null) { payload.beforeSeq = beforeSeq; payload.maxMessages = limit + TOOL_LOOKBACK_EVENTS; }
139
101
  // 上游历史接口都不支持 afterSeq(只认 beforeSeq/maxMessages),故在插件内用较大的窗口
140
102
  // 拉最近若干事件后再过滤 seq > afterSeq,覆盖常见轮询缺口;缺口超过窗口时要求客户端全量补齐。
141
103
  if (afterSeq !== null) payload.maxMessages = Math.max(limit, 200);
@@ -159,16 +121,37 @@ async function handleMessages(ctx, req, res) {
159
121
  error = normalizeHistoryError(thrown);
160
122
  }
161
123
  if (error) return sendJson(res, historyHttpStatus(error), { ok: false, error });
162
- const events = (value && value.events) || [];
163
- // 增量:只保留水位之后的事件;非 afterSeq 调用时 events 原样保留(行为与之前完全一致)。
164
- const filtered = afterSeq !== null ? events.filter((entry) => (eventSeqOf(entry) ?? -1) > afterSeq) : events;
124
+ const allEvents = (value && value.events) || [];
125
+ const lookback = beforeSeq !== null && allEvents.length > limit ? allEvents.slice(0, -limit) : null;
126
+ const events = lookback ? allEvents.slice(-limit) : allEvents;
127
+ // 增量:游标之后的事件才是要发的行,但**折叠**仍按整窗做(见下)。
165
128
  // 一页历史共用一个内联图片预算:正文里的本机图片越多,越靠后的那些保持字面 markdown,
166
129
  // 而不是让单次响应无限膨胀(见 local-images.js 的三级预算)。
130
+ //
131
+ // 折叠要看**整窗**:压缩与 slash 命令行都是「一串事件攒出来的一行」(start → summary → end、
132
+ // run → done),按游标先把事件砍掉再折叠,剩下那半截就会漏成「没有摘要的压缩行」和
133
+ // 「没有名字的命令行」——真机现场就是屏幕上并排三行:`compact·执行中`、`上下文已压缩·
134
+ // 压缩摘要不可用`、`命令·Compacted 206 history items`。所以游标只用来筛**折叠出来的行**,
135
+ // 事件仍按整窗折叠;附件只为游标之后的事件解析(游标前那批只当上下文,不出行)。
167
136
  const budget = createTextImageBudget();
168
- const resolvedEvents = await Promise.all(filtered.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget, referencesOnly: true }).then((event) => entry.event ? { ...entry, event } : event)));
169
- const messages = deriveMessages(resolvedEvents);
137
+ const resolvedEvents = await Promise.all(events.map((entry) => {
138
+ const seq = eventSeqOf(entry);
139
+ const deliverable = afterSeq === null || !Number.isSafeInteger(seq) || seq > afterSeq;
140
+ const event = entry.event || entry;
141
+ if (!deliverable) return event;
142
+ return resolveEventAttachments(ctx, sessionId, event, { budget, referencesOnly: true })
143
+ .then((resolved) => (entry.event ? { ...entry, event: resolved } : resolved));
144
+ }));
145
+ const messages = deriveMessages(resolvedEvents, sessionId, lookback);
170
146
  const hasMore = !!(value && value.hasMore);
171
- const items = wantThin ? thinEntryMessages(messages, { hasMore }) : messages;
147
+ const items = wantThin
148
+ ? thinEntryMessages(messages, { hasMore })
149
+ // 结构行(压缩 / 命令行)照发:它们由整段生命周期折出来,一次也就一两条,按 id 覆盖
150
+ // 正好把屏幕上已经歪掉的那一行修回来 —— 按游标筛掉就永远修不回来。
151
+ : (afterSeq === null
152
+ ? messages
153
+ : messages.filter((item) => item.kind === 'compaction' || item.kind === 'command'
154
+ || (Number.isSafeInteger(item.seq) && item.seq > afterSeq)));
172
155
  // 工具结果不产生消息行,但会把**缓存里已有的**工具卡从 running 补成最终态。补丁取自
173
156
  // 整个窗口(调用方游标可能已越过结果 seq);已随消息块下发的只回状态(见 toolResultPatchesFor)。
174
157
  // 增量那一发(带 afterSeq)一律只回状态:调用方手上已有一份会话,整窗重发等于每 15 秒
@@ -193,4 +176,4 @@ async function handleMessages(ctx, req, res) {
193
176
  });
194
177
  }
195
178
 
196
- module.exports = { eventSeqOf, buildMessagesSnapshot, statsProjections, buildStatsSnapshot, handleMessages };
179
+ module.exports = { eventSeqOf, buildMessagesSnapshot, handleMessages };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 会话统计投影:handleStats 与镜像推送共用的那一份取数口径。
5
+ *
6
+ * 0.2.0 把整帧投影从 history/page 的返回值里拆走了,「拉一条历史顺带取投影」这条路断了;
7
+ * 改走 `apiProxy.sessions.projections({ request: { sessionId } })`,返回值是
8
+ * `{ asOfSeq, values }`。顺带把 plan 归一化成 planMode(排队条与计划条读它)。
9
+ * 历史轮询是每 15 秒一次的热路径,多背一次投影 RPC 划不来,所以统计单独走投影端点。
10
+ */
11
+
12
+ const { unwrap } = require('./envelope.js');
13
+ const { callSessions } = require('./rpc.js');
14
+
15
+ /**
16
+ * 镜像快照用的统计投影(与 handleStats 尾部投影读取保持一致):取整帧 projections,
17
+ * 并把 plan 归一化为 planMode,供 pushMirror 为活跃会话生成统计快照。
18
+ */
19
+ async function buildStatsSnapshot(ctx, sessionId) {
20
+ return statsProjections(ctx, sessionId);
21
+ }
22
+
23
+ /**
24
+ * 统计投影快照(handleStats 与 buildStatsSnapshot 共用)。
25
+ */
26
+ async function statsProjections(ctx, sessionId) {
27
+ // 显式给 signal:宿主部分方法内部直接读 signal.aborted,不做 undefined 兜底。
28
+ const controller = new AbortController();
29
+ let value;
30
+ let error;
31
+ try {
32
+ ({ value, error } = unwrap(await callSessions(ctx, 'projections', { sessionId }, 'mobile', controller.signal)));
33
+ } catch (thrown) {
34
+ // 投影读不出来不该变成一个从路由层逃出去的 rejection:handleStats 要回 400,
35
+ // pushMirror 那边更是一条都不能抛(抛了整帧快照就丢了)。收敛成正常错误态。
36
+ error = thrown && typeof thrown === 'object' && typeof thrown.code === 'string'
37
+ ? { code: thrown.code, message: typeof thrown.message === 'string' ? thrown.message : '读会话投影失败' }
38
+ : { code: 'host-call-failed', message: thrown && thrown.message ? String(thrown.message) : '读会话投影失败' };
39
+ }
40
+ if (error) return { ok: false, error };
41
+ const projections = value && value.values && typeof value.values === 'object' ? value.values : {};
42
+ const plan = projections.plan;
43
+ return {
44
+ ok: true,
45
+ projections: plan && typeof plan === 'object' && typeof plan.active === 'boolean'
46
+ ? { ...projections, planMode: { active: plan.active, ...(typeof plan.pending === 'boolean' ? { pending: plan.pending } : {}) } }
47
+ : projections,
48
+ };
49
+ }
50
+
51
+ module.exports = { buildStatsSnapshot, statsProjections };
@@ -6,7 +6,7 @@
6
6
  * 这个文件一度是一千九百多行的单体,HTTP 路由、宿主 RPC、缓存、事件派生全混在一起;现在
7
7
  * 按域拆开:envelope(信封拆解)、rpc(窄接口封装)、session-cache(按 ctx 隔离的状态表)、
8
8
  * attachments、completion、session-models、session-search、session-send、session-snapshot、
9
- * session-mirror、session-goal、session-presets、session-preview、session-list-shortcut。
9
+ * session-stats、session-mirror、session-goal、session-presets、session-preview、session-list-shortcut。
10
10
  *
11
11
  * 剩下的就是列表:GET /sessions(10 秒缓存 + fresh 绕过 + 完成态确认 + `?rev=` 条件刷新)、
12
12
  * 单会话摘要、正文水位探针、统计、新建会话、排队消息动作。本文件继续转出各子模块的导出,
@@ -32,7 +32,8 @@ const {
32
32
  const { resolveEventAttachments } = require('./attachments.js');
33
33
  const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
34
34
  const { lastTurnOf } = require('./session-last-turn.js');
35
- const { eventSeqOf, buildMessagesSnapshot, buildStatsSnapshot, statsProjections, handleMessages } = require('./session-snapshot.js');
35
+ const { eventSeqOf, buildMessagesSnapshot, handleMessages } = require('./session-snapshot.js');
36
+ const { buildStatsSnapshot, statsProjections } = require('./session-stats.js');
36
37
  const { buildModelsSnapshot, handleModels, handleSelectModel } = require('./session-models.js');
37
38
  const { buildSingleSessionMirror, buildMirrorWarmSnapshots } = require('./session-mirror.js');
38
39
  const { promptOnce, handleSend, handleCommands, handleSessionRead, handleSessionAttachment, handleStop,