@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.
- package/README.md +8 -4
- package/build/client.mjs +7 -1
- package/build/dist/client.js +1364 -64
- package/build/dist/client.json +225 -21
- package/build/ensure.mjs +11 -2
- package/cordis.patch.yml +9 -0
- package/entries/events.mjs +7 -0
- package/entries/load.mjs +11 -0
- package/entries/telemetry.mjs +7 -0
- package/entries/turns.mjs +7 -0
- package/package.json +32 -19
- package/src/client/RootLayout.tsx +332 -0
- package/src/client/agent-requests.ts +64 -0
- package/src/client/assistant-setup.tsx +185 -0
- package/src/client/author-settings.ts +57 -0
- package/src/client/brand.tsx +21 -14
- package/src/client/contract.ts +48 -1
- package/src/client/document-context.ts +39 -0
- package/src/client/dsh.d.ts +27 -1
- package/src/client/fallback.tsx +23 -19
- package/src/client/frame.css +280 -120
- package/src/client/index.tsx +72 -69
- package/src/client/launch-target.ts +89 -0
- package/src/client/layout.ts +90 -77
- package/src/client/mascot.tsx +37 -0
- package/src/client/messages.ts +142 -49
- package/src/client/{chrome.tsx → navigation-shell.tsx} +20 -25
- package/src/client/observable.ts +16 -0
- package/src/client/paths.ts +17 -0
- package/src/client/root-reload.ts +163 -0
- package/src/client/runtime-diagnostics.tsx +228 -0
- package/src/client/settings-opener.ts +26 -0
- package/src/client/{source.tsx → source-links.tsx} +16 -26
- package/src/client/speech-settings.tsx +7 -0
- package/src/client/styles.css +42 -0
- package/src/client/theme.ts +4 -2
- package/src/client/workbench-keys.ts +36 -0
- package/src/client/workspace-events.ts +114 -0
- package/src/node/bindings.ts +345 -0
- package/src/node/context.ts +131 -20
- package/src/node/{dsh.ts → dsh-types.ts} +33 -2
- package/src/node/fake-collector.ts +46 -0
- package/src/node/fake-model.ts +80 -0
- package/src/node/launch.ts +127 -10
- package/src/node/policy.ts +20 -23
- package/src/node/routes.ts +21 -11
- package/src/node/server-routes.ts +3 -0
- package/src/node/service.ts +58 -207
- package/src/node/skills.ts +9 -31
- package/src/node/telemetry.ts +126 -0
- package/src/node/tools.ts +34 -11
- package/src/node/{turns/index.ts → turns.ts} +48 -37
- package/src/node/workbench.ts +51 -6
- package/src/node/workspace-events.ts +167 -0
- package/src/node/workspaces.ts +2 -2
- package/src/workspace-events.ts +31 -0
- package/web.patch.yml +27 -56
- package/rows/load.mjs +0 -8
- package/rows/turns.mjs +0 -7
- package/skills/presmium-author/SKILL.md +0 -95
- package/skills/presmium-author/references/assistance.md +0 -27
- package/skills/presmium-author/references/code.md +0 -52
- package/skills/presmium-author/references/directives.md +0 -592
- package/skills/presmium-author/references/publishing.md +0 -60
- package/skills/presmium-author/references/writing.md +0 -72
- package/skills/presmium-present/SKILL.md +0 -69
- package/skills/presmium-present/references/classroom.md +0 -46
- package/skills/presmium-present/references/delivery.md +0 -63
- package/skills/presmium-present/references/lecture.md +0 -58
- package/src/client/Frame.tsx +0 -314
- /package/{rows → entries}/context.mjs +0 -0
- /package/{rows → entries}/policy.mjs +0 -0
- /package/{rows → entries}/routes.mjs +0 -0
- /package/{rows → entries}/service.mjs +0 -0
- /package/{rows → entries}/skills.mjs +0 -0
- /package/{rows → entries}/tools.mjs +0 -0
- /package/{rows → entries}/workbench.mjs +0 -0
- /package/{rows → entries}/workspaces.mjs +0 -0
package/src/node/service.ts
CHANGED
|
@@ -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,
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
import type {
|
|
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
|
-
|
|
22
|
+
serviceToken: string
|
|
23
23
|
root: string
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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 #
|
|
75
|
-
|
|
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(
|
|
87
|
-
this.#ctx = ctx
|
|
88
|
-
this.#log = log
|
|
47
|
+
constructor(config: Config, log: DshLogger) {
|
|
89
48
|
const root = resolve(config.root || process.cwd())
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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,
|
|
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, ...
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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(
|
|
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
|
|
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
|
|
272
|
-
ctx
|
|
273
|
-
ctx.
|
|
274
|
-
|
|
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
|
|
129
|
+
void bindings.close().then(() => server.close())
|
|
279
130
|
})
|
|
280
131
|
}
|
package/src/node/skills.ts
CHANGED
|
@@ -1,46 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
35
|
-
const dir =
|
|
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
|
|
42
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
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, {
|
|
27
|
-
const
|
|
28
|
-
|
|
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.
|
|
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, {
|
|
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
|
-
|
|
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
|
}
|