/**
* Conversation Store
*
* Task 3.1: Persistent Conversation State Store
* Constraint Removed: In-memory state → SQLite-backed persistence
*
* Witness Outcome: Conversation state survives server restart
*/
import Database from 'better-sqlite3';
import { join, dirname } from 'node:path';
import { mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { ConversationState } from './conversation-migration.js';
export interface ConversationStoreConfig {
dbPath?: string;
}
/**
* Conversation Store
*
* Provides SQLite-backed persistence for conversation state.
* Ensures WHO/WHAT/HOW dimensions survive server restarts.
*/
export class ConversationStore {
private db: Database.Database;
constructor(config: ConversationStoreConfig = {}) {
// Use module-relative path to ensure deterministic database location
// regardless of CWD (same pattern as M1 hot-reload infrastructure)
const thisFile = fileURLToPath(import.meta.url);
const thisDir = dirname(thisFile);
const projectRoot = join(thisDir, '..', '..');
const dbPath = config.dbPath || join(projectRoot, 'data', 'conversations.db');
// Ensure data directory exists
const dir = dirname(dbPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
this.db = new Database(dbPath);
this.createSchema();
console.error(`[ConversationStore] Initialized: ${dbPath}`);
}
/**
* Create database schema
*
* Schema matches ConversationState interface:
* - conversation_id: PRIMARY KEY
* - identity: JSON (WHO dimension)
* - intent_history: JSON (WHAT dimension)
* - permissions: JSON (HOW dimension)
* - created_at: TIMESTAMP
* - updated_at: TIMESTAMP
*/
private createSchema(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS conversations (
conversation_id TEXT PRIMARY KEY,
identity TEXT NOT NULL,
intent_history TEXT NOT NULL,
permissions TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
console.error('[ConversationStore] Schema initialized');
}
/**
* Get conversation state by ID
*
* Returns null if conversation doesn't exist.
*/
getConversation(conversationId: string): ConversationState | null {
const stmt = this.db.prepare(`
SELECT conversation_id, identity, intent_history, permissions
FROM conversations
WHERE conversation_id = ?
`);
const row = stmt.get(conversationId) as any;
if (!row) {
return null;
}
return this.deserialize(row);
}
/**
* Save conversation state
*
* Creates new conversation or updates existing one.
*/
saveConversation(state: ConversationState): void {
const stmt = this.db.prepare(`
INSERT INTO conversations (conversation_id, identity, intent_history, permissions, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(conversation_id) DO UPDATE SET
identity = excluded.identity,
intent_history = excluded.intent_history,
permissions = excluded.permissions,
updated_at = CURRENT_TIMESTAMP
`);
stmt.run(
state.conversationId,
JSON.stringify(state.identity),
JSON.stringify(state.intentHistory),
JSON.stringify(state.permissionHistory)
);
console.error(`[ConversationStore] Saved: ${state.conversationId}`);
}
/**
* Delete conversation
*/
deleteConversation(conversationId: string): void {
const stmt = this.db.prepare('DELETE FROM conversations WHERE conversation_id = ?');
stmt.run(conversationId);
console.error(`[ConversationStore] Deleted: ${conversationId}`);
}
/**
* List all conversation IDs
*/
listConversations(): string[] {
const stmt = this.db.prepare('SELECT conversation_id FROM conversations ORDER BY updated_at DESC');
const rows = stmt.all() as any[];
return rows.map(row => row.conversation_id);
}
/**
* Deserialize database row to ConversationState
*/
private deserialize(row: any): ConversationState {
return {
conversationId: row.conversation_id,
identity: JSON.parse(row.identity),
intentHistory: JSON.parse(row.intent_history),
permissionHistory: JSON.parse(row.permissions),
currentLevel: row.current_level ?? 1, // M4: Default to level 1 for backward compatibility
};
}
/**
* Close database connection
*/
close(): void {
this.db.close();
console.error('[ConversationStore] Closed');
}
/**
* Get statistics (for debugging)
*/
getStats(): { totalConversations: number; oldestConversation: string | null; newestConversation: string | null } {
const countStmt = this.db.prepare('SELECT COUNT(*) as count FROM conversations');
const countRow = countStmt.get() as any;
const oldestStmt = this.db.prepare('SELECT conversation_id FROM conversations ORDER BY created_at ASC LIMIT 1');
const oldestRow = oldestStmt.get() as any;
const newestStmt = this.db.prepare('SELECT conversation_id FROM conversations ORDER BY created_at DESC LIMIT 1');
const newestRow = newestStmt.get() as any;
return {
totalConversations: countRow.count,
oldestConversation: oldestRow?.conversation_id || null,
newestConversation: newestRow?.conversation_id || null,
};
}
}