dsh-plugin-mobile-gateway 0.6.9 → 0.7.1
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/PROTOCOL.md +113 -22
- package/README.md +11 -1
- package/cordis.patch.yml +2 -2
- package/docs/blog-mobile-gateway.md +1 -1
- package/docs/remote-gateway-refactor-plan.md +52 -0
- package/lib/dsh-host-adapter.mjs +304 -0
- package/lib/index.mjs +376 -224
- package/package.json +3 -3
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
|
|
4
|
+
function requireRecord(value, endpoint) {
|
|
5
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
6
|
+
throw new Error(`${endpoint} returned an invalid object`)
|
|
7
|
+
}
|
|
8
|
+
return value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function requireArray(value, endpoint) {
|
|
12
|
+
if (!Array.isArray(value)) throw new Error(`${endpoint} returned an invalid array`)
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function chunkRowLength(event) {
|
|
17
|
+
if (event.type === 'chunkrow/tool-call-chunks') return requireArray(event.data?.args, event.type).length
|
|
18
|
+
return requireArray(event.data?.texts, event.type).length
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// DSH 0.1.2 compresses consecutive assistant deltas inside history pages.
|
|
22
|
+
// dsh-mobile-v1 predates that storage shape, so restore the exact logical
|
|
23
|
+
// SessionEvent sequence at this boundary instead of teaching every client a
|
|
24
|
+
// Host-specific encoding detail.
|
|
25
|
+
function expandChunkRow(event) {
|
|
26
|
+
const data = requireRecord(event.data, event.type)
|
|
27
|
+
const values = event.type === 'chunkrow/tool-call-chunks'
|
|
28
|
+
? requireArray(data.args, event.type)
|
|
29
|
+
: requireArray(data.texts, event.type)
|
|
30
|
+
const gaps = requireArray(data.dt, event.type)
|
|
31
|
+
if (gaps.length !== Math.max(0, values.length - 1)) {
|
|
32
|
+
throw new Error(`${event.type} returned invalid timing data`)
|
|
33
|
+
}
|
|
34
|
+
const events = []
|
|
35
|
+
let time = event.time
|
|
36
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
37
|
+
if (index > 0) time += gaps[index - 1]
|
|
38
|
+
let chunk
|
|
39
|
+
if (event.type === 'chunkrow/text-chunks') {
|
|
40
|
+
chunk = { type: 'text-delta', index: data.index, text: values[index] }
|
|
41
|
+
} else if (event.type === 'chunkrow/reasoning-chunks') {
|
|
42
|
+
chunk = { type: 'reasoning-delta', index: data.index, text: values[index] }
|
|
43
|
+
} else {
|
|
44
|
+
chunk = {
|
|
45
|
+
type: 'tool-call-delta',
|
|
46
|
+
index: data.index,
|
|
47
|
+
id: data.id,
|
|
48
|
+
...(Object.hasOwn(data, 'name') ? { name: data.name } : {}),
|
|
49
|
+
argumentsDelta: values[index],
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
events.push({
|
|
53
|
+
type: 'assistant/chunk',
|
|
54
|
+
seq: event.seq + index,
|
|
55
|
+
time,
|
|
56
|
+
data: { turn: data.turn, step: data.step, chunk },
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
return events
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function expandHistoryRecords(records) {
|
|
63
|
+
const events = []
|
|
64
|
+
for (const record of requireArray(records, 'session history')) {
|
|
65
|
+
const value = requireRecord(record, 'session history record')
|
|
66
|
+
const event = requireRecord(value.event, 'session history event')
|
|
67
|
+
if (value.type === 'event') {
|
|
68
|
+
events.push(event)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (value.type !== 'chunks' || ![
|
|
72
|
+
'chunkrow/text-chunks',
|
|
73
|
+
'chunkrow/reasoning-chunks',
|
|
74
|
+
'chunkrow/tool-call-chunks',
|
|
75
|
+
].includes(event.type)) {
|
|
76
|
+
throw new Error('session history returned an unsupported record')
|
|
77
|
+
}
|
|
78
|
+
if (chunkRowLength(event) === 0) throw new Error(`${event.type} returned an empty run`)
|
|
79
|
+
events.push(...expandChunkRow(event))
|
|
80
|
+
}
|
|
81
|
+
return events
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function firstStreamFrame(gateway, namespace, method, args, signal) {
|
|
85
|
+
const ownedAbort = signal === undefined ? new AbortController() : null
|
|
86
|
+
const streamSignal = signal ?? ownedAbort.signal
|
|
87
|
+
const iterable = await gateway.stream({ namespace, method, args, signal: streamSignal })
|
|
88
|
+
const iteratorFactory = iterable?.[Symbol.asyncIterator] ?? iterable?.[Symbol.iterator]
|
|
89
|
+
if (typeof iteratorFactory !== 'function') throw new Error(`${namespace}/${method} returned an invalid stream`)
|
|
90
|
+
const iterator = iteratorFactory.call(iterable)
|
|
91
|
+
try {
|
|
92
|
+
const first = await iterator.next()
|
|
93
|
+
if (first.done) throw new Error(`${namespace}/${method} ended before its opening frame`)
|
|
94
|
+
return first.value
|
|
95
|
+
} finally {
|
|
96
|
+
ownedAbort?.abort()
|
|
97
|
+
if (typeof iterator.return === 'function') await iterator.return()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requestArgs(request) {
|
|
102
|
+
return { request }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The RC.1 SessionController intentionally names its reserved list argument
|
|
106
|
+
// `_request`. Typert descriptors preserve that source parameter name exactly,
|
|
107
|
+
// so this endpoint cannot share the normal `{ request }` wrapper.
|
|
108
|
+
function sessionListArgs(request) {
|
|
109
|
+
return { _request: request }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The only module allowed to know DSH Remote endpoint names and argument
|
|
114
|
+
* descriptors. Its public methods intentionally match mobile-gateway domain
|
|
115
|
+
* operations, not the upstream transport envelope.
|
|
116
|
+
*/
|
|
117
|
+
export function createDshHostAdapter(typertGateway) {
|
|
118
|
+
if (!typertGateway || typeof typertGateway.invoke !== 'function' || typeof typertGateway.stream !== 'function') {
|
|
119
|
+
throw new Error('mobile-gateway requires the DSH Remote Gateway host service')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const invoke = (namespace, method, args, signal) => typertGateway.invoke({
|
|
123
|
+
namespace,
|
|
124
|
+
method,
|
|
125
|
+
args,
|
|
126
|
+
...(signal === undefined ? {} : { signal }),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const sessionSnapshot = async (request, signal) => {
|
|
130
|
+
const frame = requireRecord(await firstStreamFrame(
|
|
131
|
+
typertGateway,
|
|
132
|
+
'session',
|
|
133
|
+
'follow',
|
|
134
|
+
requestArgs(request),
|
|
135
|
+
signal,
|
|
136
|
+
), 'session/follow')
|
|
137
|
+
if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor)) {
|
|
138
|
+
throw new Error('session/follow returned an invalid opening snapshot')
|
|
139
|
+
}
|
|
140
|
+
return frame
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const history = async (payload, signal) => {
|
|
144
|
+
const request = {
|
|
145
|
+
address: { kind: 'session', sessionId: payload.sessionId },
|
|
146
|
+
...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
147
|
+
}
|
|
148
|
+
const snapshot = await sessionSnapshot(request, signal)
|
|
149
|
+
let records = snapshot.records
|
|
150
|
+
let hasMore = snapshot.hasMore === true
|
|
151
|
+
if (payload.beforeSeq !== undefined) {
|
|
152
|
+
const page = requireRecord(await invoke('session', 'page', requestArgs({
|
|
153
|
+
address: request.address,
|
|
154
|
+
throughSeq: snapshot.cursor,
|
|
155
|
+
beforeSeq: payload.beforeSeq,
|
|
156
|
+
...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
157
|
+
}), signal), 'session/page')
|
|
158
|
+
records = page.records
|
|
159
|
+
hasMore = page.hasMore === true
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
events: expandHistoryRecords(records).map(event => ({ event })),
|
|
163
|
+
hasMore,
|
|
164
|
+
projections: requireRecord(snapshot.projections, 'session/follow projections'),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const modelCatalog = async (signal) => requireRecord(
|
|
169
|
+
await invoke('session', 'modelCatalog', {}, signal),
|
|
170
|
+
'session/modelCatalog',
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const commands = {
|
|
174
|
+
list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
|
|
175
|
+
execute: (sessionId, line, images, signal) => invoke(
|
|
176
|
+
'commands',
|
|
177
|
+
'execute',
|
|
178
|
+
{ agentId: sessionId, line, images },
|
|
179
|
+
signal,
|
|
180
|
+
),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const goalRefValue = (value, endpoint) => {
|
|
184
|
+
const goal = requireRecord(value, endpoint)
|
|
185
|
+
if (typeof goal.id !== 'string' || !Number.isSafeInteger(goal.revision)) {
|
|
186
|
+
throw new Error(`${endpoint} returned an invalid goal`)
|
|
187
|
+
}
|
|
188
|
+
return { ref: { id: goal.id, revision: goal.revision } }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const describeHost = async (signal) => {
|
|
192
|
+
const [sessions, catalog, canOpenPath] = await Promise.all([
|
|
193
|
+
invoke('session', 'list', sessionListArgs({}), signal),
|
|
194
|
+
modelCatalog(signal),
|
|
195
|
+
invoke('session', 'canOpenWorkspacePath', {}, signal).catch(() => false),
|
|
196
|
+
])
|
|
197
|
+
return {
|
|
198
|
+
version: 'remote-gateway',
|
|
199
|
+
cwd: os.homedir(),
|
|
200
|
+
attachedSessions: Array.isArray(sessions?.items) ? sessions.items.length : 0,
|
|
201
|
+
canOpenPath: canOpenPath === true,
|
|
202
|
+
defaultProvider: catalog.default?.provider,
|
|
203
|
+
defaultModel: catalog.default?.model,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
sessions: {
|
|
209
|
+
list: (payload = {}, signal) => invoke('session', 'list', sessionListArgs(payload), signal),
|
|
210
|
+
search: (payload, signal) => invoke('session', 'search', requestArgs(payload), signal),
|
|
211
|
+
create: (payload, signal) => invoke('session', 'create', requestArgs(payload), signal),
|
|
212
|
+
prompt: (payload, signal) => invoke('session', 'prompt', requestArgs({
|
|
213
|
+
requestId: crypto.randomUUID(),
|
|
214
|
+
...payload,
|
|
215
|
+
}), signal),
|
|
216
|
+
attachment: (payload, signal) => invoke('session', 'attachment', requestArgs(payload), signal),
|
|
217
|
+
fork: (payload, signal) => invoke('session', 'fork', requestArgs(payload), signal),
|
|
218
|
+
selectModel: (payload, signal) => invoke('session', 'selectModel', requestArgs(payload), signal),
|
|
219
|
+
history,
|
|
220
|
+
async models(payload, signal) {
|
|
221
|
+
const [catalog, snapshot] = await Promise.all([
|
|
222
|
+
modelCatalog(signal),
|
|
223
|
+
sessionSnapshot({ address: { kind: 'session', sessionId: payload.sessionId }, maxMessages: 1 }, signal),
|
|
224
|
+
])
|
|
225
|
+
const current = snapshot.projections?.values?.modelSelection?.next ?? catalog.default
|
|
226
|
+
return {
|
|
227
|
+
current,
|
|
228
|
+
routable: typeof current?.provider === 'string'
|
|
229
|
+
&& requireArray(catalog.routableProviders, 'session/modelCatalog routableProviders').includes(current.provider),
|
|
230
|
+
groups: requireArray(catalog.groups, 'session/modelCatalog groups'),
|
|
231
|
+
failures: requireArray(catalog.failures, 'session/modelCatalog failures'),
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
workspace: {
|
|
236
|
+
async list(_payload = {}, signal) {
|
|
237
|
+
const frame = requireRecord(await firstStreamFrame(typertGateway, 'workspace', 'follow', {}, signal), 'workspace/follow')
|
|
238
|
+
if (frame.type !== 'baseline') throw new Error('workspace/follow returned an invalid opening baseline')
|
|
239
|
+
return requireRecord(frame.value, 'workspace/follow baseline')
|
|
240
|
+
},
|
|
241
|
+
create: (payload, signal) => invoke('workspace', 'create', requestArgs(payload), signal),
|
|
242
|
+
},
|
|
243
|
+
settings: {
|
|
244
|
+
describe: (_payload = {}, signal) => invoke('settings', 'describe', {}, signal),
|
|
245
|
+
update: (payload, signal) => invoke('settings', 'update', {
|
|
246
|
+
ns: payload.ns,
|
|
247
|
+
patch: payload.patch,
|
|
248
|
+
...(payload.expectedRevision === undefined ? {} : { expectedRevision: payload.expectedRevision }),
|
|
249
|
+
}, signal),
|
|
250
|
+
},
|
|
251
|
+
skills: {
|
|
252
|
+
list: (payload, signal) => invoke('skills', 'list', requestArgs(payload), signal),
|
|
253
|
+
},
|
|
254
|
+
agentPresets: {
|
|
255
|
+
list: (_payload = {}, signal) => invoke('agentPresets', 'list', {}, signal),
|
|
256
|
+
},
|
|
257
|
+
llm: {
|
|
258
|
+
async models(_payload = {}, signal) {
|
|
259
|
+
const catalog = await modelCatalog(signal)
|
|
260
|
+
return { groups: catalog.groups, failures: catalog.failures }
|
|
261
|
+
},
|
|
262
|
+
async providers(_payload = {}, signal) {
|
|
263
|
+
const providers = await invoke('llm', 'listConfigurableProviders', {}, signal)
|
|
264
|
+
return { providers: requireArray(providers, 'llm/listConfigurableProviders') }
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
goals: {
|
|
268
|
+
async edit(payload) {
|
|
269
|
+
return goalRefValue(await invoke('goals', 'edit', {
|
|
270
|
+
agentId: payload.sessionId,
|
|
271
|
+
ref: payload.ref,
|
|
272
|
+
request: {
|
|
273
|
+
...(payload.objective === undefined ? {} : { objective: payload.objective }),
|
|
274
|
+
...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }),
|
|
275
|
+
},
|
|
276
|
+
}), 'goals/edit')
|
|
277
|
+
},
|
|
278
|
+
async pause(payload) {
|
|
279
|
+
return goalRefValue(
|
|
280
|
+
await invoke('goals', 'pause', { agentId: payload.sessionId, ref: payload.ref }),
|
|
281
|
+
'goals/pause',
|
|
282
|
+
)
|
|
283
|
+
},
|
|
284
|
+
async resume(payload) {
|
|
285
|
+
return goalRefValue(
|
|
286
|
+
await invoke('goals', 'resume', { agentId: payload.sessionId, ref: payload.ref }),
|
|
287
|
+
'goals/resume',
|
|
288
|
+
)
|
|
289
|
+
},
|
|
290
|
+
clear: async payload => {
|
|
291
|
+
await invoke('goals', 'clear', { agentId: payload.sessionId, ref: payload.ref })
|
|
292
|
+
return { cleared: true }
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
commands,
|
|
296
|
+
host: {
|
|
297
|
+
describe: (_payload = {}, signal) => describeHost(signal),
|
|
298
|
+
},
|
|
299
|
+
describeHost,
|
|
300
|
+
openControlStream(signal) {
|
|
301
|
+
return typertGateway.stream({ namespace: 'session', method: 'control', args: {}, signal })
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|