dsh-mosaic 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/SKILL.md +153 -0
- package/THIRD_PARTY_NOTICES.md +79 -0
- package/assets/icon-180.png +0 -0
- package/assets/icon.svg +13 -0
- package/assets/screenshots/preview.jpg +0 -0
- package/cordis.patch.yml +10 -0
- package/docs/catalog-entry.example.yml +9 -0
- package/docs/publishing.md +29 -0
- package/lib/assets/licenses/braintree-sanitize-url.LICENSE +21 -0
- package/lib/assets/licenses/chevrotain.LICENSE +202 -0
- package/lib/assets/licenses/cytoscape-cose-bilkent.LICENSE +21 -0
- package/lib/assets/licenses/cytoscape-fcose.LICENSE +19 -0
- package/lib/assets/licenses/cytoscape.LICENSE +19 -0
- package/lib/assets/licenses/d3-sankey.LICENSE +27 -0
- package/lib/assets/licenses/d3.LICENSE +13 -0
- package/lib/assets/licenses/dagre-d3-es.LICENSE +23 -0
- package/lib/assets/licenses/dayjs.LICENSE +21 -0
- package/lib/assets/licenses/dompurify.LICENSE +202 -0
- package/lib/assets/licenses/elkjs.LICENSE +263 -0
- package/lib/assets/licenses/es-toolkit.LICENSE +21 -0
- package/lib/assets/licenses/iconify-utils.LICENSE +21 -0
- package/lib/assets/licenses/katex.LICENSE +21 -0
- package/lib/assets/licenses/khroma.LICENSE +21 -0
- package/lib/assets/licenses/marked.LICENSE +44 -0
- package/lib/assets/licenses/mermaid-js-parser.LICENSE +21 -0
- package/lib/assets/licenses/mermaid.LICENSE +21 -0
- package/lib/assets/licenses/roughjs.LICENSE +21 -0
- package/lib/assets/licenses/stylis.LICENSE +21 -0
- package/lib/assets/licenses/ts-dedent.LICENSE +21 -0
- package/lib/assets/licenses/types-d3.LICENSE +21 -0
- package/lib/assets/licenses/upsetjs-venn.js.LICENSE +22 -0
- package/lib/assets/licenses/uuid.LICENSE +9 -0
- package/lib/assets/mermaid.min.js +4330 -0
- package/lib/client.js +3191 -0
- package/lib/index.js +365 -0
- package/lib/log.js +212 -0
- package/lib/prompt-store.js +354 -0
- package/lib/render-tool.js +162 -0
- package/lib/validate-tool.js +160 -0
- package/lib/validate.js +1070 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -0
- package/screenshots.json +3 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model prompt the plugin contributes, and the routes the settings page uses to
|
|
3
|
+
* read and change it.
|
|
4
|
+
*
|
|
5
|
+
* Precedence: the user's saved override, then the row's `config.prompt`, then the text
|
|
6
|
+
* built into this package. The override lives in the Host's own data directory as one
|
|
7
|
+
* small JSON file, so it survives restarts without touching the profile's composition.
|
|
8
|
+
*
|
|
9
|
+
* The routes are plugin-owned, and `/api` does not cover them, so this module enforces
|
|
10
|
+
* its own policy: a state-changing request must be same-origin, JSON, and confirmable
|
|
11
|
+
* by the browser's `Sec-Fetch-Site` header.
|
|
12
|
+
*/
|
|
13
|
+
import { randomBytes } from 'node:crypto'
|
|
14
|
+
import { mkdir, open as openFile, readFile, rename, unlink } from 'node:fs/promises'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { basename, dirname, join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
export const PROMPT_ROUTE = '/dsh-mosaic/prompt'
|
|
19
|
+
export const PROMPT_LIMIT_BYTES = 128 * 1024
|
|
20
|
+
|
|
21
|
+
/** Where the override lives: the Host data directory this process already owns. */
|
|
22
|
+
export function promptStorePath(env = process.env) {
|
|
23
|
+
const home = typeof env.DSH_HOME === 'string' && env.DSH_HOME !== '' ? env.DSH_HOME : join(homedir(), '.dsh')
|
|
24
|
+
return join(home, 'mosaic', 'prompt.json')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read the saved override and say why it was unusable, if it was.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} path - absolute file path.
|
|
31
|
+
* @returns {Promise<{ prompt?: string, error?: 'corrupt' | 'unreadable' }>} the saved
|
|
32
|
+
* prompt, or the reason there is none. A missing file is not an error.
|
|
33
|
+
*/
|
|
34
|
+
export async function readPromptFile(path) {
|
|
35
|
+
let raw
|
|
36
|
+
try {
|
|
37
|
+
raw = await readFile(path, 'utf8')
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return error && error.code === 'ENOENT' ? {} : { error: 'unreadable' }
|
|
40
|
+
}
|
|
41
|
+
let parsed
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(raw)
|
|
44
|
+
} catch {
|
|
45
|
+
return { error: 'corrupt' }
|
|
46
|
+
}
|
|
47
|
+
const prompt = parsed !== null && typeof parsed === 'object' && typeof parsed.prompt === 'string'
|
|
48
|
+
? parsed.prompt.trim()
|
|
49
|
+
: ''
|
|
50
|
+
// An empty override is how a reset is stored, so it is not an error.
|
|
51
|
+
return prompt === '' ? {} : { prompt }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Persist one override, or clear it when `prompt` is `undefined`.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} path - absolute file path.
|
|
58
|
+
* @param {string | undefined} prompt - the text to store, or `undefined` to clear.
|
|
59
|
+
* @returns {Promise<{ persisted: boolean, reason?: string }>} whether it reached disk.
|
|
60
|
+
*/
|
|
61
|
+
export async function writeStoredPrompt(path, prompt) {
|
|
62
|
+
const body = `${JSON.stringify({
|
|
63
|
+
prompt: prompt === undefined ? '' : prompt,
|
|
64
|
+
updatedAt: new Date().toISOString(),
|
|
65
|
+
}, null, 2)}\n`
|
|
66
|
+
const directory = dirname(path)
|
|
67
|
+
const temp = join(directory, `.${basename(path)}.${randomBytes(6).toString('hex')}.tmp`)
|
|
68
|
+
let handle
|
|
69
|
+
try {
|
|
70
|
+
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
71
|
+
// Same-directory exclusive temp file, fsync, then rename: a crash leaves either the
|
|
72
|
+
// old file or the new one, never a half-written prompt.
|
|
73
|
+
handle = await openFile(temp, 'wx', 0o600)
|
|
74
|
+
await handle.writeFile(body, 'utf8')
|
|
75
|
+
await handle.sync()
|
|
76
|
+
await handle.close()
|
|
77
|
+
handle = undefined
|
|
78
|
+
await rename(temp, path)
|
|
79
|
+
await syncDirectory(directory)
|
|
80
|
+
return { persisted: true }
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (handle !== undefined) {
|
|
83
|
+
try {
|
|
84
|
+
await handle.close()
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
await unlink(temp)
|
|
89
|
+
} catch {}
|
|
90
|
+
return { persisted: false, reason: error instanceof Error ? error.message : String(error) }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Make a renamed entry crash-durable where the platform supports it. */
|
|
95
|
+
async function syncDirectory(directory) {
|
|
96
|
+
if (process.platform === 'win32') return
|
|
97
|
+
let handle
|
|
98
|
+
try {
|
|
99
|
+
handle = await openFile(directory, 'r')
|
|
100
|
+
await handle.sync()
|
|
101
|
+
} catch {
|
|
102
|
+
// Directory fsync is best effort; the rename itself already succeeded.
|
|
103
|
+
} finally {
|
|
104
|
+
if (handle !== undefined) {
|
|
105
|
+
try {
|
|
106
|
+
await handle.close()
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Accept only a plausible prompt body; anything else is a client bug. */
|
|
113
|
+
export function parsePromptBody(body) {
|
|
114
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) return { ok: false, error: 'invalid_body' }
|
|
115
|
+
if (body.reset === true) return { ok: true, reset: true }
|
|
116
|
+
if (typeof body.prompt !== 'string') return { ok: false, error: 'prompt_required' }
|
|
117
|
+
const prompt = body.prompt.trim()
|
|
118
|
+
if (prompt === '') return { ok: false, error: 'prompt_required' }
|
|
119
|
+
if (Buffer.byteLength(prompt, 'utf8') > PROMPT_LIMIT_BYTES) return { ok: false, error: 'prompt_too_large' }
|
|
120
|
+
return { ok: true, prompt }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reject a state-changing request that a foreign page could have triggered.
|
|
125
|
+
*
|
|
126
|
+
* `Sec-Fetch-Site` is sent by the browser and cannot be forged by page script; a
|
|
127
|
+
* foreign origin also fails the `Origin` comparison, and a JSON content type forces a
|
|
128
|
+
* preflight that this route never approves.
|
|
129
|
+
*
|
|
130
|
+
* @param {import('node:http').IncomingMessage} req - the request under test.
|
|
131
|
+
* @returns {string | undefined} a rejection reason, or `undefined` when allowed.
|
|
132
|
+
*/
|
|
133
|
+
export function crossSiteError(req) {
|
|
134
|
+
const site = req.headers['sec-fetch-site']
|
|
135
|
+
if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return 'cross_site'
|
|
136
|
+
const origin = req.headers.origin
|
|
137
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
138
|
+
let host
|
|
139
|
+
try {
|
|
140
|
+
host = new URL(origin).host
|
|
141
|
+
} catch {
|
|
142
|
+
return 'invalid_origin'
|
|
143
|
+
}
|
|
144
|
+
if (host !== req.headers.host) return 'cross_site'
|
|
145
|
+
}
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Policy for a state-changing request: same-origin plus a JSON body. */
|
|
150
|
+
export function requestPolicyError(req) {
|
|
151
|
+
const crossSite = crossSiteError(req)
|
|
152
|
+
if (crossSite !== undefined) return crossSite
|
|
153
|
+
const type = req.headers['content-type']
|
|
154
|
+
if (typeof type !== 'string' || !type.toLowerCase().startsWith('application/json')) return 'json_required'
|
|
155
|
+
return undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Read a small JSON request body. */
|
|
159
|
+
export async function readJsonBody(req, limit = PROMPT_LIMIT_BYTES + 1024) {
|
|
160
|
+
const chunks = []
|
|
161
|
+
let total = 0
|
|
162
|
+
for await (const chunk of req) {
|
|
163
|
+
total += chunk.length
|
|
164
|
+
if (total > limit) throw new Error('body_too_large')
|
|
165
|
+
chunks.push(chunk)
|
|
166
|
+
}
|
|
167
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
168
|
+
if (text.trim() === '') return {}
|
|
169
|
+
return JSON.parse(text)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sendJson(res, status, body) {
|
|
173
|
+
if (typeof res.setHeader === 'function') {
|
|
174
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
175
|
+
res.setHeader('cache-control', 'no-store')
|
|
176
|
+
}
|
|
177
|
+
res.statusCode = status
|
|
178
|
+
res.end(JSON.stringify(body))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Own the effective prompt: resolve its source, re-register the prompt section on every
|
|
183
|
+
* change, and answer the two routes the settings page calls.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} options - wiring.
|
|
186
|
+
* @param {object} options.ctx - the plugin context (provides `systemPrompt`).
|
|
187
|
+
* @param {unknown} options.config - the Loader row's config.
|
|
188
|
+
* @param {(config: unknown) => string} options.resolveConfigPrompt - config precedence.
|
|
189
|
+
* @param {string} options.builtin - the text shipped in this package.
|
|
190
|
+
* @param {(ctx: object, text: string) => () => void} options.registerSection - registers
|
|
191
|
+
* the prompt section and returns its disposer.
|
|
192
|
+
* @param {string} [options.path] - override file path; defaults to the Host data dir.
|
|
193
|
+
*/
|
|
194
|
+
export function createPromptController(options) {
|
|
195
|
+
const { ctx, config, resolveConfigPrompt, builtin, registerSection } = options
|
|
196
|
+
// A logger is optional so the controller stays testable without one.
|
|
197
|
+
const log = options.log ?? { info() {}, warn() {}, error() {} }
|
|
198
|
+
const path = options.path ?? promptStorePath()
|
|
199
|
+
// The Host owns its route layout; publishing the asset URL here keeps an older
|
|
200
|
+
// browser half working after a route change.
|
|
201
|
+
const assets = options.assets ?? {}
|
|
202
|
+
|
|
203
|
+
const state = {
|
|
204
|
+
/** Saved by the user in this profile, or `undefined`. */
|
|
205
|
+
user: undefined,
|
|
206
|
+
/** From the row's `config.prompt`, or `undefined`. */
|
|
207
|
+
configured: resolveConfigPrompt(config) === builtin ? undefined : resolveConfigPrompt(config),
|
|
208
|
+
/** Whether the last write reached disk. */
|
|
209
|
+
persisted: false,
|
|
210
|
+
/** Why the last write failed, when it did. */
|
|
211
|
+
persistenceError: undefined,
|
|
212
|
+
/** Why the saved override could not be used, when it could not. */
|
|
213
|
+
readError: undefined,
|
|
214
|
+
path,
|
|
215
|
+
/** Disposer of the currently registered section. */
|
|
216
|
+
dispose: undefined,
|
|
217
|
+
/** False after the owning plugin is disposed; no registration may happen then. */
|
|
218
|
+
active: false,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Prompt writes are serialized so the file always ends with the last accepted value.
|
|
222
|
+
let writes = Promise.resolve()
|
|
223
|
+
|
|
224
|
+
const effective = () => state.user ?? state.configured ?? builtin
|
|
225
|
+
const source = () => (state.user !== undefined ? 'user' : state.configured !== undefined ? 'config' : 'builtin')
|
|
226
|
+
|
|
227
|
+
function snapshot() {
|
|
228
|
+
return {
|
|
229
|
+
prompt: effective(),
|
|
230
|
+
defaultPrompt: builtin,
|
|
231
|
+
configuredPrompt: state.configured,
|
|
232
|
+
customized: source() !== 'builtin',
|
|
233
|
+
source: source(),
|
|
234
|
+
persisted: state.persisted,
|
|
235
|
+
...state.persistenceError === undefined ? {} : { persistenceError: state.persistenceError },
|
|
236
|
+
...state.readError === undefined ? {} : { readError: state.readError },
|
|
237
|
+
assets,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function applySection() {
|
|
242
|
+
if (!state.active) return
|
|
243
|
+
if (typeof state.dispose === 'function') {
|
|
244
|
+
try {
|
|
245
|
+
state.dispose()
|
|
246
|
+
} catch {}
|
|
247
|
+
}
|
|
248
|
+
state.dispose = registerSection(ctx, effective())
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
path,
|
|
253
|
+
snapshot,
|
|
254
|
+
/** Register the initial section; the returned disposer revokes whatever is live. */
|
|
255
|
+
start() {
|
|
256
|
+
state.active = true
|
|
257
|
+
applySection()
|
|
258
|
+
return () => {
|
|
259
|
+
if (!state.active) return
|
|
260
|
+
state.active = false
|
|
261
|
+
const dispose = state.dispose
|
|
262
|
+
state.dispose = undefined
|
|
263
|
+
if (typeof dispose === 'function') {
|
|
264
|
+
try {
|
|
265
|
+
dispose()
|
|
266
|
+
} catch {}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
/** Pick up a saved override on activation. */
|
|
271
|
+
async hydrate() {
|
|
272
|
+
const stored = await readPromptFile(state.path)
|
|
273
|
+
// A `start()` may have been revoked, or a save may have landed first.
|
|
274
|
+
if (!state.active || state.user !== undefined) return snapshot()
|
|
275
|
+
if (stored.error !== undefined) {
|
|
276
|
+
// Keep the file: the user may want to recover it, and a later save overwrites it.
|
|
277
|
+
state.readError = stored.error
|
|
278
|
+
log.warn('prompt.read-failed', { reason: stored.error })
|
|
279
|
+
return snapshot()
|
|
280
|
+
}
|
|
281
|
+
if (stored.prompt === undefined) {
|
|
282
|
+
log.info('prompt.hydrate', { source: source(), stored: false })
|
|
283
|
+
return snapshot()
|
|
284
|
+
}
|
|
285
|
+
state.user = stored.prompt
|
|
286
|
+
state.persisted = true
|
|
287
|
+
applySection()
|
|
288
|
+
log.info('prompt.hydrate', { source: source(), stored: true, chars: stored.prompt.length })
|
|
289
|
+
return snapshot()
|
|
290
|
+
},
|
|
291
|
+
/** Apply one accepted body and persist it. */
|
|
292
|
+
async update(body) {
|
|
293
|
+
if (!state.active) {
|
|
294
|
+
log.warn('prompt.update-rejected', { reason: 'inactive' })
|
|
295
|
+
return { status: 503, body: { error: 'inactive' } }
|
|
296
|
+
}
|
|
297
|
+
const parsed = parsePromptBody(body)
|
|
298
|
+
if (!parsed.ok) {
|
|
299
|
+
log.warn('prompt.update-rejected', { reason: parsed.error })
|
|
300
|
+
return { status: 400, body: { error: parsed.error } }
|
|
301
|
+
}
|
|
302
|
+
const next = parsed.reset ? undefined : parsed.prompt
|
|
303
|
+
state.user = next
|
|
304
|
+
const write = await (writes = writes.then(() => writeStoredPrompt(state.path, next)))
|
|
305
|
+
if (!state.active) return { status: 503, body: { error: 'inactive' } }
|
|
306
|
+
state.persisted = write.persisted
|
|
307
|
+
state.persistenceError = write.reason
|
|
308
|
+
state.readError = undefined
|
|
309
|
+
applySection()
|
|
310
|
+
if (write.persisted) {
|
|
311
|
+
log.info('prompt.update', { reset: next === undefined, source: source(), chars: next === undefined ? 0 : next.length })
|
|
312
|
+
} else {
|
|
313
|
+
log.warn('prompt.persist-failed', { reason: write.reason })
|
|
314
|
+
}
|
|
315
|
+
return { status: 200, body: snapshot() }
|
|
316
|
+
},
|
|
317
|
+
/**
|
|
318
|
+
* One HTTP handler for both methods.
|
|
319
|
+
*
|
|
320
|
+
* @param {import('node:http').IncomingMessage} req - request.
|
|
321
|
+
* @param {import('node:http').ServerResponse} res - response.
|
|
322
|
+
*/
|
|
323
|
+
async handle(req, res) {
|
|
324
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
325
|
+
const readPolicy = crossSiteError(req)
|
|
326
|
+
if (readPolicy !== undefined) {
|
|
327
|
+
sendJson(res, 403, { error: readPolicy })
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
sendJson(res, 200, snapshot())
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
if (req.method !== 'POST') {
|
|
334
|
+
sendJson(res, 405, { error: 'method_not_allowed' })
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
const policy = requestPolicyError(req)
|
|
338
|
+
if (policy !== undefined) {
|
|
339
|
+
sendJson(res, 403, { error: policy })
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
let body
|
|
343
|
+
try {
|
|
344
|
+
body = await readJsonBody(req)
|
|
345
|
+
} catch (error) {
|
|
346
|
+
const tooLarge = error instanceof Error && error.message === 'body_too_large'
|
|
347
|
+
sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'body_too_large' : 'invalid_json' })
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
const result = await this.update(body)
|
|
351
|
+
sendJson(res, result.status, result.body)
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `render_view` tool: a model-facing channel that renders a compact
|
|
3
|
+
* `dsh-view` spec as an interactive card in the conversation tool row.
|
|
4
|
+
*
|
|
5
|
+
* `execute` returns the canonical JSON value the Session persists; `output.render`
|
|
6
|
+
* turns that value into the protocol text the model reads, and
|
|
7
|
+
* `output.presentationMeta` projects the card data from the same value, so a replay
|
|
8
|
+
* renders identically without re-deriving anything from the arguments.
|
|
9
|
+
*/
|
|
10
|
+
import { isRenderable, jsonSafe, normalizeView } from './validate.js'
|
|
11
|
+
|
|
12
|
+
/** One normalized view spec, as the browser renders it. */
|
|
13
|
+
const SPEC_SCHEMA = {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
title: { type: 'string', description: 'Short card title.' },
|
|
17
|
+
gap: { type: 'integer', description: 'Vertical gap between root items in px (0-48).' },
|
|
18
|
+
items: {
|
|
19
|
+
type: 'array',
|
|
20
|
+
items: { type: 'object', additionalProperties: true },
|
|
21
|
+
description: 'Root component list (see the mosaic skill for fields).',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: ['items'],
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PARAMETERS = {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
spec: {
|
|
32
|
+
...SPEC_SCHEMA,
|
|
33
|
+
description: [
|
|
34
|
+
'Render a compact visual answer in the tool row. Use it when a structured view materially helps: a dashboard, a comparison table, a chart, a checklist, a quiz, or a sandboxed interactive widget.',
|
|
35
|
+
'The vocabulary is the same as the dsh-view fence. Prefer the fence when the UI belongs inline in the reply; use this tool when the deliverable is a self-contained card.',
|
|
36
|
+
'Pass spec as a JSON object, never as a serialized string.',
|
|
37
|
+
].join(' '),
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ['spec'],
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const OUTPUT_SCHEMA = {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
status: { type: 'string', enum: ['rendered', 'invalid'] },
|
|
48
|
+
title: { type: 'string' },
|
|
49
|
+
rendered: { type: 'integer' },
|
|
50
|
+
spec: SPEC_SCHEMA,
|
|
51
|
+
errors: { type: 'array', items: { type: 'string' } },
|
|
52
|
+
},
|
|
53
|
+
required: ['status'],
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Unwrap the shapes a model call or a session replay may hand to this tool. */
|
|
58
|
+
function unwrap(value) {
|
|
59
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
|
60
|
+
if ('spec' in value) return unwrap(value.spec)
|
|
61
|
+
if ('arguments' in value) return unwrap(value.arguments)
|
|
62
|
+
return value
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function specOf(args) {
|
|
66
|
+
if (typeof args === 'string') {
|
|
67
|
+
try {
|
|
68
|
+
return unwrap(JSON.parse(args))
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!args || typeof args !== 'object') return undefined
|
|
74
|
+
return unwrap(args)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A raw JSON-Schema tool owns its input validation: a call whose arguments are not the
|
|
79
|
+
* declared object fails the call instead of reporting a card that can never render.
|
|
80
|
+
*/
|
|
81
|
+
function assertArguments(args) {
|
|
82
|
+
const spec = specOf(args)
|
|
83
|
+
if (spec === undefined || spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
84
|
+
throw new Error('invalid arguments: expected { spec: { items: [...] } } with spec as a JSON object')
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(spec.items) && typeof spec.type !== 'string') {
|
|
87
|
+
throw new Error('invalid arguments: spec must carry an `items` array (one component object is also accepted)')
|
|
88
|
+
}
|
|
89
|
+
return spec
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The canonical value: declared JSON data, never prose a caller must parse. */
|
|
93
|
+
function canonicalValue(args) {
|
|
94
|
+
const result = normalizeView(assertArguments(args))
|
|
95
|
+
if (!isRenderable(result)) return { status: 'invalid', errors: result.errors }
|
|
96
|
+
const spec = result.spec
|
|
97
|
+
return {
|
|
98
|
+
status: 'rendered',
|
|
99
|
+
...typeof spec.title === 'string' ? { title: spec.title } : {},
|
|
100
|
+
rendered: spec.items.length,
|
|
101
|
+
spec: jsonSafe(spec),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Model-facing summary of one canonical value. */
|
|
106
|
+
function protocolOf(value) {
|
|
107
|
+
if (value.status === 'invalid') {
|
|
108
|
+
return [
|
|
109
|
+
'[mosaic-render]',
|
|
110
|
+
'status=invalid',
|
|
111
|
+
'error=invalid_spec',
|
|
112
|
+
`detail=${JSON.stringify((value.errors ?? []).join('; '))}`,
|
|
113
|
+
'next=fix_and_retry',
|
|
114
|
+
'reply_language=conversation',
|
|
115
|
+
].join('\n')
|
|
116
|
+
}
|
|
117
|
+
return [
|
|
118
|
+
'[mosaic-render]',
|
|
119
|
+
'status=rendered',
|
|
120
|
+
...(typeof value.title === 'string' ? [`title=${JSON.stringify(value.title)}`] : []),
|
|
121
|
+
`rendered=${value.rendered}`,
|
|
122
|
+
'action_feedback=[mosaic-action]',
|
|
123
|
+
'reply_language=conversation',
|
|
124
|
+
].join('\n')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function titleOf(value) {
|
|
128
|
+
return typeof value?.title === 'string' ? `Render view: ${value.title}` : 'Render view'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createRenderViewTool() {
|
|
132
|
+
return {
|
|
133
|
+
name: 'render_view',
|
|
134
|
+
description:
|
|
135
|
+
'Render a compact visual answer as an interactive card in the conversation tool row. '
|
|
136
|
+
+ 'Use it when a structured view materially helps; plain prose is enough otherwise. '
|
|
137
|
+
+ 'Use it for a dashboard, comparison table, chart, diagram, checklist, quiz, media card, or a sandboxed HTML/JS widget. '
|
|
138
|
+
+ 'The vocabulary is the same as the dsh-view fence; use the fence for inline UI and this tool for a self-contained card. '
|
|
139
|
+
+ 'Components with an action send [mosaic-action] back to you; components without one stay local.',
|
|
140
|
+
parameters: PARAMETERS,
|
|
141
|
+
output: {
|
|
142
|
+
schema: OUTPUT_SCHEMA,
|
|
143
|
+
render(_args, value) {
|
|
144
|
+
return [{ type: 'text', text: protocolOf(value) }]
|
|
145
|
+
},
|
|
146
|
+
presentationMeta(_args, value) {
|
|
147
|
+
return value.status === 'rendered' ? jsonSafe(value.spec) : undefined
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
async execute(args) {
|
|
151
|
+
return canonicalValue(args)
|
|
152
|
+
},
|
|
153
|
+
presentCall(args) {
|
|
154
|
+
const spec = specOf(args)
|
|
155
|
+
const title = spec && typeof spec === 'object' && typeof spec.title === 'string' ? spec.title : undefined
|
|
156
|
+
return { card: 'generic', title: title ? `Render view: ${title}` : 'Render view', kind: 'other' }
|
|
157
|
+
},
|
|
158
|
+
presentResult(_args, value) {
|
|
159
|
+
return { card: 'generic', title: titleOf(value) }
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `validate_view` tool: a preflight check for the dsh-view fence.
|
|
3
|
+
* It parses the exact JSON body the model is about to emit and returns a
|
|
4
|
+
* stable protocol with path-level diagnostics.
|
|
5
|
+
*/
|
|
6
|
+
import { describeFailure, isRenderable, normalizeView } from './validate.js'
|
|
7
|
+
|
|
8
|
+
const PARAMETERS = {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
spec: {
|
|
12
|
+
oneOf: [
|
|
13
|
+
{ type: 'string', description: 'The exact JSON text of the dsh-view fence body.' },
|
|
14
|
+
{ type: 'object', description: 'The spec object (serialized before validation).' },
|
|
15
|
+
],
|
|
16
|
+
description: 'Pass the exact fence body as a string for an exact check, or the spec object.',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
required: ['spec'],
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const OUTPUT_SCHEMA = {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
status: { type: 'string', enum: ['valid', 'invalid'] },
|
|
27
|
+
error: { type: 'string' },
|
|
28
|
+
detail: { type: 'string' },
|
|
29
|
+
rendered: { type: 'integer' },
|
|
30
|
+
next: { type: 'string' },
|
|
31
|
+
braceDelta: { type: 'integer' },
|
|
32
|
+
bracketDelta: { type: 'integer' },
|
|
33
|
+
likelyCauses: { type: 'array', items: { type: 'string' } },
|
|
34
|
+
},
|
|
35
|
+
required: ['status', 'next'],
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function rawOf(args) {
|
|
40
|
+
if (typeof args === 'string') return args
|
|
41
|
+
if (!args || typeof args !== 'object') return null
|
|
42
|
+
const value = 'spec' in args ? args.spec : 'arguments' in args ? args.arguments : undefined
|
|
43
|
+
if (typeof value === 'string') return value
|
|
44
|
+
if (value && typeof value === 'object') return JSON.stringify(value)
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function bracketDiagnostic(raw) {
|
|
49
|
+
let braces = 0
|
|
50
|
+
let brackets = 0
|
|
51
|
+
let inString = false
|
|
52
|
+
let escaped = false
|
|
53
|
+
for (const character of raw) {
|
|
54
|
+
if (escaped) {
|
|
55
|
+
escaped = false
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
if (inString) {
|
|
59
|
+
if (character === '\\') escaped = true
|
|
60
|
+
else if (character === '"') inString = false
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (character === '"') inString = true
|
|
64
|
+
else if (character === '{') braces += 1
|
|
65
|
+
else if (character === '}') braces -= 1
|
|
66
|
+
else if (character === '[') brackets += 1
|
|
67
|
+
else if (character === ']') brackets -= 1
|
|
68
|
+
}
|
|
69
|
+
return [
|
|
70
|
+
`brace_delta=${braces}`,
|
|
71
|
+
`bracket_delta=${brackets}`,
|
|
72
|
+
...(braces === 0 && brackets === 0 ? [] : ['likely_causes=unbalanced_delimiters,trailing_comma,unescaped_quote']),
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The canonical value: declared JSON, so a caller never parses prose for a verdict. */
|
|
77
|
+
function verdictOf(lines) {
|
|
78
|
+
const value = { status: 'invalid', next: 'fix_and_revalidate' }
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const index = line.indexOf('=')
|
|
81
|
+
if (index < 0) continue
|
|
82
|
+
const key = line.slice(0, index)
|
|
83
|
+
const rest = line.slice(index + 1)
|
|
84
|
+
if (key === 'status') value.status = rest
|
|
85
|
+
else if (key === 'error') value.error = rest
|
|
86
|
+
else if (key === 'detail') value.detail = rest
|
|
87
|
+
else if (key === 'next') value.next = rest
|
|
88
|
+
else if (key === 'rendered') value.rendered = Number(rest)
|
|
89
|
+
else if (key === 'brace_delta') value.braceDelta = Number(rest)
|
|
90
|
+
else if (key === 'bracket_delta') value.bracketDelta = Number(rest)
|
|
91
|
+
else if (key === 'likely_causes') value.likelyCauses = rest.split(',')
|
|
92
|
+
}
|
|
93
|
+
return value
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function protocolOf(value) {
|
|
97
|
+
const lines = ['[mosaic-validation]', `status=${value.status}`]
|
|
98
|
+
if (value.error) lines.push(`error=${value.error}`)
|
|
99
|
+
if (value.detail) lines.push(`detail=${JSON.stringify(value.detail)}`)
|
|
100
|
+
if (value.rendered !== undefined) lines.push(`rendered=${value.rendered}`)
|
|
101
|
+
if (value.braceDelta !== undefined) lines.push(`brace_delta=${value.braceDelta}`, `bracket_delta=${value.bracketDelta}`)
|
|
102
|
+
if (value.likelyCauses) lines.push(`likely_causes=${value.likelyCauses.join(',')}`)
|
|
103
|
+
lines.push(`next=${value.next}`, 'reply_language=conversation')
|
|
104
|
+
return lines.join('\n')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isValid(value) {
|
|
108
|
+
return value.status === 'valid'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createValidateViewTool() {
|
|
112
|
+
return {
|
|
113
|
+
name: 'validate_view',
|
|
114
|
+
description:
|
|
115
|
+
'Validate a dsh-view fence body before emitting it. Useful for non-trivial specs (>=3 components or containing a table/widget/diagram). '
|
|
116
|
+
+ 'Returns a [mosaic-validation] block with status, path-level diagnostics, and next action. '
|
|
117
|
+
+ 'When invalid JSON is caused by a trailing comma or an unescaped quote, fix it and validate again.',
|
|
118
|
+
parameters: PARAMETERS,
|
|
119
|
+
output: {
|
|
120
|
+
schema: OUTPUT_SCHEMA,
|
|
121
|
+
render(_args, value) {
|
|
122
|
+
return [{ type: 'text', text: protocolOf(value) }]
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
async execute(args) {
|
|
126
|
+
const raw = rawOf(args)
|
|
127
|
+
if (raw === null || raw.trim() === '') return verdictOf(['status=invalid', 'error=missing_spec', 'next=provide_spec'])
|
|
128
|
+
|
|
129
|
+
let parsed
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(raw)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return verdictOf([
|
|
134
|
+
'status=invalid',
|
|
135
|
+
'error=invalid_json',
|
|
136
|
+
`detail=${error instanceof Error ? error.message : String(error)}`,
|
|
137
|
+
...bracketDiagnostic(raw),
|
|
138
|
+
'next=fix_and_revalidate',
|
|
139
|
+
])
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const result = normalizeView(parsed)
|
|
143
|
+
if (!isRenderable(result)) {
|
|
144
|
+
return verdictOf([
|
|
145
|
+
'status=invalid',
|
|
146
|
+
'error=invalid_spec',
|
|
147
|
+
`detail=${describeFailure(result)}`,
|
|
148
|
+
'next=fix_and_revalidate',
|
|
149
|
+
])
|
|
150
|
+
}
|
|
151
|
+
return verdictOf(['status=valid', `rendered=${result.spec.items.length}`, 'next=emit_fence'])
|
|
152
|
+
},
|
|
153
|
+
presentCall() {
|
|
154
|
+
return { card: 'generic', title: 'Validate dsh-view', kind: 'other' }
|
|
155
|
+
},
|
|
156
|
+
presentResult(_args, value) {
|
|
157
|
+
return { card: 'generic', title: isValid(value) ? 'Validate dsh-view: valid' : 'Validate dsh-view: invalid' }
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|