dsh-browser-application 0.37.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.
Files changed (44) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +27 -0
  3. package/cordis.patch.yml +29 -0
  4. package/lib/index.js +3377 -0
  5. package/lib/invariant.js +26 -0
  6. package/lib/types/bridge-url.d.ts +27 -0
  7. package/lib/types/browser-context.d.ts +38 -0
  8. package/lib/types/dsh-gateway.d.ts +42 -0
  9. package/lib/types/event-generation.d.ts +56 -0
  10. package/lib/types/extension-sessions.d.ts +26 -0
  11. package/lib/types/host-api.d.ts +47 -0
  12. package/lib/types/image-relay.d.ts +43 -0
  13. package/lib/types/index.d.ts +158 -0
  14. package/lib/types/invariant.d.ts +16 -0
  15. package/lib/types/remote-host-api.d.ts +12 -0
  16. package/lib/types/server.d.ts +166 -0
  17. package/lib/types/session-deferral.d.ts +33 -0
  18. package/lib/types/session-history.d.ts +30 -0
  19. package/lib/types/session-purge.d.ts +55 -0
  20. package/lib/types/session-workspace.d.ts +37 -0
  21. package/lib/types/token.d.ts +57 -0
  22. package/lib/types/tools.d.ts +42 -0
  23. package/lib/types/vision-selfcheck.d.ts +18 -0
  24. package/lib/types/vision.d.ts +57 -0
  25. package/package.json +95 -0
  26. package/src/bridge-url.ts +57 -0
  27. package/src/browser-context.ts +102 -0
  28. package/src/dsh-gateway.ts +66 -0
  29. package/src/event-generation.ts +385 -0
  30. package/src/extension-sessions.ts +40 -0
  31. package/src/host-api.ts +64 -0
  32. package/src/image-relay.ts +118 -0
  33. package/src/index.ts +575 -0
  34. package/src/invariant.ts +33 -0
  35. package/src/remote-host-api.ts +397 -0
  36. package/src/server.ts +658 -0
  37. package/src/session-deferral.ts +296 -0
  38. package/src/session-history.ts +220 -0
  39. package/src/session-purge.ts +154 -0
  40. package/src/session-workspace.ts +147 -0
  41. package/src/token.ts +100 -0
  42. package/src/tools.ts +301 -0
  43. package/src/vision-selfcheck.ts +35 -0
  44. package/src/vision.ts +135 -0
@@ -0,0 +1,397 @@
1
+ /**
2
+ * dsh 0.2 Host adapter: unary calls through TypertGateway, plus the Session
3
+ * and forwarded-event plumbing assembled from the gateway, history, and
4
+ * event-generation modules.
5
+ *
6
+ * @module dsh-browser-application/src/remote-host-api
7
+ */
8
+
9
+ import type { RespondResult } from '@dsh-browser/protocol'
10
+ import type {
11
+ BrowserHostApi,
12
+ HostEventFrame,
13
+ HostRpcCall,
14
+ HostRpcFailure,
15
+ HostRpcResult,
16
+ } from './host-api.ts'
17
+ import { hostFailure, isRecord } from './host-api.ts'
18
+ import { ExtensionSessionRegistry } from './extension-sessions.ts'
19
+ import { openWireStream, type TypertGatewayLike, type HostConnectionLike } from './dsh-gateway.ts'
20
+ import {
21
+ oneShotSessionSnapshot,
22
+ historyValue,
23
+ historyPageValue,
24
+ optionalNonNegativeInteger,
25
+ optionalPositiveInteger,
26
+ } from './session-history.ts'
27
+ import { EventGeneration, type RemoteEventOutcome } from './event-generation.ts'
28
+
29
+
30
+ interface InvokeTarget {
31
+ readonly namespace: string
32
+ readonly method: string
33
+ readonly args: Readonly<Record<string, unknown>>
34
+ readonly adapt?: (value: unknown) => unknown
35
+ }
36
+
37
+
38
+ /** Build the dsh 0.2 Host implementation. */
39
+ export function createRemoteHostApi(
40
+ gateway: TypertGatewayLike,
41
+ connection: HostConnectionLike,
42
+ ): BrowserHostApi {
43
+ return new RemoteHostApi(gateway, connection)
44
+ }
45
+
46
+ class RemoteHostApi implements BrowserHostApi {
47
+ private readonly fetchHandler: ReturnType<HostConnectionLike['createSharedFetchHandler']>
48
+ private readonly extensionSessions = new ExtensionSessionRegistry()
49
+ /** Last known session/follow tip per Session; drives session/page throughSeq. */
50
+ private readonly historyCursors = new Map<string, number>()
51
+ private activeEvents: EventGeneration | undefined
52
+
53
+ constructor(
54
+ private readonly gateway: TypertGatewayLike,
55
+ connection: HostConnectionLike,
56
+ ) {
57
+ this.fetchHandler = connection.createSharedFetchHandler('/api')
58
+ }
59
+
60
+ async call(call: HostRpcCall): Promise<HostRpcResult> {
61
+ if (call.method === 'session.history') return this.sessionHistory(call)
62
+ if (call.method === 'session.models') return this.sessionModels(call)
63
+ if (call.method === 'workspace.list') return this.workspaceList(call)
64
+
65
+ const target = invokeTarget(call)
66
+ if ('error' in target) return { ok: false, error: target.error }
67
+
68
+ try {
69
+ // Deferred Sessions have no history call with which to establish the
70
+ // follower. Open it after materialization and before prompt admission,
71
+ // so the first user/turn events cannot race past the extension.
72
+ if (call.method === 'session.prompt') {
73
+ const sessionId = sessionIdOf(call.payload)
74
+ if (sessionId !== undefined) await this.activeEvents?.ensureSessionFollow(sessionId, call.signal)
75
+ }
76
+ const value = await this.gateway.invoke({
77
+ namespace: target.namespace,
78
+ method: target.method,
79
+ args: target.args,
80
+ signal: call.signal,
81
+ })
82
+ // Only claim ownership after a successful create/prompt. A failed prompt
83
+ // against a Desktop session must not steal later ask_user_question away
84
+ // from the native waterfall.
85
+ if (call.method === 'session.create' || call.method === 'session.prompt') {
86
+ this.extensionSessions.note(sessionIdOf(call.payload))
87
+ this.extensionSessions.note(sessionIdOf(value))
88
+ if (typeof value === 'string') this.extensionSessions.note(value)
89
+ }
90
+ return { ok: true, value: target.adapt?.(value) ?? value }
91
+ } catch (error: unknown) {
92
+ return { ok: false, error: this.failure(error) }
93
+ }
94
+ }
95
+
96
+ async *events(signal: AbortSignal): AsyncIterable<HostEventFrame> {
97
+ const generation = new EventGeneration(
98
+ this.gateway,
99
+ this.sendRemoteEventResult.bind(this),
100
+ this.extensionSessions,
101
+ this.noteHistoryCursor.bind(this),
102
+ signal,
103
+ )
104
+ const previous = this.activeEvents
105
+ this.activeEvents = generation
106
+ await previous?.dispose()
107
+ generation.start()
108
+ try {
109
+ yield * generation.events()
110
+ } finally {
111
+ if (this.activeEvents === generation) this.activeEvents = undefined
112
+ await generation.dispose()
113
+ }
114
+ }
115
+
116
+ async respond(rpcId: string, result: RespondResult, signal: AbortSignal): Promise<unknown> {
117
+ const generation = this.activeEvents
118
+ if (generation === undefined) return { accepted: false, reason: 'not-pending' }
119
+ return generation.respond(rpcId, result, signal)
120
+ }
121
+
122
+ private async sessionHistory(call: HostRpcCall): Promise<HostRpcResult> {
123
+ const sessionId = sessionIdOf(call.payload)
124
+ if (sessionId === undefined) return badRequest('session.history requires a non-empty sessionId')
125
+ let beforeSeq: number | undefined
126
+ let maxMessages: number | undefined
127
+ try {
128
+ beforeSeq = optionalNonNegativeInteger(call.payload, 'beforeSeq')
129
+ maxMessages = optionalPositiveInteger(call.payload, 'maxMessages')
130
+ } catch (error: unknown) {
131
+ return badRequest(error instanceof Error ? error.message : 'session.history pagination is invalid')
132
+ }
133
+ try {
134
+ if (beforeSeq !== undefined) {
135
+ const throughSeq = await this.historyThroughSeq(sessionId, call.signal)
136
+ const page = await this.gateway.invoke({
137
+ namespace: 'session',
138
+ method: 'page',
139
+ args: {
140
+ request: {
141
+ address: { kind: 'session', sessionId },
142
+ throughSeq,
143
+ beforeSeq,
144
+ ...(maxMessages === undefined ? {} : { maxMessages }),
145
+ },
146
+ },
147
+ signal: call.signal,
148
+ })
149
+ return { ok: true, value: historyPageValue(page) }
150
+ }
151
+
152
+ const snapshot = this.activeEvents === undefined
153
+ ? await oneShotSessionSnapshot(this.gateway, sessionId, call.signal, maxMessages)
154
+ : await this.activeEvents.openSessionHistory(sessionId, call.signal, maxMessages)
155
+ this.noteHistoryCursor(sessionId, snapshot.cursor)
156
+ return { ok: true, value: historyValue(snapshot) }
157
+ } catch (error: unknown) {
158
+ return { ok: false, error: this.failure(error) }
159
+ }
160
+ }
161
+
162
+ /** Combine the deployment catalog with the Session's durable next selection. */
163
+ private async sessionModels(call: HostRpcCall): Promise<HostRpcResult> {
164
+ if (!isRecord(call.payload)) return badRequest('session.models payload must be an object')
165
+ const sessionId = sessionIdOf(call.payload)
166
+ try {
167
+ const catalog = await this.gateway.invoke({
168
+ namespace: 'session', method: 'modelCatalog', args: {}, signal: call.signal,
169
+ })
170
+ // Provisional Sessions request only the catalog and overlay their pending choice.
171
+ const projections = sessionId === undefined ? undefined : await this.gateway.invoke({
172
+ namespace: 'session', method: 'projections', args: { request: { sessionId } }, signal: call.signal,
173
+ })
174
+ const values = isRecord(projections) ? projections.values : undefined
175
+ const modelSelection = isRecord(values) ? values.modelSelection : undefined
176
+ const next = isRecord(modelSelection) ? modelSelectionOf(modelSelection.next) : undefined
177
+ return { ok: true, value: adaptModelCatalog(catalog, next) }
178
+ } catch (error: unknown) {
179
+ return { ok: false, error: this.failure(error) }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Resolve a Host-legal throughSeq for older history pages.
185
+ * Never invent Number.MAX_SAFE_INTEGER — session/page rejects tips past the log cursor.
186
+ */
187
+ private async historyThroughSeq(sessionId: string, signal: AbortSignal): Promise<number> {
188
+ const cached = this.historyCursors.get(sessionId)
189
+ if (cached !== undefined) return cached
190
+ const snapshot = this.activeEvents === undefined
191
+ ? await oneShotSessionSnapshot(this.gateway, sessionId, signal)
192
+ : await this.activeEvents.openSessionHistory(sessionId, signal)
193
+ this.noteHistoryCursor(sessionId, snapshot.cursor)
194
+ const throughSeq = this.historyCursors.get(sessionId)
195
+ if (throughSeq === undefined) {
196
+ throw new TypeError('session/follow snapshot did not provide a usable history cursor')
197
+ }
198
+ return throughSeq
199
+ }
200
+
201
+ private noteHistoryCursor(sessionId: string, cursor: number): void {
202
+ // Host session/page refuses throughSeq past the durable tip; MAX_SAFE_INTEGER is
203
+ // only a UI sentinel elsewhere and must never be forwarded as a page tip.
204
+ if (!Number.isSafeInteger(cursor) || cursor < -1 || cursor === Number.MAX_SAFE_INTEGER) return
205
+ const previous = this.historyCursors.get(sessionId)
206
+ if (previous === undefined || cursor > previous) this.historyCursors.set(sessionId, cursor)
207
+ }
208
+
209
+ private async workspaceList(call: HostRpcCall): Promise<HostRpcResult> {
210
+ try {
211
+ const controller = new AbortController()
212
+ const signal = AbortSignal.any([call.signal, controller.signal])
213
+ const source = await openWireStream(this.gateway, 'workspace/follow', { args: {} }, signal)
214
+ const iterator = source[Symbol.asyncIterator]()
215
+ try {
216
+ const first = await iterator.next()
217
+ if (first.done || !isWorkspaceBaseline(first.value)) {
218
+ throw new TypeError('workspace/follow did not begin with a baseline')
219
+ }
220
+ return { ok: true, value: first.value.value }
221
+ } finally {
222
+ controller.abort(new Error('workspace baseline received'))
223
+ await iterator.return?.()
224
+ }
225
+ } catch (error: unknown) {
226
+ return { ok: false, error: this.failure(error) }
227
+ }
228
+ }
229
+
230
+ private failure(error: unknown): HostRpcFailure {
231
+ try {
232
+ return this.gateway.wireStream.failure(error)
233
+ } catch {
234
+ return hostFailure(error)
235
+ }
236
+ }
237
+
238
+ private async sendRemoteEventResult(
239
+ clientId: string,
240
+ eventId: string,
241
+ outcome: RemoteEventOutcome,
242
+ signal: AbortSignal,
243
+ ): Promise<void> {
244
+ const rpcId = crypto.randomUUID()
245
+ const request = new Request('http://dsh.internal/api/$events/result', {
246
+ method: 'POST',
247
+ headers: { 'content-type': 'application/json' },
248
+ body: JSON.stringify({
249
+ type: 'client-request',
250
+ rpcId,
251
+ method: '$events/result',
252
+ payload: { args: { clientId, eventId, outcome } },
253
+ }),
254
+ signal,
255
+ })
256
+ const response = await this.fetchHandler.fetch(request)
257
+ if (!response.ok) {
258
+ throw new Error(`$events/result transport failed with HTTP ${String(response.status)}: ${await response.text()}`)
259
+ }
260
+ const envelope = await response.json() as unknown
261
+ if (!isRecord(envelope) || envelope.type !== 'server-response' || envelope.rpcId !== rpcId
262
+ || !isRecord(envelope.result) || typeof envelope.result.ok !== 'boolean') {
263
+ throw new TypeError('$events/result returned an invalid server-response')
264
+ }
265
+ if (envelope.result.ok) return
266
+ const error = isRecord(envelope.result.error) ? envelope.result.error : {}
267
+ const failure = new Error(typeof error.message === 'string' ? error.message : '$events/result was rejected') as Error & {
268
+ code?: string
269
+ details?: unknown
270
+ }
271
+ if (typeof error.code === 'string') failure.code = error.code
272
+ if (error.details !== undefined) failure.details = error.details
273
+ throw failure
274
+ }
275
+ }
276
+
277
+
278
+ function invokeTarget(call: HostRpcCall): InvokeTarget | { readonly error: HostRpcFailure } {
279
+ if (!isRecord(call.payload)) return { error: badRequestFailure(`${call.method} payload must be an object`) }
280
+ switch (call.method) {
281
+ case 'session.list':
282
+ return { namespace: 'session', method: 'list', args: { _request: call.payload } }
283
+ case 'session.create':
284
+ case 'session.selectModel':
285
+ case 'session.attachment':
286
+ case 'session.cancel':
287
+ case 'workspace.create':
288
+ case 'workspace.rename':
289
+ case 'workspace.archiveSession': {
290
+ const [namespace, method] = call.method.split('.') as [string, string]
291
+ return { namespace, method, args: { request: call.payload } }
292
+ }
293
+ case 'session.prompt':
294
+ return {
295
+ namespace: 'session',
296
+ method: 'prompt',
297
+ args: { request: { requestId: call.rpcId, ...call.payload } },
298
+ }
299
+ case 'settings.describe':
300
+ return { namespace: 'settings', method: 'describe', args: {} }
301
+ case 'settings.mutate':
302
+ return { namespace: 'settings', method: 'mutate', args: call.payload }
303
+ case 'credentials.describe':
304
+ return {
305
+ namespace: 'credentials',
306
+ method: 'describe',
307
+ args: call.payload,
308
+ adapt: value => ({ credentials: value }),
309
+ }
310
+ case 'credentials.set':
311
+ case 'credentials.unset': {
312
+ const method = call.method.slice('credentials.'.length)
313
+ return { namespace: 'credentials', method, args: call.payload, adapt: () => ({}) }
314
+ }
315
+ case 'llm.discoverModels': {
316
+ const { settingsNs, ...request } = call.payload
317
+ if (typeof settingsNs !== 'string' || settingsNs.length === 0) {
318
+ return { error: badRequestFailure('llm.discoverModels requires settingsNs') }
319
+ }
320
+ return {
321
+ namespace: 'llm',
322
+ method: 'discoverModels',
323
+ args: { settingsNs, request },
324
+ adapt: value => ({ models: value }),
325
+ }
326
+ }
327
+ default:
328
+ return {
329
+ error: {
330
+ code: 'not-found',
331
+ message: `browser bridge Host method ${JSON.stringify(call.method)} is unavailable`,
332
+ details: {},
333
+ },
334
+ }
335
+ }
336
+ }
337
+
338
+
339
+ function sessionIdOf(payload: unknown): string | undefined {
340
+ if (!isRecord(payload)) return undefined
341
+ return typeof payload.sessionId === 'string' && payload.sessionId.length > 0
342
+ ? payload.sessionId
343
+ : undefined
344
+ }
345
+
346
+ /** Map Host ModelCatalog into the extension's session.models directory shape. */
347
+ function adaptModelCatalog(value: unknown, next?: ReturnType<typeof modelSelectionOf>): unknown {
348
+ if (!isRecord(value)) return value
349
+ const selection = next ?? modelSelectionOf(value.default)
350
+ const groups = Array.isArray(value.groups) ? value.groups : []
351
+ const failures = Array.isArray(value.failures) ? value.failures : []
352
+ const routableProviders = Array.isArray(value.routableProviders)
353
+ ? value.routableProviders.filter((entry): entry is string => typeof entry === 'string')
354
+ : []
355
+ const current = selection ?? { provider: 'none', model: 'none' }
356
+ return {
357
+ current,
358
+ routable: selection !== undefined && routableProviders.includes(selection.provider),
359
+ groups,
360
+ failures,
361
+ }
362
+ }
363
+
364
+ function modelSelectionOf(value: unknown): {
365
+ provider: string
366
+ model: string
367
+ reasoningEffort?: string
368
+ } | undefined {
369
+ if (!isRecord(value)) return undefined
370
+ const provider = typeof value.provider === 'string' ? value.provider.trim() : ''
371
+ const model = typeof value.model === 'string' ? value.model.trim() : ''
372
+ if (provider === '' || model === '') return undefined
373
+ const reasoningEffort = typeof value.reasoningEffort === 'string' && value.reasoningEffort.trim() !== ''
374
+ ? value.reasoningEffort.trim()
375
+ : undefined
376
+ return {
377
+ provider,
378
+ model,
379
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
380
+ }
381
+ }
382
+
383
+ function badRequest(message: string): HostRpcResult {
384
+ return { ok: false, error: badRequestFailure(message) }
385
+ }
386
+
387
+ function badRequestFailure(message: string): HostRpcFailure {
388
+ return { code: 'bad-request', message, details: {} }
389
+ }
390
+
391
+ function isWorkspaceBaseline(value: unknown): value is {
392
+ readonly type: 'baseline'
393
+ readonly value: Record<string, unknown>
394
+ } {
395
+ return isRecord(value) && value.type === 'baseline' && isRecord(value.value)
396
+ }
397
+