UNPKG

@endlessblink/like-i-said-v2

Version:

Task Management & Memory for Claude - Track tasks, remember context, and maintain continuity across sessions with 27 powerful tools. Works with Claude Desktop and Claude Code.

319 lines (260 loc) 10 kB
import fs from 'fs' import path from 'path' import { Memory } from '../types' export interface MarkdownMemory extends Omit<Memory, 'metadata'> { filename: string filepath: string metadata?: Memory['metadata'] } export interface StorageConfig { baseDir: string defaultProject: string } export class MarkdownStorage { private config: StorageConfig constructor(config: StorageConfig) { this.config = config this.ensureDirectories() } private ensureDirectories(): void { // Create base directory if (!fs.existsSync(this.config.baseDir)) { fs.mkdirSync(this.config.baseDir, { recursive: true }) } // Create default project directory const defaultProjectDir = path.join(this.config.baseDir, this.config.defaultProject) if (!fs.existsSync(defaultProjectDir)) { fs.mkdirSync(defaultProjectDir, { recursive: true }) } } private generateFilename(memory: Partial<Memory>): string { const date = new Date(memory.timestamp || Date.now()) const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD // Create a slug from content (first few words) const content = memory.content || 'memory' const slug = content .toLowerCase() .replace(/[^\w\s-]/g, '') // Remove special chars .replace(/\s+/g, '-') // Replace spaces with hyphens .slice(0, 50) // Limit length .replace(/-+$/, '') // Remove trailing hyphens const timestamp = Date.now().toString().slice(-6) // Last 6 digits for uniqueness return `${dateStr}-${slug}-${timestamp}.md` } private getProjectDir(project?: string): string { const projectName = project || this.config.defaultProject const projectDir = path.join(this.config.baseDir, projectName) if (!fs.existsSync(projectDir)) { fs.mkdirSync(projectDir, { recursive: true }) } return projectDir } private parseMarkdownFile(filepath: string): MarkdownMemory | null { try { const content = fs.readFileSync(filepath, 'utf8') const parsed = this.parseMarkdownContent(content) if (!parsed) return null const filename = path.basename(filepath) const projectName = path.basename(path.dirname(filepath)) return { ...parsed, filename, filepath, project: projectName === this.config.defaultProject ? undefined : projectName } } catch (error) { console.error(`Error reading markdown file ${filepath}:`, error) return null } } private parseMarkdownContent(content: string): Partial<Memory> | null { // Parse frontmatter and content const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/ const match = content.match(frontmatterRegex) if (!match) { // No frontmatter, treat entire content as memory content return { id: Date.now().toString(), content: content.trim(), timestamp: new Date().toISOString(), tags: [] } } const [, frontmatter, bodyContent] = match const memory: Partial<Memory> = { content: bodyContent.trim() } // Parse YAML-like frontmatter frontmatter.split('\n').forEach(line => { const colonIndex = line.indexOf(':') if (colonIndex === -1) return const key = line.slice(0, colonIndex).trim() const value = line.slice(colonIndex + 1).trim() switch (key) { case 'id': memory.id = value break case 'timestamp': case 'created': memory.timestamp = value break case 'category': memory.category = value as any break case 'tags': // Parse array format: [tag1, tag2] or comma-separated if (value.startsWith('[') && value.endsWith(']')) { memory.tags = value.slice(1, -1).split(',').map(t => t.trim().replace(/['"]/g, '')) } else { memory.tags = value.split(',').map(t => t.trim()).filter(Boolean) } break case 'project': memory.project = value break } }) return memory } private generateMarkdownContent(memory: Memory): string { const frontmatter = [ '---', `id: ${memory.id}`, `timestamp: ${memory.timestamp}`, memory.category ? `category: ${memory.category}` : null, memory.project ? `project: ${memory.project}` : null, memory.tags && memory.tags.length > 0 ? `tags: [${memory.tags.map(t => `"${t}"`).join(', ')}]` : null, '---', '' ].filter(Boolean).join('\n') return frontmatter + memory.content } async saveMemory(memory: Memory): Promise<string> { const projectDir = this.getProjectDir(memory.project) const filename = this.generateFilename(memory) const filepath = path.join(projectDir, filename) const markdownContent = this.generateMarkdownContent(memory) fs.writeFileSync(filepath, markdownContent, 'utf8') return filepath } async getMemory(id: string): Promise<MarkdownMemory | null> { // Search across all project directories const memories = await this.listMemories() return memories.find(m => m.id === id) || null } async listMemories(project?: string): Promise<MarkdownMemory[]> { const memories: MarkdownMemory[] = [] if (project) { // List memories for specific project const projectDir = this.getProjectDir(project) const files = fs.readdirSync(projectDir).filter(f => f.endsWith('.md')) for (const file of files) { const filepath = path.join(projectDir, file) const memory = this.parseMarkdownFile(filepath) if (memory) memories.push(memory) } } else { // List memories from all projects const projectDirs = fs.readdirSync(this.config.baseDir).filter(dir => { const dirPath = path.join(this.config.baseDir, dir) return fs.statSync(dirPath).isDirectory() }) for (const projectDir of projectDirs) { const projectPath = path.join(this.config.baseDir, projectDir) const files = fs.readdirSync(projectPath).filter(f => f.endsWith('.md')) for (const file of files) { const filepath = path.join(projectPath, file) const memory = this.parseMarkdownFile(filepath) if (memory) memories.push(memory) } } } // Sort by timestamp (newest first) return memories.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) } async updateMemory(id: string, updates: Partial<Memory>): Promise<boolean> { const existingMemory = await this.getMemory(id) if (!existingMemory) return false // Delete old file fs.unlinkSync(existingMemory.filepath) // Create updated memory const updatedMemory: Memory = { ...existingMemory, ...updates, id: existingMemory.id, // Keep original ID timestamp: existingMemory.timestamp // Keep original timestamp unless explicitly updated } await this.saveMemory(updatedMemory) return true } async deleteMemory(id: string): Promise<boolean> { const memory = await this.getMemory(id) if (!memory) return false fs.unlinkSync(memory.filepath) return true } async searchMemories(query: string, project?: string): Promise<MarkdownMemory[]> { const memories = await this.listMemories(project) const lowerQuery = query.toLowerCase() return memories.filter(memory => memory.content.toLowerCase().includes(lowerQuery) || memory.tags?.some(tag => tag.toLowerCase().includes(lowerQuery)) || memory.category?.toLowerCase().includes(lowerQuery) ) } async listProjects(): Promise<string[]> { const projectDirs = fs.readdirSync(this.config.baseDir).filter(dir => { const dirPath = path.join(this.config.baseDir, dir) return fs.statSync(dirPath).isDirectory() }) return projectDirs.filter(dir => dir !== this.config.defaultProject) } async createProject(projectName: string): Promise<string> { const projectDir = path.join(this.config.baseDir, projectName) if (!fs.existsSync(projectDir)) { fs.mkdirSync(projectDir, { recursive: true }) } return projectDir } async deleteProject(projectName: string): Promise<boolean> { if (projectName === this.config.defaultProject) { throw new Error('Cannot delete default project') } const projectDir = path.join(this.config.baseDir, projectName) if (!fs.existsSync(projectDir)) return false // Move all memories to default project const memories = await this.listMemories(projectName) for (const memory of memories) { const updatedMemory: Memory = { ...memory, project: undefined // Move to default project } await this.saveMemory(updatedMemory) fs.unlinkSync(memory.filepath) } // Remove empty directory fs.rmdirSync(projectDir) return true } // Migration utility from JSON to markdown async migrateFromJSON(jsonFilePath: string): Promise<number> { if (!fs.existsSync(jsonFilePath)) { return 0 } const jsonContent = fs.readFileSync(jsonFilePath, 'utf8') const memories: Memory[] = JSON.parse(jsonContent) let migrated = 0 for (const memory of memories) { try { await this.saveMemory(memory) migrated++ } catch (error) { console.error(`Failed to migrate memory ${memory.id}:`, error) } } // Backup original JSON file const backupPath = jsonFilePath + '.backup' fs.copyFileSync(jsonFilePath, backupPath) return migrated } }