@lincyaw/dsh 0.1.0 → 0.2.0-rc.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 (78) hide show
  1. package/README.md +8 -4
  2. package/build/client.mjs +7 -1
  3. package/build/dist/client.js +1364 -64
  4. package/build/dist/client.json +225 -21
  5. package/build/ensure.mjs +11 -2
  6. package/cordis.patch.yml +9 -0
  7. package/entries/events.mjs +7 -0
  8. package/entries/load.mjs +11 -0
  9. package/entries/telemetry.mjs +7 -0
  10. package/entries/turns.mjs +7 -0
  11. package/package.json +32 -19
  12. package/src/client/RootLayout.tsx +332 -0
  13. package/src/client/agent-requests.ts +64 -0
  14. package/src/client/assistant-setup.tsx +185 -0
  15. package/src/client/author-settings.ts +57 -0
  16. package/src/client/brand.tsx +21 -14
  17. package/src/client/contract.ts +48 -1
  18. package/src/client/document-context.ts +39 -0
  19. package/src/client/dsh.d.ts +27 -1
  20. package/src/client/fallback.tsx +23 -19
  21. package/src/client/frame.css +280 -120
  22. package/src/client/index.tsx +72 -69
  23. package/src/client/launch-target.ts +89 -0
  24. package/src/client/layout.ts +90 -77
  25. package/src/client/mascot.tsx +37 -0
  26. package/src/client/messages.ts +142 -49
  27. package/src/client/{chrome.tsx → navigation-shell.tsx} +20 -25
  28. package/src/client/observable.ts +16 -0
  29. package/src/client/paths.ts +17 -0
  30. package/src/client/root-reload.ts +163 -0
  31. package/src/client/runtime-diagnostics.tsx +228 -0
  32. package/src/client/settings-opener.ts +26 -0
  33. package/src/client/{source.tsx → source-links.tsx} +16 -26
  34. package/src/client/speech-settings.tsx +7 -0
  35. package/src/client/styles.css +42 -0
  36. package/src/client/theme.ts +4 -2
  37. package/src/client/workbench-keys.ts +36 -0
  38. package/src/client/workspace-events.ts +114 -0
  39. package/src/node/bindings.ts +345 -0
  40. package/src/node/context.ts +131 -20
  41. package/src/node/{dsh.ts → dsh-types.ts} +33 -2
  42. package/src/node/fake-collector.ts +46 -0
  43. package/src/node/fake-model.ts +80 -0
  44. package/src/node/launch.ts +127 -10
  45. package/src/node/policy.ts +20 -23
  46. package/src/node/routes.ts +21 -11
  47. package/src/node/server-routes.ts +3 -0
  48. package/src/node/service.ts +58 -207
  49. package/src/node/skills.ts +9 -31
  50. package/src/node/telemetry.ts +126 -0
  51. package/src/node/tools.ts +34 -11
  52. package/src/node/{turns/index.ts → turns.ts} +48 -37
  53. package/src/node/workbench.ts +51 -6
  54. package/src/node/workspace-events.ts +167 -0
  55. package/src/node/workspaces.ts +2 -2
  56. package/src/workspace-events.ts +31 -0
  57. package/web.patch.yml +27 -56
  58. package/rows/load.mjs +0 -8
  59. package/rows/turns.mjs +0 -7
  60. package/skills/presmium-author/SKILL.md +0 -95
  61. package/skills/presmium-author/references/assistance.md +0 -27
  62. package/skills/presmium-author/references/code.md +0 -52
  63. package/skills/presmium-author/references/directives.md +0 -592
  64. package/skills/presmium-author/references/publishing.md +0 -60
  65. package/skills/presmium-author/references/writing.md +0 -72
  66. package/skills/presmium-present/SKILL.md +0 -69
  67. package/skills/presmium-present/references/classroom.md +0 -46
  68. package/skills/presmium-present/references/delivery.md +0 -63
  69. package/skills/presmium-present/references/lecture.md +0 -58
  70. package/src/client/Frame.tsx +0 -314
  71. /package/{rows → entries}/context.mjs +0 -0
  72. /package/{rows → entries}/policy.mjs +0 -0
  73. /package/{rows → entries}/routes.mjs +0 -0
  74. /package/{rows → entries}/service.mjs +0 -0
  75. /package/{rows → entries}/skills.mjs +0 -0
  76. /package/{rows → entries}/tools.mjs +0 -0
  77. /package/{rows → entries}/workbench.mjs +0 -0
  78. /package/{rows → entries}/workspaces.mjs +0 -0
@@ -1,13 +1,20 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import type { DshAgent, DshContext, DshToolExecution } from '../dsh.ts'
3
- import type { PresmiumSession } from '../service.ts'
2
+ import { errorMessage } from '@lincyaw/kernel'
3
+ import { isInside } from '@lincyaw/kernel/node'
4
+ import type { DshAgent, DshContext, DshToolExecution } from './dsh-types.ts'
5
+ import { WORKSPACE_ROUTES } from './server-routes.ts'
6
+
7
+ // What a writing tool answers when the change cannot be recorded. The AI reads it and tells the author in their own
8
+ // language, so it names what the author sees (Version History) and none of the internals behind it.
9
+ const NOT_RECORDED = 'Version History is not turned on for this folder, so the change could not be recorded for the author to review. Tell the author to ask their administrator to turn on Version History for this folder.'
10
+ const NOTHING_CHANGED = 'Nothing was changed.'
4
11
 
5
12
  export const name = 'presmium-turns'
6
13
  export const inject = ['presmium', 'tools']
7
14
 
8
- export const WRITES = new Set(['write', 'edit', 'str_replace_editor', 'bash', 'pwsh', 'terminal_send'])
15
+ export const WRITING_TOOLS = new Set(['write', 'edit', 'str_replace_editor', 'bash', 'pwsh', 'terminal_send'])
9
16
 
10
- const REVIEW = '/__presmium/presmium.workspace/review'
17
+ const REVIEW = `${WORKSPACE_ROUTES}review`
11
18
  const RETRY_MS = 2000
12
19
 
13
20
  interface LiveAgent extends DshAgent {
@@ -29,9 +36,10 @@ interface Reverted {
29
36
  files: string[]
30
37
  }
31
38
 
32
- interface Turns {
39
+ /** The turn an agent session is in, and whether a tool wrote in it (to which document's review). */
40
+ interface TurnState {
33
41
  turn?: number
34
- document?: PresmiumSession
42
+ document?: string
35
43
  wrote: boolean
36
44
  }
37
45
 
@@ -52,26 +60,27 @@ export function revertNotice(event: Reverted): unknown {
52
60
 
53
61
  export function apply(ctx: DshContext): void {
54
62
  const log = ctx.logger?.('presmium') ?? console
55
- const sessions = new Map<string, Turns>()
63
+ const turnStates = new Map<string, TurnState>()
56
64
  const agents = new Map<string, LiveAgent>()
57
65
  const pending = new Map<string, unknown[]>()
58
- const turnsOf = (id: string) => {
59
- let known = sessions.get(id)
60
- if (!known) sessions.set(id, known = { wrote: false })
66
+ const turnStateOf = (id: string) => {
67
+ let known = turnStates.get(id)
68
+ if (!known) turnStates.set(id, known = { wrote: false })
61
69
  return known
62
70
  }
63
71
  const parents = new Map<string, string>()
64
- const reviewerOf = (id: string) => {
72
+ /** A subagent's writes go to the review of the session that started it. */
73
+ const rootAgentSessionOf = (id: string) => {
65
74
  let at = id
66
75
  for (let depth = 0; depth < 32 && parents.has(at); depth++) at = parents.get(at)!
67
76
  return at
68
77
  }
69
78
 
70
- const post = async (presmium: PresmiumSession, session: string, path: string, body: Record<string, unknown>): Promise<void> => {
71
- const response = await ctx.presmium.request(`${REVIEW}/${path}`, { body: { session, ...body }, headers: { 'presmium-document': encodeURIComponent(presmium.document.dir) } })
79
+ const postReview = async (document: string, agentSessionId: string, path: string, body: Record<string, unknown>): Promise<void> => {
80
+ const response = await ctx.presmium.server.request(`${REVIEW}/${path}`, { body: { session: agentSessionId, ...body }, headers: { 'presmium-document': encodeURIComponent(document) } })
72
81
  const text = await response.text()
73
82
  if (response.ok) return
74
- if (path === 'open' && response.status === 404) throw new Error('the workspace is not a git repository, so the author cannot review a change: run git init in it')
83
+ if (path === 'open' && response.status === 404) throw new Error(NOT_RECORDED)
75
84
  let reason = text
76
85
  try {
77
86
  reason = (JSON.parse(text) as { error?: string, detail?: string }).error ?? reason
@@ -81,32 +90,34 @@ export function apply(ctx: DshContext): void {
81
90
  }
82
91
 
83
92
  ctx.on('session/event', (session: { id: string }, event: SessionEvent) => {
84
- if (event.type === 'turn/start' && typeof event.data?.turn === 'number') turnsOf(session.id).turn = event.data.turn
93
+ if (event.type === 'turn/start' && typeof event.data?.turn === 'number') turnStateOf(session.id).turn = event.data.turn
85
94
  if (event.type !== 'turn/end') return
86
- const turns = sessions.get(session.id)
87
- if (!turns?.wrote || !turns.document) return
88
- turns.wrote = false
89
- void post(turns.document, session.id, 'step', { ...turns.turn !== undefined ? { turn: turns.turn } : {}, end: true })
90
- .catch((error: unknown) => log.warn(`presmium-turns: the end of turn ${turns.turn} of ${session.id} was not told to the review: ${error instanceof Error ? error.message : String(error)}`))
95
+ const state = turnStates.get(session.id)
96
+ if (!state?.wrote || state.document === undefined) return
97
+ state.wrote = false
98
+ void postReview(state.document, session.id, 'step', { ...state.turn !== undefined ? { turn: state.turn } : {}, end: true })
99
+ .catch((error: unknown) => log.warn(`presmium-turns: the end of turn ${state.turn} of ${session.id} was not told to the review: ${errorMessage(error)}`))
91
100
  })
92
101
 
93
102
  ctx.on('tools/execute', async (exec: DshToolExecution, next: () => Promise<unknown>) => {
94
- const presmium = WRITES.has(exec.name) ? ctx.presmium.session(exec.agent) : undefined
95
- if (!presmium) return next()
96
- const session = reviewerOf(presmium.id)
97
- const turns = turnsOf(session)
103
+ if (!WRITING_TOOLS.has(exec.name) || !exec.agent) return next()
104
+ const { root } = await ctx.presmium.server.ready
105
+ if (!isInside(ctx.presmium.bindings.workingDirectoryOf(exec.agent), root)) return next()
106
+ const document = ctx.presmium.bindings.bindingOf(exec.agent)?.document.dir ?? ''
107
+ const agentSessionId = rootAgentSessionOf(exec.agent.session.id)
108
+ const state = turnStateOf(agentSessionId)
98
109
  try {
99
- await post(presmium, session, 'open', turns.turn !== undefined ? { turn: turns.turn } : {})
110
+ await postReview(document, agentSessionId, 'open', state.turn !== undefined ? { turn: state.turn } : {})
100
111
  } catch (error) {
101
- throw new Error(`Presmium: ${error instanceof Error ? error.message : String(error)}. Nothing was changed.`)
112
+ throw new Error(`${errorMessage(error)} ${NOTHING_CHANGED}`)
102
113
  }
103
- turns.document = presmium
104
- turns.wrote = true
114
+ state.document = document
115
+ state.wrote = true
105
116
  try {
106
117
  return await next()
107
118
  } finally {
108
- await post(presmium, session, 'step', { ...turns.turn !== undefined ? { turn: turns.turn } : {}, label: labelOf(exec), tool: exec.name })
109
- .catch((error: unknown) => log.warn(`presmium-turns: no checkpoint after ${exec.name} in ${session}: ${error instanceof Error ? error.message : String(error)}`))
119
+ await postReview(document, agentSessionId, 'step', { ...state.turn !== undefined ? { turn: state.turn } : {}, label: labelOf(exec), tool: exec.name })
120
+ .catch((error: unknown) => log.warn(`presmium-turns: no checkpoint after ${exec.name} in ${agentSessionId}: ${errorMessage(error)}`))
110
121
  }
111
122
  })
112
123
 
@@ -121,11 +132,11 @@ export function apply(ctx: DshContext): void {
121
132
  if (agents.get(agent.session.id) === agent) agents.delete(agent.session.id)
122
133
  })
123
134
  ctx.on('session/disposed', (session: { id: string }) => {
124
- sessions.delete(session.id)
135
+ turnStates.delete(session.id)
125
136
  parents.delete(session.id)
126
137
  })
127
138
 
128
- const tell = (event: Reverted) => {
139
+ const injectRevertNotice = (event: Reverted) => {
129
140
  const notice = revertNotice(event)
130
141
  const agent = agents.get(event.session)
131
142
  if (agent) agent.inject(notice)
@@ -134,10 +145,10 @@ export function apply(ctx: DshContext): void {
134
145
  }
135
146
 
136
147
  const stop = new AbortController()
137
- const follow = async () => {
148
+ const subscribeReviewEvents = async () => {
138
149
  while (!stop.signal.aborted) {
139
150
  try {
140
- const response = await ctx.presmium.request(`${REVIEW}/events`, { headers: { accept: 'text/event-stream' }, signal: stop.signal })
151
+ const response = await ctx.presmium.server.request(`${REVIEW}/events`, { headers: { accept: 'text/event-stream' }, signal: stop.signal })
141
152
  if (!response.ok || !response.body) throw new Error(`review/events answered ${response.status}`)
142
153
  const decoder = new TextDecoder()
143
154
  let buffer = ''
@@ -150,16 +161,16 @@ export function apply(ctx: DshContext): void {
150
161
  const data = block.split('\n').filter(line => line.startsWith('data: ')).map(line => line.slice(6)).join('\n')
151
162
  if (!data) continue
152
163
  const event = JSON.parse(data) as { type?: string }
153
- if (event.type === 'reverted') tell(event as Reverted)
164
+ if (event.type === 'reverted') injectRevertNotice(event as Reverted)
154
165
  }
155
166
  }
156
167
  } catch (error) {
157
168
  if (stop.signal.aborted) return
158
- log.debug(`presmium-turns: the review events: ${error instanceof Error ? error.message : String(error)}`)
169
+ log.debug(`presmium-turns: the review events: ${errorMessage(error)}`)
159
170
  }
160
171
  await new Promise(done => setTimeout(done, RETRY_MS).unref())
161
172
  }
162
173
  }
163
- void follow()
174
+ void subscribeReviewEvents()
164
175
  ctx.effect(() => () => stop.abort())
165
176
  }
@@ -1,7 +1,9 @@
1
- import type { DshContext } from './dsh.ts'
1
+ import { LOCAL_AUTHOR } from '@lincyaw/kernel'
2
+ import type { DshContext, DshOtelScalar } from './dsh-types.ts'
3
+ import type { OperationClient, OperationRecord, PresmiumTelemetry } from './telemetry.ts'
2
4
 
3
5
  export const name = 'presmium-workbench'
4
- export const inject = ['presmium', 'connection', 'webServer']
6
+ export const inject = ['presmium', 'connection', 'webServer', 'presmiumTelemetry']
5
7
 
6
8
  const ICON = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0F5F5C"/><path d="M11 8h7.5a6.5 6.5 0 0 1 0 13H15v4h-4zM15 12v5h3.5a2.5 2.5 0 0 0 0-5z" fill="#F7F5F0" fill-rule="evenodd"/></svg>')}`
7
9
 
@@ -16,7 +18,35 @@ interface WebServer {
16
18
  tapIndex(transform: (html: string) => string): () => void
17
19
  }
18
20
 
19
- export function apply(ctx: DshContext & { webServer: WebServer }): void {
21
+ const OPERATION = /^[a-z][a-z0-9.-]{0,63}$/
22
+ const RECORDS = 500
23
+ const ATTRIBUTES = 64
24
+ const TEXT = 100_000
25
+
26
+ const text = (value: unknown, limit: number): string | undefined => (typeof value === 'string' && value ? value.slice(0, limit) : undefined)
27
+
28
+ /** The page's report of the author's operations, kept to names, times and scalar attributes of bounded size. */
29
+ export function readOperations(body: unknown): { client: OperationClient, records: OperationRecord[] } | undefined {
30
+ if (!body || typeof body !== 'object') return undefined
31
+ const { client, records } = body as { client?: Record<string, unknown>, records?: unknown }
32
+ const id = text(client?.id, 64)
33
+ if (!id || !Array.isArray(records) || records.length > RECORDS) return undefined
34
+ const read: OperationRecord[] = []
35
+ for (const record of records as Record<string, unknown>[]) {
36
+ if (!record || typeof record.name !== 'string' || !OPERATION.test(record.name) || typeof record.time !== 'number' || typeof record.seq !== 'number') return undefined
37
+ const attributes: Record<string, DshOtelScalar> = {}
38
+ for (const [key, value] of Object.entries(record.attributes ?? {}).slice(0, ATTRIBUTES)) {
39
+ if (typeof value === 'string') attributes[key.slice(0, 128)] = value.slice(0, TEXT)
40
+ else if ((typeof value === 'number' && Number.isFinite(value)) || typeof value === 'boolean') attributes[key.slice(0, 128)] = value
41
+ }
42
+ read.push({ name: record.name, time: record.time, seq: record.seq, attributes })
43
+ }
44
+ const browser = text(client?.browser, 64)
45
+ const locale = text(client?.locale, 32)
46
+ return { client: { id, ...browser ? { browser } : {}, ...locale ? { locale } : {} }, records: read }
47
+ }
48
+
49
+ export function apply(ctx: DshContext & { webServer: WebServer, presmiumTelemetry: PresmiumTelemetry }): void {
20
50
  ctx.effect(() => ctx.webServer.tapIndex(brandIndex))
21
51
  ctx.connection.fetch.register({
22
52
  path: '/api/presmium/context', methods: ['POST'], requestBody: 'buffered',
@@ -24,20 +54,35 @@ export function apply(ctx: DshContext & { webServer: WebServer }): void {
24
54
  const body = await request.json().catch(() => null) as { session?: unknown, document?: unknown } | null
25
55
  if (!body || typeof body.session !== 'string' || !body.session || (body.document !== null && typeof body.document !== 'string')) return new Response('Invalid context', { status: 400 })
26
56
  try {
27
- await ctx.presmium.selectDocument(body.session, body.document)
57
+ await ctx.presmium.bindings.selectDocument(body.session, body.document)
28
58
  return Response.json({ session: body.session, document: body.document })
29
59
  } catch {
30
60
  return new Response('Unknown document', { status: 400 })
31
61
  }
32
62
  },
33
63
  })
64
+ // The workbench's operations, for the operator's collector: the page learns whether to record them, then sends them in batches.
65
+ ctx.connection.fetch.register({
66
+ path: '/api/presmium/telemetry',
67
+ methods: ['GET', 'POST'],
68
+ requestBody: 'buffered',
69
+ async fetch(request) {
70
+ const telemetry = ctx.presmiumTelemetry
71
+ if (request.method === 'GET') return Response.json({ enabled: telemetry.enabled })
72
+ if (!telemetry.enabled) return new Response(null, { status: 204 })
73
+ const read = readOperations(await request.json().catch(() => null))
74
+ if (!read) return new Response('Invalid operations', { status: 400 })
75
+ telemetry.operations(read.client, read.records)
76
+ return new Response(null, { status: 204 })
77
+ },
78
+ })
34
79
  ctx.connection.fetch.register({
35
80
  path: '/api/presmium/workbench',
36
81
  methods: ['GET'],
37
82
  requestBody: 'buffered',
38
83
  async fetch() {
39
- const { url, root } = await ctx.presmium.ready
40
- return new Response(JSON.stringify({ mcp: `${url}/mcp`, root }), { headers: { 'Content-Type': 'application/json' } })
84
+ const { url, root } = await ctx.presmium.server.ready
85
+ return new Response(JSON.stringify({ mcp: `${url}/mcp`, root, member: LOCAL_AUTHOR }), { headers: { 'Content-Type': 'application/json' } })
41
86
  },
42
87
  })
43
88
  }
@@ -0,0 +1,167 @@
1
+ import type { IncomingMessage } from 'node:http'
2
+ import type { Duplex } from 'node:stream'
3
+ import { EventSource } from 'eventsource'
4
+ import { WebSocketServer, WebSocket } from 'ws'
5
+ import { AUTHORING_CLIENT_EVENT_MAX_BYTES } from '@lincyaw/kernel'
6
+ import type { AuthoringClientLease, AuthoringClientEvent } from '@lincyaw/kernel'
7
+ import { validateAuthoringClientLease } from '@lincyaw/kernel/node'
8
+ import type { DshContext } from './dsh-types.ts'
9
+ import { SERVER_ROUTES, WORKSPACE_ROUTES } from './server-routes.ts'
10
+ import { WORKSPACE_EVENTS_PATH, workspaceEventCommand } from '../workspace-events.ts'
11
+ import type { WorkspaceEventMessage } from '../workspace-events.ts'
12
+
13
+ export const name = 'presmium-events'
14
+ export const inject = ['presmium', 'connection', 'webServer']
15
+
16
+ /** The documented WebServer upgrade and Connection admission faces. */
17
+ interface EventContext extends DshContext {
18
+ webServer: { registerUpgrade(route: { path: string, handler(request: IncomingMessage, socket: Duplex, head: Buffer): void }): () => void }
19
+ connection: DshContext['connection'] & {
20
+ admit(request: IncomingMessage): { rejection: 401 | 403 } | { peer: { principal: { kind: string }, signal: AbortSignal } }
21
+ }
22
+ }
23
+
24
+ const ROUTES = {
25
+ document: { path: `${WORKSPACE_ROUTES}document/events`, event: 'document' },
26
+ diagnostics: { path: `${SERVER_ROUTES}diagnostics/events`, event: 'diagnostics' },
27
+ review: { path: `${WORKSPACE_ROUTES}review`, event: 'review' },
28
+ ir: { path: `${WORKSPACE_ROUTES}ir/events`, event: 'revision' },
29
+ jobs: { path: `${WORKSPACE_ROUTES}jobs/events`, event: 'job' },
30
+ } as const
31
+ const MAX_SUBSCRIPTIONS = 16
32
+ const MAX_BUFFERED_BYTES = 2 << 20
33
+
34
+ /** Borrow the framework listener and trust fence; never take ownership of their server or Peer. */
35
+ export function apply(ctx: EventContext): void {
36
+ const server = new WebSocketServer({ noServer: true, maxPayload: AUTHORING_CLIENT_EVENT_MAX_BYTES, perMessageDeflate: false })
37
+ const sockets = new Map<WebSocket, () => void>()
38
+ const unregister = ctx.webServer.registerUpgrade({
39
+ path: WORKSPACE_EVENTS_PATH,
40
+ handler(request, socket, head) {
41
+ const admission = ctx.connection.admit(request)
42
+ const rejected = 'rejection' in admission ? admission.rejection : admission.peer.principal.kind === 'owner' ? undefined : 403
43
+ if (rejected !== undefined) {
44
+ socket.end(`HTTP/1.1 ${rejected} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
45
+ return
46
+ }
47
+ if (!('peer' in admission) || admission.peer.signal.aborted) { socket.destroy(); return }
48
+ const peer = admission.peer
49
+ server.handleUpgrade(request, socket, head, ws => {
50
+ const subscriptions = new Map<string, { stop(): void, publish?(command: Extract<import('../workspace-events.ts').WorkspaceEventCommand, { type: 'authoring-client-publish' }>): Promise<void> }>()
51
+ let closed = false
52
+ const cleanup = () => {
53
+ if (closed) return
54
+ closed = true
55
+ peer.signal.removeEventListener('abort', revoked)
56
+ for (const entry of subscriptions.values()) entry.stop()
57
+ subscriptions.clear()
58
+ }
59
+ const revoked = () => { cleanup(); ws.terminate() }
60
+ const send = (message: WorkspaceEventMessage) => {
61
+ if (closed || ws.readyState !== WebSocket.OPEN) return
62
+ const payload = JSON.stringify(message)
63
+ if (ws.bufferedAmount + Buffer.byteLength(payload) > MAX_BUFFERED_BYTES) { cleanup(); ws.close(1013, 'Workspace event consumer is too slow'); return }
64
+ ws.send(payload, error => { if (error) revoked() })
65
+ }
66
+ sockets.set(ws, cleanup)
67
+ peer.signal.addEventListener('abort', revoked, { once: true })
68
+ if (peer.signal.aborted) { revoked(); return }
69
+ ws.on('close', () => { cleanup(); sockets.delete(ws) })
70
+ ws.on('error', revoked)
71
+ ws.on('message', (data, binary) => {
72
+ let raw: unknown
73
+ try { raw = binary ? undefined : JSON.parse(data.toString()) } catch { raw = undefined }
74
+ const parsed = workspaceEventCommand.safeParse(raw)
75
+ if (!parsed.success) { cleanup(); ws.close(1008, 'Invalid workspace subscription'); return }
76
+ const command = parsed.data
77
+ if (command.type === 'unsubscribe') {
78
+ subscriptions.get(command.id)?.stop()
79
+ subscriptions.delete(command.id)
80
+ return
81
+ }
82
+ if (command.type === 'authoring-client-publish') {
83
+ const entry = subscriptions.get(command.id)
84
+ void (entry?.publish ? entry.publish(command) : Promise.reject()).then(() => {
85
+ if (subscriptions.get(command.id) === entry) send({ type: 'published', id: command.id, sequence: command.sequence, ok: true })
86
+ }, () => { if (subscriptions.get(command.id) === entry) send({ type: 'published', id: command.id, sequence: command.sequence, ok: false }) })
87
+ return
88
+ }
89
+ if (subscriptions.has(command.id) || subscriptions.size >= MAX_SUBSCRIPTIONS) { cleanup(); ws.close(1008, 'Workspace subscription limit'); return }
90
+ const controller = new AbortController()
91
+ let source: EventSource | undefined
92
+ const entry: { stop(): void, publish?(command: Extract<import('../workspace-events.ts').WorkspaceEventCommand, { type: 'authoring-client-publish' }>): Promise<void> } = { stop: () => { controller.abort(); source?.close() } }
93
+ subscriptions.set(command.id, entry)
94
+ const current = () => !closed && !controller.signal.aborted && subscriptions.get(command.id) === entry
95
+ void ctx.presmium.server.ready.then(endpoint => {
96
+ if (!current()) return
97
+ if (command.topic === 'authoring-client') {
98
+ // Keep the client credential on the trusted host, off browser messages and MCP reads.
99
+ let lease: AuthoringClientLease | undefined
100
+ let publication: Promise<void> = Promise.resolve()
101
+ let publications = 0
102
+ const post = async (kind: string, value: unknown) => {
103
+ const path = `${WORKSPACE_ROUTES}authoring-clients/${kind}?${new URLSearchParams({ document: command.document })}`
104
+ const response = await ctx.presmium.server.request(path, { method: 'POST', body: value, signal: controller.signal })
105
+ if (!response.ok) throw new Error('client-publication-refused')
106
+ return response.json() as Promise<unknown>
107
+ }
108
+ entry.publish = value => {
109
+ if (publications >= 8) return Promise.reject(new Error('client-publication-limit'))
110
+ publications++
111
+ const next = publication.then(async () => {
112
+ if (!current() || !lease || value.connectionId !== lease.connectionId || !value.value || typeof value.value !== 'object'
113
+ || !('sequence' in value.value) || value.value.sequence !== value.sequence) throw new Error('client-connection-changed')
114
+ await post(value.kind === 'ack' ? 'ack' : 'report', { connection: lease, report: value.value })
115
+ }).finally(() => { publications-- })
116
+ publication = next.catch(() => {})
117
+ return next
118
+ }
119
+ void post('register', { clientId: command.clientId, epoch: command.epoch, snapshot: command.snapshot }).then(value => {
120
+ if (!current()) return
121
+ if (!validateAuthoringClientLease(value)) throw new Error('client-registration-invalid')
122
+ lease = value
123
+ const query = new URLSearchParams({ document: command.document, clientId: lease.clientId, epoch: lease.epoch, connectionId: lease.connectionId })
124
+ const path = `${WORKSPACE_ROUTES}authoring-clients/events?${query}`
125
+ source = new EventSource(new URL(path, endpoint.url), {
126
+ fetch: (_address, init) => ctx.presmium.server.request(path, { headers: { ...Object.fromEntries(new Headers(init?.headers)), 'X-Presmium-Authoring-Client': value.credential }, signal: init?.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal }),
127
+ })
128
+ source.addEventListener('error', () => { if (current()) send({ id: command.id, type: 'error' }) })
129
+ source.addEventListener('authoring-client', event => {
130
+ if (!current()) return
131
+ let data: AuthoringClientEvent
132
+ try { data = JSON.parse(event.data) } catch { return }
133
+ if (data.type === 'connected' && lease) lease = { ...lease, connectionId: data.connectionId }
134
+ send({ id: command.id, type: 'event', data: event.data })
135
+ })
136
+ }).catch(() => { if (current()) send({ id: command.id, type: 'error' }) })
137
+ return
138
+ }
139
+ const route = command.topic === 'viewer-site' ? { path: '/__app/events', events: ['ir', 'reload'] }
140
+ : command.topic === 'viewer-control' ? { path: `${SERVER_ROUTES}control`, events: ['ready', 'approval', 'settled', 'go', 'session'] }
141
+ : command.topic === 'approvals' ? { path: `${SERVER_ROUTES}control`, events: ['approval', 'settled'] }
142
+ : { path: ROUTES[command.topic].path, events: [ROUTES[command.topic].event] }
143
+ const query = new URLSearchParams({ document: command.document })
144
+ if (command.topic === 'viewer-site') { query.set('app', command.app); query.set('lang', command.lang); query.set('form', command.form) }
145
+ if (command.topic === 'viewer-control') query.set('window', command.window)
146
+ if (command.topic === 'approvals') query.set('observe', 'approvals')
147
+ const path = `${route.path}?${query}`
148
+ source = new EventSource(new URL(path, endpoint.url), {
149
+ fetch: (_address, init) => ctx.presmium.server.request(path, {
150
+ headers: Object.fromEntries(new Headers(init?.headers)),
151
+ signal: init?.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal,
152
+ }),
153
+ })
154
+ source.addEventListener('open', () => { if (current()) send({ id: command.id, type: 'ready' }) })
155
+ source.addEventListener('error', () => { if (current()) send({ id: command.id, type: 'error' }) })
156
+ for (const name of route.events) source.addEventListener(name, event => { if (current()) send({ id: command.id, type: 'event', ...command.topic === 'viewer-site' || command.topic === 'viewer-control' || command.topic === 'approvals' ? { event: name } : {}, data: event.data }) })
157
+ }).catch(() => { if (current()) send({ id: command.id, type: 'error' }) })
158
+ })
159
+ })
160
+ },
161
+ })
162
+ ctx.effect(() => () => {
163
+ unregister()
164
+ for (const [socket, cleanup] of sockets) { cleanup(); socket.terminate() }
165
+ server.close()
166
+ })
167
+ }
@@ -1,10 +1,10 @@
1
1
  import { basename } from 'node:path'
2
- import type { DshContext } from './dsh.ts'
2
+ import type { DshContext } from './dsh-types.ts'
3
3
 
4
4
  export const name = 'presmium-workspaces'
5
5
  export const inject = ['presmium', 'workspaceRegistry']
6
6
 
7
7
  export async function apply(ctx: DshContext): Promise<void> {
8
- const { root } = await ctx.presmium.ready
8
+ const { root } = await ctx.presmium.server.ready
9
9
  await ctx.workspaceRegistry.create(root, basename(root))
10
10
  }
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod'
2
+
3
+ /** One authenticated Studio socket carries these existing workspace subscriptions. */
4
+ export const WORKSPACE_EVENTS_PATH = '/api/presmium/events'
5
+ export const WORKSPACE_EVENT_TOPICS = ['document', 'diagnostics', 'review', 'ir', 'jobs'] as const
6
+ const subscriptionId = z.string().min(1).max(80)
7
+
8
+ const viewerSite = z.strictObject({ type: z.literal('subscribe'), id: subscriptionId, topic: z.literal('viewer-site'), document: z.string().max(1024), app: z.string().max(200), lang: z.string().max(80), form: z.enum(['web', 'slides']) })
9
+ const viewerControl = z.strictObject({ type: z.literal('subscribe'), id: subscriptionId, topic: z.literal('viewer-control'), document: z.string().max(1024), window: z.string().regex(/^[a-zA-Z0-9-]{1,80}$/) })
10
+ /** The host's pending approvals for the workbench, which is not a viewer window. */
11
+ const approvals = z.strictObject({ type: z.literal('subscribe'), id: subscriptionId, topic: z.literal('approvals'), document: z.string().max(1024) })
12
+
13
+ export const workspaceEventCommand = z.union([
14
+ z.strictObject({ type: z.literal('subscribe'), id: subscriptionId, topic: z.literal('authoring-client'), document: z.string().max(1024), clientId: subscriptionId, epoch: subscriptionId, snapshot: z.unknown() }),
15
+ z.strictObject({ type: z.literal('authoring-client-publish'), id: subscriptionId, connectionId: subscriptionId, sequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), kind: z.enum(['state', 'ack']), value: z.unknown() }),
16
+ viewerSite, viewerControl, approvals,
17
+ z.strictObject({ type: z.literal('subscribe'), id: subscriptionId, topic: z.enum(WORKSPACE_EVENT_TOPICS), document: z.string().max(1024) }),
18
+ z.strictObject({ type: z.literal('unsubscribe'), id: subscriptionId }),
19
+ ])
20
+
21
+ /** Ready describes the actual upstream connection, rather than a fabricated domain snapshot. */
22
+ export const workspaceEventMessage = z.discriminatedUnion('type', [
23
+ z.strictObject({ type: z.literal('published'), id: subscriptionId, sequence: z.number().int().nonnegative(), ok: z.boolean() }),
24
+ z.strictObject({ type: z.literal('ready'), id: subscriptionId }),
25
+ z.strictObject({ type: z.literal('error'), id: subscriptionId }),
26
+ z.strictObject({ type: z.literal('event'), id: subscriptionId, event: z.string().max(40).optional(), data: z.string() }),
27
+ ])
28
+
29
+ export type WorkspaceEventTopic = typeof WORKSPACE_EVENT_TOPICS[number] | 'viewer-site' | 'viewer-control' | 'approvals' | 'authoring-client'
30
+ export type WorkspaceEventCommand = z.infer<typeof workspaceEventCommand>
31
+ export type WorkspaceEventMessage = z.infer<typeof workspaceEventMessage>
package/web.patch.yml CHANGED
@@ -35,41 +35,42 @@
35
35
  disabled: true
36
36
  - id: ui-settings-session-log
37
37
  disabled: true
38
+ # Settings pages an author has no use for: the built-in plugins (the plugin rows of every session) and the agent presets
39
+ # (the workbench's sessions use the authoring preset). Technical rows of the General page stay behind the coding view
40
+ # (src/client/author-settings.ts).
41
+ - id: ui-settings-plugins
42
+ disabled: true
43
+ - id: ui-agent-preset
44
+ disabled: true
45
+ # The changed-files and delivery cards under the AI's replies, with the guidance to link every file the reply names: the
46
+ # author sees no files. The workbench makes the changed blocks blink on the document instead.
47
+ - id: ui-deliverables
48
+ disabled: true
38
49
 
39
- # 内置的「创作」预设:Presmium 的作者人设与发布会原则。预设的 persona 遮蔽部署级
40
- # 的人设(profile.patch.yml),所以这里重述 Presmium 的角色。
50
+ # 内置的“创作”(authoring)预设:工作台中 Presmium 助手的角色,它面对的作者,以及跟作者说话的方式。预设的
51
+ # persona 遮蔽部署级的人设(profile.patch.yml),所以这里重述 Presmium 的角色;写作的原则在
52
+ # presmium-author 技能中,改写内容时加载。预设只带讲义需要的工具:没有定时任务、目标、子 agent 与文件卡片(present)。
41
53
  - insert:
42
54
  - id: preset-authoring
43
55
  name: '@deepseek-ai/dsh-agent-preset'
44
56
  config:
45
57
  id: authoring
46
- name: 创作
47
- description: 面向受众的讲义与演示创作:信息量优先、受众视角、版式不重复。
58
+ # The preset picker (ui-agent-preset) is off, so authors never see a preset's name; the brand stands in for it in
59
+ # both languages, and a description in one language is left out.
60
+ name: Presmium
48
61
  order: 0
49
62
  plugins:
50
63
  - id: persona
51
64
  name: '@deepseek-ai/dsh-persona'
52
65
  config:
53
66
  prefix: |
54
- You are Presmium's assistant for the authors of lectures, powered by the {{model}} model. You help write and revise lecture documents (MyST Markdown that Presmium renders as a web page, slides and a notebook), run their code and check them, in the author's language.
55
-
56
- 创作或编辑面向受众的内容(讲义、幻灯片、文档、文章及其他展示材料)时,你全程遵守发布会原则:以核心观点和受众价值为中心组织内容,并根据呈现载体调整表达方式。
67
+ You are Presmium's assistant for the authors of lectures, powered by the {{model}} model. You work with an author in Presmium's workbench: you answer their questions about their documents and make the changes they ask for, in their language.
57
68
 
58
- **最高优先级:信息量原则**
69
+ The author is a teacher who may know no code. The workbench shows a document as its readers see it, as a web page or as slides; the author edits its text there, leaves comments on it and asks you for changes. They do not see, and need not know, source files, Markdown, paths or line numbers, git, builds, tools or logs.
59
70
 
60
- 每一句话都必须有明确的存在意义,对受众理解内容、形成判断或采取行动发挥具体作用。每页、每屏或每个独立内容区块都必须承载有效信息,并承担明确的说明或论证任务。句子、注释、例子、数据和重复说明,都应具有明确作用;删除后不影响受众理解或判断的内容应直接删除。叙事安排、版式变化与语言润色均须服从这一原则,不得为满足形式要求而添加无效内容。
61
-
62
- 1. 围绕最有价值、最有说服力的观点或优势组织叙事,使全部内容共同支撑核心主张。
63
- 2. 按照受众理解问题的逻辑安排内容,避免按个人思考、工作开展或方案尝试的过程罗列经历。
64
- 3. 避免“只是一些浅见”“可能没什么价值”等自我削弱表达,不主动贬低内容的价值。
65
- 4. 根据内容目的与受众需求选择讨论角度、评价维度和比较口径,集中呈现能够充分证明核心价值的内容。
66
- 5. 涉及不同观点、方法或结果时,结合各自目标、适用条件与合理取舍解释差异,避免简单作出优劣判断。
67
- 6. 明确说明核心观点为什么重要、依据是什么,以及能够帮助受众理解或解决什么问题,不依赖受众自行从文字、表格或图形中推导内容价值。
68
- 7. 对于采用分页、分屏或区块布局的材料,相同的排版结构不得连续出现;任意连续五页、五屏或五个主要展示区块内不得重复使用相同的排版结构。
69
- 8. 出现排版结构重复时,应根据内容关系重新组织信息,选择适合的流程、对照、时间线、图表或图文组合等呈现方式,避免通过更换颜色、图标或文字维持相同布局。
70
- 9. 对于幻灯片,单页文字过多时,应优先采用图形化表达,根据内容关系改用流程图、关系图、时间线、数据图表或图文组合,减少连续文字并保留必要信息。图形应承担信息表达和关系说明的作用,避免将整段文字直接放入形状中。
71
- 10. 开头应尽早呈现受众关心的问题、值得讨论的关键、核心观点及最有说服力的依据。
72
- 11. 结尾应强化核心观点与最值得记住的信息,避免突然改变立场或加入自我否定的评价。
71
+ - The author's messages say what to do. When they point at something (selected text, a picked block), the message names the section and quotes the passage as readers see them, and the document context's focus gives its source range. A message of comments quotes each comment with its place, and comment N is the focus `comment-N`. A comment is the author's own words about that place: one that asks a question is answered, one that asks for a change is carried out, and several are handled one by one.
72
+ - Speak in the terms of what the author sees: a section by its title, the slide, the paragraph, the sentence, the result of the code. Do not tell them how things are done inside Presmium: no file names or paths, line numbers, JSON or Markdown syntax, directive or plugin names, the IR, builds, diagnostic codes, commands, tool names, the sandbox, permissions or git. When something could not be done or checked, say in plain words what they cannot have yet and what they can do.
73
+ - When you have changed the document, say in a sentence or two what readers will now see, and where. The change is a version of the document that the author can undo, or accept or reject first when the workspace asks for review.
73
74
  - id: agent-instructions
74
75
  name: '@deepseek-ai/dsh-agent-instructions'
75
76
  config:
@@ -90,16 +91,10 @@
90
91
  sampleOverCapGlobResults: false
91
92
  - id: tool-jobs
92
93
  name: '@deepseek-ai/dsh-tool-jobs'
93
- - id: tool-schedule
94
- name: '@deepseek-ai/dsh-tool-schedule'
95
94
  - id: skill-filesystem
96
95
  name: '@deepseek-ai/dsh-skill-filesystem'
97
96
  - id: tool-skill
98
97
  name: '@deepseek-ai/dsh-tool-skill'
99
- - id: command-goal
100
- name: '@deepseek-ai/dsh-command-goal'
101
- - id: tool-goal
102
- name: '@deepseek-ai/dsh-tool-goal'
103
98
  - id: planning
104
99
  name: cordis:group
105
100
  group: true
@@ -138,33 +133,6 @@
138
133
  thresholdChars: 8192
139
134
  headChars: 4096
140
135
  tailChars: 1024
141
- - id: delegation
142
- name: cordis:group
143
- group: true
144
- isolate:
145
- workflowEngine: true
146
- config:
147
- - id: tool-subagent-control
148
- name: '@deepseek-ai/dsh-tool-subagent-control'
149
- - id: tool-subagent-list-agents
150
- name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
151
- - id: tool-subagent
152
- name: '@deepseek-ai/dsh-tool-subagent'
153
- config:
154
- provider: spawn
155
- toolName: subagent
156
- modelSelectionSettings: true
157
- - id: tool-subagent-fork
158
- name: '@deepseek-ai/dsh-tool-subagent'
159
- config:
160
- provider: fork
161
- toolName: subagent_fork
162
- - id: workflow-ptc
163
- name: '@deepseek-ai/dsh-workflow-ptc'
164
- config:
165
- provider: spawn
166
- - id: tool-workflow
167
- name: '@deepseek-ai/dsh-tool-workflow'
168
136
  - id: tool-ask-user
169
137
  name: '@deepseek-ai/dsh-tool-ask-user'
170
138
  - id: tool-todo
@@ -176,8 +144,6 @@
176
144
  config:
177
145
  fetch: true
178
146
  searchTimeoutMs: 60000
179
- - id: present
180
- name: '@deepseek-ai/dsh-tool-present'
181
147
  - id: tool-plugin-manager
182
148
  name: '@deepseek-ai/dsh-plugin-manager/tools'
183
149
  disabled: true
@@ -188,3 +154,8 @@
188
154
  - id: agent-preset-registry
189
155
  config:
190
156
  default: authoring
157
+
158
+ # Static shell boot does not need one HTTP/1 event stream in every open Studio tab.
159
+ # Source, diagnostics, review and jobs use Presmium's scoped event connection.
160
+ - id: client-hmr
161
+ disabled: true
package/rows/load.mjs DELETED
@@ -1,8 +0,0 @@
1
- import { fileURLToPath } from 'node:url'
2
- import { createJiti } from 'jiti'
3
-
4
- const jiti = createJiti(import.meta.url)
5
-
6
- export function load(file) {
7
- return jiti.import(fileURLToPath(new URL(`../src/node/${file}`, import.meta.url)))
8
- }
package/rows/turns.mjs DELETED
@@ -1,7 +0,0 @@
1
- import { load } from './load.mjs'
2
-
3
- const row = await load('turns/index.ts')
4
-
5
- export const name = row.name
6
- export const inject = row.inject
7
- export const apply = row.apply