From a40bdc15a804727bb183d33b0b9f6115e9d23aa0 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Fri, 22 May 2026 07:52:51 +0800 Subject: [PATCH 01/32] feat: implement Self-Improving Manager for adaptive learning - Introduced SelfImprovingManager to facilitate background learning from task outcomes. - Added LearningStore, FeedbackCollector, PatternAnalyzer, ImprovementApplier, and CodeIndexAdapter for modular functionality. - Implemented experiment gating for enabling/disabling self-improvement features. - Created comprehensive tests for SelfImprovingManager to ensure functionality and stability. - Updated localization files to include settings for self-improvement in multiple languages. --- .../src/__tests__/learning-memory.test.ts | 71 +++ packages/types/src/experiment.ts | 9 +- packages/types/src/index.ts | 2 + packages/types/src/learning.ts | 193 ++++++++ packages/types/src/memory.ts | 29 ++ packages/types/src/vscode-extension-host.ts | 9 + src/__tests__/extension.spec.ts | 1 + .../prompts/__tests__/system-prompt.spec.ts | 31 ++ src/core/prompts/system.ts | 9 +- src/core/task/Task.ts | 1 + src/core/webview/ClineProvider.ts | 67 +++ src/core/webview/generateSystemPrompt.ts | 1 + src/extension.ts | 7 + .../self-improving/CodeIndexAdapter.ts | 38 ++ .../self-improving/FeedbackCollector.ts | 122 +++++ .../self-improving/ImprovementApplier.ts | 136 ++++++ src/services/self-improving/LearningStore.ts | 391 ++++++++++++++++ .../self-improving/PatternAnalyzer.ts | 287 ++++++++++++ .../self-improving/SelfImprovingManager.ts | 439 ++++++++++++++++++ .../__tests__/SelfImprovingManager.spec.ts | 246 ++++++++++ src/services/self-improving/index.ts | 20 + src/services/self-improving/types.ts | 119 +++++ src/shared/__tests__/experiments.spec.ts | 24 + src/shared/experiments.ts | 2 + webview-ui/src/i18n/locales/ca/settings.json | 4 + webview-ui/src/i18n/locales/de/settings.json | 4 + webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/i18n/locales/es/settings.json | 4 + webview-ui/src/i18n/locales/fr/settings.json | 4 + webview-ui/src/i18n/locales/hi/settings.json | 4 + webview-ui/src/i18n/locales/id/settings.json | 4 + webview-ui/src/i18n/locales/it/settings.json | 4 + webview-ui/src/i18n/locales/ja/settings.json | 4 + webview-ui/src/i18n/locales/ko/settings.json | 4 + webview-ui/src/i18n/locales/nl/settings.json | 4 + webview-ui/src/i18n/locales/pl/settings.json | 4 + .../src/i18n/locales/pt-BR/settings.json | 4 + webview-ui/src/i18n/locales/ru/settings.json | 4 + webview-ui/src/i18n/locales/tr/settings.json | 4 + webview-ui/src/i18n/locales/vi/settings.json | 4 + .../src/i18n/locales/zh-CN/settings.json | 4 + .../src/i18n/locales/zh-TW/settings.json | 4 + 42 files changed, 2324 insertions(+), 2 deletions(-) create mode 100644 packages/types/src/__tests__/learning-memory.test.ts create mode 100644 packages/types/src/learning.ts create mode 100644 packages/types/src/memory.ts create mode 100644 src/services/self-improving/CodeIndexAdapter.ts create mode 100644 src/services/self-improving/FeedbackCollector.ts create mode 100644 src/services/self-improving/ImprovementApplier.ts create mode 100644 src/services/self-improving/LearningStore.ts create mode 100644 src/services/self-improving/PatternAnalyzer.ts create mode 100644 src/services/self-improving/SelfImprovingManager.ts create mode 100644 src/services/self-improving/__tests__/SelfImprovingManager.spec.ts create mode 100644 src/services/self-improving/index.ts create mode 100644 src/services/self-improving/types.ts 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 cd5804aecb..d9192ddded 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -14,9 +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 202326eb01..d137480cf4 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -366,6 +366,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..871554813c 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,37 @@ 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 onTaskAborted = async () => { this.emit(RooCodeEventName.TaskAborted, instance.taskId) @@ -266,6 +315,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) @@ -394,6 +446,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 +664,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 +2326,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 +2709,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/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/CodeIndexAdapter.ts b/src/services/self-improving/CodeIndexAdapter.ts new file mode 100644 index 0000000000..dffe9f6976 --- /dev/null +++ b/src/services/self-improving/CodeIndexAdapter.ts @@ -0,0 +1,38 @@ +import type { CodeIndexInfo } 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(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 { + 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/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/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/SelfImprovingManager.ts b/src/services/self-improving/SelfImprovingManager.ts new file mode 100644 index 0000000000..dae0ecf258 --- /dev/null +++ b/src/services/self-improving/SelfImprovingManager.ts @@ -0,0 +1,439 @@ +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" + +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"] + + private runtime: Runtime | undefined + private started = false + private reviewTimer: ReturnType | null = null + private curatorTimer: ReturnType | null = null + private promptRevision = 0 + + constructor(options: SelfImprovingManagerOptions) { + this.globalStoragePath = options.globalStoragePath + this.logger = options.logger + this.getExperiments = options.getExperiments + this.getCodeIndexInfo = options.getCodeIndexInfo + } + + 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() + 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()) + if (!enabled || !experimentEnabled) { + await this.dispose() + return + } + + await this.initialize() + } catch (error) { + this.logError("Experiment change handling error", error) + } + } + + 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() + } + } 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.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) + 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.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) + } catch (error) { + this.logError("recordCodeIndexEvent error", error) + } + } + + async runReviewCycle(): Promise { + if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return + } + + if (!this.started || !this.runtime) { + return + } + + 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) + } + + 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) + } + } + + async runCuratorCycle(): Promise { + if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return + } + + if (!this.started || !this.runtime) { + return + } + + try { + const config = this.runtime.store.getConfig() + const now = Date.now() + const staleThreshold = now - config.staleAfterDays * 24 * 60 * 60 * 1000 + const archiveThreshold = now - config.archiveAfterDays * 24 * 60 * 60 * 1000 + let transitions = 0 + + for (const pattern of [...this.runtime.store.getPatterns()]) { + if (pattern.state === "active" && pattern.lastSeenAt < staleThreshold) { + this.runtime.store.updatePattern(pattern.id, { state: "stale" }) + transitions += 1 + } else if (pattern.state === "stale" && pattern.lastSeenAt < archiveThreshold) { + this.runtime.store.archivePattern(pattern.id) + transitions += 1 + } + } + + if (transitions > 0) { + this.runtime.store.updateTelemetry({ lastCuratorRunAt: now }) + await this.runtime.store.persist() + this.logger.appendLine(`[SelfImprovingManager] Curator cycle: ${transitions} patterns transitioned`) + } + } catch (error) { + this.logError("Curator cycle error", error) + } + } + + 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 (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + return "" + } + + if (!this.started) { + return "" + } + + try { + const context = this.getPromptContext() + if (!context || context.entries.length === 0) { + return "" + } + + return `\n## Learned Guidance\n${context.entries.map((entry) => `- [${entry.type}] ${entry.summary}`).join("\n")}\n` + } catch { + return "" + } + } + + getStatus(): { + enabled: boolean + started: boolean + patternCount: number + eventCount: number + actionCount: number + lastReviewAt?: number + lastCuratorRunAt?: number + } { + const enabled = SelfImprovingManager.isExperimentEnabled(this.getExperiments()) + if (!enabled) { + return { enabled: false, started: false, patternCount: 0, eventCount: 0, actionCount: 0 } + } + + if (!this.started || !this.runtime) { + return { enabled: true, started: false, patternCount: 0, eventCount: 0, actionCount: 0 } + } + + try { + const telemetry = this.runtime.store.getTelemetry() + return { + enabled: true, + started: true, + patternCount: this.runtime.store.getPatterns().length, + eventCount: this.runtime.store.getRecentEvents().length, + actionCount: this.runtime.store.getPendingActions().length, + lastReviewAt: telemetry.lastReviewAt, + lastCuratorRunAt: telemetry.lastCuratorRunAt, + } + } catch { + return { enabled: true, started: true, patternCount: 0, eventCount: 0, actionCount: 0 } + } + } + + 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.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(() => { + void this.runCuratorCycle() + }, 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/__tests__/SelfImprovingManager.spec.ts b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts new file mode 100644 index 0000000000..6cd72ffd33 --- /dev/null +++ b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts @@ -0,0 +1,246 @@ +const mockState = vi.hoisted(() => ({ + stores: [] as any[], + collectors: [] as any[], + analyzers: [] as any[], + appliers: [] as any[], + adapters: [] as any[], +})) + +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(), + incrementToolIterations: vi.fn(), + incrementUserTurns: vi.fn(), + resetCounters: vi.fn(), + updateTelemetry: vi.fn(), + updatePattern: vi.fn(), + archivePattern: vi.fn(), + } +} + +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 + }), +})) + +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 + 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, + }) + }) + + 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(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 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: {}, + 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]) + analyzer.analyze.mockReturnValue([pattern]) + applier.generateActions.mockReturnValue([action]) + + await manager.recordTaskCompletion({ taskId: "task-1", success: true, toolNames: ["search_files"] }) + + expect(store.addEvent).toHaveBeenCalledTimes(1) + expect(store.incrementToolIterations).toHaveBeenCalledWith(1) + expect(analyzer.analyze).toHaveBeenCalledTimes(1) + expect(store.addPattern).toHaveBeenCalledWith(pattern) + expect(store.addAction).toHaveBeenCalledWith(action) + 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 applier = mockState.appliers[0] + applier.getPromptContext.mockReturnValue({ + entries: [{ type: "prompt", summary: "Search relevant code before editing", confidence: 0.8 }], + revision: 1, + }) + + expect(manager.getPromptContextString()).toBe( + "\n## Learned Guidance\n- [prompt] Search relevant code before editing\n", + ) + + experiments = { selfImproving: false } + await manager.handleExperimentChange(false) + + expect(mockState.stores[0].persist).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + expect(manager.getStatus()).toEqual({ + enabled: false, + started: false, + patternCount: 0, + eventCount: 0, + actionCount: 0, + }) + }) +}) diff --git a/src/services/self-improving/index.ts b/src/services/self-improving/index.ts new file mode 100644 index 0000000000..0a10796500 --- /dev/null +++ b/src/services/self-improving/index.ts @@ -0,0 +1,20 @@ +/** + * 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 type { CodeIndexInfo, Logger, PromptContext, SelfImprovingManagerOptions, TaskEventInfo } from "./types" + +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..66a8e2eeaa --- /dev/null +++ b/src/services/self-improving/types.ts @@ -0,0 +1,119 @@ +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 +} + +/** + * 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": { From 980579366fb848dd10c5fcabb57b39ba64c5fe05 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Fri, 22 May 2026 08:52:08 +0800 Subject: [PATCH 02/32] feat: Enhance SelfImprovingManager with MemoryStore and SkillUsageStore integration - Introduced MemoryStore for managing memory entries and snapshots. - Added SkillUsageStore for tracking skill usage telemetry. - Implemented ActionExecutor to handle action execution and logging. - Updated SelfImprovingManager to initialize and utilize MemoryStore and SkillUsageStore. - Enhanced telemetry reporting in SelfImprovingManager's status. - Added tests for ActionExecutor, MemoryStore, and SkillUsageStore to ensure functionality. - Updated types to include new SkillUsageStore and MemoryStore interfaces. --- src/services/self-improving/ActionExecutor.ts | 186 +++++++ src/services/self-improving/MemoryStore.ts | 504 ++++++++++++++++++ .../self-improving/SelfImprovingManager.ts | 71 ++- .../self-improving/SkillUsageStore.ts | 426 +++++++++++++++ .../__tests__/ActionExecutor.spec.ts | 107 ++++ .../__tests__/MemoryStore.spec.ts | 104 ++++ .../__tests__/SelfImprovingManager.spec.ts | 86 ++- .../__tests__/SkillUsageStore.spec.ts | 88 +++ src/services/self-improving/index.ts | 5 + src/services/self-improving/types.ts | 5 + 10 files changed, 1561 insertions(+), 21 deletions(-) create mode 100644 src/services/self-improving/ActionExecutor.ts create mode 100644 src/services/self-improving/MemoryStore.ts create mode 100644 src/services/self-improving/SkillUsageStore.ts create mode 100644 src/services/self-improving/__tests__/ActionExecutor.spec.ts create mode 100644 src/services/self-improving/__tests__/MemoryStore.spec.ts create mode 100644 src/services/self-improving/__tests__/SkillUsageStore.spec.ts diff --git a/src/services/self-improving/ActionExecutor.ts b/src/services/self-improving/ActionExecutor.ts new file mode 100644 index 0000000000..43de3c8e8c --- /dev/null +++ b/src/services/self-improving/ActionExecutor.ts @@ -0,0 +1,186 @@ +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"], + }) + + return entry !== null || summary.trim().length > 0 + } + + /** + * 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}`)], + }) + + return entry !== null || summary.trim().length > 0 + } + + /** + * 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}`)], + }) + + return entry !== null || summary.trim().length > 0 + } + + /** + * 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/MemoryStore.ts b/src/services/self-improving/MemoryStore.ts new file mode 100644 index 0000000000..81ebb2f371 --- /dev/null +++ b/src/services/self-improving/MemoryStore.ts @@ -0,0 +1,504 @@ +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) => { + const tags = entry.tags?.length ? ` [${entry.tags.join(", ")}]` : "" + return `- ${entry.content}${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/SelfImprovingManager.ts b/src/services/self-improving/SelfImprovingManager.ts index dae0ecf258..696fa36737 100644 --- a/src/services/self-improving/SelfImprovingManager.ts +++ b/src/services/self-improving/SelfImprovingManager.ts @@ -12,6 +12,9 @@ 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" const SELF_IMPROVING_EXPERIMENT_ID = "selfImproving" const REVIEW_CHECK_INTERVAL_MS = 60_000 @@ -29,6 +32,9 @@ export class SelfImprovingManager { private readonly logger: Logger private readonly getExperiments: () => Record | undefined private readonly getCodeIndexInfo: SelfImprovingManagerOptions["getCodeIndexInfo"] + public readonly memoryStore: MemoryStore + public readonly skillUsageStore: SkillUsageStore + private readonly actionExecutor: ActionExecutor private runtime: Runtime | undefined private started = false @@ -41,6 +47,9 @@ export class SelfImprovingManager { 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) } static isExperimentEnabled(experiments: Record | undefined): boolean { @@ -63,6 +72,8 @@ export class SelfImprovingManager { try { const runtime = this.getOrCreateRuntime() await runtime.store.initialize() + await this.memoryStore.initialize() + await this.skillUsageStore.initialize() this.started = true this.startTimers(runtime.store) this.logger.appendLine( @@ -103,6 +114,7 @@ export class SelfImprovingManager { try { if (this.started) { await this.runtime?.store.persist() + this.memoryStore.takeSnapshot() } } catch (error) { this.logError("Persist on dispose error", error) @@ -219,6 +231,18 @@ export class SelfImprovingManager { 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() @@ -289,21 +313,12 @@ export class SelfImprovingManager { } getPromptContextString(): string { - if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { - return "" - } - if (!this.started) { return "" } try { - const context = this.getPromptContext() - if (!context || context.entries.length === 0) { - return "" - } - - return `\n## Learned Guidance\n${context.entries.map((entry) => `- [${entry.type}] ${entry.summary}`).join("\n")}\n` + return this.memoryStore.getSnapshotString() } catch { return "" } @@ -315,31 +330,61 @@ export class SelfImprovingManager { patternCount: number eventCount: number actionCount: number + memoryEntries: number + skillRecords: number lastReviewAt?: number lastCuratorRunAt?: number } { const enabled = SelfImprovingManager.isExperimentEnabled(this.getExperiments()) if (!enabled) { - return { enabled: false, started: false, patternCount: 0, eventCount: 0, actionCount: 0 } + return { + enabled: false, + started: false, + patternCount: 0, + eventCount: 0, + actionCount: 0, + memoryEntries: 0, + skillRecords: 0, + } } if (!this.started || !this.runtime) { - return { enabled: true, started: false, patternCount: 0, eventCount: 0, actionCount: 0 } + return { + enabled: true, + started: false, + patternCount: 0, + eventCount: 0, + actionCount: 0, + memoryEntries: 0, + skillRecords: 0, + } } 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, lastReviewAt: telemetry.lastReviewAt, lastCuratorRunAt: telemetry.lastCuratorRunAt, } } catch { - return { enabled: true, started: true, patternCount: 0, eventCount: 0, actionCount: 0 } + return { + enabled: true, + started: true, + patternCount: 0, + eventCount: 0, + actionCount: 0, + memoryEntries: 0, + skillRecords: 0, + } } } 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/__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__/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__/SelfImprovingManager.spec.ts b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts index 6cd72ffd33..e06b4b1324 100644 --- a/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts +++ b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts @@ -4,6 +4,9 @@ const mockState = vi.hoisted(() => ({ analyzers: [] as any[], appliers: [] as any[], adapters: [] as any[], + memoryStores: [] as any[], + skillUsageStores: [] as any[], + actionExecutors: [] as any[], })) function createStoreMock() { @@ -34,6 +37,7 @@ function createStoreMock() { addEvent: vi.fn(), addPattern: vi.fn(), addAction: vi.fn(), + removeAction: vi.fn(), incrementToolIterations: vi.fn(), incrementUserTurns: vi.fn(), resetCounters: vi.fn(), @@ -43,6 +47,28 @@ function createStoreMock() { } } +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()), + } +} + vi.mock("../LearningStore", () => ({ LearningStore: vi.fn().mockImplementation(() => { const store = createStoreMock() @@ -109,6 +135,30 @@ vi.mock("../CodeIndexAdapter", () => ({ }), })) +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 + }), +})) + import { SelfImprovingManager } from "../SelfImprovingManager" describe("SelfImprovingManager", () => { @@ -131,6 +181,9 @@ describe("SelfImprovingManager", () => { mockState.analyzers.length = 0 mockState.appliers.length = 0 mockState.adapters.length = 0 + mockState.memoryStores.length = 0 + mockState.skillUsageStores.length = 0 + mockState.actionExecutors.length = 0 experiments = undefined logger = { appendLine: vi.fn() } }) @@ -153,6 +206,8 @@ describe("SelfImprovingManager", () => { patternCount: 0, eventCount: 0, actionCount: 0, + memoryEntries: 0, + skillRecords: 0, }) }) @@ -164,6 +219,8 @@ describe("SelfImprovingManager", () => { 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(vi.getTimerCount()).toBe(2) expect(manager.getStatus()).toMatchObject({ enabled: true, started: true }) }) @@ -176,6 +233,7 @@ describe("SelfImprovingManager", () => { const store = mockState.stores[0] const analyzer = mockState.analyzers[0] const applier = mockState.appliers[0] + const executor = mockState.actionExecutors[0] const pattern = { id: "pattern-1", patternType: "prompt", @@ -193,7 +251,7 @@ describe("SelfImprovingManager", () => { id: "action-1", actionType: "PROMPT_ENRICHMENT", target: "system-prompt", - payload: {}, + payload: { summary: "Prefer semantic search before regex search" }, timestamp: 1, } @@ -202,8 +260,10 @@ describe("SelfImprovingManager", () => { { 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"] }) @@ -212,6 +272,8 @@ describe("SelfImprovingManager", () => { 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() }) @@ -220,20 +282,26 @@ describe("SelfImprovingManager", () => { const manager = createManager() await manager.initialize() - const applier = mockState.appliers[0] - applier.getPromptContext.mockReturnValue({ - entries: [{ type: "prompt", summary: "Search relevant code before editing", confidence: 0.8 }], - revision: 1, + 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 Guidance\n- [prompt] Search relevant code before editing\n", - ) + 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, @@ -241,6 +309,8 @@ describe("SelfImprovingManager", () => { patternCount: 0, eventCount: 0, actionCount: 0, + memoryEntries: 0, + skillRecords: 0, }) }) }) 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/index.ts b/src/services/self-improving/index.ts index 0a10796500..9f9ddbfc7b 100644 --- a/src/services/self-improving/index.ts +++ b/src/services/self-improving/index.ts @@ -14,7 +14,12 @@ 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 type { CodeIndexInfo, Logger, PromptContext, SelfImprovingManagerOptions, TaskEventInfo } from "./types" +export type { MemoryStoreType } from "./MemoryStore" +export type { SkillTelemetryRecord, SkillProvenance, SkillLifecycleState } from "./SkillUsageStore" export { DEFAULT_CONFIG, EMPTY_STATE } from "./types" diff --git a/src/services/self-improving/types.ts b/src/services/self-improving/types.ts index 66a8e2eeaa..3b80542dc5 100644 --- a/src/services/self-improving/types.ts +++ b/src/services/self-improving/types.ts @@ -77,6 +77,11 @@ export interface SelfImprovingManagerOptions { logger: Logger getExperiments: () => Record | undefined getCodeIndexInfo?: () => CodeIndexInfo + /** Optional SkillsManager reference for skill telemetry integration */ + skillsManager?: { + getSkillNames(): string[] + getSkillProvenance(name: string): string + } } /** From c6d0b55c927c6c936c02e5597c8baded03334bfd Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Fri, 22 May 2026 09:08:12 +0800 Subject: [PATCH 03/32] feat: Implement ReviewPromptFactory and TranscriptRecall for self-improvement - Added ReviewPromptFactory to generate structured review prompts for memory and skill reviews. - Introduced TranscriptRecall to store and manage transcript entries for task outcomes. - Enhanced SelfImprovingManager to utilize ReviewPromptFactory and TranscriptRecall. - Updated CuratorService to support new functionality and maintain skill usage. - Created unit tests for ReviewPromptFactory, TranscriptRecall, and CuratorService to ensure reliability. - Modified types and index files to include new services and types. --- src/services/self-improving/CuratorService.ts | 421 ++++++++++++++++++ .../self-improving/ReviewPromptFactory.ts | 111 +++++ .../self-improving/SelfImprovingManager.ts | 86 +++- .../self-improving/TranscriptRecall.ts | 141 ++++++ .../__tests__/CuratorService.spec.ts | 139 ++++++ .../__tests__/ReviewPromptFactory.spec.ts | 27 ++ .../__tests__/SelfImprovingManager.spec.ts | 125 ++++++ .../__tests__/TranscriptRecall.spec.ts | 73 +++ src/services/self-improving/index.ts | 6 + src/services/self-improving/types.ts | 10 + 10 files changed, 1116 insertions(+), 23 deletions(-) create mode 100644 src/services/self-improving/CuratorService.ts create mode 100644 src/services/self-improving/ReviewPromptFactory.ts create mode 100644 src/services/self-improving/TranscriptRecall.ts create mode 100644 src/services/self-improving/__tests__/CuratorService.spec.ts create mode 100644 src/services/self-improving/__tests__/ReviewPromptFactory.spec.ts create mode 100644 src/services/self-improving/__tests__/TranscriptRecall.spec.ts diff --git a/src/services/self-improving/CuratorService.ts b/src/services/self-improving/CuratorService.ts new file mode 100644 index 0000000000..fe09e1d44e --- /dev/null +++ b/src/services/self-improving/CuratorService.ts @@ -0,0 +1,421 @@ +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 + } + + 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.lastRunAt = now + 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/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 index 696fa36737..895a3fe54c 100644 --- a/src/services/self-improving/SelfImprovingManager.ts +++ b/src/services/self-improving/SelfImprovingManager.ts @@ -15,6 +15,10 @@ 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 @@ -34,6 +38,9 @@ export class SelfImprovingManager { 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 @@ -41,6 +48,7 @@ export class SelfImprovingManager { private reviewTimer: ReturnType | null = null private curatorTimer: ReturnType | null = null private promptRevision = 0 + private lastUserActivityAt = 0 constructor(options: SelfImprovingManagerOptions) { this.globalStoragePath = options.globalStoragePath @@ -50,6 +58,14 @@ export class SelfImprovingManager { 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 { @@ -74,6 +90,8 @@ export class SelfImprovingManager { 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( @@ -137,6 +155,21 @@ export class SelfImprovingManager { 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), ) @@ -158,6 +191,7 @@ export class SelfImprovingManager { 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) @@ -174,6 +208,7 @@ export class SelfImprovingManager { } try { + this.lastUserActivityAt = Date.now() this.runtime.store.incrementUserTurns() await this.checkReviewTriggers(this.runtime.store) } catch (error) { @@ -198,6 +233,7 @@ export class SelfImprovingManager { 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) } @@ -255,39 +291,33 @@ export class SelfImprovingManager { } } - async runCuratorCycle(): Promise { + async runCuratorCycle(): Promise { if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { - return + return undefined } if (!this.started || !this.runtime) { - return + return undefined } try { - const config = this.runtime.store.getConfig() const now = Date.now() - const staleThreshold = now - config.staleAfterDays * 24 * 60 * 60 * 1000 - const archiveThreshold = now - config.archiveAfterDays * 24 * 60 * 60 * 1000 - let transitions = 0 - - for (const pattern of [...this.runtime.store.getPatterns()]) { - if (pattern.state === "active" && pattern.lastSeenAt < staleThreshold) { - this.runtime.store.updatePattern(pattern.id, { state: "stale" }) - transitions += 1 - } else if (pattern.state === "stale" && pattern.lastSeenAt < archiveThreshold) { - this.runtime.store.archivePattern(pattern.id) - transitions += 1 - } - } + const report = await this.curatorService.run( + now, + this.lastUserActivityAt > 0 ? this.lastUserActivityAt : undefined, + ) + this.runtime.store.updateTelemetry({ lastCuratorRunAt: report.timestamp }) - if (transitions > 0) { - this.runtime.store.updateTelemetry({ lastCuratorRunAt: now }) - await this.runtime.store.persist() - this.logger.appendLine(`[SelfImprovingManager] Curator cycle: ${transitions} patterns transitioned`) + if (report.transitions.length > 0) { + this.logger.appendLine(`[SelfImprovingManager] Curator cycle: ${report.transitions.length} transitions`) } + + return report } catch (error) { - this.logError("Curator cycle error", error) + this.logger.appendLine( + `[SelfImprovingManager] Curator cycle error: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined } } @@ -332,10 +362,12 @@ export class SelfImprovingManager { 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, @@ -345,6 +377,7 @@ export class SelfImprovingManager { actionCount: 0, memoryEntries: 0, skillRecords: 0, + curatorStatus, } } @@ -357,6 +390,7 @@ export class SelfImprovingManager { actionCount: 0, memoryEntries: 0, skillRecords: 0, + curatorStatus, } } @@ -372,6 +406,7 @@ export class SelfImprovingManager { actionCount: this.runtime.store.getPendingActions().length, memoryEntries: memoryStats.environment + memoryStats.userProfile, skillRecords: skillStats.total, + curatorStatus, lastReviewAt: telemetry.lastReviewAt, lastCuratorRunAt: telemetry.lastCuratorRunAt, } @@ -384,6 +419,7 @@ export class SelfImprovingManager { actionCount: 0, memoryEntries: 0, skillRecords: 0, + curatorStatus, } } } @@ -429,7 +465,11 @@ export class SelfImprovingManager { if (config.curatorEnabled) { this.curatorTimer = setInterval(() => { - void this.runCuratorCycle() + this.runCuratorCycle().catch((error) => { + this.logger.appendLine( + `[SelfImprovingManager] Curator cycle error: ${error instanceof Error ? error.message : String(error)}`, + ) + }) }, config.curatorIntervalMs) } } 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__/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__/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 index e06b4b1324..173d773544 100644 --- a/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts +++ b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts @@ -7,8 +7,25 @@ const mockState = vi.hoisted(() => ({ 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), @@ -69,6 +86,34 @@ function createActionExecutorMock() { } } +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() @@ -159,6 +204,30 @@ vi.mock("../ActionExecutor", () => ({ }), })) +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", () => { @@ -184,6 +253,9 @@ describe("SelfImprovingManager", () => { 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() } }) @@ -208,6 +280,7 @@ describe("SelfImprovingManager", () => { actionCount: 0, memoryEntries: 0, skillRecords: 0, + curatorStatus: DEFAULT_CURATOR_STATUS, }) }) @@ -221,6 +294,8 @@ describe("SelfImprovingManager", () => { 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 }) }) @@ -234,6 +309,7 @@ describe("SelfImprovingManager", () => { 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", @@ -268,6 +344,18 @@ describe("SelfImprovingManager", () => { 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) @@ -311,6 +399,43 @@ describe("SelfImprovingManager", () => { 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__/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 index 9f9ddbfc7b..92e546fedd 100644 --- a/src/services/self-improving/index.ts +++ b/src/services/self-improving/index.ts @@ -17,9 +17,15 @@ 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 index 3b80542dc5..926d9f8f25 100644 --- a/src/services/self-improving/types.ts +++ b/src/services/self-improving/types.ts @@ -77,6 +77,16 @@ export interface SelfImprovingManagerOptions { 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[] From 445e9ede2f77b347c24244a1a65ac992d2b801a6 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Fri, 22 May 2026 09:54:27 +0800 Subject: [PATCH 04/32] feat: Enhance self-improvement system with user message tracking and settings management --- src/core/webview/ClineProvider.ts | 9 ++ src/core/webview/webviewMessageHandler.ts | 9 ++ src/services/self-improving/ActionExecutor.ts | 15 ++- .../self-improving/CodeIndexAdapter.ts | 12 +- src/services/self-improving/CuratorService.ts | 4 +- src/services/self-improving/MemoryStore.ts | 17 ++- .../self-improving/SelfImprovingManager.ts | 32 +++++- .../__tests__/LearningStore.spec.ts | 107 ++++++++++++++++++ 8 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 src/services/self-improving/__tests__/LearningStore.spec.ts diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 871554813c..1209f4d32a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -287,6 +287,13 @@ export class ClineProvider // 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) @@ -335,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) @@ -352,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), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index dc029cb7dd..9403a82ed6 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/services/self-improving/ActionExecutor.ts b/src/services/self-improving/ActionExecutor.ts index 43de3c8e8c..b7a7253c8d 100644 --- a/src/services/self-improving/ActionExecutor.ts +++ b/src/services/self-improving/ActionExecutor.ts @@ -98,8 +98,11 @@ export class ActionExecutor { source: "learning", tags: ["learned", "prompt"], }) + if (entry === null) { + // null means duplicate or empty content — still counts as "handled" + } - return entry !== null || summary.trim().length > 0 + return true } /** @@ -118,8 +121,11 @@ export class ActionExecutor { source: "learning", tags: ["error-avoidance", ...errorKeys.map((key) => `error:${key}`)], }) + if (entry === null) { + // null means duplicate — still handled + } - return entry !== null || summary.trim().length > 0 + return true } /** @@ -138,8 +144,11 @@ export class ActionExecutor { source: "learning", tags: ["tool-preference", ...toolNames.map((toolName) => `tool:${toolName}`)], }) + if (entry === null) { + // null means duplicate — still handled + } - return entry !== null || summary.trim().length > 0 + return true } /** diff --git a/src/services/self-improving/CodeIndexAdapter.ts b/src/services/self-improving/CodeIndexAdapter.ts index dffe9f6976..937fcc5425 100644 --- a/src/services/self-improving/CodeIndexAdapter.ts +++ b/src/services/self-improving/CodeIndexAdapter.ts @@ -1,4 +1,4 @@ -import type { CodeIndexInfo } from "./types" +import type { CodeIndexInfo, Logger } from "./types" /** * CodeIndexAdapter - thin read-only adapter for code index integration. @@ -9,7 +9,10 @@ import type { CodeIndexInfo } from "./types" export class CodeIndexAdapter { private readonly getCodeIndexInfo: (() => CodeIndexInfo) | undefined - constructor(getCodeIndexInfo?: () => CodeIndexInfo) { + constructor( + private readonly logger?: Logger, + getCodeIndexInfo?: () => CodeIndexInfo, + ) { this.getCodeIndexInfo = getCodeIndexInfo } @@ -24,7 +27,10 @@ export class CodeIndexAdapter { try { return this.getCodeIndexInfo() - } catch { + } catch (error) { + this.logger?.appendLine( + `[CodeIndexAdapter] Error getting code index info: ${error instanceof Error ? error.message : String(error)}`, + ) return { available: false, hits: 0 } } } diff --git a/src/services/self-improving/CuratorService.ts b/src/services/self-improving/CuratorService.ts index fe09e1d44e..76add6e91b 100644 --- a/src/services/self-improving/CuratorService.ts +++ b/src/services/self-improving/CuratorService.ts @@ -168,6 +168,9 @@ export class CuratorService { return report } + // Set lastRunAt immediately to prevent concurrent runs + this.lastRunAt = now + if (this.config.backupsEnabled) { report.backupPath = await this.createBackup(runId) } @@ -178,7 +181,6 @@ export class CuratorService { report.stats.transitionsApplied = report.transitions.length this.assignStats(report) - this.lastRunAt = now this.firstRunDone = true await this.saveState() diff --git a/src/services/self-improving/MemoryStore.ts b/src/services/self-improving/MemoryStore.ts index 81ebb2f371..5341850fad 100644 --- a/src/services/self-improving/MemoryStore.ts +++ b/src/services/self-improving/MemoryStore.ts @@ -150,13 +150,22 @@ export class MemoryStore { */ getSnapshotString(): string { const context = this.getSnapshotContext() - if (context.entries.length === 0) { - return "" - } + 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 `- ${entry.content}${tags}` + return `- ${sanitized}${tags}` }) return `\n## Learned Context\n${lines.join("\n")}\n` diff --git a/src/services/self-improving/SelfImprovingManager.ts b/src/services/self-improving/SelfImprovingManager.ts index 895a3fe54c..6760114d15 100644 --- a/src/services/self-improving/SelfImprovingManager.ts +++ b/src/services/self-improving/SelfImprovingManager.ts @@ -49,6 +49,8 @@ export class SelfImprovingManager { private curatorTimer: ReturnType | null = null private promptRevision = 0 private lastUserActivityAt = 0 + private reviewInFlight = false + private curatorInFlight = false constructor(options: SelfImprovingManagerOptions) { this.globalStoragePath = options.globalStoragePath @@ -107,10 +109,11 @@ export class SelfImprovingManager { } } - async handleExperimentChange(enabled: boolean): Promise { + async handleExperimentChange(enabled?: boolean): Promise { try { const experimentEnabled = SelfImprovingManager.isExperimentEnabled(this.getExperiments()) - if (!enabled || !experimentEnabled) { + const shouldEnable = enabled ?? experimentEnabled + if (!shouldEnable || !experimentEnabled) { await this.dispose() return } @@ -121,6 +124,14 @@ export class SelfImprovingManager { } } + /** + * 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) { @@ -248,6 +259,12 @@ export class SelfImprovingManager { 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) { @@ -288,6 +305,8 @@ export class SelfImprovingManager { ) } catch (error) { this.logError("Review cycle error", error) + } finally { + this.reviewInFlight = false } } @@ -300,6 +319,11 @@ export class SelfImprovingManager { return undefined } + if (this.curatorInFlight) { + return undefined + } + this.curatorInFlight = true + try { const now = Date.now() const report = await this.curatorService.run( @@ -318,6 +342,8 @@ export class SelfImprovingManager { `[SelfImprovingManager] Curator cycle error: ${error instanceof Error ? error.message : String(error)}`, ) return undefined + } finally { + this.curatorInFlight = false } } @@ -449,7 +475,7 @@ export class SelfImprovingManager { feedbackCollector: new FeedbackCollector(), patternAnalyzer: new PatternAnalyzer(), improvementApplier: new ImprovementApplier(), - codeIndexAdapter: new CodeIndexAdapter(this.getCodeIndexInfo), + codeIndexAdapter: new CodeIndexAdapter(this.logger, this.getCodeIndexInfo), } } 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) + }) +}) From f15a9a6be05cabc9a136cc1db028309e0c4580ac Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Fri, 22 May 2026 16:15:11 +0800 Subject: [PATCH 05/32] feat: Implement memory backend system with AgentMemoryAdapter and MemoryBackendFactory --- src/core/webview/ClineProvider.ts | 2 +- src/services/self-improving/ActionExecutor.ts | 15 +- .../self-improving/AgentMemoryAdapter.ts | 252 ++++++++++++++++++ src/services/self-improving/MemoryBackend.ts | 42 +++ .../self-improving/MemoryBackendFactory.ts | 32 +++ src/services/self-improving/MemoryStore.ts | 92 ++++++- .../self-improving/SelfImprovingManager.ts | 33 ++- .../__tests__/ActionExecutor.spec.ts | 29 +- .../__tests__/AgentMemoryAdapter.spec.ts | 88 ++++++ .../__tests__/MemoryBackendFactory.spec.ts | 30 +++ .../__tests__/MemoryStore.spec.ts | 6 +- .../__tests__/SelfImprovingManager.spec.ts | 22 +- src/services/self-improving/index.ts | 3 + src/services/self-improving/types.ts | 4 + 14 files changed, 600 insertions(+), 50 deletions(-) create mode 100644 src/services/self-improving/AgentMemoryAdapter.ts create mode 100644 src/services/self-improving/MemoryBackend.ts create mode 100644 src/services/self-improving/MemoryBackendFactory.ts create mode 100644 src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts create mode 100644 src/services/self-improving/__tests__/MemoryBackendFactory.spec.ts diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1209f4d32a..741d1fbbf0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2335,7 +2335,7 @@ export class ClineProvider followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000, includeDiagnosticMessages: includeDiagnosticMessages ?? true, maxDiagnosticMessages: maxDiagnosticMessages ?? 50, - selfImprovingStatus: this.selfImprovingManager.getStatus(), + selfImprovingStatus: await this.selfImprovingManager.getStatus(), includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, diff --git a/src/services/self-improving/ActionExecutor.ts b/src/services/self-improving/ActionExecutor.ts index b7a7253c8d..01cf2b3077 100644 --- a/src/services/self-improving/ActionExecutor.ts +++ b/src/services/self-improving/ActionExecutor.ts @@ -1,6 +1,6 @@ import crypto from "crypto" -import type { MemoryStore } from "./MemoryStore" +import type { MemoryBackend } from "./MemoryBackend" import type { SkillProvenance, SkillUsageStore } from "./SkillUsageStore" import type { ImprovementAction, Logger } from "./types" @@ -18,11 +18,11 @@ import type { ImprovementAction, Logger } from "./types" * Failed actions remain pending for later retry. */ export class ActionExecutor { - private readonly memoryStore: MemoryStore + private readonly memoryStore: MemoryBackend private readonly skillUsageStore: SkillUsageStore private readonly logger: Logger - constructor(memoryStore: MemoryStore, skillUsageStore: SkillUsageStore, logger: Logger) { + constructor(memoryStore: MemoryBackend, skillUsageStore: SkillUsageStore, logger: Logger) { this.memoryStore = memoryStore this.skillUsageStore = skillUsageStore this.logger = logger @@ -94,7 +94,8 @@ export class ActionExecutor { return false } - const entry = await this.memoryStore.addEnvironmentEntry(summary, { + const entry = await this.memoryStore.store({ + content: summary, source: "learning", tags: ["learned", "prompt"], }) @@ -117,7 +118,8 @@ export class ActionExecutor { return false } - const entry = await this.memoryStore.addEnvironmentEntry(summary, { + const entry = await this.memoryStore.store({ + content: summary, source: "learning", tags: ["error-avoidance", ...errorKeys.map((key) => `error:${key}`)], }) @@ -140,7 +142,8 @@ export class ActionExecutor { return false } - const entry = await this.memoryStore.addEnvironmentEntry(summary, { + const entry = await this.memoryStore.store({ + content: summary, source: "learning", tags: ["tool-preference", ...toolNames.map((toolName) => `tool:${toolName}`)], }) diff --git a/src/services/self-improving/AgentMemoryAdapter.ts b/src/services/self-improving/AgentMemoryAdapter.ts new file mode 100644 index 0000000000..91fdc0ac8a --- /dev/null +++ b/src/services/self-improving/AgentMemoryAdapter.ts @@ -0,0 +1,252 @@ +import type { MemoryEntry } from "@roo-code/types" + +import type { MemoryBackend, MemoryBackendType } from "./MemoryBackend" +import type { Logger } from "./types" + +/** + * Default agentmemory server URL + */ +const DEFAULT_AGENTMEMORY_URL = "http://localhost:4001" + +type AgentMemoryApiResult = { + id: string + content: string + metadata?: Record +} + +/** + * AgentMemoryAdapter — implements MemoryBackend via agentmemory REST API. + * + * agentmemory (https://github.com/rohitg00/agentmemory) is a service-first + * memory system. This adapter connects to its REST API when the server is + * running, and gracefully degrades to no-op when it's not. + * + * Key REST endpoints used: + * POST /agentmemory/observe — store an observation + * POST /agentmemory/search — semantic search + * POST /agentmemory/remember — recall recent memories + * POST /agentmemory/forget — remove a memory + * GET /agentmemory/livez — health check + */ +export class AgentMemoryAdapter implements MemoryBackend { + private readonly baseUrl: string + private readonly logger: Logger + private available = false + private healthCheckInterval: ReturnType | null = null + private initialized = false + + constructor(logger: Logger, baseUrl?: string) { + this.baseUrl = baseUrl || DEFAULT_AGENTMEMORY_URL + this.logger = logger + } + + get backendType(): MemoryBackendType { + return "agentmemory" + } + + /** + * Initialize the adapter — check if agentmemory server is available. + */ + async initialize(): Promise { + if (this.initialized) return + + this.available = await this.checkHealth() + + if (this.available) { + this.logger.appendLine(`[AgentMemoryAdapter] Connected to agentmemory at ${this.baseUrl}`) + } else { + this.logger.appendLine( + `[AgentMemoryAdapter] agentmemory server not available at ${this.baseUrl} — will degrade gracefully`, + ) + } + + this.healthCheckInterval = setInterval(async () => { + this.available = await this.checkHealth() + }, 30000) + + this.initialized = true + } + + /** + * Check if agentmemory server is healthy. + */ + private async checkHealth(): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2000) + + try { + const response = await fetch(`${this.baseUrl}/agentmemory/livez`, { + signal: controller.signal, + }) + + return response.ok + } catch { + return false + } finally { + clearTimeout(timeout) + } + } + + /** + * Make a POST request to agentmemory API. + */ + private async post(path: string, body: unknown): Promise { + if (!this.available) return null + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + + try { + const response = await fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }) + + if (!response.ok) { + this.logger.appendLine(`[AgentMemoryAdapter] POST ${path} failed: ${response.status}`) + return null + } + + return (await response.json()) as T + } catch (error) { + this.logger.appendLine( + `[AgentMemoryAdapter] POST ${path} error: ${error instanceof Error ? error.message : String(error)}`, + ) + this.available = false + return null + } finally { + clearTimeout(timeout) + } + } + + /** + * Store a memory entry via agentmemory observe endpoint. + */ + async store(entry: Omit): Promise { + const result = await this.post<{ id: string }>("/agentmemory/observe", { + content: entry.content, + metadata: { + source: entry.source, + tags: entry.tags, + relevanceScore: entry.relevanceScore, + expiresAt: entry.expiresAt, + }, + }) + + if (!result) return null + + return { + id: result.id, + content: entry.content, + source: entry.source, + createdAt: Date.now(), + updatedAt: Date.now(), + relevanceScore: entry.relevanceScore, + tags: entry.tags, + expiresAt: entry.expiresAt, + } + } + + /** + * Search memory entries via agentmemory search endpoint. + */ + async search(query: string, maxResults: number = 10): Promise { + const result = await this.post<{ results: AgentMemoryApiResult[] }>("/agentmemory/search", { + query, + limit: maxResults, + }) + + if (!result?.results) return [] + + return result.results.map((entry) => this.mapResultToMemoryEntry(entry)) + } + + /** + * Recall recent memory entries via agentmemory remember endpoint. + */ + async recall(maxResults: number = 20): Promise { + const result = await this.post<{ memories: AgentMemoryApiResult[] }>("/agentmemory/remember", { + limit: maxResults, + }) + + if (!result?.memories) return [] + + return result.memories.map((entry) => this.mapResultToMemoryEntry(entry)) + } + + /** + * Remove a memory entry by ID via agentmemory forget endpoint. + */ + async forget(id: string): Promise { + const result = await this.post<{ success: boolean }>("/agentmemory/forget", { id }) + return result?.success === true + } + + /** + * Remove entries matching content substring. + * Uses agentmemory search + forget pattern. + */ + async forgetByContent(substring: string): Promise { + const entries = await this.search(substring, 50) + let removed = 0 + + for (const entry of entries) { + if (entry.content.toLowerCase().includes(substring.toLowerCase())) { + const ok = await this.forget(entry.id) + if (ok) removed += 1 + } + } + + return removed + } + + /** + * Get backend statistics. + */ + async getStats(): Promise<{ entryCount: number; backend: string }> { + if (!this.available) { + return { entryCount: 0, backend: "agentmemory (unavailable)" } + } + + const memories = await this.recall(1000) + return { + entryCount: memories.length, + backend: "agentmemory", + } + } + + /** + * Clear all entries via agentmemory governance delete. + */ + async clear(): Promise { + await this.post("/agentmemory/governance/bulk-delete", { all: true }) + } + + /** + * Dispose the adapter — stop health check interval. + */ + async dispose(): Promise { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval) + this.healthCheckInterval = null + } + + this.available = false + this.initialized = false + } + + private mapResultToMemoryEntry(entry: AgentMemoryApiResult): MemoryEntry { + return { + id: entry.id, + content: entry.content, + source: (entry.metadata?.source as MemoryEntry["source"]) || "learning", + createdAt: (entry.metadata?.createdAt as number) || Date.now(), + updatedAt: (entry.metadata?.updatedAt as number) || Date.now(), + relevanceScore: entry.metadata?.relevanceScore as number | undefined, + tags: entry.metadata?.tags as string[] | undefined, + expiresAt: entry.metadata?.expiresAt as number | undefined, + } + } +} diff --git a/src/services/self-improving/MemoryBackend.ts b/src/services/self-improving/MemoryBackend.ts new file mode 100644 index 0000000000..44a47e67ed --- /dev/null +++ b/src/services/self-improving/MemoryBackend.ts @@ -0,0 +1,42 @@ +import type { MemoryEntry } from "@roo-code/types" + +/** + * MemoryBackend — abstract interface for memory storage backends. + * + * Both the built-in MemoryStore and the optional agentmemory adapter + * implement this interface, allowing the SelfImprovingManager to + * switch between backends transparently. + */ +export interface MemoryBackend { + /** Initialize the backend */ + initialize(): Promise + + /** Store a memory entry */ + store(entry: Omit): Promise + + /** Search memory entries by query */ + search(query: string, maxResults?: number): Promise + + /** Recall recent memory entries */ + recall(maxResults?: number): Promise + + /** Remove a memory entry by ID */ + forget(id: string): Promise + + /** Remove entries matching a substring */ + forgetByContent(substring: string): Promise + + /** Get backend statistics */ + getStats(): Promise<{ entryCount: number; backend: string }> + + /** Clear all entries */ + clear(): Promise + + /** Dispose the backend */ + dispose(): Promise +} + +/** + * MemoryBackendType — supported backend implementations + */ +export type MemoryBackendType = "builtin" | "agentmemory" diff --git a/src/services/self-improving/MemoryBackendFactory.ts b/src/services/self-improving/MemoryBackendFactory.ts new file mode 100644 index 0000000000..b2e04923ba --- /dev/null +++ b/src/services/self-improving/MemoryBackendFactory.ts @@ -0,0 +1,32 @@ +import { AgentMemoryAdapter } from "./AgentMemoryAdapter" +import type { MemoryBackend, MemoryBackendType } from "./MemoryBackend" +import { MemoryStore } from "./MemoryStore" +import type { Logger } from "./types" + +/** + * MemoryBackendFactory — creates the appropriate memory backend + * based on configuration. + * + * Supports: + * - "builtin" (default): Zoo-Code's own MemoryStore + * - "agentmemory": agentmemory REST API adapter + */ +export class MemoryBackendFactory { + /** + * Create a memory backend. + * + * @param type - Backend type ("builtin" | "agentmemory") + * @param baseDir - Base directory for built-in storage + * @param logger - Logger instance + * @param agentMemoryUrl - Optional agentmemory server URL + */ + static create(type: MemoryBackendType, baseDir: string, logger: Logger, agentMemoryUrl?: string): MemoryBackend { + switch (type) { + case "agentmemory": + return new AgentMemoryAdapter(logger, agentMemoryUrl) + case "builtin": + default: + return new MemoryStore(baseDir, logger) + } + } +} diff --git a/src/services/self-improving/MemoryStore.ts b/src/services/self-improving/MemoryStore.ts index 5341850fad..630e197f88 100644 --- a/src/services/self-improving/MemoryStore.ts +++ b/src/services/self-improving/MemoryStore.ts @@ -4,6 +4,7 @@ import crypto from "crypto" import { safeWriteJson } from "../../utils/safeWriteJson" import type { MemoryContext, MemoryEntry } from "@roo-code/types" +import type { MemoryBackend, MemoryBackendType } from "./MemoryBackend" import type { Logger } from "./types" /** @@ -28,7 +29,7 @@ const MEMORY_SOURCES: ReadonlySet = new Set(["learning", * - Substring-based replace and remove * - Bounded retention per store */ -export class MemoryStore { +export class MemoryStore implements MemoryBackend { private readonly baseDir: string private readonly logger: Logger private environment: MemoryEntry[] = [] @@ -49,6 +50,10 @@ export class MemoryStore { this.logger = logger } + get backendType(): MemoryBackendType { + return "builtin" + } + /** * Initialize the memory store - load persisted entries from disk. */ @@ -72,6 +77,74 @@ export class MemoryStore { } } + async store(entry: Omit): Promise { + return this.addEnvironmentEntry(entry.content, { + source: entry.source, + tags: entry.tags, + expiresAt: entry.expiresAt, + }) + } + + async search(query: string, maxResults: number = 10): Promise { + await this.ensureInitialized() + + const lowerQuery = query.toLowerCase() + const allEntries = [...this.environment, ...this.userProfile] + return allEntries + .filter((entry) => entry.content.toLowerCase().includes(lowerQuery)) + .slice(0, maxResults) + .map((entry) => this.cloneEntry(entry)) + } + + async recall(maxResults: number = 20): Promise { + await this.ensureInitialized() + + const allEntries = [...this.environment, ...this.userProfile] + return allEntries.slice(-maxResults).map((entry) => this.cloneEntry(entry)) + } + + async forget(id: string): Promise { + await this.ensureInitialized() + + const envIdx = this.environment.findIndex((entry) => entry.id === id) + if (envIdx >= 0) { + this.environment.splice(envIdx, 1) + await this.persistStore("environment") + return true + } + + const userIdx = this.userProfile.findIndex((entry) => entry.id === id) + if (userIdx >= 0) { + this.userProfile.splice(userIdx, 1) + await this.persistStore("userProfile") + return true + } + + return false + } + + async forgetByContent(substring: string): Promise { + await this.ensureInitialized() + + const lowerSubstring = substring.toLowerCase() + let removed = 0 + + const envBefore = this.environment.length + this.environment = this.environment.filter((entry) => !entry.content.toLowerCase().includes(lowerSubstring)) + removed += envBefore - this.environment.length + + const userBefore = this.userProfile.length + this.userProfile = this.userProfile.filter((entry) => !entry.content.toLowerCase().includes(lowerSubstring)) + removed += userBefore - this.userProfile.length + + if (removed > 0) { + await this.persistStore("environment") + await this.persistStore("userProfile") + } + + return removed + } + /** * Load entries from disk with duplicate rejection. */ @@ -479,14 +552,19 @@ export class MemoryStore { /** * Get count of entries per store. */ - getStats(): { environment: number; userProfile: number; revision: number } { + async getStats(): Promise<{ entryCount: number; backend: string }> { + await this.ensureInitialized() + return { - environment: this.environment.length, - userProfile: this.userProfile.length, - revision: this.revision, + entryCount: this.environment.length + this.userProfile.length, + backend: "builtin", } } + async clear(): Promise { + await this.reset() + } + /** * Reset all memory stores. */ @@ -510,4 +588,8 @@ export class MemoryStore { ) } } + + async dispose(): Promise { + this.initialized = false + } } diff --git a/src/services/self-improving/SelfImprovingManager.ts b/src/services/self-improving/SelfImprovingManager.ts index 6760114d15..19d81421f2 100644 --- a/src/services/self-improving/SelfImprovingManager.ts +++ b/src/services/self-improving/SelfImprovingManager.ts @@ -12,6 +12,8 @@ import { FeedbackCollector } from "./FeedbackCollector" import { PatternAnalyzer } from "./PatternAnalyzer" import { ImprovementApplier } from "./ImprovementApplier" import { CodeIndexAdapter } from "./CodeIndexAdapter" +import type { MemoryBackend } from "./MemoryBackend" +import { MemoryBackendFactory } from "./MemoryBackendFactory" import { MemoryStore } from "./MemoryStore" import { SkillUsageStore } from "./SkillUsageStore" import { ActionExecutor } from "./ActionExecutor" @@ -36,7 +38,7 @@ export class SelfImprovingManager { private readonly logger: Logger private readonly getExperiments: () => Record | undefined private readonly getCodeIndexInfo: SelfImprovingManagerOptions["getCodeIndexInfo"] - public readonly memoryStore: MemoryStore + public readonly memoryStore: MemoryBackend public readonly skillUsageStore: SkillUsageStore public readonly curatorService: CuratorService public readonly reviewPromptFactory: ReviewPromptFactory @@ -57,7 +59,12 @@ export class SelfImprovingManager { this.logger = options.logger this.getExperiments = options.getExperiments this.getCodeIndexInfo = options.getCodeIndexInfo - this.memoryStore = new MemoryStore(options.globalStoragePath, options.logger) + this.memoryStore = MemoryBackendFactory.create( + options.memoryBackend || "builtin", + options.globalStoragePath, + options.logger, + options.agentMemoryUrl, + ) this.skillUsageStore = new SkillUsageStore(options.globalStoragePath, options.logger) this.actionExecutor = new ActionExecutor(this.memoryStore, this.skillUsageStore, options.logger) this.curatorService = new CuratorService( @@ -143,8 +150,12 @@ export class SelfImprovingManager { try { if (this.started) { await this.runtime?.store.persist() - this.memoryStore.takeSnapshot() + if (this.memoryStore instanceof MemoryStore) { + this.memoryStore.takeSnapshot() + } } + + await this.memoryStore.dispose() } catch (error) { this.logError("Persist on dispose error", error) } finally { @@ -374,24 +385,29 @@ export class SelfImprovingManager { } try { - return this.memoryStore.getSnapshotString() + if (this.memoryStore instanceof MemoryStore) { + return this.memoryStore.getSnapshotString() + } + + return "" } catch { return "" } } - getStatus(): { + async getStatus(): Promise<{ enabled: boolean started: boolean patternCount: number eventCount: number actionCount: number memoryEntries: number + memoryBackend?: string skillRecords: number curatorStatus: ReturnType lastReviewAt?: number lastCuratorRunAt?: number - } { + }> { const enabled = SelfImprovingManager.isExperimentEnabled(this.getExperiments()) const curatorStatus = this.curatorService.getStatus() if (!enabled) { @@ -422,7 +438,7 @@ export class SelfImprovingManager { try { const telemetry = this.runtime.store.getTelemetry() - const memoryStats = this.memoryStore.getStats() + const memStats = await this.memoryStore.getStats() const skillStats = this.skillUsageStore.getStats() return { enabled: true, @@ -430,7 +446,8 @@ export class SelfImprovingManager { patternCount: this.runtime.store.getPatterns().length, eventCount: this.runtime.store.getRecentEvents().length, actionCount: this.runtime.store.getPendingActions().length, - memoryEntries: memoryStats.environment + memoryStats.userProfile, + memoryEntries: memStats.entryCount, + memoryBackend: memStats.backend, skillRecords: skillStats.total, curatorStatus, lastReviewAt: telemetry.lastReviewAt, diff --git a/src/services/self-improving/__tests__/ActionExecutor.spec.ts b/src/services/self-improving/__tests__/ActionExecutor.spec.ts index b34110c1ee..2b66b04519 100644 --- a/src/services/self-improving/__tests__/ActionExecutor.spec.ts +++ b/src/services/self-improving/__tests__/ActionExecutor.spec.ts @@ -10,7 +10,7 @@ describe("ActionExecutor", () => { it("writes prompt, error, and tool guidance into memory", async () => { const memoryStore = { - addEnvironmentEntry: vi.fn().mockResolvedValue({ id: "mem-1" }), + store: vi.fn().mockResolvedValue({ id: "mem-1" }), } as any const skillUsageStore = { getOrCreate: vi.fn() } as any const executor = new ActionExecutor(memoryStore, skillUsageStore, logger) @@ -42,26 +42,25 @@ describe("ActionExecutor", () => { 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", { + expect(memoryStore.store).toHaveBeenNthCalledWith(1, { + content: "Prefer semantic search before regex search", + source: "learning", + tags: ["learned", "prompt"], + }) + expect(memoryStore.store).toHaveBeenNthCalledWith(2, { + content: "Handle ENOENT before retry", source: "learning", tags: ["error-avoidance", "error:ENOENT"], }) - expect(memoryStore.addEnvironmentEntry).toHaveBeenNthCalledWith(3, "Use codebase_search before search_files", { + expect(memoryStore.store).toHaveBeenNthCalledWith(3, { + content: "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 memoryStore = { store: vi.fn() } as any const skillUsageStore = { getOrCreate: vi.fn() } as any const executor = new ActionExecutor(memoryStore, skillUsageStore, logger) @@ -88,11 +87,7 @@ describe("ActionExecutor", () => { }) it("keeps invalid actions pending by reporting failure", async () => { - const executor = new ActionExecutor( - { addEnvironmentEntry: vi.fn() } as any, - { getOrCreate: vi.fn() } as any, - logger, - ) + const executor = new ActionExecutor({ store: vi.fn() } as any, { getOrCreate: vi.fn() } as any, logger) await expect( executor.execute({ diff --git a/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts b/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts new file mode 100644 index 0000000000..a84d1337e4 --- /dev/null +++ b/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { AgentMemoryAdapter } from "../AgentMemoryAdapter" + +describe("AgentMemoryAdapter", () => { + const logger = { appendLine: vi.fn() } + const adapters: AgentMemoryAdapter[] = [] + + beforeEach(() => { + logger.appendLine.mockReset() + }) + + afterEach(async () => { + await Promise.all(adapters.splice(0).map((adapter) => adapter.dispose())) + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + function createAdapter(): AgentMemoryAdapter { + const adapter = new AgentMemoryAdapter(logger) + adapters.push(adapter) + return adapter + } + + it("should report unavailable when server is not running", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Connection refused"))) + + const adapter = createAdapter() + await adapter.initialize() + + const stats = await adapter.getStats() + expect(stats.backend).toContain("unavailable") + expect(stats.entryCount).toBe(0) + }) + + it("should return null from store when unavailable", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Connection refused"))) + + const adapter = createAdapter() + await adapter.initialize() + + const result = await adapter.store({ + content: "test memory", + source: "learning", + }) + + expect(result).toBeNull() + }) + + it("should return empty array from search when unavailable", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Connection refused"))) + + const adapter = createAdapter() + await adapter.initialize() + + const results = await adapter.search("test") + expect(results).toEqual([]) + }) + + it("should return empty array from recall when unavailable", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Connection refused"))) + + const adapter = createAdapter() + await adapter.initialize() + + const results = await adapter.recall() + expect(results).toEqual([]) + }) + + it("should clean up health check interval on dispose", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Connection refused"))) + + const adapter = createAdapter() + await adapter.initialize() + await adapter.dispose() + + const result = await adapter.store({ + content: "test", + source: "learning", + }) + expect(result).toBeNull() + }) + + it("should have correct backend type", () => { + const adapter = createAdapter() + expect(adapter.backendType).toBe("agentmemory") + }) +}) diff --git a/src/services/self-improving/__tests__/MemoryBackendFactory.spec.ts b/src/services/self-improving/__tests__/MemoryBackendFactory.spec.ts new file mode 100644 index 0000000000..0346f88638 --- /dev/null +++ b/src/services/self-improving/__tests__/MemoryBackendFactory.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest" + +import { AgentMemoryAdapter } from "../AgentMemoryAdapter" +import { MemoryBackendFactory } from "../MemoryBackendFactory" +import { MemoryStore } from "../MemoryStore" + +describe("MemoryBackendFactory", () => { + const logger = { appendLine: vi.fn() } + const baseDir = "/tmp/test" + + it("should create built-in backend by default", () => { + const backend = MemoryBackendFactory.create("builtin", baseDir, logger) + expect(backend).toBeInstanceOf(MemoryStore) + }) + + it("should create agentmemory backend when specified", () => { + const backend = MemoryBackendFactory.create("agentmemory", baseDir, logger) + expect(backend).toBeInstanceOf(AgentMemoryAdapter) + }) + + it("should create built-in backend for unknown type", () => { + const backend = MemoryBackendFactory.create("builtin" as any, baseDir, logger) + expect(backend).toBeInstanceOf(MemoryStore) + }) + + it("should pass agentMemoryUrl to AgentMemoryAdapter", () => { + const backend = MemoryBackendFactory.create("agentmemory", baseDir, logger, "http://custom:5000") + expect(backend).toBeInstanceOf(AgentMemoryAdapter) + }) +}) diff --git a/src/services/self-improving/__tests__/MemoryStore.spec.ts b/src/services/self-improving/__tests__/MemoryStore.spec.ts index 7b854ed8fc..b4db3ae0fd 100644 --- a/src/services/self-improving/__tests__/MemoryStore.spec.ts +++ b/src/services/self-improving/__tests__/MemoryStore.spec.ts @@ -58,7 +58,7 @@ describe("MemoryStore", () => { const store = new MemoryStore(tempDir, logger) await store.initialize() - expect(store.getStats()).toEqual({ environment: 2, userProfile: 1, revision: 1 }) + expect(await store.getStats()).toEqual({ entryCount: 3, backend: "builtin" }) expect(store.getSnapshotString()).toContain("Prefer semantic search first") expect(store.getSnapshotString()).not.toContain("prefer semantic search first") @@ -66,7 +66,7 @@ describe("MemoryStore", () => { tags: ["live"], }) - expect(store.getStats().environment).toBe(3) + expect((await store.getStats()).entryCount).toBe(4) expect(store.getSnapshotString()).not.toContain("Live write should not appear until next snapshot") store.takeSnapshot() @@ -92,7 +92,7 @@ describe("MemoryStore", () => { await fs.readFile(path.join(tempDir, "self-improving", "memory", "environment.json"), "utf8"), ) as Array<{ content: string }> - expect(store.getStats().environment).toBe(50) + expect((await store.getStats()).entryCount).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) diff --git a/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts index 173d773544..d5f54ad77d 100644 --- a/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts +++ b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts @@ -66,10 +66,12 @@ function createStoreMock() { function createMemoryStoreMock() { return { + backendType: "builtin", initialize: vi.fn().mockResolvedValue(undefined), getSnapshotString: vi.fn().mockReturnValue(""), - getStats: vi.fn().mockReturnValue({ environment: 0, userProfile: 0, revision: 1 }), + getStats: vi.fn().mockResolvedValue({ entryCount: 0, backend: "builtin" }), takeSnapshot: vi.fn(), + dispose: vi.fn().mockResolvedValue(undefined), } } @@ -181,10 +183,9 @@ vi.mock("../CodeIndexAdapter", () => ({ })) vi.mock("../MemoryStore", () => ({ - MemoryStore: vi.fn().mockImplementation(() => { - const store = createMemoryStoreMock() - mockState.memoryStores.push(store) - return store + MemoryStore: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, createMemoryStoreMock()) + mockState.memoryStores.push(this) }), })) @@ -272,7 +273,7 @@ describe("SelfImprovingManager", () => { expect(mockState.stores).toHaveLength(0) expect(vi.getTimerCount()).toBe(0) - expect(manager.getStatus()).toEqual({ + expect(await manager.getStatus()).toEqual({ enabled: false, started: false, patternCount: 0, @@ -297,7 +298,7 @@ describe("SelfImprovingManager", () => { 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 }) + expect(await manager.getStatus()).toMatchObject({ enabled: true, started: true }) }) it("runs a review cycle from task completion triggers", async () => { @@ -372,7 +373,7 @@ describe("SelfImprovingManager", () => { 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 }) + memoryStore.getStats.mockResolvedValue({ entryCount: 3, backend: "builtin" }) mockState.skillUsageStores[0].getStats.mockReturnValue({ total: 4, active: 3, @@ -383,15 +384,16 @@ describe("SelfImprovingManager", () => { }) expect(manager.getPromptContextString()).toBe("\n## Learned Context\n- Search relevant code before editing\n") - expect(manager.getStatus()).toMatchObject({ memoryEntries: 3, skillRecords: 4 }) + expect(await manager.getStatus()).toMatchObject({ memoryEntries: 3, memoryBackend: "builtin", skillRecords: 4 }) experiments = { selfImproving: false } await manager.handleExperimentChange(false) expect(mockState.stores[0].persist).toHaveBeenCalledTimes(1) expect(memoryStore.takeSnapshot).toHaveBeenCalledTimes(1) + expect(memoryStore.dispose).toHaveBeenCalledTimes(1) expect(vi.getTimerCount()).toBe(0) - expect(manager.getStatus()).toEqual({ + expect(await manager.getStatus()).toEqual({ enabled: false, started: false, patternCount: 0, diff --git a/src/services/self-improving/index.ts b/src/services/self-improving/index.ts index 92e546fedd..7e42e37376 100644 --- a/src/services/self-improving/index.ts +++ b/src/services/self-improving/index.ts @@ -14,6 +14,8 @@ export { FeedbackCollector } from "./FeedbackCollector" export { PatternAnalyzer } from "./PatternAnalyzer" export { ImprovementApplier } from "./ImprovementApplier" export { CodeIndexAdapter } from "./CodeIndexAdapter" +export { MemoryBackendFactory } from "./MemoryBackendFactory" +export { AgentMemoryAdapter } from "./AgentMemoryAdapter" export { MemoryStore } from "./MemoryStore" export { SkillUsageStore } from "./SkillUsageStore" export { ActionExecutor } from "./ActionExecutor" @@ -22,6 +24,7 @@ export { ReviewPromptFactory } from "./ReviewPromptFactory" export { TranscriptRecall } from "./TranscriptRecall" export type { CodeIndexInfo, Logger, PromptContext, SelfImprovingManagerOptions, TaskEventInfo } from "./types" +export type { MemoryBackend, MemoryBackendType } from "./MemoryBackend" export type { MemoryStoreType } from "./MemoryStore" export type { SkillTelemetryRecord, SkillProvenance, SkillLifecycleState } from "./SkillUsageStore" export type { CuratorConfig, CuratorReport } from "./CuratorService" diff --git a/src/services/self-improving/types.ts b/src/services/self-improving/types.ts index 926d9f8f25..1c1c957c54 100644 --- a/src/services/self-improving/types.ts +++ b/src/services/self-improving/types.ts @@ -77,6 +77,10 @@ export interface SelfImprovingManagerOptions { logger: Logger getExperiments: () => Record | undefined getCodeIndexInfo?: () => CodeIndexInfo + /** Memory backend type: "builtin" (default) or "agentmemory" */ + memoryBackend?: "builtin" | "agentmemory" + /** agentmemory server URL (default: http://localhost:4001) */ + agentMemoryUrl?: string /** Optional curator configuration overrides */ curatorConfig?: { intervalMs?: number From fb39c36c05f8b4797f950137bf0a893f37a7347c Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 06:28:51 +0800 Subject: [PATCH 06/32] fix: harden self-improving memory deletion and recall - guard empty forgetByContent queries in both memory backends - sort recall results globally by timestamp across stores - add focused regression tests for deletion guards and recall ordering --- .../self-improving/AgentMemoryAdapter.ts | 7 ++++++- src/services/self-improving/MemoryStore.ts | 16 +++++++++++++--- .../__tests__/AgentMemoryAdapter.spec.ts | 10 ++++++++++ .../self-improving/__tests__/MemoryStore.spec.ts | 5 ++++- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/services/self-improving/AgentMemoryAdapter.ts b/src/services/self-improving/AgentMemoryAdapter.ts index 91fdc0ac8a..273012893e 100644 --- a/src/services/self-improving/AgentMemoryAdapter.ts +++ b/src/services/self-improving/AgentMemoryAdapter.ts @@ -189,11 +189,16 @@ export class AgentMemoryAdapter implements MemoryBackend { * Uses agentmemory search + forget pattern. */ async forgetByContent(substring: string): Promise { + const normalized = substring.trim().toLowerCase() + if (!normalized) { + return 0 + } + const entries = await this.search(substring, 50) let removed = 0 for (const entry of entries) { - if (entry.content.toLowerCase().includes(substring.toLowerCase())) { + if (entry.content.toLowerCase().includes(normalized)) { const ok = await this.forget(entry.id) if (ok) removed += 1 } diff --git a/src/services/self-improving/MemoryStore.ts b/src/services/self-improving/MemoryStore.ts index 630e197f88..58f62b2e92 100644 --- a/src/services/self-improving/MemoryStore.ts +++ b/src/services/self-improving/MemoryStore.ts @@ -99,8 +99,14 @@ export class MemoryStore implements MemoryBackend { async recall(maxResults: number = 20): Promise { await this.ensureInitialized() - const allEntries = [...this.environment, ...this.userProfile] - return allEntries.slice(-maxResults).map((entry) => this.cloneEntry(entry)) + return [...this.environment, ...this.userProfile] + .sort((left, right) => { + const leftTimestamp = left.updatedAt ?? left.createdAt + const rightTimestamp = right.updatedAt ?? right.createdAt + return rightTimestamp - leftTimestamp + }) + .slice(0, maxResults) + .map((entry) => this.cloneEntry(entry)) } async forget(id: string): Promise { @@ -126,7 +132,11 @@ export class MemoryStore implements MemoryBackend { async forgetByContent(substring: string): Promise { await this.ensureInitialized() - const lowerSubstring = substring.toLowerCase() + const lowerSubstring = substring.trim().toLowerCase() + if (!lowerSubstring) { + return 0 + } + let removed = 0 const envBefore = this.environment.length diff --git a/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts b/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts index a84d1337e4..39c5e0b494 100644 --- a/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts +++ b/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts @@ -67,6 +67,16 @@ describe("AgentMemoryAdapter", () => { expect(results).toEqual([]) }) + it("should ignore empty forgetByContent queries", async () => { + const fetchSpy = vi.fn() + vi.stubGlobal("fetch", fetchSpy) + + const adapter = createAdapter() + + await expect(adapter.forgetByContent(" ")).resolves.toBe(0) + expect(fetchSpy).not.toHaveBeenCalled() + }) + it("should clean up health check interval on dispose", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Connection refused"))) diff --git a/src/services/self-improving/__tests__/MemoryStore.spec.ts b/src/services/self-improving/__tests__/MemoryStore.spec.ts index b4db3ae0fd..dbae6df073 100644 --- a/src/services/self-improving/__tests__/MemoryStore.spec.ts +++ b/src/services/self-improving/__tests__/MemoryStore.spec.ts @@ -59,6 +59,7 @@ describe("MemoryStore", () => { await store.initialize() expect(await store.getStats()).toEqual({ entryCount: 3, backend: "builtin" }) + await expect(store.recall(2)).resolves.toMatchObject([{ id: "user-1" }, { id: "env-3" }]) expect(store.getSnapshotString()).toContain("Prefer semantic search first") expect(store.getSnapshotString()).not.toContain("prefer semantic search first") @@ -81,8 +82,10 @@ describe("MemoryStore", () => { await expect(store.addEnvironmentEntry("alpha guidance")).resolves.toBeNull() await store.addEnvironmentEntry("Beta guidance") + await store.addUserProfileEntry("User prefers short answers") await store.replaceEnvironmentEntry("beta", "Gamma guidance", { tags: ["replacement"] }) await expect(store.removeEnvironmentEntry("alpha")).resolves.toBe(true) + await expect(store.forgetByContent(" ")).resolves.toBe(0) for (let index = 0; index < 55; index += 1) { await store.addEnvironmentEntry(`Fact ${index}`) @@ -92,7 +95,7 @@ describe("MemoryStore", () => { await fs.readFile(path.join(tempDir, "self-improving", "memory", "environment.json"), "utf8"), ) as Array<{ content: string }> - expect((await store.getStats()).entryCount).toBe(50) + expect((await store.getStats()).entryCount).toBe(51) expect(persisted).toHaveLength(50) expect(persisted.some((entry) => entry.content === "Gamma guidance")).toBe(false) expect(persisted.some((entry) => entry.content === "Alpha guidance")).toBe(false) From 9f2688ff7f613c16eb8ccd31d2fe653633566b83 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 06:31:37 +0800 Subject: [PATCH 07/32] fix: tighten self-improving pattern merges - match tool-based patterns by structured context instead of summaries - preserve cumulative frequency for existing tool preferences - add regression tests for tool-combination and preference updates --- .../self-improving/PatternAnalyzer.ts | 18 +++- .../__tests__/PatternAnalyzer.spec.ts | 93 +++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 src/services/self-improving/__tests__/PatternAnalyzer.spec.ts diff --git a/src/services/self-improving/PatternAnalyzer.ts b/src/services/self-improving/PatternAnalyzer.ts index fb407b2a4e..afd25cfa97 100644 --- a/src/services/self-improving/PatternAnalyzer.ts +++ b/src/services/self-improving/PatternAnalyzer.ts @@ -122,7 +122,7 @@ export class PatternAnalyzer { for (const [toolKey, toolEvents] of byToolSet) { const frequency = toolEvents.length const existing = existingPatterns.find( - (pattern) => pattern.patternType === "tool" && pattern.summary.includes(toolKey), + (pattern) => pattern.patternType === "tool" && this.hasMatchingToolNames(pattern, toolKey.split(",")), ) if (existing) { @@ -186,13 +186,13 @@ export class PatternAnalyzer { const successRate = counts.success / total const existing = existingPatterns.find( - (pattern) => pattern.patternType === "prompt" && pattern.summary.includes(toolName), + (pattern) => pattern.patternType === "prompt" && this.hasMatchingToolNames(pattern, [toolName]), ) if (existing) { patterns.push({ ...existing, - frequency: total, + frequency: existing.frequency + total, lastSeenAt: now, successRate, confidenceScore: Math.min(1, existing.confidenceScore + 0.02), @@ -284,4 +284,16 @@ export class PatternAnalyzer { return [...names] } + + private hasMatchingToolNames(pattern: LearnedPattern, toolNames: string[]): boolean { + const existingToolNames = pattern.context.toolNames + if (!existingToolNames || existingToolNames.length !== toolNames.length) { + return false + } + + const normalizedExisting = [...existingToolNames].sort() + const normalizedIncoming = [...toolNames].sort() + + return normalizedExisting.every((toolName, index) => toolName === normalizedIncoming[index]) + } } diff --git a/src/services/self-improving/__tests__/PatternAnalyzer.spec.ts b/src/services/self-improving/__tests__/PatternAnalyzer.spec.ts new file mode 100644 index 0000000000..db07eb0a17 --- /dev/null +++ b/src/services/self-improving/__tests__/PatternAnalyzer.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest" + +import { PatternAnalyzer } from "../PatternAnalyzer" +import type { LearnedPattern, LearningEvent } from "../types" + +function createEvent(id: string, signal: LearningEvent["signal"], toolNames: string[]): LearningEvent { + return { + id, + signal, + timestamp: Number(id.replace(/\D/g, "")) || 1, + context: { + toolNames, + }, + outcome: {}, + } +} + +function createPattern(overrides: Partial): LearnedPattern { + return { + id: "pattern-1", + patternType: "tool", + state: "active", + summary: "Effective tool combination: browser,search", + confidenceScore: 0.5, + frequency: 4, + successRate: 0.8, + firstSeenAt: 1, + lastSeenAt: 1, + sourceSignals: ["TASK_SUCCESS"], + context: { + toolNames: ["browser", "search"], + }, + ...overrides, + } +} + +describe("PatternAnalyzer", () => { + it("does not merge tool-combination patterns by summary substring alone", () => { + const analyzer = new PatternAnalyzer() + const existingPatterns = [createPattern({})] + const events = [ + createEvent("event-1", "TASK_SUCCESS", ["search"]), + createEvent("event-2", "TASK_SUCCESS", ["search"]), + createEvent("event-3", "TASK_SUCCESS", ["search"]), + ] + + const patterns = analyzer.analyze(events, existingPatterns) + const toolPatterns = patterns.filter((pattern) => pattern.patternType === "tool") + + expect(toolPatterns).toHaveLength(1) + expect(toolPatterns[0]).toMatchObject({ + id: expect.not.stringMatching(/^pattern-1$/), + summary: "Effective tool combination: search", + frequency: 3, + context: { + toolNames: ["search"], + }, + }) + }) + + it("preserves cumulative frequency for existing tool-preference patterns", () => { + const analyzer = new PatternAnalyzer() + const existingPatterns = [ + createPattern({ + id: "prompt-pattern", + patternType: "prompt", + summary: "Prefer terminal for reliable results", + frequency: 5, + context: { + toolNames: ["terminal"], + }, + }), + ] + const events = [ + createEvent("event-1", "TASK_SUCCESS", ["terminal"]), + createEvent("event-2", "TASK_SUCCESS", ["terminal"]), + createEvent("event-3", "TASK_FAILURE", ["terminal"]), + ] + + const patterns = analyzer.analyze(events, existingPatterns) + const promptPatterns = patterns.filter((pattern) => pattern.patternType === "prompt") + + expect(promptPatterns).toHaveLength(1) + expect(promptPatterns[0]).toMatchObject({ + id: "prompt-pattern", + frequency: 8, + successRate: 2 / 3, + context: { + toolNames: ["terminal"], + }, + }) + }) +}) From 53490f1b0aeb350002b87ef9085b849c1805f140 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 06:33:35 +0800 Subject: [PATCH 08/32] test: tighten self-improving regression coverage - exercise unknown memory-backend fallback explicitly - replace fixed sleep with bounded polling in skill usage persistence test - correct the package types vitest file header path --- .../src/__tests__/learning-memory.test.ts | 2 +- .../__tests__/MemoryBackendFactory.spec.ts | 2 +- .../__tests__/SkillUsageStore.spec.ts | 22 +++++++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/types/src/__tests__/learning-memory.test.ts b/packages/types/src/__tests__/learning-memory.test.ts index 55ffa8fde9..6cea0de8ff 100644 --- a/packages/types/src/__tests__/learning-memory.test.ts +++ b/packages/types/src/__tests__/learning-memory.test.ts @@ -1,4 +1,4 @@ -// npx vitest run src/__tests__/learning-memory.test.ts +// npx vitest run packages/types/src/__tests__/learning-memory.test.ts import { DEFAULT_LEARNING_CONFIG, diff --git a/src/services/self-improving/__tests__/MemoryBackendFactory.spec.ts b/src/services/self-improving/__tests__/MemoryBackendFactory.spec.ts index 0346f88638..3ee8cabaae 100644 --- a/src/services/self-improving/__tests__/MemoryBackendFactory.spec.ts +++ b/src/services/self-improving/__tests__/MemoryBackendFactory.spec.ts @@ -19,7 +19,7 @@ describe("MemoryBackendFactory", () => { }) it("should create built-in backend for unknown type", () => { - const backend = MemoryBackendFactory.create("builtin" as any, baseDir, logger) + const backend = MemoryBackendFactory.create("unknown-backend" as any, baseDir, logger) expect(backend).toBeInstanceOf(MemoryStore) }) diff --git a/src/services/self-improving/__tests__/SkillUsageStore.spec.ts b/src/services/self-improving/__tests__/SkillUsageStore.spec.ts index 1a40ccf561..e0e4957021 100644 --- a/src/services/self-improving/__tests__/SkillUsageStore.spec.ts +++ b/src/services/self-improving/__tests__/SkillUsageStore.spec.ts @@ -22,11 +22,25 @@ describe("SkillUsageStore", () => { await store.initialize() const record = store.getOrCreate("skill-1", "Generated Skill", "agent") - await new Promise((resolve) => setTimeout(resolve, 20)) + const persistedPath = path.join(tempDir, "self-improving", "skill-usage.json") + const deadline = Date.now() + 1000 + let persisted: Array<{ skillId: string; skillName: string }> = [] - const persisted = JSON.parse( - await fs.readFile(path.join(tempDir, "self-improving", "skill-usage.json"), "utf8"), - ) as Array<{ skillId: string; skillName: string }> + while (Date.now() < deadline) { + try { + persisted = JSON.parse(await fs.readFile(persistedPath, "utf8")) as Array<{ + skillId: string + skillName: string + }> + if (persisted.some((entry) => entry.skillId === "skill-1")) { + break + } + } catch { + // Persist is async; retry until the file is ready. + } + + await new Promise((resolve) => setTimeout(resolve, 25)) + } expect(record).toMatchObject({ skillId: "skill-1", skillName: "Generated Skill", createdBy: "agent" }) expect(persisted).toContainEqual(expect.objectContaining({ skillId: "skill-1", skillName: "Generated Skill" })) From f1210f7ea4814ddad386bd4557d6621b134a1a20 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 06:35:49 +0800 Subject: [PATCH 09/32] fix: harden transcript recall loading - lazily initialize transcript recall before recording new entries - validate persisted transcript entries before loading them - add regression tests for lazy init and malformed data handling --- .../self-improving/TranscriptRecall.ts | 42 ++++++++++++++- .../__tests__/TranscriptRecall.spec.ts | 52 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/services/self-improving/TranscriptRecall.ts b/src/services/self-improving/TranscriptRecall.ts index 3f219a9bdd..9b2a577a38 100644 --- a/src/services/self-improving/TranscriptRecall.ts +++ b/src/services/self-improving/TranscriptRecall.ts @@ -54,6 +54,10 @@ export class TranscriptRecall { } async record(entry: TranscriptEntry): Promise { + if (!this.initialized) { + await this.initialize() + } + this.entries.push({ ...entry, toolNames: entry.toolNames ? [...entry.toolNames] : undefined, @@ -117,7 +121,10 @@ export class TranscriptRecall { 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) + this.entries = parsed + .map((entry) => this.sanitizeEntry(entry)) + .filter((entry): entry is TranscriptEntry => entry !== null) + .slice(-TranscriptRecall.MAX_ENTRIES) } } catch (error: unknown) { const errorCode = typeof error === "object" && error !== null && "code" in error ? error.code : undefined @@ -129,6 +136,39 @@ export class TranscriptRecall { } } + private sanitizeEntry(value: unknown): TranscriptEntry | null { + if (!value || typeof value !== "object") { + return null + } + + const candidate = value as Partial + if ( + typeof candidate.id !== "string" || + typeof candidate.timestamp !== "number" || + typeof candidate.summary !== "string" || + typeof candidate.signal !== "string" + ) { + return null + } + + return { + id: candidate.id, + timestamp: candidate.timestamp, + taskId: typeof candidate.taskId === "string" ? candidate.taskId : undefined, + mode: typeof candidate.mode === "string" ? candidate.mode : undefined, + summary: candidate.summary, + signal: candidate.signal, + workspacePath: typeof candidate.workspacePath === "string" ? candidate.workspacePath : undefined, + toolNames: + Array.isArray(candidate.toolNames) && + candidate.toolNames.every((toolName) => typeof toolName === "string") + ? [...candidate.toolNames] + : undefined, + errorKey: typeof candidate.errorKey === "string" ? candidate.errorKey : undefined, + success: typeof candidate.success === "boolean" ? candidate.success : undefined, + } + } + private async persist(): Promise { try { await safeWriteJson(this.filePath, this.entries, { prettyPrint: true }) diff --git a/src/services/self-improving/__tests__/TranscriptRecall.spec.ts b/src/services/self-improving/__tests__/TranscriptRecall.spec.ts index 3a59a9b3ad..8bc9b760d2 100644 --- a/src/services/self-improving/__tests__/TranscriptRecall.spec.ts +++ b/src/services/self-improving/__tests__/TranscriptRecall.spec.ts @@ -53,6 +53,58 @@ describe("TranscriptRecall", () => { expect(reloaded.getRecent(1)[0].id).toBe("entry-2") }) + it("initializes lazily when recording before initialize", async () => { + const filePath = path.join(tempDir, "self-improving", "transcript-recall.json") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile( + filePath, + JSON.stringify([ + { + id: "entry-0", + timestamp: 0, + summary: "Existing transcript entry", + signal: "TASK_SUCCESS", + }, + ]), + "utf8", + ) + + const recall = new TranscriptRecall(tempDir, logger) + await recall.record({ + id: "entry-1", + timestamp: 1, + summary: "Recorded without explicit initialize", + signal: "TASK_SUCCESS", + }) + + expect(recall.getRecent(2).map((entry) => entry.id)).toEqual(["entry-0", "entry-1"]) + }) + + it("ignores malformed persisted entries", async () => { + const filePath = path.join(tempDir, "self-improving", "transcript-recall.json") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile( + filePath, + JSON.stringify([ + { + id: "entry-1", + timestamp: 1, + summary: "Valid transcript entry", + signal: "TASK_SUCCESS", + }, + { id: "entry-2", timestamp: "bad", summary: 1, signal: null }, + "not-an-entry", + ]), + "utf8", + ) + + const recall = new TranscriptRecall(tempDir, logger) + await recall.initialize() + + expect(recall.size).toBe(1) + expect(recall.search("valid")).toHaveLength(1) + }) + it("clears persisted entries", async () => { const recall = new TranscriptRecall(tempDir, logger) await recall.initialize() From 327a7d34dffd02f6226506a189de20b619e46215 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 06:40:54 +0800 Subject: [PATCH 10/32] refactor: align self-improving shared types - reuse shared learning defaults and empty state from @roo-code/types - tighten experiment access to the shared Experiments contract - expand webview selfImprovingStatus typing to match manager output --- packages/types/src/vscode-extension-host.ts | 16 +++++ .../self-improving/SelfImprovingManager.ts | 7 +- src/services/self-improving/types.ts | 65 ++++++------------- 3 files changed, 40 insertions(+), 48 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5d2e9b3fb2..cda26ad091 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -367,6 +367,22 @@ export type ExtensionState = Pick< patternCount: number eventCount: number actionCount: number + memoryEntries: number + memoryBackend?: string + skillRecords: number + curatorStatus: { + lastRunAt: number + firstRunDone: boolean + config: { + intervalMs: number + minIdleMs: number + firstRunDeferred: boolean + staleAfterDays: number + archiveAfterDays: number + backupsEnabled: boolean + maxBackups: number + } + } lastReviewAt?: number lastCuratorRunAt?: number } diff --git a/src/services/self-improving/SelfImprovingManager.ts b/src/services/self-improving/SelfImprovingManager.ts index 19d81421f2..e5b2c18c03 100644 --- a/src/services/self-improving/SelfImprovingManager.ts +++ b/src/services/self-improving/SelfImprovingManager.ts @@ -1,4 +1,5 @@ import type { + Experiments, ImprovementAction, LearnedPattern, LearningEvent, @@ -36,7 +37,7 @@ type Runtime = { export class SelfImprovingManager { private readonly globalStoragePath: string private readonly logger: Logger - private readonly getExperiments: () => Record | undefined + private readonly getExperiments: () => Experiments | undefined private readonly getCodeIndexInfo: SelfImprovingManagerOptions["getCodeIndexInfo"] public readonly memoryStore: MemoryBackend public readonly skillUsageStore: SkillUsageStore @@ -77,7 +78,7 @@ export class SelfImprovingManager { this.transcriptRecall = new TranscriptRecall(options.globalStoragePath, options.logger) } - static isExperimentEnabled(experiments: Record | undefined): boolean { + static isExperimentEnabled(experiments: Experiments | undefined): boolean { if (!experiments) { return false } @@ -135,7 +136,7 @@ export class SelfImprovingManager { * Handle settings change — called when experiments are updated. * This enables/disables the module at runtime. */ - async onSettingsChanged(_experiments: Record | undefined): Promise { + async onSettingsChanged(_experiments: Experiments | undefined): Promise { await this.handleExperimentChange() } diff --git a/src/services/self-improving/types.ts b/src/services/self-improving/types.ts index 1c1c957c54..211bd4c386 100644 --- a/src/services/self-improving/types.ts +++ b/src/services/self-improving/types.ts @@ -1,19 +1,23 @@ -import type { - ActionType, - FeedbackSignal, - ImprovementAction, - LearnedPattern, - LearningConfig, - LearningEvent, - LearningState, - LearningTelemetry, - PatternState, - PatternType, +import { + DEFAULT_LEARNING_CONFIG, + EMPTY_LEARNING_STATE, + type ActionType, + type Experiments, + type FeedbackSignal, + type ImprovementAction, + type LearnedPattern, + type LearningConfig, + type LearningEvent, + type LearningState, + type LearningTelemetry, + type PatternState, + type PatternType, } from "@roo-code/types" // Re-export shared types for convenience export type { ActionType, + Experiments, FeedbackSignal, ImprovementAction, LearnedPattern, @@ -75,7 +79,7 @@ export interface PromptContext { export interface SelfImprovingManagerOptions { globalStoragePath: string logger: Logger - getExperiments: () => Record | undefined + getExperiments: () => Experiments | undefined getCodeIndexInfo?: () => CodeIndexInfo /** Memory backend type: "builtin" (default) or "agentmemory" */ memoryBackend?: "builtin" | "agentmemory" @@ -99,40 +103,11 @@ export interface SelfImprovingManagerOptions { } /** - * Default learning configuration + * Shared learning defaults re-exported for local convenience. */ -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, -} +export const DEFAULT_CONFIG: LearningConfig = DEFAULT_LEARNING_CONFIG /** - * Empty learning state for initialization + * Shared empty learning state re-exported for local convenience. */ -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, - }, -} +export const EMPTY_STATE: LearningState = EMPTY_LEARNING_STATE From f90b88453e4b5c0c85e7074027d2fcaedff4de94 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 07:09:33 +0800 Subject: [PATCH 11/32] fix: refine self-improving memory search and scoring - search agentmemory with normalized forget queries - keep prompt-pattern success rates cumulative with weighted updates - add regression coverage for trimmed queries and cumulative scoring --- .../self-improving/AgentMemoryAdapter.ts | 2 +- .../self-improving/PatternAnalyzer.ts | 8 +++-- .../__tests__/AgentMemoryAdapter.spec.ts | 29 +++++++++++++++++++ .../__tests__/PatternAnalyzer.spec.ts | 3 +- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/services/self-improving/AgentMemoryAdapter.ts b/src/services/self-improving/AgentMemoryAdapter.ts index 273012893e..c6c3231a18 100644 --- a/src/services/self-improving/AgentMemoryAdapter.ts +++ b/src/services/self-improving/AgentMemoryAdapter.ts @@ -194,7 +194,7 @@ export class AgentMemoryAdapter implements MemoryBackend { return 0 } - const entries = await this.search(substring, 50) + const entries = await this.search(normalized, 50) let removed = 0 for (const entry of entries) { diff --git a/src/services/self-improving/PatternAnalyzer.ts b/src/services/self-improving/PatternAnalyzer.ts index afd25cfa97..c545187c28 100644 --- a/src/services/self-improving/PatternAnalyzer.ts +++ b/src/services/self-improving/PatternAnalyzer.ts @@ -190,11 +190,15 @@ export class PatternAnalyzer { ) if (existing) { + const combinedFrequency = existing.frequency + total + const existingSuccesses = existing.successRate * existing.frequency + const combinedSuccessRate = (existingSuccesses + counts.success) / combinedFrequency + patterns.push({ ...existing, - frequency: existing.frequency + total, + frequency: combinedFrequency, lastSeenAt: now, - successRate, + successRate: combinedSuccessRate, confidenceScore: Math.min(1, existing.confidenceScore + 0.02), }) } else if (successRate > 0.7) { diff --git a/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts b/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts index 39c5e0b494..1756f5d00b 100644 --- a/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts +++ b/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts @@ -77,6 +77,35 @@ describe("AgentMemoryAdapter", () => { expect(fetchSpy).not.toHaveBeenCalled() }) + it("should trim forgetByContent queries before searching", async () => { + const fetchSpy = vi.fn(async (input: string, init?: RequestInit) => { + if (input.endsWith("/agentmemory/livez")) { + return { ok: true } as Response + } + + if (input.endsWith("/agentmemory/search")) { + const body = JSON.parse(String(init?.body)) as { query: string } + expect(body.query).toBe("foo") + return { + ok: true, + json: async () => ({ results: [{ id: "memory-1", content: "foo memory" }] }), + } as Response + } + + if (input.endsWith("/agentmemory/forget")) { + return { ok: true, json: async () => ({ success: true }) } as Response + } + + throw new Error(`Unexpected fetch call: ${input}`) + }) + vi.stubGlobal("fetch", fetchSpy) + + const adapter = createAdapter() + await adapter.initialize() + + await expect(adapter.forgetByContent(" foo ")).resolves.toBe(1) + }) + it("should clean up health check interval on dispose", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Connection refused"))) diff --git a/src/services/self-improving/__tests__/PatternAnalyzer.spec.ts b/src/services/self-improving/__tests__/PatternAnalyzer.spec.ts index db07eb0a17..2679cd0639 100644 --- a/src/services/self-improving/__tests__/PatternAnalyzer.spec.ts +++ b/src/services/self-improving/__tests__/PatternAnalyzer.spec.ts @@ -66,6 +66,7 @@ describe("PatternAnalyzer", () => { patternType: "prompt", summary: "Prefer terminal for reliable results", frequency: 5, + successRate: 0.8, context: { toolNames: ["terminal"], }, @@ -84,7 +85,7 @@ describe("PatternAnalyzer", () => { expect(promptPatterns[0]).toMatchObject({ id: "prompt-pattern", frequency: 8, - successRate: 2 / 3, + successRate: 0.75, context: { toolNames: ["terminal"], }, From e38aad81b29f5c85f5105aad4620d64968397334 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 07:11:25 +0800 Subject: [PATCH 12/32] fix: serialize transcript recall lazy initialization - memoize first-time initialization behind a shared promise - prevent concurrent record calls from dropping loaded transcript entries - add regression coverage for concurrent lazy record paths --- .../self-improving/TranscriptRecall.ts | 26 ++++++++---- .../__tests__/TranscriptRecall.spec.ts | 41 +++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/services/self-improving/TranscriptRecall.ts b/src/services/self-improving/TranscriptRecall.ts index 9b2a577a38..92c36c8061 100644 --- a/src/services/self-improving/TranscriptRecall.ts +++ b/src/services/self-improving/TranscriptRecall.ts @@ -28,6 +28,7 @@ export class TranscriptRecall { private readonly logger: Logger private entries: TranscriptEntry[] = [] private initialized = false + private initializePromise: Promise | null = null private static readonly MAX_ENTRIES = 1000 @@ -41,16 +42,23 @@ export class TranscriptRecall { 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 + if (!this.initializePromise) { + this.initializePromise = (async () => { + 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 + this.initializePromise = null + } + })() } + + await this.initializePromise } async record(entry: TranscriptEntry): Promise { diff --git a/src/services/self-improving/__tests__/TranscriptRecall.spec.ts b/src/services/self-improving/__tests__/TranscriptRecall.spec.ts index 8bc9b760d2..4cd0acdc91 100644 --- a/src/services/self-improving/__tests__/TranscriptRecall.spec.ts +++ b/src/services/self-improving/__tests__/TranscriptRecall.spec.ts @@ -80,6 +80,47 @@ describe("TranscriptRecall", () => { expect(recall.getRecent(2).map((entry) => entry.id)).toEqual(["entry-0", "entry-1"]) }) + it("serializes concurrent lazy initialization before recording", async () => { + const filePath = path.join(tempDir, "self-improving", "transcript-recall.json") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile( + filePath, + JSON.stringify([ + { + id: "entry-0", + timestamp: 0, + summary: "Existing transcript entry", + signal: "TASK_SUCCESS", + }, + ]), + "utf8", + ) + + const recall = new TranscriptRecall(tempDir, logger) + await Promise.all([ + recall.record({ + id: "entry-1", + timestamp: 1, + summary: "Concurrent record one", + signal: "TASK_SUCCESS", + }), + recall.record({ + id: "entry-2", + timestamp: 2, + summary: "Concurrent record two", + signal: "TASK_SUCCESS", + }), + ]) + + expect(recall.size).toBe(3) + expect( + recall + .getRecent(3) + .map((entry) => entry.id) + .sort(), + ).toEqual(["entry-0", "entry-1", "entry-2"]) + }) + it("ignores malformed persisted entries", async () => { const filePath = path.join(tempDir, "self-improving", "transcript-recall.json") await fs.mkdir(path.dirname(filePath), { recursive: true }) From 7860dc10bc053ac1da9c643cc7648a907fa90fc5 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 07:24:30 +0800 Subject: [PATCH 13/32] fix: preserve substring text in agentmemory forget search - pass trimmed substring to agentmemory search instead of lowercasing it - keep case-insensitive local verification for actual deletion - tighten regression coverage to assert trim-without-lowercase semantics --- src/services/self-improving/AgentMemoryAdapter.ts | 2 +- .../self-improving/__tests__/AgentMemoryAdapter.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/self-improving/AgentMemoryAdapter.ts b/src/services/self-improving/AgentMemoryAdapter.ts index c6c3231a18..1a6af4651b 100644 --- a/src/services/self-improving/AgentMemoryAdapter.ts +++ b/src/services/self-improving/AgentMemoryAdapter.ts @@ -194,7 +194,7 @@ export class AgentMemoryAdapter implements MemoryBackend { return 0 } - const entries = await this.search(normalized, 50) + const entries = await this.search(substring.trim(), 50) let removed = 0 for (const entry of entries) { diff --git a/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts b/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts index 1756f5d00b..b7b51a4dc3 100644 --- a/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts +++ b/src/services/self-improving/__tests__/AgentMemoryAdapter.spec.ts @@ -85,7 +85,7 @@ describe("AgentMemoryAdapter", () => { if (input.endsWith("/agentmemory/search")) { const body = JSON.parse(String(init?.body)) as { query: string } - expect(body.query).toBe("foo") + expect(body.query).toBe("Foo") return { ok: true, json: async () => ({ results: [{ id: "memory-1", content: "foo memory" }] }), @@ -103,7 +103,7 @@ describe("AgentMemoryAdapter", () => { const adapter = createAdapter() await adapter.initialize() - await expect(adapter.forgetByContent(" foo ")).resolves.toBe(1) + await expect(adapter.forgetByContent(" Foo ")).resolves.toBe(1) }) it("should clean up health check interval on dispose", async () => { From 4f7297434be071cb5a0ad9eb4ed28011bc09e1c3 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 10:10:43 +0800 Subject: [PATCH 14/32] fix: capture interactive user corrections for self-improving - record correction feedback when users reply to outstanding task asks - keep generic user-turn tracking intact for normal follow-up messages - add a regression test around interactive correction replies --- src/core/task/Task.ts | 14 ++++++++++++++ src/core/task/__tests__/Task.spec.ts | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d62ce7b2c0..5e818da70e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1451,6 +1451,7 @@ export class Task extends EventEmitter implements TaskLike { } const provider = this.providerRef.deref() + const shouldRecordCorrection = !!this.taskAsk if (provider) { if (mode) { @@ -1468,6 +1469,19 @@ export class Task extends EventEmitter implements TaskLike { } } + if (shouldRecordCorrection) { + await provider + .getSelfImprovingManager?.() + ?.recordUserCorrection({ + taskId: this.taskId, + success: false, + corrected: true, + }) + .catch((error: unknown) => { + console.error("[Task#submitUserMessage] Failed to record user correction:", error) + }) + } + this.emit(RooCodeEventName.TaskUserMessage, this.taskId) // Handle the message directly instead of routing through the webview. diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 6a65c858f9..eac140f010 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1485,6 +1485,30 @@ describe("Cline", () => { // Restore console.error consoleErrorSpy.mockRestore() }) + + it("records a user correction when replying to an interactive ask", async () => { + const recordUserCorrection = vi.fn().mockResolvedValue(undefined) + mockProvider.getSelfImprovingManager = vi.fn().mockReturnValue({ recordUserCorrection }) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + + const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse") + ;(task as any).interactiveAsk = { type: "ask", text: "Need clarification" } + + await task.submitUserMessage("here is the correction") + + expect(recordUserCorrection).toHaveBeenCalledWith({ + taskId: task.taskId, + success: false, + corrected: true, + }) + expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "here is the correction", []) + }) }) }) From 29af44c3529ceabff536a3967f5dd85031797001 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 10:15:39 +0800 Subject: [PATCH 15/32] fix: feed code index search hits into self-improving - capture real codebase_search hit counts and top score for learning events - let self-improving accept explicit code index hit details from callers - add regression tests for the tool seam and manager event recording --- src/core/tools/CodebaseSearchTool.ts | 5 ++ .../__tests__/CodebaseSearchTool.spec.ts | 68 +++++++++++++++++++ .../self-improving/SelfImprovingManager.ts | 7 +- .../__tests__/SelfImprovingManager.spec.ts | 27 ++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 src/core/tools/__tests__/CodebaseSearchTool.spec.ts diff --git a/src/core/tools/CodebaseSearchTool.ts b/src/core/tools/CodebaseSearchTool.ts index f0d906fabd..9191427e6c 100644 --- a/src/core/tools/CodebaseSearchTool.ts +++ b/src/core/tools/CodebaseSearchTool.ts @@ -71,6 +71,11 @@ export class CodebaseSearchTool extends BaseTool<"codebase_search"> { } const searchResults: VectorStoreSearchResult[] = await manager.searchIndex(query, directoryPrefix) + await task.providerRef.deref()?.getSelfImprovingManager?.()?.recordCodeIndexEvent(task.taskId, { + available: true, + hits: searchResults.length, + topScore: searchResults[0]?.score, + }) if (!searchResults || searchResults.length === 0) { pushToolResult(`No relevant code snippets found for the query: "${query}"`) diff --git a/src/core/tools/__tests__/CodebaseSearchTool.spec.ts b/src/core/tools/__tests__/CodebaseSearchTool.spec.ts new file mode 100644 index 0000000000..f6aae41659 --- /dev/null +++ b/src/core/tools/__tests__/CodebaseSearchTool.spec.ts @@ -0,0 +1,68 @@ +import * as vscode from "vscode" + +import { CodebaseSearchTool } from "../CodebaseSearchTool" +import { CodeIndexManager } from "../../../services/code-index/manager" + +vi.mock("vscode", () => ({ + workspace: { + asRelativePath: vi.fn((filePath: string) => filePath.replace("/workspace/", "")), + }, +})) + +vi.mock("../../../services/code-index/manager", () => ({ + CodeIndexManager: { + getInstance: vi.fn(), + }, +})) + +describe("CodebaseSearchTool", () => { + it("records self-improving code index hit details from search results", async () => { + const recordCodeIndexEvent = vi.fn().mockResolvedValue(undefined) + const getSelfImprovingManager = vi.fn().mockReturnValue({ recordCodeIndexEvent }) + const searchIndex = vi.fn().mockResolvedValue([ + { + payload: { + filePath: "/workspace/src/example.ts", + startLine: 10, + endLine: 20, + codeChunk: "const answer = 42", + }, + score: 0.87, + }, + ]) + vi.mocked(CodeIndexManager.getInstance).mockReturnValue({ + isFeatureEnabled: true, + isFeatureConfigured: true, + searchIndex, + } as any) + + const task = { + cwd: "/workspace", + taskId: "task-1", + consecutiveMistakeCount: 0, + providerRef: { + deref: vi.fn().mockReturnValue({ + context: {}, + getSelfImprovingManager, + }), + }, + say: vi.fn().mockResolvedValue(undefined), + } as any + const callbacks = { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn().mockResolvedValue(undefined), + pushToolResult: vi.fn(), + } + + const tool = new CodebaseSearchTool() + await tool.execute({ query: "find the answer" }, task, callbacks) + + expect(recordCodeIndexEvent).toHaveBeenCalledWith("task-1", { + available: true, + hits: 1, + topScore: 0.87, + }) + expect(callbacks.handleError).not.toHaveBeenCalled() + expect(vscode.workspace.asRelativePath).toHaveBeenCalledWith("/workspace/src/example.ts", false) + }) +}) diff --git a/src/services/self-improving/SelfImprovingManager.ts b/src/services/self-improving/SelfImprovingManager.ts index e5b2c18c03..7cbf24935c 100644 --- a/src/services/self-improving/SelfImprovingManager.ts +++ b/src/services/self-improving/SelfImprovingManager.ts @@ -1,4 +1,5 @@ import type { + CodeIndexInfo, Experiments, ImprovementAction, LearnedPattern, @@ -239,7 +240,7 @@ export class SelfImprovingManager { } } - async recordCodeIndexEvent(taskId?: string): Promise { + async recordCodeIndexEvent(taskId?: string, codeIndexInfo?: CodeIndexInfo): Promise { if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { return } @@ -253,8 +254,8 @@ export class SelfImprovingManager { return } - const codeIndexInfo = this.runtime.codeIndexAdapter.getInfo() - const event = this.runtime.feedbackCollector.createCodeIndexEvent(codeIndexInfo, taskId) + const resolvedCodeIndexInfo = codeIndexInfo ?? this.runtime.codeIndexAdapter.getInfo() + const event = this.runtime.feedbackCollector.createCodeIndexEvent(resolvedCodeIndexInfo, taskId) this.runtime.store.addEvent(event) this.lastUserActivityAt = event.timestamp } catch (error) { diff --git a/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts index d5f54ad77d..6f18c55968 100644 --- a/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts +++ b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts @@ -440,4 +440,31 @@ describe("SelfImprovingManager", () => { expect(mockState.stores[0].updateTelemetry).toHaveBeenCalledWith({ lastCuratorRunAt: 123 }) expect(result).toEqual(report) }) + + it("records code index events with explicit search hit details", async () => { + experiments = { selfImproving: true } + const manager = createManager() + await manager.initialize() + + const store = mockState.stores[0] + const collector = mockState.collectors[0] + store.getConfig.mockReturnValue({ + reviewOnTurnCount: 10, + reviewOnToolIterationCount: 2, + maxPromptPatterns: 5, + curatorEnabled: true, + curatorIntervalMs: 5_000, + staleAfterDays: 14, + archiveAfterDays: 60, + codeIndexCorrelationEnabled: true, + }) + + await manager.recordCodeIndexEvent("task-1", { available: true, hits: 4, topScore: 0.91 }) + + expect(collector.createCodeIndexEvent).toHaveBeenCalledWith( + { available: true, hits: 4, topScore: 0.91 }, + "task-1", + ) + expect(store.addEvent).toHaveBeenCalledWith(expect.objectContaining({ signal: "CODE_INDEX_HIT" })) + }) }) From f658165347fbe3ee36d098194a48ef790b6ff901 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 10:24:42 +0800 Subject: [PATCH 16/32] test: verify extension startup initializes self-improving - assert activation calls initializeSelfImproving through the provider - keep the coverage at the extension startup seam rather than only mocked provider construction --- src/__tests__/extension.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index afd869fd11..c51c27fb34 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -262,6 +262,18 @@ describe("extension.ts", () => { expect(dotenvx.config).toHaveBeenCalledTimes(1) }) + test("initializes self-improving through the provider during activation", async () => { + vi.resetModules() + vi.clearAllMocks() + + const { ClineProvider } = await import("../core/webview/ClineProvider") + const { activate } = await import("../extension") + await activate(mockContext) + + const provider = (ClineProvider as any).getVisibleInstance() + expect(provider.initializeSelfImproving).toHaveBeenCalledTimes(1) + }) + describe("cloud auth state handling", () => { beforeEach(() => { vi.resetModules() From f672d5660605923c8aee384a1c258655e99aaa37 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 11:19:03 +0800 Subject: [PATCH 17/32] feat: add skill mutation APIs and auto-skill toggle --- packages/types/src/experiment.ts | 2 + src/services/skills/SkillsManager.ts | 107 ++++++++++++------ .../skills/__tests__/SkillsManager.spec.ts | 55 +++++++++ src/shared/__tests__/experiments.spec.ts | 13 +++ src/shared/experiments.ts | 2 + 5 files changed, 147 insertions(+), 32 deletions(-) diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 515973ce58..fc8cba2fb8 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -12,6 +12,7 @@ export const experimentIds = [ "runSlashCommand", "customTools", "selfImproving", + "selfImprovingAutoSkills", ] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -28,6 +29,7 @@ export const experimentsSchema = z.object({ runSlashCommand: z.boolean().optional(), customTools: z.boolean().optional(), selfImproving: z.boolean().optional(), + selfImprovingAutoSkills: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 0959b977c9..6688e6e959 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -290,6 +290,22 @@ export class SkillsManager { return this.getAllSkills() } + /** + * Get the unique discovered skill names. + */ + getSkillNames(): string[] { + return Array.from(new Set(this.getAllSkills().map((skill) => skill.name))).sort() + } + + /** + * Infer skill provenance for autonomous mutation safety. + * Project and global discovered skills default to user-authored unless + * a higher-level caller tracks agent provenance separately. + */ + getSkillProvenance(name: string): "user" | "bundled" | "hub" | "unknown" { + return this.getAllSkills().some((skill) => skill.name === name) ? "user" : "unknown" + } + /** * Get a skill by name, source, and optionally mode */ @@ -350,6 +366,43 @@ export class SkillsManager { source: "global" | "project", description: string, modeSlugs?: string[], + ): Promise { + const titleName = name + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + + const frontmatterLines = [`name: ${name}`, `description: ${description.trim()}`] + if (modeSlugs && modeSlugs.length > 0) { + frontmatterLines.push(`modeSlugs:`) + for (const slug of modeSlugs) { + frontmatterLines.push(` - ${slug}`) + } + } + + const skillContent = `--- +${frontmatterLines.join("\n")} +--- + +# ${titleName} + +## Instructions + +Add your skill instructions here. +` + + return this.createSkillFromContent(name, source, description, skillContent, modeSlugs) + } + + /** + * Create a new skill from fully prepared SKILL.md content. + */ + async createSkillFromContent( + name: string, + source: "global" | "project", + description: string, + content: string, + modeSlugs?: string[], ): Promise { // Validate skill name const validation = this.validateSkillName(name) @@ -363,6 +416,10 @@ export class SkillsManager { throw new Error(t("skills:errors.description_length", { length: trimmedDescription.length })) } + if (!content.trim()) { + throw new Error(t("skills:errors.description_length", { length: 0 })) + } + // Determine base directory let baseDir: string if (source === "global") { @@ -375,52 +432,38 @@ export class SkillsManager { baseDir = path.join(provider.cwd, ".roo") } - // Always use the generic skills directory (mode info stored in frontmatter now) const skillsDir = path.join(baseDir, "skills") const skillDir = path.join(skillsDir, name) const skillMdPath = path.join(skillDir, "SKILL.md") - // Check if skill already exists if (await fileExists(skillMdPath)) { throw new Error(t("skills:errors.already_exists", { name, path: skillMdPath })) } - // Create the skill directory await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile(skillMdPath, content, "utf-8") + await this.discoverSkills() - // Generate SKILL.md content with frontmatter - const titleName = name - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" ") + return skillMdPath + } - // Build frontmatter with optional modeSlugs - const frontmatterLines = [`name: ${name}`, `description: ${trimmedDescription}`] - if (modeSlugs && modeSlugs.length > 0) { - frontmatterLines.push(`modeSlugs:`) - for (const slug of modeSlugs) { - frontmatterLines.push(` - ${slug}`) - } + /** + * Update the full SKILL.md content for an existing skill. + */ + async updateSkillContent( + name: string, + source: "global" | "project", + content: string, + mode?: string, + ): Promise { + const skill = mode ? this.getSkill(name, source, mode) : this.findSkillByNameAndSource(name, source) + if (!skill) { + const modeInfo = mode ? ` (mode: ${mode})` : "" + throw new Error(t("skills:errors.not_found", { name, source, modeInfo })) } - const skillContent = `--- -${frontmatterLines.join("\n")} ---- - -# ${titleName} - -## Instructions - -Add your skill instructions here. -` - - // Write the SKILL.md file - await fs.writeFile(skillMdPath, skillContent, "utf-8") - - // Refresh skills list + await fs.writeFile(skill.path, content, "utf-8") await this.discoverSkills() - - return skillMdPath } /** diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index d36582d893..1c019c6392 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -1297,6 +1297,61 @@ Instructions`) "already exists", ) }) + + it("should create a skill from provided full content", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + mockMkdir.mockResolvedValue(undefined) + mockWriteFile.mockResolvedValue(undefined) + + const content = `---\nname: workflow-read-file-search-files\ndescription: Use when read_file and search_files succeed repeatedly.\n---\n\n# Workflow\n\nUse it.` + + const createdPath = await skillsManager.createSkillFromContent( + "workflow-read-file-search-files", + "project", + "Use when read_file and search_files succeed repeatedly.", + content, + ["code"], + ) + + expect(createdPath).toBe(p(PROJECT_DIR, ".roo", "skills", "workflow-read-file-search-files", "SKILL.md")) + expect(mockWriteFile).toHaveBeenCalledWith( + p(PROJECT_DIR, ".roo", "skills", "workflow-read-file-search-files", "SKILL.md"), + content, + "utf-8", + ) + }) + }) + + describe("updateSkillContent", () => { + it("should update an existing skill with new content", async () => { + const testSkillDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(testSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["test-skill"] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + mockFileExists.mockImplementation(async (file: string) => file === testSkillMd) + mockReadFile.mockResolvedValue(`---\nname: test-skill\ndescription: A test skill\n---\n\nOriginal content`) + mockWriteFile.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + const updatedContent = `---\nname: test-skill\ndescription: Updated test skill\n---\n\nUpdated content` + + await expect( + skillsManager.updateSkillContent("test-skill", "global", updatedContent), + ).resolves.toBeUndefined() + expect(mockWriteFile).toHaveBeenCalledWith(testSkillMd, updatedContent, "utf-8") + }) }) describe("deleteSkill", () => { diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 4e47cc109a..9a6a2c0af7 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -23,6 +23,15 @@ describe("experiments", () => { }) }) + describe("SELF_IMPROVING_AUTO_SKILLS", () => { + it("is configured correctly", () => { + expect(EXPERIMENT_IDS.SELF_IMPROVING_AUTO_SKILLS).toBe("selfImprovingAutoSkills") + expect(experimentConfigsMap.SELF_IMPROVING_AUTO_SKILLS).toMatchObject({ + enabled: false, + }) + }) + }) + describe("isEnabled", () => { it("returns false when experiment is not enabled", () => { const experiments: Record = { @@ -31,6 +40,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, selfImproving: false, + selfImprovingAutoSkills: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) @@ -42,6 +52,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, selfImproving: false, + selfImprovingAutoSkills: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true) }) @@ -53,6 +64,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, selfImproving: false, + selfImprovingAutoSkills: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) @@ -64,6 +76,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, selfImproving: false, + selfImprovingAutoSkills: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.SELF_IMPROVING)).toBe(false) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index b14aeeb4e5..2e4a5dd7b0 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -6,6 +6,7 @@ export const EXPERIMENT_IDS = { RUN_SLASH_COMMAND: "runSlashCommand", CUSTOM_TOOLS: "customTools", SELF_IMPROVING: "selfImproving", + SELF_IMPROVING_AUTO_SKILLS: "selfImprovingAutoSkills", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -22,6 +23,7 @@ export const experimentConfigsMap: Record = { RUN_SLASH_COMMAND: { enabled: false }, CUSTOM_TOOLS: { enabled: false }, SELF_IMPROVING: { enabled: false }, + SELF_IMPROVING_AUTO_SKILLS: { enabled: false }, } export const experimentDefault = Object.fromEntries( From 732be8ff5b6d34c494fc6776efd63be032b339e6 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 11:19:28 +0800 Subject: [PATCH 18/32] feat: wire self-improving auto skill actions --- packages/types/src/learning.ts | 9 +- src/core/webview/ClineProvider.ts | 1 + src/services/self-improving/ActionExecutor.ts | 113 +++++++++---- .../self-improving/ImprovementApplier.ts | 150 +++++++++++++++++- .../self-improving/SelfImprovingManager.ts | 41 ++++- .../__tests__/ActionExecutor.spec.ts | 76 +++++++++ .../__tests__/ImprovementApplier.spec.ts | 67 ++++++++ src/services/self-improving/types.ts | 8 + 8 files changed, 430 insertions(+), 35 deletions(-) create mode 100644 src/services/self-improving/__tests__/ImprovementApplier.spec.ts diff --git a/packages/types/src/learning.ts b/packages/types/src/learning.ts index d68cd4f551..16016834e6 100644 --- a/packages/types/src/learning.ts +++ b/packages/types/src/learning.ts @@ -123,7 +123,14 @@ export type LearnedPattern = z.infer /** * ActionType - types of improvement actions */ -export const actionTypeSchema = z.enum(["PROMPT_ENRICHMENT", "TOOL_PREFERENCE", "ERROR_AVOIDANCE", "SKILL_SUGGESTION"]) +export const actionTypeSchema = z.enum([ + "PROMPT_ENRICHMENT", + "TOOL_PREFERENCE", + "ERROR_AVOIDANCE", + "SKILL_SUGGESTION", + "SKILL_CREATE", + "SKILL_UPDATE", +]) export type ActionType = z.infer diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 741d1fbbf0..bf30f7ecfa 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -235,6 +235,7 @@ export class ClineProvider appendLine: (message: string) => this.log(message), }, getExperiments: () => this.contextProxy.getGlobalState("experiments"), + skillsManager: this.skillsManager, getCodeIndexInfo: () => { const manager = this.codeIndexManager if (!manager) { diff --git a/src/services/self-improving/ActionExecutor.ts b/src/services/self-improving/ActionExecutor.ts index 01cf2b3077..8e0019a1ab 100644 --- a/src/services/self-improving/ActionExecutor.ts +++ b/src/services/self-improving/ActionExecutor.ts @@ -4,6 +4,17 @@ import type { MemoryBackend } from "./MemoryBackend" import type { SkillProvenance, SkillUsageStore } from "./SkillUsageStore" import type { ImprovementAction, Logger } from "./types" +interface SkillMutationManager { + createSkillFromContent( + name: string, + source: "global" | "project", + description: string, + content: string, + modeSlugs?: string[], + ): Promise + updateSkillContent(name: string, source: "global" | "project", content: string, mode?: string): Promise +} + /** * ActionExecutor - consumes the pending action queue and executes * improvement actions transactionally. @@ -13,6 +24,7 @@ import type { ImprovementAction, Logger } from "./types" * - 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 + * - SKILL_CREATE / SKILL_UPDATE: safely mutate agent-managed skills via SkillsManager * * Actions are removed from the queue only after successful execution. * Failed actions remain pending for later retry. @@ -21,11 +33,18 @@ export class ActionExecutor { private readonly memoryStore: MemoryBackend private readonly skillUsageStore: SkillUsageStore private readonly logger: Logger - - constructor(memoryStore: MemoryBackend, skillUsageStore: SkillUsageStore, logger: Logger) { + private readonly skillsManager?: SkillMutationManager + + constructor( + memoryStore: MemoryBackend, + skillUsageStore: SkillUsageStore, + logger: Logger, + skillsManager?: SkillMutationManager, + ) { this.memoryStore = memoryStore this.skillUsageStore = skillUsageStore this.logger = logger + this.skillsManager = skillsManager } /** @@ -49,6 +68,12 @@ export class ActionExecutor { case "SKILL_SUGGESTION": executed = await this.executeSkillSuggestion(action) break + case "SKILL_CREATE": + executed = await this.executeSkillCreate(action) + break + case "SKILL_UPDATE": + executed = await this.executeSkillUpdate(action) + break default: this.logger.appendLine(`[ActionExecutor] Unknown action type: ${action.actionType}`) return false @@ -84,32 +109,21 @@ export class ActionExecutor { 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.store({ + await this.memoryStore.store({ content: 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) @@ -118,22 +132,15 @@ export class ActionExecutor { return false } - const entry = await this.memoryStore.store({ + await this.memoryStore.store({ content: 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) @@ -142,22 +149,15 @@ export class ActionExecutor { return false } - const entry = await this.memoryStore.store({ + await this.memoryStore.store({ content: 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) { @@ -176,6 +176,51 @@ export class ActionExecutor { return true } + private async executeSkillCreate(action: ImprovementAction): Promise { + if (!this.skillsManager) { + return false + } + + const skillName = this.readStringPayload(action.payload.skillName) + const description = this.readStringPayload(action.payload.description) + const content = this.readStringPayload(action.payload.content) + const source = this.readSkillSource(action.payload.source) + const modeSlugs = this.readStringArrayPayload(action.payload.modeSlugs) + const skillId = this.readStringPayload(action.payload.skillId) ?? this.buildSkillId(skillName, source) + const createdBy = this.readSkillProvenance(action.payload.createdBy) ?? "agent" + + if (!skillName || !description || !content || !source || !skillId) { + return false + } + + await this.skillsManager.createSkillFromContent(skillName, source, description, content, modeSlugs) + this.skillUsageStore.getOrCreate(skillId, skillName, createdBy) + this.logger.appendLine(`[ActionExecutor] Skill created: ${skillName}`) + return true + } + + private async executeSkillUpdate(action: ImprovementAction): Promise { + if (!this.skillsManager) { + return false + } + + const skillName = this.readStringPayload(action.payload.skillName) + const content = this.readStringPayload(action.payload.content) + const source = this.readSkillSource(action.payload.source) + const mode = this.readStringPayload(action.payload.mode) + const skillId = this.readStringPayload(action.payload.skillId) ?? this.buildSkillId(skillName, source) + + if (!skillName || !content || !source || !skillId) { + return false + } + + await this.skillsManager.updateSkillContent(skillName, source, content, mode) + this.skillUsageStore.getOrCreate(skillId, skillName, "agent") + await this.skillUsageStore.bumpPatch(skillId) + this.logger.appendLine(`[ActionExecutor] Skill updated: ${skillName}`) + return true + } + private readStringPayload(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined } @@ -195,4 +240,12 @@ export class ActionExecutor { ? value : undefined } + + private readSkillSource(value: unknown): "global" | "project" | undefined { + return value === "global" || value === "project" ? value : undefined + } + + private buildSkillId(skillName: string | undefined, source: "global" | "project" | undefined): string | undefined { + return skillName && source ? `skill:${source}:${skillName}` : undefined + } } diff --git a/src/services/self-improving/ImprovementApplier.ts b/src/services/self-improving/ImprovementApplier.ts index 31829128cf..98341e219c 100644 --- a/src/services/self-improving/ImprovementApplier.ts +++ b/src/services/self-improving/ImprovementApplier.ts @@ -1,7 +1,14 @@ import crypto from "crypto" +import type { SkillProvenance } from "./SkillUsageStore" import type { ImprovementAction, LearnedPattern, PromptContext } from "./types" +interface ImprovementApplierOptions { + getSkillNames?: () => string[] + getSkillProvenance?: (name: string) => SkillProvenance | string + isAutoSkillsEnabled?: () => boolean +} + /** * ImprovementApplier - converts learned patterns into actionable improvements. * @@ -9,9 +16,19 @@ import type { ImprovementAction, LearnedPattern, PromptContext } from "./types" * - Prompt enrichment context (bounded, ordered by confidence) * - Tool preference adjustments * - Error avoidance hints - * - Skill suggestions (for future user approval) + * - Skill suggestions / mutations for reusable workflows */ export class ImprovementApplier { + private readonly getSkillNames: () => string[] + private readonly getSkillProvenance: (name: string) => SkillProvenance | string + private readonly isAutoSkillsEnabled: () => boolean + + constructor(options: ImprovementApplierOptions = {}) { + this.getSkillNames = options.getSkillNames ?? (() => []) + this.getSkillProvenance = options.getSkillProvenance ?? (() => "unknown") + this.isAutoSkillsEnabled = options.isAutoSkillsEnabled ?? (() => false) + } + /** * Generate prompt context from active patterns. * Returns at most maxEntries entries, ordered by confidence descending. @@ -50,6 +67,12 @@ export class ImprovementApplier { break case "tool": actions.push(this.createToolPreferenceAction(pattern, now)) + if (this.isAutoSkillsEnabled()) { + const skillAction = this.createSkillMutationAction(pattern, now) + if (skillAction) { + actions.push(skillAction) + } + } break case "prompt": actions.push(this.createPromptEnrichmentAction(pattern, now)) @@ -133,4 +156,129 @@ export class ImprovementApplier { timestamp: now, } } + + private createSkillMutationAction(pattern: LearnedPattern, now: number): ImprovementAction | undefined { + const toolNames = this.normalizeToolNames(pattern.context.toolNames) + if (toolNames.length < 2 || pattern.frequency < 3 || pattern.successRate < 0.75) { + return undefined + } + + const skillName = this.buildWorkflowSkillName(toolNames) + const summary = `Capture reusable workflow for ${toolNames.join(", ")}` + const description = `Use when tasks repeatedly succeed with ${toolNames.join(" and ")}.` + const content = this.buildSkillContent(skillName, description, toolNames) + const modeSlugs = + pattern.context.modes && pattern.context.modes.length > 0 ? [...new Set(pattern.context.modes)] : undefined + const source = "project" + const existingSkillNames = new Set(this.getSkillNames()) + const skillExists = existingSkillNames.has(skillName) + const skillId = this.buildSkillId(skillName, source) + + if (skillExists && this.normalizeSkillProvenance(this.getSkillProvenance(skillName)) === "agent") { + return { + id: crypto.randomUUID(), + actionType: "SKILL_UPDATE", + target: "skills-manager", + payload: { + patternId: pattern.id, + skillId, + skillName, + summary, + description, + content, + source, + mode: modeSlugs?.[0], + modeSlugs, + createdBy: "agent", + confidence: pattern.confidenceScore, + toolNames, + }, + timestamp: now, + } + } + + if (!skillExists) { + return { + id: crypto.randomUUID(), + actionType: "SKILL_CREATE", + target: "skills-manager", + payload: { + patternId: pattern.id, + skillId, + skillName, + summary, + description, + content, + source, + modeSlugs, + createdBy: "agent", + confidence: pattern.confidenceScore, + toolNames, + }, + timestamp: now, + } + } + + return undefined + } + + private normalizeToolNames(toolNames: string[] | undefined): string[] { + if (!Array.isArray(toolNames)) { + return [] + } + + return Array.from( + new Set(toolNames.map((toolName) => toolName.trim()).filter((toolName) => toolName.length > 0)), + ).sort() + } + + private normalizeSkillProvenance(value: SkillProvenance | string): SkillProvenance { + return value === "agent" || value === "user" || value === "bundled" || value === "hub" ? value : "unknown" + } + + private buildWorkflowSkillName(toolNames: string[]): string { + return `workflow-${toolNames + .map((toolName) => + toolName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""), + ) + .join("-")}` + } + + private buildSkillId(skillName: string, source: "global" | "project"): string { + return `skill:${source}:${skillName}` + } + + private buildSkillContent(skillName: string, description: string, toolNames: string[]): string { + const title = skillName + .split("-") + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" ") + const bulletList = toolNames.map((toolName) => "- `" + toolName + "`").join("\n") + const inlineTools = toolNames.map((toolName) => "`" + toolName + "`").join(" then ") + + return `--- +name: ${skillName} +description: ${description} +--- + +# ${title} + +## When to use + +${description} + +## Preferred tools + +${bulletList} + +## Workflow + +1. Start with ${inlineTools}. +2. Keep the sequence focused on the same reusable workflow. +3. Update this skill when the workflow changes materially. +` + } } diff --git a/src/services/self-improving/SelfImprovingManager.ts b/src/services/self-improving/SelfImprovingManager.ts index 7cbf24935c..72f5ce5d38 100644 --- a/src/services/self-improving/SelfImprovingManager.ts +++ b/src/services/self-improving/SelfImprovingManager.ts @@ -40,6 +40,7 @@ export class SelfImprovingManager { private readonly logger: Logger private readonly getExperiments: () => Experiments | undefined private readonly getCodeIndexInfo: SelfImprovingManagerOptions["getCodeIndexInfo"] + private readonly skillsManager: SelfImprovingManagerOptions["skillsManager"] public readonly memoryStore: MemoryBackend public readonly skillUsageStore: SkillUsageStore public readonly curatorService: CuratorService @@ -61,6 +62,7 @@ export class SelfImprovingManager { this.logger = options.logger this.getExperiments = options.getExperiments this.getCodeIndexInfo = options.getCodeIndexInfo + this.skillsManager = options.skillsManager this.memoryStore = MemoryBackendFactory.create( options.memoryBackend || "builtin", options.globalStoragePath, @@ -68,7 +70,12 @@ export class SelfImprovingManager { options.agentMemoryUrl, ) this.skillUsageStore = new SkillUsageStore(options.globalStoragePath, options.logger) - this.actionExecutor = new ActionExecutor(this.memoryStore, this.skillUsageStore, options.logger) + this.actionExecutor = new ActionExecutor( + this.memoryStore, + this.skillUsageStore, + options.logger, + options.skillsManager, + ) this.curatorService = new CuratorService( options.globalStoragePath, this.skillUsageStore, @@ -87,6 +94,14 @@ export class SelfImprovingManager { return experiments[SELF_IMPROVING_EXPERIMENT_ID] === true } + static isAutoSkillsEnabled(experiments: Experiments | undefined): boolean { + if (!SelfImprovingManager.isExperimentEnabled(experiments)) { + return false + } + + return experiments?.selfImprovingAutoSkills === true + } + async initialize(): Promise { if (!SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { return @@ -493,7 +508,11 @@ export class SelfImprovingManager { store: new LearningStore(this.globalStoragePath, this.logger), feedbackCollector: new FeedbackCollector(), patternAnalyzer: new PatternAnalyzer(), - improvementApplier: new ImprovementApplier(), + improvementApplier: new ImprovementApplier({ + getSkillNames: () => this.skillsManager?.getSkillNames() ?? [], + getSkillProvenance: (name: string) => this.resolveSkillProvenance(name), + isAutoSkillsEnabled: () => SelfImprovingManager.isAutoSkillsEnabled(this.getExperiments()), + }), codeIndexAdapter: new CodeIndexAdapter(this.logger, this.getCodeIndexInfo), } } @@ -557,10 +576,26 @@ export class SelfImprovingManager { actions.filter((action) => action.actionType === "ERROR_AVOIDANCE").length, skillSuggestionCount: telemetry.skillSuggestionCount + - actions.filter((action) => action.actionType === "SKILL_SUGGESTION").length, + actions.filter( + (action) => + action.actionType === "SKILL_SUGGESTION" || + action.actionType === "SKILL_CREATE" || + action.actionType === "SKILL_UPDATE", + ).length, }) } + private resolveSkillProvenance(name: string): string { + const agentRecord = this.skillUsageStore + .getAll() + .find((record) => record.skillName === name && record.createdBy === "agent") + if (agentRecord) { + return agentRecord.createdBy + } + + return this.skillsManager?.getSkillProvenance(name) ?? "unknown" + } + 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/__tests__/ActionExecutor.spec.ts b/src/services/self-improving/__tests__/ActionExecutor.spec.ts index 2b66b04519..974cc10093 100644 --- a/src/services/self-improving/__tests__/ActionExecutor.spec.ts +++ b/src/services/self-improving/__tests__/ActionExecutor.spec.ts @@ -86,6 +86,82 @@ describe("ActionExecutor", () => { ) }) + it("creates agent-managed skills from mutation actions", async () => { + const memoryStore = { store: vi.fn() } as any + const skillUsageStore = { getOrCreate: vi.fn() } as any + const skillsManager = { + createSkillFromContent: vi + .fn() + .mockResolvedValue("/tmp/.roo/skills/workflow-read-file-search-files/SKILL.md"), + } as any + const executor = new ActionExecutor(memoryStore, skillUsageStore, logger, skillsManager) + + const action: ImprovementAction = { + id: "action-skill-create", + actionType: "SKILL_CREATE", + target: "skills-manager", + payload: { + skillName: "workflow-read-file-search-files", + description: "Use when tasks repeatedly succeed with read_file and search_files.", + content: + "---\nname: workflow-read-file-search-files\ndescription: Use when tasks repeatedly succeed with read_file and search_files.\n---\n\n# Workflow\n", + source: "project", + modeSlugs: ["code"], + }, + timestamp: 1, + } + + await expect(executor.execute(action)).resolves.toBe(true) + expect(skillsManager.createSkillFromContent).toHaveBeenCalledWith( + "workflow-read-file-search-files", + "project", + "Use when tasks repeatedly succeed with read_file and search_files.", + expect.stringContaining("name: workflow-read-file-search-files"), + ["code"], + ) + expect(skillUsageStore.getOrCreate).toHaveBeenCalledWith( + "skill:project:workflow-read-file-search-files", + "workflow-read-file-search-files", + "agent", + ) + }) + + it("updates existing agent-managed skills from mutation actions", async () => { + const memoryStore = { store: vi.fn() } as any + const skillUsageStore = { + getOrCreate: vi.fn(), + bumpPatch: vi.fn().mockResolvedValue(undefined), + } as any + const skillsManager = { + updateSkillContent: vi.fn().mockResolvedValue(undefined), + } as any + const executor = new ActionExecutor(memoryStore, skillUsageStore, logger, skillsManager) + + const action: ImprovementAction = { + id: "action-skill-update", + actionType: "SKILL_UPDATE", + target: "skills-manager", + payload: { + skillId: "skill:project:workflow-read-file-search-files", + skillName: "workflow-read-file-search-files", + content: + "---\nname: workflow-read-file-search-files\ndescription: Updated workflow\n---\n\n# Workflow\nUpdated\n", + source: "project", + mode: "code", + }, + timestamp: 1, + } + + await expect(executor.execute(action)).resolves.toBe(true) + expect(skillsManager.updateSkillContent).toHaveBeenCalledWith( + "workflow-read-file-search-files", + "project", + expect.stringContaining("Updated workflow"), + "code", + ) + expect(skillUsageStore.bumpPatch).toHaveBeenCalledWith("skill:project:workflow-read-file-search-files") + }) + it("keeps invalid actions pending by reporting failure", async () => { const executor = new ActionExecutor({ store: vi.fn() } as any, { getOrCreate: vi.fn() } as any, logger) diff --git a/src/services/self-improving/__tests__/ImprovementApplier.spec.ts b/src/services/self-improving/__tests__/ImprovementApplier.spec.ts new file mode 100644 index 0000000000..e3d6778fb1 --- /dev/null +++ b/src/services/self-improving/__tests__/ImprovementApplier.spec.ts @@ -0,0 +1,67 @@ +import { ImprovementApplier } from "../ImprovementApplier" +import type { LearnedPattern } from "../types" + +function createToolPattern(): LearnedPattern { + return { + id: "pattern-tool", + patternType: "tool", + state: "active", + summary: "Effective tool combination: read_file,search_files", + confidenceScore: 0.82, + frequency: 4, + successRate: 0.9, + firstSeenAt: 1, + lastSeenAt: 2, + sourceSignals: ["TASK_SUCCESS"], + context: { + toolNames: ["read_file", "search_files"], + modes: ["code"], + }, + } +} + +describe("ImprovementApplier", () => { + it("creates agent skill actions for repeated tool workflows", () => { + const applier = new ImprovementApplier({ + getSkillNames: () => [], + getSkillProvenance: () => "unknown", + isAutoSkillsEnabled: () => true, + }) + + const actions = applier.generateActions([createToolPattern()]) + const skillAction = actions.find((action) => action.actionType === "SKILL_CREATE") + + expect(skillAction).toBeDefined() + expect(skillAction?.payload.skillName).toBe("workflow-read-file-search-files") + expect(skillAction?.payload.source).toBe("project") + expect(skillAction?.payload.description).toContain("read_file") + expect(skillAction?.payload.content).toContain("name: workflow-read-file-search-files") + expect(skillAction?.payload.content).toContain("`read_file`") + }) + + it("updates existing agent-created workflow skills instead of recreating them", () => { + const applier = new ImprovementApplier({ + getSkillNames: () => ["workflow-read-file-search-files"], + getSkillProvenance: () => "agent", + isAutoSkillsEnabled: () => true, + }) + + const actions = applier.generateActions([createToolPattern()]) + + expect(actions.some((action) => action.actionType === "SKILL_UPDATE")).toBe(true) + expect(actions.some((action) => action.actionType === "SKILL_CREATE")).toBe(false) + }) + + it("does not emit skill mutation actions when auto-skills are disabled", () => { + const applier = new ImprovementApplier({ + getSkillNames: () => [], + getSkillProvenance: () => "unknown", + isAutoSkillsEnabled: () => false, + }) + + const actions = applier.generateActions([createToolPattern()]) + + expect(actions.some((action) => action.actionType === "SKILL_CREATE")).toBe(false) + expect(actions.some((action) => action.actionType === "SKILL_UPDATE")).toBe(false) + }) +}) diff --git a/src/services/self-improving/types.ts b/src/services/self-improving/types.ts index 211bd4c386..751ca2b937 100644 --- a/src/services/self-improving/types.ts +++ b/src/services/self-improving/types.ts @@ -99,6 +99,14 @@ export interface SelfImprovingManagerOptions { skillsManager?: { getSkillNames(): string[] getSkillProvenance(name: string): string + createSkillFromContent( + name: string, + source: "global" | "project", + description: string, + content: string, + modeSlugs?: string[], + ): Promise + updateSkillContent(name: string, source: "global" | "project", content: string, mode?: string): Promise } } From 02ed3aed7a1ecd615efd1e0ffad8e3f0d7be5a4e Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 11:32:54 +0800 Subject: [PATCH 19/32] feat: expose auto-skill toggle in experimental settings --- .../settings/ExperimentalFeature.tsx | 8 +++- .../settings/ExperimentalSettings.tsx | 41 ++++++++++++++++++- .../settings/__tests__/SettingsView.spec.tsx | 36 ++++++++++++++++ webview-ui/src/i18n/locales/en/settings.json | 4 ++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/webview-ui/src/components/settings/ExperimentalFeature.tsx b/webview-ui/src/components/settings/ExperimentalFeature.tsx index a96a00a426..8c427308a5 100644 --- a/webview-ui/src/components/settings/ExperimentalFeature.tsx +++ b/webview-ui/src/components/settings/ExperimentalFeature.tsx @@ -6,9 +6,10 @@ interface ExperimentalFeatureProps { onChange: (value: boolean) => void // Additional property to identify the experiment experimentKey?: string + checkboxTestId?: string } -export const ExperimentalFeature = ({ enabled, onChange, experimentKey }: ExperimentalFeatureProps) => { +export const ExperimentalFeature = ({ enabled, onChange, experimentKey, checkboxTestId }: ExperimentalFeatureProps) => { const { t } = useAppTranslation() // Generate translation keys based on experiment key @@ -18,7 +19,10 @@ export const ExperimentalFeature = ({ enabled, onChange, experimentKey }: Experi return (
- onChange(e.target.checked)}> + onChange(e.target.checked)} + data-testid={checkboxTestId}> {t(nameKey)}
diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx index 23786ce0b9..37948b5256 100644 --- a/webview-ui/src/components/settings/ExperimentalSettings.tsx +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -43,6 +43,7 @@ export const ExperimentalSettings = ({ ...props }: ExperimentalSettingsProps) => { const { t } = useAppTranslation() + const autoSkillsVisible = experiments[EXPERIMENT_IDS.SELF_IMPROVING] ?? false return (
@@ -50,9 +51,8 @@ export const ExperimentalSettings = ({
{Object.entries(experimentConfigsMap) - .filter(([key]) => key in EXPERIMENT_IDS) + .filter(([key]) => key in EXPERIMENT_IDS && key !== "SELF_IMPROVING_AUTO_SKILLS") .map((config) => { - // Use the same translation key pattern as ExperimentalFeature const experimentKey = config[0] const label = t(`settings:experimental.${experimentKey}.name`) @@ -99,6 +99,43 @@ export const ExperimentalSettings = ({ ) } + if (config[0] === "SELF_IMPROVING") { + return ( + +
+ + setExperimentEnabled(EXPERIMENT_IDS.SELF_IMPROVING, enabled) + } + checkboxTestId="experimental-self-improving-checkbox" + /> + {autoSkillsVisible && ( +
+ + setExperimentEnabled( + EXPERIMENT_IDS.SELF_IMPROVING_AUTO_SKILLS, + enabled, + ) + } + checkboxTestId="experimental-self-improving-auto-skills-checkbox" + /> +
+ )} +
+
+ ) + } return ( { }) }) +describe("SettingsView - Experimental Settings", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("shows the auto-skills sub-option under self-improving and saves it", () => { + const { activateTab, getSettingsContent } = renderSettingsView() + + activateTab("experimental") + + const content = getSettingsContent() + const selfImprovingCheckbox = within(content).getByTestId("experimental-self-improving-checkbox") + fireEvent.click(selfImprovingCheckbox) + expect(selfImprovingCheckbox).toBeChecked() + + const autoSkillsCheckbox = within(content).getByTestId("experimental-self-improving-auto-skills-checkbox") + fireEvent.click(autoSkillsCheckbox) + expect(autoSkillsCheckbox).toBeChecked() + + const saveButton = screen.getByTestId("save-button") + fireEvent.click(saveButton) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ + experiments: expect.objectContaining({ + selfImproving: true, + selfImprovingAutoSkills: true, + }), + }), + }), + ) + }) +}) + describe("SettingsView - API Configuration", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 18da0364bc..23e047d788 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -883,6 +883,10 @@ "name": "Self-Improving", "description": "Enable background learning from task outcomes to improve prompt guidance, tool preferences, and error avoidance over time" }, + "SELF_IMPROVING_AUTO_SKILLS": { + "name": "Auto-create and update skills from learned workflows", + "description": "When enabled, Self-Improving can turn repeated successful workflows into project skills and keep agent-created skills updated automatically." + }, "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.", From f6d34080dbc1a3527efd6e76c72176b7b543f266 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 11:33:17 +0800 Subject: [PATCH 20/32] test: cover auto-skill experiment gating --- .../__tests__/SelfImprovingManager.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts index 6f18c55968..f0e4a197f1 100644 --- a/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts +++ b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts @@ -265,6 +265,17 @@ describe("SelfImprovingManager", () => { vi.useRealTimers() }) + it("requires self-improving to be enabled before auto-skills activates", () => { + expect(SelfImprovingManager.isAutoSkillsEnabled(undefined)).toBe(false) + expect(SelfImprovingManager.isAutoSkillsEnabled({ selfImprovingAutoSkills: true } as any)).toBe(false) + expect( + SelfImprovingManager.isAutoSkillsEnabled({ selfImproving: true, selfImprovingAutoSkills: false } as any), + ).toBe(false) + expect( + SelfImprovingManager.isAutoSkillsEnabled({ selfImproving: true, selfImprovingAutoSkills: true } as any), + ).toBe(true) + }) + it("has zero runtime overhead when disabled", async () => { const manager = createManager() From 7cf030e5b54dc4680bcc26f9ebd8c4841fb50ed7 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 13:12:11 +0800 Subject: [PATCH 21/32] Add configurable self-improving memory backend settings --- packages/types/src/global-settings.ts | 2 + packages/types/src/vscode-extension-host.ts | 4 + .../config/__tests__/ContextProxy.spec.ts | 10 +++ src/core/webview/ClineProvider.ts | 8 ++ .../ClineProvider.apiHandlerRebuild.spec.ts | 10 +++ .../__tests__/webviewMessageHandler.spec.ts | 29 +++++++ src/core/webview/webviewMessageHandler.ts | 5 +- .../self-improving/AgentMemoryAdapter.ts | 2 +- .../self-improving/SelfImprovingManager.ts | 79 +++++++++++++++---- .../__tests__/SelfImprovingManager.spec.ts | 25 ++++++ src/services/self-improving/types.ts | 4 +- .../settings/ExperimentalSettings.tsx | 58 +++++++++++++- .../src/components/settings/SettingsView.tsx | 30 +++++++ .../settings/__tests__/SettingsView.spec.tsx | 53 ++++++++++--- 14 files changed, 288 insertions(+), 31 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 3a5a99eb98..7882c1874a 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -92,6 +92,8 @@ export const globalSettingsSchema = z.object({ imageGenerationProvider: z.enum(["openrouter"]).optional(), openRouterImageApiKey: z.string().optional(), openRouterImageGenerationSelectedModel: z.string().optional(), + memoryBackend: z.enum(["builtin", "agentmemory"]).optional(), + agentMemoryUrl: z.string().optional(), customCondensingPrompt: z.string().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index cda26ad091..3043968253 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -290,6 +290,8 @@ export type ExtensionState = Pick< | "includeDiagnosticMessages" | "maxDiagnosticMessages" | "imageGenerationProvider" + | "memoryBackend" + | "agentMemoryUrl" | "openRouterImageGenerationSelectedModel" | "includeTaskHistoryInEnhance" | "reasoningBlockCollapsed" @@ -360,6 +362,8 @@ export type ExtensionState = Pick< profileThresholds: Record hasOpenedModeSelector: boolean openRouterImageApiKey?: string + memoryBackend?: "builtin" | "agentmemory" + agentMemoryUrl?: string messageQueue?: QueuedMessage[] selfImprovingStatus?: { enabled: boolean diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 0a24141155..d797ff918a 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -255,6 +255,16 @@ describe("ContextProxy", () => { const storedValue = proxy.getGlobalState("apiModelId") expect(storedValue).toBe("gpt-4") }) + + it("should persist self-improving memory backend settings in global settings", async () => { + await proxy.setValue("memoryBackend" as any, "agentmemory" as any) + await proxy.setValue("agentMemoryUrl" as any, "http://agentmemory.internal:4001" as any) + + const globalSettings = proxy.getGlobalSettings() as any + + expect(globalSettings.memoryBackend).toBe("agentmemory") + expect(globalSettings.agentMemoryUrl).toBe("http://agentmemory.internal:4001") + }) }) describe("setValues", () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2f6a4c395f..69fe053d4a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -235,6 +235,8 @@ export class ClineProvider appendLine: (message: string) => this.log(message), }, getExperiments: () => this.contextProxy.getGlobalState("experiments"), + getMemoryBackend: () => this.contextProxy.getGlobalState("memoryBackend"), + getAgentMemoryUrl: () => this.contextProxy.getGlobalState("agentMemoryUrl"), skillsManager: this.skillsManager, getCodeIndexInfo: () => { const manager = this.codeIndexManager @@ -2166,6 +2168,8 @@ export class ClineProvider imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + memoryBackend, + agentMemoryUrl, lockApiConfigAcrossModes, } = await this.getState() @@ -2345,6 +2349,8 @@ export class ClineProvider imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + memoryBackend, + agentMemoryUrl, openAiCodexIsAuthenticated: await (async () => { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") @@ -2541,6 +2547,8 @@ export class ClineProvider imageGenerationProvider: stateValues.imageGenerationProvider, openRouterImageApiKey: stateValues.openRouterImageApiKey, openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, + memoryBackend: stateValues.memoryBackend, + agentMemoryUrl: stateValues.agentMemoryUrl, } } diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 9d81880d4a..c7bc2508dd 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -579,4 +579,14 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(getModelId({})).toBeUndefined() }) }) + + test("includes self-improving memory backend settings in provider state", async () => { + await (provider as any).setValue("memoryBackend", "agentmemory") + await (provider as any).setValue("agentMemoryUrl", "http://agentmemory.internal:4001") + + const state = await provider.getState() + + expect((state as any).memoryBackend).toBe("agentmemory") + expect((state as any).agentMemoryUrl).toBe("http://agentmemory.internal:4001") + }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 17e0caebb0..0825b1d7c7 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -61,6 +61,9 @@ const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInf const mockClineProvider = { getState: vi.fn(), postMessageToWebview: vi.fn(), + selfImprovingManager: { + onSettingsChanged: vi.fn(), + }, customModesManager: { getCustomModes: vi.fn(), deleteCustomMode: vi.fn(), @@ -76,6 +79,7 @@ const mockClineProvider = { }, setValue: vi.fn(), getValue: vi.fn(), + getGlobalState: vi.fn(), }, log: vi.fn(), postStateToWebview: vi.fn(), @@ -860,6 +864,31 @@ describe("webviewMessageHandler - mcpEnabled", () => { }) }) +describe("webviewMessageHandler - self-improving memory settings", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.contextProxy.getGlobalState).mockReturnValue(undefined) + }) + + it("persists memory backend settings and refreshes self-improving runtime", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { + memoryBackend: "agentmemory", + agentMemoryUrl: "http://agentmemory.internal:4001", + } as any, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("memoryBackend", "agentmemory") + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith( + "agentMemoryUrl", + "http://agentmemory.internal:4001", + ) + expect(mockClineProvider.selfImprovingManager.onSettingsChanged).toHaveBeenCalledWith(undefined) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + }) +}) + describe("webviewMessageHandler - requestCommands", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5ba7a4c988..9bff836aa9 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -663,6 +663,7 @@ export const webviewMessageHandler = async ( case "updateSettings": if (message.updatedSettings) { let experimentsUpdated = false + let selfImprovingSettingsUpdated = false for (const [key, value] of Object.entries(message.updatedSettings)) { let newValue = value @@ -747,6 +748,8 @@ export const webviewMessageHandler = async ( ...(getGlobalState("experiments") ?? experimentDefault), ...(value as Record), } + } else if (key === "memoryBackend" || key === "agentMemoryUrl") { + selfImprovingSettingsUpdated = true } else if (key === "customSupportPrompts") { if (!value) { continue @@ -756,7 +759,7 @@ export const webviewMessageHandler = async ( await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) } - if (experimentsUpdated) { + if (experimentsUpdated || selfImprovingSettingsUpdated) { await provider.selfImprovingManager.onSettingsChanged( provider.contextProxy.getGlobalState("experiments"), ) diff --git a/src/services/self-improving/AgentMemoryAdapter.ts b/src/services/self-improving/AgentMemoryAdapter.ts index 1a6af4651b..e042fa5039 100644 --- a/src/services/self-improving/AgentMemoryAdapter.ts +++ b/src/services/self-improving/AgentMemoryAdapter.ts @@ -6,7 +6,7 @@ import type { Logger } from "./types" /** * Default agentmemory server URL */ -const DEFAULT_AGENTMEMORY_URL = "http://localhost:4001" +const DEFAULT_AGENTMEMORY_URL = "http://localhost:3111" type AgentMemoryApiResult = { id: string diff --git a/src/services/self-improving/SelfImprovingManager.ts b/src/services/self-improving/SelfImprovingManager.ts index 72f5ce5d38..db43c7a616 100644 --- a/src/services/self-improving/SelfImprovingManager.ts +++ b/src/services/self-improving/SelfImprovingManager.ts @@ -40,13 +40,17 @@ export class SelfImprovingManager { private readonly logger: Logger private readonly getExperiments: () => Experiments | undefined private readonly getCodeIndexInfo: SelfImprovingManagerOptions["getCodeIndexInfo"] + private readonly getMemoryBackend: SelfImprovingManagerOptions["getMemoryBackend"] + private readonly getAgentMemoryUrl: SelfImprovingManagerOptions["getAgentMemoryUrl"] private readonly skillsManager: SelfImprovingManagerOptions["skillsManager"] - public readonly memoryStore: MemoryBackend + public memoryStore: MemoryBackend public readonly skillUsageStore: SkillUsageStore public readonly curatorService: CuratorService public readonly reviewPromptFactory: ReviewPromptFactory public readonly transcriptRecall: TranscriptRecall - private readonly actionExecutor: ActionExecutor + private actionExecutor: ActionExecutor + private memoryBackendType: "builtin" | "agentmemory" + private agentMemoryUrl: string | undefined private runtime: Runtime | undefined private started = false @@ -62,20 +66,14 @@ export class SelfImprovingManager { this.logger = options.logger this.getExperiments = options.getExperiments this.getCodeIndexInfo = options.getCodeIndexInfo + this.getMemoryBackend = options.getMemoryBackend + this.getAgentMemoryUrl = options.getAgentMemoryUrl this.skillsManager = options.skillsManager - this.memoryStore = MemoryBackendFactory.create( - options.memoryBackend || "builtin", - options.globalStoragePath, - options.logger, - options.agentMemoryUrl, - ) + this.memoryBackendType = this.resolveMemoryBackend(options.memoryBackend) + this.agentMemoryUrl = this.resolveAgentMemoryUrl(options.agentMemoryUrl) + this.memoryStore = this.createMemoryStore() this.skillUsageStore = new SkillUsageStore(options.globalStoragePath, options.logger) - this.actionExecutor = new ActionExecutor( - this.memoryStore, - this.skillUsageStore, - options.logger, - options.skillsManager, - ) + this.actionExecutor = this.createActionExecutor() this.curatorService = new CuratorService( options.globalStoragePath, this.skillUsageStore, @@ -153,6 +151,7 @@ export class SelfImprovingManager { * This enables/disables the module at runtime. */ async onSettingsChanged(_experiments: Experiments | undefined): Promise { + await this.reconfigureMemoryBackendIfNeeded() await this.handleExperimentChange() } @@ -520,6 +519,58 @@ export class SelfImprovingManager { return this.runtime } + private resolveMemoryBackend(fallback?: "builtin" | "agentmemory"): "builtin" | "agentmemory" { + return this.getMemoryBackend?.() ?? fallback ?? "builtin" + } + + private resolveAgentMemoryUrl(fallback?: string): string | undefined { + return this.getAgentMemoryUrl?.() ?? fallback + } + + private createMemoryStore(): MemoryBackend { + return MemoryBackendFactory.create( + this.memoryBackendType, + this.globalStoragePath, + this.logger, + this.agentMemoryUrl, + ) + } + + private createActionExecutor(): ActionExecutor { + return new ActionExecutor(this.memoryStore, this.skillUsageStore, this.logger, this.skillsManager) + } + + private async reconfigureMemoryBackendIfNeeded(): Promise { + const nextBackend = this.resolveMemoryBackend(this.memoryBackendType) + const nextUrl = this.resolveAgentMemoryUrl(this.agentMemoryUrl) + + if (nextBackend === this.memoryBackendType && nextUrl === this.agentMemoryUrl) { + return + } + + const wasStarted = this.started + const previousStore = this.memoryStore + + if (wasStarted && previousStore instanceof MemoryStore) { + previousStore.takeSnapshot() + } + + await previousStore.dispose() + + this.memoryBackendType = nextBackend + this.agentMemoryUrl = nextUrl + this.memoryStore = this.createMemoryStore() + this.actionExecutor = this.createActionExecutor() + + if (wasStarted && SelfImprovingManager.isExperimentEnabled(this.getExperiments())) { + await this.memoryStore.initialize() + } + + this.logger.appendLine( + `[SelfImprovingManager] Memory backend configured: ${this.memoryBackendType}${this.agentMemoryUrl ? ` (${this.agentMemoryUrl})` : ""}`, + ) + } + private startTimers(store: LearningStore): void { this.stopTimers() const config = store.getConfig() diff --git a/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts index f0e4a197f1..469c8437f4 100644 --- a/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts +++ b/src/services/self-improving/__tests__/SelfImprovingManager.spec.ts @@ -233,6 +233,8 @@ import { SelfImprovingManager } from "../SelfImprovingManager" describe("SelfImprovingManager", () => { let experiments: Record | undefined + let memoryBackend: "builtin" | "agentmemory" | undefined + let agentMemoryUrl: string | undefined let logger: { appendLine: ReturnType } const createManager = () => @@ -240,6 +242,8 @@ describe("SelfImprovingManager", () => { globalStoragePath: "/tmp/zoo-code-tests", logger, getExperiments: () => experiments, + getMemoryBackend: () => memoryBackend, + getAgentMemoryUrl: () => agentMemoryUrl, getCodeIndexInfo: () => ({ available: true, hits: 2, topScore: 0.8 }), }) @@ -258,6 +262,8 @@ describe("SelfImprovingManager", () => { mockState.reviewPromptFactories.length = 0 mockState.transcriptRecalls.length = 0 experiments = undefined + memoryBackend = undefined + agentMemoryUrl = undefined logger = { appendLine: vi.fn() } }) @@ -416,6 +422,25 @@ describe("SelfImprovingManager", () => { }) }) + it("rebuilds the memory backend when settings change", async () => { + experiments = { selfImproving: true } + const manager = createManager() + await manager.initialize() + + const originalStore = mockState.memoryStores[0] + memoryBackend = "agentmemory" + agentMemoryUrl = "http://agentmemory.internal:4001" + + await manager.onSettingsChanged(experiments as any) + + expect(originalStore.takeSnapshot).toHaveBeenCalledTimes(1) + expect(originalStore.dispose).toHaveBeenCalledTimes(1) + expect(manager.memoryStore.backendType).toBe("agentmemory") + expect(logger.appendLine).toHaveBeenCalledWith( + "[SelfImprovingManager] Memory backend configured: agentmemory (http://agentmemory.internal:4001)", + ) + }) + it("runs curator cycles through the curator service", async () => { experiments = { selfImproving: true } const manager = createManager() diff --git a/src/services/self-improving/types.ts b/src/services/self-improving/types.ts index 751ca2b937..e67befe47b 100644 --- a/src/services/self-improving/types.ts +++ b/src/services/self-improving/types.ts @@ -81,9 +81,11 @@ export interface SelfImprovingManagerOptions { logger: Logger getExperiments: () => Experiments | undefined getCodeIndexInfo?: () => CodeIndexInfo + getMemoryBackend?: () => "builtin" | "agentmemory" | undefined + getAgentMemoryUrl?: () => string | undefined /** Memory backend type: "builtin" (default) or "agentmemory" */ memoryBackend?: "builtin" | "agentmemory" - /** agentmemory server URL (default: http://localhost:4001) */ + /** agentmemory server URL (default: http://localhost:3111) */ agentMemoryUrl?: string /** Optional curator configuration overrides */ curatorConfig?: { diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx index 37948b5256..b10444189c 100644 --- a/webview-ui/src/components/settings/ExperimentalSettings.tsx +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -5,6 +5,7 @@ import type { Experiments, ImageGenerationProvider } from "@roo-code/types" import { EXPERIMENT_IDS, experimentConfigsMap } from "@roo/experiments" import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui" import { cn } from "@src/lib/utils" import { SetExperimentEnabled } from "./types" @@ -23,9 +24,13 @@ type ExperimentalSettingsProps = HTMLAttributes & { imageGenerationProvider?: ImageGenerationProvider openRouterImageApiKey?: string openRouterImageGenerationSelectedModel?: string + memoryBackend?: "builtin" | "agentmemory" + agentMemoryUrl?: string setImageGenerationProvider?: (provider: ImageGenerationProvider) => void setOpenRouterImageApiKey?: (apiKey: string) => void setImageGenerationSelectedModel?: (model: string) => void + setMemoryBackend?: (backend: "builtin" | "agentmemory") => void + setAgentMemoryUrl?: (url: string) => void } export const ExperimentalSettings = ({ @@ -36,14 +41,19 @@ export const ExperimentalSettings = ({ imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + memoryBackend, + agentMemoryUrl, setImageGenerationProvider, setOpenRouterImageApiKey, setImageGenerationSelectedModel, + setMemoryBackend, + setAgentMemoryUrl, className, ...props }: ExperimentalSettingsProps) => { const { t } = useAppTranslation() const autoSkillsVisible = experiments[EXPERIMENT_IDS.SELF_IMPROVING] ?? false + const currentMemoryBackend = memoryBackend ?? "builtin" return (
@@ -116,7 +126,7 @@ export const ExperimentalSettings = ({ checkboxTestId="experimental-self-improving-checkbox" /> {autoSkillsVisible && ( -
+
+ {setMemoryBackend && ( +
+ + +
+ )} + {currentMemoryBackend === "agentmemory" && setAgentMemoryUrl && ( +
+ + setAgentMemoryUrl(event.target.value)} + placeholder="http://localhost:3111" + data-testid="self-improving-agent-memory-url-input" + /> +
+ )}
)}
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 47e087615e..595a7eca35 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -198,6 +198,8 @@ const SettingsView = forwardRef(({ onDone, t imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + memoryBackend, + agentMemoryUrl, reasoningBlockCollapsed, enterBehavior, includeCurrentTime, @@ -342,6 +344,28 @@ const SettingsView = forwardRef(({ onDone, t }) }, []) + const setMemoryBackend = useCallback((backend: "builtin" | "agentmemory") => { + setCachedState((prevState) => { + if (prevState.memoryBackend === backend) { + return prevState + } + + setChangeDetected(true) + return { ...prevState, memoryBackend: backend } + }) + }, []) + + const setAgentMemoryUrl = useCallback((url: string) => { + setCachedState((prevState) => { + if (prevState.agentMemoryUrl === url) { + return prevState + } + + setChangeDetected(true) + return { ...prevState, agentMemoryUrl: url } + }) + }, []) + const setCustomSupportPromptsField = useCallback((prompts: Record) => { setCachedState((prevState) => { const previousStr = JSON.stringify(prevState.customSupportPrompts) @@ -420,6 +444,8 @@ const SettingsView = forwardRef(({ onDone, t imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + memoryBackend, + agentMemoryUrl: agentMemoryUrl || "http://localhost:3111", experiments, customSupportPrompts, }, @@ -908,9 +934,13 @@ const SettingsView = forwardRef(({ onDone, t openRouterImageGenerationSelectedModel={ openRouterImageGenerationSelectedModel as string | undefined } + memoryBackend={memoryBackend} + agentMemoryUrl={agentMemoryUrl} setImageGenerationProvider={setImageGenerationProvider} setOpenRouterImageApiKey={setOpenRouterImageApiKey} setImageGenerationSelectedModel={setImageGenerationSelectedModel} + setMemoryBackend={setMemoryBackend} + setAgentMemoryUrl={setAgentMemoryUrl} /> )} diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index a3568a2e59..a6904ac1d1 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -173,21 +173,17 @@ vi.mock("@/components/ui", () => ({ Input: ({ value, onChange, placeholder, "data-testid": dataTestId }: any) => ( ), - Select: ({ children, value, onValueChange }: any) => ( -
- + Select: ({ children, value, onValueChange, "data-testid": dataTestId }: any) => ( + ), - SelectTrigger: ({ children }: any) =>
{children}
, - SelectValue: ({ placeholder }: any) =>
{placeholder}
, + SelectContent: ({ children }: any) => <>{children}, + SelectGroup: ({ children }: any) => <>{children}, + SelectItem: ({ children, value }: any) => , + SelectTrigger: () => null, + SelectValue: () => null, SearchableSelect: ({ value, onValueChange, options, placeholder }: any) => ( + setSelfImprovingScope(value as "workspace" | "global") + } + data-testid="self-improving-scope-select"> + + + + + Workspace + Global + + +
+ )} setExperimentEnabled( EXPERIMENT_IDS.SELF_IMPROVING_AUTO_SKILLS, @@ -140,6 +174,36 @@ export const ExperimentalSettings = ({ } checkboxTestId="experimental-self-improving-auto-skills-checkbox" /> + {autoSkillsEnabled && setSelfImprovingAutoSkillsScope && ( +
+ + +
+ )} {setMemoryBackend && (
diff --git a/webview-ui/src/components/settings/__tests__/SkillsSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/SkillsSettings.spec.tsx index 5c42a2dc51..ff3e53153a 100644 --- a/webview-ui/src/components/settings/__tests__/SkillsSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SkillsSettings.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" +import { render, screen, fireEvent, waitFor, act } from "@/utils/test-utils" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import type { SkillMetadata } from "@roo-code/types" @@ -166,7 +166,7 @@ vi.mock("@/context/ExtensionStateContext", () => ({ useExtensionState: () => mockExtensionState, })) -const renderSkillsSettings = (skills: SkillMetadata[] = mockSkills, cwd?: string) => { +const renderSkillsSettings = (skills: SkillMetadata[] = mockSkills, cwd?: string, skillsUpdateNotice?: string) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -179,6 +179,7 @@ const renderSkillsSettings = (skills: SkillMetadata[] = mockSkills, cwd?: string skills, cwd: cwd !== undefined ? cwd : "/workspace", customModes: [], + skillsUpdateNotice, } return render( @@ -193,6 +194,7 @@ const renderSkillsSettings = (skills: SkillMetadata[] = mockSkills, cwd?: string describe("SkillsSettings", () => { beforeEach(() => { vi.clearAllMocks() + vi.useRealTimers() }) it("renders section header", () => { @@ -208,6 +210,25 @@ describe("SkillsSettings", () => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: "requestSkills" }) }) + it("shows a live skills update banner when the extension pushes one", () => { + renderSkillsSettings(mockSkills, undefined, 'Skill created: "workflow-terminal-file"') + + expect(screen.getByRole("status")).toHaveTextContent('Skill created: "workflow-terminal-file"') + }) + + it("auto-dismisses the live skills update banner after a short delay", () => { + vi.useFakeTimers() + renderSkillsSettings(mockSkills, undefined, 'Skill created: "workflow-terminal-file"') + + expect(screen.getByRole("status")).toHaveTextContent('Skill created: "workflow-terminal-file"') + + act(() => { + vi.advanceTimersByTime(4000) + }) + + expect(screen.queryByRole("status")).not.toBeInTheDocument() + }) + it("displays project skills section when in a workspace", () => { renderSkillsSettings() diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 10b49e3de0..38bdc6d313 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -144,6 +144,7 @@ export interface ExtensionStateContextType extends ExtensionState { showWorktreesInHomeScreen: boolean setShowWorktreesInHomeScreen: (value: boolean) => void skills?: SkillMetadata[] + skillsUpdateNotice?: string } export const ExtensionStateContext = createContext(undefined) @@ -282,6 +283,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode global: {}, }) const [skills, setSkills] = useState([]) + const [skillsUpdateNotice, setSkillsUpdateNotice] = useState() const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState(true) const [includeCurrentTime, setIncludeCurrentTime] = useState(true) const [includeCurrentCost, setIncludeCurrentCost] = useState(true) @@ -397,6 +399,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode } break } + case "skillsUpdated": { + if (message.skills) { + setSkills(message.skills) + } + setSkillsUpdateNotice(message.text) + break + } case "mcpServers": { setMcpServers(message.mcpServers ?? []) break @@ -594,6 +603,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode includeCurrentCost, setIncludeCurrentCost, skills, + skillsUpdateNotice, showWorktreesInHomeScreen: state.showWorktreesInHomeScreen ?? true, setShowWorktreesInHomeScreen: (value) => setState((prevState) => ({ ...prevState, showWorktreesInHomeScreen: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 2a5e74c40d..37c0789442 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -5,6 +5,7 @@ import { type ExperimentId, type ExtensionState, type ClineMessage, + type SkillMetadata, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, } from "@roo-code/types" @@ -47,6 +48,17 @@ const ApiConfigTestComponent = () => { ) } +const SkillsUpdateTestComponent = () => { + const { skills, skillsUpdateNotice } = useExtensionState() + + return ( +
+
{JSON.stringify(skills ?? [])}
+
{skillsUpdateNotice ?? ""}
+
+ ) +} + describe("ExtensionStateContext", () => { it("initializes with empty allowedCommands array", () => { render( @@ -181,6 +193,38 @@ describe("ExtensionStateContext", () => { }), ) }) + + it("updates skills and notice when a live skillsUpdated message arrives", () => { + render( + + + , + ) + + const updatedSkills: SkillMetadata[] = [ + { + name: "workflow-terminal-file", + description: "Generated workflow skill", + path: "/tmp/workflow-terminal-file/SKILL.md", + source: "global", + }, + ] + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "skillsUpdated", + text: 'Skill created: "workflow-terminal-file"', + skills: updatedSkills, + }, + }), + ) + }) + + expect(JSON.parse(screen.getByTestId("skills-state").textContent || "[]")).toEqual(updatedSkills) + expect(screen.getByTestId("skills-update-notice").textContent).toBe('Skill created: "workflow-terminal-file"') + }) }) describe("mergeExtensionState", () => { From b6b80aaf35c163fd021d3ee8a73b01ec7b89523a Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 19:25:20 +0800 Subject: [PATCH 24/32] fix: avoid blocking submitUserMessage on correction telemetry --- src/core/task/Task.ts | 2 +- src/core/task/__tests__/Task.spec.ts | 34 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 5e818da70e..f8253cc7b4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1470,7 +1470,7 @@ export class Task extends EventEmitter implements TaskLike { } if (shouldRecordCorrection) { - await provider + void provider .getSelfImprovingManager?.() ?.recordUserCorrection({ taskId: this.taskId, diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index eac140f010..e8e59734fe 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1509,6 +1509,40 @@ describe("Cline", () => { }) expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "here is the correction", []) }) + + it("does not wait for correction telemetry before handling the user response", async () => { + let resolveCorrection: (() => void) | undefined + const recordUserCorrection = vi.fn( + () => + new Promise((resolve) => { + resolveCorrection = resolve + }), + ) + mockProvider.getSelfImprovingManager = vi.fn().mockReturnValue({ recordUserCorrection }) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + + const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse") + ;(task as any).interactiveAsk = { type: "ask", text: "Need clarification" } + + const submitPromise = task.submitUserMessage("here is the correction") + await Promise.resolve() + + expect(recordUserCorrection).toHaveBeenCalledWith({ + taskId: task.taskId, + success: false, + corrected: true, + }) + expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "here is the correction", []) + + resolveCorrection?.() + await submitPromise + }) }) }) From 813f4c986b2cae8ba0a05188a1329462e85cceb9 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 19:30:47 +0800 Subject: [PATCH 25/32] fix: make default auto-skill lookup source-aware --- src/services/self-improving/ImprovementApplier.ts | 5 ++++- .../__tests__/ImprovementApplier.spec.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/services/self-improving/ImprovementApplier.ts b/src/services/self-improving/ImprovementApplier.ts index 8de793dbb0..f15e041c38 100644 --- a/src/services/self-improving/ImprovementApplier.ts +++ b/src/services/self-improving/ImprovementApplier.ts @@ -37,7 +37,10 @@ export class ImprovementApplier { this.getSkillProvenance = options.getSkillProvenance ?? (() => "unknown") this.getSkillProvenanceForSource = options.getSkillProvenanceForSource ?? ((name: string) => this.getSkillProvenance(name)) - this.hasSkill = options.hasSkill ?? ((name: string) => this.getSkillNames().includes(name)) + this.hasSkill = + options.hasSkill ?? + ((name: string, source: "global" | "project") => + source === "project" && this.getSkillNames().includes(name)) this.isAutoSkillsEnabled = options.isAutoSkillsEnabled ?? (() => false) this.getAutoSkillsScope = options.getAutoSkillsScope ?? (() => "workspace") } diff --git a/src/services/self-improving/__tests__/ImprovementApplier.spec.ts b/src/services/self-improving/__tests__/ImprovementApplier.spec.ts index 467b75d135..87f20419c8 100644 --- a/src/services/self-improving/__tests__/ImprovementApplier.spec.ts +++ b/src/services/self-improving/__tests__/ImprovementApplier.spec.ts @@ -99,4 +99,18 @@ describe("ImprovementApplier", () => { expect(actions.some((action) => action.actionType === "SKILL_CREATE")).toBe(true) expect(actions.some((action) => action.actionType === "SKILL_UPDATE")).toBe(false) }) + + it("defaults to source-aware skill existence checks when only getSkillNames is provided", () => { + const applier = new ImprovementApplier({ + getSkillNames: () => ["workflow-read-file-search-files"], + getSkillProvenance: () => "agent", + isAutoSkillsEnabled: () => true, + getAutoSkillsScope: () => "global", + }) + + const actions = applier.generateActions([createToolPattern()]) + + expect(actions.some((action) => action.actionType === "SKILL_CREATE")).toBe(true) + expect(actions.some((action) => action.actionType === "SKILL_UPDATE")).toBe(false) + }) }) From f024a3fdb50098bca592c8e5976795d07ea7ad61 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 19:35:07 +0800 Subject: [PATCH 26/32] fix: support secondary mode slugs in skill updates --- src/services/skills/SkillsManager.ts | 9 +++++- .../skills/__tests__/SkillsManager.spec.ts | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 5248efac70..932aecd0eb 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -482,7 +482,14 @@ Add your skill instructions here. content: string, mode?: string, ): Promise { - const skill = mode ? this.getSkill(name, source, mode) : this.findSkillByNameAndSource(name, source) + const skill = mode + ? Array.from(this.skills.values()).find( + (candidate) => + candidate.name === name && + candidate.source === source && + (candidate.mode === mode || candidate.modeSlugs?.includes(mode)), + ) + : this.findSkillByNameAndSource(name, source) if (!skill) { const modeInfo = mode ? ` (mode: ${mode})` : "" throw new Error(t("skills:errors.not_found", { name, source, modeInfo })) diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index 0f316f2cc9..a5a39442cf 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -1400,6 +1400,35 @@ Instructions`) expect(mockWriteFile).toHaveBeenCalledWith(testSkillMd, updatedContent, "utf-8") }) + it("updates a multi-mode skill when addressed by a secondary mode slug", async () => { + const testSkillDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(testSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["test-skill"] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + mockFileExists.mockImplementation(async (file: string) => file === testSkillMd) + mockReadFile.mockResolvedValue( + `---\nname: test-skill\ndescription: A test skill\nmodeSlugs:\n - code\n - architect\n---\n\nOriginal content`, + ) + mockWriteFile.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + const updatedContent = `---\nname: test-skill\ndescription: Updated test skill\nmodeSlugs:\n - code\n - architect\n---\n\nUpdated content` + + await expect( + skillsManager.updateSkillContent("test-skill", "global", updatedContent, "architect"), + ).resolves.toBeUndefined() + expect(mockWriteFile).toHaveBeenCalledWith(testSkillMd, updatedContent, "utf-8") + }) + it("should push a live skills updated message to the webview after updating a skill", async () => { const testSkillDir = p(globalSkillsDir, "test-skill") const testSkillMd = p(testSkillDir, "SKILL.md") From 3b8b1508173daf750f016408bb381bc91fec8bb6 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 19:37:37 +0800 Subject: [PATCH 27/32] refactor: type getGlobalStateSafe against GlobalState --- src/core/webview/ClineProvider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 42c6289353..0c4979512c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1941,8 +1941,8 @@ export class ClineProvider await this.postStateToWebview() } - private getGlobalStateSafe(key: string): T | undefined { - return (this.contextProxy as any).getGlobalState?.(key) + private getGlobalStateSafe(key: K): GlobalState[K] | undefined { + return this.contextProxy.getGlobalState(key) } async refreshWorkspace() { From 1c17fdc0e7cf41e1cd48b72287d3b1b42dc5c197 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 19:40:06 +0800 Subject: [PATCH 28/32] test: assert global routing for self-improving settings --- .../config/__tests__/ContextProxy.spec.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index d8c61eeb23..6f07feb901 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -257,23 +257,27 @@ describe("ContextProxy", () => { }) it("should persist self-improving memory backend settings in global settings", async () => { - await proxy.setValue("memoryBackend" as any, "agentmemory" as any) - await proxy.setValue("agentMemoryUrl" as any, "http://agentmemory.internal:4001" as any) + const updateGlobalStateSpy = vi.spyOn(proxy, "updateGlobalState") - const globalSettings = proxy.getGlobalSettings() as any + await proxy.setValue("memoryBackend", "agentmemory") + await proxy.setValue("agentMemoryUrl", "http://agentmemory.internal:4001") - expect(globalSettings.memoryBackend).toBe("agentmemory") - expect(globalSettings.agentMemoryUrl).toBe("http://agentmemory.internal:4001") + expect(updateGlobalStateSpy).toHaveBeenCalledWith("memoryBackend", "agentmemory") + expect(updateGlobalStateSpy).toHaveBeenCalledWith("agentMemoryUrl", "http://agentmemory.internal:4001") + expect(proxy.getGlobalState("memoryBackend")).toBe("agentmemory") + expect(proxy.getGlobalState("agentMemoryUrl")).toBe("http://agentmemory.internal:4001") }) it("should persist self-improving scope settings in global settings", async () => { - await proxy.setValue("selfImprovingScope" as any, "workspace" as any) - await proxy.setValue("selfImprovingAutoSkillsScope" as any, "global" as any) + const updateGlobalStateSpy = vi.spyOn(proxy, "updateGlobalState") - const globalSettings = proxy.getGlobalSettings() as any + await proxy.setValue("selfImprovingScope", "workspace") + await proxy.setValue("selfImprovingAutoSkillsScope", "global") - expect(globalSettings.selfImprovingScope).toBe("workspace") - expect(globalSettings.selfImprovingAutoSkillsScope).toBe("global") + expect(updateGlobalStateSpy).toHaveBeenCalledWith("selfImprovingScope", "workspace") + expect(updateGlobalStateSpy).toHaveBeenCalledWith("selfImprovingAutoSkillsScope", "global") + expect(proxy.getGlobalState("selfImprovingScope")).toBe("workspace") + expect(proxy.getGlobalState("selfImprovingAutoSkillsScope")).toBe("global") }) }) From b5b4c8b3fb5971fd7ecf0fddb29267e89ca34c19 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 19:45:59 +0800 Subject: [PATCH 29/32] test: verify self-improving settings stay local before save --- .../components/settings/__tests__/SettingsView.spec.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index 9c053a4be2..d99087851a 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -526,12 +526,16 @@ describe("SettingsView - Experimental Settings", () => { fireEvent.click(autoSkillsCheckbox) expect(autoSkillsCheckbox).toBeChecked() + vi.clearAllMocks() + const selfImprovingScopeSelect = within(content).getByTestId("self-improving-scope-select") fireEvent.change(selfImprovingScopeSelect, { target: { value: "workspace" } }) const autoSkillsScopeSelect = within(content).getByTestId("self-improving-auto-skills-scope-select") fireEvent.change(autoSkillsScopeSelect, { target: { value: "global" } }) + expect(vscode.postMessage).not.toHaveBeenCalled() + const saveButton = screen.getByTestId("save-button") fireEvent.click(saveButton) @@ -560,6 +564,8 @@ describe("SettingsView - Experimental Settings", () => { fireEvent.click(selfImprovingCheckbox) expect(selfImprovingCheckbox).toBeChecked() + vi.clearAllMocks() + const selfImprovingScopeSelect = within(content).getByTestId("self-improving-scope-select") fireEvent.change(selfImprovingScopeSelect, { target: { value: "global" } }) @@ -569,6 +575,7 @@ describe("SettingsView - Experimental Settings", () => { const urlInput = within(content).getByTestId("self-improving-agent-memory-url-input") fireEvent.change(urlInput, { target: { value: "http://agentmemory.internal:4001" } }) expect(urlInput).toHaveValue("http://agentmemory.internal:4001") + expect(vscode.postMessage).not.toHaveBeenCalled() const saveButton = screen.getByTestId("save-button") fireEvent.click(saveButton) From 3f5c5c79da0f3c05e456c19efbff485d636453d6 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 19:52:36 +0800 Subject: [PATCH 30/32] i18n: localize self-improving scope labels --- .../settings/ExperimentalSettings.tsx | 45 ++++++++++++++++--- webview-ui/src/i18n/locales/en/settings.json | 5 ++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx index 9becf7eea9..a02c135b53 100644 --- a/webview-ui/src/components/settings/ExperimentalSettings.tsx +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -157,8 +157,22 @@ export const ExperimentalSettings = ({ /> - Workspace - Global + + {t( + "settings:experimental.SELF_IMPROVING.scopeWorkspace", + { + defaultValue: "Workspace", + }, + )} + + + {t( + "settings:experimental.SELF_IMPROVING.scopeGlobal", + { + defaultValue: "Global", + }, + )} +
@@ -198,8 +212,22 @@ export const ExperimentalSettings = ({ /> - Workspace - Global + + {t( + "settings:experimental.SELF_IMPROVING.scopeWorkspace", + { + defaultValue: "Workspace", + }, + )} + + + {t( + "settings:experimental.SELF_IMPROVING.scopeGlobal", + { + defaultValue: "Global", + }, + )} +
@@ -226,7 +254,14 @@ export const ExperimentalSettings = ({ /> - Built-in + + {t( + "settings:experimental.SELF_IMPROVING.memoryBackendBuiltin", + { + defaultValue: "Built-in", + }, + )} + agentmemory diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 23e047d788..8e17840bad 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -881,7 +881,10 @@ }, "SELF_IMPROVING": { "name": "Self-Improving", - "description": "Enable background learning from task outcomes to improve prompt guidance, tool preferences, and error avoidance over time" + "description": "Enable background learning from task outcomes to improve prompt guidance, tool preferences, and error avoidance over time", + "scopeWorkspace": "Workspace", + "scopeGlobal": "Global", + "memoryBackendBuiltin": "Built-in" }, "SELF_IMPROVING_AUTO_SKILLS": { "name": "Auto-create and update skills from learned workflows", From 4dbb09134bc47e9899eb47e5aa36c4288aa45ed9 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 20:02:11 +0800 Subject: [PATCH 31/32] fix: validate SKILL.md structure before persisting --- src/i18n/locales/en/skills.json | 1 + src/services/skills/SkillsManager.ts | 32 ++++++++++++++ .../skills/__tests__/SkillsManager.spec.ts | 42 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/src/i18n/locales/en/skills.json b/src/i18n/locales/en/skills.json index 307b59d365..447947e4af 100644 --- a/src/i18n/locales/en/skills.json +++ b/src/i18n/locales/en/skills.json @@ -3,6 +3,7 @@ "name_length": "Skill name must be 1-{{maxLength}} characters (got {{length}})", "name_format": "Skill name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)", "description_length": "Skill description must be 1-1024 characters (got {{length}})", + "invalid_structure": "Invalid SKILL.md structure: {{reason}}", "no_workspace": "Cannot create project skill: no workspace folder is open", "already_exists": "Skill \"{{name}}\" already exists at {{path}}", "not_found": "Skill \"{{name}}\" not found in {{source}}{{modeInfo}}", diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 932aecd0eb..5f63f99fe3 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -364,6 +364,36 @@ export class SkillsManager { return { valid: true } } + private validateSkillDocumentStructure(name: string, content: string, expectedDescription?: string): void { + const { data: frontmatter } = matter(content) + const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name.trim() : "" + const frontmatterDescription = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "" + + if (!frontmatterName || !frontmatterDescription) { + throw new Error( + t("skills:errors.invalid_structure", { + reason: "missing required frontmatter fields: name, description", + }), + ) + } + + if (frontmatterName !== name) { + throw new Error(t("skills:errors.invalid_structure", { reason: `frontmatter name must match \"${name}\"` })) + } + + if (frontmatterDescription.length < 1 || frontmatterDescription.length > 1024) { + throw new Error(t("skills:errors.description_length", { length: frontmatterDescription.length })) + } + + if (expectedDescription && frontmatterDescription !== expectedDescription.trim()) { + throw new Error( + t("skills:errors.invalid_structure", { + reason: "frontmatter description must match the provided description", + }), + ) + } + } + /** * Convert skill name validation error code to a user-friendly error message. */ @@ -444,6 +474,7 @@ Add your skill instructions here. if (!content.trim()) { throw new Error(t("skills:errors.description_length", { length: 0 })) } + this.validateSkillDocumentStructure(name, content, trimmedDescription) // Determine base directory let baseDir: string @@ -494,6 +525,7 @@ Add your skill instructions here. const modeInfo = mode ? ` (mode: ${mode})` : "" throw new Error(t("skills:errors.not_found", { name, source, modeInfo })) } + this.validateSkillDocumentStructure(name, content) await fs.writeFile(skill.path, content, "utf-8") await this.discoverSkills() diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index a5a39442cf..678267277d 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -101,6 +101,7 @@ vi.mock("../../../i18n", () => ({ "skills:errors.name_format": "Skill name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)", "skills:errors.description_length": `Skill description must be 1-1024 characters (got ${params?.length})`, + "skills:errors.invalid_structure": `Invalid SKILL.md structure: ${params?.reason}`, "skills:errors.no_workspace": "Cannot create project skill: no workspace folder is open", "skills:errors.already_exists": `Skill "${params?.name}" already exists at ${params?.path}`, "skills:errors.not_found": `Skill "${params?.name}" not found in ${params?.source}${params?.modeInfo}`, @@ -1327,6 +1328,22 @@ Instructions`) ) }) + it("rejects createSkillFromContent when SKILL.md frontmatter is missing required fields", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockFileExists.mockResolvedValue(false) + + await expect( + skillsManager.createSkillFromContent( + "workflow-read-file-search-files", + "project", + "Use when read_file and search_files succeed repeatedly.", + "# Workflow\n\nUse it.", + ["code"], + ), + ).rejects.toThrow("Invalid SKILL.md structure") + expect(mockWriteFile).not.toHaveBeenCalled() + }) + it("should push a live skills created message to the webview after creating a skill", async () => { const newSkillDir = p(globalSkillsDir, "new-skill") const newSkillMd = p(newSkillDir, "SKILL.md") @@ -1400,6 +1417,31 @@ Instructions`) expect(mockWriteFile).toHaveBeenCalledWith(testSkillMd, updatedContent, "utf-8") }) + it("rejects updateSkillContent when SKILL.md frontmatter becomes invalid", async () => { + const testSkillDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(testSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["test-skill"] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + mockFileExists.mockImplementation(async (file: string) => file === testSkillMd) + mockReadFile.mockResolvedValue(`---\nname: test-skill\ndescription: A test skill\n---\n\nOriginal content`) + mockWriteFile.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + await expect(skillsManager.updateSkillContent("test-skill", "global", "# Broken content")).rejects.toThrow( + "Invalid SKILL.md structure", + ) + expect(mockWriteFile).not.toHaveBeenCalled() + }) + it("updates a multi-mode skill when addressed by a secondary mode slug", async () => { const testSkillDir = p(globalSkillsDir, "test-skill") const testSkillMd = p(testSkillDir, "SKILL.md") From 00d160e4d4654e94953c1e8dfc66efdd38c7a9d2 Mon Sep 17 00:00:00 2001 From: Iskandar Sulaili Date: Sat, 23 May 2026 20:08:18 +0800 Subject: [PATCH 32/32] fix: commit learning state after pattern persistence --- src/services/self-improving/LearningStore.ts | 12 ++--- .../__tests__/LearningStore.spec.ts | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/services/self-improving/LearningStore.ts b/src/services/self-improving/LearningStore.ts index 9a33a97d80..c9b1c04c50 100644 --- a/src/services/self-improving/LearningStore.ts +++ b/src/services/self-improving/LearningStore.ts @@ -173,7 +173,7 @@ export class LearningStore { } /** - * Persist the full state to disk atomically. + * Persist the full state to disk with state.json committed last. */ async persist(): Promise { if (!this.initialized) { @@ -183,12 +183,10 @@ export class LearningStore { 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(), - ]) + await this.persistPatternFiles(this.patternsDir, this.state.patterns) + await this.persistPatternFiles(this.archiveDir, this.state.archivedPatterns) + await this.writePatternIndex() + await safeWriteJson(path.join(this.baseDir, STATE_FILE), this.state, { prettyPrint: true }) } catch (error) { this.logger.appendLine( `[LearningStore] Persist error: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/services/self-improving/__tests__/LearningStore.spec.ts b/src/services/self-improving/__tests__/LearningStore.spec.ts index 6967de459d..489302b3e0 100644 --- a/src/services/self-improving/__tests__/LearningStore.spec.ts +++ b/src/services/self-improving/__tests__/LearningStore.spec.ts @@ -61,6 +61,54 @@ describe("LearningStore", () => { expect(store2.getRecentEvents()).toHaveLength(1) }) + it("does not commit state.json when earlier pattern persistence fails", async () => { + const store = new LearningStore(testDir, logger) + await store.initialize() + + store.addEvent({ + id: "baseline-event", + signal: "TASK_SUCCESS", + timestamp: 1, + context: {}, + outcome: { success: true }, + }) + await store.persist() + + const statePath = path.join(testDir, "self-improving", "state.json") + const baselineState = await fs.readFile(statePath, "utf-8") + + store.addPattern({ + id: "pattern-1", + patternType: "prompt", + state: "active", + summary: "Pattern 1", + confidenceScore: 0.8, + frequency: 1, + successRate: 1, + firstSeenAt: 1, + lastSeenAt: 1, + sourceSignals: ["TASK_SUCCESS"], + context: {}, + }) + store.addEvent({ + id: "new-event", + signal: "TASK_SUCCESS", + timestamp: 2, + context: {}, + outcome: { success: true }, + }) + + vi.spyOn(store as any, "persistPatternFiles").mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + throw new Error("pattern persist failed") + }) + + await store.persist() + + const stateAfterFailedPersist = await fs.readFile(statePath, "utf-8") + expect(stateAfterFailedPersist).toBe(baselineState) + }) + it("should enforce max patterns bound", async () => { const store = new LearningStore(testDir, logger) await store.initialize()