diff --git a/src/core/context-tracking/ContextSnapshot.ts b/src/core/context-tracking/ContextSnapshot.ts new file mode 100644 index 0000000000..c315bdddeb --- /dev/null +++ b/src/core/context-tracking/ContextSnapshot.ts @@ -0,0 +1,141 @@ +import type { TodoItem } from "@roo-code/types" + +/** + * Represents a file that was analyzed during task execution + */ +export interface AnalyzedFile { + /** File path relative to workspace */ + path: string + /** Content hash to detect changes */ + contentHash: string + /** When this file was last analyzed */ + lastAnalyzed: number + /** Key insights discovered about this file */ + insights: string[] + /** File size at time of analysis */ + size: number + /** File modification time at analysis */ + lastModified: number +} + +/** + * Represents discovered patterns or architectural insights + */ +export interface ArchitecturalInsight { + /** Unique identifier for this insight */ + id: string + /** Type of insight (e.g., 'pattern', 'dependency', 'structure') */ + type: 'pattern' | 'dependency' | 'structure' | 'convention' | 'issue' + /** Human-readable description */ + description: string + /** Files related to this insight */ + relatedFiles: string[] + /** Confidence level (0-1) */ + confidence: number + /** When this insight was discovered */ + discoveredAt: number +} + +/** + * Represents the working context of a task + */ +export interface WorkingContext { + /** Current working directory */ + cwd: string + /** Files that have been analyzed */ + analyzedFiles: Map + /** Architectural insights discovered */ + insights: ArchitecturalInsight[] + /** Current todo list state */ + todoList?: TodoItem[] + /** Key decisions made during the task */ + decisions: TaskDecision[] + /** Important discoveries about the codebase */ + codebaseKnowledge: CodebaseKnowledge +} + +/** + * Represents a decision made during task execution + */ +export interface TaskDecision { + /** Unique identifier */ + id: string + /** What decision was made */ + decision: string + /** Why this decision was made */ + reasoning: string + /** When the decision was made */ + timestamp: number + /** Files affected by this decision */ + affectedFiles: string[] +} + +/** + * Represents knowledge about the codebase structure and patterns + */ +export interface CodebaseKnowledge { + /** Main technology stack detected */ + techStack: string[] + /** Project structure patterns */ + projectStructure: { + type: 'monorepo' | 'single-package' | 'multi-package' + mainDirectories: string[] + configFiles: string[] + } + /** Coding conventions discovered */ + conventions: { + naming: string[] + fileOrganization: string[] + patterns: string[] + } + /** Dependencies and their purposes */ + dependencies: { + name: string + purpose: string + files: string[] + }[] +} + +/** + * Complete context snapshot for a task + */ +export interface ContextSnapshot { + /** Snapshot version for compatibility */ + version: string + /** Task ID this snapshot belongs to */ + taskId: string + /** When this snapshot was created */ + createdAt: number + /** Working context at time of snapshot */ + context: WorkingContext + /** Hash of the snapshot for integrity */ + hash: string +} + +/** + * Options for creating context snapshots + */ +export interface ContextSnapshotOptions { + /** Whether to include file content hashes */ + includeContentHashes?: boolean + /** Maximum number of insights to store */ + maxInsights?: number + /** Whether to compress the snapshot */ + compress?: boolean +} + +/** + * Result of context snapshot operations + */ +export interface ContextSnapshotResult { + /** Whether the operation was successful */ + success: boolean + /** Error message if operation failed */ + error?: string + /** Size of the snapshot in bytes */ + size?: number + /** Number of files included */ + fileCount?: number + /** Number of insights included */ + insightCount?: number +} \ No newline at end of file diff --git a/src/core/context-tracking/ContextSnapshotManager.ts b/src/core/context-tracking/ContextSnapshotManager.ts new file mode 100644 index 0000000000..992dd7fea9 --- /dev/null +++ b/src/core/context-tracking/ContextSnapshotManager.ts @@ -0,0 +1,318 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as crypto from "crypto" +import { safeWriteJson } from "../../utils/safeWriteJson" + +import type { + ContextSnapshot, + WorkingContext, + AnalyzedFile, + ArchitecturalInsight, + TaskDecision, + CodebaseKnowledge, + ContextSnapshotOptions, + ContextSnapshotResult, +} from "./ContextSnapshot" + +// Local TodoItem type definition to avoid import issues during development +interface TodoItem { + id: string + content: string + status: "pending" | "in_progress" | "completed" +} + +/** + * Manages context snapshots for tasks, enabling smart resumption + */ +export class ContextSnapshotManager { + private readonly globalStoragePath: string + private readonly snapshotsDir: string + + constructor(globalStoragePath: string) { + this.globalStoragePath = globalStoragePath + this.snapshotsDir = path.join(globalStoragePath, "context-snapshots") + } + + /** + * Initialize the snapshots directory + */ + async initialize(): Promise { + try { + await fs.mkdir(this.snapshotsDir, { recursive: true }) + } catch (error) { + console.error("Failed to initialize context snapshots directory:", error) + } + } + + /** + * Create a context snapshot for a task + */ + async createSnapshot( + taskId: string, + context: WorkingContext, + options: ContextSnapshotOptions = {}, + ): Promise { + try { + const snapshot: ContextSnapshot = { + version: "1.0.0", + taskId, + createdAt: Date.now(), + context: this.sanitizeContext(context, options), + hash: "", + } + + // Generate hash for integrity + const snapshotData = JSON.stringify(snapshot) + snapshot.hash = crypto.createHash("sha256").update(snapshotData).digest("hex") + + // Save to file + const snapshotPath = this.getSnapshotPath(taskId) + await safeWriteJson(snapshotPath, snapshot) + + return { + success: true, + size: Buffer.byteLength(snapshotData, "utf8"), + fileCount: snapshot.context.analyzedFiles.size, + insightCount: snapshot.context.insights.length, + } + } catch (error) { + console.error("Failed to create context snapshot:", error) + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } + } + + /** + * Load a context snapshot for a task + */ + async loadSnapshot(taskId: string): Promise { + try { + const snapshotPath = this.getSnapshotPath(taskId) + const snapshotData = await fs.readFile(snapshotPath, "utf8") + const snapshot: ContextSnapshot = JSON.parse(snapshotData) + + // Verify integrity + const expectedHash = snapshot.hash + const actualHash = crypto + .createHash("sha256") + .update(JSON.stringify({ ...snapshot, hash: "" })) + .digest("hex") + + if (expectedHash !== actualHash) { + console.warn(`Context snapshot integrity check failed for task ${taskId}`) + return null + } + + // Convert analyzedFiles back to Map + if (snapshot.context.analyzedFiles && typeof snapshot.context.analyzedFiles === "object") { + snapshot.context.analyzedFiles = new Map(Object.entries(snapshot.context.analyzedFiles as any)) + } + + return snapshot + } catch (error) { + if ((error as any).code !== "ENOENT") { + console.error("Failed to load context snapshot:", error) + } + return null + } + } + + /** + * Check if a snapshot exists for a task + */ + async hasSnapshot(taskId: string): Promise { + try { + const snapshotPath = this.getSnapshotPath(taskId) + await fs.access(snapshotPath) + return true + } catch { + return false + } + } + + /** + * Delete a context snapshot + */ + async deleteSnapshot(taskId: string): Promise { + try { + const snapshotPath = this.getSnapshotPath(taskId) + await fs.unlink(snapshotPath) + return true + } catch (error) { + if ((error as any).code !== "ENOENT") { + console.error("Failed to delete context snapshot:", error) + } + return false + } + } + + /** + * Get all available snapshots + */ + async listSnapshots(): Promise<{ taskId: string; createdAt: number; size: number }[]> { + try { + const files = await fs.readdir(this.snapshotsDir) + const snapshots = [] + + for (const file of files) { + if (file.endsWith(".json")) { + const taskId = file.replace(".json", "") + const filePath = path.join(this.snapshotsDir, file) + const stats = await fs.stat(filePath) + snapshots.push({ + taskId, + createdAt: stats.mtime.getTime(), + size: stats.size, + }) + } + } + + return snapshots.sort((a, b) => b.createdAt - a.createdAt) + } catch (error) { + console.error("Failed to list context snapshots:", error) + return [] + } + } + + /** + * Clean up old snapshots (keep only the most recent N snapshots) + */ + async cleanupOldSnapshots(keepCount: number = 50): Promise { + try { + const snapshots = await this.listSnapshots() + const toDelete = snapshots.slice(keepCount) + let deletedCount = 0 + + for (const snapshot of toDelete) { + if (await this.deleteSnapshot(snapshot.taskId)) { + deletedCount++ + } + } + + return deletedCount + } catch (error) { + console.error("Failed to cleanup old snapshots:", error) + return 0 + } + } + + /** + * Get the file path for a task's snapshot + */ + private getSnapshotPath(taskId: string): string { + return path.join(this.snapshotsDir, `${taskId}.json`) + } + + /** + * Sanitize context data before saving + */ + private sanitizeContext(context: WorkingContext, options: ContextSnapshotOptions): WorkingContext { + const sanitized = { ...context } + + // Convert Map to object for JSON serialization + if (sanitized.analyzedFiles instanceof Map) { + sanitized.analyzedFiles = Object.fromEntries(sanitized.analyzedFiles) as any + } + + // Limit insights if specified + if (options.maxInsights && sanitized.insights.length > options.maxInsights) { + sanitized.insights = sanitized.insights + .sort((a, b) => b.confidence - a.confidence) + .slice(0, options.maxInsights) + } + + return sanitized + } + + /** + * Create an empty working context + */ + static createEmptyContext(cwd: string): WorkingContext { + return { + cwd, + analyzedFiles: new Map(), + insights: [], + decisions: [], + codebaseKnowledge: { + techStack: [], + projectStructure: { + type: "single-package", + mainDirectories: [], + configFiles: [], + }, + conventions: { + naming: [], + fileOrganization: [], + patterns: [], + }, + dependencies: [], + }, + } + } + + /** + * Merge two working contexts + */ + static mergeContexts(base: WorkingContext, overlay: WorkingContext): WorkingContext { + const merged: WorkingContext = { + cwd: overlay.cwd || base.cwd, + analyzedFiles: new Map([...base.analyzedFiles, ...overlay.analyzedFiles]), + insights: [...base.insights, ...overlay.insights], + decisions: [...base.decisions, ...overlay.decisions], + todoList: overlay.todoList || base.todoList, + codebaseKnowledge: { + techStack: [...new Set([...base.codebaseKnowledge.techStack, ...overlay.codebaseKnowledge.techStack])], + projectStructure: { + type: overlay.codebaseKnowledge.projectStructure.type || base.codebaseKnowledge.projectStructure.type, + mainDirectories: [ + ...new Set([ + ...base.codebaseKnowledge.projectStructure.mainDirectories, + ...overlay.codebaseKnowledge.projectStructure.mainDirectories, + ]), + ], + configFiles: [ + ...new Set([ + ...base.codebaseKnowledge.projectStructure.configFiles, + ...overlay.codebaseKnowledge.projectStructure.configFiles, + ]), + ], + }, + conventions: { + naming: [ + ...new Set([ + ...base.codebaseKnowledge.conventions.naming, + ...overlay.codebaseKnowledge.conventions.naming, + ]), + ], + fileOrganization: [ + ...new Set([ + ...base.codebaseKnowledge.conventions.fileOrganization, + ...overlay.codebaseKnowledge.conventions.fileOrganization, + ]), + ], + patterns: [ + ...new Set([ + ...base.codebaseKnowledge.conventions.patterns, + ...overlay.codebaseKnowledge.conventions.patterns, + ]), + ], + }, + dependencies: [...base.codebaseKnowledge.dependencies, ...overlay.codebaseKnowledge.dependencies], + }, + } + + // Remove duplicate insights based on ID + const seenInsightIds = new Set() + merged.insights = merged.insights.filter((insight) => { + if (seenInsightIds.has(insight.id)) { + return false + } + seenInsightIds.add(insight.id) + return true + }) + + return merged + } +} \ No newline at end of file diff --git a/src/core/context-tracking/ContextTracker.ts b/src/core/context-tracking/ContextTracker.ts new file mode 100644 index 0000000000..e8812b0310 --- /dev/null +++ b/src/core/context-tracking/ContextTracker.ts @@ -0,0 +1,335 @@ +import * as crypto from "crypto" +import * as fs from "fs/promises" +import * as path from "path" + +import type { + WorkingContext, + AnalyzedFile, + ArchitecturalInsight, + TaskDecision, + CodebaseKnowledge, +} from "./ContextSnapshot" +import { ContextSnapshotManager } from "./ContextSnapshotManager" + +// Local TodoItem type definition +interface TodoItem { + id: string + content: string + status: "pending" | "in_progress" | "completed" +} + +/** + * Tracks and captures context during task execution + */ +export class ContextTracker { + private context: WorkingContext + private snapshotManager: ContextSnapshotManager + private taskId: string + private autoSnapshotInterval?: NodeJS.Timeout + + constructor(taskId: string, cwd: string, globalStoragePath: string) { + this.taskId = taskId + this.context = ContextSnapshotManager.createEmptyContext(cwd) + this.snapshotManager = new ContextSnapshotManager(globalStoragePath) + } + + /** + * Initialize the context tracker + */ + async initialize(): Promise { + await this.snapshotManager.initialize() + + // Try to load existing snapshot + const existingSnapshot = await this.snapshotManager.loadSnapshot(this.taskId) + if (existingSnapshot) { + this.context = existingSnapshot.context + console.log(`Loaded existing context snapshot for task ${this.taskId}`) + } + + // Start auto-snapshot timer (every 5 minutes) + this.startAutoSnapshot() + } + + /** + * Record that a file has been analyzed + */ + async recordFileAnalysis(filePath: string, insights: string[] = []): Promise { + try { + const fullPath = path.resolve(this.context.cwd, filePath) + const stats = await fs.stat(fullPath) + const content = await fs.readFile(fullPath, "utf8") + const contentHash = crypto.createHash("md5").update(content).digest("hex") + + const analyzedFile: AnalyzedFile = { + path: filePath, + contentHash, + lastAnalyzed: Date.now(), + insights, + size: stats.size, + lastModified: stats.mtime.getTime(), + } + + this.context.analyzedFiles.set(filePath, analyzedFile) + } catch (error) { + console.warn(`Failed to record file analysis for ${filePath}:`, error) + } + } + + /** + * Add an architectural insight + */ + addInsight( + type: ArchitecturalInsight["type"], + description: string, + relatedFiles: string[] = [], + confidence: number = 0.8, + ): void { + const insight: ArchitecturalInsight = { + id: crypto.randomUUID(), + type, + description, + relatedFiles, + confidence, + discoveredAt: Date.now(), + } + + this.context.insights.push(insight) + } + + /** + * Record a task decision + */ + recordDecision(decision: string, reasoning: string, affectedFiles: string[] = []): void { + const taskDecision: TaskDecision = { + id: crypto.randomUUID(), + decision, + reasoning, + timestamp: Date.now(), + affectedFiles, + } + + this.context.decisions.push(taskDecision) + } + + /** + * Update codebase knowledge + */ + updateCodebaseKnowledge(updates: Partial): void { + if (updates.techStack) { + this.context.codebaseKnowledge.techStack = [ + ...new Set([...this.context.codebaseKnowledge.techStack, ...updates.techStack]), + ] + } + + if (updates.projectStructure) { + Object.assign(this.context.codebaseKnowledge.projectStructure, updates.projectStructure) + } + + if (updates.conventions) { + const conventions = this.context.codebaseKnowledge.conventions + if (updates.conventions.naming) { + conventions.naming = [...new Set([...conventions.naming, ...updates.conventions.naming])] + } + if (updates.conventions.fileOrganization) { + conventions.fileOrganization = [ + ...new Set([...conventions.fileOrganization, ...updates.conventions.fileOrganization]), + ] + } + if (updates.conventions.patterns) { + conventions.patterns = [...new Set([...conventions.patterns, ...updates.conventions.patterns])] + } + } + + if (updates.dependencies) { + this.context.codebaseKnowledge.dependencies = [ + ...this.context.codebaseKnowledge.dependencies, + ...updates.dependencies, + ] + } + } + + /** + * Update the todo list + */ + updateTodoList(todoList: TodoItem[]): void { + this.context.todoList = todoList + } + + /** + * Get the current context + */ + getContext(): WorkingContext { + return { ...this.context } + } + + /** + * Check if a file has been analyzed recently + */ + async isFileAnalyzedRecently(filePath: string, maxAgeMs: number = 300000): Promise { + const analyzedFile = this.context.analyzedFiles.get(filePath) + if (!analyzedFile) { + return false + } + + // Check if file has been modified since analysis + try { + const fullPath = path.resolve(this.context.cwd, filePath) + const stats = await fs.stat(fullPath) + if (stats.mtime.getTime() > analyzedFile.lastModified) { + return false + } + } catch { + return false + } + + // Check if analysis is recent enough + return Date.now() - analyzedFile.lastAnalyzed < maxAgeMs + } + + /** + * Get insights related to specific files + */ + getInsightsForFiles(filePaths: string[]): ArchitecturalInsight[] { + return this.context.insights.filter((insight) => + insight.relatedFiles.some((file) => filePaths.includes(file)), + ) + } + + /** + * Get the most confident insights + */ + getTopInsights(limit: number = 10): ArchitecturalInsight[] { + return this.context.insights + .sort((a, b) => b.confidence - a.confidence) + .slice(0, limit) + } + + /** + * Create a snapshot of the current context + */ + async createSnapshot(): Promise { + const result = await this.snapshotManager.createSnapshot(this.taskId, this.context) + return result.success + } + + /** + * Start automatic snapshot creation + */ + private startAutoSnapshot(): void { + // Create snapshot every 5 minutes + this.autoSnapshotInterval = setInterval(async () => { + await this.createSnapshot() + }, 5 * 60 * 1000) + } + + /** + * Stop automatic snapshot creation + */ + stopAutoSnapshot(): void { + if (this.autoSnapshotInterval) { + clearInterval(this.autoSnapshotInterval) + this.autoSnapshotInterval = undefined + } + } + + /** + * Cleanup and create final snapshot + */ + async dispose(): Promise { + this.stopAutoSnapshot() + await this.createSnapshot() + } + + /** + * Analyze file content and extract insights + */ + async analyzeFileContent(filePath: string): Promise { + try { + const fullPath = path.resolve(this.context.cwd, filePath) + const content = await fs.readFile(fullPath, "utf8") + const insights: string[] = [] + + // Basic analysis patterns + const ext = path.extname(filePath).toLowerCase() + + // Detect technology stack + if (ext === ".ts" || ext === ".tsx") { + insights.push("TypeScript file") + if (content.includes("import React")) { + insights.push("React component") + this.updateCodebaseKnowledge({ techStack: ["React", "TypeScript"] }) + } + if (content.includes("import * as vscode")) { + insights.push("VSCode extension code") + this.updateCodebaseKnowledge({ techStack: ["VSCode Extension"] }) + } + } + + // Detect patterns + if (content.includes("export class") && content.includes("extends")) { + insights.push("Class inheritance pattern") + } + if (content.includes("interface ") && content.includes("export")) { + insights.push("TypeScript interface definition") + } + if (content.includes("async ") && content.includes("await ")) { + insights.push("Async/await pattern") + } + + // Detect architectural patterns + if (filePath.includes("Manager") || filePath.includes("Service")) { + insights.push("Service/Manager pattern") + } + if (filePath.includes("Provider")) { + insights.push("Provider pattern") + } + + await this.recordFileAnalysis(filePath, insights) + return insights + } catch (error) { + console.warn(`Failed to analyze file content for ${filePath}:`, error) + return [] + } + } + + /** + * Generate context summary for task resumption + */ + generateContextSummary(): string { + const summary = [] + + // Files analyzed + if (this.context.analyzedFiles.size > 0) { + summary.push(`Analyzed ${this.context.analyzedFiles.size} files:`) + const recentFiles = Array.from(this.context.analyzedFiles.entries()) + .sort(([, a], [, b]) => b.lastAnalyzed - a.lastAnalyzed) + .slice(0, 5) + .map(([path]) => ` - ${path}`) + summary.push(...recentFiles) + } + + // Key insights + if (this.context.insights.length > 0) { + summary.push(`\nKey insights discovered:`) + const topInsights = this.getTopInsights(3).map((insight) => ` - ${insight.description}`) + summary.push(...topInsights) + } + + // Technology stack + if (this.context.codebaseKnowledge.techStack.length > 0) { + summary.push(`\nTechnology stack: ${this.context.codebaseKnowledge.techStack.join(", ")}`) + } + + // Recent decisions + if (this.context.decisions.length > 0) { + summary.push(`\nRecent decisions:`) + const recentDecisions = this.context.decisions + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 2) + .map((decision) => ` - ${decision.decision}`) + summary.push(...recentDecisions) + } + + return summary.join("\n") + } +} \ No newline at end of file diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 53b8ef5b87..9e4d9ec07a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -69,6 +69,7 @@ import { SYSTEM_PROMPT } from "../prompts/system" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { FileContextTracker } from "../context-tracking/FileContextTracker" +import { ContextTracker } from "../context-tracking/ContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, parseAssistantMessage, presentAssistantMessage } from "../assistant-message" @@ -165,6 +166,7 @@ export class Task extends EventEmitter { rooIgnoreController?: RooIgnoreController rooProtectedController?: RooProtectedController fileContextTracker: FileContextTracker + contextTracker: ContextTracker urlContentFetcher: UrlContentFetcher terminalProcess?: RooTerminalProcess @@ -245,6 +247,7 @@ export class Task extends EventEmitter { this.rooIgnoreController = new RooIgnoreController(this.cwd) this.rooProtectedController = new RooProtectedController(this.cwd) this.fileContextTracker = new FileContextTracker(provider, this.taskId) + this.contextTracker = new ContextTracker(this.taskId, this.cwd, provider.context.globalStorageUri.fsPath) this.rooIgnoreController.initialize().catch((error) => { console.error("Failed to initialize RooIgnoreController:", error) @@ -797,6 +800,17 @@ export class Task extends EventEmitter { modifiedClineMessages.splice(lastRelevantMessageIndex + 1) } + // Initialize context tracker and try to restore context snapshot for better resumption + try { + await this.contextTracker.initialize() + const contextSummary = this.contextTracker.generateContextSummary() + if (contextSummary) { + console.log("Context snapshot restored for task resumption:", contextSummary) + } + } catch (error) { + console.log("No context snapshot available for restoration:", error) + } + // since we don't use api_req_finished anymore, we need to check if the last api_req_started has a cost value, if it doesn't and no cancellation reason to present, then we remove it since it indicates an api request without any partial content streamed const lastApiReqStartedIndex = findLastIndex( modifiedClineMessages, @@ -1064,6 +1078,12 @@ export class Task extends EventEmitter { console.error("Error disposing file context tracker:", error) } + try { + this.contextTracker.dispose() + } catch (error) { + console.error("Error disposing context tracker:", error) + } + try { // If we're not streaming then `abortStream` won't be called if (this.isStreaming && this.diffViewProvider.isEditing) { @@ -1127,6 +1147,15 @@ export class Task extends EventEmitter { this.emit("taskStarted") + // Initialize context tracker for new tasks + if (!this.isInitialized) { + try { + await this.contextTracker.initialize() + } catch (error) { + console.warn("Failed to initialize context tracker:", error) + } + } + while (!this.abort) { const didEndLoop = await this.recursivelyMakeClineRequests(nextUserContent, includeFileDetails) includeFileDetails = false // we only need file details the first time diff --git a/src/tests/context-tracking/ContextTracker.test.ts b/src/tests/context-tracking/ContextTracker.test.ts new file mode 100644 index 0000000000..688855dc02 --- /dev/null +++ b/src/tests/context-tracking/ContextTracker.test.ts @@ -0,0 +1,191 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest" +import * as fs from "fs/promises" +import * as path from "path" +import * as os from "os" +import { ContextTracker } from "../../core/context-tracking/ContextTracker" + +describe("ContextTracker", () => { + let contextTracker: ContextTracker + let tempDir: string + let globalStoragePath: string + + beforeEach(async () => { + // Create temporary directories for testing + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "context-tracker-test-")) + globalStoragePath = await fs.mkdtemp(path.join(os.tmpdir(), "context-storage-test-")) + + contextTracker = new ContextTracker("test-task-id", tempDir, globalStoragePath) + await contextTracker.initialize() + }) + + afterEach(async () => { + // Cleanup + await contextTracker.dispose() + await fs.rm(tempDir, { recursive: true, force: true }) + await fs.rm(globalStoragePath, { recursive: true, force: true }) + }) + + test("should initialize with empty context", () => { + const context = contextTracker.getContext() + expect(context.cwd).toBe(tempDir) + expect(context.analyzedFiles.size).toBe(0) + expect(context.insights).toHaveLength(0) + expect(context.decisions).toHaveLength(0) + }) + + test("should record file analysis", async () => { + // Create a test file + const testFile = "test.ts" + const testFilePath = path.join(tempDir, testFile) + const testContent = ` +export class TestClass { + async testMethod(): Promise { + console.log("test"); + } +} +` + await fs.writeFile(testFilePath, testContent) + + // Analyze the file + const insights = await contextTracker.analyzeFileContent(testFile) + + expect(insights).toContain("TypeScript file") + expect(insights).toContain("Class inheritance pattern") + expect(insights).toContain("Async/await pattern") + + const context = contextTracker.getContext() + expect(context.analyzedFiles.has(testFile)).toBe(true) + + const analyzedFile = context.analyzedFiles.get(testFile) + expect(analyzedFile).toBeDefined() + expect(analyzedFile!.path).toBe(testFile) + expect(analyzedFile!.insights).toEqual(insights) + }) + + test("should add architectural insights", () => { + contextTracker.addInsight("pattern", "Service pattern detected", ["service.ts"], 0.9) + + const context = contextTracker.getContext() + expect(context.insights).toHaveLength(1) + + const insight = context.insights[0] + expect(insight.type).toBe("pattern") + expect(insight.description).toBe("Service pattern detected") + expect(insight.relatedFiles).toEqual(["service.ts"]) + expect(insight.confidence).toBe(0.9) + }) + + test("should record task decisions", () => { + contextTracker.recordDecision( + "Use TypeScript for implementation", + "TypeScript provides better type safety", + ["src/main.ts"] + ) + + const context = contextTracker.getContext() + expect(context.decisions).toHaveLength(1) + + const decision = context.decisions[0] + expect(decision.decision).toBe("Use TypeScript for implementation") + expect(decision.reasoning).toBe("TypeScript provides better type safety") + expect(decision.affectedFiles).toEqual(["src/main.ts"]) + }) + + test("should update codebase knowledge", () => { + contextTracker.updateCodebaseKnowledge({ + techStack: ["React", "TypeScript"], + conventions: { + naming: ["camelCase"], + fileOrganization: ["feature-based"], + patterns: ["hooks"] + } + }) + + const context = contextTracker.getContext() + expect(context.codebaseKnowledge.techStack).toContain("React") + expect(context.codebaseKnowledge.techStack).toContain("TypeScript") + expect(context.codebaseKnowledge.conventions.naming).toContain("camelCase") + }) + + test("should generate context summary", async () => { + // Add some context data + const testFile = "test.ts" + const testFilePath = path.join(tempDir, testFile) + await fs.writeFile(testFilePath, "export class Test {}") + + await contextTracker.analyzeFileContent(testFile) + contextTracker.addInsight("pattern", "Class pattern detected") + contextTracker.recordDecision("Use classes", "Better organization") + + const summary = contextTracker.generateContextSummary() + + expect(summary).toContain("Analyzed 1 files") + expect(summary).toContain("test.ts") + expect(summary).toContain("Key insights discovered") + expect(summary).toContain("Class pattern detected") + expect(summary).toContain("Recent decisions") + expect(summary).toContain("Use classes") + }) + + test("should create and restore snapshots", async () => { + // Add some context data + contextTracker.addInsight("pattern", "Test insight") + contextTracker.recordDecision("Test decision", "Test reasoning") + + // Create snapshot + const success = await contextTracker.createSnapshot() + expect(success).toBe(true) + + // Create new tracker and verify it loads the snapshot + const newTracker = new ContextTracker("test-task-id", tempDir, globalStoragePath) + await newTracker.initialize() + + const context = newTracker.getContext() + expect(context.insights).toHaveLength(1) + expect(context.insights[0].description).toBe("Test insight") + expect(context.decisions).toHaveLength(1) + expect(context.decisions[0].decision).toBe("Test decision") + + await newTracker.dispose() + }) + + test("should check if file was analyzed recently", async () => { + const testFile = "test.ts" + const testFilePath = path.join(tempDir, testFile) + await fs.writeFile(testFilePath, "export class Test {}") + + // File not analyzed yet + expect(await contextTracker.isFileAnalyzedRecently(testFile)).toBe(false) + + // Analyze file + await contextTracker.analyzeFileContent(testFile) + + // File should be considered recently analyzed + expect(await contextTracker.isFileAnalyzedRecently(testFile)).toBe(true) + + // Should not be recent with very short max age + expect(await contextTracker.isFileAnalyzedRecently(testFile, 1)).toBe(false) + }) + + test("should get insights for specific files", () => { + contextTracker.addInsight("pattern", "Insight 1", ["file1.ts"]) + contextTracker.addInsight("structure", "Insight 2", ["file2.ts"]) + contextTracker.addInsight("pattern", "Insight 3", ["file1.ts", "file3.ts"]) + + const insights = contextTracker.getInsightsForFiles(["file1.ts"]) + expect(insights).toHaveLength(2) + expect(insights.map(i => i.description)).toContain("Insight 1") + expect(insights.map(i => i.description)).toContain("Insight 3") + }) + + test("should get top insights by confidence", () => { + contextTracker.addInsight("pattern", "Low confidence", [], 0.3) + contextTracker.addInsight("structure", "High confidence", [], 0.9) + contextTracker.addInsight("pattern", "Medium confidence", [], 0.6) + + const topInsights = contextTracker.getTopInsights(2) + expect(topInsights).toHaveLength(2) + expect(topInsights[0].description).toBe("High confidence") + expect(topInsights[1].description).toBe("Medium confidence") + }) +}) \ No newline at end of file