Skip to main content
Glama
session.ts16.9 kB
import { Effect, pipe, Array as A, Option as O } from 'effect' import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as os from 'node:os' import type { Message, SessionMeta, Project } from './schema.js' // Get Claude sessions directory export const getSessionsDir = (): string => path.join(os.homedir(), '.claude', 'projects') // List all project directories export const listProjects = Effect.gen(function* () { const sessionsDir = getSessionsDir() const exists = yield* Effect.tryPromise(() => fs .access(sessionsDir) .then(() => true) .catch(() => false) ) if (!exists) { return [] as Project[] } const entries = yield* Effect.tryPromise(() => fs.readdir(sessionsDir, { withFileTypes: true })) const projects = yield* Effect.all( entries .filter((e) => e.isDirectory()) .map((entry) => Effect.gen(function* () { const projectPath = path.join(sessionsDir, entry.name) const files = yield* Effect.tryPromise(() => fs.readdir(projectPath)) const sessionFiles = files.filter((f) => f.endsWith('.jsonl')) return { name: entry.name, path: projectPath, sessionCount: sessionFiles.length, } satisfies Project }) ), { concurrency: 10 } ) return projects }) // List sessions in a project export const listSessions = (projectName: string) => Effect.gen(function* () { const projectPath = path.join(getSessionsDir(), projectName) const files = yield* Effect.tryPromise(() => fs.readdir(projectPath)) const sessionFiles = files.filter((f) => f.endsWith('.jsonl')) const sessions = yield* Effect.all( sessionFiles.map((file) => Effect.gen(function* () { const filePath = path.join(projectPath, file) const content = yield* Effect.tryPromise(() => fs.readFile(filePath, 'utf-8')) const lines = content.trim().split('\n').filter(Boolean) const messages = lines.map((line) => JSON.parse(line) as Message) const sessionId = file.replace('.jsonl', '') const firstMessage = messages[0] const lastMessage = messages[messages.length - 1] // Extract title from first user message const title = pipe( messages, A.findFirst((m) => m.type === 'human'), O.map((m) => { const msg = m.message as { content?: string } | undefined const content = msg?.content ?? '' return content.slice(0, 50) + (content.length > 50 ? '...' : '') }), O.getOrElse(() => 'Untitled') ) return { id: sessionId, projectName, title, messageCount: messages.length, createdAt: firstMessage?.timestamp, updatedAt: lastMessage?.timestamp, } satisfies SessionMeta }) ), { concurrency: 10 } ) return sessions }) // Read session messages export const readSession = (projectName: string, sessionId: string) => Effect.gen(function* () { const filePath = path.join(getSessionsDir(), projectName, `${sessionId}.jsonl`) const content = yield* Effect.tryPromise(() => fs.readFile(filePath, 'utf-8')) const lines = content.trim().split('\n').filter(Boolean) return lines.map((line) => JSON.parse(line) as Message) }) // Find agent files linked to a session export const findLinkedAgents = (projectName: string, sessionId: string) => Effect.gen(function* () { const projectPath = path.join(getSessionsDir(), projectName) const files = yield* Effect.tryPromise(() => fs.readdir(projectPath)) const agentFiles = files.filter((f) => f.startsWith('agent-') && f.endsWith('.jsonl')) const linkedAgents: string[] = [] for (const agentFile of agentFiles) { const filePath = path.join(projectPath, agentFile) const content = yield* Effect.tryPromise(() => fs.readFile(filePath, 'utf-8')) const firstLine = content.split('\n')[0] if (firstLine) { try { const parsed = JSON.parse(firstLine) as { sessionId?: string } if (parsed.sessionId === sessionId) { linkedAgents.push(agentFile.replace('.jsonl', '')) } } catch { // Skip invalid JSON } } } return linkedAgents }) // Delete a session and its linked agent files export const deleteSession = (projectName: string, sessionId: string) => Effect.gen(function* () { const sessionsDir = getSessionsDir() const projectPath = path.join(sessionsDir, projectName) const filePath = path.join(projectPath, `${sessionId}.jsonl`) // Create backup directory const backupDir = path.join(projectPath, '.bak') yield* Effect.tryPromise(() => fs.mkdir(backupDir, { recursive: true })) // Find and delete linked agent files const linkedAgents = yield* findLinkedAgents(projectName, sessionId) const deletedAgents: string[] = [] for (const agentId of linkedAgents) { const agentPath = path.join(projectPath, `${agentId}.jsonl`) const agentBackupPath = path.join(backupDir, `${agentId}.jsonl`) yield* Effect.tryPromise(() => fs.rename(agentPath, agentBackupPath)) deletedAgents.push(agentId) } // Move session file to backup const backupPath = path.join(backupDir, `${sessionId}.jsonl`) yield* Effect.tryPromise(() => fs.rename(filePath, backupPath)) return { success: true, backupPath, deletedAgents } }) // Rename session by adding title prefix export const renameSession = (projectName: string, sessionId: string, newTitle: string) => Effect.gen(function* () { const filePath = path.join(getSessionsDir(), projectName, `${sessionId}.jsonl`) const content = yield* Effect.tryPromise(() => fs.readFile(filePath, 'utf-8')) const lines = content.trim().split('\n').filter(Boolean) if (lines.length === 0) { return { success: false, error: 'Empty session' } } const messages = lines.map((line) => JSON.parse(line) as Message) const firstMsg = messages[0] // Add title prefix to first message if (firstMsg && typeof firstMsg.message === 'object' && firstMsg.message !== null) { const msg = firstMsg.message as { content?: string } if (msg.content) { // Remove existing title prefix if any const cleanContent = msg.content.replace(/^\[.*?\]\s*/, '') msg.content = `[${newTitle}] ${cleanContent}` } } const newContent = messages.map((m) => JSON.stringify(m)).join('\n') + '\n' yield* Effect.tryPromise(() => fs.writeFile(filePath, newContent, 'utf-8')) return { success: true } }) // Delete a message from session export const deleteMessage = (projectName: string, sessionId: string, messageUuid: string) => Effect.gen(function* () { const filePath = path.join(getSessionsDir(), projectName, `${sessionId}.jsonl`) const content = yield* Effect.tryPromise(() => fs.readFile(filePath, 'utf-8')) const lines = content.trim().split('\n').filter(Boolean) const messages = lines.map((line) => JSON.parse(line) as Message) const targetIndex = messages.findIndex((m) => m.uuid === messageUuid) if (targetIndex === -1) { return { success: false, error: 'Message not found' } } // Get the parent UUID of deleted message const deletedMsg = messages[targetIndex] const parentUuid = deletedMsg?.parentUuid // Update child message to point to deleted message's parent const nextMsg = messages[targetIndex + 1] if (nextMsg) { nextMsg.parentUuid = parentUuid } // Remove the message messages.splice(targetIndex, 1) const newContent = messages.map((m) => JSON.stringify(m)).join('\n') + '\n' yield* Effect.tryPromise(() => fs.writeFile(filePath, newContent, 'utf-8')) return { success: true } }) // Preview cleanup - find empty and invalid sessions export const previewCleanup = (projectName?: string) => Effect.gen(function* () { const projects = yield* listProjects const targetProjects = projectName ? projects.filter((p) => p.name === projectName) : projects const results = yield* Effect.all( targetProjects.map((project) => Effect.gen(function* () { const sessions = yield* listSessions(project.name) const emptySessions = sessions.filter((s) => s.messageCount === 0) const invalidSessions = sessions.filter( (s) => s.title?.includes('Invalid API key') || s.title?.includes('API key') ) return { project: project.name, emptySessions, invalidSessions, } }) ), { concurrency: 5 } ) return results }) // Find orphan agent files (agents whose parent session no longer exists) export const findOrphanAgents = (projectName: string) => Effect.gen(function* () { const projectPath = path.join(getSessionsDir(), projectName) const files = yield* Effect.tryPromise(() => fs.readdir(projectPath)) const sessionIds = new Set( files .filter((f) => !f.startsWith('agent-') && f.endsWith('.jsonl')) .map((f) => f.replace('.jsonl', '')) ) const agentFiles = files.filter((f) => f.startsWith('agent-') && f.endsWith('.jsonl')) const orphanAgents: Array<{ agentId: string; sessionId: string }> = [] for (const agentFile of agentFiles) { const filePath = path.join(projectPath, agentFile) const content = yield* Effect.tryPromise(() => fs.readFile(filePath, 'utf-8')) const firstLine = content.split('\n')[0] if (firstLine) { try { const parsed = JSON.parse(firstLine) as { sessionId?: string } if (parsed.sessionId && !sessionIds.has(parsed.sessionId)) { orphanAgents.push({ agentId: agentFile.replace('.jsonl', ''), sessionId: parsed.sessionId, }) } } catch { // Skip invalid JSON } } } return orphanAgents }) // Delete orphan agent files export const deleteOrphanAgents = (projectName: string) => Effect.gen(function* () { const projectPath = path.join(getSessionsDir(), projectName) const orphans = yield* findOrphanAgents(projectName) // Create backup directory const backupDir = path.join(projectPath, '.bak') yield* Effect.tryPromise(() => fs.mkdir(backupDir, { recursive: true })) const deletedAgents: string[] = [] for (const orphan of orphans) { const agentPath = path.join(projectPath, `${orphan.agentId}.jsonl`) const agentBackupPath = path.join(backupDir, `${orphan.agentId}.jsonl`) yield* Effect.tryPromise(() => fs.rename(agentPath, agentBackupPath)) deletedAgents.push(orphan.agentId) } return { success: true, deletedAgents, count: deletedAgents.length } }) // Clear sessions (empty and invalid) export const clearSessions = (options: { projectName?: string clearEmpty?: boolean clearInvalid?: boolean clearOrphanAgents?: boolean }) => Effect.gen(function* () { const { projectName, clearEmpty = true, clearInvalid = true, clearOrphanAgents = true, } = options const cleanupPreview = yield* previewCleanup(projectName) let deletedCount = 0 let deletedAgentCount = 0 for (const result of cleanupPreview) { const toDelete = [ ...(clearEmpty ? result.emptySessions : []), ...(clearInvalid ? result.invalidSessions : []), ] for (const session of toDelete) { const deleteResult = yield* deleteSession(result.project, session.id) deletedCount++ deletedAgentCount += deleteResult.deletedAgents.length } // Clean up orphan agents after deleting sessions if (clearOrphanAgents) { const orphanResult = yield* deleteOrphanAgents(result.project) deletedAgentCount += orphanResult.count } } return { success: true, deletedCount, deletedAgentCount } }) // File change info extracted from session export interface FileChange { path: string action: 'created' | 'modified' | 'deleted' timestamp?: string messageUuid?: string } // Session file changes summary export interface SessionFilesSummary { sessionId: string projectName: string files: FileChange[] totalChanges: number } // Get changed files from a session export const getSessionFiles = (projectName: string, sessionId: string) => Effect.gen(function* () { const messages = yield* readSession(projectName, sessionId) const fileChanges: FileChange[] = [] const seenFiles = new Set<string>() for (const msg of messages) { // Check for file-history-snapshot type if (msg.type === 'file-history-snapshot') { const snapshot = msg as unknown as { type: string messageId?: string snapshot?: { trackedFileBackups?: Record<string, unknown> timestamp?: string } } const backups = snapshot.snapshot?.trackedFileBackups if (backups && typeof backups === 'object') { for (const filePath of Object.keys(backups)) { if (!seenFiles.has(filePath)) { seenFiles.add(filePath) fileChanges.push({ path: filePath, action: 'modified', timestamp: snapshot.snapshot?.timestamp, messageUuid: snapshot.messageId ?? msg.uuid, }) } } } } // Also check tool_use for Write/Edit operations if (msg.type === 'assistant' && msg.message) { const assistantMsg = msg.message as { content?: Array<{ type: string; name?: string; input?: { file_path?: string } }> } const content = assistantMsg.content if (Array.isArray(content)) { for (const block of content) { if (block.type === 'tool_use' && (block.name === 'Write' || block.name === 'Edit')) { const filePath = block.input?.file_path if (filePath && !seenFiles.has(filePath)) { seenFiles.add(filePath) fileChanges.push({ path: filePath, action: block.name === 'Write' ? 'created' : 'modified', timestamp: msg.timestamp, messageUuid: msg.uuid, }) } } } } } } return { sessionId, projectName, files: fileChanges, totalChanges: fileChanges.length, } satisfies SessionFilesSummary }) // Get file changes diff summary for a session export interface FileDiffSummary { sessionId: string projectName: string title: string changes: Array<{ path: string action: 'created' | 'modified' | 'deleted' hasBackup: boolean backupPreview?: string }> totalFiles: number snapshotCount: number } export const getSessionDiffSummary = (projectName: string, sessionId: string) => Effect.gen(function* () { const messages = yield* readSession(projectName, sessionId) // Extract title const title = pipe( messages, A.findFirst((m) => m.type === 'human'), O.map((m) => { const msg = m.message as { content?: string } | undefined const content = msg?.content ?? '' return content.slice(0, 50) + (content.length > 50 ? '...' : '') }), O.getOrElse(() => 'Untitled') ) const changes: FileDiffSummary['changes'] = [] const seenFiles = new Set<string>() let snapshotCount = 0 for (const msg of messages) { if (msg.type === 'file-history-snapshot') { snapshotCount++ const snapshot = msg as { type: string snapshot?: { trackedFileBackups?: Record<string, { content?: string }> } } const backups = snapshot.snapshot?.trackedFileBackups if (backups && typeof backups === 'object') { for (const [filePath, backup] of Object.entries(backups)) { if (!seenFiles.has(filePath)) { seenFiles.add(filePath) const backupData = backup as { content?: string } | undefined const content = backupData?.content ?? '' changes.push({ path: filePath, action: 'modified', hasBackup: content.length > 0, backupPreview: content.slice(0, 100) + (content.length > 100 ? '...' : ''), }) } } } } } return { sessionId, projectName, title, changes, totalFiles: changes.length, snapshotCount, } satisfies FileDiffSummary })

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DrumRobot/claude-sessions-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server