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.
@@ -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
+ }