flint-agent 1.14.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.
Files changed (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,310 @@
1
+ // Context compression with configurable threshold
2
+ // Only compresses when total context exceeds config.compressAfterTokens
3
+ //
4
+ // Levels (when compression IS triggered):
5
+ // Current iteration: full results
6
+ // Previous iteration (age=1): head/tail truncation
7
+ // Older iterations (age>=2): one-line type-aware summary
8
+ //
9
+ // Facts extracted SYNCHRONOUSLY before summarizing (await, not fire-and-forget)
10
+ // Action log: deterministic tracking of side-effect tools
11
+
12
+ import { getSpendLevel, spendSettings, windowShare } from "../spend.js";
13
+ import { extractFacts } from "../memory/extract-facts.js";
14
+ import { saveSessionFacts, saveSessionFact } from "../memory/session-facts.js";
15
+ import { config } from "../config.js";
16
+ import { contextWindow } from "./usage.js";
17
+ import { createLogger } from "../logging/logger.js";
18
+
19
+ const log = createLogger("compression");
20
+
21
+ const COMPRESS_THRESHOLD = config.compressThreshold;
22
+ const HEAD_LINES = 8;
23
+ const TAIL_LINES = 3;
24
+
25
+ // Tools that change state — tracked as action facts
26
+ const SIDE_EFFECT_TOOLS = new Set([
27
+ "write_file", "edit_file", "delete_file", "copy_file", "move_file", "create_directory",
28
+ "run_command", "run_background_command",
29
+ ]);
30
+
31
+ // MCP tool patterns that indicate side effects
32
+ const MCP_SIDE_EFFECT_PATTERNS = [
33
+ /desktop_click/, /desktop_type/, /desktop_key/, /desktop_shell/,
34
+ /desktop_batch/, /desktop_manage/, /desktop_chrome.*navigate/,
35
+ /desktop_file/, /gmail_send/, /mesh_add/,
36
+ ];
37
+
38
+ function isSideEffectTool(toolName, toolArgs) {
39
+ if (SIDE_EFFECT_TOOLS.has(toolName)) return true;
40
+ for (const pat of MCP_SIDE_EFFECT_PATTERNS) {
41
+ if (pat.test(toolName)) return true;
42
+ }
43
+ // desktop_chrome with action=navigate, type, click
44
+ if (toolName.includes("desktop_chrome")) {
45
+ const action = toolArgs?.action || "";
46
+ if (["navigate", "type", "click", "new_tab", "close_tab"].includes(action)) return true;
47
+ }
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Generate a short action description from a tool call
53
+ */
54
+ function describeAction(toolName, toolArgs, result) {
55
+ const shortResult = typeof result === "string" ? result.slice(0, 100) : "";
56
+
57
+ if (toolName === "write_file" || toolName === "edit_file") {
58
+ return `Modified file: ${toolArgs?.path || "?"}`;
59
+ }
60
+ if (toolName === "run_command") {
61
+ const cmd = (toolArgs?.command || "?").slice(0, 60);
62
+ return `Ran command: ${cmd}`;
63
+ }
64
+ if (toolName.includes("desktop_click")) {
65
+ return `Clicked at (${toolArgs?.x}, ${toolArgs?.y})`;
66
+ }
67
+ if (toolName.includes("desktop_shell")) {
68
+ const cmd = (toolArgs?.command || "?").slice(0, 60);
69
+ return `Desktop shell: ${cmd}`;
70
+ }
71
+ if (toolName.includes("desktop_batch")) {
72
+ const count = toolArgs?.actions?.length || toolArgs?.action_count || "?";
73
+ return `Desktop batch: ${count} actions`;
74
+ }
75
+ if (toolName.includes("desktop_chrome")) {
76
+ const action = toolArgs?.action || "?";
77
+ const url = toolArgs?.url || "";
78
+ return url ? `Chrome ${action}: ${url.slice(0, 60)}` : `Chrome ${action}`;
79
+ }
80
+ if (toolName.includes("desktop_manage")) {
81
+ const action = toolArgs?.action || "?";
82
+ const app = toolArgs?.app || "";
83
+ return app ? `Desktop ${action}: ${app}` : `Desktop ${action}`;
84
+ }
85
+ if (toolName.includes("desktop_type")) {
86
+ return `Typed text (${toolArgs?.text?.length || "?"} chars)`;
87
+ }
88
+ if (toolName.includes("desktop_key")) {
89
+ return `Pressed: ${toolArgs?.keys || "?"}`;
90
+ }
91
+ return `${toolName}(${JSON.stringify(toolArgs).slice(0, 80)})`;
92
+ }
93
+
94
+ export function summarizeToolResult(content, toolName, toolArgs) {
95
+ const lines = content.split("\n");
96
+ const chars = content.length;
97
+ const firstLine = lines[0]?.slice(0, 80) || "";
98
+
99
+ switch (toolName) {
100
+ case "read_file": {
101
+ const filePath = toolArgs?.path || "unknown";
102
+ return `[Read ${filePath} (${lines.length} lines, ${chars} chars): ${firstLine}...]`;
103
+ }
104
+ case "list_directory": {
105
+ const dirPath = toolArgs?.path || "unknown";
106
+ const itemCount = lines.length;
107
+ const preview = lines.slice(0, 3).join(", ");
108
+ const more = itemCount > 3 ? ` +${itemCount - 3} more` : "";
109
+ return `[Listed ${dirPath}: ${itemCount} items — ${preview}${more}]`;
110
+ }
111
+ case "run_command": {
112
+ const cmd = toolArgs?.command || "unknown";
113
+ const cmdShort = cmd.length > 40 ? cmd.slice(0, 40) + "..." : cmd;
114
+ const exitMatch = content.match(/exit(?:ed| code)[:\s]*(\d+)/i);
115
+ const exitCode = exitMatch ? exitMatch[1] : "?";
116
+ return `[Ran '${cmdShort}' (exit ${exitCode}, ${lines.length} lines): ${firstLine}...]`;
117
+ }
118
+ case "search_in_files": {
119
+ const pat = toolArgs?.pattern || "?";
120
+ const matchCount = lines.length - 1;
121
+ const files = new Set(lines.slice(1).map(l => l.split(":")[0]).filter(Boolean));
122
+ return `[Search /${pat}/: ${matchCount} matches in ${files.size} files — ${[...files].slice(0, 5).join(", ")}${files.size > 5 ? " +" + (files.size - 5) + " more" : ""}]`;
123
+ }
124
+ case "think": {
125
+ return `[Thought: ${firstLine}...]`;
126
+ }
127
+ default: {
128
+ return `[${toolName}(...) → ${lines.length} lines, ${chars} chars: ${firstLine}...]`;
129
+ }
130
+ }
131
+ }
132
+
133
+ function compressHeadTail(content) {
134
+ const originalLen = content.length;
135
+ const lines = content.split("\n");
136
+ if (lines.length > HEAD_LINES + TAIL_LINES + 2) {
137
+ const head = lines.slice(0, HEAD_LINES).join("\n");
138
+ const tail = lines.slice(-TAIL_LINES).join("\n");
139
+ const omitted = lines.length - HEAD_LINES - TAIL_LINES;
140
+ return `${head}\n[... ${omitted} lines omitted, ${originalLen} chars total ...]\n${tail}`;
141
+ }
142
+ return content.slice(0, COMPRESS_THRESHOLD) + `\n[... truncated, ${originalLen} chars total]`;
143
+ }
144
+
145
+ /**
146
+ * Estimate token count from messages (rough: 1 token ≈ 4 chars)
147
+ */
148
+ function estimateTokens(messages) {
149
+ let total = 0;
150
+ for (const m of messages) {
151
+ const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content || "");
152
+ total += Math.ceil(content.length / 4);
153
+ if (m.tool_calls) {
154
+ for (const tc of m.tool_calls) {
155
+ total += Math.ceil((tc.function?.arguments || "").length / 4) + 10;
156
+ }
157
+ }
158
+ }
159
+ return total;
160
+ }
161
+
162
+ /**
163
+ * When history may be rewritten to make room, in tokens.
164
+ *
165
+ * Half the model's window, capped at 128k because a bigger
166
+ * prompt is paid for on every call even when cached; 64k when the window is
167
+ * unknown. COMPRESS_AFTER_TOKENS overrides. On the 2026-09-26 auto-budget run
168
+ * the old fixed point (25k for the eager pass) collapsed sixteen file reads
169
+ * into one-liners at call 16 and again every few calls after; the agent never
170
+ * edited anything, it re-read. Room is not the problem at 25k on any current
171
+ * model, forgetting is.
172
+ */
173
+ export function compressThreshold() {
174
+ if (config.compressAfterTokens) return config.compressAfterTokens;
175
+ return compressThresholdFor(contextWindow(), getSpendLevel());
176
+ }
177
+
178
+ /** The threshold for a window (null: unknown) at a spend level (spend.js). */
179
+ export function compressThresholdFor(window, level) {
180
+ return windowShare(spendSettings(level).compress, window);
181
+ }
182
+
183
+ /**
184
+ * compressContext — threshold-based, sync fact extraction, action logging
185
+ *
186
+ * @param {Array} messages
187
+ * @param {number} prevIterationStart
188
+ * @param {number} iterationStart
189
+ * @param {string} [sessionId]
190
+ * @returns {Promise<number>} tokens saved
191
+ */
192
+ export async function compressContext(messages, prevIterationStart, iterationStart, sessionId, { forceCompress = false } = {}) {
193
+ const estimatedTokens = estimateTokens(messages);
194
+ const threshold = compressThreshold();
195
+
196
+ // === Action logging (always, regardless of compression threshold) ===
197
+ // Track side-effect tools as session facts for context preservation
198
+ if (sessionId) {
199
+ for (let i = prevIterationStart; i < iterationStart; i++) {
200
+ const m = messages[i];
201
+ if (m.role === "assistant" && m.tool_calls) {
202
+ for (const tc of m.tool_calls) {
203
+ const name = tc.function?.name;
204
+ let args;
205
+ try { args = JSON.parse(tc.function?.arguments || "{}"); } catch { args = {}; }
206
+
207
+ if (isSideEffectTool(name, args)) {
208
+ // Find corresponding tool result
209
+ const resultMsg = messages.find((rm, ri) =>
210
+ ri > i && rm.role === "tool" && rm.tool_call_id === tc.id
211
+ );
212
+ const resultText = resultMsg?.content || "";
213
+ const actionDesc = describeAction(name, args, resultText);
214
+
215
+ saveSessionFact(sessionId, {
216
+ content: actionDesc,
217
+ category: "action",
218
+ source: "action-log",
219
+ });
220
+ log.debug("action-logged", { tool: name, action: actionDesc });
221
+ }
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ // === Check threshold — skip compression if under budget ===
228
+ if (!forceCompress && estimatedTokens < threshold) {
229
+ log.debug("compression-skipped", { estimatedTokens, threshold });
230
+ return 0;
231
+ }
232
+
233
+ log.info("compression-triggered", { estimatedTokens, threshold, messages: messages.length });
234
+
235
+ let saved = 0;
236
+ const messagesToExtract = [];
237
+
238
+ for (let i = 0; i < iterationStart; i++) {
239
+ const m = messages[i];
240
+
241
+ // Replace previous iteration images with existing text description
242
+ // No vision API call — use the OCR/caption text already in the adjacent tool result message.
243
+ // Only the LAST image in context stays as full base64 (current iteration).
244
+ if (m._isImage && Array.isArray(m.content) && m.content.some((c) => c.type === "image_url")) {
245
+ if (!m._compressed) {
246
+ const toolName = m._imageTool || "unknown";
247
+ const imagePath = m._imagePath || null;
248
+ // Use existing text from the image message itself (already has OCR/caption)
249
+ const textBlock = m.content.find((c) => c.type === "text");
250
+ const description = textBlock?.text || "[Previously viewed screenshot]";
251
+ const ref = imagePath ? ` [file: ${imagePath}]` : "";
252
+ m.content = `[Image from ${toolName}${ref}: ${description}]`;
253
+ m._compressed = true;
254
+ saved += 50000; // approximate base64 size saved
255
+ log.info("image-replaced", { tool: toolName, descLen: description.length });
256
+ }
257
+ continue;
258
+ }
259
+
260
+ // Collect uncompressed old messages for fact extraction
261
+ const isOld = i < prevIterationStart; // age >= 2
262
+ if (isOld && m.role === "tool" && m._compressed !== "summary") {
263
+ messagesToExtract.push(m);
264
+ }
265
+ if (isOld && (m.role === "user" || m.role === "assistant") && !m._compressed) {
266
+ messagesToExtract.push(m);
267
+ }
268
+
269
+ if (m.role !== "tool") continue;
270
+ if (m._compressed === "summary") continue;
271
+
272
+ if (!isOld && m.content.length <= COMPRESS_THRESHOLD) continue;
273
+
274
+ const originalLen = m.content.length;
275
+
276
+ if (isOld) {
277
+ m.content = summarizeToolResult(m.content, m._toolName, m._toolArgs);
278
+ m._compressed = "summary";
279
+ } else if (m._compressed !== "headtail") {
280
+ m.content = compressHeadTail(m.content);
281
+ m._compressed = "headtail";
282
+ }
283
+
284
+ saved += originalLen - m.content.length;
285
+ }
286
+
287
+ // Mark user/assistant old messages as compressed
288
+ for (let i = 0; i < prevIterationStart; i++) {
289
+ const m = messages[i];
290
+ if ((m.role === "user" || m.role === "assistant") && !m._compressed) {
291
+ m._compressed = true;
292
+ }
293
+ }
294
+
295
+ // Extract facts SYNCHRONOUSLY before they're lost
296
+ if (sessionId && messagesToExtract.length > 0) {
297
+ try {
298
+ const facts = await extractFacts(messagesToExtract);
299
+ if (facts.length > 0) {
300
+ saveSessionFacts(sessionId, facts);
301
+ log.info("facts-extracted", { count: facts.length, facts: facts.map(f => f.content.slice(0, 60)) });
302
+ }
303
+ } catch (err) {
304
+ log.warn("fact-extraction-failed", { error: err.message });
305
+ }
306
+ }
307
+
308
+ log.info("compression-done", { saved, estimatedAfter: estimateTokens(messages) });
309
+ return saved;
310
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Content Type Resolver — determines the actual type of MCP response data.
3
+ *
4
+ * Analyzes raw MCP content blocks by inspecting mimeType, data content,
5
+ * and magic bytes — NOT by trusting block.type strings.
6
+ *
7
+ * Returns a normalized typed result that downstream modules (perception,
8
+ * agent loop) can work with without knowing MCP internals.
9
+ *
10
+ * Architecture:
11
+ * MCP raw → resolveContent() → typed result → perception → history
12
+ */
13
+
14
+ import { createLogger } from "../logging/logger.js";
15
+
16
+ const log = createLogger("content-resolver");
17
+
18
+ // Magic byte signatures for common formats (first bytes of base64-decoded data)
19
+ const MAGIC_BYTES = {
20
+ // Images
21
+ "iVBOR": { type: "image", mimeType: "image/png" }, // PNG (\x89PNG)
22
+ "/9j/": { type: "image", mimeType: "image/jpeg" }, // JPEG (\xFF\xD8)
23
+ "R0lGOD": { type: "image", mimeType: "image/gif" }, // GIF (GIF8)
24
+ "UklGR": { type: "image", mimeType: "image/webp" }, // WebP (RIFF)
25
+ "AAABAA": { type: "image", mimeType: "image/x-icon" }, // ICO
26
+
27
+ // Audio
28
+ "SUQz": { type: "audio", mimeType: "audio/mpeg" }, // MP3 (ID3)
29
+ "T2dnUw": { type: "audio", mimeType: "audio/ogg" }, // OGG (OggS)
30
+ "ZkxhQw": { type: "audio", mimeType: "audio/flac" }, // FLAC (fLaC)
31
+ "UklGR": null, // RIFF — could be WAV or WebP, check further
32
+
33
+ // Video
34
+ "AAAA": null, // Could be MP4/MOV (ftyp box) — needs more bytes
35
+ "GkXFo": { type: "video", mimeType: "video/webm" }, // WebM (\x1A\x45)
36
+
37
+ // Documents
38
+ "JVBER": { type: "document", mimeType: "application/pdf" }, // PDF (%PDF)
39
+ "UEsDB": { type: "document", mimeType: "application/zip" }, // ZIP/XLSX/DOCX (PK)
40
+ };
41
+
42
+ /**
43
+ * Detect media type from base64-encoded data by inspecting magic bytes.
44
+ */
45
+ function detectFromBase64(data) {
46
+ if (!data || data.length < 4) return null;
47
+
48
+ const prefix = data.slice(0, 6);
49
+
50
+ for (const [magic, info] of Object.entries(MAGIC_BYTES)) {
51
+ if (prefix.startsWith(magic) && info) {
52
+ return info;
53
+ }
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * Detect media type from mimeType string.
61
+ */
62
+ function detectFromMimeType(mimeType) {
63
+ if (!mimeType) return null;
64
+
65
+ const major = mimeType.split("/")[0];
66
+ switch (major) {
67
+ case "image": return { type: "image", mimeType };
68
+ case "audio": return { type: "audio", mimeType };
69
+ case "video": return { type: "video", mimeType };
70
+ case "application":
71
+ if (mimeType.includes("pdf")) return { type: "document", mimeType };
72
+ if (mimeType.includes("json")) return { type: "text", mimeType };
73
+ if (mimeType.includes("xml")) return { type: "text", mimeType };
74
+ return { type: "binary", mimeType };
75
+ case "text": return { type: "text", mimeType };
76
+ default: return { type: "binary", mimeType };
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Resolve a single MCP content block into a typed part.
82
+ */
83
+ function resolveBlock(block) {
84
+ // Text block — always text
85
+ if (block.text != null) {
86
+ return {
87
+ type: "text",
88
+ mimeType: block.mimeType || "text/plain",
89
+ text: block.text,
90
+ };
91
+ }
92
+
93
+ // Data block — determine type by mimeType first, then magic bytes
94
+ if (block.data != null) {
95
+ const byMime = detectFromMimeType(block.mimeType);
96
+ const byMagic = detectFromBase64(block.data);
97
+
98
+ const resolved = byMime || byMagic || { type: "binary", mimeType: "application/octet-stream" };
99
+
100
+ return {
101
+ ...resolved,
102
+ data: block.data,
103
+ dataLength: block.data.length,
104
+ };
105
+ }
106
+
107
+ // Resource block
108
+ if (block.resource) {
109
+ return {
110
+ type: "text",
111
+ mimeType: "text/plain",
112
+ text: block.resource.text || JSON.stringify(block.resource),
113
+ };
114
+ }
115
+
116
+ // Unknown block type
117
+ log.warn("unknown-block", { blockType: block.type, keys: Object.keys(block) });
118
+ return {
119
+ type: "text",
120
+ mimeType: "text/plain",
121
+ text: JSON.stringify(block),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Resolve MCP content blocks into a typed result.
127
+ *
128
+ * @param {Array} blocks - MCP content blocks from tool result
129
+ * @returns {{ type: string, mimeType?: string, data?: string, text?: string, parts?: Array }}
130
+ */
131
+ export function resolveContent(blocks) {
132
+ if (!blocks || blocks.length === 0) {
133
+ return { type: "text", text: "OK" };
134
+ }
135
+
136
+ const resolved = blocks.map(resolveBlock);
137
+
138
+ // Single block — return directly
139
+ if (resolved.length === 1) {
140
+ log.debug("resolved-single", { type: resolved[0].type, mimeType: resolved[0].mimeType });
141
+ return resolved[0];
142
+ }
143
+
144
+ // Multiple blocks — check if mixed types
145
+ const types = new Set(resolved.map(r => r.type));
146
+
147
+ // All same type — merge
148
+ if (types.size === 1) {
149
+ const type = resolved[0].type;
150
+
151
+ if (type === "text") {
152
+ // Merge all text blocks
153
+ return {
154
+ type: "text",
155
+ mimeType: "text/plain",
156
+ text: resolved.map(r => r.text).filter(Boolean).join("\n"),
157
+ };
158
+ }
159
+
160
+ // Multiple images/audio of same type — return as mixed
161
+ log.debug("resolved-multi-same", { type, count: resolved.length });
162
+ return { type: "mixed", parts: resolved };
163
+ }
164
+
165
+ // Mixed types — common case: image + text (screenshot + OCR)
166
+ const mediaParts = resolved.filter(r => ["image", "audio", "video"].includes(r.type));
167
+ const textParts = resolved.filter(r => r.type === "text");
168
+
169
+ // Single media + text = media with text metadata
170
+ if (mediaParts.length === 1 && textParts.length >= 1) {
171
+ const media = mediaParts[0];
172
+ media.text = textParts.map(r => r.text).filter(Boolean).join("\n");
173
+ log.debug("resolved-media-with-text", { type: media.type, mimeType: media.mimeType, textLength: media.text.length });
174
+ return media;
175
+ }
176
+
177
+ // Everything else — mixed
178
+ log.debug("resolved-mixed", { types: [...types], count: resolved.length });
179
+ return { type: "mixed", parts: resolved };
180
+ }