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.
Files changed (47) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/LICENSE +21 -0
  3. package/README.md +151 -0
  4. package/SKILL.md +153 -0
  5. package/THIRD_PARTY_NOTICES.md +79 -0
  6. package/assets/icon-180.png +0 -0
  7. package/assets/icon.svg +13 -0
  8. package/assets/screenshots/preview.jpg +0 -0
  9. package/cordis.patch.yml +10 -0
  10. package/docs/catalog-entry.example.yml +9 -0
  11. package/docs/publishing.md +29 -0
  12. package/lib/assets/licenses/braintree-sanitize-url.LICENSE +21 -0
  13. package/lib/assets/licenses/chevrotain.LICENSE +202 -0
  14. package/lib/assets/licenses/cytoscape-cose-bilkent.LICENSE +21 -0
  15. package/lib/assets/licenses/cytoscape-fcose.LICENSE +19 -0
  16. package/lib/assets/licenses/cytoscape.LICENSE +19 -0
  17. package/lib/assets/licenses/d3-sankey.LICENSE +27 -0
  18. package/lib/assets/licenses/d3.LICENSE +13 -0
  19. package/lib/assets/licenses/dagre-d3-es.LICENSE +23 -0
  20. package/lib/assets/licenses/dayjs.LICENSE +21 -0
  21. package/lib/assets/licenses/dompurify.LICENSE +202 -0
  22. package/lib/assets/licenses/elkjs.LICENSE +263 -0
  23. package/lib/assets/licenses/es-toolkit.LICENSE +21 -0
  24. package/lib/assets/licenses/iconify-utils.LICENSE +21 -0
  25. package/lib/assets/licenses/katex.LICENSE +21 -0
  26. package/lib/assets/licenses/khroma.LICENSE +21 -0
  27. package/lib/assets/licenses/marked.LICENSE +44 -0
  28. package/lib/assets/licenses/mermaid-js-parser.LICENSE +21 -0
  29. package/lib/assets/licenses/mermaid.LICENSE +21 -0
  30. package/lib/assets/licenses/roughjs.LICENSE +21 -0
  31. package/lib/assets/licenses/stylis.LICENSE +21 -0
  32. package/lib/assets/licenses/ts-dedent.LICENSE +21 -0
  33. package/lib/assets/licenses/types-d3.LICENSE +21 -0
  34. package/lib/assets/licenses/upsetjs-venn.js.LICENSE +22 -0
  35. package/lib/assets/licenses/uuid.LICENSE +9 -0
  36. package/lib/assets/mermaid.min.js +4330 -0
  37. package/lib/client.js +3191 -0
  38. package/lib/index.js +365 -0
  39. package/lib/log.js +212 -0
  40. package/lib/prompt-store.js +354 -0
  41. package/lib/render-tool.js +162 -0
  42. package/lib/validate-tool.js +160 -0
  43. package/lib/validate.js +1070 -0
  44. package/locale/en.json +6 -0
  45. package/locale/zh.json +6 -0
  46. package/package.json +66 -0
  47. 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
+ }