@lincyaw/dsh 0.0.0-stage → 0.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +26 -2
- package/build/client.mjs +72 -0
- package/build/dist/client.js +1390 -0
- package/build/dist/client.json +274 -0
- package/build/ensure.mjs +39 -0
- package/cordis.patch.yml +26 -0
- package/entries/context.mjs +7 -0
- package/entries/events.mjs +7 -0
- package/entries/load.mjs +11 -0
- package/entries/policy.mjs +7 -0
- package/entries/routes.mjs +7 -0
- package/entries/service.mjs +7 -0
- package/entries/skills.mjs +7 -0
- package/entries/telemetry.mjs +7 -0
- package/entries/tools.mjs +7 -0
- package/entries/turns.mjs +7 -0
- package/entries/workbench.mjs +9 -0
- package/entries/workspaces.mjs +7 -0
- package/package.json +108 -3
- package/profile.patch.yml +25 -0
- 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 +97 -0
- package/src/client/contract.ts +82 -0
- package/src/client/document-context.ts +39 -0
- package/src/client/dsh.d.ts +155 -0
- package/src/client/fallback.tsx +53 -0
- package/src/client/frame.css +505 -0
- package/src/client/index.tsx +157 -0
- package/src/client/launch-target.ts +89 -0
- package/src/client/layout.ts +230 -0
- package/src/client/mascot.tsx +37 -0
- package/src/client/messages.ts +171 -0
- package/src/client/navigation-shell.tsx +74 -0
- 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-links.tsx +71 -0
- package/src/client/speech-settings.tsx +7 -0
- package/src/client/styles.css +45 -0
- package/src/client/theme.ts +151 -0
- 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 +166 -0
- package/src/node/dsh-types.ts +124 -0
- package/src/node/fake-collector.ts +46 -0
- package/src/node/fake-model.ts +80 -0
- package/src/node/launch.ts +176 -0
- package/src/node/policy.ts +56 -0
- package/src/node/routes.ts +56 -0
- package/src/node/server-routes.ts +3 -0
- package/src/node/service.ts +131 -0
- package/src/node/skills.ts +24 -0
- package/src/node/telemetry.ts +126 -0
- package/src/node/tools.ts +66 -0
- package/src/node/turns.ts +176 -0
- package/src/node/watchdog.mjs +36 -0
- package/src/node/workbench.ts +88 -0
- package/src/node/workspace-events.ts +167 -0
- package/src/node/workspaces.ts +10 -0
- package/src/workspace-events.ts +31 -0
- package/web.patch.yml +161 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { DshContext } from './dsh-types.ts'
|
|
2
|
+
import { SERVER_ROUTES, WORKSPACE_ROUTES } from './server-routes.ts'
|
|
3
|
+
|
|
4
|
+
export const name = 'presmium-routes'
|
|
5
|
+
export const inject = ['presmium', 'connection']
|
|
6
|
+
|
|
7
|
+
export const ROUTES: Record<string, string> = Object.fromEntries([
|
|
8
|
+
['speech-settings', `${WORKSPACE_ROUTES}speech-settings`],
|
|
9
|
+
...['project/capabilities', 'project/plan', 'project/apply', 'documents', 'document', 'document/capabilities', 'document/outline', 'document/part', 'document/page/plan', 'document/page/apply', 'document/action/plan', 'document/action/impact', 'document/action/apply', 'document/asset', 'document/events', 'document/change', 'document/image', 'document/audio', 'document/recording/adopt', 'document/media-clip', 'files', 'source', 'source/create', 'source/transaction', 'changes', 'changes/commit', 'changes/discard', 'revisions', 'revisions/revert', 'history/list', 'history/file', 'history/undo', 'history/redo',
|
|
10
|
+
'document/media-clip/validate', 'document/transcription/capabilities', 'document/transcription', 'document/transcription/result', 'document/transcription/cancel',
|
|
11
|
+
'document/transcription/adoption/plan', 'document/transcription/adoption/apply',
|
|
12
|
+
'ir', 'ir/events', 'preview', 'build', 'check', 'jobs', 'jobs/cancel', 'jobs/events', 'voice-catalog', 'voice-capabilities',
|
|
13
|
+
'review', 'review/open', 'review/step', 'review/accept', 'review/reject', 'review/restore'].map(path => [path, `${WORKSPACE_ROUTES}${path}`]),
|
|
14
|
+
['focus', `${SERVER_ROUTES}focus`],
|
|
15
|
+
['control', `${SERVER_ROUTES}control`],
|
|
16
|
+
['diagnostics', `${SERVER_ROUTES}diagnostics`],
|
|
17
|
+
['diagnostics/events', `${SERVER_ROUTES}diagnostics/events`],
|
|
18
|
+
['diagnostics/authoring', `${SERVER_ROUTES}diagnostics/authoring`],
|
|
19
|
+
...['voiceover', 'voiceover/jobs', 'voiceover/job', 'voiceover/cancel', 'voice-preview', 'voice-preview/media', 'production-plan', 'production-media', 'production-asset'].map(path => [path, `/__presmium/presmium.lecture/${path}`]),
|
|
20
|
+
// Training a cloned voice and its status: the speech plugin's owner routes.
|
|
21
|
+
['voice-clone', '/__presmium/presmium.speech/clone'],
|
|
22
|
+
['voice-clone/status', '/__presmium/presmium.speech/clone/status'],
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
const PASSED = ['accept', 'content-type', 'presmium-document', 'last-event-id', 'if-none-match']
|
|
26
|
+
const RETURNED = ['content-type', 'cache-control', 'content-disposition', 'content-security-policy', 'x-content-type-options', 'cross-origin-resource-policy', 'x-accel-buffering', 'etag', 'retry-after']
|
|
27
|
+
|
|
28
|
+
export function apply(ctx: DshContext): void {
|
|
29
|
+
for (const [path, target] of Object.entries(ROUTES)) {
|
|
30
|
+
ctx.connection.fetch.register({
|
|
31
|
+
path: `/api/presmium/${path}`,
|
|
32
|
+
methods: ['GET', 'POST'],
|
|
33
|
+
requestBody: 'buffered',
|
|
34
|
+
async fetch(request) {
|
|
35
|
+
const headers: Record<string, string> = {}
|
|
36
|
+
for (const name of PASSED) {
|
|
37
|
+
const value = request.headers.get(name)
|
|
38
|
+
if (value !== null) headers[name] = value
|
|
39
|
+
}
|
|
40
|
+
const upstream = await ctx.presmium.server.request(`${target}${new URL(request.url).search}`, {
|
|
41
|
+
method: request.method,
|
|
42
|
+
headers,
|
|
43
|
+
...request.method === 'POST' ? { raw: await request.arrayBuffer() } : {},
|
|
44
|
+
signal: request.signal,
|
|
45
|
+
})
|
|
46
|
+
if ((path === 'documents' || path === 'project/apply') && request.method === 'POST' && upstream.ok) ctx.presmium.server.invalidateDocuments()
|
|
47
|
+
const answered = new Headers()
|
|
48
|
+
for (const name of RETURNED) {
|
|
49
|
+
const value = upstream.headers.get(name)
|
|
50
|
+
if (value !== null) answered.set(name, value)
|
|
51
|
+
}
|
|
52
|
+
return new Response(upstream.body, { status: upstream.status, headers: answered })
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
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
|
+
|
|
12
|
+
export const name = 'presmium-service'
|
|
13
|
+
|
|
14
|
+
export interface Config {
|
|
15
|
+
url?: string
|
|
16
|
+
token?: string
|
|
17
|
+
root?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Endpoint {
|
|
21
|
+
url: string
|
|
22
|
+
serviceToken: string
|
|
23
|
+
root: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the row provides to the other rows as `ctx.presmium`. */
|
|
27
|
+
export interface Presmium {
|
|
28
|
+
server: WorkspaceServerClient
|
|
29
|
+
bindings: DocumentBindings
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DOCUMENTS_MS = 2000
|
|
33
|
+
|
|
34
|
+
/** The `presmium` command of the CLI this package depends on. */
|
|
35
|
+
function cliBin(): string {
|
|
36
|
+
const entry = createRequire(import.meta.url).resolve('@lincyaw/cli')
|
|
37
|
+
return join(dirname(dirname(entry)), 'bin', 'presmium.mjs')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The workspace server the bundle works with: the one the launcher gives (`url`, `token`), or one it launches itself. */
|
|
41
|
+
export class WorkspaceServerClient {
|
|
42
|
+
readonly ready: Promise<Endpoint>
|
|
43
|
+
readonly #launched: LaunchedProcess | undefined
|
|
44
|
+
#documents: { at: number, list: Promise<DocumentSummary[]> } | undefined
|
|
45
|
+
#closed = false
|
|
46
|
+
|
|
47
|
+
constructor(config: Config, log: DshLogger) {
|
|
48
|
+
const root = resolve(config.root || process.cwd())
|
|
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
|
+
},
|
|
60
|
+
})
|
|
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})`)
|
|
65
|
+
})
|
|
66
|
+
this.ready = server.ready.then(({ url, root }) => ({ url, serviceToken, root }))
|
|
67
|
+
}
|
|
68
|
+
this.ready.catch((error: unknown) => log.warn(`presmium-service: ${errorMessage(error)}`))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async request(path: string, init: { method?: string, body?: unknown, raw?: ArrayBuffer, headers?: Record<string, string>, signal?: AbortSignal } = {}): Promise<Response> {
|
|
72
|
+
const { url, serviceToken } = await this.ready
|
|
73
|
+
const json = init.body !== undefined
|
|
74
|
+
return fetch(`${url}${path}`, {
|
|
75
|
+
method: init.method ?? (json || init.raw ? 'POST' : 'GET'),
|
|
76
|
+
headers: { ...json ? { 'Content-Type': 'application/json' } : {}, ...init.headers, ...serviceToken ? { Authorization: `Bearer ${serviceToken}` } : {} },
|
|
77
|
+
...json ? { body: JSON.stringify(init.body) } : init.raw ? { body: init.raw } : {},
|
|
78
|
+
...init.signal ? { signal: init.signal } : {},
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async requestJson<T>(path: string, init?: Parameters<WorkspaceServerClient['request']>[1]): Promise<T> {
|
|
83
|
+
const response = await this.request(path, init)
|
|
84
|
+
const text = await response.text()
|
|
85
|
+
if (!response.ok) throw new Error(`${path} answered ${response.status}: ${text}`)
|
|
86
|
+
return JSON.parse(text) as T
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A short-lived workspace document list; author mutations invalidate it immediately. */
|
|
90
|
+
readDocuments(): Promise<DocumentSummary[]> {
|
|
91
|
+
if (!this.#documents || Date.now() - this.#documents.at > DOCUMENTS_MS) {
|
|
92
|
+
const list = this.requestJson<{ documents: DocumentSummary[] }>(`${WORKSPACE_ROUTES}documents`).then(answer => answer.documents)
|
|
93
|
+
this.#documents = { at: Date.now(), list }
|
|
94
|
+
list.catch(() => this.#documents = undefined)
|
|
95
|
+
}
|
|
96
|
+
return this.#documents.list
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A successful author operation must be visible before its next context binding. */
|
|
100
|
+
invalidateDocuments(): void { this.#documents = undefined }
|
|
101
|
+
|
|
102
|
+
/** Stops the server it launched, or revokes the session tokens a given server issued to it. */
|
|
103
|
+
async close(): Promise<void> {
|
|
104
|
+
this.#closed = true
|
|
105
|
+
if (this.#launched) {
|
|
106
|
+
this.#launched.child.kill('SIGTERM')
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
await this.request(`${SERVER_ROUTES}tokens`, { method: 'DELETE', body: {} }).then(r => r.body?.cancel(), () => undefined)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** At the host's exit: stops the server it launched, synchronously. */
|
|
113
|
+
stop(): void {
|
|
114
|
+
this.#closed = true
|
|
115
|
+
if (this.#launched?.child.exitCode === null) this.#launched.child.kill('SIGTERM')
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function apply(ctx: DshContext, config: Config = {}): void {
|
|
120
|
+
const log = ctx.logger?.('presmium') ?? console
|
|
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()
|
|
126
|
+
process.once('exit', stop)
|
|
127
|
+
ctx.effect(() => () => {
|
|
128
|
+
process.off('exit', stop)
|
|
129
|
+
void bindings.close().then(() => server.close())
|
|
130
|
+
})
|
|
131
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { listBundledSkills, skillDirectory } from '@lincyaw/skills'
|
|
4
|
+
import type { DshContext } from './dsh-types.ts'
|
|
5
|
+
|
|
6
|
+
export const name = 'presmium-skills'
|
|
7
|
+
export const inject = ['skills']
|
|
8
|
+
|
|
9
|
+
export function bundledSkills(): string[] {
|
|
10
|
+
return listBundledSkills().map(skill => skill.name)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function skillDir(skill: string): string | undefined {
|
|
14
|
+
try { return skillDirectory(skill) } catch { return undefined }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function apply(ctx: DshContext): void {
|
|
18
|
+
for (const skill of listBundledSkills()) {
|
|
19
|
+
const dir = skillDirectory(skill.name)
|
|
20
|
+
const path = join(dir, 'SKILL.md')
|
|
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 } })
|
|
23
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ListedTool } from '@lincyaw/mcp/client'
|
|
2
|
+
import type { DshAgent, DshContext, DshToolDefinition } from './dsh-types.ts'
|
|
3
|
+
import type { DocumentBinding } from './bindings.ts'
|
|
4
|
+
|
|
5
|
+
export const name = 'presmium-tools'
|
|
6
|
+
export const inject = ['presmium', 'tools']
|
|
7
|
+
|
|
8
|
+
export const PREFIX = 'presmium__'
|
|
9
|
+
|
|
10
|
+
function definition(binding: DocumentBinding, tool: ListedTool): DshToolDefinition {
|
|
11
|
+
return {
|
|
12
|
+
name: `${PREFIX}${tool.name}`,
|
|
13
|
+
description: tool.description,
|
|
14
|
+
parameters: tool.inputSchema,
|
|
15
|
+
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] },
|
|
16
|
+
async execute(args, exec) {
|
|
17
|
+
const result = await binding.client.call(tool.name, (args ?? {}) as Record<string, unknown>, exec.signal)
|
|
18
|
+
if (result.isError) throw new Error(result.text)
|
|
19
|
+
return result.text
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function apply(ctx: DshContext): void {
|
|
25
|
+
const live = new WeakSet<DshAgent>()
|
|
26
|
+
const selections = new WeakMap<DshAgent, () => void>()
|
|
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
|
|
30
|
+
const previous = registered.get(agent)
|
|
31
|
+
if (previous && previous.binding === binding && previous.tools === binding?.tools) return
|
|
32
|
+
previous?.stop?.()
|
|
33
|
+
for (const dispose of previous?.dispose ?? []) dispose()
|
|
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)
|
|
44
|
+
}
|
|
45
|
+
ctx.on('agent/created', async ({ agent }: { agent: DshAgent }) => {
|
|
46
|
+
live.add(agent)
|
|
47
|
+
await 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()
|
|
58
|
+
})
|
|
59
|
+
ctx.on('agent/pre-step', async ({ agent }: { agent: DshAgent }, next: () => Promise<unknown>) => {
|
|
60
|
+
await sync(agent)
|
|
61
|
+
return next()
|
|
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
|
+
})
|
|
66
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
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.'
|
|
11
|
+
|
|
12
|
+
export const name = 'presmium-turns'
|
|
13
|
+
export const inject = ['presmium', 'tools']
|
|
14
|
+
|
|
15
|
+
export const WRITING_TOOLS = new Set(['write', 'edit', 'str_replace_editor', 'bash', 'pwsh', 'terminal_send'])
|
|
16
|
+
|
|
17
|
+
const REVIEW = `${WORKSPACE_ROUTES}review`
|
|
18
|
+
const RETRY_MS = 2000
|
|
19
|
+
|
|
20
|
+
interface LiveAgent extends DshAgent {
|
|
21
|
+
inject(message: unknown): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SessionEvent {
|
|
25
|
+
type: string
|
|
26
|
+
data?: { turn?: number }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Reverted {
|
|
30
|
+
type: 'reverted'
|
|
31
|
+
session: string
|
|
32
|
+
turns?: string
|
|
33
|
+
revision: string
|
|
34
|
+
subject: string
|
|
35
|
+
revertedBy: string
|
|
36
|
+
files: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The turn an agent session is in, and whether a tool wrote in it (to which document's review). */
|
|
40
|
+
interface TurnState {
|
|
41
|
+
turn?: number
|
|
42
|
+
document?: string
|
|
43
|
+
wrote: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function labelOf(exec: Readonly<DshToolExecution>): string {
|
|
47
|
+
const args = (exec.arguments ?? {}) as { file_path?: unknown, path?: unknown, command?: unknown, input?: unknown }
|
|
48
|
+
const what = [args.file_path, args.path, args.command, args.input].find(value => typeof value === 'string' && value.trim())
|
|
49
|
+
return what ? `${exec.name} ${String(what).replace(/\s+/g, ' ').trim().slice(0, 160)}` : exec.name
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function revertNotice(event: Reverted): unknown {
|
|
53
|
+
const short = (id: string) => id.slice(0, 10)
|
|
54
|
+
const files = event.files.length ? event.files.join(', ') : 'its files'
|
|
55
|
+
const turns = event.turns ? ` (turns ${event.turns})` : ''
|
|
56
|
+
const text = `[Presmium] The author undid the change this session made${turns}: commit ${short(event.revision)} "${event.subject}", undone by commit ${short(event.revertedBy)}. ${files} ${event.files.length === 1 ? 'is' : 'are'} back as before that change. Read a file again before you edit it, and do not make that change again unless the author asks.`
|
|
57
|
+
const summary = `Presmium: the author undid commit ${short(event.revision)}${turns}`
|
|
58
|
+
return Object.freeze({ id: randomUUID(), role: 'user', content: Object.freeze([Object.freeze({ type: 'text', text })]), source: Object.freeze({ kind: 'presmium', form: 'notice', summary: summary.slice(0, 120) }) })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function apply(ctx: DshContext): void {
|
|
62
|
+
const log = ctx.logger?.('presmium') ?? console
|
|
63
|
+
const turnStates = new Map<string, TurnState>()
|
|
64
|
+
const agents = new Map<string, LiveAgent>()
|
|
65
|
+
const pending = new Map<string, unknown[]>()
|
|
66
|
+
const turnStateOf = (id: string) => {
|
|
67
|
+
let known = turnStates.get(id)
|
|
68
|
+
if (!known) turnStates.set(id, known = { wrote: false })
|
|
69
|
+
return known
|
|
70
|
+
}
|
|
71
|
+
const parents = new Map<string, string>()
|
|
72
|
+
/** A subagent's writes go to the review of the session that started it. */
|
|
73
|
+
const rootAgentSessionOf = (id: string) => {
|
|
74
|
+
let at = id
|
|
75
|
+
for (let depth = 0; depth < 32 && parents.has(at); depth++) at = parents.get(at)!
|
|
76
|
+
return at
|
|
77
|
+
}
|
|
78
|
+
|
|
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) } })
|
|
81
|
+
const text = await response.text()
|
|
82
|
+
if (response.ok) return
|
|
83
|
+
if (path === 'open' && response.status === 404) throw new Error(NOT_RECORDED)
|
|
84
|
+
let reason = text
|
|
85
|
+
try {
|
|
86
|
+
reason = (JSON.parse(text) as { error?: string, detail?: string }).error ?? reason
|
|
87
|
+
} catch {
|
|
88
|
+
}
|
|
89
|
+
throw new Error(reason || `the review answered ${response.status}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
ctx.on('session/event', (session: { id: string }, event: SessionEvent) => {
|
|
93
|
+
if (event.type === 'turn/start' && typeof event.data?.turn === 'number') turnStateOf(session.id).turn = event.data.turn
|
|
94
|
+
if (event.type !== 'turn/end') return
|
|
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)}`))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
ctx.on('tools/execute', async (exec: DshToolExecution, next: () => Promise<unknown>) => {
|
|
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)
|
|
109
|
+
try {
|
|
110
|
+
await postReview(document, agentSessionId, 'open', state.turn !== undefined ? { turn: state.turn } : {})
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new Error(`${errorMessage(error)} ${NOTHING_CHANGED}`)
|
|
113
|
+
}
|
|
114
|
+
state.document = document
|
|
115
|
+
state.wrote = true
|
|
116
|
+
try {
|
|
117
|
+
return await next()
|
|
118
|
+
} finally {
|
|
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)}`))
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
ctx.on('agent/created', ({ agent }: { agent: LiveAgent }) => {
|
|
125
|
+
const header = agent.session.header as { origin?: string, parentSession?: string } | undefined
|
|
126
|
+
if (header?.origin === 'subagent' && header.parentSession) parents.set(agent.session.id, header.parentSession)
|
|
127
|
+
agents.set(agent.session.id, agent)
|
|
128
|
+
for (const notice of pending.get(agent.session.id) ?? []) agent.inject(notice)
|
|
129
|
+
pending.delete(agent.session.id)
|
|
130
|
+
})
|
|
131
|
+
ctx.on('agent/disposed', ({ agent }: { agent: LiveAgent }) => {
|
|
132
|
+
if (agents.get(agent.session.id) === agent) agents.delete(agent.session.id)
|
|
133
|
+
})
|
|
134
|
+
ctx.on('session/disposed', (session: { id: string }) => {
|
|
135
|
+
turnStates.delete(session.id)
|
|
136
|
+
parents.delete(session.id)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const injectRevertNotice = (event: Reverted) => {
|
|
140
|
+
const notice = revertNotice(event)
|
|
141
|
+
const agent = agents.get(event.session)
|
|
142
|
+
if (agent) agent.inject(notice)
|
|
143
|
+
else pending.set(event.session, [...pending.get(event.session) ?? [], notice])
|
|
144
|
+
log.info(`presmium-turns: told session ${event.session} that commit ${event.revision} was undone`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const stop = new AbortController()
|
|
148
|
+
const subscribeReviewEvents = async () => {
|
|
149
|
+
while (!stop.signal.aborted) {
|
|
150
|
+
try {
|
|
151
|
+
const response = await ctx.presmium.server.request(`${REVIEW}/events`, { headers: { accept: 'text/event-stream' }, signal: stop.signal })
|
|
152
|
+
if (!response.ok || !response.body) throw new Error(`review/events answered ${response.status}`)
|
|
153
|
+
const decoder = new TextDecoder()
|
|
154
|
+
let buffer = ''
|
|
155
|
+
for await (const chunk of response.body) {
|
|
156
|
+
buffer += decoder.decode(chunk as Uint8Array, { stream: true })
|
|
157
|
+
let end: number
|
|
158
|
+
while ((end = buffer.indexOf('\n\n')) >= 0) {
|
|
159
|
+
const block = buffer.slice(0, end)
|
|
160
|
+
buffer = buffer.slice(end + 2)
|
|
161
|
+
const data = block.split('\n').filter(line => line.startsWith('data: ')).map(line => line.slice(6)).join('\n')
|
|
162
|
+
if (!data) continue
|
|
163
|
+
const event = JSON.parse(data) as { type?: string }
|
|
164
|
+
if (event.type === 'reverted') injectRevertNotice(event as Reverted)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (stop.signal.aborted) return
|
|
169
|
+
log.debug(`presmium-turns: the review events: ${errorMessage(error)}`)
|
|
170
|
+
}
|
|
171
|
+
await new Promise(done => setTimeout(done, RETRY_MS).unref())
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
void subscribeReviewEvents()
|
|
175
|
+
ctx.effect(() => () => stop.abort())
|
|
176
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
const GRACE_MS = 5000
|
|
5
|
+
|
|
6
|
+
const [command, ...args] = process.argv.slice(2)
|
|
7
|
+
if (!command) {
|
|
8
|
+
process.stderr.write('usage: watchdog.mjs <command> [args...]\n')
|
|
9
|
+
process.exit(2)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const group = (signal) => {
|
|
13
|
+
try {
|
|
14
|
+
process.kill(-process.pid, signal)
|
|
15
|
+
} catch {
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const child = spawn(command, args, { stdio: ['ignore', 'inherit', 'inherit'] })
|
|
20
|
+
let ending = false
|
|
21
|
+
const end = () => {
|
|
22
|
+
if (ending) return
|
|
23
|
+
ending = true
|
|
24
|
+
group('SIGTERM')
|
|
25
|
+
setTimeout(() => group('SIGKILL'), GRACE_MS).unref()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
process.on('SIGTERM', end)
|
|
29
|
+
process.on('SIGINT', end)
|
|
30
|
+
process.stdin.on('end', end).on('close', end).on('error', end)
|
|
31
|
+
process.stdin.resume()
|
|
32
|
+
child.on('error', (error) => {
|
|
33
|
+
process.stderr.write(`watchdog: ${error.message}\n`)
|
|
34
|
+
process.exit(1)
|
|
35
|
+
})
|
|
36
|
+
child.on('exit', () => group('SIGKILL'))
|