@changfenhuang/dsh-genui 0.11.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +21 -6
  3. package/README.zh-CN.md +21 -6
  4. package/SKILL.md +18 -6
  5. package/lib/assets/mermaid.js +325 -325
  6. package/lib/assets/standalone-runtime.js +10 -9
  7. package/lib/client.js +31 -33
  8. package/lib/index.js +1060 -371
  9. package/lib/types/client/blocks/forms.d.ts +5 -6
  10. package/lib/types/client/blocks/state.d.ts +2 -11
  11. package/lib/types/client/dom-fence.d.ts +2 -1
  12. package/lib/types/client/file-address.d.ts +4 -0
  13. package/lib/types/client/file-link-context.d.ts +41 -0
  14. package/lib/types/client/file-link.d.ts +17 -0
  15. package/lib/types/client/i18n/en.d.ts +5 -1
  16. package/lib/types/client/inline.d.ts +1 -1
  17. package/lib/types/client/mermaid-colors.d.ts +2 -0
  18. package/lib/types/client/panel.d.ts +6 -1
  19. package/lib/types/client/spec.d.ts +8 -11
  20. package/lib/types/client/submission-registry.d.ts +51 -0
  21. package/lib/types/client/table-details.d.ts +20 -0
  22. package/lib/types/client/walk-spec.d.ts +3 -0
  23. package/lib/types/plugin/fence-feedback.d.ts +55 -12
  24. package/lib/types/plugin/index.d.ts +7 -3
  25. package/lib/types/shared/fence-repair.d.ts +19 -7
  26. package/package.json +1 -12
  27. package/src/client/GenuiBlock.module.css +100 -4
  28. package/src/client/GenuiBlock.tsx +7 -14
  29. package/src/client/artifact/ArtifactExport.module.css +34 -3
  30. package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
  31. package/src/client/blocks/charts.tsx +63 -14
  32. package/src/client/blocks/forms.tsx +26 -48
  33. package/src/client/blocks/render-node.tsx +11 -8
  34. package/src/client/blocks/state.ts +2 -12
  35. package/src/client/dom-fence.tsx +190 -50
  36. package/src/client/file-address.ts +37 -0
  37. package/src/client/file-link-context.tsx +114 -0
  38. package/src/client/file-link.ts +46 -0
  39. package/src/client/genui-runtime/diagnostics.ts +20 -0
  40. package/src/client/genui-runtime/normalize.ts +28 -9
  41. package/src/client/guard.ts +98 -153
  42. package/src/client/i18n/en.ts +5 -1
  43. package/src/client/i18n/zh.ts +5 -1
  44. package/src/client/index.tsx +23 -5
  45. package/src/client/inline.ts +28 -10
  46. package/src/client/mermaid-colors.ts +39 -0
  47. package/src/client/mermaid-core.ts +7 -3
  48. package/src/client/panel.tsx +18 -5
  49. package/src/client/spec.ts +8 -11
  50. package/src/client/submission-registry.ts +150 -0
  51. package/src/client/table-details.ts +151 -0
  52. package/src/client/walk-spec.ts +43 -0
  53. package/src/plugin/fence-feedback.ts +384 -53
  54. package/src/plugin/index.ts +20 -9
  55. package/src/plugin/tool.ts +8 -2
  56. package/src/shared/fence-repair.ts +345 -128
  57. package/tsdown.config.ts +3 -7
  58. package/lib/invariant.js +0 -19
  59. package/lib/types/plugin/invariant.d.ts +0 -15
  60. package/src/plugin/invariant.ts +0 -30
@@ -83,6 +83,8 @@ async function serveGenuiAsset(req: IncomingMessage, res: ServerResponse): Promi
83
83
  }
84
84
  }
85
85
 
86
+ const RENDER_UI_TOOL_GUIDANCE = '- Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片;围栏用于回答内联 UI。'
87
+
86
88
  /** The fence language description injected into every assembled system prompt.
87
89
  * Deliberately slim: the `genui` skill carries the full component→field
88
90
  * mapping; this section keeps only the contract that must always be
@@ -119,12 +121,13 @@ Rules:
119
121
  - JSON 严格:坏组件被丢弃,坏围栏变代码块;≥3 节点或含 table 时调 validate_dsh_ui,按诊断修改并重验;小围栏字段存疑也先验证。
120
122
  - warning=block_markdown:按 replacement 改写并重验。
121
123
  - 规模: ≤200 节点、嵌套≤8 层(超出被截断);一条回答 3–8 个组件,一个主题一个主组件;3D mesh 1–5;plot 给合理 xMin/xMax。
122
- - LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互组件带 "action":"name",交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
123
- - Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;重渲染相同内容保留,新内容重置。
124
+ - LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
125
+ - Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;相同内容保留,新内容重置。
124
126
  - 卷子模式: 每题一个 radio(group+answer+explanation)+ 一个 submit(groups 全列),本地判分。
125
127
  - Secrets ban: 不索取密码、API Key、Token、恢复码;需要时拒绝并解释。
126
- - Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片(交付物型界面用);围栏用于回答内联 UI。
127
- - Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并(同标签 tabs 追加/新标签加入/尾部追加);上限 200 节点/200 次追加,满了发 replace 重建。面板组件来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认,不解释、不用普通围栏。`
128
+ ${RENDER_UI_TOOL_GUIDANCE}
129
+ - 围栏位置:\`dsh-ui\` 只写在**回答正文**;写在 reasoning/思考块里不渲染、用户看不到——思考里验证好 spec,正文再输出同一份。
130
+ - Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并;上限 200 节点/200 次追加,满了发 replace 重建。面板来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认。`
128
131
 
129
132
  /**
130
133
  * Register the GenUI output-language section and the render_ui tool.
@@ -184,25 +187,33 @@ function bundledSkillProvider(): SkillProvider {
184
187
  export interface GenuiPluginConfig {
185
188
  /**
186
189
  * 在最终 dsh-ui 围栏无法渲染的回合中请求模型发送一次修正版(issue #160)。
187
- * 默认开启,设置为 false 可以关闭。每回合和每个围栏正文最多请求一次,子代理不触发,
188
- * 每次请求会消耗模型步数。
190
+ * 默认开启,设置为 false 可以关闭同回合围栏修正。GenUI 回合的 reasoning-only 响应仍会交给宿主重试策略处理。
191
+ * 每回合和每个围栏正文最多请求一次,子代理不触发,每次修正请求会消耗模型步数。
189
192
  */
190
193
  fenceFeedback?: boolean
194
+
195
+ /**
196
+ * 是否注册 render_ui 模型工具。默认开启,设置为 false 时关闭;不影响 validate_dsh_ui 和 dsh-ui 围栏渲染。
197
+ */
198
+ renderUiTool?: boolean
191
199
  }
192
200
 
193
201
  export function apply(ctx: Context, config?: GenuiPluginConfig): void {
202
+ const renderUiToolEnabled = config?.renderUiTool !== false
194
203
  ctx.systemPrompt.section({
195
204
  name: 'genui:fence',
196
205
  order: ctx.systemPrompt.getSectionOrder('STRUCTURED_OUTPUT'),
197
- text: GENUI_SECTION_TEXT,
206
+ text: renderUiToolEnabled ? GENUI_SECTION_TEXT : GENUI_SECTION_TEXT.replace(`${RENDER_UI_TOOL_GUIDANCE}\n`, ''),
198
207
  })
199
- installFenceFeedback(ctx, config?.fenceFeedback !== false)
208
+ installFenceFeedback(ctx, config?.fenceFeedback !== false, renderUiToolEnabled)
200
209
  // Hosts without tool access keep the fence channel. The dependency fiber
201
210
  // starts whenever tools becomes available and unloads its registrations
202
211
  // before either the service or this plugin is replaced.
203
212
  ctx.inject(['tools'], (toolsCtx) => {
204
213
  toolsCtx.effect(function* () {
205
- yield toolsCtx.tools.register(createRenderUiTool())
214
+ if (renderUiToolEnabled) {
215
+ yield toolsCtx.tools.register(createRenderUiTool())
216
+ }
206
217
  yield toolsCtx.tools.register(createValidateDshUiTool())
207
218
  }, 'dsh-genui: model tools')
208
219
  })
@@ -162,6 +162,9 @@ function formatProcessWarnings(processed: GenuiProcessResult): string[] {
162
162
  ? `warning=alias_ignored path=${warning.path} canonical=${canonicalPath}`
163
163
  : `warning=alias_normalized path=${warning.path} canonical=${canonicalPath}`
164
164
  }
165
+ if (warning.kind === 'unknown-field') {
166
+ return `warning=unknown_field path=${JSON.stringify(warning.path)} detail=${JSON.stringify(warning.message)}`
167
+ }
165
168
  return `warning=process detail=${JSON.stringify(warning.message)}`
166
169
  })
167
170
  }
@@ -260,6 +263,7 @@ const VALIDATE_DESCRIPTION =
260
263
  + 'Pass the exact JSON text you are about to put inside the fence as the "spec" argument (a string). '
261
264
  + 'Returns a [genui-validation] protocol block with status, diagnostics, next action, and reply_language=conversation. '
262
265
  + 'When invalid JSON is repairable, repaired_json contains the fixed body; emit it only with next=emit_repaired_fence. '
266
+ + 'A warning=unknown_field requires fixing the indicated path and another validation call. '
263
267
  + 'A warning=block_markdown requires the indicated replacement node and another validation call.'
264
268
 
265
269
  const VALIDATE_PARAMETERS: Record<string, unknown> = {
@@ -368,6 +372,7 @@ export function createValidateDshUiTool(): ToolDefinition {
368
372
  if (processed.spec !== null && processed.errors.length === 0) {
369
373
  const warnings = formatProcessWarnings(processed)
370
374
  const inlineWarnings = formatInlineContentWarnings(processed.spec)
375
+ const requiresCorrection = inlineWarnings.length > 0 || processed.warnings.some(warning => warning.kind === 'unknown-field')
371
376
  return `${validationProtocol([
372
377
  'status=invalid',
373
378
  'error=invalid_json',
@@ -377,7 +382,7 @@ export function createValidateDshUiTool(): ToolDefinition {
377
382
  ...inlineWarnings,
378
383
  'repair=applied',
379
384
  `repair_count=${repaired.repairs}`,
380
- inlineWarnings.length === 0 ? 'next=emit_repaired_fence' : 'next=fix_and_revalidate',
385
+ requiresCorrection ? 'next=fix_and_revalidate' : 'next=emit_repaired_fence',
381
386
  ])}\nrepaired_json:\n\`\`\`\n${repaired.text}\n\`\`\``
382
387
  }
383
388
  }
@@ -409,9 +414,10 @@ export function createValidateDshUiTool(): ToolDefinition {
409
414
  }
410
415
  const warnings = formatProcessWarnings(processed)
411
416
  const inlineWarnings = formatInlineContentWarnings(processed.spec)
417
+ const requiresCorrection = inlineWarnings.length > 0 || processed.warnings.some(warning => warning.kind === 'unknown-field')
412
418
  return validationProtocol([
413
419
  'status=valid', `rendered=${processed.renderedCount}`, ...warnings, ...inlineWarnings,
414
- inlineWarnings.length === 0 ? 'next=emit_fence' : 'next=fix_and_revalidate',
420
+ requiresCorrection ? 'next=fix_and_revalidate' : 'next=emit_fence',
415
421
  ])
416
422
  },
417
423
  presentCall(): GenericCallView | undefined {
@@ -51,12 +51,12 @@ export function describeJsonFailure(raw: string): string | null {
51
51
  * with ASCII quotes (e.g. `对"别名路径"判定失败`), which makes JSON.parse
52
52
  * fail near that quote with "Expected ',' or ']'...".
53
53
  * 2. Trailing commas before `}` / `]` or at end of input.
54
+ * 3. Valueless object keys immediately before a complete key-and-colon pair.
54
55
  *
55
- * The state-machine scan walks the raw body tracking string-open state:
56
- * - inside a string, a quote whose next non-space char is NOT one of `, ] } :`
57
- * (or end of input) cannot legally close the string → escape it as `\"`;
58
- * - a `,` whose next non-space char is `}` / `]` / end of input is a trailing
59
- * comma → drop it.
56
+ * A shared grammar-aware scan distinguishes object keys from values and
57
+ * checks the continuation after a potential string terminator. Ambiguous
58
+ * value quotes use bounded backtracking; trailing commas are dropped only
59
+ * outside strings.
60
60
  *
61
61
  * Returns `{ text, repairs }` on success, or null when nothing needed fixing
62
62
  * or the body still does not parse (callers fall through to tier-2 / banner).
@@ -68,61 +68,370 @@ export function repairFenceJson(raw: string): { text: string; repairs: number }
68
68
  } catch {
69
69
  // fall through to the repair scan
70
70
  }
71
+ return scanFenceJson(raw, false)
72
+ }
73
+
74
+ /** A fixed search budget prevents quote ambiguity from becoming exponential. */
75
+ const MAX_QUOTE_ATTEMPTS = 32
76
+ const MAX_QUOTE_LOOKAHEAD = 4096
77
+
78
+ type JsonScope = {
79
+ closer: '}' | ']'
80
+ expecting: 'key' | 'colon' | 'value' | 'comma'
81
+ }
82
+ type RepairScan = {
83
+ text: string
84
+ repairs: number
85
+ rootEnd: number
86
+ rawRootEnd: number
87
+ choices: number[]
88
+ unfinishedString: boolean
89
+ invalidValue: boolean
90
+ }
91
+
92
+ function isJsonSpace(ch: string | undefined): boolean {
93
+ return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
94
+ }
95
+
96
+ /** Skip only complete, valueless object keys followed by a valued member. */
97
+ function skipValuelessKeys(raw: string, start: number, limit = raw.length): { start: number; repairs: number } | null {
98
+ let cursor = start
99
+ let repairs = 0
100
+ while (cursor < limit && raw[cursor] === '"') {
101
+ const keyStart = cursor++
102
+ while (cursor < limit && raw[cursor] !== '"') {
103
+ if (raw[cursor] === '\\') cursor += 2
104
+ else cursor++
105
+ }
106
+ if (cursor >= limit) return null
107
+ cursor++
108
+ try { JSON.parse(raw.slice(keyStart, cursor)) } catch { return null }
109
+ while (cursor < limit && isJsonSpace(raw[cursor])) cursor++
110
+ if (raw[cursor] === ':') return repairs > 0 ? { start: keyStart, repairs } : null
111
+ if (raw[cursor] !== ',') return null
112
+ repairs++
113
+ cursor++
114
+ while (cursor < limit && isJsonSpace(raw[cursor])) cursor++
115
+ }
116
+ return null
117
+ }
118
+
119
+ /**
120
+ * Quote closure is contextual: only keys may be followed by `:`, and a comma
121
+ * must introduce the next member of the enclosing object/array. Walk closing
122
+ * delimiters too, so a bracket in a quoted code example cannot end a value
123
+ * when prose immediately follows it inside the enclosing JSON structure.
124
+ * Lookahead has a fixed bound; inconclusive long continuations keep the
125
+ * terminator interpretation and leave the final JSON.parse as the arbiter.
126
+ */
127
+ function quoteCanClose(raw: string, index: number, key: boolean, scopes: JsonScope[], complete: boolean): boolean {
128
+ const limit = Math.min(raw.length, index + MAX_QUOTE_LOOKAHEAD)
129
+ let cursor = index + 1
130
+ const skipSpace = (): void => { while (cursor < limit && isJsonSpace(raw[cursor])) cursor++ }
131
+ skipSpace()
132
+ if (cursor >= limit) return true
133
+ if (key) return raw[cursor] === ':'
134
+ let scopeIndex = scopes.length - 1
135
+ if (raw[cursor] === ':') return false
136
+ while (raw[cursor] === '}' || raw[cursor] === ']') {
137
+ const scope = scopes[scopeIndex]
138
+ if (scope === undefined) return false
139
+ if (raw[cursor] === scope.closer) scopeIndex--
140
+ else if (!complete) return false
141
+ cursor++
142
+ skipSpace()
143
+ if (cursor >= limit) return true
144
+ // Tier-2 can retain a complete root prefix followed by junk. It tries
145
+ // whole-body quote alternatives before actually adopting that prefix.
146
+ if (scopeIndex < 0) return complete
147
+ }
148
+ if (raw[cursor] !== ',') return false
149
+ const scope = scopes[scopeIndex]
150
+ if (scope === undefined) return false
151
+ cursor++
152
+ skipSpace()
153
+ if (cursor >= limit) return true
154
+ if (raw[cursor] === scope.closer) return true // a removable trailing comma
155
+ if (scope.closer === ']') return /["[{tfn\-0-9]/.test(raw[cursor]!)
156
+ if (raw[cursor] !== '"') return false
157
+ // A valueless sibling is removable; it must not make a real value's
158
+ // closing quote absorb that sibling into the preceding string.
159
+ const valueless = skipValuelessKeys(raw, cursor, limit)
160
+ if (valueless !== null) cursor = valueless.start
161
+ // An object comma must be followed by a quoted key and its colon. Escapes
162
+ // in that key are skipped without changing them.
163
+ cursor++
164
+ while (cursor < limit) {
165
+ if (raw[cursor] === '\\') { cursor += 2; continue }
166
+ if (raw[cursor] === '"') {
167
+ cursor++
168
+ skipSpace()
169
+ return cursor >= limit || raw[cursor] === ':'
170
+ }
171
+ if (raw[cursor]!.charCodeAt(0) < 0x20) return false
172
+ cursor++
173
+ }
174
+ return true
175
+ }
176
+
177
+ /** Both tiers share the same string-aware scan and quote decisions. */
178
+ function scanFenceCandidate(raw: string, complete: boolean, contentQuotes: ReadonlySet<number>): RepairScan {
71
179
  let out = ''
180
+ const scopes: JsonScope[] = []
72
181
  let inString = false
182
+ let key = false
183
+ let interiorQuote = false
73
184
  let escaped = false
74
185
  let repairs = 0
186
+ let rootEnd = 0
187
+ let rawRootEnd = 0
188
+ let invalidValue = false
189
+ const choices: number[] = []
190
+ const finishValue = (): void => {
191
+ const scope = scopes[scopes.length - 1]
192
+ if (scope !== undefined) scope.expecting = 'comma'
193
+ }
75
194
  for (let i = 0; i < raw.length; i++) {
76
- const ch = raw[i]
195
+ const ch = raw[i]!
77
196
  if (escaped) {
197
+ if (inString && ch === '"') interiorQuote = true
78
198
  out += ch
79
199
  escaped = false
80
200
  continue
81
201
  }
82
- if (inString && ch === '\\') {
83
- out += ch
84
- escaped = true
85
- continue
86
- }
87
- if (ch === '"') {
88
- if (!inString) {
89
- inString = true
90
- out += ch
91
- continue
92
- }
93
- // Inside a string: is this quote the terminator? Look past whitespace.
94
- let j = i + 1
95
- while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) j++
96
- const next = j < raw.length ? raw[j] : ''
97
- if (next === ',' || next === ']' || next === '}' || next === ':' || next === '') {
202
+ if (inString) {
203
+ if (ch === '\\') { out += ch; escaped = true; continue }
204
+ if (ch !== '"') { out += ch; continue }
205
+ let next = i + 1
206
+ while (next < raw.length && isJsonSpace(raw[next])) next++
207
+ // Without any interior quote, a delimiter-looking quote is a normal
208
+ // terminator even if its sibling is malformed. Do not absorb a broken
209
+ // bare value merely to make structural completion succeed.
210
+ const ordinaryEnd = !key && ((!interiorQuote && ',}]:'.includes(raw[next] ?? '\0'))
211
+ || (scopes[scopes.length - 1]?.closer === ']' && raw[next] === ','))
212
+ if (!contentQuotes.has(i) && (ordinaryEnd || quoteCanClose(raw, i, key, scopes, complete))) {
98
213
  inString = false
99
214
  out += ch
215
+ const scope = scopes[scopes.length - 1]
216
+ if (key) {
217
+ if (scope !== undefined) scope.expecting = 'colon'
218
+ } else {
219
+ finishValue()
220
+ // Only value terminators create alternatives. Retaining at most
221
+ // 32 recent decisions bounds the pending search and its memory.
222
+ if (scope !== undefined && interiorQuote) {
223
+ if (choices.length === MAX_QUOTE_ATTEMPTS) choices.shift()
224
+ choices.push(i)
225
+ }
226
+ }
100
227
  } else {
101
- // Free-standing quote inside a value → escape it.
102
228
  out += '\\"'
229
+ interiorQuote = true
103
230
  repairs++
104
231
  }
105
232
  continue
106
233
  }
234
+ if (ch === '"') {
235
+ key = scopes[scopes.length - 1]?.expecting === 'key'
236
+ if (key) {
237
+ const valueless = skipValuelessKeys(raw, i)
238
+ if (valueless !== null) {
239
+ repairs += valueless.repairs
240
+ i = valueless.start - 1
241
+ continue
242
+ }
243
+ }
244
+ interiorQuote = false
245
+ inString = true
246
+ out += ch
247
+ continue
248
+ }
249
+ if (ch === '{' || ch === '[') {
250
+ finishValue()
251
+ scopes.push({ closer: ch === '{' ? '}' : ']', expecting: ch === '{' ? 'key' : 'value' })
252
+ out += ch
253
+ continue
254
+ }
255
+ if (ch === '}' || ch === ']') {
256
+ if (scopes[scopes.length - 1]?.closer === ch) {
257
+ scopes.pop()
258
+ out += ch
259
+ if (scopes.length === 0 && rootEnd === 0) {
260
+ rootEnd = out.length
261
+ rawRootEnd = i + 1
262
+ }
263
+ } else if (complete) repairs++
264
+ else out += ch
265
+ continue
266
+ }
107
267
  if (ch === ',') {
108
- // Trailing comma before `}` / `]` / end of input → drop it.
109
268
  let j = i + 1
110
- while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) j++
111
- const next = j < raw.length ? raw[j] : ''
112
- if (next === '}' || next === ']' || next === '') {
269
+ while (j < raw.length && isJsonSpace(raw[j])) j++
270
+ if (j === raw.length || raw[j] === '}' || raw[j] === ']') {
113
271
  repairs++
114
272
  continue
115
273
  }
274
+ const scope = scopes[scopes.length - 1]
275
+ if (scope !== undefined) scope.expecting = scope.closer === '}' ? 'key' : 'value'
276
+ } else if (ch === ':') {
277
+ const scope = scopes[scopes.length - 1]
278
+ if (scope !== undefined) scope.expecting = 'value'
279
+ } else if (!isJsonSpace(ch) && scopes[scopes.length - 1]?.expecting === 'value') {
280
+ // Invalid bare values are not quote ambiguity: do not turn an earlier
281
+ // finished string into a container for `broken` / `undefined` tokens.
282
+ let j = i + 1
283
+ while (j < raw.length && !isJsonSpace(raw[j]) && !'{}[],:"'.includes(raw[j]!)) j++
284
+ const token = raw.slice(i, j)
285
+ if (!/^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)$/.test(token)) invalidValue = true
286
+ finishValue()
287
+ out += token
288
+ i = j - 1
289
+ continue
116
290
  }
117
291
  out += ch
118
292
  }
119
- if (repairs === 0) return null
120
- try {
121
- JSON.parse(out)
122
- return { text: out, repairs }
123
- } catch {
124
- return null
293
+ const unfinishedString = inString
294
+ if (complete) {
295
+ if (inString) { out += '"'; repairs++ }
296
+ while (scopes.length > 0) { out += scopes.pop()!.closer; repairs++ }
297
+ }
298
+ return { text: out, repairs, rootEnd, rawRootEnd, choices, unfinishedString, invalidValue }
299
+ }
300
+
301
+ /** Find an authoritative, already-valid object/array prefix without repair. */
302
+ function originalJsonPrefix(raw: string): string | null {
303
+ const scopes: string[] = []
304
+ let inString = false
305
+ let escaped = false
306
+ let started = false
307
+ for (let i = 0; i < raw.length; i++) {
308
+ const ch = raw[i]!
309
+ if (escaped) { escaped = false; continue }
310
+ if (inString) {
311
+ if (ch === '\\') escaped = true
312
+ else if (ch === '"') inString = false
313
+ continue
314
+ }
315
+ if (!started) {
316
+ if (isJsonSpace(ch)) continue
317
+ if (ch !== '{' && ch !== '[') return null
318
+ started = true
319
+ }
320
+ if (ch === '"') { inString = true; continue }
321
+ if (ch === '{' || ch === '[') scopes.push(ch === '{' ? '}' : ']')
322
+ else if (ch === '}' || ch === ']') {
323
+ if (scopes.pop() !== ch) return null
324
+ if (scopes.length === 0) {
325
+ const text = raw.slice(0, i + 1).trimEnd()
326
+ try { JSON.parse(text); return text } catch { return null }
327
+ }
328
+ }
125
329
  }
330
+ return null
331
+ }
332
+
333
+ /**
334
+ * Prefer real terminators; if the resulting whole body fails, try interpreting
335
+ * a bounded number of ambiguous value quotes as content. Each attempt is an
336
+ * iterative scan, not a recursive parser; no engine-specific error offset is
337
+ * needed. Fixed attempts/lookahead give O(n) time and O(n) working storage.
338
+ */
339
+ function scanFenceJson(raw: string, complete: boolean): { text: string; repairs: number } | null {
340
+ // A genuine original root is authoritative. Never reinterpret its closing
341
+ // quote to absorb a second object or prose, even during speculative repair.
342
+ const original = originalJsonPrefix(raw)
343
+ if (original !== null) return complete ? { text: original, repairs: 1 } : null
344
+ const pending: number[][] = [[]]
345
+ let prefix: { text: string; repairs: number; rawEnd: number } | null = null
346
+ for (let attempt = 0; attempt < MAX_QUOTE_ATTEMPTS && pending.length > 0; attempt++) {
347
+ const forced = pending.pop()!
348
+ const scanned = scanFenceCandidate(raw, complete, new Set(forced))
349
+ // A speculative branch must find a real closing quote. Appending one to
350
+ // that branch could swallow malformed sibling fields into invented text.
351
+ if (scanned.repairs > 0 && !(forced.length > 0 && scanned.unfinishedString)) {
352
+ try {
353
+ JSON.parse(scanned.text)
354
+ return { text: scanned.text, repairs: scanned.repairs }
355
+ } catch { /* try a different bounded quote interpretation */ }
356
+ }
357
+ // Validate possible prefixes now, but do not adopt one until all whole-
358
+ // body alternatives have failed. Prefer the one that retains the most
359
+ // source text, rather than an earlier quote mistaken for a root end.
360
+ if (complete && scanned.rootEnd > 0 && (prefix === null || scanned.rawRootEnd > prefix.rawEnd)) {
361
+ const text = scanned.text.slice(0, scanned.rootEnd).trimEnd()
362
+ try {
363
+ JSON.parse(text)
364
+ prefix = { text, repairs: scanned.repairs + 1, rawEnd: scanned.rawRootEnd }
365
+ } catch { /* a balanced but invalid prefix cannot be adopted */ }
366
+ }
367
+ if (scanned.invalidValue) continue
368
+ const last = forced[forced.length - 1] ?? -1
369
+ for (const quote of scanned.choices) {
370
+ if (quote > last) pending.push([...forced, quote])
371
+ }
372
+ // Newer decisions are tried first. Discard older pending branches when
373
+ // the fixed budget is full instead of accumulating an exponential tree.
374
+ if (pending.length > MAX_QUOTE_ATTEMPTS) pending.splice(0, pending.length - MAX_QUOTE_ATTEMPTS)
375
+ }
376
+ // Balanced-prefix adoption remains strictly tier-2 / settled-only, and only
377
+ // after whole-body repair attempts have failed.
378
+ if (prefix !== null) return { text: prefix.text, repairs: prefix.repairs }
379
+ return null
380
+ }
381
+
382
+ /**
383
+ * 取**第一个平衡根值**的文本(丢弃其后的杂字符);没有平衡根时返回 null。
384
+ *
385
+ * 与 {@link completeFenceJson} 里的前缀回退同源,但**只做裁剪、不做结构补全**:
386
+ * 内容识别用它来容忍「合法 JSON + 尾部泄漏文本」(真实样本:模型把自己的工具调用
387
+ * 模板泄漏在 JSON 之后,且围栏没闭合)。根值正好结束在末尾时返回 null —— 那种情况
388
+ * `JSON.parse` 本来就会成功。
389
+ *
390
+ * @param text - 候选正文。
391
+ * @returns 平衡根前缀;无可裁剪内容时 null。
392
+ */
393
+ export function trimToBalancedRoot(text: string): string | null {
394
+ const stack: Array<'}' | ']'> = []
395
+ let inString = false
396
+ let escaped = false
397
+ let started = false
398
+ for (let i = 0; i < text.length; i += 1) {
399
+ const ch = text[i]
400
+ if (escaped) {
401
+ escaped = false
402
+ continue
403
+ }
404
+ if (inString) {
405
+ if (ch === '\\') escaped = true
406
+ else if (ch === '"') inString = false
407
+ continue
408
+ }
409
+ if (ch === '"') {
410
+ inString = true
411
+ started = true
412
+ continue
413
+ }
414
+ if (ch === '{') {
415
+ stack.push('}')
416
+ started = true
417
+ continue
418
+ }
419
+ if (ch === '[') {
420
+ stack.push(']')
421
+ started = true
422
+ continue
423
+ }
424
+ if (ch === '}' || ch === ']') {
425
+ // 不匹配的闭括号 ⇒ 这份正文结构上已经坏了,不做"裁剪"式的猜测。
426
+ if (stack[stack.length - 1] !== ch) return null
427
+ stack.pop()
428
+ if (stack.length === 0 && started) {
429
+ if (i + 1 >= text.length) return null
430
+ return text.slice(0, i + 1).trim()
431
+ }
432
+ }
433
+ }
434
+ return null
126
435
  }
127
436
 
128
437
  /**
@@ -230,8 +539,8 @@ function rewriteTetrisTableColumns(raw: string): { text: string; repairs: number
230
539
  * client uses the host-provided fence source; the validate tool is by
231
540
  * definition pre-emission), so a streaming half can never flash premature UI.
232
541
  *
233
- * ONE unified scan: the tier-1 fixes (quote escaping + trailing-comma drops)
234
- * are folded into the same pass, so bodies that combine BOTH defect classes
542
+ * One shared scan implementation folds the tier-1 fixes (quote escaping +
543
+ * trailing-comma drops) into structural completion, so bodies with BOTH defects
235
544
  * (a trailing comma AND a missing closer) heal in one shot — the old
236
545
  * two-phase chain lost tier-1's partial work when its whole-body parse
237
546
  * failed, and re-scanning the raw text could not compose the repairs.
@@ -259,97 +568,5 @@ export function completeFenceJson(raw: string): { text: string; repairs: number
259
568
  if (scanned === null) return null
260
569
  return { text: scanned.text, repairs: scanned.repairs + tetris.repairs }
261
570
  }
262
- let out = ''
263
- const stack: Array<'}' | ']'> = []
264
- let inString = false
265
- let escaped = false
266
- let repairs = 0
267
- for (let i = 0; i < raw.length; i++) {
268
- const ch = raw[i]
269
- if (escaped) {
270
- out += ch
271
- escaped = false
272
- continue
273
- }
274
- if (inString) {
275
- if (ch === '\\') {
276
- out += ch
277
- escaped = true
278
- continue
279
- }
280
- if (ch !== '"') {
281
- out += ch
282
- continue
283
- }
284
- // Inside a string: is this quote the terminator? Look past whitespace.
285
- let j = i + 1
286
- while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) j++
287
- const next = j < raw.length ? raw[j] : ''
288
- if (next === ',' || next === ']' || next === '}' || next === ':' || next === '') {
289
- inString = false
290
- out += ch
291
- } else {
292
- // Free-standing quote inside a value → escape it (tier-1 fix).
293
- out += '\\"'
294
- repairs++
295
- }
296
- continue
297
- }
298
- if (ch === '"') {
299
- inString = true
300
- out += ch
301
- continue
302
- }
303
- if (ch === '{') {
304
- stack.push('}')
305
- out += ch
306
- continue
307
- }
308
- if (ch === '[') {
309
- stack.push(']')
310
- out += ch
311
- continue
312
- }
313
- if (ch === '}' || ch === ']') {
314
- if (stack[stack.length - 1] === ch) {
315
- stack.pop()
316
- out += ch
317
- } else {
318
- // Mismatched closer (e.g. a `]` mistyped as `}`, or a duplicated
319
- // terminator): no legal JSON can contain it here, so skip it and let
320
- // the remaining closers pair up again. The whole-body parse below is
321
- // the final arbiter — if skipping made things worse, nothing is
322
- // adopted and the diagnostic banner stays.
323
- repairs++
324
- }
325
- continue
326
- }
327
- if (ch === ',') {
328
- // Trailing comma before `}` / `]` / end of input → drop it (tier-1 fix).
329
- let j = i + 1
330
- while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) j++
331
- const next = j < raw.length ? raw[j] : ''
332
- if (next === '}' || next === ']' || next === '') {
333
- repairs++
334
- continue
335
- }
336
- }
337
- out += ch
338
- }
339
- if (inString) {
340
- // Unterminated string value → close it.
341
- out += '"'
342
- repairs++
343
- }
344
- while (stack.length > 0) {
345
- out += stack.pop()
346
- repairs++
347
- }
348
- if (repairs === 0) return null
349
- try {
350
- JSON.parse(out)
351
- return { text: out, repairs }
352
- } catch {
353
- return null
354
- }
571
+ return scanFenceJson(raw, true)
355
572
  }
package/tsdown.config.ts CHANGED
@@ -245,19 +245,15 @@ function assetConfig(name: 'mermaid' | 'three' | 'echarts', entry: string): User
245
245
 
246
246
  const libConfig: UserConfig = {
247
247
  name: ID,
248
- // Named entries keep the published layout stable (lib/index.js +
249
- // lib/invariant.js) while src/index.ts becomes the canonical bundle
250
- // source entry that plugin_check's tool-bundle contract expects.
251
- entry: { index: 'src/index.ts', invariant: 'src/plugin/invariant.ts' },
248
+ // Keep the root plugin bundle at lib/index.js, built from src/index.ts.
249
+ entry: { index: 'src/index.ts' },
252
250
  outDir: 'lib',
253
251
  format: ['esm'],
254
252
  platform: 'node',
255
253
  target: 'es2024',
256
254
  fixedExtension: false,
257
255
  dts: false,
258
- // Flatten entry outputs to lib/<basename>.js so package exports keep the
259
- // stable paths lib/index.js + lib/invariant.js (a directory-mirroring
260
- // default would emit lib/plugin/invariant.js).
256
+ // Keep the output name aligned with the package's root export.
261
257
  outputOptions: { entryFileNames: '[name].js' },
262
258
  // Cleanup happens in the build script (`rm -rf lib` before tsc): tsdown
263
259
  // must never wipe lib/types (tsc's declaration output) mid-pipeline.