dsh-artcraft-plugin-gridcraft 0.2.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-artcraft-plugin-gridcraft",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "DSH plugin: drive GridCraft (spreadsheets) headlessly through its own CLI and MCP server.",
@@ -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,6 +8,8 @@
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'
@@ -314,13 +316,17 @@ export function renderCatalog(
314
316
 
315
317
 
316
318
 
317
- /** One tool's definition: name/description/schema plus its executor. */
319
+ /** One tool's definition: name/description/schema plus its executor and views. */
318
320
 
319
321
 
320
322
 
321
323
 
322
324
 
323
325
 
326
+
327
+
328
+
329
+
324
330
 
325
331
 
326
332
  /**
@@ -918,6 +924,7 @@ export function createToolSet(runtime ) {
918
924
  }
919
925
 
920
926
 
927
+
921
928
  /** Prompt-section slot: after the built-in tool sections, app prose follows. */
922
929
  export const CRAFT_SECTION_ORDER = 120
923
930
 
@@ -934,8 +941,34 @@ export const CRAFT_SECTION_ORDER = 120
934
941
 
935
942
 
936
943
 
944
+
945
+
937
946
 
938
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
+
939
972
  /** The `ctx` surface the kernel uses; kept structural for the same reason. */
940
973
 
941
974
 
@@ -983,8 +1016,32 @@ export function applyCraftPlugin(
983
1016
  render: (_args , value ) => [{ type: 'text' , text: value }],
984
1017
  },
985
1018
  async execute(args ) {
986
- return await spec.execute(args )
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
+ }
987
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
+ ),
988
1045
  })))
989
1046
  }
990
1047
  ctx.systemPrompt.section({