claude-story
Version:
Automatic conversation history manager for Claude Code. Auto-saves all Claude conversations to markdown files with SQLite database tracking and auto-start support, just like SpecStory for Cursor.
291 lines (254 loc) • 8.04 kB
JavaScript
import sqlite3 from 'sqlite3';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
export class ClaudeStoryDB {
constructor(projectPath) {
this.dbPath = path.join(projectPath, '.claude-story', 'conversations.db');
this.historyPath = path.join(projectPath, '.claude-story', 'history');
// Ensure directories exist
fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
fs.mkdirSync(this.historyPath, { recursive: true });
this.db = null;
}
async init() {
return new Promise((resolve, reject) => {
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) reject(err);
else {
this.createTables().then(resolve).catch(reject);
}
});
});
}
async createTables() {
return new Promise((resolve, reject) => {
this.db.serialize(() => {
// Conversations table
this.db.run(`
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
export_path TEXT,
session_id TEXT UNIQUE
)
`);
// Messages table
this.db.run(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
uuid TEXT UNIQUE,
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
)
`);
// No metadata table needed - everything is in conversations/messages
resolve();
});
});
}
async startConversation(title, sessionId = null) {
const id = uuidv4();
// End any active conversations
await this.endActiveConversations();
return new Promise((resolve, reject) => {
this.db.run(
'INSERT INTO conversations (id, title, session_id) VALUES (?, ?, ?)',
[id, title, sessionId],
function(err) {
if (err) reject(err);
else resolve(id);
}
);
});
}
async endActiveConversations() {
return new Promise((resolve, reject) => {
this.db.run(
'UPDATE conversations SET is_active = 0 WHERE is_active = 1',
(err) => {
if (err) reject(err);
else resolve();
}
);
});
}
async addMessage(conversationId, role, content) {
const db = this.db;
return new Promise((resolve, reject) => {
db.run(
'INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)',
[conversationId, role, content],
function(err) {
if (err) reject(err);
else {
// Update conversation timestamp
db.run(
'UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[conversationId],
() => resolve(this.lastID)
);
}
}
);
});
}
async checkAndEndStaleConversations(timeoutMinutes = 30) {
const timeoutMs = timeoutMinutes * 60 * 1000;
const cutoffTime = new Date(Date.now() - timeoutMs).toISOString();
return new Promise((resolve, reject) => {
this.db.run(
'UPDATE conversations SET is_active = 0 WHERE is_active = 1 AND updated_at < ?',
[cutoffTime],
function(err) {
if (err) reject(err);
else resolve(this.changes);
}
);
});
}
async autoStartConversationIfNeeded(defaultTitle = 'Untitled Conversation') {
// Check for and end stale conversations
await this.checkAndEndStaleConversations();
// Check if we have an active conversation
const active = await this.getActiveConversation();
if (!active) {
// Auto-start a new conversation
const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const title = `${defaultTitle} (${timestamp})`;
return await this.startConversation(title);
}
return active.id;
}
async getActiveConversation() {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT * FROM conversations WHERE is_active = 1',
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
}
async getConversation(id) {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT * FROM conversations WHERE id = ?',
[id],
(err, conversation) => {
if (err) reject(err);
else if (!conversation) resolve(null);
else {
// Get messages
this.db.all(
'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at',
[id],
(err, messages) => {
if (err) reject(err);
else resolve({ ...conversation, messages });
}
);
}
}
);
});
}
async exportToMarkdown(conversationId) {
const conversation = await this.getConversation(conversationId);
if (!conversation) return null;
const timestamp = new Date(conversation.created_at).toISOString()
.replace(/:/g, '-').slice(0, 19) + 'Z';
const slug = conversation.title.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50);
const filename = `${timestamp}-${slug}.md`;
const filepath = path.join(this.historyPath, filename);
let content = `<!-- Generated by Claude Story -->
# ${conversation.title} (${new Date(conversation.created_at).toISOString().slice(0, 10)} ${new Date(conversation.created_at).toTimeString().slice(0, 5)})
`;
for (const msg of conversation.messages) {
content += `_**${msg.role === 'user' ? 'User' : 'Assistant'}**_
${msg.content}
---
`;
}
fs.writeFileSync(filepath, content);
// Update export path
await new Promise((resolve, reject) => {
this.db.run(
'UPDATE conversations SET export_path = ? WHERE id = ?',
[filepath, conversationId],
(err) => err ? reject(err) : resolve()
);
});
return filename;
}
async listConversations() {
return new Promise((resolve, reject) => {
this.db.all(
'SELECT * FROM conversations ORDER BY updated_at DESC',
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
}
async getConversationBySessionId(sessionId) {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT * FROM conversations WHERE session_id = ?',
[sessionId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
}
async messageExists(uuid) {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT 1 FROM messages WHERE uuid = ?',
[uuid],
(err, row) => {
if (err) reject(err);
else resolve(!!row);
}
);
});
}
async addMessageWithUuid(conversationId, role, content, timestamp, uuid) {
const db = this.db;
return new Promise((resolve, reject) => {
db.run(
'INSERT INTO messages (conversation_id, role, content, created_at, uuid) VALUES (?, ?, ?, ?, ?)',
[conversationId, role, content, timestamp, uuid],
function(err) {
if (err) reject(err);
else {
// Update conversation timestamp
db.run(
'UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[conversationId],
() => resolve(this.lastID)
);
}
}
);
});
}
close() {
if (this.db) {
this.db.close();
}
}
}