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.
- package/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- 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
|
+
}
|