diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 0e0c44d7515..751dc657afb 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -623,6 +623,22 @@ function buildChatCommands(): ChatCommandDefinition[] { }, ], }), + defineChatCommand({ + key: "learn", + nativeName: "learn", + description: "Trigger agent to learn and remember insights from the session.", + textAlias: "/learn", + acceptsArgs: true, + category: "session", + args: [ + { + name: "focus", + description: "What to focus on learning (optional)", + type: "string", + captureRemaining: true, + }, + ], + }), defineChatCommand({ key: "think", nativeName: "think", diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index 9c3c9f28c29..21d88af173e 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -13,6 +13,7 @@ import { import { logVerbose } from "../../globals.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { formatContextUsageShort, formatTokenCount } from "../status.js"; +import { runLearnForSession } from "./commands-learn.js"; import type { CommandHandler } from "./commands-types.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { incrementCompactionCount } from "./session-updates.js"; @@ -68,6 +69,40 @@ export const handleCompactCommand: CommandHandler = async (params) => { abortEmbeddedPiRun(sessionId); await waitForEmbeddedPiRunEnd(sessionId, 15_000); } + + const thinkLevel = params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()); + + const learnResult = params.sessionEntry.sessionFile + ? await runLearnForSession({ + sessionId, + sessionKey: params.sessionKey, + messageChannel: params.command.channel, + groupId: params.sessionEntry.groupId, + groupChannel: params.sessionEntry.groupChannel, + groupSpace: params.sessionEntry.space, + spawnedBy: params.sessionEntry.spawnedBy, + sessionFile: params.sessionEntry.sessionFile, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + config: params.cfg, + skillsSnapshot: params.sessionEntry.skillsSnapshot, + provider: params.provider, + model: params.model, + thinkLevel, + customFocus: "What insights and lessons should be remembered before context compaction?", + senderIsOwner: params.command.senderIsOwner, + ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined, + }) + : null; + + if (learnResult?.ok) { + logVerbose(`Pre-compaction learning completed for session ${params.sessionKey}`); + } else if (learnResult) { + logVerbose( + `Pre-compaction learning failed for session ${params.sessionKey}: ${learnResult.message ?? "unknown error"}`, + ); + } + const customInstructions = extractCompactInstructions({ rawBody: params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body, ctx: params.ctx, @@ -98,7 +133,7 @@ export const handleCompactCommand: CommandHandler = async (params) => { skillsSnapshot: params.sessionEntry.skillsSnapshot, provider: params.provider, model: params.model, - thinkLevel: params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()), + thinkLevel, bashElevated: { enabled: false, allowed: false, diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index c3425161773..c58abfccf9f 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -1,4 +1,6 @@ import fs from "node:fs/promises"; +import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js"; +import { resolveSessionFilePath, resolveSessionFilePathOptions } from "../../config/sessions.js"; import { resetConfiguredBindingTargetInPlace } from "../../channels/plugins/binding-targets.js"; import { logVerbose } from "../../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; @@ -22,6 +24,7 @@ import { handleStatusCommand, handleWhoamiCommand, } from "./commands-info.js"; +import { handleLearnCommand, runLearnForSession } from "./commands-learn.js"; import { handleMcpCommand } from "./commands-mcp.js"; import { handleModelsCommand } from "./commands-models.js"; import { handlePluginCommand } from "./commands-plugin.js"; @@ -203,6 +206,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise 0 ? params.command.ownerList : undefined, + lane: "learn", + }).then((learnResult) => { + if (learnResult.ok) { + logVerbose(`Background pre-reset learning completed for session ${targetSessionKey}`); + } else { + logVerbose( + `Background pre-reset learning failed for session ${targetSessionKey}: ${learnResult.message ?? "unknown error"}`, + ); + } + }); + } if (boundAcpKey) { const resetResult = await resetConfiguredBindingTargetInPlace({ cfg: params.cfg, diff --git a/src/auto-reply/reply/commands-learn.ts b/src/auto-reply/reply/commands-learn.ts new file mode 100644 index 00000000000..506cc4f608a --- /dev/null +++ b/src/auto-reply/reply/commands-learn.ts @@ -0,0 +1,200 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import type { SkillSnapshot } from "../../agents/skills.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; +import type { ThinkLevel } from "./directives.js"; + +const LEARN_SYSTEM_PROMPT = [ + "Learning turn.", + "Analyze the session history and remember important insights in the specific way the user requests.", +].join(" "); + +const LEARN_DEFAULT_PROMPT = [ + "Learning turn.", + "Analyze the session history and remember important insights in the specific way the user requests.", + "Focus on: problems identified, solutions discovered, methods that worked, patterns noticed, and any valuable context.", + "IMPORTANT: Remember ONLY what is truly useful and worth retaining - filter out noise.", +].join(" "); + +async function resolveSessionFileWithResetFallback(sessionFile: string): Promise { + // For pre-reset learning, the session file has been archived to .reset.* + // so we need to look for the archived file directly + try { + const dir = path.dirname(sessionFile); + const base = path.basename(sessionFile); + const resetPrefix = `${base}.reset.`; + const files = await fs.readdir(dir); + const resetCandidates = files + .filter((name) => name.startsWith(resetPrefix)) + .sort() + .reverse(); + + if (resetCandidates.length > 0) { + const archivedPath = path.join(dir, resetCandidates[0]); + logVerbose(`Learning: using archived session file ${archivedPath}`); + return archivedPath; + } + } catch { + // Fallback to original path + } + + return sessionFile; +} + +function extractLearnFocus(rawBody?: string): string | undefined { + const trimmed = rawBody?.trim() ?? ""; + if (!trimmed) { + return undefined; + } + const lowered = trimmed.toLowerCase(); + const prefix = lowered.startsWith("/learn") ? "/learn" : null; + if (!prefix) { + return undefined; + } + let rest = trimmed.slice(prefix.length).trimStart(); + if (rest.startsWith(":")) { + rest = rest.slice(1).trimStart(); + } + return rest.length ? rest : undefined; +} + +export async function runLearnForSession(params: { + sessionId: string; + sessionKey: string; + messageChannel: string; + groupId?: string; + groupChannel?: string; + groupSpace?: string; + spawnedBy?: string; + sessionFile: string; + workspaceDir: string; + agentDir?: string; + config: OpenClawConfig; + skillsSnapshot?: SkillSnapshot; + provider: string; + model: string; + thinkLevel?: ThinkLevel; + customFocus?: string; + senderIsOwner: boolean; + ownerNumbers?: string[]; + lane?: string; +}): Promise<{ ok: boolean; message?: string }> { + const prompt = params.customFocus + ? `Focus area: ${params.customFocus}. ${LEARN_DEFAULT_PROMPT}` + : LEARN_DEFAULT_PROMPT; + + // Resolve session file, falling back to archived .reset.* file if needed + const resolvedSessionFile = await resolveSessionFileWithResetFallback(params.sessionFile); + + // Build memory write path for learning output + const memoryDir = path.join(params.workspaceDir, "memory"); + const now = new Date(); + const dateStr = now.toISOString().split("T")[0]; + const memoryFlushWritePath = path.join(memoryDir, `${dateStr}.md`); + + try { + // Ensure memory directory exists + await fs.mkdir(memoryDir, { recursive: true }); + + await runEmbeddedPiAgent({ + runId: crypto.randomUUID(), + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: resolvedSessionFile, + messageChannel: params.messageChannel, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, + workspaceDir: params.workspaceDir, + memoryFlushWritePath, + agentDir: params.agentDir, + config: params.config, + skillsSnapshot: params.skillsSnapshot, + provider: params.provider, + model: params.model, + thinkLevel: params.thinkLevel ?? "medium", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + prompt, + extraSystemPrompt: LEARN_SYSTEM_PROMPT, + trigger: "memory", + timeoutMs: 5 * 60 * 1000, // 5 minutes + lane: params.lane, + senderIsOwner: params.senderIsOwner, + ownerNumbers: params.ownerNumbers, + }); + + return { ok: true, message: "Learning completed. Insights saved to memory." }; + } catch (err) { + logVerbose(`Learning failed for session ${params.sessionKey}: ${String(err)}`); + return { ok: false, message: String(err) }; + } +} + +export const handleLearnCommand = async ( + params: import("./commands-types.js").HandleCommandsParams, + allowTextCommands: boolean, +): Promise => { + const learnRequested = + params.command.commandBodyNormalized === "/learn" || + params.command.commandBodyNormalized.startsWith("/learn "); + if (!learnRequested) { + return null; + } + + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /learn from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + + if (!params.sessionEntry?.sessionId) { + return { + shouldContinue: false, + reply: { text: "Learning unavailable (missing session id)." }, + }; + } + + const sessionId = params.sessionEntry.sessionId; + const customFocus = extractLearnFocus(params.command.commandBodyNormalized); + + if (!params.sessionEntry.sessionFile) { + return { + shouldContinue: false, + reply: { text: "Learning unavailable (missing session file)." }, + }; + } + + const result = await runLearnForSession({ + sessionId, + sessionKey: params.sessionKey, + messageChannel: params.command.channel, + groupId: params.sessionEntry.groupId, + groupChannel: params.sessionEntry.groupChannel, + groupSpace: params.sessionEntry.space, + spawnedBy: params.sessionEntry.spawnedBy, + sessionFile: params.sessionEntry.sessionFile, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + config: params.cfg, + skillsSnapshot: params.sessionEntry.skillsSnapshot, + provider: params.provider, + model: params.model, + thinkLevel: params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()), + customFocus, + senderIsOwner: params.command.senderIsOwner, + ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined, + }); + + return { + shouldContinue: false, + reply: { text: result.ok ? `📚 ${result.message}` : `⚠️ ${result.message}` }, + }; +};