@aigne/doc-smith 0.9.9-beta.2 → 0.9.9-beta.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.9-beta.4](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.9.9-beta.3...v0.9.9-beta.4) (2025-12-12)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * compress large images before sending them to llm ([#362](https://github.com/AIGNE-io/aigne-doc-smith/issues/362)) ([ffb21f0](https://github.com/AIGNE-io/aigne-doc-smith/commit/ffb21f0f29cb414bf3c09df387bd29abf7248ea7))
9
+
10
+ ## [0.9.9-beta.3](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.9.9-beta.2...v0.9.9-beta.3) (2025-12-11)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **diagram:** ensure accurate timestamp handling for images and generated content ([#360](https://github.com/AIGNE-io/aigne-doc-smith/issues/360)) ([b78f833](https://github.com/AIGNE-io/aigne-doc-smith/commit/b78f833bc413fb666b7481f4fa720d0656e0e1cc))
16
+
3
17
  ## [0.9.9-beta.2](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.9.9-beta.1...v0.9.9-beta.2) (2025-12-11)
4
18
 
5
19
 
@@ -4,9 +4,17 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { parse, stringify } from "yaml";
6
6
  import { getMediaDescriptionCachePath } from "../../utils/file-utils.mjs";
7
+ import { compressImage } from "../../utils/image-compress.mjs";
8
+ import sharp from "sharp";
9
+ import { debug } from "../../utils/debug.mjs";
10
+ import { DOC_SMITH_DIR, TMP_DIR } from "../../utils/constants/index.mjs";
11
+ import { ensureTmpDir } from "../../utils/d2-utils.mjs";
12
+ import pMap from "p-map";
7
13
 
8
14
  const SIZE_THRESHOLD = 10 * 1024 * 1024; // 10MB
9
15
  const SVG_SIZE_THRESHOLD = 50 * 1024; // 50KB for SVG files
16
+ const MAX_IMAGE_SIZE = 1 * 1024 * 1024; // 1MB
17
+ const MAX_IMAGE_RESOLUTION = 2048; // 2K
10
18
 
11
19
  // Supported MIME types for Gemini AI
12
20
  const SUPPORTED_IMAGE_TYPES = new Set([
@@ -128,6 +136,107 @@ export default async function loadMediaDescription(input, options) {
128
136
  console.warn(`Failed to read SVG file ${mediaFile.path}:`, error.message);
129
137
  }
130
138
  } else {
139
+ // For non-SVG media files, check if compression is needed
140
+ let finalImagePath = absolutePath;
141
+ let shouldCompress = false;
142
+
143
+ try {
144
+ // Check file size and dimensions
145
+ const fileStats = await stat(absolutePath);
146
+ const fileSize = fileStats.size;
147
+
148
+ // Get image dimensions
149
+ const metadata = await sharp(absolutePath).metadata();
150
+ const { width, height } = metadata;
151
+
152
+ // Determine if compression is needed
153
+ // Compression rules:
154
+ // 1. Only compress files larger than 1MB
155
+ // 2. Compressed file must have resolution <= 2K and size < 1MB
156
+ // 3. Files <= 1MB are never compressed, regardless of resolution
157
+ const exceedsSize = fileSize > MAX_IMAGE_SIZE;
158
+
159
+ // Only compress if file size > 1MB
160
+ // For files <= 1MB, skip compression regardless of resolution
161
+ if (exceedsSize) {
162
+ // Create temporary compressed file path in temp directory with same relative structure
163
+ // Example: docs/assets/images/photo.jpg -> .aigne/doc-smith/.tmp/docs/assets/images/photo.compressed.jpg
164
+ await ensureTmpDir();
165
+ const tmpBaseDir = path.join(process.cwd(), DOC_SMITH_DIR, TMP_DIR);
166
+
167
+ // Get relative path from docsDir to maintain structure
168
+ // mediaFile.path is already relative to docsDir (e.g., "assets/images/photo.jpg")
169
+ const relativePath = mediaFile.path;
170
+ const relativeDir = path.dirname(relativePath);
171
+ const fileName = path.basename(relativePath, path.extname(relativePath));
172
+ const fileExt = path.extname(relativePath);
173
+
174
+ // Normalize docsDir to handle both relative and absolute paths
175
+ // If docsDir is absolute, extract the relative part from cwd
176
+ let normalizedDocsDir = docsDir;
177
+ if (path.isAbsolute(docsDir)) {
178
+ const cwd = process.cwd();
179
+ if (docsDir.startsWith(cwd)) {
180
+ normalizedDocsDir = path.relative(cwd, docsDir);
181
+ } else {
182
+ // If docsDir is outside cwd, use just the basename
183
+ normalizedDocsDir = path.basename(docsDir);
184
+ }
185
+ }
186
+
187
+ // Create temp directory structure matching the relative path
188
+ // Structure: .aigne/doc-smith/.tmp/{docsDir}/{relativeDir}
189
+ const tempDir = path.join(tmpBaseDir, normalizedDocsDir, relativeDir);
190
+ await mkdir(tempDir, { recursive: true });
191
+
192
+ // Create compressed file path
193
+ const tempFileName = `${fileName}.compressed${fileExt}`;
194
+ const tempPath = path.join(tempDir, tempFileName);
195
+
196
+ // Check if compressed file already exists in cache directory
197
+ if (existsSync(tempPath)) {
198
+ debug(`Compressed file already exists for ${mediaFile.path}, skipping compression`);
199
+ finalImagePath = tempPath;
200
+ shouldCompress = true;
201
+ } else {
202
+ shouldCompress = true;
203
+ debug(
204
+ `Compressing image ${mediaFile.path} (size: ${(fileSize / 1024 / 1024).toFixed(2)}MB, resolution: ${width}x${height})`,
205
+ );
206
+
207
+ // Compress image with constraints
208
+ // For files > 1MB: compress to resolution <= 2K and size < 1MB
209
+ finalImagePath = await compressImage(absolutePath, {
210
+ maxWidth: MAX_IMAGE_RESOLUTION, // Always limit to 2K
211
+ maxHeight: MAX_IMAGE_RESOLUTION, // Always limit to 2K
212
+ maxSizeBytes: MAX_IMAGE_SIZE, // Always limit to 1MB
213
+ outputPath: tempPath,
214
+ quality: 70, // Start with good quality
215
+ });
216
+
217
+ // Verify compressed file size
218
+ const compressedStats = await stat(finalImagePath);
219
+ if (compressedStats.size > MAX_IMAGE_SIZE) {
220
+ console.warn(
221
+ `Compressed image ${mediaFile.path} still exceeds ${MAX_IMAGE_SIZE / 1024 / 1024}MB (${(compressedStats.size / 1024 / 1024).toFixed(2)}MB), using compressed version anyway`,
222
+ );
223
+ } else {
224
+ debug(
225
+ `✅ Image compressed: ${mediaFile.path} -> ${(compressedStats.size / 1024 / 1024).toFixed(2)}MB`,
226
+ );
227
+ }
228
+ }
229
+ } else {
230
+ debug(
231
+ `Image ${mediaFile.path} is <= 1MB (size: ${(fileSize / 1024 / 1024).toFixed(2)}MB, resolution: ${width}x${height}), skipping compression`,
232
+ );
233
+ }
234
+ } catch (error) {
235
+ console.warn(`Failed to compress image ${mediaFile.path}:`, error.message);
236
+ // Use original path if compression fails
237
+ finalImagePath = absolutePath;
238
+ }
239
+
131
240
  // For non-SVG media files, use mediaFile field
132
241
  mediaToDescribe.push({
133
242
  ...mediaFile,
@@ -136,55 +245,144 @@ export default async function loadMediaDescription(input, options) {
136
245
  mediaFile: [
137
246
  {
138
247
  type: "local",
139
- path: absolutePath,
248
+ path: finalImagePath,
140
249
  filename: mediaFile.name,
141
250
  mimeType: mediaFile.mimeType,
142
251
  },
143
252
  ],
253
+ _compressed: shouldCompress, // Track if compression was applied for cleanup
254
+ _originalPath: shouldCompress ? absolutePath : undefined, // Store original path for cleanup
144
255
  });
145
256
  }
146
257
  }
147
258
  }
148
259
 
149
- // Generate descriptions for media files without cache - use team agent for concurrent processing
260
+ // Generate descriptions for media files without cache - batch processing with incremental save
150
261
  const newDescriptions = {};
262
+ const results = []; // Track all results for accurate counting
263
+
151
264
  if (mediaToDescribe.length > 0) {
152
- try {
153
- // Use batch team agent for concurrent processing
154
- const results = await options.context.invoke(
155
- options.context.agents["batchGenerateMediaDescription"],
156
- {
157
- mediaToDescribe,
158
- },
159
- );
265
+ // Ensure cache directory exists
266
+ await mkdir(path.dirname(cacheFilePath), { recursive: true });
267
+
268
+ // Create a write lock queue to ensure thread-safe cache updates
269
+ let writeQueue = Promise.resolve();
270
+ // Keep in-memory cache in sync to avoid unnecessary file reads
271
+ const inMemoryCache = { ...cache };
272
+
273
+ // Helper function to save cache with lock
274
+ // Optimized: Use in-memory cache to reduce file I/O
275
+ const saveCacheWithLock = async (newEntry) => {
276
+ // Add to write queue to ensure sequential writes
277
+ writeQueue = writeQueue
278
+ .then(async () => {
279
+ try {
280
+ // Merge new entry into in-memory cache
281
+ Object.assign(inMemoryCache, newEntry);
160
282
 
161
- // Process results - results is an array of individual results
162
- if (Array.isArray(results?.mediaToDescribe)) {
163
- for (const result of results.mediaToDescribe) {
164
- if (result?.hash && result?.description) {
165
- newDescriptions[result.hash] = {
166
- path: result.path,
167
- description: result.description,
283
+ // Save to disk
284
+ const cacheYaml = stringify({
285
+ descriptions: inMemoryCache,
286
+ lastUpdated: new Date().toISOString(),
287
+ });
288
+ await writeFile(cacheFilePath, cacheYaml, "utf8");
289
+ // Only update in-memory cache after successful write
290
+ return true;
291
+ } catch (error) {
292
+ // Rollback: remove the entry from in-memory cache if write failed
293
+ for (const key of Object.keys(newEntry)) {
294
+ delete inMemoryCache[key];
295
+ }
296
+ console.error(`Failed to save cache: ${error.message}`);
297
+ throw error;
298
+ }
299
+ })
300
+ .catch(() => {
301
+ // Don't let one write failure break the queue
302
+ // Error is already logged above
303
+ return false;
304
+ });
305
+ await writeQueue;
306
+ };
307
+
308
+ // Process media files concurrently with incremental save
309
+ // Use pMap for concurrent processing with controlled concurrency
310
+ const CONCURRENCY = 5; // Process 5 files concurrently
311
+
312
+ await pMap(
313
+ mediaToDescribe,
314
+ async (mediaItem, index) => {
315
+ const result = { success: false, path: mediaItem.path, error: null };
316
+
317
+ try {
318
+ // Generate description for single media file
319
+ // Note: If compression was applied, mediaItem.mediaFile[0].path points to compressed file
320
+ // This compressed file is used for upload, but cache uses original file hash and path
321
+ const agentResult = await options.context.invoke(
322
+ options.context.agents["generateMediaDescription"],
323
+ mediaItem,
324
+ );
325
+
326
+ // Check if description was generated successfully
327
+ // Note: agentResult.hash and agentResult.path come from the agent, but we need to ensure
328
+ // we use the original file path and hash for caching, not the compressed file path
329
+ if (agentResult?.hash && agentResult?.description) {
330
+ // Use original file path and hash for cache entry
331
+ // The compressed file is only used for upload, but cache should reference original file
332
+ const originalPath = mediaItem.path;
333
+ const originalHash = mediaItem.hash;
334
+
335
+ const descriptionEntry = {
336
+ path: originalPath, // Use original file path, not compressed file path
337
+ description: agentResult.description,
168
338
  generatedAt: new Date().toISOString(),
169
339
  };
340
+
341
+ // Immediately save to cache using lock mechanism
342
+ await saveCacheWithLock({ [originalHash]: descriptionEntry });
343
+
344
+ // Track in memory for summary
345
+ newDescriptions[originalHash] = descriptionEntry;
346
+ result.success = true;
347
+
348
+ debug(
349
+ `✅ Generated and saved description for ${mediaItem.path} (${index + 1}/${mediaToDescribe.length})`,
350
+ );
351
+ } else {
352
+ result.error = "No description in result";
353
+ console.warn(
354
+ `Failed to generate description for ${mediaItem.path}: No description in result`,
355
+ );
170
356
  }
357
+ } catch (error) {
358
+ result.error = error.message;
359
+ console.error(`Failed to generate description for ${mediaItem.path}:`, error.message);
360
+ // Continue processing other files even if one fails
171
361
  }
172
- }
173
362
 
174
- // Merge new descriptions into cache
175
- Object.assign(cache, newDescriptions);
363
+ results.push(result);
364
+ return result;
365
+ },
366
+ { concurrency: CONCURRENCY },
367
+ );
176
368
 
177
- // Save updated cache
178
- await mkdir(path.dirname(cacheFilePath), { recursive: true });
179
- const cacheYaml = stringify({
180
- descriptions: cache,
181
- lastUpdated: new Date().toISOString(),
182
- });
183
- await writeFile(cacheFilePath, cacheYaml, "utf8");
369
+ // Calculate accurate counts from results
370
+ const successCount = results.filter((r) => r.success).length;
371
+ const errorCount = results.filter((r) => !r.success).length;
184
372
 
185
- console.log(`Generated descriptions for ${Object.keys(newDescriptions).length} media files`);
186
- } catch (error) {
187
- console.error("Failed to generate media descriptions:", error.message);
373
+ // Update cache reference to in-memory cache
374
+ Object.assign(cache, inMemoryCache);
375
+
376
+ // Log summary
377
+ if (successCount > 0) {
378
+ console.log(
379
+ `Generated descriptions for ${successCount} media files (${errorCount} errors, ${mediaToDescribe.length - successCount - errorCount} skipped)`,
380
+ );
381
+ }
382
+ if (errorCount > 0) {
383
+ console.warn(
384
+ `⚠️ Failed to generate descriptions for ${errorCount} media files. Completed descriptions have been saved.`,
385
+ );
188
386
  }
189
387
  }
190
388
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aigne/doc-smith",
3
- "version": "0.9.9-beta.2",
3
+ "version": "0.9.9-beta.4",
4
4
  "description": "AI-driven documentation generation tool built on the AIGNE Framework",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -13,21 +13,21 @@ export const DIAGRAM_PLACEHOLDER = "DIAGRAM_PLACEHOLDER";
13
13
  // Diagram image regex patterns for reuse across the codebase
14
14
  // Pattern 1: Match only the start marker (for checking existence)
15
15
  export const diagramImageStartRegex =
16
- /<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(:\d+)?\s*-->/g;
16
+ /<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(::?\d+)?\s*-->/g;
17
17
 
18
18
  // Pattern 2: Match full diagram image block without capturing image path (for finding/replacing)
19
19
  // Supports both old format (without aspectRatio) and new format (with aspectRatio)
20
20
  export const diagramImageBlockRegex =
21
- /<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+)(?::(\d+:\d+))?(:\d+)?\s*-->\s*[\s\S]*?<!--\s*DIAGRAM_IMAGE_END\s*-->/g;
21
+ /<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+)(?::(\d+:\d+))?(::?\d+)?\s*-->\s*[\s\S]*?<!--\s*DIAGRAM_IMAGE_END\s*-->/g;
22
22
 
23
23
  // Pattern 3: Match full diagram image block with image path capture (for extracting paths)
24
24
  // Compatible with old format (without timestamp): <!-- DIAGRAM_IMAGE_START:type:aspectRatio -->
25
25
  export const diagramImageWithPathRegex =
26
- /<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(:\d+)?\s*-->\s*!\[[^\]]*\]\(([^)]+)\)\s*<!--\s*DIAGRAM_IMAGE_END\s*-->/g;
26
+ /<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(::?\d+)?\s*-->\s*!\[[^\]]*\]\(([^)]+)\)\s*<!--\s*DIAGRAM_IMAGE_END\s*-->/g;
27
27
 
28
28
  // Pattern 4: Match full diagram image block with all details (type, aspectRatio, timestamp, path, altText)
29
29
  export const diagramImageFullRegex =
30
- /<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(:\d+)?\s*-->\s*!\[([^\]]*)\]\(([^)]+)\)\s*<!--\s*DIAGRAM_IMAGE_END\s*-->/g;
30
+ /<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(::?\d+)?\s*-->\s*!\[([^\]]*)\]\(([^)]+)\)\s*<!--\s*DIAGRAM_IMAGE_END\s*-->/g;
31
31
 
32
32
  export async function ensureTmpDir() {
33
33
  const tmpDir = path.join(DOC_SMITH_DIR, TMP_DIR);
@@ -95,7 +95,7 @@ function parseGitignoreContent(content) {
95
95
  const lines = content
96
96
  .split("\n")
97
97
  .map((line) => line.trim())
98
- .filter((line) => line && !line.startsWith("#"))
98
+ .filter((line) => line && !line.startsWith("#") && line !== ".") // A standalone dot (.) in .gitignore is ineffective and should be removed.
99
99
  .map((line) => line.replace(/^\//, "")); // Remove leading slash
100
100
 
101
101
  // Convert each gitignore pattern to glob patterns
@@ -1,5 +1,6 @@
1
1
  import sharp from "sharp";
2
2
  import path from "node:path";
3
+ import { stat } from "node:fs/promises";
3
4
  import { debug } from "./debug.mjs";
4
5
 
5
6
  /**
@@ -10,10 +11,13 @@ import { debug } from "./debug.mjs";
10
11
  * @param {number} options.quality - Compression quality (0-100, default: 80)
11
12
  * @param {string} options.outputFormat - Output format: 'jpeg', 'png', 'webp' (default: auto-detect from input)
12
13
  * @param {string} options.outputPath - Output path for compressed image (if not provided, creates temp file)
14
+ * @param {number} options.maxWidth - Maximum width in pixels (default: undefined, no limit)
15
+ * @param {number} options.maxHeight - Maximum height in pixels (default: undefined, no limit)
16
+ * @param {number} options.maxSizeBytes - Maximum file size in bytes (default: undefined, no limit)
13
17
  * @returns {Promise<string>} - Path to the compressed image (outputPath if provided, or temp path, or inputPath if compression fails)
14
18
  */
15
19
  export async function compressImage(inputPath, options = {}) {
16
- const { quality = 80, outputFormat, outputPath } = options;
20
+ const { quality = 80, outputFormat, outputPath, maxWidth, maxHeight, maxSizeBytes } = options;
17
21
 
18
22
  try {
19
23
  const inputExt = path.extname(inputPath).toLowerCase();
@@ -45,9 +49,37 @@ export async function compressImage(inputPath, options = {}) {
45
49
  finalOutputPath = path.join(inputDir, `${inputBase}.compressed${outputExt}`);
46
50
  }
47
51
 
48
- // Create sharp instance and compress
52
+ // Get image metadata
53
+ const metadata = await sharp(inputPath).metadata();
54
+ const { width, height } = metadata;
55
+
56
+ // Calculate target dimensions if maxWidth or maxHeight is specified
57
+ let targetWidth = width;
58
+ let targetHeight = height;
59
+ if (maxWidth || maxHeight) {
60
+ const aspectRatio = width / height;
61
+ if (maxWidth && width > maxWidth) {
62
+ targetWidth = maxWidth;
63
+ targetHeight = Math.round(maxWidth / aspectRatio);
64
+ }
65
+ if (maxHeight && targetHeight > maxHeight) {
66
+ targetHeight = maxHeight;
67
+ targetWidth = Math.round(maxHeight * aspectRatio);
68
+ }
69
+ }
70
+
71
+ // Create sharp instance
49
72
  let sharpInstance = sharp(inputPath);
50
73
 
74
+ // Resize if needed
75
+ if (targetWidth !== width || targetHeight !== height) {
76
+ sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
77
+ fit: "inside",
78
+ withoutEnlargement: true,
79
+ });
80
+ debug(`Resizing image from ${width}x${height} to ${targetWidth}x${targetHeight}`);
81
+ }
82
+
51
83
  // Apply format-specific compression options
52
84
  if (format === "jpeg") {
53
85
  // mozjpeg is a valid sharp option for better JPEG compression
@@ -59,11 +91,58 @@ export async function compressImage(inputPath, options = {}) {
59
91
  sharpInstance = sharpInstance.webp({ quality });
60
92
  }
61
93
 
94
+ // If maxSizeBytes is specified, try to compress to target size
95
+ if (maxSizeBytes) {
96
+ let currentQuality = quality;
97
+ const compressedPath = finalOutputPath;
98
+ let attempts = 0;
99
+ const maxAttempts = 10;
100
+ const qualityStep = 5;
101
+
102
+ while (attempts < maxAttempts) {
103
+ // Create a new sharp instance for each attempt
104
+ let attemptInstance = sharp(inputPath);
105
+ if (targetWidth !== width || targetHeight !== height) {
106
+ attemptInstance = attemptInstance.resize(targetWidth, targetHeight, {
107
+ fit: "inside",
108
+ withoutEnlargement: true,
109
+ });
110
+ }
111
+
112
+ // Apply format with current quality
113
+ if (format === "jpeg") {
114
+ attemptInstance = attemptInstance.jpeg({ quality: currentQuality, mozjpeg: true });
115
+ } else if (format === "png") {
116
+ attemptInstance = attemptInstance.png({ quality: currentQuality, compressionLevel: 9 });
117
+ } else if (format === "webp") {
118
+ attemptInstance = attemptInstance.webp({ quality: currentQuality });
119
+ }
120
+
121
+ await attemptInstance.toFile(compressedPath);
122
+
123
+ const stats = await stat(compressedPath);
124
+ if (stats.size <= maxSizeBytes || currentQuality <= 20) {
125
+ // Target size achieved or quality too low, stop
126
+ break;
127
+ }
128
+
129
+ // Reduce quality and try again
130
+ currentQuality = Math.max(20, currentQuality - qualityStep);
131
+ attempts++;
132
+ }
133
+
134
+ debug(
135
+ `✅ Image compressed: ${inputPath} -> ${compressedPath} (format: ${format}, quality: ${currentQuality}, size: ${(await stat(compressedPath)).size} bytes)`,
136
+ );
137
+
138
+ return compressedPath;
139
+ }
140
+
62
141
  // Write compressed image directly to output path
63
142
  await sharpInstance.toFile(finalOutputPath);
64
143
 
65
144
  debug(
66
- `✅ Image compressed: ${inputPath} -> ${finalOutputPath} (format: ${format}, quality: ${quality})`,
145
+ `✅ Image compressed: ${inputPath} -> ${finalOutputPath} (format: ${format}, quality: ${quality}, dimensions: ${targetWidth}x${targetHeight})`,
67
146
  );
68
147
 
69
148
  return finalOutputPath;
@@ -87,7 +87,7 @@ function extractDiagramImagesWithTimestamp(content) {
87
87
  images.push({
88
88
  type: match[1] || DEFAULT_DIAGRAM_TYPE, // Diagram type (e.g., "architecture", "guide")
89
89
  aspectRatio: match[2] || DEFAULT_ASPECT_RATIO, // Aspect ratio (e.g., "16:9", "4:3")
90
- timestamp: match[3] || null, // Timestamp (null for old format)
90
+ timestamp: (match[3] || "").replace(/^:/, ""), // Timestamp without leading colon (null for old format)
91
91
  altText: match[4] || DEFAULT_ALT_TEXT, // Alt text from markdown
92
92
  path: match[5] || "", // Image path
93
93
  fullMatch: match[0] || "", // Full matched block
@@ -229,7 +229,8 @@ function createImageMarkdown(diagramType, aspectRatio, timestamp, altText, image
229
229
  const type = diagramType || DEFAULT_DIAGRAM_TYPE;
230
230
  const ratio = aspectRatio || DEFAULT_ASPECT_RATIO;
231
231
  const alt = altText || DEFAULT_ALT_TEXT;
232
- return `<!-- DIAGRAM_IMAGE_START:${type}:${ratio}:${timestamp} -->\n![${alt}](${imagePath})\n<!-- DIAGRAM_IMAGE_END -->`;
232
+ const timestampPart = timestamp ? `:${timestamp}` : "";
233
+ return `<!-- DIAGRAM_IMAGE_START:${type}:${ratio}${timestampPart} -->\n![${alt}](${imagePath})\n<!-- DIAGRAM_IMAGE_END -->`;
233
234
  }
234
235
 
235
236
  const processedDocuments = new Set();