85ed7c3d2f
Published from npm package build Source: https://github.com/thedotmack/claude-mem-source
492 lines
14 KiB
TypeScript
492 lines
14 KiB
TypeScript
/**
|
|
* Context Templates for Human-Readable Formatting
|
|
*
|
|
* Essential templates for user-facing messages in the memory system.
|
|
* Focused on session start messages, error handling, and operation feedback.
|
|
* Previously included Handlebars templates for session start formatting; current
|
|
* version renders directly via console for clarity and performance.
|
|
*/
|
|
|
|
import { formatRelativeTime, parseTimestamp } from '../../../lib/time-utils.js';
|
|
|
|
// =============================================================================
|
|
// TERMINAL WIDTH & WORD WRAPPING
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Determines target wrap width based on:
|
|
* 1) CLAUDE_MEM_WRAP_WIDTH env override
|
|
* 2) TTY columns (capped at 120)
|
|
* 3) Fallback default of 80
|
|
*/
|
|
function getWrapWidth(): number {
|
|
const env = process.env.CLAUDE_MEM_WRAP_WIDTH;
|
|
if (env) {
|
|
const n = parseInt(env, 10);
|
|
if (!Number.isNaN(n) && n > 40 && n <= 200) return n;
|
|
}
|
|
// Default to classic 80 columns unless overridden
|
|
return 80;
|
|
}
|
|
|
|
/**
|
|
* Wrap a single logical line to the given width, preserving leading indentation.
|
|
* Also avoids wrapping pure separator lines (====, ----, etc.).
|
|
*/
|
|
function wrapSingleLine(line: string, width: number): string {
|
|
if (!line) return '';
|
|
// Don't wrap long separator lines
|
|
if (/^[\-=\u2014_\u2500\u2550]{5,}$/.test(line.trim())) return line;
|
|
|
|
// If already short enough, return as-is
|
|
if (line.length <= width) return line;
|
|
|
|
const indentMatch = line.match(/^\s*/);
|
|
const indent = indentMatch ? indentMatch[0] : '';
|
|
const content = line.slice(indent.length);
|
|
const avail = Math.max(10, width - indent.length); // keep some minimum
|
|
|
|
const words = content.split(/(\s+)/); // keep whitespace tokens
|
|
const out: string[] = [];
|
|
let current = '';
|
|
|
|
const pushLine = () => {
|
|
out.push(indent + current.trimEnd());
|
|
current = '';
|
|
};
|
|
|
|
for (const token of words) {
|
|
if (token === '') continue;
|
|
// If token itself is longer than available width, hard-break it
|
|
if (!/\s/.test(token) && token.length > avail) {
|
|
if (current.trim().length > 0) pushLine();
|
|
let start = 0;
|
|
while (start < token.length) {
|
|
const chunk = token.slice(start, start + avail);
|
|
out.push(indent + chunk);
|
|
start += avail;
|
|
}
|
|
current = '';
|
|
continue;
|
|
}
|
|
|
|
if (indent.length + current.length + token.length > width) {
|
|
pushLine();
|
|
}
|
|
current += token;
|
|
}
|
|
|
|
if (current.trim().length > 0 || out.length === 0) pushLine();
|
|
return out.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Wrap a block of text (possibly multi-line) to the given width.
|
|
* Preserves blank lines and wraps each line independently.
|
|
*/
|
|
function wrapText(text: string, width: number): string {
|
|
if (!text) return '';
|
|
return text
|
|
.split('\n')
|
|
.map((line) => wrapSingleLine(line, width))
|
|
.join('\n');
|
|
}
|
|
|
|
/** Create a full-width horizontal line with the given character */
|
|
function makeLine(char: string = '─', width: number = getWrapWidth()): string {
|
|
if (!char || char.length === 0) char = '-';
|
|
// Repeat and slice to exact width to avoid multi-column surprises
|
|
return char.repeat(width).slice(0, width);
|
|
}
|
|
|
|
// =============================================================================
|
|
// SESSION START MESSAGES
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Creates a completion message after context operations
|
|
*/
|
|
export function createCompletionMessage(
|
|
operation: string,
|
|
count?: number,
|
|
details?: string
|
|
): string {
|
|
const countInfo = count !== undefined ? ` (${count} items)` : '';
|
|
const detailInfo = details ? `\n${details}` : '';
|
|
const width = getWrapWidth();
|
|
return wrapText(
|
|
`✅ ${operation} completed successfully${countInfo}${detailInfo}`,
|
|
width
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// ERROR MESSAGES (USER-FRIENDLY)
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Creates user-friendly error messages with helpful suggestions
|
|
*/
|
|
export function createUserFriendlyError(
|
|
operation: string,
|
|
error: string,
|
|
suggestion?: string
|
|
): string {
|
|
const suggestionText = suggestion ? `\n\n💡 ${suggestion}` : '';
|
|
const width = getWrapWidth();
|
|
return wrapText(
|
|
`❌ ${operation} encountered an issue: ${error}${suggestionText}`,
|
|
width
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Common error scenarios with built-in suggestions
|
|
*/
|
|
export const ERROR_SCENARIOS = {
|
|
NO_MEMORIES: (projectName: string) => ({
|
|
message: `No previous memories found for ${projectName}`,
|
|
suggestion:
|
|
'This appears to be your first session. Memories will be created as you work.',
|
|
}),
|
|
|
|
CONNECTION_FAILED: () => ({
|
|
message: 'Could not connect to memory system',
|
|
suggestion:
|
|
'Try restarting Claude Code or check if the MCP server is properly configured.',
|
|
}),
|
|
|
|
SEARCH_FAILED: (query: string) => ({
|
|
message: `Search for "${query}" didn't return any results`,
|
|
suggestion:
|
|
'Try using different keywords or check if memories exist for this project.',
|
|
}),
|
|
|
|
LOAD_TIMEOUT: () => ({
|
|
message: 'Memory loading timed out',
|
|
suggestion:
|
|
'The operation is taking longer than expected. You can continue without loaded context.',
|
|
}),
|
|
};
|
|
|
|
/**
|
|
* Creates contextual error messages based on common scenarios
|
|
*/
|
|
export function createContextualError(
|
|
scenario: keyof typeof ERROR_SCENARIOS,
|
|
...args: string[]
|
|
): string {
|
|
const errorInfo = (ERROR_SCENARIOS[scenario] as any)(...args);
|
|
return createUserFriendlyError(
|
|
'Memory system',
|
|
errorInfo.message,
|
|
errorInfo.suggestion
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// TIME AND DATE FORMATTING
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Formats timestamps into human-readable "time ago" format
|
|
*/
|
|
export function formatTimeAgo(timestamp: string | Date): string {
|
|
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
|
const now = new Date();
|
|
const diff = now.getTime() - date.getTime();
|
|
|
|
const minutes = Math.floor(diff / 60000);
|
|
const hours = Math.floor(diff / 3600000);
|
|
const days = Math.floor(diff / 86400000);
|
|
|
|
if (minutes < 1) return 'just now';
|
|
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
|
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
|
if (days < 7) return `${days} day${days > 1 ? 's' : ''} ago`;
|
|
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
// =============================================================================
|
|
// SESSION START TEMPLATE SYSTEM (data processing only)
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Interface for memory entry data structure
|
|
*/
|
|
interface MemoryEntry {
|
|
summary: string;
|
|
keywords?: string;
|
|
location?: string;
|
|
sessionId?: string;
|
|
number?: number;
|
|
}
|
|
|
|
/**
|
|
* Interface for grouped session memories
|
|
*/
|
|
interface SessionGroup {
|
|
sessionId: string;
|
|
memories: MemoryEntry[];
|
|
}
|
|
|
|
/**
|
|
* Interface for overview with timestamp
|
|
*/
|
|
interface OverviewEntry {
|
|
content: string;
|
|
timestamp?: Date;
|
|
timeAgo?: string;
|
|
sessionId?: string;
|
|
}
|
|
|
|
/**
|
|
* Interface for session-grouped overviews
|
|
*/
|
|
interface SessionOverviewGroup {
|
|
sessionId: string;
|
|
overviews: OverviewEntry[];
|
|
earliestTimestamp?: Date;
|
|
timeAgo?: string;
|
|
}
|
|
|
|
/**
|
|
* Pure data processing function - converts JSON objects into structured memory entries
|
|
* No formatting is done here, only data parsing and cleaning
|
|
*/
|
|
function processMemoryEntries(recentObjects: any[]): MemoryEntry[] {
|
|
if (recentObjects.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Filter only memory type objects and convert to MemoryEntry format
|
|
return recentObjects
|
|
.filter((obj) => obj.type === 'memory')
|
|
.map((obj) => {
|
|
const entry: MemoryEntry = {
|
|
summary: obj.text || '',
|
|
sessionId: obj.session_id || '',
|
|
};
|
|
|
|
// Add optional fields if present
|
|
if (obj.keywords) {
|
|
entry.keywords = obj.keywords;
|
|
}
|
|
if (obj.document_id && !obj.document_id.includes('Session:')) {
|
|
entry.location = obj.document_id;
|
|
}
|
|
|
|
return entry;
|
|
})
|
|
.filter((entry) => entry.summary.length > 0);
|
|
}
|
|
|
|
/**
|
|
* Groups memories by session ID and adds numbering
|
|
*/
|
|
function groupMemoriesBySession(memories: MemoryEntry[]): SessionGroup[] {
|
|
const sessionMap = new Map<string, MemoryEntry[]>();
|
|
|
|
// Group memories by session ID
|
|
memories.forEach((memory) => {
|
|
const sessionId = memory.sessionId;
|
|
if (sessionId) {
|
|
if (!sessionMap.has(sessionId)) {
|
|
sessionMap.set(sessionId, []);
|
|
}
|
|
sessionMap.get(sessionId)!.push(memory);
|
|
}
|
|
});
|
|
|
|
// Convert to session groups with numbering
|
|
return Array.from(sessionMap.entries()).map(
|
|
([sessionId, sessionMemories]) => {
|
|
const numberedMemories = sessionMemories.map((memory, index) => ({
|
|
...memory,
|
|
number: index + 1,
|
|
}));
|
|
|
|
return {
|
|
sessionId,
|
|
memories: numberedMemories,
|
|
};
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Groups overviews by session ID and calculates session timestamps
|
|
*/
|
|
function groupOverviewsBySession(
|
|
overviews: OverviewEntry[]
|
|
): SessionOverviewGroup[] {
|
|
const sessionMap = new Map<string, OverviewEntry[]>();
|
|
|
|
// Group overviews by session ID
|
|
overviews.forEach((overview) => {
|
|
const sessionId = overview.sessionId || 'unknown';
|
|
if (!sessionMap.has(sessionId)) {
|
|
sessionMap.set(sessionId, []);
|
|
}
|
|
sessionMap.get(sessionId)!.push(overview);
|
|
});
|
|
|
|
// Convert to session groups with timestamps
|
|
return Array.from(sessionMap.entries()).map(
|
|
([sessionId, sessionOverviews]) => {
|
|
// Find the earliest timestamp in this session's overviews
|
|
const timestamps = sessionOverviews
|
|
.map((o) => o.timestamp)
|
|
.filter((t): t is Date => t !== undefined)
|
|
.sort((a, b) => a.getTime() - b.getTime());
|
|
|
|
const group: SessionOverviewGroup = {
|
|
sessionId,
|
|
overviews: sessionOverviews,
|
|
};
|
|
|
|
// Add session-level timestamp if available
|
|
if (timestamps.length > 0) {
|
|
group.earliestTimestamp = timestamps[0];
|
|
group.timeAgo = formatRelativeTime(timestamps[0]);
|
|
}
|
|
|
|
return group;
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders the complete session start template with provided data using Handlebars
|
|
* Data processing is separated from presentation - template controls the format
|
|
*/
|
|
// Intentionally removed Handlebars-based renderer; console output is handled by
|
|
// outputSessionStartContent() below.
|
|
|
|
/**
|
|
* Outputs session start content using dual streams:
|
|
* - stdout (console.log) -> Claude's context only (granular memories)
|
|
* - stderr (console.error) -> User visible (clean overviews)
|
|
*/
|
|
export function outputSessionStartContent(params: {
|
|
projectName: string;
|
|
memoryCount: number;
|
|
lastSessionTime?: string;
|
|
recentObjects: any[];
|
|
}): void {
|
|
const { projectName, memoryCount, lastSessionTime, recentObjects } = params;
|
|
const width = getWrapWidth();
|
|
|
|
// Start with current date and time at the top
|
|
const now = new Date();
|
|
const dateTimeFormatted = now.toLocaleString('en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
timeZoneName: 'short',
|
|
});
|
|
|
|
console.log('');
|
|
console.log(wrapText(`📅 ${dateTimeFormatted}`, width));
|
|
console.log(makeLine('─', width));
|
|
|
|
// Extract overviews for user display - get more to show session grouping
|
|
const overviews = extractOverviews(recentObjects, 10, projectName);
|
|
|
|
// Debug: Log what we're getting
|
|
console.error(`[DEBUG] recentObjects has ${recentObjects.length} items`);
|
|
console.error(`[DEBUG] overviews extracted: ${overviews.length}`);
|
|
|
|
// Process memory entries for Claude context
|
|
const memories = processMemoryEntries(recentObjects);
|
|
// Helper to split and normalize keywords into a map (lowercased -> original)
|
|
const splitKeywordsInto = (kw: string, dest: Map<string, string>) => {
|
|
const tokens =
|
|
kw.includes(',') || kw.includes('\n') ? kw.split(/[\n,]+/) : [kw];
|
|
for (const t of tokens) {
|
|
const trimmed = t.trim();
|
|
if (!trimmed) continue;
|
|
const key = trimmed.toLowerCase();
|
|
if (!dest.has(key)) dest.set(key, trimmed);
|
|
}
|
|
};
|
|
|
|
// Output memories first, then overviews at bottom, all sorted oldest to newest
|
|
if (memories.length > 0) {
|
|
const sessionGroups = groupMemoriesBySession(memories);
|
|
console.log('');
|
|
console.log('');
|
|
|
|
console.log(wrapText('📚 Memories', width));
|
|
sessionGroups.forEach((group) => {
|
|
console.log(makeLine('─', width));
|
|
|
|
console.log('');
|
|
|
|
console.log(wrapText(`🔍 ${group.sessionId}`, width));
|
|
|
|
// Collect keywords for this session as we iterate its memories
|
|
const groupKeywordMap = new Map<string, string>();
|
|
|
|
group.memories.forEach((memory) => {
|
|
console.log('');
|
|
console.log(wrapText(`${memory.number}. ${memory.summary}`, width));
|
|
if (memory.keywords)
|
|
splitKeywordsInto(memory.keywords, groupKeywordMap);
|
|
});
|
|
|
|
// Print this session's aggregated keywords under the session block
|
|
const groupKeywords = Array.from(groupKeywordMap.values());
|
|
if (groupKeywords.length > 0) {
|
|
console.log('');
|
|
console.log(wrapText(`🏷️ ${groupKeywords.join(', ')}`, width));
|
|
}
|
|
console.log('');
|
|
});
|
|
}
|
|
|
|
// Overview section at bottom with session grouping
|
|
if (overviews.length > 0) {
|
|
const sessionGroups = groupOverviewsBySession(overviews);
|
|
|
|
// Sort groups by timestamp, oldest first for chronological reading order
|
|
sessionGroups.sort((a, b) => {
|
|
const timeA = a.earliestTimestamp?.getTime() || 0;
|
|
const timeB = b.earliestTimestamp?.getTime() || 0;
|
|
return timeA - timeB; // Ascending order (oldest first)
|
|
});
|
|
|
|
console.log('');
|
|
|
|
console.log(wrapText('🧠 Overviews', width));
|
|
console.log(makeLine('─', width));
|
|
|
|
// Match the memories section layout: session header, numbered items, per-session separator
|
|
sessionGroups.forEach((group) => {
|
|
console.log('');
|
|
console.log(wrapText(`🔍 ${group.sessionId}`, width));
|
|
|
|
group.overviews.forEach((overview, index) => {
|
|
console.log('');
|
|
console.log(wrapText(`${index + 1}. ${overview.content}`, width));
|
|
console.log('');
|
|
|
|
if (overview.timeAgo) {
|
|
console.log(wrapText(`📅 ${overview.timeAgo}`, width));
|
|
}
|
|
});
|
|
|
|
console.log('');
|
|
console.log(makeLine('─', width));
|
|
});
|
|
} else if (memories.length === 0) {
|
|
console.log(
|
|
wrapText(`🧠 No recent context found for ${projectName}`, width)
|
|
);
|
|
}
|
|
}
|