|
14 | 14 |
|
15 | 15 | import { existsSync, readFileSync, writeFileSync } from 'fs'; |
16 | 16 | import { join } from 'path'; |
| 17 | +import { createHash } from 'crypto'; |
17 | 18 | import { ProjectContext } from './project'; |
18 | 19 | import { config, getApiKey, Message, resolveBaseUrl } from '../config/index'; |
19 | 20 | import { loadProjectIntelligence, generateContextFromIntelligence } from './projectIntelligence'; |
@@ -201,6 +202,83 @@ export function formatChatHistoryForAgent( |
201 | 202 | return `\n\n## Prior Conversation Context\nThe following is the recent chat history from this session. Use it as background context to understand the user's intent, but focus on completing the current task.\n\n${lines}`; |
202 | 203 | } |
203 | 204 |
|
| 205 | +// Same noise filter formatChatHistoryForAgent uses — kept in sync so the two |
| 206 | +// functions agree on which messages are "real" conversation. |
| 207 | +function filterAgentHistory<T extends { role: string; content: string }>(history: T[]): T[] { |
| 208 | + return history.filter(m => { |
| 209 | + const content = m.content.trimStart(); |
| 210 | + if (content.startsWith('[AGENT]') || content.startsWith('[DRY RUN]')) return false; |
| 211 | + if (content.startsWith('Agent completed') || content.startsWith('Agent failed') || content.startsWith('Agent stopped')) return false; |
| 212 | + return true; |
| 213 | + }); |
| 214 | +} |
| 215 | + |
| 216 | +// Cache summaries by a hash of the dropped messages, so re-running the agent in |
| 217 | +// the same session (same overflow) doesn't re-summarize on every task. |
| 218 | +const earlierSummaryCache = new Map<string, string>(); |
| 219 | + |
| 220 | +/** |
| 221 | + * Summarize the OVERFLOW that `formatChatHistoryForAgent` drops. When prior |
| 222 | + * history exceeds `maxChars`, that function keeps only the most recent messages |
| 223 | + * and silently discards the older ones — losing early decisions/constraints on |
| 224 | + * long sessions. This condenses those dropped messages into a short recap that |
| 225 | + * the caller prepends *before* the recent verbatim history. |
| 226 | + * |
| 227 | + * Returns '' when: opted out (`autoSummarizeHistory === false`), nothing |
| 228 | + * overflows, or the summarization call fails (graceful fallback — the recent |
| 229 | + * history still goes in, we just don't add a recap). |
| 230 | + */ |
| 231 | +export async function summarizeEarlierHistory( |
| 232 | + history?: Array<{ role: 'user' | 'assistant'; content: string }>, |
| 233 | + maxChars: number = 16000, |
| 234 | +): Promise<string> { |
| 235 | + if (config.get('autoSummarizeHistory') === false) return ''; |
| 236 | + if (!history || history.length === 0) return ''; |
| 237 | + |
| 238 | + const filtered = filterAgentHistory(history); |
| 239 | + if (filtered.length === 0) return ''; |
| 240 | + |
| 241 | + // Mirror formatChatHistoryForAgent's newest→oldest budget walk to find which |
| 242 | + // messages it KEEPS; everything older than the oldest kept message is dropped. |
| 243 | + let totalChars = 0; |
| 244 | + let firstKept = filtered.length; |
| 245 | + for (let i = filtered.length - 1; i >= 0; i--) { |
| 246 | + const entry = `${filtered[i].role === 'user' ? 'User' : 'Assistant'}: ${filtered[i].content}`; |
| 247 | + if (totalChars + entry.length > maxChars && firstKept < filtered.length) break; |
| 248 | + if (entry.length > maxChars) { firstKept = i; break; } |
| 249 | + firstKept = i; |
| 250 | + totalChars += entry.length; |
| 251 | + } |
| 252 | + const dropped = filtered.slice(0, firstKept); |
| 253 | + if (dropped.length === 0) return ''; |
| 254 | + |
| 255 | + const key = createHash('sha256') |
| 256 | + .update(dropped.map(m => `${m.role}:${m.content}`).join(' |