feat(session): add optional end-of-session summarization
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<SessionEndSummaryResult | null> {
|
||||
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,
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user