@mseep/obsidian-agent-client 0.10.6

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.
Files changed (146) hide show
  1. package/.claude/hooks/gh-setup.sh +49 -0
  2. package/.claude/settings.json +15 -0
  3. package/.claude/skills/release-notes/SKILL.md +331 -0
  4. package/.editorconfig +10 -0
  5. package/.github/FUNDING.yml +2 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
  7. package/.github/ISSUE_TEMPLATE/config.yml +11 -0
  8. package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
  9. package/.github/copilot-instructions.md +45 -0
  10. package/.github/pull_request_template.md +32 -0
  11. package/.github/workflows/ci.yaml +25 -0
  12. package/.github/workflows/docs.yml +58 -0
  13. package/.github/workflows/relay_to_openclaw.yml +59 -0
  14. package/.github/workflows/release.yaml +45 -0
  15. package/.prettierignore +10 -0
  16. package/.prettierrc +13 -0
  17. package/.vscode/extensions.json +7 -0
  18. package/.vscode/settings.json +37 -0
  19. package/.zed/settings.json +42 -0
  20. package/AGENTS.md +330 -0
  21. package/ARCHITECTURE.md +390 -0
  22. package/CONTRIBUTING.md +216 -0
  23. package/LICENSE +202 -0
  24. package/NOTICE +2 -0
  25. package/README.ja.md +121 -0
  26. package/README.md +125 -0
  27. package/docs/.vitepress/config.mts +124 -0
  28. package/docs/.vitepress/theme/custom.css +111 -0
  29. package/docs/.vitepress/theme/index.ts +4 -0
  30. package/docs/agent-setup/claude-code.md +84 -0
  31. package/docs/agent-setup/codex.md +76 -0
  32. package/docs/agent-setup/custom-agents.md +67 -0
  33. package/docs/agent-setup/gemini-cli.md +99 -0
  34. package/docs/agent-setup/index.md +34 -0
  35. package/docs/announcements/gemini-cli-deprecation.md +73 -0
  36. package/docs/getting-started/index.md +78 -0
  37. package/docs/getting-started/quick-start.md +38 -0
  38. package/docs/help/faq.md +181 -0
  39. package/docs/help/troubleshooting.md +221 -0
  40. package/docs/index.md +63 -0
  41. package/docs/public/apple-touch-icon.png +0 -0
  42. package/docs/public/demo.mp4 +0 -0
  43. package/docs/public/favicon-16x16.png +0 -0
  44. package/docs/public/favicon-32x32.png +0 -0
  45. package/docs/public/favicon.ico +0 -0
  46. package/docs/public/images/editing.webp +0 -0
  47. package/docs/public/images/export.webp +0 -0
  48. package/docs/public/images/floating-chat-button.webp +0 -0
  49. package/docs/public/images/floating-chat-instance-menu.webp +0 -0
  50. package/docs/public/images/floating-chat-view.webp +0 -0
  51. package/docs/public/images/mode-selection.webp +0 -0
  52. package/docs/public/images/model-selection.webp +0 -0
  53. package/docs/public/images/multi-session.webp +0 -0
  54. package/docs/public/images/remove-image.webp +0 -0
  55. package/docs/public/images/ribbon-icon.webp +0 -0
  56. package/docs/public/images/selection-context.gif +0 -0
  57. package/docs/public/images/sending-images.webp +0 -0
  58. package/docs/public/images/sending-messages.webp +0 -0
  59. package/docs/public/images/session-history-button.webp +0 -0
  60. package/docs/public/images/slash-commands-1.webp +0 -0
  61. package/docs/public/images/slash-commands-2.webp +0 -0
  62. package/docs/public/images/switch-agent.webp +0 -0
  63. package/docs/public/images/switch-default-agent.webp +0 -0
  64. package/docs/public/images/temporary-disable.gif +0 -0
  65. package/docs/reference/acp-support.md +110 -0
  66. package/docs/usage/chat-export.md +80 -0
  67. package/docs/usage/commands.md +51 -0
  68. package/docs/usage/context-files.md +57 -0
  69. package/docs/usage/editing.md +69 -0
  70. package/docs/usage/floating-chat.md +84 -0
  71. package/docs/usage/index.md +97 -0
  72. package/docs/usage/mcp-tools.md +33 -0
  73. package/docs/usage/mentions.md +70 -0
  74. package/docs/usage/mode-selection.md +28 -0
  75. package/docs/usage/model-selection.md +32 -0
  76. package/docs/usage/multi-session.md +68 -0
  77. package/docs/usage/sending-images.md +64 -0
  78. package/docs/usage/session-history.md +91 -0
  79. package/docs/usage/slash-commands.md +44 -0
  80. package/esbuild.config.mjs +49 -0
  81. package/eslint.config.mjs +25 -0
  82. package/main.js +228 -0
  83. package/manifest.json +11 -0
  84. package/package.json +52 -0
  85. package/src/acp/acp-client.ts +921 -0
  86. package/src/acp/acp-handler.ts +252 -0
  87. package/src/acp/permission-handler.ts +282 -0
  88. package/src/acp/terminal-handler.ts +264 -0
  89. package/src/acp/type-converter.ts +272 -0
  90. package/src/hooks/useAgent.ts +250 -0
  91. package/src/hooks/useAgentMessages.ts +470 -0
  92. package/src/hooks/useAgentSession.ts +544 -0
  93. package/src/hooks/useChatActions.ts +400 -0
  94. package/src/hooks/useHistoryModal.ts +219 -0
  95. package/src/hooks/useSessionHistory.ts +863 -0
  96. package/src/hooks/useSettings.ts +19 -0
  97. package/src/hooks/useSuggestions.ts +342 -0
  98. package/src/main.ts +9 -0
  99. package/src/plugin.ts +1126 -0
  100. package/src/services/chat-exporter.ts +552 -0
  101. package/src/services/message-sender.ts +755 -0
  102. package/src/services/message-state.ts +375 -0
  103. package/src/services/session-helpers.ts +211 -0
  104. package/src/services/session-state.ts +130 -0
  105. package/src/services/session-storage.ts +267 -0
  106. package/src/services/settings-normalizer.ts +255 -0
  107. package/src/services/settings-service.ts +285 -0
  108. package/src/services/update-checker.ts +128 -0
  109. package/src/services/vault-service.ts +558 -0
  110. package/src/services/view-registry.ts +345 -0
  111. package/src/types/agent.ts +92 -0
  112. package/src/types/chat.ts +351 -0
  113. package/src/types/errors.ts +136 -0
  114. package/src/types/obsidian-internals.d.ts +14 -0
  115. package/src/types/session.ts +731 -0
  116. package/src/ui/ChangeDirectoryModal.ts +137 -0
  117. package/src/ui/ChatContext.ts +25 -0
  118. package/src/ui/ChatHeader.tsx +295 -0
  119. package/src/ui/ChatPanel.tsx +1162 -0
  120. package/src/ui/ChatView.tsx +348 -0
  121. package/src/ui/ErrorBanner.tsx +104 -0
  122. package/src/ui/FloatingButton.tsx +351 -0
  123. package/src/ui/FloatingChatView.tsx +531 -0
  124. package/src/ui/InputArea.tsx +1107 -0
  125. package/src/ui/InputToolbar.tsx +371 -0
  126. package/src/ui/MessageBubble.tsx +442 -0
  127. package/src/ui/MessageList.tsx +265 -0
  128. package/src/ui/PermissionBanner.tsx +61 -0
  129. package/src/ui/SessionHistoryModal.tsx +821 -0
  130. package/src/ui/SettingsTab.ts +1337 -0
  131. package/src/ui/SuggestionPopup.tsx +138 -0
  132. package/src/ui/TerminalBlock.tsx +107 -0
  133. package/src/ui/ToolCallBlock.tsx +456 -0
  134. package/src/ui/shared/AttachmentStrip.tsx +57 -0
  135. package/src/ui/shared/IconButton.tsx +55 -0
  136. package/src/ui/shared/MarkdownRenderer.tsx +103 -0
  137. package/src/ui/view-host.ts +56 -0
  138. package/src/utils/error-utils.ts +274 -0
  139. package/src/utils/logger.ts +44 -0
  140. package/src/utils/mention-parser.ts +129 -0
  141. package/src/utils/paths.ts +246 -0
  142. package/src/utils/platform.ts +425 -0
  143. package/styles.css +2322 -0
  144. package/tsconfig.json +18 -0
  145. package/version-bump.mjs +18 -0
  146. package/versions.json +3 -0
@@ -0,0 +1,552 @@
1
+ import type AgentClientPlugin from "../plugin";
2
+ import type { ChatMessage, MessageContent } from "../types/chat";
3
+ import { getLogger, Logger } from "../utils/logger";
4
+ import { TFile } from "obsidian";
5
+
6
+ /**
7
+ * Context for content conversion, tracking state across messages.
8
+ */
9
+ interface ConvertContext {
10
+ /** Path of the export markdown file */
11
+ exportFilePath: string;
12
+ /** Counter for image numbering */
13
+ imageIndex: number;
14
+ /** Whether to include images in export */
15
+ includeImages: boolean;
16
+ /** Where to save images */
17
+ imageLocation: "obsidian" | "custom" | "base64";
18
+ /** Custom folder for images */
19
+ imageCustomFolder: string;
20
+ }
21
+
22
+ export class ChatExporter {
23
+ private logger: Logger;
24
+
25
+ constructor(private plugin: AgentClientPlugin) {
26
+ this.logger = getLogger();
27
+ }
28
+
29
+ async exportToMarkdown(
30
+ messages: ChatMessage[],
31
+ agentLabel: string,
32
+ agentId: string,
33
+ sessionId: string,
34
+ sessionCreatedAt: Date,
35
+ openFile = true,
36
+ ): Promise<string> {
37
+ const settings = this.plugin.settings.exportSettings;
38
+
39
+ // Use first message timestamp if available, fallback to session creation time
40
+ const effectiveTimestamp =
41
+ messages.length > 0 ? messages[0].timestamp : sessionCreatedAt;
42
+
43
+ const baseFileName = this.generateFileName(effectiveTimestamp);
44
+ const folderPath = settings.defaultFolder || "Agent Client";
45
+
46
+ // Create folder if it doesn't exist
47
+ await this.ensureFolderExists(folderPath);
48
+
49
+ // Resolve file path considering session ID conflicts
50
+ const filePath = this.resolveExportFilePath(
51
+ folderPath,
52
+ baseFileName,
53
+ sessionId,
54
+ );
55
+
56
+ try {
57
+ const frontmatter = this.generateFrontmatter(
58
+ agentLabel,
59
+ agentId,
60
+ sessionId,
61
+ effectiveTimestamp,
62
+ );
63
+ const chatContent = await this.convertMessagesToMarkdown(
64
+ messages,
65
+ agentLabel,
66
+ filePath,
67
+ );
68
+ const fullContent = `${frontmatter}\n\n${chatContent}`;
69
+
70
+ // Check if file already exists (path is already resolved for our session)
71
+ const existingFile =
72
+ this.plugin.app.vault.getAbstractFileByPath(filePath);
73
+ let file: TFile;
74
+
75
+ if (existingFile instanceof TFile) {
76
+ // File exists (same session), update it
77
+ await this.plugin.app.vault.modify(existingFile, fullContent);
78
+ file = existingFile;
79
+ } else {
80
+ // File doesn't exist, create it
81
+ file = await this.plugin.app.vault.create(
82
+ filePath,
83
+ fullContent,
84
+ );
85
+ }
86
+
87
+ // Open the exported file if requested
88
+ if (openFile) {
89
+ const leaf = this.plugin.app.workspace.getLeaf(false);
90
+ await leaf.openFile(file);
91
+ }
92
+
93
+ this.logger.log(`Chat exported to: ${filePath}`);
94
+ return filePath;
95
+ } catch (error) {
96
+ this.logger.error("Export error:", error);
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ private async ensureFolderExists(folderPath: string): Promise<void> {
102
+ const folder = this.plugin.app.vault.getAbstractFileByPath(folderPath);
103
+ if (!folder) {
104
+ await this.plugin.app.vault.createFolder(folderPath);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Extract session_id from file's frontmatter cache.
110
+ *
111
+ * Uses Obsidian's metadataCache for efficient access (no disk I/O).
112
+ * This is the same pattern used in vault.adapter.ts and mention-service.ts.
113
+ *
114
+ * @param file - TFile to extract session_id from
115
+ * @returns session_id string if found, null otherwise
116
+ */
117
+ private getSessionIdFromFile(file: TFile): string | null {
118
+ const cache = this.plugin.app.metadataCache.getFileCache(file);
119
+ const sessionId = cache?.frontmatter?.session_id as string | undefined;
120
+ return sessionId ?? null;
121
+ }
122
+
123
+ /**
124
+ * Resolve export file path, handling session ID conflicts.
125
+ *
126
+ * Resolution logic:
127
+ * 1. If no file exists at base path → use base path
128
+ * 2. If file exists with same sessionId → use base path (overwrite)
129
+ * 3. If file exists with different sessionId → try suffixed paths (_2, _3, ...)
130
+ * - If suffixed file has same sessionId → use that path (overwrite)
131
+ * - If suffixed file has different sessionId → continue searching
132
+ * - If no suffixed file exists → use that path (create new)
133
+ *
134
+ * @param folderPath - Export folder path
135
+ * @param baseFileName - Base file name without extension
136
+ * @param sessionId - Current session's ID
137
+ * @returns Resolved file path
138
+ */
139
+ private resolveExportFilePath(
140
+ folderPath: string,
141
+ baseFileName: string,
142
+ sessionId: string,
143
+ ): string {
144
+ const basePath = `${folderPath}/${baseFileName}.md`;
145
+
146
+ // Check if base file exists
147
+ const existingFile =
148
+ this.plugin.app.vault.getAbstractFileByPath(basePath);
149
+
150
+ if (!(existingFile instanceof TFile)) {
151
+ // No existing file, use base path
152
+ return basePath;
153
+ }
154
+
155
+ // File exists - check sessionId in frontmatter
156
+ const existingSessionId = this.getSessionIdFromFile(existingFile);
157
+
158
+ if (existingSessionId === sessionId) {
159
+ // Same session, overwrite
160
+ return basePath;
161
+ }
162
+
163
+ // Different session (or no sessionId in frontmatter) - find available suffix
164
+ // Start from 2 to follow common convention (file, file_2, file_3, ...)
165
+ for (let suffix = 2; suffix <= 100; suffix++) {
166
+ const suffixedPath = `${folderPath}/${baseFileName}_${suffix}.md`;
167
+ const suffixedFile =
168
+ this.plugin.app.vault.getAbstractFileByPath(suffixedPath);
169
+
170
+ if (!(suffixedFile instanceof TFile)) {
171
+ // No file at this path, use it
172
+ return suffixedPath;
173
+ }
174
+
175
+ // Check if this file belongs to our session
176
+ const suffixedSessionId = this.getSessionIdFromFile(suffixedFile);
177
+ if (suffixedSessionId === sessionId) {
178
+ // Found our session's file
179
+ return suffixedPath;
180
+ }
181
+
182
+ // Different session, continue searching
183
+ }
184
+
185
+ // Safety fallback: exceeded limit, use the last checked path
186
+ // This should rarely happen (100+ exports with same timestamp)
187
+ this.logger.warn(
188
+ `Too many export files with same base name: ${baseFileName}`,
189
+ );
190
+ return `${folderPath}/${baseFileName}_101.md`;
191
+ }
192
+
193
+ private generateFileName(timestamp: Date): string {
194
+ const settings = this.plugin.settings.exportSettings;
195
+ const template =
196
+ settings.filenameTemplate || "agent_client_{date}_{time}";
197
+
198
+ // Format date in local timezone: 20251115
199
+ const year = timestamp.getFullYear();
200
+ const month = String(timestamp.getMonth() + 1).padStart(2, "0");
201
+ const day = String(timestamp.getDate()).padStart(2, "0");
202
+ const dateStr = `${year}${month}${day}`;
203
+
204
+ // Format time in local timezone: 012345
205
+ const hours = String(timestamp.getHours()).padStart(2, "0");
206
+ const minutes = String(timestamp.getMinutes()).padStart(2, "0");
207
+ const seconds = String(timestamp.getSeconds()).padStart(2, "0");
208
+ const timeStr = `${hours}${minutes}${seconds}`;
209
+
210
+ return template.replace("{date}", dateStr).replace("{time}", timeStr);
211
+ }
212
+
213
+ private generateFrontmatter(
214
+ agentLabel: string,
215
+ agentId: string,
216
+ sessionId: string,
217
+ timestamp: Date,
218
+ ): string {
219
+ const settings = this.plugin.settings.exportSettings;
220
+
221
+ // Format timestamp in local timezone: YYYY-MM-DDTHH:mm:ss
222
+ const year = timestamp.getFullYear();
223
+ const month = String(timestamp.getMonth() + 1).padStart(2, "0");
224
+ const day = String(timestamp.getDate()).padStart(2, "0");
225
+ const hours = String(timestamp.getHours()).padStart(2, "0");
226
+ const minutes = String(timestamp.getMinutes()).padStart(2, "0");
227
+ const seconds = String(timestamp.getSeconds()).padStart(2, "0");
228
+ const localTimestamp = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
229
+
230
+ // Build tags line only if tag is specified
231
+ const tagsLine = settings.frontmatterTag.trim()
232
+ ? `\ntags: [${settings.frontmatterTag.trim()}]`
233
+ : "";
234
+
235
+ return `---
236
+ created: ${localTimestamp}
237
+ agentDisplayName: ${agentLabel}
238
+ agentId: ${agentId}
239
+ session_id: ${sessionId}${tagsLine}
240
+ ---`;
241
+ }
242
+
243
+ private async convertMessagesToMarkdown(
244
+ messages: ChatMessage[],
245
+ agentLabel: string,
246
+ exportFilePath: string,
247
+ ): Promise<string> {
248
+ const settings = this.plugin.settings.exportSettings;
249
+ const context: ConvertContext = {
250
+ exportFilePath,
251
+ imageIndex: 0,
252
+ includeImages: settings.includeImages,
253
+ imageLocation: settings.imageLocation,
254
+ imageCustomFolder: settings.imageCustomFolder,
255
+ };
256
+
257
+ let markdown = `# ${agentLabel}\n\n`;
258
+
259
+ for (const message of messages) {
260
+ const timeStr = message.timestamp.toLocaleTimeString();
261
+ const role = message.role === "user" ? "User" : "Assistant";
262
+
263
+ markdown += `## ${timeStr} - ${role}\n\n`;
264
+
265
+ for (const content of message.content) {
266
+ markdown += await this.convertContentToMarkdown(
267
+ content,
268
+ context,
269
+ );
270
+ }
271
+
272
+ markdown += "\n---\n\n";
273
+ }
274
+
275
+ return markdown;
276
+ }
277
+
278
+ private async convertContentToMarkdown(
279
+ content: MessageContent,
280
+ context: ConvertContext,
281
+ ): Promise<string> {
282
+ switch (content.type) {
283
+ case "text":
284
+ return content.text + "\n\n";
285
+
286
+ case "text_with_context": {
287
+ // User messages with auto-mention context
288
+ // Add auto-mention in @[[note]] format at the beginning
289
+ let exportText = "";
290
+ if (content.autoMentionContext) {
291
+ const { noteName, selection } = content.autoMentionContext;
292
+ if (selection) {
293
+ exportText += `@[[${noteName}]]:${selection.fromLine}-${selection.toLine}\n`;
294
+ } else {
295
+ exportText += `@[[${noteName}]]\n`;
296
+ }
297
+ }
298
+ // Add the message text (which may contain additional @[[note]] mentions)
299
+ exportText += content.text + "\n\n";
300
+ return exportText;
301
+ }
302
+
303
+ case "agent_thought":
304
+ return `> [!info]- Thinking\n> ${content.text.split("\n").join("\n> ")}\n\n`;
305
+
306
+ case "tool_call":
307
+ return this.convertToolCallToMarkdown(content);
308
+
309
+ case "terminal":
310
+ return `### 🖥️ Terminal: ${content.terminalId.slice(0, 8)}\n\n`;
311
+
312
+ case "plan":
313
+ return this.convertPlanToMarkdown(content);
314
+
315
+ case "permission_request":
316
+ return this.convertPermissionRequestToMarkdown(content);
317
+
318
+ case "resource_link":
319
+ return `[${content.name}](${content.uri})\n\n`;
320
+
321
+ case "image":
322
+ // Skip if images are not included
323
+ if (!context.includeImages) {
324
+ return "";
325
+ }
326
+
327
+ // External URI - use as-is
328
+ if (content.uri) {
329
+ return `![Image](${content.uri})\n\n`;
330
+ }
331
+
332
+ // Base64 embedding mode
333
+ if (context.imageLocation === "base64") {
334
+ return `![Image](data:${content.mimeType};base64,${content.data})\n\n`;
335
+ }
336
+
337
+ // Save as attachment (obsidian or custom)
338
+ try {
339
+ context.imageIndex++;
340
+ const attachmentPath = await this.saveImageAsAttachment(
341
+ content.data,
342
+ content.mimeType,
343
+ context.exportFilePath,
344
+ context.imageIndex,
345
+ context.imageLocation,
346
+ context.imageCustomFolder,
347
+ );
348
+ // Use filename only (Obsidian resolves it)
349
+ const fileName = attachmentPath.split("/").pop();
350
+ return `![[${fileName}]]\n\n`;
351
+ } catch (error) {
352
+ this.logger.error(
353
+ `Failed to save image as attachment: ${error}`,
354
+ );
355
+ // Fallback to base64 embedding
356
+ return `![Image](data:${content.mimeType};base64,${content.data})\n\n`;
357
+ }
358
+
359
+ default:
360
+ return "";
361
+ }
362
+ }
363
+
364
+ private convertToolCallToMarkdown(
365
+ content: Extract<MessageContent, { type: "tool_call" }>,
366
+ ): string {
367
+ let md = `### 🔧 ${content.title || "Tool"}\n\n`;
368
+
369
+ // Add locations if present
370
+ if (content.locations && content.locations.length > 0) {
371
+ const locationStrs = content.locations.map((loc) =>
372
+ loc.line != null
373
+ ? `\`${loc.path}:${loc.line}\``
374
+ : `\`${loc.path}\``,
375
+ );
376
+ md += `**Locations**: ${locationStrs.join(", ")}\n\n`;
377
+ }
378
+
379
+ md += `**Status**: ${content.status}\n\n`;
380
+
381
+ // Only export diffs
382
+ if (content.content && content.content.length > 0) {
383
+ for (const item of content.content) {
384
+ if (item.type === "diff") {
385
+ md += this.convertDiffToMarkdown(item);
386
+ }
387
+ }
388
+ }
389
+
390
+ return md;
391
+ }
392
+
393
+ private convertDiffToMarkdown(diff: {
394
+ type: "diff";
395
+ path: string;
396
+ oldText?: string | null;
397
+ newText: string;
398
+ }): string {
399
+ let md = `**File**: \`${diff.path}\`\n\n`;
400
+
401
+ // Check if this is a new file
402
+ if (
403
+ diff.oldText === null ||
404
+ diff.oldText === undefined ||
405
+ diff.oldText === ""
406
+ ) {
407
+ md += "```diff\n";
408
+ diff.newText.split("\n").forEach((line) => {
409
+ md += `+ ${line}\n`;
410
+ });
411
+ md += "```\n\n";
412
+ return md;
413
+ }
414
+
415
+ // Generate proper diff format
416
+ const oldLines = diff.oldText.split("\n");
417
+ const newLines = diff.newText.split("\n");
418
+
419
+ md += "```diff\n";
420
+
421
+ // Show removed lines
422
+ oldLines.forEach((line) => {
423
+ md += `- ${line}\n`;
424
+ });
425
+
426
+ // Show added lines
427
+ newLines.forEach((line) => {
428
+ md += `+ ${line}\n`;
429
+ });
430
+
431
+ md += "```\n\n";
432
+ return md;
433
+ }
434
+
435
+ private convertPlanToMarkdown(
436
+ content: Extract<MessageContent, { type: "plan" }>,
437
+ ): string {
438
+ let md = `> [!plan] Plan\n`;
439
+ for (const entry of content.entries) {
440
+ const status =
441
+ entry.status === "completed"
442
+ ? "✅"
443
+ : entry.status === "in_progress"
444
+ ? "🔄"
445
+ : "⏳";
446
+ md += `> ${status} ${entry.content}\n`;
447
+ }
448
+ md += `\n`;
449
+ return md;
450
+ }
451
+
452
+ private convertPermissionRequestToMarkdown(
453
+ content: Extract<MessageContent, { type: "permission_request" }>,
454
+ ): string {
455
+ const status = content.isCancelled ? "Cancelled" : "Requested";
456
+ return `### ⚠️ Permission: ${content.toolCall.title || "Unknown"} (${status})\n\n`;
457
+ }
458
+
459
+ /**
460
+ * Save a base64-encoded image as an attachment file.
461
+ * Uses Obsidian's attachment settings to determine the save location.
462
+ * Skips saving if the file already exists.
463
+ */
464
+ private async saveImageAsAttachment(
465
+ base64Data: string,
466
+ mimeType: string,
467
+ exportFilePath: string,
468
+ imageIndex: number,
469
+ imageLocation: "obsidian" | "custom",
470
+ imageCustomFolder: string,
471
+ ): Promise<string> {
472
+ const ext = this.getExtensionFromMimeType(mimeType);
473
+
474
+ // Generate image filename based on export filename
475
+ const exportFileName = exportFilePath.replace(/\.md$/, "");
476
+ const baseName = exportFileName.split("/").pop() || "image";
477
+ const imageFileName = `${baseName}_${String(imageIndex).padStart(3, "0")}.${ext}`;
478
+
479
+ let attachmentPath: string;
480
+
481
+ if (imageLocation === "custom") {
482
+ // Save to custom folder
483
+ const folder = imageCustomFolder || "Agent Client";
484
+ await this.ensureFolderExists(folder);
485
+ attachmentPath = `${folder}/${imageFileName}`;
486
+
487
+ // Check if file already exists
488
+ const existingFile =
489
+ this.plugin.app.vault.getAbstractFileByPath(attachmentPath);
490
+ if (existingFile instanceof TFile) {
491
+ this.logger.log(
492
+ `Image already exists, skipping: ${attachmentPath}`,
493
+ );
494
+ return attachmentPath;
495
+ }
496
+ } else {
497
+ // Use Obsidian's attachment folder settings
498
+ attachmentPath =
499
+ await this.plugin.app.fileManager.getAvailablePathForAttachment(
500
+ imageFileName,
501
+ exportFilePath,
502
+ );
503
+
504
+ // Check if file already exists by comparing paths
505
+ // getAvailablePathForAttachment returns the original name if it doesn't exist,
506
+ // or adds a suffix (e.g., "image_001 1.png") if it does exist.
507
+ if (!attachmentPath.endsWith(imageFileName)) {
508
+ // File exists - return the original path (without suffix)
509
+ const originalPath = attachmentPath.replace(
510
+ / \d+(\.[^.]+)$/,
511
+ "$1",
512
+ );
513
+ this.logger.log(
514
+ `Image already exists, skipping: ${originalPath}`,
515
+ );
516
+ return originalPath;
517
+ }
518
+ }
519
+
520
+ // Save the image
521
+ const binaryData = this.base64ToArrayBuffer(base64Data);
522
+ await this.plugin.app.vault.createBinary(attachmentPath, binaryData);
523
+ this.logger.log(`Image saved as attachment: ${attachmentPath}`);
524
+
525
+ return attachmentPath;
526
+ }
527
+
528
+ /**
529
+ * Get file extension from MIME type.
530
+ */
531
+ private getExtensionFromMimeType(mimeType: string): string {
532
+ const map: Record<string, string> = {
533
+ "image/png": "png",
534
+ "image/jpeg": "jpg",
535
+ "image/gif": "gif",
536
+ "image/webp": "webp",
537
+ };
538
+ return map[mimeType] || "png";
539
+ }
540
+
541
+ /**
542
+ * Convert base64 string to ArrayBuffer.
543
+ */
544
+ private base64ToArrayBuffer(base64: string): ArrayBuffer {
545
+ const binaryString = atob(base64);
546
+ const bytes = new Uint8Array(binaryString.length);
547
+ for (let i = 0; i < binaryString.length; i++) {
548
+ bytes[i] = binaryString.charCodeAt(i);
549
+ }
550
+ return bytes.buffer;
551
+ }
552
+ }