@sparkelf/dsh-plugin-mobile-gateway 0.8.0
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/LICENSE +21 -0
- package/PROTOCOL.md +1180 -0
- package/README.md +281 -0
- package/bin/setup-ip.mjs +474 -0
- package/cordis.patch.yml +44 -0
- package/docs/assets/mobile-device-management.png +0 -0
- package/docs/assets/public-access-ui.png +0 -0
- package/docs/assets/whale-girl-ios-app-promo-16x9.png +0 -0
- package/docs/blog-mobile-gateway.md +542 -0
- package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -0
- package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -0
- package/docs/dsh-rc2-mobile-integration.md +155 -0
- package/docs/multi-gateway-app-integration.md +124 -0
- package/docs/multi-gateway-phase1-acceptance.md +179 -0
- package/docs/multi-gateway-todo.md +137 -0
- package/docs/remote-gateway-refactor-plan.md +52 -0
- package/docs/runtime-architecture.architecture.json +264 -0
- package/docs/runtime-architecture.html +15001 -0
- package/docs/runtime-architecture.visual-check.html +32 -0
- package/docs/runtime-architecture.visual-check.json +548 -0
- package/docs/session-agent-preset-app-integration.md +93 -0
- package/docs/typert-remote-gateway-feature-checklist.md +294 -0
- package/helper/dsh_mobile_gateway_helper.py +227 -0
- package/lib/client.js +520 -0
- package/lib/devices.js +209 -0
- package/lib/dsh-host-adapter.mjs +466 -0
- package/lib/gateway-state.mjs +57 -0
- package/lib/index.mjs +3197 -0
- package/lib/session-follower.mjs +136 -0
- package/lib/wire-json.mjs +7 -0
- package/package.json +50 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { readSessionSnapshot } from './dsh-host-adapter.mjs'
|
|
3
|
+
|
|
4
|
+
function integer(value, minimum = 0) {
|
|
5
|
+
return Number.isSafeInteger(value) && value >= minimum
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function requireAttempt(attempt) {
|
|
9
|
+
if (!attempt || typeof attempt.attemptId !== 'string' || !attempt.attemptId
|
|
10
|
+
|| !integer(attempt.turn) || !integer(attempt.step)
|
|
11
|
+
|| !integer(attempt.startedAfterSeq, -1)) throw new Error('invalid assistant attempt')
|
|
12
|
+
return attempt
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Validate the two independent clocks. No transient frame owns a Session seq.
|
|
16
|
+
export function createFollowDecoder(sessionId) {
|
|
17
|
+
let cursor
|
|
18
|
+
let revision
|
|
19
|
+
let active
|
|
20
|
+
return frame => {
|
|
21
|
+
if (cursor === undefined) {
|
|
22
|
+
const history = readSessionSnapshot(frame, sessionId)
|
|
23
|
+
const baseline = frame.assistantStream
|
|
24
|
+
if (!baseline || !integer(baseline.revision)) throw new Error('missing assistant stream baseline')
|
|
25
|
+
if (baseline.activeAttempt !== undefined) {
|
|
26
|
+
const attempt = requireAttempt(baseline.activeAttempt)
|
|
27
|
+
if (!integer(attempt.nextIndex) || !Array.isArray(attempt.stream)
|
|
28
|
+
|| attempt.startedAfterSeq > history.cursor) throw new Error('invalid assistant baseline')
|
|
29
|
+
active = { ...attempt }
|
|
30
|
+
}
|
|
31
|
+
cursor = history.cursor
|
|
32
|
+
revision = baseline.revision
|
|
33
|
+
return { type: 'snapshot', history, assistantStream: baseline }
|
|
34
|
+
}
|
|
35
|
+
if (frame?.type === 'event') {
|
|
36
|
+
if (!frame.event || frame.event.seq !== cursor + 1) throw new Error('session event sequence gap')
|
|
37
|
+
cursor = frame.event.seq
|
|
38
|
+
return frame
|
|
39
|
+
}
|
|
40
|
+
if (frame?.type !== 'assistant-stream') throw new Error('unexpected session follow frame')
|
|
41
|
+
const next = frame.frame
|
|
42
|
+
if (!next || next.revision !== revision + 1) throw new Error('assistant stream revision gap')
|
|
43
|
+
if (next.type === 'start') {
|
|
44
|
+
requireAttempt(next)
|
|
45
|
+
if (active || next.startedAfterSeq > cursor) throw new Error('unexpected assistant start')
|
|
46
|
+
active = { ...next, nextIndex: 0 }
|
|
47
|
+
} else {
|
|
48
|
+
if (!active || next.attemptId !== active.attemptId || next.index !== active.nextIndex) {
|
|
49
|
+
throw new Error('assistant stream attempt/index gap')
|
|
50
|
+
}
|
|
51
|
+
if (next.type === 'chunk') {
|
|
52
|
+
if (!integer(next.time) || !next.chunk || typeof next.chunk.type !== 'string') {
|
|
53
|
+
throw new Error('invalid assistant chunk')
|
|
54
|
+
}
|
|
55
|
+
active.nextIndex += 1
|
|
56
|
+
} else if (next.type === 'end') {
|
|
57
|
+
const outcome = next.outcome
|
|
58
|
+
if (!outcome || (outcome.kind !== 'abandoned' && (outcome.kind !== 'committed'
|
|
59
|
+
|| !['assistant/message', 'assistant/attempt'].includes(outcome.eventType)
|
|
60
|
+
|| !integer(outcome.seq) || outcome.seq > cursor || outcome.seq <= active.startedAfterSeq))) {
|
|
61
|
+
throw new Error('invalid assistant settlement')
|
|
62
|
+
}
|
|
63
|
+
} else throw new Error('unexpected assistant frame')
|
|
64
|
+
}
|
|
65
|
+
revision = next.revision
|
|
66
|
+
const normalized = { ...next, turn: active.turn, step: active.step }
|
|
67
|
+
if (next.type === 'end') active = undefined
|
|
68
|
+
return { type: 'assistant-stream', frame: normalized }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function delay(ms, signal) {
|
|
73
|
+
return new Promise(resolve => {
|
|
74
|
+
if (signal.aborted) return resolve()
|
|
75
|
+
const finish = () => {
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
signal.removeEventListener('abort', finish)
|
|
78
|
+
resolve()
|
|
79
|
+
}
|
|
80
|
+
const timer = setTimeout(finish, ms)
|
|
81
|
+
signal.addEventListener('abort', finish, { once: true })
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// One follow per opted-in connection. Each opening owns a new stream identity
|
|
86
|
+
// and atomic snapshot, including a reconnecting Agent's active attempt.
|
|
87
|
+
export function createSessionFollower(api, { onFrame, onError, retryMs = 1000 }) {
|
|
88
|
+
let current
|
|
89
|
+
const stop = () => {
|
|
90
|
+
const previous = current
|
|
91
|
+
current = undefined
|
|
92
|
+
previous?.abort.abort()
|
|
93
|
+
}
|
|
94
|
+
const start = (sessionId, subscriptionId) => {
|
|
95
|
+
stop()
|
|
96
|
+
const state = { abort: new AbortController() }
|
|
97
|
+
current = state
|
|
98
|
+
const task = (async () => {
|
|
99
|
+
let failures = 0
|
|
100
|
+
while (current === state && !state.abort.signal.aborted) {
|
|
101
|
+
const attemptAbort = new AbortController()
|
|
102
|
+
const signal = AbortSignal.any([state.abort.signal, attemptAbort.signal])
|
|
103
|
+
const streamId = crypto.randomUUID()
|
|
104
|
+
const context = { sessionId, subscriptionId, streamId }
|
|
105
|
+
const decode = createFollowDecoder(sessionId)
|
|
106
|
+
let iterator
|
|
107
|
+
let permanent = false
|
|
108
|
+
try {
|
|
109
|
+
const stream = await api.openSessionStream(sessionId, signal)
|
|
110
|
+
iterator = stream[Symbol.asyncIterator]()
|
|
111
|
+
while (current === state && !signal.aborted) {
|
|
112
|
+
const item = await iterator.next()
|
|
113
|
+
if (current !== state || signal.aborted) break
|
|
114
|
+
if (item.done) throw new Error('session follow ended')
|
|
115
|
+
const decoded = decode(item.value)
|
|
116
|
+
onFrame(decoded, context)
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (current !== state || signal.aborted) break
|
|
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 || ''))
|
|
123
|
+
onError(error, { ...context, retrying: !permanent })
|
|
124
|
+
} finally {
|
|
125
|
+
// Abort before return: the producer may be waiting for another event.
|
|
126
|
+
attemptAbort.abort()
|
|
127
|
+
try { await iterator?.return?.() } catch { /* The opening failure was already reported. */ }
|
|
128
|
+
}
|
|
129
|
+
if (permanent) break
|
|
130
|
+
await delay(Math.min(retryMs * 2 ** Math.min(failures++, 5), 30_000), state.abort.signal)
|
|
131
|
+
}
|
|
132
|
+
})()
|
|
133
|
+
return task
|
|
134
|
+
}
|
|
135
|
+
return { start, stop }
|
|
136
|
+
}
|
|
@@ -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
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sparkelf/dsh-plugin-mobile-gateway",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Mobile gateway for DSH 0.1.7 (Session format 4), carrying the fork's format adaptation: v3 rows are lifted on read so a Session written by an earlier release still opens. Per-session Agent preset selection, live preset updates, and the upstream LAN/pairing protocol are unchanged.",
|
|
5
|
+
"main": "lib/index.mjs",
|
|
6
|
+
"files": [
|
|
7
|
+
"bin",
|
|
8
|
+
"helper",
|
|
9
|
+
"lib",
|
|
10
|
+
"docs",
|
|
11
|
+
"cordis.patch.yml",
|
|
12
|
+
"PROTOCOL.md"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/SparkElf/dsh-plugin-mobile-gateway.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/Clarklevis1995/dsh-plugin-mobile-gateway#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/Clarklevis1995/dsh-plugin-mobile-gateway/issues"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
24
|
+
"ws": "^8.18.0",
|
|
25
|
+
"qrcode": "^1.5.4"
|
|
26
|
+
},
|
|
27
|
+
"bin": "bin/setup-ip.mjs",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.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
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./lib/index.mjs",
|
|
33
|
+
"./client": "./lib/client.js",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
},
|
|
40
|
+
"client": {
|
|
41
|
+
"platform": "web",
|
|
42
|
+
"inject": [
|
|
43
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"license": "MIT"
|
|
50
|
+
}
|