dsh-mobile-plugin 0.5.1 → 0.5.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.
@@ -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,
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 「这一页里只看到工具调用、看不到它的结果」时的补救:把结果记在进程里,供更早的页用。
5
+ *
6
+ * 为什么需要:历史是倒着分页取的(`beforeSeq`),一页的事件是 `[oldest, beforeSeq)`。而
7
+ * `tool/result` 事件的 seq 比「发起这次调用的 assistant 消息」**大** —— 边界正好切在两者
8
+ * 之间时,那一页只看得到调用,看不到结果,于是那条消息的工具图缺一块;等下一页(更新的一页,
9
+ * 本机早就拿到过)把结果带回来再补,手机侧那一行已经从「没图」的高度长到「有图」的高度,
10
+ * 而它在用户头顶上 —— 一变就把正看的内容推走(真机录制:同一条消息 589 ↔ 817 来回)。
11
+ *
12
+ * 做法:每次投影都把这一页看到的结果记下来;下一次投影更早的页时,把这些记下的结果一并喂给
13
+ * `deriveMessages`,图就跟着消息一起下发,行在挂载那一帧就是终高。
14
+ *
15
+ * 边界:只活在插件进程里、只按会话分桶、每桶按插入序淘汰 —— 进程重启就没了,那时手机重新
16
+ * 进对话本来也要重拉。
17
+ */
18
+
19
+ /** 每条会话记住多少个调用:一次翻页窗口最多几百个,2000 足够覆盖一次通读。 */
20
+ const MAX_CALLS_PER_SESSION = 2000;
21
+ /** 同时记几条会话:手机一般只在一两条会话里翻历史。 */
22
+ const MAX_SESSIONS = 8;
23
+
24
+ /** sessionId → Map(callId → 结果)。Map 的插入序就是淘汰序。 */
25
+ const stores = new Map();
26
+
27
+ /**
28
+ * 记住这一页看到的结果(已存在的不覆盖:同一次调用的结果不会变,先记下的就是它)。
29
+ */
30
+ function rememberToolResults(sessionId, results) {
31
+ if (!sessionId || !results || results.size === 0) return;
32
+ let bucket = stores.get(sessionId);
33
+ if (!bucket) {
34
+ bucket = new Map();
35
+ stores.set(sessionId, bucket);
36
+ // 新会话进来,把最旧的那条挤掉。
37
+ while (stores.size > MAX_SESSIONS) {
38
+ const oldest = stores.keys().next();
39
+ if (oldest.done) break;
40
+ stores.delete(oldest.value);
41
+ }
42
+ } else {
43
+ // 读到就挪到末尾(按使用序淘汰,而不是按写入序)。
44
+ stores.delete(sessionId);
45
+ stores.set(sessionId, bucket);
46
+ }
47
+ for (const [callId, result] of results) {
48
+ if (bucket.has(callId)) continue;
49
+ bucket.set(callId, result);
50
+ if (bucket.size > MAX_CALLS_PER_SESSION) {
51
+ const oldest = bucket.keys().next();
52
+ if (oldest.done) break;
53
+ bucket.delete(oldest.value);
54
+ }
55
+ }
56
+ }
57
+
58
+ /** 这条会话已经记下的结果(没有就是 empty Map)。调用方不要改它。 */
59
+ function toolResultsFor(sessionId) {
60
+ return (sessionId && stores.get(sessionId)) || null;
61
+ }
62
+
63
+ /** 测试用:清掉一条(或全部)会话的记录。 */
64
+ function clearToolResults(sessionId) {
65
+ if (sessionId) stores.delete(sessionId);
66
+ else stores.clear();
67
+ }
68
+
69
+ module.exports = {
70
+ rememberToolResults,
71
+ toolResultsFor,
72
+ clearToolResults,
73
+ MAX_CALLS_PER_SESSION,
74
+ MAX_SESSIONS,
75
+ };