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
package/lib/index.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mosaic host half.
|
|
3
|
+
*
|
|
4
|
+
* Adds a compact visual-answer section to the system prompt, two model tools
|
|
5
|
+
* (`render_view`, `validate_view`), and a bundled `mosaic` skill with the full
|
|
6
|
+
* field reference. The browser half renders the `dsh-view` fence and the
|
|
7
|
+
* `render_view` tool card; it ships no heavy chart or diagram engines.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { createRequire } from 'node:module'
|
|
11
|
+
import { readFile } from 'node:fs/promises'
|
|
12
|
+
import { dirname, resolve } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { createLog } from './log.js'
|
|
15
|
+
import { createPromptController, crossSiteError, PROMPT_ROUTE, readJsonBody } from './prompt-store.js'
|
|
16
|
+
import { createRenderViewTool } from './render-tool.js'
|
|
17
|
+
import { createValidateViewTool } from './validate-tool.js'
|
|
18
|
+
|
|
19
|
+
const PACKAGE_VERSION = createRequire(import.meta.url)('../package.json').version
|
|
20
|
+
|
|
21
|
+
export const name = 'dsh-mosaic'
|
|
22
|
+
export const inject = ['systemPrompt']
|
|
23
|
+
|
|
24
|
+
/* ------------------------------------------------------------------ */
|
|
25
|
+
/* lazy Mermaid engine */
|
|
26
|
+
/* ------------------------------------------------------------------ */
|
|
27
|
+
|
|
28
|
+
// `/plugins` belongs to the client module system, so a plugin asset route keeps to a
|
|
29
|
+
// package-private prefix and never shadows or collides with it.
|
|
30
|
+
const ASSET_ROUTE_PATH = '/dsh-mosaic/assets'
|
|
31
|
+
const ASSET_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
|
|
32
|
+
// The unmodified official Mermaid distribution; see THIRD_PARTY_NOTICES.md.
|
|
33
|
+
const MERMAID_ASSET_FILE = 'mermaid.min.js'
|
|
34
|
+
const MERMAID_VERSION = '12.2.0'
|
|
35
|
+
// Client-side diagnostics land here; bounded body, same-origin only.
|
|
36
|
+
const LOG_ROUTE = '/dsh-mosaic/log'
|
|
37
|
+
const LOG_BODY_LIMIT = 4096
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the prompt this plugin contributes to the system prompt. The row's
|
|
41
|
+
* `config.prompt` overrides the built-in text; an absent, blank, oversized, or
|
|
42
|
+
* non-string value falls back to it, so a malformed patch can never silence the
|
|
43
|
+
* visualization guidance.
|
|
44
|
+
*
|
|
45
|
+
* @param {unknown} config - the Loader row's config.
|
|
46
|
+
* @returns {string} the prompt to contribute.
|
|
47
|
+
*/
|
|
48
|
+
export function resolvePrompt(config) {
|
|
49
|
+
const prompt = config !== null && typeof config === 'object' ? config.prompt : undefined
|
|
50
|
+
if (typeof prompt !== 'string') return MOSAIC_SECTION_TEXT
|
|
51
|
+
const trimmed = prompt.trim()
|
|
52
|
+
if (trimmed === '' || Buffer.byteLength(trimmed, 'utf8') > 128 * 1024) return MOSAIC_SECTION_TEXT
|
|
53
|
+
return trimmed
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function serveMosaicAsset(req, res) {
|
|
57
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
58
|
+
res.writeHead(405)
|
|
59
|
+
res.end()
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
let pathname
|
|
63
|
+
try {
|
|
64
|
+
pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
|
|
65
|
+
} catch {
|
|
66
|
+
res.writeHead(400)
|
|
67
|
+
res.end()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (!pathname.startsWith(`${ASSET_ROUTE_PATH}/`)) {
|
|
71
|
+
res.writeHead(404)
|
|
72
|
+
res.end()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
const file = pathname.slice(ASSET_ROUTE_PATH.length + 1)
|
|
76
|
+
if (!ASSET_FILE_RE.test(file)) {
|
|
77
|
+
res.writeHead(404)
|
|
78
|
+
res.end()
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const moduleDirectory = dirname(fileURLToPath(import.meta.url))
|
|
83
|
+
const body = await readFile(resolve(moduleDirectory, 'assets', file))
|
|
84
|
+
const versioned = new URL(req.url ?? '/', 'http://x').searchParams.has('v')
|
|
85
|
+
res.writeHead(200, {
|
|
86
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
87
|
+
// The client appends ?v=<version>, so a versioned URL can be cached for good;
|
|
88
|
+
// anything else keeps revalidating.
|
|
89
|
+
'cache-control': versioned ? 'public, max-age=31536000, immutable' : 'no-cache',
|
|
90
|
+
})
|
|
91
|
+
res.end(body)
|
|
92
|
+
} catch {
|
|
93
|
+
res.writeHead(404)
|
|
94
|
+
res.end()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const MOSAIC_SECTION_TEXT = `Render a visual answer with a fenced \`dsh-view\` JSON block, or with the \`render_view\` tool when a self-contained card is useful.
|
|
99
|
+
|
|
100
|
+
\`\`\`dsh-view
|
|
101
|
+
{"title":"Ops snapshot","gap":12,"items":[{"type":"stat","label":"Uptime","value":"99.96%"}]}
|
|
102
|
+
\`\`\`
|
|
103
|
+
|
|
104
|
+
Mosaic is optional. Use it when a component makes the answer easier to scan; plain prose is fine. Do not add a component just to satisfy a format. When you do use one, prefer a single focal component and do not repeat the same data as both a table and a chart.
|
|
105
|
+
|
|
106
|
+
Components (fields in the \`mosaic\` skill):
|
|
107
|
+
- Layout: text · row · col · grid · card · divider · spacer
|
|
108
|
+
- Display: stat · badge · progress · list · table · keyvalue · steps · callout · code · diff · chart · diagram
|
|
109
|
+
- Media: image · video · audio · pdf · link
|
|
110
|
+
- Interactive: button · input · select · slider · tabs · quiz
|
|
111
|
+
- Advanced: widget (HTML/CSS/JS in an opaque-origin, no-network sandbox iframe)
|
|
112
|
+
|
|
113
|
+
Rules:
|
|
114
|
+
- Put \`dsh-view\` in the reply body, never in reasoning. JSON must be strict.
|
|
115
|
+
- The opening fence tag MUST be exactly \`dsh-view\`. Never use \`\`\`json, \`\`\`text, or an unlabelled fence for a full spec; a \`json\` fence is at best a legacy fallback and may not render.
|
|
116
|
+
- Files and Office documents: use a \`link\` card. Mosaic does not read local files or render Office; the host app opens them.
|
|
117
|
+
- Media accepts an http(s) or root-relative URL.
|
|
118
|
+
- Interactions are local by default. A button \`action\` sends \`[mosaic-action]\`; \`local\` supports reset/copy/open/fullscreen. A button with neither is disabled.
|
|
119
|
+
- When you show a flow, sequence, or layered architecture, use \`diagram\` (\`flowchart\`, \`sequence\`, \`timeline\`, \`layers\`); do not hand-write Mermaid unless it is inside a widget. For a long flowchart (more than 6 nodes), prefer \`direction:"TB"\`; \`LR\` becomes horizontally scrollable.
|
|
120
|
+
- Limits: 80 nodes, depth 6, table 40×8, chart 40 points, diagram 12 nodes/20 edges, widget code 20k. \`validate_view\` is available for a preflight check on complex specs.
|
|
121
|
+
|
|
122
|
+
Field quick reference:
|
|
123
|
+
- text {content, size, align, tone, mono}
|
|
124
|
+
- stat {label, value, delta, spark:[numbers]}
|
|
125
|
+
- table {columns:[strings], rows:[[cells]], types:[text|num|delta|bar|badge], total, export, filter:<field id>, sort:<field id>}
|
|
126
|
+
- chart {kind:bars|line|donut, data:[{label,value}], series:[{label,data:[{label,value}]}], stacked}
|
|
127
|
+
- diagram {kind:flowchart|sequence|timeline|layers, direction:TB|LR, nodes:[{id,label,sub,tone,shape}], edges:[{from,to,label,kind}], participants:[{id,label}], messages:[{from,to,label,kind}], items:[{title,desc,time}], layers:[{title,items}]}
|
|
128
|
+
- callout {content, title, tone:info|success|warning|error}
|
|
129
|
+
- list {items: strings | {title,desc} | components}
|
|
130
|
+
- button {label, action | local:reset|copy|open|fullscreen, text, url, tone}
|
|
131
|
+
- input/select/slider {id, label, ...}; table/list filter binds to an input/select id
|
|
132
|
+
- quiz {question, options:[{label,correct,feedback}], explanation}
|
|
133
|
+
- widget {code, height, title, caption}
|
|
134
|
+
- link {label, href, description}; image/video/audio/pdf {src, ...}
|
|
135
|
+
|
|
136
|
+
The \`mosaic\` skill carries the full component table, examples, and the widget bridge (\`window.mosaic.action\`, \`window.mosaic.resize\`).`
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Externally chosen finite order: `getSectionOrder(name)` resolves centrally allocated
|
|
140
|
+
* positions reserved for repository-owned contributors, so a plugin names its own slot.
|
|
141
|
+
* 9800 sits after the deliverable guidance and before the structured-output section.
|
|
142
|
+
*/
|
|
143
|
+
const PROMPT_SECTION_ORDER = 9800
|
|
144
|
+
|
|
145
|
+
function registerPromptSection(ctx, text) {
|
|
146
|
+
return ctx.systemPrompt.section({
|
|
147
|
+
name: 'mosaic:view',
|
|
148
|
+
order: PROMPT_SECTION_ORDER,
|
|
149
|
+
text,
|
|
150
|
+
interpolate: false,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const BUNDLED_SKILL_PROVIDER = 'dsh-mosaic'
|
|
155
|
+
const BUNDLED_SKILL_NAME = 'mosaic'
|
|
156
|
+
// Lower than the packaged-skill standard rank (600) on purpose: a user-installed skill
|
|
157
|
+
// named `mosaic` should win over this plugin's guidance copy.
|
|
158
|
+
const MOSAIC_SKILL_RANK = 550
|
|
159
|
+
const BUNDLED_SKILL_INVOCATION = { modelInvocable: true, userInvocable: true }
|
|
160
|
+
|
|
161
|
+
function frontmatterField(front, key) {
|
|
162
|
+
const match = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'mu').exec(front)
|
|
163
|
+
return match?.[1]?.replace(/^['"]|['"]$/gu, '')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function bundledSkillProvider() {
|
|
167
|
+
const skill = bundledSkill()
|
|
168
|
+
return {
|
|
169
|
+
name: BUNDLED_SKILL_PROVIDER,
|
|
170
|
+
// A missing or malformed SKILL.md reports an incomplete catalog instead of
|
|
171
|
+
// throwing at plugin activation, so the fence and tool channels keep working.
|
|
172
|
+
list: () => Promise.resolve({
|
|
173
|
+
candidates: skill === undefined ? [] : [skillEntry(skill)],
|
|
174
|
+
complete: skill !== undefined,
|
|
175
|
+
}),
|
|
176
|
+
get: (candidate) => {
|
|
177
|
+
if (candidate === undefined) return Promise.resolve(undefined)
|
|
178
|
+
const current = bundledSkill()
|
|
179
|
+
if (current === undefined) return Promise.resolve(undefined)
|
|
180
|
+
return Promise.resolve({ ...skillEntry(current), content: current.raw.slice(current.end + 5) })
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Read the bundled skill, or `undefined` when it cannot be used. */
|
|
186
|
+
function bundledSkill() {
|
|
187
|
+
const moduleDirectory = dirname(fileURLToPath(import.meta.url))
|
|
188
|
+
const path = resolve(moduleDirectory, '../SKILL.md')
|
|
189
|
+
let raw
|
|
190
|
+
try {
|
|
191
|
+
raw = readFileSync(path, 'utf8')
|
|
192
|
+
} catch {
|
|
193
|
+
return undefined
|
|
194
|
+
}
|
|
195
|
+
const end = raw.indexOf('\n---\n', 4)
|
|
196
|
+
if (!raw.startsWith('---\n') || end < 0) return undefined
|
|
197
|
+
const description = frontmatterField(raw.slice(4, end), 'description')
|
|
198
|
+
if (description === undefined) return undefined
|
|
199
|
+
return { path, raw, end, description }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** One catalog entry; `locator` is the opaque handle this provider owns. */
|
|
203
|
+
function skillEntry(skill) {
|
|
204
|
+
return {
|
|
205
|
+
name: BUNDLED_SKILL_NAME,
|
|
206
|
+
description: skill.description,
|
|
207
|
+
invocation: BUNDLED_SKILL_INVOCATION,
|
|
208
|
+
source: 'bundled',
|
|
209
|
+
provider: BUNDLED_SKILL_PROVIDER,
|
|
210
|
+
path: skill.path,
|
|
211
|
+
resourceBase: { kind: 'directory', path: dirname(skill.path) },
|
|
212
|
+
rank: MOSAIC_SKILL_RANK,
|
|
213
|
+
locator: skill.path,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Register the Mosaic prompt section, tools, and skill.
|
|
218
|
+
*
|
|
219
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
220
|
+
*/
|
|
221
|
+
export function apply(ctx, config) {
|
|
222
|
+
const log = createLog({ ctx, level: config && typeof config === 'object' ? config.logLevel : undefined })
|
|
223
|
+
const clock = () => Date.now()
|
|
224
|
+
log.info('plugin.activate', {
|
|
225
|
+
version: PACKAGE_VERSION,
|
|
226
|
+
node: process.version,
|
|
227
|
+
configKeys: config && typeof config === 'object' ? Object.keys(config) : [],
|
|
228
|
+
logFile: log.file,
|
|
229
|
+
})
|
|
230
|
+
ctx.effect(() => () => {
|
|
231
|
+
log.info('plugin.deactivate', {})
|
|
232
|
+
void log.flush()
|
|
233
|
+
}, 'dsh-mosaic: log lifecycle')
|
|
234
|
+
|
|
235
|
+
// The effective prompt is the user's saved override, then the row's `config.prompt`,
|
|
236
|
+
// then the built-in text. The controller owns re-registration on every change, so the
|
|
237
|
+
// settings page and the profile patch both take effect without a reload.
|
|
238
|
+
const prompts = createPromptController({
|
|
239
|
+
ctx,
|
|
240
|
+
config,
|
|
241
|
+
log,
|
|
242
|
+
builtin: MOSAIC_SECTION_TEXT,
|
|
243
|
+
resolveConfigPrompt: resolvePrompt,
|
|
244
|
+
registerSection: registerPromptSection,
|
|
245
|
+
// A version query lets the asset route answer with an immutable cache entry.
|
|
246
|
+
assets: { mermaid: `${ASSET_ROUTE_PATH}/${MERMAID_ASSET_FILE}?v=${MERMAID_VERSION}` },
|
|
247
|
+
})
|
|
248
|
+
ctx.effect(() => prompts.start(), 'dsh-mosaic: prompt section')
|
|
249
|
+
prompts.hydrate().catch((error) => {
|
|
250
|
+
log.error('prompt.hydrate-failed', error)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// Tools are optional: a host without a tool registry keeps the fence channel.
|
|
254
|
+
// Every execution is recorded with its outcome and duration, never its arguments.
|
|
255
|
+
const traced = (definition) => ({
|
|
256
|
+
...definition,
|
|
257
|
+
async execute(args, exec) {
|
|
258
|
+
const started = clock()
|
|
259
|
+
try {
|
|
260
|
+
const value = await definition.execute(args, exec)
|
|
261
|
+
log.info('tool.execute', {
|
|
262
|
+
tool: definition.name,
|
|
263
|
+
status: typeof value?.status === 'string' ? value.status : 'ok',
|
|
264
|
+
rendered: typeof value?.rendered === 'number' ? value.rendered : undefined,
|
|
265
|
+
errors: Array.isArray(value?.errors) ? value.errors.length : undefined,
|
|
266
|
+
ms: clock() - started,
|
|
267
|
+
})
|
|
268
|
+
return value
|
|
269
|
+
} catch (error) {
|
|
270
|
+
log.error('tool.failed', error, { tool: definition.name, ms: clock() - started })
|
|
271
|
+
throw error
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
ctx.inject(['tools'], (toolsCtx) => {
|
|
276
|
+
toolsCtx.effect(function* () {
|
|
277
|
+
yield toolsCtx.tools.register(traced(createRenderViewTool()))
|
|
278
|
+
yield toolsCtx.tools.register(traced(createValidateViewTool()))
|
|
279
|
+
}, 'dsh-mosaic: tools')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// The skill is optional too; the compact prompt section remains usable.
|
|
283
|
+
ctx.inject(['skills'], (skillCtx) => {
|
|
284
|
+
skillCtx.effect(
|
|
285
|
+
() => skillCtx.skills.registerProvider(() => {
|
|
286
|
+
const provider = bundledSkillProvider()
|
|
287
|
+
return {
|
|
288
|
+
...provider,
|
|
289
|
+
async list(options) {
|
|
290
|
+
const result = await provider.list(options)
|
|
291
|
+
log.info('skill.list', { candidates: Array.isArray(result?.candidates) ? result.candidates.length : 0, complete: result?.complete })
|
|
292
|
+
return result
|
|
293
|
+
},
|
|
294
|
+
async get(candidate) {
|
|
295
|
+
const definition = await provider.get(candidate)
|
|
296
|
+
log.info('skill.get', { found: definition !== undefined })
|
|
297
|
+
return definition
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
}),
|
|
301
|
+
'dsh-mosaic: skill provider',
|
|
302
|
+
)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
/** Record one served route without changing its response. */
|
|
306
|
+
const tracedRoute = (name, handler) => async (req, res) => {
|
|
307
|
+
const started = clock()
|
|
308
|
+
try {
|
|
309
|
+
await handler(req, res)
|
|
310
|
+
} finally {
|
|
311
|
+
log.info('route', { route: name, method: req.method ?? '?', status: res.statusCode, ms: clock() - started })
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Accept one bounded diagnostic line from the browser half. */
|
|
316
|
+
async function handleClientLog(req, res) {
|
|
317
|
+
if (req.method !== 'POST') {
|
|
318
|
+
res.writeHead(405)
|
|
319
|
+
res.end()
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
const policy = crossSiteError(req)
|
|
323
|
+
if (policy !== undefined) {
|
|
324
|
+
log.warn('client.log-rejected', { reason: policy })
|
|
325
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
|
|
326
|
+
res.end(JSON.stringify({ error: policy }))
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
let body
|
|
330
|
+
try {
|
|
331
|
+
body = await readJsonBody(req, LOG_BODY_LIMIT)
|
|
332
|
+
} catch {
|
|
333
|
+
res.writeHead(400)
|
|
334
|
+
res.end()
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
const event = typeof body?.event === 'string' ? body.event.slice(0, 60) : 'client.event'
|
|
338
|
+
const detail = body?.detail !== null && typeof body?.detail === 'object' ? body.detail : {}
|
|
339
|
+
const fields = { source: 'client', ...detail }
|
|
340
|
+
if (body?.level === 'error') log.error(`client.${event}`, body?.message === undefined ? undefined : new Error(String(body.message)), fields)
|
|
341
|
+
else log.warn(`client.${event}`, fields)
|
|
342
|
+
res.writeHead(204)
|
|
343
|
+
res.end()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Mermaid is a lazy standalone asset, served from this package's own route.
|
|
347
|
+
// A host without a webserver keeps the built-in SVG fallback.
|
|
348
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
349
|
+
const webServer = webCtx.webServer
|
|
350
|
+
webCtx.effect(
|
|
351
|
+
() => webServer.register({ kind: 'prefix', path: ASSET_ROUTE_PATH, handler: tracedRoute('assets', serveMosaicAsset) }),
|
|
352
|
+
'dsh-mosaic: asset route',
|
|
353
|
+
)
|
|
354
|
+
// The settings page reads and writes the prompt here. State-changing requests are
|
|
355
|
+
// same-origin only; see requestPolicyError for the exact checks.
|
|
356
|
+
webCtx.effect(
|
|
357
|
+
() => webServer.register({ kind: 'exact', path: PROMPT_ROUTE, handler: tracedRoute('prompt', (req, res) => prompts.handle(req, res)) }),
|
|
358
|
+
'dsh-mosaic: prompt route',
|
|
359
|
+
)
|
|
360
|
+
webCtx.effect(
|
|
361
|
+
() => webServer.register({ kind: 'exact', path: LOG_ROUTE, handler: (req, res) => handleClientLog(req, res) }),
|
|
362
|
+
'dsh-mosaic: log route',
|
|
363
|
+
)
|
|
364
|
+
})
|
|
365
|
+
}
|
package/lib/log.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin's own diagnostic log.
|
|
3
|
+
*
|
|
4
|
+
* One JSON object per line, one file per day, under `<DSH_HOME>/logs/dsh-mosaic/`.
|
|
5
|
+
* A plugin that misbehaves should be diagnosable from this file alone, so every
|
|
6
|
+
* lifecycle step, route decision and tool execution is recorded; user text never is.
|
|
7
|
+
*
|
|
8
|
+
* Design rules, in order of importance:
|
|
9
|
+
* 1. Never throw. Logging must not be able to break the plugin.
|
|
10
|
+
* 2. Never record secrets or user content: strings are truncated, prompt-like fields
|
|
11
|
+
* are replaced by their length.
|
|
12
|
+
* 3. Bounded: a size cap rotates the current day's file, and old days are deleted.
|
|
13
|
+
* 4. Cheap: writes are queued on one promise chain, so callers never await disk IO.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { appendFile, mkdir, readdir, rename, stat, unlink } from 'node:fs/promises'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { basename, join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
export const LOG_RETENTION_DAYS = 7
|
|
21
|
+
export const LOG_MAX_BYTES_PER_FILE = 2 * 1024 * 1024
|
|
22
|
+
export const LOG_MAX_STRING = 300
|
|
23
|
+
export const LOG_LEVELS = ['debug', 'info', 'warn', 'error']
|
|
24
|
+
|
|
25
|
+
/** Field names whose value may be user content or a secret; only their size is kept. */
|
|
26
|
+
const REDACTED_FIELD = /^(prompt|text|code|content|secret|token|password|apiKey)$/i
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the log directory for this Host.
|
|
30
|
+
*
|
|
31
|
+
* @param {Record<string, string | undefined>} [env] - process environment.
|
|
32
|
+
* @returns {string} absolute directory path.
|
|
33
|
+
*/
|
|
34
|
+
export function logDirectory(env = process.env) {
|
|
35
|
+
const home = typeof env.DSH_HOME === 'string' && env.DSH_HOME !== '' ? env.DSH_HOME : join(homedir(), '.dsh')
|
|
36
|
+
return join(home, 'logs', 'dsh-mosaic')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `YYYY-MM-DD` in local time, so a day boundary matches the operator's clock. */
|
|
40
|
+
export function dayStamp(date = new Date()) {
|
|
41
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
42
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Make a value safe for a log line: bounded depth, bounded strings, no user content.
|
|
47
|
+
*
|
|
48
|
+
* @param {unknown} value - any structured value.
|
|
49
|
+
* @param {number} [depth] - remaining nesting budget.
|
|
50
|
+
* @returns {unknown} a JSON-safe projection.
|
|
51
|
+
*/
|
|
52
|
+
export function sanitizeForLog(value, depth = 0) {
|
|
53
|
+
if (value === null || value === undefined) return value
|
|
54
|
+
if (typeof value === 'string') return value.length > LOG_MAX_STRING ? `${value.slice(0, LOG_MAX_STRING)}…(${value.length})` : value
|
|
55
|
+
if (typeof value === 'number' || typeof value === 'boolean') return value
|
|
56
|
+
if (typeof value === 'bigint') return String(value)
|
|
57
|
+
if (typeof value === 'function' || typeof value === 'symbol') return undefined
|
|
58
|
+
if (value instanceof Error) return describeError(value)
|
|
59
|
+
if (depth >= 4) return '[depth]'
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
const items = value.slice(0, 20).map((item) => sanitizeForLog(item, depth + 1))
|
|
62
|
+
return value.length > 20 ? [...items, `…(${value.length})`] : items
|
|
63
|
+
}
|
|
64
|
+
const out = {}
|
|
65
|
+
for (const [key, child] of Object.entries(value)) {
|
|
66
|
+
if (REDACTED_FIELD.test(key) && typeof child === 'string') {
|
|
67
|
+
out[`${key}Chars`] = child.length
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
const safe = sanitizeForLog(child, depth + 1)
|
|
71
|
+
if (safe !== undefined) out[key] = safe
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Describe an error without leaking payloads.
|
|
78
|
+
*
|
|
79
|
+
* @param {unknown} error - thrown value.
|
|
80
|
+
* @returns {{ name: string, message: string, code?: string, stack?: string }} fields.
|
|
81
|
+
*/
|
|
82
|
+
export function describeError(error) {
|
|
83
|
+
if (!(error instanceof Error)) return { name: 'Error', message: String(error).slice(0, LOG_MAX_STRING) }
|
|
84
|
+
const code = typeof error.code === 'string' ? error.code : undefined
|
|
85
|
+
return {
|
|
86
|
+
name: error.name,
|
|
87
|
+
message: error.message.slice(0, LOG_MAX_STRING),
|
|
88
|
+
...code === undefined ? {} : { code },
|
|
89
|
+
...typeof error.stack === 'string' ? { stack: error.stack.split('\n').slice(0, 6).join('\n') } : {},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create the plugin log.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} [options] - wiring.
|
|
97
|
+
* @param {object} [options.ctx] - plugin context; `ctx.logger` mirrors warnings upward.
|
|
98
|
+
* @param {string} [options.dir] - override directory (tests).
|
|
99
|
+
* @param {() => Date} [options.now] - clock (tests).
|
|
100
|
+
* @param {string} [options.level] - minimum level to record, from `config.logLevel`.
|
|
101
|
+
* @param {number} [options.retentionDays] - files older than this are deleted.
|
|
102
|
+
* @param {number} [options.maxBytes] - rotate the daily file past this size.
|
|
103
|
+
*/
|
|
104
|
+
export function createLog(options = {}) {
|
|
105
|
+
const {
|
|
106
|
+
ctx,
|
|
107
|
+
dir = logDirectory(),
|
|
108
|
+
now = () => new Date(),
|
|
109
|
+
level = 'info',
|
|
110
|
+
retentionDays = LOG_RETENTION_DAYS,
|
|
111
|
+
maxBytes = LOG_MAX_BYTES_PER_FILE,
|
|
112
|
+
} = options
|
|
113
|
+
const threshold = LOG_LEVELS.includes(level) ? LOG_LEVELS.indexOf(level) : LOG_LEVELS.indexOf('info')
|
|
114
|
+
const state = { disabled: false, warned: false, queue: Promise.resolve(), swept: false, file: undefined }
|
|
115
|
+
|
|
116
|
+
const currentFile = () => join(dir, `${dayStamp(now())}.jsonl`)
|
|
117
|
+
|
|
118
|
+
async function sweep() {
|
|
119
|
+
if (state.swept) return
|
|
120
|
+
state.swept = true
|
|
121
|
+
const cutoff = now().getTime() - retentionDays * 24 * 60 * 60 * 1000
|
|
122
|
+
try {
|
|
123
|
+
for (const name of await readdir(dir)) {
|
|
124
|
+
if (!name.endsWith('.jsonl')) continue
|
|
125
|
+
const path = join(dir, name)
|
|
126
|
+
const info = await stat(path).catch(() => undefined)
|
|
127
|
+
if (info !== undefined && info.mtimeMs < cutoff) await unlink(path).catch(() => {})
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// A missing or unreadable directory is not an error: the first write creates it.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function rotateIfNeeded(path) {
|
|
135
|
+
const info = await stat(path).catch(() => undefined)
|
|
136
|
+
if (info === undefined || info.size < maxBytes) return
|
|
137
|
+
await rename(path, join(dir, `${basename(path, '.jsonl')}.1.jsonl`)).catch(() => {})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function mirror(entry) {
|
|
141
|
+
if (entry.level !== 'warn' && entry.level !== 'error') return
|
|
142
|
+
const logger = ctx?.logger
|
|
143
|
+
const method = entry.level === 'error' ? 'error' : 'warn'
|
|
144
|
+
try {
|
|
145
|
+
logger?.[method]?.(`mosaic: ${entry.event}`)
|
|
146
|
+
} catch {
|
|
147
|
+
// Logging upward must never fail the plugin either.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function write(levelName, event, fields, error) {
|
|
152
|
+
const index = LOG_LEVELS.indexOf(levelName)
|
|
153
|
+
if (index < threshold) return
|
|
154
|
+
const entry = {
|
|
155
|
+
ts: now().toISOString(),
|
|
156
|
+
level: levelName,
|
|
157
|
+
event: String(event).slice(0, 80),
|
|
158
|
+
...sanitizeForLog(fields) ?? {},
|
|
159
|
+
...error === undefined ? {} : { error: describeError(error) },
|
|
160
|
+
}
|
|
161
|
+
mirror(entry)
|
|
162
|
+
if (state.disabled) return
|
|
163
|
+
const line = `${JSON.stringify(entry)}\n`
|
|
164
|
+
state.queue = state.queue
|
|
165
|
+
.then(async () => {
|
|
166
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
167
|
+
const file = currentFile()
|
|
168
|
+
state.file = file
|
|
169
|
+
await sweep()
|
|
170
|
+
await rotateIfNeeded(file)
|
|
171
|
+
await appendFile(file, line, { mode: 0o600 })
|
|
172
|
+
})
|
|
173
|
+
.catch(() => {
|
|
174
|
+
// One warning upward, then stay quiet so a broken disk cannot flood anything.
|
|
175
|
+
if (state.disabled) return
|
|
176
|
+
state.disabled = true
|
|
177
|
+
if (!state.warned) {
|
|
178
|
+
state.warned = true
|
|
179
|
+
try {
|
|
180
|
+
ctx?.logger?.warn?.('mosaic: diagnostic log is unwritable; continuing without it')
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
dir,
|
|
188
|
+
level: LOG_LEVELS[threshold] ?? 'info',
|
|
189
|
+
/** Path of the file currently being written, once known. */
|
|
190
|
+
get file() {
|
|
191
|
+
return state.file ?? currentFile()
|
|
192
|
+
},
|
|
193
|
+
/** Whether the directory accepted writes so far. */
|
|
194
|
+
get writable() {
|
|
195
|
+
return !state.disabled
|
|
196
|
+
},
|
|
197
|
+
debug: (event, fields) => write('debug', event, fields),
|
|
198
|
+
info: (event, fields) => write('info', event, fields),
|
|
199
|
+
warn: (event, fields) => write('warn', event, fields),
|
|
200
|
+
error: (event, error, fields) => write('error', event, fields, error),
|
|
201
|
+
/** Wait for queued lines; used by tests and by the dispose path. */
|
|
202
|
+
async flush() {
|
|
203
|
+
try {
|
|
204
|
+
await state.queue
|
|
205
|
+
} catch {}
|
|
206
|
+
},
|
|
207
|
+
/** Stable short fingerprint, for correlating two log lines without logging content. */
|
|
208
|
+
fingerprint(text) {
|
|
209
|
+
return createHash('sha256').update(String(text)).digest('hex').slice(0, 12)
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
}
|