/ src / server / storage / chat / sessions.ts
sessions.ts
  1  import { randomUUID } from 'node:crypto'
  2  
  3  import type { SessionSnapshot } from '@/lib/shared/chat'
  4  import { getAppSettings } from '@/server/storage/app-settings'
  5  import { getDb } from '@/server/storage/db'
  6  import { getMessages, getMessageCount } from './messages'
  7  import { listMemories } from './memories'
  8  import type {
  9    SessionListItem,
 10    SessionListRow,
 11    SessionRow,
 12  } from './types'
 13  
 14  export function ensureSession(sessionId?: string): SessionRow {
 15    const db = getDb()
 16    const now = new Date().toISOString()
 17    const id = sessionId?.trim() || randomUUID()
 18  
 19    const existing = db
 20      .prepare(
 21        `SELECT id, created_at, updated_at, compacted_summary, chat_model_id_override, thinking_level, reasoning_level
 22         FROM sessions WHERE id = ?`,
 23      )
 24      .get(id) as SessionRow | undefined
 25  
 26    if (existing) {
 27      return existing
 28    }
 29  
 30    const defaults = getAppSettings()
 31    const seededChatModel = null
 32  
 33    db.prepare(
 34      `INSERT INTO sessions (
 35        id, created_at, updated_at, compacted_summary, chat_model_id_override, thinking_level, reasoning_level
 36      ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
 37    ).run(
 38      id,
 39      now,
 40      now,
 41      '',
 42      seededChatModel,
 43      defaults.thinkingLevel,
 44      defaults.reasoningLevel,
 45    )
 46  
 47    return {
 48      id,
 49      created_at: now,
 50      updated_at: now,
 51      compacted_summary: '',
 52      chat_model_id_override: seededChatModel,
 53      thinking_level: defaults.thinkingLevel,
 54      reasoning_level: defaults.reasoningLevel,
 55      used_capabilities_json: '[]',
 56    }
 57  }
 58  
 59  export function getSessionSnapshot(
 60    sessionId: string,
 61    options?: { messageLimit?: number },
 62  ): SessionSnapshot | null {
 63    const db = getDb()
 64    const session = db
 65      .prepare(
 66        'SELECT id, created_at, updated_at, compacted_summary, used_capabilities_json FROM sessions WHERE id = ?',
 67      )
 68      .get(sessionId) as SessionRow | undefined
 69  
 70    if (!session) return null
 71  
 72    let usedCapabilities: string[] = []
 73    try {
 74      usedCapabilities = session.used_capabilities_json
 75        ? JSON.parse(session.used_capabilities_json)
 76        : []
 77    } catch {
 78      usedCapabilities = []
 79    }
 80  
 81    const messageLimit = options?.messageLimit
 82    const messages = getMessages(sessionId, messageLimit != null ? { limit: messageLimit } : undefined)
 83    const totalMessageCount = messageLimit != null ? getMessageCount(sessionId) : messages.length
 84  
 85    return {
 86      sessionId: session.id,
 87      createdAt: session.created_at,
 88      updatedAt: session.updated_at,
 89      compactedSummary: session.compacted_summary,
 90      messages,
 91      memories: listMemories(sessionId),
 92      usedCapabilities,
 93      totalMessageCount,
 94      hasMoreMessages: messages.length < totalMessageCount,
 95    }
 96  }
 97  
 98  export function listSessions(limit = 20, search?: string): SessionListItem[] {
 99    const db = getDb()
100    const cappedLimit = Math.min(Math.max(limit, 1), 100)
101    const searchTerm = search?.trim() ? `%${search.trim()}%` : null
102  
103    const rows = db
104      .prepare(
105        `SELECT
106           s.id,
107           s.created_at,
108           s.updated_at,
109           s.compacted_summary,
110           s.chat_model_id_override,
111           s.thinking_level,
112           s.reasoning_level,
113           COALESCE((
114             SELECT COUNT(*)
115             FROM messages m
116             WHERE m.session_id = s.id
117           ), 0) AS message_count,
118           (
119             SELECT m2.text
120             FROM messages m2
121             WHERE m2.session_id = s.id
122             ORDER BY m2.created_at DESC
123             LIMIT 1
124           ) AS last_message_text,
125           (
126             SELECT m2.role
127             FROM messages m2
128             WHERE m2.session_id = s.id
129             ORDER BY m2.created_at DESC
130             LIMIT 1
131           ) AS last_message_role
132         FROM sessions s
133         WHERE (? IS NULL OR s.id LIKE ? OR (
134           SELECT m3.text 
135           FROM messages m3 
136           WHERE m3.session_id = s.id 
137           AND m3.text LIKE ?
138           LIMIT 1
139         ) IS NOT NULL)
140         ORDER BY s.updated_at DESC
141         LIMIT ?`,
142      )
143      .all(searchTerm, searchTerm, searchTerm, cappedLimit) as SessionListRow[]
144  
145    return rows.map((row) => ({
146      id: row.id,
147      createdAt: row.created_at,
148      updatedAt: row.updated_at,
149      compactedSummary: row.compacted_summary,
150      chatModelOverride: row.chat_model_id_override,
151      thinkingLevel: row.thinking_level,
152      reasoningLevel: row.reasoning_level,
153      messageCount: row.message_count,
154      lastMessageText: row.last_message_text,
155      lastMessageRole: row.last_message_role,
156    }))
157  }
158  
159  export function deleteSession(sessionId: string): void {
160    const db = getDb()
161    db.transaction(() => {
162      db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId)
163      db.prepare('DELETE FROM memories WHERE session_id = ?').run(sessionId)
164      db.prepare('DELETE FROM memory_embeddings WHERE session_id = ?').run(sessionId)
165      db.prepare('DELETE FROM uploads WHERE session_id = ?').run(sessionId)
166      db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId)
167    })()
168  }
169  
170  export function getSessionChatModelOverride(sessionId: string): string | null {
171    const db = getDb()
172    const row = db
173      .prepare('SELECT chat_model_id_override FROM sessions WHERE id = ?')
174      .get(sessionId) as Pick<SessionRow, 'chat_model_id_override'> | undefined
175  
176    return row?.chat_model_id_override ?? null
177  }
178  
179  export function setSessionChatModelOverride(
180    sessionId: string,
181    model: string | null,
182  ): void {
183    const db = getDb()
184    const normalized = model?.trim() ? model.trim() : null
185    const now = new Date().toISOString()
186    db.prepare(
187      'UPDATE sessions SET chat_model_id_override = ?, updated_at = ? WHERE id = ?',
188    ).run(normalized, now, sessionId)
189  }
190  
191  export function getSessionThinkingLevelOverride(
192    sessionId: string,
193  ): string | null {
194    const db = getDb()
195    const row = db
196      .prepare('SELECT thinking_level FROM sessions WHERE id = ?')
197      .get(sessionId) as { thinking_level: string | null } | undefined
198  
199    return row?.thinking_level ?? null
200  }
201  
202  export function setSessionThinkingLevelOverride(
203    sessionId: string,
204    thinkingLevel: string | null,
205  ): void {
206    const db = getDb()
207    const normalized = thinkingLevel?.trim() ? thinkingLevel.trim() : null
208    const now = new Date().toISOString()
209    db.prepare(
210      'UPDATE sessions SET thinking_level = ?, updated_at = ? WHERE id = ?',
211    ).run(normalized, now, sessionId)
212  }
213  
214  export function getSessionReasoningLevelOverride(
215    sessionId: string,
216  ): string | null {
217    const db = getDb()
218    const row = db
219      .prepare('SELECT reasoning_level FROM sessions WHERE id = ?')
220      .get(sessionId) as { reasoning_level: string | null } | undefined
221  
222    return row?.reasoning_level ?? null
223  }
224  
225  export function setSessionReasoningLevelOverride(
226    sessionId: string,
227    reasoningLevel: string | null,
228  ): void {
229    const db = getDb()
230    const normalized = reasoningLevel?.trim() ? reasoningLevel.trim() : null
231    const now = new Date().toISOString()
232    db.prepare(
233      'UPDATE sessions SET reasoning_level = ?, updated_at = ? WHERE id = ?',
234    ).run(normalized, now, sessionId)
235  }
236  
237  export function getSessionUsedCapabilities(sessionId: string): string[] {
238    const db = getDb()
239    const row = db
240      .prepare('SELECT used_capabilities_json FROM sessions WHERE id = ?')
241      .get(sessionId) as { used_capabilities_json: string } | undefined
242  
243    if (!row?.used_capabilities_json) return []
244    try {
245      return JSON.parse(row.used_capabilities_json) as string[]
246    } catch {
247      return []
248    }
249  }
250  
251  export function addSessionUsedCapability(
252    sessionId: string,
253    capability: string,
254  ): void {
255    const db = getDb()
256    const existing = getSessionUsedCapabilities(sessionId)
257    if (!existing.includes(capability)) {
258      const updated = [...existing, capability]
259      const now = new Date().toISOString()
260      db.prepare(
261        'UPDATE sessions SET used_capabilities_json = ?, updated_at = ? WHERE id = ?',
262      ).run(JSON.stringify(updated), now, sessionId)
263    }
264  }
265  
266  export function touchSession(
267    sessionId: string,
268    updatedAt = new Date().toISOString(),
269  ): void {
270    const db = getDb()
271    db.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(
272      updatedAt,
273      sessionId,
274    )
275  }