decant-core 1.11.0 → 1.13.0

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/ai/claude.js CHANGED
@@ -103,6 +103,124 @@ const MIME_TO_LANG = {
103
103
  "application/vnd.ant.code": "text",
104
104
  };
105
105
 
106
+ // Binary archives cannot be inlined as text in any export format; they are
107
+ // listed by name so they at least appear in markdown/html/json exports.
108
+ const BINARY_ARCHIVE_MIMES = new Set([
109
+ "application/x-tar",
110
+ "application/gzip",
111
+ "application/zip",
112
+ "application/x-7z-compressed",
113
+ "application/x-rar-compressed",
114
+ ]);
115
+
116
+ function formatFileSize(bytes) {
117
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) {
118
+ return "";
119
+ }
120
+ if (bytes < 1024) return `${bytes} B`;
121
+ return `${(bytes / 1024).toFixed(1)} KB`;
122
+ }
123
+
124
+ function basenameOfPath(filePath) {
125
+ if (typeof filePath !== "string" || !filePath) return "file";
126
+ const base = filePath.split("/").pop();
127
+ return base || "file";
128
+ }
129
+
130
+ function isBinaryArchive(mimeType, filePath) {
131
+ if (mimeType && BINARY_ARCHIVE_MIMES.has(mimeType)) return true;
132
+ return /\.(tar\.gz|tgz|tar|zip|gz|7z|rar)$/i.test(filePath || "");
133
+ }
134
+
135
+ // Collect files surfaced via the `present_files` tool (File Creation
136
+ // integration). The tool_use block carries `input.filepaths`; the matching
137
+ // tool_result block (joined via tool_use_id) carries display names + mime
138
+ // types as `local_resource` entries. Either side may be missing, so resolve
139
+ // metadata when available and fall back to bare paths otherwise.
140
+ function collectPresentedFiles(branch) {
141
+ const pathsByToolUseId = new Map();
142
+ const resourcesByToolUseId = new Map();
143
+ for (const msg of branch) {
144
+ if (!Array.isArray(msg?.content)) continue;
145
+ for (const block of msg.content) {
146
+ if (block?.type === "tool_use" && block?.name === "present_files") {
147
+ const filepaths = block?.input?.filepaths;
148
+ if (Array.isArray(filepaths) && filepaths.length > 0 && block.id) {
149
+ pathsByToolUseId.set(block.id, filepaths);
150
+ }
151
+ } else if (
152
+ block?.type === "tool_result" &&
153
+ Array.isArray(block.content)
154
+ ) {
155
+ const resources = block.content.filter(
156
+ (item) => item && item.type === "local_resource" && item.file_path,
157
+ );
158
+ if (resources.length > 0) {
159
+ const toolUseId = block.tool_use_id || block.id;
160
+ if (toolUseId) resourcesByToolUseId.set(toolUseId, resources);
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ const filesByToolUseId = new Map();
167
+ const seenPaths = new Set();
168
+ const toEntry = (resource, fallbackPath) => {
169
+ const filePath = resource?.file_path || fallbackPath || "";
170
+ const name = resource?.name || basenameOfPath(filePath);
171
+ return {
172
+ key: resource?.uuid || filePath || `${name}`,
173
+ name,
174
+ path: filePath,
175
+ mime: resource?.mime_type || "",
176
+ };
177
+ };
178
+
179
+ for (const [toolUseId, resources] of resourcesByToolUseId.entries()) {
180
+ const entries = [];
181
+ for (const resource of resources) {
182
+ // Skip the inline "say they are below" helper text item (type: text).
183
+ if (!resource.file_path) continue;
184
+ if (seenPaths.has(resource.file_path)) continue;
185
+ seenPaths.add(resource.file_path);
186
+ entries.push(toEntry(resource));
187
+ }
188
+ if (entries.length > 0) filesByToolUseId.set(toolUseId, entries);
189
+ }
190
+
191
+ for (const [toolUseId, filepaths] of pathsByToolUseId.entries()) {
192
+ const existing = filesByToolUseId.get(toolUseId) || [];
193
+ const entries = [...existing];
194
+ for (const filePath of filepaths) {
195
+ if (typeof filePath !== "string" || !filePath) continue;
196
+ if (seenPaths.has(filePath)) continue;
197
+ seenPaths.add(filePath);
198
+ entries.push(toEntry(null, filePath));
199
+ }
200
+ if (entries.length > 0) filesByToolUseId.set(toolUseId, entries);
201
+ }
202
+
203
+ return filesByToolUseId;
204
+ }
205
+
206
+ function formatGeneratedFilesSection(files) {
207
+ if (!Array.isArray(files) || files.length === 0) return "";
208
+ const lines = files.map((file) => {
209
+ const displayName = file.name || basenameOfPath(file.path);
210
+ const meta = [];
211
+ if (file.mime) meta.push(file.mime);
212
+ if (isBinaryArchive(file.mime, file.path || displayName)) {
213
+ meta.push("binary archive — download from Claude UI");
214
+ }
215
+ const suffix = meta.length > 0 ? ` _(${meta.join(", ")})_` : "";
216
+ const pathSuffix =
217
+ file.path && file.path !== displayName ? ` — \`${file.path}\`` : "";
218
+ return `- \`${displayName}\`${suffix}${pathSuffix}`;
219
+ });
220
+ const label = files.length === 1 ? "Generated file:" : "Generated files:";
221
+ return `\n\n**${label}**\n${lines.join("\n")}\n\n`;
222
+ }
223
+
106
224
  function extractArtifactsFromText(text) {
107
225
  const artifactRegex = /<antArtifact[^>]*>([\s\S]*?)<\/antArtifact>/g;
108
226
  const artifacts = [];
@@ -267,6 +385,44 @@ function extractArtifacts(message, foldedArtifacts = new Map()) {
267
385
  return artifacts;
268
386
  }
269
387
 
388
+ // File cards rendered for generated files (markdown docs, tarballs, …).
389
+ // They carry no text content for convertToMarkdown, so extract their display
390
+ // names (aria-label="View <name>") and drop the nodes to avoid button noise.
391
+ function extractFileCards(root) {
392
+ if (!root || typeof root.querySelectorAll !== "function") return [];
393
+ const names = [];
394
+ const cards = root.querySelectorAll('[data-testid="file-card-open"]');
395
+ for (const card of cards) {
396
+ const label = card.getAttribute && card.getAttribute("aria-label");
397
+ const match = typeof label === "string" && label.match(/^View\s+(.+)$/i);
398
+ const name = match ? match[1].trim() : "";
399
+ // No name-based dedup: two cards may legitimately share a display name
400
+ // (same basename in different directories). Each card element is visited
401
+ // exactly once, so nothing is double-counted here.
402
+ if (name) {
403
+ names.push(name);
404
+ }
405
+ // Remove the whole card element so download buttons / type badges
406
+ // don't leak into the markdown conversion — but only when the parent
407
+ // is a dedicated card wrapper. If the button shares its parent with
408
+ // other prose, remove just the button to avoid deleting message text.
409
+ const parent =
410
+ card.parentElement && card.parentElement !== root
411
+ ? card.parentElement
412
+ : null;
413
+ const isDedicatedCard =
414
+ !!parent &&
415
+ parent.querySelectorAll('[data-testid="file-card-open"]').length === 1;
416
+ const target = isDedicatedCard ? parent : card;
417
+ if (target && typeof target.remove === "function") {
418
+ target.remove();
419
+ } else if (card.parentNode) {
420
+ card.parentNode.removeChild(card);
421
+ }
422
+ }
423
+ return names;
424
+ }
425
+
270
426
  function unrollInteractiveElements(root, doc) {
271
427
  if (!root || !doc) return;
272
428
 
@@ -371,6 +527,8 @@ export class ClaudeParser extends ChatParser {
371
527
 
372
528
  const branch = getCurrentBranch(data);
373
529
  const foldedArtifacts = collectArtifacts(branch);
530
+ const presentedFilesByToolUseId = collectPresentedFiles(branch);
531
+ const emittedFileKeys = new Set();
374
532
 
375
533
  const toolResultMap = new Map();
376
534
  for (const msg of branch) {
@@ -402,12 +560,30 @@ export class ClaudeParser extends ChatParser {
402
560
  const role = message.sender === "human" ? "User" : "Claude";
403
561
 
404
562
  let contentStr = "";
563
+ let thinkingStr = "";
405
564
 
406
565
  // Construct content
407
566
  if (message.content && Array.isArray(message.content)) {
408
567
  for (const block of message.content) {
409
- if (block.type === "thinking" && block.thinking) {
410
- contentStr += `> **Thinking Process:**\n> \n> ${block.thinking.replace(/\n/g, "\n> ")}\n\n`;
568
+ if (block.type === "thinking") {
569
+ let thoughtText = "";
570
+ if (
571
+ typeof block.thinking === "string" &&
572
+ block.thinking.trim()
573
+ ) {
574
+ thoughtText = block.thinking.trim();
575
+ } else if (Array.isArray(block.summaries)) {
576
+ thoughtText = block.summaries
577
+ .map((s) =>
578
+ typeof s === "string" ? s : s?.summary || "",
579
+ )
580
+ .map((s) => s.trim())
581
+ .filter(Boolean)
582
+ .join("\n");
583
+ }
584
+ if (thoughtText) {
585
+ thinkingStr += (thinkingStr ? "\n\n" : "") + thoughtText;
586
+ }
411
587
  } else if (block.type === "text" && block.text) {
412
588
  const cleanText = block.text
413
589
  .replace(/<antArtifact[^>]*>[\s\S]*?<\/antArtifact>/g, "")
@@ -455,6 +631,37 @@ export class ClaudeParser extends ChatParser {
455
631
  : `> - ${qText}\n`;
456
632
  }
457
633
  contentStr += `${qStr}\n`;
634
+ } else if (
635
+ block.name === "present_files" &&
636
+ presentedFilesByToolUseId.has(block.id)
637
+ ) {
638
+ // Files surfaced via the File Creation integration
639
+ // (markdown docs, tarball, …). Without this they are
640
+ // silently dropped from every export format.
641
+ const files = presentedFilesByToolUseId.get(block.id);
642
+ const fresh = files.filter(
643
+ (file) => !emittedFileKeys.has(file.key),
644
+ );
645
+ fresh.forEach((file) => emittedFileKeys.add(file.key));
646
+ if (fresh.length > 0) {
647
+ contentStr += formatGeneratedFilesSection(fresh);
648
+ }
649
+ }
650
+ } else if (
651
+ block.type === "tool_result" &&
652
+ Array.isArray(block.content)
653
+ ) {
654
+ // Orphan file presentation: the matching tool_use block may
655
+ // sit outside the current branch, so emit unseen
656
+ // local_resource entries directly from the result.
657
+ const toolUseId = block.tool_use_id || block.id;
658
+ const files = presentedFilesByToolUseId.get(toolUseId) || [];
659
+ const fresh = files.filter(
660
+ (file) => !emittedFileKeys.has(file.key),
661
+ );
662
+ fresh.forEach((file) => emittedFileKeys.add(file.key));
663
+ if (fresh.length > 0) {
664
+ contentStr += formatGeneratedFilesSection(fresh);
458
665
  }
459
666
  }
460
667
  }
@@ -473,8 +680,9 @@ export class ClaudeParser extends ChatParser {
473
680
  if (attachment.file_name) {
474
681
  let header = `### Attachment: ${attachment.file_name}`;
475
682
  const meta = [];
476
- if (attachment.file_size) {
477
- meta.push(`${(attachment.file_size / 1024).toFixed(1)} KB`);
683
+ const sizeLabel = formatFileSize(attachment.file_size);
684
+ if (sizeLabel) {
685
+ meta.push(sizeLabel);
478
686
  }
479
687
  if (attachment.file_type) {
480
688
  meta.push(attachment.file_type);
@@ -487,7 +695,9 @@ export class ClaudeParser extends ChatParser {
487
695
  contentStr += `\`\`\`\`\n${attachment.extracted_content}\n\`\`\`\`\n\n`;
488
696
  }
489
697
  } else if (attachment.extracted_content) {
490
- contentStr += `\n\n### Pasted\n\`\`\`\`\n${attachment.extracted_content}\n\`\`\`\`\n\n`;
698
+ const sizeLabel = formatFileSize(attachment.file_size);
699
+ const sizeSuffix = sizeLabel ? ` _(${sizeLabel})_` : "";
700
+ contentStr += `\n\n### Pasted content${sizeSuffix}\n\`\`\`\`\n${attachment.extracted_content}\n\`\`\`\`\n\n`;
491
701
  }
492
702
  }
493
703
  }
@@ -516,9 +726,17 @@ export class ClaudeParser extends ChatParser {
516
726
  }
517
727
  }
518
728
 
729
+ if (thinkingStr) {
730
+ contentStr = `<think>\n${thinkingStr}\n</think>\n\n` + contentStr;
731
+ }
732
+
519
733
  contentStr = contentStr.trim();
520
734
  if (contentStr) {
521
- messages.push({ role, content: contentStr });
735
+ const msgObj = { role, content: contentStr };
736
+ if (thinkingStr) {
737
+ msgObj.thinking = thinkingStr;
738
+ }
739
+ messages.push(msgObj);
522
740
  }
523
741
 
524
742
  // Extract and push artifacts
@@ -652,8 +870,16 @@ export class ClaudeParser extends ChatParser {
652
870
  ) {
653
871
  role = "Claude";
654
872
  const clone = el.cloneNode(true);
873
+ // Extract file cards first: unrollInteractiveElements strips all
874
+ // buttons, which would destroy the card markers.
875
+ const fileCardNames = extractFileCards(clone);
655
876
  unrollInteractiveElements(clone, el.ownerDocument || document);
656
877
  content = convertToMarkdown(clone);
878
+ if (fileCardNames.length > 0) {
879
+ content += formatGeneratedFilesSection(
880
+ fileCardNames.map((name) => ({ name, path: "", mime: "" })),
881
+ );
882
+ }
657
883
  } else if (el.matches(".artifact-block-cell")) {
658
884
  role = "Claude Artifact";
659
885
 
package/ai/deepseek.js CHANGED
@@ -98,7 +98,29 @@ async function fetchDeepSeekConversation(sessionId, token) {
98
98
  const isUser = msgNode.role === "USER" || msgNode.role === "user";
99
99
  const role = isUser ? "User" : "DeepSeek";
100
100
  const content = extractDeepSeekMessageContent(msgNode);
101
- return { role, content: content.trim() };
101
+ let thinking = "";
102
+ if (!isUser && Array.isArray(msgNode.fragments)) {
103
+ const thinkFragments = msgNode.fragments.filter(
104
+ (f) => f && f.type === "THINK" && typeof f.content === "string",
105
+ );
106
+ thinking = thinkFragments
107
+ .map((f) => f.content.trim())
108
+ .filter(Boolean)
109
+ .join("\n\n");
110
+ }
111
+ let fullContent = "";
112
+ if (thinking) {
113
+ fullContent += `<think>\n${thinking}\n</think>\n\n`;
114
+ }
115
+ if (content.trim()) {
116
+ fullContent += content.trim();
117
+ }
118
+ fullContent = fullContent.trim();
119
+ const msg = { role, content: fullContent };
120
+ if (thinking) {
121
+ msg.thinking = thinking;
122
+ }
123
+ return msg;
102
124
  })
103
125
  .filter((msg) => msg.content.length > 0);
104
126
  }
@@ -182,15 +204,40 @@ export class DeepSeekParser extends ChatParser {
182
204
 
183
205
  outerElements.forEach((el) => {
184
206
  let role = "Unknown";
207
+ let thinking = "";
185
208
  if (el.matches(userSelector)) {
186
209
  role = "User";
187
210
  } else if (el.matches(assistantSelector)) {
188
211
  role = "DeepSeek";
212
+ const messageContainer =
213
+ (el.closest && el.closest(".ds-message")) || el.parentElement;
214
+ if (messageContainer) {
215
+ const thinkContainers =
216
+ messageContainer.querySelectorAll(".ds-think-content");
217
+ if (thinkContainers.length > 0) {
218
+ thinking = Array.from(thinkContainers)
219
+ .map((tc) => convertToMarkdown(tc).trim())
220
+ .filter(Boolean)
221
+ .join("\n\n");
222
+ }
223
+ }
189
224
  }
190
225
 
191
226
  const text = convertToMarkdown(el);
227
+ let fullContent = "";
228
+ if (thinking) {
229
+ fullContent += `<think>\n${thinking}\n</think>\n\n`;
230
+ }
192
231
  if (text.trim()) {
193
- messages.push({ role, content: text.trim() });
232
+ fullContent += text.trim();
233
+ }
234
+ fullContent = fullContent.trim();
235
+ if (fullContent) {
236
+ const msg = { role, content: fullContent };
237
+ if (thinking) {
238
+ msg.thinking = thinking;
239
+ }
240
+ messages.push(msg);
194
241
  }
195
242
  });
196
243
 
@@ -202,9 +249,37 @@ export class DeepSeekParser extends ChatParser {
202
249
  messageRows.forEach((row) => {
203
250
  const isUser = row.classList.contains("ds-user-message");
204
251
  const role = isUser ? "User" : "DeepSeek";
205
- const text = convertToMarkdown(row);
252
+ const rowClone = row.cloneNode(true);
253
+ let thinking = "";
254
+ if (!isUser) {
255
+ const thinkContainers =
256
+ rowClone.querySelectorAll(".ds-think-content");
257
+ if (thinkContainers.length > 0) {
258
+ thinking = Array.from(thinkContainers)
259
+ .map((tc) => {
260
+ const md = convertToMarkdown(tc).trim();
261
+ tc.remove();
262
+ return md;
263
+ })
264
+ .filter(Boolean)
265
+ .join("\n\n");
266
+ }
267
+ }
268
+ const text = convertToMarkdown(rowClone);
269
+ let fullContent = "";
270
+ if (thinking) {
271
+ fullContent += `<think>\n${thinking}\n</think>\n\n`;
272
+ }
206
273
  if (text.trim()) {
207
- messages.push({ role, content: text.trim() });
274
+ fullContent += text.trim();
275
+ }
276
+ fullContent = fullContent.trim();
277
+ if (fullContent) {
278
+ const msg = { role, content: fullContent };
279
+ if (thinking) {
280
+ msg.thinking = thinking;
281
+ }
282
+ messages.push(msg);
208
283
  }
209
284
  });
210
285
  }