dsh-artcraft-plugin-lightcraft 0.2.4 → 0.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-artcraft-plugin-lightcraft",
3
- "version": "0.2.4",
3
+ "version": "0.2.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "DSH plugin: drive LightCraft (photo library and raw development) headlessly through its own CLI and MCP server.",
@@ -42,5 +42,8 @@
42
42
  ],
43
43
  "publishConfig": {
44
44
  "access": "public"
45
+ },
46
+ "engines": {
47
+ "dsh": "^0.2.1-rc.3"
45
48
  }
46
49
  }
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
16
16
  import { applyCraftPlugin, profileOf } from './kit/index.js'
17
17
 
18
18
  import { PLUGIN_ROOT } from './root.js'
19
+ import { readFileSync } from 'node:fs'
19
20
 
20
21
  export const name = 'dsh-artcraft-lightcraft'
21
22
  export const inject = ['tools', 'systemPrompt']
@@ -26,6 +27,27 @@ export const craft = profileOf('lightcraft')
26
27
  /** Configuration is the shared craft config. */
27
28
 
28
29
 
30
+ /**
31
+ * Commit a rendered preview so the transcript shows the document after the call.
32
+ * The attachment service is optional: without it the path stays in the text.
33
+ */
34
+ const attachImage = async (ctx , path ) => {
35
+ const attachments = ctx.get('attachments')
36
+
37
+
38
+ if (attachments?.saveImage === undefined) return undefined
39
+ try {
40
+ const ref = await attachments.saveImage({
41
+ data: readFileSync(path),
42
+ mediaType: 'image/png',
43
+ name: path.split(/[\\/]/).pop(),
44
+ })
45
+ return { type: 'image', attachment: ref }
46
+ } catch {
47
+ return undefined
48
+ }
49
+ }
50
+
29
51
  export function apply(ctx , config = {}) {
30
52
  if (craft === undefined) throw new Error('dsh-artcraft-lightcraft: profile "lightcraft" is missing from the inlined kernel')
31
53
  // `ctx.effect` ties the registration and the MCP server to this plugin's fiber.
@@ -34,5 +56,6 @@ export function apply(ctx , config = {}) {
34
56
  packageRoot: PLUGIN_ROOT,
35
57
  config,
36
58
  defineTool: defineTool ,
59
+ attachImage: (path ) => attachImage(ctx, path),
37
60
  }))
38
61
  }
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import type { Context } from '@deepseek-ai/cordis'
16
16
  import { applyCraftPlugin, profileOf } from './kit/index.js'
17
17
  import type { CraftPluginConfig } from './kit/index.js'
18
18
  import { PLUGIN_ROOT } from './root.ts'
19
+ import { readFileSync } from 'node:fs'
19
20
 
20
21
  export const name = 'dsh-artcraft-lightcraft'
21
22
  export const inject = ['tools', 'systemPrompt']
@@ -26,6 +27,27 @@ export const craft = profileOf('lightcraft')
26
27
  /** Configuration is the shared craft config. */
27
28
  export type Config = CraftPluginConfig
28
29
 
30
+ /**
31
+ * Commit a rendered preview so the transcript shows the document after the call.
32
+ * The attachment service is optional: without it the path stays in the text.
33
+ */
34
+ const attachImage = async (ctx: Context, path: string): Promise<unknown> => {
35
+ const attachments = ctx.get('attachments') as
36
+ | { saveImage?: (input: { data: Buffer; mediaType: string; name?: string }) => Promise<unknown> }
37
+ | undefined
38
+ if (attachments?.saveImage === undefined) return undefined
39
+ try {
40
+ const ref = await attachments.saveImage({
41
+ data: readFileSync(path),
42
+ mediaType: 'image/png',
43
+ name: path.split(/[\\/]/).pop(),
44
+ })
45
+ return { type: 'image', attachment: ref }
46
+ } catch {
47
+ return undefined
48
+ }
49
+ }
50
+
29
51
  export function apply(ctx: Context, config: Config = {}): void {
30
52
  if (craft === undefined) throw new Error('dsh-artcraft-lightcraft: profile "lightcraft" is missing from the inlined kernel')
31
53
  // `ctx.effect` ties the registration and the MCP server to this plugin's fiber.
@@ -34,5 +56,6 @@ export function apply(ctx: Context, config: Config = {}): void {
34
56
  packageRoot: PLUGIN_ROOT,
35
57
  config,
36
58
  defineTool: defineTool as never,
59
+ attachImage: (path: string) => attachImage(ctx, path),
37
60
  }))
38
61
  }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Render a document's current state for the live preview.
3
+ *
4
+ * Three things share this module: the image block a tool returns after an edit,
5
+ * the local panel in `scripts/preview/`, and the DSH client panel: they all read
6
+ * the same state directory (`docs/PREVIEW.md` holds the contract).
7
+ *
8
+ * Rendering goes through the craft's own binary — never a screenshot of a window
9
+ * — so the preview shows what the application itself would show.
10
+ * @module @dsh-artcraft/craft-kit/preview
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import { homedir } from 'node:os'
15
+ import { basename, dirname, join } from 'node:path'
16
+ import { runCraft } from './exec.js'
17
+
18
+
19
+ /** Where the previews live when no configuration overrides it. */
20
+ export function previewRoot(configured ) {
21
+ if (configured !== undefined && configured.length > 0) return configured
22
+ const fromEnv = process.env.DSH_ARTCRAFT_PREVIEW_DIR
23
+ if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv
24
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
25
+ return join(home, 'artcraft', 'preview')
26
+ }
27
+
28
+ /**
29
+ * How one craft renders a document to an image.
30
+ *
31
+ * A craft with no recipe still reports a step in the panel; it just has no
32
+ * picture (SoundCraft has no page, and the MCP-only crafts need their server).
33
+ */
34
+
35
+
36
+
37
+
38
+
39
+ /** Per-craft render recipes, verified against the 2026-11 CLI releases. */
40
+ const RECIPES = {
41
+ // `render FILE OUT [--page N] [--scale S]` draws a page as a PNG.
42
+ wordcraft: { argv: (file, out, scale) => ['render', file, out, '--page', '1', '--scale', String(scale)] },
43
+ // `convert` picks the format from the output extension.
44
+ cadcraft: { argv: (file, out) => ['convert', file, out] },
45
+ figcraft: { argv: (file, out) => ['convert', file, out] },
46
+ // `render FILE [--slide N] [--scale S] OUT` draws one slide as a PNG.
47
+ deckcraft: { argv: (file, out, scale) => ['render', file, '--slide', '0', '--scale', String(scale), out] },
48
+ // VectorCraft exports the active artboard through `run --export`.
49
+ vectorcraft: { argv: (file, out) => ['run', file, '--export', out] },
50
+ // TextureCraft renders the viewport (2D view; the 3D one needs a GPU).
51
+ texturecraft: { argv: (file, out) => ['render', file, out, '--view', '2d'] },
52
+ // PhotoCraft converts the document; `convert` picks the format by extension.
53
+ photocraft: { argv: (file, out) => ['convert', file, out] },
54
+ // EffectCraft renders one frame at t=0 without the render queue.
55
+ effectcraft: { argv: (file, out) => ['render-frame', '--time', '0', '--out', out] },
56
+ // LightCraft develops one file through the same encoder as the export dialog.
57
+ lightcraft: { argv: (file, out) => ['render', file, '-o', out] },
58
+ }
59
+
60
+ /** Whether a craft can render a preview at all. */
61
+ export function canPreview(profile ) {
62
+ return RECIPES[profile.id] !== undefined
63
+ }
64
+
65
+ /** One preview step, as documented in docs/PREVIEW.md. */
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+
80
+ /**
81
+ * One clip on a time-based craft's timeline, in seconds.
82
+ *
83
+ * `timeline` is a property of the DOCUMENT (the whole sequence), not of a step:
84
+ * the panel draws one bar under the current render, and a per-step bar would
85
+ * make it jump between steps that did not change the sequence.
86
+ */
87
+
88
+
89
+
90
+
91
+
92
+
93
+ /** A craft's `state.json`. */
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+
103
+
104
+
105
+
106
+ /**
107
+ * Render one document to `<dir>/<craft>/step-NNN.png`.
108
+ *
109
+ * Never throws: a preview is a convenience, and a craft that cannot render must
110
+ * not fail the edit that produced it.
111
+ * @param profile - the craft that wrote the document.
112
+ * @param binary - its resolved binary.
113
+ * @param options - document path, output directory and render scale.
114
+ * @returns the absolute image path, or `undefined` when the craft cannot render.
115
+ */
116
+ export async function renderPreview(
117
+ profile ,
118
+ binary ,
119
+ options ,
120
+ ) {
121
+ const recipe = RECIPES[profile.id]
122
+ if (recipe === undefined) return undefined
123
+ if (!existsSync(options.file)) return undefined
124
+ try {
125
+ mkdirSync(options.dir, { recursive: true })
126
+ const argv = recipe.argv(options.file, options.out, options.scale ?? 1.5)
127
+ const result = await runCraft({ profile, binary, argv, cwd: process.cwd(), timeoutMs: options.timeoutMs ?? 60_000 })
128
+ return result.ok && existsSync(options.out) ? options.out : undefined
129
+ } catch {
130
+ return undefined
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The next step number for a craft, across processes.
136
+ *
137
+ * A plugin process starts its own counter at zero, so numbering from memory
138
+ * alone makes a restarted session overwrite the earlier steps it wrote to disk.
139
+ * The state file is the truth: continue after the highest number recorded.
140
+ * @param root - preview root.
141
+ * @param craft - craft id.
142
+ * @returns the next 1-based step number.
143
+ */
144
+ export function nextStepNumber(root , craft ) {
145
+ try {
146
+ const state = JSON.parse(readFileSync(join(root, craft, 'state.json'), 'utf8'))
147
+ return state.steps.reduce((highest, step) => Math.max(highest, step.n), 0) + 1
148
+ } catch {
149
+ return 1
150
+ }
151
+ }
152
+
153
+ /** Read-modify-write one craft's state and refresh the index. */
154
+ export function recordPreview(
155
+ root ,
156
+ profile ,
157
+ step ,
158
+ file ,
159
+ pages ,
160
+ timeline ,
161
+ ) {
162
+ const craftDir = join(root, profile.id)
163
+ mkdirSync(craftDir, { recursive: true })
164
+ const statePath = join(craftDir, 'state.json')
165
+ let previous
166
+ try {
167
+ previous = JSON.parse(readFileSync(statePath, 'utf8'))
168
+ } catch {
169
+ previous = undefined
170
+ }
171
+ const resolved = { ...step, n: previous === undefined ? step.n : Math.max(step.n, (previous.steps.reduce((highest, entry) => Math.max(highest, entry.n), 0)) + 1) }
172
+ const steps = [...(previous?.steps ?? []).filter(entry => entry.n !== resolved.n), resolved]
173
+ const state = {
174
+ craft: profile.id,
175
+ product: profile.product,
176
+ file,
177
+ pages,
178
+ updatedAt: new Date().toISOString(),
179
+ current: resolved.n,
180
+ // Keep the last known timeline: a step that only moved the playhead must
181
+ // not erase the clips a previous step reported.
182
+ ...timeline !== undefined ? { timeline } : previous?.timeline !== undefined ? { timeline: previous.timeline } : {},
183
+ steps,
184
+ }
185
+ writeFileSync(statePath, JSON.stringify(state, undefined, 2) + '\n')
186
+
187
+ // index.json is the panel's first read: one entry per craft, no history.
188
+ const indexPath = join(root, 'index.json')
189
+ let index =
190
+ { updatedAt: state.updatedAt, crafts: {} }
191
+ try {
192
+ index = JSON.parse(readFileSync(indexPath, 'utf8'))
193
+ } catch {
194
+ // First write for this session.
195
+ }
196
+ index.crafts[profile.id] = { file, pages, stepCount: state.steps.length, updatedAt: state.updatedAt }
197
+ index.updatedAt = state.updatedAt
198
+ writeFileSync(indexPath, JSON.stringify(index, undefined, 2) + '\n')
199
+ return state
200
+ }
201
+
202
+ /** A stable name for this step's render, e.g. `step-003.png`. */
203
+ export function stepName(n ) {
204
+ return `step-${String(n).padStart(3, '0')}.png`
205
+ }
206
+
207
+ /** The display name of a document path, for the panel's header. */
208
+ export function documentLabel(file ) {
209
+ return basename(dirname(file)) + '/' + basename(file)
210
+ }
package/src/kit/tools.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { craftCallView, craftResultView } from './present.js'
12
+ import { canPreview, nextStepNumber, previewRoot, recordPreview, renderPreview, stepName } from './preview.js'
12
13
  import { appendJournal } from './journal.js'
13
14
  import { CraftMcpClient, renderSchema } from './mcp.js'
14
15
 
@@ -161,10 +162,35 @@ export function createRuntime(profile , packageRoot , config
161
162
  let catalogCache
162
163
  const mcpClients = new Map ()
163
164
  const artifacts = config.artifactsDir ?? join(tmpdir(), 'dsh-artcraft', profile.id)
165
+ const previewDir = previewRoot(config.previewDir)
166
+ let previewStep = 0
164
167
  const jobs = new Map ()
165
168
  const jobChildren = new Map ()
166
169
  let jobCounter = 0
167
170
  return {
171
+ async preview(options) {
172
+ if (config.preview === 'off') return undefined
173
+ if (!canPreview(profile)) return undefined
174
+ const binary = this.binary()
175
+ if (binary === undefined) return undefined
176
+ previewStep = nextStepNumber(previewDir, profile.id)
177
+ const n = previewStep
178
+ const dir = join(previewDir, profile.id)
179
+ const out = join(dir, stepName(n))
180
+ const scale = options.scale ?? config.previewScale ?? 1.5
181
+ const rendered = await renderPreview(profile, binary, { file: options.file, dir, out, scale })
182
+ const relative = rendered === undefined ? undefined : stepName(n)
183
+ recordPreview(previewDir, profile, {
184
+ n,
185
+ at: new Date().toISOString(),
186
+ tool: options.tool,
187
+ title: options.title,
188
+ summary: options.summary,
189
+ ...relative === undefined ? {} : { image: relative, thumb: relative },
190
+ ...options.durationMs === undefined ? {} : { durationMs: options.durationMs },
191
+ }, options.file, 1)
192
+ return rendered
193
+ },
168
194
  startJob(binary, argv) {
169
195
  jobCounter += 1
170
196
  const id = `j${jobCounter}`
@@ -382,7 +408,13 @@ export function createToolSet(runtime ) {
382
408
  * Renders, bakes and bounces outlast the tool timeout; a job keeps the process
383
409
  * alive after the call returns and leaves its output in a log file.
384
410
  */
385
- const runOrStart = async (binary , argv , background ) => {
411
+ const runOrStart = async (
412
+ binary ,
413
+ argv ,
414
+ background ,
415
+ document ,
416
+ label ,
417
+ ) => {
386
418
  if (background === true) {
387
419
  const job = runtime.startJob(binary, argv)
388
420
  return [
@@ -392,7 +424,21 @@ export function createToolSet(runtime ) {
392
424
  `or wait with {"action":"wait","job":"${job.id}","timeoutMs":300000}`,
393
425
  ].join('\n')
394
426
  }
395
- return renderRun(await runCraft({ profile, binary, argv, cwd: process.cwd(), timeoutMs }), profile)
427
+ const result = await runCraft({ profile, binary, argv, cwd: process.cwd(), timeoutMs })
428
+ let text = renderRun(result, profile)
429
+ // A render after every write is what turns the transcript into a step-by-step
430
+ // view of the document; the same image feeds the preview panel.
431
+ if (result.ok && document !== undefined && config.preview !== 'off') {
432
+ const rendered = await runtime.preview({
433
+ file: document,
434
+ tool: 'run',
435
+ title: label === undefined || label.length === 0 ? `${profile.id}_run` : label,
436
+ summary: `exit ${result.exitCode ?? 0} · ${result.durationMs} ms`,
437
+ durationMs: result.durationMs,
438
+ })
439
+ if (rendered !== undefined) text += `\n[preview] ${rendered}`
440
+ }
441
+ return text
396
442
  }
397
443
 
398
444
  /** Guard every tool: a missing binary is an answer, never a crash. */
@@ -418,6 +464,32 @@ export function createToolSet(runtime ) {
418
464
  return await runOrStart(binary, argv, background)
419
465
  }
420
466
 
467
+ /** A short, human step label: the first command ids, not the tool name. */
468
+ const labelOf = (args ) => {
469
+ const raw = args.commands
470
+ if (!Array.isArray(raw) || raw.length === 0) return undefined
471
+ const ids = raw.map((entry) => {
472
+ if (typeof entry === 'string') return entry.trim().split(/\s+/)[0] ?? ''
473
+ if (typeof entry === 'object' && entry !== null) {
474
+ const id = (entry ).id
475
+ return typeof id === 'string' ? id : ''
476
+ }
477
+ return ''
478
+ }).filter(id => id.length > 0)
479
+ if (ids.length === 0) return undefined
480
+ const shown = ids.slice(0, 3).join(', ')
481
+ return `${profile.product} · ${ids.length <= 3 ? shown : `${shown} +${ids.length - 3}`}`
482
+ }
483
+
484
+ /** The document this call wrote or opened, when it named one. */
485
+ const documentOf = (args ) => {
486
+ for (const key of ['save', 'export', 'input'] ) {
487
+ const value = args[key]
488
+ if (typeof value === 'string' && value.length > 0) return value
489
+ }
490
+ return undefined
491
+ }
492
+
421
493
  const session = { commands: [], stale: false }
422
494
 
423
495
  /**
@@ -541,7 +613,7 @@ export function createToolSet(runtime ) {
541
613
  }
542
614
  writeFileSync(scriptPath, typeof body === 'string' ? body : JSON.stringify(body, null, 2) + '\n')
543
615
  const extra = Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : []
544
- return await runOrStart(binary, [...argv, ...extra], args.background)
616
+ return await runOrStart(binary, [...argv, ...extra], args.background, documentOf(args), labelOf(args))
545
617
  }
546
618
  if (profile.run.scriptFlag !== undefined) {
547
619
  const scriptPath = join(craftWorkDir(profile), `steps-${Date.now()}.json`)
@@ -556,7 +628,7 @@ export function createToolSet(runtime ) {
556
628
  profile.run.scriptFlag, scriptPath,
557
629
  ...Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : [],
558
630
  ]
559
- return await runOrStart(binary, [profile.run.verb, ...extra], args.background)
631
+ return await runOrStart(binary, [profile.run.verb, ...extra], args.background, documentOf(args), labelOf(args))
560
632
  }
561
633
  if (profile.run.scriptMode === true) {
562
634
  const scriptPath = join(craftWorkDir(profile), `run-${Date.now()}.jsonl`)
@@ -568,7 +640,7 @@ export function createToolSet(runtime ) {
568
640
  scriptPath,
569
641
  ...Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : [],
570
642
  ]
571
- return await runOrStart(binary, [...globalFlagsFor(profile, args), profile.run.verb, ...extra], args.background)
643
+ return await runOrStart(binary, [...globalFlagsFor(profile, args), profile.run.verb, ...extra], args.background, documentOf(args), labelOf(args))
572
644
  }
573
645
  // PdfCraft's `run` accepts exactly one tool per invocation; sending a
574
646
  // list would produce "expected key=value" from the CLI, so the extra
@@ -597,7 +669,7 @@ export function createToolSet(runtime ) {
597
669
  ...args.print === true ? { print: true } : {},
598
670
  ...Array.isArray(args.extraArgs) ? { extra: args.extraArgs.filter((a) => typeof a === 'string') } : {},
599
671
  })
600
- return await runOrStart(binary, argv, args.background)
672
+ return await runOrStart(binary, argv, args.background, documentOf(args), labelOf(args))
601
673
  },
602
674
  }
603
675
 
@@ -711,7 +783,7 @@ export function createToolSet(runtime ) {
711
783
  else if (argMode === 'section') argv.push(typeof args.section === 'string' && args.section.length > 0 ? args.section : inspectSpec.section ?? 'project')
712
784
  if (inspectSpec.projectFlag !== undefined && typeof args.project === 'string') argv.push(inspectSpec.projectFlag, args.project)
713
785
  argv.push(...inspectSpec.extraArgs ?? [], ...extra)
714
- return await runOrStart(binary, argv, args.background)
786
+ return await runOrStart(binary, argv, args.background, documentOf(args), labelOf(args))
715
787
  },
716
788
  }
717
789
 
@@ -938,7 +1010,7 @@ export const CRAFT_SECTION_ORDER = 120
938
1010
 
939
1011
 
940
1012
 
941
-
1013
+
942
1014
 
943
1015
 
944
1016
 
@@ -999,6 +1071,12 @@ export function applyCraftPlugin(
999
1071
 
1000
1072
 
1001
1073
 
1074
+
1075
+
1076
+
1077
+
1078
+
1079
+
1002
1080
  ,
1003
1081
  ) {
1004
1082
  const { profile, packageRoot, config = {}, defineTool } = options
@@ -1012,8 +1090,20 @@ export function applyCraftPlugin(
1012
1090
  description: spec.description,
1013
1091
  parameters: spec.parameters,
1014
1092
  output: {
1015
- schema: { type: 'string' },
1016
- render: (_args , value ) => [{ type: 'text' , text: value }],
1093
+ schema: {
1094
+ type: 'object',
1095
+ additionalProperties: false,
1096
+ properties: {
1097
+ text: { type: 'string' },
1098
+ images: { type: 'array', items: { type: 'json' } },
1099
+ },
1100
+ },
1101
+ render: (_args , value ) => {
1102
+ const blocks = []
1103
+ if (value.text.length > 0) blocks.push({ type: 'text' , text: value.text })
1104
+ for (const image of value.images ?? []) if (image !== undefined) blocks.push(image)
1105
+ return blocks.length > 0 ? blocks : [{ type: 'text' , text: '' }]
1106
+ },
1017
1107
  },
1018
1108
  async execute(args ) {
1019
1109
  const typed = args
@@ -1021,7 +1111,18 @@ export function applyCraftPlugin(
1021
1111
  try {
1022
1112
  const text = await spec.execute(typed)
1023
1113
  appendJournal(profile, { at: startedAt, kind: spec.kind, summary: journalSummary(spec.kind, typed, text, Date.now() - startedAt) })
1024
- return text
1114
+ // A preview line becomes a real image block when the plugin can commit
1115
+ // one: that is what makes the transcript show each step of the work.
1116
+ const previewPaths = [...text.matchAll(/^\[preview\] (.+)$/gm)].map(match => match[1].trim())
1117
+ if (previewPaths.length > 0 && options.attachImage !== undefined) {
1118
+ const images = []
1119
+ for (const path of previewPaths) {
1120
+ const block = await options.attachImage(path)
1121
+ if (block !== undefined) images.push(block)
1122
+ }
1123
+ return { text: text.replace(/^\[preview\] .+$/gm, '').trim(), images }
1124
+ }
1125
+ return { text, images: [] }
1025
1126
  } catch (error) {
1026
1127
  appendJournal(profile, {
1027
1128
  at: startedAt,