mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-28 10:22:32 +00:00
refactor: split memory-core plugin helpers
This commit is contained in:
@@ -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",
|
||||
|
||||
138
extensions/memory-core/src/flush-plan.ts
Normal file
138
extensions/memory-core/src/flush-plan.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
38
extensions/memory-core/src/prompt-section.ts
Normal file
38
extensions/memory-core/src/prompt-section.ts
Normal 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;
|
||||
};
|
||||
90
extensions/memory-core/src/tools.citations.ts
Normal file
90
extensions/memory-core/src/tools.citations.ts
Normal 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";
|
||||
}
|
||||
123
extensions/memory-core/src/tools.shared.ts
Normal file
123
extensions/memory-core/src/tools.shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user