dsh-surface-bridge 0.1.0-alpha.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -0
  3. package/cordis.patch.yml +9 -0
  4. package/lib/client.js +310 -0
  5. package/lib/types/client/SurfaceSelectionDock.d.ts +37 -0
  6. package/lib/types/client/SurfaceSelectionDock.js +125 -0
  7. package/lib/types/client/index.d.ts +40 -0
  8. package/lib/types/client/index.js +41 -0
  9. package/lib/types/client/locales.d.ts +30 -0
  10. package/lib/types/client/locales.js +36 -0
  11. package/lib/types/client/service.d.ts +46 -0
  12. package/lib/types/client/service.js +89 -0
  13. package/lib/types/client/transport.d.ts +19 -0
  14. package/lib/types/client/transport.js +38 -0
  15. package/lib/types/contract.d.ts +329 -0
  16. package/lib/types/contract.js +38 -0
  17. package/lib/types/host/narrow.d.ts +25 -0
  18. package/lib/types/host/narrow.js +193 -0
  19. package/lib/types/host/render.d.ts +63 -0
  20. package/lib/types/host/render.js +228 -0
  21. package/lib/types/host/routes.d.ts +31 -0
  22. package/lib/types/host/routes.js +108 -0
  23. package/lib/types/host/service.d.ts +41 -0
  24. package/lib/types/host/service.js +93 -0
  25. package/lib/types/host/store.d.ts +85 -0
  26. package/lib/types/host/store.js +206 -0
  27. package/lib/types/index.d.ts +93 -0
  28. package/lib/types/index.js +132 -0
  29. package/package.json +88 -0
  30. package/src/client/SurfaceSelectionDock.module.css +186 -0
  31. package/src/client/SurfaceSelectionDock.tsx +245 -0
  32. package/src/client/index.ts +65 -0
  33. package/src/client/locales.ts +42 -0
  34. package/src/client/service.ts +110 -0
  35. package/src/client/transport.ts +39 -0
  36. package/src/contract.ts +351 -0
  37. package/src/css-modules.d.ts +10 -0
  38. package/src/host/narrow.ts +180 -0
  39. package/src/host/render.ts +226 -0
  40. package/src/host/routes.ts +117 -0
  41. package/src/host/service.ts +116 -0
  42. package/src/host/store.ts +236 -0
  43. package/src/index.ts +194 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Host-side state of the surface bridge: one pending selection and one operation
3
+ * queue per Session, plus the liveness stamp that lets a write-back tool fail
4
+ * loudly instead of pretending a closed surface accepted its edit.
5
+ *
6
+ * Everything here is in-memory and process-local by design. A selection is a
7
+ * snapshot of what the user is looking at right now; persisting it across a
8
+ * restart would resurrect a context the user never chose in this process. The
9
+ * operation queue is a handoff channel between two halves of one running app,
10
+ * so it has no meaning once either half is gone.
11
+ *
12
+ * @module dsh-surface-bridge/host/store
13
+ */
14
+
15
+ import type { SurfaceOperation, SurfaceOperationResult, SurfaceSelection } from '../contract.ts'
16
+ import { READ_SELECTION_OP } from '../contract.ts'
17
+
18
+ /** How long a poll may hold before it answers empty, in milliseconds. */
19
+ export const DEFAULT_POLL_HOLD_MS = 20_000
20
+ /** How long after the last poll a surface is still considered open. */
21
+ export const SURFACE_LIVENESS_MS = 45_000
22
+ /** How long a write-back tool waits for the surface to report a result. */
23
+ export const DEFAULT_OPERATION_TIMEOUT_MS = 25_000
24
+ /**
25
+ * How long a model step waits for a surface to answer a read.
26
+ *
27
+ * Short on purpose: a live surface is already parked on a poll, so an answer is a
28
+ * round trip on an idle connection. If it does not come, the step proceeds without
29
+ * the selection rather than delaying the user's message.
30
+ */
31
+ export const DEFAULT_READ_TIMEOUT_MS = 600
32
+
33
+ /** Per-Session bridge state. */
34
+ interface SessionState {
35
+ /** Operations awaiting a poll. */
36
+ readonly queued: SurfaceOperation[]
37
+ /** Settled results by operation id. */
38
+ readonly settled: Map<string, SurfaceOperationResult>
39
+ /** Waiters woken when the queue or a settled result changes. */
40
+ readonly waiters: Set<() => void>
41
+ /** Host clock of the most recent poll; the surface's liveness stamp. */
42
+ lastPollAt: number
43
+ /** Monotonic operation counter for this Session. */
44
+ operationSeq: number
45
+ /** Set by {@link SurfaceBridgeStore.forget} so waiters stop holding a dead Session. */
46
+ released: boolean
47
+ }
48
+
49
+ /** The bridge's Host state, keyed by Session id. */
50
+ export class SurfaceBridgeStore {
51
+ private readonly sessions = new Map<string, SessionState>()
52
+
53
+ /** Resolve one Session's state, creating it on first use. */
54
+ private stateOf(sessionId: string): SessionState {
55
+ const existing = this.sessions.get(sessionId)
56
+ if (existing !== undefined) return existing
57
+ const created: SessionState = {
58
+ queued: [],
59
+ settled: new Map(),
60
+ waiters: new Set(),
61
+ lastPollAt: 0,
62
+ operationSeq: 0,
63
+ released: false,
64
+ }
65
+ this.sessions.set(sessionId, created)
66
+ return created
67
+ }
68
+
69
+ /** Wake every waiter of one Session. */
70
+ private wake(state: SessionState): void {
71
+ for (const waiter of [...state.waiters]) waiter()
72
+ }
73
+
74
+ /** Whether any surface of one Session has polled recently enough to be considered open. */
75
+ isSurfaceLive(sessionId: string, now = Date.now()): boolean {
76
+ const state = this.sessions.get(sessionId)
77
+ if (state === undefined) return false
78
+ return now - state.lastPollAt <= SURFACE_LIVENESS_MS
79
+ }
80
+
81
+ /** Queue one operation for a surface and return it with its minted id. */
82
+ enqueue(sessionId: string, source: string, op: string, payload: unknown): SurfaceOperation {
83
+ const state = this.stateOf(sessionId)
84
+ state.operationSeq += 1
85
+ const operation: SurfaceOperation = {
86
+ id: `${sessionId}:${state.operationSeq}`,
87
+ source,
88
+ op,
89
+ payload,
90
+ }
91
+ state.queued.push(operation)
92
+ this.wake(state)
93
+ return operation
94
+ }
95
+
96
+ /**
97
+ * Ask a live surface what it has selected, and wait for its answer.
98
+ *
99
+ * Implemented on the operation queue rather than as its own channel: the browser
100
+ * already holds a poll open, so a read is one enqueue plus the answer the loop
101
+ * sends back. A surface that is closed answers nothing, and the read gives up
102
+ * rather than holding a model step open.
103
+ *
104
+ * @param sessionId - Session whose surfaces are asked.
105
+ * @param timeoutMs - Bound on the wait.
106
+ * @param signal - Aborts the wait when the turn is cancelled.
107
+ * @param consume - Whether the surface should spend its selection answering this.
108
+ * @returns the raw answer (an array of selections), or `undefined` when nothing answered.
109
+ */
110
+ async readSelections(
111
+ sessionId: string,
112
+ timeoutMs: number,
113
+ signal?: AbortSignal,
114
+ consume = false,
115
+ ): Promise<readonly unknown[] | undefined> {
116
+ // No answer can come if nothing is polling: the surface is closed, or its tab
117
+ // was never opened in this Session.
118
+ if (!this.isSurfaceLive(sessionId)) return undefined
119
+ const request = this.enqueue(sessionId, '*', READ_SELECTION_OP, { consume })
120
+ const result = await this.awaitResult(sessionId, request.id, timeoutMs, signal)
121
+ if (result === undefined || !result.ok) return undefined
122
+ return Array.isArray(result.value) ? result.value as readonly unknown[] : []
123
+ }
124
+
125
+ /**
126
+ * Take the operations waiting for one Session, holding the request open until
127
+ * something arrives or the hold expires.
128
+ *
129
+ * A long poll rather than a socket: the surface already runs a fetch loop, and
130
+ * a held request is one line of client code with no reconnect protocol of its
131
+ * own. The hold is bounded so a broken connection cannot pin a handler forever.
132
+ *
133
+ * @param sessionId - Session whose surface is polling.
134
+ * @param holdMs - Maximum time to hold before answering empty.
135
+ * @param signal - Aborts the hold when the browser goes away.
136
+ * @returns the queued operations, oldest first.
137
+ */
138
+ async poll(sessionId: string, holdMs: number, signal?: AbortSignal): Promise<readonly SurfaceOperation[]> {
139
+ const state = this.stateOf(sessionId)
140
+ state.lastPollAt = Date.now()
141
+ if (state.queued.length > 0) return state.queued.splice(0, state.queued.length)
142
+ if (holdMs <= 0 || signal?.aborted === true) return []
143
+
144
+ await new Promise<void>((resolve) => {
145
+ let settled = false
146
+ const finish = (): void => {
147
+ if (settled) return
148
+ settled = true
149
+ state.waiters.delete(onWake)
150
+ clearTimeout(timer)
151
+ signal?.removeEventListener('abort', onWake)
152
+ resolve()
153
+ }
154
+ const onWake = (): void => { finish() }
155
+ const timer = setTimeout(finish, holdMs)
156
+ state.waiters.add(onWake)
157
+ signal?.addEventListener('abort', onWake, { once: true })
158
+ })
159
+
160
+ return state.queued.splice(0, state.queued.length)
161
+ }
162
+
163
+ /** Record a surface-reported result and wake whoever is waiting for it. */
164
+ settle(sessionId: string, result: SurfaceOperationResult): boolean {
165
+ const state = this.sessions.get(sessionId)
166
+ if (state === undefined) return false
167
+ state.settled.set(result.id, result)
168
+ this.wake(state)
169
+ return true
170
+ }
171
+
172
+ /**
173
+ * Wait for one operation's result.
174
+ *
175
+ * @param sessionId - Session that owns the operation.
176
+ * @param operationId - Operation to await.
177
+ * @param timeoutMs - Bound on the wait; a timeout is a real failure the caller reports.
178
+ * @param signal - Aborts the wait when the Agent turn is cancelled.
179
+ * @returns the result, or `undefined` when the surface never reported.
180
+ */
181
+ async awaitResult(
182
+ sessionId: string,
183
+ operationId: string,
184
+ timeoutMs: number,
185
+ signal?: AbortSignal,
186
+ ): Promise<SurfaceOperationResult | undefined> {
187
+ const state = this.stateOf(sessionId)
188
+ const immediate = state.settled.get(operationId)
189
+ if (immediate !== undefined) {
190
+ state.settled.delete(operationId)
191
+ return immediate
192
+ }
193
+ if (signal?.aborted === true) return undefined
194
+
195
+ return await new Promise<SurfaceOperationResult | undefined>((resolve) => {
196
+ let settled = false
197
+ const finish = (result: SurfaceOperationResult | undefined): void => {
198
+ if (settled) return
199
+ settled = true
200
+ state.waiters.delete(onWake)
201
+ clearTimeout(timer)
202
+ signal?.removeEventListener('abort', onWake)
203
+ resolve(result)
204
+ }
205
+ const onWake = (): void => {
206
+ if (signal?.aborted === true || state.released) {
207
+ finish(undefined)
208
+ return
209
+ }
210
+ const result = state.settled.get(operationId)
211
+ if (result === undefined) return
212
+ state.settled.delete(operationId)
213
+ finish(result)
214
+ }
215
+ const timer = setTimeout(() => { finish(undefined) }, timeoutMs)
216
+ state.waiters.add(onWake)
217
+ signal?.addEventListener('abort', onWake, { once: true })
218
+ })
219
+ }
220
+
221
+ /** Forget one Session's whole bridge state (Session teardown). */
222
+ forget(sessionId: string): void {
223
+ const state = this.sessions.get(sessionId)
224
+ if (state === undefined) return
225
+ // Mark before waking: a waiter must see the release and stop waiting, not
226
+ // look up a result that will never arrive.
227
+ state.released = true
228
+ this.sessions.delete(sessionId)
229
+ this.wake(state)
230
+ }
231
+
232
+ /** Every Session currently holding bridge state; used by teardown sweeps. */
233
+ sessions_(): readonly string[] {
234
+ return [...this.sessions.keys()]
235
+ }
236
+ }
package/src/index.ts ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Node half of dsh-surface-bridge.
3
+ *
4
+ * The bridge exists so a right-Sidebar business surface can hand a selection to
5
+ * the composer once, and have it arrive in the model step as real context. This
6
+ * half owns the two things that must be unique per process: the Session-keyed
7
+ * selection/operation state, and the `agent/pre-step` listener that turns a read
8
+ * selection into **two** durable messages — one visible row the person can see (a
9
+ * file chip for the drawing, plus a one-line summary) and one hidden row carrying
10
+ * the element table the model works from.
11
+ *
12
+ * Why a mounted bundle rather than a shared library: a library would be inlined
13
+ * into each consuming plugin's own bundle, giving every consumer its own service
14
+ * instance and its own composer chip. The uniqueness the seam needs — one chip,
15
+ * one injection point — can only be guaranteed by the Loader mounting one bundle.
16
+ *
17
+ * @module dsh-surface-bridge
18
+ */
19
+
20
+ import type { Context } from '@deepseek-ai/cordis'
21
+ import type {} from '@deepseek-ai/dsh-client-connection'
22
+ import type {} from '@deepseek-ai/dsh-attachment'
23
+ import type {} from '@deepseek-ai/dsh-agent'
24
+ import { createUserMessage, type ContentBlock, type ContextFormed } from '@deepseek-ai/dsh-llm'
25
+ import type {
26
+ SurfaceBridgeHostFace,
27
+ SurfaceBridgeService,
28
+ SurfaceSelection,
29
+ } from './contract.ts'
30
+ import { renderSelectionChip, renderSelectionText } from './host/render.ts'
31
+ import { registerSurfaceBridgeRoutes } from './host/routes.ts'
32
+ import { SurfaceBridgeHost } from './host/service.ts'
33
+ import { SurfaceBridgeStore } from './host/store.ts'
34
+
35
+ declare module '@deepseek-ai/dsh-llm' {
36
+ interface MessageSourceMap {
37
+ /**
38
+ * The hidden half of one surface selection: the element table the model reads.
39
+ *
40
+ * Deliberately a producer kind, because that is what makes it *invisible*: the Chat
41
+ * view projects any source whose kind is not `user` into a `context` node, and renders
42
+ * no row for those. The visible half is a plain user message; see
43
+ * {@link buildSelectionMessages}.
44
+ */
45
+ 'surface-selection': {
46
+ kind: 'surface-selection'
47
+ } & ContextFormed
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The whole shared vocabulary, re-exported from the entry consumers already use.
53
+ *
54
+ * A surface plugin calls `ctx.surfaceBridge`/`ctx.surfaceBridgeHost` at runtime and
55
+ * never imports either half, but it must be able to *name* the faces and the
56
+ * descriptor it registers. Type-only, so neither half gains a runtime dependency
57
+ * on the other — and this module is also what carries the `Context` augmentation
58
+ * below, which is why a consumer should import the bridge by its package name
59
+ * rather than reaching for `./contract` alone.
60
+ */
61
+ export type * from './contract.ts'
62
+
63
+ /**
64
+ * The two services this bundle provides.
65
+ *
66
+ * This block MUST stay in the package entry module. TypeScript merges an
67
+ * `interface Context` augmentation into the cordis class only when the augmenting
68
+ * file is reached as a program root through a non-empty import; placed in
69
+ * `contract.ts` (or any module that also carries the shared types) the same block
70
+ * degrades into an *ambient* declaration that REPLACES cordis's `Context`, and
71
+ * every `ctx.effect`/`ctx.on` in the graph stops type-checking. The build guard in
72
+ * `tests/context-augmentation.test.mjs` fails if it moves.
73
+ */
74
+ declare module '@deepseek-ai/cordis' {
75
+ interface Context {
76
+ /** Browser face of the surface bridge; the seam a business surface publishes into. */
77
+ surfaceBridge: SurfaceBridgeService
78
+ /** Host face of the surface bridge; the seam a business plugin's tools call. */
79
+ surfaceBridgeHost: SurfaceBridgeHostFace
80
+ }
81
+ }
82
+
83
+ /** Cordis plugin name used by loader diagnostics. */
84
+ export const name = 'dsh-surface-bridge'
85
+
86
+ /** Route registration and image admission are the only Host capabilities this half needs. */
87
+ export const inject = ['connection', 'attachments'] as const
88
+
89
+ /** Decode a raster into the bytes the attachment service admits. */
90
+ function decodeRaster(data: string): Uint8Array {
91
+ return new Uint8Array(Buffer.from(data, 'base64'))
92
+ }
93
+
94
+ /**
95
+ * Build the two durable messages one selection travels in.
96
+ *
97
+ * **Two messages, one visible.** The person who sent a selection must be able to see what
98
+ * went out; the model needs the whole element table. Those are different appetites, and one
99
+ * message cannot satisfy both:
100
+ *
101
+ * · the **visible** row is a plain user message carrying one line — `画布选区 · main.excalidraw ·
102
+ * 3 个元素` — because the Chat view renders no row at all for a producer-tagged context node
103
+ * (`isVisibleChatNode` excludes ordinary Context, keeping only tool changes). Before this,
104
+ * the selection reached the model and appeared nowhere in the transcript.
105
+ * · the **detail** row is producer-tagged, and therefore hidden: it carries the element ids,
106
+ * coordinates and sizes the model edits from, plus the file path the write-back must name.
107
+ *
108
+ * Images ride in the visible row, so a person sees the pictures they sent. Bytes never enter
109
+ * the text, and a surface with no image element pays for no image.
110
+ *
111
+ * @param ctx - Plugin context carrying the attachment service.
112
+ * @param selection - Selection to render.
113
+ * @returns the messages to append to the step, visible row first.
114
+ */
115
+ export async function buildSelectionMessages(ctx: Context, selection: SurfaceSelection): Promise<readonly ReturnType<typeof createUserMessage>[]> {
116
+ const reference = `${selection.source}#${selection.revision}`
117
+ const detail = createUserMessage({
118
+ content: [{ type: 'text', text: renderSelectionText(selection, reference) }],
119
+ // A producer kind, on purpose: this is the row the transcript hides.
120
+ source: { kind: 'surface-selection' },
121
+ })
122
+
123
+ // One line, and nothing above it. An earlier version put a `file` block first, which the
124
+ // shell renders as a 240x64 card — two stacked blocks for one selection, and the card was a
125
+ // *snapshot* of the drawing that the model had to be told not to edit. The line alone says
126
+ // which drawing and how many elements, which is what the reader needs.
127
+ const visible: ContentBlock[] = []
128
+ const images = selection.images ?? []
129
+ if (images.length > 0) {
130
+ const refs = await ctx.attachments.saveImages(images.map((image, index) => ({
131
+ data: decodeRaster(image.data),
132
+ mediaType: image.mediaType,
133
+ name: image.name ?? `${selection.source}-${image.elementId ?? `image-${String(index + 1)}`}.png`,
134
+ })))
135
+ for (const ref of refs) visible.push({ type: 'image', attachment: ref })
136
+ }
137
+ visible.push({ type: 'text', text: renderSelectionChip(selection) })
138
+
139
+ const shown = createUserMessage({
140
+ content: visible,
141
+ // `user`, so the transcript shows it. Any other kind makes the row a `context` node,
142
+ // and the Chat view renders no row for those.
143
+ source: { kind: 'user' },
144
+ })
145
+ return [shown, detail]
146
+ }
147
+
148
+ /**
149
+ * Plugin body: provide the Host face, serve the bridge routes, and arm the
150
+ * pre-step injection.
151
+ *
152
+ * @param ctx - Registrant context; every registration is disposed with it.
153
+ */
154
+ /**
155
+ * Last turn that read each Session's surface.
156
+ *
157
+ * A turn has several steps (a model call, then the tool results, then another). The
158
+ * selection is a property of the message that opened the turn, so it is read once
159
+ * per turn; without this the context would be re-injected on every step and the
160
+ * model would see the same drawing three times.
161
+ */
162
+ const lastReadTurn = new Map<string, number>()
163
+
164
+ export function apply(ctx: Context): void {
165
+ const store = new SurfaceBridgeStore()
166
+ const host = new SurfaceBridgeHost(store)
167
+ ctx.effect(() => ctx.reflect.provide('surfaceBridgeHost', host), 'dsh-surface-bridge: host face')
168
+ registerSurfaceBridgeRoutes(ctx, store)
169
+
170
+ ctx.on('agent/pre-step', async ({ agent, signal, turn, step }, next) => {
171
+ const decision = await next()
172
+ if (decision.kind === 'reject' || signal.aborted) return decision
173
+ // A selection belongs to the message that opened the turn, and that message is
174
+ // admitted at step 1. Gating on `step` rather than on the admitted message batch
175
+ // is deliberate: an empty batch at step 1 is a real case (a turn opened by
176
+ // something other than a typed prompt), and the batch is not a reliable signal
177
+ // for "the user just sent this". The whole point is also that the selection is
178
+ // read *here* — when the message is going out — not pushed while the user drew.
179
+ if (step !== 1) return decision
180
+ const sessionId = String(agent.id)
181
+ if (lastReadTurn.get(sessionId) === turn) return decision
182
+ // Consuming: this read exists to carry the selection into the message, so the
183
+ // surface drops it as it answers and the chip leaves the composer. A peek would
184
+ // leave it on screen and let the same drawing ride the next message too.
185
+ const selections = await host.consumeSelections(sessionId, signal)
186
+ if (selections === undefined || selections.length === 0) return decision
187
+ lastReadTurn.set(sessionId, turn)
188
+ const messages = [...decision.messages]
189
+ for (const selection of selections) {
190
+ messages.push(...await buildSelectionMessages(ctx, selection))
191
+ }
192
+ return { ...decision, messages }
193
+ }, { prepend: true })
194
+ }