Merge f2fd5471985d9f639edd62a1fb71216d4f1e7964 into 5e417b44e1540f528d2ae63e3e20229a902d1db2

This commit is contained in:
Aobing 2026-03-21 05:00:17 +03:00 committed by GitHub
commit 4f4e6b1184
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 304 additions and 1 deletions

View File

@ -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",

View File

@ -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,

View File

@ -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<Comm
handleModelsCommand,
handleStopCommand,
handleCompactCommand,
handleLearnCommand,
handleAbortTrigger,
];
}
@ -227,6 +231,54 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
boundAcpSessionKey && isAcpSessionKey(boundAcpSessionKey)
? boundAcpSessionKey.trim()
: undefined;
// Determine which session to learn from (after ACP resolution)
// For non-ACP resets, use previousSessionEntry because initSessionState already rotated to fresh session
const targetSessionKey = boundAcpKey ?? params.sessionKey;
let targetSessionEntry: typeof params.sessionEntry;
if (boundAcpKey) {
targetSessionEntry = resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
} else if (params.previousSessionEntry?.sessionId) {
targetSessionEntry = params.previousSessionEntry;
} else {
targetSessionEntry = params.sessionEntry;
}
// Trigger learning before reset/new commands (after ACP target resolution)
// Run in background with dedicated lane to avoid blocking user interactions
if (targetSessionEntry?.sessionId && targetSessionEntry.sessionFile) {
const thinkLevel = params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel());
runLearnForSession({
sessionId: targetSessionEntry.sessionId,
sessionKey: targetSessionKey,
messageChannel: params.command.channel,
groupId: targetSessionEntry.groupId,
groupChannel: targetSessionEntry.groupChannel,
groupSpace: targetSessionEntry.space,
spawnedBy: targetSessionEntry.spawnedBy,
sessionFile: targetSessionEntry.sessionFile,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
config: params.cfg,
skillsSnapshot: targetSessionEntry.skillsSnapshot,
provider: params.provider,
model: params.model,
thinkLevel,
customFocus:
"What insights and lessons should be remembered before starting a new session?",
senderIsOwner: params.command.senderIsOwner,
ownerNumbers: params.command.ownerList.length > 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,

View File

@ -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<string> {
// 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<import("./commands-types.js").CommandHandlerResult | null> => {
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 || "<unknown>"}`,
);
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}` },
};
};