dsh-artcraft-plugin-vectorcraft 0.2.2 → 0.2.7

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-vectorcraft",
3
- "version": "0.2.2",
3
+ "version": "0.2.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "DSH plugin: drive VectorCraft (vector art) headlessly through its own CLI and MCP server.",
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-vectorcraft'
21
22
  export const inject = ['tools', 'systemPrompt']
@@ -26,6 +27,27 @@ export const craft = profileOf('vectorcraft')
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-vectorcraft: profile "vectorcraft" 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-vectorcraft'
21
22
  export const inject = ['tools', 'systemPrompt']
@@ -26,6 +27,27 @@ export const craft = profileOf('vectorcraft')
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-vectorcraft: profile "vectorcraft" 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,55 @@
1
+ /**
2
+ * A readable work journal for the craft tools.
3
+ *
4
+ * The harness shows each tool call, but a session that builds a report spans a
5
+ * dozen calls across four applications, and reconstructing what actually happened
6
+ * means scrolling a transcript. Every call also appends one line to a per-craft
7
+ * journal in the workspace (`.artcraft/journal/`), so the user can open one file
8
+ * and read the story: which craft, which operation, how long, what came out.
9
+ *
10
+ * A journal line is best-effort and never fails a tool call: a read-only
11
+ * workspace loses the record, not the work.
12
+ * @module @dsh-artcraft/craft-kit/journal
13
+ */
14
+
15
+ import { appendFileSync, mkdirSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+
18
+
19
+ /** One recorded event. */
20
+
21
+
22
+
23
+  
24
+
25
+  
26
+
27
+
28
+
29
+
30
+
31
+ /** Where the journals live, relative to the workspace the agent runs in. */
32
+ export function journalDir(cwd = process.cwd()) {
33
+ return join(cwd, '.artcraft', 'journal')
34
+ }
35
+
36
+ /**
37
+ * Append one entry to the craft's journal.
38
+ * @param profile - the craft that ran.
39
+ * @param entry - what happened.
40
+ * @param cwd - workspace root; defaults to the process cwd.
41
+ */
42
+ export function appendJournal(profile , entry , cwd = process.cwd()) {
43
+ try {
44
+ const dir = journalDir(cwd)
45
+ mkdirSync(dir, { recursive: true })
46
+ const time = new Date(entry.at).toTimeString().slice(0, 8)
47
+ const artifacts = entry.artifacts !== undefined && entry.artifacts.length > 0
48
+ ? `\n → ${entry.artifacts.join(', ')}`
49
+ : ''
50
+ appendFileSync(join(dir, `${profile.id}.md`), `${time} · ${entry.kind} · ${entry.summary}${artifacts}\n`)
51
+ appendFileSync(join(dir, 'events.jsonl'), JSON.stringify({ craft: profile.id, ...entry }) + '\n')
52
+ } catch {
53
+ // A journal is a convenience; never let it fail the call that produced it.
54
+ }
55
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Render intents for the craft tools: what a call looks like *while running* and
3
+ * what its result looks like afterwards.
4
+ *
5
+ * A craft run is one call that performs a dozen steps, and a raw JSONL dump is a
6
+ * poor card: the reader cannot see how many steps there are, which one failed, or
7
+ * what files came out. These presenters turn the same result into a titled card
8
+ * (a UI that understands {@link ToolCallView}/{@link ToolResultView} shows it as a
9
+ * tool row with a header and a short body).
10
+ *
11
+ * The kernel stays DSH-free: these are plain objects shaped like the harness's
12
+ * view contracts, and the calling plugin passes them to `defineTool`.
13
+ * @module @dsh-artcraft/craft-kit/present
14
+ */
15
+
16
+
17
+
18
+ /** One text block, as the harness's content-block shape expects. */
19
+
20
+
21
+
22
+
23
+
24
+ /** A file the call touches, for editor follow-along. */
25
+
26
+
27
+
28
+
29
+
30
+ /** Pending-call card: `{card:'generic'}` with a title and an optional body. */
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+ /** Completed-call card: a replacement title and reformatted content. */
41
+
42
+
43
+
44
+
45
+
46
+
47
+ /** Arguments the model passed, as far as these presenters care. */
48
+
49
+
50
+ /** First `count` entries of a list, plus a count when longer. */
51
+ function summarise (items , render , count = 8) {
52
+ const shown = items.slice(0, count).map(render)
53
+ if (items.length > shown.length) shown.push(`…and ${items.length - shown.length} more`)
54
+ return shown.length === 0 ? [{ type: 'text', text: '(no commands)' }] : shown.map(text => ({ type: 'text', text }))
55
+ }
56
+
57
+ /** The command list a run call carries, whether objects or raw strings. */
58
+ function commandLabels(raw ) {
59
+ if (!Array.isArray(raw)) return []
60
+ return raw.map((entry) => {
61
+ if (typeof entry === 'string') return entry
62
+ if (typeof entry === 'object' && entry !== null) {
63
+ const record = entry
64
+ const id = typeof record.id === 'string' ? record.id : '(unnamed)'
65
+ const json = record.json === undefined ? '' : ` ${JSON.stringify(record.json)}`
66
+ return `${id}${json.length > 60 ? `${json.slice(0, 60)}…` : json}`
67
+ }
68
+ return String(entry)
69
+ })
70
+ }
71
+
72
+ /** A path from the arguments, when the call writes one. */
73
+ function outputPath(args ) {
74
+ for (const key of ['save', 'export', 'output', 'out'] ) {
75
+ const value = args[key]
76
+ if (typeof value === 'string' && value.length > 0) return value
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ /** A path from the arguments, when the call reads one. */
82
+ function inputPath(args ) {
83
+ const value = args.input ?? args.file
84
+ return typeof value === 'string' && value.length > 0 ? value : undefined
85
+ }
86
+
87
+ /**
88
+ * The pending-call card for one tool.
89
+ * @param profile - the craft, for the title.
90
+ * @param kind - which tool of the set is being called.
91
+ * @param args - the model's arguments.
92
+ * @returns a generic call view.
93
+ */
94
+ export function craftCallView(profile , kind , args ) {
95
+ const name = profile.product
96
+ switch (kind) {
97
+ case 'run': {
98
+ const labels = commandLabels(args.commands)
99
+ const target = outputPath(args)
100
+ return {
101
+ card: 'generic',
102
+ kind: 'execute',
103
+ title: `${name} · ${labels.length} command${labels.length === 1 ? '' : 's'}${target === undefined ? '' : ` → ${target}`}`,
104
+ content: summarise(labels, label => `• ${label}`),
105
+ ...target === undefined ? {} : { locations: [{ path: target }] },
106
+ }
107
+ }
108
+ case 'commands': {
109
+ const search = typeof args.search === 'string' && args.search.length > 0 ? ` "${args.search}"` : ''
110
+ return { card: 'generic', kind: 'search', title: `${name} · command catalogue${search}` }
111
+ }
112
+ case 'describe': {
113
+ const id = typeof args.id === 'string' ? args.id : ''
114
+ return { card: 'generic', kind: 'read', title: `${name} · describe ${id}` }
115
+ }
116
+ case 'inspect': {
117
+ const file = inputPath(args) ?? '(default)'
118
+ return { card: 'generic', kind: 'read', title: `${name} · inspect ${file}`, locations: [{ path: file }] }
119
+ }
120
+ case 'convert': {
121
+ const input = inputPath(args) ?? ''
122
+ const output = typeof args.output === 'string' ? args.output : ''
123
+ return {
124
+ card: 'generic',
125
+ kind: 'execute',
126
+ title: `${name} · convert ${input}${output.length === 0 ? '' : ` → ${output}`}`,
127
+ locations: output.length === 0 ? [] : [{ path: output }],
128
+ }
129
+ }
130
+ case 'mcpCall': {
131
+ const tool = typeof args.tool === 'string' ? args.tool : '(tool)'
132
+ const attach = typeof args.attach === 'number' ? ` · attached :${args.attach}` : ''
133
+ return { card: 'generic', kind: 'execute', title: `${name} · ${tool}${attach}`, rawInput: args.arguments }
134
+ }
135
+ case 'mcpTools': {
136
+ const search = typeof args.search === 'string' && args.search.length > 0 ? ` "${args.search}"` : ''
137
+ return { card: 'generic', kind: 'search', title: `${name} · MCP tools, resources and prompts${search}` }
138
+ }
139
+ case 'mcpStatus':
140
+ return { card: 'generic', kind: 'read', title: `${name} · MCP bridge status` }
141
+ case 'job': {
142
+ const action = typeof args.action === 'string' ? args.action : 'list'
143
+ const job = typeof args.job === 'string' ? ` ${args.job}` : ''
144
+ return { card: 'generic', kind: 'other', title: `${name} · job ${action}${job}` }
145
+ }
146
+ default:
147
+ return { card: 'generic', kind: 'other', title: `${name} · ${kind}` }
148
+ }
149
+ }
150
+
151
+ /** What a run result text says: exit status, duration, artifacts, per-step failures. */
152
+ function summariseRun(profile , text ) {
153
+ const steps = text.split('\n').filter(line => line.startsWith('{"command"') || line.startsWith('{"step"'))
154
+ let ok = 0
155
+ let failed = 0
156
+ let firstFailure = ''
157
+ for (const line of steps) {
158
+ try {
159
+ const parsed = JSON.parse(line)
160
+ if (parsed.ok === false) {
161
+ failed += 1
162
+ if (firstFailure.length === 0) firstFailure = `${parsed.command ?? 'step'}: ${parsed.error ?? 'failed'}`
163
+ } else ok += 1
164
+ } catch {
165
+ // A craft that prints non-JSON progress lines still runs; the exit line below decides.
166
+ }
167
+ }
168
+ const exit = /exited (\d+) in (\d+)ms/.exec(text)
169
+ const artifacts = [...text.matchAll(/^(?:wrote|saved|exported) (.+)$/gm)].map(match => match[1])
170
+ const title = `${profile.product} · ${steps.length > 0 ? `${ok} ok${failed > 0 ? `, ${failed} failed` : ''}` : 'finished'}${exit === null ? '' : ` · exit ${exit[1]}`}`
171
+ const body = []
172
+ if (failed > 0) body.push(`first failure — ${firstFailure}`)
173
+ if (artifacts.length > 0) body.push(`wrote ${artifacts.join(', ')}`)
174
+ if (exit !== null) body.push(`took ${exit[2]} ms`)
175
+ if (body.length === 0) body.push(text.trim().split('\n')[0] ?? '(no output)')
176
+ return { card: 'generic', title, content: body.map(line => ({ type: 'text', text: line })) }
177
+ }
178
+
179
+ /**
180
+ * The completed-call card for one tool.
181
+ * @param profile - the craft, for the title.
182
+ * @param kind - which tool ran.
183
+ * @param text - the model-facing result.
184
+ * @param isError - whether the call failed.
185
+ * @returns a generic result view.
186
+ */
187
+ export function craftResultView(profile , kind , text , isError ) {
188
+ if (isError) {
189
+ return {
190
+ card: 'generic',
191
+ title: `${profile.product} · ${kind} failed`,
192
+ content: [{ type: 'text', text: text.split('\n').slice(0, 4).join('\n') }],
193
+ }
194
+ }
195
+ if (kind === 'run') return summariseRun(profile, text)
196
+ const all = text.trim().split('\n').filter(line => line.trim().length > 0)
197
+ // A few lines are usually the whole story (job status, an image path and its
198
+ // dimensions); keep them rather than collapsing to one line and a count.
199
+ const shown = all.slice(0, 4)
200
+ if (all.length > shown.length) shown.push(`…and ${all.length - shown.length} more lines`)
201
+ return {
202
+ card: 'generic',
203
+ title: `${profile.product} · ${kind}`,
204
+ content: (shown.length === 0 ? ['(no output)'] : shown).map(line => ({ type: 'text', text: line })),
205
+ }
206
+ }
@@ -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
@@ -8,6 +8,9 @@
8
8
  * @module @dsh-artcraft/craft-kit/tools
9
9
  */
10
10
 
11
+ import { craftCallView, craftResultView } from './present.js'
12
+ import { canPreview, nextStepNumber, previewRoot, recordPreview, renderPreview, stepName } from './preview.js'
13
+ import { appendJournal } from './journal.js'
11
14
  import { CraftMcpClient, renderSchema } from './mcp.js'
12
15
 
13
16
  import { groupsOf, loadCatalog, score } from './catalog.js'
@@ -159,10 +162,35 @@ export function createRuntime(profile , packageRoot , config
159
162
  let catalogCache
160
163
  const mcpClients = new Map ()
161
164
  const artifacts = config.artifactsDir ?? join(tmpdir(), 'dsh-artcraft', profile.id)
165
+ const previewDir = previewRoot(config.previewDir)
166
+ let previewStep = 0
162
167
  const jobs = new Map ()
163
168
  const jobChildren = new Map ()
164
169
  let jobCounter = 0
165
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
+ },
166
194
  startJob(binary, argv) {
167
195
  jobCounter += 1
168
196
  const id = `j${jobCounter}`
@@ -314,13 +342,17 @@ export function renderCatalog(
314
342
 
315
343
 
316
344
 
317
- /** One tool's definition: name/description/schema plus its executor. */
345
+ /** One tool's definition: name/description/schema plus its executor and views. */
318
346
 
319
347
 
320
348
 
321
349
 
322
350
 
323
351
 
352
+
353
+
354
+
355
+
324
356
 
325
357
 
326
358
  /**
@@ -376,7 +408,13 @@ export function createToolSet(runtime ) {
376
408
  * Renders, bakes and bounces outlast the tool timeout; a job keeps the process
377
409
  * alive after the call returns and leaves its output in a log file.
378
410
  */
379
- const runOrStart = async (binary , argv , background ) => {
411
+ const runOrStart = async (
412
+ binary ,
413
+ argv ,
414
+ background ,
415
+ document ,
416
+ label ,
417
+ ) => {
380
418
  if (background === true) {
381
419
  const job = runtime.startJob(binary, argv)
382
420
  return [
@@ -386,7 +424,21 @@ export function createToolSet(runtime ) {
386
424
  `or wait with {"action":"wait","job":"${job.id}","timeoutMs":300000}`,
387
425
  ].join('\n')
388
426
  }
389
- 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
390
442
  }
391
443
 
392
444
  /** Guard every tool: a missing binary is an answer, never a crash. */
@@ -412,6 +464,32 @@ export function createToolSet(runtime ) {
412
464
  return await runOrStart(binary, argv, background)
413
465
  }
414
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
+
415
493
  const session = { commands: [], stale: false }
416
494
 
417
495
  /**
@@ -535,7 +613,7 @@ export function createToolSet(runtime ) {
535
613
  }
536
614
  writeFileSync(scriptPath, typeof body === 'string' ? body : JSON.stringify(body, null, 2) + '\n')
537
615
  const extra = Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : []
538
- return await runOrStart(binary, [...argv, ...extra], args.background)
616
+ return await runOrStart(binary, [...argv, ...extra], args.background, documentOf(args), labelOf(args))
539
617
  }
540
618
  if (profile.run.scriptFlag !== undefined) {
541
619
  const scriptPath = join(craftWorkDir(profile), `steps-${Date.now()}.json`)
@@ -550,7 +628,7 @@ export function createToolSet(runtime ) {
550
628
  profile.run.scriptFlag, scriptPath,
551
629
  ...Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : [],
552
630
  ]
553
- 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))
554
632
  }
555
633
  if (profile.run.scriptMode === true) {
556
634
  const scriptPath = join(craftWorkDir(profile), `run-${Date.now()}.jsonl`)
@@ -562,7 +640,7 @@ export function createToolSet(runtime ) {
562
640
  scriptPath,
563
641
  ...Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : [],
564
642
  ]
565
- 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))
566
644
  }
567
645
  // PdfCraft's `run` accepts exactly one tool per invocation; sending a
568
646
  // list would produce "expected key=value" from the CLI, so the extra
@@ -591,7 +669,7 @@ export function createToolSet(runtime ) {
591
669
  ...args.print === true ? { print: true } : {},
592
670
  ...Array.isArray(args.extraArgs) ? { extra: args.extraArgs.filter((a) => typeof a === 'string') } : {},
593
671
  })
594
- return await runOrStart(binary, argv, args.background)
672
+ return await runOrStart(binary, argv, args.background, documentOf(args), labelOf(args))
595
673
  },
596
674
  }
597
675
 
@@ -705,7 +783,7 @@ export function createToolSet(runtime ) {
705
783
  else if (argMode === 'section') argv.push(typeof args.section === 'string' && args.section.length > 0 ? args.section : inspectSpec.section ?? 'project')
706
784
  if (inspectSpec.projectFlag !== undefined && typeof args.project === 'string') argv.push(inspectSpec.projectFlag, args.project)
707
785
  argv.push(...inspectSpec.extraArgs ?? [], ...extra)
708
- return await runOrStart(binary, argv, args.background)
786
+ return await runOrStart(binary, argv, args.background, documentOf(args), labelOf(args))
709
787
  },
710
788
  }
711
789
 
@@ -918,6 +996,7 @@ export function createToolSet(runtime ) {
918
996
  }
919
997
 
920
998
 
999
+
921
1000
  /** Prompt-section slot: after the built-in tool sections, app prose follows. */
922
1001
  export const CRAFT_SECTION_ORDER = 120
923
1002
 
@@ -931,11 +1010,37 @@ export const CRAFT_SECTION_ORDER = 120
931
1010
 
932
1011
 
933
1012
 
934
-
1013
+
935
1014
 
936
1015
 
1016
+
1017
+
937
1018
 
938
1019
 
1020
+ /** One-line journal summary for a finished call. */
1021
+ function journalSummary(kind , args , text , elapsedMs ) {
1022
+ const output = ['save', 'export', 'output', 'out'].map(key => args[key]).find(value => typeof value === 'string' && value.length > 0)
1023
+ switch (kind) {
1024
+ case 'run': {
1025
+ const count = Array.isArray(args.commands) ? args.commands.length : 0
1026
+ const exit = /exited (\d+)/.exec(text)
1027
+ return `${count} command(s)${output === undefined ? '' : ` → ${String(output)}`}`
1028
+ + `${exit === null ? '' : ` · exit ${exit[1]}`} · ${elapsedMs}ms`
1029
+ }
1030
+ case 'mcpCall':
1031
+ return `tool ${String(args.tool ?? '?')} · ${elapsedMs}ms`
1032
+ case 'job':
1033
+ return `job ${String(args.action ?? 'list')}${typeof args.job === 'string' ? ` ${args.job}` : ''}`
1034
+ case 'inspect':
1035
+ case 'convert':
1036
+ return `${String(args.file ?? args.input ?? '')}${args.output === undefined ? '' : ` → ${String(args.output)}`}`
1037
+ default: {
1038
+ const first = text.trim().split('\n')[0] ?? ''
1039
+ return first.length > 120 ? `${first.slice(0, 120)}…` : first
1040
+ }
1041
+ }
1042
+ }
1043
+
939
1044
  /** The `ctx` surface the kernel uses; kept structural for the same reason. */
940
1045
 
941
1046
 
@@ -966,6 +1071,12 @@ export function applyCraftPlugin(
966
1071
 
967
1072
 
968
1073
 
1074
+
1075
+
1076
+
1077
+
1078
+
1079
+
969
1080
  ,
970
1081
  ) {
971
1082
  const { profile, packageRoot, config = {}, defineTool } = options
@@ -979,12 +1090,59 @@ export function applyCraftPlugin(
979
1090
  description: spec.description,
980
1091
  parameters: spec.parameters,
981
1092
  output: {
982
- schema: { type: 'string' },
983
- 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
+ },
984
1107
  },
985
1108
  async execute(args ) {
986
- return await spec.execute(args )
1109
+ const typed = args
1110
+ const startedAt = Date.now()
1111
+ try {
1112
+ const text = await spec.execute(typed)
1113
+ appendJournal(profile, { at: startedAt, kind: spec.kind, summary: journalSummary(spec.kind, typed, text, Date.now() - startedAt) })
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: [] }
1126
+ } catch (error) {
1127
+ appendJournal(profile, {
1128
+ at: startedAt,
1129
+ kind: spec.kind,
1130
+ summary: `failed: ${String((error ).message ?? error).slice(0, 140)}`,
1131
+ })
1132
+ throw error
1133
+ }
987
1134
  },
1135
+ // Cards: a run is one call with a dozen steps, so the raw JSONL is a poor
1136
+ // row. These turn it into a titled card — what the call does, then what it
1137
+ // produced — and the journal keeps the same story on disk.
1138
+ presentCall: (args ) => craftCallView(profile, spec.kind, (args ?? {}) ),
1139
+ presentResult: (args , result ) =>
1140
+ craftResultView(
1141
+ profile,
1142
+ spec.kind,
1143
+ (result.content ?? []).filter(block => block.type === 'text').map(block => block.text ?? '').join('\n'),
1144
+ result.isError === true,
1145
+ ),
988
1146
  })))
989
1147
  }
990
1148
  ctx.systemPrompt.section({