mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: Smart Context Preservation for Task Resumption
- Add ContextSnapshot interfaces for rich context data structures - Implement ContextSnapshotManager for persistent storage and retrieval - Create ContextTracker for active context capture during task execution - Integrate context tracking into Task class for automatic context preservation - Add context restoration logic to resumeTaskFromHistory method - Include comprehensive test suite for context tracking functionality This feature enables much more intelligent task resumption by capturing and storing rich contextual information including analyzed files, architectural insights, task decisions, and codebase knowledge, rather than relying solely on conversation history reconstruction.
This commit is contained in:
parent
38d8edf05a
commit
f7e984e5da
5 changed files with 1014 additions and 0 deletions
141
src/core/context-tracking/ContextSnapshot.ts
Normal file
141
src/core/context-tracking/ContextSnapshot.ts
Normal file
|
|
@ -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<string, AnalyzedFile>
|
||||
/** 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
|
||||
}
|
||||
318
src/core/context-tracking/ContextSnapshotManager.ts
Normal file
318
src/core/context-tracking/ContextSnapshotManager.ts
Normal file
|
|
@ -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<void> {
|
||||
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<ContextSnapshotResult> {
|
||||
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<ContextSnapshot | null> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const snapshotPath = this.getSnapshotPath(taskId)
|
||||
await fs.access(snapshotPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a context snapshot
|
||||
*/
|
||||
async deleteSnapshot(taskId: string): Promise<boolean> {
|
||||
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<number> {
|
||||
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
|
||||
}
|
||||
}
|
||||
335
src/core/context-tracking/ContextTracker.ts
Normal file
335
src/core/context-tracking/ContextTracker.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<CodebaseKnowledge>): 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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
this.stopAutoSnapshot()
|
||||
await this.createSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze file content and extract insights
|
||||
*/
|
||||
async analyzeFileContent(filePath: string): Promise<string[]> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ClineEvents> {
|
|||
rooIgnoreController?: RooIgnoreController
|
||||
rooProtectedController?: RooProtectedController
|
||||
fileContextTracker: FileContextTracker
|
||||
contextTracker: ContextTracker
|
||||
urlContentFetcher: UrlContentFetcher
|
||||
terminalProcess?: RooTerminalProcess
|
||||
|
||||
|
|
@ -245,6 +247,7 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
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<ClineEvents> {
|
|||
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<ClineEvents> {
|
|||
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<ClineEvents> {
|
|||
|
||||
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
|
||||
|
|
|
|||
191
src/tests/context-tracking/ContextTracker.test.ts
Normal file
191
src/tests/context-tracking/ContextTracker.test.ts
Normal file
|
|
@ -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<void> {
|
||||
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")
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue