@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.
@@ -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, Member } from '@lincyaw/kernel'
7
- import { buildView, Compiler, findPackageDir, resolveEntry, runBlock } from '@lincyaw/compiler'
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 USAGE = `Usage: presmium-mcp [deck] [--allow <kinds>]
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(s => s.mtimeMs, () => -1)))
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 compilerHost(entry: string, allow: readonly ApprovalKind[]): McpHost {
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.files, ...compilation.dependencies.files]
40
- latest = { compilation, files, stamp: await stamps(files) }
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
- const report = await buildView(await only(document), { to: options.to })
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(`${USAGE}\n`)
85
+ process.stdout.write(`${t.usage}\n`)
85
86
  return 0
86
87
  }
87
- if (positionals.length > 1) throw new Error(`unexpected argument "${positionals[1]}"`)
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 => !KINDS.includes(kind))
91
- if (unknown) throw new Error(`--allow takes ${KINDS.join(', ')}, not "${unknown}"`)
92
- await stat(deck).catch(() => {
93
- throw new Error(`${deck} not found`)
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${USAGE}\n`)
96
+ process.stderr.write(`presmium-mcp: ${errorMessage(error)}\n\n${t.usage}\n`)
97
97
  return 2
98
98
  }
99
- serveMcpStdio(compilerHost(deck, allow), { member: AUTHOR, mode: 'author' })
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', 'operate', 'spend', 'write', 'agent'])
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['presmium/kind'] === 'string' && KINDS.has(meta['presmium/kind']) ? meta['presmium/kind'] as ListedTool['kind'] : (tool.annotations?.readOnlyHint ? 'read' : 'operate')
41
- const approval = meta['presmium/approval'] === 'allow' ? 'allow' : meta['presmium/approval'] === 'ask' ? 'ask' : undefined
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
- close: () => client.close(),
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) && args.to.length ? args.to.join(', ') : m.allRenderers)
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
- export interface Place {
7
- file: string
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: place(doc, section.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: place(doc, { file: doc.meta.entry, line: 1 }).file,
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: place(doc, block.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: place(doc, block.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 ? place(doc, { file: f.source.file, line: f.source.line ?? 1 }) : {},
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: place(doc, b.source), code: origin(b.node as CodeNode) })))
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: place(doc, findBlock(section, 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: place(doc, b.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: place(doc, section.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, place } from './deck.ts'
5
+ import { findBlock, findSection, relativeToDeck } from './deck.ts'
6
6
 
7
- export const EXCERPT_LINES = 20
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 focusReport(value: unknown): FocusReport | string {
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
- export function excerptOf(text: string, start: number, end: number): string {
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 = place(doc, block.source)
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 = place(doc, s.source)
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 ? place(doc, findBlock(found, report.block).source) : place(doc, found.source)
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
+ ]