Skip to main content
Glama
DecisionExtractor.tsโ€ข9.2 kB
/** * DecisionExtractor - Extract writing decisions from conversation history * * Parses Claude Code conversation files to identify and extract: * - Structural decisions (reorganization, chapter order) * - Content decisions (what to include/exclude) * - Terminology decisions (word choices, naming) * - Style decisions (tone, formatting) * * Captures the WHY behind decisions for holistic memory. */ import { nanoid } from "nanoid"; import type { SQLiteManager } from "../storage/SQLiteManager.js"; export interface WritingDecision { id: string; sessionId?: string; filePath?: string; section?: string; decisionText: string; rationale?: string; alternativesConsidered?: string[]; timestamp: number; decisionType?: DecisionType; createdAt: number; } export type DecisionType = "structure" | "content" | "terminology" | "style"; export interface ConversationMessage { role: "user" | "assistant"; content: string | unknown[]; timestamp?: number; } export class DecisionExtractor { private db: SQLiteManager; constructor(db: SQLiteManager) { this.db = db; } /** * Extract decisions from conversation messages */ extractDecisions( messages: ConversationMessage[], sessionId?: string, projectPath?: string ): WritingDecision[] { const decisions: WritingDecision[] = []; for (let i = 0; i < messages.length; i++) { const message = messages[i]; const nextMessage = i < messages.length - 1 ? messages[i + 1] : undefined; // Look for decision patterns in user-assistant exchanges if (message.role === "user" && nextMessage?.role === "assistant") { const extracted = this.analyzeExchange( message, nextMessage, sessionId, projectPath ); if (extracted) { decisions.push(extracted); } } } return decisions; } /** * Store a decision in the database */ storeDecision(decision: WritingDecision): void { this.db .prepare( `INSERT INTO writing_decisions (id, session_id, file_path, section, decision_text, rationale, alternatives_considered, timestamp, decision_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( decision.id, decision.sessionId || null, decision.filePath || null, decision.section || null, decision.decisionText, decision.rationale || null, decision.alternativesConsidered ? JSON.stringify(decision.alternativesConsidered) : null, decision.timestamp, decision.decisionType || null, decision.createdAt ); } /** * Get decisions for a session */ getDecisionsBySession(sessionId: string): WritingDecision[] { const rows = this.db .prepare( `SELECT id, session_id, file_path, section, decision_text, rationale, alternatives_considered, timestamp, decision_type, created_at FROM writing_decisions WHERE session_id = ? ORDER BY timestamp ASC` ) .all(sessionId) as DecisionRow[]; return rows.map((row) => this.rowToDecision(row)); } /** * Get decisions for a file */ getDecisionsByFile(filePath: string, limit = 10): WritingDecision[] { const rows = this.db .prepare( `SELECT id, session_id, file_path, section, decision_text, rationale, alternatives_considered, timestamp, decision_type, created_at FROM writing_decisions WHERE file_path = ? ORDER BY timestamp DESC LIMIT ?` ) .all(filePath, limit) as DecisionRow[]; return rows.map((row) => this.rowToDecision(row)); } /** * Search decisions by type */ getDecisionsByType( decisionType: DecisionType, limit = 20 ): WritingDecision[] { const rows = this.db .prepare( `SELECT id, session_id, file_path, section, decision_text, rationale, alternatives_considered, timestamp, decision_type, created_at FROM writing_decisions WHERE decision_type = ? ORDER BY timestamp DESC LIMIT ?` ) .all(decisionType, limit) as DecisionRow[]; return rows.map((row) => this.rowToDecision(row)); } /** * Analyze user-assistant exchange for decision patterns */ private analyzeExchange( userMessage: ConversationMessage, assistantMessage: ConversationMessage, sessionId?: string, projectPath?: string ): WritingDecision | undefined { const userText = this.extractText(userMessage.content); const assistantText = this.extractText(assistantMessage.content); // Decision indicators in user messages const decisionIndicators = [ /should I (.*)/i, /which (approach|method|way|option) (.*)/i, /how (should|can) I (.*)/i, /what('s| is) (the )?best (way|approach) (.*)/i, /reorganize|restructure|refactor/i, /move (chapter|section)/i, /rename|change (the name|terminology)/i, ]; const hasDecisionIndicator = decisionIndicators.some((pattern) => pattern.test(userText) ); if (!hasDecisionIndicator) { return undefined; } // Extract decision components from assistant response const decision = this.parseDecision( userText, assistantText, sessionId, projectPath ); return decision; } /** * Parse decision from user question and assistant response */ private parseDecision( userText: string, assistantText: string, sessionId?: string, projectPath?: string ): WritingDecision | undefined { // Extract file path if mentioned const fileMatch = userText.match(/chapter(\d+)\.md/i) || userText.match(/([a-z-]+)\.md/i) || assistantText.match(/chapter(\d+)\.md/i) || assistantText.match(/([a-z-]+)\.md/i); const filePath = fileMatch ? fileMatch[0] : undefined; // Extract decision text (first substantive sentence from assistant) const sentences = assistantText.split(/[.!?]\s+/); const decisionText = sentences.find( (s) => s.length > 20 && !s.startsWith("I") ); if (!decisionText) { return undefined; } // Extract rationale (sentences with "because", "since", "to", "for") const rationaleMatch = assistantText.match( /(because|since|to|for) ([^.!?]+)/i ); const rationale = rationaleMatch ? rationaleMatch[0] : undefined; // Determine decision type const decisionType = this.classifyDecision(userText, assistantText); const now = Date.now(); return { id: nanoid(), sessionId, filePath: filePath ? `${projectPath ? projectPath + "/" : ""}${filePath}` : undefined, decisionText, rationale, timestamp: now, decisionType, createdAt: now, }; } /** * Classify decision type based on content */ private classifyDecision( userText: string, assistantText: string ): DecisionType | undefined { const combined = (userText + " " + assistantText).toLowerCase(); if ( /reorganize|restructure|move chapter|reorder|chapter.*before/i.test( combined ) ) { return "structure"; } if (/rename|terminology|call it|name.*instead|word choice/i.test(combined)) { return "terminology"; } if (/tone|style|format|heading|citation/i.test(combined)) { return "style"; } if (/add|remove|include|exclude|content|paragraph/i.test(combined)) { return "content"; } return undefined; } /** * Extract text from message content (handles both string and array formats) */ private extractText(content: string | unknown[]): string { if (typeof content === "string") { return content; } if (Array.isArray(content)) { return content .map((item) => { if (typeof item === "string") { return item; } if ( typeof item === "object" && item !== null && "text" in item && typeof item.text === "string" ) { return item.text; } return ""; }) .join(" "); } return ""; } /** * Convert database row to WritingDecision */ private rowToDecision(row: DecisionRow): WritingDecision { return { id: row.id, sessionId: row.session_id || undefined, filePath: row.file_path || undefined, section: row.section || undefined, decisionText: row.decision_text, rationale: row.rationale || undefined, alternativesConsidered: row.alternatives_considered ? JSON.parse(row.alternatives_considered) : undefined, timestamp: row.timestamp, decisionType: row.decision_type as DecisionType | undefined, createdAt: row.created_at, }; } } interface DecisionRow { id: string; session_id: string | null; file_path: string | null; section: string | null; decision_text: string; rationale: string | null; alternatives_considered: string | null; timestamp: number; decision_type: string | null; created_at: number; }

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/xiaolai/claude-writers-aid-mcp'

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