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