dsh-artcraft-plugin-pdfcraft 0.2.1 → 0.2.4
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/kit/exec.js +65 -0
- package/src/kit/journal.js +55 -0
- package/src/kit/present.js +206 -0
- package/src/kit/tools.js +202 -21
- package/src/kit/types.js +4 -0
package/package.json
CHANGED
package/src/kit/exec.js
CHANGED
|
@@ -155,6 +155,71 @@ export async function runCraft(options )
|
|
|
155
155
|
})
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Run one craft process in the background and stream its output to a log file.
|
|
160
|
+
*
|
|
161
|
+
* Long operations — a 4K render, a texture bake, an audio bounce — outlast any
|
|
162
|
+
* reasonable tool timeout, and killing them at the timer loses the work. A job
|
|
163
|
+
* keeps the process alive after the tool returns, so the caller can poll or wait
|
|
164
|
+
* and still read the output afterwards.
|
|
165
|
+
*/
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
/** A started job, including the process handle the runtime uses to observe it. */
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
/** Live job state kept by the runtime. */
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Start a craft CLI in the background.
|
|
199
|
+
* @param options - profile, binary, argv, cwd and the log file to write.
|
|
200
|
+
* @returns a handle the caller can poll through its runtime.
|
|
201
|
+
*/
|
|
202
|
+
export function startCraftJob(options ) {
|
|
203
|
+
const { profile, binary, argv, cwd, logPath, id } = options
|
|
204
|
+
const child = spawn(binary.path, [...argv], {
|
|
205
|
+
cwd,
|
|
206
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
207
|
+
env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
|
|
208
|
+
windowsHide: true,
|
|
209
|
+
})
|
|
210
|
+
const write = (chunk ) => {
|
|
211
|
+
try {
|
|
212
|
+
appendFileSync(logPath, chunk)
|
|
213
|
+
} catch {
|
|
214
|
+
// A full disk must not take the tool down; the process keeps running.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
child.stdout?.on('data', write)
|
|
218
|
+
child.stderr?.on('data', write)
|
|
219
|
+
void profile
|
|
220
|
+
return { id, pid: child.pid, logPath, startedAt: Date.now(), argv, child }
|
|
221
|
+
}
|
|
222
|
+
|
|
158
223
|
/**
|
|
159
224
|
* Compose the argv for one headless `run` invocation.
|
|
160
225
|
*
|
|
@@ -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
|
+
}
|
package/src/kit/tools.js
CHANGED
|
@@ -8,15 +8,17 @@
|
|
|
8
8
|
* @module @dsh-artcraft/craft-kit/tools
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { craftCallView, craftResultView } from './present.js'
|
|
12
|
+
import { appendJournal } from './journal.js'
|
|
11
13
|
import { CraftMcpClient, renderSchema } from './mcp.js'
|
|
12
14
|
|
|
13
15
|
import { groupsOf, loadCatalog, score } from './catalog.js'
|
|
14
16
|
import { craftVersion, discoverCraft, missingBinaryMessage } from './discover.js'
|
|
15
|
-
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
16
18
|
import { tmpdir } from 'node:os'
|
|
17
19
|
import { dirname, join } from 'node:path'
|
|
18
|
-
import { DEFAULT_TIMEOUT_MS, buildRunArgv, runCraft } from './exec.js'
|
|
19
|
-
|
|
20
|
+
import { DEFAULT_TIMEOUT_MS, buildRunArgv, killTree, runCraft, startCraftJob } from './exec.js'
|
|
21
|
+
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -159,7 +161,53 @@ export function createRuntime(profile , packageRoot , config
|
|
|
159
161
|
let catalogCache
|
|
160
162
|
const mcpClients = new Map ()
|
|
161
163
|
const artifacts = config.artifactsDir ?? join(tmpdir(), 'dsh-artcraft', profile.id)
|
|
164
|
+
const jobs = new Map ()
|
|
165
|
+
const jobChildren = new Map ()
|
|
166
|
+
let jobCounter = 0
|
|
162
167
|
return {
|
|
168
|
+
startJob(binary, argv) {
|
|
169
|
+
jobCounter += 1
|
|
170
|
+
const id = `j${jobCounter}`
|
|
171
|
+
const jobDir = join(artifacts, 'jobs')
|
|
172
|
+
mkdirSync(jobDir, { recursive: true })
|
|
173
|
+
const logPath = join(jobDir, `${profile.id}-${id}.log`)
|
|
174
|
+
const started = startCraftJob({ profile, binary, argv, cwd: process.cwd(), logPath, id })
|
|
175
|
+
const state = { ...started, running: true, argv: started.argv, logPath, id, pid: started.pid, startedAt: started.startedAt }
|
|
176
|
+
jobs.set(id, state)
|
|
177
|
+
jobChildren.set(id, started.child)
|
|
178
|
+
started.child.on('close', (code, signal) => {
|
|
179
|
+
const settled = {
|
|
180
|
+
...state, running: false, exitCode: code, signal,
|
|
181
|
+
durationMs: Date.now() - state.startedAt,
|
|
182
|
+
}
|
|
183
|
+
jobs.set(id, settled)
|
|
184
|
+
jobChildren.delete(id)
|
|
185
|
+
})
|
|
186
|
+
started.child.unref()
|
|
187
|
+
return started
|
|
188
|
+
},
|
|
189
|
+
jobState(id) {
|
|
190
|
+
return jobs.get(id)
|
|
191
|
+
},
|
|
192
|
+
jobs() {
|
|
193
|
+
return [...jobs.values()].sort((a, b) => a.startedAt - b.startedAt)
|
|
194
|
+
},
|
|
195
|
+
async waitForJob(id, timeoutMs) {
|
|
196
|
+
const state = jobs.get(id)
|
|
197
|
+
if (state === undefined) return undefined
|
|
198
|
+
const deadline = Date.now() + Math.max(0, timeoutMs)
|
|
199
|
+
for (;;) {
|
|
200
|
+
const current = jobs.get(id)
|
|
201
|
+
if (current === undefined || !current.running) return current
|
|
202
|
+
if (Date.now() >= deadline) return current
|
|
203
|
+
await new Promise(resolve => setTimeout(resolve, 200))
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
killJob(id) {
|
|
207
|
+
const child = jobChildren.get(id)
|
|
208
|
+
if (child !== undefined) killTree(child)
|
|
209
|
+
return child !== undefined
|
|
210
|
+
},
|
|
163
211
|
artifactsDir() {
|
|
164
212
|
return artifacts
|
|
165
213
|
},
|
|
@@ -264,15 +312,21 @@ export function renderCatalog(
|
|
|
264
312
|
|
|
265
313
|
|
|
266
314
|
|
|
315
|
+
|
|
316
|
+
|
|
267
317
|
|
|
268
318
|
|
|
269
|
-
/** One tool's definition: name/description/schema plus its executor. */
|
|
319
|
+
/** One tool's definition: name/description/schema plus its executor and views. */
|
|
270
320
|
|
|
271
321
|
|
|
272
322
|
|
|
273
323
|
|
|
274
324
|
|
|
275
325
|
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
|
|
276
330
|
|
|
277
331
|
|
|
278
332
|
/**
|
|
@@ -322,6 +376,25 @@ export function createToolSet(runtime ) {
|
|
|
322
376
|
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
323
377
|
const catalogLimit = config.catalogLimit ?? 60
|
|
324
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Run now, or start a background job when `background` is set.
|
|
381
|
+
*
|
|
382
|
+
* Renders, bakes and bounces outlast the tool timeout; a job keeps the process
|
|
383
|
+
* alive after the call returns and leaves its output in a log file.
|
|
384
|
+
*/
|
|
385
|
+
const runOrStart = async (binary , argv , background ) => {
|
|
386
|
+
if (background === true) {
|
|
387
|
+
const job = runtime.startJob(binary, argv)
|
|
388
|
+
return [
|
|
389
|
+
`started ${profile.id} job ${job.id}${job.pid === undefined ? '' : ` (pid ${job.pid})`}`,
|
|
390
|
+
`log: ${job.logPath}`,
|
|
391
|
+
`poll with \`${profile.id}_job\` {"action":"status","job":"${job.id}"}`,
|
|
392
|
+
`or wait with {"action":"wait","job":"${job.id}","timeoutMs":300000}`,
|
|
393
|
+
].join('\n')
|
|
394
|
+
}
|
|
395
|
+
return renderRun(await runCraft({ profile, binary, argv, cwd: process.cwd(), timeoutMs }), profile)
|
|
396
|
+
}
|
|
397
|
+
|
|
325
398
|
/** Guard every tool: a missing binary is an answer, never a crash. */
|
|
326
399
|
const requireBinary = () => {
|
|
327
400
|
const binary = runtime.binary()
|
|
@@ -335,13 +408,14 @@ export function createToolSet(runtime ) {
|
|
|
335
408
|
input ,
|
|
336
409
|
sample ,
|
|
337
410
|
commands ,
|
|
411
|
+
background ,
|
|
338
412
|
) => {
|
|
339
413
|
const argv = buildRunArgv(profile, {
|
|
340
414
|
...input !== undefined ? { input } : {},
|
|
341
415
|
...sample !== undefined ? { sample } : {},
|
|
342
416
|
commands,
|
|
343
417
|
})
|
|
344
|
-
return
|
|
418
|
+
return await runOrStart(binary, argv, background)
|
|
345
419
|
}
|
|
346
420
|
|
|
347
421
|
const session = { commands: [], stale: false }
|
|
@@ -373,6 +447,7 @@ export function createToolSet(runtime ) {
|
|
|
373
447
|
+ `${profile.blurb} Files: ${profile.extensions.join(', ')}.`,
|
|
374
448
|
parameters: {
|
|
375
449
|
input: { type: 'string', description: `Absolute or workspace-relative path to an existing ${profile.extensions[0]} document. Mutually exclusive with \`sample\`.` },
|
|
450
|
+
background: { type: 'boolean', description: 'Start the work as a background job and return immediately; poll it with the job tool. Use for long renders, bakes and bounces.' },
|
|
376
451
|
commands: {
|
|
377
452
|
type: 'array',
|
|
378
453
|
description: 'Ordered commands to apply. Each is { "id": "cell.set", "json": { "cell": "A1", "input": "x" } }. An entry may also be a raw CLI string.',
|
|
@@ -466,8 +541,7 @@ export function createToolSet(runtime ) {
|
|
|
466
541
|
}
|
|
467
542
|
writeFileSync(scriptPath, typeof body === 'string' ? body : JSON.stringify(body, null, 2) + '\n')
|
|
468
543
|
const extra = Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : []
|
|
469
|
-
|
|
470
|
-
return renderRun(scriptResult, profile)
|
|
544
|
+
return await runOrStart(binary, [...argv, ...extra], args.background)
|
|
471
545
|
}
|
|
472
546
|
if (profile.run.scriptFlag !== undefined) {
|
|
473
547
|
const scriptPath = join(craftWorkDir(profile), `steps-${Date.now()}.json`)
|
|
@@ -482,8 +556,7 @@ export function createToolSet(runtime ) {
|
|
|
482
556
|
profile.run.scriptFlag, scriptPath,
|
|
483
557
|
...Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : [],
|
|
484
558
|
]
|
|
485
|
-
|
|
486
|
-
return renderRun(result, profile)
|
|
559
|
+
return await runOrStart(binary, [profile.run.verb, ...extra], args.background)
|
|
487
560
|
}
|
|
488
561
|
if (profile.run.scriptMode === true) {
|
|
489
562
|
const scriptPath = join(craftWorkDir(profile), `run-${Date.now()}.jsonl`)
|
|
@@ -495,12 +568,7 @@ export function createToolSet(runtime ) {
|
|
|
495
568
|
scriptPath,
|
|
496
569
|
...Array.isArray(args.extraArgs) ? args.extraArgs.filter((a) => typeof a === 'string') : [],
|
|
497
570
|
]
|
|
498
|
-
|
|
499
|
-
profile, binary,
|
|
500
|
-
argv: [...globalFlagsFor(profile, args), profile.run.verb, ...extra],
|
|
501
|
-
cwd: process.cwd(), timeoutMs,
|
|
502
|
-
})
|
|
503
|
-
return renderRun(result, profile)
|
|
571
|
+
return await runOrStart(binary, [...globalFlagsFor(profile, args), profile.run.verb, ...extra], args.background)
|
|
504
572
|
}
|
|
505
573
|
// PdfCraft's `run` accepts exactly one tool per invocation; sending a
|
|
506
574
|
// list would produce "expected key=value" from the CLI, so the extra
|
|
@@ -511,7 +579,7 @@ export function createToolSet(runtime ) {
|
|
|
511
579
|
// whole list in a single process; the history recorded above is replayed
|
|
512
580
|
// through that path, so a second call continues the same document.
|
|
513
581
|
if (profile.oneToolPerRun === true && profile.run.scriptFlag === undefined && rest.length > 0) {
|
|
514
|
-
const only = await runSingle(binary, input, sample, single)
|
|
582
|
+
const only = await runSingle(binary, input, sample, single, args.background)
|
|
515
583
|
return `${only}\n\n[${profile.product} runs one tool per call: ${rest.length} further command(s) were not sent — ${rest.map(c => c.id).join(', ')}. Call this tool again for each.]`
|
|
516
584
|
}
|
|
517
585
|
const argv = buildRunArgv(profile, {
|
|
@@ -529,8 +597,7 @@ export function createToolSet(runtime ) {
|
|
|
529
597
|
...args.print === true ? { print: true } : {},
|
|
530
598
|
...Array.isArray(args.extraArgs) ? { extra: args.extraArgs.filter((a) => typeof a === 'string') } : {},
|
|
531
599
|
})
|
|
532
|
-
|
|
533
|
-
return renderRun(result, profile)
|
|
600
|
+
return await runOrStart(binary, argv, args.background)
|
|
534
601
|
},
|
|
535
602
|
}
|
|
536
603
|
|
|
@@ -644,8 +711,7 @@ export function createToolSet(runtime ) {
|
|
|
644
711
|
else if (argMode === 'section') argv.push(typeof args.section === 'string' && args.section.length > 0 ? args.section : inspectSpec.section ?? 'project')
|
|
645
712
|
if (inspectSpec.projectFlag !== undefined && typeof args.project === 'string') argv.push(inspectSpec.projectFlag, args.project)
|
|
646
713
|
argv.push(...inspectSpec.extraArgs ?? [], ...extra)
|
|
647
|
-
|
|
648
|
-
return renderRun(result, profile)
|
|
714
|
+
return await runOrStart(binary, argv, args.background)
|
|
649
715
|
},
|
|
650
716
|
}
|
|
651
717
|
|
|
@@ -670,6 +736,69 @@ export function createToolSet(runtime ) {
|
|
|
670
736
|
},
|
|
671
737
|
} : undefined
|
|
672
738
|
|
|
739
|
+
const job = {
|
|
740
|
+
kind: 'job',
|
|
741
|
+
name: `${profile.id}_job`,
|
|
742
|
+
description:
|
|
743
|
+
`Track background work started by \`${profile.id}_run\` with \`background: true\`. `
|
|
744
|
+
+ `Long renders, bakes and bounces outlive a tool timeout; the job keeps running and logs to a file. `
|
|
745
|
+
+ `Actions: \`list\` every job, \`status\` one (with the log tail), \`wait\` for it to finish, \`kill\` it.`,
|
|
746
|
+
parameters: {
|
|
747
|
+
action: { type: 'string', enum: ['list', 'status', 'wait', 'kill'], description: 'What to do. Defaults to list.' },
|
|
748
|
+
job: { type: 'string', description: 'Job id returned by the run call, e.g. "j1".' },
|
|
749
|
+
timeoutMs: { type: 'number', description: 'For action "wait": milliseconds to block (default 60000, at most 600000).' },
|
|
750
|
+
tailLines: { type: 'number', description: 'For "status"/"wait": how many log lines to show (default 20).' },
|
|
751
|
+
},
|
|
752
|
+
async execute(args) {
|
|
753
|
+
const action = typeof args.action === 'string' ? args.action : 'list'
|
|
754
|
+
const tailLines = typeof args.tailLines === 'number' && args.tailLines > 0 ? Math.min(Math.floor(args.tailLines), 200) : 20
|
|
755
|
+
/** Last lines of a job's log, with a byte cap so a huge log cannot flood the model. */
|
|
756
|
+
const tail = (path ) => {
|
|
757
|
+
try {
|
|
758
|
+
const text = readFileSync(path, 'utf8')
|
|
759
|
+
const lines = text.split('\n')
|
|
760
|
+
const shown = lines.slice(Math.max(0, lines.length - tailLines)).join('\n').trim()
|
|
761
|
+
return shown.length > 8000 ? `…${shown.slice(-8000)}` : shown
|
|
762
|
+
} catch {
|
|
763
|
+
return '(no log yet)'
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** A job's argv, shortened: a 200-command render would otherwise print pages. */
|
|
767
|
+
const commandLine = (argv ) => {
|
|
768
|
+
const text = argv.join(' ')
|
|
769
|
+
return text.length > 240 ? `${text.slice(0, 240)}… (${argv.length} arguments)` : text
|
|
770
|
+
}
|
|
771
|
+
const describe = (state ) => [
|
|
772
|
+
`${state.id}${state.running ? ' (running)' : ` (exit ${state.exitCode ?? state.signal ?? '?'})`}`
|
|
773
|
+
+ ` — ${state.durationMs === undefined ? `${Math.round((Date.now() - state.startedAt) / 1000)}s so far` : `${Math.round(state.durationMs / 1000)}s`}`,
|
|
774
|
+
` command: ${commandLine(state.argv)}`,
|
|
775
|
+
` log: ${state.logPath}`,
|
|
776
|
+
].join('\n')
|
|
777
|
+
|
|
778
|
+
if (action === 'list') {
|
|
779
|
+
const all = runtime.jobs()
|
|
780
|
+
if (all.length === 0) return `${profile.product}: no background jobs in this session.`
|
|
781
|
+
return [`${profile.product} background jobs: ${all.length}`, ...all.map(describe)].join('\n')
|
|
782
|
+
}
|
|
783
|
+
const id = typeof args.job === 'string' ? args.job.trim() : ''
|
|
784
|
+
if (id.length === 0) throw new Error('job is required for action ' + JSON.stringify(action))
|
|
785
|
+
if (action === 'kill') {
|
|
786
|
+
const killed = runtime.killJob(id)
|
|
787
|
+
const state = runtime.jobState(id)
|
|
788
|
+
return killed ? `killed ${id}` : `${id} is not running${state === undefined ? ' (unknown job)' : ''}`
|
|
789
|
+
}
|
|
790
|
+
if (action === 'wait') {
|
|
791
|
+
const timeout = typeof args.timeoutMs === 'number' && args.timeoutMs > 0 ? Math.min(Math.floor(args.timeoutMs), 600_000) : 60_000
|
|
792
|
+
const state = await runtime.waitForJob(id, timeout)
|
|
793
|
+
if (state === undefined) return `No job named ${JSON.stringify(id)}.`
|
|
794
|
+
return [describe(state), '', tail(state.logPath)].join('\n')
|
|
795
|
+
}
|
|
796
|
+
const state = runtime.jobState(id)
|
|
797
|
+
if (state === undefined) return `No job named ${JSON.stringify(id)}.`
|
|
798
|
+
return [describe(state), '', tail(state.logPath)].join('\n')
|
|
799
|
+
},
|
|
800
|
+
}
|
|
801
|
+
|
|
673
802
|
const mcp = runtime.mcp()
|
|
674
803
|
const idleMs = config.mcpIdleMs ?? 300_000
|
|
675
804
|
|
|
@@ -790,10 +919,12 @@ export function createToolSet(runtime ) {
|
|
|
790
919
|
...mcpTools !== undefined ? { mcpTools } : {},
|
|
791
920
|
...mcpCall !== undefined ? { mcpCall } : {},
|
|
792
921
|
...mcpStatus !== undefined ? { mcpStatus } : {},
|
|
922
|
+
job,
|
|
793
923
|
}
|
|
794
924
|
}
|
|
795
925
|
|
|
796
926
|
|
|
927
|
+
|
|
797
928
|
/** Prompt-section slot: after the built-in tool sections, app prose follows. */
|
|
798
929
|
export const CRAFT_SECTION_ORDER = 120
|
|
799
930
|
|
|
@@ -810,8 +941,34 @@ export const CRAFT_SECTION_ORDER = 120
|
|
|
810
941
|
|
|
811
942
|
|
|
812
943
|
|
|
944
|
+
|
|
945
|
+
|
|
813
946
|
|
|
814
947
|
|
|
948
|
+
/** One-line journal summary for a finished call. */
|
|
949
|
+
function journalSummary(kind , args , text , elapsedMs ) {
|
|
950
|
+
const output = ['save', 'export', 'output', 'out'].map(key => args[key]).find(value => typeof value === 'string' && value.length > 0)
|
|
951
|
+
switch (kind) {
|
|
952
|
+
case 'run': {
|
|
953
|
+
const count = Array.isArray(args.commands) ? args.commands.length : 0
|
|
954
|
+
const exit = /exited (\d+)/.exec(text)
|
|
955
|
+
return `${count} command(s)${output === undefined ? '' : ` → ${String(output)}`}`
|
|
956
|
+
+ `${exit === null ? '' : ` · exit ${exit[1]}`} · ${elapsedMs}ms`
|
|
957
|
+
}
|
|
958
|
+
case 'mcpCall':
|
|
959
|
+
return `tool ${String(args.tool ?? '?')} · ${elapsedMs}ms`
|
|
960
|
+
case 'job':
|
|
961
|
+
return `job ${String(args.action ?? 'list')}${typeof args.job === 'string' ? ` ${args.job}` : ''}`
|
|
962
|
+
case 'inspect':
|
|
963
|
+
case 'convert':
|
|
964
|
+
return `${String(args.file ?? args.input ?? '')}${args.output === undefined ? '' : ` → ${String(args.output)}`}`
|
|
965
|
+
default: {
|
|
966
|
+
const first = text.trim().split('\n')[0] ?? ''
|
|
967
|
+
return first.length > 120 ? `${first.slice(0, 120)}…` : first
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
815
972
|
/** The `ctx` surface the kernel uses; kept structural for the same reason. */
|
|
816
973
|
|
|
817
974
|
|
|
@@ -859,8 +1016,32 @@ export function applyCraftPlugin(
|
|
|
859
1016
|
render: (_args , value ) => [{ type: 'text' , text: value }],
|
|
860
1017
|
},
|
|
861
1018
|
async execute(args ) {
|
|
862
|
-
|
|
1019
|
+
const typed = args
|
|
1020
|
+
const startedAt = Date.now()
|
|
1021
|
+
try {
|
|
1022
|
+
const text = await spec.execute(typed)
|
|
1023
|
+
appendJournal(profile, { at: startedAt, kind: spec.kind, summary: journalSummary(spec.kind, typed, text, Date.now() - startedAt) })
|
|
1024
|
+
return text
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
appendJournal(profile, {
|
|
1027
|
+
at: startedAt,
|
|
1028
|
+
kind: spec.kind,
|
|
1029
|
+
summary: `failed: ${String((error ).message ?? error).slice(0, 140)}`,
|
|
1030
|
+
})
|
|
1031
|
+
throw error
|
|
1032
|
+
}
|
|
863
1033
|
},
|
|
1034
|
+
// Cards: a run is one call with a dozen steps, so the raw JSONL is a poor
|
|
1035
|
+
// row. These turn it into a titled card — what the call does, then what it
|
|
1036
|
+
// produced — and the journal keeps the same story on disk.
|
|
1037
|
+
presentCall: (args ) => craftCallView(profile, spec.kind, (args ?? {}) ),
|
|
1038
|
+
presentResult: (args , result ) =>
|
|
1039
|
+
craftResultView(
|
|
1040
|
+
profile,
|
|
1041
|
+
spec.kind,
|
|
1042
|
+
(result.content ?? []).filter(block => block.type === 'text').map(block => block.text ?? '').join('\n'),
|
|
1043
|
+
result.isError === true,
|
|
1044
|
+
),
|
|
864
1045
|
})))
|
|
865
1046
|
}
|
|
866
1047
|
ctx.systemPrompt.section({
|