@goodandready/dsh-image-gen 0.10.4 → 0.10.5

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.
@@ -0,0 +1,243 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { estimateSharpnessAndVariance } from './providers.js'
4
+
5
+ /**
6
+ * Scan active session messages in reverse order to find the latest image attachment or file ref.
7
+ *
8
+ * @param {object} exec - Tool execution context provided by DSH
9
+ * @returns {{ ref: string, name?: string } | undefined}
10
+ */
11
+ export function findLastConversationImage(exec) {
12
+ if (!exec || !exec.agent || !exec.agent.session) return undefined
13
+ const session = exec.agent.session
14
+ const messages = typeof session.deriveMessages === 'function'
15
+ ? session.deriveMessages()
16
+ : Array.isArray(session.messages) ? session.messages : []
17
+
18
+ for (let i = messages.length - 1; i >= 0; i--) {
19
+ const msg = messages[i]
20
+ if (!msg) continue
21
+
22
+ // 1. Check direct attachments array on message
23
+ if (Array.isArray(msg.attachments)) {
24
+ for (const att of msg.attachments) {
25
+ if (att && (att.mediaType?.startsWith('image/') || att.attachmentId?.startsWith('sha256:') || att.id?.startsWith('sha256:'))) {
26
+ return {
27
+ ref: att.attachmentId || att.id || att.ref,
28
+ name: att.name || att.filename || 'previous-image',
29
+ }
30
+ }
31
+ }
32
+ }
33
+
34
+ // 2. Check content blocks
35
+ if (Array.isArray(msg.content)) {
36
+ for (const block of msg.content) {
37
+ if (!block) continue
38
+ if (block.type === 'image' && block.source) {
39
+ const ref = block.source.attachmentId || block.source.id || block.source.data || block.source.url
40
+ if (ref) return { ref, name: 'conversation-image' }
41
+ }
42
+ if (block.type === 'tool_result' && block.content) {
43
+ // Look for file path or attachment reference in tool results
44
+ const str = typeof block.content === 'string' ? block.content : JSON.stringify(block.content)
45
+ const shaMatch = str.match(/sha256:[a-f0-9]{64}/i)
46
+ if (shaMatch) return { ref: shaMatch[0], name: 'tool-image' }
47
+ const fileMatch = str.match(/(?:File|Path|Saved to):\s*[`"']?([^\r\n`"']+\.(?:png|jpe?g|webp|svg))[`"']?/i)
48
+ if (fileMatch) return { ref: fileMatch[1].trim(), name: path.basename(fileMatch[1].trim()) }
49
+ }
50
+ }
51
+ }
52
+
53
+ // 3. Check metadata fields
54
+ if (msg.metadata) {
55
+ const metaImg = msg.metadata.attachmentId || msg.metadata.image || msg.metadata.outputFile
56
+ if (metaImg && typeof metaImg === 'string') {
57
+ return { ref: metaImg, name: 'metadata-image' }
58
+ }
59
+ }
60
+ }
61
+
62
+ return undefined
63
+ }
64
+
65
+ /**
66
+ * Determine MIME type by extension or signature.
67
+ */
68
+ export function detectMediaType(filePath, buffer) {
69
+ if (buffer && buffer.length >= 8) {
70
+ // PNG signature: 89 50 4E 47 0D 0A 1A 0A
71
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) return 'image/png'
72
+ // JPEG signature: FF D8 FF
73
+ if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) return 'image/jpeg'
74
+ // WebP signature: RIFF ... WEBP
75
+ if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
76
+ buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) return 'image/webp'
77
+ // SVG xml/svg check
78
+ const head = buffer.slice(0, 100).toString('utf8').toLowerCase()
79
+ if (head.includes('<svg') || head.includes('<?xml')) return 'image/svg+xml'
80
+ }
81
+ const ext = path.extname(String(filePath || '')).toLowerCase()
82
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
83
+ if (ext === '.webp') return 'image/webp'
84
+ if (ext === '.svg') return 'image/svg+xml'
85
+ return 'image/png'
86
+ }
87
+
88
+ /**
89
+ * Resolve target image bytes and mediaType, automatically looking up session history if omitted.
90
+ *
91
+ * @param {object} ctx - Cordis context
92
+ * @param {object} exec - Tool execution context
93
+ * @param {string} [targetRef] - User provided path, attachment ID, or 'latest'
94
+ * @returns {Promise<{ bytes: Buffer, mediaType: string, ref: string, name: string }>}
95
+ */
96
+ export async function resolveConversationImage(ctx, exec, targetRef) {
97
+ const sessionCwd = exec?.agent?.session?.header?.cwd || process.cwd()
98
+ let effectiveRef = typeof targetRef === 'string' ? targetRef.trim() : ''
99
+
100
+ const isImplicit = !effectiveRef ||
101
+ effectiveRef.toLowerCase() === 'latest' ||
102
+ effectiveRef.toLowerCase() === 'last' ||
103
+ effectiveRef.toLowerCase() === 'previous' ||
104
+ effectiveRef.toLowerCase() === 'current'
105
+
106
+ if (isImplicit) {
107
+ const found = findLastConversationImage(exec)
108
+ if (!found || !found.ref) {
109
+ throw new Error(
110
+ 'No previous image found in the active conversation session. ' +
111
+ 'Please provide an explicit image path or attachment ID.'
112
+ )
113
+ }
114
+ effectiveRef = found.ref
115
+ }
116
+
117
+ // 1. Attachment store resolution
118
+ if (effectiveRef.startsWith('sha256:')) {
119
+ if (!ctx?.attachments?.readImage) {
120
+ throw new Error(`Attachments service unavailable to read ${effectiveRef}`)
121
+ }
122
+ const stored = await ctx.attachments.readImage({
123
+ attachmentId: effectiveRef,
124
+ mediaType: 'image/png',
125
+ bytes: 0,
126
+ width: 0,
127
+ height: 0,
128
+ })
129
+ const bytes = Buffer.from(stored.data || stored.bytes || [])
130
+ return {
131
+ bytes,
132
+ mediaType: stored.ref?.mediaType || detectMediaType('', bytes),
133
+ ref: effectiveRef,
134
+ name: `attachment-${effectiveRef.slice(7, 15)}`,
135
+ }
136
+ }
137
+
138
+ // 2. Data URI
139
+ if (effectiveRef.startsWith('data:')) {
140
+ const commaIndex = effectiveRef.indexOf(',')
141
+ if (commaIndex > 0) {
142
+ const meta = effectiveRef.slice(0, commaIndex)
143
+ const data = effectiveRef.slice(commaIndex + 1)
144
+ const mediaMatch = meta.match(/data:([^;]+)/)
145
+ const mediaType = mediaMatch ? mediaMatch[1] : 'image/png'
146
+ const bytes = Buffer.from(data, 'base64')
147
+ return {
148
+ bytes,
149
+ mediaType,
150
+ ref: effectiveRef.slice(0, 32) + '...',
151
+ name: 'inline-image',
152
+ }
153
+ }
154
+ }
155
+
156
+ // 3. Local filesystem resolution
157
+ const absPath = path.isAbsolute(effectiveRef)
158
+ ? effectiveRef
159
+ : path.resolve(sessionCwd, effectiveRef)
160
+
161
+ const bytes = await readFile(absPath)
162
+ const mediaType = detectMediaType(absPath, bytes)
163
+ return {
164
+ bytes,
165
+ mediaType,
166
+ ref: effectiveRef,
167
+ name: path.basename(absPath, path.extname(absPath)),
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Query @goodandready/dsh-vision-bridge or Cordis tools to extract structural scene cues.
173
+ *
174
+ * @param {object} ctx - Cordis context
175
+ * @param {object} exec - Tool execution context
176
+ * @param {{ bytes: Buffer, mediaType: string, ref: string }} source - Source image
177
+ * @returns {Promise<{ available: boolean, summary: string, subjects?: string[], palette?: string }>}
178
+ */
179
+ export async function analyzeImageWithVision(ctx, exec, source) {
180
+ if (!source || !source.bytes) {
181
+ return { available: false, summary: 'No image data to analyze' }
182
+ }
183
+
184
+ // Check if dsh-vision-bridge tools exist in runtime
185
+ const inspectTool = ctx?.tools?.get?.('inspect_image') || ctx?.tools?.get?.('describe_image')
186
+ if (inspectTool && typeof inspectTool.execute === 'function') {
187
+ try {
188
+ const res = await inspectTool.execute(
189
+ {
190
+ source: source.ref,
191
+ attachmentIds: source.ref.startsWith('sha256:') ? [source.ref] : undefined,
192
+ paths: !source.ref.startsWith('sha256:') ? [source.ref] : undefined,
193
+ question: 'Summarize in 1 sentence: key subjects, visual style, and dominant colors for image editing.',
194
+ detail: 'low',
195
+ },
196
+ exec,
197
+ )
198
+ const text = res?.description || res?.summary || (typeof res === 'string' ? res : '')
199
+ if (text && text.trim()) {
200
+ return {
201
+ available: true,
202
+ summary: text.trim(),
203
+ }
204
+ }
205
+ } catch {
206
+ // Vision bridge execution failed or timed out; proceed with local fallback
207
+ }
208
+ }
209
+
210
+ // Local fallback: analyze sharpness and dimensions without external model call
211
+ const analysis = estimateSharpnessAndVariance(source.bytes)
212
+ const fallbackSummary = analysis.isBlank
213
+ ? 'Low-contrast or uniform canvas'
214
+ : `Detected visual asset (${source.mediaType}, sharpness: ${analysis.score})`
215
+
216
+ return {
217
+ available: false,
218
+ summary: fallbackSummary,
219
+ }
220
+ }
221
+ /**
222
+ * Форматирует структурированный текстовый отчёт для безопасного возврата в LLM (#150 Dual-Output).
223
+ */
224
+ export function buildDualOutputMarkdown({
225
+ action = 'generated',
226
+ filePath,
227
+ width,
228
+ height,
229
+ mediaType,
230
+ seed,
231
+ provider,
232
+ model,
233
+ cost,
234
+ attachmentId,
235
+ }) {
236
+ return `✅ **Image ${action} successfully**\n`
237
+ + `- **File:** \`${filePath}\`\n`
238
+ + `- **Dimensions:** ${width}x${height} (${mediaType || 'image/png'})\n`
239
+ + `- **Seed:** ${seed ?? 'N/A'} | **Provider:** ${provider} (${model})\n`
240
+ + (cost !== undefined ? `- **Cost:** $${Number(cost).toFixed(4)}\n` : '')
241
+ + `- **Attachment ID:** \`${attachmentId}\`\n\n`
242
+ + `*(Interactive visual card rendered in conversation UI)*`
243
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.10.4",
3
+ "version": "0.10.5",
4
4
  "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
5
5
  "keywords": [
6
6
  "deepseek-harness",