@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.
Files changed (68) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +26 -2
  3. package/build/client.mjs +72 -0
  4. package/build/dist/client.js +1390 -0
  5. package/build/dist/client.json +274 -0
  6. package/build/ensure.mjs +39 -0
  7. package/cordis.patch.yml +26 -0
  8. package/entries/context.mjs +7 -0
  9. package/entries/events.mjs +7 -0
  10. package/entries/load.mjs +11 -0
  11. package/entries/policy.mjs +7 -0
  12. package/entries/routes.mjs +7 -0
  13. package/entries/service.mjs +7 -0
  14. package/entries/skills.mjs +7 -0
  15. package/entries/telemetry.mjs +7 -0
  16. package/entries/tools.mjs +7 -0
  17. package/entries/turns.mjs +7 -0
  18. package/entries/workbench.mjs +9 -0
  19. package/entries/workspaces.mjs +7 -0
  20. package/package.json +108 -3
  21. package/profile.patch.yml +25 -0
  22. package/src/client/RootLayout.tsx +332 -0
  23. package/src/client/agent-requests.ts +64 -0
  24. package/src/client/assistant-setup.tsx +185 -0
  25. package/src/client/author-settings.ts +57 -0
  26. package/src/client/brand.tsx +97 -0
  27. package/src/client/contract.ts +82 -0
  28. package/src/client/document-context.ts +39 -0
  29. package/src/client/dsh.d.ts +155 -0
  30. package/src/client/fallback.tsx +53 -0
  31. package/src/client/frame.css +505 -0
  32. package/src/client/index.tsx +157 -0
  33. package/src/client/launch-target.ts +89 -0
  34. package/src/client/layout.ts +230 -0
  35. package/src/client/mascot.tsx +37 -0
  36. package/src/client/messages.ts +171 -0
  37. package/src/client/navigation-shell.tsx +74 -0
  38. package/src/client/observable.ts +16 -0
  39. package/src/client/paths.ts +17 -0
  40. package/src/client/root-reload.ts +163 -0
  41. package/src/client/runtime-diagnostics.tsx +228 -0
  42. package/src/client/settings-opener.ts +26 -0
  43. package/src/client/source-links.tsx +71 -0
  44. package/src/client/speech-settings.tsx +7 -0
  45. package/src/client/styles.css +45 -0
  46. package/src/client/theme.ts +151 -0
  47. package/src/client/workbench-keys.ts +36 -0
  48. package/src/client/workspace-events.ts +114 -0
  49. package/src/node/bindings.ts +345 -0
  50. package/src/node/context.ts +166 -0
  51. package/src/node/dsh-types.ts +124 -0
  52. package/src/node/fake-collector.ts +46 -0
  53. package/src/node/fake-model.ts +80 -0
  54. package/src/node/launch.ts +176 -0
  55. package/src/node/policy.ts +56 -0
  56. package/src/node/routes.ts +56 -0
  57. package/src/node/server-routes.ts +3 -0
  58. package/src/node/service.ts +131 -0
  59. package/src/node/skills.ts +24 -0
  60. package/src/node/telemetry.ts +126 -0
  61. package/src/node/tools.ts +66 -0
  62. package/src/node/turns.ts +176 -0
  63. package/src/node/watchdog.mjs +36 -0
  64. package/src/node/workbench.ts +88 -0
  65. package/src/node/workspace-events.ts +167 -0
  66. package/src/node/workspaces.ts +10 -0
  67. package/src/workspace-events.ts +31 -0
  68. 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,3 @@
1
+ /** Where the workspace server mounts the routes of its workspace plugin and its own routes. */
2
+ export const WORKSPACE_ROUTES = '/__presmium/presmium.workspace/'
3
+ export const SERVER_ROUTES = '/__presmium/presmium.server/'
@@ -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'))