Merge f2fd5471985d9f639edd62a1fb71216d4f1e7964 into 5e417b44e1540f528d2ae63e3e20229a902d1db2
This commit is contained in:
commit
4f4e6b1184
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
200
src/auto-reply/reply/commands-learn.ts
Normal file
200
src/auto-reply/reply/commands-learn.ts
Normal 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}` },
|
||||
};
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user