@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.
729 lines (608 loc) • 24 kB
JavaScript
/**
* Title and Summary Generator for Memory Cards
* Generates concise, card-optimized titles and summaries for memories
*/
import { MemoryFormat } from './memory-format.js';
import { OllamaClient } from './ollama-client.js';
export class TitleSummaryGenerator {
static ollamaClient = null;
/**
* Initialize Ollama client for local AI processing
*/
static async initializeOllama(options = {}) {
this.ollamaClient = new OllamaClient('http://localhost:11434', {
model: options.model || 'llama3.1:8b',
temperature: 0.1,
batchSize: options.batchSize || 5,
...options
});
const available = await this.ollamaClient.isAvailable();
if (!available) {
console.warn('🤖 Ollama server not available - falling back to rule-based generation');
this.ollamaClient = null;
return false;
}
console.error(`🤖 Ollama client initialized with model: ${this.ollamaClient.options.model}`);
return true;
}
/**
* Enhance memory with local AI (Ollama)
*/
static async enhanceWithOllama(content, metadata = {}) {
if (!this.ollamaClient) {
await this.initializeOllama();
}
if (!this.ollamaClient) {
throw new Error('Ollama not available');
}
try {
const enhancement = await this.ollamaClient.enhanceMemory(content, metadata);
return enhancement;
} catch (error) {
console.error('Ollama enhancement failed:', error);
throw error;
}
}
/**
* Batch enhance multiple memories with Ollama
*/
static async batchEnhanceWithOllama(memories, onProgress = null) {
if (!this.ollamaClient) {
await this.initializeOllama();
}
if (!this.ollamaClient) {
throw new Error('Ollama not available');
}
return await this.ollamaClient.enhanceMemoriesBatch(memories, onProgress);
}
/**
* Generate a concise title for memory card display
* @param {string} content - The memory content
* @param {Object} metadata - Memory metadata (category, tags, etc.)
* @param {boolean} useOllama - Whether to use local AI (default: false)
* @returns {string} - Title (max 60 chars)
*/
static async generateTitle(content, metadata = {}, useOllama = false) {
// Parse content if it's in markdown format
let parsedContent = content;
let parsedMetadata = metadata;
try {
const parsed = MemoryFormat.parseMarkdown(content);
if (parsed && parsed.metadata) {
parsedContent = parsed.content;
parsedMetadata = { ...parsed.metadata, ...metadata };
}
} catch (e) {
// Not markdown format, use as-is
}
// Check for existing title in tags
if (parsedMetadata.tags && Array.isArray(parsedMetadata.tags)) {
const titleTag = parsedMetadata.tags.find(tag => tag.startsWith('title:'));
if (titleTag) {
return titleTag.substring(6).trim();
}
}
// Try Ollama enhancement if requested
if (useOllama) {
try {
const enhancement = await this.enhanceWithOllama(parsedContent, parsedMetadata);
return enhancement.title;
} catch (error) {
console.warn('Ollama title generation failed, falling back to rule-based:', error.message);
}
}
// Generate title based on content type (fallback)
const category = parsedMetadata.category || this.detectCategory(parsedContent);
return this.generateTitleByCategory(parsedContent, category);
}
/**
* Generate a concise summary for memory card display
* @param {string} content - The memory content
* @param {Object} metadata - Memory metadata
* @returns {string} - Summary (max 150 chars)
*/
static generateSummary(content, metadata = {}) {
// Parse content if it's in markdown format
let parsedContent = content;
let parsedMetadata = metadata;
try {
const parsed = MemoryFormat.parseMarkdown(content);
if (parsed && parsed.metadata) {
parsedContent = parsed.content;
parsedMetadata = { ...parsed.metadata, ...metadata };
}
} catch (e) {
// Not markdown format, use as-is
}
// Check for existing summary in tags
if (parsedMetadata.tags && Array.isArray(parsedMetadata.tags)) {
const summaryTag = parsedMetadata.tags.find(tag => tag.startsWith('summary:'));
if (summaryTag) {
return summaryTag.substring(8).trim();
}
}
// Generate summary based on content type
const category = parsedMetadata.category || this.detectCategory(parsedContent);
return this.generateSummaryByCategory(parsedContent, category);
}
/**
* Generate title based on category
*/
static generateTitleByCategory(content, category) {
const maxLength = 60;
// First try to extract a smart title from markdown structure
const smartTitle = this.extractSmartTitle(content, maxLength);
if (smartTitle) {
return smartTitle;
}
switch (category) {
case 'code':
return this.generateCodeTitle(content, maxLength);
case 'work':
return this.generateWorkTitle(content, maxLength);
case 'research':
return this.generateResearchTitle(content, maxLength);
case 'conversations':
return this.generateConversationTitle(content, maxLength);
case 'personal':
return this.generatePersonalTitle(content, maxLength);
default:
return this.generateGenericTitle(content, maxLength);
}
}
/**
* Extract smart title from markdown content
*/
static extractSmartTitle(content, maxLength) {
// Remove common markdown formatting
const cleanContent = content.replace(/[#*_`]/g, '').trim();
// Look for markdown headers and extract meaningful parts
const headerMatch = content.match(/^#{1,6}\s+(.+)$/m);
if (headerMatch) {
let title = headerMatch[1].trim();
// Handle long titles by extracting key words
if (title.length > maxLength) {
title = this.extractKeyWordsFromTitle(title, maxLength);
}
return this.truncate(title, maxLength);
}
// Look for structured patterns (improved)
const structuredPatterns = [
/^(.+?):\s*[#\n]/m, // "Title: content" or "Title: #"
/^"(.+?)"/m, // Quoted titles
/^\*\*(.+?)\*\*/m, // Bold markdown
/^__(.+?)__/m, // Bold underscore
/^\[(.+?)\]/m, // Bracketed content
/^(\d+\.?\s*[A-Z][^.!?]*)/m, // Numbered items
];
for (const pattern of structuredPatterns) {
const match = content.match(pattern);
if (match && match[1].length > 5) {
let title = match[1].trim();
// Handle long titles
if (title.length > maxLength) {
title = this.extractKeyWordsFromTitle(title, maxLength);
}
return this.truncate(title, maxLength);
}
}
// Extract from first meaningful sentence
const sentences = cleanContent.split(/[.!?\n]+/).filter(s => s.trim().length > 10);
for (const sentence of sentences.slice(0, 3)) {
const cleaned = sentence.trim();
// Skip generic patterns
if (!cleaned.match(/^(project location|current|status|update|working|running|command|please|this|that|the|a|an)/i)) {
if (cleaned.length > maxLength) {
const keyWords = this.extractKeyWordsFromTitle(cleaned, maxLength);
return this.truncate(keyWords, maxLength);
}
if (cleaned.length > 15 && cleaned.length <= maxLength) {
return cleaned;
}
}
}
return null;
}
/**
* Extract key words from long titles
*/
static extractKeyWordsFromTitle(title, maxLength) {
// Remove common words and extract key terms
const stopWords = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall'];
const words = title.toLowerCase().split(/\s+/);
const keyWords = words.filter(word =>
word.length > 2 &&
!stopWords.includes(word) &&
/^[a-z0-9]+$/i.test(word)
);
// Build title from key words
let result = '';
for (const word of keyWords) {
const capitalized = word.charAt(0).toUpperCase() + word.slice(1);
if ((result + ' ' + capitalized).length > maxLength - 3) {
break;
}
result += (result ? ' ' : '') + capitalized;
}
// If we couldn't extract enough key words, try a different approach
if (result.length < 20) {
// Take first few words of the original title
const firstWords = title.split(/\s+/).slice(0, 6);
result = firstWords.join(' ');
}
return result;
}
/**
* Generate summary based on category
*/
static generateSummaryByCategory(content, category) {
const maxLength = 150;
// First try to extract a smart summary from markdown structure
const smartSummary = this.extractSmartSummary(content, maxLength);
if (smartSummary) {
return smartSummary;
}
switch (category) {
case 'code':
return this.generateCodeSummary(content, maxLength);
case 'work':
return this.generateWorkSummary(content, maxLength);
case 'research':
return this.generateResearchSummary(content, maxLength);
case 'conversations':
return this.generateConversationSummary(content, maxLength);
case 'personal':
return this.generatePersonalSummary(content, maxLength);
default:
return this.generateGenericSummary(content, maxLength);
}
}
/**
* Extract smart summary from markdown content
*/
static extractSmartSummary(content, maxLength) {
// Remove markdown formatting for better text extraction
const cleanContent = content.replace(/[#*_`]/g, '').trim();
// Look for summary patterns in markdown
const summaryPatterns = [
/^#{1,6}\s+.+?\n+(.+?)(?:\n#{1,6}|\n\n|$)/m, // Text after first header
/^(.+?)\n+#{1,6}/m, // Text before first header
/^>(.+?)(?:\n|$)/m, // Blockquote
/^\*\*(.+?)\*\*(.+?)(?:\n|$)/m, // Bold text with description
];
for (const pattern of summaryPatterns) {
const match = content.match(pattern);
if (match) {
let summary = (match[1] || match[0]).trim();
// Clean up the summary
summary = summary.replace(/[#*_`]/g, '').trim();
// Skip if too short or looks like a title
if (summary.length > 20 && summary.length <= maxLength) {
return summary;
} else if (summary.length > maxLength) {
return this.truncate(summary, maxLength);
}
}
}
// Extract meaningful sentences
const sentences = cleanContent.split(/[.!?\n]+/).filter(s => s.trim().length > 15);
// Skip header-like sentences and get description
const meaningfulSentences = sentences.filter(sentence => {
const cleaned = sentence.trim();
return !cleaned.match(/^(#{1,6}|command|please|this is|here is|ultimate|comprehensive)/i) &&
cleaned.length > 20 &&
cleaned.length < 200;
});
if (meaningfulSentences.length > 0) {
let summary = meaningfulSentences.slice(0, 2).join('. ').trim();
// Ensure proper sentence ending
if (summary && !summary.match(/[.!?]$/)) {
summary += '.';
}
return this.truncate(summary, maxLength);
}
// Fallback: use first decent sentence
for (const sentence of sentences.slice(0, 3)) {
const cleaned = sentence.trim();
if (cleaned.length > 30 && cleaned.length <= maxLength) {
return cleaned.endsWith('.') ? cleaned : cleaned + '.';
}
}
return null;
}
/**
* Code-specific title generation
*/
static generateCodeTitle(content, maxLength) {
// Look for function/class names
const functionMatch = content.match(/(?:function|const|let|var)\s+(\w+)/);
if (functionMatch) {
return this.truncate(`Function: ${functionMatch[1]}`, maxLength);
}
const classMatch = content.match(/class\s+(\w+)/);
if (classMatch) {
return this.truncate(`Class: ${classMatch[1]}`, maxLength);
}
// Look for imports/requires
const importMatch = content.match(/(?:import|require)\s+.*?from\s+['"](.+?)['"]/);
if (importMatch) {
const moduleName = importMatch[1].split('/').pop();
return this.truncate(`Code using ${moduleName}`, maxLength);
}
// Look for language hints
const langMatch = content.match(/```(\w+)/);
if (langMatch) {
return this.truncate(`${langMatch[1]} code snippet`, maxLength);
}
return this.truncate('Code snippet', maxLength);
}
/**
* Work-specific title generation
*/
static generateWorkTitle(content, maxLength) {
// Look for meeting patterns
if (content.toLowerCase().includes('meeting')) {
const dateMatch = content.match(/\d{1,2}[/-]\d{1,2}[/-]\d{2,4}/);
if (dateMatch) {
return this.truncate(`Meeting notes - ${dateMatch[0]}`, maxLength);
}
return this.truncate('Meeting notes', maxLength);
}
// Look for project mentions
const projectMatch = content.match(/project[:\s]+([^.\n]+)/i);
if (projectMatch) {
return this.truncate(`Project: ${projectMatch[1].trim()}`, maxLength);
}
// Look for task patterns
const taskMatch = content.match(/(?:task|todo|action)[:\s]+([^.\n]+)/i);
if (taskMatch) {
return this.truncate(`Task: ${taskMatch[1].trim()}`, maxLength);
}
return this.extractFirstMeaningfulPhrase(content, maxLength, 'Work note');
}
/**
* Research-specific title generation
*/
static generateResearchTitle(content, maxLength) {
// Look for research topics
const topicMatch = content.match(/(?:research|study|analysis)\s+(?:on|of|about)\s+([^.\n]+)/i);
if (topicMatch) {
return this.truncate(`Research: ${topicMatch[1].trim()}`, maxLength);
}
// Look for findings
const findingMatch = content.match(/(?:found|discovered|concluded)\s+(?:that\s+)?([^.\n]+)/i);
if (findingMatch) {
return this.truncate(`Finding: ${findingMatch[1].trim()}`, maxLength);
}
return this.extractFirstMeaningfulPhrase(content, maxLength, 'Research note');
}
/**
* Conversation-specific title generation
*/
static generateConversationTitle(content, maxLength) {
// Look for participants
const withMatch = content.match(/(?:with|call with|talked to)\s+([^.\n,]+)/i);
if (withMatch) {
return this.truncate(`Conversation with ${withMatch[1].trim()}`, maxLength);
}
// Look for topics discussed
const aboutMatch = content.match(/(?:discussed|talked about)\s+([^.\n]+)/i);
if (aboutMatch) {
return this.truncate(`Discussion: ${aboutMatch[1].trim()}`, maxLength);
}
return this.extractFirstMeaningfulPhrase(content, maxLength, 'Conversation');
}
/**
* Personal-specific title generation
*/
static generatePersonalTitle(content, maxLength) {
// Look for personal patterns
const feelingMatch = content.match(/(?:feel|feeling|felt)\s+([^.\n]+)/i);
if (feelingMatch) {
return this.truncate(`Feeling: ${feelingMatch[1].trim()}`, maxLength);
}
const thoughtMatch = content.match(/(?:think|thought|believe)\s+([^.\n]+)/i);
if (thoughtMatch) {
return this.truncate(`Thought: ${thoughtMatch[1].trim()}`, maxLength);
}
return this.extractFirstMeaningfulPhrase(content, maxLength, 'Personal note');
}
/**
* Generic title generation
*/
static generateGenericTitle(content, maxLength) {
return this.extractFirstMeaningfulPhrase(content, maxLength, 'Memory');
}
/**
* Code-specific summary generation
*/
static generateCodeSummary(content, maxLength) {
const parts = [];
// Language
const langMatch = content.match(/```(\w+)/);
if (langMatch) {
parts.push(langMatch[1]);
}
// Function/class names
const functionMatches = content.match(/(?:function|const|let|var)\s+(\w+)/g);
if (functionMatches && functionMatches.length > 0) {
parts.push(`${functionMatches.length} function${functionMatches.length > 1 ? 's' : ''}`);
}
const classMatches = content.match(/class\s+(\w+)/g);
if (classMatches && classMatches.length > 0) {
parts.push(`${classMatches.length} class${classMatches.length > 1 ? 'es' : ''}`);
}
// Key operations
if (content.includes('import') || content.includes('require')) {
parts.push('with imports');
}
if (parts.length > 0) {
return this.truncate(parts.join(', '), maxLength);
}
return this.truncate(this.getFirstSentences(content, 2), maxLength);
}
/**
* Work-specific summary generation
*/
static generateWorkSummary(content, maxLength) {
const keyPoints = [];
// Extract action items
const actionMatches = content.match(/(?:todo|action|task)[:\s]+([^.\n]+)/gi);
if (actionMatches) {
keyPoints.push(`${actionMatches.length} action item${actionMatches.length > 1 ? 's' : ''}`);
}
// Extract decisions
const decisionMatches = content.match(/(?:decided|agreed|concluded)[:\s]+([^.\n]+)/gi);
if (decisionMatches) {
keyPoints.push(`${decisionMatches.length} decision${decisionMatches.length > 1 ? 's' : ''}`);
}
if (keyPoints.length > 0) {
const summary = this.getFirstSentences(content, 1) + '. ' + keyPoints.join(', ');
return this.truncate(summary, maxLength);
}
return this.truncate(this.getFirstSentences(content, 2), maxLength);
}
/**
* Research-specific summary generation
*/
static generateResearchSummary(content, maxLength) {
// Look for key findings or conclusions
const conclusionMatch = content.match(/(?:conclusion|finding|result)[:\s]+([^.\n]+)/i);
if (conclusionMatch) {
return this.truncate(`Key finding: ${conclusionMatch[1].trim()}`, maxLength);
}
return this.truncate(this.getFirstSentences(content, 2), maxLength);
}
/**
* Conversation-specific summary generation
*/
static generateConversationSummary(content, maxLength) {
const topics = [];
// Extract topics
const topicMatches = content.match(/(?:discussed|talked about|mentioned)\s+([^.\n,]+)/gi);
if (topicMatches) {
topics.push(...topicMatches.slice(0, 2).map(m => m.replace(/discussed|talked about|mentioned/i, '').trim()));
}
if (topics.length > 0) {
return this.truncate(`Topics: ${topics.join(', ')}`, maxLength);
}
return this.truncate(this.getFirstSentences(content, 2), maxLength);
}
/**
* Personal-specific summary generation
*/
static generatePersonalSummary(content, maxLength) {
return this.truncate(this.getFirstSentences(content, 2), maxLength);
}
/**
* Generic summary generation
*/
static generateGenericSummary(content, maxLength) {
return this.truncate(this.getFirstSentences(content, 2), maxLength);
}
/**
* Helper: Extract first meaningful phrase
*/
static extractFirstMeaningfulPhrase(content, maxLength, fallback) {
const lines = content.split('\n').filter(line => line.trim().length > 10);
for (const line of lines) {
// Skip generic starters
if (line.match(/^(the|this|that|it|there|here)/i)) {
continue;
}
// Remove markdown formatting
const cleaned = line.replace(/[#*_`]/g, '').trim();
if (cleaned.length > 15) {
return this.truncate(cleaned, maxLength);
}
}
return fallback;
}
/**
* Helper: Get first N sentences
*/
static getFirstSentences(content, count) {
const sentences = content
.split(/[.!?]+/)
.map(s => s.trim())
.filter(s => s.length > 10);
return sentences.slice(0, count).join('. ');
}
/**
* Helper: Truncate text with ellipsis
*/
static truncate(text, maxLength) {
if (!text) return '';
const cleaned = text.trim();
if (cleaned.length <= maxLength) {
return cleaned;
}
return cleaned.substring(0, maxLength - 3) + '...';
}
/**
* Helper: Detect category from content
*/
static detectCategory(content) {
const lowerContent = content.toLowerCase();
// Code detection
if (content.includes('```') ||
content.includes('function') ||
content.includes('const ') ||
content.includes('import ') ||
/\b(bug|fix|debug|error|exception)\b/.test(lowerContent)) {
return 'code';
}
// Work detection
if (/\b(meeting|deadline|project|team|client|business)\b/.test(lowerContent)) {
return 'work';
}
// Research detection
if (/\b(research|study|analysis|investigation|findings)\b/.test(lowerContent)) {
return 'research';
}
// Conversation detection
if (/\b(conversation|discussed|talked|said|mentioned)\b/.test(lowerContent) ||
content.includes('"')) {
return 'conversations';
}
// Personal detection
if (/\b(my|I|me|myself|personal|feel|think)\b/.test(lowerContent)) {
return 'personal';
}
return 'general';
}
/**
* Generate prompts for LLM-based title/summary generation
*/
static generateTitlePrompt(content, category) {
const categoryDescriptions = {
code: 'code snippet or technical content',
work: 'work-related note or meeting',
research: 'research or analysis',
conversations: 'conversation or discussion',
personal: 'personal note or thought',
general: 'general content'
};
const categoryDesc = categoryDescriptions[category] || categoryDescriptions.general;
return `Generate a concise, descriptive title (max 60 characters) for this ${categoryDesc}.
The title should clearly indicate what this memory is about and be optimized for display on a card interface.
Content: ${content.substring(0, 1000)}${content.length > 1000 ? '...' : ''}
Respond with ONLY the title, no quotes or additional text.`;
}
static generateSummaryPrompt(content, category) {
const categoryInstructions = {
code: 'Focus on what the code does, key functions/classes, and technologies used.',
work: 'Highlight key decisions, action items, or outcomes.',
research: 'Emphasize findings, conclusions, or key insights.',
conversations: 'Mention participants and main topics discussed.',
personal: 'Capture the main thought or feeling expressed.',
general: 'Summarize the main points or purpose.'
};
const instruction = categoryInstructions[category] || categoryInstructions.general;
return `Generate a brief summary (max 150 characters) for this memory.
${instruction}
The summary should give a clear overview for card display.
Content: ${content.substring(0, 2000)}${content.length > 2000 ? '...' : ''}
Respond with ONLY the summary, no quotes or additional text.`;
}
}