diff --git a/packages/types/src/__tests__/learning-memory.test.ts b/packages/types/src/__tests__/learning-memory.test.ts new file mode 100644 index 0000000000..55ffa8fde9 --- /dev/null +++ b/packages/types/src/__tests__/learning-memory.test.ts @@ -0,0 +1,71 @@ +// npx vitest run src/__tests__/learning-memory.test.ts + +import { + DEFAULT_LEARNING_CONFIG, + EMPTY_LEARNING_STATE, + learningConfigSchema, + learningStateSchema, + memoryContextSchema, + type LearningState, + type MemoryContext, +} from "../index.js" + +describe("learning types", () => { + it("exports the default learning config", () => { + expect(DEFAULT_LEARNING_CONFIG).toMatchObject({ + enabled: false, + reviewOnTurnCount: 10, + reviewOnToolIterationCount: 50, + }) + }) + + it("parses the empty learning state", () => { + const result = learningStateSchema.safeParse(EMPTY_LEARNING_STATE) + + expect(result.success).toBe(true) + expect(result.data).toEqual(EMPTY_LEARNING_STATE) + }) + + it("applies learning config defaults", () => { + const result = learningConfigSchema.parse({}) + + expect(result).toEqual(DEFAULT_LEARNING_CONFIG) + }) + + it("preserves TypeScript inference for learning state", () => { + const state: LearningState = EMPTY_LEARNING_STATE + + expect(state.version).toBe(1) + }) +}) + +describe("memory types", () => { + it("parses a valid memory context", () => { + const input: MemoryContext = { + entries: [], + revision: 0, + generatedAt: Date.now(), + } + + const result = memoryContextSchema.safeParse(input) + + expect(result.success).toBe(true) + expect(result.data).toEqual(input) + }) + + it("rejects more than ten memory entries", () => { + const result = memoryContextSchema.safeParse({ + entries: Array.from({ length: 11 }, (_, index) => ({ + id: `entry-${index}`, + content: "memory", + source: "learning", + createdAt: index, + updatedAt: index, + })), + revision: 0, + generatedAt: Date.now(), + }) + + expect(result.success).toBe(false) + }) +}) diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index d7eb0b03d6..515973ce58 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -6,7 +6,13 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js" * ExperimentId */ -export const experimentIds = ["preventFocusDisruption", "imageGeneration", "runSlashCommand", "customTools"] as const +export const experimentIds = [ + "preventFocusDisruption", + "imageGeneration", + "runSlashCommand", + "customTools", + "selfImproving", +] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -21,6 +27,7 @@ export const experimentsSchema = z.object({ imageGeneration: z.boolean().optional(), runSlashCommand: z.boolean().optional(), customTools: z.boolean().optional(), + selfImproving: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4f03a7c879..401076b918 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -14,8 +14,11 @@ export * from "./global-settings.js" export * from "./history.js" export * from "./image-generation.js" export * from "./ipc.js" +export * from "./learning.js" +export * from "./marketplace.js" export * from "./mcp.js" export * from "./message.js" +export * from "./memory.js" export * from "./mode.js" export * from "./model.js" export * from "./provider-settings.js" diff --git a/packages/types/src/learning.ts b/packages/types/src/learning.ts new file mode 100644 index 0000000000..d68cd4f551 --- /dev/null +++ b/packages/types/src/learning.ts @@ -0,0 +1,193 @@ +import { z } from "zod" + +/** + * FeedbackSignal - types of learning observations + */ +export const feedbackSignalSchema = z.enum([ + "USER_CORRECTION", + "TASK_SUCCESS", + "TASK_FAILURE", + "PATTERN_REPEAT", + "CODE_INDEX_HIT", + "PROMPT_QUALITY", +]) + +export type FeedbackSignal = z.infer + +/** + * LearningConfig - configuration for the learning system + */ +export const learningConfigSchema = z.object({ + enabled: z.boolean().default(false), + reviewOnTurnCount: z.number().int().min(1).default(10), + reviewOnToolIterationCount: z.number().int().min(1).default(50), + maxStoredPatterns: z.number().int().min(1).default(100), + maxStoredEvents: z.number().int().min(1).default(500), + maxPromptPatterns: z.number().int().min(1).default(5), + curatorEnabled: z.boolean().default(true), + curatorIntervalMs: z.number().int().min(60000).default(3600000), + staleAfterDays: z.number().int().min(1).default(14), + archiveAfterDays: z.number().int().min(1).default(60), + codeIndexCorrelationEnabled: z.boolean().default(true), +}) + +export type LearningConfig = z.infer + +export const DEFAULT_LEARNING_CONFIG: LearningConfig = { + enabled: false, + reviewOnTurnCount: 10, + reviewOnToolIterationCount: 50, + maxStoredPatterns: 100, + maxStoredEvents: 500, + maxPromptPatterns: 5, + curatorEnabled: true, + curatorIntervalMs: 3600000, + staleAfterDays: 14, + archiveAfterDays: 60, + codeIndexCorrelationEnabled: true, +} + +/** + * LearningEvent - a single learning observation + */ +export const learningEventSchema = z.object({ + id: z.string(), + signal: feedbackSignalSchema, + timestamp: z.number(), + taskId: z.string().optional(), + workspacePath: z.string().optional(), + mode: z.string().optional(), + context: z.object({ + userTurnCount: z.number().optional(), + toolIterationCount: z.number().optional(), + toolNames: z.array(z.string()).optional(), + promptFingerprint: z.string().optional(), + errorKey: z.string().optional(), + codeIndex: z + .object({ + available: z.boolean(), + hits: z.number(), + topScore: z.number().optional(), + }) + .optional(), + }), + outcome: z.object({ + success: z.boolean().optional(), + corrected: z.boolean().optional(), + summary: z.string().optional(), + confidenceDelta: z.number().optional(), + }), +}) + +export type LearningEvent = z.infer + +/** + * PatternState - lifecycle state for learned patterns + */ +export const patternStateSchema = z.enum(["active", "stale", "archived"]) + +export type PatternState = z.infer + +/** + * PatternType - category of learned pattern + */ +export const patternTypeSchema = z.enum(["prompt", "tool", "error", "skill", "code-index"]) + +export type PatternType = z.infer + +/** + * LearnedPattern - a pattern extracted from learning events + */ +export const learnedPatternSchema = z.object({ + id: z.string(), + patternType: patternTypeSchema, + state: patternStateSchema, + summary: z.string(), + confidenceScore: z.number().min(0).max(1), + frequency: z.number().int().min(0), + successRate: z.number().min(0).max(1), + firstSeenAt: z.number(), + lastSeenAt: z.number(), + lastAppliedAt: z.number().optional(), + sourceSignals: z.array(feedbackSignalSchema), + context: z.object({ + toolNames: z.array(z.string()).optional(), + errorKeys: z.array(z.string()).optional(), + modes: z.array(z.string()).optional(), + workspacePaths: z.array(z.string()).optional(), + }), +}) + +export type LearnedPattern = z.infer + +/** + * ActionType - types of improvement actions + */ +export const actionTypeSchema = z.enum(["PROMPT_ENRICHMENT", "TOOL_PREFERENCE", "ERROR_AVOIDANCE", "SKILL_SUGGESTION"]) + +export type ActionType = z.infer + +/** + * ImprovementAction - an action to apply based on learned patterns + */ +export const improvementActionSchema = z.object({ + id: z.string(), + actionType: actionTypeSchema, + target: z.enum(["system-prompt", "task-execution", "skills-manager", "review-queue"]), + payload: z.record(z.string(), z.unknown()), + timestamp: z.number(), +}) + +export type ImprovementAction = z.infer + +/** + * LearningTelemetry - telemetry counters for the learning system + */ +export const learningTelemetrySchema = z.object({ + promptEnrichmentUses: z.number().int().default(0), + toolPreferenceUses: z.number().int().default(0), + errorAvoidanceUses: z.number().int().default(0), + skillSuggestionCount: z.number().int().default(0), + lastReviewAt: z.number().optional(), + lastCuratorRunAt: z.number().optional(), +}) + +export type LearningTelemetry = z.infer + +/** + * LearningState - full serializable state of the learning system + */ +export const learningStateSchema = z.object({ + version: z.literal(1), + config: learningConfigSchema, + counters: z.object({ + userTurnsSinceReview: z.number().int().default(0), + toolIterationsSinceReview: z.number().int().default(0), + }), + patterns: z.array(learnedPatternSchema).default([]), + archivedPatterns: z.array(learnedPatternSchema).default([]), + recentEvents: z.array(learningEventSchema).default([]), + pendingActions: z.array(improvementActionSchema).default([]), + telemetry: learningTelemetrySchema, +}) + +export type LearningState = z.infer + +export const EMPTY_LEARNING_STATE: LearningState = { + version: 1, + config: DEFAULT_LEARNING_CONFIG, + counters: { + userTurnsSinceReview: 0, + toolIterationsSinceReview: 0, + }, + patterns: [], + archivedPatterns: [], + recentEvents: [], + pendingActions: [], + telemetry: { + promptEnrichmentUses: 0, + toolPreferenceUses: 0, + errorAvoidanceUses: 0, + skillSuggestionCount: 0, + }, +} diff --git a/packages/types/src/memory.ts b/packages/types/src/memory.ts new file mode 100644 index 0000000000..9cd191b670 --- /dev/null +++ b/packages/types/src/memory.ts @@ -0,0 +1,29 @@ +import { z } from "zod" + +/** + * MemoryEntry - a single durable memory entry for prompt-facing context + * Adapted from Hermes' bounded memory store concept. + */ +export const memoryEntrySchema = z.object({ + id: z.string(), + content: z.string().max(2000), + source: z.enum(["learning", "user", "system", "review"]), + createdAt: z.number(), + updatedAt: z.number(), + relevanceScore: z.number().min(0).max(1).optional(), + tags: z.array(z.string()).optional(), + expiresAt: z.number().optional(), +}) + +export type MemoryEntry = z.infer + +/** + * MemoryContext - bounded set of memory entries for prompt injection + */ +export const memoryContextSchema = z.object({ + entries: z.array(memoryEntrySchema).max(10), + revision: z.number().int().default(0), + generatedAt: z.number(), +}) + +export type MemoryContext = z.infer diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c09f22aed7..5d2e9b3fb2 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -361,6 +361,15 @@ export type ExtensionState = Pick< hasOpenedModeSelector: boolean openRouterImageApiKey?: string messageQueue?: QueuedMessage[] + selfImprovingStatus?: { + enabled: boolean + started: boolean + patternCount: number + eventCount: number + actionCount: number + lastReviewAt?: number + lastCuratorRunAt?: number + } lastShownAnnouncementId?: string apiModelId?: string mcpServers?: McpServer[] diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index e01c739edc..afd869fd11 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -184,6 +184,7 @@ vi.mock("../core/webview/ClineProvider", async () => { postStateToWebview: vi.fn(), postStateToWebviewWithoutClineMessages: vi.fn(), getState: vi.fn().mockResolvedValue({}), + initializeSelfImproving: vi.fn().mockResolvedValue(undefined), initializeCloudProfileSyncWhenReady: vi.fn().mockResolvedValue(undefined), providerSettingsManager: {}, contextProxy: { getGlobalState: vi.fn() }, diff --git a/src/core/prompts/__tests__/system-prompt.spec.ts b/src/core/prompts/__tests__/system-prompt.spec.ts index f555daba06..1b649986ba 100644 --- a/src/core/prompts/__tests__/system-prompt.spec.ts +++ b/src/core/prompts/__tests__/system-prompt.spec.ts @@ -49,6 +49,7 @@ import { ModeConfig } from "@roo-code/types" import { SYSTEM_PROMPT } from "../system" import { McpHub } from "../../../services/mcp/McpHub" +import type { SelfImprovingManager } from "../../../services/self-improving" import { defaultModeSlug, modes, Mode } from "../../../shared/modes" import "../../../utils/path" import { addCustomInstructions } from "../sections/custom-instructions" @@ -272,6 +273,36 @@ describe("SYSTEM_PROMPT", () => { expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-undefined-mcp-hub.snap") }) + it("should include learned guidance before rules when available", async () => { + const selfImprovingManager = { + getPromptContextString: () => "\n## Learned Guidance\n- [prompt] Search relevant code before editing\n", + } as unknown as SelfImprovingManager + + const prompt = await SYSTEM_PROMPT( + mockContext, + "/test/path", + false, + undefined, // mcpHub + undefined, // diffStrategy + defaultModeSlug, // mode + undefined, // customModePrompts + undefined, // customModes + undefined, // globalCustomInstructions + experiments, + undefined, // language + undefined, // rooIgnoreInstructions + undefined, // settings + undefined, // todoList + undefined, // modelId + undefined, // skillsManager + selfImprovingManager, + ) + + expect(prompt).toContain("## Learned Guidance") + expect(prompt).toContain("- [prompt] Search relevant code before editing") + expect(prompt.indexOf("## Learned Guidance")).toBeLessThan(prompt.indexOf("====\n\nRULES")) + }) + it("should include vscode language in custom instructions", async () => { // Mock vscode.env.language const vscode = vi.mocked(await import("vscode")) as any diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 0d6071644a..51ace9a039 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -10,6 +10,7 @@ import { isEmpty } from "../../utils/object" import { McpHub } from "../../services/mcp/McpHub" import { CodeIndexManager } from "../../services/code-index/manager" import { SkillsManager } from "../../services/skills/SkillsManager" +import { SelfImprovingManager } from "../../services/self-improving" import type { SystemPromptSettings } from "./types" import { @@ -55,6 +56,7 @@ async function generatePrompt( todoList?: TodoItem[], modelId?: string, skillsManager?: SkillsManager, + selfImprovingManager?: SelfImprovingManager, ): Promise { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -79,6 +81,9 @@ async function generatePrompt( getSkillsSection(skillsManager, mode as string), ]) + // Inject learned guidance from self-improving system (experiment-gated) + const learningContext = selfImprovingManager?.getPromptContextString() || "" + // Tools catalog is not included in the system prompt. const toolsCatalog = "" @@ -93,7 +98,7 @@ ${getSharedToolUseSection()}${toolsCatalog} ${getCapabilitiesSection(cwd, shouldIncludeMcp ? mcpHub : undefined)} ${modesSection} -${skillsSection ? `\n${skillsSection}` : ""} +${skillsSection ? `\n${skillsSection}` : ""}${learningContext} ${getRulesSection(cwd, settings)} ${getSystemInfoSection(cwd)} @@ -126,6 +131,7 @@ export const SYSTEM_PROMPT = async ( todoList?: TodoItem[], modelId?: string, skillsManager?: SkillsManager, + selfImprovingManager?: SelfImprovingManager, ): Promise => { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -154,5 +160,6 @@ export const SYSTEM_PROMPT = async ( todoList, modelId, skillsManager, + selfImprovingManager, ) } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fcdfd0263d..d62ce7b2c0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3699,6 +3699,7 @@ export class Task extends EventEmitter implements TaskLike { undefined, // todoList this.api.getModel().id, provider.getSkillsManager(), + provider.getSelfImprovingManager(), ) })() } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 37e99054b1..1209f4d32a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -75,6 +75,7 @@ import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckp import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" +import { SelfImprovingManager } from "../../services/self-improving" import { SkillsManager } from "../../services/skills/SkillsManager" import { fileExistsAtPath } from "../../utils/fs" @@ -163,6 +164,7 @@ export class ClineProvider public readonly latestAnnouncementId = "apr-2026-v3.53.0-community-handoff-gpt55-opus47" // v3.53.0 Community handoff, GPT-5.5, Claude Opus 4.7, checkpoint navigation public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager + public readonly selfImprovingManager: SelfImprovingManager constructor( readonly context: vscode.ExtensionContext, @@ -226,6 +228,26 @@ export class ClineProvider this.log(`Failed to initialize Skills Manager: ${error}`) }) + // Initialize Self-Improving Manager (experiment-gated, zero overhead when disabled) + this.selfImprovingManager = new SelfImprovingManager({ + globalStoragePath: this.contextProxy.globalStorageUri.fsPath, + logger: { + appendLine: (message: string) => this.log(message), + }, + getExperiments: () => this.contextProxy.getGlobalState("experiments"), + getCodeIndexInfo: () => { + const manager = this.codeIndexManager + if (!manager) { + return { available: false, hits: 0 } + } + + return { + available: true, + hits: 0, // Simplified - actual hit count would come from code index events + } + }, + }) + this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) // Forward task events to the provider. @@ -233,10 +255,44 @@ export class ClineProvider this.taskCreationCallback = (instance: Task) => { this.emit(RooCodeEventName.TaskCreated, instance) + const recordTaskCompletionForLearning = (success: boolean) => { + void instance + .getTaskMode() + .catch(() => defaultModeSlug) + .then((mode) => + this.selfImprovingManager.recordTaskCompletion({ + taskId: instance.taskId, + mode, + workspacePath: this.currentWorkspacePath, + success, + ...(success + ? { + toolNames: instance.toolUsage ? Object.keys(instance.toolUsage) : undefined, + } + : {}), + }), + ) + .catch((error) => { + this.log( + `[SelfImproving] recordTaskCompletion error: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + } + // Create named listener functions so we can remove them later. const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId) const onTaskCompleted = (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => { this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage) + + // Feed task completion into self-improving system + recordTaskCompletionForLearning(true) + } + const onTaskUserMessageForLearning = (_taskId: string) => { + this.selfImprovingManager.recordUserTurn().catch((error) => { + this.log( + `[SelfImproving] recordUserTurn error: ${error instanceof Error ? error.message : String(error)}`, + ) + }) } const onTaskAborted = async () => { this.emit(RooCodeEventName.TaskAborted, instance.taskId) @@ -266,6 +322,9 @@ export class ClineProvider }`, ) } + + // Feed task abortion into self-improving system + recordTaskCompletionForLearning(false) } const onTaskFocused = () => this.emit(RooCodeEventName.TaskFocused, instance.taskId) const onTaskUnfocused = () => this.emit(RooCodeEventName.TaskUnfocused, instance.taskId) @@ -283,6 +342,7 @@ export class ClineProvider // Attach the listeners. instance.on(RooCodeEventName.TaskStarted, onTaskStarted) instance.on(RooCodeEventName.TaskCompleted, onTaskCompleted) + instance.on(RooCodeEventName.TaskUserMessage, onTaskUserMessageForLearning) instance.on(RooCodeEventName.TaskAborted, onTaskAborted) instance.on(RooCodeEventName.TaskFocused, onTaskFocused) instance.on(RooCodeEventName.TaskUnfocused, onTaskUnfocused) @@ -300,6 +360,7 @@ export class ClineProvider this.taskEventListeners.set(instance, [ () => instance.off(RooCodeEventName.TaskStarted, onTaskStarted), () => instance.off(RooCodeEventName.TaskCompleted, onTaskCompleted), + () => instance.off(RooCodeEventName.TaskUserMessage, onTaskUserMessageForLearning), () => instance.off(RooCodeEventName.TaskAborted, onTaskAborted), () => instance.off(RooCodeEventName.TaskFocused, onTaskFocused), () => instance.off(RooCodeEventName.TaskUnfocused, onTaskUnfocused), @@ -394,6 +455,15 @@ export class ClineProvider this.log("Cloud profile synchronization is disabled in compatibility mode") } + /** + * Initialize the self-improving manager. + * Called from extension activation after provider construction. + * No-op when experiment is disabled (zero overhead guarantee). + */ + async initializeSelfImproving(): Promise { + await this.selfImprovingManager.initialize() + } + // Adds a new Task instance to clineStack, marking the start of a new task. // The instance is pushed to the top of the stack (LIFO order). // When the task is completed, the top instance is removed, reactivating the @@ -603,6 +673,7 @@ export class ClineProvider this.mcpHub = undefined await this.skillsManager?.dispose() this.skillsManager = undefined + await this.selfImprovingManager.dispose() this.marketplaceManager?.cleanup() this.customModesManager?.dispose() this.taskHistoryStore.dispose() @@ -2264,6 +2335,7 @@ export class ClineProvider followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000, includeDiagnosticMessages: includeDiagnosticMessages ?? true, maxDiagnosticMessages: maxDiagnosticMessages ?? 50, + selfImprovingStatus: this.selfImprovingManager.getStatus(), includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, @@ -2646,6 +2718,10 @@ export class ClineProvider return this.skillsManager } + public getSelfImprovingManager(): SelfImprovingManager { + return this.selfImprovingManager + } + /** * Check if the current state is compliant with MDM policy * @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 8af2f5ff5d..433563e672 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -64,6 +64,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web undefined, // todoList undefined, // modelId provider.getSkillsManager(), + provider.getSelfImprovingManager(), ) return systemPrompt diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 429de051b8..5ba7a4c988 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -662,6 +662,8 @@ export const webviewMessageHandler = async ( case "updateSettings": if (message.updatedSettings) { + let experimentsUpdated = false + for (const [key, value] of Object.entries(message.updatedSettings)) { let newValue = value @@ -740,6 +742,7 @@ export const webviewMessageHandler = async ( continue } + experimentsUpdated = true newValue = { ...(getGlobalState("experiments") ?? experimentDefault), ...(value as Record), @@ -753,6 +756,12 @@ export const webviewMessageHandler = async ( await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) } + if (experimentsUpdated) { + await provider.selfImprovingManager.onSettingsChanged( + provider.contextProxy.getGlobalState("experiments"), + ) + } + await provider.postStateToWebview() } diff --git a/src/extension.ts b/src/extension.ts index 44c1243528..246f31a376 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -198,6 +198,13 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize the provider *before* the Roo Code Cloud service. const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService) + // Initialize self-improving manager (experiment-gated, zero overhead when disabled) + provider.initializeSelfImproving().catch((error) => { + outputChannel.appendLine( + `[SelfImproving] Initialization error: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + // Initialize Roo Code Cloud service. const postStateListener = () => ClineProvider.getVisibleInstance()?.postStateToWebviewWithoutClineMessages() diff --git a/src/services/self-improving/ActionExecutor.ts b/src/services/self-improving/ActionExecutor.ts new file mode 100644 index 0000000000..b7a7253c8d --- /dev/null +++ b/src/services/self-improving/ActionExecutor.ts @@ -0,0 +1,195 @@ +import crypto from "crypto" + +import type { MemoryStore } from "./MemoryStore" +import type { SkillProvenance, SkillUsageStore } from "./SkillUsageStore" +import type { ImprovementAction, Logger } from "./types" + +/** + * ActionExecutor - consumes the pending action queue and executes + * improvement actions transactionally. + * + * Each action type maps to a specific executor: + * - PROMPT_ENRICHMENT: writes to MemoryStore (environment) + * - ERROR_AVOIDANCE: writes to MemoryStore (environment, with error tags) + * - TOOL_PREFERENCE: writes to MemoryStore (environment, with tool tags) + * - SKILL_SUGGESTION: records in SkillUsageStore for future user approval + * + * Actions are removed from the queue only after successful execution. + * Failed actions remain pending for later retry. + */ +export class ActionExecutor { + private readonly memoryStore: MemoryStore + private readonly skillUsageStore: SkillUsageStore + private readonly logger: Logger + + constructor(memoryStore: MemoryStore, skillUsageStore: SkillUsageStore, logger: Logger) { + this.memoryStore = memoryStore + this.skillUsageStore = skillUsageStore + this.logger = logger + } + + /** + * Execute a single improvement action. + * Returns true if the action was executed successfully. + */ + async execute(action: ImprovementAction): Promise { + try { + let executed = false + + switch (action.actionType) { + case "PROMPT_ENRICHMENT": + executed = await this.executePromptEnrichment(action) + break + case "ERROR_AVOIDANCE": + executed = await this.executeErrorAvoidance(action) + break + case "TOOL_PREFERENCE": + executed = await this.executeToolPreference(action) + break + case "SKILL_SUGGESTION": + executed = await this.executeSkillSuggestion(action) + break + default: + this.logger.appendLine(`[ActionExecutor] Unknown action type: ${action.actionType}`) + return false + } + + this.logger.appendLine( + `[ActionExecutor] ${executed ? "Executed" : "Deferred"} ${action.actionType} action ${action.id}`, + ) + + return executed + } catch (error) { + this.logger.appendLine( + `[ActionExecutor] Execution error for ${action.id}: ${error instanceof Error ? error.message : String(error)}`, + ) + return false + } + } + + /** + * Execute a batch of actions. + * Returns the set of successfully executed action IDs. + */ + async executeBatch(actions: ImprovementAction[]): Promise> { + const succeeded = new Set() + + for (const action of actions) { + const ok = await this.execute(action) + if (ok) { + succeeded.add(action.id) + } + } + + return succeeded + } + + /** + * Execute a PROMPT_ENRICHMENT action. + * Writes the learned guidance to the environment memory store. + */ + private async executePromptEnrichment(action: ImprovementAction): Promise { + const summary = this.readStringPayload(action.payload.summary) + if (!summary) { + return false + } + + const entry = await this.memoryStore.addEnvironmentEntry(summary, { + source: "learning", + tags: ["learned", "prompt"], + }) + if (entry === null) { + // null means duplicate or empty content — still counts as "handled" + } + + return true + } + + /** + * Execute an ERROR_AVOIDANCE action. + * Writes the error avoidance guidance to the environment memory store. + */ + private async executeErrorAvoidance(action: ImprovementAction): Promise { + const summary = this.readStringPayload(action.payload.summary) + const errorKeys = this.readStringArrayPayload(action.payload.errorKeys) + + if (!summary) { + return false + } + + const entry = await this.memoryStore.addEnvironmentEntry(summary, { + source: "learning", + tags: ["error-avoidance", ...errorKeys.map((key) => `error:${key}`)], + }) + if (entry === null) { + // null means duplicate — still handled + } + + return true + } + + /** + * Execute a TOOL_PREFERENCE action. + * Writes the tool preference guidance to the environment memory store. + */ + private async executeToolPreference(action: ImprovementAction): Promise { + const summary = this.readStringPayload(action.payload.summary) + const toolNames = this.readStringArrayPayload(action.payload.toolNames) + + if (!summary) { + return false + } + + const entry = await this.memoryStore.addEnvironmentEntry(summary, { + source: "learning", + tags: ["tool-preference", ...toolNames.map((toolName) => `tool:${toolName}`)], + }) + if (entry === null) { + // null means duplicate — still handled + } + + return true + } + + /** + * Execute a SKILL_SUGGESTION action. + * Records the suggestion in SkillUsageStore for future user approval. + */ + private async executeSkillSuggestion(action: ImprovementAction): Promise { + const summary = this.readStringPayload(action.payload.summary) + if (!summary) { + return false + } + + const skillName = this.readStringPayload(action.payload.skillName) ?? summary + const skillId = + this.readStringPayload(action.payload.skillId) ?? + `suggested:${crypto.createHash("sha256").update(skillName.toLowerCase()).digest("hex").slice(0, 16)}` + const createdBy = this.readSkillProvenance(action.payload.createdBy) ?? "agent" + + this.skillUsageStore.getOrCreate(skillId, skillName, createdBy) + this.logger.appendLine(`[ActionExecutor] Skill suggestion recorded: ${summary}`) + + return true + } + + private readStringPayload(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined + } + + private readStringArrayPayload(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return Array.from( + new Set(value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)), + ) + } + + private readSkillProvenance(value: unknown): SkillProvenance | undefined { + return value === "agent" || value === "user" || value === "bundled" || value === "hub" || value === "unknown" + ? value + : undefined + } +} diff --git a/src/services/self-improving/CodeIndexAdapter.ts b/src/services/self-improving/CodeIndexAdapter.ts new file mode 100644 index 0000000000..937fcc5425 --- /dev/null +++ b/src/services/self-improving/CodeIndexAdapter.ts @@ -0,0 +1,44 @@ +import type { CodeIndexInfo, Logger } from "./types" + +/** + * CodeIndexAdapter - thin read-only adapter for code index integration. + * + * When code index is unavailable or disabled, returns a no-op payload + * and the learning loop proceeds normally. + */ +export class CodeIndexAdapter { + private readonly getCodeIndexInfo: (() => CodeIndexInfo) | undefined + + constructor( + private readonly logger?: Logger, + getCodeIndexInfo?: () => CodeIndexInfo, + ) { + this.getCodeIndexInfo = getCodeIndexInfo + } + + /** + * Get current code index info. + * Returns a safe default if the adapter is not configured. + */ + getInfo(): CodeIndexInfo { + if (!this.getCodeIndexInfo) { + return { available: false, hits: 0 } + } + + try { + return this.getCodeIndexInfo() + } catch (error) { + this.logger?.appendLine( + `[CodeIndexAdapter] Error getting code index info: ${error instanceof Error ? error.message : String(error)}`, + ) + return { available: false, hits: 0 } + } + } + + /** + * Check if code index is available and configured. + */ + isAvailable(): boolean { + return this.getInfo().available + } +} diff --git a/src/services/self-improving/CuratorService.ts b/src/services/self-improving/CuratorService.ts new file mode 100644 index 0000000000..76add6e91b --- /dev/null +++ b/src/services/self-improving/CuratorService.ts @@ -0,0 +1,423 @@ +import * as fs from "fs/promises" +import * as path from "path" +import crypto from "crypto" + +import { safeWriteJson } from "../../utils/safeWriteJson" +import type { Logger } from "./types" +import type { SkillTelemetryRecord, SkillUsageStore } from "./SkillUsageStore" + +/** + * Curator configuration + */ +export interface CuratorConfig { + /** Minimum interval between curator runs (ms) */ + intervalMs: number + /** Minimum idle time since last user activity before curator runs (ms) */ + minIdleMs: number + /** Whether to defer the first curator run */ + firstRunDeferred: boolean + /** Days of inactivity before a skill is marked stale */ + staleAfterDays: number + /** Days of inactivity before a stale skill is archived */ + archiveAfterDays: number + /** Whether to create pre-run backups */ + backupsEnabled: boolean + /** Maximum number of backup snapshots to retain */ + maxBackups: number +} + +/** + * Default curator configuration + */ +export const DEFAULT_CURATOR_CONFIG: CuratorConfig = { + intervalMs: 3_600_000, + minIdleMs: 300_000, + firstRunDeferred: true, + staleAfterDays: 14, + archiveAfterDays: 60, + backupsEnabled: true, + maxBackups: 5, +} + +/** + * Curator run report + */ +export interface CuratorReport { + runId: string + timestamp: number + durationMs: number + transitions: Array<{ + skillId: string + skillName: string + fromState: string + toState: string + reason: string + }> + stats: { + totalSkills: number + activeSkills: number + staleSkills: number + archivedSkills: number + pinnedSkills: number + transitionsApplied: number + } + backupPath?: string + error?: string +} + +type CuratorStatus = { + lastRunAt: number + firstRunDone: boolean + config: CuratorConfig +} + +/** + * CuratorService — telemetry-driven skill lifecycle management. + */ +export class CuratorService { + private readonly baseDir: string + private readonly statePath: string + private readonly backupsDir: string + private readonly reportsDir: string + private readonly skillUsageStore: SkillUsageStore + private readonly logger: Logger + private config: CuratorConfig + private lastRunAt = 0 + private firstRunDone = false + private initialized = false + + constructor(baseDir: string, skillUsageStore: SkillUsageStore, logger: Logger, config?: Partial) { + this.baseDir = path.join(baseDir, "self-improving", "curator") + this.statePath = path.join(this.baseDir, "state.json") + this.backupsDir = path.join(this.baseDir, "backups") + this.reportsDir = path.join(this.baseDir, "reports") + this.skillUsageStore = skillUsageStore + this.logger = logger + this.config = { ...DEFAULT_CURATOR_CONFIG, ...config } + } + + async initialize(): Promise { + if (this.initialized) { + return + } + + try { + await fs.mkdir(this.backupsDir, { recursive: true }) + await fs.mkdir(this.reportsDir, { recursive: true }) + await this.loadState() + this.logger.appendLine("[CuratorService] Initialized") + } catch (error) { + this.logger.appendLine( + `[CuratorService] Initialization error: ${error instanceof Error ? error.message : String(error)}`, + ) + } finally { + this.initialized = true + } + } + + shouldRun(now: number, lastUserActivityAt?: number): boolean { + if (this.config.firstRunDeferred && !this.firstRunDone && this.lastRunAt === 0) { + return false + } + + if (now - this.lastRunAt < this.config.intervalMs) { + return false + } + + if (typeof lastUserActivityAt === "number" && now - lastUserActivityAt < this.config.minIdleMs) { + return false + } + + return true + } + + async run(now: number, lastUserActivityAt?: number): Promise { + await this.initialize() + + const startedAt = Date.now() + const runId = crypto.randomUUID() + const report: CuratorReport = { + runId, + timestamp: now, + durationMs: 0, + transitions: [], + stats: { + totalSkills: 0, + activeSkills: 0, + staleSkills: 0, + archivedSkills: 0, + pinnedSkills: 0, + transitionsApplied: 0, + }, + } + + try { + if (this.shouldDeferFirstRun()) { + this.firstRunDone = true + await this.saveState() + report.error = "Skipped: first-run deferral" + report.durationMs = Date.now() - startedAt + await this.writeReport(report) + return report + } + + if (!this.shouldRun(now, lastUserActivityAt)) { + report.error = "Skipped: gates not satisfied" + report.durationMs = Date.now() - startedAt + await this.writeReport(report) + return report + } + + // Set lastRunAt immediately to prevent concurrent runs + this.lastRunAt = now + + if (this.config.backupsEnabled) { + report.backupPath = await this.createBackup(runId) + } + + this.assignStats(report) + report.transitions = await this.applyDeterministicTransitions() + await this.runCuratorReview(report) + report.stats.transitionsApplied = report.transitions.length + this.assignStats(report) + + this.firstRunDone = true + await this.saveState() + + report.durationMs = Date.now() - startedAt + await this.writeReport(report) + this.logger.appendLine( + `[CuratorService] Run ${runId}: ${report.transitions.length} transitions in ${report.durationMs}ms`, + ) + } catch (error) { + report.error = error instanceof Error ? error.message : String(error) + report.durationMs = Date.now() - startedAt + this.logger.appendLine(`[CuratorService] Run error: ${report.error}`) + await this.writeReport(report) + } + + return report + } + + async getLatestReport(): Promise { + try { + const entries = await fs.readdir(this.reportsDir, { withFileTypes: true }) + const candidates = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + const runPath = path.join(this.reportsDir, entry.name, "run.json") + const stats = await fs.stat(runPath) + return { runPath, mtimeMs: stats.mtimeMs } + }), + ) + + if (candidates.length === 0) { + return null + } + + candidates.sort((left, right) => right.mtimeMs - left.mtimeMs) + const raw = await fs.readFile(candidates[0].runPath, "utf-8") + return JSON.parse(raw) as CuratorReport + } catch { + return null + } + } + + getConfig(): Readonly { + return this.config + } + + setConfig(config: Partial): void { + this.config = { ...this.config, ...config } + } + + getStatus(): CuratorStatus { + return { + lastRunAt: this.lastRunAt, + firstRunDone: this.firstRunDone, + config: { ...this.config }, + } + } + + private async loadState(): Promise { + try { + const raw = await fs.readFile(this.statePath, "utf-8") + const parsed = JSON.parse(raw) as Partial + this.lastRunAt = typeof parsed.lastRunAt === "number" ? parsed.lastRunAt : 0 + this.firstRunDone = parsed.firstRunDone === true + } catch { + this.lastRunAt = 0 + this.firstRunDone = false + } + } + + private async saveState(): Promise { + try { + await safeWriteJson( + this.statePath, + { + lastRunAt: this.lastRunAt, + firstRunDone: this.firstRunDone, + }, + { prettyPrint: true }, + ) + } catch (error) { + this.logger.appendLine( + `[CuratorService] Save state error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + private shouldDeferFirstRun(): boolean { + return this.config.firstRunDeferred && !this.firstRunDone && this.lastRunAt === 0 + } + + private async createBackup(runId: string): Promise { + const backupDir = path.join(this.backupsDir, `backup-${Date.now()}-${runId}`) + await fs.mkdir(backupDir, { recursive: true }) + await safeWriteJson( + path.join(backupDir, "snapshot.json"), + { + createdAt: Date.now(), + curatorState: { + lastRunAt: this.lastRunAt, + firstRunDone: this.firstRunDone, + }, + skillUsage: this.skillUsageStore.getAll(), + }, + { prettyPrint: true }, + ) + await this.cleanupOldBackups() + return backupDir + } + + private async cleanupOldBackups(): Promise { + try { + const entries = await fs.readdir(this.backupsDir, { withFileTypes: true }) + const backups = await Promise.all( + entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith("backup-")) + .map(async (entry) => { + const backupPath = path.join(this.backupsDir, entry.name) + const stats = await fs.stat(backupPath) + return { backupPath, mtimeMs: stats.mtimeMs } + }), + ) + + backups.sort((left, right) => right.mtimeMs - left.mtimeMs) + for (const staleBackup of backups.slice(this.config.maxBackups)) { + await fs.rm(staleBackup.backupPath, { recursive: true, force: true }) + } + } catch { + // Best-effort retention cleanup. + } + } + + private assignStats(report: CuratorReport): void { + const stats = this.skillUsageStore.getStats() + report.stats.totalSkills = stats.total + report.stats.activeSkills = stats.active + report.stats.staleSkills = stats.stale + report.stats.archivedSkills = stats.archived + report.stats.pinnedSkills = stats.pinned + } + + private async applyDeterministicTransitions(): Promise { + const transitions: CuratorReport["transitions"] = [] + + for (const candidate of this.skillUsageStore.getStaleCandidates(this.config.staleAfterDays)) { + if (this.isProtected(candidate)) { + continue + } + + await this.skillUsageStore.transitionState(candidate.skillId, "stale") + transitions.push({ + skillId: candidate.skillId, + skillName: candidate.skillName, + fromState: "active", + toState: "stale", + reason: `No activity for ${this.config.staleAfterDays} days`, + }) + } + + for (const candidate of this.skillUsageStore.getArchiveCandidates(this.config.archiveAfterDays)) { + if (this.isProtected(candidate)) { + continue + } + + await this.skillUsageStore.transitionState(candidate.skillId, "archived") + transitions.push({ + skillId: candidate.skillId, + skillName: candidate.skillName, + fromState: "stale", + toState: "archived", + reason: `No activity for ${this.config.archiveAfterDays} days`, + }) + } + + return transitions + } + + private isProtected(record: SkillTelemetryRecord): boolean { + return record.pinned || record.createdBy !== "agent" + } + + private async runCuratorReview(_report: CuratorReport): Promise { + // Reserved for future rubric-driven LLM curator review. + } + + private async writeReport(report: CuratorReport): Promise { + try { + const runDir = path.join(this.reportsDir, report.runId) + await fs.mkdir(runDir, { recursive: true }) + await safeWriteJson(path.join(runDir, "run.json"), report, { prettyPrint: true }) + await fs.writeFile(path.join(runDir, "REPORT.md"), this.buildReportMarkdown(report), "utf-8") + } catch (error) { + this.logger.appendLine( + `[CuratorService] Report write error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + private buildReportMarkdown(report: CuratorReport): string { + const lines = [ + `# Curator Run Report: ${report.runId}`, + "", + `**Timestamp**: ${new Date(report.timestamp).toISOString()}`, + `**Duration**: ${report.durationMs}ms`, + "", + "## Summary", + "", + "| Metric | Value |", + "|--------|-------|", + `| Total Skills | ${report.stats.totalSkills} |`, + `| Active | ${report.stats.activeSkills} |`, + `| Stale | ${report.stats.staleSkills} |`, + `| Archived | ${report.stats.archivedSkills} |`, + `| Pinned | ${report.stats.pinnedSkills} |`, + `| Transitions Applied | ${report.stats.transitionsApplied} |`, + "", + ] + + if (report.transitions.length > 0) { + lines.push("## Transitions", "", "| Skill | From | To | Reason |", "|-------|------|----|--------|") + for (const transition of report.transitions) { + lines.push( + `| ${transition.skillName} | ${transition.fromState} | ${transition.toState} | ${transition.reason} |`, + ) + } + lines.push("") + } + + if (report.backupPath) { + lines.push(`**Backup**: ${report.backupPath}`, "") + } + + if (report.error) { + lines.push("## Error", "", report.error, "") + } + + return lines.join("\n") + } +} diff --git a/src/services/self-improving/FeedbackCollector.ts b/src/services/self-improving/FeedbackCollector.ts new file mode 100644 index 0000000000..af19168a63 --- /dev/null +++ b/src/services/self-improving/FeedbackCollector.ts @@ -0,0 +1,122 @@ +import crypto from "crypto" + +import type { CodeIndexInfo, FeedbackSignal, LearningEvent, TaskEventInfo } from "./types" + +/** + * FeedbackCollector - normalizes task/user/tool/code-index signals + * into structured LearningEvent objects. + * + * This is a stateless converter - it creates events from raw signals + * without side effects. The caller (SelfImprovingManager) owns + * persistence and lifecycle. + */ +export class FeedbackCollector { + /** + * Create a learning event from a task completion signal. + */ + createTaskEvent(info: TaskEventInfo): LearningEvent { + const signal: FeedbackSignal = info.success ? "TASK_SUCCESS" : "TASK_FAILURE" + + return { + id: crypto.randomUUID(), + signal, + timestamp: Date.now(), + taskId: info.taskId, + workspacePath: info.workspacePath, + mode: info.mode, + context: { + userTurnCount: info.userTurnCount, + toolIterationCount: info.toolIterationCount, + toolNames: info.toolNames, + promptFingerprint: info.promptFingerprint, + errorKey: info.errorKey, + }, + outcome: { + success: info.success, + corrected: info.corrected, + confidenceDelta: info.success ? 0.05 : -0.1, + }, + } + } + + /** + * Create a learning event from a user correction signal. + */ + createCorrectionEvent(info: TaskEventInfo): LearningEvent { + return { + id: crypto.randomUUID(), + signal: "USER_CORRECTION", + timestamp: Date.now(), + taskId: info.taskId, + workspacePath: info.workspacePath, + mode: info.mode, + context: { + toolNames: info.toolNames, + errorKey: info.errorKey, + promptFingerprint: info.promptFingerprint, + }, + outcome: { + corrected: true, + confidenceDelta: -0.15, + }, + } + } + + /** + * Create a learning event from a pattern repeat signal. + */ + createPatternRepeatEvent(patternId: string, taskId?: string, mode?: string): LearningEvent { + return { + id: crypto.randomUUID(), + signal: "PATTERN_REPEAT", + timestamp: Date.now(), + taskId, + mode, + context: { + promptFingerprint: patternId, + }, + outcome: { + confidenceDelta: 0.02, + }, + } + } + + /** + * Create a learning event from a code index hit. + */ + createCodeIndexEvent(codeIndex: CodeIndexInfo, taskId?: string): LearningEvent { + return { + id: crypto.randomUUID(), + signal: "CODE_INDEX_HIT", + timestamp: Date.now(), + taskId, + context: { + codeIndex: { + available: codeIndex.available, + hits: codeIndex.hits, + topScore: codeIndex.topScore, + }, + }, + outcome: { + confidenceDelta: codeIndex.hits > 0 ? 0.03 : 0, + }, + } + } + + /** + * Create a learning event from a prompt quality signal. + */ + createPromptQualityEvent(quality: number, promptFingerprint?: string): LearningEvent { + return { + id: crypto.randomUUID(), + signal: "PROMPT_QUALITY", + timestamp: Date.now(), + context: { + promptFingerprint, + }, + outcome: { + confidenceDelta: quality > 0.5 ? 0.01 : -0.01, + }, + } + } +} diff --git a/src/services/self-improving/ImprovementApplier.ts b/src/services/self-improving/ImprovementApplier.ts new file mode 100644 index 0000000000..31829128cf --- /dev/null +++ b/src/services/self-improving/ImprovementApplier.ts @@ -0,0 +1,136 @@ +import crypto from "crypto" + +import type { ImprovementAction, LearnedPattern, PromptContext } from "./types" + +/** + * ImprovementApplier - converts learned patterns into actionable improvements. + * + * Generates: + * - Prompt enrichment context (bounded, ordered by confidence) + * - Tool preference adjustments + * - Error avoidance hints + * - Skill suggestions (for future user approval) + */ +export class ImprovementApplier { + /** + * Generate prompt context from active patterns. + * Returns at most maxEntries entries, ordered by confidence descending. + */ + getPromptContext(patterns: LearnedPattern[], maxEntries = 5, revision = 0): PromptContext { + const activePatterns = patterns + .filter((pattern) => pattern.state === "active") + .sort((left, right) => right.confidenceScore - left.confidenceScore) + .slice(0, maxEntries) + + return { + entries: activePatterns.map((pattern) => ({ + type: pattern.patternType, + summary: pattern.summary, + confidence: pattern.confidenceScore, + })), + revision, + } + } + + /** + * Generate improvement actions from patterns. + */ + generateActions(patterns: LearnedPattern[]): ImprovementAction[] { + const actions: ImprovementAction[] = [] + const now = Date.now() + + for (const pattern of patterns) { + if (pattern.state !== "active") { + continue + } + + switch (pattern.patternType) { + case "error": + actions.push(this.createErrorAvoidanceAction(pattern, now)) + break + case "tool": + actions.push(this.createToolPreferenceAction(pattern, now)) + break + case "prompt": + actions.push(this.createPromptEnrichmentAction(pattern, now)) + break + case "skill": + actions.push(this.createSkillSuggestionAction(pattern, now)) + break + } + } + + return actions + } + + /** + * Create an error avoidance action from an error pattern. + */ + private createErrorAvoidanceAction(pattern: LearnedPattern, now: number): ImprovementAction { + return { + id: crypto.randomUUID(), + actionType: "ERROR_AVOIDANCE", + target: "task-execution", + payload: { + patternId: pattern.id, + errorKeys: pattern.context.errorKeys, + summary: pattern.summary, + confidence: pattern.confidenceScore, + }, + timestamp: now, + } + } + + /** + * Create a tool preference action from a tool pattern. + */ + private createToolPreferenceAction(pattern: LearnedPattern, now: number): ImprovementAction { + return { + id: crypto.randomUUID(), + actionType: "TOOL_PREFERENCE", + target: "task-execution", + payload: { + patternId: pattern.id, + toolNames: pattern.context.toolNames, + summary: pattern.summary, + confidence: pattern.confidenceScore, + }, + timestamp: now, + } + } + + /** + * Create a prompt enrichment action from a prompt pattern. + */ + private createPromptEnrichmentAction(pattern: LearnedPattern, now: number): ImprovementAction { + return { + id: crypto.randomUUID(), + actionType: "PROMPT_ENRICHMENT", + target: "system-prompt", + payload: { + patternId: pattern.id, + summary: pattern.summary, + confidence: pattern.confidenceScore, + }, + timestamp: now, + } + } + + /** + * Create a skill suggestion action from a skill pattern. + */ + private createSkillSuggestionAction(pattern: LearnedPattern, now: number): ImprovementAction { + return { + id: crypto.randomUUID(), + actionType: "SKILL_SUGGESTION", + target: "skills-manager", + payload: { + patternId: pattern.id, + summary: pattern.summary, + confidence: pattern.confidenceScore, + toolNames: pattern.context.toolNames, + }, + timestamp: now, + } + } +} diff --git a/src/services/self-improving/LearningStore.ts b/src/services/self-improving/LearningStore.ts new file mode 100644 index 0000000000..9a33a97d80 --- /dev/null +++ b/src/services/self-improving/LearningStore.ts @@ -0,0 +1,391 @@ +import * as fs from "fs/promises" +import * as path from "path" +import crypto from "crypto" + +import { safeWriteJson } from "../../utils/safeWriteJson" +import type { ImprovementAction, LearnedPattern, LearningConfig, LearningEvent, LearningState, Logger } from "./types" +import { EMPTY_STATE } from "./types" + +/** + * File names for the learning store + */ +const STATE_FILE = "state.json" +const PATTERNS_DIR = "patterns" +const ARCHIVE_DIR = "archive" +const PATTERN_INDEX_FILE = "_index.json" + +/** + * LearningStore - atomic file-based persistence for learning state. + * + * Storage layout: + * globalStorage/self-improving/ + * state.json - canonical LearningState metadata + counters + * patterns/ - per-pattern source-of-truth files + * _index.json - compact index of active/stale/archived patterns + * .json - individual pattern data + * archive/ - cold storage for archived patterns + * .json + */ +export class LearningStore { + private readonly baseDir: string + private readonly patternsDir: string + private readonly archiveDir: string + private state: LearningState + private readonly logger: Logger + private initialized = false + + constructor(baseDir: string, logger: Logger) { + this.baseDir = path.join(baseDir, "self-improving") + this.patternsDir = path.join(this.baseDir, PATTERNS_DIR) + this.archiveDir = path.join(this.baseDir, ARCHIVE_DIR) + this.state = this.createEmptyState() + this.logger = logger + } + + /** + * Initialize the store - create directories and load persisted state. + * If state is corrupted or missing, falls back to empty defaults. + */ + async initialize(): Promise { + if (this.initialized) { + return + } + + try { + await fs.mkdir(this.patternsDir, { recursive: true }) + await fs.mkdir(this.archiveDir, { recursive: true }) + await this.loadState() + this.initialized = true + } catch (error) { + this.logger.appendLine( + `[LearningStore] Initialization error: ${error instanceof Error ? error.message : String(error)}`, + ) + this.state = this.createEmptyState() + this.initialized = true + } + } + + /** + * Load state from disk with graceful degradation. + */ + private async loadState(): Promise { + const statePath = path.join(this.baseDir, STATE_FILE) + + try { + const raw = await fs.readFile(statePath, "utf-8") + const parsed = JSON.parse(raw) as Partial + + if (parsed && typeof parsed === "object" && parsed.version === 1) { + this.state = this.mergeWithDefaults(parsed) + await this.loadPatternFiles() + this.logger.appendLine( + `[LearningStore] Loaded state: ${this.state.patterns.length} patterns, ${this.state.recentEvents.length} events`, + ) + return + } + + this.logger.appendLine("[LearningStore] Invalid state version, using defaults") + } catch (error: unknown) { + const errorCode = typeof error === "object" && error !== null && "code" in error ? error.code : undefined + if (errorCode === "ENOENT") { + this.logger.appendLine("[LearningStore] No existing state, starting fresh") + } else { + this.logger.appendLine( + `[LearningStore] Corrupted state (${error instanceof Error ? error.message : String(error)}), using defaults`, + ) + } + } + + this.state = this.createEmptyState() + } + + private async loadPatternFiles(): Promise { + const activePatterns = await this.readPatternDirectory(this.patternsDir) + const archivedPatterns = await this.readPatternDirectory(this.archiveDir) + + if (activePatterns.length > 0) { + this.state.patterns = activePatterns + } + + if (archivedPatterns.length > 0) { + this.state.archivedPatterns = archivedPatterns.map((pattern) => ({ + ...pattern, + state: "archived", + })) + } + } + + private async readPatternDirectory(directoryPath: string): Promise { + try { + const entries = await fs.readdir(directoryPath, { withFileTypes: true }) + const patterns: LearnedPattern[] = [] + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json") || entry.name === PATTERN_INDEX_FILE) { + continue + } + + try { + const raw = await fs.readFile(path.join(directoryPath, entry.name), "utf-8") + const parsed = JSON.parse(raw) as LearnedPattern + if (parsed?.id) { + patterns.push(parsed) + } + } catch (error) { + this.logger.appendLine( + `[LearningStore] Failed to read pattern ${entry.name}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + return patterns + } catch { + return [] + } + } + + /** + * Merge parsed state with defaults to handle schema evolution. + */ + private mergeWithDefaults(parsed: Partial): LearningState { + const config = { ...EMPTY_STATE.config, ...(parsed.config ?? {}) } + + return { + version: 1, + config, + counters: { + userTurnsSinceReview: parsed.counters?.userTurnsSinceReview ?? 0, + toolIterationsSinceReview: parsed.counters?.toolIterationsSinceReview ?? 0, + }, + patterns: Array.isArray(parsed.patterns) ? parsed.patterns : [], + archivedPatterns: Array.isArray(parsed.archivedPatterns) ? parsed.archivedPatterns : [], + recentEvents: Array.isArray(parsed.recentEvents) ? parsed.recentEvents.slice(-config.maxStoredEvents) : [], + pendingActions: Array.isArray(parsed.pendingActions) ? parsed.pendingActions : [], + telemetry: { + promptEnrichmentUses: parsed.telemetry?.promptEnrichmentUses ?? 0, + toolPreferenceUses: parsed.telemetry?.toolPreferenceUses ?? 0, + errorAvoidanceUses: parsed.telemetry?.errorAvoidanceUses ?? 0, + skillSuggestionCount: parsed.telemetry?.skillSuggestionCount ?? 0, + lastReviewAt: parsed.telemetry?.lastReviewAt, + lastCuratorRunAt: parsed.telemetry?.lastCuratorRunAt, + }, + } + } + + /** + * Persist the full state to disk atomically. + */ + async persist(): Promise { + if (!this.initialized) { + return + } + + try { + this.enforceBounds() + + await Promise.all([ + safeWriteJson(path.join(this.baseDir, STATE_FILE), this.state, { prettyPrint: true }), + this.persistPatternFiles(this.patternsDir, this.state.patterns), + this.persistPatternFiles(this.archiveDir, this.state.archivedPatterns), + this.writePatternIndex(), + ]) + } catch (error) { + this.logger.appendLine( + `[LearningStore] Persist error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + private async persistPatternFiles(directoryPath: string, patterns: readonly LearnedPattern[]): Promise { + const expectedNames = new Set(patterns.map((pattern) => `${pattern.id}.json`)) + + await Promise.all( + patterns.map((pattern) => + safeWriteJson(path.join(directoryPath, `${pattern.id}.json`), pattern, { prettyPrint: true }), + ), + ) + + try { + const existingEntries = await fs.readdir(directoryPath, { withFileTypes: true }) + await Promise.all( + existingEntries + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith(".json") && + entry.name !== PATTERN_INDEX_FILE && + !expectedNames.has(entry.name), + ) + .map((entry) => fs.rm(path.join(directoryPath, entry.name), { force: true })), + ) + } catch (error) { + this.logger.appendLine( + `[LearningStore] Pattern cleanup error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + private async writePatternIndex(): Promise { + await safeWriteJson( + path.join(this.patternsDir, PATTERN_INDEX_FILE), + { + version: 1, + updatedAt: Date.now(), + activePatternIds: this.state.patterns.map((pattern) => pattern.id), + archivedPatternIds: this.state.archivedPatterns.map((pattern) => pattern.id), + }, + { prettyPrint: true }, + ) + } + + /** + * Enforce storage bounds (max patterns, max events). + */ + private enforceBounds(): void { + const maxPatterns = this.state.config.maxStoredPatterns + const maxEvents = this.state.config.maxStoredEvents + + if (this.state.patterns.length > maxPatterns) { + this.state.patterns = [...this.state.patterns] + .sort((a, b) => a.confidenceScore - b.confidenceScore) + .slice(-maxPatterns) + } + + if (this.state.archivedPatterns.length > maxPatterns) { + this.state.archivedPatterns = [...this.state.archivedPatterns] + .sort((a, b) => a.lastSeenAt - b.lastSeenAt) + .slice(-maxPatterns) + } + + if (this.state.recentEvents.length > maxEvents) { + this.state.recentEvents = this.state.recentEvents.slice(-maxEvents) + } + } + + // ──── Getters ──── + + getState(): Readonly { + return this.state + } + + getConfig(): Readonly { + return this.state.config + } + + getPatterns(): readonly LearnedPattern[] { + return this.state.patterns + } + + getArchivedPatterns(): readonly LearnedPattern[] { + return this.state.archivedPatterns + } + + getRecentEvents(): readonly LearningEvent[] { + return this.state.recentEvents + } + + getPendingActions(): readonly ImprovementAction[] { + return this.state.pendingActions + } + + getTelemetry(): Readonly { + return this.state.telemetry + } + + getCounters(): Readonly { + return this.state.counters + } + + // ──── Mutations ──── + + setConfig(config: Partial): void { + this.state.config = { ...this.state.config, ...config } + } + + addEvent(event: LearningEvent): void { + this.state.recentEvents.push(event) + } + + addPattern(pattern: LearnedPattern): void { + const existing = this.state.patterns.findIndex((candidate) => candidate.id === pattern.id) + if (existing >= 0) { + this.state.patterns[existing] = pattern + return + } + + this.state.patterns.push(pattern) + } + + updatePattern(id: string, updates: Partial): void { + const index = this.state.patterns.findIndex((pattern) => pattern.id === id) + if (index >= 0) { + this.state.patterns[index] = { ...this.state.patterns[index], ...updates } + } + } + + removePattern(id: string): void { + this.state.patterns = this.state.patterns.filter((pattern) => pattern.id !== id) + } + + archivePattern(id: string): void { + const index = this.state.patterns.findIndex((pattern) => pattern.id === id) + if (index >= 0) { + const pattern = { ...this.state.patterns[index], state: "archived" as const } + this.state.archivedPatterns.push(pattern) + this.state.patterns.splice(index, 1) + } + } + + addAction(action: ImprovementAction): void { + this.state.pendingActions.push(action) + } + + removeAction(id: string): void { + this.state.pendingActions = this.state.pendingActions.filter((action) => action.id !== id) + } + + incrementUserTurns(): void { + this.state.counters.userTurnsSinceReview++ + } + + incrementToolIterations(delta = 1): void { + this.state.counters.toolIterationsSinceReview += delta + } + + resetCounters(): void { + this.state.counters.userTurnsSinceReview = 0 + this.state.counters.toolIterationsSinceReview = 0 + } + + updateTelemetry(updates: Partial): void { + this.state.telemetry = { ...this.state.telemetry, ...updates } + } + + /** + * Reset all learning state to defaults. + */ + async reset(): Promise { + this.state = this.createEmptyState() + await this.persist() + } + + private createEmptyState(): LearningState { + return { + ...EMPTY_STATE, + config: { ...EMPTY_STATE.config }, + counters: { ...EMPTY_STATE.counters }, + patterns: [], + archivedPatterns: [], + recentEvents: [], + pendingActions: [], + telemetry: { ...EMPTY_STATE.telemetry }, + } + } + + /** + * Generate a unique ID for patterns, events, and actions. + */ + static generateId(): string { + return crypto.randomUUID() + } +} diff --git a/src/services/self-improving/MemoryStore.ts b/src/services/self-improving/MemoryStore.ts new file mode 100644 index 0000000000..5341850fad --- /dev/null +++ b/src/services/self-improving/MemoryStore.ts @@ -0,0 +1,513 @@ +import * as fs from "fs/promises" +import * as path from "path" +import crypto from "crypto" + +import { safeWriteJson } from "../../utils/safeWriteJson" +import type { MemoryContext, MemoryEntry } from "@roo-code/types" +import type { Logger } from "./types" + +/** + * Store type for memory categorization. + * Mirrors Hermes' MEMORY.md (environment) vs USER.md (user profile) split. + */ +export type MemoryStoreType = "environment" | "userProfile" + +const MEMORY_SOURCES: ReadonlySet = new Set(["learning", "user", "system", "review"]) + +/** + * MemoryStore - real bounded memory subsystem. + * + * Implements Hermes' dual-store approach: + * - environment: durable operational facts, project knowledge, learned patterns + * - userProfile: user preferences, corrections, style feedback + * + * Key design: + * - Frozen snapshot at session start (prompt stability) + * - Live writes go to disk but NOT to active snapshot + * - Exact-duplicate rejection on load + * - Substring-based replace and remove + * - Bounded retention per store + */ +export class MemoryStore { + private readonly baseDir: string + private readonly logger: Logger + private environment: MemoryEntry[] = [] + private userProfile: MemoryEntry[] = [] + private environmentSnapshot: MemoryEntry[] = [] + private userProfileSnapshot: MemoryEntry[] = [] + private revision = 0 + private initialized = false + + private static readonly MAX_ENVIRONMENT_ENTRIES = 50 + private static readonly MAX_USER_PROFILE_ENTRIES = 20 + private static readonly MAX_ENTRY_LENGTH = 2000 + private static readonly MAX_SNAPSHOT_ENVIRONMENT_ENTRIES = 5 + private static readonly MAX_SNAPSHOT_USER_PROFILE_ENTRIES = 5 + + constructor(baseDir: string, logger: Logger) { + this.baseDir = path.join(baseDir, "self-improving", "memory") + this.logger = logger + } + + /** + * Initialize the memory store - load persisted entries from disk. + */ + async initialize(): Promise { + if (this.initialized) { + return + } + + try { + await fs.mkdir(this.baseDir, { recursive: true }) + await this.loadFromDisk() + this.logger.appendLine( + `[MemoryStore] Initialized: ${this.environment.length} environment, ${this.userProfile.length} user profile entries`, + ) + } catch (error) { + this.logger.appendLine( + `[MemoryStore] Initialization error: ${error instanceof Error ? error.message : String(error)}`, + ) + } finally { + this.initialized = true + } + } + + /** + * Load entries from disk with duplicate rejection. + */ + private async loadFromDisk(): Promise { + this.environment = await this.loadStoreFile("environment") + this.userProfile = await this.loadStoreFile("userProfile") + this.takeSnapshot() + } + + /** + * Load a single store file with validation and dedup. + */ + private async loadStoreFile(type: MemoryStoreType): Promise { + try { + const raw = await fs.readFile(this.getFilePath(type), "utf-8") + const parsed = JSON.parse(raw) + + if (!Array.isArray(parsed)) { + return [] + } + + const seen = new Set() + const deduped: MemoryEntry[] = [] + + for (const candidate of parsed) { + const entry = this.sanitizePersistedEntry(candidate) + if (!entry) { + continue + } + + const contentKey = this.normalizeContent(entry.content) + if (seen.has(contentKey)) { + continue + } + + seen.add(contentKey) + deduped.push(entry) + } + + return this.enforceBounds(type, deduped) + } catch (error: unknown) { + const errorCode = typeof error === "object" && error !== null && "code" in error ? error.code : undefined + if (errorCode !== "ENOENT") { + this.logger.appendLine( + `[MemoryStore] Load error for ${this.getFilePath(type)}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + return [] + } + } + + /** + * Take a frozen snapshot of current memory for prompt injection. + * Live writes update the working store but NOT the snapshot. + */ + takeSnapshot(): void { + this.environmentSnapshot = this.environment.map((entry) => this.cloneEntry(entry)) + this.userProfileSnapshot = this.userProfile.map((entry) => this.cloneEntry(entry)) + this.revision += 1 + } + + /** + * Get the frozen snapshot context for prompt injection. + */ + getSnapshotContext(): MemoryContext { + return { + entries: this.buildSnapshotEntries(), + revision: this.revision, + generatedAt: Date.now(), + } + } + + /** + * Get snapshot as formatted string for prompt injection. + */ + getSnapshotString(): string { + const context = this.getSnapshotContext() + if (context.entries.length === 0) return "" + + const lines = context.entries.map((entry) => { + // Sanitize: single line, strip control characters, no markdown headings + const sanitized = entry.content + .split("") + .filter((c) => { + const code = c.charCodeAt(0) + return code >= 32 || code === 9 || code === 10 || code === 13 + }) + .join("") + .replace(/\n/g, " ") + .replace(/^#+\s*/gm, "") + .trim() + const tags = entry.tags?.length ? ` [${entry.tags.join(", ")}]` : "" + return `- ${sanitized}${tags}` + }) + + return `\n## Learned Context\n${lines.join("\n")}\n` + } + + // ──── Environment store operations ──── + + /** + * Add an entry to the environment store. + * Rejects exact duplicates. Persists to disk but does NOT update the snapshot. + */ + async addEnvironmentEntry( + content: string, + options?: { + source?: MemoryEntry["source"] + tags?: string[] + expiresAt?: number + }, + ): Promise { + return this.addEntry("environment", content, options) + } + + /** + * Replace entries in the environment store that contain a substring. + * If no match is found, adds as new entry. + */ + async replaceEnvironmentEntry( + substring: string, + newContent: string, + options?: { + source?: MemoryEntry["source"] + tags?: string[] + }, + ): Promise { + return this.replaceEntry("environment", substring, newContent, options) + } + + /** + * Remove entries from the environment store that contain a substring. + */ + async removeEnvironmentEntry(substring: string): Promise { + return this.removeEntry("environment", substring) + } + + // ──── User profile store operations ──── + + /** + * Add an entry to the user profile store. + */ + async addUserProfileEntry( + content: string, + options?: { + source?: MemoryEntry["source"] + tags?: string[] + expiresAt?: number + }, + ): Promise { + return this.addEntry("userProfile", content, options) + } + + /** + * Replace entries in the user profile store that contain a substring. + */ + async replaceUserProfileEntry( + substring: string, + newContent: string, + options?: { + source?: MemoryEntry["source"] + tags?: string[] + }, + ): Promise { + return this.replaceEntry("userProfile", substring, newContent, options) + } + + /** + * Remove entries from the user profile store that contain a substring. + */ + async removeUserProfileEntry(substring: string): Promise { + return this.removeEntry("userProfile", substring) + } + + // ──── Generic store operations ──── + + private getStore(type: MemoryStoreType): MemoryEntry[] { + return type === "environment" ? this.environment : this.userProfile + } + + private setStore(type: MemoryStoreType, entries: MemoryEntry[]): void { + if (type === "environment") { + this.environment = entries + return + } + + this.userProfile = entries + } + + private getMaxEntries(type: MemoryStoreType): number { + return type === "environment" ? MemoryStore.MAX_ENVIRONMENT_ENTRIES : MemoryStore.MAX_USER_PROFILE_ENTRIES + } + + private getFilePath(type: MemoryStoreType): string { + return path.join(this.baseDir, type === "environment" ? "environment.json" : "user-profile.json") + } + + private async ensureInitialized(): Promise { + if (!this.initialized) { + await this.initialize() + } + } + + private async addEntry( + type: MemoryStoreType, + content: string, + options?: { + source?: MemoryEntry["source"] + tags?: string[] + expiresAt?: number + }, + ): Promise { + await this.ensureInitialized() + + const trimmed = content.trim() + if (!trimmed || trimmed.length > MemoryStore.MAX_ENTRY_LENGTH) { + return null + } + + const normalized = this.normalizeContent(trimmed) + const store = this.getStore(type) + if (store.some((entry) => this.normalizeContent(entry.content) === normalized)) { + return null + } + + const now = Date.now() + const entry: MemoryEntry = { + id: crypto.randomUUID(), + content: trimmed, + source: this.normalizeSource(options?.source), + createdAt: now, + updatedAt: now, + relevanceScore: 1, + tags: this.normalizeTags(options?.tags), + expiresAt: typeof options?.expiresAt === "number" ? options.expiresAt : undefined, + } + + this.setStore(type, this.enforceBounds(type, [...store, entry])) + await this.persistStore(type) + + return this.cloneEntry(entry) + } + + private async replaceEntry( + type: MemoryStoreType, + substring: string, + newContent: string, + options?: { + source?: MemoryEntry["source"] + tags?: string[] + }, + ): Promise { + await this.ensureInitialized() + + const trimmedContent = newContent.trim() + if (!trimmedContent || trimmedContent.length > MemoryStore.MAX_ENTRY_LENGTH) { + throw new Error("Replacement memory content must be non-empty and within bounds") + } + + const normalizedSubstring = substring.trim().toLowerCase() + const store = this.getStore(type) + const remaining = + normalizedSubstring.length > 0 + ? store.filter((entry) => !entry.content.toLowerCase().includes(normalizedSubstring)) + : [...store] + + const duplicate = remaining.find( + (entry) => this.normalizeContent(entry.content) === this.normalizeContent(trimmedContent), + ) + + if (duplicate) { + this.setStore(type, this.enforceBounds(type, remaining)) + if (remaining.length !== store.length) { + await this.persistStore(type) + } + + return this.cloneEntry(duplicate) + } + + const now = Date.now() + const entry: MemoryEntry = { + id: crypto.randomUUID(), + content: trimmedContent, + source: this.normalizeSource(options?.source), + createdAt: now, + updatedAt: now, + relevanceScore: 1, + tags: this.normalizeTags(options?.tags), + } + + this.setStore(type, this.enforceBounds(type, [...remaining, entry])) + await this.persistStore(type) + + return this.cloneEntry(entry) + } + + private async removeEntry(type: MemoryStoreType, substring: string): Promise { + await this.ensureInitialized() + + const normalizedSubstring = substring.trim().toLowerCase() + if (!normalizedSubstring) { + return false + } + + const store = this.getStore(type) + const remaining = store.filter((entry) => !entry.content.toLowerCase().includes(normalizedSubstring)) + const removed = remaining.length !== store.length + + if (!removed) { + return false + } + + this.setStore(type, remaining) + await this.persistStore(type) + + return true + } + + private async persistStore(type: MemoryStoreType): Promise { + try { + await safeWriteJson(this.getFilePath(type), this.getStore(type), { prettyPrint: true }) + } catch (error) { + this.logger.appendLine( + `[MemoryStore] Persist error for ${type}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + private sanitizePersistedEntry(value: unknown): MemoryEntry | null { + if (!value || typeof value !== "object") { + return null + } + + const candidate = value as Partial + const trimmedContent = typeof candidate.content === "string" ? candidate.content.trim() : "" + if (!trimmedContent) { + return null + } + + const now = Date.now() + const createdAt = typeof candidate.createdAt === "number" ? candidate.createdAt : now + const updatedAt = typeof candidate.updatedAt === "number" ? candidate.updatedAt : createdAt + + return { + id: typeof candidate.id === "string" && candidate.id.trim().length > 0 ? candidate.id : crypto.randomUUID(), + content: trimmedContent.slice(0, MemoryStore.MAX_ENTRY_LENGTH), + source: this.normalizeSource(candidate.source), + createdAt, + updatedAt, + relevanceScore: + typeof candidate.relevanceScore === "number" + ? Math.min(1, Math.max(0, candidate.relevanceScore)) + : undefined, + tags: this.normalizeTags(candidate.tags), + expiresAt: typeof candidate.expiresAt === "number" ? candidate.expiresAt : undefined, + } + } + + private buildSnapshotEntries(): MemoryEntry[] { + const environmentEntries = this.environmentSnapshot + .slice(-MemoryStore.MAX_SNAPSHOT_ENVIRONMENT_ENTRIES) + .map((entry) => this.cloneEntry(entry)) + const userProfileEntries = this.userProfileSnapshot + .slice(-MemoryStore.MAX_SNAPSHOT_USER_PROFILE_ENTRIES) + .map((entry) => this.cloneEntry(entry)) + + return [...environmentEntries, ...userProfileEntries] + } + + private enforceBounds(type: MemoryStoreType, entries: MemoryEntry[]): MemoryEntry[] { + const maxEntries = this.getMaxEntries(type) + if (entries.length <= maxEntries) { + return entries + } + + return [...entries].sort((left, right) => left.createdAt - right.createdAt).slice(-maxEntries) + } + + private normalizeContent(content: string): string { + return content.trim().toLowerCase() + } + + private normalizeSource(source: MemoryEntry["source"] | undefined): MemoryEntry["source"] { + return source && MEMORY_SOURCES.has(source) ? source : "learning" + } + + private normalizeTags(tags: string[] | undefined): string[] | undefined { + if (!Array.isArray(tags)) { + return undefined + } + + const normalized = Array.from(new Set(tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))) + + return normalized.length > 0 ? normalized : undefined + } + + private cloneEntry(entry: MemoryEntry): MemoryEntry { + return { + ...entry, + tags: entry.tags ? [...entry.tags] : undefined, + } + } + + /** + * Get count of entries per store. + */ + getStats(): { environment: number; userProfile: number; revision: number } { + return { + environment: this.environment.length, + userProfile: this.userProfile.length, + revision: this.revision, + } + } + + /** + * Reset all memory stores. + */ + async reset(): Promise { + await this.ensureInitialized() + + this.environment = [] + this.userProfile = [] + this.environmentSnapshot = [] + this.userProfileSnapshot = [] + this.revision = 0 + + try { + await Promise.all([ + safeWriteJson(this.getFilePath("environment"), [], { prettyPrint: true }), + safeWriteJson(this.getFilePath("userProfile"), [], { prettyPrint: true }), + ]) + } catch (error) { + this.logger.appendLine( + `[MemoryStore] Reset error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } +} diff --git a/src/services/self-improving/PatternAnalyzer.ts b/src/services/self-improving/PatternAnalyzer.ts new file mode 100644 index 0000000000..fb407b2a4e --- /dev/null +++ b/src/services/self-improving/PatternAnalyzer.ts @@ -0,0 +1,287 @@ +import crypto from "crypto" + +import type { LearnedPattern, LearningEvent } from "./types" + +/** + * PatternAnalyzer - extracts learned patterns from event streams + * using deterministic heuristics (frequency analysis, correction tracking, + * success correlation). + * + * Adapted from Hermes' symbolic pattern extraction approach. + */ +export class PatternAnalyzer { + /** + * Analyze a batch of events and return new/updated patterns. + * This is the main entry point called during review cycles. + */ + analyze(events: LearningEvent[], existingPatterns: LearnedPattern[]): LearnedPattern[] { + const patterns: LearnedPattern[] = [] + const now = Date.now() + + const correctionPatterns = this.extractCorrectionPatterns(events, existingPatterns, now) + patterns.push(...correctionPatterns) + + const successPatterns = this.extractSuccessPatterns(events, existingPatterns, now) + patterns.push(...successPatterns) + + const toolPatterns = this.extractToolPatterns(events, existingPatterns, now) + patterns.push(...toolPatterns) + + const codeIndexPatterns = this.extractCodeIndexPatterns(events, existingPatterns, now) + patterns.push(...codeIndexPatterns) + + return patterns + } + + /** + * Extract error-avoidance patterns from correction/failure events. + */ + private extractCorrectionPatterns( + events: LearningEvent[], + existingPatterns: LearnedPattern[], + now: number, + ): LearnedPattern[] { + const patterns: LearnedPattern[] = [] + const correctionEvents = events.filter( + (event) => + event.signal === "USER_CORRECTION" || (event.signal === "TASK_FAILURE" && event.outcome.corrected), + ) + + const byErrorKey = new Map() + for (const event of correctionEvents) { + const key = event.context.errorKey || "unknown" + const bucket = byErrorKey.get(key) ?? [] + bucket.push(event) + byErrorKey.set(key, bucket) + } + + for (const [errorKey, errorEvents] of byErrorKey) { + const frequency = errorEvents.length + const existing = existingPatterns.find( + (pattern) => pattern.patternType === "error" && pattern.context.errorKeys?.includes(errorKey), + ) + + if (existing) { + patterns.push({ + ...existing, + frequency: existing.frequency + frequency, + lastSeenAt: now, + confidenceScore: Math.min(1, existing.confidenceScore + frequency * 0.05), + successRate: Math.max(0, existing.successRate - frequency * 0.02), + }) + } else if (frequency >= 2) { + patterns.push({ + id: crypto.randomUUID(), + patternType: "error", + state: "active", + summary: `Avoid: repeated ${errorKey} errors detected`, + confidenceScore: Math.min(0.5, frequency * 0.1), + frequency, + successRate: 0.3, + firstSeenAt: now, + lastSeenAt: now, + sourceSignals: ["USER_CORRECTION", "TASK_FAILURE"], + context: { + errorKeys: [errorKey], + toolNames: this.collectToolNames(errorEvents), + }, + }) + } + } + + return patterns + } + + /** + * Extract success patterns from task success events. + */ + private extractSuccessPatterns( + events: LearningEvent[], + existingPatterns: LearnedPattern[], + now: number, + ): LearnedPattern[] { + const patterns: LearnedPattern[] = [] + const successEvents = events.filter((event) => event.signal === "TASK_SUCCESS") + + if (successEvents.length < 3) { + return patterns + } + + const byToolSet = new Map() + for (const event of successEvents) { + const toolKey = [...(event.context.toolNames ?? [])].sort().join(",") + if (!toolKey) { + continue + } + + const bucket = byToolSet.get(toolKey) ?? [] + bucket.push(event) + byToolSet.set(toolKey, bucket) + } + + for (const [toolKey, toolEvents] of byToolSet) { + const frequency = toolEvents.length + const existing = existingPatterns.find( + (pattern) => pattern.patternType === "tool" && pattern.summary.includes(toolKey), + ) + + if (existing) { + patterns.push({ + ...existing, + frequency: existing.frequency + frequency, + lastSeenAt: now, + confidenceScore: Math.min(1, existing.confidenceScore + frequency * 0.03), + successRate: Math.min(1, existing.successRate + frequency * 0.02), + }) + } else if (frequency >= 3) { + patterns.push({ + id: crypto.randomUUID(), + patternType: "tool", + state: "active", + summary: `Effective tool combination: ${toolKey}`, + confidenceScore: Math.min(0.6, frequency * 0.1), + frequency, + successRate: 0.7, + firstSeenAt: now, + lastSeenAt: now, + sourceSignals: ["TASK_SUCCESS"], + context: { + toolNames: toolKey.split(","), + }, + }) + } + } + + return patterns + } + + /** + * Extract tool preference patterns. + */ + private extractToolPatterns( + events: LearningEvent[], + existingPatterns: LearnedPattern[], + now: number, + ): LearnedPattern[] { + const patterns: LearnedPattern[] = [] + const toolCounts = new Map() + + for (const event of events) { + for (const toolName of event.context.toolNames ?? []) { + const counts = toolCounts.get(toolName) ?? { success: 0, failure: 0 } + if (event.signal === "TASK_SUCCESS") { + counts.success++ + } else if (event.signal === "TASK_FAILURE") { + counts.failure++ + } + toolCounts.set(toolName, counts) + } + } + + for (const [toolName, counts] of toolCounts) { + const total = counts.success + counts.failure + if (total < 3) { + continue + } + + const successRate = counts.success / total + const existing = existingPatterns.find( + (pattern) => pattern.patternType === "prompt" && pattern.summary.includes(toolName), + ) + + if (existing) { + patterns.push({ + ...existing, + frequency: total, + lastSeenAt: now, + successRate, + confidenceScore: Math.min(1, existing.confidenceScore + 0.02), + }) + } else if (successRate > 0.7) { + patterns.push({ + id: crypto.randomUUID(), + patternType: "prompt", + state: "active", + summary: `Prefer ${toolName} for reliable results`, + confidenceScore: Math.min(0.5, successRate * 0.5), + frequency: total, + successRate, + firstSeenAt: now, + lastSeenAt: now, + sourceSignals: ["TASK_SUCCESS"], + context: { + toolNames: [toolName], + }, + }) + } + } + + return patterns + } + + /** + * Extract code index correlation patterns. + */ + private extractCodeIndexPatterns( + events: LearningEvent[], + existingPatterns: LearnedPattern[], + now: number, + ): LearnedPattern[] { + const codeIndexEvents = events.filter((event) => event.signal === "CODE_INDEX_HIT") + if (codeIndexEvents.length < 3) { + return [] + } + + const totalHits = codeIndexEvents.reduce((sum, event) => sum + (event.context.codeIndex?.hits ?? 0), 0) + const averageHits = totalHits / codeIndexEvents.length + if (averageHits <= 0) { + return [] + } + + const summary = `Code indexing correlates with task outcomes (avg ${averageHits.toFixed(1)} hits/event)` + const existing = existingPatterns.find( + (pattern) => pattern.patternType === "code-index" && pattern.summary === summary, + ) + + if (existing) { + return [ + { + ...existing, + frequency: existing.frequency + codeIndexEvents.length, + lastSeenAt: now, + confidenceScore: Math.min(1, existing.confidenceScore + averageHits * 0.01), + }, + ] + } + + return [ + { + id: crypto.randomUUID(), + patternType: "code-index", + state: "active", + summary, + confidenceScore: Math.min(0.5, averageHits * 0.05), + frequency: codeIndexEvents.length, + successRate: 0.6, + firstSeenAt: now, + lastSeenAt: now, + sourceSignals: ["CODE_INDEX_HIT"], + context: {}, + }, + ] + } + + /** + * Collect unique tool names from a set of events. + */ + private collectToolNames(events: LearningEvent[]): string[] { + const names = new Set() + for (const event of events) { + for (const name of event.context.toolNames ?? []) { + names.add(name) + } + } + + return [...names] + } +} diff --git a/src/services/self-improving/ReviewPromptFactory.ts b/src/services/self-improving/ReviewPromptFactory.ts new file mode 100644 index 0000000000..016efe1ec9 --- /dev/null +++ b/src/services/self-improving/ReviewPromptFactory.ts @@ -0,0 +1,111 @@ +/** + * Review type + */ +export type ReviewType = "memory" | "skill" | "combined" + +/** + * Review prompt result + */ +export interface ReviewPrompt { + type: ReviewType + systemPrompt: string + userPrompt: string +} + +/** + * ReviewPromptFactory — generates structured review prompts. + */ +export class ReviewPromptFactory { + createMemoryReviewPrompt(transcriptSummary: string): ReviewPrompt { + return { + type: "memory", + systemPrompt: `You are a memory review specialist. Your task is to review the recent conversation transcript and identify durable facts that should be saved to long-term memory. + +## Guidelines +- Save facts that are likely to be useful across multiple sessions +- Save user preferences, project conventions, and environment details +- Do NOT save transient information (one-off commands, temporary errors) +- Do NOT save information that is already in memory +- Prefer concise, actionable facts over verbose descriptions +- Each fact should be a single, clear statement + +## Output Format +For each fact you want to save, output: +FACT: +CATEGORY: +REASON: `, + userPrompt: `Review the following conversation transcript and identify durable facts to save to memory. + +${transcriptSummary} + +Output your recommendations following the specified format.`, + } + } + + createSkillReviewPrompt(transcriptSummary: string): ReviewPrompt { + return { + type: "skill", + systemPrompt: `You are a skill review specialist. Your task is to review the recent conversation transcript and identify reusable procedures that should be saved as skills. + +## Guidelines +- Create skills for procedures that are repeated or likely to be repeated +- Update existing skills if the transcript reveals improvements +- Prefer class-level skills over one-off task narratives +- Avoid creating skills for transient environment failures +- Each skill should have a clear, single responsibility +- Support files (scripts, templates) should be separate from the main skill definition + +## Priority Order +1. Update an existing loaded skill if the transcript reveals improvements +2. Create an umbrella skill that groups related procedures +3. Add a support file (script, template) to an existing skill +4. Create a new standalone skill + +## Output Format +For each skill action, output: +ACTION: +SKILL_NAME: +DESCRIPTION: +CONTENT: +REASON: `, + userPrompt: `Review the following conversation transcript and identify reusable procedures to save as skills. + +${transcriptSummary} + +Output your recommendations following the specified format.`, + } + } + + createCombinedReviewPrompt(transcriptSummary: string): ReviewPrompt { + return { + type: "combined", + systemPrompt: `You are a self-improvement review specialist. Your task is to review the recent conversation transcript and identify both durable facts (memory) and reusable procedures (skills). + +## Memory Guidelines +- Memory is for facts: user preferences, project conventions, environment details +- Save facts that are durable and useful across sessions +- Each fact should be concise and actionable + +## Skill Guidelines +- Skills are for procedures: repeatable workflows, command sequences, code patterns +- Create skills for procedures likely to be repeated +- Update existing skills with improvements from the transcript +- Prefer class-level skills over one-off narratives + +## Output Format +For memory facts: +MEMORY_FACT: +MEMORY_CATEGORY: + +For skill actions: +SKILL_ACTION: +SKILL_NAME: +SKILL_CONTENT: `, + userPrompt: `Review the following conversation transcript and identify both durable facts (memory) and reusable procedures (skills). + +${transcriptSummary} + +Output your recommendations following the specified format.`, + } + } +} diff --git a/src/services/self-improving/SelfImprovingManager.ts b/src/services/self-improving/SelfImprovingManager.ts new file mode 100644 index 0000000000..6760114d15 --- /dev/null +++ b/src/services/self-improving/SelfImprovingManager.ts @@ -0,0 +1,550 @@ +import type { + ImprovementAction, + LearnedPattern, + LearningEvent, + Logger, + PromptContext, + SelfImprovingManagerOptions, + TaskEventInfo, +} from "./types" +import { LearningStore } from "./LearningStore" +import { FeedbackCollector } from "./FeedbackCollector" +import { PatternAnalyzer } from "./PatternAnalyzer" +import { ImprovementApplier } from "./ImprovementApplier" +import { CodeIndexAdapter } from "./CodeIndexAdapter" +import { MemoryStore } from "./MemoryStore" +import { SkillUsageStore } from "./SkillUsageStore" +import { ActionExecutor } from "./ActionExecutor" +import { CuratorService } from "./CuratorService" +import type { CuratorReport } from "./CuratorService" +import { ReviewPromptFactory } from "./ReviewPromptFactory" +import { TranscriptRecall } from "./TranscriptRecall" + +const SELF_IMPROVING_EXPERIMENT_ID = "selfImproving" +const REVIEW_CHECK_INTERVAL_MS = 60_000 + +type Runtime = { + store: LearningStore + feedbackCollector: FeedbackCollector + patternAnalyzer: PatternAnalyzer + improvementApplier: ImprovementApplier + codeIndexAdapter: CodeIndexAdapter +} + +export class SelfImprovingManager { + private readonly globalStoragePath: string + private readonly logger: Logger + private readonly getExperiments: () => Record | undefined + private readonly getCodeIndexInfo: SelfImprovingManagerOptions["getCodeIndexInfo"] + public readonly memoryStore: MemoryStore + public readonly skillUsageStore: SkillUsageStore + public readonly curatorService: CuratorService + public readonly reviewPromptFactory: ReviewPromptFactory + public readonly transcriptRecall: TranscriptRecall + private readonly actionExecutor: ActionExecutor + + private runtime: Runtime | undefined + private started = false + private reviewTimer: ReturnType | null = null + private curatorTimer: ReturnType | null = null + private promptRevision = 0 + private lastUserActivityAt = 0 + private reviewInFlight = false + private curatorInFlight = false + + constructor(options: SelfImprovingManagerOptions) { + this.globalStoragePath = options.globalStoragePath + this.logger = options.logger + this.getExperiments = options.getExperiments + this.getCodeIndexInfo = options.getCodeIndexInfo + this.memoryStore = new MemoryStore(options.globalStoragePath, options.logger) + this.skillUsageStore = new SkillUsageStore(options.globalStoragePath, options.logger) + this.actionExecutor = new ActionExecutor(this.memoryStore, this.skillUsageStore, options.logger) + this.curatorService = new CuratorService( + options.globalStoragePath, + this.skillUsageStore, + options.logger, + options.curatorConfig, + ) + this.reviewPromptFactory = new ReviewPromptFactory() + this.transcriptRecall = new TranscriptRecall(options.globalStoragePath, options.logger) + } + + static isExperimentEnabled(experiments: Record | undefined): boolean { + if (!experiments) { + return false + } + + return experiments[SELF_IMPROVING_EXPERIMENT_ID] === true + } + + async initialize(): Promise { + if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return + } + + if (this.started) { + return + } + + try { + const runtime = this.getOrCreateRuntime() + await runtime.store.initialize() + await this.memoryStore.initialize() + await this.skillUsageStore.initialize() + await this.transcriptRecall.initialize() + await this.curatorService.initialize() + this.started = true + this.startTimers(runtime.store) + this.logger.appendLine( + "[SelfImprovingManager] Initialized: " + + `${runtime.store.getPatterns().length} patterns, ` + + `${runtime.store.getRecentEvents().length} events`, + ) + } catch (error) { + this.stopTimers() + this.started = false + this.runtime = undefined + this.logError("Initialization error", error) + } + } + + async handleExperimentChange(enabled?: boolean): Promise { + try { + const experimentEnabled = SelfImprovingManager.isExperimentEnabled(this.getExperiments()) + const shouldEnable = enabled ?? experimentEnabled + if (!shouldEnable || !experimentEnabled) { + await this.dispose() + return + } + + await this.initialize() + } catch (error) { + this.logError("Experiment change handling error", error) + } + } + + /** + * Handle settings change — called when experiments are updated. + * This enables/disables the module at runtime. + */ + async onSettingsChanged(_experiments: Record | undefined): Promise { + await this.handleExperimentChange() + } + + async dispose(): Promise { + const enabled = SelfImprovingManager.isExperimentEnabled(this.getExperiments()) + if (!enabled && !this.started) { + return + } + + this.stopTimers() + + try { + if (this.started) { + await this.runtime?.store.persist() + this.memoryStore.takeSnapshot() + } + } catch (error) { + this.logError("Persist on dispose error", error) + } finally { + this.started = false + this.promptRevision = 0 + this.runtime = undefined + } + } + + async recordTaskCompletion(info: TaskEventInfo): Promise { + if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return + } + + if (!this.started || !this.runtime) { + return + } + + try { + const event = this.runtime.feedbackCollector.createTaskEvent(info) + this.runtime.store.addEvent(event) + this.lastUserActivityAt = event.timestamp + await this.transcriptRecall.record({ + id: event.id, + timestamp: event.timestamp, + taskId: info.taskId, + mode: info.mode, + summary: info.success + ? `Task completed: ${info.mode || "unknown"}` + : `Task failed: ${info.errorKey || "unknown"}`, + signal: info.success ? "TASK_SUCCESS" : "TASK_FAILURE", + workspacePath: info.workspacePath, + toolNames: info.toolNames, + errorKey: info.errorKey, + success: info.success, + }) + this.runtime.store.incrementToolIterations( + Math.max(1, info.toolIterationCount ?? info.toolNames?.length ?? 1), + ) + await this.checkReviewTriggers(this.runtime.store) + } catch (error) { + this.logError("recordTaskCompletion error", error) + } + } + + async recordUserCorrection(info: TaskEventInfo): Promise { + if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return + } + + if (!this.started || !this.runtime) { + return + } + + try { + const event = this.runtime.feedbackCollector.createCorrectionEvent(info) + this.runtime.store.addEvent(event) + this.lastUserActivityAt = event.timestamp + await this.checkReviewTriggers(this.runtime.store) + } catch (error) { + this.logError("recordUserCorrection error", error) + } + } + + async recordUserTurn(): Promise { + if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return + } + + if (!this.started || !this.runtime) { + return + } + + try { + this.lastUserActivityAt = Date.now() + this.runtime.store.incrementUserTurns() + await this.checkReviewTriggers(this.runtime.store) + } catch (error) { + this.logError("recordUserTurn error", error) + } + } + + async recordCodeIndexEvent(taskId?: string): Promise { + if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return + } + + if (!this.started || !this.runtime) { + return + } + + try { + if (!this.runtime.store.getConfig().codeIndexCorrelationEnabled) { + return + } + + const codeIndexInfo = this.runtime.codeIndexAdapter.getInfo() + const event = this.runtime.feedbackCollector.createCodeIndexEvent(codeIndexInfo, taskId) + this.runtime.store.addEvent(event) + this.lastUserActivityAt = event.timestamp + } catch (error) { + this.logError("recordCodeIndexEvent error", error) + } + } + + async runReviewCycle(): Promise { + if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return + } + + if (!this.started || !this.runtime) { + return + } + + if (this.reviewInFlight) { + this.logger.appendLine("[SelfImprovingManager] Review cycle already in progress, skipping") + return + } + this.reviewInFlight = true + + try { + const events = [...this.runtime.store.getRecentEvents()] as LearningEvent[] + if (events.length === 0) { + return + } + + const existingPatterns = [...this.runtime.store.getPatterns()] as LearnedPattern[] + const newPatterns = this.runtime.patternAnalyzer.analyze(events, existingPatterns) + for (const pattern of newPatterns) { + this.runtime.store.addPattern(pattern) + } + + const actions = this.runtime.improvementApplier.generateActions([ + ...this.runtime.store.getPatterns(), + ] as LearnedPattern[]) + for (const action of actions) { + this.runtime.store.addAction(action) + } + + const pendingActions = [...this.runtime.store.getPendingActions()] as ImprovementAction[] + if (pendingActions.length > 0) { + const succeeded = await this.actionExecutor.executeBatch(pendingActions) + for (const actionId of succeeded) { + this.runtime.store.removeAction(actionId) + } + + this.logger.appendLine( + `[SelfImprovingManager] Executed ${succeeded.size}/${pendingActions.length} actions`, + ) + } + + this.updateReviewTelemetry(this.runtime.store, actions) + this.promptRevision += 1 + this.runtime.store.resetCounters() + await this.runtime.store.persist() + this.logger.appendLine( + `[SelfImprovingManager] Review cycle: ${newPatterns.length} patterns, ${actions.length} actions`, + ) + } catch (error) { + this.logError("Review cycle error", error) + } finally { + this.reviewInFlight = false + } + } + + async runCuratorCycle(): Promise { + if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return undefined + } + + if (!this.started || !this.runtime) { + return undefined + } + + if (this.curatorInFlight) { + return undefined + } + this.curatorInFlight = true + + try { + const now = Date.now() + const report = await this.curatorService.run( + now, + this.lastUserActivityAt > 0 ? this.lastUserActivityAt : undefined, + ) + this.runtime.store.updateTelemetry({ lastCuratorRunAt: report.timestamp }) + + if (report.transitions.length > 0) { + this.logger.appendLine(`[SelfImprovingManager] Curator cycle: ${report.transitions.length} transitions`) + } + + return report + } catch (error) { + this.logger.appendLine( + `[SelfImprovingManager] Curator cycle error: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } finally { + this.curatorInFlight = false + } + } + + getPromptContext(): PromptContext | undefined { + if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return undefined + } + + if (!this.started || !this.runtime) { + return undefined + } + + try { + return this.runtime.improvementApplier.getPromptContext( + [...this.runtime.store.getPatterns()] as LearnedPattern[], + this.runtime.store.getConfig().maxPromptPatterns, + this.promptRevision, + ) + } catch (error) { + this.logError("getPromptContext error", error) + return undefined + } + } + + getPromptContextString(): string { + if (!this.started) { + return "" + } + + try { + return this.memoryStore.getSnapshotString() + } catch { + return "" + } + } + + getStatus(): { + enabled: boolean + started: boolean + patternCount: number + eventCount: number + actionCount: number + memoryEntries: number + skillRecords: number + curatorStatus: ReturnType + lastReviewAt?: number + lastCuratorRunAt?: number + } { + const enabled = SelfImprovingManager.isExperimentEnabled(this.getExperiments()) + const curatorStatus = this.curatorService.getStatus() + if (!enabled) { + return { + enabled: false, + started: false, + patternCount: 0, + eventCount: 0, + actionCount: 0, + memoryEntries: 0, + skillRecords: 0, + curatorStatus, + } + } + + if (!this.started || !this.runtime) { + return { + enabled: true, + started: false, + patternCount: 0, + eventCount: 0, + actionCount: 0, + memoryEntries: 0, + skillRecords: 0, + curatorStatus, + } + } + + try { + const telemetry = this.runtime.store.getTelemetry() + const memoryStats = this.memoryStore.getStats() + const skillStats = this.skillUsageStore.getStats() + return { + enabled: true, + started: true, + patternCount: this.runtime.store.getPatterns().length, + eventCount: this.runtime.store.getRecentEvents().length, + actionCount: this.runtime.store.getPendingActions().length, + memoryEntries: memoryStats.environment + memoryStats.userProfile, + skillRecords: skillStats.total, + curatorStatus, + lastReviewAt: telemetry.lastReviewAt, + lastCuratorRunAt: telemetry.lastCuratorRunAt, + } + } catch { + return { + enabled: true, + started: true, + patternCount: 0, + eventCount: 0, + actionCount: 0, + memoryEntries: 0, + skillRecords: 0, + curatorStatus, + } + } + } + + async reset(): Promise { + if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return + } + + if (!this.started || !this.runtime) { + return + } + + try { + await this.runtime.store.reset() + this.promptRevision = 0 + this.logger.appendLine("[SelfImprovingManager] Learning state reset") + } catch (error) { + this.logError("Reset error", error) + } + } + + private getOrCreateRuntime(): Runtime { + if (!this.runtime) { + this.runtime = { + store: new LearningStore(this.globalStoragePath, this.logger), + feedbackCollector: new FeedbackCollector(), + patternAnalyzer: new PatternAnalyzer(), + improvementApplier: new ImprovementApplier(), + codeIndexAdapter: new CodeIndexAdapter(this.logger, this.getCodeIndexInfo), + } + } + + return this.runtime + } + + private startTimers(store: LearningStore): void { + this.stopTimers() + const config = store.getConfig() + this.reviewTimer = setInterval(() => { + void this.runReviewCycle() + }, REVIEW_CHECK_INTERVAL_MS) + + if (config.curatorEnabled) { + this.curatorTimer = setInterval(() => { + this.runCuratorCycle().catch((error) => { + this.logger.appendLine( + `[SelfImprovingManager] Curator cycle error: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + }, config.curatorIntervalMs) + } + } + + private stopTimers(): void { + if (this.reviewTimer) { + clearInterval(this.reviewTimer) + this.reviewTimer = null + } + + if (this.curatorTimer) { + clearInterval(this.curatorTimer) + this.curatorTimer = null + } + } + + private async checkReviewTriggers(store: LearningStore): Promise { + const counters = store.getCounters() + const config = store.getConfig() + if ( + counters.userTurnsSinceReview >= config.reviewOnTurnCount || + counters.toolIterationsSinceReview >= config.reviewOnToolIterationCount + ) { + await this.runReviewCycle() + } + } + + private updateReviewTelemetry(store: LearningStore, actions: ImprovementAction[]): void { + const telemetry = store.getTelemetry() + store.updateTelemetry({ + lastReviewAt: Date.now(), + promptEnrichmentUses: + telemetry.promptEnrichmentUses + + actions.filter((action) => action.actionType === "PROMPT_ENRICHMENT").length, + toolPreferenceUses: + telemetry.toolPreferenceUses + + actions.filter((action) => action.actionType === "TOOL_PREFERENCE").length, + errorAvoidanceUses: + telemetry.errorAvoidanceUses + + actions.filter((action) => action.actionType === "ERROR_AVOIDANCE").length, + skillSuggestionCount: + telemetry.skillSuggestionCount + + actions.filter((action) => action.actionType === "SKILL_SUGGESTION").length, + }) + } + + private logError(context: string, error: unknown): void { + this.logger.appendLine( + `[SelfImprovingManager] ${context}: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} diff --git a/src/services/self-improving/SkillUsageStore.ts b/src/services/self-improving/SkillUsageStore.ts new file mode 100644 index 0000000000..7554a2a2da --- /dev/null +++ b/src/services/self-improving/SkillUsageStore.ts @@ -0,0 +1,426 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import { safeWriteJson } from "../../utils/safeWriteJson" +import type { Logger } from "./types" + +/** + * Skill provenance - who created the skill + */ +export type SkillProvenance = "agent" | "user" | "bundled" | "hub" | "unknown" + +/** + * Skill lifecycle state + */ +export type SkillLifecycleState = "active" | "stale" | "archived" + +/** + * Skill telemetry record + */ +export interface SkillTelemetryRecord { + /** Unique skill identifier */ + skillId: string + /** Skill name */ + skillName: string + /** Who created this skill */ + createdBy: SkillProvenance + /** Current lifecycle state */ + state: SkillLifecycleState + /** Whether the skill is pinned (protected from auto-mutation) */ + pinned: boolean + /** Number of times the skill has been loaded/viewed */ + viewCount: number + /** Number of times the skill has been used in a task */ + useCount: number + /** Number of times the skill has been patched/updated */ + patchCount: number + /** Timestamp of first creation */ + createdAt: number + /** Timestamp of last activity */ + lastActivityAt: number + /** Timestamp of archival (if archived) */ + archivedAt?: number + /** Tags for categorization */ + tags?: string[] +} + +const SKILL_PROVENANCE_VALUES: ReadonlySet = new Set(["agent", "user", "bundled", "hub", "unknown"]) +const SKILL_LIFECYCLE_VALUES: ReadonlySet = new Set(["active", "stale", "archived"]) + +/** + * SkillUsageStore - telemetry sidecar for skill usage tracking. + * + * Mirrors Hermes' skill_usage.py pattern with: + * - Use/view/patch counters + * - Provenance tracking (agent-created vs user-authored vs bundled) + * - Pinning support (protected from autonomous mutation) + * - Lifecycle state management + * - Atomic file persistence + */ +export class SkillUsageStore { + private readonly filePath: string + private readonly logger: Logger + private records: Map = new Map() + private initialized = false + + constructor(baseDir: string, logger: Logger) { + this.filePath = path.join(baseDir, "self-improving", "skill-usage.json") + this.logger = logger + } + + /** + * Initialize the store - load persisted telemetry from disk. + */ + async initialize(): Promise { + if (this.initialized) { + return + } + + try { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }) + await this.loadFromDisk() + this.logger.appendLine(`[SkillUsageStore] Initialized: ${this.records.size} skill records`) + } catch (error) { + this.logger.appendLine( + `[SkillUsageStore] Initialization error: ${error instanceof Error ? error.message : String(error)}`, + ) + } finally { + this.initialized = true + } + } + + /** + * Load telemetry from disk. + */ + private async loadFromDisk(): Promise { + try { + const raw = await fs.readFile(this.filePath, "utf-8") + const parsed = JSON.parse(raw) + + if (!Array.isArray(parsed)) { + return + } + + for (const candidate of parsed) { + const record = this.sanitizeRecord(candidate) + if (record) { + this.records.set(record.skillId, record) + } + } + } catch (error: unknown) { + const errorCode = typeof error === "object" && error !== null && "code" in error ? error.code : undefined + if (errorCode !== "ENOENT") { + this.logger.appendLine( + `[SkillUsageStore] Load error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + + /** + * Persist telemetry to disk atomically. + */ + private async persist(): Promise { + try { + await safeWriteJson(this.filePath, Array.from(this.records.values()), { prettyPrint: true }) + } catch (error) { + this.logger.appendLine( + `[SkillUsageStore] Persist error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + private queuePersist(): void { + void this.persist() + } + + private getRecord(skillId: string): SkillTelemetryRecord | undefined { + return this.records.get(skillId) + } + + private cloneRecord(record: SkillTelemetryRecord): SkillTelemetryRecord { + return { + ...record, + tags: record.tags ? [...record.tags] : undefined, + } + } + + private sanitizeRecord(value: unknown): SkillTelemetryRecord | null { + if (!value || typeof value !== "object") { + return null + } + + const candidate = value as Partial + if (typeof candidate.skillId !== "string" || candidate.skillId.trim().length === 0) { + return null + } + + const now = Date.now() + const createdAt = typeof candidate.createdAt === "number" ? candidate.createdAt : now + const lastActivityAt = typeof candidate.lastActivityAt === "number" ? candidate.lastActivityAt : createdAt + + return { + skillId: candidate.skillId, + skillName: + typeof candidate.skillName === "string" && candidate.skillName.trim().length > 0 + ? candidate.skillName + : candidate.skillId, + createdBy: this.normalizeProvenance(candidate.createdBy), + state: this.normalizeState(candidate.state), + pinned: candidate.pinned === true, + viewCount: this.normalizeCounter(candidate.viewCount), + useCount: this.normalizeCounter(candidate.useCount), + patchCount: this.normalizeCounter(candidate.patchCount), + createdAt, + lastActivityAt, + archivedAt: typeof candidate.archivedAt === "number" ? candidate.archivedAt : undefined, + tags: this.normalizeTags(candidate.tags), + } + } + + private normalizeCounter(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0 + } + + private normalizeProvenance(value: SkillProvenance | undefined): SkillProvenance { + return value && SKILL_PROVENANCE_VALUES.has(value) ? value : "unknown" + } + + private normalizeState(value: SkillLifecycleState | undefined): SkillLifecycleState { + return value && SKILL_LIFECYCLE_VALUES.has(value) ? value : "active" + } + + private normalizeTags(tags: string[] | undefined): string[] | undefined { + if (!Array.isArray(tags)) { + return undefined + } + + const normalized = Array.from(new Set(tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))) + + return normalized.length > 0 ? normalized : undefined + } + + // ──── Record management ──── + + /** + * Get or create a telemetry record for a skill. + */ + getOrCreate(skillId: string, skillName: string, createdBy: SkillProvenance = "unknown"): SkillTelemetryRecord { + const existing = this.getRecord(skillId) + if (existing) { + return this.cloneRecord(existing) + } + + const now = Date.now() + const record: SkillTelemetryRecord = { + skillId, + skillName, + createdBy, + state: "active", + pinned: false, + viewCount: 0, + useCount: 0, + patchCount: 0, + createdAt: now, + lastActivityAt: now, + } + + this.records.set(skillId, record) + this.queuePersist() + + return this.cloneRecord(record) + } + + /** + * Get a telemetry record by skill ID. + */ + get(skillId: string): SkillTelemetryRecord | undefined { + const record = this.getRecord(skillId) + return record ? this.cloneRecord(record) : undefined + } + + /** + * Get all telemetry records. + */ + getAll(): SkillTelemetryRecord[] { + return Array.from(this.records.values(), (record) => this.cloneRecord(record)) + } + + /** + * Get records filtered by provenance. + */ + getByProvenance(provenance: SkillProvenance): SkillTelemetryRecord[] { + return this.getAll().filter((record) => record.createdBy === provenance) + } + + /** + * Get records filtered by lifecycle state. + */ + getByState(state: SkillLifecycleState): SkillTelemetryRecord[] { + return this.getAll().filter((record) => record.state === state) + } + + // ──── Telemetry bumps ──── + + /** + * Record that a skill was viewed/loaded. + */ + async bumpView(skillId: string): Promise { + const record = this.getRecord(skillId) + if (!record) { + return + } + + record.viewCount += 1 + record.lastActivityAt = Date.now() + await this.persist() + } + + /** + * Record that a skill was used in a task. + */ + async bumpUse(skillId: string): Promise { + const record = this.getRecord(skillId) + if (!record) { + return + } + + record.useCount += 1 + record.lastActivityAt = Date.now() + await this.persist() + } + + /** + * Record that a skill was patched/updated. + */ + async bumpPatch(skillId: string): Promise { + const record = this.getRecord(skillId) + if (!record) { + return + } + + record.patchCount += 1 + record.lastActivityAt = Date.now() + await this.persist() + } + + // ──── Lifecycle management ──── + + /** + * Pin a skill (protect from autonomous mutation). + */ + async pin(skillId: string): Promise { + const record = this.getRecord(skillId) + if (!record) { + return + } + + record.pinned = true + record.lastActivityAt = Date.now() + await this.persist() + } + + /** + * Unpin a skill. + */ + async unpin(skillId: string): Promise { + const record = this.getRecord(skillId) + if (!record) { + return + } + + record.pinned = false + record.lastActivityAt = Date.now() + await this.persist() + } + + /** + * Check if a skill is pinned. + */ + isPinned(skillId: string): boolean { + return this.getRecord(skillId)?.pinned ?? false + } + + /** + * Transition a skill to a new lifecycle state. + */ + async transitionState(skillId: string, newState: SkillLifecycleState): Promise { + const record = this.getRecord(skillId) + if (!record || record.pinned) { + return + } + + record.state = newState + record.lastActivityAt = Date.now() + + if (newState === "archived") { + record.archivedAt = Date.now() + } else { + delete record.archivedAt + } + + await this.persist() + } + + /** + * Get skills eligible for stale transition. + * Skills with no activity for staleAfterDays. + */ + getStaleCandidates(staleAfterDays: number): SkillTelemetryRecord[] { + const threshold = Date.now() - staleAfterDays * 24 * 60 * 60 * 1000 + return this.getAll().filter( + (record) => record.state === "active" && !record.pinned && record.lastActivityAt < threshold, + ) + } + + /** + * Get skills eligible for archive transition. + */ + getArchiveCandidates(archiveAfterDays: number): SkillTelemetryRecord[] { + const threshold = Date.now() - archiveAfterDays * 24 * 60 * 60 * 1000 + return this.getAll().filter( + (record) => record.state === "stale" && !record.pinned && record.lastActivityAt < threshold, + ) + } + + /** + * Remove a skill record entirely. + */ + async remove(skillId: string): Promise { + if (!this.records.delete(skillId)) { + return + } + + await this.persist() + } + + /** + * Get aggregate statistics. + */ + getStats(): { + total: number + active: number + stale: number + archived: number + pinned: number + agentCreated: number + } { + const records = Array.from(this.records.values()) + return { + total: records.length, + active: records.filter((record) => record.state === "active").length, + stale: records.filter((record) => record.state === "stale").length, + archived: records.filter((record) => record.state === "archived").length, + pinned: records.filter((record) => record.pinned).length, + agentCreated: records.filter((record) => record.createdBy === "agent").length, + } + } + + /** + * Reset all telemetry. + */ + async reset(): Promise { + this.records.clear() + await this.persist() + } +} diff --git a/src/services/self-improving/TranscriptRecall.ts b/src/services/self-improving/TranscriptRecall.ts new file mode 100644 index 0000000000..3f219a9bdd --- /dev/null +++ b/src/services/self-improving/TranscriptRecall.ts @@ -0,0 +1,141 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import { safeWriteJson } from "../../utils/safeWriteJson" +import type { Logger } from "./types" + +/** + * Transcript entry — a single recorded event or task outcome + */ +export interface TranscriptEntry { + id: string + timestamp: number + taskId?: string + mode?: string + summary: string + signal: string + workspacePath?: string + toolNames?: string[] + errorKey?: string + success?: boolean +} + +/** + * TranscriptRecall — searchable transcript evidence store. + */ +export class TranscriptRecall { + private readonly filePath: string + private readonly logger: Logger + private entries: TranscriptEntry[] = [] + private initialized = false + + private static readonly MAX_ENTRIES = 1000 + + constructor(baseDir: string, logger: Logger) { + this.filePath = path.join(baseDir, "self-improving", "transcript-recall.json") + this.logger = logger + } + + async initialize(): Promise { + if (this.initialized) { + return + } + + try { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }) + await this.loadFromDisk() + } catch (error) { + this.logger.appendLine( + `[TranscriptRecall] Initialization error: ${error instanceof Error ? error.message : String(error)}`, + ) + } finally { + this.initialized = true + } + } + + async record(entry: TranscriptEntry): Promise { + this.entries.push({ + ...entry, + toolNames: entry.toolNames ? [...entry.toolNames] : undefined, + }) + + if (this.entries.length > TranscriptRecall.MAX_ENTRIES) { + this.entries = this.entries.slice(-TranscriptRecall.MAX_ENTRIES) + } + + await this.persist() + } + + search(query: string): TranscriptEntry[] { + const normalizedQuery = query.toLowerCase() + return this.entries + .filter((entry) => { + if (entry.summary.toLowerCase().includes(normalizedQuery)) { + return true + } + + if (entry.signal.toLowerCase().includes(normalizedQuery)) { + return true + } + + if (entry.errorKey?.toLowerCase().includes(normalizedQuery)) { + return true + } + + if (entry.mode?.toLowerCase().includes(normalizedQuery)) { + return true + } + + return entry.toolNames?.some((toolName) => toolName.toLowerCase().includes(normalizedQuery)) ?? false + }) + .slice(-20) + } + + searchBySignal(signal: string): TranscriptEntry[] { + return this.entries.filter((entry) => entry.signal === signal).slice(-50) + } + + searchByErrorKey(errorKey: string): TranscriptEntry[] { + return this.entries.filter((entry) => entry.errorKey === errorKey).slice(-20) + } + + getRecent(count = 10): TranscriptEntry[] { + return this.entries.slice(-count) + } + + get size(): number { + return this.entries.length + } + + async clear(): Promise { + this.entries = [] + await this.persist() + } + + private async loadFromDisk(): Promise { + try { + const raw = await fs.readFile(this.filePath, "utf-8") + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) { + this.entries = parsed.slice(-TranscriptRecall.MAX_ENTRIES) + } + } catch (error: unknown) { + const errorCode = typeof error === "object" && error !== null && "code" in error ? error.code : undefined + if (errorCode !== "ENOENT") { + this.logger.appendLine( + `[TranscriptRecall] Load error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + + private async persist(): Promise { + try { + await safeWriteJson(this.filePath, this.entries, { prettyPrint: true }) + } catch (error) { + this.logger.appendLine( + `[TranscriptRecall] Persist error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } +} diff --git a/src/services/self-improving/__tests__/ActionExecutor.spec.ts b/src/services/self-improving/__tests__/ActionExecutor.spec.ts new file mode 100644 index 0000000000..b34110c1ee --- /dev/null +++ b/src/services/self-improving/__tests__/ActionExecutor.spec.ts @@ -0,0 +1,107 @@ +import { ActionExecutor } from "../ActionExecutor" +import type { ImprovementAction } from "../types" + +describe("ActionExecutor", () => { + const logger = { appendLine: vi.fn() } + + beforeEach(() => { + logger.appendLine.mockReset() + }) + + it("writes prompt, error, and tool guidance into memory", async () => { + const memoryStore = { + addEnvironmentEntry: vi.fn().mockResolvedValue({ id: "mem-1" }), + } as any + const skillUsageStore = { getOrCreate: vi.fn() } as any + const executor = new ActionExecutor(memoryStore, skillUsageStore, logger) + + const actions: ImprovementAction[] = [ + { + id: "action-1", + actionType: "PROMPT_ENRICHMENT", + target: "system-prompt", + payload: { summary: "Prefer semantic search before regex search" }, + timestamp: 1, + }, + { + id: "action-2", + actionType: "ERROR_AVOIDANCE", + target: "task-execution", + payload: { summary: "Handle ENOENT before retry", errorKeys: ["ENOENT"] }, + timestamp: 2, + }, + { + id: "action-3", + actionType: "TOOL_PREFERENCE", + target: "task-execution", + payload: { summary: "Use codebase_search before search_files", toolNames: ["codebase_search"] }, + timestamp: 3, + }, + ] + + const succeeded = await executor.executeBatch(actions) + + expect(succeeded).toEqual(new Set(["action-1", "action-2", "action-3"])) + expect(memoryStore.addEnvironmentEntry).toHaveBeenNthCalledWith( + 1, + "Prefer semantic search before regex search", + { + source: "learning", + tags: ["learned", "prompt"], + }, + ) + expect(memoryStore.addEnvironmentEntry).toHaveBeenNthCalledWith(2, "Handle ENOENT before retry", { + source: "learning", + tags: ["error-avoidance", "error:ENOENT"], + }) + expect(memoryStore.addEnvironmentEntry).toHaveBeenNthCalledWith(3, "Use codebase_search before search_files", { + source: "learning", + tags: ["tool-preference", "tool:codebase_search"], + }) + }) + + it("records skill suggestions in the telemetry sidecar", async () => { + const memoryStore = { addEnvironmentEntry: vi.fn() } as any + const skillUsageStore = { getOrCreate: vi.fn() } as any + const executor = new ActionExecutor(memoryStore, skillUsageStore, logger) + + const action: ImprovementAction = { + id: "action-skill", + actionType: "SKILL_SUGGESTION", + target: "skills-manager", + payload: { + summary: "Create a self-improving review skill", + skillName: "Self Improving Review Skill", + }, + timestamp: 1, + } + + await expect(executor.execute(action)).resolves.toBe(true) + expect(skillUsageStore.getOrCreate).toHaveBeenCalledWith( + expect.stringMatching(/^suggested:/), + "Self Improving Review Skill", + "agent", + ) + expect(logger.appendLine).toHaveBeenCalledWith( + "[ActionExecutor] Skill suggestion recorded: Create a self-improving review skill", + ) + }) + + it("keeps invalid actions pending by reporting failure", async () => { + const executor = new ActionExecutor( + { addEnvironmentEntry: vi.fn() } as any, + { getOrCreate: vi.fn() } as any, + logger, + ) + + await expect( + executor.execute({ + id: "invalid", + actionType: "PROMPT_ENRICHMENT", + target: "system-prompt", + payload: {}, + timestamp: 1, + }), + ).resolves.toBe(false) + }) +}) diff --git a/src/services/self-improving/__tests__/CuratorService.spec.ts b/src/services/self-improving/__tests__/CuratorService.spec.ts new file mode 100644 index 0000000000..a6a5556b5a --- /dev/null +++ b/src/services/self-improving/__tests__/CuratorService.spec.ts @@ -0,0 +1,139 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { CuratorService } from "../CuratorService" +import { SkillUsageStore } from "../SkillUsageStore" + +const DAY_MS = 24 * 60 * 60 * 1000 + +describe("CuratorService", () => { + let tempDir: string + let logger: { appendLine: ReturnType } + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-curator-")) + logger = { appendLine: vi.fn() } + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + async function seedSkillUsage(records: unknown[]): Promise { + const targetDir = path.join(tempDir, "self-improving") + await fs.mkdir(targetDir, { recursive: true }) + await fs.writeFile(path.join(targetDir, "skill-usage.json"), JSON.stringify(records, null, 2), "utf-8") + } + + it("defers the first run once before applying transitions", async () => { + const now = Date.now() + await seedSkillUsage([ + { + skillId: "agent-skill", + skillName: "Agent Skill", + createdBy: "agent", + state: "active", + pinned: false, + viewCount: 1, + useCount: 1, + patchCount: 0, + createdAt: now - 30 * DAY_MS, + lastActivityAt: now - 20 * DAY_MS, + }, + ]) + + const skillUsageStore = new SkillUsageStore(tempDir, logger) + await skillUsageStore.initialize() + + const service = new CuratorService(tempDir, skillUsageStore, logger, { + intervalMs: 0, + minIdleMs: 0, + firstRunDeferred: true, + backupsEnabled: false, + }) + await service.initialize() + + const deferred = await service.run(now) + expect(deferred.error).toBe("Skipped: first-run deferral") + expect(deferred.transitions).toHaveLength(0) + + const applied = await service.run(now + 1) + expect(applied.transitions).toHaveLength(1) + expect(applied.transitions[0]).toMatchObject({ + skillId: "agent-skill", + fromState: "active", + toState: "stale", + }) + expect(skillUsageStore.get("agent-skill")?.state).toBe("stale") + }) + + it("writes backups and reports while protecting pinned and non-agent skills", async () => { + const now = Date.now() + await seedSkillUsage([ + { + skillId: "agent-skill", + skillName: "Agent Skill", + createdBy: "agent", + state: "active", + pinned: false, + viewCount: 1, + useCount: 1, + patchCount: 0, + createdAt: now - 30 * DAY_MS, + lastActivityAt: now - 20 * DAY_MS, + }, + { + skillId: "user-skill", + skillName: "User Skill", + createdBy: "user", + state: "active", + pinned: false, + viewCount: 1, + useCount: 1, + patchCount: 0, + createdAt: now - 30 * DAY_MS, + lastActivityAt: now - 20 * DAY_MS, + }, + { + skillId: "pinned-skill", + skillName: "Pinned Skill", + createdBy: "agent", + state: "active", + pinned: true, + viewCount: 1, + useCount: 1, + patchCount: 0, + createdAt: now - 30 * DAY_MS, + lastActivityAt: now - 20 * DAY_MS, + }, + ]) + + const skillUsageStore = new SkillUsageStore(tempDir, logger) + await skillUsageStore.initialize() + + const service = new CuratorService(tempDir, skillUsageStore, logger, { + intervalMs: 0, + minIdleMs: 0, + firstRunDeferred: false, + backupsEnabled: true, + }) + await service.initialize() + + const report = await service.run(now) + expect(report.backupPath).toBeTruthy() + expect(report.transitions).toHaveLength(1) + expect(skillUsageStore.get("agent-skill")?.state).toBe("stale") + expect(skillUsageStore.get("user-skill")?.state).toBe("active") + expect(skillUsageStore.get("pinned-skill")?.state).toBe("active") + + const runDir = path.join(tempDir, "self-improving", "curator", "reports", report.runId) + const runJson = JSON.parse(await fs.readFile(path.join(runDir, "run.json"), "utf-8")) + const markdown = await fs.readFile(path.join(runDir, "REPORT.md"), "utf-8") + const latest = await service.getLatestReport() + + expect(runJson.runId).toBe(report.runId) + expect(markdown).toContain("# Curator Run Report") + expect(latest?.runId).toBe(report.runId) + }) +}) diff --git a/src/services/self-improving/__tests__/LearningStore.spec.ts b/src/services/self-improving/__tests__/LearningStore.spec.ts new file mode 100644 index 0000000000..6967de459d --- /dev/null +++ b/src/services/self-improving/__tests__/LearningStore.spec.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import * as fs from "fs/promises" +import * as path from "path" +import os from "os" +import crypto from "crypto" + +import { LearningStore } from "../LearningStore" + +describe("LearningStore", () => { + let testDir: string + const logger = { appendLine: () => {} } + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `learning-store-test-${crypto.randomUUID()}`) + await fs.mkdir(testDir, { recursive: true }) + }) + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }) + }) + + it("should initialize with empty state when no files exist", async () => { + const store = new LearningStore(testDir, logger) + + await store.initialize() + + expect(store.getPatterns()).toHaveLength(0) + expect(store.getRecentEvents()).toHaveLength(0) + }) + + it("should fall back to empty state on corrupted JSON", async () => { + const stateDir = path.join(testDir, "self-improving") + await fs.mkdir(stateDir, { recursive: true }) + await fs.writeFile(path.join(stateDir, "state.json"), "not valid json{{{", "utf-8") + + const store = new LearningStore(testDir, logger) + + await store.initialize() + + expect(store.getPatterns()).toHaveLength(0) + expect(store.getRecentEvents()).toHaveLength(0) + }) + + it("should load valid state correctly", async () => { + const store = new LearningStore(testDir, logger) + await store.initialize() + + store.addEvent({ + id: "test-event", + signal: "TASK_SUCCESS", + timestamp: Date.now(), + context: {}, + outcome: { success: true }, + }) + + await store.persist() + + const store2 = new LearningStore(testDir, logger) + await store2.initialize() + + expect(store2.getRecentEvents()).toHaveLength(1) + }) + + it("should enforce max patterns bound", async () => { + const store = new LearningStore(testDir, logger) + await store.initialize() + + for (let i = 0; i < 120; i++) { + store.addPattern({ + id: `pattern-${i}`, + patternType: "prompt", + state: "active", + summary: `Pattern ${i}`, + confidenceScore: i / 120, + frequency: 1, + successRate: 1, + firstSeenAt: i, + lastSeenAt: i, + sourceSignals: ["TASK_SUCCESS"], + context: {}, + }) + } + + await store.persist() + + expect(store.getPatterns().length).toBeLessThanOrEqual(100) + }) + + it("should enforce max events bound", async () => { + const store = new LearningStore(testDir, logger) + await store.initialize() + + for (let i = 0; i < 600; i++) { + store.addEvent({ + id: `event-${i}`, + signal: "TASK_SUCCESS", + timestamp: Date.now(), + context: {}, + outcome: { success: true }, + }) + } + + await store.persist() + + expect(store.getRecentEvents().length).toBeLessThanOrEqual(500) + }) +}) diff --git a/src/services/self-improving/__tests__/MemoryStore.spec.ts b/src/services/self-improving/__tests__/MemoryStore.spec.ts new file mode 100644 index 0000000000..7b854ed8fc --- /dev/null +++ b/src/services/self-improving/__tests__/MemoryStore.spec.ts @@ -0,0 +1,104 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { MemoryStore } from "../MemoryStore" + +describe("MemoryStore", () => { + let tempDir: string + const logger = { appendLine: vi.fn() } + + beforeEach(async () => { + logger.appendLine.mockReset() + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-store-")) + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + it("deduplicates persisted entries and keeps the active snapshot frozen", async () => { + const memoryDir = path.join(tempDir, "self-improving", "memory") + await fs.mkdir(memoryDir, { recursive: true }) + await fs.writeFile( + path.join(memoryDir, "environment.json"), + JSON.stringify([ + { + id: "env-1", + content: "Prefer semantic search first", + source: "learning", + createdAt: 1, + updatedAt: 1, + }, + { + id: "env-2", + content: "prefer semantic search first", + source: "learning", + createdAt: 2, + updatedAt: 2, + }, + { + id: "env-3", + content: "Check existing tests before edits", + source: "learning", + createdAt: 3, + updatedAt: 3, + }, + ]), + "utf8", + ) + await fs.writeFile( + path.join(memoryDir, "user-profile.json"), + JSON.stringify([ + { id: "user-1", content: "User prefers concise summaries", source: "user", createdAt: 4, updatedAt: 4 }, + ]), + "utf8", + ) + + const store = new MemoryStore(tempDir, logger) + await store.initialize() + + expect(store.getStats()).toEqual({ environment: 2, userProfile: 1, revision: 1 }) + expect(store.getSnapshotString()).toContain("Prefer semantic search first") + expect(store.getSnapshotString()).not.toContain("prefer semantic search first") + + await store.addEnvironmentEntry("Live write should not appear until next snapshot", { + tags: ["live"], + }) + + expect(store.getStats().environment).toBe(3) + expect(store.getSnapshotString()).not.toContain("Live write should not appear until next snapshot") + + store.takeSnapshot() + expect(store.getSnapshotString()).toContain("Live write should not appear until next snapshot") + }) + + it("supports duplicate rejection, substring replace/remove, and bounded persistence", async () => { + const store = new MemoryStore(tempDir, logger) + await store.initialize() + + await expect(store.addEnvironmentEntry("Alpha guidance")).resolves.toMatchObject({ content: "Alpha guidance" }) + await expect(store.addEnvironmentEntry("alpha guidance")).resolves.toBeNull() + + await store.addEnvironmentEntry("Beta guidance") + await store.replaceEnvironmentEntry("beta", "Gamma guidance", { tags: ["replacement"] }) + await expect(store.removeEnvironmentEntry("alpha")).resolves.toBe(true) + + for (let index = 0; index < 55; index += 1) { + await store.addEnvironmentEntry(`Fact ${index}`) + } + + const persisted = JSON.parse( + await fs.readFile(path.join(tempDir, "self-improving", "memory", "environment.json"), "utf8"), + ) as Array<{ content: string }> + + expect(store.getStats().environment).toBe(50) + expect(persisted).toHaveLength(50) + expect(persisted.some((entry) => entry.content === "Gamma guidance")).toBe(false) + expect(persisted.some((entry) => entry.content === "Alpha guidance")).toBe(false) + expect(persisted.some((entry) => entry.content === "Fact 4")).toBe(false) + expect(persisted.some((entry) => entry.content === "Fact 5")).toBe(true) + expect(persisted.some((entry) => entry.content === "Fact 54")).toBe(true) + expect(store.getSnapshotContext().entries.length).toBeLessThanOrEqual(10) + }) +}) diff --git a/src/services/self-improving/__tests__/ReviewPromptFactory.spec.ts b/src/services/self-improving/__tests__/ReviewPromptFactory.spec.ts new file mode 100644 index 0000000000..bf278c5ece --- /dev/null +++ b/src/services/self-improving/__tests__/ReviewPromptFactory.spec.ts @@ -0,0 +1,27 @@ +import { ReviewPromptFactory } from "../ReviewPromptFactory" + +describe("ReviewPromptFactory", () => { + it("creates rubric-driven memory and skill prompts", () => { + const factory = new ReviewPromptFactory() + + const memoryPrompt = factory.createMemoryReviewPrompt("Conversation summary") + const skillPrompt = factory.createSkillReviewPrompt("Conversation summary") + + expect(memoryPrompt.type).toBe("memory") + expect(memoryPrompt.systemPrompt).toContain("FACT:") + expect(memoryPrompt.userPrompt).toContain("Conversation summary") + expect(skillPrompt.type).toBe("skill") + expect(skillPrompt.systemPrompt).toContain("ACTION:") + expect(skillPrompt.systemPrompt).toContain("Priority Order") + }) + + it("creates a combined prompt with memory and skill output sections", () => { + const factory = new ReviewPromptFactory() + const prompt = factory.createCombinedReviewPrompt("Combined summary") + + expect(prompt.type).toBe("combined") + expect(prompt.systemPrompt).toContain("MEMORY_FACT:") + expect(prompt.systemPrompt).toContain("SKILL_ACTION:") + expect(prompt.userPrompt).toContain("Combined summary") + }) +}) diff --git a/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts new file mode 100644 index 0000000000..173d773544 --- /dev/null +++ b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts @@ -0,0 +1,441 @@ +const mockState = vi.hoisted(() => ({ + stores: [] as any[], + collectors: [] as any[], + analyzers: [] as any[], + appliers: [] as any[], + adapters: [] as any[], + memoryStores: [] as any[], + skillUsageStores: [] as any[], + actionExecutors: [] as any[], + curatorServices: [] as any[], + reviewPromptFactories: [] as any[], + transcriptRecalls: [] as any[], +})) + +const DEFAULT_CURATOR_STATUS = { + lastRunAt: 0, + firstRunDone: false, + config: { + intervalMs: 3_600_000, + minIdleMs: 300_000, + firstRunDeferred: true, + staleAfterDays: 14, + archiveAfterDays: 60, + backupsEnabled: true, + maxBackups: 5, + }, +} + +function createStoreMock() { + return { + initialize: vi.fn().mockResolvedValue(undefined), + persist: vi.fn().mockResolvedValue(undefined), + reset: vi.fn().mockResolvedValue(undefined), + getConfig: vi.fn().mockReturnValue({ + reviewOnTurnCount: 10, + reviewOnToolIterationCount: 2, + maxPromptPatterns: 5, + curatorEnabled: true, + curatorIntervalMs: 5_000, + staleAfterDays: 14, + archiveAfterDays: 60, + codeIndexCorrelationEnabled: true, + }), + getPatterns: vi.fn().mockReturnValue([]), + getRecentEvents: vi.fn().mockReturnValue([]), + getPendingActions: vi.fn().mockReturnValue([]), + getTelemetry: vi.fn().mockReturnValue({ + promptEnrichmentUses: 0, + toolPreferenceUses: 0, + errorAvoidanceUses: 0, + skillSuggestionCount: 0, + }), + getCounters: vi.fn().mockReturnValue({ userTurnsSinceReview: 0, toolIterationsSinceReview: 0 }), + addEvent: vi.fn(), + addPattern: vi.fn(), + addAction: vi.fn(), + removeAction: vi.fn(), + incrementToolIterations: vi.fn(), + incrementUserTurns: vi.fn(), + resetCounters: vi.fn(), + updateTelemetry: vi.fn(), + updatePattern: vi.fn(), + archivePattern: vi.fn(), + } +} + +function createMemoryStoreMock() { + return { + initialize: vi.fn().mockResolvedValue(undefined), + getSnapshotString: vi.fn().mockReturnValue(""), + getStats: vi.fn().mockReturnValue({ environment: 0, userProfile: 0, revision: 1 }), + takeSnapshot: vi.fn(), + } +} + +function createSkillUsageStoreMock() { + return { + initialize: vi.fn().mockResolvedValue(undefined), + getStats: vi.fn().mockReturnValue({ total: 0, active: 0, stale: 0, archived: 0, pinned: 0, agentCreated: 0 }), + } +} + +function createActionExecutorMock() { + return { + executeBatch: vi.fn().mockResolvedValue(new Set()), + } +} + +function createCuratorServiceMock() { + return { + initialize: vi.fn().mockResolvedValue(undefined), + run: vi.fn().mockResolvedValue({ + runId: "curator-run-1", + timestamp: 1, + durationMs: 1, + transitions: [], + stats: { + totalSkills: 0, + activeSkills: 0, + staleSkills: 0, + archivedSkills: 0, + pinnedSkills: 0, + transitionsApplied: 0, + }, + }), + getStatus: vi.fn().mockReturnValue({ ...DEFAULT_CURATOR_STATUS, config: { ...DEFAULT_CURATOR_STATUS.config } }), + } +} + +function createTranscriptRecallMock() { + return { + initialize: vi.fn().mockResolvedValue(undefined), + record: vi.fn().mockResolvedValue(undefined), + } +} + +vi.mock("../LearningStore", () => ({ + LearningStore: vi.fn().mockImplementation(() => { + const store = createStoreMock() + mockState.stores.push(store) + return store + }), +})) + +vi.mock("../FeedbackCollector", () => ({ + FeedbackCollector: vi.fn().mockImplementation(() => { + const collector = { + createTaskEvent: vi.fn().mockImplementation((info) => ({ + id: "evt-task", + signal: info.success ? "TASK_SUCCESS" : "TASK_FAILURE", + timestamp: 1, + taskId: info.taskId, + context: { toolNames: info.toolNames }, + outcome: { success: info.success }, + })), + createCorrectionEvent: vi.fn().mockReturnValue({ + id: "evt-correction", + signal: "USER_CORRECTION", + timestamp: 1, + context: {}, + outcome: { corrected: true }, + }), + createCodeIndexEvent: vi.fn().mockReturnValue({ + id: "evt-code-index", + signal: "CODE_INDEX_HIT", + timestamp: 1, + context: {}, + outcome: {}, + }), + } + mockState.collectors.push(collector) + return collector + }), +})) + +vi.mock("../PatternAnalyzer", () => ({ + PatternAnalyzer: vi.fn().mockImplementation(() => { + const analyzer = { analyze: vi.fn().mockReturnValue([]) } + mockState.analyzers.push(analyzer) + return analyzer + }), +})) + +vi.mock("../ImprovementApplier", () => ({ + ImprovementApplier: vi.fn().mockImplementation(() => { + const applier = { + generateActions: vi.fn().mockReturnValue([]), + getPromptContext: vi.fn().mockReturnValue({ entries: [], revision: 0 }), + } + mockState.appliers.push(applier) + return applier + }), +})) + +vi.mock("../CodeIndexAdapter", () => ({ + CodeIndexAdapter: vi.fn().mockImplementation(() => { + const adapter = { getInfo: vi.fn().mockReturnValue({ available: true, hits: 3, topScore: 0.9 }) } + mockState.adapters.push(adapter) + return adapter + }), +})) + +vi.mock("../MemoryStore", () => ({ + MemoryStore: vi.fn().mockImplementation(() => { + const store = createMemoryStoreMock() + mockState.memoryStores.push(store) + return store + }), +})) + +vi.mock("../SkillUsageStore", () => ({ + SkillUsageStore: vi.fn().mockImplementation(() => { + const store = createSkillUsageStoreMock() + mockState.skillUsageStores.push(store) + return store + }), +})) + +vi.mock("../ActionExecutor", () => ({ + ActionExecutor: vi.fn().mockImplementation(() => { + const executor = createActionExecutorMock() + mockState.actionExecutors.push(executor) + return executor + }), +})) + +vi.mock("../CuratorService", () => ({ + CuratorService: vi.fn().mockImplementation(() => { + const service = createCuratorServiceMock() + mockState.curatorServices.push(service) + return service + }), +})) + +vi.mock("../ReviewPromptFactory", () => ({ + ReviewPromptFactory: vi.fn().mockImplementation(() => { + const factory = {} + mockState.reviewPromptFactories.push(factory) + return factory + }), +})) + +vi.mock("../TranscriptRecall", () => ({ + TranscriptRecall: vi.fn().mockImplementation(() => { + const store = createTranscriptRecallMock() + mockState.transcriptRecalls.push(store) + return store + }), +})) + +import { SelfImprovingManager } from "../SelfImprovingManager" + +describe("SelfImprovingManager", () => { + let experiments: Record | undefined + let logger: { appendLine: ReturnType } + + const createManager = () => + new SelfImprovingManager({ + globalStoragePath: "/tmp/zoo-code-tests", + logger, + getExperiments: () => experiments, + getCodeIndexInfo: () => ({ available: true, hits: 2, topScore: 0.8 }), + }) + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mockState.stores.length = 0 + mockState.collectors.length = 0 + mockState.analyzers.length = 0 + mockState.appliers.length = 0 + mockState.adapters.length = 0 + mockState.memoryStores.length = 0 + mockState.skillUsageStores.length = 0 + mockState.actionExecutors.length = 0 + mockState.curatorServices.length = 0 + mockState.reviewPromptFactories.length = 0 + mockState.transcriptRecalls.length = 0 + experiments = undefined + logger = { appendLine: vi.fn() } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("has zero runtime overhead when disabled", async () => { + const manager = createManager() + + await manager.initialize() + await manager.recordTaskCompletion({ taskId: "task-1", success: true, toolNames: ["read_file"] }) + + expect(mockState.stores).toHaveLength(0) + expect(vi.getTimerCount()).toBe(0) + expect(manager.getStatus()).toEqual({ + enabled: false, + started: false, + patternCount: 0, + eventCount: 0, + actionCount: 0, + memoryEntries: 0, + skillRecords: 0, + curatorStatus: DEFAULT_CURATOR_STATUS, + }) + }) + + it("initializes store state and schedules timers when enabled", async () => { + experiments = { selfImproving: true } + const manager = createManager() + + await manager.initialize() + + expect(mockState.stores).toHaveLength(1) + expect(mockState.stores[0].initialize).toHaveBeenCalledTimes(1) + expect(mockState.memoryStores[0].initialize).toHaveBeenCalledTimes(1) + expect(mockState.skillUsageStores[0].initialize).toHaveBeenCalledTimes(1) + expect(mockState.transcriptRecalls[0].initialize).toHaveBeenCalledTimes(1) + expect(mockState.curatorServices[0].initialize).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(2) + expect(manager.getStatus()).toMatchObject({ enabled: true, started: true }) + }) + + it("runs a review cycle from task completion triggers", async () => { + experiments = { selfImproving: true } + const manager = createManager() + await manager.initialize() + + const store = mockState.stores[0] + const analyzer = mockState.analyzers[0] + const applier = mockState.appliers[0] + const executor = mockState.actionExecutors[0] + const transcriptRecall = mockState.transcriptRecalls[0] + const pattern = { + id: "pattern-1", + patternType: "prompt", + state: "active", + summary: "Prefer semantic search before regex search", + confidenceScore: 0.9, + frequency: 3, + successRate: 0.8, + firstSeenAt: 1, + lastSeenAt: 1, + sourceSignals: ["TASK_SUCCESS"], + context: {}, + } + const action = { + id: "action-1", + actionType: "PROMPT_ENRICHMENT", + target: "system-prompt", + payload: { summary: "Prefer semantic search before regex search" }, + timestamp: 1, + } + + store.getCounters.mockReturnValue({ userTurnsSinceReview: 0, toolIterationsSinceReview: 2 }) + store.getRecentEvents.mockReturnValue([ + { id: "evt-1", signal: "TASK_SUCCESS", timestamp: 1, context: {}, outcome: {} }, + ]) + store.getPatterns.mockReturnValueOnce([]).mockReturnValue([pattern]) + store.getPendingActions.mockReturnValue([action]) + analyzer.analyze.mockReturnValue([pattern]) + applier.generateActions.mockReturnValue([action]) + executor.executeBatch.mockResolvedValue(new Set(["action-1"])) + + await manager.recordTaskCompletion({ taskId: "task-1", success: true, toolNames: ["search_files"] }) + + expect(store.addEvent).toHaveBeenCalledTimes(1) + expect(transcriptRecall.record).toHaveBeenCalledWith({ + id: "evt-task", + timestamp: 1, + taskId: "task-1", + mode: undefined, + summary: "Task completed: unknown", + signal: "TASK_SUCCESS", + workspacePath: undefined, + toolNames: ["search_files"], + errorKey: undefined, + success: true, + }) + expect(store.incrementToolIterations).toHaveBeenCalledWith(1) + expect(analyzer.analyze).toHaveBeenCalledTimes(1) + expect(store.addPattern).toHaveBeenCalledWith(pattern) + expect(store.addAction).toHaveBeenCalledWith(action) + expect(executor.executeBatch).toHaveBeenCalledWith([action]) + expect(store.removeAction).toHaveBeenCalledWith("action-1") + expect(store.persist).toHaveBeenCalled() + }) + + it("formats prompt context and disposes cleanly on experiment disable", async () => { + experiments = { selfImproving: true } + const manager = createManager() + await manager.initialize() + + const memoryStore = mockState.memoryStores[0] + memoryStore.getSnapshotString.mockReturnValue("\n## Learned Context\n- Search relevant code before editing\n") + memoryStore.getStats.mockReturnValue({ environment: 2, userProfile: 1, revision: 1 }) + mockState.skillUsageStores[0].getStats.mockReturnValue({ + total: 4, + active: 3, + stale: 1, + archived: 0, + pinned: 1, + agentCreated: 2, + }) + + expect(manager.getPromptContextString()).toBe("\n## Learned Context\n- Search relevant code before editing\n") + expect(manager.getStatus()).toMatchObject({ memoryEntries: 3, skillRecords: 4 }) + + experiments = { selfImproving: false } + await manager.handleExperimentChange(false) + + expect(mockState.stores[0].persist).toHaveBeenCalledTimes(1) + expect(memoryStore.takeSnapshot).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + expect(manager.getStatus()).toEqual({ + enabled: false, + started: false, + patternCount: 0, + eventCount: 0, + actionCount: 0, + memoryEntries: 0, + skillRecords: 0, + curatorStatus: DEFAULT_CURATOR_STATUS, + }) + }) + + it("runs curator cycles through the curator service", async () => { + experiments = { selfImproving: true } + const manager = createManager() + await manager.initialize() + + const report = { + runId: "curator-run-2", + timestamp: 123, + durationMs: 5, + transitions: [ + { + skillId: "skill-1", + skillName: "Skill 1", + fromState: "active", + toState: "stale", + reason: "No activity for 14 days", + }, + ], + stats: { + totalSkills: 1, + activeSkills: 0, + staleSkills: 1, + archivedSkills: 0, + pinnedSkills: 0, + transitionsApplied: 1, + }, + } + mockState.curatorServices[0].run.mockResolvedValue(report) + + const result = await manager.runCuratorCycle() + + expect(mockState.curatorServices[0].run).toHaveBeenCalledTimes(1) + expect(mockState.stores[0].updateTelemetry).toHaveBeenCalledWith({ lastCuratorRunAt: 123 }) + expect(result).toEqual(report) + }) +}) diff --git a/src/services/self-improving/__tests__/SkillUsageStore.spec.ts b/src/services/self-improving/__tests__/SkillUsageStore.spec.ts new file mode 100644 index 0000000000..1a40ccf561 --- /dev/null +++ b/src/services/self-improving/__tests__/SkillUsageStore.spec.ts @@ -0,0 +1,88 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { SkillUsageStore } from "../SkillUsageStore" + +describe("SkillUsageStore", () => { + let tempDir: string + const logger = { appendLine: vi.fn() } + + beforeEach(async () => { + logger.appendLine.mockReset() + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "skill-usage-store-")) + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + it("creates and persists telemetry sidecar records", async () => { + const store = new SkillUsageStore(tempDir, logger) + await store.initialize() + + const record = store.getOrCreate("skill-1", "Generated Skill", "agent") + await new Promise((resolve) => setTimeout(resolve, 20)) + + const persisted = JSON.parse( + await fs.readFile(path.join(tempDir, "self-improving", "skill-usage.json"), "utf8"), + ) as Array<{ skillId: string; skillName: string }> + + expect(record).toMatchObject({ skillId: "skill-1", skillName: "Generated Skill", createdBy: "agent" }) + expect(persisted).toContainEqual(expect.objectContaining({ skillId: "skill-1", skillName: "Generated Skill" })) + }) + + it("tracks counters, pinning, and lifecycle candidates", async () => { + const filePath = path.join(tempDir, "self-improving", "skill-usage.json") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile( + filePath, + JSON.stringify([ + { + skillId: "skill-1", + skillName: "Dormant Skill", + createdBy: "bundled", + state: "active", + pinned: false, + viewCount: 0, + useCount: 0, + patchCount: 0, + createdAt: 1, + lastActivityAt: 1, + }, + ]), + "utf8", + ) + + const store = new SkillUsageStore(tempDir, logger) + await store.initialize() + + await store.bumpView("skill-1") + await store.bumpUse("skill-1") + await store.bumpPatch("skill-1") + await store.pin("skill-1") + await store.transitionState("skill-1", "stale") + + expect(store.get("skill-1")).toMatchObject({ + pinned: true, + state: "active", + viewCount: 1, + useCount: 1, + patchCount: 1, + }) + + await store.unpin("skill-1") + await store.transitionState("skill-1", "stale") + + expect(store.getByState("stale")).toHaveLength(1) + expect(store.getArchiveCandidates(0)).toHaveLength(1) + expect(store.getStats()).toEqual({ + total: 1, + active: 0, + stale: 1, + archived: 0, + pinned: 0, + agentCreated: 0, + }) + }) +}) diff --git a/src/services/self-improving/__tests__/TranscriptRecall.spec.ts b/src/services/self-improving/__tests__/TranscriptRecall.spec.ts new file mode 100644 index 0000000000..3a59a9b3ad --- /dev/null +++ b/src/services/self-improving/__tests__/TranscriptRecall.spec.ts @@ -0,0 +1,73 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { TranscriptRecall } from "../TranscriptRecall" + +describe("TranscriptRecall", () => { + let tempDir: string + let logger: { appendLine: ReturnType } + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-transcript-")) + logger = { appendLine: vi.fn() } + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + it("records, persists, and searches transcript evidence", async () => { + const recall = new TranscriptRecall(tempDir, logger) + await recall.initialize() + + await recall.record({ + id: "entry-1", + timestamp: 1, + taskId: "task-1", + mode: "code", + summary: "Task failed while writing file", + signal: "TASK_FAILURE", + toolNames: ["write_to_file"], + errorKey: "EACCES", + success: false, + }) + await recall.record({ + id: "entry-2", + timestamp: 2, + taskId: "task-2", + mode: "code", + summary: "Task completed after search", + signal: "TASK_SUCCESS", + toolNames: ["search_files"], + success: true, + }) + + expect(recall.size).toBe(2) + expect(recall.search("search_files")).toHaveLength(1) + expect(recall.searchBySignal("TASK_FAILURE")).toHaveLength(1) + expect(recall.searchByErrorKey("EACCES")).toHaveLength(1) + + const reloaded = new TranscriptRecall(tempDir, logger) + await reloaded.initialize() + expect(reloaded.getRecent(1)[0].id).toBe("entry-2") + }) + + it("clears persisted entries", async () => { + const recall = new TranscriptRecall(tempDir, logger) + await recall.initialize() + await recall.record({ + id: "entry-1", + timestamp: 1, + summary: "Task completed", + signal: "TASK_SUCCESS", + }) + + await recall.clear() + expect(recall.size).toBe(0) + + const reloaded = new TranscriptRecall(tempDir, logger) + await reloaded.initialize() + expect(reloaded.size).toBe(0) + }) +}) diff --git a/src/services/self-improving/index.ts b/src/services/self-improving/index.ts new file mode 100644 index 0000000000..92e546fedd --- /dev/null +++ b/src/services/self-improving/index.ts @@ -0,0 +1,31 @@ +/** + * Self-Improving Module + * + * A standalone, experiment-gated subsystem that learns from task outcomes + * to improve prompt guidance, tool preferences, and error avoidance over time. + * + * Architecture: Hermes-agent symbolic learning loop adapted to Zoo-Code patterns. + * See ARCHITECTURE.md for full design documentation. + */ + +export { SelfImprovingManager } from "./SelfImprovingManager" +export { LearningStore } from "./LearningStore" +export { FeedbackCollector } from "./FeedbackCollector" +export { PatternAnalyzer } from "./PatternAnalyzer" +export { ImprovementApplier } from "./ImprovementApplier" +export { CodeIndexAdapter } from "./CodeIndexAdapter" +export { MemoryStore } from "./MemoryStore" +export { SkillUsageStore } from "./SkillUsageStore" +export { ActionExecutor } from "./ActionExecutor" +export { CuratorService } from "./CuratorService" +export { ReviewPromptFactory } from "./ReviewPromptFactory" +export { TranscriptRecall } from "./TranscriptRecall" + +export type { CodeIndexInfo, Logger, PromptContext, SelfImprovingManagerOptions, TaskEventInfo } from "./types" +export type { MemoryStoreType } from "./MemoryStore" +export type { SkillTelemetryRecord, SkillProvenance, SkillLifecycleState } from "./SkillUsageStore" +export type { CuratorConfig, CuratorReport } from "./CuratorService" +export type { ReviewType, ReviewPrompt } from "./ReviewPromptFactory" +export type { TranscriptEntry } from "./TranscriptRecall" + +export { DEFAULT_CONFIG, EMPTY_STATE } from "./types" diff --git a/src/services/self-improving/types.ts b/src/services/self-improving/types.ts new file mode 100644 index 0000000000..926d9f8f25 --- /dev/null +++ b/src/services/self-improving/types.ts @@ -0,0 +1,134 @@ +import type { + ActionType, + FeedbackSignal, + ImprovementAction, + LearnedPattern, + LearningConfig, + LearningEvent, + LearningState, + LearningTelemetry, + PatternState, + PatternType, +} from "@roo-code/types" + +// Re-export shared types for convenience +export type { + ActionType, + FeedbackSignal, + ImprovementAction, + LearnedPattern, + LearningConfig, + LearningEvent, + LearningState, + LearningTelemetry, + PatternState, + PatternType, +} + +/** + * Output channel logger interface - abstracts VS Code OutputChannel + */ +export interface Logger { + appendLine(message: string): void +} + +/** + * Code index adapter contract - read-only view of code index availability + */ +export interface CodeIndexInfo { + available: boolean + hits: number + topScore?: number +} + +/** + * Task lifecycle event adapter - normalizes task events into learning signals + */ +export interface TaskEventInfo { + taskId: string + mode?: string + workspacePath?: string + success?: boolean + corrected?: boolean + toolNames?: string[] + userTurnCount?: number + toolIterationCount?: number + errorKey?: string + promptFingerprint?: string +} + +/** + * Prompt context result - bounded set of learned guidance for prompt injection + */ +export interface PromptContext { + entries: Array<{ + type: PatternType + summary: string + confidence: number + }> + revision: number +} + +/** + * Manager options for construction + */ +export interface SelfImprovingManagerOptions { + globalStoragePath: string + logger: Logger + getExperiments: () => Record | undefined + getCodeIndexInfo?: () => CodeIndexInfo + /** Optional curator configuration overrides */ + curatorConfig?: { + intervalMs?: number + minIdleMs?: number + firstRunDeferred?: boolean + staleAfterDays?: number + archiveAfterDays?: number + backupsEnabled?: boolean + maxBackups?: number + } + /** Optional SkillsManager reference for skill telemetry integration */ + skillsManager?: { + getSkillNames(): string[] + getSkillProvenance(name: string): string + } +} + +/** + * Default learning configuration + */ +export const DEFAULT_CONFIG: LearningConfig = { + enabled: false, + reviewOnTurnCount: 10, + reviewOnToolIterationCount: 50, + maxStoredPatterns: 100, + maxStoredEvents: 500, + maxPromptPatterns: 5, + curatorEnabled: true, + curatorIntervalMs: 3600000, + staleAfterDays: 14, + archiveAfterDays: 60, + codeIndexCorrelationEnabled: true, +} + +/** + * Empty learning state for initialization + */ +export const EMPTY_STATE: LearningState = { + version: 1, + config: DEFAULT_CONFIG, + counters: { + userTurnsSinceReview: 0, + toolIterationsSinceReview: 0, + }, + patterns: [], + archivedPatterns: [], + recentEvents: [], + pendingActions: [], + telemetry: { + promptEnrichmentUses: 0, + toolPreferenceUses: 0, + errorAvoidanceUses: 0, + skillSuggestionCount: 0, + }, +} diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 92a7d7604f..4e47cc109a 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -14,6 +14,15 @@ describe("experiments", () => { }) }) + describe("SELF_IMPROVING", () => { + it("is configured correctly", () => { + expect(EXPERIMENT_IDS.SELF_IMPROVING).toBe("selfImproving") + expect(experimentConfigsMap.SELF_IMPROVING).toMatchObject({ + enabled: false, + }) + }) + }) + describe("isEnabled", () => { it("returns false when experiment is not enabled", () => { const experiments: Record = { @@ -21,6 +30,7 @@ describe("experiments", () => { imageGeneration: false, runSlashCommand: false, customTools: false, + selfImproving: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) @@ -31,6 +41,7 @@ describe("experiments", () => { imageGeneration: false, runSlashCommand: false, customTools: false, + selfImproving: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true) }) @@ -41,8 +52,21 @@ describe("experiments", () => { imageGeneration: false, runSlashCommand: false, customTools: false, + selfImproving: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) + + it("returns false for self improving by default", () => { + const experiments: Record = { + preventFocusDisruption: false, + imageGeneration: false, + runSlashCommand: false, + customTools: false, + selfImproving: false, + } + + expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.SELF_IMPROVING)).toBe(false) + }) }) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index e189f99e23..b14aeeb4e5 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -5,6 +5,7 @@ export const EXPERIMENT_IDS = { IMAGE_GENERATION: "imageGeneration", RUN_SLASH_COMMAND: "runSlashCommand", CUSTOM_TOOLS: "customTools", + SELF_IMPROVING: "selfImproving", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -20,6 +21,7 @@ export const experimentConfigsMap: Record = { IMAGE_GENERATION: { enabled: false }, RUN_SLASH_COMMAND: { enabled: false }, CUSTOM_TOOLS: { enabled: false }, + SELF_IMPROVING: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 92ecfa7e73..e489eaf9ff 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Eines actualitzades correctament", "refreshError": "Error en actualitzar les eines", "toolParameters": "Paràmetres" + }, + "SELF_IMPROVING": { + "name": "Auto-millora", + "description": "Activa l'aprenentatge en segon pla a partir dels resultats de les tasques per millorar les indicacions, les preferències d'eines i l'evitació d'errors amb el temps" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 5154d11039..15b2ced4d9 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Tools erfolgreich aktualisiert", "refreshError": "Fehler beim Aktualisieren der Tools", "toolParameters": "Parameter" + }, + "SELF_IMPROVING": { + "name": "Selbstverbesserung", + "description": "Aktiviert Hintergrundlernen aus Aufgabenergebnissen, um Prompt-Anleitungen, Werkzeugpräferenzen und Fehlervermeidung im Laufe der Zeit zu verbessern" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index a3c11be386..18da0364bc 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -879,6 +879,10 @@ "name": "Enable model-initiated slash commands", "description": "When enabled, Zoo can run your slash commands to execute workflows." }, + "SELF_IMPROVING": { + "name": "Self-Improving", + "description": "Enable background learning from task outcomes to improve prompt guidance, tool preferences, and error avoidance over time" + }, "CUSTOM_TOOLS": { "name": "Enable custom tools", "description": "When enabled, Zoo can load and use custom TypeScript/JavaScript tools from your project's .roo/tools directory or ~/.roo/tools for global tools. Note: these tools will automatically be auto-approved.", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 8ec3e29338..83b1e59212 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Herramientas actualizadas correctamente", "refreshError": "Error al actualizar las herramientas", "toolParameters": "Parámetros" + }, + "SELF_IMPROVING": { + "name": "Automejora", + "description": "Activa el aprendizaje en segundo plano a partir de los resultados de las tareas para mejorar la orientación de prompts, las preferencias de herramientas y la prevención de errores con el tiempo" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index d746111a0a..af559d91d9 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Outils actualisés avec succès", "refreshError": "Échec de l'actualisation des outils", "toolParameters": "Paramètres" + }, + "SELF_IMPROVING": { + "name": "Auto-amélioration", + "description": "Active l’apprentissage en arrière-plan à partir des résultats des tâches afin d’améliorer le guidage des prompts, les préférences d’outils et l’évitement des erreurs au fil du temps" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 9a77b69ee4..1558777d4f 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "टूल्स सफलतापूर्वक रिफ्रेश हुए", "refreshError": "टूल्स रिफ्रेश करने में विफल", "toolParameters": "पैरामीटर्स" + }, + "SELF_IMPROVING": { + "name": "स्व-सुधार", + "description": "कार्य परिणामों से पृष्ठभूमि में सीखने को सक्षम करें ताकि समय के साथ प्रॉम्प्ट मार्गदर्शन, टूल प्राथमिकताओं और त्रुटि-परिहार में सुधार हो सके" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 41eae1b053..edff96583b 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Tool berhasil direfresh", "refreshError": "Gagal merefresh tool", "toolParameters": "Parameter" + }, + "SELF_IMPROVING": { + "name": "Peningkatan Diri", + "description": "Aktifkan pembelajaran latar belakang dari hasil tugas untuk meningkatkan panduan prompt, preferensi alat, dan penghindaran kesalahan seiring waktu" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 806fb44462..d05c088fab 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Strumenti aggiornati con successo", "refreshError": "Impossibile aggiornare gli strumenti", "toolParameters": "Parametri" + }, + "SELF_IMPROVING": { + "name": "Auto-miglioramento", + "description": "Abilita l'apprendimento in background dai risultati delle attività per migliorare nel tempo la guida dei prompt, le preferenze degli strumenti e la prevenzione degli errori" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index b9a0976c42..5e2535bd15 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "ツールが正常に更新されました", "refreshError": "ツールの更新に失敗しました", "toolParameters": "パラメーター" + }, + "SELF_IMPROVING": { + "name": "自己改善", + "description": "タスク結果からのバックグラウンド学習を有効にして、時間の経過とともにプロンプトのガイダンス、ツールの好み、エラー回避を改善します" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 3a88c9fbde..a3e51093ce 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "도구가 성공적으로 새로고침되었습니다", "refreshError": "도구 새로고침에 실패했습니다", "toolParameters": "매개변수" + }, + "SELF_IMPROVING": { + "name": "자기 개선", + "description": "작업 결과를 바탕으로 백그라운드 학습을 활성화하여 시간이 지남에 따라 프롬프트 안내, 도구 선호도 및 오류 회피를 개선합니다" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index a91bb87de5..0800fd5675 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Tools succesvol vernieuwd", "refreshError": "Fout bij vernieuwen van tools", "toolParameters": "Parameters" + }, + "SELF_IMPROVING": { + "name": "Zelfverbeterend", + "description": "Schakel achtergrondleren in op basis van taakresultaten om promptbegeleiding, toolvoorkeuren en foutvermijding in de loop van de tijd te verbeteren" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index f3751196d8..e777922766 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Narzędzia odświeżone pomyślnie", "refreshError": "Nie udało się odświeżyć narzędzi", "toolParameters": "Parametry" + }, + "SELF_IMPROVING": { + "name": "Samodoskonalenie", + "description": "Włącz uczenie w tle na podstawie wyników zadań, aby z czasem ulepszać wskazówki dla promptów, preferencje narzędzi i unikanie błędów" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 05d3557f76..f9cf904920 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Ferramentas atualizadas com sucesso", "refreshError": "Falha ao atualizar ferramentas", "toolParameters": "Parâmetros" + }, + "SELF_IMPROVING": { + "name": "Autoaperfeiçoamento", + "description": "Ative o aprendizado em segundo plano a partir dos resultados das tarefas para melhorar a orientação de prompts, preferências de ferramentas e prevenção de erros ao longo do tempo" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 1aedb8a575..8d79c11ae8 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Инструменты успешно обновлены", "refreshError": "Не удалось обновить инструменты", "toolParameters": "Параметры" + }, + "SELF_IMPROVING": { + "name": "Самоулучшение", + "description": "Включить фоновое обучение на основе результатов задач, чтобы со временем улучшать рекомендации по промптам, предпочтения инструментов и предотвращение ошибок" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 22bd730488..b5edd00e29 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Araçlar başarıyla yenilendi", "refreshError": "Araçlar yenilenemedi", "toolParameters": "Parametreler" + }, + "SELF_IMPROVING": { + "name": "Kendini Geliştirme", + "description": "Görev sonuçlarından arka planda öğrenmeyi etkinleştirerek zaman içinde istem yönlendirmesini, araç tercihlerini ve hata önlemeyi iyileştirin" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 0b09ae004b..39b7afcf3a 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "Làm mới công cụ thành công", "refreshError": "Không thể làm mới công cụ", "toolParameters": "Thông số" + }, + "SELF_IMPROVING": { + "name": "Tự cải thiện", + "description": "Bật khả năng học nền từ kết quả tác vụ để cải thiện hướng dẫn prompt, tùy chọn công cụ và khả năng tránh lỗi theo thời gian" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index f58ea87f8c..208c3d3663 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -826,6 +826,10 @@ "refreshSuccess": "工具刷新成功", "refreshError": "工具刷新失败", "toolParameters": "参数" + }, + "SELF_IMPROVING": { + "name": "自我改进", + "description": "启用基于任务结果的后台学习,以便随着时间推移改进提示词引导、工具偏好和错误规避" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index e4268280de..0876b3ec4a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -836,6 +836,10 @@ "refreshSuccess": "工具重新整理成功", "refreshError": "工具重新整理失敗", "toolParameters": "參數" + }, + "SELF_IMPROVING": { + "name": "自我改進", + "description": "啟用根據任務結果進行的後台學習,以便隨著時間改善提示詞引導、工具偏好和錯誤避免" } }, "promptCaching": {