From d0ce2d1044430d41efd711e368739b47729bc6a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Mar 2026 22:04:58 +0000 Subject: [PATCH] refactor: split memory-core plugin helpers --- extensions/memory-core/index.ts | 193 ++------------- extensions/memory-core/src/flush-plan.ts | 138 +++++++++++ extensions/memory-core/src/prompt-section.ts | 38 +++ extensions/memory-core/src/tools.citations.ts | 90 +++++++ extensions/memory-core/src/tools.shared.ts | 123 ++++++++++ extensions/memory-core/src/tools.ts | 227 ++---------------- 6 files changed, 419 insertions(+), 390 deletions(-) create mode 100644 extensions/memory-core/src/flush-plan.ts create mode 100644 extensions/memory-core/src/prompt-section.ts create mode 100644 extensions/memory-core/src/tools.citations.ts create mode 100644 extensions/memory-core/src/tools.shared.ts diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 17ed707d1ad..f6f28f3429d 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -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: 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", diff --git a/extensions/memory-core/src/flush-plan.ts b/extensions/memory-core/src/flush-plan.ts new file mode 100644 index 00000000000..f5472d74c15 --- /dev/null +++ b/extensions/memory-core/src/flush-plan.ts @@ -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, + }; +} diff --git a/extensions/memory-core/src/prompt-section.ts b/extensions/memory-core/src/prompt-section.ts new file mode 100644 index 00000000000..00eee233799 --- /dev/null +++ b/extensions/memory-core/src/prompt-section.ts @@ -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: when it helps the user verify memory snippets.", + ); + } + lines.push(""); + return lines; +}; diff --git a/extensions/memory-core/src/tools.citations.ts b/extensions/memory-core/src/tools.citations.ts new file mode 100644 index 00000000000..9be94899377 --- /dev/null +++ b/extensions/memory-core/src/tools.citations.ts @@ -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"; +} diff --git a/extensions/memory-core/src/tools.shared.ts b/extensions/memory-core/src/tools.shared.ts new file mode 100644 index 00000000000..08405537c5d --- /dev/null +++ b/extensions/memory-core/src/tools.shared.ts @@ -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 | null = null; + +export async function loadMemoryToolRuntime(): Promise { + 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; + } + | { + error: string | undefined; + } +> { + return await getMemoryManagerContextWithPurpose({ ...params, purpose: undefined }); +} + +export async function getMemoryManagerContextWithPurpose(params: { + cfg: OpenClawConfig; + agentId: string; + purpose?: "default" | "status"; +}): Promise< + | { + manager: NonNullable; + } + | { + 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, + }; +} diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index 5006cbcea6e..72929dfec3a 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -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 | null = null; - -async function loadMemoryToolRuntime(): Promise { - 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; - } - | { - error: string | undefined; - } -> { - return await getMemoryManagerContextWithPurpose({ ...params, purpose: undefined }); -} - -async function getMemoryManagerContextWithPurpose(params: { - cfg: OpenClawConfig; - agentId: string; - purpose?: "default" | "status"; -}): Promise< - | { - manager: NonNullable; - } - | { - 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"; -}