import { z } from "zod"; import { writeLearningAuditLog } from "../audit/learningAudit.js"; import { type SupportedModel } from "../chat/models.js"; import { type ChatRequestContext } from "../chat/sessionBridge.js"; import { config } from "../config.js"; import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js"; import { logger } from "../logger.js"; import { SessionLearningStateStore } from "./sessionStateStore.js"; import { MemoryStore, type MemoryScope } from "../memory/store.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { SkillStore } from "../skills/store.js"; import { removeRuntimeSessionContext, setRuntimeSessionContext, } from "../runtime/sessionContext.js"; import { sanitizePersistentDocument, sanitizePersistentLine, } from "../utils/persistencePolicy.js"; const gateResultSchema = z.object({ confidence: z.number().min(0).max(1).default(0), focus: z.enum(["memory", "skill", "both", "none"]).default("none"), reason: z.string().default(""), should_review: z.boolean().default(false), }); const reviewResultSchema = z.object({ memories: z .array( z.object({ action: z.enum(["add", "replace", "remove"]), confidence: z.number().min(0).max(1), content: z.string().optional(), evidence: z.string().default(""), scope: z.enum(["user", "workspace"]), target_id: z.string().optional(), }), ) .default([]), skills: z .array( z.object({ action: z.enum([ "append_pattern", "remove_pattern", "write_reference", "write_skill", "remove_skill", ]), confidence: z.number().min(0).max(1), content: z.string().optional(), evidence: z.string().default(""), file_path: z.string().optional(), pattern: z.string().optional(), skill_path: z.string(), target_id: z.string().optional(), }), ) .default([]), summary: z.string().default(""), }); type GateResult = z.infer; type ReviewResult = z.infer; type TurnReviewInput = { assistantMessage: string; model?: SupportedModel; requestContext: ChatRequestContext; sessionId: string; toolCallCount: number; userMessage: string; }; export class LearningOrchestrator { private readonly activeReviews = new Set(); private readonly sessionLearningStateStore = new SessionLearningStateStore(); private readonly skillStore = new SkillStore(); constructor( private readonly runtime: OpencodeRuntimeAdapter, private readonly memoryStore: MemoryStore, private readonly transcriptStore: SessionTranscriptStore, ) {} async initialize() { await this.sessionLearningStateStore.initialize(); } async onTurnCompleted(input: TurnReviewInput) { const transcript = await this.transcriptStore.appendTurn( { actorKey: input.requestContext.actorKey, clientSessionId: input.requestContext.clientSessionId, projectKey: input.requestContext.projectKey, sessionId: input.sessionId, }, { assistantMessage: input.assistantMessage, toolCallCount: input.toolCallCount, userMessage: input.userMessage, }, ); const turnCount = transcript.turns.length; if (this.activeReviews.has(input.sessionId)) { return; } this.activeReviews.add(input.sessionId); try { const state = await this.sessionLearningStateStore.read(input.sessionId); const turnsSinceGate = Math.max(0, turnCount - state.lastGatedTurn); if (turnsSinceGate < config.LEARNING_GATE_TURN_COOLDOWN) { this.activeReviews.delete(input.sessionId); return; } } catch (error) { this.activeReviews.delete(input.sessionId); throw error; } queueMicrotask(() => { void this.runGate({ input, recentTurns: transcript.turns.slice(-config.LEARNING_REVIEW_MAX_RECENT_TURNS), turnCount, }).finally(() => { this.activeReviews.delete(input.sessionId); }); }); } private async runGate({ input, recentTurns, turnCount, }: { input: TurnReviewInput; recentTurns: SessionTurnRecord[]; turnCount: number; }) { let gateSessionId: string | null = null; try { const gateSession = await this.runtime.createSession( `learning-gate-${input.requestContext.clientSessionId}`, ); gateSessionId = gateSession.id; setRuntimeSessionContext({ actorKey: input.requestContext.actorKey, allowLearningWrite: false, clientSessionId: `gate-${input.requestContext.clientSessionId}`, learningMode: "review", projectId: input.requestContext.projectId, projectKey: input.requestContext.projectKey, sessionId: gateSession.id, traceId: input.requestContext.traceId, }); await this.runtime.prompt( gateSession.id, buildGatePrompt({ recentTurns }), GATE_MODEL, ); const messages = await this.runtime.messages(gateSession.id, 20); const assistantMessage = [...messages] .reverse() .find((message) => message.info.role === "assistant"); const gateText = collectTextContent(assistantMessage?.parts ?? []); const gate = parseGateResult(gateText); if (!gate) { await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount); await writeLearningAuditLog({ action: "review-gate", detail: "gate result was not valid JSON", outcome: "error", projectId: input.requestContext.projectId, sessionId: input.sessionId, traceId: input.requestContext.traceId, }); return; } const shouldPromote = gate.should_review && gate.confidence >= config.LEARNING_GATE_MIN_CONFIDENCE && gate.focus !== "none"; await writeLearningAuditLog({ action: "review-gate", detail: sanitizeAuditDetail(gate.reason), outcome: shouldPromote ? "accepted" : "skipped", projectId: input.requestContext.projectId, proposal: sanitizeGateForAudit(gate), sessionId: input.sessionId, traceId: input.requestContext.traceId, }); if (!shouldPromote) { await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount); return; } await this.runReview({ focus: gate.focus, input, recentTurns, turnCount, }); } catch (error) { logger.warn({ err: error, sessionId: input.sessionId }, "learning gate failed"); await writeLearningAuditLog({ action: "review-gate", detail: sanitizeAuditDetail(error instanceof Error ? error.message : String(error)), outcome: "error", projectId: input.requestContext.projectId, sessionId: input.sessionId, traceId: input.requestContext.traceId, }); } finally { if (gateSessionId) { removeRuntimeSessionContext(gateSessionId); await this.runtime.abortSession(gateSessionId).catch(() => undefined); } } } private async runReview({ focus, input, recentTurns, turnCount, }: { focus: GateResult["focus"]; input: TurnReviewInput; recentTurns: SessionTurnRecord[]; turnCount: number; }) { const reviewSession = await this.runtime.createSession( `learning-review-${input.requestContext.clientSessionId}`, ); setRuntimeSessionContext({ actorKey: input.requestContext.actorKey, allowLearningWrite: false, clientSessionId: `review-${input.requestContext.clientSessionId}`, learningMode: "review", projectId: input.requestContext.projectId, projectKey: input.requestContext.projectKey, sessionId: reviewSession.id, traceId: input.requestContext.traceId, }); try { const existingMemory = await this.loadMemoryContext(input.requestContext); await this.runtime.prompt( reviewSession.id, buildReviewPrompt({ existingMemory, focus, recentTurns }), toRuntimeModel(input.model), ); const messages = await this.runtime.messages(reviewSession.id, 20); const assistantMessage = [...messages] .reverse() .find((message) => message.info.role === "assistant"); const reviewText = collectTextContent(assistantMessage?.parts ?? []); const parsed = parseReviewResult(reviewText); if (!parsed) { await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount); await writeLearningAuditLog({ action: "review-parse", detail: "review result was not valid JSON", outcome: "error", projectId: input.requestContext.projectId, sessionId: input.sessionId, traceId: input.requestContext.traceId, }); return; } await this.applyReviewResult(input, parsed, turnCount); await this.sessionLearningStateStore.completeReview(input.sessionId, turnCount); } catch (error) { logger.warn({ err: error, sessionId: input.sessionId }, "learning review failed"); await writeLearningAuditLog({ action: "review-run", detail: sanitizeAuditDetail(error instanceof Error ? error.message : String(error)), outcome: "error", projectId: input.requestContext.projectId, sessionId: input.sessionId, traceId: input.requestContext.traceId, }); } finally { removeRuntimeSessionContext(reviewSession.id); await this.runtime.abortSession(reviewSession.id).catch(() => undefined); } } private async applyReviewResult( input: TurnReviewInput, result: ReviewResult, turnCount: number, ) { const threshold = config.LEARNING_MIN_PROPOSAL_CONFIDENCE; let accepted = 0; for (const proposal of result.memories) { const outcome = await this.applyMemoryProposal(input, proposal, threshold); accepted += outcome ? 1 : 0; } for (const proposal of result.skills) { const outcome = await this.applySkillProposal(input, proposal, threshold); accepted += outcome ? 1 : 0; } await writeLearningAuditLog({ action: "review-summary", detail: sanitizeAuditDetail(result.summary), outcome: accepted > 0 ? "accepted" : "skipped", projectId: input.requestContext.projectId, proposal: { accepted, memories: result.memories.length, skills: result.skills.length, turnCount, }, sessionId: input.sessionId, traceId: input.requestContext.traceId, }); } private async loadMemoryContext(context: ChatRequestContext) { const [userMemory, workspaceMemory] = await Promise.all([ this.memoryStore.list("user", context.actorKey), this.memoryStore.list("workspace", context.projectKey), ]); return { userMemory, workspaceMemory }; } private async applyMemoryProposal( input: TurnReviewInput, proposal: ReviewResult["memories"][number], threshold: number, ) { if (proposal.confidence < threshold) { await writeLearningAuditLog({ action: `memory-${proposal.action}`, detail: "proposal below confidence threshold", outcome: "skipped", projectId: input.requestContext.projectId, proposal: sanitizeMemoryProposalForAudit(proposal), sessionId: input.sessionId, traceId: input.requestContext.traceId, }); return false; } const scopeKey = proposal.scope === "user" ? input.requestContext.actorKey : input.requestContext.projectKey; const draft = { content: proposal.content ?? "", sessionId: input.sessionId, source: "review" as const, traceId: input.requestContext.traceId, }; let accepted = false; let detail = "memory rejected"; if (proposal.action === "add") { const result = await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft); accepted = Boolean(result.entry); detail = result.detail; } else if (proposal.action === "replace") { const result = await this.memoryStore.replace( proposal.scope as MemoryScope, scopeKey, proposal.target_id ?? "", draft, ); accepted = Boolean(result.changed); detail = result.detail; } else { const result = await this.memoryStore.remove( proposal.scope as MemoryScope, scopeKey, proposal.target_id ?? "", ); accepted = Boolean(result.changed); detail = result.detail; } await writeLearningAuditLog({ action: `memory-${proposal.action}`, detail: sanitizeAuditDetail(detail), outcome: accepted ? "accepted" : "rejected", projectId: input.requestContext.projectId, proposal: sanitizeMemoryProposalForAudit(proposal), sessionId: input.sessionId, traceId: input.requestContext.traceId, }); return accepted; } private async applySkillProposal( input: TurnReviewInput, proposal: ReviewResult["skills"][number], threshold: number, ) { if (proposal.confidence < threshold) { await writeLearningAuditLog({ action: `skill-${proposal.action}`, detail: "proposal below confidence threshold", outcome: "skipped", projectId: input.requestContext.projectId, proposal: sanitizeSkillProposalForAudit(proposal), sessionId: input.sessionId, traceId: input.requestContext.traceId, }); return false; } const result = proposal.action === "append_pattern" ? await this.skillStore.appendPattern(proposal.skill_path, proposal.pattern ?? "") : proposal.action === "remove_pattern" ? await this.skillStore.removePattern( proposal.skill_path, proposal.target_id ?? "", ) : proposal.action === "write_reference" ? await this.skillStore.writeReference( proposal.skill_path, proposal.file_path ?? "", proposal.content ?? "", ) : proposal.action === "write_skill" ? await this.skillStore.writeSkill(proposal.skill_path, proposal.content ?? "") : await this.skillStore.removeSkill(proposal.skill_path); await writeLearningAuditLog({ action: `skill-${proposal.action}`, detail: sanitizeAuditDetail(result.detail), outcome: result.changed ? "accepted" : "rejected", projectId: input.requestContext.projectId, proposal: sanitizeSkillProposalForAudit(proposal), sessionId: input.sessionId, traceId: input.requestContext.traceId, }); return result.changed; } } const buildGatePrompt = ({ recentTurns }: { recentTurns: SessionTurnRecord[] }) => { const transcript = recentTurns .map( (turn, index) => `Turn ${index + 1}\nUser: ${turn.userMessage}\nAssistant: ${turn.assistantMessage}\nTool calls: ${turn.toolCallCount}`, ) .join("\n\n"); return [ "You are the learning gate for TJWaterAgent.", "Do NOT call any tools. Return JSON only. Do NOT wrap in markdown fences.", "Decide whether this recent conversation is worth a deeper learning review.", "A review is warranted only when there is likely durable memory or reusable skill signal.", "Ignore one-off cases, temporary outcomes, and task-local noise.", "", 'Return JSON schema: {"should_review":true|false,"reason":"string","confidence":0.0,"focus":"memory|skill|both|none"}', "", "Conversation transcript:", transcript || "(empty)", ].join("\n"); }; const buildReviewPrompt = ({ existingMemory, focus, recentTurns, }: { existingMemory: { userMemory: Array<{ content: string; id: string }>; workspaceMemory: Array<{ content: string; id: string }>; }; focus: GateResult["focus"]; recentTurns: SessionTurnRecord[]; }) => { const transcript = recentTurns .map( (turn, index) => `Turn ${index + 1}\nUser: ${turn.userMessage}\nAssistant: ${turn.assistantMessage}\nTool calls: ${turn.toolCallCount}`, ) .join("\n\n"); const userMemoryBlock = existingMemory.userMemory.length > 0 ? existingMemory.userMemory.map((entry) => `- [${entry.id}] ${entry.content}`).join("\n") : "(empty)"; const workspaceMemoryBlock = existingMemory.workspaceMemory.length > 0 ? existingMemory.workspaceMemory .map((entry) => `- [${entry.id}] ${entry.content}`) .join("\n") : "(empty)"; return [ "You are doing an internal self-improvement review for TJWaterAgent.", "Do NOT call any tools. Return JSON only. Do NOT wrap in markdown fences.", `Focus: ${focus}`, "Decide what durable lessons to keep from the conversation below.", "", "Memory rules:", "- Keep only stable user preferences, durable constraints, or stable workspace facts.", "- Use scope='user' for user preferences and constraints.", "- Use scope='workspace' for project or environment facts.", "- Read the existing memories first before proposing changes.", "- If a new lesson overlaps, refines, or supersedes an existing memory, prefer replace/remove using target_id instead of add.", "- Use add only when the lesson is genuinely new after reviewing the existing memory list.", "- Do not store one-off task outcomes, temporary facts, or speculative conclusions.", "", "Current persisted memories:", "[User memory]", userMemoryBlock, "[Workspace memory]", workspaceMemoryBlock, "", "Skill rules:", "- Save only reusable workflows, methods, or pitfalls that will help in future similar tasks.", "- Prefer append_pattern for concise reusable lessons.", "- Use write_reference only for compact durable supporting notes under references/*.md.", "- Use write_skill only when the conversation establishes a complete reusable SKILL.md with frontmatter name and description; it creates or overwrites the main SKILL.md.", "- Use remove_skill only when the conversation clearly establishes the whole skill is obsolete or invalid.", "", "Output JSON schema:", `{"summary":"string","memories":[{"action":"add|replace|remove","scope":"user|workspace","content":"string?","target_id":"string?","confidence":0.0,"evidence":"string"}],"skills":[{"action":"append_pattern|remove_pattern|write_reference|write_skill|remove_skill","skill_path":"string","pattern":"string?","target_id":"string?","file_path":"references/example.md?","content":"string?","confidence":0.0,"evidence":"string"}]}`, "", "If nothing should be saved, return empty arrays.", "", "Conversation transcript:", transcript || "(empty)", ].join("\n"); }; const parseGateResult = (text: string): GateResult | null => { const trimmed = text.trim(); if (!trimmed) { return null; } const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); const candidate = fenced?.[1]?.trim() ?? trimmed; try { return gateResultSchema.parse(JSON.parse(candidate)); } catch { return null; } }; const parseReviewResult = (text: string): ReviewResult | null => { const trimmed = text.trim(); if (!trimmed) { return null; } const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); const candidate = fenced?.[1]?.trim() ?? trimmed; try { return reviewResultSchema.parse(JSON.parse(candidate)); } catch { return null; } }; const collectTextContent = ( parts: Array<{ type: string; text?: string }>, ) => parts .filter((part): part is { type: "text"; text: string } => part.type === "text") .map((part) => part.text) .join(""); const toRuntimeModel = (model?: SupportedModel) => { if (!model) { return undefined; } const [providerID, modelID] = model.split("/"); if (!providerID || !modelID) { return undefined; } return { modelID, providerID, }; }; const GATE_MODEL = { modelID: "deepseek-v4-flash", providerID: "deepseek", } as const; const REDACTED_AUDIT_FIELD = "[redacted by persistence policy]"; const sanitizeAuditDetail = (detail?: string) => { if (!detail) { return undefined; } return sanitizePersistentDocument(detail, 1000) || REDACTED_AUDIT_FIELD; }; const sanitizeAuditLine = (value?: string, maxLength = 320) => { if (value === undefined) { return undefined; } return sanitizePersistentLine(value, maxLength) || REDACTED_AUDIT_FIELD; }; const sanitizeGateForAudit = (gate: GateResult): Record => ({ confidence: gate.confidence, focus: gate.focus, reason: sanitizeAuditLine(gate.reason), should_review: gate.should_review, }); const sanitizeMemoryProposalForAudit = ( proposal: ReviewResult["memories"][number], ): Record => ({ action: proposal.action, confidence: proposal.confidence, content: sanitizeAuditLine(proposal.content), evidence: sanitizeAuditLine(proposal.evidence), scope: proposal.scope, target_id: sanitizeAuditLine(proposal.target_id, 120), }); const sanitizeSkillProposalForAudit = ( proposal: ReviewResult["skills"][number], ): Record => ({ action: proposal.action, confidence: proposal.confidence, content: sanitizeAuditDetail(proposal.content), evidence: sanitizeAuditLine(proposal.evidence), file_path: sanitizeAuditLine(proposal.file_path, 200), pattern: sanitizeAuditLine(proposal.pattern), skill_path: sanitizeAuditLine(proposal.skill_path, 200), target_id: sanitizeAuditLine(proposal.target_id, 120), });