Sync adabot changes on top of origin/main

Includes:
- memory-neo4j: four-phase sleep cycle (dedup, decay, extraction, cleanup)
- memory-neo4j: full plugin implementation with hybrid search
- memory-lancedb: updates and benchmarks
- OpenSpec workflow skills and commands
- Session memory hooks
- Various CLI and config improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Tarun Sukhani
2026-02-04 15:14:46 +00:00
parent 7cfd0aed5f
commit e65d1deedd
59 changed files with 7326 additions and 310 deletions

View File

@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js";
import type { AgentBootstrapHookContext } from "../hooks/internal-hooks.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
export async function applyBootstrapHookOverrides(params: {
@@ -27,5 +28,30 @@ export async function applyBootstrapHookOverrides(params: {
const event = createInternalHookEvent("agent", "bootstrap", sessionKey, context);
await triggerInternalHook(event);
const updated = (event.context as AgentBootstrapHookContext).bootstrapFiles;
return Array.isArray(updated) ? updated : params.files;
const internalResult = Array.isArray(updated) ? updated : params.files;
// After internal hooks, run plugin hooks
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("agent_bootstrap")) {
const result = await hookRunner.runAgentBootstrap(
{
files: internalResult.map((f) => ({
name: f.name,
path: f.path,
content: f.content,
missing: f.missing,
})),
},
{
agentId,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
},
);
if (result?.files) {
return result.files as WorkspaceBootstrapFile[];
}
}
return internalResult;
}

View File

@@ -1,6 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
import { completeSimple } from "@mariozechner/pi-ai";
import { convertToLlm, estimateTokens, serializeConversation } from "@mariozechner/pi-coding-agent";
import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
import { repairToolUseResultPairing, stripToolResultDetails } from "./session-transcript-repair.js";
@@ -13,6 +14,163 @@ const MERGE_SUMMARIES_INSTRUCTIONS =
"Merge these partial summaries into a single cohesive summary. Preserve decisions," +
" TODOs, open questions, and any constraints.";
// ---------------------------------------------------------------------------
// Enhanced summarization prompts with "Immediate Context" section
// ---------------------------------------------------------------------------
// These replace the upstream pi-coding-agent prompts to add recency awareness.
// The key addition is "## Immediate Context" which captures what was being
// actively discussed/worked on in the most recent messages, solving the problem
// of losing the "last thing we were doing" after compaction.
const ENHANCED_SUMMARIZATION_SYSTEM_PROMPT =
"You are a context summarization assistant. Your task is to read a conversation " +
"between a user and an AI assistant, then produce a structured summary following " +
"the exact format specified.\n\n" +
"Do NOT continue the conversation. Do NOT respond to any questions in the " +
"conversation. ONLY output the structured summary.";
const ENHANCED_SUMMARIZATION_PROMPT =
"The messages above are a conversation to summarize. Create a structured context " +
"checkpoint summary that another LLM will use to continue the work.\n\n" +
"Use this EXACT format:\n\n" +
"## Immediate Context\n" +
"[What was the user MOST RECENTLY asking about or working on? Describe the active " +
"conversation topic from the last few exchanges in detail. Include any pending " +
"questions, partial results, or the exact state of the task right before this " +
"summary. This section should read like a handoff note: 'You were just working " +
"on X, the user asked Y, and you were in the middle of Z.']\n\n" +
"## Goal\n" +
"[What is the user trying to accomplish? Can be multiple items if the session " +
"covers different tasks.]\n\n" +
"## Constraints & Preferences\n" +
"- [Any constraints, preferences, or requirements mentioned by user]\n" +
'- [Or "(none)" if none were mentioned]\n\n' +
"## Progress\n" +
"### Done\n" +
"- [x] [Completed tasks/changes]\n\n" +
"### In Progress\n" +
"- [ ] [Current work]\n\n" +
"### Blocked\n" +
"- [Issues preventing progress, if any]\n\n" +
"## Key Decisions\n" +
"- **[Decision]**: [Brief rationale]\n\n" +
"## Next Steps\n" +
"1. [Ordered list of what should happen next]\n\n" +
"## Critical Context\n" +
"- [Any data, examples, or references needed to continue]\n" +
'- [Or "(none)" if not applicable]\n\n' +
"Keep each section concise. Preserve exact file paths, function names, and error messages.";
const ENHANCED_UPDATE_SUMMARIZATION_PROMPT =
"The messages above are NEW conversation messages to incorporate into the existing " +
"summary provided in <previous-summary> tags.\n\n" +
"Update the existing structured summary with new information. RULES:\n" +
"- REPLACE the Immediate Context section entirely with what the NEWEST messages " +
"are about — this must always reflect the most recent conversation topic\n" +
"- PRESERVE all existing information from the previous summary in other sections\n" +
"- ADD new progress, decisions, and context from the new messages\n" +
'- UPDATE the Progress section: move items from "In Progress" to "Done" when completed\n' +
'- UPDATE "Next Steps" based on what was accomplished\n' +
"- PRESERVE exact file paths, function names, and error messages\n" +
"- If something is no longer relevant, you may remove it\n\n" +
"Use this EXACT format:\n\n" +
"## Immediate Context\n" +
"[What is the conversation CURRENTLY about based on these newest messages? " +
"Describe the active topic, any pending questions, and the exact state of work. " +
"This REPLACES any previous immediate context — always reflect the latest exchanges.]\n\n" +
"## Goal\n" +
"[Preserve existing goals, add new ones if the task expanded]\n\n" +
"## Constraints & Preferences\n" +
"- [Preserve existing, add new ones discovered]\n\n" +
"## Progress\n" +
"### Done\n" +
"- [x] [Include previously done items AND newly completed items]\n\n" +
"### In Progress\n" +
"- [ ] [Current work - update based on progress]\n\n" +
"### Blocked\n" +
"- [Current blockers - remove if resolved]\n\n" +
"## Key Decisions\n" +
"- **[Decision]**: [Brief rationale] (preserve all previous, add new)\n\n" +
"## Next Steps\n" +
"1. [Update based on current state]\n\n" +
"## Critical Context\n" +
"- [Preserve important context, add new if needed]\n\n" +
"Keep each section concise. Preserve exact file paths, function names, and error messages.";
/**
* Enhanced version of generateSummary that includes an "Immediate Context" section
* in the compaction summary. This ensures that the most recent conversation topic
* is prominently captured, solving the "can't remember what we were just doing"
* problem after compaction.
*/
async function generateSummary(
currentMessages: AgentMessage[],
model: NonNullable<ExtensionContext["model"]>,
reserveTokens: number,
apiKey: string,
signal: AbortSignal,
customInstructions?: string,
previousSummary?: string,
): Promise<string> {
const maxTokens = Math.floor(0.8 * reserveTokens);
// Use update prompt if we have a previous summary, otherwise initial prompt
let basePrompt = previousSummary
? ENHANCED_UPDATE_SUMMARIZATION_PROMPT
: ENHANCED_SUMMARIZATION_PROMPT;
if (customInstructions) {
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
}
// Serialize conversation to text so model doesn't try to continue it
// Use type assertion since convertToLlm accepts AgentMessage[] at runtime
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const llmMessages = convertToLlm(currentMessages as any);
const conversationText = serializeConversation(llmMessages);
// Build the prompt with conversation wrapped in tags
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
if (previousSummary) {
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
}
promptText += basePrompt;
// Build user message for summarization request
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const summarizationMessages: any[] = [
{
role: "user",
content: [{ type: "text", text: promptText }],
timestamp: Date.now(),
},
];
const response = await completeSimple(
model,
{
systemPrompt: ENHANCED_SUMMARIZATION_SYSTEM_PROMPT,
messages: summarizationMessages,
},
{ maxTokens, signal, apiKey, reasoning: "high" },
);
if (response.stopReason === "error") {
throw new Error(
`Summarization failed: ${
(response as { errorMessage?: string }).errorMessage || "Unknown error"
}`,
);
}
// Extract text content from response
const textContent = (response.content as Array<{ type: string; text?: string }>)
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!)
.join("\n");
return textContent;
}
export function estimateMessagesTokens(messages: AgentMessage[]): number {
// SECURITY: toolResult.details can contain untrusted/verbose payloads; never include in LLM-facing compaction.
const safe = stripToolResultDetails(messages);

View File

@@ -107,5 +107,39 @@ export function lookupContextTokens(modelId?: string): number | undefined {
}
// Best-effort: kick off loading, but don't block.
void loadPromise;
return MODEL_CACHE.get(modelId);
// Try exact match first (only if it contains a slash, i.e., already has provider prefix)
if (modelId.includes("/")) {
const exact = MODEL_CACHE.get(modelId);
if (exact !== undefined) {
return exact;
}
}
// For bare model names (no slash), try common provider prefixes first
// to prefer our custom config over built-in defaults.
// Priority order: prefer anthropic, then openai, then google
const prefixes = ["anthropic", "openai", "google"];
for (const prefix of prefixes) {
const prefixedKey = `${prefix}/${modelId}`;
const prefixed = MODEL_CACHE.get(prefixedKey);
if (prefixed !== undefined) {
return prefixed;
}
}
// Fallback to exact match for bare model names (built-in defaults)
const exact = MODEL_CACHE.get(modelId);
if (exact !== undefined) {
return exact;
}
// Final fallback: any matching suffix
for (const [key, value] of MODEL_CACHE) {
if (key.endsWith(`/${modelId}`)) {
return value;
}
}
return undefined;
}

View File

@@ -297,6 +297,13 @@ export function resolveMemorySearchConfig(
cfg: OpenClawConfig,
agentId: string,
): ResolvedMemorySearchConfig | null {
// Only one memory system can be active at a time.
// When a memory plugin owns the slot, core memory-search is unconditionally disabled.
const memoryPluginSlot = cfg.plugins?.slots?.memory;
if (memoryPluginSlot && memoryPluginSlot !== "none") {
return null;
}
const defaults = cfg.agents?.defaults?.memorySearch;
const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch;
const resolved = mergeConfig(defaults, overrides, agentId);

View File

@@ -79,6 +79,17 @@ import { splitSdkTools } from "./tool-split.js";
import { describeUnknownError, mapThinkingLevel } from "./utils.js";
import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.js";
export const DEFAULT_COMPACTION_INSTRUCTIONS = [
"When summarizing this conversation, prioritize the following:",
"1. Any active or in-progress tasks: include task name, current step, what has been done, what remains, and any pending user decisions.",
"2. Key decisions made and their rationale.",
"3. Exact values that would be needed to resume work: names, URLs, file paths, configuration values, row numbers, IDs.",
"4. What the user was last working on and their most recent request.",
"5. Tool state: any browser sessions, file operations, or API calls in progress.",
"6. If TASKS.md was updated during this conversation, note which tasks changed and their current status.",
"De-prioritize: casual conversation, greetings, completed tasks with no follow-up needed, resolved errors.",
].join("\n");
export type CompactEmbeddedPiSessionParams = {
sessionId: string;
runId?: string;
@@ -585,6 +596,48 @@ export async function compactEmbeddedPiSessionDirect(
if (limited.length > 0) {
session.agent.replaceMessages(limited);
}
// Pre-check: detect "already compacted but context is high" scenario
// The SDK rejects compaction if the last entry is a compaction, but this is
// too aggressive when context has grown back to threshold levels.
const branchEntries = sessionManager.getBranch();
const lastEntry = branchEntries.length > 0 ? branchEntries[branchEntries.length - 1] : null;
const isLastEntryCompaction = lastEntry?.type === "compaction";
if (isLastEntryCompaction) {
// Check if there's actually new content since the compaction
const compactionIndex = branchEntries.findIndex((e) => e.id === lastEntry.id);
const hasNewContent = branchEntries
.slice(compactionIndex + 1)
.some((e) => e.type === "message" || e.type === "custom_message");
if (!hasNewContent) {
// No new content since last compaction - estimate current context
let currentTokens = 0;
for (const message of session.messages) {
currentTokens += estimateTokens(message);
}
const contextWindow = model.contextWindow ?? 200000;
const contextPercent = (currentTokens / contextWindow) * 100;
// If context is still high (>70%) but no new content, provide clear error
if (contextPercent > 70) {
return {
ok: false,
compacted: false,
reason: `Already compacted • Context ${Math.round(currentTokens / 1000)}k/${Math.round(contextWindow / 1000)}k (${Math.round(contextPercent)}%) — the compaction summary itself is large. Consider starting a new session with /new`,
};
}
// Context is fine, just skip compaction gracefully
return {
ok: true,
compacted: false,
reason: "Already compacted",
};
}
// Has new content - fall through to let SDK handle it (it should work now)
}
// Run before_compaction hooks (fire-and-forget).
// The session JSONL already contains all messages on disk, so plugins
// can read sessionFile asynchronously and process in parallel with

View File

@@ -129,6 +129,54 @@ describe("buildWorkspaceSkillCommandSpecs", () => {
const cmd = commands.find((entry) => entry.skillName === "tool-dispatch");
expect(cmd?.dispatch).toEqual({ kind: "tool", toolName: "sessions_send", argMode: "raw" });
});
it("includes thinking and model from skill config", async () => {
const workspaceDir = await makeWorkspace();
await writeSkill({
dir: path.join(workspaceDir, "skills", "browser"),
name: "browser",
description: "Browser automation",
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "replicate-image"),
name: "replicate-image",
description: "Image generation",
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "no-config"),
name: "no-config",
description: "No special config",
});
const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, {
config: {
skills: {
entries: {
browser: {
thinking: "xhigh",
model: "anthropic/claude-opus-4-5",
},
"replicate-image": {
thinking: "low",
},
},
},
},
});
const browserCmd = commands.find((entry) => entry.skillName === "browser");
const replicateCmd = commands.find((entry) => entry.skillName === "replicate-image");
const noConfigCmd = commands.find((entry) => entry.skillName === "no-config");
expect(browserCmd?.thinking).toBe("xhigh");
expect(browserCmd?.model).toBe("anthropic/claude-opus-4-5");
expect(replicateCmd?.thinking).toBe("low");
expect(replicateCmd?.model).toBeUndefined();
expect(noConfigCmd?.thinking).toBeUndefined();
expect(noConfigCmd?.model).toBeUndefined();
});
});
describe("buildWorkspaceSkillsPrompt", () => {

View File

@@ -54,6 +54,8 @@ export type SkillCommandSpec = {
description: string;
/** Optional deterministic dispatch behavior for this command. */
dispatch?: SkillCommandDispatchSpec;
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
model?: string;
};
export type SkillsInstallPreferences = {

View File

@@ -18,12 +18,13 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import { resolveSandboxPath } from "../sandbox-paths.js";
import { resolveBundledSkillsDir } from "./bundled-dir.js";
import { shouldIncludeSkill } from "./config.js";
import { resolveSkillConfig, shouldIncludeSkill } from "./config.js";
import { normalizeSkillFilter } from "./filter.js";
import {
parseFrontmatter,
resolveOpenClawMetadata,
resolveSkillInvocationPolicy,
resolveSkillKey,
} from "./frontmatter.js";
import { resolvePluginSkillDirs } from "./plugin-skills.js";
import { serializeByKey } from "./serialize.js";
@@ -511,11 +512,18 @@ export function buildWorkspaceSkillCommandSpecs(
return { kind: "tool", toolName, argMode: "raw" } as const;
})();
const skillKey = resolveSkillKey(entry.skill, entry);
const skillConfig = resolveSkillConfig(opts?.config, skillKey);
const thinking = skillConfig?.thinking;
const model = skillConfig?.model;
specs.push({
name: unique,
skillName: rawName,
description,
...(dispatch ? { dispatch } : {}),
...(thinking ? { thinking } : {}),
...(model ? { model } : {}),
});
}
return specs;

View File

@@ -73,11 +73,19 @@ function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: bool
return ["## User Identity", ownerLine, ""];
}
function buildTimeSection(params: { userTimezone?: string }) {
function buildTimeSection(params: { userTimezone?: string; userTime?: string }) {
if (!params.userTimezone) {
return [];
}
return ["## Current Date & Time", `Time zone: ${params.userTimezone}`, ""];
const lines = ["## Current Date & Time", `Time zone: ${params.userTimezone}`];
if (params.userTime) {
lines.push(`Current time: ${params.userTime}`);
}
lines.push(
"If you need the current date, time, or day of week, use the session_status tool.",
"",
);
return lines;
}
function buildReplyTagsSection(isMinimal: boolean) {
@@ -340,6 +348,7 @@ export function buildAgentSystemPrompt(params: {
: undefined;
const reasoningLevel = params.reasoningLevel ?? "off";
const userTimezone = params.userTimezone?.trim();
const userTime = params.userTime?.trim();
const skillsPrompt = params.skillsPrompt?.trim();
const heartbeatPrompt = params.heartbeatPrompt?.trim();
const heartbeatPromptLine = heartbeatPrompt
@@ -526,6 +535,7 @@ export function buildAgentSystemPrompt(params: {
...buildUserIdentitySection(ownerLine, isMinimal),
...buildTimeSection({
userTimezone,
userTime,
}),
"## Workspace Files (injected)",
"These user-editable files are loaded by OpenClaw and included below in Project Context.",

View File

@@ -111,8 +111,8 @@ function assertCommandRegistry(commands: ChatCommandDefinition[]): void {
}
for (const alias of command.textAliases) {
if (!alias.startsWith("/")) {
throw new Error(`Command alias missing leading '/': ${alias}`);
if (!alias.startsWith("/") && !alias.startsWith(".")) {
throw new Error(`Command alias missing leading '/' or '.': ${alias}`);
}
const aliasKey = alias.toLowerCase();
if (textAliases.has(aliasKey)) {
@@ -618,6 +618,8 @@ function buildChatCommands(): ChatCommandDefinition[] {
registerAlias(commands, "reasoning", "/reason");
registerAlias(commands, "elevated", "/elev");
registerAlias(commands, "steer", "/tell");
registerAlias(commands, "model", ".model");
registerAlias(commands, "models", ".models");
assertCommandRegistry(commands);
return commands;

View File

@@ -14,7 +14,7 @@ export function extractModelDirective(
}
const modelMatch = body.match(
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
/(?:^|\s)[/.]model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
);
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
@@ -23,7 +23,7 @@ export function extractModelDirective(
? null
: body.match(
new RegExp(
`(?:^|\\s)\\/(${aliases.map(escapeRegExp).join("|")})(?=$|\\s|:)(?:\\s*:\\s*)?`,
`(?:^|\\s)[/.](${aliases.map(escapeRegExp).join("|")})(?=$|\\s|:)(?:\\s*:\\s*)?`,
"i",
),
);

View File

@@ -23,7 +23,7 @@ const matchLevelDirective = (
names: string[],
): { start: number; end: number; rawLevel?: string } | null => {
const namePattern = names.map(escapeRegExp).join("|");
const match = body.match(new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)`, "i"));
const match = body.match(new RegExp(`(?:^|\\s)[/.](?:${namePattern})(?=$|\\s|:)`, "i"));
if (!match || match.index === undefined) {
return null;
}
@@ -79,7 +79,7 @@ const extractSimpleDirective = (
): { cleaned: string; hasDirective: boolean } => {
const namePattern = names.map(escapeRegExp).join("|");
const match = body.match(
new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)(?:\\s*:\\s*)?`, "i"),
new RegExp(`(?:^|\\s)[/.](?:${namePattern})(?=$|\\s|:)(?:\\s*:\\s*)?`, "i"),
);
const cleaned = match ? body.replace(match[0], " ").replace(/\s+/g, " ").trim() : body.trim();
return {

View File

@@ -172,7 +172,7 @@ export function extractExecDirective(body?: string): ExecDirectiveParse {
invalidNode: false,
};
}
const re = /(?:^|\s)\/exec(?=$|\s|:)/i;
const re = /(?:^|\s)[/.]exec(?=$|\s|:)/i;
const match = re.exec(body);
if (!match) {
return {
@@ -185,8 +185,10 @@ export function extractExecDirective(body?: string): ExecDirectiveParse {
invalidNode: false,
};
}
const start = match.index + match[0].indexOf("/exec");
const argsStart = start + "/exec".length;
// Find the directive start (handle both /exec and .exec)
const execMatch = match[0].match(/[/.]exec/i);
const start = match.index + (execMatch ? match[0].indexOf(execMatch[0]) : 0);
const argsStart = start + 5; // "/exec" or ".exec" is always 5 chars
const parsed = parseExecDirectiveArgs(body.slice(argsStart));
const cleanedRaw = `${body.slice(0, start)} ${body.slice(argsStart + parsed.consumed)}`;
const cleaned = cleanedRaw.replace(/\s+/g, " ").trim();

View File

@@ -262,6 +262,23 @@ export async function handleInlineActions(params: {
sessionCtx.BodyForAgent = rewrittenBody;
sessionCtx.BodyStripped = rewrittenBody;
cleanedBody = rewrittenBody;
// Apply skill-level thinking/model overrides if configured
if (skillInvocation.command.thinking) {
directives = {
...directives,
hasThinkDirective: true,
thinkLevel: skillInvocation.command.thinking,
rawThinkLevel: skillInvocation.command.thinking,
};
}
if (skillInvocation.command.model) {
directives = {
...directives,
hasModelDirective: true,
rawModelDirective: skillInvocation.command.model,
};
}
}
const sendInlineReply = async (reply?: ReplyPayload) => {

View File

@@ -7,6 +7,7 @@ import {
resolveAgentSkillsFilter,
} from "../../agents/agent-scope.js";
import { resolveModelRefFromString } from "../../agents/model-selection.js";
import { compactEmbeddedPiSession } from "../../agents/pi-embedded-runner.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js";
import { type OpenClawConfig, loadConfig } from "../../config/config.js";
@@ -154,6 +155,7 @@ export async function getReplyFromConfig(
sessionId,
isNewSession,
resetTriggered,
compactTriggered,
systemSent,
abortedLastRun,
storePath,
@@ -293,6 +295,35 @@ export async function getReplyFromConfig(
workspaceDir,
});
// Handle compact trigger - force compaction without resetting session
if (compactTriggered && sessionEntry.sessionFile) {
try {
const compactResult = await compactEmbeddedPiSession({
sessionId: sessionEntry.sessionId,
sessionFile: sessionEntry.sessionFile,
config: cfg,
workspaceDir,
provider,
model,
});
if (compactResult.compacted && compactResult.result) {
const tokensBefore = compactResult.result.tokensBefore;
const tokensAfter = compactResult.result.tokensAfter ?? 0;
return {
text: `✅ Context compacted successfully.\n\n**Before:** ${tokensBefore.toLocaleString()} tokens\n**After:** ${tokensAfter.toLocaleString()} tokens\n**Saved:** ${(tokensBefore - tokensAfter).toLocaleString()} tokens`,
};
} else {
return {
text: ` Nothing to compact. ${compactResult.reason ?? "Session is already compact."}`,
};
}
} catch (err) {
return {
text: `❌ Compaction failed: ${String(err)}`,
};
}
}
return runPreparedReply({
ctx,
sessionCtx,

View File

@@ -44,6 +44,7 @@ export type SessionInitResult = {
sessionId: string;
isNewSession: boolean;
resetTriggered: boolean;
compactTriggered: boolean;
systemSent: boolean;
abortedLastRun: boolean;
storePath: string;
@@ -133,6 +134,7 @@ export async function initSessionState(params: {
let systemSent = false;
let abortedLastRun = false;
let resetTriggered = false;
let compactTriggered = false;
let persistedThinking: string | undefined;
let persistedVerbose: string | undefined;
@@ -198,6 +200,22 @@ export async function initSessionState(params: {
}
}
// Check for compact triggers (e.g., ".compact", "/compact")
const compactTriggers = sessionCfg?.compactTriggers ?? [];
if (!resetTriggered && resetAuthorized) {
for (const trigger of compactTriggers) {
if (!trigger) {
continue;
}
const triggerLower = trigger.toLowerCase();
if (trimmedBodyLower === triggerLower || strippedForResetLower === triggerLower) {
compactTriggered = true;
bodyStripped = "";
break;
}
}
}
sessionKey = resolveSessionKey(sessionScope, sessionCtxForState, mainKey);
const entry = sessionStore[sessionKey];
const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
@@ -458,6 +476,7 @@ export async function initSessionState(params: {
sessionId: sessionId ?? crypto.randomUUID(),
isNewSession,
resetTriggered,
compactTriggered,
systemSent,
abortedLastRun,
storePath,

View File

@@ -100,8 +100,9 @@ const MAX_CONSOLE_MESSAGES = 500;
const MAX_PAGE_ERRORS = 200;
const MAX_NETWORK_REQUESTS = 500;
let cached: ConnectedBrowser | null = null;
let connecting: Promise<ConnectedBrowser> | null = null;
// Per-profile caching to allow parallel connections to different Chrome instances
const cachedByUrl = new Map<string, ConnectedBrowser>();
const connectingByUrl = new Map<string, Promise<ConnectedBrowser>>();
function normalizeCdpUrl(raw: string) {
return raw.replace(/\/$/, "");
@@ -315,11 +316,17 @@ function observeBrowser(browser: Browser) {
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
const normalized = normalizeCdpUrl(cdpUrl);
if (cached?.cdpUrl === normalized) {
// Check if we already have a cached connection for this specific URL
const cached = cachedByUrl.get(normalized);
if (cached) {
return cached;
}
if (connecting) {
return await connecting;
// Check if there's already a connection in progress for this specific URL
const existingConnecting = connectingByUrl.get(normalized);
if (existingConnecting) {
return await existingConnecting;
}
const connectWithRetry = async (): Promise<ConnectedBrowser> => {
@@ -332,12 +339,12 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
const headers = getHeadersWithAuth(endpoint);
const browser = await chromium.connectOverCDP(endpoint, { timeout, headers });
const onDisconnected = () => {
if (cached?.browser === browser) {
cached = null;
if (cachedByUrl.get(normalized)?.browser === browser) {
cachedByUrl.delete(normalized);
}
};
const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected };
cached = connected;
cachedByUrl.set(normalized, connected);
browser.on("disconnected", onDisconnected);
observeBrowser(browser);
return connected;
@@ -354,11 +361,12 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
throw new Error(message);
};
connecting = connectWithRetry().finally(() => {
connecting = null;
const connectingPromise = connectWithRetry().finally(() => {
connectingByUrl.delete(normalized);
});
connectingByUrl.set(normalized, connectingPromise);
return await connecting;
return await connectingPromise;
}
async function getAllPages(browser: Browser): Promise<Page[]> {
@@ -512,16 +520,16 @@ export function refLocator(page: Page, ref: string) {
}
export async function closePlaywrightBrowserConnection(): Promise<void> {
const cur = cached;
cached = null;
connecting = null;
if (!cur) {
return;
// Close all cached browser connections
const connections = Array.from(cachedByUrl.values());
cachedByUrl.clear();
connectingByUrl.clear();
for (const c of connections) {
if (c.onDisconnected && typeof c.browser.off === "function") {
c.browser.off("disconnected", c.onDisconnected);
}
}
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
}
await cur.browser.close().catch(() => {});
await Promise.all(connections.map((c) => c.browser.close().catch(() => {})));
}
function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
@@ -649,31 +657,30 @@ export async function forceDisconnectPlaywrightForTarget(opts: {
reason?: string;
}): Promise<void> {
const normalized = normalizeCdpUrl(opts.cdpUrl);
if (cached?.cdpUrl !== normalized) {
const cur = cachedByUrl.get(normalized);
if (!cur) {
return;
}
const cur = cached;
cached = null;
// Also clear `connecting` so the next call does a fresh connectOverCDP
cachedByUrl.delete(normalized);
// Also clear the connecting promise so the next call does a fresh connectOverCDP
// rather than awaiting a stale promise.
connecting = null;
if (cur) {
// Remove the "disconnected" listener to prevent the old browser's teardown
// from racing with a fresh connection and nulling the new `cached`.
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
}
connectingByUrl.delete(normalized);
// Best-effort: kill any stuck JS to unblock the target's execution context before we
// disconnect Playwright's CDP connection.
const targetId = opts.targetId?.trim() || "";
if (targetId) {
await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {});
}
// Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe.
cur.browser.close().catch(() => {});
// Remove the "disconnected" listener to prevent the old browser's teardown
// from racing with a fresh connection and clearing the new cached entry.
if (cur.onDisconnected && typeof cur.browser.off === "function") {
cur.browser.off("disconnected", cur.onDisconnected);
}
// Best-effort: kill any stuck JS to unblock the target's execution context before we
// disconnect Playwright's CDP connection.
const targetId = opts.targetId?.trim() || "";
if (targetId) {
await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {});
}
// Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe.
cur.browser.close().catch(() => {});
}
/**

View File

@@ -1,4 +1,4 @@
import { resolveCommitHash } from "../infra/git-commit.js";
import { resolveCommitHash, resolveUpstreamCommitHash } from "../infra/git-commit.js";
import { visibleWidth } from "../terminal/ansi.js";
import { isRich, theme } from "../terminal/theme.js";
import { pickTagline, type TaglineOptions } from "./tagline.js";
@@ -6,6 +6,7 @@ import { pickTagline, type TaglineOptions } from "./tagline.js";
type BannerOptions = TaglineOptions & {
argv?: string[];
commit?: string | null;
upstreamCommit?: string | null;
columns?: number;
richTty?: boolean;
};
@@ -36,30 +37,33 @@ const hasVersionFlag = (argv: string[]) =>
export function formatCliBannerLine(version: string, options: BannerOptions = {}): string {
const commit = options.commit ?? resolveCommitHash({ env: options.env });
const upstreamCommit = options.upstreamCommit ?? resolveUpstreamCommitHash();
const commitLabel = commit ?? "unknown";
// Show upstream if different from current (indicates local commits ahead)
const showUpstream = upstreamCommit && upstreamCommit !== commit;
const commitDisplay = showUpstream ? `${commitLabel}${upstreamCommit}` : commitLabel;
const tagline = pickTagline(options);
const rich = options.richTty ?? isRich();
const title = "🦞 OpenClaw";
const prefix = "🦞 ";
const columns = options.columns ?? process.stdout.columns ?? 120;
const plainFullLine = `${title} ${version} (${commitLabel}) — ${tagline}`;
const plainFullLine = `${title} ${version} (${commitDisplay}) — ${tagline}`;
const fitsOnOneLine = visibleWidth(plainFullLine) <= columns;
if (rich) {
const commitPart = showUpstream
? `${theme.muted("(")}${commitLabel}${theme.muted(" ← ")}${theme.muted(upstreamCommit)}${theme.muted(")")}`
: theme.muted(`(${commitLabel})`);
if (fitsOnOneLine) {
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`,
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
return `${theme.heading(title)} ${theme.info(version)} ${commitPart} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
}
const line1 = `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`,
)}`;
const line1 = `${theme.heading(title)} ${theme.info(version)} ${commitPart}`;
const line2 = `${" ".repeat(prefix.length)}${theme.accentDim(tagline)}`;
return `${line1}\n${line2}`;
}
if (fitsOnOneLine) {
return plainFullLine;
}
const line1 = `${title} ${version} (${commitLabel})`;
const line1 = `${title} ${version} (${commitDisplay})`;
const line2 = `${" ".repeat(prefix.length)}${tagline}`;
return `${line1}\n${line2}`;
}

View File

@@ -280,11 +280,14 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
scan?: MemorySourceScan;
}> = [];
const disabledAgentIds: string[] = [];
for (const agentId of agentIds) {
const managerPurpose = opts.index ? "default" : "status";
await withManager<MemoryManager>({
getManager: () => getMemorySearchManager({ cfg, agentId, purpose: managerPurpose }),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onMissing: () => {
disabledAgentIds.push(agentId);
},
onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
close: async (manager) => {
@@ -374,11 +377,22 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
const accent = (text: string) => colorize(rich, theme.accent, text);
const label = (text: string) => muted(`${text}:`);
const emptyAgentIds: string[] = [];
for (const result of allResults) {
const { agentId, status, embeddingProbe, indexError, scan } = result;
const filesIndexed = status.files ?? 0;
const chunksIndexed = status.chunks ?? 0;
const totalFiles = scan?.totalFiles ?? null;
// Skip agents with no indexed content (0 files, 0 chunks, no source files, no errors).
// These agents aren't using the core memory search system — no need to show them.
const isEmpty =
status.files === 0 && status.chunks === 0 && (totalFiles ?? 0) === 0 && !indexError;
if (isEmpty) {
emptyAgentIds.push(agentId);
continue;
}
const indexedLabel =
totalFiles === null
? `${filesIndexed}/? files · ${chunksIndexed} chunks`
@@ -510,6 +524,28 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
defaultRuntime.log(lines.join("\n"));
defaultRuntime.log("");
}
// Show compact summary for agents with no indexed memory-search content
if (emptyAgentIds.length > 0) {
const agentList = emptyAgentIds.join(", ");
defaultRuntime.log(
muted(
`Memory Search: ${emptyAgentIds.length} agent${emptyAgentIds.length > 1 ? "s" : ""} with no indexed files (${agentList})`,
),
);
defaultRuntime.log("");
}
// Show compact summary for agents with memory search disabled
if (disabledAgentIds.length > 0 && emptyAgentIds.length === 0) {
const agentList = disabledAgentIds.join(", ");
defaultRuntime.log(
muted(
`Memory Search: disabled for ${disabledAgentIds.length} agent${disabledAgentIds.length > 1 ? "s" : ""} (${agentList})`,
),
);
defaultRuntime.log("");
}
}
export function registerMemoryCli(program: Command) {

View File

@@ -313,9 +313,10 @@ export async function statusCommand(
}
if (!memory) {
const slot = memoryPlugin.slot ? `plugin ${memoryPlugin.slot}` : "plugin";
// Custom (non-built-in) memory plugins can't be probed — show enabled, not unavailable
// External plugins (non memory-core) don't have detailed status available,
// but that doesn't mean they're unavailable - just that we can't query them here
if (memoryPlugin.slot && memoryPlugin.slot !== "memory-core") {
return `enabled (${slot})`;
return muted(`enabled (${slot})`);
}
return muted(`enabled (${slot}) · unavailable`);
}

View File

@@ -0,0 +1,143 @@
/**
* Per-session metadata storage to eliminate lock contention.
*
* Instead of storing all session metadata in a single sessions.json file
* (which requires a global lock), each session gets its own .meta.json file.
* This allows parallel updates without blocking.
*/
import JSON5 from "json5";
import fs from "node:fs/promises";
import path from "node:path";
import type { SessionEntry } from "./types.js";
import { resolveSessionTranscriptsDirForAgent } from "./paths.js";
const META_SUFFIX = ".meta.json";
/**
* Get the path to a session's metadata file.
*/
export function getSessionMetaPath(sessionId: string, agentId?: string): string {
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
return path.join(sessionsDir, `${sessionId}${META_SUFFIX}`);
}
/**
* Load session metadata from per-session file.
* Returns undefined if the file doesn't exist.
*/
export async function loadSessionMeta(
sessionId: string,
agentId?: string,
): Promise<SessionEntry | undefined> {
const metaPath = getSessionMetaPath(sessionId, agentId);
try {
const content = await fs.readFile(metaPath, "utf-8");
const entry = JSON5.parse(content);
return entry;
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return undefined;
}
throw err;
}
}
/**
* Save session metadata to per-session file.
* Uses atomic write (write to temp, then rename) to prevent corruption.
*/
export async function saveSessionMeta(
sessionId: string,
entry: SessionEntry,
agentId?: string,
): Promise<void> {
const metaPath = getSessionMetaPath(sessionId, agentId);
const dir = path.dirname(metaPath);
await fs.mkdir(dir, { recursive: true });
// Atomic write: write to temp file, then rename
const tempPath = `${metaPath}.tmp.${process.pid}.${Date.now()}`;
const content = JSON.stringify(entry, null, 2);
try {
await fs.writeFile(tempPath, content, "utf-8");
await fs.rename(tempPath, metaPath);
} catch (err) {
// Clean up temp file on error
await fs.unlink(tempPath).catch(() => {});
throw err;
}
}
/**
* Update session metadata atomically.
* Reads current state, applies patch, and writes back.
* No lock needed since we use atomic writes and per-session files.
*/
export async function updateSessionMeta(
sessionId: string,
patch: Partial<SessionEntry>,
agentId?: string,
): Promise<SessionEntry> {
const existing = await loadSessionMeta(sessionId, agentId);
const updatedAt = Date.now();
const merged: SessionEntry = {
...existing,
...patch,
sessionId,
updatedAt,
};
await saveSessionMeta(sessionId, merged, agentId);
return merged;
}
/**
* Delete session metadata file.
*/
export async function deleteSessionMeta(sessionId: string, agentId?: string): Promise<void> {
const metaPath = getSessionMetaPath(sessionId, agentId);
await fs.unlink(metaPath).catch((err) => {
if ((err as { code?: string }).code !== "ENOENT") {
throw err;
}
});
}
/**
* List all session metadata files in the sessions directory.
* Returns an array of session IDs.
*/
export async function listSessionMetas(agentId?: string): Promise<string[]> {
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
try {
const files = await fs.readdir(sessionsDir);
return files.filter((f) => f.endsWith(META_SUFFIX)).map((f) => f.slice(0, -META_SUFFIX.length));
} catch (err) {
if ((err as { code?: string }).code === "ENOENT") {
return [];
}
throw err;
}
}
/**
* Load all session metadata from per-session files.
* This is used for backwards compatibility and for building the session index.
*/
export async function loadAllSessionMetas(agentId?: string): Promise<Record<string, SessionEntry>> {
const sessionIds = await listSessionMetas(agentId);
const entries: Record<string, SessionEntry> = {};
await Promise.all(
sessionIds.map(async (sessionId) => {
const entry = await loadSessionMeta(sessionId, agentId);
if (entry) {
entries[sessionId] = entry;
}
}),
);
return entries;
}

View File

@@ -763,21 +763,112 @@ export async function updateSessionStoreEntry(params: {
update: (entry: SessionEntry) => Promise<Partial<SessionEntry> | null>;
}): Promise<SessionEntry | null> {
const { storePath, sessionKey, update } = params;
return await withSessionStoreLock(storePath, async () => {
const store = loadSessionStore(storePath);
const existing = store[sessionKey];
if (!existing) {
return null;
// Fast path: read the store without locking to get the session entry
// The store is cached and TTL-validated, so this is cheap
const store = loadSessionStore(storePath);
const existing = store[sessionKey];
if (!existing) {
return null;
}
// Get the sessionId for per-session file access
const sessionId = existing.sessionId;
if (!sessionId) {
// Fallback to locked update for legacy entries without sessionId
return await withSessionStoreLock(storePath, async () => {
const freshStore = loadSessionStore(storePath, { skipCache: true });
const freshExisting = freshStore[sessionKey];
if (!freshExisting) {
return null;
}
const patch = await update(freshExisting);
if (!patch) {
return freshExisting;
}
const next = mergeSessionEntry(freshExisting, patch);
freshStore[sessionKey] = next;
await saveSessionStoreUnlocked(storePath, freshStore);
return next;
});
}
// Compute the patch
const patch = await update(existing);
if (!patch) {
return existing;
}
// Merge and create the updated entry
const next = mergeSessionEntry(existing, patch);
// Write to per-session meta file (no global lock needed)
const { updateSessionMeta } = await import("./per-session-store.js");
const agentId = extractAgentIdFromStorePath(storePath);
await updateSessionMeta(sessionId, next, agentId);
// Update the in-memory cache so subsequent reads see the update
store[sessionKey] = next;
invalidateSessionStoreCache(storePath);
// Async background sync to sessions.json (debounced, best-effort)
debouncedSyncToSessionsJson(storePath, sessionKey, next);
return next;
}
// Helper to extract agentId from store path
function extractAgentIdFromStorePath(storePath: string): string | undefined {
// storePath is like: ~/.openclaw/agents/{agentId}/sessions/sessions.json
const match = storePath.match(/agents\/([^/]+)\/sessions/);
return match?.[1];
}
// Debounced sync to sessions.json to keep it in sync (background, best-effort)
const pendingSyncs = new Map<string, { sessionKey: string; entry: SessionEntry }>();
let syncTimer: NodeJS.Timeout | null = null;
function debouncedSyncToSessionsJson(
storePath: string,
sessionKey: string,
entry: SessionEntry,
): void {
const key = `${storePath}::${sessionKey}`;
pendingSyncs.set(key, { sessionKey, entry });
if (syncTimer) {
return;
} // Already scheduled
syncTimer = setTimeout(async () => {
syncTimer = null;
const toSync = new Map(pendingSyncs);
pendingSyncs.clear();
// Group by storePath
const byStore = new Map<string, Array<{ sessionKey: string; entry: SessionEntry }>>();
for (const [key, value] of toSync) {
const [sp] = key.split("::");
const list = byStore.get(sp) ?? [];
list.push(value);
byStore.set(sp, list);
}
const patch = await update(existing);
if (!patch) {
return existing;
// Batch update each store
for (const [sp, entries] of byStore) {
try {
await withSessionStoreLock(sp, async () => {
const store = loadSessionStore(sp, { skipCache: true });
for (const { sessionKey: sk, entry: e } of entries) {
store[sk] = e;
}
await saveSessionStoreUnlocked(sp, store);
});
} catch {
// Best-effort sync, ignore errors
}
}
const next = mergeSessionEntry(existing, patch);
store[sessionKey] = next;
await saveSessionStoreUnlocked(storePath, store, { activeSessionKey: sessionKey });
return next;
});
}, 5000); // 5 second debounce
}
export async function recordSessionMetaFromInbound(params: {

View File

@@ -91,6 +91,7 @@ export type SessionConfig = {
/** Map platform-prefixed identities (e.g. "telegram:123") to canonical DM peers. */
identityLinks?: Record<string, string[]>;
resetTriggers?: string[];
compactTriggers?: string[];
idleMinutes?: number;
reset?: SessionResetConfig;
resetByType?: SessionResetByTypeConfig;

View File

@@ -3,6 +3,8 @@ export type SkillConfig = {
apiKey?: string;
env?: Record<string, string>;
config?: Record<string, unknown>;
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
model?: string;
};
export type SkillsLoadConfig = {

View File

@@ -34,6 +34,7 @@ export const SessionSchema = z
.optional(),
identityLinks: z.record(z.string(), z.array(z.string())).optional(),
resetTriggers: z.array(z.string()).optional(),
compactTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),
reset: SessionResetConfigSchema.optional(),
resetByType: z

View File

@@ -588,6 +588,8 @@ export const OpenClawSchema = z
apiKey: z.string().optional().register(sensitive),
env: z.record(z.string(), z.string()).optional(),
config: z.record(z.string(), z.unknown()).optional(),
thinking: z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]).optional(),
model: z.string().optional(),
})
.strict(),
)

View File

@@ -368,6 +368,9 @@ export function normalizeCronJobInput(
stripLegacyTopLevelFields(next);
if (options.applyDefaults) {
if (typeof next.enabled !== "boolean") {
next.enabled = true;
}
if (!next.wakeMode) {
next.wakeMode = "now";
}

View File

@@ -202,11 +202,21 @@ export async function run(state: CronServiceState, id: string, mode?: "due" | "f
return { ok: true, ran: false, reason: "already-running" as const };
}
const now = state.deps.nowMs();
const due = isJobDue(job, now, { forced: mode === "force" });
const forced = mode === "force";
const due = isJobDue(job, now, { forced });
if (!due) {
return { ok: true, ran: false, reason: "not-due" as const };
}
await executeJob(state, job, now, { forced: mode === "force" });
if (forced) {
// Fire-and-forget: don't block the caller waiting for job completion
void executeJob(state, job, now, { forced }).then(() => {
recomputeNextRuns(state);
persist(state).catch(() => {});
armTimer(state);
});
return { ok: true, ran: true } as const;
}
await executeJob(state, job, now, { forced });
recomputeNextRuns(state);
await persist(state);
armTimer(state);

View File

@@ -59,11 +59,14 @@ The hook uses your configured LLM provider to generate slugs, so it works with a
The hook supports optional configuration:
| Option | Type | Default | Description |
| ---------- | ------ | ------- | --------------------------------------------------------------- |
| `messages` | number | 15 | Number of user/assistant messages to include in the memory file |
| Option | Type | Default | Description |
| ---------- | ------ | -------- | ------------------------------------------------------------------------ |
| `messages` | number | 15 | Number of user/assistant messages to include in the memory |
| `target` | string | `"file"` | Storage target: `"file"` (markdown files) or `"lancedb"` (LanceDB store) |
Example configuration:
### File Target (Default)
Saves session context to markdown files in `<workspace>/memory/`:
```json
{
@@ -72,6 +75,7 @@ Example configuration:
"entries": {
"session-memory": {
"enabled": true,
"target": "file",
"messages": 25
}
}
@@ -80,11 +84,41 @@ Example configuration:
}
```
### LanceDB Target
Stores session summaries in LanceDB via the Gateway API instead of creating files:
```json
{
"hooks": {
"internal": {
"entries": {
"session-memory": {
"enabled": true,
"target": "lancedb",
"messages": 15
}
}
}
}
}
```
**LanceDB target features:**
- Stores session context as searchable memory entries
- Automatically truncates conversation content to 2000 chars
- Includes date, time, session key, and LLM-generated slug
- Category: `"fact"`, Importance: `0.7`
- Requires Gateway API with `memory_store` tool available
- Use `memory_recall` to search through stored sessions
The hook automatically:
- Uses your workspace directory (`~/.openclaw/workspace` by default)
- Uses your workspace directory (`~/.openclaw/workspace` by default) for file target
- Uses your configured LLM for slug generation
- Falls back to timestamp slugs if LLM is unavailable
- Uses Gateway API at `localhost:<gateway.port>` with `gateway.auth.token` for LanceDB target
## Disabling

View File

@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import type { HookHandler } from "../../hooks.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js";
@@ -273,4 +273,263 @@ describe("session-memory hook", () => {
expect(memoryContent).toContain("user: Only message 1");
expect(memoryContent).toContain("assistant: Only message 2");
});
describe("LanceDB target", () => {
let originalFetch: typeof global.fetch;
let fetchCalls: Array<{ url: string; options: RequestInit }>;
beforeEach(() => {
// Mock fetch
fetchCalls = [];
originalFetch = global.fetch;
global.fetch = async (url: string | URL, options?: RequestInit) => {
fetchCalls.push({ url: url.toString(), options: options || {} });
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
});
afterEach(() => {
global.fetch = originalFetch;
});
it("calls Gateway API with memory_store when target is lancedb", async () => {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionContent = createMockSessionContent([
{ role: "user", content: "Test question" },
{ role: "assistant", content: "Test answer" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: OpenClawConfig = {
agents: { defaults: { workspace: tempDir } },
gateway: {
port: 18789,
auth: { token: "test-token-123" },
},
hooks: {
internal: {
entries: {
"session-memory": { enabled: true, target: "lancedb" },
},
},
},
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
// Verify fetch was called
expect(fetchCalls.length).toBe(1);
expect(fetchCalls[0].url).toBe("http://localhost:18789/tools/invoke");
// Verify headers
const headers = fetchCalls[0].options.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer test-token-123");
expect(headers["Content-Type"]).toBe("application/json");
// Verify body
const body = JSON.parse(fetchCalls[0].options.body as string);
expect(body.tool).toBe("memory_store");
expect(body.args.category).toBe("fact");
expect(body.args.importance).toBe(0.7);
expect(body.args.text).toContain("user: Test question");
expect(body.args.text).toContain("assistant: Test answer");
});
it("truncates content to 2000 chars for lancedb", async () => {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create a long message
const longContent = "A".repeat(2500);
const sessionContent = createMockSessionContent([{ role: "user", content: longContent }]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: OpenClawConfig = {
agents: { defaults: { workspace: tempDir } },
gateway: {
port: 18789,
auth: { token: "test-token" },
},
hooks: {
internal: {
entries: {
"session-memory": { enabled: true, target: "lancedb" },
},
},
},
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
// Verify content was truncated
const body = JSON.parse(fetchCalls[0].options.body as string);
expect(body.args.text).toContain("[...truncated to 2000 chars]");
// Full content would be > 2000, but text should be <= 2000 + metadata + truncation notice
const contentLines = body.args.text.split("\n");
const conversationStart = contentLines.findIndex((line: string) => line.includes("user:"));
const conversationText = contentLines.slice(conversationStart).join("\n");
// The conversation part should be truncated at 2000 chars
expect(conversationText.length).toBeLessThan(2100); // 2000 + some overhead for truncation message
});
it("does not create memory file when target is lancedb", async () => {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionContent = createMockSessionContent([{ role: "user", content: "Test" }]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: OpenClawConfig = {
agents: { defaults: { workspace: tempDir } },
gateway: {
port: 18789,
auth: { token: "test-token" },
},
hooks: {
internal: {
entries: {
"session-memory": { enabled: true, target: "lancedb" },
},
},
},
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
// Memory directory should not be created
const memoryDir = path.join(tempDir, "memory");
await expect(fs.access(memoryDir)).rejects.toThrow();
});
it("handles Gateway API errors gracefully", async () => {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionContent = createMockSessionContent([{ role: "user", content: "Test" }]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
// Mock fetch to return error
global.fetch = async () => {
return new Response("Gateway error", { status: 500 });
};
const cfg: OpenClawConfig = {
agents: { defaults: { workspace: tempDir } },
gateway: {
port: 18789,
auth: { token: "test-token" },
},
hooks: {
internal: {
entries: {
"session-memory": { enabled: true, target: "lancedb" },
},
},
},
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
// Should not throw - errors are logged and caught
await expect(handler(event)).resolves.toBeUndefined();
});
it("defaults to file target when target config is invalid", async () => {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionContent = createMockSessionContent([{ role: "user", content: "Test" }]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: OpenClawConfig = {
agents: { defaults: { workspace: tempDir } },
hooks: {
internal: {
entries: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
"session-memory": { enabled: true, target: "invalid" as any },
},
},
},
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
// Should fall back to file target
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
expect(files.length).toBe(1);
// Fetch should not have been called
expect(fetchCalls.length).toBe(0);
});
});
});

View File

@@ -67,6 +67,67 @@ async function getRecentSessionContent(
}
}
/**
* Save session to LanceDB via Gateway API
*/
async function saveToLanceDB(params: {
cfg: OpenClawConfig;
sessionKey: string;
slug: string;
sessionContent: string;
timestamp: Date;
}): Promise<void> {
const { cfg, sessionKey, slug, sessionContent, timestamp } = params;
// Get gateway config
const gatewayPort = cfg.gateway?.port || 18789;
const gatewayToken = cfg.gateway?.auth?.token;
if (!gatewayToken) {
throw new Error("Gateway auth token not found in config");
}
// Format memory text with metadata and truncated content
const dateStr = timestamp.toISOString().split("T")[0];
const timeStr = timestamp.toISOString().split("T")[1].split(".")[0];
const truncatedContent = sessionContent.slice(0, 2000);
const wasTruncated = sessionContent.length > 2000;
const memoryText = [
`Session: ${slug}`,
`Date: ${dateStr} ${timeStr} UTC`,
`Session Key: ${sessionKey}`,
"",
truncatedContent,
wasTruncated ? "\n[...truncated to 2000 chars]" : "",
].join("\n");
// Call Gateway API to invoke memory_store
const apiUrl = `http://localhost:${gatewayPort}/tools/invoke`;
const response = await fetch(apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${gatewayToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
tool: "memory_store",
args: {
text: memoryText,
importance: 0.7,
category: "fact",
},
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gateway API call failed: ${response.status} ${errorText}`);
}
log.debug("Successfully stored to LanceDB via Gateway API");
}
/**
* Save session context to memory when /new command is triggered
*/
@@ -85,8 +146,6 @@ const saveSessionToMemory: HookHandler = async (event) => {
const workspaceDir = cfg
? resolveAgentWorkspaceDir(cfg, agentId)
: path.join(resolveStateDir(process.env, os.homedir), "workspace");
const memoryDir = path.join(workspaceDir, "memory");
await fs.mkdir(memoryDir, { recursive: true });
// Get today's date for filename
const now = new Date(event.timestamp);
@@ -108,12 +167,15 @@ const saveSessionToMemory: HookHandler = async (event) => {
const sessionFile = currentSessionFile || undefined;
// Read message count from hook config (default: 15)
// Read hook config (default: 15 messages, file target)
const hookConfig = resolveHookConfig(cfg, "session-memory");
const messageCount =
typeof hookConfig?.messages === "number" && hookConfig.messages > 0
? hookConfig.messages
: 15;
const target = hookConfig?.target === "lancedb" ? "lancedb" : "file";
log.debug("Storage target resolved", { target });
let slug: string | null = null;
let sessionContent: string | null = null;
@@ -149,45 +211,70 @@ const saveSessionToMemory: HookHandler = async (event) => {
log.debug("Using fallback timestamp slug", { slug });
}
// Create filename with date and slug
const filename = `${dateStr}-${slug}.md`;
const memoryFilePath = path.join(memoryDir, filename);
log.debug("Memory file path resolved", {
filename,
path: memoryFilePath.replace(os.homedir(), "~"),
});
// Route to appropriate storage target
if (target === "lancedb") {
// Store in LanceDB via Gateway API
if (!cfg) {
throw new Error("Config not available for LanceDB storage");
}
if (!sessionContent) {
log.debug("No session content available, skipping LanceDB storage");
return;
}
// Format time as HH:MM:SS UTC
const timeStr = now.toISOString().split("T")[1].split(".")[0];
await saveToLanceDB({
cfg,
sessionKey: event.sessionKey,
slug,
sessionContent,
timestamp: now,
});
log.info(`Session context stored in LanceDB: ${slug}`);
} else {
// Store in file (default behavior)
const memoryDir = path.join(workspaceDir, "memory");
await fs.mkdir(memoryDir, { recursive: true });
// Extract context details
const sessionId = (sessionEntry.sessionId as string) || "unknown";
const source = (context.commandSource as string) || "unknown";
// Create filename with date and slug
const filename = `${dateStr}-${slug}.md`;
const memoryFilePath = path.join(memoryDir, filename);
log.debug("Memory file path resolved", {
filename,
path: memoryFilePath.replace(os.homedir(), "~"),
});
// Build Markdown entry
const entryParts = [
`# Session: ${dateStr} ${timeStr} UTC`,
"",
`- **Session Key**: ${event.sessionKey}`,
`- **Session ID**: ${sessionId}`,
`- **Source**: ${source}`,
"",
];
// Format time as HH:MM:SS UTC
const timeStr = now.toISOString().split("T")[1].split(".")[0];
// Include conversation content if available
if (sessionContent) {
entryParts.push("## Conversation Summary", "", sessionContent, "");
// Extract context details
const sessionId = (sessionEntry.sessionId as string) || "unknown";
const source = (context.commandSource as string) || "unknown";
// Build Markdown entry
const entryParts = [
`# Session: ${dateStr} ${timeStr} UTC`,
"",
`- **Session Key**: ${event.sessionKey}`,
`- **Session ID**: ${sessionId}`,
`- **Source**: ${source}`,
"",
];
// Include conversation content if available
if (sessionContent) {
entryParts.push("## Conversation Summary", "", sessionContent, "");
}
const entry = entryParts.join("\n");
// Write to new memory file
await fs.writeFile(memoryFilePath, entry, "utf-8");
log.debug("Memory file written successfully");
// Log completion (but don't send user-visible confirmation - it's internal housekeeping)
const relPath = memoryFilePath.replace(os.homedir(), "~");
log.info(`Session context saved to ${relPath}`);
}
const entry = entryParts.join("\n");
// Write to new memory file
await fs.writeFile(memoryFilePath, entry, "utf-8");
log.debug("Memory file written successfully");
// Log completion (but don't send user-visible confirmation - it's internal housekeeping)
const relPath = memoryFilePath.replace(os.homedir(), "~");
log.info(`Session context saved to ${relPath}`);
} catch (err) {
if (err instanceof Error) {
log.error("Failed to save session memory", {

View File

@@ -80,6 +80,105 @@ const readCommitFromBuildInfo = () => {
}
};
/**
* Resolve the commit hash of the upstream tracking branch (e.g., origin/main).
* Returns null if not in a git repo or no upstream is configured.
*/
export const resolveUpstreamCommitHash = (options: { cwd?: string } = {}): string | null => {
try {
const gitDir = resolveGitDir(options.cwd ?? process.cwd());
if (!gitDir) {
return null;
}
// Read the current branch name
const headPath = path.join(gitDir, "HEAD");
const head = fs.readFileSync(headPath, "utf-8").trim();
if (!head.startsWith("ref:")) {
return null; // detached HEAD
}
const ref = head.replace(/^ref:\s*/i, "").trim();
const branchName = ref.replace(/^refs\/heads\//, "");
// Read the upstream tracking branch from config
const configPath = path.join(gitDir, "config");
const config = fs.readFileSync(configPath, "utf-8");
// Parse the config to find [branch "branchName"] section
const branchSection = new RegExp(`\\[branch\\s+"${branchName}"\\]([^\\[]+)`, "i");
const match = config.match(branchSection);
if (!match) {
return null;
}
const section = match[1];
const remoteMatch = section.match(/remote\s*=\s*(\S+)/i);
const mergeMatch = section.match(/merge\s*=\s*(\S+)/i);
if (!remoteMatch || !mergeMatch) {
return null;
}
const remote = remoteMatch[1];
const mergeBranch = mergeMatch[1].replace(/^refs\/heads\//, "");
// Read the upstream ref
const upstreamRef = `refs/remotes/${remote}/${mergeBranch}`;
const packedRefsPath = path.join(gitDir, "packed-refs");
const looseRefPath = path.join(gitDir, upstreamRef);
// Try loose ref first
try {
const hash = fs.readFileSync(looseRefPath, "utf-8").trim();
return formatCommit(hash);
} catch {
// Try packed-refs
try {
const packed = fs.readFileSync(packedRefsPath, "utf-8");
const lines = packed.split("\n");
for (const line of lines) {
if (line.startsWith("#") || !line.trim()) {
continue;
}
const [hash, refName] = line.split(/\s+/);
if (refName === upstreamRef) {
return formatCommit(hash);
}
}
} catch {
// No packed-refs
}
}
return null;
} catch {
return null;
}
};
const resolveGitDir = (startDir: string): string | null => {
let current = startDir;
for (let i = 0; i < 12; i += 1) {
const gitPath = path.join(current, ".git");
try {
const stat = fs.statSync(gitPath);
if (stat.isDirectory()) {
return gitPath;
}
if (stat.isFile()) {
const raw = fs.readFileSync(gitPath, "utf-8");
const match = raw.match(/gitdir:\s*(.+)/i);
if (match?.[1]) {
return path.resolve(current, match[1].trim());
}
}
} catch {
// ignore missing .git at this level
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return null;
};
export const resolveCommitHash = (options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) => {
if (cachedCommit !== undefined) {
return cachedCommit;

View File

@@ -8,6 +8,9 @@ import { loadOpenClawPlugins } from "./loader.js";
const log = createSubsystemLogger("plugins");
// Track which plugins have already registered CLI commands (idempotency guard)
const registeredPluginClis = new Set<string>();
export function registerPluginCliCommands(program: Command, cfg?: OpenClawConfig) {
const config = cfg ?? loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
@@ -26,6 +29,10 @@ export function registerPluginCliCommands(program: Command, cfg?: OpenClawConfig
const existingCommands = new Set(program.commands.map((cmd) => cmd.name()));
for (const entry of registry.cliRegistrars) {
// Skip if this plugin's CLI was already registered (idempotency)
if (registeredPluginClis.has(entry.pluginId)) {
continue;
}
if (entry.commands.length > 0) {
const overlaps = entry.commands.filter((command) => existingCommands.has(command));
if (overlaps.length > 0) {
@@ -52,6 +59,8 @@ export function registerPluginCliCommands(program: Command, cfg?: OpenClawConfig
for (const command of entry.commands) {
existingCommands.add(command);
}
// Mark as registered after successful registration
registeredPluginClis.add(entry.pluginId);
} catch (err) {
log.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`);
}

View File

@@ -19,6 +19,9 @@ import type {
PluginHookBeforeResetEvent,
PluginHookBeforeToolCallEvent,
PluginHookBeforeToolCallResult,
PluginHookBootstrapContext,
PluginHookBootstrapEvent,
PluginHookBootstrapResult,
PluginHookGatewayContext,
PluginHookGatewayStartEvent,
PluginHookGatewayStopEvent,
@@ -45,6 +48,9 @@ export type {
PluginHookBeforeAgentStartResult,
PluginHookLlmInputEvent,
PluginHookLlmOutputEvent,
PluginHookBootstrapContext,
PluginHookBootstrapEvent,
PluginHookBootstrapResult,
PluginHookAgentEndEvent,
PluginHookBeforeCompactionEvent,
PluginHookBeforeResetEvent,
@@ -234,6 +240,25 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return runVoidHook("llm_output", event, ctx);
}
/**
* Run agent_bootstrap hook.
* Allows plugins to inject or replace bootstrap files (e.g. virtual MEMORY.md).
* Runs sequentially, merging file lists.
*/
async function runAgentBootstrap(
event: PluginHookBootstrapEvent,
ctx: PluginHookBootstrapContext,
): Promise<PluginHookBootstrapResult | undefined> {
return runModifyingHook<"agent_bootstrap", PluginHookBootstrapResult>(
"agent_bootstrap",
event,
ctx,
(acc, next) => ({
files: next.files ?? acc?.files,
}),
);
}
/**
* Run before_compaction hook.
*/
@@ -483,6 +508,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
runLlmInput,
runLlmOutput,
runAgentEnd,
runAgentBootstrap,
runBeforeCompaction,
runAfterCompaction,
runBeforeReset,

View File

@@ -300,6 +300,7 @@ export type PluginHookName =
| "llm_input"
| "llm_output"
| "agent_end"
| "agent_bootstrap"
| "before_compaction"
| "after_compaction"
| "before_reset"
@@ -323,6 +324,21 @@ export type PluginHookAgentContext = {
messageProvider?: string;
};
// agent_bootstrap hook
export type PluginHookBootstrapContext = {
agentId?: string;
sessionKey?: string;
workspaceDir?: string;
};
export type PluginHookBootstrapEvent = {
files: Array<{ name: string; path: string; content?: string; missing: boolean }>;
};
export type PluginHookBootstrapResult = {
files?: Array<{ name: string; path: string; content?: string; missing: boolean }>;
};
// before_agent_start hook
export type PluginHookBeforeAgentStartEvent = {
prompt: string;
@@ -535,6 +551,10 @@ export type PluginHookHandlerMap = {
ctx: PluginHookAgentContext,
) => Promise<void> | void;
agent_end: (event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext) => Promise<void> | void;
agent_bootstrap: (
event: PluginHookBootstrapEvent,
ctx: PluginHookBootstrapContext,
) => Promise<PluginHookBootstrapResult | void> | PluginHookBootstrapResult | void;
before_compaction: (
event: PluginHookBeforeCompactionEvent,
ctx: PluginHookAgentContext,

View File

@@ -36,6 +36,15 @@ export function applyModelOverrideToSessionEntry(params: {
}
}
// Clear cached contextTokens when model changes so it gets re-looked up
// from the model catalog with the new model's context window.
// Always clear if contextTokens exists, even if no other fields changed
// (e.g., when resetting to default while already on default).
if (entry.contextTokens !== undefined) {
delete entry.contextTokens;
updated = true;
}
if (profileOverride) {
if (entry.authProfileOverride !== profileOverride) {
entry.authProfileOverride = profileOverride;

View File

@@ -94,4 +94,47 @@ describe("splitShellArgs", () => {
expect(splitShellArgs(`echo "oops`)).toBeNull();
expect(splitShellArgs(`echo 'oops`)).toBeNull();
});
it("returns null for trailing escape", () => {
expect(splitShellArgs("foo bar\\")).toBeNull();
});
it("handles multiple and leading/trailing spaces", () => {
expect(splitShellArgs("foo bar baz")).toEqual(["foo", "bar", "baz"]);
expect(splitShellArgs(" foo bar ")).toEqual(["foo", "bar"]);
});
it("handles escaped spaces outside quotes", () => {
expect(splitShellArgs("foo bar\\ baz qux")).toEqual(["foo", "bar baz", "qux"]);
});
it("handles adjacent quoted and unquoted parts", () => {
expect(splitShellArgs('pre"quoted"post')).toEqual(["prequotedpost"]);
});
it("handles empty and whitespace-only input", () => {
expect(splitShellArgs("")).toEqual([]);
expect(splitShellArgs(" ")).toEqual([]);
});
it("handles quotes inside quotes (different type)", () => {
expect(splitShellArgs(`foo "it's working" bar`)).toEqual(["foo", "it's working", "bar"]);
expect(splitShellArgs(`foo 'he said "hello"' bar`)).toEqual(["foo", 'he said "hello"', "bar"]);
});
it("handles paths with spaces in quotes", () => {
expect(splitShellArgs('cmd "/path/with spaces/file.txt"')).toEqual([
"cmd",
"/path/with spaces/file.txt",
]);
});
it("handles unicode characters", () => {
expect(splitShellArgs("echo 'héllo wörld' 日本語")).toEqual(["echo", "héllo wörld", "日本語"]);
});
it("handles tabs and newlines as whitespace", () => {
expect(splitShellArgs("foo\tbar\tbaz")).toEqual(["foo", "bar", "baz"]);
expect(splitShellArgs("foo\nbar")).toEqual(["foo", "bar"]);
});
});