mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-23 09:48:11 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
143
src/config/sessions/per-session-store.ts
Normal file
143
src/config/sessions/per-session-store.ts
Normal 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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user