Skip to main content
Glama
branchManager.ts52.2 kB
import chalk from 'chalk'; import { ThoughtBranch, ThoughtData, Insight, CrossReference, InsightType, CrossRefType, BranchingThoughtInput, ThoughtLink, CodeSnippet, TaskItem, ReviewSuggestion, VisualizationData, VisualizationNode, VisualizationEdge, ExternalSearchResult, Profile, VisualizationOptions } from './types.js'; import { pipeline, FeatureExtractionPipeline } from '@xenova/transformers'; import { LRUCache } from 'lru-cache'; /** * Embedding cache for node/thought embeddings. * Uses LRU and TTL for freshness and memory efficiency. */ const EMBEDDING_CACHE_TTL = 1000 * 60 * 30; // 30 minutes const EMBEDDING_CACHE_MAX = 1000; /** * Summary cache for expensive summarization results. * Uses LRU and TTL for speed and freshness. */ const SUMMARY_CACHE_TTL = 1000 * 60 * 5; // 5 minutes const SUMMARY_CACHE_MAX = 100; import * as _ from 'lodash'; import graphlibPkg from '@dagrejs/graphlib'; import type { Graph as GraphType } from '@dagrejs/graphlib'; const { Graph, alg } = graphlibPkg as typeof import('@dagrejs/graphlib'); import { kmeans } from 'ml-kmeans'; // Cosine similarity for two vectors function cosineSimilarity(a: number[], b: number[]): number { let dot = 0, normA = 0, normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } export class BranchManager { /** * Embedding cache with LRU and TTL. */ private embeddingCache = new LRUCache<string, number[]>({ max: EMBEDDING_CACHE_MAX, ttl: EMBEDDING_CACHE_TTL }); /** * Summary cache for branch summaries (LRU+TTL). */ private summaryCache = new LRUCache<string, string>({ max: SUMMARY_CACHE_MAX, ttl: SUMMARY_CACHE_TTL }); // --- New feature fields --- private snippets: CodeSnippet[] = []; private snippetCounter = 0; // Embedding pipeline and cache private embeddingPipeline: FeatureExtractionPipeline | null = null; // Use 'any' for summarization pipeline due to type issues with summary_text property private summarizationPipeline: any = null; private embeddings: Map<string, number[]> = new Map(); // thoughtId -> embedding // Flag to optionally skip automatic task extraction private skipNextTaskExtraction: boolean = false; /** * Invalidate caches for a given thought or branch. Call after mutation. */ private invalidateCachesFor(thoughtId?: string, branchId?: string) { if (thoughtId) this.embeddingCache.delete(thoughtId); if (branchId) this.summaryCache.delete(branchId); // Add more cache invalidations as needed } // Example: Call this.invalidateCachesFor when a thought/branch/task is updated. /** * Load the summarization pipeline if not already loaded. */ private async getSummarizationPipeline(): Promise<any> { if (!this.summarizationPipeline) { this.summarizationPipeline = await pipeline('summarization', 'Xenova/distilbart-cnn-6-6'); } return this.summarizationPipeline; } /** * Summarize all thoughts in a branch as a digest. */ /** * Summarize all thoughts in a branch as a digest, using cache for speed. * @param branchId The branch to summarize */ public async summarizeBranchThoughts(branchId: string): Promise<string> { if (this.summaryCache.has(branchId)) return this.summaryCache.get(branchId)!; const branch = this.branches.get(branchId); if (!branch) throw new Error('Branch not found'); const text = branch.thoughts.map(t => t.content).join('\n'); const summarizer = await this.getSummarizationPipeline(); const summary = await summarizer(text, { min_length: 20, max_length: 120 }); const result = Array.isArray(summary) ? (summary as any)[0].summary_text : (summary as any).summary_text; this.summaryCache.set(branchId, result); return result; } /** * Load the embedding pipeline (MiniLM) if not already loaded. */ private async getEmbeddingPipeline(): Promise<FeatureExtractionPipeline> { if (!this.embeddingPipeline) { this.embeddingPipeline = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2'); } return this.embeddingPipeline; } // Truncate text to 512 tokens (OpenAI/transformers style) for embedding speed private truncateText(text: string, maxTokens = 512): string { try { // Synchronously import js-tiktoken // eslint-disable-next-line @typescript-eslint/no-var-requires const { encoding_for_model } = require("js-tiktoken"); // Always use o200k_base for compatibility with modern models const enc = encoding_for_model("o200k_base"); const tokens = enc.encode(text); if (tokens.length > maxTokens) { return enc.decode(tokens.slice(0, maxTokens)); } return text; } catch (err) { // Fallback: word-based truncation const words = text.split(/\s+/); return words.length > maxTokens ? words.slice(0, maxTokens).join(' ') : text; } } // Compute a simple hash for content private hashContent(content: string): string { let hash = 0; for (let i = 0; i < content.length; i++) { hash = ((hash << 5) - hash) + content.charCodeAt(i); hash |= 0; } return hash.toString(); } // Load persistent embedding cache from disk (JSON) private persistentEmbeddingPath = './embeddings-cache.json'; private persistentEmbeddingCache: Record<string, { embedding: number[]; hash: string }> = {}; private persistentCacheLoaded = false; private async loadPersistentEmbeddingCache() { if (this.persistentCacheLoaded) return; try { const fs = await import('fs/promises'); const data = await fs.readFile(this.persistentEmbeddingPath, 'utf8'); this.persistentEmbeddingCache = JSON.parse(data); } catch (e) { this.persistentEmbeddingCache = {}; } this.persistentCacheLoaded = true; } private async savePersistentEmbeddingCache() { try { const fs = await import('fs/promises'); await fs.writeFile(this.persistentEmbeddingPath, JSON.stringify(this.persistentEmbeddingCache), 'utf8'); } catch (e) {} } // LRU cache for embeddings (in-memory) private embeddingLRU = new LRUCache<string, number[]>({ max: 256 }); /** * Compute an embedding for a given text. */ /** * Compute an embedding for a given text. * Uses token-based truncation (o200k_base) for consistent input length. * Handles pooling for MiniLM-style output. */ public async embedText(text: string): Promise<number[]> { const key = this.hashContent(text); // Check LRU first for fastest access if (this.embeddingLRU.has(key)) { return this.embeddingLRU.get(key)!; } // Fallback to larger embeddingCache if (this.embeddingCache.has(key)) { const emb = this.embeddingCache.get(key)!; // Promote to LRU for faster future access this.embeddingLRU.set(key, emb); return emb; } const pipeline = await this.getEmbeddingPipeline(); const truncated = this.truncateText(text); // Execute pipeline and extract array data const rawOutput: any = await pipeline(truncated, { pooling: 'mean', normalize: true }); // rawOutput may be an array or an object with 'data' property const arr: any[] = Array.isArray(rawOutput) ? rawOutput : Array.isArray(rawOutput.data) ? rawOutput.data : ([] as any[]); const first = arr[0]; const embedding: number[] = Array.isArray(first) ? (first as number[]) : (Array.isArray(arr) ? (arr as unknown as number[]) : []); // Set in both caches this.embeddingLRU.set(key, embedding); this.embeddingCache.set(key, embedding); return embedding; } /** * Compute and store embeddings for all thoughts (batch & parallelized, only new/changed). */ public async embedAllThoughts(): Promise<void> { await this.loadPersistentEmbeddingCache(); const tasks: Promise<void>[] = []; for (const branch of this.branches.values()) { for (const thought of branch.thoughts) { // Use token-based truncation for hashing and embedding tasks.push((async () => { const truncated = this.truncateText(thought.content); const hash = this.hashContent(truncated); // Only embed if not in persistent cache or content changed if (!this.persistentEmbeddingCache[hash]) { const emb = await this.embedText(thought.content); this.embeddings.set(thought.id, emb); } else { this.embeddings.set(thought.id, this.persistentEmbeddingCache[hash].embedding); } })()); } } // Batch/parallelize up to 8 at a time for (let i = 0; i < tasks.length; i += 8) { await Promise.all(tasks.slice(i, i + 8)); } await this.savePersistentEmbeddingCache(); await this.updateAllCrossRefsAndScores(); } /** * Compute pairwise similarity for all thoughts in each branch. Store top cross-refs and update scores. */ // Configurable cross-ref similarity threshold private crossRefThreshold = 0.7; // Helper: recency bonus (1 if <1d, 0.5 if <7d, else 0) private recencyBonus(ts: Date): number { const now = Date.now(); const diffMs = now - new Date(ts).getTime(); const diffDays = diffMs / (1000 * 60 * 60 * 24); if (diffDays < 1) return 1; if (diffDays < 7) return 0.5; return 0; } // Helper: diversity bonus (unique branchIds in cross-refs, normalized) private diversityBonus(crossRefs: { toThoughtId: string; branchId?: string }[], allThoughts: ThoughtData[]): number { const ids = new Set<string>(); for (const cr of crossRefs) { const t = allThoughts.find(t => t.id === cr.toThoughtId); if (t) ids.add(t.branchId); } return Math.min(ids.size / 5, 1); // up to 1.0 if 5+ branches } public async updateAllCrossRefsAndScores(): Promise<void> { // Build a flat map of all thoughts for diversity const allThoughts: ThoughtData[] = []; for (const branch of this.branches.values()) { allThoughts.push(...branch.thoughts); } // Cross-ref and score per branch for (const branch of this.branches.values()) { const thoughts = branch.thoughts; const embeddings = thoughts.map(t => this.embeddings.get(t.id)); // Build cross-ref candidates with threshold/type for (let i = 0; i < thoughts.length; i++) { const t = thoughts[i]; const embA = embeddings[i]; if (!embA) continue; const sims: { toThoughtId: string; score: number; type: string }[] = []; for (let j = 0; j < allThoughts.length; j++) { if (thoughts[i].id === allThoughts[j].id) continue; const embB = this.embeddings.get(allThoughts[j].id); if (!embB) continue; const sim = cosineSimilarity(embA, embB); if (sim > this.crossRefThreshold) { let type = sim > 0.85 ? 'very similar' : 'related'; sims.push({ toThoughtId: allThoughts[j].id, score: sim, type }); } } // Sort and keep top 3 t.crossRefs = sims.sort((a, b) => b.score - a.score).slice(0, 3).map(cr => ({ toThoughtId: cr.toThoughtId, score: cr.score, type: cr.type })); } } // Bidirectional links: if A cross-refs B, ensure B links to A if mutual for (const branch of this.branches.values()) { for (const t of branch.thoughts) { if (!t.crossRefs) continue; for (const cr of t.crossRefs) { const other = allThoughts.find(x => x.id === cr.toThoughtId); if (!other) continue; if (!other.crossRefs) other.crossRefs = []; if (!other.crossRefs.find(x => x.toThoughtId === t.id)) { // Only add if similarity is mutual (above threshold) const embA = this.embeddings.get(t.id); const embB = this.embeddings.get(other.id); if (embA && embB) { const sim = cosineSimilarity(embA, embB); if (sim > this.crossRefThreshold) { other.crossRefs.push({ toThoughtId: t.id, score: sim, type: sim > 0.85 ? 'very similar' : 'related' }); // Keep only top 3 other.crossRefs.sort((a, b) => b.score - a.score); other.crossRefs = other.crossRefs.slice(0, 3); } } } } } } // Multi-hop (2-hop and 3-hop) const multiHopThreshold = 0.5; for (const t of allThoughts) { if (!t.crossRefs) continue; const directIds = new Set(t.crossRefs.map(cr => cr.toThoughtId)); // 2-hop for (const cr1 of t.crossRefs) { const t2 = allThoughts.find(x => x.id === cr1.toThoughtId); if (!t2 || !t2.crossRefs) continue; for (const cr2 of t2.crossRefs) { if (cr2.toThoughtId === t.id || directIds.has(cr2.toThoughtId)) continue; // Path score = min(sim1, sim2) const pathScore = Math.min(cr1.score, cr2.score); if (pathScore > multiHopThreshold && !(t.crossRefs.find(x => x.toThoughtId === cr2.toThoughtId))) { t.crossRefs.push({ toThoughtId: cr2.toThoughtId, score: pathScore, type: 'multi-hop' }); } } } // 3-hop for (const cr1 of t.crossRefs) { const t2 = allThoughts.find(x => x.id === cr1.toThoughtId); if (!t2 || !t2.crossRefs) continue; for (const cr2 of t2.crossRefs) { const t3 = allThoughts.find(x => x.id === cr2.toThoughtId); if (!t3 || !t3.crossRefs) continue; for (const cr3 of t3.crossRefs) { if (cr3.toThoughtId === t.id || directIds.has(cr3.toThoughtId) || t.crossRefs.find(x => x.toThoughtId === cr3.toThoughtId && x.type === 'multi-hop')) continue; // Path score = min(sim1, sim2, sim3) const pathScore = Math.min(cr1.score, cr2.score, cr3.score); if (pathScore > multiHopThreshold) { t.crossRefs.push({ toThoughtId: cr3.toThoughtId, score: pathScore, type: 'multi-hop' }); } } } } // Optionally, keep only top 6 (direct+multi-hop) t.crossRefs.sort((a, b) => b.score - a.score); t.crossRefs = t.crossRefs.slice(0, 6); } // Scoring for (const branch of this.branches.values()) { for (const t of branch.thoughts) { const directSum = t.crossRefs ? t.crossRefs.filter(cr => cr.type !== 'multi-hop').reduce((sum, cr) => sum + cr.score, 0) : 0; const multiHopSum = t.crossRefs ? t.crossRefs.filter(cr => cr.type === 'multi-hop').reduce((sum, cr) => sum + cr.score, 0) : 0; const degree = t.crossRefs ? t.crossRefs.length : 0; const recency = this.recencyBonus(t.timestamp); const diversity = t.crossRefs ? this.diversityBonus(t.crossRefs, allThoughts) : 0; const confidence = t.metadata.confidence || 0; const keyPoints = t.metadata.keyPoints?.length || 0; t.score = 0.5 * directSum + 0.25 * multiHopSum + 0.2 * degree + 0.1 * recency + 0.1 * diversity + 0.2 * confidence + 0.1 * keyPoints; } branch['score'] = branch.thoughts.length > 0 ? branch.thoughts.reduce((sum, t) => sum + (t.score || 0), 0) / branch.thoughts.length : 0; } } /** * Semantic search: find top N most similar thoughts to a query. */ public async semanticSearch(query: string, topN: number = 5): Promise<{ thought: ThoughtData; score: number }[]> { await this.embedAllThoughts(); const queryEmb = await this.embedText(query); // Compute cosine similarity const scores: { thought: ThoughtData; score: number }[] = []; for (const branch of this.branches.values()) { for (const thought of branch.thoughts) { const emb = this.embeddings.get(thought.id); if (emb) { const score = cosineSimilarity(queryEmb, emb); scores.push({ thought, score }); } } } return scores.sort((a, b) => b.score - a.score).slice(0, topN); } /** * Link two thoughts (across any branches) with a semantic relation. * @param fromThoughtId The source thought ID * @param toThoughtId The target thought ID * @param type The type of link (supports, contradicts, etc.) * @param reason Optional reason for the link */ public linkThoughts(fromThoughtId: string, toThoughtId: string, type: 'supports' | 'contradicts' | 'related' | 'expands' | 'refines', reason?: string): boolean { const from = this.findThoughtById(fromThoughtId); const to = this.findThoughtById(toThoughtId); if (!from || !to) return false; if (!from.linkedThoughts) from.linkedThoughts = []; // Prevent duplicate links if (!from.linkedThoughts.some(l => l.toThoughtId === toThoughtId && l.type === type)) { from.linkedThoughts.push({ toThoughtId, type, reason }); return true; } return false; } /** * Retrieve all linked thoughts for a given thought ID, across all branches. */ public getLinkedThoughts(thoughtId: string): { thought: ThoughtData; link: ThoughtLink }[] { const thought = this.findThoughtById(thoughtId); if (!thought || !thought.linkedThoughts) return []; return thought.linkedThoughts .map(link => { const t = this.findThoughtById(link.toThoughtId); if (t) return { thought: t, link }; return null; }) .filter((x): x is { thought: ThoughtData; link: ThoughtLink } => x !== null); } /** * Utility: Find a thought by ID across all branches. */ public findThoughtById(thoughtId: string): ThoughtData | undefined { for (const branch of this.branches.values()) { const t = branch.thoughts.find(th => th.id === thoughtId); if (t) return t; } return undefined; } private branches: Map<string, ThoughtBranch> = new Map(); private insightCounter = 0; private thoughtCounter = 0; private crossRefCounter = 0; private activeBranchId: string | null = null; // LRU caches private historyCache = new LRUCache<string, string>({ max: 32 }); private statusCache = new LRUCache<string, string>({ max: 32 }); private insightsCache = new LRUCache<string, Insight[]>({ max: 64 }); generateId(prefix: string): string { const timestamp = Date.now(); const random = Math.floor(Math.random() * 1000); return `${prefix}-${timestamp}-${random}`; } createBranch(branchId: string, parentBranchId?: string): ThoughtBranch { const branch: ThoughtBranch = { id: branchId, parentBranchId, state: 'active', priority: 1.0, confidence: 1.0, thoughts: [], insights: [], crossRefs: [] }; this.branches.set(branchId, branch); // Set as active if it's the first branch if (!this.activeBranchId) { this.activeBranchId = branchId; } return branch; } private createInsight( type: InsightType, content: string, context: string[], parentInsights?: string[] ): Insight { return { id: `insight-${++this.insightCounter}`, type, content, context, parentInsights, applicabilityScore: 1.0, supportingEvidence: {} }; } private createCrossReference( fromBranch: string, toBranch: string, type: CrossRefType, reason: string, strength: number ): CrossReference { return { id: `xref-${++this.crossRefCounter}`, fromBranch, toBranch, type, reason, strength, touchpoints: [] }; } /** * Add one or more thoughts and/or insights to a branch. * Accepts a single BranchingThoughtInput or an array of them. * Returns the last ThoughtData added (for compatibility). */ public addThought(input: BranchingThoughtInput | BranchingThoughtInput[]): ThoughtData { const inputs = Array.isArray(input) ? input : [input]; // Set skip flag for next status/history task extraction this.skipNextTaskExtraction = inputs.some(item => item.skipExtractTasks === true); let lastThought: ThoughtData | undefined; for (const item of inputs) { // Validate content if (!item.content || !item.content.trim()) { throw new Error('Thought content cannot be empty'); } // Validate and assign profile if provided if (item.profileId && !this.profiles.has(item.profileId)) { throw new Error(`Profile not found: ${item.profileId}`); } // Use active branch if no branchId provided const branchId = item.branchId || this.activeBranchId || this.generateId('branch'); let branch = this.branches.get(branchId); if (!branch) { branch = this.createBranch(branchId, item.parentBranchId); } const thought: ThoughtData = { id: `thought-${++this.thoughtCounter}`, content: item.content, branchId: branch.id, profileId: item.profileId, timestamp: new Date(), metadata: { type: item.type, confidence: item.confidence || 1.0, keyPoints: item.keyPoints || [] } }; // Compute thought score let score = thought.metadata.confidence; score += thought.metadata.keyPoints.length * 0.1; score += (item.crossRefs?.length || 0) * 0.2; score += (item.thoughtCrossRefs?.length || 0) * 0.2; thought.score = score; // Auto-generate a simple insight per thought const simpleInsight = this.createInsight( 'observation', `Auto-generated insight from thought: ${thought.content.slice(0, 50)}`, [thought.id] ); branch.insights.push(simpleInsight); this.insightsCache.set(branch.id, branch.insights.slice(-10)); // Add thought-level cross references if provided if (item.thoughtCrossRefs) { thought.linkedThoughts = item.thoughtCrossRefs.map(ref => ({ toThoughtId: ref.toThoughtId, type: ref.type, reason: ref.reason })); } branch.thoughts.push(thought); lastThought = thought; // Create insights if key points are provided if (item.keyPoints) { const insight = this.createInsight( 'observation', `Identified key points: ${item.keyPoints.join(', ')}`, [item.type], item.relatedInsights ); branch.insights.push(insight); // Update insights cache for this branch this.insightsCache.set(branchId, branch.insights.slice(-10)); } // Create cross references if specified if (item.crossRefs) { item.crossRefs.forEach(ref => { const crossRef = this.createCrossReference( branch!.id, ref.toBranch, ref.type, ref.reason, ref.strength ); branch!.crossRefs.push(crossRef); // Auto-reverse cross reference on target branch const targetBranch = this.branches.get(ref.toBranch); if (targetBranch) { const reverseCr = this.createCrossReference( ref.toBranch, branch!.id, ref.type, `[Auto] ${ref.reason}`, ref.strength ); targetBranch.crossRefs.push(reverseCr); } }); } // Auto-generate advanced insights per thought this.generateAdvancedInsights(branch); this.updateBranchMetrics(branch); // Invalidate caches for this branch this.historyCache.delete(branchId); this.statusCache.delete(branchId); } return lastThought!; } /** * Analyze thoughts in a branch for advanced insight generation. * Adds new insights for frequent key points and sentiment trends. */ public generateAdvancedInsights(branch: ThoughtBranch): void { // Prepare linking: existing insight IDs const parentIds: string[] = branch.insights.map(i => i.id); // Analyze frequent key points const keyPointCounts: Record<string, number> = {}; branch.thoughts.forEach(t => { (t.metadata.keyPoints || []).forEach(kp => { keyPointCounts[kp] = (keyPointCounts[kp] || 0) + 1; }); }); const frequentKeyPoints = Object.entries(keyPointCounts) .filter(([_, count]) => count > 1) .map(([kp]) => kp); if (frequentKeyPoints.length > 0) { const insight = this.createInsight( 'behavioral_pattern', `Frequent key points detected: ${frequentKeyPoints.join(', ')}`, frequentKeyPoints, parentIds ); branch.insights.push(insight); this.insightsCache.set(branch.id, branch.insights.slice(-10)); // Auto cross-reference new insight to prior insights parentIds.forEach(pid => { const cr = this.createCrossReference( branch.id, branch.id, 'builds_upon', `Insight ${insight.id} builds on ${pid}`, 1.0 ); branch.crossRefs.push(cr); }); parentIds.push(insight.id); } // Simple sentiment analysis: count positive/negative/neutral let pos = 0, neg = 0, neu = 0; const positiveWords = ['good', 'great', 'positive', 'success', 'improve', 'yes']; const negativeWords = ['bad', 'fail', 'negative', 'problem', 'issue', 'no']; branch.thoughts.forEach(t => { const lc = t.content.toLowerCase(); if (positiveWords.some(w => lc.includes(w))) pos++; else if (negativeWords.some(w => lc.includes(w))) neg++; else neu++; }); if (pos || neg) { const sentiment = pos > neg ? 'positive' : (neg > pos ? 'negative' : 'mixed'); const insight = this.createInsight( 'observation', `Branch sentiment trend: ${sentiment} (${pos} positive, ${neg} negative, ${neu} neutral)`, [], parentIds ); branch.insights.push(insight); this.insightsCache.set(branch.id, branch.insights.slice(-10)); // Auto cross-reference new sentiment insight to prior insights parentIds.forEach(pid => { const cr = this.createCrossReference( branch.id, branch.id, 'builds_upon', `Insight ${insight.id} builds on ${pid}`, 1.0 ); branch.crossRefs.push(cr); }); } } /** * Smarter prioritization: combines confidence, insight score, cross-ref, recency, diversity. */ private updateBranchMetrics(branch: ThoughtBranch): void { const avgConfidence = branch.thoughts.reduce((sum, t) => sum + t.metadata.confidence, 0) / branch.thoughts.length; const insightScore = branch.insights.reduce((sum, i) => sum + (i.applicabilityScore || 1), 0) * 0.1; const crossRefScore = branch.crossRefs.reduce((sum, ref) => sum + ref.strength, 0) * 0.1; // Recency: newer thoughts boost priority const now = Date.now(); const recencyScore = branch.thoughts.length > 0 ? Math.max(0, 1 - ((now - branch.thoughts[branch.thoughts.length-1].timestamp.getTime()) / (1000*60*60))) : 0; // Diversity: number of unique key points const keyPointSet = new Set(branch.thoughts.flatMap(t => t.metadata.keyPoints || [])); const diversityScore = keyPointSet.size * 0.05; branch.priority = avgConfidence + insightScore + crossRefScore + recencyScore + diversityScore; branch.confidence = avgConfidence; } getBranch(branchId: string): ThoughtBranch | undefined { return this.branches.get(branchId); } getAllBranches(): ThoughtBranch[] { return Array.from(this.branches.values()); } getActiveBranch(): ThoughtBranch | undefined { return this.activeBranchId ? this.branches.get(this.activeBranchId) : undefined; } setActiveBranch(branchId: string): void { if (!this.branches.has(branchId)) { throw new Error(`Branch ${branchId} not found`); } this.activeBranchId = branchId; } /** * Get cached insights for a branch, or the latest from the branch if not cached. */ public getCachedInsights(branchId: string): Insight[] { const cached = this.insightsCache.get(branchId); if (cached) return cached; const branch = this.branches.get(branchId); if (!branch) return []; const insights = branch.insights.slice(-10); this.insightsCache.set(branchId, insights); return insights; } async getBranchHistory(branchId: string): Promise<string> { // Try cache first const cached = this.historyCache.get(branchId); if (cached) return cached; const branch = this.branches.get(branchId); if (!branch) { throw new Error(`Branch ${branchId} not found`); } const header = chalk.blue(`History for branch: ${branchId} (${branch.state})`); const timeline = branch.thoughts.map((t, i) => { const timestamp = t.timestamp.toLocaleTimeString(); return `│ [${timestamp}] ${t.content}`; }).join('\n'); // Optionally skip task extraction const tasks = this.skipNextTaskExtraction ? [] : await this.extractTasks(branchId); const taskLines = tasks.map(t => `│ [Task] ${t.content}`).join('\n'); const insights = branch.insights.slice(-10).map(i => `│ [Insight] ${i.content}`).join('\n'); const result = ` ┌───────────────────────────────────────────── │ ${header} ├───────────────────────────────────────────── ${timeline} ${insights ? `\n├─────────────────────────────────────────────\n${insights}` : ''} ${taskLines ? `\n├─────────────────────────────────────────────\n${taskLines}` : ''} └─────────────────────────────────────────────`; this.historyCache.set(branchId, result); // Reset skip flag this.skipNextTaskExtraction = false; return result; } // Format branch status public async formatBranchStatus(branch: ThoughtBranch): Promise<string> { // Try cache first const cached = this.statusCache.get(branch.id); if (cached) return cached; const isActive = branch.id === this.activeBranchId; const header = `${chalk.blue('Branch:')} ${branch.id} (${branch.state})${isActive ? chalk.green(' [ACTIVE]') : ''}`; const stats = `Priority: ${branch.priority.toFixed(2)} | Confidence: ${branch.confidence.toFixed(2)}`; const thoughts = branch.thoughts.map(t => ` ${chalk.green('•')} ${t.content} (${t.metadata.type})` ).join('\n'); const insights = branch.insights.map(i => ` ${chalk.yellow('→')} ${i.content}` ).join('\n'); const crossRefs = branch.crossRefs.map(r => ` ${chalk.magenta('↔')} ${r.toBranch}: ${r.reason} (${r.strength.toFixed(2)})` ).join('\n'); // --- New: snippets, tasks, reviews --- const snippets = this.snippets.filter(s => s.tags.includes(branch.id)); const snippetSection = snippets.length ? chalk.cyan(`Snippets:\n${snippets.map(s => ` - ${s.content.slice(0, 40)} [${s.tags.join(', ')}]`).join('\n')}`) : ''; // Optionally skip automatic task extraction const tasks = this.skipNextTaskExtraction ? [] : await this.extractTasks(branch.id); const taskSection = tasks.length ? chalk.magenta(`Tasks:\n${tasks.map((t: TaskItem) => ` - ${t.content}`).join('\n')}`) : ''; const reviews = await this.reviewBranch(branch.id); const reviewSection = reviews.length ? chalk.red(`Reviews:\n${reviews.map((r: ReviewSuggestion) => ` - ${r.content}`).join('\n')}`) : ''; const result = ` ┌───────────────────────────────────────────── │ ${header} │ ${stats} ├───────────────────────────────────────────── │ Thoughts: ${thoughts} ${snippetSection ? `│ ${snippetSection}\n` : ''}${taskSection ? `│ ${taskSection}\n` : ''}${reviewSection ? `│ ${reviewSection}\n` : ''}│ Insights: ${insights} │ Cross References: ${crossRefs} └─────────────────────────────────────────────`; this.statusCache.set(branch.id, result); // Reset skip flag after formatting this.skipNextTaskExtraction = false; return result; } /** * Merge one branch into another, transferring thoughts, insights, and cross-references. */ public mergeBranches(sourceBranchId: string, targetBranchId: string): ThoughtBranch { const source = this.branches.get(sourceBranchId); const target = this.branches.get(targetBranchId); if (!source || !target) { throw new Error(`Cannot merge: branch not found`); } // Transfer content target.thoughts.push(...source.thoughts); target.insights.push(...source.insights); target.crossRefs.push(...source.crossRefs); this.updateBranchMetrics(target); // Remove source branch this.branches.delete(sourceBranchId); if (this.activeBranchId === sourceBranchId) { this.activeBranchId = targetBranchId; } return target; } // ... (other methods remain unchanged) public searchSnippets(query: string, topN: number = 5): CodeSnippet[] { // Simple search: match query in content or tags const lower = query.toLowerCase(); const matches = this.snippets.filter(s => s.content.toLowerCase().includes(lower) || s.tags.some(tag => tag.toLowerCase().includes(lower)) ); // Sort by recency return matches.sort((a, b) => b.created.getTime() - a.created.getTime()).slice(0, topN); } // --- Automated Documentation Generation --- public async summarizeBranch(branchId: string): Promise<string> { return this.summarizeBranchThoughts(branchId); // Uses existing summarization pipeline } public async summarizeThought(thoughtId: string): Promise<string> { const thought = this.findThoughtById(thoughtId); if (!thought) throw new Error('Thought not found'); // Use summarization pipeline on single thought const summarizer = await this.getSummarizationPipeline(); const summary = await summarizer(thought.content, { min_length: 10, max_length: 60 }); return Array.isArray(summary) ? (summary as any)[0].summary_text : (summary as any).summary_text; } // --- Automated Task and Issue Extraction --- // --- Persistent Task Store --- private taskStorePath = './.tasks.json'; private tasks: TaskItem[] = []; private async loadTasks(): Promise<void> { try { const fs = await import('fs/promises'); const raw = await fs.readFile(this.taskStorePath, 'utf8'); this.tasks = JSON.parse(raw).tasks || []; } catch (e) { this.tasks = []; } } private async saveTasks(): Promise<void> { try { const fs = await import('fs/promises'); await fs.writeFile(this.taskStorePath, JSON.stringify({ tasks: this.tasks }, null, 2), 'utf8'); } catch (e) {} } /** * Query tasks by various filters */ public async queryTasks({ branchId, status, assignee, due, priority }: { branchId?: string, status?: string, assignee?: string, due?: string, priority?: number }): Promise<TaskItem[]> { await this.loadTasks(); let tasks = this.tasks; if (branchId) tasks = tasks.filter(t => t.branchId === branchId); if (status) tasks = tasks.filter(t => t.status === status); if (assignee) tasks = tasks.filter(t => t.assignee === assignee); if (due) tasks = tasks.filter(t => t.due === due); if (priority) tasks = tasks.filter(t => t.priority === priority); // Sort by priority, due, status tasks = tasks.sort((a, b) => (a.priority ?? 3) - (b.priority ?? 3) || (a.due ? new Date(a.due).getTime() : 0) - (b.due ? new Date(b.due).getTime() : 0)); return tasks; } /** * Summarize task stats for a branch or all branches */ public async summarizeTasks(branchId?: string): Promise<string> { await this.loadTasks(); const tasks = branchId ? this.tasks.filter(t => t.branchId === branchId) : this.tasks; const open = tasks.filter(t => t.status === 'open'); const inProgress = tasks.filter(t => t.status === 'in_progress'); const closed = tasks.filter(t => t.status === 'closed'); const stale = tasks.filter(t => t.stale); return [ `Total tasks: ${tasks.length}`, `Open: ${open.length}`, `In Progress: ${inProgress.length}`, `Closed: ${closed.length}`, `Stale: ${stale.length}` ].join('\n'); } // --- Task Extraction and Management --- /** * Extract actionable tasks from thoughts in a branch. * Updates persistent task store and returns the array of tasks. */ public async extractTasks(branchId?: string): Promise<TaskItem[]> { await this.loadTasks(); const branches = branchId ? [this.getBranch(branchId)].filter(Boolean) : Array.from(this.branches.values()); const taskRegex = /(?<type>TODO|FIXME|ACTION|TASK)(?:\((?<assignee>\w+)\))?:?\s*(?<description>.+?)(?:\s+by\s+(?<due>\d{4}-\d{2}-\d{2}))?(?=\n|$)/gi; let foundTasks: TaskItem[] = []; for (const branch of branches) { if (!branch) continue; for (const thought of branch.thoughts) { let match: RegExpExecArray | null; while ((match = taskRegex.exec(thought.content)) !== null) { const { type, assignee, description, due } = match.groups || {}; const task: TaskItem = { id: `task-${branch.id}-${thought.id}-${match.index}`, branchId: branch.id, thoughtId: thought.id, type: type || 'TASK', content: description?.trim() || '', status: 'open', assignee: assignee || '', due: due || '', priority: 3, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), creator: '', lastEditor: '', auditTrail: [], stale: false }; // Avoid duplicate tasks if (!this.tasks.some(t => t.id === task.id)) { this.tasks.push(task); } foundTasks.push(task); } } } await this.saveTasks(); return foundTasks; } // --- Contextual Code Review and Suggestions --- // --- Improved Code Review (ESLint stub) --- public async reviewBranch(branchId: string): Promise<ReviewSuggestion[]> { const branch = this.getBranch(branchId); if (!branch) throw new Error('Branch not found'); const reviews: ReviewSuggestion[] = []; for (const thought of branch.thoughts) { // Use ESLint CLI for JS/TS code blocks (stub) if (/```[jt]s([\s\S]+?)```/gi.test(thought.content)) { // TODO: Integrate ESLint CLI and parse output for actionable suggestions reviews.push({ id: `review-${branchId}-${thought.id}`, branchId, thoughtId: thought.id, content: `Static analysis review: (stub) found code block in thought`, type: 'improvement', created: new Date() }); } else if (/bad|fix|improve|refactor|bug/i.test(thought.content)) { reviews.push({ id: `review-${branchId}-${thought.id}`, branchId, thoughtId: thought.id, content: `Review suggested: ${thought.content}`, type: 'improvement', created: new Date() }); } } return reviews; } // --- Task Status Update --- /** * Update the status of a task in the persistent store. */ public async updateTaskStatus(taskId: string, status: 'open' | 'in_progress' | 'closed', user: string = ''): Promise<boolean> { await this.loadTasks(); const idx = this.tasks.findIndex(t => t.id === taskId); if (idx === -1) return false; const task = this.tasks[idx]; const oldStatus = task.status; task.status = status; task.updatedAt = new Date().toISOString(); task.lastEditor = user; if (!task.auditTrail) task.auditTrail = []; task.auditTrail.push({ timestamp: new Date().toISOString(), action: `Status changed from ${oldStatus} to ${status}`, user }); await this.saveTasks(); return true; } // --- External Tool Integration Stubs --- public async syncTasksWithGitHub(): Promise<void> { // TODO: Implement GitHub Issues sync (stub) // For each open task, create/update a GitHub Issue } public async notifyTask(user: string, task: TaskItem): Promise<void> { // TODO: Send notification (Slack/email) (stub) } /** * Visualize one or more branches as a graph structure with advanced metadata for agent guidance. * * - Clusters nodes using k-means (on embeddings if available, else degree) * - Adds cluster label and color * - Adds node metadata: task status, priority, next-action * - Uses graphlib for centrality (agent can focus on key nodes) * - Supports focusNode and multi-branch visualization * * @param options VisualizationOptions (branchId, branches, focusNode, etc.) * @returns VisualizationData with nodes, edges, and meta */ /** * Compute closeness centrality for a graph. */ private computeClosenessCentrality(g: GraphType): Record<string, number> { const centrality: Record<string, number> = {}; for (const nodeId of g.nodes()) { const result = alg.dijkstra(g, nodeId); let sum = 0; let reachable = 0; for (const { distance } of Object.values(result)) { if (distance < Infinity) { sum += distance; reachable++; } } centrality[nodeId] = sum > 0 ? (reachable - 1) / sum : 0; } return centrality; } public visualizeBranch(options: VisualizationOptions = {}): VisualizationData { // Destructure options with defaults const { branchId, branches: optBranches, showClusters = true, edgeBundling = false, focusNode, levelOfDetail: lod = 'auto' } = options; // Determine branches to include const branchIds = optBranches ?? (branchId ? [branchId] : Array.from(this.branches.keys())); const branches = branchIds.map(id => this.getBranch(id)).filter(Boolean) as ThoughtBranch[]; // Initialize graph and containers const g = new Graph({ directed: true }); let nodes: VisualizationNode[] = []; let edges: VisualizationEdge[] = []; // Build nodes and edges for (const branch of branches) { g.setNode(branch.id); nodes.push({ id: branch.id, label: branch.id, type: 'branch' }); for (const thought of branch.thoughts) { const label = thought.content.slice(0, 30); g.setNode(thought.id); nodes.push({ id: thought.id, label, type: 'thought' }); g.setEdge(branch.id, thought.id); edges.push({ from: branch.id, to: thought.id }); if (thought.linkedThoughts) { for (const link of thought.linkedThoughts) { g.setEdge(thought.id, link.toThoughtId); edges.push({ from: thought.id, to: link.toThoughtId, label: link.type, type: 'link' }); } } } for (const cross of branch.crossRefs) { g.setEdge(branch.id, cross.toBranch); edges.push({ from: branch.id, to: cross.toBranch, label: cross.type, type: 'crossref' }); } } // Deduplicate nodes = _.uniqBy(nodes, 'id'); edges = _.uniqWith(edges, (a, b) => a.from === b.from && a.to === b.to); // Determine detail level const detail: 'low' | 'medium' | 'high' = lod === 'auto' ? (nodes.length > 100 ? 'medium' : 'high') : lod; // Container for analytics const analytics: Record<string, any> = {}; // Clustering if (showClusters && detail !== 'low') { const useEmbeddings = this.embeddings.size >= nodes.length / 2; const features = nodes.map(n => useEmbeddings ? this.embeddings.get(n.id) || [0] : [(g.inEdges(n.id)?.length || 0) + (g.outEdges(n.id)?.length || 0)] ); const k = Math.max(2, Math.round(Math.sqrt(nodes.length / 2))); try { const result = kmeans(features, k, { initialization: 'kmeans++', maxIterations: 100 }); analytics.centroids = result.centroids; analytics.clusters = result.clusters; // Annotate nodes nodes = nodes.map((n, i) => ({ ...n, cluster: result.clusters[i], clusterLabel: `Cluster ${result.clusters[i]}`, clusterColor: `hsl(${(result.clusters[i] * 30)}, 70%, 50%)` })); } catch {} // Group by cluster analytics.clusterGroups = _.groupBy(nodes, 'cluster'); } // Centrality if (detail !== 'low') { const central = this.computeClosenessCentrality(g); analytics.centrality = central; nodes = nodes.map(n => ({ ...n, centrality: central[n.id], highlight: focusNode === n.id })); } // High-detail analytics if (detail === 'high') { try { analytics.cycles = alg.findCycles(g); } catch {} try { analytics.topsort = alg.topsort(g); } catch {} if (focusNode && g.hasNode(focusNode)) { try { analytics.shortestPaths = alg.dijkstra(g, focusNode); } catch {} } } // Edge bundling stub if (edgeBundling) { analytics.edgeBundles = _.groupBy(edges, e => `${e.from}->${e.to}`); } // Task metadata nodes = nodes.map(n => { const task = this.tasks.find(t => t.id === n.id); if (task) { return { ...n, taskStatus: task.status, taskPriority: task.priority, nextAction: task.status !== 'closed' }; } return n; }); // Return combined result return { nodes, edges, meta: { ...options, ...analytics } }; } // --- Code Snippet Management --- /** * Add a code snippet to the manager. * @param content The code content * @param tags Tags for the snippet * @param author Optional author * @returns The saved CodeSnippet */ public addSnippet(content: string, tags: string[], author?: string): CodeSnippet { const snippet: CodeSnippet = { id: `snippet-${++this.snippetCounter}`, content, tags, created: new Date(), author }; this.snippets.push(snippet); return snippet; } // --- Smart Q&A (stub) --- /** * Ask a question about the branch/thoughts (stub for AI/LLM) * @param question The question to answer * @returns AI-generated answer (stub) */ public async askQuestion(question: string): Promise<string> { // Placeholder: In real implementation, use AI/LLM return `AI answer to: ${question}`; } // --- Profile management --- private profiles: Map<string, Profile> = new Map(); /** * Create a new profile for thoughts. * @param name The profile name * @returns The created Profile */ public createProfile(name: string): Profile { const id = this.generateId('profile'); const profile: Profile = { id, name, settings: {} }; this.profiles.set(id, profile); return profile; } /** * Get a profile by ID. * @param id Profile ID * @returns The Profile or undefined */ public getProfile(id: string): Profile | undefined { return this.profiles.get(id); } /** Retrieve all tasks for a branch or all tasks */ public async getTasks(branchId?: string): Promise<TaskItem[]> { await this.loadTasks(); return branchId ? this.tasks.filter(t => t.branchId === branchId) : this.tasks; } /** Get the next open task by creation time */ public async getNextTask(branchId?: string): Promise<TaskItem | null> { await this.loadTasks(); const openTasks = (branchId ? this.tasks.filter(t => t.branchId === branchId) : this.tasks).filter(t => t.status === 'open'); if (openTasks.length === 0) return null; openTasks.sort((a, b) => (new Date(a.createdAt!).getTime()) - (new Date(b.createdAt!).getTime())); return openTasks[0]; } /** Advance a task's status */ public async advanceTask(taskId: string, nextStatus: 'open' | 'in_progress' | 'closed'): Promise<boolean> { return this.updateTaskStatus(taskId, nextStatus); } /** Assign a task to a user */ public async assignTask(taskId: string, assignee: string): Promise<boolean> { await this.loadTasks(); const task = this.tasks.find(t => t.id === taskId); if (!task) throw new Error(`Task not found: ${taskId}`); task.assignee = assignee; task.lastEditor = assignee; task.auditTrail = task.auditTrail || []; task.auditTrail.push({ action: `Assigned to ${assignee}`, user: assignee, timestamp: new Date().toISOString() }); await this.saveTasks(); return true; } /** * Clear all in-memory and persistent caches */ public clearCache(): void { this.embeddingCache.clear(); this.embeddings.clear(); this.persistentEmbeddingCache = {}; } /** * Get statistics about current caches */ public getCacheStats(): Record<string, number> { return { embeddingCacheSize: this.embeddingCache.size, embeddingsMapSize: this.embeddings.size, persistentCacheEntries: Object.keys(this.persistentEmbeddingCache).length, snippetCount: this.snippets.length, }; } /** * Cache for visualization analytics (e.g., from visualizeBranch). * Uses LRU and short TTL to speed repeated agent requests. */ private analyticsCache = new LRUCache<string, VisualizationData>({ max: 50, ttl: 1000 * 60 * 5 }); // 5 min /** * Get or compute embedding for a thought and cache it. */ private async getOrCreateEmbedding(thoughtId: string, content: string): Promise<number[]> { if (this.embeddingCache.has(thoughtId)) return this.embeddingCache.get(thoughtId)!; const pipeline = await this.getEmbeddingPipeline(); const text = this.truncateText(content); const embeddingResult = await pipeline(text, {}); // embeddingResult may be numeric[][]; take first token vector and flatten const vector = Array.isArray(embeddingResult) && Array.isArray((embeddingResult as any)[0]) ? (embeddingResult as any)[0] as number[] : (embeddingResult as any) as number[]; this.embeddingCache.set(thoughtId, vector); this.embeddings.set(thoughtId, vector); return vector; } /** * Prefetch embeddings for all thoughts in a branch. */ private async prefetchEmbeddingsForBranch(branch: ThoughtBranch): Promise<void> { for (const t of branch.thoughts) { // fire-and-forget this.getOrCreateEmbedding(t.id, t.content).catch(() => {}); } } /** * Prefetch caches for a branch: summary, embeddings, and optional analytics. * @param branchId The branch to prefetch * @param advanced When true, also precompute full visualization analytics */ public async prefetchBranchCaches(branchId: string, advanced = false): Promise<void> { const branch = this.branches.get(branchId); if (!branch) return; // 1. Prefetch summary this.summarizeBranchThoughts(branchId).catch(() => {}); // 2. Prefetch embeddings this.prefetchEmbeddingsForBranch(branch); // 3. Prefetch analytics if advanced if (advanced) { const key = branchId; if (!this.analyticsCache.has(key)) { // compute in background setImmediate(() => { try { const viz = this.visualizeBranch({ branchId, showClusters: true, edgeBundling: true, levelOfDetail: 'high' }); this.analyticsCache.set(key, viz); } catch {} }); } } } /** * Retrieve cached analytics or compute+cache if missing. */ public getCachedAnalytics(branchId: string): VisualizationData { if (this.analyticsCache.has(branchId)) { return this.analyticsCache.get(branchId)!; } const viz = this.visualizeBranch({ branchId, showClusters: true, edgeBundling: true, levelOfDetail: 'high' }); this.analyticsCache.set(branchId, viz); return viz; } }

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/ssdeanx/branch-thinking-mcp'

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