wave-agent-sdk 0.13.6 → 0.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 (54) hide show
  1. package/dist/agent.d.ts.map +1 -1
  2. package/dist/agent.js +4 -2
  3. package/dist/managers/aiManager.d.ts +3 -0
  4. package/dist/managers/aiManager.d.ts.map +1 -1
  5. package/dist/managers/aiManager.js +93 -8
  6. package/dist/managers/messageManager.d.ts +15 -0
  7. package/dist/managers/messageManager.d.ts.map +1 -1
  8. package/dist/managers/messageManager.js +52 -2
  9. package/dist/managers/permissionManager.d.ts +4 -0
  10. package/dist/managers/permissionManager.d.ts.map +1 -1
  11. package/dist/managers/permissionManager.js +6 -0
  12. package/dist/managers/subagentManager.d.ts.map +1 -1
  13. package/dist/managers/subagentManager.js +23 -17
  14. package/dist/prompts/index.d.ts +2 -1
  15. package/dist/prompts/index.d.ts.map +1 -1
  16. package/dist/prompts/index.js +50 -25
  17. package/dist/services/aiService.d.ts.map +1 -1
  18. package/dist/services/aiService.js +11 -1
  19. package/dist/tools/agentTool.d.ts.map +1 -1
  20. package/dist/tools/agentTool.js +14 -2
  21. package/dist/tools/bashTool.d.ts.map +1 -1
  22. package/dist/tools/bashTool.js +27 -5
  23. package/dist/tools/types.d.ts +1 -0
  24. package/dist/tools/types.d.ts.map +1 -1
  25. package/dist/tools/webFetchTool.d.ts.map +1 -1
  26. package/dist/tools/webFetchTool.js +202 -78
  27. package/dist/types/messaging.d.ts +1 -0
  28. package/dist/types/messaging.d.ts.map +1 -1
  29. package/dist/utils/convertMessagesForAPI.js +1 -1
  30. package/dist/utils/groupMessagesByApiRound.d.ts +24 -0
  31. package/dist/utils/groupMessagesByApiRound.d.ts.map +1 -0
  32. package/dist/utils/groupMessagesByApiRound.js +97 -0
  33. package/dist/utils/messageOperations.d.ts +1 -0
  34. package/dist/utils/messageOperations.d.ts.map +1 -1
  35. package/dist/utils/microcompact.d.ts +7 -0
  36. package/dist/utils/microcompact.d.ts.map +1 -0
  37. package/dist/utils/microcompact.js +78 -0
  38. package/package.json +2 -1
  39. package/src/agent.ts +4 -2
  40. package/src/managers/aiManager.ts +117 -15
  41. package/src/managers/messageManager.ts +64 -2
  42. package/src/managers/permissionManager.ts +7 -0
  43. package/src/managers/subagentManager.ts +28 -24
  44. package/src/prompts/index.ts +51 -25
  45. package/src/services/aiService.ts +14 -1
  46. package/src/tools/agentTool.ts +14 -2
  47. package/src/tools/bashTool.ts +27 -5
  48. package/src/tools/types.ts +1 -0
  49. package/src/tools/webFetchTool.ts +276 -86
  50. package/src/types/messaging.ts +1 -0
  51. package/src/utils/convertMessagesForAPI.ts +1 -1
  52. package/src/utils/groupMessagesByApiRound.ts +120 -0
  53. package/src/utils/messageOperations.ts +1 -0
  54. package/src/utils/microcompact.ts +101 -0
@@ -1,35 +1,105 @@
1
1
  import TurndownService from "turndown";
2
+ import { LRUCache } from "lru-cache";
2
3
  import { WEB_FETCH_TOOL_NAME } from "../constants/tools.js";
3
4
  import type { ToolPlugin, ToolResult, ToolContext } from "./types.js";
4
5
  import { logger } from "../utils/globalLogger.js";
5
6
 
7
+ // --- Security Limits ---
8
+ const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024; // 10MB
9
+ const FETCH_TIMEOUT_MS = 60_000; // 60s
10
+ const MAX_REDIRECTS = 10;
11
+ const MAX_MARKDOWN_LENGTH = 100_000;
12
+ const USER_AGENT = "Wave-User (+https://github.com/netease-lcap/wave-agent)";
13
+
14
+ // --- Cache (LRU with 15min TTL, 50MB max) ---
6
15
  const CACHE_TTL = 15 * 60 * 1000; // 15 minutes
7
- const cache = new Map<string, { content: string; timestamp: number }>();
16
+ const CACHE_MAX_BYTES = 50 * 1024 * 1024; // 50MB
17
+
18
+ interface CacheEntry {
19
+ bytes: number;
20
+ code: number;
21
+ codeText: string;
22
+ content: string;
23
+ contentType: string;
24
+ }
25
+
26
+ const cache = new LRUCache<string, CacheEntry>({
27
+ ttl: CACHE_TTL,
28
+ maxSize: CACHE_MAX_BYTES,
29
+ sizeCalculation: (entry) => entry.bytes,
30
+ });
31
+
32
+ // --- Helpers ---
8
33
 
9
- function getFromCache(url: string): string | null {
10
- const cached = cache.get(url);
11
- if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
12
- return cached.content;
34
+ function formatSize(bytes: number): string {
35
+ if (bytes < 1024) return `${bytes}B`;
36
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
37
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
38
+ }
39
+
40
+ type URLValidationResult = { valid: true } | { valid: false; error: string };
41
+
42
+ function validateURL(url: string): URLValidationResult {
43
+ if (url.length > 2000) {
44
+ return {
45
+ valid: false,
46
+ error: "URL exceeds maximum length of 2000 characters",
47
+ };
13
48
  }
14
- if (cached) {
15
- cache.delete(url);
49
+
50
+ let parsed: URL;
51
+ try {
52
+ parsed = new URL(url);
53
+ } catch (error) {
54
+ return {
55
+ valid: false,
56
+ error: `Invalid URL: ${error instanceof Error ? error.message : String(error)}`,
57
+ };
16
58
  }
17
- return null;
18
- }
19
59
 
20
- function setToCache(url: string, content: string) {
21
- cache.set(url, { content, timestamp: Date.now() });
60
+ if (parsed.username || parsed.password) {
61
+ return { valid: false, error: "URL must not contain username or password" };
62
+ }
63
+
64
+ const hostParts = parsed.hostname.split(".");
65
+ if (hostParts.length < 2) {
66
+ return {
67
+ valid: false,
68
+ error: "URL hostname must have at least two parts (e.g., example.com)",
69
+ };
70
+ }
71
+
72
+ return { valid: true };
22
73
  }
23
74
 
24
- // Clean up cache every 15 minutes
25
- setInterval(() => {
26
- const now = Date.now();
27
- for (const [url, cached] of cache.entries()) {
28
- if (now - cached.timestamp >= CACHE_TTL) {
29
- cache.delete(url);
30
- }
75
+ function isPermittedRedirect(
76
+ originalUrl: string,
77
+ redirectUrl: string,
78
+ ): boolean {
79
+ try {
80
+ const original = new URL(originalUrl);
81
+ const redirect = new URL(redirectUrl);
82
+ const origHost = original.host;
83
+ const redirHost = redirect.host;
84
+
85
+ // Same host
86
+ if (origHost === redirHost) return true;
87
+
88
+ // www. variation (e.g., example.com <-> www.example.com)
89
+ const bareOrig = origHost.replace(/^www\./, "");
90
+ const bareRedir = redirHost.replace(/^www\./, "");
91
+ if (bareOrig === bareRedir) return true;
92
+
93
+ return false;
94
+ } catch {
95
+ return false;
31
96
  }
32
- }, CACHE_TTL).unref();
97
+ }
98
+
99
+ const GITHUB_URL_ERROR =
100
+ "For GitHub URLs, please use the 'gh' CLI via the Bash tool instead (e.g., 'gh pr view', 'gh issue view', 'gh api').";
101
+
102
+ // --- Tool ---
33
103
 
34
104
  export const webFetchTool: ToolPlugin = {
35
105
  name: WEB_FETCH_TOOL_NAME,
@@ -50,10 +120,11 @@ Usage notes:
50
120
  - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions.
51
121
  - The URL must be a fully-formed valid URL
52
122
  - HTTP URLs will be automatically upgraded to HTTPS
123
+ - Content exceeding ${formatSize(MAX_MARKDOWN_LENGTH)} will be truncated
53
124
  - The prompt should describe what information you want to extract from the page
54
125
  - This tool is read-only and does not modify any files
55
126
  - Results may be summarized if the content is very large
56
- - Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL
127
+ - Includes an LRU cache with a 15-minute TTL for faster responses when repeatedly accessing the same URL
57
128
  - When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content.
58
129
  - For GitHub URLs, prefer using the gh CLI via Bash instead (e.g., gh pr view, gh issue view, gh api).`,
59
130
  parameters: {
@@ -94,92 +165,110 @@ Usage notes:
94
165
  url = "https://" + url.substring(7);
95
166
  }
96
167
 
168
+ // Validate URL
169
+ const validation = validateURL(url);
170
+ if (!validation.valid) {
171
+ return {
172
+ success: false,
173
+ content: "",
174
+ error: validation.error,
175
+ };
176
+ }
177
+
97
178
  // Check for GitHub URLs
98
179
  if (url.includes("github.com")) {
99
180
  return {
100
181
  success: false,
101
182
  content: "",
102
- error:
103
- "For GitHub URLs, please use the 'gh' CLI via the Bash tool instead (e.g., 'gh pr view', 'gh issue view', 'gh api').",
183
+ error: GITHUB_URL_ERROR,
104
184
  };
105
185
  }
106
186
 
107
187
  try {
108
- let markdown = getFromCache(url);
109
-
110
- if (!markdown) {
111
- const response = await fetch(url, {
112
- redirect: "manual",
113
- headers: {
114
- "User-Agent":
115
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
116
- },
117
- });
118
-
119
- if (response.status >= 300 && response.status < 400) {
120
- const location = response.headers.get("location");
121
- if (location) {
122
- const redirectUrl = new URL(location, url).toString();
123
- const originalHost = new URL(url).host;
124
- const redirectHost = new URL(redirectUrl).host;
125
-
126
- if (originalHost !== redirectHost) {
127
- return {
128
- success: true,
129
- content: `REDIRECT_TO: ${redirectUrl}\nThe URL redirected to a different host. Please make a new WebFetch request with this redirect URL if you wish to continue.`,
130
- };
131
- }
132
- // If same host, we could follow it, but the requirement says "When a URL redirects to a different host, the tool will inform you".
133
- // For simplicity and following the requirement strictly, let's just return the redirect for different hosts.
134
- // If it's the same host, we can try to fetch again or just return it too.
135
- return {
136
- success: true,
137
- content: `REDIRECT_TO: ${redirectUrl}\nThe URL redirected. Please make a new WebFetch request with this redirect URL.`,
138
- };
139
- }
140
- }
141
-
142
- if (!response.ok) {
143
- return {
144
- success: false,
145
- content: "",
146
- error: `Failed to fetch URL: ${response.status} ${response.statusText}`,
147
- };
148
- }
149
-
150
- const html = await response.text();
151
- const turndownService = new TurndownService();
152
- markdown = turndownService.turndown(html);
153
- setToCache(url, markdown);
188
+ const cached = cache.get(url);
189
+ if (cached) {
190
+ const markdown = cached.content;
191
+ return processWithAI(
192
+ url,
193
+ prompt,
194
+ markdown,
195
+ cached.code,
196
+ cached.codeText,
197
+ context,
198
+ );
199
+ }
200
+
201
+ // Fetch with redirect following
202
+ const result = await fetchWithRedirects(url, context.abortSignal);
203
+
204
+ if (result.kind === "redirect") {
205
+ return {
206
+ success: true,
207
+ content: `REDIRECT_TO: ${result.redirectUrl}\nThe URL redirected to a different host. Please make a new WebFetch request with this redirect URL if you wish to continue.`,
208
+ };
209
+ }
210
+
211
+ if (result.kind === "error") {
212
+ return {
213
+ success: false,
214
+ content: "",
215
+ error: result.error,
216
+ };
217
+ }
218
+
219
+ const { response, finalUrl } = result;
220
+
221
+ if (!response.ok) {
222
+ return {
223
+ success: false,
224
+ content: "",
225
+ error: `Failed to fetch URL: ${response.status} ${response.statusText}`,
226
+ };
154
227
  }
155
228
 
156
- // Process with AI
157
- if (!context.aiManager || !context.aiService) {
229
+ const contentLengthHeader = response.headers.get("content-length");
230
+ const contentLength = contentLengthHeader
231
+ ? parseInt(contentLengthHeader, 10)
232
+ : null;
233
+ if (contentLength !== null && contentLength > MAX_HTTP_CONTENT_LENGTH) {
158
234
  return {
159
235
  success: false,
160
- content: markdown,
161
- error:
162
- "AI Manager or AI Service not available for processing content",
236
+ content: "",
237
+ error: `Content too large: ${formatSize(contentLength)} exceeds limit of ${formatSize(MAX_HTTP_CONTENT_LENGTH)}`,
163
238
  };
164
239
  }
165
240
 
166
- const modelConfig = context.aiManager.getModelConfig();
167
- const fastModel = modelConfig.fastModel;
241
+ const html = await response.text();
242
+ const turndownService = new TurndownService();
243
+ let markdown = turndownService.turndown(html);
244
+
245
+ const markdownBytes = new TextEncoder().encode(markdown).length;
246
+
247
+ // Truncate if too large
248
+ if (markdown.length > MAX_MARKDOWN_LENGTH) {
249
+ markdown =
250
+ markdown.substring(0, MAX_MARKDOWN_LENGTH) +
251
+ `[Content truncated at ${MAX_MARKDOWN_LENGTH} characters due to length limit.]`;
252
+ }
168
253
 
169
- const aiResponse = await context.aiService.processWebContent({
170
- gatewayConfig: context.aiManager.getGatewayConfig(),
171
- modelConfig: modelConfig,
254
+ // Store in LRU cache
255
+ cache.set(finalUrl, {
256
+ bytes: markdownBytes,
257
+ code: response.status,
258
+ codeText: response.statusText,
172
259
  content: markdown,
173
- prompt: prompt,
174
- model: fastModel,
175
- abortSignal: context.abortSignal,
260
+ contentType: response.headers.get("content-type") || "",
176
261
  });
177
262
 
178
- return {
179
- success: true,
180
- content: aiResponse.content || "",
181
- shortResult: `Processed content from ${url}`,
182
- };
263
+ return processWithAI(
264
+ finalUrl,
265
+ prompt,
266
+ markdown,
267
+ response.status,
268
+ response.statusText,
269
+ context,
270
+ markdownBytes,
271
+ );
183
272
  } catch (error) {
184
273
  logger.error(`WebFetch failed for ${url}:`, error);
185
274
  return {
@@ -193,3 +282,104 @@ Usage notes:
193
282
  return `Fetch ${params.url}`;
194
283
  },
195
284
  };
285
+
286
+ // --- Fetch with redirect following ---
287
+
288
+ async function fetchWithRedirects(
289
+ initialUrl: string,
290
+ abortSignal?: AbortSignal,
291
+ redirectCount = 0,
292
+ ): Promise<
293
+ | { kind: "response"; response: Response; finalUrl: string }
294
+ | { kind: "redirect"; redirectUrl: string }
295
+ | { kind: "error"; error: string }
296
+ > {
297
+ if (redirectCount >= MAX_REDIRECTS) {
298
+ return {
299
+ kind: "error",
300
+ error: `Too many redirects (max ${MAX_REDIRECTS})`,
301
+ };
302
+ }
303
+
304
+ const controller = new AbortController();
305
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
306
+
307
+ // Forward the context's abort signal if provided
308
+ if (abortSignal) {
309
+ abortSignal.addEventListener("abort", () => controller.abort(), {
310
+ once: true,
311
+ });
312
+ }
313
+
314
+ let response: Response;
315
+ try {
316
+ response = await fetch(initialUrl, {
317
+ redirect: "manual",
318
+ signal: controller.signal,
319
+ headers: {
320
+ "User-Agent": USER_AGENT,
321
+ Accept: "text/markdown, text/html, */*",
322
+ },
323
+ });
324
+ } finally {
325
+ clearTimeout(timeoutId);
326
+ }
327
+
328
+ if (response.status >= 300 && response.status < 400) {
329
+ const location = response.headers.get("location");
330
+ if (location) {
331
+ const redirectUrl = new URL(location, initialUrl).toString();
332
+
333
+ if (!isPermittedRedirect(initialUrl, redirectUrl)) {
334
+ return { kind: "redirect", redirectUrl };
335
+ }
336
+
337
+ // Follow permitted redirect recursively
338
+ return fetchWithRedirects(redirectUrl, abortSignal, redirectCount + 1);
339
+ }
340
+ }
341
+
342
+ return { kind: "response", response, finalUrl: initialUrl };
343
+ }
344
+
345
+ // --- AI Processing ---
346
+
347
+ async function processWithAI(
348
+ url: string,
349
+ prompt: string,
350
+ markdown: string,
351
+ statusCode: number,
352
+ statusText: string,
353
+ context: ToolContext,
354
+ contentSize?: number,
355
+ ): Promise<ToolResult> {
356
+ if (!context.aiManager || !context.aiService) {
357
+ return {
358
+ success: false,
359
+ content: markdown,
360
+ error: "AI Manager or AI Service not available for processing content",
361
+ };
362
+ }
363
+
364
+ const modelConfig = context.aiManager.getModelConfig();
365
+ const fastModel = modelConfig.fastModel;
366
+
367
+ const aiResponse = await context.aiService.processWebContent({
368
+ gatewayConfig: context.aiManager.getGatewayConfig(),
369
+ modelConfig: modelConfig,
370
+ content: markdown,
371
+ prompt: prompt,
372
+ model: fastModel,
373
+ abortSignal: context.abortSignal,
374
+ });
375
+
376
+ const sizeStr =
377
+ contentSize !== undefined ? formatSize(contentSize) : "unknown size";
378
+ const statusStr = `${statusCode} ${statusText}`.trim();
379
+
380
+ return {
381
+ success: true,
382
+ content: aiResponse.content || "",
383
+ shortResult: `Received ${sizeStr} (${statusStr}) from ${url}`,
384
+ };
385
+ }
@@ -69,6 +69,7 @@ export interface ToolBlock {
69
69
  compactParams?: string; // Compact parameter display
70
70
  parametersChunk?: string; // Incremental parameter updates for streaming
71
71
  isManuallyBackgrounded?: boolean; // Whether the tool was manually backgrounded by the user
72
+ timestamp?: number; // Unix ms, set when tool result is finalized (stage="end")
72
73
  }
73
74
 
74
75
  export interface ImageBlock {
@@ -58,7 +58,7 @@ export function convertMessagesForAPI(
58
58
  );
59
59
  if (compressBlock && compressBlock.type === "compress") {
60
60
  recentMessages.unshift({
61
- role: "assistant",
61
+ role: "user",
62
62
  content: compressBlock.content,
63
63
  });
64
64
  }
@@ -0,0 +1,120 @@
1
+ import type { Message } from "../types/index.js";
2
+
3
+ export interface ApiRound {
4
+ messages: Message[];
5
+ estimatedTokens: number;
6
+ }
7
+
8
+ /**
9
+ * Groups messages into "API rounds" — each round corresponds to one API
10
+ * call-response cycle. This is critical because in agentic sessions with a
11
+ * single user prompt, Wave creates a new Message per API round (each recursive
12
+ * sendAIMessage call creates a new assistant message).
13
+ *
14
+ * Boundaries:
15
+ * - A new `role: "user"` message starts a new round.
16
+ * - A new `role: "assistant"` message with a different `id` starts a new round.
17
+ * - A message with a `compress` block is pushed as its own round and starts a
18
+ * new round after it.
19
+ */
20
+ export function groupMessagesByApiRound(messages: Message[]): ApiRound[] {
21
+ const rounds: ApiRound[] = [];
22
+ let currentRound: Message[] = [];
23
+ let lastAssistantId: string | undefined;
24
+
25
+ for (const msg of messages) {
26
+ let startNewRound = false;
27
+
28
+ if (msg.role === "user") {
29
+ startNewRound = true;
30
+ } else if (msg.role === "assistant") {
31
+ // Compress block is always its own round
32
+ const hasCompress = msg.blocks.some((b) => b.type === "compress");
33
+ if (hasCompress) {
34
+ startNewRound = true;
35
+ } else if (msg.id !== lastAssistantId) {
36
+ // New assistant id starts a new round.
37
+ // Exception: if the current round is [user] (first assistant after a
38
+ // user prompt in a normal conversation), keep them together as one
39
+ // round. But if we already have assistant(s) in this round (agentic
40
+ // tool loop), the new id starts a new round.
41
+ const roundHasOtherAssistant = currentRound.some(
42
+ (m) => m.role === "assistant" && m.id !== msg.id,
43
+ );
44
+ if (roundHasOtherAssistant) {
45
+ startNewRound = true;
46
+ }
47
+ }
48
+ lastAssistantId = msg.id;
49
+ }
50
+
51
+ if (startNewRound && currentRound.length > 0) {
52
+ rounds.push({
53
+ messages: currentRound,
54
+ estimatedTokens: estimateTokens(currentRound),
55
+ });
56
+ currentRound = [];
57
+ }
58
+
59
+ currentRound.push(msg);
60
+
61
+ // After pushing a compress message as its own round, flush immediately
62
+ if (
63
+ msg.role === "assistant" &&
64
+ msg.blocks.some((b) => b.type === "compress")
65
+ ) {
66
+ rounds.push({
67
+ messages: currentRound,
68
+ estimatedTokens: estimateTokens(currentRound),
69
+ });
70
+ currentRound = [];
71
+ }
72
+ }
73
+
74
+ if (currentRound.length > 0) {
75
+ rounds.push({
76
+ messages: currentRound,
77
+ estimatedTokens: estimateTokens(currentRound),
78
+ });
79
+ }
80
+
81
+ return rounds;
82
+ }
83
+
84
+ /**
85
+ * Returns the last `roundCount` complete API rounds as a flat message array.
86
+ * Never splits a tool_use/tool_result pair. If fewer rounds exist, returns all.
87
+ */
88
+ export function getLastApiRounds(
89
+ messages: Message[],
90
+ roundCount: number,
91
+ ): Message[] {
92
+ const rounds = groupMessagesByApiRound(messages);
93
+ const lastRounds = rounds.slice(-roundCount);
94
+ return lastRounds.flatMap((r) => r.messages);
95
+ }
96
+
97
+ /**
98
+ * Roughly estimate token count from character count (~4 chars per token).
99
+ */
100
+ function estimateTokens(messages: Message[]): number {
101
+ let chars = 0;
102
+ for (const msg of messages) {
103
+ for (const block of msg.blocks) {
104
+ if ("content" in block && typeof block.content === "string") {
105
+ chars += block.content.length;
106
+ }
107
+ if (
108
+ block.type === "tool" &&
109
+ block.parameters &&
110
+ typeof block.parameters === "string"
111
+ ) {
112
+ chars += block.parameters.length;
113
+ }
114
+ if (block.type === "tool" && block.result) {
115
+ chars += block.result.length;
116
+ }
117
+ }
118
+ }
119
+ return Math.ceil(chars / 4);
120
+ }
@@ -49,6 +49,7 @@ export interface UpdateToolBlockParams {
49
49
  compactParams?: string;
50
50
  parametersChunk?: string; // Incremental parameter updates for streaming
51
51
  isManuallyBackgrounded?: boolean;
52
+ timestamp?: number;
52
53
  }
53
54
 
54
55
  // Agent specific interfaces (without messages parameter)
@@ -0,0 +1,101 @@
1
+ import type { Message, ToolBlock } from "../types/messaging.js";
2
+
3
+ export interface MicrocompactOptions {
4
+ timeThresholdMS: number;
5
+ recentResultsToKeep: number;
6
+ }
7
+
8
+ const CLEARED_RESULT = "[Old tool result content cleared]";
9
+
10
+ export function microcompactMessages(
11
+ messages: Message[],
12
+ options: MicrocompactOptions,
13
+ ): Message[] {
14
+ const { timeThresholdMS, recentResultsToKeep } = options;
15
+
16
+ // 1. Find the latest tool block timestamp across all assistant messages
17
+ let lastAssistantTime = 0;
18
+ for (const msg of messages) {
19
+ if (msg.role === "assistant") {
20
+ for (const block of msg.blocks) {
21
+ if (block.type === "tool" && block.stage === "end" && block.timestamp) {
22
+ if (block.timestamp > lastAssistantTime) {
23
+ lastAssistantTime = block.timestamp;
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ // 2. If no prior assistant messages with completed tools, return unchanged
31
+ if (lastAssistantTime === 0) {
32
+ return messages;
33
+ }
34
+
35
+ // 3. If within threshold, return unchanged
36
+ if (Date.now() - lastAssistantTime < timeThresholdMS) {
37
+ return messages;
38
+ }
39
+
40
+ // 4. Collect all completed tool results with timestamps, sorted newest-first
41
+ type ToolRef = { msgIndex: number; blockIndex: number; timestamp: number };
42
+
43
+ const toolRefs: ToolRef[] = [];
44
+ for (let mi = 0; mi < messages.length; mi++) {
45
+ const msg = messages[mi];
46
+ if (msg.role === "assistant") {
47
+ for (let bi = 0; bi < msg.blocks.length; bi++) {
48
+ const block = msg.blocks[bi];
49
+ if (block.type === "tool" && block.stage === "end" && block.timestamp) {
50
+ toolRefs.push({
51
+ msgIndex: mi,
52
+ blockIndex: bi,
53
+ timestamp: block.timestamp,
54
+ });
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ toolRefs.sort((a, b) => b.timestamp - a.timestamp);
61
+
62
+ // 5. Mark the top N as "keep"
63
+ const keepSet = new Set<string>();
64
+ for (let i = 0; i < Math.min(recentResultsToKeep, toolRefs.length); i++) {
65
+ const ref = toolRefs[i];
66
+ keepSet.add(`${ref.msgIndex}:${ref.blockIndex}`);
67
+ }
68
+
69
+ // 6. Deep-copy messages and clear result + shortResult on non-kept blocks
70
+ const result: Message[] = messages.map((msg) => ({
71
+ ...msg,
72
+ blocks: msg.blocks.map((block) => {
73
+ if (block.type === "tool" && block.stage === "end" && block.timestamp) {
74
+ return { ...block } as ToolBlock;
75
+ }
76
+ return block;
77
+ }),
78
+ }));
79
+
80
+ // Clear non-kept tool blocks
81
+ for (const ref of toolRefs) {
82
+ const key = `${ref.msgIndex}:${ref.blockIndex}`;
83
+ if (!keepSet.has(key)) {
84
+ result[ref.msgIndex] = {
85
+ ...result[ref.msgIndex],
86
+ blocks: result[ref.msgIndex].blocks.map((b, idx) => {
87
+ if (idx === ref.blockIndex && b.type === "tool") {
88
+ return {
89
+ ...b,
90
+ result: CLEARED_RESULT,
91
+ shortResult: undefined,
92
+ } as ToolBlock;
93
+ }
94
+ return b;
95
+ }),
96
+ };
97
+ }
98
+ }
99
+
100
+ return result;
101
+ }