Release v3.6.3

Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
This commit is contained in:
Alex Newman
2025-09-11 17:15:50 -04:00
parent c4eb2e2dc9
commit 97807494fd
43 changed files with 10632 additions and 286 deletions
+238
View File
@@ -0,0 +1,238 @@
/**
* ChunkManager - Handles intelligent chunking of large transcripts
*
* This class manages the splitting of large filtered transcripts into chunks
* that fit within Claude's 32k token limit while preserving conversation context
* and maintaining message integrity.
*/
export interface ChunkMetadata {
chunkNumber: number;
totalChunks: number;
startIndex: number;
endIndex: number;
messageCount: number;
estimatedTokens: number;
sizeBytes: number;
hasOverlap: boolean;
overlapMessages?: number;
firstTimestamp?: string;
lastTimestamp?: string;
}
export interface ChunkingOptions {
maxTokensPerChunk?: number; // default: 28000 (leaving 4k buffer)
maxBytesPerChunk?: number; // default: 98000 (98KB)
preserveContext?: boolean; // keep context overlap between chunks
contextOverlap?: number; // messages to repeat (default: 2)
parallel?: boolean; // process chunks in parallel
}
export interface ChunkedMessage {
content: string;
estimatedTokens: number;
}
export class ChunkManager {
private static readonly DEFAULT_MAX_TOKENS = 28000;
private static readonly DEFAULT_MAX_BYTES = 98000;
private static readonly DEFAULT_CONTEXT_OVERLAP = 2;
private static readonly CHARS_PER_TOKEN_ESTIMATE = 3.5;
private options: Required<ChunkingOptions>;
constructor(options: ChunkingOptions = {}) {
this.options = {
maxTokensPerChunk: options.maxTokensPerChunk ?? ChunkManager.DEFAULT_MAX_TOKENS,
maxBytesPerChunk: options.maxBytesPerChunk ?? ChunkManager.DEFAULT_MAX_BYTES,
preserveContext: options.preserveContext ?? true,
contextOverlap: options.contextOverlap ?? ChunkManager.DEFAULT_CONTEXT_OVERLAP,
parallel: options.parallel ?? false
};
}
/**
* Estimates token count for a given text
* Uses rough approximation of 3.5 characters per token
*/
public estimateTokenCount(text: string): number {
return Math.ceil(text.length / ChunkManager.CHARS_PER_TOKEN_ESTIMATE);
}
/**
* Parses the filtered output format into structured messages
* Format: "- content"
*/
public parseFilteredOutput(filteredContent: string): ChunkedMessage[] {
const lines = filteredContent.split('\n').filter(line => line.trim());
const messages: ChunkedMessage[] = [];
for (const line of lines) {
// Parse format: "- content"
if (line.startsWith('- ')) {
const content = line.substring(2); // Remove "- " prefix
messages.push({
content,
estimatedTokens: this.estimateTokenCount(content)
});
}
}
return messages;
}
/**
* Chunks the filtered transcript into manageable pieces
*/
public chunkTranscript(filteredContent: string): Array<{ content: string; metadata: ChunkMetadata }> {
const messages = this.parseFilteredOutput(filteredContent);
const chunks: Array<{ content: string; metadata: ChunkMetadata }> = [];
let currentChunk: ChunkedMessage[] = [];
let currentTokens = 0;
let currentBytes = 0;
let chunkStartIndex = 0;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const messageText = this.formatMessage(message);
const messageBytes = Buffer.byteLength(messageText, 'utf8');
const messageTokens = message.estimatedTokens;
// Check if adding this message would exceed limits
if (currentChunk.length > 0 &&
(currentTokens + messageTokens > this.options.maxTokensPerChunk ||
currentBytes + messageBytes > this.options.maxBytesPerChunk)) {
// Save current chunk
const chunkContent = this.formatChunk(currentChunk);
chunks.push({
content: chunkContent,
metadata: {
chunkNumber: chunks.length + 1,
totalChunks: 0, // Will be updated after all chunks are created
startIndex: chunkStartIndex,
endIndex: i - 1,
messageCount: currentChunk.length,
estimatedTokens: currentTokens,
sizeBytes: currentBytes,
hasOverlap: false
}
});
// Start new chunk with optional context overlap
currentChunk = [];
currentTokens = 0;
currentBytes = 0;
chunkStartIndex = i;
// Add overlap messages from previous chunk if enabled
if (this.options.preserveContext && chunks.length > 0) {
const overlapStart = Math.max(0, i - this.options.contextOverlap);
for (let j = overlapStart; j < i; j++) {
const overlapMessage = messages[j];
const overlapText = this.formatMessage(overlapMessage);
currentChunk.push(overlapMessage);
currentTokens += overlapMessage.estimatedTokens;
currentBytes += Buffer.byteLength(overlapText, 'utf8');
}
if (currentChunk.length > 0) {
// Mark that this chunk has overlap
chunkStartIndex = overlapStart;
}
}
}
// Add message to current chunk
currentChunk.push(message);
currentTokens += messageTokens;
currentBytes += messageBytes;
}
// Save final chunk if it has content
if (currentChunk.length > 0) {
const chunkContent = this.formatChunk(currentChunk);
chunks.push({
content: chunkContent,
metadata: {
chunkNumber: chunks.length + 1,
totalChunks: 0,
startIndex: chunkStartIndex,
endIndex: messages.length - 1,
messageCount: currentChunk.length,
estimatedTokens: currentTokens,
sizeBytes: currentBytes,
hasOverlap: this.options.preserveContext && chunks.length > 0
}
});
}
// Update total chunks count in metadata
chunks.forEach(chunk => {
chunk.metadata.totalChunks = chunks.length;
});
return chunks;
}
/**
* Formats a single message back to the filtered output format
*/
private formatMessage(message: ChunkedMessage): string {
return `- ${message.content}`;
}
/**
* Formats a chunk of messages
*/
private formatChunk(messages: ChunkedMessage[]): string {
return messages.map(m => this.formatMessage(m)).join('\n');
}
/**
* Creates a header for a chunk file with metadata
*/
public createChunkHeader(metadata: ChunkMetadata): string {
const lines = [];
// Add timestamp range if available, otherwise chunk number
if (metadata.firstTimestamp && metadata.lastTimestamp) {
lines.push(`# ${metadata.firstTimestamp} to ${metadata.lastTimestamp} (chunk ${metadata.chunkNumber}/${metadata.totalChunks})`);
} else {
lines.push(`# Chunk ${metadata.chunkNumber} of ${metadata.totalChunks}`);
}
return lines.join('\n') + '\n';
}
/**
* Checks if content needs chunking based on size
*/
public needsChunking(content: string): boolean {
const estimatedTokens = this.estimateTokenCount(content);
const sizeBytes = Buffer.byteLength(content, 'utf8');
return estimatedTokens > this.options.maxTokensPerChunk ||
sizeBytes > this.options.maxBytesPerChunk;
}
/**
* Gets chunking statistics for logging
*/
public getChunkingStats(chunks: Array<{ metadata: ChunkMetadata }>): string {
const totalMessages = chunks.reduce((sum, c) => sum + c.metadata.messageCount, 0);
const totalTokens = chunks.reduce((sum, c) => sum + c.metadata.estimatedTokens, 0);
const totalBytes = chunks.reduce((sum, c) => sum + c.metadata.sizeBytes, 0);
return [
`📊 Chunking Statistics:`,
` • Total chunks: ${chunks.length}`,
` • Total messages: ${totalMessages}`,
` • Total estimated tokens: ${totalTokens.toLocaleString()}`,
` • Total size: ${(totalBytes / 1024).toFixed(1)} KB`,
` • Average tokens per chunk: ${Math.round(totalTokens / chunks.length).toLocaleString()}`,
` • Average size per chunk: ${(totalBytes / chunks.length / 1024).toFixed(1)} KB`
].join('\n');
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,366 @@
/**
* PromptOrchestrator - Single source of truth for all prompt generation
*
* This class serves as the central orchestrator for generating different types of prompts
* used throughout the claude-mem system. It provides clear, well-typed interfaces and
* methods for creating prompts for LLM analysis, human context, and system integration.
*/
import { createAnalysisPrompt } from '../../prompts/templates/analysis/AnalysisTemplates.js';
// =============================================================================
// CORE INTERFACES
// =============================================================================
/**
* Context data for LLM analysis prompts
*/
export interface AnalysisContext {
/** The transcript content to analyze */
transcriptContent: string;
/** Session identifier */
sessionId: string;
/** Project name for context */
projectName?: string;
/** Custom analysis instructions */
customInstructions?: string;
/** Compression trigger type */
trigger?: 'manual' | 'auto';
/** Original token count */
originalTokens?: number;
/** Target compression ratio */
targetCompressionRatio?: number;
}
/**
* Context data for human-facing session prompts
*/
export interface SessionContext {
/** Session identifier */
sessionId: string;
/** Source of the session start */
source: 'startup' | 'compact' | 'vscode' | 'web';
/** Project name */
projectName?: string;
/** Additional context to provide to the human */
additionalContext?: string;
/** Path to the transcript file */
transcriptPath?: string;
/** Working directory */
cwd?: string;
}
/**
* Context data for hook response generation
*/
export interface HookContext {
/** The hook event name */
hookEventName: string;
/** Session identifier */
sessionId: string;
/** Success status */
success: boolean;
/** Optional message */
message?: string;
/** Additional data specific to the hook */
data?: Record<string, unknown>;
/** Whether to continue processing */
shouldContinue?: boolean;
/** Reason for stopping if applicable */
stopReason?: string;
}
/**
* Generated analysis prompt for LLM consumption
*/
export interface AnalysisPrompt {
/** The formatted prompt text */
prompt: string;
/** Context used to generate the prompt */
context: AnalysisContext;
/** Prompt type identifier */
type: 'analysis';
/** Generated timestamp */
timestamp: string;
}
/**
* Generated session prompt for human context
*/
export interface SessionPrompt {
/** The formatted message text */
message: string;
/** Context used to generate the prompt */
context: SessionContext;
/** Prompt type identifier */
type: 'session';
/** Generated timestamp */
timestamp: string;
}
/**
* Generated hook response
*/
export interface HookResponse {
/** Whether to continue processing */
continue: boolean;
/** Reason for stopping if continue is false */
stopReason?: string;
/** Whether to suppress output */
suppressOutput?: boolean;
/** Hook-specific output data */
hookSpecificOutput?: Record<string, unknown>;
/** Context used to generate the response */
context: HookContext;
/** Response type identifier */
type: 'hook';
/** Generated timestamp */
timestamp: string;
}
// =============================================================================
// PROMPT ORCHESTRATOR CLASS
// =============================================================================
/**
* Central orchestrator for all prompt generation in the claude-mem system
*/
export class PromptOrchestrator {
private projectName: string;
constructor(projectName = 'claude-mem') {
this.projectName = projectName;
}
/**
* Creates an analysis prompt for LLM processing of transcript content
*/
public createAnalysisPrompt(context: AnalysisContext): AnalysisPrompt {
const timestamp = new Date().toISOString();
const prompt = this.buildAnalysisPrompt(context);
return {
prompt,
context,
type: 'analysis',
timestamp,
};
}
/**
* Creates a session start prompt for human context
*/
public createSessionStartPrompt(context: SessionContext): SessionPrompt {
const timestamp = new Date().toISOString();
const message = this.buildSessionStartMessage(context);
return {
message,
context,
type: 'session',
timestamp,
};
}
/**
* Creates a hook response for system integration
*/
public createHookResponse(context: HookContext): HookResponse {
const timestamp = new Date().toISOString();
const response = this.buildHookResponse(context);
return {
...response,
context,
type: 'hook',
timestamp,
};
}
// =============================================================================
// PRIVATE PROMPT BUILDERS
// =============================================================================
private buildAnalysisPrompt(context: AnalysisContext): string {
const {
transcriptContent,
sessionId,
projectName = this.projectName,
} = context;
// Extract project prefix from project name (convert to snake_case)
const projectPrefix = projectName.replace(/[-\s]/g, '_').toLowerCase();
// Use the simple prompt with the transcript included
return createAnalysisPrompt(
transcriptContent,
sessionId,
projectPrefix
);
}
private buildSessionStartMessage(context: SessionContext): string {
const {
sessionId,
source,
projectName = this.projectName,
additionalContext,
transcriptPath,
cwd,
} = context;
let message = `## Session Started (${source})
**Project**: ${projectName}
**Session ID**: ${sessionId} `;
if (transcriptPath) {
message += `**Transcript**: ${transcriptPath} `;
}
if (cwd) {
message += `**Working Directory**: ${cwd} `;
}
if (additionalContext) {
message += `\n### Additional Context\n${additionalContext}`;
}
message += `\n\nMemory system is active and ready to preserve context across sessions.`;
return message;
}
private buildHookResponse(context: HookContext): Omit<HookResponse, 'context' | 'type' | 'timestamp'> {
const {
hookEventName,
success,
message,
data,
shouldContinue = success,
stopReason,
} = context;
const response: Omit<HookResponse, 'context' | 'type' | 'timestamp'> = {
continue: shouldContinue,
suppressOutput: false,
};
if (!shouldContinue && stopReason) {
response.stopReason = stopReason;
}
// Add hook-specific output based on event type
if (hookEventName === 'SessionStart') {
response.hookSpecificOutput = {
hookEventName: 'SessionStart',
additionalContext: message,
...data,
};
} else if (data) {
response.hookSpecificOutput = data;
}
return response;
}
// =============================================================================
// UTILITY METHODS
// =============================================================================
/**
* Validates that an AnalysisContext has required fields
*/
public validateAnalysisContext(context: Partial<AnalysisContext>): context is AnalysisContext {
return !!(context.transcriptContent && context.sessionId);
}
/**
* Validates that a SessionContext has required fields
*/
public validateSessionContext(context: Partial<SessionContext>): context is SessionContext {
return !!(context.sessionId && context.source);
}
/**
* Validates that a HookContext has required fields
*/
public validateHookContext(context: Partial<HookContext>): context is HookContext {
return !!(context.hookEventName && context.sessionId && typeof context.success === 'boolean');
}
/**
* Gets the project name for this orchestrator instance
*/
public getProjectName(): string {
return this.projectName;
}
/**
* Sets a new project name for this orchestrator instance
*/
public setProjectName(projectName: string): void {
this.projectName = projectName;
}
}
// =============================================================================
// FACTORY FUNCTIONS
// =============================================================================
/**
* Creates a new PromptOrchestrator instance
*/
export function createPromptOrchestrator(projectName?: string): PromptOrchestrator {
return new PromptOrchestrator(projectName);
}
/**
* Creates an analysis context from basic parameters
*/
export function createAnalysisContext(
transcriptContent: string,
sessionId: string,
options: Partial<Omit<AnalysisContext, 'transcriptContent' | 'sessionId'>> = {}
): AnalysisContext {
return {
transcriptContent,
sessionId,
...options,
};
}
/**
* Creates a session context from basic parameters
*/
export function createSessionContext(
sessionId: string,
source: SessionContext['source'],
options: Partial<Omit<SessionContext, 'sessionId' | 'source'>> = {}
): SessionContext {
return {
sessionId,
source,
...options,
};
}
/**
* Creates a hook context from basic parameters
*/
export function createHookContext(
hookEventName: string,
sessionId: string,
success: boolean,
options: Partial<Omit<HookContext, 'hookEventName' | 'sessionId' | 'success'>> = {}
): HookContext {
return {
hookEventName,
sessionId,
success,
...options,
};
}
+128
View File
@@ -0,0 +1,128 @@
import { query } from '@anthropic-ai/claude-code';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { getClaudePath } from '../../shared/settings.js';
export interface TitleGenerationRequest {
sessionId: string;
projectName: string;
firstMessage: string;
}
export interface GeneratedTitle {
session_id: string;
generated_title: string;
timestamp: string;
project_name: string;
}
export class TitleGenerator {
private titlesIndexPath: string;
constructor() {
this.titlesIndexPath = path.join(os.homedir(), '.claude-mem', 'conversation-titles.jsonl');
this.ensureTitlesIndex();
}
private ensureTitlesIndex(): void {
const dir = path.dirname(this.titlesIndexPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
if (!fs.existsSync(this.titlesIndexPath)) {
fs.writeFileSync(this.titlesIndexPath, '', 'utf-8');
}
}
async generateTitle(firstMessage: string): Promise<string> {
const prompt = `Generate a 3-7 word descriptive title for this conversation based on the first message.
The title should:
- Capture the main topic or intent
- Be concise and descriptive
- Use proper capitalization
- Not include "Help with" or "Question about" prefixes
First message: "${firstMessage.substring(0, 500)}"
Respond with just the title, nothing else.`;
const response = await query({
prompt,
options: {
model: 'claude-3-5-haiku-20241022',
pathToClaudeCodeExecutable: getClaudePath(),
},
});
let title = '';
if (response && typeof response === 'object' && Symbol.asyncIterator in response) {
for await (const message of response) {
if (message?.content) title += message.content;
if (message?.text) title += message.text;
}
} else if (typeof response === 'string') {
title = response;
}
return title.trim().replace(/^["']|["']$/g, '');
}
async batchGenerateTitles(requests: TitleGenerationRequest[]): Promise<GeneratedTitle[]> {
const results: GeneratedTitle[] = [];
for (const request of requests) {
try {
const title = await this.generateTitle(request.firstMessage);
const generatedTitle: GeneratedTitle = {
session_id: request.sessionId,
generated_title: title,
timestamp: new Date().toISOString(),
project_name: request.projectName
};
results.push(generatedTitle);
this.storeTitleInIndex(generatedTitle);
} catch (error) {
console.error(`Failed to generate title for ${request.sessionId}:`, error);
}
}
return results;
}
private storeTitleInIndex(title: GeneratedTitle): void {
const line = JSON.stringify(title) + '\n';
fs.appendFileSync(this.titlesIndexPath, line, 'utf-8');
}
getExistingTitles(): Map<string, GeneratedTitle> {
const titles = new Map<string, GeneratedTitle>();
if (!fs.existsSync(this.titlesIndexPath)) {
return titles;
}
const content = fs.readFileSync(this.titlesIndexPath, 'utf-8');
const lines = content.trim().split('\n').filter(Boolean);
for (const line of lines) {
try {
const title = JSON.parse(line) as GeneratedTitle;
titles.set(title.session_id, title);
} catch (error) {
// Skip invalid lines
}
}
return titles;
}
getTitleForSession(sessionId: string): string | null {
const titles = this.getExistingTitles();
const title = titles.get(sessionId);
return title ? title.generated_title : null;
}
}