refactor: split memory-core plugin helpers

This commit is contained in:
Peter Steinberger
2026-03-26 22:04:58 +00:00
parent 9dea807b28
commit d0ce2d1044
6 changed files with 419 additions and 390 deletions

View File

@@ -1,185 +1,20 @@
import type {
MemoryFlushPlan,
MemoryPromptSectionBuilder,
OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core";
import {
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR,
parseNonNegativeByteSize,
resolveCronStyleNow,
SILENT_REPLY_TOKEN,
} from "openclaw/plugin-sdk/memory-core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerMemoryCli } from "./src/cli.js";
import {
buildMemoryFlushPlan,
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES,
DEFAULT_MEMORY_FLUSH_PROMPT,
DEFAULT_MEMORY_FLUSH_SOFT_TOKENS,
} from "./src/flush-plan.js";
import { buildPromptSection } from "./src/prompt-section.js";
import { createMemoryGetTool, createMemorySearchTool } from "./src/tools.js";
export const buildPromptSection: MemoryPromptSectionBuilder = ({
availableTools,
citationsMode,
}) => {
const hasMemorySearch = availableTools.has("memory_search");
const hasMemoryGet = availableTools.has("memory_get");
if (!hasMemorySearch && !hasMemoryGet) {
return [];
}
let toolGuidance: string;
if (hasMemorySearch && hasMemoryGet) {
toolGuidance =
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.";
} else if (hasMemorySearch) {
toolGuidance =
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md and answer from the matching results. If low confidence after search, say you checked.";
} else {
toolGuidance =
"Before answering anything about prior work, decisions, dates, people, preferences, or todos that already point to a specific memory file or note: run memory_get to pull only the needed lines. If low confidence after reading them, say you checked.";
}
const lines = ["## Memory Recall", toolGuidance];
if (citationsMode === "off") {
lines.push(
"Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.",
);
} else {
lines.push(
"Citations: include Source: <path#line> when it helps the user verify memory snippets.",
);
}
lines.push("");
return lines;
};
export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;
export const DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
const MEMORY_FLUSH_TARGET_HINT =
"Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).";
const MEMORY_FLUSH_APPEND_ONLY_HINT =
"If memory/YYYY-MM-DD.md already exists, APPEND new content only and do not overwrite existing entries.";
const MEMORY_FLUSH_READ_ONLY_HINT =
"Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.";
const MEMORY_FLUSH_REQUIRED_HINTS = [
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
];
export const DEFAULT_MEMORY_FLUSH_PROMPT = [
"Pre-compaction memory flush.",
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
"Do NOT create timestamped variant files (e.g., YYYY-MM-DD-HHMM.md); always use the canonical YYYY-MM-DD.md filename.",
`If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`,
].join(" ");
export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [
"Pre-compaction memory flush turn.",
"The session is near auto-compaction; capture durable memories to disk.",
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
`You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`,
].join(" ");
function formatDateStampInTimezone(nowMs: number, timezone: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date(nowMs));
const year = parts.find((part) => part.type === "year")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
if (year && month && day) {
return `${year}-${month}-${day}`;
}
return new Date(nowMs).toISOString().slice(0, 10);
}
function normalizeNonNegativeInt(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
const int = Math.floor(value);
return int >= 0 ? int : null;
}
function ensureNoReplyHint(text: string): string {
if (text.includes(SILENT_REPLY_TOKEN)) {
return text;
}
return `${text}\n\nIf no user-visible reply is needed, start with ${SILENT_REPLY_TOKEN}.`;
}
function ensureMemoryFlushSafetyHints(text: string): string {
let next = text.trim();
for (const hint of MEMORY_FLUSH_REQUIRED_HINTS) {
if (!next.includes(hint)) {
next = next ? `${next}\n\n${hint}` : hint;
}
}
return next;
}
function appendCurrentTimeLine(text: string, timeLine: string): string {
const trimmed = text.trimEnd();
if (!trimmed) {
return timeLine;
}
if (trimmed.includes("Current time:")) {
return trimmed;
}
return `${trimmed}\n${timeLine}`;
}
export function buildMemoryFlushPlan(
params: {
cfg?: OpenClawConfig;
nowMs?: number;
} = {},
): MemoryFlushPlan | null {
const resolved = params;
const nowMs = Number.isFinite(resolved.nowMs) ? (resolved.nowMs as number) : Date.now();
const cfg = resolved.cfg;
const defaults = cfg?.agents?.defaults?.compaction?.memoryFlush;
if (defaults?.enabled === false) {
return null;
}
const softThresholdTokens =
normalizeNonNegativeInt(defaults?.softThresholdTokens) ?? DEFAULT_MEMORY_FLUSH_SOFT_TOKENS;
const forceFlushTranscriptBytes =
parseNonNegativeByteSize(defaults?.forceFlushTranscriptBytes) ??
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES;
const reserveTokensFloor =
normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ??
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
const { timeLine, userTimezone } = resolveCronStyleNow(cfg ?? {}, nowMs);
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
const relativePath = `memory/${dateStamp}.md`;
const promptBase = ensureNoReplyHint(
ensureMemoryFlushSafetyHints(defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT),
);
const systemPrompt = ensureNoReplyHint(
ensureMemoryFlushSafetyHints(
defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT,
),
);
return {
softThresholdTokens,
forceFlushTranscriptBytes,
reserveTokensFloor,
prompt: appendCurrentTimeLine(promptBase.replaceAll("YYYY-MM-DD", dateStamp), timeLine),
systemPrompt: systemPrompt.replaceAll("YYYY-MM-DD", dateStamp),
relativePath,
};
}
export {
buildMemoryFlushPlan,
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES,
DEFAULT_MEMORY_FLUSH_PROMPT,
DEFAULT_MEMORY_FLUSH_SOFT_TOKENS,
} from "./src/flush-plan.js";
export { buildPromptSection } from "./src/prompt-section.js";
export default definePluginEntry({
id: "memory-core",

View File

@@ -0,0 +1,138 @@
import type { MemoryFlushPlan, OpenClawConfig } from "openclaw/plugin-sdk/memory-core";
import {
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR,
parseNonNegativeByteSize,
resolveCronStyleNow,
SILENT_REPLY_TOKEN,
} from "openclaw/plugin-sdk/memory-core";
export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;
export const DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
const MEMORY_FLUSH_TARGET_HINT =
"Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).";
const MEMORY_FLUSH_APPEND_ONLY_HINT =
"If memory/YYYY-MM-DD.md already exists, APPEND new content only and do not overwrite existing entries.";
const MEMORY_FLUSH_READ_ONLY_HINT =
"Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.";
const MEMORY_FLUSH_REQUIRED_HINTS = [
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
];
export const DEFAULT_MEMORY_FLUSH_PROMPT = [
"Pre-compaction memory flush.",
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
"Do NOT create timestamped variant files (e.g., YYYY-MM-DD-HHMM.md); always use the canonical YYYY-MM-DD.md filename.",
`If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`,
].join(" ");
export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [
"Pre-compaction memory flush turn.",
"The session is near auto-compaction; capture durable memories to disk.",
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
`You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`,
].join(" ");
function formatDateStampInTimezone(nowMs: number, timezone: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date(nowMs));
const year = parts.find((part) => part.type === "year")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
if (year && month && day) {
return `${year}-${month}-${day}`;
}
return new Date(nowMs).toISOString().slice(0, 10);
}
function normalizeNonNegativeInt(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
const int = Math.floor(value);
return int >= 0 ? int : null;
}
function ensureNoReplyHint(text: string): string {
if (text.includes(SILENT_REPLY_TOKEN)) {
return text;
}
return `${text}\n\nIf no user-visible reply is needed, start with ${SILENT_REPLY_TOKEN}.`;
}
function ensureMemoryFlushSafetyHints(text: string): string {
let next = text.trim();
for (const hint of MEMORY_FLUSH_REQUIRED_HINTS) {
if (!next.includes(hint)) {
next = next ? `${next}\n\n${hint}` : hint;
}
}
return next;
}
function appendCurrentTimeLine(text: string, timeLine: string): string {
const trimmed = text.trimEnd();
if (!trimmed) {
return timeLine;
}
if (trimmed.includes("Current time:")) {
return trimmed;
}
return `${trimmed}\n${timeLine}`;
}
export function buildMemoryFlushPlan(
params: {
cfg?: OpenClawConfig;
nowMs?: number;
} = {},
): MemoryFlushPlan | null {
const resolved = params;
const nowMs = Number.isFinite(resolved.nowMs) ? (resolved.nowMs as number) : Date.now();
const cfg = resolved.cfg;
const defaults = cfg?.agents?.defaults?.compaction?.memoryFlush;
if (defaults?.enabled === false) {
return null;
}
const softThresholdTokens =
normalizeNonNegativeInt(defaults?.softThresholdTokens) ?? DEFAULT_MEMORY_FLUSH_SOFT_TOKENS;
const forceFlushTranscriptBytes =
parseNonNegativeByteSize(defaults?.forceFlushTranscriptBytes) ??
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES;
const reserveTokensFloor =
normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ??
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
const { timeLine, userTimezone } = resolveCronStyleNow(cfg ?? {}, nowMs);
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
const relativePath = `memory/${dateStamp}.md`;
const promptBase = ensureNoReplyHint(
ensureMemoryFlushSafetyHints(defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT),
);
const systemPrompt = ensureNoReplyHint(
ensureMemoryFlushSafetyHints(
defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT,
),
);
return {
softThresholdTokens,
forceFlushTranscriptBytes,
reserveTokensFloor,
prompt: appendCurrentTimeLine(promptBase.replaceAll("YYYY-MM-DD", dateStamp), timeLine),
systemPrompt: systemPrompt.replaceAll("YYYY-MM-DD", dateStamp),
relativePath,
};
}

View File

@@ -0,0 +1,38 @@
import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-core";
export const buildPromptSection: MemoryPromptSectionBuilder = ({
availableTools,
citationsMode,
}) => {
const hasMemorySearch = availableTools.has("memory_search");
const hasMemoryGet = availableTools.has("memory_get");
if (!hasMemorySearch && !hasMemoryGet) {
return [];
}
let toolGuidance: string;
if (hasMemorySearch && hasMemoryGet) {
toolGuidance =
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.";
} else if (hasMemorySearch) {
toolGuidance =
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md and answer from the matching results. If low confidence after search, say you checked.";
} else {
toolGuidance =
"Before answering anything about prior work, decisions, dates, people, preferences, or todos that already point to a specific memory file or note: run memory_get to pull only the needed lines. If low confidence after reading them, say you checked.";
}
const lines = ["## Memory Recall", toolGuidance];
if (citationsMode === "off") {
lines.push(
"Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.",
);
} else {
lines.push(
"Citations: include Source: <path#line> when it helps the user verify memory snippets.",
);
}
lines.push("");
return lines;
};

View File

@@ -0,0 +1,90 @@
import type {
MemoryCitationsMode,
MemorySearchResult,
OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core";
import { parseAgentSessionKey } from "openclaw/plugin-sdk/memory-core";
export function resolveMemoryCitationsMode(cfg: OpenClawConfig): MemoryCitationsMode {
const mode = cfg.memory?.citations;
if (mode === "on" || mode === "off" || mode === "auto") {
return mode;
}
return "auto";
}
export function decorateCitations(
results: MemorySearchResult[],
include: boolean,
): MemorySearchResult[] {
if (!include) {
return results.map((entry) => ({ ...entry, citation: undefined }));
}
return results.map((entry) => {
const citation = formatCitation(entry);
const snippet = `${entry.snippet.trim()}\n\nSource: ${citation}`;
return { ...entry, citation, snippet };
});
}
function formatCitation(entry: MemorySearchResult): string {
const lineRange =
entry.startLine === entry.endLine
? `#L${entry.startLine}`
: `#L${entry.startLine}-L${entry.endLine}`;
return `${entry.path}${lineRange}`;
}
export function clampResultsByInjectedChars(
results: MemorySearchResult[],
budget?: number,
): MemorySearchResult[] {
if (!budget || budget <= 0) {
return results;
}
let remaining = budget;
const clamped: MemorySearchResult[] = [];
for (const entry of results) {
if (remaining <= 0) {
break;
}
const snippet = entry.snippet ?? "";
if (snippet.length <= remaining) {
clamped.push(entry);
remaining -= snippet.length;
} else {
const trimmed = snippet.slice(0, Math.max(0, remaining));
clamped.push({ ...entry, snippet: trimmed });
break;
}
}
return clamped;
}
export function shouldIncludeCitations(params: {
mode: MemoryCitationsMode;
sessionKey?: string;
}): boolean {
if (params.mode === "on") {
return true;
}
if (params.mode === "off") {
return false;
}
return deriveChatTypeFromSessionKey(params.sessionKey) === "direct";
}
function deriveChatTypeFromSessionKey(sessionKey?: string): "direct" | "group" | "channel" {
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed?.rest) {
return "direct";
}
const tokens = new Set(parsed.rest.toLowerCase().split(":").filter(Boolean));
if (tokens.has("channel")) {
return "channel";
}
if (tokens.has("group")) {
return "group";
}
return "direct";
}

View File

@@ -0,0 +1,123 @@
import { Type } from "@sinclair/typebox";
import type { AnyAgentTool, OpenClawConfig } from "openclaw/plugin-sdk/memory-core";
import { resolveMemorySearchConfig, resolveSessionAgentId } from "openclaw/plugin-sdk/memory-core";
type MemoryToolRuntime = typeof import("./tools.runtime.js");
type MemorySearchManagerResult = Awaited<
ReturnType<(typeof import("openclaw/plugin-sdk/memory-core"))["getMemorySearchManager"]>
>;
let memoryToolRuntimePromise: Promise<MemoryToolRuntime> | null = null;
export async function loadMemoryToolRuntime(): Promise<MemoryToolRuntime> {
memoryToolRuntimePromise ??= import("./tools.runtime.js");
return await memoryToolRuntimePromise;
}
export const MemorySearchSchema = Type.Object({
query: Type.String(),
maxResults: Type.Optional(Type.Number()),
minScore: Type.Optional(Type.Number()),
});
export const MemoryGetSchema = Type.Object({
path: Type.String(),
from: Type.Optional(Type.Number()),
lines: Type.Optional(Type.Number()),
});
export function resolveMemoryToolContext(options: {
config?: OpenClawConfig;
agentSessionKey?: string;
}) {
const cfg = options.config;
if (!cfg) {
return null;
}
const agentId = resolveSessionAgentId({
sessionKey: options.agentSessionKey,
config: cfg,
});
if (!resolveMemorySearchConfig(cfg, agentId)) {
return null;
}
return { cfg, agentId };
}
export async function getMemoryManagerContext(params: {
cfg: OpenClawConfig;
agentId: string;
}): Promise<
| {
manager: NonNullable<MemorySearchManagerResult["manager"]>;
}
| {
error: string | undefined;
}
> {
return await getMemoryManagerContextWithPurpose({ ...params, purpose: undefined });
}
export async function getMemoryManagerContextWithPurpose(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: "default" | "status";
}): Promise<
| {
manager: NonNullable<MemorySearchManagerResult["manager"]>;
}
| {
error: string | undefined;
}
> {
const { getMemorySearchManager } = await loadMemoryToolRuntime();
const { manager, error } = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId,
purpose: params.purpose,
});
return manager ? { manager } : { error };
}
export function createMemoryTool(params: {
options: {
config?: OpenClawConfig;
agentSessionKey?: string;
};
label: string;
name: string;
description: string;
parameters: typeof MemorySearchSchema | typeof MemoryGetSchema;
execute: (ctx: { cfg: OpenClawConfig; agentId: string }) => AnyAgentTool["execute"];
}): AnyAgentTool | null {
const ctx = resolveMemoryToolContext(params.options);
if (!ctx) {
return null;
}
return {
label: params.label,
name: params.name,
description: params.description,
parameters: params.parameters,
execute: params.execute(ctx),
};
}
export function buildMemorySearchUnavailableResult(error: string | undefined) {
const reason = (error ?? "memory search unavailable").trim() || "memory search unavailable";
const isQuotaError = /insufficient_quota|quota|429/.test(reason.toLowerCase());
const warning = isQuotaError
? "Memory search is unavailable because the embedding provider quota is exhausted."
: "Memory search is unavailable due to an embedding/provider error.";
const action = isQuotaError
? "Top up or switch embedding provider, then retry memory_search."
: "Check embedding provider configuration and retry memory_search.";
return {
results: [],
disabled: true,
unavailable: true,
error: reason,
warning,
action,
};
}

View File

@@ -1,113 +1,20 @@
import { Type } from "@sinclair/typebox";
import type {
AnyAgentTool,
MemoryCitationsMode,
MemorySearchResult,
OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core";
import type { AnyAgentTool, OpenClawConfig } from "openclaw/plugin-sdk/memory-core";
import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/memory-core";
import {
jsonResult,
parseAgentSessionKey,
readNumberParam,
readStringParam,
resolveMemorySearchConfig,
resolveSessionAgentId,
} from "openclaw/plugin-sdk/memory-core";
type MemoryToolRuntime = typeof import("./tools.runtime.js");
type MemorySearchManagerResult = Awaited<
ReturnType<(typeof import("openclaw/plugin-sdk/memory-core"))["getMemorySearchManager"]>
>;
let memoryToolRuntimePromise: Promise<MemoryToolRuntime> | null = null;
async function loadMemoryToolRuntime(): Promise<MemoryToolRuntime> {
memoryToolRuntimePromise ??= import("./tools.runtime.js");
return await memoryToolRuntimePromise;
}
const MemorySearchSchema = Type.Object({
query: Type.String(),
maxResults: Type.Optional(Type.Number()),
minScore: Type.Optional(Type.Number()),
});
const MemoryGetSchema = Type.Object({
path: Type.String(),
from: Type.Optional(Type.Number()),
lines: Type.Optional(Type.Number()),
});
function resolveMemoryToolContext(options: { config?: OpenClawConfig; agentSessionKey?: string }) {
const cfg = options.config;
if (!cfg) {
return null;
}
const agentId = resolveSessionAgentId({
sessionKey: options.agentSessionKey,
config: cfg,
});
if (!resolveMemorySearchConfig(cfg, agentId)) {
return null;
}
return { cfg, agentId };
}
async function getMemoryManagerContext(params: { cfg: OpenClawConfig; agentId: string }): Promise<
| {
manager: NonNullable<MemorySearchManagerResult["manager"]>;
}
| {
error: string | undefined;
}
> {
return await getMemoryManagerContextWithPurpose({ ...params, purpose: undefined });
}
async function getMemoryManagerContextWithPurpose(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: "default" | "status";
}): Promise<
| {
manager: NonNullable<MemorySearchManagerResult["manager"]>;
}
| {
error: string | undefined;
}
> {
const { getMemorySearchManager } = await loadMemoryToolRuntime();
const { manager, error } = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId,
purpose: params.purpose,
});
return manager ? { manager } : { error };
}
function createMemoryTool(params: {
options: {
config?: OpenClawConfig;
agentSessionKey?: string;
};
label: string;
name: string;
description: string;
parameters: typeof MemorySearchSchema | typeof MemoryGetSchema;
execute: (ctx: { cfg: OpenClawConfig; agentId: string }) => AnyAgentTool["execute"];
}): AnyAgentTool | null {
const ctx = resolveMemoryToolContext(params.options);
if (!ctx) {
return null;
}
return {
label: params.label,
name: params.name,
description: params.description,
parameters: params.parameters,
execute: params.execute(ctx),
};
}
clampResultsByInjectedChars,
decorateCitations,
resolveMemoryCitationsMode,
shouldIncludeCitations,
} from "./tools.citations.js";
import {
buildMemorySearchUnavailableResult,
createMemoryTool,
getMemoryManagerContext,
getMemoryManagerContextWithPurpose,
loadMemoryToolRuntime,
MemoryGetSchema,
MemorySearchSchema,
} from "./tools.shared.js";
export function createMemorySearchTool(options: {
config?: OpenClawConfig;
@@ -222,105 +129,3 @@ export function createMemoryGetTool(options: {
},
});
}
function resolveMemoryCitationsMode(cfg: OpenClawConfig): MemoryCitationsMode {
const mode = cfg.memory?.citations;
if (mode === "on" || mode === "off" || mode === "auto") {
return mode;
}
return "auto";
}
function decorateCitations(results: MemorySearchResult[], include: boolean): MemorySearchResult[] {
if (!include) {
return results.map((entry) => ({ ...entry, citation: undefined }));
}
return results.map((entry) => {
const citation = formatCitation(entry);
const snippet = `${entry.snippet.trim()}\n\nSource: ${citation}`;
return { ...entry, citation, snippet };
});
}
function formatCitation(entry: MemorySearchResult): string {
const lineRange =
entry.startLine === entry.endLine
? `#L${entry.startLine}`
: `#L${entry.startLine}-L${entry.endLine}`;
return `${entry.path}${lineRange}`;
}
function clampResultsByInjectedChars(
results: MemorySearchResult[],
budget?: number,
): MemorySearchResult[] {
if (!budget || budget <= 0) {
return results;
}
let remaining = budget;
const clamped: MemorySearchResult[] = [];
for (const entry of results) {
if (remaining <= 0) {
break;
}
const snippet = entry.snippet ?? "";
if (snippet.length <= remaining) {
clamped.push(entry);
remaining -= snippet.length;
} else {
const trimmed = snippet.slice(0, Math.max(0, remaining));
clamped.push({ ...entry, snippet: trimmed });
break;
}
}
return clamped;
}
function buildMemorySearchUnavailableResult(error: string | undefined) {
const reason = (error ?? "memory search unavailable").trim() || "memory search unavailable";
const isQuotaError = /insufficient_quota|quota|429/.test(reason.toLowerCase());
const warning = isQuotaError
? "Memory search is unavailable because the embedding provider quota is exhausted."
: "Memory search is unavailable due to an embedding/provider error.";
const action = isQuotaError
? "Top up or switch embedding provider, then retry memory_search."
: "Check embedding provider configuration and retry memory_search.";
return {
results: [],
disabled: true,
unavailable: true,
error: reason,
warning,
action,
};
}
function shouldIncludeCitations(params: {
mode: MemoryCitationsMode;
sessionKey?: string;
}): boolean {
if (params.mode === "on") {
return true;
}
if (params.mode === "off") {
return false;
}
// auto: show citations in direct chats; suppress in groups/channels by default.
const chatType = deriveChatTypeFromSessionKey(params.sessionKey);
return chatType === "direct";
}
function deriveChatTypeFromSessionKey(sessionKey?: string): "direct" | "group" | "channel" {
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed?.rest) {
return "direct";
}
const tokens = new Set(parsed.rest.toLowerCase().split(":").filter(Boolean));
if (tokens.has("channel")) {
return "channel";
}
if (tokens.has("group")) {
return "group";
}
return "direct";
}