dsh-plugin-mobile-gateway 0.7.4 → 0.7.5
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/docs/dsh-rc2-mobile-integration.md +4 -0
- package/lib/dsh-host-adapter.mjs +5 -2
- package/lib/index.mjs +37 -23
- package/lib/session-follower.mjs +2 -0
- package/lib/wire-json.mjs +7 -0
- package/package.json +3 -3
|
@@ -66,6 +66,10 @@
|
|
|
66
66
|
|
|
67
67
|
这是最新历史窗口和活动生成状态在同一个上游切点的快照。没有正在生成的 attempt 时省略 `activeAttempt`,客户端应清空临时输出。`hasMore` 为 true 时同时返回 `nextBeforeSeq`,按普通历史请求补更早内容;`replace` 指当前订阅基线需要替换,不能把该窗口误认为完整历史。
|
|
68
68
|
|
|
69
|
+
首屏窗口:网关向 Host `session.follow` 显式传入 `maxMessages: 12`,再将 conversation 事件限制在约 256 KiB 的最新连续后缀。单条最新消息不可拆分,独自超限时仍完整保留;活动 attempt 和 projections 不计入此正文预算,也不裁剪。被预算排除的较早记录通过 `hasMore/nextBeforeSeq` 按原协议分页获取。历史正文窗口可以缩小,但 `cursor` 始终保留 Host 的原子切点。
|
|
70
|
+
|
|
71
|
+
读取更早页时,为获取当前切点而打开的短暂 follow 仅请求 1 条消息;实际 `session.page` 继续使用客户端请求的 `maxMessages`。此调整同时作用于 iOS 和 Android,不依赖本地历史磁盘缓存。
|
|
72
|
+
|
|
69
73
|
客户端只处理当前 `subscriptionId`。收到新 snapshot 后更新 `streamId`,清理上一个 stream 的临时状态,并把持久流水位设为 `cursor`,不能使用精简 events 的最大 seq 代替它:精简视图可能隐藏了末尾的系统事件。
|
|
70
74
|
|
|
71
75
|
未发送 `assistantStream: true` 的连接仍接收持久 `event`,不会收到伪装成持久事件的 token。control 连接不能订阅对话流。
|
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -104,7 +104,9 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
104
104
|
const history = async (payload, signal) => {
|
|
105
105
|
const request = {
|
|
106
106
|
address: { kind: 'session', sessionId: payload.sessionId },
|
|
107
|
-
|
|
107
|
+
// Older-page requests need only the opening cursor/projections, not another large latest page.
|
|
108
|
+
...(payload.beforeSeq !== undefined ? { maxMessages: 1 }
|
|
109
|
+
: payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
108
110
|
}
|
|
109
111
|
const snapshot = await sessionSnapshot(request, signal)
|
|
110
112
|
let events = snapshot.events
|
|
@@ -270,7 +272,8 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
270
272
|
openSessionStream(sessionId, signal) {
|
|
271
273
|
return typertGateway.stream({
|
|
272
274
|
namespace: 'session', method: 'follow',
|
|
273
|
-
|
|
275
|
+
// Bound the opening window at the Host; older records remain available via session.page.
|
|
276
|
+
args: requestArgs({ address: { kind: 'session', sessionId }, maxMessages: 12, assistantStream: true }),
|
|
274
277
|
signal,
|
|
275
278
|
})
|
|
276
279
|
},
|
package/lib/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stringifyWireFrame } from './wire-json.mjs'
|
|
1
2
|
// dsh-plugin-mobile-gateway — persistent Host plugin.
|
|
2
3
|
//
|
|
3
4
|
// Two directions:
|
|
@@ -278,6 +279,7 @@ function buildWireEvent(session, event) {
|
|
|
278
279
|
type: 'user/message',
|
|
279
280
|
text: textOf(d.content || []),
|
|
280
281
|
source: d.source && d.source.kind,
|
|
282
|
+
...(typeof d.id === 'string' && d.id ? { raw: { id: d.id } } : {}),
|
|
281
283
|
...(images.length ? { images } : {}),
|
|
282
284
|
},
|
|
283
285
|
})
|
|
@@ -562,6 +564,13 @@ async function proxyQuery(api, type, method, payload, signal) {
|
|
|
562
564
|
}
|
|
563
565
|
}
|
|
564
566
|
|
|
567
|
+
// Projection queries must keep the caller's request identity on failure.
|
|
568
|
+
// They need the opening cut and projections, not the full conversation body.
|
|
569
|
+
async function querySessionProjection(api, requestType, sessionId) {
|
|
570
|
+
const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId, maxMessages: 1 })
|
|
571
|
+
return history.kind === 'error' ? { ...history, requestType, sessionId } : history
|
|
572
|
+
}
|
|
573
|
+
|
|
565
574
|
function fileTransferError(code, message, requestType, sessionId) {
|
|
566
575
|
return {
|
|
567
576
|
kind: 'error',
|
|
@@ -1025,6 +1034,9 @@ async function mutateGoal(api, type, method, payload) {
|
|
|
1025
1034
|
// and truncates oversized tool-result text.
|
|
1026
1035
|
// ---------------------------------------------------------------------------
|
|
1027
1036
|
const HISTORY_DEFAULT_MAX_BYTES = 4 * 1024 * 1024 // 4 MiB per frame
|
|
1037
|
+
// Opening snapshots should be readable before older history is requested.
|
|
1038
|
+
// Keep the newest indivisible message even when it alone exceeds this budget.
|
|
1039
|
+
const HISTORY_OPENING_MAX_BYTES = 256 * 1024
|
|
1028
1040
|
const HISTORY_TOOL_RESULT_MAX_CHARS = 2000 // per text block in conversation view
|
|
1029
1041
|
|
|
1030
1042
|
function eventBytes(event) {
|
|
@@ -1624,7 +1636,9 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1624
1636
|
}
|
|
1625
1637
|
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
1626
1638
|
if (sessionId) {
|
|
1627
|
-
|
|
1639
|
+
out.sessionId = sessionId
|
|
1640
|
+
const hist = await querySessionProjection(api, msg.type, sessionId)
|
|
1641
|
+
if (hist.kind === 'error') return hist
|
|
1628
1642
|
if (hist.kind === 'history' && hist.projections && hist.projections.values) {
|
|
1629
1643
|
out.sessionPermissions = hist.projections.values.permissions || null
|
|
1630
1644
|
}
|
|
@@ -1672,7 +1686,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1672
1686
|
if (msg.type === 'context-usage') {
|
|
1673
1687
|
const sessionId = requireSessionId(msg)
|
|
1674
1688
|
if (sessionId.error) return sessionId.error
|
|
1675
|
-
const hist = await
|
|
1689
|
+
const hist = await querySessionProjection(api, msg.type, sessionId.value)
|
|
1676
1690
|
if (hist.kind !== 'history') return hist
|
|
1677
1691
|
const values = (hist.projections && hist.projections.values) || {}
|
|
1678
1692
|
return {
|
|
@@ -1686,7 +1700,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1686
1700
|
if (msg.type === 'session-stats') {
|
|
1687
1701
|
const sessionId = requireSessionId(msg)
|
|
1688
1702
|
if (sessionId.error) return sessionId.error
|
|
1689
|
-
const hist = await
|
|
1703
|
+
const hist = await querySessionProjection(api, msg.type, sessionId.value)
|
|
1690
1704
|
if (hist.kind !== 'history') return hist
|
|
1691
1705
|
const values = (hist.projections && hist.projections.values) || {}
|
|
1692
1706
|
return {
|
|
@@ -2179,7 +2193,7 @@ const plugin = {
|
|
|
2179
2193
|
})
|
|
2180
2194
|
|
|
2181
2195
|
const broadcastInteractionFrame = (frame) => {
|
|
2182
|
-
const wire =
|
|
2196
|
+
const wire = stringifyWireFrame(frame)
|
|
2183
2197
|
for (const client of clients) {
|
|
2184
2198
|
if (client.mobileChannel === 'conversation') continue
|
|
2185
2199
|
if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
|
|
@@ -2191,7 +2205,7 @@ const plugin = {
|
|
|
2191
2205
|
// subscribed to one conversation. Subscriptions only scope the heavier
|
|
2192
2206
|
// conversation and interaction streams.
|
|
2193
2207
|
const broadcastSessionMetadataFrame = (frame) => {
|
|
2194
|
-
const wire =
|
|
2208
|
+
const wire = stringifyWireFrame(frame)
|
|
2195
2209
|
for (const client of clients) {
|
|
2196
2210
|
if (client.mobileChannel === 'conversation') continue
|
|
2197
2211
|
if (client.readyState === 1) client.send(wire)
|
|
@@ -2204,12 +2218,12 @@ const plugin = {
|
|
|
2204
2218
|
let approvalCount = 0
|
|
2205
2219
|
for (const pending of pendingQuestions.values()) {
|
|
2206
2220
|
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
2207
|
-
ws.send(
|
|
2221
|
+
ws.send(stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true)))
|
|
2208
2222
|
questionCount += 1
|
|
2209
2223
|
}
|
|
2210
2224
|
for (const pending of pendingApprovals.values()) {
|
|
2211
2225
|
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
2212
|
-
ws.send(
|
|
2226
|
+
ws.send(stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true)))
|
|
2213
2227
|
approvalCount += 1
|
|
2214
2228
|
}
|
|
2215
2229
|
log(`interaction replay: trigger=${trigger} filtered=${Boolean(ws.filterSessionId)} questions=${questionCount} approvals=${approvalCount}`)
|
|
@@ -2565,12 +2579,12 @@ const plugin = {
|
|
|
2565
2579
|
}
|
|
2566
2580
|
|
|
2567
2581
|
const sendFrame = frame => {
|
|
2568
|
-
if (ws.readyState === 1) ws.send(
|
|
2582
|
+
if (ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2569
2583
|
}
|
|
2570
2584
|
ws.sessionFollower = createSessionFollower(api, {
|
|
2571
2585
|
onFrame(frame, context) {
|
|
2572
2586
|
if (frame.type === 'snapshot') {
|
|
2573
|
-
sendFrame({ ...historyPage(frame.history, { sessionId: context.sessionId, view: 'conversation' }),
|
|
2587
|
+
sendFrame({ ...historyPage(frame.history, { sessionId: context.sessionId, view: 'conversation', maxBytes: HISTORY_OPENING_MAX_BYTES }),
|
|
2574
2588
|
kind: 'session-snapshot', ...context, assistantStream: frame.assistantStream, replace: true })
|
|
2575
2589
|
} else if (frame.type === 'event') {
|
|
2576
2590
|
sendFrame({ ...buildWireEvent({ id: context.sessionId }, frame.event), ...context })
|
|
@@ -2589,19 +2603,19 @@ const plugin = {
|
|
|
2589
2603
|
try {
|
|
2590
2604
|
msg = JSON.parse(data.toString())
|
|
2591
2605
|
} catch (error) {
|
|
2592
|
-
ws.send(
|
|
2606
|
+
ws.send(stringifyWireFrame({ kind: 'error', message: 'invalid json' }))
|
|
2593
2607
|
return
|
|
2594
2608
|
}
|
|
2595
2609
|
if (!msg || typeof msg.type !== 'string') return
|
|
2596
2610
|
const conversationRequest = ['message', 'history', 'subscribe', 'unsubscribe'].includes(msg.type)
|
|
2597
2611
|
if ((ws.mobileChannel === 'control' && conversationRequest) ||
|
|
2598
2612
|
(ws.mobileChannel === 'conversation' && !conversationRequest && msg.type !== 'ping')) {
|
|
2599
|
-
ws.send(
|
|
2613
|
+
ws.send(stringifyWireFrame({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
|
|
2600
2614
|
return
|
|
2601
2615
|
}
|
|
2602
2616
|
|
|
2603
2617
|
if (msg.type === 'ping') {
|
|
2604
|
-
ws.send(
|
|
2618
|
+
ws.send(stringifyWireFrame({ kind: 'pong', at: Date.now() }))
|
|
2605
2619
|
} else if (msg.type === 'subscribe') {
|
|
2606
2620
|
if (msg.assistantStream !== undefined && typeof msg.assistantStream !== 'boolean') {
|
|
2607
2621
|
sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream must be a boolean' })
|
|
@@ -2631,20 +2645,20 @@ const plugin = {
|
|
|
2631
2645
|
sendFrame({ kind: 'subscribed', sessionId: null, assistantStream: false })
|
|
2632
2646
|
} else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
|
|
2633
2647
|
respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
|
|
2634
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2648
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2635
2649
|
})
|
|
2636
2650
|
} else if (msg.type === 'approval-response') {
|
|
2637
2651
|
respondToApproval(msg).then((frame) => {
|
|
2638
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2652
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2639
2653
|
})
|
|
2640
2654
|
} else if (msg.type === 'file-list' || msg.type === 'file-download-open' ||
|
|
2641
2655
|
msg.type === 'file-download-read' || msg.type === 'file-download-cancel') {
|
|
2642
2656
|
fileTransfers.handle(ws, msg).then((frame) => {
|
|
2643
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2657
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2644
2658
|
})
|
|
2645
2659
|
} else if (msg.type === 'message') {
|
|
2646
2660
|
admitMessage(api, msg).then((frame) => {
|
|
2647
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2661
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2648
2662
|
})
|
|
2649
2663
|
} else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
|
|
2650
2664
|
msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
|
|
@@ -2658,10 +2672,10 @@ const plugin = {
|
|
|
2658
2672
|
msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
|
|
2659
2673
|
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
|
|
2660
2674
|
handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
|
|
2661
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2675
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2662
2676
|
})
|
|
2663
2677
|
} else {
|
|
2664
|
-
ws.send(
|
|
2678
|
+
ws.send(stringifyWireFrame({ kind: 'error', message: 'unknown message type: ' + msg.type }))
|
|
2665
2679
|
}
|
|
2666
2680
|
})
|
|
2667
2681
|
|
|
@@ -2678,14 +2692,14 @@ const plugin = {
|
|
|
2678
2692
|
|
|
2679
2693
|
log(`client connected (id=${id}, total=${clients.size})${device ? ' device=' + device.name : ''}`)
|
|
2680
2694
|
if (paired) {
|
|
2681
|
-
ws.send(
|
|
2695
|
+
ws.send(stringifyWireFrame({
|
|
2682
2696
|
kind: 'paired',
|
|
2683
2697
|
token: paired.token,
|
|
2684
2698
|
device: paired.device,
|
|
2685
2699
|
...gatewayIdentity,
|
|
2686
2700
|
}))
|
|
2687
2701
|
}
|
|
2688
|
-
ws.send(
|
|
2702
|
+
ws.send(stringifyWireFrame({
|
|
2689
2703
|
kind: 'hello',
|
|
2690
2704
|
...gatewayIdentity,
|
|
2691
2705
|
protocol: 3,
|
|
@@ -2713,10 +2727,10 @@ const plugin = {
|
|
|
2713
2727
|
...(device ? { device: { id: device.id, name: device.name } } : {}),
|
|
2714
2728
|
}))
|
|
2715
2729
|
if (ws.mobileChannel !== 'conversation' && archivedSessionIds !== null) {
|
|
2716
|
-
ws.send(
|
|
2730
|
+
ws.send(stringifyWireFrame({ kind: 'session-archives', archivedSessionIds }))
|
|
2717
2731
|
}
|
|
2718
2732
|
if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
|
|
2719
|
-
ws.send(
|
|
2733
|
+
ws.send(stringifyWireFrame({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
|
|
2720
2734
|
}
|
|
2721
2735
|
if (ws.mobileChannel !== 'conversation') {
|
|
2722
2736
|
sendFrame({ kind: 'projection-baseline', projections: sessionProjectionBaselines })
|
|
@@ -2788,7 +2802,7 @@ const plugin = {
|
|
|
2788
2802
|
...(event.data.source ? { source: event.data.source } : {}),
|
|
2789
2803
|
})
|
|
2790
2804
|
}
|
|
2791
|
-
const payload =
|
|
2805
|
+
const payload = stringifyWireFrame(wire)
|
|
2792
2806
|
for (const client of clients) {
|
|
2793
2807
|
if (client.mobileChannel === 'control' || client.sessionFollowerActive) continue
|
|
2794
2808
|
if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
|
package/lib/session-follower.mjs
CHANGED
|
@@ -118,6 +118,8 @@ export function createSessionFollower(api, { onFrame, onError, retryMs = 1000 })
|
|
|
118
118
|
} catch (error) {
|
|
119
119
|
if (current !== state || signal.aborted) break
|
|
120
120
|
permanent = ['unsupported-session-format', 'session/not-found', 'gateway/bad-request'].includes(error.code)
|
|
121
|
+
|| (error.code === 'SESSION_QUERY_PERSISTENCE_FAILED'
|
|
122
|
+
&& /refuses this format .*Session/.test(error.message || ''))
|
|
121
123
|
onError(error, { ...context, retrying: !permanent })
|
|
122
124
|
} finally {
|
|
123
125
|
// Abort before return: the producer may be waiting for another event.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Host 的摘要截断可能留下单个 UTF-16 代理项。JSON.stringify 会将其转义,
|
|
2
|
+
// 但 Swift/Foundation 仍拒绝整帧;在传输边界统一输出有效 Unicode。
|
|
3
|
+
// 不修改原对象、历史正文、有效 emoji 或字面量反斜杠转义文本。
|
|
4
|
+
export function stringifyWireFrame(frame) {
|
|
5
|
+
return JSON.stringify(frame, (_key, value) =>
|
|
6
|
+
typeof value === 'string' ? value.replace(/[\uD800-\uDFFF]/gu, '\uFFFD') : value)
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.5",
|
|
4
|
+
"description": "Mobile gateway for dsh 0.1.5-rc.1/2 with smaller opening history snapshots, accurate query error reporting, Unicode-safe wire frames, and preserved user message IDs",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
7
7
|
"bin",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"bin": "bin/setup-ip.mjs",
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
|
|
29
|
+
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./lib/index.mjs",
|