@lincyaw/mcp 1.0.0-rc.0 → 1.0.0-rc.2
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 +74 -6
- package/bin/presmium-mcp.mjs +1 -1
- package/package.json +4 -3
- package/src/audio-authoring.ts +91 -0
- package/src/authoring-actions.ts +69 -0
- package/src/authoring-clients.ts +34 -0
- package/src/authoring.ts +157 -0
- package/src/{stdio.ts → cli.ts} +27 -27
- package/src/client.ts +126 -7
- package/src/deck.ts +11 -16
- package/src/diagnostics.ts +101 -0
- package/src/focus.ts +20 -8
- package/src/history.ts +38 -0
- package/src/host.ts +37 -10
- package/src/image-authoring.ts +43 -0
- package/src/index.ts +4 -4
- package/src/mcp.ts +335 -99
- package/src/messages.ts +246 -17
- package/src/projects.ts +18 -0
- package/src/skills.ts +107 -0
- package/src/tool-meta.ts +2 -0
- package/src/transcription.ts +92 -0
- package/src/voice-authoring.ts +93 -0
package/src/{stdio.ts → cli.ts}
RENAMED
|
@@ -2,42 +2,42 @@ import { stat } from 'node:fs/promises'
|
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { parseArgs } from 'node:util'
|
|
5
|
-
import { errorMessage } from '@lincyaw/kernel'
|
|
6
|
-
import type { ApprovalKind
|
|
7
|
-
import {
|
|
5
|
+
import { APPROVAL_KINDS, errorMessage, LOCAL_AUTHOR } from '@lincyaw/kernel'
|
|
6
|
+
import type { ApprovalKind } from '@lincyaw/kernel'
|
|
7
|
+
import { fileVersion } from '@lincyaw/kernel/node'
|
|
8
|
+
import { buildSummary, buildToDist, Compiler, findPackageDir, resolveEntry, runBlock } from '@lincyaw/compiler'
|
|
8
9
|
import type { Compilation } from '@lincyaw/compiler'
|
|
9
10
|
import type { McpHost } from './host.ts'
|
|
10
11
|
import { serveMcpStdio } from './mcp.ts'
|
|
12
|
+
import { MCP_CLI_MESSAGES, systemLocale } from './messages.ts'
|
|
11
13
|
|
|
12
|
-
const AUTHOR: Member = { id: 'local', name: 'local author', roles: ['owner'] }
|
|
13
|
-
const KINDS: readonly ApprovalKind[] = ['operate', 'spend', 'write', 'agent']
|
|
14
14
|
const OWN_DIR = fileURLToPath(new URL('..', import.meta.url))
|
|
15
15
|
const CLI_PACKAGE = '@lincyaw/cli'
|
|
16
16
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
Serves the deck (a directory holding lecture.md, or an entry file; default: .)
|
|
20
|
-
as MCP tools and resources on stdio, for the agent of an editor.
|
|
21
|
-
|
|
22
|
-
--allow <kinds> offer and allow these operations: operate (run_code, build), spend
|
|
23
|
-
-h, --help show this help`
|
|
17
|
+
const t = MCP_CLI_MESSAGES[systemLocale()]
|
|
24
18
|
|
|
25
19
|
async function stamps(files: string[]): Promise<string> {
|
|
26
|
-
const times = await Promise.all(files.map(file => stat(file).then(
|
|
20
|
+
const times = await Promise.all(files.map(file => stat(file, { bigint: true }).then(fileVersion, () => 'missing')))
|
|
27
21
|
return times.join(',')
|
|
28
22
|
}
|
|
29
23
|
|
|
30
|
-
export function
|
|
24
|
+
export function compilerMcpHost(entry: string, allow: readonly ApprovalKind[]): McpHost {
|
|
31
25
|
const bundled = findPackageDir(CLI_PACKAGE, dirname(entry)) ?? findPackageDir(CLI_PACKAGE, OWN_DIR) ?? OWN_DIR
|
|
32
26
|
const compiler = new Compiler(entry, { bundled })
|
|
27
|
+
let inputs = [entry]
|
|
33
28
|
let latest: { compilation: Compilation, files: string[], stamp: string } | undefined
|
|
34
29
|
let queue = Promise.resolve()
|
|
35
30
|
const current = () => {
|
|
36
31
|
const next = queue.then(async () => {
|
|
37
32
|
if (latest && await stamps(latest.files) === latest.stamp) return latest.compilation
|
|
33
|
+
const before = await stamps(inputs)
|
|
38
34
|
const compilation = await compiler.compile()
|
|
39
|
-
const files = [...compilation.
|
|
40
|
-
|
|
35
|
+
const files = [...new Set([...compilation.sourceFiles, ...compilation.inputs.files])]
|
|
36
|
+
const stamp = await stamps(files)
|
|
37
|
+
const known = new Set(inputs)
|
|
38
|
+
const stable = files.length === known.size && files.every(file => known.has(file)) && await stamps(inputs) === before
|
|
39
|
+
inputs = files
|
|
40
|
+
latest = stable ? { compilation, files, stamp } : undefined
|
|
41
41
|
return compilation
|
|
42
42
|
})
|
|
43
43
|
queue = next.then(() => undefined, () => undefined)
|
|
@@ -50,6 +50,7 @@ export function compilerHost(entry: string, allow: readonly ApprovalKind[]): Mcp
|
|
|
50
50
|
return {
|
|
51
51
|
process: { command: 'presmium-mcp' },
|
|
52
52
|
async documents() {
|
|
53
|
+
if (!await stat(entry).then(info => info.isFile(), () => false)) return []
|
|
53
54
|
const { doc } = await current()
|
|
54
55
|
return [{ dir: '', title: doc.meta.title, lang: doc.meta.lang }]
|
|
55
56
|
},
|
|
@@ -62,13 +63,13 @@ export function compilerHost(entry: string, allow: readonly ApprovalKind[]): Mcp
|
|
|
62
63
|
return [...compilation.doc.diagnostics, ...await compilation.check()]
|
|
63
64
|
},
|
|
64
65
|
async build(document, options) {
|
|
65
|
-
|
|
66
|
-
return { outputs: report.outputs.map(({ renderer, outDir }) => ({ renderer, outDir })), findings: report.findings }
|
|
66
|
+
return buildSummary(await buildToDist(await only(document), { to: options.to }))
|
|
67
67
|
},
|
|
68
68
|
run: async (document, target, signal) => runBlock(await only(document), target, signal),
|
|
69
69
|
approve: async request => allow.includes(request.kind),
|
|
70
70
|
offers: kind => allow.includes(kind),
|
|
71
71
|
async tools() {
|
|
72
|
+
if (!await stat(entry).then(info => info.isFile(), () => false)) return { tools: [], service: () => undefined }
|
|
72
73
|
const compilation = await current()
|
|
73
74
|
return { tools: compilation.plugins.flatMap(loaded => loaded.plugin?.tools ?? []), service: compilation.service }
|
|
74
75
|
},
|
|
@@ -81,21 +82,20 @@ export async function main(argv: string[]): Promise<number | undefined> {
|
|
|
81
82
|
try {
|
|
82
83
|
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { allow: { type: 'string' }, help: { type: 'boolean', short: 'h' } } })
|
|
83
84
|
if (values.help) {
|
|
84
|
-
process.stdout.write(`${
|
|
85
|
+
process.stdout.write(`${t.usage}\n`)
|
|
85
86
|
return 0
|
|
86
87
|
}
|
|
87
|
-
if (positionals.length > 1) throw new Error(
|
|
88
|
+
if (positionals.length > 1) throw new Error(t.unexpectedArgument(positionals[1]!))
|
|
88
89
|
deck = resolveEntry(positionals[0] ?? '.')
|
|
89
90
|
allow = (values.allow?.split(',').map(kind => kind.trim()).filter(Boolean) ?? []) as ApprovalKind[]
|
|
90
|
-
const unknown = allow.find(kind => !
|
|
91
|
-
if (unknown) throw new Error(
|
|
92
|
-
await stat(deck).
|
|
93
|
-
|
|
94
|
-
})
|
|
91
|
+
const unknown = allow.find(kind => !APPROVAL_KINDS.includes(kind))
|
|
92
|
+
if (unknown) throw new Error(t.badAllow(APPROVAL_KINDS, unknown))
|
|
93
|
+
const exists = await stat(deck).then(info => info.isFile(), () => false)
|
|
94
|
+
if (!exists && !await stat(positionals[0] ?? '.').then(info => info.isDirectory(), () => false)) throw new Error(t.notFound(deck))
|
|
95
95
|
} catch (error) {
|
|
96
|
-
process.stderr.write(`presmium-mcp: ${errorMessage(error)}\n\n${
|
|
96
|
+
process.stderr.write(`presmium-mcp: ${errorMessage(error)}\n\n${t.usage}\n`)
|
|
97
97
|
return 2
|
|
98
98
|
}
|
|
99
|
-
serveMcpStdio(
|
|
99
|
+
serveMcpStdio(compilerMcpHost(deck, allow), { member: LOCAL_AUTHOR, mode: 'author', document: '' })
|
|
100
100
|
return undefined
|
|
101
101
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
|
2
|
-
import { deckLocale, isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import { APPROVAL_KINDS, deckLocale, errorMessage, isRecord } from '@lincyaw/kernel'
|
|
3
3
|
import type { ApprovalKind } from '@lincyaw/kernel'
|
|
4
4
|
import { MCP_MESSAGES } from './messages.ts'
|
|
5
|
+
import { TOOL_APPROVAL_META, TOOL_KIND_META } from './tool-meta.ts'
|
|
5
6
|
|
|
6
7
|
export interface ListedTool {
|
|
7
8
|
name: string
|
|
@@ -18,33 +19,129 @@ export interface CalledTool {
|
|
|
18
19
|
|
|
19
20
|
export interface PresmiumMcpClient {
|
|
20
21
|
tools(): Promise<ListedTool[]>
|
|
22
|
+
subscribeTools(changed: () => void): Promise<() => Promise<void>>
|
|
21
23
|
call(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<CalledTool>
|
|
22
24
|
read(uri: string): Promise<unknown>
|
|
25
|
+
subscribe(uri: string, changed: () => void): Promise<() => Promise<void>>
|
|
23
26
|
close(): Promise<void>
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
const KINDS = new Set(['read',
|
|
29
|
+
const KINDS = new Set<string>(['read', ...APPROVAL_KINDS])
|
|
27
30
|
const CALL_TIMEOUT_MS = 10 * 60_000
|
|
28
31
|
|
|
29
32
|
export async function connectPresmiumMcp(url: string, token: string, name = 'presmium-dsh'): Promise<PresmiumMcpClient> {
|
|
30
33
|
const client = new Client({ name, version: '0.1.0' }, { versionNegotiation: { mode: 'auto' } })
|
|
31
34
|
await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { Authorization: `Bearer ${token}` } } }))
|
|
35
|
+
type Subscription = Awaited<ReturnType<Client['listen']>>
|
|
36
|
+
interface SubscriptionEntry {
|
|
37
|
+
key: string | symbol
|
|
38
|
+
label: string
|
|
39
|
+
filter: Parameters<Client['listen']>[0]
|
|
40
|
+
listeners: Set<() => void>
|
|
41
|
+
controller: AbortController
|
|
42
|
+
subscription?: Subscription
|
|
43
|
+
opening?: Promise<void>
|
|
44
|
+
queued: boolean
|
|
45
|
+
}
|
|
46
|
+
const toolsKey = Symbol('tools')
|
|
47
|
+
const subscriptions = new Map<string | symbol, SubscriptionEntry>()
|
|
48
|
+
let closed = false
|
|
49
|
+
const active = (entry: SubscriptionEntry) => !closed && subscriptions.get(entry.key) === entry && entry.listeners.size > 0
|
|
50
|
+
const changed = (entry: SubscriptionEntry) => {
|
|
51
|
+
if (!active(entry) || entry.queued) return
|
|
52
|
+
entry.queued = true
|
|
53
|
+
queueMicrotask(() => {
|
|
54
|
+
entry.queued = false
|
|
55
|
+
if (active(entry)) for (const listener of entry.listeners) listener()
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
const open = (entry: SubscriptionEntry): Promise<void> => {
|
|
59
|
+
if (entry.subscription) return Promise.resolve()
|
|
60
|
+
if (entry.opening) return entry.opening
|
|
61
|
+
const opening = client.listen(entry.filter, { signal: entry.controller.signal }).then(async (subscription) => {
|
|
62
|
+
if (!active(entry)) {
|
|
63
|
+
await subscription.close()
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
entry.subscription = subscription
|
|
67
|
+
void subscription.closed.then(async (reason) => {
|
|
68
|
+
if (entry.subscription !== subscription) return
|
|
69
|
+
entry.subscription = undefined
|
|
70
|
+
if (reason === 'local' || !active(entry)) return
|
|
71
|
+
changed(entry)
|
|
72
|
+
if (reason !== 'remote') return
|
|
73
|
+
await entry.opening
|
|
74
|
+
if (!active(entry)) return
|
|
75
|
+
try {
|
|
76
|
+
await open(entry)
|
|
77
|
+
changed(entry)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (!active(entry)) return
|
|
80
|
+
console.error(`[presmium] the subscription to ${entry.label} did not reconnect: ${errorMessage(error)}`)
|
|
81
|
+
changed(entry)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}).finally(() => {
|
|
85
|
+
if (entry.opening === opening) entry.opening = undefined
|
|
86
|
+
})
|
|
87
|
+
entry.opening = opening
|
|
88
|
+
return opening
|
|
89
|
+
}
|
|
90
|
+
client.setNotificationHandler('notifications/resources/updated', (notification) => {
|
|
91
|
+
const entry = subscriptions.get(notification.params.uri)
|
|
92
|
+
if (entry) changed(entry)
|
|
93
|
+
})
|
|
94
|
+
client.setNotificationHandler('notifications/tools/list_changed', () => {
|
|
95
|
+
const entry = subscriptions.get(toolsKey)
|
|
96
|
+
if (entry) changed(entry)
|
|
97
|
+
})
|
|
98
|
+
client.onclose = () => { for (const entry of subscriptions.values()) changed(entry) }
|
|
99
|
+
const subscribe = async (key: string | symbol, label: string, filter: SubscriptionEntry['filter'], onChanged: () => void) => {
|
|
100
|
+
let entry = subscriptions.get(key)
|
|
101
|
+
if (!entry) {
|
|
102
|
+
entry = { key, label, filter, listeners: new Set(), controller: new AbortController(), queued: false }
|
|
103
|
+
subscriptions.set(key, entry)
|
|
104
|
+
}
|
|
105
|
+
const own = entry
|
|
106
|
+
const listener = () => onChanged()
|
|
107
|
+
own.listeners.add(listener)
|
|
108
|
+
const stop = async () => {
|
|
109
|
+
own.listeners.delete(listener)
|
|
110
|
+
if (own.listeners.size > 0 || subscriptions.get(key) !== own) return
|
|
111
|
+
subscriptions.delete(key)
|
|
112
|
+
own.controller.abort()
|
|
113
|
+
await own.subscription?.close()
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await open(own)
|
|
117
|
+
} catch (error) {
|
|
118
|
+
await stop()
|
|
119
|
+
throw error
|
|
120
|
+
}
|
|
121
|
+
return stop
|
|
122
|
+
}
|
|
32
123
|
return {
|
|
33
124
|
async tools() {
|
|
34
125
|
const listed: ListedTool[] = []
|
|
35
126
|
let cursor: string | undefined
|
|
36
127
|
do {
|
|
37
|
-
const page = await client.listTools(cursor ? { cursor } : undefined)
|
|
128
|
+
const page = await client.listTools(cursor ? { cursor } : undefined, { cacheMode: 'refresh' })
|
|
38
129
|
for (const tool of page.tools) {
|
|
39
130
|
const meta = isRecord(tool._meta) ? tool._meta : {}
|
|
40
|
-
const kind = typeof meta[
|
|
41
|
-
const approval = meta[
|
|
131
|
+
const kind = typeof meta[TOOL_KIND_META] === 'string' && KINDS.has(meta[TOOL_KIND_META]) ? meta[TOOL_KIND_META] as ListedTool['kind'] : (tool.annotations?.readOnlyHint ? 'read' : 'operate')
|
|
132
|
+
const approval = meta[TOOL_APPROVAL_META] === 'allow' ? 'allow' : meta[TOOL_APPROVAL_META] === 'ask' ? 'ask' : undefined
|
|
42
133
|
listed.push({ name: tool.name, description: tool.description ?? tool.name, inputSchema: tool.inputSchema as Record<string, unknown>, kind, ...approval ? { approval } : {} })
|
|
43
134
|
}
|
|
44
135
|
cursor = page.nextCursor
|
|
45
136
|
} while (cursor)
|
|
46
137
|
return listed
|
|
47
138
|
},
|
|
139
|
+
async subscribeTools(onChanged) {
|
|
140
|
+
if (closed) throw new Error('the MCP client is closed')
|
|
141
|
+
if (client.getProtocolEra() !== 'modern') throw new Error('live tool subscriptions need MCP 2026-07-28 or later')
|
|
142
|
+
if (!client.getServerCapabilities()?.tools?.listChanged) throw new Error('this MCP server does not offer tool-list subscriptions')
|
|
143
|
+
return subscribe(toolsKey, 'tools', { toolsListChanged: true }, onChanged)
|
|
144
|
+
},
|
|
48
145
|
async call(tool, args, signal) {
|
|
49
146
|
const result = await client.callTool({ name: tool, arguments: args }, { signal, timeout: CALL_TIMEOUT_MS })
|
|
50
147
|
const content = Array.isArray(result.content) ? result.content as { type: string, text?: string }[] : []
|
|
@@ -59,7 +156,23 @@ export async function connectPresmiumMcp(url: string, token: string, name = 'pre
|
|
|
59
156
|
return text
|
|
60
157
|
}
|
|
61
158
|
},
|
|
62
|
-
|
|
159
|
+
async subscribe(uri, onChanged) {
|
|
160
|
+
if (closed) throw new Error('the MCP client is closed')
|
|
161
|
+
if (client.getProtocolEra() !== 'modern') throw new Error('live resource subscriptions need MCP 2026-07-28 or later')
|
|
162
|
+
if (!client.getServerCapabilities()?.resources?.subscribe) throw new Error('this MCP server does not offer resource subscriptions')
|
|
163
|
+
return subscribe(uri, uri, { resourceSubscriptions: [uri] }, onChanged)
|
|
164
|
+
},
|
|
165
|
+
async close() {
|
|
166
|
+
closed = true
|
|
167
|
+
const entries = [...subscriptions.values()]
|
|
168
|
+
subscriptions.clear()
|
|
169
|
+
for (const entry of entries) entry.controller.abort()
|
|
170
|
+
try {
|
|
171
|
+
await Promise.all(entries.map(entry => entry.subscription?.close()))
|
|
172
|
+
} finally {
|
|
173
|
+
await client.close()
|
|
174
|
+
}
|
|
175
|
+
},
|
|
63
176
|
}
|
|
64
177
|
}
|
|
65
178
|
|
|
@@ -69,7 +182,13 @@ export function describeOperation(tool: string, args: Record<string, unknown>, l
|
|
|
69
182
|
switch (tool) {
|
|
70
183
|
case 'navigate': return m.navigate(text(args.section), typeof args.step === 'number' ? args.step : 0)
|
|
71
184
|
case 'run_code': return m.run(text(args.section), text(args.block))
|
|
72
|
-
case 'build': return m.build(Array.isArray(args.to)
|
|
185
|
+
case 'build': return m.build(m.renderers(Array.isArray(args.to) ? args.to.map(text) : []))
|
|
186
|
+
case 'apply_document_change': return m.applyDocumentChange
|
|
187
|
+
case 'apply_page_duplication': return m.duplicatePage
|
|
188
|
+
case 'apply_page_change': return m.applyPagePlan
|
|
189
|
+
case 'start_transcription': return m.startTranscription
|
|
190
|
+
case 'cancel_transcription': return m.cancelTranscription
|
|
191
|
+
case 'apply_transcription_adoption': return m.adoptTranscription
|
|
73
192
|
case 'classroom_start': return m.classroomStart(text(args.name))
|
|
74
193
|
case 'classroom_end': return m.classroomEnd
|
|
75
194
|
default: return m.tool(tool)
|
package/src/deck.ts
CHANGED
|
@@ -3,13 +3,8 @@ import { blockState, blockText, blockVisible, chapters, irBlocks, narrationAt, t
|
|
|
3
3
|
import type { CodeNode, Finding, IRBlock, IRDocument, IRSection, RecordedRun, RunOutput, RunResult, SourcePos } from '@lincyaw/kernel'
|
|
4
4
|
import MiniSearch from 'minisearch'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
line: number
|
|
9
|
-
endLine?: number
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function place(doc: IRDocument, pos: SourcePos): Place {
|
|
6
|
+
/** The source position with its file relative to the deck directory. */
|
|
7
|
+
export function relativeToDeck(doc: IRDocument, pos: SourcePos): SourcePos {
|
|
13
8
|
return { file: relative(dirname(doc.meta.entry), pos.file) || pos.file, line: pos.line, ...pos.endLine ? { endLine: pos.endLine } : {} }
|
|
14
9
|
}
|
|
15
10
|
|
|
@@ -32,7 +27,7 @@ export function sectionSummary(doc: IRDocument, section: IRSection) {
|
|
|
32
27
|
title: section.title,
|
|
33
28
|
depth: section.depth,
|
|
34
29
|
steps: section.steps,
|
|
35
|
-
source:
|
|
30
|
+
source: relativeToDeck(doc, section.source),
|
|
36
31
|
...section.audience ? { audience: section.audience } : {},
|
|
37
32
|
}
|
|
38
33
|
}
|
|
@@ -43,7 +38,7 @@ export function deckInfo(doc: IRDocument, document: string) {
|
|
|
43
38
|
document,
|
|
44
39
|
title: doc.meta.title,
|
|
45
40
|
lang: doc.meta.lang,
|
|
46
|
-
entry:
|
|
41
|
+
entry: relativeToDeck(doc, { file: doc.meta.entry, line: 1 }).file,
|
|
47
42
|
sections: doc.sections.length,
|
|
48
43
|
steps: doc.sections.reduce((sum, s) => sum + s.steps + 1, 0),
|
|
49
44
|
plugins: doc.config.plugins,
|
|
@@ -70,7 +65,7 @@ export function blockInfo(doc: IRDocument, block: IRBlock, step?: number) {
|
|
|
70
65
|
...block.span ? { span: block.span } : {},
|
|
71
66
|
...block.only ? { only: block.only } : {},
|
|
72
67
|
...block.audience ? { audience: block.audience } : {},
|
|
73
|
-
source:
|
|
68
|
+
source: relativeToDeck(doc, block.source),
|
|
74
69
|
}
|
|
75
70
|
if (node.type !== 'code') return { ...base, text: blockText(block) }
|
|
76
71
|
const variant = state !== undefined ? node.variants?.[state] : undefined
|
|
@@ -121,7 +116,7 @@ export function codeRef(doc: IRDocument, section: IRSection, id: string) {
|
|
|
121
116
|
lang: node.lang,
|
|
122
117
|
...node.title ? { title: node.title } : {},
|
|
123
118
|
code: node.value,
|
|
124
|
-
source:
|
|
119
|
+
source: relativeToDeck(doc, block.source),
|
|
125
120
|
origin: origin(node) ?? null,
|
|
126
121
|
...node.variants ? { variants: node.variants.map(v => ({ label: v.label, ...v.startLine ? { startLine: v.startLine, endLine: v.endLine } : {} })) } : {},
|
|
127
122
|
...node.highlight ? { highlight: node.highlight } : {},
|
|
@@ -164,7 +159,7 @@ export function diagnosticsOf(doc: IRDocument, findings: Finding[]) {
|
|
|
164
159
|
severity: f.severity,
|
|
165
160
|
check: f.checkId,
|
|
166
161
|
message: f.message,
|
|
167
|
-
...f.source ?
|
|
162
|
+
...f.source ? relativeToDeck(doc, { file: f.source.file, line: f.source.line ?? 1 }) : {},
|
|
168
163
|
...f.hint ? { hint: f.hint } : {},
|
|
169
164
|
}))
|
|
170
165
|
}
|
|
@@ -173,18 +168,18 @@ export function locate(doc: IRDocument, target: { section?: string, step?: numbe
|
|
|
173
168
|
if (target.anchor) {
|
|
174
169
|
const found = doc.sections.flatMap(section => [...irBlocks(section.blocks)]
|
|
175
170
|
.filter(b => b.node.type === 'code' && b.node.origin?.anchor === target.anchor)
|
|
176
|
-
.map(b => ({ section: section.id, block: b.id, source:
|
|
171
|
+
.map(b => ({ section: section.id, block: b.id, source: relativeToDeck(doc, b.source), code: origin(b.node as CodeNode) })))
|
|
177
172
|
if (found.length === 0) throw new Error(`no code block references ${target.anchor}`)
|
|
178
173
|
return { anchor: target.anchor, references: found }
|
|
179
174
|
}
|
|
180
175
|
if (!target.section) throw new Error('name a section, or an anchor')
|
|
181
176
|
const section = findSection(doc, target.section)
|
|
182
|
-
if (target.block) return { section: section.id, block: target.block, source:
|
|
177
|
+
if (target.block) return { section: section.id, block: target.block, source: relativeToDeck(doc, findBlock(section, target.block).source) }
|
|
183
178
|
if (target.step !== undefined) {
|
|
184
179
|
const blocks = section.blocks.filter(b => b.at === target.step)
|
|
185
|
-
return { section: section.id, step: target.step, blocks: blocks.map(b => ({ block: b.id, role: b.role, source:
|
|
180
|
+
return { section: section.id, step: target.step, blocks: blocks.map(b => ({ block: b.id, role: b.role, source: relativeToDeck(doc, b.source) })) }
|
|
186
181
|
}
|
|
187
|
-
return { section: section.id, source:
|
|
182
|
+
return { section: section.id, source: relativeToDeck(doc, section.source) }
|
|
188
183
|
}
|
|
189
184
|
|
|
190
185
|
const indexes = new WeakMap<IRDocument, MiniSearch>()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { join, sep } from 'node:path'
|
|
2
|
+
import { diagnosticOf, fnv1a64 } from '@lincyaw/kernel'
|
|
3
|
+
import { irRevision } from '@lincyaw/kernel/node'
|
|
4
|
+
import type { DiagnosticSnapshot, Finding, IRDocument } from '@lincyaw/kernel'
|
|
5
|
+
import type { McpHost } from './host.ts'
|
|
6
|
+
import type { Call, ToolSpec } from './mcp.ts'
|
|
7
|
+
import { relativeToDeck } from './deck.ts'
|
|
8
|
+
|
|
9
|
+
const RESPONSE_LIMIT = 7168
|
|
10
|
+
const REQUEST_LIMIT = 2048
|
|
11
|
+
const snapshots = new WeakMap<McpHost, Map<string, { compilation: string, snapshot: DiagnosticSnapshot }>>()
|
|
12
|
+
const fail = (status: number, message: string): never => { throw Object.assign(new Error(message), { status }) }
|
|
13
|
+
const fits = (value: unknown) => JSON.stringify(value, null, 2).length <= RESPONSE_LIMIT
|
|
14
|
+
const boundary = (text: string, offset: number) => !(offset > 0 && offset < text.length && /[\uD800-\uDBFF]/.test(text[offset - 1]!) && /[\uDC00-\uDFFF]/.test(text[offset]!))
|
|
15
|
+
|
|
16
|
+
function remember(host: McpHost, document: string, doc: IRDocument, findings: Finding[], origin: 'compile' | 'check'): DiagnosticSnapshot {
|
|
17
|
+
let documents = snapshots.get(host)
|
|
18
|
+
if (!documents) snapshots.set(host, documents = new Map())
|
|
19
|
+
const previous = documents.get(document), compilation = irRevision(doc)
|
|
20
|
+
const diagnostics = findings.map(finding => diagnosticOf(document, origin, {
|
|
21
|
+
...finding,
|
|
22
|
+
...finding.source ? { source: { ...finding.source, file: join(document, relativeToDeck(doc, { ...finding.source, line: finding.source.line ?? 1 }).file).split(sep).join('/') } } : {},
|
|
23
|
+
}))
|
|
24
|
+
const same = previous?.compilation === compilation && JSON.stringify(previous.snapshot.diagnostics) === JSON.stringify(diagnostics)
|
|
25
|
+
const snapshot = same ? previous.snapshot : { version: (previous?.snapshot.version ?? 0) + 1, diagnostics }
|
|
26
|
+
documents.set(document, { compilation, snapshot })
|
|
27
|
+
return snapshot
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Legacy compiler-only resources can compile; passive tools only read actual observations. */
|
|
31
|
+
export async function diagnosticSnapshot(call: Call): Promise<DiagnosticSnapshot> {
|
|
32
|
+
const document = await call.document()
|
|
33
|
+
if (call.host.diagnostics) return call.host.diagnostics(document)
|
|
34
|
+
const { doc, diagnostics } = await call.compiled(), previous = snapshots.get(call.host)?.get(document)
|
|
35
|
+
if (previous?.compilation === irRevision(doc)) return previous.snapshot
|
|
36
|
+
return remember(call.host, document, doc, diagnostics, 'compile')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function query(call: Call) {
|
|
40
|
+
if (JSON.stringify(call.args).length > REQUEST_LIMIT) return fail(413, 'diagnostic-read-request-too-large')
|
|
41
|
+
if (Object.keys(call.args).some(key => !['document', 'version', 'digest', 'offset'].includes(key))) return fail(400, 'diagnostic-read-query-invalid')
|
|
42
|
+
const { version, digest, offset = 0 } = call.args
|
|
43
|
+
if (call.args.document !== undefined && (typeof call.args.document !== 'string' || call.args.document.length > 200)) return fail(400, 'diagnostic-read-document-invalid')
|
|
44
|
+
if (version !== undefined && (!Number.isSafeInteger(version) || Number(version) < 0)) return fail(400, 'diagnostic-read-version-invalid')
|
|
45
|
+
if (digest !== undefined && (typeof digest !== 'string' || !/^[a-f\d]{16}$/.test(digest))) return fail(400, 'diagnostic-read-digest-invalid')
|
|
46
|
+
if (!Number.isSafeInteger(offset) || Number(offset) < 0) return fail(400, 'diagnostic-read-offset-invalid')
|
|
47
|
+
if (Number(offset) > 0 && (version === undefined || digest === undefined)) return fail(400, 'diagnostic-read-continuation-required')
|
|
48
|
+
return { version, digest, offset: Number(offset) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function read(call: Call) {
|
|
52
|
+
const asked = query(call), document = await call.document()
|
|
53
|
+
const snapshot = call.host.diagnostics ? await call.host.diagnostics(document) : snapshots.get(call.host)?.get(document)?.snapshot
|
|
54
|
+
if (!snapshot) return { document, available: false, reason: 'snapshot-unavailable', verification: 'unobserved', next: 'check_document' }
|
|
55
|
+
const text = JSON.stringify(snapshot), digest = fnv1a64(text)
|
|
56
|
+
if (asked.version !== undefined && asked.version !== snapshot.version || asked.digest !== undefined && asked.digest !== digest) return fail(409, 'diagnostic-snapshot-changed')
|
|
57
|
+
if (asked.offset > text.length || !boundary(text, asked.offset)) return fail(400, 'diagnostic-read-offset-invalid')
|
|
58
|
+
const base = { document, available: true, version: snapshot.version, digest, verification: 'unobserved' as const }
|
|
59
|
+
const whole = { ...base, encoding: 'value', complete: true, value: snapshot }
|
|
60
|
+
if (asked.offset === 0 && fits(whole)) return whole
|
|
61
|
+
const fragment = (end: number) => ({ ...base, encoding: 'json-text-fragment', offsetUnit: 'utf16', offset: asked.offset,
|
|
62
|
+
...end < text.length ? { nextOffset: end } : {}, totalChars: text.length, complete: end === text.length, text: text.slice(asked.offset, end) })
|
|
63
|
+
let low = asked.offset, high = text.length
|
|
64
|
+
while (low < high) { const mid = Math.ceil((low + high) / 2); if (fits(fragment(mid))) low = mid; else high = mid - 1 }
|
|
65
|
+
if (!boundary(text, low)) low--
|
|
66
|
+
if (low === asked.offset && low < text.length || !fits(fragment(low))) return fail(413, 'diagnostic-read-response-too-large')
|
|
67
|
+
return fragment(low)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function check(call: Call) {
|
|
71
|
+
if (Object.keys(call.args).some(key => key !== 'document') || call.args.document !== undefined && (typeof call.args.document !== 'string' || call.args.document.length > 200)) return fail(400, 'diagnostic-check-query-invalid')
|
|
72
|
+
const document = await call.document()
|
|
73
|
+
// Hosts with a real diagnostic producer publish their own check contribution.
|
|
74
|
+
const compiled = call.host.diagnostics ? undefined : await call.compiled()
|
|
75
|
+
const findings = await call.host.check(document)
|
|
76
|
+
call.signal.throwIfAborted()
|
|
77
|
+
const snapshot = call.host.diagnostics ? await call.host.diagnostics(document) : remember(call.host, document, compiled!.doc, findings, 'check')
|
|
78
|
+
return { document, completed: true, findings: { total: findings.length,
|
|
79
|
+
errors: findings.filter(finding => finding.severity === 'error').length,
|
|
80
|
+
warnings: findings.filter(finding => finding.severity === 'warning').length,
|
|
81
|
+
infos: findings.filter(finding => finding.severity === 'info').length },
|
|
82
|
+
snapshot: { version: snapshot.version, digest: fnv1a64(JSON.stringify(snapshot)) },
|
|
83
|
+
verification: { currentSource: 'unverified', browser: 'unobserved' }, next: 'get_diagnostics' }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const DIAGNOSTIC_TOOLS: ToolSpec[] = [
|
|
87
|
+
{
|
|
88
|
+
name: 'get_diagnostics', kind: 'read', structured: true,
|
|
89
|
+
description: 'Reads the same current diagnostic snapshot as the workbench, without compiling or running plugin checks. Entries retain their origins and reported revisions; an empty snapshot does not verify current source or browser health. Replies fit 7168 pretty-JSON UTF-16 characters. encoding:value contains the complete snapshot; json-text-fragment requires concatenating decoded text in nextOffset order then JSON.parse once. Continue with the returned version AND digest; changed snapshots return 409. Digest is fnv1a64 of JSON.stringify(snapshot). No snapshot producer or prior observation returns unavailable. Use check_document only when explicitly requesting compilation and plugin checks.',
|
|
90
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: {
|
|
91
|
+
document: { type: 'string', maxLength: 200 }, version: { type: 'integer', minimum: 0 }, digest: { type: 'string', pattern: '^[a-f\\d]{16}$' }, offset: { type: 'integer', minimum: 0 },
|
|
92
|
+
} },
|
|
93
|
+
run: read,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'check_document', kind: 'read', structured: true,
|
|
97
|
+
description: 'Explicitly runs document compilation and plugin checks. Returns bounded counts and the actual diagnostic snapshot version/digest; read full observations with get_diagnostics. It does not synthesize speech, export, play or prove a browser rendered the current source. Checks can execute project plugin code and may take time; do not use this tool merely to read current problems. Current-source verification is not established by these counts.',
|
|
98
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: { document: { type: 'string', maxLength: 200 } } },
|
|
99
|
+
run: check,
|
|
100
|
+
},
|
|
101
|
+
]
|
package/src/focus.ts
CHANGED
|
@@ -2,34 +2,38 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { dirname, isAbsolute, join, normalize, sep } from 'node:path'
|
|
3
3
|
import { docBlocks, isRecord } from '@lincyaw/kernel'
|
|
4
4
|
import type { Focus, FocusReport, IRDocument, SourceRange } from '@lincyaw/kernel'
|
|
5
|
-
import { findBlock, findSection,
|
|
5
|
+
import { findBlock, findSection, relativeToDeck } from './deck.ts'
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const EXCERPT_LINES = 20
|
|
8
8
|
const QUOTE_CHARS = 2000
|
|
9
|
+
const WORKSPACE_EXCERPT_CHARS = 8000
|
|
9
10
|
const FROM = new Set<Focus['from']>(['source', 'web', 'slides'])
|
|
10
11
|
|
|
11
12
|
const str = (value: unknown, max = 200) => (typeof value === 'string' && value !== '' && value.length <= max ? value : undefined)
|
|
12
13
|
const line = (value: unknown) => (typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined)
|
|
13
14
|
|
|
14
|
-
export function
|
|
15
|
+
export function toFocusReport(value: unknown): FocusReport | string {
|
|
15
16
|
if (!isRecord(value)) return 'a focus is an object'
|
|
16
17
|
const id = str(value.id)
|
|
17
18
|
const from = value.from as Focus['from']
|
|
18
19
|
if (!id) return 'a focus has an id'
|
|
19
20
|
if (!FROM.has(from)) return 'a focus is from source, web or slides'
|
|
21
|
+
if (value.scope !== undefined && value.scope !== 'document' && value.scope !== 'workspace') return 'a focus scope is document or workspace'
|
|
20
22
|
const source = isRecord(value.source) ? value.source : undefined
|
|
21
23
|
const range = source && str(source.file, 1000) && line(source.startLine) ? { file: source.file as string, startLine: source.startLine as number, endLine: line(source.endLine) ?? source.startLine as number } : undefined
|
|
22
24
|
if (source && !range) return 'a focus\'s source is { file, startLine, endLine }'
|
|
23
25
|
const report: FocusReport = { id, from }
|
|
26
|
+
if (value.scope === 'workspace' || value.scope === 'document') report.scope = value.scope
|
|
24
27
|
for (const key of ['section', 'block'] as const) if (str(value[key])) report[key] = value[key] as string
|
|
25
28
|
if (typeof value.step === 'number' && Number.isInteger(value.step) && value.step >= 0) report.step = value.step
|
|
26
29
|
if (typeof value.quote === 'string' && value.quote.trim()) report.quote = value.quote.slice(0, QUOTE_CHARS)
|
|
27
30
|
if (range) report.source = range
|
|
28
31
|
if (!report.section && !report.block && !report.source) return 'a focus names a section, a block or a range of the source'
|
|
32
|
+
if (report.scope === 'workspace' && (report.from !== 'source' || !report.source || report.section || report.block || report.step !== undefined)) return 'a workspace focus is a source range without a section, block or step'
|
|
29
33
|
return report
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
function excerptOf(text: string, start: number, end: number): string {
|
|
33
37
|
const lines = text.split(/\r?\n/).slice(start - 1, end)
|
|
34
38
|
if (lines.length <= EXCERPT_LINES) return lines.join('\n')
|
|
35
39
|
const half = EXCERPT_LINES / 2
|
|
@@ -39,21 +43,29 @@ export function excerptOf(text: string, start: number, end: number): string {
|
|
|
39
43
|
function blockAt(doc: IRDocument, range: SourceRange): { section: string, block?: string } | undefined {
|
|
40
44
|
let found: { section: string, block: string, span: number } | undefined
|
|
41
45
|
for (const { section, block } of docBlocks(doc)) {
|
|
42
|
-
const at =
|
|
46
|
+
const at = relativeToDeck(doc, block.source)
|
|
43
47
|
const end = at.endLine ?? at.line
|
|
44
48
|
if (at.file !== range.file || range.startLine < at.line || range.startLine > end) continue
|
|
45
49
|
if (!found || end - at.line < found.span) found = { section: section.id, block: block.id, span: end - at.line }
|
|
46
50
|
}
|
|
47
51
|
if (found) return { section: found.section, block: found.block }
|
|
48
52
|
const sections = doc.sections.filter((s) => {
|
|
49
|
-
const at =
|
|
53
|
+
const at = relativeToDeck(doc, s.source)
|
|
50
54
|
return at.file === range.file && at.line <= range.startLine
|
|
51
55
|
})
|
|
52
56
|
const section = sections.at(-1)
|
|
53
57
|
return section && { section: section.id }
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
export function resolveFocus(doc: IRDocument, document: string, report: FocusReport, read: (file: string) => string = file => readFileSync(file, 'utf8')): Focus {
|
|
60
|
+
export function resolveFocus(doc: IRDocument | undefined, document: string, report: FocusReport, read: (file: string) => string = file => readFileSync(file, 'utf8'), readWorkspaceFile?: (file: string) => string): Focus {
|
|
61
|
+
if (report.scope === 'workspace') {
|
|
62
|
+
if (!readWorkspaceFile || !report.source) throw new Error('this host does not read workspace source selections')
|
|
63
|
+
const range = report.source
|
|
64
|
+
const source = { ...range, endLine: Math.max(range.startLine, range.endLine) }
|
|
65
|
+
const excerpt = excerptOf(readWorkspaceFile(source.file), source.startLine, source.endLine)
|
|
66
|
+
return { id: report.id, from: report.from, document, scope: 'workspace', source, excerpt: excerpt.length > WORKSPACE_EXCERPT_CHARS ? `${excerpt.slice(0, WORKSPACE_EXCERPT_CHARS)}\n[… excerpt truncated …]` : excerpt, ...report.quote ? { quote: report.quote } : {} }
|
|
67
|
+
}
|
|
68
|
+
if (!doc) throw new Error('a document focus requires the compiled document')
|
|
57
69
|
const deckDir = dirname(doc.meta.entry)
|
|
58
70
|
let source: SourceRange
|
|
59
71
|
let section = report.section
|
|
@@ -61,7 +73,7 @@ export function resolveFocus(doc: IRDocument, document: string, report: FocusRep
|
|
|
61
73
|
if (report.block || (report.section && !report.source)) {
|
|
62
74
|
const found = findSection(doc, report.section ?? [...docBlocks(doc)].find(b => b.block.id === report.block)?.section.id ?? '')
|
|
63
75
|
section = found.id
|
|
64
|
-
const at = report.block ?
|
|
76
|
+
const at = report.block ? relativeToDeck(doc, findBlock(found, report.block).source) : relativeToDeck(doc, found.source)
|
|
65
77
|
source = { file: at.file, startLine: at.line, endLine: at.endLine ?? at.line }
|
|
66
78
|
} else {
|
|
67
79
|
const range = report.source!
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DOCUMENT_HISTORY_CHANGE_SCHEMA, DOCUMENT_HISTORY_MAX_REQUEST_CHARS, DOCUMENT_HISTORY_QUERY_SCHEMA, DOCUMENT_HISTORY_READ_SCHEMA } from '@lincyaw/kernel'
|
|
2
|
+
import type { DocumentHistory, DocumentHistoryQuery } from '@lincyaw/kernel'
|
|
3
|
+
import type { Call, ToolSpec } from './mcp.ts'
|
|
4
|
+
import { validateDocumentHistoryRead, validateDocumentHistoryChange } from '@lincyaw/kernel/node'
|
|
5
|
+
|
|
6
|
+
const documentProperty = { type: 'string', maxLength: 200, description: 'The served document directory; empty string names the root document, never the entire workspace.' }
|
|
7
|
+
const service = (call: Call): DocumentHistory => {
|
|
8
|
+
if (!call.host.documentHistory) throw Object.assign(new Error('history-host-unavailable'), { status: 503 })
|
|
9
|
+
return call.host.documentHistory
|
|
10
|
+
}
|
|
11
|
+
const budget = (call: Call) => { if (JSON.stringify(call.args).length > DOCUMENT_HISTORY_MAX_REQUEST_CHARS) throw Object.assign(new Error('history-request-too-large'), { status: 413 }) }
|
|
12
|
+
const readSchema = (schema: typeof DOCUMENT_HISTORY_QUERY_SCHEMA | typeof DOCUMENT_HISTORY_READ_SCHEMA) => ({ ...schema, properties: { document: documentProperty, ...schema.properties } })
|
|
13
|
+
const readArgs = (call: Call) => { budget(call); const { document: _document, ...query } = call.args; return query }
|
|
14
|
+
export const HISTORY_TOOLS: ToolSpec[] = [
|
|
15
|
+
{
|
|
16
|
+
name: 'get_document_history', kind: 'read', structured: true, inputSchema: readSchema(DOCUMENT_HISTORY_QUERY_SCHEMA),
|
|
17
|
+
description: 'Reads the served document’s actual git history and exact next undo/redo IDs, without compiling or restoring. Root document "" is scoped. At most 20 compact entries and 7168 UTF-16 response characters; summaries/file lists may be abbreviated. Continue with snapshot and nextOffset. The historyBinding contains saved raw source plus actual git HEAD; malformed current JSON is still readable. No git returns available:false. Viewing never changes files.',
|
|
18
|
+
run: async call => service(call).list(await call.document(), readArgs(call) as DocumentHistoryQuery, call.caller.member),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'read_history_file', kind: 'read', structured: true, inputSchema: readSchema(DOCUMENT_HISTORY_READ_SCHEMA),
|
|
22
|
+
description: 'Reads a regular UTF-8 file at an exact reachable historical commit, scoped to this served document. Defaults to its lecture.json. Replies are bounded text fragments with actual SHA-256 and nextOffset; append decoded text and parse JSON only after complete. Rejects hidden, binary, symlink, cross-document and oversized files. This is a historical view, never checkout, restore, discard, compile or validation of the current document.',
|
|
23
|
+
run: async call => { const query = readArgs(call); if (!validateDocumentHistoryRead(query)) throw Object.assign(new Error('history-read-invalid'), { status: 400 }); return service(call).read(await call.document(), query, call.caller.member) },
|
|
24
|
+
},
|
|
25
|
+
...(['undo', 'redo', 'revert'] as const).map((which): ToolSpec => ({
|
|
26
|
+
name: `${which}_document_change`, kind: 'operate', structured: true,
|
|
27
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['request'], properties: { document: documentProperty, request: DOCUMENT_HISTORY_CHANGE_SCHEMA } },
|
|
28
|
+
description: `Explicitly ${which}s ${which === 'revert' ? 'the specified reachable historical change' : 'the exact next target'} returned by get_document_history using its historyBinding and full expect commit. Requires host operate approval and existing editor/lock/review permissions. Refuses changed source/HEAD/target, mixed-document commits and uncommitted target files; never silently rebase. Use a stable intent id and retry identical input after lost ACK only while the returned result HEAD/source is current (64 process-local receipts). Uses the same Revisions transaction as Web; does not discard drafts, checkout, compile or play.`,
|
|
29
|
+
run: async call => {
|
|
30
|
+
budget(call)
|
|
31
|
+
const document = await call.document(), request = call.args.request
|
|
32
|
+
if (!validateDocumentHistoryChange(request)) throw Object.assign(new Error('history-change-invalid'), { status: 400 })
|
|
33
|
+
const lang = (await call.host.documents()).find(item => item.dir === document)?.lang ?? 'zh-CN'
|
|
34
|
+
await call.approve('operate', m => m.historyChange(which, request.expect), { lang, check: () => service(call).checkChange(document, which, request, call.caller.member, 'agent') })
|
|
35
|
+
return service(call).change(document, which, request, call.caller.member, 'agent')
|
|
36
|
+
},
|
|
37
|
+
})),
|
|
38
|
+
]
|