dsh-artcraft-plugin-lightcraft 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 +1 -1
- package/src/index.js +23 -0
- package/src/index.ts +23 -0
- package/src/kit/journal.js +55 -0
- package/src/kit/present.js +206 -0
- package/src/kit/preview.js +210 -0
- package/src/kit/tools.js +170 -12
package/package.json
CHANGED
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,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 (
|
|
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
|
-
|
|
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: {
|
|
983
|
-
|
|
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
|
-
|
|
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({
|