From 3f627cc1ade8d514c785e366b26e35bbf26adfd5 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 13:18:42 -0800 Subject: [PATCH] feat(session): add optional end-of-session summarization --- config/default.yaml | 12 ++++ src/config/schema.test.ts | 46 ++++++++++++++++ src/config/schema.ts | 9 +++ src/gateway/session-bridge.test.ts | 51 +++++++++++++++++ src/gateway/session-bridge.ts | 24 ++++++++ src/session/endSummary.test.ts | 76 ++++++++++++++++++++++++++ src/session/endSummary.ts | 88 ++++++++++++++++++++++++++++++ src/session/index.ts | 1 + 8 files changed, 307 insertions(+) create mode 100644 src/session/endSummary.test.ts create mode 100644 src/session/endSummary.ts diff --git a/config/default.yaml b/config/default.yaml index a2e63c5..6876a8e 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -302,6 +302,18 @@ hooks: # prefix: flynn # secure: true +# ── Session Lifecycle ─────────────────────────────────────────────── +# sessions: +# ttl: "30d" +# end_summary: +# enabled: false +# tier: fast +# max_messages: 50 +# max_input_chars: 20000 +# max_tokens: 512 +# write_to_memory: true +# memory_namespace: session/summaries + # ── Audio ──────────────────────────────────────────────────────────── # Configure a Whisper-compatible endpoint for audio transcription. # Models that support native audio input (Gemini, OpenAI, GitHub) will diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index c354b5b..03461b6 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -244,6 +244,52 @@ describe('configSchema — backup', () => { }); }); +describe('configSchema — sessions', () => { + const minimalConfig = { + telegram: { bot_token: 'test', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + }; + + it('defaults end_summary settings', () => { + const result = configSchema.parse(minimalConfig); + expect(result.sessions.ttl).toBe('30d'); + expect(result.sessions.end_summary.enabled).toBe(false); + expect(result.sessions.end_summary.tier).toBe('fast'); + expect(result.sessions.end_summary.max_messages).toBe(50); + expect(result.sessions.end_summary.max_input_chars).toBe(20000); + expect(result.sessions.end_summary.max_tokens).toBe(512); + expect(result.sessions.end_summary.write_to_memory).toBe(true); + expect(result.sessions.end_summary.memory_namespace).toBe('session/summaries'); + }); + + it('accepts custom end_summary settings', () => { + const result = configSchema.parse({ + ...minimalConfig, + sessions: { + ttl: '7d', + end_summary: { + enabled: true, + tier: 'complex', + max_messages: 100, + max_input_chars: 50000, + max_tokens: 1024, + write_to_memory: false, + memory_namespace: 'notes/session', + }, + }, + }); + + expect(result.sessions.ttl).toBe('7d'); + expect(result.sessions.end_summary.enabled).toBe(true); + expect(result.sessions.end_summary.tier).toBe('complex'); + expect(result.sessions.end_summary.max_messages).toBe(100); + expect(result.sessions.end_summary.max_input_chars).toBe(50000); + expect(result.sessions.end_summary.max_tokens).toBe(1024); + expect(result.sessions.end_summary.write_to_memory).toBe(false); + expect(result.sessions.end_summary.memory_namespace).toBe('notes/session'); + }); +}); + describe('configSchema — agent_configs', () => { const minimalConfig = { telegram: { bot_token: 'test', allowed_chat_ids: [1] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index b7b4b8d..104fcd8 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -670,6 +670,15 @@ const promptSchema = z.object({ const sessionsSchema = z.object({ ttl: z.string().default('30d'), + end_summary: z.object({ + enabled: z.boolean().default(false), + tier: modelTierEnum.default('fast'), + max_messages: z.number().min(2).max(500).default(50), + max_input_chars: z.number().min(500).max(200000).default(20000), + max_tokens: z.number().min(64).max(4096).default(512), + write_to_memory: z.boolean().default(true), + memory_namespace: z.string().default('session/summaries'), + }).default({}), }).default({}); const backupSchema = z.object({ diff --git a/src/gateway/session-bridge.test.ts b/src/gateway/session-bridge.test.ts index 92d5d45..7acc4f1 100644 --- a/src/gateway/session-bridge.test.ts +++ b/src/gateway/session-bridge.test.ts @@ -22,6 +22,7 @@ const mockSessionManager = { }; const mockModelClient = { + getClient: vi.fn(() => mockModelClient), chat: vi.fn(async () => ({ content: 'test', stopReason: 'end_turn', @@ -103,6 +104,56 @@ describe('SessionBridge', () => { expect(bridge.getAgent('conn-1')).toBeUndefined(); }); + it('runs session-end summary on final disconnect when enabled', async () => { + mockSession.getHistory.mockReturnValueOnce([ + { role: 'user', content: 'Please remember my preference for concise updates.' }, + { role: 'assistant', content: 'Noted.' }, + ] as any); + const memoryStore = { + write: vi.fn(), + }; + const bridge = new SessionBridge({ + sessionManager: mockSessionManager as unknown as SessionBridgeConfig['sessionManager'], + modelClient: mockModelClient, + systemPrompt: 'test prompt', + toolRegistry: mockToolRegistry as unknown as SessionBridgeConfig['toolRegistry'], + toolExecutor: mockToolExecutor as unknown as SessionBridgeConfig['toolExecutor'], + memoryStore: memoryStore as unknown as SessionBridgeConfig['memoryStore'], + config: { + agents: { + primary_tier: 'default', + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + max_delegation_depth: 3, + }, + compaction: { enabled: false }, + models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } }, + sessions: { + ttl: '30d', + end_summary: { + enabled: true, + tier: 'fast', + max_messages: 50, + max_input_chars: 20000, + max_tokens: 256, + write_to_memory: true, + memory_namespace: 'session/summaries', + }, + }, + } as unknown as SessionBridgeConfig['config'], + }); + bridge.connect('conn-end-summary'); + bridge.disconnect('conn-end-summary'); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(memoryStore.write).toHaveBeenCalled(); + }); + it('tracks busy state', () => { const bridge = createBridge(); bridge.connect('conn-1'); diff --git a/src/gateway/session-bridge.ts b/src/gateway/session-bridge.ts index 0a55312..367c544 100644 --- a/src/gateway/session-bridge.ts +++ b/src/gateway/session-bridge.ts @@ -8,6 +8,7 @@ import { AgentOrchestrator, type DelegationConfig } from '../backends/native/orc import type { ToolUseEvent } from '../backends/native/agent.js'; import type { MemoryStore } from '../memory/store.js'; import type { Config } from '../config/index.js'; +import { summarizeSessionOnEnd, type SessionEndSummaryConfig } from '../session/endSummary.js'; export interface SessionBridgeConfig { sessionManager: SessionManager; @@ -59,6 +60,29 @@ export class SessionBridge { const otherClients = Array.from(this.clients.values()) .filter(c => c.sessionId === client.sessionId && c.connectionId !== connectionId); if (otherClients.length === 0) { + const agent = this.agents.get(client.sessionId); + const summaryConfig = this.config.config?.sessions?.end_summary; + if (agent && summaryConfig?.enabled) { + const history = agent.getHistory(); + const mappedConfig: SessionEndSummaryConfig = { + enabled: summaryConfig.enabled, + tier: summaryConfig.tier, + maxMessages: summaryConfig.max_messages, + maxInputChars: summaryConfig.max_input_chars, + maxTokens: summaryConfig.max_tokens, + writeToMemory: summaryConfig.write_to_memory, + memoryNamespace: summaryConfig.memory_namespace, + }; + void summarizeSessionOnEnd({ + agent, + sessionId: client.sessionId, + history, + config: mappedConfig, + memoryStore: this.config.memoryStore, + }).catch((error) => { + console.warn('Session end summary failed:', error); + }); + } this.agents.delete(client.sessionId); } this.clients.delete(connectionId); diff --git a/src/session/endSummary.test.ts b/src/session/endSummary.test.ts new file mode 100644 index 0000000..b9f30b7 --- /dev/null +++ b/src/session/endSummary.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { AgentOrchestrator } from '../backends/native/orchestrator.js'; +import { summarizeSessionOnEnd, sessionEndSummaryInternals } from './endSummary.js'; +import type { MemoryStore } from '../memory/store.js'; + +describe('session end summary', () => { + it('sanitizes session ids for memory namespaces', () => { + expect(sessionEndSummaryInternals.sanitizeSessionId('ws:abc-123')).toBe('ws/abc-123'); + expect(sessionEndSummaryInternals.sanitizeSessionId('telegram:user@x')).toBe('telegram/user_x'); + }); + + it('summarizes and writes to memory when enabled', async () => { + const agent = { + delegate: vi.fn().mockResolvedValue({ + content: '- User prefers concise responses.\n- Next step: run deploy tomorrow.', + usage: { inputTokens: 10, outputTokens: 8 }, + tier: 'fast', + }), + } as unknown as AgentOrchestrator; + const memoryStore = { + write: vi.fn(), + } as unknown as MemoryStore; + + const result = await summarizeSessionOnEnd({ + agent, + sessionId: 'ws:conn-1', + history: [ + { role: 'user', content: 'Please keep answers concise.' }, + { role: 'assistant', content: 'Noted.' }, + ], + config: { + enabled: true, + tier: 'fast', + maxMessages: 50, + maxInputChars: 20000, + maxTokens: 512, + writeToMemory: true, + memoryNamespace: 'session/summaries', + }, + memoryStore, + }); + + expect(agent.delegate).toHaveBeenCalledTimes(1); + expect(result?.summary).toContain('concise'); + expect(result?.namespace).toBe('session/summaries/ws/conn-1'); + expect(memoryStore.write).toHaveBeenCalledWith( + 'session/summaries/ws/conn-1', + expect.stringContaining('User prefers concise responses'), + 'append', + ); + }); + + it('skips when disabled', async () => { + const agent = { + delegate: vi.fn(), + } as unknown as AgentOrchestrator; + + const result = await summarizeSessionOnEnd({ + agent, + sessionId: 'ws:conn-1', + history: [{ role: 'user', content: 'hello' }], + config: { + enabled: false, + tier: 'fast', + maxMessages: 50, + maxInputChars: 20000, + maxTokens: 512, + writeToMemory: true, + memoryNamespace: 'session/summaries', + }, + }); + + expect(result).toBeNull(); + expect(agent.delegate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/session/endSummary.ts b/src/session/endSummary.ts new file mode 100644 index 0000000..b27b5c0 --- /dev/null +++ b/src/session/endSummary.ts @@ -0,0 +1,88 @@ +import type { AgentOrchestrator } from '../backends/native/orchestrator.js'; +import type { Message } from '../models/types.js'; +import { getMessageText } from '../models/media.js'; +import type { MemoryStore } from '../memory/store.js'; +import type { ModelTier } from '../models/router.js'; + +export interface SessionEndSummaryConfig { + enabled: boolean; + tier: ModelTier; + maxMessages: number; + maxInputChars: number; + maxTokens: number; + writeToMemory: boolean; + memoryNamespace: string; +} + +export interface SessionEndSummaryResult { + summary: string; + namespace?: string; +} + +const SESSION_END_SUMMARY_PROMPT = [ + 'Summarize this completed conversation for future continuity.', + 'Focus on durable facts, decisions, preferences, unresolved tasks, and next steps.', + 'Be concise and structured with short bullets.', + 'Do not include private secrets.', +].join(' '); + +function sanitizeSessionId(sessionId: string): string { + return sessionId + .replace(/:/g, '/') + .replace(/[^a-zA-Z0-9/_-]+/g, '_') + .replace(/_+/g, '_'); +} + +function formatHistory(messages: Message[], maxInputChars: number): string { + const text = messages + .map((msg) => `${msg.role}: ${getMessageText(msg)}`) + .join('\n\n'); + if (text.length <= maxInputChars) { + return text; + } + return text.slice(text.length - maxInputChars); +} + +export async function summarizeSessionOnEnd(opts: { + agent: AgentOrchestrator; + sessionId: string; + history: Message[]; + config: SessionEndSummaryConfig; + memoryStore?: MemoryStore; +}): Promise { + if (!opts.config.enabled || opts.history.length === 0) { + return null; + } + + const selected = opts.history.slice(-opts.config.maxMessages); + const conversation = formatHistory(selected, opts.config.maxInputChars); + if (!conversation.trim()) { + return null; + } + + const result = await opts.agent.delegate({ + tier: opts.config.tier, + systemPrompt: SESSION_END_SUMMARY_PROMPT, + message: conversation, + maxTokens: opts.config.maxTokens, + }); + + const summary = result.content.trim(); + if (!summary) { + return null; + } + + let namespace: string | undefined; + if (opts.memoryStore && opts.config.writeToMemory) { + namespace = `${opts.config.memoryNamespace}/${sanitizeSessionId(opts.sessionId)}`; + const block = `## ${new Date().toISOString()}\n\n${summary}\n\n`; + opts.memoryStore.write(namespace, block, 'append'); + } + + return { summary, namespace }; +} + +export const sessionEndSummaryInternals = { + sanitizeSessionId, + formatHistory, +}; diff --git a/src/session/index.ts b/src/session/index.ts index afe3225..3ba6ea4 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -4,3 +4,4 @@ export { SessionIndexer, tokenize } from './indexer.js'; export type { HistoryMetadata, HistoryIndexerConfig } from './indexer.js'; export { SessionSearch } from './search.js'; export type { HistorySearchResult, HistorySearchConfig } from './search.js'; +export { summarizeSessionOnEnd, sessionEndSummaryInternals, type SessionEndSummaryConfig, type SessionEndSummaryResult } from './endSummary.js';