@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,13 @@
1
- import { spawn } from 'node:child_process'
2
- import type { ChildProcess } from 'node:child_process'
3
1
  import { randomBytes } from 'node:crypto'
4
- import { realpathSync } from 'node:fs'
5
2
  import { createRequire } from 'node:module'
6
- import { dirname, join, relative, resolve, sep } from 'node:path'
7
- import { fileURLToPath } from 'node:url'
8
- import { connectPresmiumMcp } from '@lincyaw/mcp/client'
9
- import type { ListedTool, PresmiumMcpClient } from '@lincyaw/mcp/client'
10
- import type { DshAgent, DshContext, DshLogger } from './dsh.ts'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import { errorMessage } from '@lincyaw/kernel'
5
+ import type { DocumentSummary } from '@lincyaw/kernel'
6
+ import { DocumentBindings } from './bindings.ts'
7
+ import type { DshContext, DshLogger } from './dsh-types.ts'
8
+ import { launchWorkspaceServer } from './launch.ts'
9
+ import type { LaunchedProcess } from './launch.ts'
10
+ import { SERVER_ROUTES, WORKSPACE_ROUTES } from './server-routes.ts'
11
11
 
12
12
  export const name = 'presmium-service'
13
13
 
@@ -19,262 +19,113 @@ export interface Config {
19
19
 
20
20
  export interface Endpoint {
21
21
  url: string
22
- token: string
22
+ serviceToken: string
23
23
  root: string
24
24
  }
25
25
 
26
- export interface WorkspaceDocument {
27
- dir: string
28
- title: string
29
- lang?: string
26
+ /** What the row provides to the other rows as `ctx.presmium`. */
27
+ export interface Presmium {
28
+ server: WorkspaceServerClient
29
+ bindings: DocumentBindings
30
30
  }
31
31
 
32
- export interface PresmiumSession {
33
- id: string
34
- document: WorkspaceDocument
35
- dir: string
36
- client: PresmiumMcpClient
37
- tools: ListedTool[]
38
- }
39
-
40
- const START_MS = 180_000
41
32
  const DOCUMENTS_MS = 2000
42
- const WATCHDOG = fileURLToPath(new URL('./watchdog.mjs', import.meta.url))
43
33
 
44
- function presmiumBin(): string {
34
+ /** The `presmium` command of the CLI this package depends on. */
35
+ function cliBin(): string {
45
36
  const entry = createRequire(import.meta.url).resolve('@lincyaw/cli')
46
37
  return join(dirname(dirname(entry)), 'bin', 'presmium.mjs')
47
38
  }
48
39
 
49
- function real(path: string): string {
50
- try {
51
- return realpathSync(path)
52
- } catch {
53
- return resolve(path)
54
- }
55
- }
56
-
57
- export function documentAt(root: string, dir: string, documents: WorkspaceDocument[]): WorkspaceDocument | undefined {
58
- const rel = relative(real(root), real(dir)).split(sep).join('/')
59
- if (rel.startsWith('..')) return undefined
60
- return documents.find(doc => doc.dir === rel)
61
- }
62
-
63
- function serviceEnv(token: string): NodeJS.ProcessEnv {
64
- const env: NodeJS.ProcessEnv = {}
65
- for (const [key, value] of Object.entries(process.env)) {
66
- if (key.startsWith('DSH_') || key.startsWith('DEEPSEEK_') || key === 'NODE_ENV') continue
67
- env[key] = value
68
- }
69
- return { ...env, PRESMIUM_SERVICE_TOKEN: token }
70
- }
71
-
72
- export class PresmiumService {
40
+ /** The workspace server the bundle works with: the one the launcher gives (`url`, `token`), or one it launches itself. */
41
+ export class WorkspaceServerClient {
73
42
  readonly ready: Promise<Endpoint>
74
- readonly #ctx: DshContext
75
- readonly #log: DshLogger
76
- #child: ChildProcess | undefined
77
- #documents: { at: number, list: Promise<WorkspaceDocument[]> } | undefined
78
- readonly #opening = new Map<string, Promise<PresmiumSession | undefined>>()
79
- readonly #open = new Map<string, PresmiumSession>()
80
- readonly #refreshing = new Map<string, Promise<PresmiumSession | undefined>>()
81
- readonly #selectionVersions = new Map<string, number>()
82
- readonly #selectionListeners = new Map<string, { before(): Promise<void>, after(): Promise<void> }>()
83
- readonly #selected = new Map<string, string | null>()
43
+ readonly #launched: LaunchedProcess | undefined
44
+ #documents: { at: number, list: Promise<DocumentSummary[]> } | undefined
84
45
  #closed = false
85
46
 
86
- constructor(ctx: DshContext, config: Config, log: DshLogger) {
87
- this.#ctx = ctx
88
- this.#log = log
47
+ constructor(config: Config, log: DshLogger) {
89
48
  const root = resolve(config.root || process.cwd())
90
- this.ready = config.url
91
- ? Promise.resolve({ url: config.url.replace(/\/+$/, ''), token: config.token ?? '', root })
92
- : this.#start(root)
93
- this.ready.catch((error: unknown) => log.warn(`presmium-service: ${error instanceof Error ? error.message : String(error)}`))
94
- }
95
-
96
- #start(root: string): Promise<Endpoint> {
97
- const token = randomBytes(24).toString('base64url')
98
- const child = spawn(process.execPath, [WATCHDOG, process.execPath, presmiumBin(), 'serve', root, '--port', '0'], {
99
- cwd: root,
100
- env: serviceEnv(token),
101
- stdio: ['pipe', 'pipe', 'pipe'],
102
- detached: true,
103
- })
104
- this.#child = child
105
- child.stdin!.on('error', () => {})
106
- let output = ''
107
- return new Promise<Endpoint>((resolveStart, reject) => {
108
- const timer = setTimeout(() => reject(new Error(`presmium serve did not start within ${START_MS / 1000} s:\n${output.slice(-2000)}`)), START_MS)
109
- const read = (chunk: Buffer) => {
110
- output = (output + chunk.toString()).slice(-20_000)
111
- const url = /^serve {2}(http\S+)$/m.exec(output)?.[1]
112
- const workspace = /^workspace {2}(.+)$/m.exec(output)?.[1]
113
- if (url && workspace) {
114
- clearTimeout(timer)
115
- resolveStart({ url, token, root: workspace.trim() })
116
- }
117
- }
118
- child.stdout!.on('data', read)
119
- child.stderr!.on('data', (chunk: Buffer) => {
120
- output = (output + chunk.toString()).slice(-20_000)
121
- for (const line of chunk.toString().split('\n').filter(Boolean)) this.#log.debug(`presmium serve: ${line}`)
49
+ if (config.url) {
50
+ this.ready = Promise.resolve({ url: config.url.replace(/\/+$/, ''), serviceToken: config.token ?? '', root })
51
+ } else {
52
+ const serviceToken = randomBytes(24).toString('base64url')
53
+ const server = launchWorkspaceServer({
54
+ presmium: cliBin(),
55
+ dir: root,
56
+ serviceToken,
57
+ onOutput: (text) => {
58
+ for (const line of text.split('\n').filter(Boolean)) log.debug(`presmium serve: ${line}`)
59
+ },
122
60
  })
123
- child.once('exit', (code, signal) => {
124
- clearTimeout(timer)
125
- if (!this.#closed) this.#log.warn(`presmium serve exited (${signal ?? code})`)
126
- reject(new Error(`presmium serve exited (${signal ?? code}):\n${output.slice(-2000)}`))
61
+ this.#launched = server.process
62
+ const { child } = server.process
63
+ void server.process.exited.then(() => {
64
+ if (!this.#closed) log.warn(`presmium serve exited (${child.signalCode ?? child.exitCode})`)
127
65
  })
128
- })
66
+ this.ready = server.ready.then(({ url, root }) => ({ url, serviceToken, root }))
67
+ }
68
+ this.ready.catch((error: unknown) => log.warn(`presmium-service: ${errorMessage(error)}`))
129
69
  }
130
70
 
131
71
  async request(path: string, init: { method?: string, body?: unknown, raw?: ArrayBuffer, headers?: Record<string, string>, signal?: AbortSignal } = {}): Promise<Response> {
132
- const { url, token } = await this.ready
72
+ const { url, serviceToken } = await this.ready
133
73
  const json = init.body !== undefined
134
74
  return fetch(`${url}${path}`, {
135
75
  method: init.method ?? (json || init.raw ? 'POST' : 'GET'),
136
- headers: { ...json ? { 'Content-Type': 'application/json' } : {}, ...init.headers, ...token ? { Authorization: `Bearer ${token}` } : {} },
76
+ headers: { ...json ? { 'Content-Type': 'application/json' } : {}, ...init.headers, ...serviceToken ? { Authorization: `Bearer ${serviceToken}` } : {} },
137
77
  ...json ? { body: JSON.stringify(init.body) } : init.raw ? { body: init.raw } : {},
138
78
  ...init.signal ? { signal: init.signal } : {},
139
79
  })
140
80
  }
141
81
 
142
- async #json<T>(path: string, init?: Parameters<PresmiumService['request']>[1]): Promise<T> {
82
+ async requestJson<T>(path: string, init?: Parameters<WorkspaceServerClient['request']>[1]): Promise<T> {
143
83
  const response = await this.request(path, init)
144
84
  const text = await response.text()
145
85
  if (!response.ok) throw new Error(`${path} answered ${response.status}: ${text}`)
146
86
  return JSON.parse(text) as T
147
87
  }
148
88
 
149
- documents(): Promise<WorkspaceDocument[]> {
89
+ /** A short-lived workspace document list; author mutations invalidate it immediately. */
90
+ readDocuments(): Promise<DocumentSummary[]> {
150
91
  if (!this.#documents || Date.now() - this.#documents.at > DOCUMENTS_MS) {
151
- const list = this.#json<{ documents: WorkspaceDocument[] }>('/__presmium/presmium.workspace/documents').then(answer => answer.documents)
92
+ const list = this.requestJson<{ documents: DocumentSummary[] }>(`${WORKSPACE_ROUTES}documents`).then(answer => answer.documents)
152
93
  this.#documents = { at: Date.now(), list }
153
94
  list.catch(() => this.#documents = undefined)
154
95
  }
155
96
  return this.#documents.list
156
97
  }
157
98
 
158
- directoryOf(agent: DshAgent): string {
159
- const dirs = this.#ctx.get('workingDirectory') as { get(session: unknown): string } | undefined
160
- try {
161
- return dirs?.get(agent.session) ?? agent.session.header?.cwd ?? process.cwd()
162
- } catch {
163
- return agent.session.header?.cwd ?? process.cwd()
164
- }
165
- }
166
-
167
- attach(agent: DshAgent): Promise<PresmiumSession | undefined> {
168
- const id = agent.session.id
169
- let known = this.#opening.get(id)
170
- if (!known) {
171
- known = this.#attach(agent).catch((error: unknown) => {
172
- this.#log.warn(`presmium-service: the session ${id} has no Presmium tools: ${error instanceof Error ? error.message : String(error)}`)
173
- this.#opening.delete(id)
174
- return undefined
175
- })
176
- this.#opening.set(id, known)
177
- }
178
- return known
179
- }
180
-
181
- async #attach(agent: DshAgent): Promise<PresmiumSession | undefined> {
182
- const { root } = await this.ready
183
- const id = agent.session.id
184
- const documents = await this.documents()
185
- const selected = this.#selected.get(id)
186
- const document = selected === undefined ? documentAt(root, this.directoryOf(agent), documents) : documents.find(doc => doc.dir === selected)
187
- if (!document) return undefined
188
- const grant = await this.#json<{ url: string, token: string }>('/__presmium/presmium.server/tokens', { body: { document: document.dir, session: id } })
189
- const client = await connectPresmiumMcp(grant.url, grant.token)
190
- const session: PresmiumSession = { id, document, dir: resolve(root, document.dir), client, tools: await client.tools() }
191
- this.#open.set(id, session)
192
- return session
193
- }
194
-
195
- async selectDocument(id: string, document: string | null): Promise<void> {
196
- if (document !== null && !(await this.documents()).some(doc => doc.dir === document)) throw new Error('Unknown Presmium document')
197
- const version = (this.#selectionVersions.get(id) ?? 0) + 1
198
- this.#selectionVersions.set(id, version)
199
- const listener = this.#selectionListeners.get(id)
200
- await listener?.before()
201
- if (this.#selectionVersions.get(id) !== version) return
202
- this.#selected.set(id, document)
203
- await listener?.after()
204
- }
205
-
206
- watchSelection(id: string, listener: { before(): Promise<void>, after(): Promise<void> }): () => void {
207
- this.#selectionListeners.set(id, listener)
208
- return () => { if (this.#selectionListeners.get(id) === listener) this.#selectionListeners.delete(id) }
209
- }
210
-
211
- refresh(agent: DshAgent): Promise<PresmiumSession | undefined> {
212
- const id = agent.session.id
213
- let pending = this.#refreshing.get(id)
214
- if (!pending) {
215
- pending = this.#refresh(agent).finally(() => this.#refreshing.delete(id))
216
- this.#refreshing.set(id, pending)
217
- }
218
- return pending
219
- }
220
-
221
- async #refresh(agent: DshAgent): Promise<PresmiumSession | undefined> {
222
- const id = agent.session.id
223
- const selected = this.#selected.get(id)
224
- const current = await this.attach(agent)
225
- if (selected === undefined || current?.document.dir === selected || (!current && selected === null)) return current
226
- await this.detach(id, false)
227
- return this.attach(agent)
228
- }
229
-
230
- session(agent: DshAgent | undefined): PresmiumSession | undefined {
231
- return agent ? this.#open.get(agent.session.id) : undefined
232
- }
233
-
234
- async detach(id: string, forget = true): Promise<void> {
235
- if (forget) {
236
- this.#selectionVersions.delete(id)
237
- this.#selected.delete(id)
238
- this.#selectionListeners.delete(id)
239
- }
240
- const session = this.#open.get(id)
241
- this.#open.delete(id)
242
- this.#opening.delete(id)
243
- if (!session) return
244
- await session.client.close().catch(() => undefined)
245
- await this.request('/__presmium/presmium.server/tokens', { method: 'DELETE', body: { session: id } }).then(r => r.body?.cancel(), () => undefined)
246
- }
99
+ /** A successful author operation must be visible before its next context binding. */
100
+ invalidateDocuments(): void { this.#documents = undefined }
247
101
 
102
+ /** Stops the server it launched, or revokes the session tokens a given server issued to it. */
248
103
  async close(): Promise<void> {
249
104
  this.#closed = true
250
- for (const session of this.#open.values()) await session.client.close().catch(() => undefined)
251
- this.#open.clear()
252
- this.#opening.clear()
253
- this.#selected.clear()
254
- this.#selectionVersions.clear()
255
- this.#selectionListeners.clear()
256
- if (this.#child) {
257
- this.#child.kill('SIGTERM')
105
+ if (this.#launched) {
106
+ this.#launched.child.kill('SIGTERM')
258
107
  return
259
108
  }
260
- await this.request('/__presmium/presmium.server/tokens', { method: 'DELETE', body: {} }).then(r => r.body?.cancel(), () => undefined)
109
+ await this.request(`${SERVER_ROUTES}tokens`, { method: 'DELETE', body: {} }).then(r => r.body?.cancel(), () => undefined)
261
110
  }
262
111
 
112
+ /** At the host's exit: stops the server it launched, synchronously. */
263
113
  stop(): void {
264
114
  this.#closed = true
265
- if (this.#child?.exitCode === null) this.#child.kill('SIGTERM')
115
+ if (this.#launched?.child.exitCode === null) this.#launched.child.kill('SIGTERM')
266
116
  }
267
117
  }
268
118
 
269
119
  export function apply(ctx: DshContext, config: Config = {}): void {
270
120
  const log = ctx.logger?.('presmium') ?? console
271
- const service = new PresmiumService(ctx, config, log)
272
- ctx.provide('presmium', service)
273
- ctx.on('session/disposed', (session: { id: string }) => void service.detach(session.id))
274
- const stop = () => service.stop()
121
+ const server = new WorkspaceServerClient(config, log)
122
+ const bindings = new DocumentBindings(ctx, server, log)
123
+ ctx.provide('presmium', { server, bindings } satisfies Presmium)
124
+ ctx.on('session/disposed', (session: { id: string }) => void bindings.unbind(session.id))
125
+ const stop = () => server.stop()
275
126
  process.once('exit', stop)
276
127
  ctx.effect(() => () => {
277
128
  process.off('exit', stop)
278
- void service.close()
129
+ void bindings.close().then(() => server.close())
279
130
  })
280
131
  }
@@ -1,46 +1,24 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import type { DshContext } from './dsh.ts'
3
+ import { listBundledSkills, skillDirectory } from '@lincyaw/skills'
4
+ import type { DshContext } from './dsh-types.ts'
5
5
 
6
6
  export const name = 'presmium-skills'
7
7
  export const inject = ['skills']
8
8
 
9
- const PACKAGE = fileURLToPath(new URL('../..', import.meta.url))
10
-
11
9
  export function bundledSkills(): string[] {
12
- const manifest = JSON.parse(readFileSync(join(PACKAGE, 'package.json'), 'utf8')) as { presmium?: { skills?: string[] } }
13
- return manifest.presmium?.skills ?? []
10
+ return listBundledSkills().map(skill => skill.name)
14
11
  }
15
12
 
16
13
  export function skillDir(skill: string): string | undefined {
17
- return [join(PACKAGE, '..', '..', '.agents', 'skills', skill), join(PACKAGE, 'skills', skill)].find(dir => existsSync(join(dir, 'SKILL.md')))
18
- }
19
-
20
- function field(frontmatter: string, key: string): string | undefined {
21
- const raw = new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(frontmatter)?.[1]?.trim()
22
- if (!raw) return undefined
23
- if (raw.startsWith('"')) {
24
- try {
25
- return JSON.parse(raw) as string
26
- } catch {
27
- return raw.slice(1, -1)
28
- }
29
- }
30
- return raw.replace(/^'|'$/g, '')
14
+ try { return skillDirectory(skill) } catch { return undefined }
31
15
  }
32
16
 
33
17
  export function apply(ctx: DshContext): void {
34
- for (const skill of bundledSkills()) {
35
- const dir = skillDir(skill)
36
- if (!dir) {
37
- ctx.logger?.('presmium').warn(`presmium-skills: the skill ${skill} is not in this package`)
38
- continue
39
- }
18
+ for (const skill of listBundledSkills()) {
19
+ const dir = skillDirectory(skill.name)
40
20
  const path = join(dir, 'SKILL.md')
41
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(readFileSync(path, 'utf8'))
42
- const description = match && field(match[1]!, 'description')
43
- if (!match || !description) continue
44
- ctx.skills.register({ name: field(match[1]!, 'name') ?? skill, description, content: match[2]!, path, source: 'bundled', resourceBase: { kind: 'directory', path: dir } })
21
+ const content = readFileSync(path, 'utf8').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
22
+ ctx.skills.register({ name: skill.name, description: skill.description, content, path, source: 'bundled', resourceBase: { kind: 'directory', path: dir } })
45
23
  }
46
24
  }
@@ -0,0 +1,126 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { basename, dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { errorMessage } from '@lincyaw/kernel'
7
+ import type { DshContext, DshOtel, DshOtelScalar, DshSessionEvent } from './dsh-types.ts'
8
+
9
+ export const name = 'presmium-telemetry'
10
+ export const inject = ['otel', 'presmium']
11
+
12
+ /** The operator's collector: an OTLP/HTTP logs endpoint, its headers (`key=value,…`), and who is using this machine. */
13
+ interface Config {
14
+ url?: string
15
+ headers?: string
16
+ user?: string
17
+ }
18
+
19
+ /** The page that reports operations: one load of the workbench, in one browser. */
20
+ export interface OperationClient {
21
+ id: string
22
+ browser?: string
23
+ locale?: string
24
+ }
25
+
26
+ /** One operation of the author in the workbench, as the page records it. */
27
+ export interface OperationRecord {
28
+ name: string
29
+ time: number
30
+ seq: number
31
+ attributes: Record<string, DshOtelScalar>
32
+ }
33
+
34
+ /** What the bundle collects for the operator: each session's events and the author's operations in the workbench. */
35
+ export interface PresmiumTelemetry {
36
+ readonly enabled: boolean
37
+ operations(client: OperationClient, records: readonly OperationRecord[]): void
38
+ }
39
+
40
+ const PACKAGE = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
41
+ const SCOPE = { name: '@lincyaw/dsh', version: (JSON.parse(readFileSync(join(PACKAGE, 'package.json'), 'utf8')) as { version: string }).version }
42
+ // A deadline for the queues at the host's exit: what is not sent by then is lost.
43
+ const DRAIN_MS = 3000
44
+
45
+ /** `key=value,key=value`, values percent-encoded, as in OTEL_EXPORTER_OTLP_HEADERS. */
46
+ export function parseHeaders(text: string | undefined): Record<string, string> {
47
+ const headers: Record<string, string> = {}
48
+ for (const pair of (text ?? '').split(',')) {
49
+ const at = pair.indexOf('=')
50
+ if (at <= 0) continue
51
+ headers[pair.slice(0, at).trim().toLowerCase()] = decodeURIComponent(pair.slice(at + 1).trim())
52
+ }
53
+ return headers
54
+ }
55
+
56
+ /** An id of this installation, kept in the state directory, so that its reports can be told apart from another's. */
57
+ function installationId(): string {
58
+ const file = join(process.env.DSH_HOME ?? join(homedir(), '.presmium'), 'telemetry-installation')
59
+ try {
60
+ if (existsSync(file)) return readFileSync(file, 'utf8').trim()
61
+ mkdirSync(dirname(file), { recursive: true })
62
+ const id = randomUUID()
63
+ writeFileSync(file, `${id}\n`)
64
+ return id
65
+ } catch {
66
+ return randomUUID()
67
+ }
68
+ }
69
+
70
+ export function apply(ctx: DshContext & { otel: DshOtel }, config: Config = {}): void {
71
+ if (!config.url) {
72
+ ctx.provide('presmiumTelemetry', { enabled: false, operations() {} } satisfies PresmiumTelemetry)
73
+ return
74
+ }
75
+ const log = ctx.logger?.('presmium') ?? console
76
+ const exporter = { url: config.url, headers: parseHeaders(config.headers) }
77
+ const resourceAttributes = { 'service.name': 'presmium-studio', 'service.version': SCOPE.version, 'presmium.installation.id': installationId(), ...config.user ? { 'user.id': config.user } : {} }
78
+ const onFailure = (message: string, error?: Error) => log.warn(`presmium-telemetry: ${message}${error ? `: ${errorMessage(error)}` : ''}`)
79
+ const sessions = ctx.otel.createSessionLogReporter({ exporter, scope: SCOPE, resourceAttributes, onFailure })
80
+ const operations = ctx.otel.createEventReporter({ exporter, scope: SCOPE, resourceAttributes, onFailure, processor: { scheduledDelayMillis: 1000, maxExportBatchSize: 512 } })
81
+ let workspace: string | undefined
82
+ void ctx.presmium.server.ready.then(({ root }) => workspace = basename(root), () => undefined)
83
+
84
+ // Every event of every session, whole: the trajectory of the conversation and of each turn of the model.
85
+ const turns = new Map<string, number>()
86
+ ctx.on('session/event', (session: { id: string }, event: DshSessionEvent) => {
87
+ const data = event.data as { turn?: unknown } | undefined
88
+ if (event.type === 'turn/start' && typeof data?.turn === 'number') turns.set(session.id, data.turn)
89
+ const turn = turns.get(session.id)
90
+ const document = ctx.presmium.bindings.documentOf(session.id)
91
+ sessions.reportSessionLog({
92
+ sessionId: session.id,
93
+ event,
94
+ attributes: { ...workspace ? { 'presmium.workspace': workspace } : {}, ...document === undefined ? {} : { 'presmium.document': document }, ...turn === undefined ? {} : { 'presmium.turn': turn } },
95
+ })
96
+ })
97
+ ctx.on('session/disposed', (session: { id: string }) => void turns.delete(session.id))
98
+
99
+ ctx.provide('presmiumTelemetry', {
100
+ enabled: true,
101
+ operations(client, records) {
102
+ for (const record of records) {
103
+ operations.emit({
104
+ eventName: `presmium.workbench.${record.name}`,
105
+ body: record.name,
106
+ timestamp: record.time,
107
+ attributes: {
108
+ ...record.attributes,
109
+ 'presmium.operation': record.name,
110
+ 'presmium.seq': record.seq,
111
+ 'presmium.client.id': client.id,
112
+ ...client.browser ? { 'presmium.browser.id': client.browser } : {},
113
+ ...client.locale ? { 'presmium.locale': client.locale } : {},
114
+ ...workspace ? { 'presmium.workspace': workspace } : {},
115
+ },
116
+ })
117
+ }
118
+ },
119
+ } satisfies PresmiumTelemetry)
120
+ ctx.effect(() => () => {
121
+ const deadline = AbortSignal.timeout(DRAIN_MS)
122
+ void operations.shutdown(deadline).catch(() => undefined)
123
+ const drained = sessions.shutdown().catch(() => undefined)
124
+ void Promise.race([drained, new Promise(done => setTimeout(done, DRAIN_MS).unref())]).then(() => sessions.stopPending())
125
+ })
126
+ }
package/src/node/tools.ts CHANGED
@@ -1,20 +1,20 @@
1
1
  import type { ListedTool } from '@lincyaw/mcp/client'
2
- import type { DshAgent, DshContext, DshToolDefinition } from './dsh.ts'
3
- import type { PresmiumSession } from './service.ts'
2
+ import type { DshAgent, DshContext, DshToolDefinition } from './dsh-types.ts'
3
+ import type { DocumentBinding } from './bindings.ts'
4
4
 
5
5
  export const name = 'presmium-tools'
6
6
  export const inject = ['presmium', 'tools']
7
7
 
8
8
  export const PREFIX = 'presmium__'
9
9
 
10
- function definition(session: PresmiumSession, tool: ListedTool): DshToolDefinition {
10
+ function definition(binding: DocumentBinding, tool: ListedTool): DshToolDefinition {
11
11
  return {
12
12
  name: `${PREFIX}${tool.name}`,
13
13
  description: tool.description,
14
14
  parameters: tool.inputSchema,
15
15
  output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] },
16
16
  async execute(args, exec) {
17
- const result = await session.client.call(tool.name, (args ?? {}) as Record<string, unknown>, exec.signal)
17
+ const result = await binding.client.call(tool.name, (args ?? {}) as Record<string, unknown>, exec.signal)
18
18
  if (result.isError) throw new Error(result.text)
19
19
  return result.text
20
20
  },
@@ -22,22 +22,45 @@ function definition(session: PresmiumSession, tool: ListedTool): DshToolDefiniti
22
22
  }
23
23
 
24
24
  export function apply(ctx: DshContext): void {
25
+ const live = new WeakSet<DshAgent>()
25
26
  const selections = new WeakMap<DshAgent, () => void>()
26
- const registered = new WeakMap<DshAgent, { session?: PresmiumSession, dispose: (() => void)[] }>()
27
- const sync = async (agent: DshAgent) => {
28
- const session = await ctx.presmium.refresh(agent)
27
+ const registered = new WeakMap<DshAgent, { binding?: DocumentBinding, tools?: ListedTool[], stop?: () => void, dispose: (() => void)[] }>()
28
+ const install = (agent: DshAgent, binding: DocumentBinding | undefined) => {
29
+ if (!live.has(agent) || ctx.presmium.bindings.bindingOf(agent) !== binding) return
29
30
  const previous = registered.get(agent)
30
- if (previous && previous.session === session) return
31
+ if (previous && previous.binding === binding && previous.tools === binding?.tools) return
32
+ previous?.stop?.()
31
33
  for (const dispose of previous?.dispose ?? []) dispose()
32
- registered.set(agent, { session, dispose: session?.tools.map(tool => agent.ctx.tools.register(definition(session, tool))) ?? [] })
34
+ registered.set(agent, {
35
+ binding, tools: binding?.tools,
36
+ stop: binding?.toolChanges.watch(() => { if (agent.status === 'idle') install(agent, binding) }),
37
+ dispose: binding?.tools.map(tool => agent.ctx.tools.register(definition(binding, tool))) ?? [],
38
+ })
39
+ }
40
+ const sync = async (agent: DshAgent) => {
41
+ const binding = await ctx.presmium.bindings.syncBinding(agent)
42
+ if (binding?.toolChanges.error) await binding.toolChanges.refresh()
43
+ install(agent, binding)
33
44
  }
34
45
  ctx.on('agent/created', async ({ agent }: { agent: DshAgent }) => {
46
+ live.add(agent)
35
47
  await sync(agent)
36
- selections.set(agent, ctx.presmium.watchSelection(agent.session.id, { before: () => agent.whenIdle(), after: () => sync(agent) }))
48
+ if (!live.has(agent)) return
49
+ selections.set(agent, ctx.presmium.bindings.watchSelection(agent.session.id, { before: () => agent.whenIdle(), after: () => sync(agent) }))
50
+ })
51
+ ctx.on('agent/disposed', ({ agent }: { agent: DshAgent }) => {
52
+ live.delete(agent)
53
+ selections.get(agent)?.(); selections.delete(agent)
54
+ const previous = registered.get(agent)
55
+ registered.delete(agent)
56
+ previous?.stop?.()
57
+ for (const dispose of previous?.dispose ?? []) dispose()
37
58
  })
38
- ctx.on('agent/disposed', ({ agent }: { agent: DshAgent }) => { selections.get(agent)?.(); selections.delete(agent) })
39
59
  ctx.on('agent/pre-step', async ({ agent }: { agent: DshAgent }, next: () => Promise<unknown>) => {
40
60
  await sync(agent)
41
61
  return next()
42
62
  })
63
+ ctx.on('agent/status', ({ agent, status }: { agent: DshAgent, status: DshAgent['status'] }) => {
64
+ if (status === 'idle') install(agent, ctx.presmium.bindings.bindingOf(agent))
65
+ })
43
66
  }