From f35a4203d45e26b87959a7fff7e9b23b178a4b5a Mon Sep 17 00:00:00 2001 From: lubolin0925 <163701194+lubolin0925@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:46:46 +0800 Subject: [PATCH] Add /learn and memory --- src/auto-reply/commands-registry.data.ts | 16 +++ src/auto-reply/reply/commands-compact.ts | 33 +++++ src/auto-reply/reply/commands-core.ts | 41 ++++++ src/auto-reply/reply/commands-learn.ts | 166 +++++++++++++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 src/auto-reply/reply/commands-learn.ts diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 6a2bf205ffd..0330a736682 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -565,6 +565,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 1533bb24393..ad2756a3bc9 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -16,6 +16,7 @@ import { formatContextUsageShort, formatTokenCount } from "../status.js"; import type { CommandHandler } from "./commands-types.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { incrementCompactionCount } from "./session-updates.js"; +import { runLearnForSession } from "./commands-learn.js"; function extractCompactInstructions(params: { rawBody?: string; @@ -68,6 +69,38 @@ export const handleCompactCommand: CommandHandler = async (params) => { abortEmbeddedPiRun(sessionId); await waitForEmbeddedPiRunEnd(sessionId, 15_000); } + + const learnResult = 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: resolveSessionFilePath( + sessionId, + params.sessionEntry, + resolveSessionFilePathOptions({ + agentId: params.agentId, + storePath: params.storePath, + }), + ), + 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: "What insights and lessons should be remembered before context compaction?", + senderIsOwner: params.command.senderIsOwner, + ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined, + }); + if (learnResult.ok) { + logVerbose(`Pre-compaction learning completed for session ${params.sessionKey}`); + } + const customInstructions = extractCompactInstructions({ rawBody: params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body, ctx: params.ctx, diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index d57d679fdb6..a42bd7018b0 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -13,6 +13,7 @@ import { handleApproveCommand } from "./commands-approve.js"; import { handleBashCommand } from "./commands-bash.js"; import { handleCompactCommand } from "./commands-compact.js"; import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; +import { handleLearnCommand } from "./commands-learn.js"; import { handleCommandsListCommand, handleContextCommand, @@ -40,6 +41,11 @@ import type { HandleCommandsParams, } from "./commands-types.js"; import { routeReply } from "./route-reply.js"; +import { runLearnForSession } from "./commands-learn.js"; +import { + resolveSessionFilePath, + resolveSessionFilePathOptions, +} from "../../config/sessions.js"; let HANDLERS: CommandHandler[] | null = null; @@ -194,6 +200,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise 0 ? params.command.ownerList : undefined, + }); + if (learnResult.ok) { + logVerbose(`Pre-reset learning completed for session ${params.sessionKey}`); + } + } + // Trigger internal hook for reset/new commands if (resetRequested && params.command.isAuthorizedSender) { const commandAction: ResetCommandAction = resetMatch?.[1] === "reset" ? "reset" : "new"; diff --git a/src/auto-reply/reply/commands-learn.ts b/src/auto-reply/reply/commands-learn.ts new file mode 100644 index 00000000000..4d200b91abc --- /dev/null +++ b/src/auto-reply/reply/commands-learn.ts @@ -0,0 +1,166 @@ +import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + resolveSessionFilePath, + resolveSessionFilePathOptions, +} from "../../config/sessions.js"; +import { logVerbose } from "../../globals.js"; + +const LEARN_SYSTEM_PROMPT = [ + "Learning turn.", + "Analyze the session history and remember important insights.", +].join(" "); + +const LEARN_DEFAULT_PROMPT = [ + "Learning turn.", + "What important insights, lessons, or information should be remembered from this session?", +].join(" "); + +function extractLearnFocus(params: { + rawBody?: string; + ctx: import("../templating.js").MsgContext; + cfg: OpenClawConfig; + agentId?: string; + isGroup: boolean; +}): string | undefined { + const trimmed = params.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?: Record; + provider: string; + model: string; + thinkLevel?: string; + customFocus?: string; + senderIsOwner: boolean; + ownerNumbers?: string[]; +}): Promise<{ ok: boolean; message?: string }> { + const customInstructions = params.customFocus + ? `Focus area: ${params.customFocus}. ${LEARN_DEFAULT_PROMPT}` + : LEARN_DEFAULT_PROMPT; + + const result = await compactEmbeddedPiSession({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + messageChannel: params.messageChannel, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, + sessionFile: params.sessionFile, + workspaceDir: params.workspaceDir, + 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", + }, + customInstructions, + extraSystemPrompt: LEARN_SYSTEM_PROMPT, + trigger: "manual", + senderIsOwner: params.senderIsOwner, + ownerNumbers: params.ownerNumbers, + }); + + if (result.ok) { + return { ok: true, message: "Learning completed. Insights saved to memory." }; + } + return { ok: false, message: result.reason ?? "Learning failed" }; +} + +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({ + rawBody: params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body, + ctx: params.ctx, + cfg: params.cfg, + agentId: params.agentId, + isGroup: params.isGroup, + }); + + 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: resolveSessionFilePath( + sessionId, + params.sessionEntry, + resolveSessionFilePathOptions({ + agentId: params.agentId, + storePath: params.storePath, + }), + ), + 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}` }, + }; +};