@goodandready/dsh-image-gen 0.11.24 → 0.11.26

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.
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import { mkdir, writeFile } from 'node:fs/promises'
3
- import { saveAttachmentSafe, buildSidecar, toLosslessJson } from './providers.js'
3
+ import { saveAttachmentSafe, buildSidecar, toLosslessJson, ensureImageFormat } from './providers.js'
4
4
  import { recordSpend } from './cost-meter.js'
5
5
  import { buildDualOutputMarkdown } from './resolve-image.js'
6
6
 
@@ -21,6 +21,10 @@ export async function saveAndAttachResult(ctx, exec, cfg, {
21
21
  args = {},
22
22
  action = 'generated',
23
23
  }) {
24
+ const targetFormat = format || (mediaType ? mediaType.replace('image/', '') : undefined)
25
+ const formatted = await ensureImageFormat(bytes, targetFormat, { fallbackMime: mediaType })
26
+ bytes = formatted.bytes
27
+ mediaType = formatted.mediaType
24
28
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
25
29
 
26
30
  const sessionCwd = exec?.agent?.session?.header?.cwd
package/lib/commands.js CHANGED
@@ -85,11 +85,14 @@ export function registerImageCommand(ctx, deps) {
85
85
  const size = flags.size || cfg.defaultSize || 'square'
86
86
  const format = flags.format || cfg.defaultFormat || 'png'
87
87
  const aspectRatio = flags.aspect || flags.aspect_ratio || cfg.defaultAspectRatio
88
- const effectiveStyle = flags.style || cfg.stylePreset
88
+ const effectivePreset = (flags.style_preset !== undefined && flags.style_preset !== null && flags.style_preset !== '')
89
+ ? flags.style_preset
90
+ : (flags.style || cfg.stylePreset || cfg.defaultStylePreset)
89
91
  const seed = flags.seed ? Number(flags.seed) : Math.floor(Math.random() * 2147483647)
90
92
 
91
- const styleInfo = resolveStylePreset(effectiveStyle, flags.negative_prompt, flags.guidance_scale)
92
- const effectivePrompt = applyStylePreset(prompt, effectiveStyle)
93
+ const styleInfo = resolveStylePreset(effectivePreset, flags.negative_prompt, flags.guidance_scale)
94
+ const effectivePrompt = styleInfo.promptSuffix ? `${prompt}, ${styleInfo.promptSuffix}` : prompt
95
+ const effectiveNegative = styleInfo.negativePrompt || flags.negative_prompt || cfg.negativePrompt
93
96
 
94
97
  const job = {
95
98
  prompt: effectivePrompt,
@@ -99,6 +102,9 @@ export function registerImageCommand(ctx, deps) {
99
102
  aspectPixels: aspectRatio ? ASPECT_RATIOS[aspectRatio] : undefined,
100
103
  seed,
101
104
  signal: invocation?.signal,
105
+ negativePrompt: effectiveNegative,
106
+ guidanceScale: styleInfo.guidanceScale ?? flags.guidance_scale,
107
+ style: flags.style,
102
108
  }
103
109
 
104
110
  const pdeps = {
@@ -176,8 +176,8 @@ export const Config = z.object({
176
176
  .volatile(),
177
177
  geminiModel: z
178
178
  .string()
179
- .description('Google Gemini Imagen model identifier.')
180
- .default('imagen-3.0-generate-002')
179
+ .description('Google Gemini image generation model identifier.')
180
+ .default('gemini-2.0-flash-exp-image-generation')
181
181
  .volatile(),
182
182
  geminiKeyEnv: z
183
183
  .string()
@@ -1,3 +1,4 @@
1
+ import sharp from 'sharp'
1
2
  // @ts-check
2
3
  import path from 'node:path'
3
4
  import fs from 'node:fs/promises'
@@ -64,13 +65,12 @@ export function getContrastRatio(hex1, hex2) {
64
65
  }
65
66
 
66
67
  /**
67
- * Extract palette sample colors from raw image bytes.
68
- * Scans byte frequencies and samples color anchors across buffer.
69
- * @param {Buffer} buffer
70
- * @returns {string[]} Hex colors
68
+ * Extract dominant palette colors from decoded image pixels with alpha awareness (#372).
69
+ * @param {Buffer|Uint8Array} buffer
70
+ * @returns {Promise<string[]>} Hex colors
71
71
  */
72
- export function extractSampleColorsFromBuffer(buffer) {
73
- if (!buffer || buffer.length < 16) {
72
+ export async function extractSampleColorsFromBuffer(buffer) {
73
+ if (!buffer || buffer.length < 8) {
74
74
  return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
75
75
  }
76
76
 
@@ -95,42 +95,80 @@ export function extractSampleColorsFromBuffer(buffer) {
95
95
  }
96
96
  }
97
97
 
98
- // Sample bytes from buffer chunks
99
- const step = Math.max(4, Math.floor(buffer.length / 500))
100
- const samples = []
101
- for (let i = 64; i < buffer.length - 8; i += step) {
102
- const r = buffer[i]
103
- const g = buffer[i + 1]
104
- const b = buffer[i + 2]
105
- samples.push({ r, g, b })
106
- }
107
-
108
- if (samples.length === 0) {
109
- return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
110
- }
98
+ // Decode raster container to raw RGBA pixels via Sharp
99
+ try {
100
+ const { data, info } = await sharp(buffer)
101
+ .raw()
102
+ .ensureAlpha()
103
+ .toBuffer({ resolveWithObject: true })
104
+
105
+ const totalPixels = info.width * info.height
106
+ const step = Math.max(1, Math.floor(totalPixels / 2048))
107
+ const colorBuckets = new Map()
108
+
109
+ for (let i = 0; i < totalPixels; i += step) {
110
+ const off = i * 4
111
+ const r = data[off]
112
+ const g = data[off + 1]
113
+ const b = data[off + 2]
114
+ const a = data[off + 3]
115
+
116
+ // Ignore fully or mostly transparent pixels
117
+ if (a < 128) continue
118
+
119
+ // Quantize to 16-level RGB bins to cluster nearby nuances
120
+ const qr = Math.round(r / 16) * 16
121
+ const qg = Math.round(g / 16) * 16
122
+ const qb = Math.round(b / 16) * 16
123
+ const key = `${qr},${qg},${qb}`
124
+
125
+ let bucket = colorBuckets.get(key)
126
+ if (!bucket) {
127
+ bucket = { count: 0, rSum: 0, gSum: 0, bSum: 0 }
128
+ colorBuckets.set(key, bucket)
129
+ }
130
+ bucket.count++
131
+ bucket.rSum += r
132
+ bucket.gSum += g
133
+ bucket.bSum += b
134
+ }
111
135
 
112
- samples.sort((a, b) => (a.r * 299 + a.g * 587 + a.b * 114) - (b.r * 299 + b.g * 587 + b.b * 114))
113
-
114
- const anchors = []
115
- const bucketSize = Math.floor(samples.length / 6)
116
- for (let b = 0; b < 6; b++) {
117
- const start = b * bucketSize
118
- const end = b === 5 ? samples.length : start + bucketSize
119
- let sumR = 0, sumG = 0, sumB = 0
120
- for (let j = start; j < end; j++) {
121
- sumR += samples[j].r
122
- sumG += samples[j].g
123
- sumB += samples[j].b
136
+ if (colorBuckets.size > 0) {
137
+ const sorted = Array.from(colorBuckets.values()).sort((a, b) => b.count - a.count)
138
+ const dominant = sorted.slice(0, 6).map((b) => {
139
+ return rgbToHex(b.rSum / b.count, b.gSum / b.count, b.bSum / b.count)
140
+ })
141
+
142
+ const result = [...dominant]
143
+ if (result.length < 5) {
144
+ const primaryRgb = hexToRgb(result[0])
145
+ const isNearBlack = primaryRgb.r === 0 && primaryRgb.g === 0 && primaryRgb.b === 0
146
+ if (isNearBlack) {
147
+ const darkShades = ['#1a1a1a', '#333333', '#4d4d4d', '#666666', '#808080']
148
+ for (const s of darkShades) {
149
+ if (result.length >= 6) break
150
+ if (!result.includes(s)) result.push(s)
151
+ }
152
+ } else {
153
+ const factors = [0.2, 0.4, 0.6, 0.8, 1.2, 1.4]
154
+ for (const factor of factors) {
155
+ if (result.length >= 6) break
156
+ const shade = rgbToHex(
157
+ Math.min(255, Math.round(primaryRgb.r * factor)),
158
+ Math.min(255, Math.round(primaryRgb.g * factor)),
159
+ Math.min(255, Math.round(primaryRgb.b * factor)),
160
+ )
161
+ if (!result.includes(shade)) result.push(shade)
162
+ }
163
+ }
164
+ }
165
+ return result.slice(0, 6)
124
166
  }
125
- const count = Math.max(1, end - start)
126
- anchors.push(rgbToHex(sumR / count, sumG / count, sumB / count))
167
+ } catch (_err) {
168
+ // If sharp cannot decode or buffer is non-image, fallback
127
169
  }
128
170
 
129
- const unique = Array.from(new Set(anchors))
130
- while (unique.length < 5) {
131
- unique.push('#3b82f6')
132
- }
133
- return unique
171
+ return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
134
172
  }
135
173
 
136
174
  /**
@@ -394,9 +432,57 @@ export const ${componentName}: React.FC<${componentName}Props> = ({ size = 24, c
394
432
  }
395
433
  }
396
434
 
435
+ export function buildIcoBuffer(pngFrames) {
436
+ const count = pngFrames.length
437
+ const headerLen = 6 + count * 16
438
+ let currentOffset = headerLen
439
+
440
+ const entries = []
441
+ for (const item of pngFrames) {
442
+ entries.push({
443
+ width: item.width >= 256 ? 0 : item.width,
444
+ height: item.height >= 256 ? 0 : item.height,
445
+ size: item.buffer.length,
446
+ offset: currentOffset,
447
+ buffer: item.buffer,
448
+ })
449
+ currentOffset += item.buffer.length
450
+ }
451
+
452
+ const out = Buffer.alloc(currentOffset)
453
+ // Header: reserved 0, type 1 (icon), count
454
+ out.writeUInt16LE(0, 0)
455
+ out.writeUInt16LE(1, 2)
456
+ out.writeUInt16LE(count, 4)
457
+
458
+ let entryPos = 6
459
+ for (const entry of entries) {
460
+ out.writeUInt8(entry.width, entryPos)
461
+ out.writeUInt8(entry.height, entryPos + 1)
462
+ out.writeUInt8(0, entryPos + 2) // color count
463
+ out.writeUInt8(0, entryPos + 3) // reserved
464
+ out.writeUInt16LE(1, entryPos + 4) // planes
465
+ out.writeUInt16LE(32, entryPos + 6) // bpp
466
+ out.writeUInt32LE(entry.size, entryPos + 8) // size
467
+ out.writeUInt32LE(entry.offset, entryPos + 12) // offset
468
+ entry.buffer.copy(out, entry.offset)
469
+ entryPos += 16
470
+ }
471
+ return out
472
+ }
473
+
474
+ async function getDefaultIconBytes(name, themeColor) {
475
+ const initial = (name || 'A').trim().charAt(0).toUpperCase() || 'A'
476
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
477
+ <rect width="512" height="512" rx="100" fill="${themeColor || '#0f172a'}"/>
478
+ <text x="50%" y="54%" font-family="system-ui, -apple-system, sans-serif" font-size="280" font-weight="bold" fill="#ffffff" dominant-baseline="middle" text-anchor="middle">${initial}</text>
479
+ </svg>`
480
+ return sharp(Buffer.from(svg)).png().toBuffer()
481
+ }
482
+
397
483
  /**
398
- * Issue #190: generate_pwa_icon_suite
399
- * Generates standard PWA icon suite metadata, web app manifest.json, and icon configuration.
484
+ * Issue #190 & #387: generate_pwa_icon_suite
485
+ * Generates standard PWA icon suite, web app manifest.json, multi-resolution favicon.ico, and HTML snippet.
400
486
  */
401
487
  export async function generatePwaIconSuite({
402
488
  name = 'App',
@@ -405,7 +491,20 @@ export async function generatePwaIconSuite({
405
491
  backgroundColor = '#ffffff',
406
492
  iconsDir = 'icons',
407
493
  sourcePath = null,
494
+ imageBytes = null,
495
+ outDir = null,
408
496
  } = {}) {
497
+ // 1. Resolve source image buffer
498
+ let source = imageBytes
499
+ if (!source && sourcePath) {
500
+ try {
501
+ source = await fs.readFile(sourcePath)
502
+ } catch (_err) { source = null }
503
+ }
504
+ if (!source) {
505
+ source = await getDefaultIconBytes(name, themeColor)
506
+ }
507
+
409
508
  const iconSizes = [
410
509
  { size: 16, rel: 'icon', filename: `${iconsDir}/favicon-16x16.png`, purpose: 'any' },
411
510
  { size: 32, rel: 'icon', filename: `${iconsDir}/favicon-32x32.png`, purpose: 'any' },
@@ -416,6 +515,53 @@ export async function generatePwaIconSuite({
416
515
  { size: 512, rel: 'manifest', filename: `${iconsDir}/icon-512x512-maskable.png`, purpose: 'maskable' },
417
516
  ]
418
517
 
518
+ // 2. Render all PNG icons via Sharp
519
+ const generatedIcons = []
520
+ const icoFrames = []
521
+
522
+ for (const item of iconSizes) {
523
+ let pngBuf
524
+ if (item.purpose === 'maskable') {
525
+ // Safe zone requires 10% padding on each side -> inner icon is 80% of canvas (#387)
526
+ const innerSize = Math.max(1, Math.round(item.size * 0.8))
527
+ const inner = await sharp(source)
528
+ .resize(innerSize, innerSize, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
529
+ .png()
530
+ .toBuffer()
531
+
532
+ pngBuf = await sharp({
533
+ create: {
534
+ width: item.size,
535
+ height: item.size,
536
+ channels: 4,
537
+ background: backgroundColor || '#ffffff',
538
+ },
539
+ })
540
+ .composite([{ input: inner, gravity: 'center' }])
541
+ .png()
542
+ .toBuffer()
543
+ } else {
544
+ pngBuf = await sharp(source)
545
+ .resize(item.size, item.size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
546
+ .png()
547
+ .toBuffer()
548
+ }
549
+
550
+ generatedIcons.push({
551
+ ...item,
552
+ bytes: pngBuf,
553
+ })
554
+
555
+ if ([16, 32, 48].includes(item.size) && item.rel === 'icon') {
556
+ icoFrames.push({ width: item.size, height: item.size, buffer: pngBuf })
557
+ }
558
+ }
559
+
560
+ // 3. Multi-resolution favicon.ico containing 16, 32, 48 px PNG frames (#387)
561
+ const faviconIcoBuffer = buildIcoBuffer(icoFrames)
562
+ const faviconIcoFilename = `${iconsDir}/favicon.ico`
563
+
564
+ // 4. Manifest JSON
419
565
  const manifest = {
420
566
  name,
421
567
  short_name: shortName,
@@ -423,7 +569,7 @@ export async function generatePwaIconSuite({
423
569
  display: 'standalone',
424
570
  background_color: backgroundColor,
425
571
  theme_color: themeColor,
426
- icons: iconSizes
572
+ icons: generatedIcons
427
573
  .filter((i) => i.rel === 'manifest')
428
574
  .map((i) => ({
429
575
  src: i.filename,
@@ -432,8 +578,11 @@ export async function generatePwaIconSuite({
432
578
  purpose: i.purpose,
433
579
  })),
434
580
  }
581
+ const manifestJson = JSON.stringify(manifest, null, 2)
435
582
 
583
+ // 5. HTML snippet
436
584
  const htmlHeadSnippet = [
585
+ `<link rel="icon" href="/${faviconIcoFilename}" sizes="any">`,
437
586
  `<link rel="icon" type="image/png" sizes="16x16" href="/${iconsDir}/favicon-16x16.png">`,
438
587
  `<link rel="icon" type="image/png" sizes="32x32" href="/${iconsDir}/favicon-32x32.png">`,
439
588
  `<link rel="apple-touch-icon" sizes="180x180" href="/${iconsDir}/apple-touch-icon.png">`,
@@ -441,13 +590,39 @@ export async function generatePwaIconSuite({
441
590
  `<meta name="theme-color" content="${themeColor}">`,
442
591
  ].join('\n')
443
592
 
593
+ // 6. Write files to outDir if provided
594
+ const exportedFiles = []
595
+ if (outDir) {
596
+ const fullIconsDir = path.resolve(outDir, iconsDir)
597
+ await fs.mkdir(fullIconsDir, { recursive: true })
598
+
599
+ for (const item of generatedIcons) {
600
+ const filePath = path.resolve(outDir, item.filename)
601
+ await fs.writeFile(filePath, item.bytes)
602
+ exportedFiles.push(filePath)
603
+ }
604
+
605
+ const icoPath = path.resolve(outDir, faviconIcoFilename)
606
+ await fs.writeFile(icoPath, faviconIcoBuffer)
607
+ exportedFiles.push(icoPath)
608
+
609
+ const manifestPath = path.resolve(outDir, 'manifest.json')
610
+ await fs.writeFile(manifestPath, manifestJson)
611
+ exportedFiles.push(manifestPath)
612
+ }
613
+
614
+ // Strip internal bytes property from public icons list to avoid huge JSON serialization
615
+ const cleanIcons = generatedIcons.map(({ bytes, ...rest }) => rest)
616
+
444
617
  return {
445
618
  name,
446
619
  shortName,
447
620
  themeColor,
448
621
  backgroundColor,
449
- icons: iconSizes,
450
- manifestJson: JSON.stringify(manifest, null, 2),
622
+ icons: cleanIcons,
623
+ faviconIco: faviconIcoFilename,
624
+ manifestJson,
451
625
  htmlHeadSnippet,
626
+ exportedFiles,
452
627
  }
453
628
  }
@@ -13,6 +13,7 @@ import {
13
13
  embedPngMetadata,
14
14
  resolveStylePreset,
15
15
  saveAttachmentSafe,
16
+ ensureImageFormat,
16
17
  } from './providers.js'
17
18
  import { enhancePrompt } from './prompt-enhancer.js'
18
19
  import { buildFaceConsistencyPrompt, getSessionAnchor, applyAnchorPromptHints } from './anchor-helpers.js'
@@ -23,6 +24,7 @@ import {
23
24
  readHistory,
24
25
  writeHistory,
25
26
  pruneHistory,
27
+ appendHistoryEntry,
26
28
  } from './history.js'
27
29
 
28
30
  /** Run an array of async task functions with a concurrency cap */
@@ -62,8 +64,10 @@ export function validateGenerationParams(cfg, args) {
62
64
  /** Prepares enhanced prompt, style hints, facial identity, anchors, and sanitized negative prompt */
63
65
  export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource }) {
64
66
  const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
65
- const effectiveStyle = args.style || cfg.stylePreset
66
- const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
67
+ const candidatePreset = (args.style_preset !== undefined && args.style_preset !== null && args.style_preset !== '')
68
+ ? args.style_preset
69
+ : (cfg.stylePreset || cfg.defaultStylePreset)
70
+ const styleInfo = resolveStylePreset(candidatePreset, args.negative_prompt, args.guidance_scale)
67
71
  let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
68
72
  let effectiveNegative = styleInfo.negativePrompt
69
73
  const activeAnchor = getSessionAnchor(sessionId)
@@ -132,8 +136,10 @@ export async function saveGeneratedImageArtifact({
132
136
  estimatedCost,
133
137
  totalCount,
134
138
  }) {
135
- const mediaType = gen.mediaType || 'image/png'
136
- let bytes = gen.bytes
139
+ const targetFormat = format || (args.output_format || cfg.defaultFormat)
140
+ const formatted = await ensureImageFormat(gen.bytes, targetFormat, { fallbackMime: gen.mediaType })
141
+ let bytes = formatted.bytes
142
+ const mediaType = formatted.mediaType || 'image/png'
137
143
  if (mediaType === 'image/png') {
138
144
  bytes = embedPngMetadata(bytes, {
139
145
  prompt: promptArg,
@@ -189,7 +195,10 @@ export async function saveGeneratedImageArtifact({
189
195
  prompt: promptArg,
190
196
  seed: gen.seed,
191
197
  size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
192
- style: args.style || cfg.stylePreset,
198
+ style: args.style,
199
+ stylePreset: (args.style_preset !== undefined && args.style_preset !== null && args.style_preset !== '')
200
+ ? args.style_preset
201
+ : (cfg.stylePreset || cfg.defaultStylePreset),
193
202
  format: format || args.output_format || cfg.defaultFormat || 'png',
194
203
  aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
195
204
  negativePrompt: args.negative_prompt || cfg.negativePrompt,
@@ -229,13 +238,10 @@ export async function saveGeneratedImageArtifact({
229
238
  attachmentId: attachment.attachmentId,
230
239
  }
231
240
  // Save to history (#385: historyLimit=0 skips history retention; bounds guard against negative values)
241
+ // #365: Atomic transaction with file lock prevents read-modify-write race during concurrent batches
232
242
  const historyLimit = typeof cfg.historyLimit === 'number' ? Math.max(0, Math.floor(cfg.historyLimit)) : 50
233
243
  if (historyLimit > 0) {
234
- let current = await readHistory()
235
- current = await pruneHistory(current, cfg.pruneDays)
236
- current.unshift(entry)
237
- if (current.length > historyLimit) current.length = historyLimit
238
- await writeHistory(current)
244
+ await appendHistoryEntry(entry, { pruneDays: cfg.pruneDays, historyLimit })
239
245
  }
240
246
 
241
247
  // #167: Record spend in cost meter
package/lib/history.js CHANGED
@@ -1,9 +1,30 @@
1
1
  import path from 'node:path'
2
2
  import os from 'node:os'
3
3
  import { existsSync, unlinkSync, chmodSync, readdirSync, statSync } from 'node:fs'
4
- import { readFile, writeFile, mkdir } from 'node:fs/promises'
4
+ import { readFile, mkdir } from 'node:fs/promises'
5
+ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
5
6
  import { enforceSecurePermissions } from './security.js'
6
7
 
8
+ let inMemoryLockQueue = Promise.resolve()
9
+
10
+ /** Execute an operation with in-process queue + cross-process file lock (#365) */
11
+ export async function withHistoryLock(operation, options = { waitMs: 5000 }) {
12
+ const file = historyFile()
13
+ const dir = path.dirname(file)
14
+ try {
15
+ await mkdir(dir, { recursive: true, mode: 0o700 })
16
+ try { chmodSync(dir, 0o700) } catch (_err) { /* non-fatal permission repair */ }
17
+ } catch (_err) { /* non-fatal mkdir */ }
18
+
19
+ const runWithLocks = async () => {
20
+ return await withFileLock(file, operation, options)
21
+ }
22
+
23
+ const next = inMemoryLockQueue.then(runWithLocks, runWithLocks)
24
+ inMemoryLockQueue = next.catch(() => {})
25
+ return await next
26
+ }
27
+
7
28
  export function historyFile() {
8
29
  return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
9
30
  }
@@ -104,21 +125,50 @@ export async function readHistory() {
104
125
  const raw = await readFile(historyFile(), 'utf-8')
105
126
  const parsed = JSON.parse(raw)
106
127
  return Array.isArray(parsed) ? parsed : []
107
- } catch (e) {
128
+ } catch (_e) {
108
129
  return []
109
130
  }
110
131
  }
111
132
 
112
- /** Save history to disk file (atomic overwrite). */
133
+ /** Save history to disk file (atomic overwrite with 0600 mode via dsh-atomic-write). */
113
134
  export async function writeHistory(entries) {
114
135
  try {
115
136
  const file = historyFile()
116
137
  const dir = path.dirname(file)
117
138
  await mkdir(dir, { recursive: true, mode: 0o700 })
118
139
  try { chmodSync(dir, 0o700) } catch (_err) { /* non-fatal permission repair */ }
119
- await writeFile(file, JSON.stringify(entries, null, 2), { mode: 0o600 })
140
+ await writeFileAtomic(file, JSON.stringify(entries, null, 2), {
141
+ mode: 0o600,
142
+ dirMode: 0o700,
143
+ })
120
144
  enforceSecurePermissions(file)
121
- } catch (e) { /* history write failure is non-fatal */ }
145
+ } catch (_e) { /* history write failure is non-fatal */ }
146
+ }
147
+
148
+ /**
149
+ * Atomically append an entry to history, running read -> prune -> unshift -> write
150
+ * under an exclusive lock transaction (#365).
151
+ */
152
+ export async function appendHistoryEntry(entry, { pruneDays = 0, historyLimit = 50 } = {}) {
153
+ if (!entry || historyLimit <= 0) return []
154
+ return await withHistoryLock(async () => {
155
+ let current = await readHistory()
156
+ if (pruneDays > 0) {
157
+ current = await pruneHistory(current, pruneDays)
158
+ }
159
+ const existingIdx = current.findIndex(
160
+ (e) => (entry.attachmentId && e.attachmentId === entry.attachmentId) || (entry.path && e.path === entry.path)
161
+ )
162
+ if (existingIdx !== -1) {
163
+ current.splice(existingIdx, 1)
164
+ }
165
+ current.unshift(entry)
166
+ if (current.length > historyLimit) {
167
+ current.length = historyLimit
168
+ }
169
+ await writeHistory(current)
170
+ return current
171
+ })
122
172
  }
123
173
 
124
174
  /** Filter entries whose files exist on disk; newest first. */
@@ -1,3 +1,4 @@
1
+ import sharp from 'sharp'
1
2
  // Pure helpers for generate_seamless_pattern (#178)
2
3
 
3
4
  export function buildSeamlessPrompt(motif, style, density) {
@@ -85,3 +86,19 @@ export function tilePixels(pixels, width, height, channels, cols, rows) {
85
86
  }
86
87
  return { pixels: out, width: outW, height: outH }
87
88
  }
89
+
90
+ /**
91
+ * Decodes image container to raw RGBA pixels and scores edge wrap continuity (#371).
92
+ * Returns 0..1 (1 = identical opposite edges).
93
+ * @param {Buffer|Uint8Array} imageBytes
94
+ * @returns {Promise<number>}
95
+ */
96
+ export async function scoreImageEdgeWrap(imageBytes) {
97
+ if (!imageBytes || imageBytes.length === 0) return 0
98
+ try {
99
+ const { data, info } = await sharp(imageBytes).raw().ensureAlpha().toBuffer({ resolveWithObject: true })
100
+ return scoreEdgeWrap(data, info.width, info.height, info.channels)
101
+ } catch {
102
+ return 0
103
+ }
104
+ }