fix: adding login and fixing the plugin to follow the openclaw plugin standards (#4686)

This commit is contained in:
Kartik
2026-04-03 20:59:23 +05:30
committed by GitHub
parent 66230b3f1f
commit cfb3f58e4a
33 changed files with 5254 additions and 1376 deletions
+76
View File
@@ -0,0 +1,76 @@
/**
* Tool aggregator — single entry-point for registering all Mem0 tools.
*
* Re-exports the canonical `ToolContext` interface and provides
* `registerAllTools(ctx)` which wires up every tool in one call.
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { Backend } from "../backend/base.ts";
import type {
Mem0Config,
Mem0Provider,
AddOptions,
SearchOptions,
} from "../types.ts";
// ---------------------------------------------------------------------------
// Canonical ToolContext definition
// ---------------------------------------------------------------------------
export interface ToolContext {
api: OpenClawPluginApi;
backend: Backend;
cfg: Mem0Config;
provider?: Mem0Provider;
resolveUserId: (opts: { agentId?: string; userId?: string }) => string;
effectiveUserId: (sessionKey?: string) => string;
agentUserId: (id: string) => string;
getCurrentSessionId: () => string | undefined;
skillsActive: boolean;
buildAddOptions: (
userIdOverride?: string,
runId?: string,
sessionKey?: string,
) => AddOptions;
buildSearchOptions: (
userIdOverride?: string,
limit?: number,
runId?: string,
sessionKey?: string,
) => SearchOptions;
}
// ---------------------------------------------------------------------------
// Tool factory imports
// ---------------------------------------------------------------------------
import { createMemorySearchTool } from "./memory-search.ts";
import { createMemoryStoreTool } from "./memory-store.ts";
import { createMemoryGetTool } from "./memory-get.ts";
import { createMemoryListTool } from "./memory-list.ts";
import { createMemoryUpdateTool } from "./memory-update.ts";
import { createMemoryDeleteTool } from "./memory-delete.ts";
import { createMemoryHistoryTool } from "./memory-history.ts";
// ---------------------------------------------------------------------------
// Aggregator
// ---------------------------------------------------------------------------
/**
* Registers all Mem0 tools with the plugin API.
*
* 7 core tools for memory management:
* search, store, get, list, update, delete, history
*/
export function registerAllTools(ctx: ToolContext): void {
const { api } = ctx;
api.registerTool(createMemorySearchTool(ctx), { name: "memory_search" });
api.registerTool(createMemoryStoreTool(ctx), { name: "memory_store" });
api.registerTool(createMemoryGetTool(ctx), { name: "memory_get" });
api.registerTool(createMemoryListTool(ctx), { name: "memory_list" });
api.registerTool(createMemoryUpdateTool(ctx), { name: "memory_update" });
api.registerTool(createMemoryDeleteTool(ctx), { name: "memory_delete" });
api.registerTool(createMemoryHistoryTool(ctx), { name: "memory_history" });
}
+257
View File
@@ -0,0 +1,257 @@
/**
* memory_delete tool — unified delete tool replacing memory_forget and memory_delete_all.
*
* Supports four modes:
* 1. By memory_id — direct deletion of a specific memory
* 2. By query — search-and-delete (auto-deletes high-confidence match, otherwise lists candidates)
* 3. all:true — bulk-delete all memories for a user (requires confirm:true)
* 4. entity:true — cascade-delete an entity and all its memories (requires confirm:true, platform only)
*/
import { Type } from "@sinclair/typebox";
import { isSubagentSession } from "../isolation.ts";
import type { ToolContext } from "./index.ts";
// ---------------------------------------------------------------------------
// Tool factory
// ---------------------------------------------------------------------------
/**
* Creates the `memory_delete` tool config object suitable for
* `api.registerTool(config, { name })`.
*
* This replaces both `memory_forget` and `memory_delete_all` from the
* original index.ts implementation.
*/
export function createMemoryDeleteTool(ctx: ToolContext) {
const {
api,
provider,
resolveUserId,
getCurrentSessionId,
buildSearchOptions,
backend,
} = ctx;
return {
name: "memory_delete",
label: "Memory Delete",
description:
"Delete memories from Mem0. Provide a specific memoryId for direct deletion, a query to search and delete, all:true for bulk deletion, or entity:true to cascade-delete an entity. Bulk operations require confirm:true. GDPR-compliant.",
parameters: Type.Object({
memory_id: Type.Optional(
Type.String({ description: "Specific memory ID to delete" }),
),
query: Type.Optional(
Type.String({
description:
"Search query to find memory to delete (searches and deletes best match)",
}),
),
all: Type.Optional(
Type.Boolean({
description:
"Delete ALL memories matching scope filters. Requires confirm: true",
}),
),
entity: Type.Optional(
Type.Boolean({
description:
"Delete entity and all its memories (cascade). Requires confirm: true",
}),
),
confirm: Type.Optional(
Type.Boolean({
description:
"Must be true for bulk operations (all/entity). Safety gate.",
}),
),
user_id: Type.Optional(Type.String({ description: "Scope to user" })),
agent_id: Type.Optional(Type.String({ description: "Scope to agent" })),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const { memory_id, query, all, entity, confirm, user_id, agent_id } =
params as {
memory_id?: string;
query?: string;
all?: boolean;
entity?: boolean;
confirm?: boolean;
user_id?: string;
agent_id?: string;
};
try {
// Block subagent deletes at the tool level.
const currentSessionId = getCurrentSessionId();
if (isSubagentSession(currentSessionId)) {
api.logger.warn(
"openclaw-mem0: blocked memory_delete from subagent session",
);
return {
content: [
{
type: "text",
text: "Memory deletion is not available in subagent sessions. The main agent handles memory.",
},
],
details: { error: "subagent_blocked" },
};
}
// Mode 1: Delete by specific memory ID
if (memory_id) {
await provider!.delete(memory_id);
return {
content: [{ type: "text", text: `Memory ${memory_id} deleted.` }],
details: { action: "deleted", id: memory_id },
};
}
// Mode 2: Search-and-delete by query
if (query) {
const uid = resolveUserId({ agentId: agent_id, userId: user_id });
const results = await provider!.search(
query,
buildSearchOptions(uid, 5),
);
if (!results || results.length === 0) {
return {
content: [{ type: "text", text: "No matching memories found." }],
details: { found: 0 },
};
}
// If single high-confidence match, delete directly
if (results.length === 1 || (results[0].score ?? 0) > 0.9) {
await provider!.delete(results[0].id);
return {
content: [
{
type: "text",
text: `Deleted: "${results[0].memory}"`,
},
],
details: { action: "deleted", id: results[0].id },
};
}
// Multiple ambiguous results — return candidates for user to pick
const list = results
.map(
(r) =>
`- [${r.id}] ${r.memory.slice(0, 80)}${r.memory.length > 80 ? "..." : ""} (score: ${((r.score ?? 0) * 100).toFixed(0)}%)`,
)
.join("\n");
const candidates = results.map((r) => ({
id: r.id,
memory: r.memory,
score: r.score,
}));
return {
content: [
{
type: "text",
text: `Found ${results.length} candidates. Specify memory_id to delete:\n${list}`,
},
],
details: { action: "candidates", candidates },
};
}
// Mode 3: Bulk-delete all memories for a user
if (all) {
if (!confirm) {
return {
content: [
{
type: "text",
text: "Bulk deletion requires confirm: true. Ask the user to confirm before proceeding.",
},
],
details: { error: "confirmation_required" },
};
}
const uid = resolveUserId({ agentId: agent_id, userId: user_id });
await provider!.deleteAll(uid);
api.logger.info(
`openclaw-mem0: deleted all memories for user ${uid}`,
);
return {
content: [
{
type: "text",
text: `All memories deleted for user "${uid}".`,
},
],
details: { action: "deleted_all", user_id: uid },
};
}
// Mode 4: Cascade-delete entity (platform only)
if (entity) {
if (!confirm) {
return {
content: [
{
type: "text",
text: "Entity deletion requires confirm: true. Ask the user to confirm before proceeding.",
},
],
details: { error: "confirmation_required" },
};
}
const entityOpts: { userId?: string; agentId?: string } = {};
if (user_id) entityOpts.userId = user_id;
if (agent_id) entityOpts.agentId = agent_id;
await backend.deleteEntities(entityOpts);
api.logger.info(
`openclaw-mem0: deleted entity (userId=${user_id ?? "default"}, agentId=${agent_id ?? "none"})`,
);
return {
content: [
{
type: "text",
text: `Entity and all associated memories deleted (userId=${user_id ?? "default"}, agentId=${agent_id ?? "none"}).`,
},
],
details: {
action: "entity_deleted",
user_id: user_id ?? "default",
agent_id: agent_id,
},
};
}
// No valid mode specified
return {
content: [
{
type: "text",
text: "Provide memory_id, query, all, or entity to specify what to delete.",
},
],
details: { error: "missing_param" },
};
} catch (err) {
return {
content: [
{
type: "text",
text: `Memory delete failed: ${String(err)}`,
},
],
details: { error: String(err) },
};
}
},
};
}
+57
View File
@@ -0,0 +1,57 @@
/**
* memory_get tool — extracted from index.ts registerTools().
*
* Retrieves a specific memory by its ID from Mem0.
*/
import { Type } from "@sinclair/typebox";
import type { ToolContext } from "./index.ts";
// ---------------------------------------------------------------------------
// Tool factory
// ---------------------------------------------------------------------------
/**
* Creates the `memory_get` tool config object suitable for
* `api.registerTool(config, { name })`.
*/
export function createMemoryGetTool(ctx: ToolContext) {
const { provider } = ctx;
return {
name: "memory_get",
label: "Memory Get",
description: "Retrieve a specific memory by its ID from Mem0.",
parameters: Type.Object({
memoryId: Type.String({ description: "The memory ID to retrieve" }),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const { memoryId } = params as { memoryId: string };
try {
const memory = await provider!.get(memoryId);
return {
content: [
{
type: "text",
text: `Memory ${memory.id}:\n${memory.memory}\n\nCreated: ${memory.created_at ?? "unknown"}\nUpdated: ${memory.updated_at ?? "unknown"}`,
},
],
details: { memory },
};
} catch (err) {
return {
content: [
{
type: "text",
text: `Memory get failed: ${String(err)}`,
},
],
details: { error: String(err) },
};
}
},
};
}
+77
View File
@@ -0,0 +1,77 @@
/**
* memory_history tool — extracted from index.ts registerTools().
*
* Views the edit history of a specific memory, showing all changes
* over time including previous values, new values, and timestamps.
*/
import { Type } from "@sinclair/typebox";
import type { ToolContext } from "./index.ts";
// ---------------------------------------------------------------------------
// Tool factory
// ---------------------------------------------------------------------------
/**
* Creates the `memory_history` tool config object suitable for
* `api.registerTool(config, { name })`.
*/
export function createMemoryHistoryTool(ctx: ToolContext) {
const { provider } = ctx;
return {
name: "memory_history",
label: "Memory History",
description:
"View the edit history of a specific memory. Shows all changes over time including previous values, new values, and timestamps. Useful for understanding how a memory evolved.",
parameters: Type.Object({
memoryId: Type.String({
description: "The memory ID to view history for",
}),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const { memoryId } = params as { memoryId: string };
try {
const history = await provider!.history(memoryId);
if (!history || history.length === 0) {
return {
content: [
{
type: "text",
text: `No history found for memory ${memoryId}.`,
},
],
details: { count: 0 },
};
}
const text = history
.map(
(h, i) =>
`${i + 1}. [${h.event}] ${h.created_at}\n Old: ${h.old_memory || "(none)"}\n New: ${h.new_memory || "(none)"}`,
)
.join("\n\n");
return {
content: [
{
type: "text",
text: `History for memory ${memoryId} (${history.length} entries):\n\n${text}`,
},
],
details: { count: history.length, history },
};
} catch (err) {
return {
content: [
{ type: "text", text: `Memory history failed: ${String(err)}` },
],
details: { error: String(err) },
};
}
},
};
}
+184
View File
@@ -0,0 +1,184 @@
/**
* memory_list tool — extracted from index.ts registerTools().
*
* Lists all stored memories for a user or agent. Supports scope filtering
* (session/long-term/all), deduplication, and CLI-parity parameters for
* pagination, category filtering, date ranges, and graph support.
*/
import { Type } from "@sinclair/typebox";
import type { Mem0Config, Mem0Provider, MemoryItem } from "../types.ts";
import type { ListOptions } from "../types.ts";
import type { ToolContext } from "./index.ts";
// ---------------------------------------------------------------------------
// Tool factory
// ---------------------------------------------------------------------------
/**
* Creates the `memory_list` tool config object suitable for
* `api.registerTool(config, { name })`.
*/
export function createMemoryListTool(ctx: ToolContext) {
const { provider, resolveUserId, getCurrentSessionId } = ctx;
return {
name: "memory_list",
label: "Memory List",
description:
"List all stored memories for a user or agent. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
parameters: Type.Object({
userId: Type.Optional(
Type.String({
description:
"User ID to list memories for (default: configured userId)",
}),
),
agentId: Type.Optional(
Type.String({
description:
'Agent ID to list memories for a specific agent (e.g. "researcher"). Overrides userId.',
}),
),
scope: Type.Optional(
Type.Union(
[
Type.Literal("session"),
Type.Literal("long-term"),
Type.Literal("all"),
],
{
description:
'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"',
},
),
),
// --- NEW CLI-parity parameters ---
page: Type.Optional(
Type.Number({ description: "Page number (default: 1)" }),
),
pageSize: Type.Optional(
Type.Number({ description: "Results per page (default: 100)" }),
),
category: Type.Optional(
Type.String({ description: "Filter by category" }),
),
after: Type.Optional(
Type.String({ description: "Created after date (YYYY-MM-DD)" }),
),
before: Type.Optional(
Type.String({ description: "Created before date (YYYY-MM-DD)" }),
),
enableGraph: Type.Optional(
Type.Boolean({ description: "Enable graph in listing" }),
),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const {
userId,
agentId,
scope = "all",
// New CLI-parity params
page,
pageSize,
category,
after,
before,
enableGraph,
} = params as {
userId?: string;
agentId?: string;
scope?: "session" | "long-term" | "all";
page?: number;
pageSize?: number;
category?: string;
after?: string;
before?: string;
enableGraph?: boolean;
};
try {
let memories: MemoryItem[] = [];
const uid = resolveUserId({ agentId, userId });
const currentSessionId = getCurrentSessionId();
// Build base options for getAll, incorporating new CLI-parity params
const buildGetAllOpts = (extra?: { run_id?: string }): ListOptions => {
const opts: Record<string, unknown> = {
user_id: uid,
source: "OPENCLAW",
};
if (extra?.run_id) opts.run_id = extra.run_id;
if (pageSize !== undefined) opts.page_size = pageSize;
if (page !== undefined) opts.page = page;
if (category !== undefined) opts.category = category;
if (after !== undefined) opts.after = after;
if (before !== undefined) opts.before = before;
if (enableGraph !== undefined) opts.enable_graph = enableGraph;
return opts as unknown as ListOptions;
};
if (scope === "session") {
if (currentSessionId) {
memories = await provider!.getAll(
buildGetAllOpts({ run_id: currentSessionId }),
);
}
} else if (scope === "long-term") {
memories = await provider!.getAll(buildGetAllOpts());
} else {
// "all" — combine both scopes
const longTerm = await provider!.getAll(buildGetAllOpts());
let session: MemoryItem[] = [];
if (currentSessionId) {
session = await provider!.getAll(
buildGetAllOpts({ run_id: currentSessionId }),
);
}
const seen = new Set(longTerm.map((r) => r.id));
memories = [...longTerm, ...session.filter((r) => !seen.has(r.id))];
}
if (!memories || memories.length === 0) {
return {
content: [{ type: "text", text: "No memories stored yet." }],
details: { count: 0 },
};
}
const text = memories
.map((r, i) => `${i + 1}. ${r.memory} (id: ${r.id})`)
.join("\n");
const sanitized = memories.map((r) => ({
id: r.id,
memory: r.memory,
categories: r.categories,
created_at: r.created_at,
}));
return {
content: [
{
type: "text",
text: `${memories.length} memories:\n\n${text}`,
},
],
details: { count: memories.length, memories: sanitized },
};
} catch (err) {
return {
content: [
{
type: "text",
text: `Memory list failed: ${String(err)}`,
},
],
details: { error: String(err) },
};
}
},
};
}
+244
View File
@@ -0,0 +1,244 @@
/**
* memory_search tool — extracted from index.ts registerTools().
*
* Searches long-term and session-scoped memories stored in Mem0.
* Supports scope filtering (session/long-term/all), categories,
* advanced filters, and deduplication.
*/
import { Type } from "@sinclair/typebox";
import type {
Mem0Config,
Mem0Provider,
MemoryItem,
SearchOptions,
} from "../types.ts";
import type { ToolContext } from "./index.ts";
// ---------------------------------------------------------------------------
// Tool factory
// ---------------------------------------------------------------------------
/**
* Creates the `memory_search` tool config object suitable for
* `api.registerTool(config, { name })`.
*/
export function createMemorySearchTool(ctx: ToolContext) {
const {
cfg,
provider,
resolveUserId,
getCurrentSessionId,
buildSearchOptions,
} = ctx;
return {
name: "memory_search",
label: "Memory Search",
description:
"Search through long-term memories stored in Mem0. Use when you need context about user preferences, past decisions, or previously discussed topics.",
parameters: Type.Object({
query: Type.String({ description: "Search query" }),
limit: Type.Optional(
Type.Number({
description: `Max results (default: ${cfg.topK})`,
}),
),
userId: Type.Optional(
Type.String({
description: "User ID to scope search (default: configured userId)",
}),
),
agentId: Type.Optional(
Type.String({
description:
'Agent ID to search memories for a specific agent (e.g. "researcher"). Overrides userId.',
}),
),
scope: Type.Optional(
Type.Union(
[
Type.Literal("session"),
Type.Literal("long-term"),
Type.Literal("all"),
],
{
description:
'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"',
},
),
),
categories: Type.Optional(
Type.Array(Type.String(), {
description:
'Filter results by category (e.g. ["identity", "preference"]). Only returns memories tagged with these categories.',
}),
),
filters: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description:
'Advanced filters object. Supports date ranges and metadata filtering. Examples: {"created_at": {"gte": "2026-03-01"}} for recent memories, {"AND": [{"categories": {"contains": "decision"}}, {"created_at": {"gte": "2026-01-01"}}]} for decisions this year. Operators: eq, ne, gt, gte, lt, lte, in, contains, icontains. Logical: AND, OR, NOT.',
}),
),
// --- NEW CLI-parity parameters ---
rerank: Type.Optional(
Type.Boolean({ description: "Enable reranking (platform only)" }),
),
keyword: Type.Optional(
Type.Boolean({ description: "Use keyword search instead of semantic" }),
),
threshold: Type.Optional(
Type.Number({ description: "Minimum similarity score (0-1)" }),
),
topK: Type.Optional(
Type.Number({ description: "Maximum number of results" }),
),
fields: Type.Optional(
Type.Array(Type.String(), {
description: "Specific fields to return",
}),
),
enableGraph: Type.Optional(
Type.Boolean({ description: "Enable graph memory in search" }),
),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const {
query,
limit,
userId,
agentId,
scope = "all",
categories: filterCategories,
filters: agentFilters,
// New CLI-parity params
rerank,
keyword,
threshold,
topK,
fields,
enableGraph,
} = params as {
query: string;
limit?: number;
userId?: string;
agentId?: string;
scope?: "session" | "long-term" | "all";
categories?: string[];
filters?: Record<string, unknown>;
rerank?: boolean;
keyword?: boolean;
threshold?: number;
topK?: number;
fields?: string[];
enableGraph?: boolean;
};
try {
let results: MemoryItem[] = [];
const uid = resolveUserId({ agentId, userId });
const currentSessionId = getCurrentSessionId();
// Apply agent-provided filters and new CLI-parity params to search options
const applyFilters = (opts: SearchOptions): SearchOptions => {
if (filterCategories?.length) opts.categories = filterCategories;
if (agentFilters) opts.filters = agentFilters;
// Pass through new CLI-parity parameters
if (rerank !== undefined) opts.reranking = rerank;
if (keyword !== undefined) opts.keyword_search = keyword;
if (threshold !== undefined) opts.threshold = threshold;
if (topK !== undefined) {
opts.top_k = topK;
opts.limit = topK;
}
// Note: 'fields' and 'enableGraph' are not in the legacy SearchOptions
// type from types.ts. They are passed through for forward compatibility
// when the backend supports them.
if (fields !== undefined)
(opts as unknown as Record<string, unknown>).fields = fields;
if (enableGraph !== undefined)
(opts as unknown as Record<string, unknown>).enable_graph =
enableGraph;
return opts;
};
if (scope === "session") {
if (currentSessionId) {
results = await provider!.search(
query,
applyFilters(buildSearchOptions(uid, limit, currentSessionId)),
);
}
} else if (scope === "long-term") {
results = await provider!.search(
query,
applyFilters(buildSearchOptions(uid, limit)),
);
} else {
// "all" -- search both scopes and combine
const longTermResults = await provider!.search(
query,
applyFilters(buildSearchOptions(uid, limit)),
);
let sessionResults: MemoryItem[] = [];
if (currentSessionId) {
sessionResults = await provider!.search(
query,
applyFilters(buildSearchOptions(uid, limit, currentSessionId)),
);
}
// Deduplicate by ID, preferring long-term
const seen = new Set(longTermResults.map((r) => r.id));
results = [
...longTermResults,
...sessionResults.filter((r) => !seen.has(r.id)),
];
}
if (!results || results.length === 0) {
return {
content: [{ type: "text", text: "No relevant memories found." }],
details: { count: 0 },
};
}
const text = results
.map(
(r, i) =>
`${i + 1}. ${r.memory} (score: ${((r.score ?? 0) * 100).toFixed(0)}%, id: ${r.id})`,
)
.join("\n");
const sanitized = results.map((r) => ({
id: r.id,
memory: r.memory,
score: r.score,
categories: r.categories,
created_at: r.created_at,
}));
return {
content: [
{
type: "text",
text: `Found ${results.length} memories:\n\n${text}`,
},
],
details: { count: results.length, memories: sanitized },
};
} catch (err) {
return {
content: [
{
type: "text",
text: `Memory search failed: ${String(err)}`,
},
],
details: { error: String(err) },
};
}
},
};
}
+339
View File
@@ -0,0 +1,339 @@
/**
* memory_store tool — extracted from index.ts registerTools().
*
* Saves important information in long-term memory via Mem0.
* Supports skills mode (direct storage with infer=false) and
* legacy mode (mem0 extraction LLM handles deduplication).
*/
import { Type } from "@sinclair/typebox";
import type {
Mem0Config,
Mem0Provider,
AddOptions,
SearchOptions,
} from "../types.ts";
import { isSubagentSession } from "../isolation.ts";
import { resolveCategories, ttlToExpirationDate } from "../skill-loader.ts";
import type { ToolContext } from "./index.ts";
// ---------------------------------------------------------------------------
// Tool factory
// ---------------------------------------------------------------------------
/**
* Creates the `memory_store` tool config object suitable for
* `api.registerTool(config, { name })`.
*/
export function createMemoryStoreTool(ctx: ToolContext) {
const {
api,
cfg,
provider,
resolveUserId,
getCurrentSessionId,
buildAddOptions,
buildSearchOptions,
skillsActive,
} = ctx;
return {
name: "memory_store",
label: "Memory Store",
description:
"Save important information in long-term memory via Mem0. Use for preferences, facts, decisions, and anything worth remembering.",
parameters: Type.Object({
text: Type.Optional(
Type.String({
description:
"Single fact to remember. Use 'facts' array instead when storing multiple facts from one conversation turn.",
}),
),
facts: Type.Optional(
Type.Array(Type.String(), {
description:
"Array of facts to store in one call. ALL facts MUST share the same category. If a turn has facts in different categories, make one call per category. Category determines retention policy (TTL, immutability).",
}),
),
category: Type.Optional(
Type.String({
description:
'Memory category. Determines retention policy (TTL, immutability). All facts in this call inherit this category. Options: "identity", "preference", "decision", "rule", "project", "configuration", "technical", "relationship"',
}),
),
importance: Type.Optional(
Type.Number({
description:
"Importance override (0.0-1.0). Omit to use category default. Applies to all facts in this call. Defaults: identity/config 0.95, rules 0.90, preferences 0.85, decisions 0.80, projects 0.75, operational 0.60",
}),
),
userId: Type.Optional(
Type.String({
description: "User ID to scope this memory",
}),
),
agentId: Type.Optional(
Type.String({
description:
'Agent ID to store memory under a specific agent\'s namespace (e.g. "researcher"). Overrides userId.',
}),
),
metadata: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description: "Additional metadata to attach to this memory",
}),
),
longTerm: Type.Optional(
Type.Boolean({
description:
"Store as long-term (user-scoped) memory. Default: true. Set to false for session-scoped memory.",
}),
),
// --- NEW CLI-parity parameters ---
immutable: Type.Optional(
Type.Boolean({
description: "Prevent future updates to this memory",
}),
),
infer: Type.Optional(
Type.Boolean({
description: "Set to false to skip inference and store raw",
}),
),
expires: Type.Optional(
Type.String({
description: "Expiration date (YYYY-MM-DD)",
}),
),
enableGraph: Type.Optional(
Type.Boolean({
description: "Enable graph memory extraction",
}),
),
categories: Type.Optional(
Type.Array(Type.String(), {
description: "Categories for this memory",
}),
),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const p = params as {
text?: string;
facts?: string[];
category?: string;
importance?: number;
userId?: string;
agentId?: string;
metadata?: Record<string, unknown>;
longTerm?: boolean;
// New CLI-parity params
immutable?: boolean;
infer?: boolean;
expires?: string;
enableGraph?: boolean;
categories?: string[];
};
const { userId, agentId, longTerm = true } = p;
// Resolve facts: prefer 'facts' array, fall back to single 'text'
const allFacts: string[] = p.facts?.length
? p.facts
: p.text
? [p.text]
: [];
if (allFacts.length === 0) {
return {
content: [
{
type: "text",
text: "No facts provided. Pass 'text' or 'facts' array.",
},
],
details: { error: "missing_facts" },
};
}
try {
const currentSessionId = getCurrentSessionId();
// Block subagent writes at the tool level. The system prompt
// instructs subagents not to store, but a disobedient tool call
// would write to a transient namespace that is never read again.
if (isSubagentSession(currentSessionId)) {
api.logger.warn(
"openclaw-mem0: blocked memory_store from subagent session",
);
return {
content: [
{
type: "text",
text: "Memory storage is not available in subagent sessions. The main agent handles memory.",
},
],
details: { error: "subagent_blocked" },
};
}
const uid = resolveUserId({ agentId, userId });
const runId =
!longTerm && currentSessionId ? currentSessionId : undefined;
// Skills mode: bypass extraction LLM, store directly via infer=false
if (skillsActive) {
// Enforce batch homogeneity: if no category provided for a multi-fact
// batch, warn. The prompt teaches batch-by-category but this is the
// runtime safety net.
if (allFacts.length > 1 && !p.category) {
api.logger.warn(
`openclaw-mem0: multi-fact batch (${allFacts.length} facts) without category. Retention policy defaults to uncategorized. Prompt instructs batch-by-category.`,
);
}
// Resolve metadata: prefer explicit params, fall back to metadata record
const rawMetadata = p.metadata;
const category =
p.category ?? (rawMetadata?.category as string | undefined);
const importance =
p.importance ?? (rawMetadata?.importance as number | undefined);
const parsedMetadata: Record<string, unknown> = {
...(rawMetadata ?? {}),
...(category && { category }),
...(importance !== undefined && { importance }),
};
const categories = resolveCategories(cfg.skills);
const catConfig = category ? categories[category] : undefined;
const expirationDate = catConfig
? ttlToExpirationDate(catConfig.ttl)
: undefined;
const isImmutable = catConfig?.immutable ?? false;
// Single API call: all facts go as deduced_memories array
const addOpts: AddOptions = {
user_id: uid,
source: "OPENCLAW",
infer: false,
deduced_memories: allFacts,
metadata: parsedMetadata ?? {},
...(expirationDate && { expiration_date: expirationDate }),
...(isImmutable && { immutable: true }),
};
if (runId) addOpts.run_id = runId;
if (cfg.mode === "platform") {
addOpts.output_format = "v1.1";
if (cfg.enableGraph || cfg.skills?.triage?.enableGraph) {
addOpts.enable_graph = true;
}
}
// Apply new CLI-parity params (override category-derived values)
if (p.immutable !== undefined) addOpts.immutable = p.immutable;
if (p.infer !== undefined) addOpts.infer = p.infer;
if (p.expires !== undefined) addOpts.expiration_date = p.expires;
if (p.enableGraph !== undefined) addOpts.enable_graph = p.enableGraph;
if (p.categories !== undefined)
(addOpts as unknown as Record<string, unknown>).categories =
p.categories;
const result = await provider!.add(
[{ role: "user", content: allFacts.join("\n") }],
addOpts,
);
const count = result.results?.length ?? 0;
api.logger.info(
`openclaw-mem0: skills-mode stored ${count} memor${count === 1 ? "y" : "ies"} from ${allFacts.length} fact(s) in 1 API call (infer=false, category=${category ?? "none"})`,
);
return {
content: [
{
type: "text",
text: `Stored ${allFacts.length} fact(s) [${category ?? "uncategorized"}]: ${allFacts.map((f) => `"${f.slice(0, 60)}${f.length > 60 ? "..." : ""}"`).join(", ")}`,
},
],
details: {
action: "stored",
mode: "skills",
infer: false,
category,
factCount: allFacts.length,
results: result.results,
},
};
}
// Legacy mode: let mem0 extraction LLM handle it
const combinedText = allFacts.join("\n");
// Pre-check for near-duplicates so the extraction model has
// context about existing memories and can UPDATE rather than ADD
const preview = combinedText.slice(0, 200);
const dedupOpts = buildSearchOptions(uid, 3);
dedupOpts.threshold = 0.85;
const existing = await provider!.search(preview, dedupOpts);
if (existing.length > 0) {
api.logger.info(
`openclaw-mem0: found ${existing.length} similar existing memories — mem0 may update instead of add`,
);
}
const legacyAddOpts = buildAddOptions(uid, runId, currentSessionId);
// Apply new CLI-parity params to legacy mode as well
if (p.immutable !== undefined) legacyAddOpts.immutable = p.immutable;
if (p.infer !== undefined) legacyAddOpts.infer = p.infer;
if (p.expires !== undefined) legacyAddOpts.expiration_date = p.expires;
if (p.enableGraph !== undefined)
legacyAddOpts.enable_graph = p.enableGraph;
if (p.categories !== undefined)
(legacyAddOpts as unknown as Record<string, unknown>).categories =
p.categories;
const result = await provider!.add(
[{ role: "user", content: combinedText }],
legacyAddOpts,
);
const added = result.results?.filter((r) => r.event === "ADD") ?? [];
const updated =
result.results?.filter((r) => r.event === "UPDATE") ?? [];
const summary = [];
if (added.length > 0)
summary.push(
`${added.length} new memor${added.length === 1 ? "y" : "ies"} added`,
);
if (updated.length > 0)
summary.push(
`${updated.length} memor${updated.length === 1 ? "y" : "ies"} updated`,
);
if (summary.length === 0) summary.push("No new memories extracted");
return {
content: [
{
type: "text",
text: `Stored: ${summary.join(", ")}. ${result.results?.map((r) => `[${r.event}] ${r.memory}`).join("; ") ?? ""}`,
},
],
details: {
action: "stored",
results: result.results,
},
};
} catch (err) {
return {
content: [
{
type: "text",
text: `Memory store failed: ${String(err)}`,
},
],
details: { error: String(err) },
};
}
},
};
}
+95
View File
@@ -0,0 +1,95 @@
/**
* memory_update tool — extracted from index.ts registerTools().
*
* Updates an existing memory's text in place. Preserves the memory's
* history and supports subagent blocking. Supports optional metadata
* updates for CLI parity.
*/
import { Type } from "@sinclair/typebox";
import { isSubagentSession } from "../isolation.ts";
import type { ToolContext } from "./index.ts";
// ---------------------------------------------------------------------------
// Tool factory
// ---------------------------------------------------------------------------
/**
* Creates the `memory_update` tool config object suitable for
* `api.registerTool(config, { name })`.
*/
export function createMemoryUpdateTool(ctx: ToolContext) {
const { api, provider, getCurrentSessionId } = ctx;
return {
name: "memory_update",
label: "Memory Update",
description:
"Update an existing memory's text in place. Use when a fact has changed and you have the memory ID. This is atomic and preserves the memory's history. Preferred over delete-then-store for corrections.",
parameters: Type.Object({
memoryId: Type.String({ description: "The memory ID to update" }),
text: Type.String({
description: "The new text for this memory (replaces the old text)",
}),
// --- NEW CLI-parity parameter ---
metadata: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description: "Metadata to update (JSON object)",
}),
),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const { memoryId, text, metadata } = params as {
memoryId: string;
text: string;
metadata?: Record<string, unknown>;
};
try {
const currentSessionId = getCurrentSessionId();
if (isSubagentSession(currentSessionId)) {
api.logger.warn(
"openclaw-mem0: blocked memory_update from subagent session",
);
return {
content: [
{
type: "text",
text: "Memory update is not available in subagent sessions.",
},
],
details: { error: "subagent_blocked" },
};
}
await provider!.update(memoryId, text);
// If metadata was provided, note it in the response. The provider's
// update() currently only accepts (id, text). Metadata-only updates
// may need to go through the backend for full support.
let metadataNote = "";
if (metadata && Object.keys(metadata).length > 0) {
metadataNote = `\nNote: metadata was provided but the current provider only supports text updates. Metadata-only updates may require backend support.`;
}
return {
content: [
{
type: "text",
text: `Updated memory ${memoryId}: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"${metadataNote}`,
},
],
details: { action: "updated", id: memoryId, metadata: metadata },
};
} catch (err) {
return {
content: [
{ type: "text", text: `Memory update failed: ${String(err)}` },
],
details: { error: String(err) },
};
}
},
};
}