/ src / lib / server / memory / session-archive-memory.ts
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  }