@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
text/typescript
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
}
}