/** * chat-context.ts — Context window management for the chat route. * * Extracted from chat.ts to keep files under 800 LOC. * Pure functions with no server state dependencies. */ /** Maximum number of conversation messages passed to the agent loop per turn. */ export const MAX_CONTEXT_MESSAGES = 50; /** * Apply a sliding window to conversation history. * Returns at most MAX_CONTEXT_MESSAGES messages. * If the full history exceeds the limit, a system message is prepended * informing the agent that earlier context was truncated. */ export function applyContextWindow( fullHistory: Array<{ role: string; content: string }>, maxMessages: number = MAX_CONTEXT_MESSAGES, ): Array<{ role: string; content: string }> { if (fullHistory.length <= maxMessages) { return fullHistory; } // W3.5: Summarize dropped messages instead of just noting their count const droppedMessages = fullHistory.slice(0, fullHistory.length - maxMessages); const truncatedCount = droppedMessages.length; const summary = summarizeDroppedContext(droppedMessages); const truncationNotice: { role: string; content: string } = { role: 'system', content: `[Context summary — ${truncatedCount} earlier messages compressed]\n${summary}`, }; return [truncationNotice, ...fullHistory.slice(-maxMessages)]; } /** W3.5: Extract key decisions, topics, and requests from dropped messages */ export function summarizeDroppedContext(messages: Array<{ role: string; content: string }>): string { const decisions: string[] = []; const topics: Set = new Set(); const userRequests: string[] = []; for (const msg of messages) { const text = msg.content; if (!text || text.length < 10) continue; // Extract decisions const decisionPatterns = [/\bdecid/i, /\bagreed\b/i, /\bchose\b/i, /\bselected\b/i, /\bwent with\b/i, /\bfinal call\b/i]; if (decisionPatterns.some(p => p.test(text))) { const firstSentence = text.split(/[.!?\n]/)[0]?.trim(); if (firstSentence && firstSentence.length > 10 && firstSentence.length < 200) { decisions.push(firstSentence); } } // Extract user request summaries (first line of user messages) if (msg.role === 'user') { const firstLine = text.split('\n')[0]?.trim(); if (firstLine && firstLine.length > 15 && firstLine.length < 150) { userRequests.push(firstLine); } } } const lines: string[] = []; if (decisions.length > 0) { lines.push('Decisions made: ' + decisions.slice(0, 5).join(' | ')); } if (userRequests.length > 0) { // Show first and last few requests to convey conversation arc const shown = userRequests.length <= 4 ? userRequests : [...userRequests.slice(0, 2), '...', ...userRequests.slice(-2)]; lines.push('Topics discussed: ' + shown.join(' → ')); } if (lines.length === 0) { lines.push(`${messages.length} messages covering earlier conversation context.`); } return lines.join('\n'); } /** * Build the skill-awareness section of the system prompt. * Exported for testability. */ export function buildSkillPromptSection(skills: Array<{ name: string; content: string }>): string { if (skills.length === 0) return ''; let section = '\n\n# Active Skills\n\n'; section += 'You have specialized skills loaded. The registry below is for routing only; it does not contain the complete workflows.\n\n'; section += '## Skill-Aware Routing\n'; section += 'Before responding to any substantial user request:\n'; section += '1. Check if any loaded skill matches the request (catch-up → catch-up skill, draft → draft-memo skill, etc.)\n'; section += '2. If a skill matches, follow its structured workflow — it produces better output than ad-hoc responses\n'; section += '3. If no skill matches but one could help, mention it: "I have a [skill-name] skill that could help with this"\n'; section += 'Before applying a matching skill, call read_skill with its exact name to load the complete workflow.\n'; section += '4. Use suggest_skill to find relevant skills when unsure\n\n'; section += `## Loaded Skills (${skills.length})\n`; for (const skill of skills) { section += `\n### ${skill.name}\n${summarizeSkillForRouting(skill.content)}\n`; } return section; } function summarizeSkillForRouting(content: string): string { const lines = content.split(/\r?\n/); const summary: string[] = []; let inFrontmatter = lines[0]?.trim() === '---'; for (let index = inFrontmatter ? 1 : 0; index < lines.length; index += 1) { const line = lines[index]?.trim() ?? ''; if (inFrontmatter) { if (line === '---') inFrontmatter = false; continue; } if (!line) { if (summary.length > 0) break; continue; } if (/^#{1,6}\s/.test(line) || /^(?:[-*+]\s|\d+[.)]\s)/.test(line)) { if (summary.length > 0) break; continue; } summary.push(line); if (summary.join(' ').length >= 240) break; } const text = summary.join(' ').trim(); return text ? `${text.slice(0, 240)}${text.length > 240 ? '...' : ''}` : 'Open with read_skill to load the complete workflow.'; }