(messages: Message[], opts: DedupOptions = {})
| 61 | * Pure: returns a new array; the input is unchanged. |
| 62 | */ |
| 63 | export function dedupHistory(messages: Message[], opts: DedupOptions = {}): DedupResult { |
| 64 | const minBytes = opts.minBytes ?? 200; |
| 65 | const keepRecent = opts.keepRecent ?? 4; |
| 66 | const safeTools = opts.extraSafeTools |
| 67 | ? new Set([...SAFE_FOR_DEDUP, ...opts.extraSafeTools]) |
| 68 | : SAFE_FOR_DEDUP; |
| 69 | |
| 70 | // First pass: index every tool result by (toolName + contentHash) → first turn index. |
| 71 | // We need toolName, which is stored on the tool message as `name` (we set this in the |
| 72 | // agent loop when persisting). Without it, dedup is skipped for that message. |
| 73 | type Indexed = { index: number; toolName: string; hash: string }; |
| 74 | const firstSeen = new Map<string, Indexed>(); // key = toolName + ':' + hash |
| 75 | |
| 76 | // Locate tool-message indices, in order |
| 77 | const toolIdxs: number[] = []; |
| 78 | for (let i = 0; i < messages.length; i++) { |
| 79 | if (messages[i]!.role === 'tool') toolIdxs.push(i); |
| 80 | } |
| 81 | // Mark which tool messages are within the "keep recent" tail |
| 82 | const tailStart = Math.max(0, toolIdxs.length - keepRecent); |
| 83 | |
| 84 | const out = messages.slice(); // shallow copy; we'll replace specific entries |
| 85 | let replaced = 0; |
| 86 | let bytesSaved = 0; |
| 87 | |
| 88 | for (let pos = 0; pos < toolIdxs.length; pos++) { |
| 89 | const i = toolIdxs[pos]!; |
| 90 | const msg = out[i]!; |
| 91 | const content = msg.content ?? ''; |
| 92 | const toolName = (msg as any).name as string | undefined; |
| 93 | if (!toolName || !safeTools.has(toolName)) continue; |
| 94 | if (content.length < minBytes) continue; |
| 95 | if (pos >= tailStart) continue; // keep recent results full |
| 96 | |
| 97 | const hash = hashContent(content); |
| 98 | const key = toolName + ':' + hash; |
| 99 | const seen = firstSeen.get(key); |
| 100 | if (seen) { |
| 101 | // Build a pointer line that's also useful to the model — same toolName + hash |
| 102 | // means the model knows nothing changed without us saying it changed/didn't. |
| 103 | const pointer = |
| 104 | `[DEDUP] Same content as turn earlier in this session ` + |
| 105 | `(tool=${toolName}, sha=${hash.slice(0, 10)}, ${content.length}B suppressed). ` + |
| 106 | `Reuse what you already saw; call the tool again if you suspect the file changed.`; |
| 107 | out[i] = { ...msg, content: pointer }; |
| 108 | replaced += 1; |
| 109 | bytesSaved += content.length - pointer.length; |
| 110 | } else { |
| 111 | firstSeen.set(key, { index: i, toolName, hash }); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | return { messages: out, replaced, bytesSaved }; |
| 116 | } |
| 117 | |
| 118 | function hashContent(s: string): string { |
| 119 | return crypto.createHash('sha256').update(s).digest('hex'); |
no test coverage detected