session-archive-memory.ts
1 import { createHash } from 'crypto' 2 import fs from 'fs' 3 import path from 'path' 4 import type { Agent, MemoryEntry, MemoryReference, Message, Session } from '@/types' 5 import { getMemoryDb } from '@/lib/server/memory/memory-db' 6 import { loadAgents, loadSessions, saveSessions } from '@/lib/server/storage' 7 import { DATA_DIR } from '@/lib/server/data-dir' 8 import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind' 9 import { getMessageCount, getRecentMessages } from '@/lib/server/messages/message-repository' 10 11 const MAX_ARCHIVE_MESSAGES = 36 12 const MAX_ARCHIVE_LINE_CHARS = 320 13 const SESSION_ARCHIVE_EXPORT_DIR = path.join(DATA_DIR, 'session-archives') 14 15 function toOneLine(value: unknown, maxChars: number): string { 16 return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxChars) 17 } 18 19 function messageSpeaker(session: Session, agent: Partial<Agent> | null | undefined, message: Message): string { 20 if (message.role === 'assistant') return agent?.name || 'assistant' 21 return (isDirectConnectorSession(session) ? session.connectorContext?.senderName : null) || session.user || 'user' 22 } 23 24 function slugifySegment(value: string, fallback: string): string { 25 const normalized = value 26 .toLowerCase() 27 .replace(/[^a-z0-9._-]+/g, '-') 28 .replace(/^-+|-+$/g, '') 29 return normalized || fallback 30 } 31 32 export function buildSessionArchivePayload( 33 session: Session, 34 agent?: Partial<Agent> | null, 35 ): { 36 title: string 37 content: string 38 metadata: Record<string, unknown> 39 references: MemoryReference[] 40 hash: string 41 } | null { 42 const messageCount = getMessageCount(session.id) 43 if (messageCount < 2) return null 44 45 const excerpt = getRecentMessages(session.id, MAX_ARCHIVE_MESSAGES).map((message) => { 46 const speaker = messageSpeaker(session, agent, message) 47 const kind = message.kind && message.kind !== 'chat' ? ` [${message.kind}]` : '' 48 const text = toOneLine(message.text, MAX_ARCHIVE_LINE_CHARS) 49 const tools = Array.isArray(message.toolEvents) && message.toolEvents.length > 0 50 ? ` | tools=${message.toolEvents.map((event) => event.name).join(',')}` 51 : '' 52 return `- ${speaker}${kind}: ${text}${tools}` 53 }).join('\n') 54 55 const title = `Session archive: ${session.name || session.id}` 56 const content = [ 57 `session_id: ${session.id}`, 58 `session_name: ${toOneLine(session.name, 160)}`, 59 `session_type: ${toOneLine(session.sessionType || 'human', 32)}`, 60 `agent_name: ${toOneLine(agent?.name || '', 80)}`, 61 `last_active_iso: ${new Date(session.lastActiveAt || Date.now()).toISOString()}`, 62 `message_count: ${messageCount}`, 63 session.identityState?.personaLabel ? `persona_label: ${toOneLine(session.identityState.personaLabel, 120)}` : '', 64 '', 65 'Transcript excerpt:', 66 excerpt, 67 ].filter(Boolean).join('\n') 68 69 const hash = createHash('sha256').update(`${title}\n${content}`).digest('hex').slice(0, 16) 70 return { 71 title, 72 content, 73 metadata: { 74 tier: 'archive', 75 archiveHash: hash, 76 sessionName: session.name, 77 sessionType: session.sessionType || 'human', 78 messageCount, 79 lastActiveAt: session.lastActiveAt || Date.now(), 80 personaLabel: session.identityState?.personaLabel || null, 81 }, 82 references: [{ 83 type: 'session', 84 path: session.id, 85 title: session.name, 86 note: 'Searchable session archive snapshot', 87 timestamp: Date.now(), 88 }], 89 hash, 90 } 91 } 92 93 export function buildSessionArchiveMarkdown( 94 session: Session, 95 payload: NonNullable<ReturnType<typeof buildSessionArchivePayload>>, 96 agent?: Partial<Agent> | null, 97 ): string { 98 const transcriptLines = getRecentMessages(session.id, MAX_ARCHIVE_MESSAGES).map((message) => { 99 const speaker = messageSpeaker(session, agent, message) 100 const kind = message.kind && message.kind !== 'chat' ? ` (${message.kind})` : '' 101 const toolSummary = Array.isArray(message.toolEvents) && message.toolEvents.length > 0 102 ? ` [tools: ${message.toolEvents.map((event) => event.name).join(', ')}]` 103 : '' 104 return `- **${speaker}**${kind}: ${toOneLine(message.text, MAX_ARCHIVE_LINE_CHARS)}${toolSummary}` 105 }) 106 107 return [ 108 `# ${payload.title}`, 109 '', 110 `- Session ID: ${session.id}`, 111 `- Session Name: ${toOneLine(session.name, 160)}`, 112 `- Session Type: ${toOneLine(session.sessionType || 'human', 32)}`, 113 `- Agent: ${toOneLine(agent?.name || session.agentId || 'unknown', 80)}`, 114 `- Last Active: ${new Date(session.lastActiveAt || Date.now()).toISOString()}`, 115 `- Messages: ${getMessageCount(session.id)}`, 116 session.identityState?.personaLabel ? `- Persona: ${toOneLine(session.identityState.personaLabel, 120)}` : '', 117 '', 118 '## Archive Snapshot', 119 '', 120 '```text', 121 payload.content, 122 '```', 123 '', 124 '## Transcript Excerpt', 125 '', 126 ...transcriptLines, 127 '', 128 ].filter(Boolean).join('\n') 129 } 130 131 function exportSessionArchiveMarkdown( 132 session: Session, 133 payload: NonNullable<ReturnType<typeof buildSessionArchivePayload>>, 134 agent?: Partial<Agent> | null, 135 ): string | null { 136 try { 137 const agentSegment = slugifySegment(agent?.name || session.agentId || 'shared', 'shared') 138 const sessionSegment = slugifySegment(session.name || session.id, session.id) 139 const dir = path.join(SESSION_ARCHIVE_EXPORT_DIR, agentSegment) 140 fs.mkdirSync(dir, { recursive: true }) 141 const filePath = path.join(dir, `${sessionSegment}-${session.id}.md`) 142 fs.writeFileSync(filePath, buildSessionArchiveMarkdown(session, payload, agent)) 143 return filePath 144 } catch { 145 return null 146 } 147 } 148 149 export function syncSessionArchiveMemory( 150 session: Session, 151 opts?: { agent?: Partial<Agent> | null }, 152 ): { stored: boolean; memoryId?: string; reason?: string } { 153 const agent = opts?.agent ?? (session.agentId ? loadAgents()[session.agentId] : null) 154 if (!session.agentId && !agent?.id) { 155 return { stored: false, reason: 'missing_agent' } 156 } 157 158 const payload = buildSessionArchivePayload(session, agent) 159 if (!payload) { 160 return { stored: false, reason: 'insufficient_messages' } 161 } 162 163 const memDb = getMemoryDb() 164 const existing = memDb.getLatestBySessionCategory(session.id, 'session_archive') 165 const existingHash = typeof existing?.metadata?.archiveHash === 'string' 166 ? existing.metadata.archiveHash 167 : null 168 if (session.sessionArchiveState?.lastHash === payload.hash || existingHash === payload.hash) { 169 session.sessionArchiveState = { 170 memoryId: session.sessionArchiveState?.memoryId || existing?.id || null, 171 lastHash: payload.hash, 172 lastSyncedAt: session.sessionArchiveState?.lastSyncedAt || existing?.updatedAt || null, 173 messageCount: getMessageCount(session.id), 174 exportPath: session.sessionArchiveState?.exportPath || null, 175 } 176 return { stored: false, memoryId: existing?.id || session.sessionArchiveState.memoryId || undefined, reason: 'unchanged' } 177 } 178 const entry: MemoryEntry | null = existing 179 ? memDb.update(existing.id, { 180 title: payload.title, 181 content: payload.content, 182 metadata: payload.metadata, 183 references: payload.references, 184 linkedMemoryIds: existing.linkedMemoryIds, 185 }) 186 : memDb.add({ 187 agentId: session.agentId || agent?.id || null, 188 sessionId: session.id, 189 category: 'session_archive', 190 title: payload.title, 191 content: payload.content, 192 metadata: payload.metadata, 193 references: payload.references, 194 linkedMemoryIds: [], 195 }) 196 197 if (!entry) return { stored: false, reason: 'store_failed' } 198 const exportPath = exportSessionArchiveMarkdown(session, payload, agent) 199 200 session.sessionArchiveState = { 201 memoryId: entry.id, 202 lastHash: payload.hash, 203 lastSyncedAt: Date.now(), 204 messageCount: getMessageCount(session.id), 205 exportPath, 206 } 207 208 return { stored: true, memoryId: entry.id } 209 } 210 211 export function syncAllSessionArchiveMemories(): { synced: number; skipped: number; sessionIds: string[] } { 212 const sessions = loadSessions() 213 const agents = loadAgents() 214 let changed = false 215 let synced = 0 216 let skipped = 0 217 const sessionIds: string[] = [] 218 219 for (const session of Object.values(sessions) as Session[]) { 220 const agent = session.agentId ? agents[session.agentId] : null 221 const result = syncSessionArchiveMemory(session, { agent }) 222 if (result.stored) { 223 synced += 1 224 sessionIds.push(session.id) 225 changed = true 226 } else { 227 skipped += 1 228 } 229 } 230 231 if (changed) saveSessions(sessions) 232 return { synced, skipped, sessionIds } 233 }