340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
/**
|
|
* 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) },
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|