diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 5bc280dc423..11dba280e38 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -1115,6 +1115,7 @@ export class Cline { experiments, enableMcpServerCreation, rooIgnoreInstructions, + provider.getSchedulableRulesManager(), ) })() diff --git a/src/core/prompts/sections/__tests__/custom-instructions-schedulable.test.ts b/src/core/prompts/sections/__tests__/custom-instructions-schedulable.test.ts new file mode 100644 index 00000000000..db2753f7752 --- /dev/null +++ b/src/core/prompts/sections/__tests__/custom-instructions-schedulable.test.ts @@ -0,0 +1,111 @@ +/*********************************************** + * FILE: custom-instructions-schedulable.test.ts + * CREATED: 2025-03-11 03:19:21 + * + * CHANGELOG: + * - 2025-03-11 03:19:21: (Schedulable Rules - Implementation) Initial implementation of tests for schedulable rules integration with custom instructions + * + * PURPOSE: + * This file contains tests for schedulable rules integration with the custom instructions system. + * + * METHODS: + * - None (test file) + ***********************************************/ + +import { addCustomInstructions } from "../custom-instructions" +import { SchedulableRulesManager, SchedulableRule } from "../schedulable-rules" +import * as fs from "fs/promises" + +// Mock dependencies +jest.mock("fs/promises") +jest.mock("../schedulable-rules") + +describe("addCustomInstructions with schedulable rules", () => { + let mockSchedulableRulesManager: jest.Mocked + const mockRules: SchedulableRule[] = [ + { + filePath: "/path/to/.clinerules-5m", + fileName: ".clinerules-5m", + interval: 5 * 60 * 1000, + timeUnit: "m", + displayInterval: "5 minutes", + content: "Some rule content for 5 minutes", + lastExecuted: 0, + }, + { + filePath: "/path/to/.clinerules-10s", + fileName: ".clinerules-10s", + interval: 10 * 1000, + timeUnit: "s", + displayInterval: "10 seconds", + content: "Some rule content for 10 seconds", + lastExecuted: 0, + }, + ] + + beforeEach(() => { + jest.resetAllMocks() + + // Mock SchedulableRulesManager + mockSchedulableRulesManager = { + resetAllRules: jest.fn(), + loadSchedulableRules: jest.fn(), + shouldExecuteRule: jest.fn(), + markRuleAsExecuted: jest.fn(), + getExecutableRules: jest.fn().mockResolvedValue(mockRules), + getAllRules: jest.fn(), + } as unknown as jest.Mocked + + // Mock fs + ;(fs.readFile as jest.Mock).mockImplementation((filePath: string) => { + if (filePath.endsWith(".clinerules")) { + return Promise.resolve("Generic rules content") + } + if (filePath.endsWith(".clinerules-code")) { + return Promise.resolve("Mode specific rules content") + } + return Promise.resolve("") + }) + }) + + test("should include schedulable rules in custom instructions", async () => { + const result = await addCustomInstructions( + "Mode custom instructions", + "Global custom instructions", + "/fake/cwd", + "code", + {}, + mockSchedulableRulesManager, + ) + + // Check that getExecutableRules was called + expect(mockSchedulableRulesManager.getExecutableRules).toHaveBeenCalledWith("/fake/cwd") + + // Check that markRuleAsExecuted was called for each rule + expect(mockSchedulableRulesManager.markRuleAsExecuted).toHaveBeenCalledTimes(2) + expect(mockSchedulableRulesManager.markRuleAsExecuted).toHaveBeenCalledWith(mockRules[0]) + expect(mockSchedulableRulesManager.markRuleAsExecuted).toHaveBeenCalledWith(mockRules[1]) + + // Check that the result includes the rule content + expect(result).toContain("Rules from .clinerules-5m (every 5 minutes)") + expect(result).toContain("Some rule content for 5 minutes") + expect(result).toContain("Rules from .clinerules-10s (every 10 seconds)") + expect(result).toContain("Some rule content for 10 seconds") + }) + + test("should work without a schedulable rules manager", async () => { + const result = await addCustomInstructions( + "Mode custom instructions", + "Global custom instructions", + "/fake/cwd", + "code", + {}, + ) + + // Check that the result includes normal content but not schedulable rules + expect(result).not.toContain("Rules from .clinerules-5m") + expect(result).not.toContain("Rules from .clinerules-10s") + expect(result).toContain("Mode custom instructions") + expect(result).toContain("Global custom instructions") + }) +}) diff --git a/src/core/prompts/sections/__tests__/schedulable-rules.test.ts b/src/core/prompts/sections/__tests__/schedulable-rules.test.ts new file mode 100644 index 00000000000..e539922bbdd --- /dev/null +++ b/src/core/prompts/sections/__tests__/schedulable-rules.test.ts @@ -0,0 +1,215 @@ +/*********************************************** + * FILE: schedulable-rules.test.ts + * CREATED: 2025-03-11 03:18:01 + * + * CHANGELOG: + * - 2025-03-11 03:18:01: (Schedulable Rules - Implementation) Initial implementation of unit tests for parseTimeInterval and SchedulableRulesManager + * - 2025-03-11 03:53:05: (Schedulable Rules - Bug Fix) Fixed import path for utils/logging module in test mocks + * - 2025-03-11 03:53:37: (Schedulable Rules - Bug Fix) Added explicit mock implementations for fs/promises readdir and readFile functions + * + * PURPOSE: + * This file contains tests for the schedulable rules implementation. + * + * METHODS: + * - None (test file) + ***********************************************/ + +import { parseTimeInterval, SchedulableRule, SchedulableRulesManager } from "../schedulable-rules" +import * as fs from "fs/promises" +import * as path from "path" + +// Mock dependencies +jest.mock("fs/promises", () => ({ + readdir: jest.fn(), + readFile: jest.fn(), +})) +jest.mock("../../../../utils/logging", () => ({ + logger: { + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})) + +describe("parseTimeInterval", () => { + test("should correctly parse seconds", () => { + const result = parseTimeInterval("5s") + expect(result.interval).toBe(5 * 1000) + expect(result.unit).toBe("s") + expect(result.display).toBe("5 seconds") + }) + + test("should correctly parse minutes", () => { + const result = parseTimeInterval("10m") + expect(result.interval).toBe(10 * 60 * 1000) + expect(result.unit).toBe("m") + expect(result.display).toBe("10 minutes") + }) + + test("should correctly parse hours", () => { + const result = parseTimeInterval("2h") + expect(result.interval).toBe(2 * 60 * 60 * 1000) + expect(result.unit).toBe("h") + expect(result.display).toBe("2 hours") + }) + + test("should correctly parse days", () => { + const result = parseTimeInterval("1d") + expect(result.interval).toBe(24 * 60 * 60 * 1000) + expect(result.unit).toBe("d") + expect(result.display).toBe("1 day") + }) + + test("should handle singular units correctly", () => { + const result = parseTimeInterval("1s") + expect(result.display).toBe("1 second") + }) + + test("should throw an error for invalid formats", () => { + expect(() => parseTimeInterval("5x")).toThrow() + expect(() => parseTimeInterval("abc")).toThrow() + expect(() => parseTimeInterval("5")).toThrow() + }) +}) + +describe("SchedulableRulesManager", () => { + let manager: SchedulableRulesManager + const mockRules: SchedulableRule[] = [ + { + filePath: "/path/to/.clinerules-5m", + fileName: ".clinerules-5m", + interval: 5 * 60 * 1000, + timeUnit: "m", + displayInterval: "5 minutes", + content: "Some rule content", + lastExecuted: 0, + }, + { + filePath: "/path/to/.clinerules-10s", + fileName: ".clinerules-10s", + interval: 10 * 1000, + timeUnit: "s", + displayInterval: "10 seconds", + content: "Another rule content", + lastExecuted: 0, + }, + ] + + beforeEach(() => { + jest.resetAllMocks() + manager = new SchedulableRulesManager() + + // Mock readdir to return rule files + const mockFiles = [".clinerules-5m", ".clinerules-10s", "other-file.txt"] + ;(fs.readdir as jest.Mock).mockResolvedValue(mockFiles) + + // Mock readFile to return content + ;(fs.readFile as jest.Mock).mockImplementation((filePath) => { + if (filePath.includes(".clinerules-5m")) { + return Promise.resolve("Some rule content") + } else if (filePath.includes(".clinerules-10s")) { + return Promise.resolve("Another rule content") + } + return Promise.resolve("") + }) + }) + + test("should load schedulable rules from directory", async () => { + const rules = await manager.loadSchedulableRules("/fake/cwd") + + expect(fs.readdir).toHaveBeenCalledWith("/fake/cwd") + expect(rules).toHaveLength(2) + expect(rules[0].fileName).toBe(".clinerules-5m") + expect(rules[1].fileName).toBe(".clinerules-10s") + }) + + test("should check if a rule should be executed", () => { + // Rule should execute if it has never been executed + expect(manager.shouldExecuteRule(mockRules[0])).toBe(true) + + // Mark the rule as executed + manager.markRuleAsExecuted(mockRules[0]) + + // Rule should not execute immediately after being marked + expect(manager.shouldExecuteRule(mockRules[0])).toBe(false) + + // Simulate time passing (manually setting lastExecutionTimes) + const sixMinutesAgo = Date.now() - 6 * 60 * 1000 + Object.defineProperty(manager, "lastExecutionTimes", { + value: new Map([[mockRules[0].fileName, sixMinutesAgo]]), + }) + + // Now the rule should execute again (5 minutes have passed) + expect(manager.shouldExecuteRule(mockRules[0])).toBe(true) + }) + + test("should return executable rules", async () => { + // Mock implementation to return our test rules + jest.spyOn(manager, "loadSchedulableRules").mockResolvedValue(mockRules) + + // Initially, all rules should be executable + let executableRules = await manager.getExecutableRules("/fake/cwd") + expect(executableRules).toHaveLength(2) + + // Mark one rule as executed + manager.markRuleAsExecuted(mockRules[0]) + + // Mock shouldExecuteRule to return false for the first rule + jest.spyOn(manager, "shouldExecuteRule").mockImplementation((rule) => { + return rule.fileName !== mockRules[0].fileName + }) + + // Now only one rule should be executable + executableRules = await manager.getExecutableRules("/fake/cwd") + expect(executableRules).toHaveLength(1) + expect(executableRules[0].fileName).toBe(mockRules[1].fileName) + }) + + test("should reset all rules", () => { + // Mark rules as executed + manager.markRuleAsExecuted(mockRules[0]) + manager.markRuleAsExecuted(mockRules[1]) + + // Verify the lastExecutionTimes map has entries + expect((manager as any).lastExecutionTimes.size).toBe(2) + + // Reset all rules + manager.resetAllRules() + + // Verify the lastExecutionTimes map is cleared + expect((manager as any).lastExecutionTimes.size).toBe(0) + }) + + test("should handle errors when loading rules", async () => { + // Mock readdir to throw an error + ;(fs.readdir as jest.Mock).mockRejectedValue(new Error("Directory not found")) + + const rules = await manager.loadSchedulableRules("/fake/cwd") + + // Should return empty array on error + expect(rules).toEqual([]) + }) + + test("should get all rules with next execution time", async () => { + // Mock implementation to return our test rules + jest.spyOn(manager, "loadSchedulableRules").mockResolvedValue(mockRules) + + // Mark one rule as executed 3 minutes ago + const threeMinutesAgo = Date.now() - 3 * 60 * 1000 + Object.defineProperty(manager, "lastExecutionTimes", { + value: new Map([[mockRules[0].fileName, threeMinutesAgo]]), + }) + + const rulesWithStatus = await manager.getAllRules("/fake/cwd") + + expect(rulesWithStatus).toHaveLength(2) + + // First rule should have nextExecution time of approximately 2 minutes (5 - 3) + const twoMinutesMs = 2 * 60 * 1000 + expect(rulesWithStatus[0].nextExecution).toBeGreaterThan(twoMinutesMs - 100) + expect(rulesWithStatus[0].nextExecution).toBeLessThan(twoMinutesMs + 100) + + // Second rule should have nextExecution time of 0 (never executed) + expect(rulesWithStatus[1].nextExecution).toBe(0) + }) +}) diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index 8e94a785632..9ee288fb476 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -1,5 +1,6 @@ import fs from "fs/promises" import path from "path" +import { SchedulableRulesManager } from "./schedulable-rules" async function safeReadFile(filePath: string): Promise { try { @@ -34,6 +35,7 @@ export async function addCustomInstructions( cwd: string, mode: string, options: { preferredLanguage?: string; rooIgnoreInstructions?: string } = {}, + schedulableRulesManager?: SchedulableRulesManager, ): Promise { const sections = [] @@ -64,6 +66,7 @@ export async function addCustomInstructions( // Add rules - include both mode-specific and generic rules if they exist const rules = [] + // Add mode-specific rules first if they exist // Add mode-specific rules first if they exist if (modeRuleContent && modeRuleContent.trim()) { const modeRuleFile = `.clinerules-${mode}` @@ -74,12 +77,20 @@ export async function addCustomInstructions( rules.push(options.rooIgnoreInstructions) } + // Add schedulable rules if manager is provided + if (schedulableRulesManager) { + const executableRules = await schedulableRulesManager.getExecutableRules(cwd) + for (const rule of executableRules) { + rules.push(`# Rules from ${rule.fileName} (every ${rule.displayInterval}):\n${rule.content}`) + schedulableRulesManager.markRuleAsExecuted(rule) + } + } + // Add generic rules const genericRuleContent = await loadRuleFiles(cwd) if (genericRuleContent && genericRuleContent.trim()) { rules.push(genericRuleContent.trim()) } - if (rules.length > 0) { sections.push(`Rules:\n\n${rules.join("\n\n")}`) } diff --git a/src/core/prompts/sections/schedulable-rules.ts b/src/core/prompts/sections/schedulable-rules.ts new file mode 100644 index 00000000000..fc2077cb722 --- /dev/null +++ b/src/core/prompts/sections/schedulable-rules.ts @@ -0,0 +1,406 @@ +/*********************************************** + * FILE: schedulable-rules.ts + * CREATED: 2025-03-11 03:16:11 + * + * CHANGELOG: + * - 2025-03-11 03:16:11: (Schedulable Rules - Implementation) Initial implementation with parseTimeInterval, SchedulableRule interface, and SchedulableRulesManager class + * - 2025-03-11 03:16:58: (Schedulable Rules - Implementation) Fixed TypeScript errors related to imports and type annotations + * - 2025-03-11 06:30:00: (Schedulable Rules - Persistence) Added persistence for rule execution times using VSCode global state + * - 2025-03-11 06:36:30: (Schedulable Rules - Async) Fixed async handling for non-awaited markRuleAsExecuted calls + * + * PURPOSE: + * This file handles loading and management of time-based rule files (.clinerules-5m, etc.) + * that are executed on a schedule and appended to the system prompt. + * + * METHODS: + * - parseTimeInterval(): Parses time intervals from filenames + * - SchedulableRulesManager.constructor(): Initializes a new rules manager + * - SchedulableRulesManager.setContext(): Sets the VSCode extension context for state persistence + * - SchedulableRulesManager.loadExecutionTimes(): Loads execution times from global state + * - SchedulableRulesManager.saveExecutionTimes(): Saves execution times to global state + * - SchedulableRulesManager.resetAllRules(): Resets all rule execution times + * - SchedulableRulesManager.loadSchedulableRules(): Loads all schedulable rules from a directory + * - SchedulableRulesManager.shouldExecuteRule(): Checks if a rule should be executed + * - SchedulableRulesManager.markRuleAsExecuted(): Marks a rule as executed + * - SchedulableRulesManager.getExecutableRules(): Gets rules that should be executed + * - SchedulableRulesManager.getAllRules(): Gets all rules with their status for UI display + ***********************************************/ + +import * as path from "path" +import * as fs from "fs/promises" +import * as vscode from "vscode" +import { logger } from "../../../utils/logging" + +/** + * Interface representing a schedulable rule + */ +export interface SchedulableRule { + filePath: string + fileName: string + interval: number // in milliseconds + timeUnit: string // 's', 'm', 'h', 'd' + displayInterval: string // Human readable (e.g., "5 minutes") + content: string + lastExecuted: number +} + +/** + * Time unit conversion mapping + */ +const TIME_UNITS = { + s: { ms: 1000, name: "second" }, + m: { ms: 60 * 1000, name: "minute" }, + h: { ms: 60 * 60 * 1000, name: "hour" }, + d: { ms: 24 * 60 * 60 * 1000, name: "day" }, +} as const + +type TimeUnit = keyof typeof TIME_UNITS + +/** + * Parse time component from file name (e.g., "5m" => 300000ms) + * @param timeStr - The time string to parse (e.g., "5m", "10s", "1h", "1d") + * @returns Object containing the interval in milliseconds, unit, and display string + * @throws Error if the time format is invalid + */ +export function parseTimeInterval(timeStr: string): { interval: number; unit: string; display: string } { + // Match pattern like "5m", "10s", "1h", "1d" + const match = timeStr.match(/^(\d+)([smhd])$/) + if (!match) { + throw new Error(`Invalid time format: ${timeStr}. Expected format: e.g., "5m", "10s", "1h", "1d"`) + } + + const value = parseInt(match[1], 10) + const unit = match[2] as TimeUnit + + const intervalMs = value * TIME_UNITS[unit].ms + const unitName = TIME_UNITS[unit].name + const display = `${value} ${unitName}${value !== 1 ? "s" : ""}` + + return { + interval: intervalMs, + unit, + display, + } +} + +/** + * Manager for schedulable rule files + */ +export class SchedulableRulesManager { + private lastExecutionTimes: Map = new Map() + private context: vscode.ExtensionContext | null = null + private outputChannel: vscode.OutputChannel | null = null + private readonly STORAGE_KEY = "schedulableRules.lastExecutionTimes" + + /** + * Create a new SchedulableRulesManager + */ + constructor() { + logger.info("SchedulableRulesManager initialized") + } + + /** + * Set the extension context for persistence + * @param context - VSCode extension context + */ + public setContext(context: vscode.ExtensionContext): void { + this.context = context + this.loadExecutionTimes() + logger.debug("SchedulableRulesManager context set") + this.log("debug", "SchedulableRulesManager context set") + } + + /** + * Set the output channel for logging + * @param outputChannel - VSCode output channel + */ + public setOutputChannel(outputChannel: vscode.OutputChannel): void { + this.outputChannel = outputChannel + this.log("info", "SchedulableRulesManager output channel set") + } + + /** + * Log a message to both the outputChannel (if available) and the logger + * @param level - Log level + * @param message - Message to log + */ + private log(level: "debug" | "info" | "warn" | "error", message: string): void { + // Add timestamp for better time tracking + const timestamp = new Date().toLocaleTimeString() + const formattedMessage = `[${timestamp}] [SchedulableRules] ${message}` + + // Always show output channel when logging + if (this.outputChannel) { + this.outputChannel.appendLine(formattedMessage) + + // Show the output panel for important messages + if (level === "info" || level === "warn" || level === "error") { + if (typeof this.outputChannel.show === "function") { + this.outputChannel.show(true) + } + } + } + + // Also log to the regular logger for completeness + switch (level) { + case "debug": + if (typeof logger.debug === "function") logger.debug(message) + break + case "info": + if (typeof logger.info === "function") logger.info(message) + break + case "warn": + if (typeof logger.warn === "function") logger.warn(message) + break + case "error": + if (typeof logger.error === "function") logger.error(message) + break + } + } + + /** + * Load execution times from global state + */ + private loadExecutionTimes(): void { + if (!this.context) { + this.log("warn", "Cannot load execution times: context not set") + return + } + + try { + const savedTimes = this.context.globalState.get>(this.STORAGE_KEY) + if (savedTimes) { + this.lastExecutionTimes = new Map(Object.entries(savedTimes)) + this.log("debug", `Loaded ${this.lastExecutionTimes.size} rule execution times from storage`) + } + } catch (err) { + this.log("error", `Failed to load execution times: ${err instanceof Error ? err.message : String(err)}`) + } + } + + /** + * Save execution times to global state + */ + private saveExecutionTimes(): Promise { + if (!this.context) { + this.log("warn", "Cannot save execution times: context not set") + return Promise.resolve() + } + + try { + const timesObject = Object.fromEntries(this.lastExecutionTimes.entries()) + // Convert Thenable to Promise and handle errors + return Promise.resolve(this.context.globalState.update(this.STORAGE_KEY, timesObject)) + .then(() => { + this.log("debug", `Saved ${this.lastExecutionTimes.size} rule execution times to storage`) + }) + .catch((err: unknown) => { + this.log( + "error", + `Failed to save execution times: ${err instanceof Error ? err.message : String(err)}`, + ) + }) + } catch (err: unknown) { + this.log("error", `Failed to save execution times: ${err instanceof Error ? err.message : String(err)}`) + return Promise.resolve() + } + } + + /** + * Reset all rule execution times + */ + public resetAllRules(): Promise { + this.lastExecutionTimes.clear() + this.log("debug", "All schedulable rules reset") + return this.saveExecutionTimes() + } + + /** + * Load all schedulable rules from a directory + * @param cwd - The current working directory + * @returns Promise resolving to an array of SchedulableRule objects + */ + public async loadSchedulableRules(cwd: string): Promise { + try { + this.log("debug", `Loading schedulable rules from: ${cwd}`) + const files = await fs.readdir(cwd) + + // Filter for files matching the pattern .clinerules-\d+[smhd] + const ruleFiles = files.filter((file: string) => /^\.clinerules-\d+[smhd]$/.test(file)) + this.log("debug", `Found ${ruleFiles.length} schedulable rule files`) + + const rules: SchedulableRule[] = [] + + for (const file of ruleFiles) { + try { + const filePath = path.join(cwd, file) + const content = await fs.readFile(filePath, "utf-8") + + // Extract the time component (e.g., "5m" from ".clinerules-5m") + const timeComponent = file.replace(/^\.clinerules-/, "") + const { interval, unit, display } = parseTimeInterval(timeComponent) + + rules.push({ + filePath, + fileName: file, + interval, + timeUnit: unit, + displayInterval: display, + content: content.trim(), + lastExecuted: this.lastExecutionTimes.get(file) || 0, + }) + + this.log("debug", `Loaded rule file: ${file}, interval: ${display}`) + } catch (err) { + this.log( + "error", + `Failed to parse schedulable rule file ${file}: ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + return rules + } catch (err) { + this.log("error", `Failed to load schedulable rules: ${err instanceof Error ? err.message : String(err)}`) + return [] + } + } + + /** + * Check if a rule should be executed based on its interval + * @param rule - The rule to check + /** + * Check if a rule should be executed based on its interval + * @param rule - The rule to check + * @returns True if the rule should be executed, false otherwise + */ + public shouldExecuteRule(rule: SchedulableRule): boolean { + const now = Date.now() + const lastExecution = this.lastExecutionTimes.get(rule.fileName) || 0 + const timeElapsed = now - lastExecution + const timeRemaining = Math.max(0, rule.interval - timeElapsed) + const shouldExecute = timeElapsed >= rule.interval + + // Format time remaining in a human-readable format + const formatTimeRemaining = (): string => { + if (timeRemaining === 0) return "ready now" + + const seconds = Math.floor(timeRemaining / 1000) % 60 + const minutes = Math.floor(timeRemaining / (1000 * 60)) % 60 + const hours = Math.floor(timeRemaining / (1000 * 60 * 60)) + + const parts = [] + if (hours > 0) parts.push(`${hours}h`) + if (minutes > 0) parts.push(`${minutes}m`) + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`) + + return parts.join(" ") + } + + // Always log at "info" level for better visibility in the output panel + if (shouldExecute) { + this.log( + "info", + `Rule ${rule.fileName} is ready to execute (last executed: ${lastExecution > 0 ? new Date(lastExecution).toISOString() : "never"})`, + ) + } else { + const nextRunTimeFormatted = new Date(lastExecution + rule.interval).toLocaleTimeString() + this.log( + "info", // Changed from debug to info for visibility + `Rule ${rule.fileName} will execute in ${formatTimeRemaining()} at ${nextRunTimeFormatted} (last executed: ${new Date(lastExecution).toISOString()})`, + ) + } + + return shouldExecute + } + /* + * Non-blocking method that saves the execution time to storage + * without requiring the caller to await + * + * @param rule - The rule to mark as executed + */ + public markRuleAsExecuted(rule: SchedulableRule): void { + const now = Date.now() + this.lastExecutionTimes.set(rule.fileName, now) + this.log("info", `Rule ${rule.fileName} marked as executed at ${new Date(now).toISOString()}`) + + // Save to persistent storage without blocking + // This ensures that even if the caller doesn't await the promise, + // the execution times will still be saved to global state + Promise.resolve(this.saveExecutionTimes()).catch((err: unknown) => { + this.log( + "error", + `Failed to save execution times for ${rule.fileName}: ${err instanceof Error ? err.message : String(err)}`, + ) + }) + } + + /** + * Get rules that should be executed + * @param cwd - The current working directory + * @returns Promise resolving to an array of rules that should be executed + */ + public async getExecutableRules(cwd: string): Promise { + const rules = await this.loadSchedulableRules(cwd) + const executableRules = rules.filter((rule) => this.shouldExecuteRule(rule)) + this.log("debug", `Found ${executableRules.length} executable rules out of ${rules.length} total rules`) + return executableRules + } + + /** + * Get all rules with their status (for UI display) + * @param cwd - The current working directory + * @returns Promise resolving to an array of rules with next execution time + */ + public async getAllRules( + cwd: string, + ): Promise> { + const rules = await this.loadSchedulableRules(cwd) + const now = Date.now() + + return rules.map((rule) => { + const lastExecution = this.lastExecutionTimes.get(rule.fileName) || 0 + const nextExecutionTimestamp = lastExecution + rule.interval + const timeRemaining = Math.max(0, nextExecutionTimestamp - now) + + // Format time remaining in a human-readable format + const timeUntilNextRun = this.formatTimeRemaining(timeRemaining) + + // Format next run time as a nice clock time + const nextRunTime = new Date(nextExecutionTimestamp).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + + return { + ...rule, + lastExecuted: lastExecution, + nextExecution: timeRemaining, + nextExecutionTimestamp: nextExecutionTimestamp, // Add absolute timestamp to enable UI countdown + timeUntilNextRun: timeUntilNextRun, // Human-readable time remaining + nextRunTime: nextRunTime, // Clock time of next execution + } + }) + } + + /** + * Format milliseconds into a human-readable time format + * @param milliseconds - Time in milliseconds + * @returns Human-readable time string + */ + private formatTimeRemaining(milliseconds: number): string { + if (milliseconds === 0) return "ready now" + + const seconds = Math.floor(milliseconds / 1000) % 60 + const minutes = Math.floor(milliseconds / (1000 * 60)) % 60 + const hours = Math.floor(milliseconds / (1000 * 60 * 60)) + + const parts = [] + if (hours > 0) parts.push(`${hours}h`) + if (minutes > 0) parts.push(`${minutes}m`) + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`) + + return parts.join(" ") + } +} diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 294bb04c6f1..656489515e0 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -24,6 +24,7 @@ import { getModesSection, addCustomInstructions, } from "./sections" +import { SchedulableRulesManager } from "./sections/schedulable-rules" import { loadSystemPromptFile } from "./sections/custom-system-prompt" async function generatePrompt( @@ -42,6 +43,7 @@ async function generatePrompt( experiments?: Record, enableMcpServerCreation?: boolean, rooIgnoreInstructions?: string, + schedulableRulesManager?: SchedulableRulesManager, ): Promise { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -90,7 +92,7 @@ ${getSystemInfoSection(cwd, mode, customModeConfigs)} ${getObjectiveSection()} -${await addCustomInstructions(promptComponent?.customInstructions || modeConfig.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage, rooIgnoreInstructions })}` +${await addCustomInstructions(promptComponent?.customInstructions || modeConfig.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage, rooIgnoreInstructions }, schedulableRulesManager)}` return basePrompt } @@ -111,6 +113,7 @@ export const SYSTEM_PROMPT = async ( experiments?: Record, enableMcpServerCreation?: boolean, rooIgnoreInstructions?: string, + schedulableRulesManager?: SchedulableRulesManager, ): Promise => { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -139,7 +142,7 @@ export const SYSTEM_PROMPT = async ( ${fileCustomSystemPrompt} -${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage, rooIgnoreInstructions })}` +${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage, rooIgnoreInstructions }, schedulableRulesManager)}` } // If diff is disabled, don't pass the diffStrategy @@ -161,5 +164,6 @@ ${await addCustomInstructions(promptComponent?.customInstructions || currentMode experiments, enableMcpServerCreation, rooIgnoreInstructions, + schedulableRulesManager, ) } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f98f19e4ff6..9214ac7c4f0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -22,6 +22,7 @@ import { Mode, PromptComponent, defaultModeSlug, ModeConfig } from "../../shared import { checkExistKey } from "../../shared/checkExistApiConfig" import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments" import { TERMINAL_OUTPUT_LIMIT } from "../../shared/terminal" +import { TelemetrySetting } from "../../shared/TelemetrySetting" import { downloadTask } from "../../integrations/misc/export-markdown" import { openFile, openImage } from "../../integrations/misc/open-file" import { selectImages } from "../../integrations/misc/process-images" @@ -56,7 +57,7 @@ import { openMention } from "../mentions" import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { telemetryService } from "../../services/telemetry/TelemetryService" -import { TelemetrySetting } from "../../shared/TelemetrySetting" +import { SchedulableRulesManager } from "../prompts/sections/schedulable-rules" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -73,6 +74,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { private clineStack: Cline[] = [] private workspaceTracker?: WorkspaceTracker protected mcpHub?: McpHub // Change from private to protected + private schedulableRulesManager: SchedulableRulesManager private latestAnnouncementId = "mar-7-2025-3-8" // update to some unique identifier when we add a new announcement private contextProxy: ContextProxy configManager: ConfigManager @@ -90,6 +92,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { // Register this provider with the telemetry service to enable it to add properties like mode and provider telemetryService.setProvider(this) + // Initialize SchedulableRulesManager + this.schedulableRulesManager = new SchedulableRulesManager() + this.schedulableRulesManager.setContext(this.context) + this.schedulableRulesManager.setOutputChannel(this.outputChannel) + this.outputChannel.appendLine("SchedulableRulesManager initialized with OutputChannel") + if (typeof this.outputChannel.show === "function") { + this.outputChannel.show() // Show the output channel to make logs visible + } + this.workspaceTracker = new WorkspaceTracker(this) this.configManager = new ConfigManager(this.context) this.customModesManager = new CustomModesManager(this.context, async () => { @@ -1963,6 +1974,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { experiments, enableMcpServerCreation, rooIgnoreInstructions, + this.schedulableRulesManager, ) return systemPrompt } @@ -2335,6 +2347,37 @@ export class ClineProvider implements vscode.WebviewViewProvider { telemetrySetting, showRooIgnoredFiles, } = await this.getState() + + // Get schedulable rules data + const workspaceFolder = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || "" + this.outputChannel.appendLine( + `[${new Date().toLocaleTimeString()}] Fetching schedulable rules from ${workspaceFolder}`, + ) + + // Use existing API to get rules + const rawRules = await this.schedulableRulesManager.loadSchedulableRules(workspaceFolder) + + // Process rules to add the required properties + const now = Date.now() + const schedulableRules = rawRules.map((rule) => { + const nextExecutionTime = rule.lastExecuted + rule.interval + const timeRemaining = Math.max(0, nextExecutionTime - now) + + const nextTime = new Date(nextExecutionTime).toLocaleTimeString() + this.outputChannel.appendLine( + `Rule: ${rule.fileName}, next execution at ${nextTime}, time remaining: ${timeRemaining}ms`, + ) + + return { + ...rule, + nextExecution: timeRemaining, + nextExecutionTimestamp: nextExecutionTime, + } + }) + + this.outputChannel.appendLine( + `Found ${schedulableRules.length} schedulable rules, fetched at ${new Date().toLocaleTimeString()}`, + ) const telemetryKey = process.env.POSTHOG_API_KEY const machineId = vscode.env.machineId @@ -2353,6 +2396,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { alwaysAllowMcp: alwaysAllowMcp ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: alwaysAllowSubtasks ?? false, + schedulableRules, uriScheme: vscode.env.uriScheme, currentTaskItem: this.getCurrentCline()?.taskId ? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentCline()?.taskId) @@ -2661,6 +2705,13 @@ export class ClineProvider implements vscode.WebviewViewProvider { return this.mcpHub } + /** + * Get the SchedulableRulesManager instance + */ + public getSchedulableRulesManager(): SchedulableRulesManager { + return this.schedulableRulesManager + } + /** * Returns properties to be included in every telemetry event * This method is called by the telemetry service to get context information diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 9298b0cb1b9..548356a269d 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -54,6 +54,7 @@ export interface ExtensionMessage { | "browserToolEnabled" | "browserConnectionResult" | "remoteBrowserEnabled" + | "schedulableRules" text?: string action?: | "chatButtonClicked" @@ -83,6 +84,12 @@ export interface ExtensionMessage { mcpServers?: McpServer[] commits?: GitCommit[] listApiConfig?: ApiConfigMeta[] + schedulableRules?: Array<{ + fileName: string + displayInterval: string + nextExecution: number + lastExecuted: number + }> mode?: Mode customMode?: ModeConfig slug?: string @@ -150,6 +157,12 @@ export interface ExtensionState { telemetryKey?: string machineId?: string showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings + schedulableRules?: Array<{ + fileName: string + displayInterval: string + nextExecution: number + lastExecuted: number + }> } export type { ClineMessage, ClineAsk, ClineSay } diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx index 64e33ac70a1..22d3e6e0f7d 100644 --- a/webview-ui/src/components/prompts/PromptsView.tsx +++ b/webview-ui/src/components/prompts/PromptsView.tsx @@ -23,6 +23,7 @@ import { } from "@/components/ui" import { cn } from "@/lib/utils" import { useExtensionState } from "../../context/ExtensionStateContext" +import SchedulableRulesSection from "./SchedulableRulesSection" import { Mode, PromptComponent, @@ -54,8 +55,8 @@ type PromptsViewProps = { } // Helper to get group name regardless of format -function getGroupName(group: GroupEntry): ToolGroup { - return Array.isArray(group) ? group[0] : group +function getGroupName(group: GroupEntry): string { + return Array.isArray(group) ? group[0] : (group as string) } const PromptsView = ({ onDone }: PromptsViewProps) => { @@ -74,6 +75,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { customModes, enableCustomModeCreation, setEnableCustomModeCreation, + schedulableRules, } = useExtensionState() // Memoize modes to preserve array order @@ -949,6 +951,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { + {/* Schedulable Rules Section */} + {schedulableRules && schedulableRules.length > 0 && ( + + )} +
= ({ rules }) => { + const [isExpanded, setIsExpanded] = useState(true) + + const formatTimeRemaining = (ms: number): string => { + if (ms <= 0) return "Now" + + const seconds = Math.floor(ms / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) return `${days}d ${hours % 24}h` + if (hours > 0) return `${hours}h ${minutes % 60}m` + if (minutes > 0) return `${minutes}m ${seconds % 60}s` + return `${seconds}s` + } + + if (rules.length === 0) return null + + return ( +
+
+
setIsExpanded(!isExpanded)} className="cursor-pointer flex items-center"> + +

Schedulable Rules

+
+
+ + {isExpanded && ( + <> +
+ Rules that are automatically applied at specified time intervals. Click a rule to edit it. +
+ +
+ {rules.map((rule) => ( +
{ + vscode.postMessage({ + type: "openFile", + text: `./${rule.fileName}`, + values: { + create: false, + }, + }) + }}> +
+
{rule.fileName}
+
+ Every {rule.displayInterval} +
+
+
+ + Next: {formatTimeRemaining(rule.nextExecution)} + +
+
+ ))} +
+ + )} +
+ ) +} + +export default SchedulableRulesSection diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d212900c033..16b1a8fb9ef 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -78,6 +78,12 @@ export interface ExtensionStateContextType extends ExtensionState { remoteBrowserEnabled?: boolean setRemoteBrowserEnabled: (value: boolean) => void machineId?: string + schedulableRules?: Array<{ + fileName: string + displayInterval: string + nextExecution: number + lastExecuted: number + }> } export const ExtensionStateContext = createContext(undefined) @@ -208,6 +214,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setCurrentCheckpoint(message.text) break } + case "schedulableRules": { + setState((prevState) => ({ ...prevState, schedulableRules: message.schedulableRules })) + break + } case "listApiConfig": { setListApiConfigMeta(message.listApiConfig ?? []) break