fix: adding login and fixing the plugin to follow the openclaw plugin standards (#4686)
This commit is contained in:
@@ -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) },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user