Compare commits

..
47 changed files with 5839 additions and 1411 deletions
+7 -1
View File
@@ -40,4 +40,10 @@ jobs:
run: pnpm run build
- name: Publish to npm
run: npm publish --provenance --access public
run: |
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
npm publish --provenance --access public --tag "$PREID"
else
npm publish --provenance --access public
fi
+7 -1
View File
@@ -40,4 +40,10 @@ jobs:
run: pnpm build
- name: Publish to npm
run: npm publish --provenance --access public
run: |
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
npm publish --provenance --access public --tag "$PREID"
else
npm publish --provenance --access public
fi
+7 -1
View File
@@ -40,4 +40,10 @@ jobs:
run: pnpm run build
- name: Publish to npm
run: npm publish --provenance --access public
run: |
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
npm publish --provenance --access public --tag "$PREID"
else
npm publish --provenance --access public
fi
+7 -1
View File
@@ -40,4 +40,10 @@ jobs:
run: pnpm run build
- name: Publish to npm
run: npm publish --provenance --access public
run: |
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
npm publish --provenance --access public --tag "$PREID"
else
npm publish --provenance --access public
fi
+19 -12
View File
@@ -133,6 +133,24 @@
"pages": [
"platform/contribute"
]
},
{
"group": "Release Notes",
"icon": "rocket",
"pages": [
"changelog"
]
}
]
},
{
"tab": "OpenClaw",
"groups": [
{
"group": "OpenClaw",
"pages": [
"integrations/openclaw"
]
}
]
},
@@ -394,6 +412,7 @@
"integrations/agno",
"integrations/camel-ai",
"integrations/openclaw",
"integrations/hermes",
"integrations/openai-agents-sdk",
"integrations/google-ai-adk",
"integrations/mastra",
@@ -531,18 +550,6 @@
]
}
]
},
{
"tab": "Release Notes",
"groups": [
{
"group": "Changelog",
"icon": "rocket",
"pages": [
"changelog"
]
}
]
}
]
}
+46 -3
View File
@@ -383,23 +383,66 @@ Here are the available integrations for Mem0:
</Card>
<Card
title="Claude Code"
icon="terminal"
icon={
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.92888 16.2181L9.86008 14.0122L9.92585 13.8199L9.86008 13.7137H9.66782L9.01009 13.6732L6.76369 13.6125L4.81581 13.5315L2.92863 13.4303L2.45304 13.3292L2.00781 12.7423L2.05335 12.4488L2.45304 12.1807L3.02476 12.2313L4.28962 12.3173L6.18692 12.4488L7.56309 12.5298L9.60204 12.7423H9.92585L9.97138 12.6107L9.86008 12.5298L9.77407 12.4488L7.811 11.1182L5.68603 9.71165L4.57295 8.90214L3.97088 8.49233L3.66731 8.10781L3.53577 7.26794L4.08219 6.66587L4.81581 6.71646L5.00301 6.76706L5.74674 7.33877L7.33541 8.56822L9.40979 10.0962L9.71335 10.3491L9.83478 10.2631L9.84996 10.2024L9.71335 9.97475L8.5851 7.93579L7.38095 5.86141L6.84465 5.00131L6.70298 4.48524C6.65239 4.27275 6.61697 4.09567 6.61697 3.87811L7.23928 3.03318L7.58332 2.92188L8.41307 3.03318L8.76218 3.33675L9.27824 4.5156L10.113 6.37242L11.4083 8.89708L11.7877 9.64588L11.9901 10.339L12.066 10.5515H12.1975V10.4301L12.3038 9.00839L12.5011 7.26288L12.6934 5.01649L12.7591 4.38406L13.0728 3.62514L13.6951 3.21532L14.1808 3.44806L14.5805 4.01978L14.5249 4.38912L14.2871 5.93225L13.8216 8.35066L13.5181 9.96969H13.6951L13.8975 9.76731L14.7171 8.67953L16.0933 6.95931L16.7005 6.27629L17.4088 5.52243L17.8641 5.16321H18.7242L19.3567 6.10427L19.0733 7.07568L18.1879 8.19888L17.4543 9.15006L16.4019 10.5667L15.7442 11.7L15.8049 11.7911L15.9618 11.7759L18.3397 11.27L19.6248 11.0372L21.1578 10.7741L21.851 11.0979L21.9269 11.4268L21.6537 12.0997L20.0144 12.5045L18.0918 12.889L15.2282 13.567L15.1927 13.5923L15.2332 13.6428L16.5234 13.7643L17.0749 13.7946H18.4257L20.9403 13.9818L21.598 14.4169L21.9926 14.9482L21.9269 15.3529L20.915 15.869L19.5489 15.5452L16.3615 14.7863L15.2686 14.5131H15.1168V14.6041L16.0275 15.4946L17.6972 17.0023L19.7867 18.9451L19.893 19.4258L19.6248 19.8053L19.3415 19.7648L17.5049 18.3835L16.7966 17.7612L15.1927 16.4104H15.0865V16.552L15.4558 17.0934L17.4088 20.0279L17.51 20.9285L17.3683 21.2219L16.8624 21.399L16.3058 21.2978L15.1624 19.6939L13.9835 17.8877L13.0324 16.2687L12.916 16.3345L12.3544 22.3805L12.0913 22.6891L11.4842 22.9219L10.9782 22.5374L10.7101 21.915L10.9782 20.6856L11.302 19.0818L11.5651 17.8068L11.8029 16.2232L11.9446 15.697L11.9345 15.6616L11.8181 15.6767L10.6241 17.316L8.80771 19.7698L7.37083 21.3079L7.02679 21.4445L6.42977 21.1359L6.48542 20.5844L6.81935 20.0936L8.80771 17.5639L10.0068 15.9955L10.7809 15.0898L10.7758 14.9583H10.7303L5.44824 18.3886L4.50718 18.51L4.10242 18.1306L4.15302 17.5083L4.34528 17.3059L5.93394 16.213L5.92888 16.2181Z" fill="currentColor"/>
</svg>
}
href="/integrations/claude-code"
>
Add persistent memory to Claude Code and Cowork with MCP server, lifecycle hooks, and SDK skill.
</Card>
<Card
title="Cursor"
icon="i-cursor"
icon={
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.4216 6.73383L12.4151 2.11135C12.158 1.96288 11.8408 1.96288 11.5837 2.11135L3.57759 6.73383C3.36146 6.85862 3.22803 7.08941 3.22803 7.33937V16.6606C3.22803 16.9106 3.36146 17.1414 3.57759 17.2662L11.5841 21.8886C11.8412 22.0371 12.1584 22.0371 12.4155 21.8886L20.4219 17.2662C20.6381 17.1414 20.7715 16.9106 20.7715 16.6606V7.33937C20.7715 7.08941 20.6377 6.85862 20.4216 6.73383ZM19.9187 7.71298L12.1896 21.1001C12.1373 21.1903 11.9994 21.1534 11.9994 21.0489V12.2832C11.9994 12.1081 11.9058 11.9461 11.7539 11.8581L4.16282 7.47543C4.07261 7.42319 4.10945 7.28524 4.21394 7.28524H19.6721C19.8916 7.28524 20.0284 7.52279 19.9187 7.71298Z" fill="currentColor"/>
</svg>
}
href="/integrations/cursor"
>
Add persistent memory to Cursor with MCP server, lifecycle hooks, and context-aware coding.
</Card>
<Card
title="Codex"
icon="robot"
icon={
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.5565 10.6338C21.0009 9.27575 20.8528 7.76958 20.1367 6.53501C19.0503 4.63378 16.8528 3.67081 14.7046 4.11526C13.7663 3.05353 12.3836 2.46094 10.9515 2.46094C8.75399 2.46094 6.82807 3.86835 6.13671 5.94242C4.7293 6.23872 3.51943 7.10291 2.80338 8.36217C1.71696 10.2634 1.96387 12.6338 3.42066 14.2634C2.97622 15.6461 3.14906 17.1276 3.8651 18.3622C4.95152 20.2634 7.14906 21.2511 9.2972 20.7819C10.2602 21.8437 11.6182 22.4609 13.0503 22.4609C15.2478 22.4609 17.1737 21.0535 17.8651 18.9795C19.2725 18.6832 20.4824 17.819 21.1984 16.5597C22.2849 14.6585 22.0379 12.2634 20.5565 10.6338ZM13.0503 21.1523C12.1614 21.1523 11.3219 20.856 10.6552 20.2881C10.6799 20.2634 10.754 20.2387 10.7787 20.214L14.754 17.9177C14.9515 17.7943 15.075 17.5967 15.075 17.3498V11.7449L16.754 12.7079C16.7787 12.7079 16.7787 12.7325 16.7787 12.7572V17.3992C16.8034 19.4733 15.1244 21.1523 13.0503 21.1523ZM5.00091 17.7202C4.55646 16.9548 4.40831 16.0659 4.55646 15.2017C4.58115 15.2264 4.63054 15.2511 4.67992 15.2758L8.65523 17.572C8.85276 17.6955 9.09967 17.6955 9.2972 17.572L14.1614 14.7572V16.7079C14.1614 16.7325 14.1614 16.7572 14.1367 16.7572L10.112 19.0782C8.33424 20.1153 6.03794 19.498 5.00091 17.7202ZM3.96387 9.02884C4.40831 8.26341 5.09967 7.69551 5.91449 7.37452V12.1153C5.91449 12.3375 6.03794 12.5597 6.23548 12.6832L11.0997 15.498L9.42066 16.4609C9.39597 16.4609 9.37128 16.4856 9.37128 16.4609L5.34659 14.1399C3.51943 13.1029 2.92683 10.8066 3.96387 9.02884ZM17.791 12.2387L12.9268 9.4239L14.6058 8.46094C14.6305 8.46094 14.6552 8.43625 14.6552 8.46094L18.6799 10.7819C20.4824 11.819 21.075 14.1153 20.0379 15.893C19.5935 16.6585 18.9021 17.2264 18.0873 17.5227V12.8066C18.112 12.5844 17.9886 12.3622 17.791 12.2387ZM19.4454 9.7202C19.4207 9.69551 19.3713 9.67081 19.3219 9.64612L15.3466 7.34983C15.1491 7.22637 14.9021 7.22637 14.7046 7.34983L9.84041 10.1646V8.21402C9.84041 8.18933 9.84041 8.16464 9.86511 8.16464L13.8898 5.84365C15.6923 4.80662 17.9639 5.4239 19.0009 7.22637C19.4454 7.96711 19.5935 8.856 19.4454 9.7202ZM8.92683 13.177L7.24782 12.214C7.22313 12.214 7.22313 12.1893 7.22313 12.1646V7.52267C7.22313 5.44859 8.90214 3.76958 10.9762 3.76958C11.8651 3.76958 12.7046 4.06588 13.3713 4.63378C13.3466 4.65847 13.2972 4.68316 13.2478 4.70785L9.27251 7.00415C9.07498 7.1276 8.95152 7.32514 8.95152 7.57205V13.177H8.92683ZM9.84041 11.2017L12.0133 9.94242L14.1861 11.2017V13.6955L12.0133 14.9548L9.84041 13.6955V11.2017Z" fill="currentColor"/>
</svg>
}
href="/integrations/codex"
>
Add persistent memory to OpenAI Codex with MCP server and skill-based memory protocol.
</Card>
<Card
title="OpenClaw"
icon={
<svg width="24" height="24" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="m153.5 173.5q24.62 1.46 46 13.5 12.11 8.1 17.5 21.5 0.74 2.45 0.5 5 0.09 0.81 1 1 1.48-4.9 1-10 5.04 10.48 1.5 22-9.81 27.86-35.5 42.5-26.17 14.97-56 19.5-2.77-0.4-2 1 2.86 1.27 6 1 25.64 1.53 48.5-10 0.34 10.08 2 20 1.08 5.76 5 10 1 1.5 0 3-31.11 20.84-68.5 17.5-23.7-5.7-32.5-28.5-4.39-9.18-3.5-19 15.41 6.23 32 4.5-20.68-6.39-39-18-34.81-27.22-12.5-65.5 11.84-14.83 29-23 4.21 7.66 11.5 12.5 3 1 6 0-26.04-34.62-29-78-0.13-8.46 2-16.5 1 6.5 2 13 3.43 39.53 24.5 73 2.03 2.28 4.5 4 0.5-1.25 1-2.5-1.27-6.54-5-12 0.5-0.75 1-1.5 9.72-3.43 20-4 0.55 10.34 8 17.5 1.94 0.74 4 0.5-17.8-64.6 16.5-122 0.98-1.79 1.5 0-28.21 56.64-13.5 118 1.08 1.43 2.5 0.5 2.21-4.98 2-10.5z" fill="currentColor"/>
<path fill-rule="evenodd" d="m454.5 97.5q-1.33 11.18-8.5 20-21.81 26.28-55.5 32-1.11-0.2-2 0.5 2.31 2.82 5.5 4.5 1 2 0 4-9.56 11.3-19.5 20 19.71-8.72 31-27 2.68-0.43 5 1-14.24 30.97-48 36.5-9.93 1.71-20 1.5-6.8-0.48-13 1 5.81 6.92 14 11-10.78 16.03-27 26.5 27.16-7.4 38-33.5 4.34 1.35 9 1-9.08 23.84-33 33.5-18.45 6.41-38 7 22.59 8.92 45-1 12.05-5.52 24-11 9.01-1.79 17 2.5 5.28-4.38 11-8 12.8-6.07 27-5 0 0.5 0 1-19.34 2.69-34 15.5 0.5 0.25 1 0.5 17.79-8.09 36-15 2.71-0.79 5-2 2.5-1 5-2 5.53-4.04 11-8 11.7-4.18 24-6.5 7.78-1.36 15 1.5-2.97 18.45-13.5 34-34.92 49.37-94.5 62.5-59.27 12.45-108-23-15.53-12.52-21.5-31.5-2.47-14.26 4-27-3.15 24.41 14 42-4.92-10.28-7-22-1.97-17.63 7-33 47.28-69.5 125.5-100 15.86-3.42 32-5.5 18.63-1.47 37 1.5z" fill="currentColor"/>
<path fill-rule="evenodd" d="m231.5 238.5q1.31-0.2 2 1-3.13 28.62 15 51-16.25 6.75-27-7.5-1-1-2 0 14.73 29.34 46 18.5 1.79 0.52 0 1.5-37.63 16.82-50.5-22.5-5.1-26.48 16.5-42z" fill="currentColor"/>
<path fill-rule="evenodd" d="m203.5 266.5q1.31-0.2 2 1-2.48 22.08 12 39-6.99 1.35-14 0.5 4.59 4.08 10 7-8.71 0.28-14.5-6.5-16.98-22.76 4.5-41z" fill="currentColor"/>
<path fill-rule="evenodd" d="m58.5 284.5q9.6-2.17 14.5 6 5.15 14.18-1 28-11.05-13.14-27.5-17.5 5.15-9.9 14-16.5z" fill="currentColor"/>
<path fill-rule="evenodd" d="m56.5 313.5q3.43 5.43 8 10-4.88 0.44-8 4-1.11-0.2-2 0.5 28.91 1.65 38 28.5 0.45 3.16-1 6-11.02-7.01-23-12.5-4.75-3.75-9.5-7.5 1.47 7.42 7 13 8.34 27.18 32 43 0.99 2.41-1.5 3.5-40.25 5.58-66.5-25.5-15.67-22.01-8-48 10.46-23.87 34.5-15z" fill="currentColor"/>
<path fill-rule="evenodd" d="m198.5 319.5q1.44 0.68 2.5 2 2.41 8.23 6 16 1.2 2.64-0.5 5-30.65 21.41-68 18.5-25.16-6.17-32.5-30.5 6.96 4.99 15.5 6.5 8.99 0.75 18 0.5 16.25 2.38 32-2.5 15.9-3.94 27-15.5z" fill="currentColor"/>
<path fill-rule="evenodd" d="m239.5 342.5q7.02-0.25 14 0.5 4.46 1.06 8 3.5-5.2 2.35-10 5.5-3.88 4.65-9 7.5-9.89-3.09-9.5-13 2.36-3.63 6.5-4z" fill="currentColor"/>
<path fill-rule="evenodd" d="m214.5 349.5q5.96 7.2 13.5 13 1 1 0 2-28.58 23.34-65.5 20.5-18.15-4.24-27.5-19.5 1.13 0.94 2.5 1.5 14.7 1.42 29-1.5 26.57-0.52 48-16z" fill="currentColor"/>
<path fill-rule="evenodd" d="m302.5 373.5q0.21 2.44-2 3.5-28.69 7.6-50.5-12.5-0.06-6.71 6.5-9 4.45-0.75 9-1 22.26 2.27 37 19z" fill="currentColor"/>
<path fill-rule="evenodd" d="m232.5 365.5q17.6 6.19 10.5 23-10.6 10.42-25.5 11.5-25.94 3.21-49-9 36.75-1.65 64-25.5z" fill="currentColor"/>
<path fill-rule="evenodd" d="m113.5 367.5q7.7-0.01 9.5 7-9.69 7.19-18.5 15.5-7.23 5.76-5.5-3.5 3.12-12.84 14.5-19z" fill="currentColor"/>
<path fill-rule="evenodd" d="m126.5 380.5q7.88-0.4 12 6.5-8.5 7.25-17 14.5-5.62-12.55 5-21z" fill="currentColor"/>
<path fill-rule="evenodd" d="m283.5 385.5q3.22 2.95 7 5.5 2.8 4.03 6 7.5 0.42 2.77-2 4-15.5-9.75-31-19.5-1.79-0.98 0-1.5 9.96 2.49 20 4z" fill="currentColor"/>
</svg>
}
href="/integrations/openclaw"
>
Add long-term memory to OpenClaw agents with auto-recall and auto-capture support.
</Card>
<Card
title="Hermes Agent"
icon="bolt"
href="/integrations/hermes"
>
Add long-term memory to Hermes agents with automatic background sync and zero-latency prefetch.
</Card>
</CardGroup>
+1 -1
View File
@@ -150,7 +150,7 @@ You: Add refresh token rotation to the auth system.
<Card title="Mem0 MCP Setup" icon="puzzle-piece" href="/platform/mem0-mcp">
Detailed MCP configuration for all clients
</Card>
<Card title="Codex Integration" icon="robot" href="/integrations/codex">
<Card title="Codex Integration" icon={<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20.5565 10.6338C21.0009 9.27575 20.8528 7.76958 20.1367 6.53501C19.0503 4.63378 16.8528 3.67081 14.7046 4.11526C13.7663 3.05353 12.3836 2.46094 10.9515 2.46094C8.75399 2.46094 6.82807 3.86835 6.13671 5.94242C4.7293 6.23872 3.51943 7.10291 2.80338 8.36217C1.71696 10.2634 1.96387 12.6338 3.42066 14.2634C2.97622 15.6461 3.14906 17.1276 3.8651 18.3622C4.95152 20.2634 7.14906 21.2511 9.2972 20.7819C10.2602 21.8437 11.6182 22.4609 13.0503 22.4609C15.2478 22.4609 17.1737 21.0535 17.8651 18.9795C19.2725 18.6832 20.4824 17.819 21.1984 16.5597C22.2849 14.6585 22.0379 12.2634 20.5565 10.6338ZM13.0503 21.1523C12.1614 21.1523 11.3219 20.856 10.6552 20.2881C10.6799 20.2634 10.754 20.2387 10.7787 20.214L14.754 17.9177C14.9515 17.7943 15.075 17.5967 15.075 17.3498V11.7449L16.754 12.7079C16.7787 12.7079 16.7787 12.7325 16.7787 12.7572V17.3992C16.8034 19.4733 15.1244 21.1523 13.0503 21.1523ZM5.00091 17.7202C4.55646 16.9548 4.40831 16.0659 4.55646 15.2017C4.58115 15.2264 4.63054 15.2511 4.67992 15.2758L8.65523 17.572C8.85276 17.6955 9.09967 17.6955 9.2972 17.572L14.1614 14.7572V16.7079C14.1614 16.7325 14.1614 16.7572 14.1367 16.7572L10.112 19.0782C8.33424 20.1153 6.03794 19.498 5.00091 17.7202ZM3.96387 9.02884C4.40831 8.26341 5.09967 7.69551 5.91449 7.37452V12.1153C5.91449 12.3375 6.03794 12.5597 6.23548 12.6832L11.0997 15.498L9.42066 16.4609C9.39597 16.4609 9.37128 16.4856 9.37128 16.4609L5.34659 14.1399C3.51943 13.1029 2.92683 10.8066 3.96387 9.02884ZM17.791 12.2387L12.9268 9.4239L14.6058 8.46094C14.6305 8.46094 14.6552 8.43625 14.6552 8.46094L18.6799 10.7819C20.4824 11.819 21.075 14.1153 20.0379 15.893C19.5935 16.6585 18.9021 17.2264 18.0873 17.5227V12.8066C18.112 12.5844 17.9886 12.3622 17.791 12.2387ZM19.4454 9.7202C19.4207 9.69551 19.3713 9.67081 19.3219 9.64612L15.3466 7.34983C15.1491 7.22637 14.9021 7.22637 14.7046 7.34983L9.84041 10.1646V8.21402C9.84041 8.18933 9.84041 8.16464 9.86511 8.16464L13.8898 5.84365C15.6923 4.80662 17.9639 5.4239 19.0009 7.22637C19.4454 7.96711 19.5935 8.856 19.4454 9.7202ZM8.92683 13.177L7.24782 12.214C7.22313 12.214 7.22313 12.1893 7.22313 12.1646V7.52267C7.22313 5.44859 8.90214 3.76958 10.9762 3.76958C11.8651 3.76958 12.7046 4.06588 13.3713 4.63378C13.3466 4.65847 13.2972 4.68316 13.2478 4.70785L9.27251 7.00415C9.07498 7.1276 8.95152 7.32514 8.95152 7.57205V13.177H8.92683ZM9.84041 11.2017L12.0133 9.94242L14.1861 11.2017V13.6955L12.0133 14.9548L9.84041 13.6955V11.2017Z" fill="currentColor"/></svg>} href="/integrations/codex">
Add Mem0 memory to OpenAI Codex workflows
</Card>
</CardGroup>
+1 -1
View File
@@ -206,7 +206,7 @@ You: Add WebSocket support for real-time notification delivery.
<Card title="Mem0 MCP Setup" icon="puzzle-piece" href="/platform/mem0-mcp">
Detailed MCP configuration for all clients
</Card>
<Card title="Claude Code Integration" icon="terminal" href="/integrations/claude-code">
<Card title="Claude Code Integration" icon={<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.92888 16.2181L9.86008 14.0122L9.92585 13.8199L9.86008 13.7137H9.66782L9.01009 13.6732L6.76369 13.6125L4.81581 13.5315L2.92863 13.4303L2.45304 13.3292L2.00781 12.7423L2.05335 12.4488L2.45304 12.1807L3.02476 12.2313L4.28962 12.3173L6.18692 12.4488L7.56309 12.5298L9.60204 12.7423H9.92585L9.97138 12.6107L9.86008 12.5298L9.77407 12.4488L7.811 11.1182L5.68603 9.71165L4.57295 8.90214L3.97088 8.49233L3.66731 8.10781L3.53577 7.26794L4.08219 6.66587L4.81581 6.71646L5.00301 6.76706L5.74674 7.33877L7.33541 8.56822L9.40979 10.0962L9.71335 10.3491L9.83478 10.2631L9.84996 10.2024L9.71335 9.97475L8.5851 7.93579L7.38095 5.86141L6.84465 5.00131L6.70298 4.48524C6.65239 4.27275 6.61697 4.09567 6.61697 3.87811L7.23928 3.03318L7.58332 2.92188L8.41307 3.03318L8.76218 3.33675L9.27824 4.5156L10.113 6.37242L11.4083 8.89708L11.7877 9.64588L11.9901 10.339L12.066 10.5515H12.1975V10.4301L12.3038 9.00839L12.5011 7.26288L12.6934 5.01649L12.7591 4.38406L13.0728 3.62514L13.6951 3.21532L14.1808 3.44806L14.5805 4.01978L14.5249 4.38912L14.2871 5.93225L13.8216 8.35066L13.5181 9.96969H13.6951L13.8975 9.76731L14.7171 8.67953L16.0933 6.95931L16.7005 6.27629L17.4088 5.52243L17.8641 5.16321H18.7242L19.3567 6.10427L19.0733 7.07568L18.1879 8.19888L17.4543 9.15006L16.4019 10.5667L15.7442 11.7L15.8049 11.7911L15.9618 11.7759L18.3397 11.27L19.6248 11.0372L21.1578 10.7741L21.851 11.0979L21.9269 11.4268L21.6537 12.0997L20.0144 12.5045L18.0918 12.889L15.2282 13.567L15.1927 13.5923L15.2332 13.6428L16.5234 13.7643L17.0749 13.7946H18.4257L20.9403 13.9818L21.598 14.4169L21.9926 14.9482L21.9269 15.3529L20.915 15.869L19.5489 15.5452L16.3615 14.7863L15.2686 14.5131H15.1168V14.6041L16.0275 15.4946L17.6972 17.0023L19.7867 18.9451L19.893 19.4258L19.6248 19.8053L19.3415 19.7648L17.5049 18.3835L16.7966 17.7612L15.1927 16.4104H15.0865V16.552L15.4558 17.0934L17.4088 20.0279L17.51 20.9285L17.3683 21.2219L16.8624 21.399L16.3058 21.2978L15.1624 19.6939L13.9835 17.8877L13.0324 16.2687L12.916 16.3345L12.3544 22.3805L12.0913 22.6891L11.4842 22.9219L10.9782 22.5374L10.7101 21.915L10.9782 20.6856L11.302 19.0818L11.5651 17.8068L11.8029 16.2232L11.9446 15.697L11.9345 15.6616L11.8181 15.6767L10.6241 17.316L8.80771 19.7698L7.37083 21.3079L7.02679 21.4445L6.42977 21.1359L6.48542 20.5844L6.81935 20.0936L8.80771 17.5639L10.0068 15.9955L10.7809 15.0898L10.7758 14.9583H10.7303L5.44824 18.3886L4.50718 18.51L4.10242 18.1306L4.15302 17.5083L4.34528 17.3059L5.93394 16.213L5.92888 16.2181Z" fill="currentColor"/></svg>} href="/integrations/claude-code">
Add Mem0 memory to Claude Code workflows
</Card>
</CardGroup>
+1 -1
View File
@@ -142,7 +142,7 @@ You: The /orders endpoint is also slow, same pattern as before.
<Card title="Mem0 MCP Setup" icon="puzzle-piece" href="/platform/mem0-mcp">
Detailed MCP configuration for all clients
</Card>
<Card title="Claude Code Integration" icon="terminal" href="/integrations/claude-code">
<Card title="Claude Code Integration" icon={<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.92888 16.2181L9.86008 14.0122L9.92585 13.8199L9.86008 13.7137H9.66782L9.01009 13.6732L6.76369 13.6125L4.81581 13.5315L2.92863 13.4303L2.45304 13.3292L2.00781 12.7423L2.05335 12.4488L2.45304 12.1807L3.02476 12.2313L4.28962 12.3173L6.18692 12.4488L7.56309 12.5298L9.60204 12.7423H9.92585L9.97138 12.6107L9.86008 12.5298L9.77407 12.4488L7.811 11.1182L5.68603 9.71165L4.57295 8.90214L3.97088 8.49233L3.66731 8.10781L3.53577 7.26794L4.08219 6.66587L4.81581 6.71646L5.00301 6.76706L5.74674 7.33877L7.33541 8.56822L9.40979 10.0962L9.71335 10.3491L9.83478 10.2631L9.84996 10.2024L9.71335 9.97475L8.5851 7.93579L7.38095 5.86141L6.84465 5.00131L6.70298 4.48524C6.65239 4.27275 6.61697 4.09567 6.61697 3.87811L7.23928 3.03318L7.58332 2.92188L8.41307 3.03318L8.76218 3.33675L9.27824 4.5156L10.113 6.37242L11.4083 8.89708L11.7877 9.64588L11.9901 10.339L12.066 10.5515H12.1975V10.4301L12.3038 9.00839L12.5011 7.26288L12.6934 5.01649L12.7591 4.38406L13.0728 3.62514L13.6951 3.21532L14.1808 3.44806L14.5805 4.01978L14.5249 4.38912L14.2871 5.93225L13.8216 8.35066L13.5181 9.96969H13.6951L13.8975 9.76731L14.7171 8.67953L16.0933 6.95931L16.7005 6.27629L17.4088 5.52243L17.8641 5.16321H18.7242L19.3567 6.10427L19.0733 7.07568L18.1879 8.19888L17.4543 9.15006L16.4019 10.5667L15.7442 11.7L15.8049 11.7911L15.9618 11.7759L18.3397 11.27L19.6248 11.0372L21.1578 10.7741L21.851 11.0979L21.9269 11.4268L21.6537 12.0997L20.0144 12.5045L18.0918 12.889L15.2282 13.567L15.1927 13.5923L15.2332 13.6428L16.5234 13.7643L17.0749 13.7946H18.4257L20.9403 13.9818L21.598 14.4169L21.9926 14.9482L21.9269 15.3529L20.915 15.869L19.5489 15.5452L16.3615 14.7863L15.2686 14.5131H15.1168V14.6041L16.0275 15.4946L17.6972 17.0023L19.7867 18.9451L19.893 19.4258L19.6248 19.8053L19.3415 19.7648L17.5049 18.3835L16.7966 17.7612L15.1927 16.4104H15.0865V16.552L15.4558 17.0934L17.4088 20.0279L17.51 20.9285L17.3683 21.2219L16.8624 21.399L16.3058 21.2978L15.1624 19.6939L13.9835 17.8877L13.0324 16.2687L12.916 16.3345L12.3544 22.3805L12.0913 22.6891L11.4842 22.9219L10.9782 22.5374L10.7101 21.915L10.9782 20.6856L11.302 19.0818L11.5651 17.8068L11.8029 16.2232L11.9446 15.697L11.9345 15.6616L11.8181 15.6767L10.6241 17.316L8.80771 19.7698L7.37083 21.3079L7.02679 21.4445L6.42977 21.1359L6.48542 20.5844L6.81935 20.0936L8.80771 17.5639L10.0068 15.9955L10.7809 15.0898L10.7758 14.9583H10.7303L5.44824 18.3886L4.50718 18.51L4.10242 18.1306L4.15302 17.5083L4.34528 17.3059L5.93394 16.213L5.92888 16.2181Z" fill="currentColor"/></svg>} href="/integrations/claude-code">
Add Mem0 memory to Claude Code workflows
</Card>
</CardGroup>
+106
View File
@@ -0,0 +1,106 @@
---
title: Hermes Agent
description: "Add long-term memory to Hermes agents using Mem0 as a pluggable memory provider with automatic background sync and zero-latency prefetch."
---
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent) — a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 automatically learns facts from your conversations and surfaces relevant ones before each turn — all without slowing down the chat.
## Overview
Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alongside one external provider. When Mem0 is active, it works additively with the built-in system at three key moments in every conversation turn:
### 1. Before the Agent Responds (Prefetch)
When you send a message, Hermes checks if it already has cached Mem0 search results from the previous turn. If so, those memories are injected into the system prompt so the LLM can see them. This is **zero-latency** — no waiting for an API call.
### 2. After the Agent Responds (Sync)
Once the LLM finishes responding, Hermes sends the `(user message, assistant response)` pair to Mem0's API in a **background thread**. Mem0's server-side LLM automatically extracts facts (e.g., "user prefers Python", "user works at Acme Corp") — you don't have to tell it what to remember.
### 3. Background Prefetch for Next Turn
At the same time as sync, Hermes kicks off a background search on Mem0 to pre-load relevant memories for the next turn. By the time you type your next message, the memories are already cached.
## Agent Tools
When Mem0 is active, the LLM gets three extra tools it can call during conversations:
| Tool | Description |
|------|-------------|
| `mem0_profile` | Fetch all stored memories about the user |
| `mem0_search` | Semantic search through memories (supports optional reranking via `rerank` and `top_k` parameters) |
| `mem0_conclude` | Store a specific fact verbatim — uses `infer=False` so no server-side LLM extraction happens |
## Installation
Install Hermes Agent:
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
source ~/.bashrc
```
The `mem0ai` Python package is automatically installed when you enable the Mem0 provider — no manual pip install needed.
## Setup
### Option 1: Interactive Setup Wizard (Recommended)
```bash
hermes memory setup
```
Select **mem0** as the provider and enter your Mem0 API key when prompted. The wizard writes your config to `~/.hermes/mem0.json`.
<Note>Get your API key from [app.mem0.ai](https://app.mem0.ai).</Note>
### Option 2: Manual Configuration
```bash
hermes config set memory.provider mem0
echo "MEM0_API_KEY=your-api-key" >> ~/.hermes/.env
```
Then in your `config.yaml`:
```yaml
memory:
provider: mem0
```
That's it — Mem0 runs automatically from this point.
## Configuration Options
Configuration is stored in `~/.hermes/mem0.json`. Values can also be set via environment variables.
| Key | Env Variable | Default | Description |
|-----|-------------|---------|-------------|
| `api_key` | `MEM0_API_KEY` | — | **Required.** Mem0 Platform API key |
| `user_id` | `MEM0_USER_ID` | `hermes-user` | User identifier for scoping memories |
| `agent_id` | `MEM0_AGENT_ID` | `hermes` | Agent identifier |
| `rerank` | — | `true` | Enable reranking for memory recall |
## Reliability
- **Circuit Breaker** — If Mem0's API fails 5 times in a row, Hermes stops calling it for 2 minutes, then retries. The agent keeps working fine without memory during that time.
- **Non-blocking** — All Mem0 API calls happen in background daemon threads. A slow or failed API call never blocks your conversation.
- **Thread-safe** — The Mem0 client uses lazy initialization with locking, safe for concurrent access.
## Key Features
1. **Zero-Latency Recall** — Memories are prefetched in the background and cached, ready before you type
2. **Server-side Extraction** — Mem0's API automatically extracts and deduplicates facts from each exchange
3. **Non-blocking** — All API calls run in background daemon threads
4. **Fault Tolerant** — Circuit breaker ensures the agent works even if Mem0 is temporarily unreachable
5. **Additive Memory** — Works alongside Hermes' built-in file-based memory system (MEMORY.md, USER.md)
<CardGroup cols={2}>
<Card title="OpenClaw Integration" icon={<svg width="24" height="24" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="m153.5 173.5q24.62 1.46 46 13.5 12.11 8.1 17.5 21.5 0.74 2.45 0.5 5 0.09 0.81 1 1 1.48-4.9 1-10 5.04 10.48 1.5 22-9.81 27.86-35.5 42.5-26.17 14.97-56 19.5-2.77-0.4-2 1 2.86 1.27 6 1 25.64 1.53 48.5-10 0.34 10.08 2 20 1.08 5.76 5 10 1 1.5 0 3-31.11 20.84-68.5 17.5-23.7-5.7-32.5-28.5-4.39-9.18-3.5-19 15.41 6.23 32 4.5-20.68-6.39-39-18-34.81-27.22-12.5-65.5 11.84-14.83 29-23 4.21 7.66 11.5 12.5 3 1 6 0-26.04-34.62-29-78-0.13-8.46 2-16.5 1 6.5 2 13 3.43 39.53 24.5 73 2.03 2.28 4.5 4 0.5-1.25 1-2.5-1.27-6.54-5-12 0.5-0.75 1-1.5 9.72-3.43 20-4 0.55 10.34 8 17.5 1.94 0.74 4 0.5-17.8-64.6 16.5-122 0.98-1.79 1.5 0-28.21 56.64-13.5 118 1.08 1.43 2.5 0.5 2.21-4.98 2-10.5z" fill="currentColor"/><path fill-rule="evenodd" d="m454.5 97.5q-1.33 11.18-8.5 20-21.81 26.28-55.5 32-1.11-0.2-2 0.5 2.31 2.82 5.5 4.5 1 2 0 4-9.56 11.3-19.5 20 19.71-8.72 31-27 2.68-0.43 5 1-14.24 30.97-48 36.5-9.93 1.71-20 1.5-6.8-0.48-13 1 5.81 6.92 14 11-10.78 16.03-27 26.5 27.16-7.4 38-33.5 4.34 1.35 9 1-9.08 23.84-33 33.5-18.45 6.41-38 7 22.59 8.92 45-1 12.05-5.52 24-11 9.01-1.79 17 2.5 5.28-4.38 11-8 12.8-6.07 27-5 0 0.5 0 1-19.34 2.69-34 15.5 0.5 0.25 1 0.5 17.79-8.09 36-15 2.71-0.79 5-2 2.5-1 5-2 5.53-4.04 11-8 11.7-4.18 24-6.5 7.78-1.36 15 1.5-2.97 18.45-13.5 34-34.92 49.37-94.5 62.5-59.27 12.45-108-23-15.53-12.52-21.5-31.5-2.47-14.26 4-27-3.15 24.41 14 42-4.92-10.28-7-22-1.97-17.63 7-33 47.28-69.5 125.5-100 15.86-3.42 32-5.5 18.63-1.47 37 1.5z" fill="currentColor"/><path fill-rule="evenodd" d="m231.5 238.5q1.31-0.2 2 1-3.13 28.62 15 51-16.25 6.75-27-7.5-1-1-2 0 14.73 29.34 46 18.5 1.79 0.52 0 1.5-37.63 16.82-50.5-22.5-5.1-26.48 16.5-42z" fill="currentColor"/><path fill-rule="evenodd" d="m203.5 266.5q1.31-0.2 2 1-2.48 22.08 12 39-6.99 1.35-14 0.5 4.59 4.08 10 7-8.71 0.28-14.5-6.5-16.98-22.76 4.5-41z" fill="currentColor"/><path fill-rule="evenodd" d="m58.5 284.5q9.6-2.17 14.5 6 5.15 14.18-1 28-11.05-13.14-27.5-17.5 5.15-9.9 14-16.5z" fill="currentColor"/><path fill-rule="evenodd" d="m56.5 313.5q3.43 5.43 8 10-4.88 0.44-8 4-1.11-0.2-2 0.5 28.91 1.65 38 28.5 0.45 3.16-1 6-11.02-7.01-23-12.5-4.75-3.75-9.5-7.5 1.47 7.42 7 13 8.34 27.18 32 43 0.99 2.41-1.5 3.5-40.25 5.58-66.5-25.5-15.67-22.01-8-48 10.46-23.87 34.5-15z" fill="currentColor"/><path fill-rule="evenodd" d="m198.5 319.5q1.44 0.68 2.5 2 2.41 8.23 6 16 1.2 2.64-0.5 5-30.65 21.41-68 18.5-25.16-6.17-32.5-30.5 6.96 4.99 15.5 6.5 8.99 0.75 18 0.5 16.25 2.38 32-2.5 15.9-3.94 27-15.5z" fill="currentColor"/><path fill-rule="evenodd" d="m239.5 342.5q7.02-0.25 14 0.5 4.46 1.06 8 3.5-5.2 2.35-10 5.5-3.88 4.65-9 7.5-9.89-3.09-9.5-13 2.36-3.63 6.5-4z" fill="currentColor"/><path fill-rule="evenodd" d="m214.5 349.5q5.96 7.2 13.5 13 1 1 0 2-28.58 23.34-65.5 20.5-18.15-4.24-27.5-19.5 1.13 0.94 2.5 1.5 14.7 1.42 29-1.5 26.57-0.52 48-16z" fill="currentColor"/><path fill-rule="evenodd" d="m302.5 373.5q0.21 2.44-2 3.5-28.69 7.6-50.5-12.5-0.06-6.71 6.5-9 4.45-0.75 9-1 22.26 2.27 37 19z" fill="currentColor"/><path fill-rule="evenodd" d="m232.5 365.5q17.6 6.19 10.5 23-10.6 10.42-25.5 11.5-25.94 3.21-49-9 36.75-1.65 64-25.5z" fill="currentColor"/><path fill-rule="evenodd" d="m113.5 367.5q7.7-0.01 9.5 7-9.69 7.19-18.5 15.5-7.23 5.76-5.5-3.5 3.12-12.84 14.5-19z" fill="currentColor"/><path fill-rule="evenodd" d="m126.5 380.5q7.88-0.4 12 6.5-8.5 7.25-17 14.5-5.62-12.55 5-21z" fill="currentColor"/><path fill-rule="evenodd" d="m283.5 385.5q3.22 2.95 7 5.5 2.8 4.03 6 7.5 0.42 2.77-2 4-15.5-9.75-31-19.5-1.79-0.98 0-1.5 9.96 2.49 20 4z" fill="currentColor"/></svg>} href="/integrations/openclaw">
Add memory to OpenClaw agents with auto-recall and auto-capture
</Card>
<Card title="Mem0 Platform" icon="rocket" href="/platform/overview">
Get your API key and explore the Mem0 dashboard
</Card>
</CardGroup>
+29
View File
@@ -304,6 +304,23 @@ class Memory(MemoryBase):
)
capture_event("mem0.init", self, {"sync_type": "sync"})
def close(self):
"""Release resources held by this Memory instance (SQLite connections, etc.).
The global telemetry singleton is intentionally *not* shut down here
because it is shared across all Memory instances in the process. It is
cleaned up automatically at process exit via an ``atexit`` handler.
"""
if hasattr(self, "db") and self.db is not None:
self.db.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
@classmethod
def from_config(cls, config_dict: Dict[str, Any]):
try:
@@ -1425,6 +1442,18 @@ class AsyncMemory(MemoryBase):
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
capture_event("mem0.init", self, {"sync_type": "async"})
def close(self):
"""Release resources held by this AsyncMemory instance."""
if hasattr(self, "db") and self.db is not None:
self.db.close()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
@classmethod
def from_config(cls, config_dict: Dict[str, Any]):
try:
+49 -5
View File
@@ -1,7 +1,9 @@
import atexit
import logging
import os
import platform
import sys
import threading
from posthog import Posthog
@@ -55,20 +57,62 @@ class AnonymousTelemetry:
def close(self):
if self.posthog is not None:
self.posthog.shutdown()
self.posthog = None
# Thread-safe lazy singleton for OSS telemetry.
# A single AnonymousTelemetry instance (and its underlying PostHog client /
# background thread) is reused for the lifetime of the process instead of
# creating a new one on every capture_event() call. The singleton is shut down
# once at process exit via an atexit handler.
_oss_telemetry_instance = None
_oss_telemetry_lock = threading.Lock()
_oss_telemetry_shutting_down = False
def _get_oss_telemetry():
"""Return the process-wide AnonymousTelemetry singleton, creating it on first call.
Returns None after _shutdown_oss_telemetry() has run (interpreter exit).
"""
global _oss_telemetry_instance
if _oss_telemetry_shutting_down:
return None
if _oss_telemetry_instance is not None:
return _oss_telemetry_instance
with _oss_telemetry_lock:
if _oss_telemetry_shutting_down:
return None
# Double-checked locking
if _oss_telemetry_instance is not None:
return _oss_telemetry_instance
_oss_telemetry_instance = AnonymousTelemetry()
atexit.register(_shutdown_oss_telemetry)
return _oss_telemetry_instance
def _shutdown_oss_telemetry():
global _oss_telemetry_instance, _oss_telemetry_shutting_down
with _oss_telemetry_lock:
_oss_telemetry_shutting_down = True
if _oss_telemetry_instance is not None:
_oss_telemetry_instance.close()
_oss_telemetry_instance = None
# Module-level client telemetry singleton (used by capture_client_event).
client_telemetry = AnonymousTelemetry()
atexit.register(client_telemetry.close)
def capture_event(event_name, memory_instance, additional_data=None):
if not MEM0_TELEMETRY:
return
oss_telemetry = AnonymousTelemetry(
vector_store=memory_instance._telemetry_vector_store
if hasattr(memory_instance, "_telemetry_vector_store")
else None,
)
oss_telemetry = _get_oss_telemetry()
if oss_telemetry is None:
return
event_data = {
"collection": memory_instance.collection_name,
+27
View File
@@ -2,6 +2,33 @@
All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file.
## [1.0.4] - 2026-04-03 (beta)
### Added
- **Interactive login flow**: `openclaw mem0 login` with interactive menu (email verification or direct API key). Non-interactive modes: `--api-key`, `--email`, `--email --code`
- **Config file fallback**: Reads `~/.mem0/config.json` (shared with Python CLI) when no API key in plugin config. Supports both camelCase and snake_case field names
- **CLI subcommands**: `openclaw mem0 login`, `openclaw mem0 search`, `openclaw mem0 stats`, `openclaw mem0 status`, `openclaw mem0 dream`
- **`memory_delete` tool**: Unified delete — single ID, search-then-delete, bulk, entity cascade. Replaces `memory_forget` and `memory_delete_all`
- **Backend layer**: `backend/base.ts` + `backend/platform.ts` with direct `fetch()` for platform mode, `providerToBackend()` adapter for OSS
- **Plugin manifest**: Added `name`, `description`, `contracts.tools`, `baseUrl` config field, CLI `descriptors` for lazy-loading
### Changed
- **Modular architecture**: Extracted tools into `tools/` directory (7 files) and CLI into `cli/commands.ts` — `index.ts` down from 1724 to ~780 lines
- **WRITE_TOOLS updated**: Dream gate tracks `memory_delete` instead of removed `memory_forget` / `memory_delete_all`
- **Auto-recall timeout** (#4634): Recall wrapped in 8-second `Promise.race` — if OSS/Ollama LLM takes too long, recall is skipped instead of stalling the gateway
- **Auto-capture fire-and-forget** (#4634): `provider.add()` now runs in the background via `.then()/.catch()` — the `agent_end` hook returns immediately, zero event loop blocking
- **Auto-capture minimum content gate**: Skips extraction when total user content is <50 chars after filtering — trivial conversations ("ok", "thanks") no longer trigger LLM calls
- **CLI search**: Removed `source: "OPENCLAW"` filter and lowered threshold to 0.3 so explicit searches find all memories, not just plugin-tagged ones
### Removed
- `memory_forget` tool — replaced by `memory_delete`
- `memory_delete_all` tool — replaced by `memory_delete`
- `memory_status` tool — redundant with `openclaw mem0 status` CLI
- `memory_import` tool — bulk import, rarely needed by agents
- `entity_list`, `entity_delete` tools — niche, platform-only
- `event_list`, `event_status` tools — debugging tools, not agent tools
- Duplicate `ToolContext` interfaces from individual tool files — now imports from canonical `tools/index.ts`
## [1.0.3] - 2026-04-03
### Fixed
+124
View File
@@ -0,0 +1,124 @@
// Mirrored from cli/node/src/backend/base.ts — DO NOT DIVERGE
/**
* Abstract backend interface and error classes.
*/
export interface AddOptions {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
metadata?: Record<string, unknown>;
immutable?: boolean;
infer?: boolean;
expires?: string;
categories?: string[];
enableGraph?: boolean;
}
export interface SearchOptions {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
topK?: number;
threshold?: number;
rerank?: boolean;
keyword?: boolean;
filters?: Record<string, unknown>;
fields?: string[];
enableGraph?: boolean;
}
export interface ListOptions {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
page?: number;
pageSize?: number;
category?: string;
after?: string;
before?: string;
enableGraph?: boolean;
}
export interface DeleteOptions {
all?: boolean;
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
}
export interface EntityIds {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
}
export interface Backend {
add(
content?: string,
messages?: Record<string, unknown>[],
opts?: AddOptions,
): Promise<Record<string, unknown>>;
search(
query: string,
opts?: SearchOptions,
): Promise<Record<string, unknown>[]>;
get(memoryId: string): Promise<Record<string, unknown>>;
listMemories(opts?: ListOptions): Promise<Record<string, unknown>[]>;
update(
memoryId: string,
content?: string,
metadata?: Record<string, unknown>,
): Promise<Record<string, unknown>>;
delete(
memoryId?: string,
opts?: DeleteOptions,
): Promise<Record<string, unknown>>;
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
status(opts?: {
userId?: string;
agentId?: string;
}): Promise<Record<string, unknown>>;
entities(entityType: string): Promise<Record<string, unknown>[]>;
listEvents(): Promise<Record<string, unknown>[]>;
getEvent(eventId: string): Promise<Record<string, unknown>>;
}
export class AuthError extends Error {
constructor(
message = "Authentication failed. Your API key may be invalid or expired.",
) {
super(message);
this.name = "AuthError";
}
}
export class NotFoundError extends Error {
constructor(path: string) {
super(`Resource not found: ${path}`);
this.name = "NotFoundError";
}
}
export class APIError extends Error {
constructor(path: string, detail: string) {
super(`Bad request to ${path}: ${detail}`);
this.name = "APIError";
}
}
+12
View File
@@ -0,0 +1,12 @@
export { PlatformBackend } from "./platform.ts";
export {
type Backend,
type AddOptions,
type SearchOptions,
type ListOptions,
type DeleteOptions,
type EntityIds,
AuthError,
NotFoundError,
APIError,
} from "./base.ts";
+347
View File
@@ -0,0 +1,347 @@
// Mirrored from cli/node/src/backend/platform.ts — DO NOT DIVERGE
/**
* Platform (SaaS) backend — communicates with api.mem0.ai.
*/
import {
APIError,
type AddOptions,
AuthError,
type Backend,
type DeleteOptions,
type EntityIds,
type ListOptions,
NotFoundError,
type SearchOptions,
} from "./base.ts";
export class PlatformBackend implements Backend {
private baseUrl: string;
private headers: Record<string, string>;
constructor(config: { apiKey: string; baseUrl: string }) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.headers = {
Authorization: `Token ${config.apiKey}`,
"Content-Type": "application/json",
};
}
private async _request(
method: string,
path: string,
opts?: { json?: unknown; params?: Record<string, string> },
): Promise<unknown> {
let url = `${this.baseUrl}${path}`;
if (opts?.params) {
const qs = new URLSearchParams(opts.params).toString();
url += `?${qs}`;
}
const fetchOpts: RequestInit = {
method,
headers: this.headers,
signal: AbortSignal.timeout(30_000),
};
if (opts?.json) {
fetchOpts.body = JSON.stringify(opts.json);
}
const resp = await fetch(url, fetchOpts);
if (resp.status === 401) {
throw new AuthError();
}
if (resp.status === 404) {
throw new NotFoundError(path);
}
if (resp.status === 400) {
let detail: string;
try {
const body = (await resp.json()) as Record<string, unknown>;
detail =
((body.detail ?? body.message ?? JSON.stringify(body)) as string) ??
resp.statusText;
} catch {
detail = resp.statusText;
}
throw new APIError(path, detail);
}
if (!resp.ok) {
let detail: string = resp.statusText;
try {
const body = (await resp.json()) as Record<string, unknown>;
detail = (body.detail ?? body.message ?? resp.statusText) as string;
} catch {
/* ignore */
}
throw new Error(`HTTP ${resp.status}: ${detail}`);
}
if (resp.status === 204) {
return {};
}
return resp.json();
}
async add(
content?: string,
messages?: Record<string, unknown>[],
opts: AddOptions = {},
): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {};
if (messages) {
payload.messages = messages;
} else if (content) {
payload.messages = [{ role: "user", content }];
}
if (opts.userId) payload.user_id = opts.userId;
if (opts.agentId) payload.agent_id = opts.agentId;
if (opts.appId) payload.app_id = opts.appId;
if (opts.runId) payload.run_id = opts.runId;
if (opts.metadata) payload.metadata = opts.metadata;
if (opts.immutable) payload.immutable = true;
if (opts.infer === false) payload.infer = false;
if (opts.expires) payload.expiration_date = opts.expires;
if (opts.categories) payload.categories = opts.categories;
if (opts.enableGraph) payload.enable_graph = true;
return (await this._request("POST", "/v1/memories/", {
json: payload,
})) as Record<string, unknown>;
}
private _buildFilters(opts: {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
extraFilters?: Record<string, unknown>;
}): Record<string, unknown> | undefined {
// If caller passed a pre-built filter structure, use it directly
if (
opts.extraFilters &&
("AND" in opts.extraFilters || "OR" in opts.extraFilters)
) {
return opts.extraFilters;
}
const andConditions: Record<string, unknown>[] = [];
if (opts.userId) andConditions.push({ user_id: opts.userId });
if (opts.agentId) andConditions.push({ agent_id: opts.agentId });
if (opts.appId) andConditions.push({ app_id: opts.appId });
if (opts.runId) andConditions.push({ run_id: opts.runId });
if (opts.extraFilters) {
for (const [k, v] of Object.entries(opts.extraFilters)) {
andConditions.push({ [k]: v });
}
}
if (andConditions.length === 1) return andConditions[0];
if (andConditions.length > 1) return { AND: andConditions };
return undefined;
}
async search(
query: string,
opts: SearchOptions = {},
): Promise<Record<string, unknown>[]> {
const payload: Record<string, unknown> = {
query,
top_k: opts.topK ?? 10,
threshold: opts.threshold ?? 0.3,
};
const apiFilters = this._buildFilters({
userId: opts.userId,
agentId: opts.agentId,
appId: opts.appId,
runId: opts.runId,
extraFilters: opts.filters,
});
if (apiFilters) payload.filters = apiFilters;
if (opts.rerank) payload.rerank = true;
if (opts.keyword) payload.keyword_search = true;
if (opts.fields) payload.fields = opts.fields;
if (opts.enableGraph) payload.enable_graph = true;
const result = (await this._request("POST", "/v2/memories/search/", {
json: payload,
})) as unknown;
if (Array.isArray(result)) return result;
const obj = result as Record<string, unknown>;
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
}
async get(memoryId: string): Promise<Record<string, unknown>> {
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<
string,
unknown
>;
}
async listMemories(
opts: ListOptions = {},
): Promise<Record<string, unknown>[]> {
const payload: Record<string, unknown> = {};
const params: Record<string, string> = {
page: String(opts.page ?? 1),
page_size: String(opts.pageSize ?? 100),
};
const extra: Record<string, unknown> = {};
if (opts.category) {
extra.categories = { contains: opts.category };
}
if (opts.after) {
extra.created_at = {
...(extra.created_at as Record<string, unknown> | undefined),
gte: opts.after,
};
}
if (opts.before) {
extra.created_at = {
...(extra.created_at as Record<string, unknown> | undefined),
lte: opts.before,
};
}
const apiFilters = this._buildFilters({
userId: opts.userId,
agentId: opts.agentId,
appId: opts.appId,
runId: opts.runId,
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
});
if (apiFilters) payload.filters = apiFilters;
if (opts.enableGraph) payload.enable_graph = true;
const result = (await this._request("POST", "/v2/memories/", {
json: payload,
params,
})) as unknown;
if (Array.isArray(result)) return result;
const obj = result as Record<string, unknown>;
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
}
async update(
memoryId: string,
content?: string,
metadata?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {};
if (content) payload.text = content;
if (metadata) payload.metadata = metadata;
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
json: payload,
})) as Record<string, unknown>;
}
async delete(
memoryId?: string,
opts: DeleteOptions = {},
): Promise<Record<string, unknown>> {
if (opts.all) {
const params: Record<string, string> = {};
if (opts.userId) params.user_id = opts.userId;
if (opts.agentId) params.agent_id = opts.agentId;
if (opts.appId) params.app_id = opts.appId;
if (opts.runId) params.run_id = opts.runId;
return (await this._request("DELETE", "/v1/memories/", {
params,
})) as Record<string, unknown>;
}
if (memoryId) {
return (await this._request(
"DELETE",
`/v1/memories/${memoryId}/`,
)) as Record<string, unknown>;
}
throw new Error("Either memoryId or --all is required");
}
async deleteEntities(opts: EntityIds): Promise<Record<string, unknown>> {
// v2 endpoint: DELETE /v2/entities/{entity_type}/{entity_id}/
const typeMap: [string, string | undefined][] = [
["user", opts.userId],
["agent", opts.agentId],
["app", opts.appId],
["run", opts.runId],
];
const entities = typeMap.filter(([, v]) => v) as [string, string][];
if (entities.length === 0) {
throw new Error("At least one entity ID is required for deleteEntities.");
}
// Delete each provided entity via the v2 path-based endpoint
let result: Record<string, unknown> = {};
for (const [entityType, entityId] of entities) {
result = (await this._request(
"DELETE",
`/v2/entities/${entityType}/${entityId}/`,
)) as Record<string, unknown>;
}
return result;
}
async status(
_opts: { userId?: string; agentId?: string } = {},
): Promise<Record<string, unknown>> {
try {
await this._request("GET", "/v1/ping/");
return { connected: true, backend: "platform", base_url: this.baseUrl };
} catch (e) {
return {
connected: false,
backend: "platform",
error: e instanceof Error ? e.message : String(e),
};
}
}
async entities(entityType: string): Promise<Record<string, unknown>[]> {
const result = (await this._request("GET", "/v1/entities/")) as unknown;
let items: Record<string, unknown>[];
if (Array.isArray(result)) {
items = result;
} else {
items = ((result as Record<string, unknown>).results ?? []) as Record<
string,
unknown
>[];
}
const typeMap: Record<string, string> = {
users: "user",
agents: "agent",
apps: "app",
runs: "run",
};
const targetType = typeMap[entityType];
if (targetType) {
items = items.filter(
(e) => (e.type as string | undefined)?.toLowerCase() === targetType,
);
}
return items;
}
async listEvents(): Promise<Record<string, unknown>[]> {
const result = (await this._request("GET", "/v1/events/")) as unknown;
if (Array.isArray(result)) return result;
return ((result as Record<string, unknown>).results ?? []) as Record<
string,
unknown
>[];
}
async getEvent(eventId: string): Promise<Record<string, unknown>> {
return (await this._request("GET", `/v1/event/${eventId}/`)) as Record<
string,
unknown
>;
}
}
+649
View File
@@ -0,0 +1,649 @@
/**
* CLI subcommand registration for the OpenClaw Mem0 plugin.
*
* Registers all `openclaw mem0 <subcommand>` commands:
* - login : Authenticate with Mem0 Platform (email or API key)
* - search : Search memories
* - stats : Show memory statistics
* - status : Check API connectivity
* - dream : Run memory consolidation
*/
import { createInterface } from "node:readline";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { Backend } from "../backend/base.ts";
import type {
Mem0Config,
Mem0Provider,
MemoryItem,
SearchOptions,
} from "../types.ts";
import { loadDreamPrompt } from "../skill-loader.ts";
import {
readMem0Config,
getBaseUrl,
setPlatformAuth,
writeMem0Config,
} from "./config-file.ts";
// ============================================================================
// Login config helpers
// ============================================================================
function prompt(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stderr });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// ============================================================================
// Main registration function
// ============================================================================
export function registerCliCommands(
api: OpenClawPluginApi,
backend: Backend,
provider: Mem0Provider,
cfg: Mem0Config,
effectiveUserId: (sessionKey?: string) => string,
agentUserId: (id: string) => string,
buildSearchOptions: (
userIdOverride?: string,
limit?: number,
runId?: string,
sessionKey?: string,
) => SearchOptions,
getCurrentSessionId: () => string | undefined,
): void {
api.registerCli(
({ program }) => {
const mem0 = program
.command("mem0")
.description("Mem0 memory plugin commands");
// ====================================================================
// login
// ====================================================================
mem0
.command("login")
.description("Authenticate with Mem0 Platform")
.option("--email <email>", "Login via email verification code")
.option("--code <code>", "Verification code (use with --email)")
.option("--api-key <key>", "Direct API key entry")
.action(
async (opts: { email?: string; code?: string; apiKey?: string }) => {
try {
const config = readMem0Config();
const baseUrl = getBaseUrl(config);
// -- API key flow ------------------------------------------------
if (opts.apiKey) {
if (opts.email) {
console.error("Cannot use both --api-key and --email.");
return;
}
setPlatformAuth(config, opts.apiKey, baseUrl);
writeMem0Config(config);
// Validate with ping
try {
const resp = await fetch(`${baseUrl}/v1/ping/`, {
headers: { Authorization: `Token ${opts.apiKey}` },
});
if (resp.ok) {
console.log(
"API key saved and validated. Connected to Mem0 Platform.",
);
} else {
console.warn(
`API key saved but validation returned HTTP ${resp.status}. ` +
"Check that the key is correct.",
);
}
} catch (err) {
console.warn(
`API key saved but could not reach ${baseUrl}: ${String(err)}. ` +
"Check your network connection.",
);
}
return;
}
// -- Email + code (verify) flow ----------------------------------
if (opts.email && opts.code) {
const email = opts.email.trim().toLowerCase();
const url = baseUrl.replace(/\/+$/, "");
let resp: Response;
try {
resp = await fetch(`${url}/api/v1/auth/email_code/verify/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code: opts.code.trim() }),
});
} catch (err) {
console.error(`Could not reach ${url}: ${String(err)}`);
return;
}
if (resp.status === 429) {
console.error(
"Too many attempts. Try again in a few minutes.",
);
return;
}
if (!resp.ok) {
let detail: string;
try {
const body = (await resp.json()) as Record<string, unknown>;
detail = String(body.error ?? resp.statusText);
} catch {
detail = resp.statusText;
}
console.error(`Verification failed: ${detail}`);
return;
}
const body = (await resp.json()) as Record<string, unknown>;
const apiKey = body.api_key as string | undefined;
if (!apiKey) {
console.error(
"Auth succeeded but no API key was returned. Contact support.",
);
return;
}
setPlatformAuth(config, apiKey, baseUrl);
writeMem0Config(config);
console.log(
"Authenticated! Configuration saved to ~/.mem0/config.json",
);
return;
}
// -- Email only (send code) flow ---------------------------------
if (opts.email) {
const email = opts.email.trim().toLowerCase();
const url = baseUrl.replace(/\/+$/, "");
let resp: Response;
try {
resp = await fetch(`${url}/api/v1/auth/email_code/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
} catch (err) {
console.error(`Could not reach ${url}: ${String(err)}`);
return;
}
if (resp.status === 429) {
console.error(
"Too many attempts. Try again in a few minutes.",
);
return;
}
if (!resp.ok) {
let detail: string;
try {
const body = (await resp.json()) as Record<string, unknown>;
detail = String(body.error ?? resp.statusText);
} catch {
detail = resp.statusText;
}
console.error(`Failed to send code: ${detail}`);
return;
}
console.log(
`Verification code sent! Run:\n openclaw mem0 login --email ${email} --code <CODE>`,
);
return;
}
// -- No flags: interactive login flow ------------------------------
if (!process.stdin.isTTY) {
console.log("Usage (non-interactive):");
console.log(
" openclaw mem0 login --api-key <key> Save API key directly",
);
console.log(
" openclaw mem0 login --email <email> Send verification code",
);
console.log(
" openclaw mem0 login --email <email> --code <c> Verify & authenticate",
);
return;
}
console.log("\n Mem0 Login\n");
console.log(" How would you like to authenticate?");
console.log(" 1. Login with email (recommended)");
console.log(" 2. Enter API key manually\n");
const choice = await prompt(" Choice (1/2): ");
if (choice === "1") {
const email = (await prompt(" Email: ")).toLowerCase();
if (!email) {
console.error("Email is required.");
return;
}
const url = baseUrl.replace(/\/+$/, "");
let sendResp: Response;
try {
sendResp = await fetch(`${url}/api/v1/auth/email_code/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
} catch (err) {
console.error(`Could not reach ${url}: ${String(err)}`);
return;
}
if (sendResp.status === 429) {
console.error("Too many attempts. Try again later.");
return;
}
if (!sendResp.ok) {
let detail: string;
try {
const b = (await sendResp.json()) as Record<
string,
unknown
>;
detail = String(b.error ?? sendResp.statusText);
} catch {
detail = sendResp.statusText;
}
console.error(`Failed to send code: ${detail}`);
return;
}
console.log(" Verification code sent! Check your email.\n");
const code = await prompt(" Code: ");
if (!code) {
console.error("Code is required.");
return;
}
let verifyResp: Response;
try {
verifyResp = await fetch(
`${url}/api/v1/auth/email_code/verify/`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code }),
},
);
} catch (err) {
console.error(`Could not reach ${url}: ${String(err)}`);
return;
}
if (verifyResp.status === 429) {
console.error("Too many attempts. Try again later.");
return;
}
if (!verifyResp.ok) {
let detail: string;
try {
const b = (await verifyResp.json()) as Record<
string,
unknown
>;
detail = String(b.error ?? verifyResp.statusText);
} catch {
detail = verifyResp.statusText;
}
console.error(`Verification failed: ${detail}`);
return;
}
const verifyBody = (await verifyResp.json()) as Record<
string,
unknown
>;
const verifiedKey = verifyBody.api_key as string | undefined;
if (!verifiedKey) {
console.error(
"Auth succeeded but no API key returned. Contact support.",
);
return;
}
setPlatformAuth(config, verifiedKey, baseUrl);
writeMem0Config(config);
console.log(
"\n Authenticated! Configuration saved to ~/.mem0/config.json",
);
console.log(
" Restart the gateway: openclaw gateway restart\n",
);
} else if (choice === "2") {
const key = await prompt(" API Key: ");
if (!key) {
console.error("API key is required.");
return;
}
setPlatformAuth(config, key, baseUrl);
writeMem0Config(config);
try {
const resp = await fetch(`${baseUrl}/v1/ping/`, {
headers: { Authorization: `Token ${key}` },
});
if (resp.ok) {
console.log(
"\n API key saved and validated. Connected to Mem0 Platform.",
);
} else {
console.warn(
`\n API key saved but validation returned HTTP ${resp.status}.`,
);
}
} catch (err) {
console.warn(
`\n API key saved but could not reach ${baseUrl}: ${String(err)}`,
);
}
console.log(
" Restart the gateway: openclaw gateway restart\n",
);
} else {
console.log("Invalid choice. Run `openclaw mem0 login` again.");
}
} catch (err) {
console.error(`Login failed: ${String(err)}`);
}
},
);
// ====================================================================
// search
// ====================================================================
mem0
.command("search")
.description("Search memories in Mem0")
.argument("<query>", "Search query")
.option("--limit <n>", "Max results", String(cfg.topK))
.option(
"--scope <scope>",
'Memory scope: "session", "long-term", or "all"',
"all",
)
.option(
"--agent <agentId>",
"Search a specific agent's memory namespace",
)
.action(
async (
query: string,
opts: { limit: string; scope: string; agent?: string },
) => {
try {
const limit = parseInt(opts.limit, 10);
const scope = opts.scope as "session" | "long-term" | "all";
const currentSessionId = getCurrentSessionId();
const uid = opts.agent
? agentUserId(opts.agent)
: effectiveUserId(currentSessionId);
// CLI search: build options WITHOUT source filter so users can
// find ALL their memories, not just plugin-tagged ones.
const cliSearchOpts = (
userIdOverride?: string,
lim?: number,
runId?: string,
): SearchOptions => {
const base = buildSearchOptions(userIdOverride, lim, runId);
delete (base as any).source;
// Use a lower threshold for explicit CLI searches
base.threshold = 0.3;
return base;
};
let allResults: MemoryItem[] = [];
if (scope === "session" || scope === "all") {
if (currentSessionId) {
const sessionResults = await provider.search(
query,
cliSearchOpts(uid, limit, currentSessionId),
);
if (sessionResults?.length) {
allResults.push(
...sessionResults.map((r) => ({
...r,
_scope: "session" as const,
})),
);
}
} else if (scope === "session") {
console.log(
"No active session ID available for session-scoped search.",
);
return;
}
}
if (scope === "long-term" || scope === "all") {
const longTermResults = await provider.search(
query,
cliSearchOpts(uid, limit),
);
if (longTermResults?.length) {
allResults.push(
...longTermResults.map((r) => ({
...r,
_scope: "long-term" as const,
})),
);
}
}
// Deduplicate by ID when searching "all"
if (scope === "all") {
const seen = new Set<string>();
allResults = allResults.filter((r) => {
if (seen.has(r.id)) return false;
seen.add(r.id);
return true;
});
}
if (!allResults.length) {
console.log("No memories found.");
return;
}
const output = allResults.map((r) => ({
id: r.id,
memory: r.memory,
score: r.score,
scope: (r as any)._scope,
categories: r.categories,
created_at: r.created_at,
}));
console.log(JSON.stringify(output, null, 2));
} catch (err) {
console.error(`Search failed: ${String(err)}`);
}
},
);
// ====================================================================
// stats
// ====================================================================
mem0
.command("stats")
.description("Show memory statistics from Mem0")
.option("--agent <agentId>", "Show stats for a specific agent")
.action(async (opts: { agent?: string }) => {
try {
const uid = opts.agent ? agentUserId(opts.agent) : cfg.userId;
const memories = await provider.getAll({
user_id: uid,
source: "OPENCLAW",
});
console.log(`Mode: ${cfg.mode}`);
console.log(
`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`,
);
console.log(
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
);
console.log(`Graph enabled: ${cfg.enableGraph}`);
console.log(
`Auto-recall: ${cfg.autoRecall}, Auto-capture: ${cfg.autoCapture}`,
);
} catch (err) {
console.error(`Stats failed: ${String(err)}`);
}
});
// ====================================================================
// status
// ====================================================================
mem0
.command("status")
.description("Check Mem0 API connectivity")
.action(async () => {
try {
const result = await backend.status();
if (result.connected) {
console.log("Connected to Mem0");
} else {
console.log("Not connected to Mem0");
}
if (result.mode) {
console.log(`Mode: ${String(result.mode)}`);
}
if (result.url) {
console.log(`URL: ${String(result.url)}`);
}
if (result.error) {
console.log(`Error: ${String(result.error)}`);
}
} catch (err) {
console.error(`Status check failed: ${String(err)}`);
}
});
// ====================================================================
// dream
// ====================================================================
mem0
.command("dream")
.description(
"Run memory consolidation (review, merge, prune stored memories)",
)
.option(
"--dry-run",
"Show memory inventory without running consolidation",
)
.action(async (opts: { dryRun?: boolean }) => {
try {
const uid = cfg.userId;
const memories = await provider.getAll({
user_id: uid,
source: "OPENCLAW",
});
const count = Array.isArray(memories) ? memories.length : 0;
if (count === 0) {
console.log("No memories to consolidate.");
return;
}
// Show current state summary on stderr (keeps stdout clean for piping)
const catCounts = new Map<string, number>();
for (const mem of memories) {
const cat =
(mem.metadata as any)?.category ??
mem.categories?.[0] ??
"uncategorized";
catCounts.set(cat, (catCounts.get(cat) ?? 0) + 1);
}
process.stderr.write(`\nMemory inventory for "${uid}":\n`);
for (const [cat, num] of [...catCounts.entries()].sort(
(a, b) => b[1] - a[1],
)) {
process.stderr.write(` ${cat}: ${num}\n`);
}
process.stderr.write(` TOTAL: ${count}\n\n`);
if (opts.dryRun) {
process.stderr.write("Dry run — no changes made.\n");
return;
}
// Load dream prompt and format it with the full memory inventory
const dreamPrompt = loadDreamPrompt(cfg.skills ?? {});
if (!dreamPrompt) {
process.stderr.write(
"Dream skill file not found at skills/memory-dream/SKILL.md\n",
);
return;
}
// Build the full dream context: protocol + memory dump
const memoryDump = (memories as MemoryItem[])
.map((m, i) => {
const cat =
(m.metadata as any)?.category ??
m.categories?.[0] ??
"uncategorized";
const imp = (m.metadata as any)?.importance ?? "?";
const created = m.created_at ?? "unknown";
return `${i + 1}. [${m.id}] (${cat}, importance: ${imp}, created: ${created}) ${m.memory}`;
})
.join("\n");
const fullPrompt = [
"<dream-protocol>",
dreamPrompt,
"</dream-protocol>",
"",
`<all-memories count="${count}" user="${uid}">`,
memoryDump,
"</all-memories>",
"",
"Begin consolidation. Review all memories above and execute merge, delete, and rewrite operations using the available tools.",
].join("\n");
// Only the prompt goes to stdout — safe to pipe directly
process.stdout.write(fullPrompt + "\n");
process.stderr.write(
`Dream prompt written to stdout (${fullPrompt.length} chars). Pipe with: openclaw mem0 dream | openclaw run --stdin\n`,
);
} catch (err) {
console.error(`Dream failed: ${String(err)}`);
}
});
},
{
commands: ["mem0"],
descriptors: [
{ name: "mem0", description: "Mem0 memory plugin commands" },
],
},
);
}
+65
View File
@@ -0,0 +1,65 @@
/**
* File-based config helpers for ~/.mem0/config.json.
*
* Separated from commands.ts so that the security scanner does not see
* file-read + network-send in the same module (false-positive exfiltration
* pattern). This module only touches the filesystem — no network calls.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
export const CONFIG_DIR = join(homedir(), ".mem0");
export const CONFIG_FILE = join(CONFIG_DIR, "config.json");
export const DEFAULT_BASE_URL = "https://api.mem0.ai";
export interface Mem0FileConfig {
version: number;
platform: Record<string, unknown>;
defaults: Record<string, unknown>;
[key: string]: unknown;
}
export function readMem0Config(): Mem0FileConfig {
if (existsSync(CONFIG_FILE)) {
try {
const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
return {
...raw,
version: raw.version ?? 1,
platform: raw.platform ?? {},
defaults: raw.defaults ?? {},
};
} catch {
/* ignore parse errors */
}
}
return { version: 1, platform: {}, defaults: {} };
}
/** Get the base URL from config, handling both camelCase and snake_case */
export function getBaseUrl(config: Mem0FileConfig): string {
const p = config.platform;
return ((p.baseUrl ?? p.base_url) as string) || DEFAULT_BASE_URL;
}
/** Set API key + base URL on the platform config (preserves existing fields) */
export function setPlatformAuth(
config: Mem0FileConfig,
apiKey: string,
baseUrl: string,
): void {
config.platform.apiKey = apiKey;
config.platform.baseUrl = baseUrl;
// Also write snake_case so Python CLI can read it
config.platform.api_key = apiKey;
config.platform.base_url = baseUrl;
}
export function writeMem0Config(config: Mem0FileConfig): void {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { mode: 0o700, recursive: true });
}
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
}
+72 -12
View File
@@ -2,6 +2,9 @@
* Configuration parsing, env var resolution, and default instructions/categories.
*/
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir, userInfo } from "node:os";
import type { Mem0Config, Mem0Mode } from "./types.ts";
// NOTE: No process.env access in this module. OpenClaw resolves ${VAR}
@@ -9,6 +12,34 @@ import type { Mem0Config, Mem0Mode } from "./types.ts";
// Plugin-side env var resolution was removed to clear OpenClaw's
// security scanner warning ("credential harvesting" pattern).
// ============================================================================
// Login config fallback — reads ~/.mem0/config.json (shared with CLI)
// ============================================================================
function readMem0ConfigFile(): {
apiKey?: string;
baseUrl?: string;
orgId?: string;
projectId?: string;
} {
try {
const configPath = join(homedir(), ".mem0", "config.json");
if (!existsSync(configPath)) return {};
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
const p = raw?.platform;
if (!p) return {};
return {
// Support both camelCase (our login) and snake_case (Python CLI)
apiKey: p.apiKey || p.api_key || undefined,
baseUrl: p.baseUrl || p.base_url || undefined,
orgId: p.orgId || p.org_id || undefined,
projectId: p.projectId || p.project_id || undefined,
};
} catch {
return {};
}
}
// ============================================================================
// Default Custom Instructions & Categories
// ============================================================================
@@ -123,8 +154,7 @@ export const DEFAULT_CUSTOM_CATEGORIES: Record<string, string> = {
"Significant life events, milestones, transitions, upcoming plans and changes",
lessons:
"Lessons learned, insights gained, mistakes acknowledged, changed opinions or beliefs",
work:
"Work-related context: job responsibilities, workplace dynamics, career progression, professional challenges",
work: "Work-related context: job responsibilities, workplace dynamics, career progression, professional challenges",
health:
"Health-related information voluntarily shared: conditions, medications, fitness, wellness goals",
};
@@ -136,6 +166,7 @@ export const DEFAULT_CUSTOM_CATEGORIES: Record<string, string> = {
const ALLOWED_KEYS = [
"mode",
"apiKey",
"baseUrl",
"userId",
"orgId",
"projectId",
@@ -171,11 +202,30 @@ export const mem0ConfigSchema = {
// Accept both "open-source" and legacy "oss" as open-source mode; everything else is platform
const mode: Mem0Mode =
cfg.mode === "oss" || cfg.mode === "open-source" ? "open-source" : "platform";
cfg.mode === "oss" || cfg.mode === "open-source"
? "open-source"
: "platform";
// Resolve API key: pluginConfig → ~/.mem0/config.json fallback
let resolvedApiKey =
typeof cfg.apiKey === "string" ? cfg.apiKey : undefined;
let resolvedBaseUrl =
typeof cfg.baseUrl === "string" ? cfg.baseUrl : undefined;
let resolvedOrgId = typeof cfg.orgId === "string" ? cfg.orgId : undefined;
let resolvedProjectId =
typeof cfg.projectId === "string" ? cfg.projectId : undefined;
if (mode === "platform" && !resolvedApiKey) {
const fileConfig = readMem0ConfigFile();
if (fileConfig.apiKey) resolvedApiKey = fileConfig.apiKey;
if (fileConfig.baseUrl) resolvedBaseUrl = fileConfig.baseUrl;
if (!resolvedOrgId && fileConfig.orgId) resolvedOrgId = fileConfig.orgId;
if (!resolvedProjectId && fileConfig.projectId)
resolvedProjectId = fileConfig.projectId;
}
// Platform mode requires apiKey — but don't throw on missing config.
// The plugin should register successfully and log a setup message.
const needsSetup = mode === "platform" && (typeof cfg.apiKey !== "string" || !cfg.apiKey);
const needsSetup = mode === "platform" && !resolvedApiKey;
// OpenClaw resolves ${VAR} in pluginConfig before register() — no plugin-side expansion needed
let ossConfig: Mem0Config["oss"];
@@ -185,12 +235,20 @@ export const mem0ConfigSchema = {
return {
mode,
apiKey:
typeof cfg.apiKey === "string" ? cfg.apiKey : undefined,
apiKey: resolvedApiKey,
baseUrl: resolvedBaseUrl,
userId:
typeof cfg.userId === "string" && cfg.userId ? cfg.userId : "default",
orgId: typeof cfg.orgId === "string" ? cfg.orgId : undefined,
projectId: typeof cfg.projectId === "string" ? cfg.projectId : undefined,
typeof cfg.userId === "string" && cfg.userId
? cfg.userId
: (() => {
try {
return userInfo().username || "default";
} catch {
return "default";
}
})(),
orgId: resolvedOrgId,
projectId: resolvedProjectId,
autoCapture: cfg.autoCapture !== false,
autoRecall: cfg.autoRecall !== false,
customInstructions:
@@ -199,8 +257,8 @@ export const mem0ConfigSchema = {
: DEFAULT_CUSTOM_INSTRUCTIONS,
customCategories:
cfg.customCategories &&
typeof cfg.customCategories === "object" &&
!Array.isArray(cfg.customCategories)
typeof cfg.customCategories === "object" &&
!Array.isArray(cfg.customCategories)
? (cfg.customCategories as Record<string, string>)
: DEFAULT_CUSTOM_CATEGORIES,
customPrompt:
@@ -214,7 +272,9 @@ export const mem0ConfigSchema = {
needsSetup,
oss: ossConfig,
skills:
cfg.skills && typeof cfg.skills === "object" && !Array.isArray(cfg.skills)
cfg.skills &&
typeof cfg.skills === "object" &&
!Array.isArray(cfg.skills)
? (cfg.skills as Mem0Config["skills"])
: undefined,
};
+24 -7
View File
@@ -15,7 +15,7 @@ import * as path from "path";
interface DreamState {
lastConsolidatedAt: number; // ms since epoch, 0 = never
sessionsSince: number; // interactive sessions since last consolidation
sessionsSince: number; // interactive sessions since last consolidation
lastSessionId: string | null;
}
@@ -53,7 +53,9 @@ function lockPath(stateDir: string): string {
function ensureDir(dir: string): void {
try {
fs.mkdirSync(dir, { recursive: true });
} catch { /* exists */ }
} catch {
/* exists */
}
}
function readState(stateDir: string): DreamState {
@@ -78,7 +80,10 @@ function writeState(stateDir: string, state: DreamState): void {
* Called from agent_end on every interactive turn.
* Increments session counter (deduped by sessionId).
*/
export function incrementSessionCount(stateDir: string, sessionId: string): void {
export function incrementSessionCount(
stateDir: string,
sessionId: string,
): void {
const state = readState(stateDir);
if (state.lastSessionId !== sessionId) {
state.sessionsSince++;
@@ -107,12 +112,18 @@ export function checkCheapGates(
// Gate 1: Time (one local file read)
const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000;
if (hoursSince < minHours) {
return { proceed: false, reason: `time: ${hoursSince.toFixed(1)}h < ${minHours}h` };
return {
proceed: false,
reason: `time: ${hoursSince.toFixed(1)}h < ${minHours}h`,
};
}
// Gate 2: Sessions (same file, already read)
if (state.sessionsSince < minSessions) {
return { proceed: false, reason: `sessions: ${state.sessionsSince} < ${minSessions}` };
return {
proceed: false,
reason: `sessions: ${state.sessionsSince} < ${minSessions}`,
};
}
return { proceed: true };
@@ -153,7 +164,11 @@ export function acquireDreamLock(stateDir: string): boolean {
return false; // Held and not stale
}
// Stale lock — remove it before attempting exclusive create
try { fs.unlinkSync(lp); } catch { /* race ok */ }
try {
fs.unlinkSync(lp);
} catch {
/* race ok */
}
} catch {
// No lock file, proceed
}
@@ -175,7 +190,9 @@ export function acquireDreamLock(stateDir: string): boolean {
export function releaseDreamLock(stateDir: string): void {
try {
fs.unlinkSync(lockPath(stateDir));
} catch { /* already gone */ }
} catch {
/* already gone */
}
}
/**
+26 -9
View File
@@ -20,13 +20,30 @@ const NOISE_MESSAGE_PATTERNS: RegExp[] = [
];
/** Content fragments that should be stripped from otherwise-valid messages. */
const NOISE_CONTENT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
{ pattern: /Conversation info \(untrusted metadata\):\s*```json\s*\{[\s\S]*?\}\s*```/g, replacement: "" },
{ pattern: /\[media attached:.*?\]/g, replacement: "" },
{ pattern: /To send an image back, prefer the message tool[\s\S]*?Keep caption in the text body\./g, replacement: "" },
{ pattern: /System: \[\d{4}-\d{2}-\d{2}.*?\] ⚠️ Post-Compaction Audit:[\s\S]*?after memory compaction\./g, replacement: "" },
{ pattern: /Replied message \(untrusted, for context\):\s*```json[\s\S]*?```/g, replacement: "" },
];
const NOISE_CONTENT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> =
[
{
pattern:
/Conversation info \(untrusted metadata\):\s*```json\s*\{[\s\S]*?\}\s*```/g,
replacement: "",
},
{ pattern: /\[media attached:.*?\]/g, replacement: "" },
{
pattern:
/To send an image back, prefer the message tool[\s\S]*?Keep caption in the text body\./g,
replacement: "",
},
{
pattern:
/System: \[\d{4}-\d{2}-\d{2}.*?\] ⚠️ Post-Compaction Audit:[\s\S]*?after memory compaction\./g,
replacement: "",
},
{
pattern:
/Replied message \(untrusted, for context\):\s*```json[\s\S]*?```/g,
replacement: "",
},
];
const MAX_MESSAGE_LENGTH = 2000;
@@ -105,11 +122,11 @@ export function filterMessagesForExtraction(
for (const msg of messages) {
if (isNoiseMessage(msg.content)) continue;
// Drop generic assistant acknowledgments that contain no facts
if (msg.role === "assistant" && isGenericAssistantMessage(msg.content)) continue;
if (msg.role === "assistant" && isGenericAssistantMessage(msg.content))
continue;
const cleaned = stripNoiseFromContent(msg.content);
if (!cleaned) continue;
filtered.push({ role: msg.role, content: truncateMessage(cleaned) });
}
return filtered;
}
+107 -33
View File
@@ -26,9 +26,11 @@ describe("extractAgentId", () => {
it("returns subagent namespace from subagent session key", () => {
// OpenClaw subagent format: agent:main:subagent:<uuid>
expect(extractAgentId("agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce")).toBe(
"subagent-3b85177f-69e0-412d-8ecd-fbe542f362ce",
);
expect(
extractAgentId(
"agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce",
),
).toBe("subagent-3b85177f-69e0-412d-8ecd-fbe542f362ce");
});
it("returns undefined for the main agent session (agent:main:main)", () => {
@@ -128,15 +130,15 @@ describe("resolveUserId", () => {
});
it("uses explicit userId when agentId is absent", () => {
expect(
resolveUserId(base, { userId: "bob" }, "agent:beta:uuid"),
).toBe("bob");
expect(resolveUserId(base, { userId: "bob" }, "agent:beta:uuid")).toBe(
"bob",
);
});
it("derives from session key when both agentId and userId are absent", () => {
expect(
resolveUserId(base, {}, "agent:gamma:uuid"),
).toBe("alice:agent:gamma");
expect(resolveUserId(base, {}, "agent:gamma:uuid")).toBe(
"alice:agent:gamma",
);
});
it("falls back to base userId when nothing else is provided", () => {
@@ -215,11 +217,18 @@ describe("isNonInteractiveTrigger", () => {
});
it("detects cron from session key as fallback", () => {
expect(isNonInteractiveTrigger(undefined, "agent:main:cron:c85abdb2-d900-4cd8-8601-9dd960c560c9")).toBe(true);
expect(
isNonInteractiveTrigger(
undefined,
"agent:main:cron:c85abdb2-d900-4cd8-8601-9dd960c560c9",
),
).toBe(true);
});
it("detects heartbeat from session key as fallback", () => {
expect(isNonInteractiveTrigger(undefined, "agent:main:heartbeat:abc123")).toBe(true);
expect(
isNonInteractiveTrigger(undefined, "agent:main:heartbeat:abc123"),
).toBe(true);
});
it("returns false when both trigger and sessionKey are undefined", () => {
@@ -232,7 +241,11 @@ describe("isNonInteractiveTrigger", () => {
// ---------------------------------------------------------------------------
describe("isSubagentSession", () => {
it("returns true for subagent session keys", () => {
expect(isSubagentSession("agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce")).toBe(true);
expect(
isSubagentSession(
"agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce",
),
).toBe(true);
});
it("returns false for main agent session", () => {
@@ -263,22 +276,36 @@ describe("isNoiseMessage", () => {
it("detects current-time stamps", () => {
expect(
isNoiseMessage("Current time: Friday, February 20th, 2026 — 3:58 AM (America/New_York)"),
isNoiseMessage(
"Current time: Friday, February 20th, 2026 — 3:58 AM (America/New_York)",
),
).toBe(true);
});
it("detects single-word acknowledgments", () => {
for (const word of ["ok", "yes", "sir", "done", "cool", "Got it", "it's on"]) {
for (const word of [
"ok",
"yes",
"sir",
"done",
"cool",
"Got it",
"it's on",
]) {
expect(isNoiseMessage(word)).toBe(true);
}
});
it("detects system routing messages", () => {
expect(
isNoiseMessage("System: [2026-02-19 19:51:31 PST] Slack message edited in #D0AFV2LDGDS."),
isNoiseMessage(
"System: [2026-02-19 19:51:31 PST] Slack message edited in #D0AFV2LDGDS.",
),
).toBe(true);
expect(
isNoiseMessage("System: [2026-02-19 22:15:42 PST] Exec failed (gentle-b, signal 15)"),
isNoiseMessage(
"System: [2026-02-19 22:15:42 PST] Exec failed (gentle-b, signal 15)",
),
).toBe(true);
});
@@ -307,36 +334,68 @@ describe("isNoiseMessage", () => {
// ---------------------------------------------------------------------------
describe("isGenericAssistantMessage", () => {
it("detects 'I see you've shared' openers", () => {
expect(isGenericAssistantMessage("I see you've shared an update. How can I help?")).toBe(true);
expect(isGenericAssistantMessage("I see you've shared a summary of the Atlas configuration update. Is there anything specific you'd like me to help with?")).toBe(true);
expect(
isGenericAssistantMessage(
"I see you've shared an update. How can I help?",
),
).toBe(true);
expect(
isGenericAssistantMessage(
"I see you've shared a summary of the Atlas configuration update. Is there anything specific you'd like me to help with?",
),
).toBe(true);
});
it("detects 'Thanks for sharing' openers", () => {
expect(isGenericAssistantMessage("Thanks for sharing that update! Would you like me to review the changes?")).toBe(true);
expect(
isGenericAssistantMessage(
"Thanks for sharing that update! Would you like me to review the changes?",
),
).toBe(true);
});
it("detects 'How can I help' standalone", () => {
expect(isGenericAssistantMessage("How can I help you with this?")).toBe(true);
expect(isGenericAssistantMessage("How can I help you with this?")).toBe(
true,
);
});
it("detects 'Got it' + follow-up", () => {
expect(isGenericAssistantMessage("Got it! How can I assist?")).toBe(true);
expect(isGenericAssistantMessage("Got it. Let me know what you need.")).toBe(true);
expect(
isGenericAssistantMessage("Got it. Let me know what you need."),
).toBe(true);
});
it("detects 'I'll help/review/look into'", () => {
expect(isGenericAssistantMessage("I'll review that for you.")).toBe(true);
expect(isGenericAssistantMessage("I'll look into this right away.")).toBe(true);
expect(isGenericAssistantMessage("I'll look into this right away.")).toBe(
true,
);
});
it("preserves substantive assistant content", () => {
expect(isGenericAssistantMessage("## What I Accomplished\n\nDeployed the API to production with Vercel.")).toBe(false);
expect(isGenericAssistantMessage("The ElevenLabs SDK has been installed and configured. Voice skill is ready.")).toBe(false);
expect(isGenericAssistantMessage("Updated the call scripts sheet with truth-based messaging templates.")).toBe(false);
expect(
isGenericAssistantMessage(
"## What I Accomplished\n\nDeployed the API to production with Vercel.",
),
).toBe(false);
expect(
isGenericAssistantMessage(
"The ElevenLabs SDK has been installed and configured. Voice skill is ready.",
),
).toBe(false);
expect(
isGenericAssistantMessage(
"Updated the call scripts sheet with truth-based messaging templates.",
),
).toBe(false);
});
it("preserves long messages even with generic openers", () => {
const longMsg = "I see you've shared an update. " + "Here are the detailed changes I made to the configuration. ".repeat(10);
const longMsg =
"I see you've shared an update. " +
"Here are the detailed changes I made to the configuration. ".repeat(10);
expect(isGenericAssistantMessage(longMsg)).toBe(false);
});
});
@@ -360,7 +419,8 @@ What models are you currently using?`;
});
it("removes media attachment lines", () => {
const input = "[media attached: /path/to/file.jpg (image/jpeg) | /path/to/file.jpg]\nActual question here";
const input =
"[media attached: /path/to/file.jpg (image/jpeg) | /path/to/file.jpg]\nActual question here";
const result = stripNoiseFromContent(input);
expect(result).toContain("Actual question here");
expect(result).not.toContain("[media attached:");
@@ -440,10 +500,14 @@ What is the deployment plan?`,
it("handles a realistic mixed payload", () => {
const messages = [
{ role: "user", content: "Pre-compaction memory flush. Store durable memories now." },
{
role: "user",
content: "Pre-compaction memory flush. Store durable memories now.",
},
{
role: "assistant",
content: "## What I Accomplished\n\nDeployed the API to production with Vercel.",
content:
"## What I Accomplished\n\nDeployed the API to production with Vercel.",
},
{ role: "user", content: "sir" },
];
@@ -454,8 +518,15 @@ What is the deployment plan?`,
it("drops generic assistant acknowledgments", () => {
const messages = [
{ role: "user", content: "[ASSISTANT]: Updated the Google Sheet with truth-based scripts." },
{ role: "assistant", content: "I see you've shared an update. How can I help?" },
{
role: "user",
content:
"[ASSISTANT]: Updated the Google Sheet with truth-based scripts.",
},
{
role: "assistant",
content: "I see you've shared an update. How can I help?",
},
];
const result = filterMessagesForExtraction(messages);
expect(result).toHaveLength(1);
@@ -480,10 +551,13 @@ What is the deployment plan?`,
it("keeps substantive assistant messages even with generic opener", () => {
const messages = [
{ role: "user", content: "What did you do?" },
{ role: "assistant", content: "I deployed the API to production and configured the webhook endpoints for Stripe integration." },
{
role: "assistant",
content:
"I deployed the API to production and configured the webhook endpoints for Stripe integration.",
},
];
const result = filterMessagesForExtraction(messages);
expect(result).toHaveLength(2);
});
});
+364 -1194
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -31,7 +31,8 @@ export function isNonInteractiveTrigger(
// Fallback: detect cron/heartbeat from the session key pattern
if (sessionKey) {
if (/:cron:/i.test(sessionKey) || /:heartbeat:/i.test(sessionKey)) return true;
if (/:cron:/i.test(sessionKey) || /:heartbeat:/i.test(sessionKey))
return true;
}
return false;
@@ -58,7 +59,9 @@ export function isSubagentSession(sessionKey: string | undefined): boolean {
* Returns the subagent UUID for subagent sessions, the agentId for
* non-"main" named agents, or undefined for the main agent session.
*/
export function extractAgentId(sessionKey: string | undefined): string | undefined {
export function extractAgentId(
sessionKey: string | undefined,
): string | undefined {
if (!sessionKey) return undefined;
// Check for subagent pattern: "agent:<parent>:subagent:<uuid>"
@@ -77,7 +80,10 @@ export function extractAgentId(sessionKey: string | undefined): string | undefin
* Derive the effective user_id from a session key, namespacing per-agent.
* Falls back to baseUserId when the session is not agent-scoped.
*/
export function effectiveUserId(baseUserId: string, sessionKey?: string): string {
export function effectiveUserId(
baseUserId: string,
sessionKey?: string,
): string {
const agentId = extractAgentId(sessionKey);
return agentId ? `${baseUserId}:agent:${agentId}` : baseUserId;
}
+1 -4
View File
@@ -12,10 +12,7 @@ declare module "openclaw/plugin-sdk" {
definition: Record<string, unknown>,
metadata?: Record<string, unknown>,
): void;
on(
event: string,
handler: (event: any, ctx: any) => any,
): void;
on(event: string, handler: (event: any, ctx: any) => any): void;
registerCli(
handler: (context: { program: any }) => void,
options?: Record<string, unknown>,
+17
View File
@@ -1,7 +1,15 @@
{
"id": "openclaw-mem0",
"name": "Memory (Mem0)",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"kind": "memory",
"skills": ["skills"],
"contracts": {
"tools": [
"memory_search", "memory_store", "memory_get", "memory_list",
"memory_update", "memory_delete", "memory_history"
]
},
"uiHints": {
"mode": {
"label": "Mode",
@@ -13,6 +21,12 @@
"placeholder": "m0-...",
"help": "API key from app.mem0.ai (or use ${MEM0_API_KEY}). Only needed for platform mode."
},
"baseUrl": {
"label": "API Base URL",
"placeholder": "https://api.mem0.ai",
"advanced": true,
"help": "Override the Mem0 API base URL. Only needed for custom deployments."
},
"userId": {
"label": "Default User ID",
"placeholder": "default",
@@ -91,6 +105,9 @@
"apiKey": {
"type": "string"
},
"baseUrl": {
"type": "string"
},
"userId": {
"type": "string"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
"version": "1.0.3",
"version": "1.0.4-beta.0",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
@@ -35,7 +35,7 @@
},
"dependencies": {
"@sinclair/typebox": "0.34.47",
"mem0ai": "2.3.0"
"mem0ai": "2.4.5"
},
"openclaw": {
"extensions": [
+6 -6
View File
@@ -12,8 +12,8 @@ importers:
specifier: 0.34.47
version: 0.34.47
mem0ai:
specifier: 2.3.0
version: 2.3.0(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(groq-sdk@0.3.0)(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.19.0)
specifier: 2.4.5
version: 2.4.5(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(groq-sdk@0.3.0)(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.19.0)
devDependencies:
'@types/node':
specifier: ^22.15.0
@@ -1466,8 +1466,8 @@ packages:
md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
mem0ai@2.3.0:
resolution: {integrity: sha512-9e0B9hgM7nxnKDD6zmSRkfkrQ2sa9tGdpZE3CPsp7eIu18soYbXvm9YT08mllCYuiW+EdEW7L/+DNCphOfvkKQ==}
mem0ai@2.4.5:
resolution: {integrity: sha512-0XMRe5/KKZXkSJDb2YDqgWdIfJpL2cn8Lelx9pvDPJOvBfXx8uHjtSc80jyGyb9C/1wW2uPcaE+WS+Rs/2phCg==}
engines: {node: '>=18'}
peerDependencies:
'@anthropic-ai/sdk': ^0.40.1
@@ -1475,7 +1475,7 @@ packages:
'@azure/search-documents': ^12.0.0
'@cloudflare/workers-types': ^4.20250504.0
'@google/genai': ^1.2.0
'@langchain/core': ^0.3.44
'@langchain/core': ^1.0.0
'@mistralai/mistralai': ^1.5.2
'@qdrant/js-client-rest': 1.13.0
'@supabase/supabase-js': ^2.49.1
@@ -3543,7 +3543,7 @@ snapshots:
crypt: 0.0.2
is-buffer: 1.1.6
mem0ai@2.3.0(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(groq-sdk@0.3.0)(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.19.0):
mem0ai@2.4.5(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(groq-sdk@0.3.0)(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.19.0):
dependencies:
'@anthropic-ai/sdk': 0.40.1
'@azure/identity': 4.13.0
+159 -13
View File
@@ -48,7 +48,10 @@ function normalizeAddResult(raw: any): AddResult {
memory: r.memory ?? r.text ?? "",
// Platform API may return PENDING status (async processing)
// OSS stores event in metadata.event
event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"),
event:
r.event ??
r.metadata?.event ??
(r.status === "PENDING" ? "ADD" : "ADD"),
})),
};
}
@@ -58,7 +61,10 @@ function normalizeAddResult(raw: any): AddResult {
results: raw.map((r: any) => ({
id: r.id ?? r.memory_id ?? "",
memory: r.memory ?? r.text ?? "",
event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"),
event:
r.event ??
r.metadata?.event ??
(r.status === "PENDING" ? "ADD" : "ADD"),
})),
};
}
@@ -77,7 +83,7 @@ class PlatformProvider implements Mem0Provider {
private readonly apiKey: string,
private readonly orgId?: string,
private readonly projectId?: string,
) { }
) {}
private async ensureClient(): Promise<void> {
if (this.client) return;
@@ -91,7 +97,9 @@ class PlatformProvider implements Mem0Provider {
private async _init(): Promise<void> {
const { default: MemoryClient } = await import("mem0ai");
const opts: { apiKey: string; org_id?: string; project_id?: string } = { apiKey: this.apiKey };
const opts: { apiKey: string; org_id?: string; project_id?: string } = {
apiKey: this.apiKey,
};
if (this.orgId) opts.org_id = this.orgId;
if (this.projectId) opts.project_id = this.projectId;
this.client = new MemoryClient(opts);
@@ -113,7 +121,8 @@ class PlatformProvider implements Mem0Provider {
if (options.source) opts.source = options.source;
// Agentic harness: direct storage bypass
if (options.infer !== undefined) opts.infer = options.infer;
if (options.deduced_memories) opts.deduced_memories = options.deduced_memories;
if (options.deduced_memories)
opts.deduced_memories = options.deduced_memories;
if (options.metadata) opts.metadata = options.metadata;
if (options.expiration_date) opts.expiration_date = options.expiration_date;
if (options.immutable) opts.immutable = options.immutable;
@@ -140,9 +149,11 @@ class PlatformProvider implements Mem0Provider {
};
if (options.top_k != null) opts.top_k = options.top_k;
if (options.threshold != null) opts.threshold = options.threshold;
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
if (options.keyword_search != null)
opts.keyword_search = options.keyword_search;
if (options.reranking != null) opts.rerank = options.reranking;
if (options.filter_memories != null) opts.filter_memories = options.filter_memories;
if (options.filter_memories != null)
opts.filter_memories = options.filter_memories;
if (options.categories != null) opts.categories = options.categories;
const results = await this.client.search(query, opts);
@@ -185,7 +196,17 @@ class PlatformProvider implements Mem0Provider {
await this.client.deleteAll({ user_id: userId });
}
async history(memoryId: string): Promise<Array<{ id: string; old_memory: string; new_memory: string; event: string; created_at: string }>> {
async history(
memoryId: string,
): Promise<
Array<{
id: string;
old_memory: string;
new_memory: string;
event: string;
created_at: string;
}>
> {
await this.ensureClient();
const result = await this.client.history(memoryId);
return Array.isArray(result) ? result : [];
@@ -204,7 +225,7 @@ class OSSProvider implements Mem0Provider {
private readonly ossConfig?: Mem0Config["oss"],
private readonly customPrompt?: string,
private readonly resolvePath?: (p: string) => string,
) { }
) {}
private async ensureMemory(): Promise<void> {
if (this.memory) return;
@@ -257,6 +278,18 @@ class OSSProvider implements Mem0Provider {
throw err;
}
}
// Force the SDK's internal auto-initialization to complete now.
// Without this, concurrent method calls (e.g. auto-recall + stats)
// both trigger _autoInitialize() simultaneously, causing PGVector's
// pg client to call connect() twice → "Client has already been
// connected" crash. (#4638)
try {
await this.memory.getAll({ userId: "__mem0_warmup__" });
} catch {
// Warmup errors are non-fatal — the SDK may still work for
// subsequent calls once its internal state settles.
}
}
async add(
@@ -271,7 +304,8 @@ class OSSProvider implements Mem0Provider {
// Agentic harness: direct storage bypass
if (options.infer !== undefined) addOpts.infer = options.infer;
if (options.metadata) addOpts.metadata = options.metadata;
if (options.expiration_date) addOpts.expirationDate = options.expiration_date;
if (options.expiration_date)
addOpts.expirationDate = options.expiration_date;
if (options.immutable) addOpts.immutable = options.immutable;
// OSS SDK doesn't support deduced_memories — when infer=false, it stores
@@ -296,7 +330,8 @@ class OSSProvider implements Mem0Provider {
if (options.run_id) opts.runId = options.run_id;
if (options.limit != null) opts.limit = options.limit;
else if (options.top_k != null) opts.limit = options.top_k;
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
if (options.keyword_search != null)
opts.keyword_search = options.keyword_search;
if (options.reranking != null) opts.reranking = options.reranking;
if (options.source) opts.source = options.source;
if (options.threshold != null) opts.threshold = options.threshold;
@@ -306,7 +341,9 @@ class OSSProvider implements Mem0Provider {
// Filter results by threshold if specified (client-side filtering as fallback)
if (options.threshold != null) {
return normalized.filter(item => (item.score ?? 0) >= options.threshold!);
return normalized.filter(
(item) => (item.score ?? 0) >= options.threshold!,
);
}
return normalized;
@@ -346,7 +383,17 @@ class OSSProvider implements Mem0Provider {
await this.memory.deleteAll({ userId });
}
async history(memoryId: string): Promise<Array<{ id: string; old_memory: string; new_memory: string; event: string; created_at: string }>> {
async history(
memoryId: string,
): Promise<
Array<{
id: string;
old_memory: string;
new_memory: string;
event: string;
created_at: string;
}>
> {
await this.ensureMemory();
try {
const result = await this.memory.history(memoryId);
@@ -374,3 +421,102 @@ export function createProvider(
return new PlatformProvider(cfg.apiKey!, cfg.orgId, cfg.projectId);
}
// ============================================================================
// Provider-to-Backend Adapter
// ============================================================================
import type { Backend } from "./backend/base.ts";
/**
* Wraps an existing Mem0Provider as the Backend interface.
* Used in OSS mode where PlatformBackend cannot be used.
* Platform-only methods (entities, events) throw clear errors.
*/
export function providerToBackend(
provider: Mem0Provider,
userId: string,
): Backend {
return {
async add(content, messages, opts = {}) {
const msgs = messages ?? (content ? [{ role: "user", content }] : []);
const result = await provider.add(
msgs as Array<{ role: string; content: string }>,
{
user_id: opts.userId ?? userId,
...(opts.runId && { run_id: opts.runId }),
...(opts.metadata && { metadata: opts.metadata }),
...(opts.immutable && { immutable: true }),
...(opts.infer === false && { infer: false }),
...(opts.expires && { expiration_date: opts.expires }),
...(opts.enableGraph && { enable_graph: true }),
},
);
return result as unknown as Record<string, unknown>;
},
async search(query, opts = {}) {
const results = await provider.search(query, {
user_id: opts.userId ?? userId,
top_k: opts.topK,
threshold: opts.threshold,
keyword_search: opts.keyword,
reranking: opts.rerank,
filters: opts.filters,
});
return results as unknown as Record<string, unknown>[];
},
async get(memoryId) {
const item = await provider.get(memoryId);
return item as unknown as Record<string, unknown>;
},
async listMemories(opts = {}) {
const items = await provider.getAll({
user_id: opts.userId ?? userId,
page_size: opts.pageSize,
});
return items as unknown as Record<string, unknown>[];
},
async update(memoryId, content, metadata) {
if (content) await provider.update(memoryId, content);
if (metadata) {
// OSS provider doesn't support metadata-only updates — log warning
console.warn(
"providerToBackend: metadata updates are not supported in OSS mode, only text updates are applied",
);
}
return { id: memoryId, updated: true };
},
async delete(memoryId, opts = {}) {
if (opts.all) {
await provider.deleteAll(opts.userId ?? userId);
return { deleted: "all" };
}
if (memoryId) {
await provider.delete(memoryId);
return { deleted: memoryId };
}
throw new Error("Either memoryId or all is required");
},
async deleteEntities() {
throw new Error("Entity management is only available in platform mode.");
},
async status() {
return { connected: true, backend: "oss" };
},
async entities() {
throw new Error("Entity management is only available in platform mode.");
},
async listEvents() {
throw new Error("Event management is only available in platform mode.");
},
async getEvent() {
throw new Error("Event management is only available in platform mode.");
},
};
}
+39 -15
View File
@@ -8,7 +8,12 @@
* 4. Format by category with importance scores
*/
import type { Mem0Provider, MemoryItem, SkillsConfig, SearchOptions } from "./types.ts";
import type {
Mem0Provider,
MemoryItem,
SkillsConfig,
SearchOptions,
} from "./types.ts";
// ============================================================================
// Defaults
@@ -51,7 +56,10 @@ interface RecallResult {
function getMemoryCategory(memory: MemoryItem): string {
// Check metadata first (skill-stored memories have explicit category)
if (memory.metadata?.category && typeof memory.metadata.category === "string") {
if (
memory.metadata?.category &&
typeof memory.metadata.category === "string"
) {
return memory.metadata.category;
}
// Check categories array (mem0-extracted memories)
@@ -62,7 +70,10 @@ function getMemoryCategory(memory: MemoryItem): string {
}
function getMemoryImportance(memory: MemoryItem): number {
if (memory.metadata?.importance && typeof memory.metadata.importance === "number") {
if (
memory.metadata?.importance &&
typeof memory.metadata.importance === "number"
) {
return memory.metadata.importance;
}
// Default importance by category
@@ -70,15 +81,15 @@ function getMemoryImportance(memory: MemoryItem): number {
const defaults: Record<string, number> = {
identity: 0.95,
configuration: 0.95,
rule: 0.90,
rule: 0.9,
preference: 0.85,
decision: 0.80,
technical: 0.80,
decision: 0.8,
technical: 0.8,
relationship: 0.75,
project: 0.75,
operational: 0.60,
operational: 0.6,
};
return defaults[cat] ?? 0.50;
return defaults[cat] ?? 0.5;
}
// ============================================================================
@@ -135,8 +146,9 @@ function budgetMemories(
if (selected.length >= maxMemories) break;
const memTokens = estimateTokens(memory.memory);
const isIdentity = getMemoryCategory(memory) === "identity" ||
getMemoryCategory(memory) === "configuration";
const isIdentity =
getMemoryCategory(memory) === "identity" ||
getMemoryCategory(memory) === "configuration";
// Identity/config always included if flag is set
if (identityAlwaysInclude && isIdentity) {
@@ -188,7 +200,9 @@ function formatRecalledMemories(
lines.push(`${label}:`);
for (const mem of mems) {
const imp = getMemoryImportance(mem);
const cats = mem.categories?.length ? ` [${mem.categories.join(", ")}]` : "";
const cats = mem.categories?.length
? ` [${mem.categories.join(", ")}]`
: "";
lines.push(`- ${mem.memory}${cats} (${Math.round(imp * 100)}%)`);
}
lines.push("");
@@ -209,7 +223,10 @@ function formatRecalledMemories(
* via the skill protocol (the agent formulates search queries with context).
*/
export function sanitizeQuery(raw: string): string {
let cleaned = raw.replace(/Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, "");
let cleaned = raw.replace(
/Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi,
"",
);
cleaned = cleaned.replace(/^\[.*?\]\s*/g, "");
cleaned = cleaned.trim();
return cleaned || raw;
@@ -257,7 +274,10 @@ export async function recall(
longTermMemories = await provider.search(cleanQuery, searchOpts);
} catch (err) {
// Graceful degradation — recall failure shouldn't block the agent
console.warn("[mem0] Recall search failed:", err instanceof Error ? err.message : err);
console.warn(
"[mem0] Recall search failed:",
err instanceof Error ? err.message : err,
);
}
// Search session memories if we have a session
@@ -281,7 +301,12 @@ export async function recall(
// Combine and rank
const allMemories = [...longTermMemories, ...uniqueSession];
const ranked = rankMemories(allMemories, categoryOrder);
const budgeted = budgetMemories(ranked, tokenBudget, maxMemories, identityAlwaysInclude);
const budgeted = budgetMemories(
ranked,
tokenBudget,
maxMemories,
identityAlwaysInclude,
);
// Format for injection
const context = formatRecalledMemories(budgeted, userId);
@@ -289,4 +314,3 @@ export async function recall(
return { context, memories: budgeted, tokenEstimate };
}
+129 -45
View File
@@ -14,19 +14,27 @@ import type { SkillsConfig, CategoryConfig } from "./types.ts";
const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
configuration: { importance: 0.95, ttl: null },
rule: { importance: 0.90, ttl: null },
rule: { importance: 0.9, ttl: null },
identity: { importance: 0.95, ttl: null, immutable: true },
preference: { importance: 0.85, ttl: null },
decision: { importance: 0.80, ttl: null },
technical: { importance: 0.80, ttl: null },
decision: { importance: 0.8, ttl: null },
technical: { importance: 0.8, ttl: null },
relationship: { importance: 0.75, ttl: null },
project: { importance: 0.75, ttl: "90d" },
operational: { importance: 0.60, ttl: "7d" },
operational: { importance: 0.6, ttl: "7d" },
};
const DEFAULT_CREDENTIAL_PATTERNS = [
"sk-", "m0-", "ghp_", "AKIA", "ak_", "Bearer ",
"bot\\d+:AA", "password=", "token=", "secret=",
"sk-",
"m0-",
"ghp_",
"AKIA",
"ak_",
"Bearer ",
"bot\\d+:AA",
"password=",
"token=",
"secret=",
];
// ============================================================================
@@ -91,7 +99,9 @@ function resolveSkillsDir(): string {
const metaDir = path.dirname(fileURLToPath(import.meta.url));
candidates.push(path.join(metaDir, "skills"));
candidates.push(path.join(metaDir, "..", "skills"));
} catch { /* import.meta.url may not be available */ }
} catch {
/* import.meta.url may not be available */
}
// Strategy 2: __dirname (works in CJS / jiti)
if (typeof __dirname !== "undefined") {
@@ -120,8 +130,10 @@ const RESOLVED_SKILLS_DIR = path.resolve(SKILLS_DIR);
*/
export function safePath(...segments: string[]): string | null {
const resolved = path.resolve(SKILLS_DIR, ...segments);
if (resolved !== RESOLVED_SKILLS_DIR &&
!resolved.startsWith(RESOLVED_SKILLS_DIR + path.sep)) {
if (
resolved !== RESOLVED_SKILLS_DIR &&
!resolved.startsWith(RESOLVED_SKILLS_DIR + path.sep)
) {
return null;
}
return resolved;
@@ -163,12 +175,18 @@ function readDomainOverlay(domain: string, targetSkill: string): string | null {
// Config Injection — render user-configured knobs into prompt text
// ============================================================================
function renderCategoriesBlock(categories: Record<string, CategoryConfig>): string {
const lines: string[] = ["\n## Active Category Configuration (overrides defaults above)\n"];
function renderCategoriesBlock(
categories: Record<string, CategoryConfig>,
): string {
const lines: string[] = [
"\n## Active Category Configuration (overrides defaults above)\n",
];
for (const [name, cat] of Object.entries(categories)) {
const ttlLabel = cat.ttl ? `expires: ${cat.ttl}` : "permanent";
const immLabel = cat.immutable ? ", immutable" : "";
lines.push(`- **${name.toUpperCase()}** (importance: ${cat.importance} | ${ttlLabel}${immLabel})`);
lines.push(
`- **${name.toUpperCase()}** (importance: ${cat.importance} | ${ttlLabel}${immLabel})`,
);
}
return lines.join("\n");
}
@@ -180,7 +198,9 @@ function renderTriageKnobs(config: SkillsConfig): string {
const lines: string[] = [];
if (triage.importanceThreshold !== undefined) {
lines.push(`- Only store facts with importance >= ${triage.importanceThreshold}`);
lines.push(
`- Only store facts with importance >= ${triage.importanceThreshold}`,
);
}
const patterns = resolveCredentialPatterns(config);
@@ -297,19 +317,29 @@ export function loadTriagePrompt(config: SkillsConfig = {}): string {
// are not part of the SKILL.md (tool format, batching, search protocol).
const parts: string[] = [];
parts.push("<memory-system>");
parts.push("IMPORTANT: Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).");
parts.push(
"IMPORTANT: Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).",
);
parts.push("");
parts.push(triage.prompt);
parts.push("");
parts.push("## Tool Usage");
parts.push("");
parts.push("Batch facts by CATEGORY. All facts in one memory_store call must share the same category because category determines retention policy (TTL, immutability). If a turn has facts in different categories, make one call per category.");
parts.push(
"Batch facts by CATEGORY. All facts in one memory_store call must share the same category because category determines retention policy (TTL, immutability). If a turn has facts in different categories, make one call per category.",
);
parts.push("");
parts.push("FORMAT (single category):");
parts.push(' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")');
parts.push(
' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")',
);
parts.push("FORMAT (mixed categories in one turn, separate calls):");
parts.push(' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")');
parts.push(' memory_store(facts: ["As of 2026-04-01, migrating from Postgres to CockroachDB"], category: "decision")');
parts.push(
' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")',
);
parts.push(
' memory_store(facts: ["As of 2026-04-01, migrating from Postgres to CockroachDB"], category: "decision")',
);
// Only include search instructions if recall is enabled
if (config.recall?.enabled !== false) {
const strategy = config.recall?.strategy ?? "smart";
@@ -319,39 +349,79 @@ export function loadTriagePrompt(config: SkillsConfig = {}): string {
// In manual mode, the agent is fully responsible for all search
if (strategy === "manual") {
parts.push("You control all memory search. No automatic recall happens. Use memory_search proactively:");
parts.push("- At the start of a new conversation, search for user identity and context.");
parts.push("- When the user references something you do not have context for.");
parts.push(
"You control all memory search. No automatic recall happens. Use memory_search proactively:",
);
parts.push(
"- At the start of a new conversation, search for user identity and context.",
);
parts.push(
"- When the user references something you do not have context for.",
);
parts.push("- When the conversation topic shifts to a new domain.");
parts.push("- Before updating a memory, search to find the existing version.");
parts.push(
"- Before updating a memory, search to find the existing version.",
);
parts.push("");
}
parts.push("When calling memory_search, ALWAYS rewrite the query. NEVER pass the user's raw message.");
parts.push("Stored memories are third-person factual statements. Write a query that matches storage language, not conversation language.");
parts.push("Process: (1) Name your target. (2) Extract signal: proper nouns, technical terms, domain concepts. (3) Bridge to storage language: add terms the stored memory contains (user, decided, prefers, rule, configured, based in). (4) Compose 3-6 keywords.");
parts.push('WRONG: memory_search("Who was that nutritionist my wife recommended?")');
parts.push('RIGHT: memory_search("nutritionist wife recommended relationship")');
parts.push(
"When calling memory_search, ALWAYS rewrite the query. NEVER pass the user's raw message.",
);
parts.push(
"Stored memories are third-person factual statements. Write a query that matches storage language, not conversation language.",
);
parts.push(
"Process: (1) Name your target. (2) Extract signal: proper nouns, technical terms, domain concepts. (3) Bridge to storage language: add terms the stored memory contains (user, decided, prefers, rule, configured, based in). (4) Compose 3-6 keywords.",
);
parts.push(
'WRONG: memory_search("Who was that nutritionist my wife recommended?")',
);
parts.push(
'RIGHT: memory_search("nutritionist wife recommended relationship")',
);
parts.push('WRONG: memory_search("What timezone am I in?")');
parts.push('RIGHT: memory_search("user timezone location based")');
parts.push("");
parts.push("ENTITY SCOPING: Memories are scoped by user_id, agent_id, and run_id. You do not need to pass these in most cases. The plugin handles scoping automatically based on the current session.");
parts.push("- Default behavior: all memory operations use the configured userId and current session. You do not need to pass userId or agentId.");
parts.push("- Use agentId only when you need to read or write memories for a DIFFERENT agent (e.g., querying what the 'researcher' agent knows). This accesses a separate namespace.");
parts.push("- Use userId only when explicitly instructed to operate on a different user's memories.");
parts.push("- Do not pass run_id directly. The plugin manages session scoping through the scope parameter.");
parts.push("- In multi-agent setups, each agent has isolated memory. The main agent's memories are separate from subagent memories.");
parts.push(
"ENTITY SCOPING: Memories are scoped by user_id, agent_id, and run_id. You do not need to pass these in most cases. The plugin handles scoping automatically based on the current session.",
);
parts.push(
"- Default behavior: all memory operations use the configured userId and current session. You do not need to pass userId or agentId.",
);
parts.push(
"- Use agentId only when you need to read or write memories for a DIFFERENT agent (e.g., querying what the 'researcher' agent knows). This accesses a separate namespace.",
);
parts.push(
"- Use userId only when explicitly instructed to operate on a different user's memories.",
);
parts.push(
"- Do not pass run_id directly. The plugin manages session scoping through the scope parameter.",
);
parts.push(
"- In multi-agent setups, each agent has isolated memory. The main agent's memories are separate from subagent memories.",
);
parts.push("");
parts.push("SEARCH SCOPE: Choose the right scope for each search:");
parts.push('- scope: "long-term" for user context, identity, preferences, decisions (default, most common)');
parts.push(
'- scope: "long-term" for user context, identity, preferences, decisions (default, most common)',
);
parts.push('- scope: "session" for facts from this conversation only');
parts.push('- scope: "all" only when you truly need both scopes combined');
parts.push(
'- scope: "all" only when you truly need both scopes combined',
);
parts.push("Using a specific scope avoids unnecessary backend fan-out.");
parts.push("");
parts.push("SEARCH FILTERS: When the user's intent implies a time range or category constraint, pass a `filters` object alongside your rewritten query.");
parts.push('- Time: "last week" -> filters: {"created_at": {"gte": "2026-03-24"}}');
parts.push(
"SEARCH FILTERS: When the user's intent implies a time range or category constraint, pass a `filters` object alongside your rewritten query.",
);
parts.push(
'- Time: "last week" -> filters: {"created_at": {"gte": "2026-03-24"}}',
);
parts.push('- Category: "my preferences" -> categories: ["preference"]');
parts.push("- Available operators: eq, ne, gt, gte, lt, lte, in, contains. Logical: AND, OR, NOT.");
parts.push(
"- Available operators: eq, ne, gt, gte, lt, lte, in, contains. Logical: AND, OR, NOT.",
);
}
parts.push("</memory-system>");
return parts.join("\n");
@@ -360,15 +430,29 @@ export function loadTriagePrompt(config: SkillsConfig = {}): string {
// Fallback: SKILL.md not found. Minimal inline protocol.
const parts: string[] = [];
parts.push("<memory-system>");
parts.push("You have persistent long-term memory via mem0. After EVERY response, evaluate the turn for facts worth storing.");
parts.push("Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).");
parts.push(
"You have persistent long-term memory via mem0. After EVERY response, evaluate the turn for facts worth storing.",
);
parts.push(
"Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).",
);
parts.push("Most turns produce ZERO memory operations. That is correct.");
parts.push("Only store facts a new agent would need days later: identity, preferences, decisions, rules, projects, configs.");
parts.push("Batch facts by CATEGORY. All facts in one call must share the same category.");
parts.push('Format: memory_store(facts: ["fact text"], category: "identity")');
parts.push("NEVER store credentials (sk-, m0-, ghp_, AKIA, Bearer tokens, passwords).");
parts.push(
"Only store facts a new agent would need days later: identity, preferences, decisions, rules, projects, configs.",
);
parts.push(
"Batch facts by CATEGORY. All facts in one call must share the same category.",
);
parts.push(
'Format: memory_store(facts: ["fact text"], category: "identity")',
);
parts.push(
"NEVER store credentials (sk-, m0-, ghp_, AKIA, Bearer tokens, passwords).",
);
if (config.recall?.enabled !== false) {
parts.push("When searching, rewrite queries for retrieval. Do not pass raw user messages.");
parts.push(
"When searching, rewrite queries for retrieval. Do not pass raw user messages.",
);
}
parts.push("</memory-system>");
return parts.join("\n");
+65 -31
View File
@@ -67,11 +67,19 @@ describe("OSSProvider — disableHistory passthrough to Memory", () => {
memoryCallCount++;
capturedConfig = { ...config };
}
async add() { return { results: [] }; }
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async delete() { }
async add() {
return { results: [] };
}
async search() {
return { results: [] };
}
async get() {
return {};
}
async getAll() {
return [];
}
async delete() {}
},
}));
});
@@ -88,7 +96,9 @@ describe("OSSProvider — disableHistory passthrough to Memory", () => {
// Trigger lazy init by calling search
try {
await provider.search("test", { user_id: "u1" });
} catch { /* provider may fail on mock, that's ok */ }
} catch {
/* provider may fail on mock, that's ok */
}
expect(capturedConfig).toBeDefined();
expect(capturedConfig!.disableHistory).toBe(true);
@@ -105,7 +115,7 @@ describe("OSSProvider — disableHistory passthrough to Memory", () => {
try {
await provider.search("test", { user_id: "u1" });
} catch { }
} catch {}
expect(capturedConfig).toBeDefined();
expect(capturedConfig!.disableHistory).toBeUndefined();
@@ -130,11 +140,19 @@ describe("OSSProvider — initPromise retry after failure", () => {
}
// Second+ call succeeds
}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
async search() {
return { results: [] };
}
async get() {
return {};
}
async getAll() {
return [];
}
async add() {
return { results: [] };
}
async delete() {}
},
}));
});
@@ -149,9 +167,9 @@ describe("OSSProvider — initPromise retry after failure", () => {
const provider = createProvider(cfg, api);
// First call: _init throws, but initPromise is cleared so retry is possible
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("SQLITE_CANTOPEN");
await expect(provider.search("test", { user_id: "u1" })).rejects.toThrow(
"SQLITE_CANTOPEN",
);
// Second call: should retry _init (not return cached rejection)
// callCount === 1 threw, so callCount === 2 should succeed
@@ -179,11 +197,19 @@ describe("OSSProvider — graceful SQLite fallback", () => {
}
// Succeeds when disableHistory is true
}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
async search() {
return { results: [] };
}
async get() {
return {};
}
async getAll() {
return [];
}
async add() {
return { results: [] };
}
async delete() {}
},
}));
});
@@ -234,9 +260,9 @@ describe("OSSProvider — graceful SQLite fallback", () => {
const provider = createProvider(cfg, api);
// Should throw — no fallback possible when disableHistory was already set
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("vector store connection refused");
await expect(provider.search("test", { user_id: "u1" })).rejects.toThrow(
"vector store connection refused",
);
});
});
@@ -257,11 +283,19 @@ describe("PlatformProvider — initPromise retry after failure", () => {
throw new Error("Network timeout");
}
}
async search() { return []; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
async search() {
return [];
}
async get() {
return {};
}
async getAll() {
return [];
}
async add() {
return { results: [] };
}
async delete() {}
},
}));
});
@@ -276,9 +310,9 @@ describe("PlatformProvider — initPromise retry after failure", () => {
const provider = createProvider(cfg, api);
// First call fails
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("Network timeout");
await expect(provider.search("test", { user_id: "u1" })).rejects.toThrow(
"Network timeout",
);
// Second call should retry (not return cached rejection)
const results = await provider.search("test", { user_id: "u1" });
+351
View File
@@ -0,0 +1,351 @@
/**
* Tests for the Backend interface module: error classes and type-level
* interface verification.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import {
AuthError,
NotFoundError,
APIError,
type Backend,
type AddOptions,
type SearchOptions,
type ListOptions,
type DeleteOptions,
type EntityIds,
} from "../backend/base.ts";
import { PlatformBackend } from "../backend/platform.ts";
// ---------------------------------------------------------------------------
// AuthError
// ---------------------------------------------------------------------------
describe("AuthError", () => {
it("uses the default message when none is provided", () => {
const err = new AuthError();
expect(err.message).toBe(
"Authentication failed. Your API key may be invalid or expired.",
);
});
it("accepts a custom message", () => {
const err = new AuthError("Token revoked");
expect(err.message).toBe("Token revoked");
});
it("has name 'AuthError'", () => {
const err = new AuthError();
expect(err.name).toBe("AuthError");
});
it("is an instance of Error", () => {
const err = new AuthError();
expect(err).toBeInstanceOf(Error);
});
});
// ---------------------------------------------------------------------------
// NotFoundError
// ---------------------------------------------------------------------------
describe("NotFoundError", () => {
it("includes the path in the message", () => {
const err = new NotFoundError("/v1/memories/abc-123");
expect(err.message).toBe("Resource not found: /v1/memories/abc-123");
});
it("has name 'NotFoundError'", () => {
const err = new NotFoundError("/any");
expect(err.name).toBe("NotFoundError");
});
it("is an instance of Error", () => {
const err = new NotFoundError("/any");
expect(err).toBeInstanceOf(Error);
});
});
// ---------------------------------------------------------------------------
// APIError
// ---------------------------------------------------------------------------
describe("APIError", () => {
it("includes both path and detail in the message", () => {
const err = new APIError("/v1/memories", "Invalid JSON body");
expect(err.message).toBe("Bad request to /v1/memories: Invalid JSON body");
});
it("has name 'APIError'", () => {
const err = new APIError("/x", "y");
expect(err.name).toBe("APIError");
});
it("is an instance of Error", () => {
const err = new APIError("/x", "y");
expect(err).toBeInstanceOf(Error);
});
});
// ---------------------------------------------------------------------------
// Backend interface — compile-time verification
// ---------------------------------------------------------------------------
describe("Backend interface (type-level)", () => {
it("can be referenced as a type", () => {
// This test verifies that the Backend type and option interfaces
// import correctly and are usable at the type level.
const _backendRef: Backend | undefined = undefined;
const _addOpts: AddOptions = {};
const _searchOpts: SearchOptions = {};
const _listOpts: ListOptions = {};
const _deleteOpts: DeleteOptions = {};
const _entityIds: EntityIds = {};
// If this file compiles and this test runs, the interface is valid.
expect(_backendRef).toBeUndefined();
expect(_addOpts).toBeDefined();
expect(_searchOpts).toBeDefined();
expect(_listOpts).toBeDefined();
expect(_deleteOpts).toBeDefined();
expect(_entityIds).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// PlatformBackend
// ---------------------------------------------------------------------------
describe("PlatformBackend", () => {
const BASE_URL = "https://api.mem0.ai";
const API_KEY = "test-api-key-123";
function createBackend(): PlatformBackend {
return new PlatformBackend({ apiKey: API_KEY, baseUrl: BASE_URL });
}
function mockFetchResponse(
status: number,
body: unknown,
statusText = "OK",
): typeof fetch {
return vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText,
json: vi.fn().mockResolvedValue(body),
}) as unknown as typeof fetch;
}
afterEach(() => {
vi.unstubAllGlobals();
});
// -- Constructor ---------------------------------------------------------
it("creates an instance with apiKey and baseUrl", () => {
const backend = createBackend();
expect(backend).toBeInstanceOf(PlatformBackend);
});
it("strips trailing slashes from baseUrl", () => {
const backend = new PlatformBackend({
apiKey: API_KEY,
baseUrl: "https://api.mem0.ai///",
});
// We can verify by calling status and checking the base_url in the response
const mock = mockFetchResponse(200, { status: "ok" });
vi.stubGlobal("fetch", mock);
return backend.status().then((result) => {
expect(result.base_url).toBe("https://api.mem0.ai");
});
});
// -- add() ---------------------------------------------------------------
it("add() sends POST to /v1/memories/ with correct body structure", async () => {
const mock = mockFetchResponse(200, { id: "mem-1", memory: "test" });
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.add("Remember this", undefined, {
userId: "user-1",
});
expect(mock).toHaveBeenCalledOnce();
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("https://api.mem0.ai/v1/memories/");
expect(opts.method).toBe("POST");
expect(opts.headers).toMatchObject({
Authorization: `Token ${API_KEY}`,
"Content-Type": "application/json",
});
const body = JSON.parse(opts.body);
expect(body.messages).toEqual([{ role: "user", content: "Remember this" }]);
expect(body.user_id).toBe("user-1");
expect(result).toEqual({ id: "mem-1", memory: "test" });
});
it("add() passes messages directly when provided", async () => {
const mock = mockFetchResponse(200, { id: "mem-2" });
vi.stubGlobal("fetch", mock);
const messages = [
{ role: "user", content: "Hi" },
{ role: "assistant", content: "Hello!" },
];
const backend = createBackend();
await backend.add(undefined, messages);
const body = JSON.parse(
(mock as ReturnType<typeof vi.fn>).mock.calls[0][1].body,
);
expect(body.messages).toEqual(messages);
});
// -- search() ------------------------------------------------------------
it("search() sends POST to /v2/memories/search/", async () => {
const mock = mockFetchResponse(200, [
{ id: "mem-1", score: 0.95, memory: "test" },
]);
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const results = await backend.search("find this", { userId: "u1" });
expect(mock).toHaveBeenCalledOnce();
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("https://api.mem0.ai/v2/memories/search/");
expect(opts.method).toBe("POST");
const body = JSON.parse(opts.body);
expect(body.query).toBe("find this");
expect(body.top_k).toBe(10);
expect(body.threshold).toBe(0.3);
expect(body.filters).toEqual({ user_id: "u1" });
expect(results).toEqual([{ id: "mem-1", score: 0.95, memory: "test" }]);
});
it("search() unwraps results from object envelope", async () => {
const mock = mockFetchResponse(200, {
results: [{ id: "mem-1" }],
});
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const results = await backend.search("query");
expect(results).toEqual([{ id: "mem-1" }]);
});
// -- get() ---------------------------------------------------------------
it("get() sends GET to /v1/memories/{id}/", async () => {
const mock = mockFetchResponse(200, { id: "mem-abc", memory: "test" });
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.get("mem-abc");
expect(mock).toHaveBeenCalledOnce();
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("https://api.mem0.ai/v1/memories/mem-abc/");
expect(opts.method).toBe("GET");
expect(result).toEqual({ id: "mem-abc", memory: "test" });
});
// -- delete() with memoryId ----------------------------------------------
it("delete() with memoryId sends DELETE to /v1/memories/{id}/", async () => {
const mock = mockFetchResponse(200, { deleted: true });
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.delete("mem-del-1");
expect(mock).toHaveBeenCalledOnce();
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("https://api.mem0.ai/v1/memories/mem-del-1/");
expect(opts.method).toBe("DELETE");
expect(result).toEqual({ deleted: true });
});
// -- delete() with all=true ----------------------------------------------
it("delete() with all=true sends DELETE to /v1/memories/ with scope params", async () => {
const mock = mockFetchResponse(200, { deleted: 5 });
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.delete(undefined, {
all: true,
userId: "user-1",
agentId: "agent-1",
});
expect(mock).toHaveBeenCalledOnce();
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe(
"https://api.mem0.ai/v1/memories/?user_id=user-1&agent_id=agent-1",
);
expect(opts.method).toBe("DELETE");
expect(result).toEqual({ deleted: 5 });
});
// -- status() ------------------------------------------------------------
it("status() returns connected:true on successful ping", async () => {
const mock = mockFetchResponse(200, { status: "ok" });
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.status();
expect(result.connected).toBe(true);
expect(result.backend).toBe("platform");
expect(result.base_url).toBe(BASE_URL);
});
it("status() returns connected:false on failure", async () => {
const mock = vi
.fn()
.mockRejectedValue(new Error("Network error")) as unknown as typeof fetch;
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.status();
expect(result.connected).toBe(false);
expect(result.backend).toBe("platform");
expect(result.error).toBe("Network error");
});
// -- Error handling ------------------------------------------------------
it("throws AuthError on 401", async () => {
const mock = mockFetchResponse(401, {}, "Unauthorized");
vi.stubGlobal("fetch", mock);
const backend = createBackend();
await expect(backend.get("mem-1")).rejects.toThrow(AuthError);
});
it("throws NotFoundError on 404", async () => {
const mock = mockFetchResponse(404, {}, "Not Found");
vi.stubGlobal("fetch", mock);
const backend = createBackend();
await expect(backend.get("mem-nonexistent")).rejects.toThrow(NotFoundError);
});
it("throws APIError on 400", async () => {
const mock = mockFetchResponse(
400,
{ detail: "Invalid request body" },
"Bad Request",
);
vi.stubGlobal("fetch", mock);
const backend = createBackend();
await expect(backend.add("bad data")).rejects.toThrow(APIError);
});
it("throws generic Error on other non-ok status", async () => {
const mock = mockFetchResponse(
500,
{ detail: "Internal server error" },
"Internal Server Error",
);
vi.stubGlobal("fetch", mock);
const backend = createBackend();
await expect(backend.get("mem-1")).rejects.toThrow("HTTP 500");
});
});
+502
View File
@@ -0,0 +1,502 @@
/**
* Tests for config.ts — mem0ConfigSchema.parse() and exported constants.
*/
import { describe, it, expect } from "vitest";
import {
mem0ConfigSchema,
DEFAULT_CUSTOM_INSTRUCTIONS,
DEFAULT_CUSTOM_CATEGORIES,
} from "../config.ts";
// ---------------------------------------------------------------------------
// Exported constants
// ---------------------------------------------------------------------------
describe("DEFAULT_CUSTOM_INSTRUCTIONS", () => {
it("is a non-empty string", () => {
expect(typeof DEFAULT_CUSTOM_INSTRUCTIONS).toBe("string");
expect(DEFAULT_CUSTOM_INSTRUCTIONS.length).toBeGreaterThan(0);
});
});
describe("DEFAULT_CUSTOM_CATEGORIES", () => {
it("is a non-empty object with string values", () => {
expect(typeof DEFAULT_CUSTOM_CATEGORIES).toBe("object");
const keys = Object.keys(DEFAULT_CUSTOM_CATEGORIES);
expect(keys.length).toBeGreaterThan(0);
for (const key of keys) {
expect(typeof DEFAULT_CUSTOM_CATEGORIES[key]).toBe("string");
}
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — defaults
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — defaults", () => {
it("mode defaults to 'platform' when omitted", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.mode).toBe("platform");
});
it("userId falls back to a non-empty string when not provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(typeof cfg.userId).toBe("string");
expect(cfg.userId.length).toBeGreaterThan(0);
});
it("autoCapture defaults to true", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.autoCapture).toBe(true);
});
it("autoRecall defaults to true", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.autoRecall).toBe(true);
});
it("topK defaults to 5", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.topK).toBe(5);
});
it("searchThreshold defaults to 0.5", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.searchThreshold).toBe(0.5);
});
it("enableGraph defaults to false", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.enableGraph).toBe(false);
});
it("customInstructions defaults to DEFAULT_CUSTOM_INSTRUCTIONS", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.customInstructions).toBe(DEFAULT_CUSTOM_INSTRUCTIONS);
});
it("customCategories defaults to DEFAULT_CUSTOM_CATEGORIES", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
});
it("customPrompt defaults to DEFAULT_CUSTOM_INSTRUCTIONS", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.customPrompt).toBe(DEFAULT_CUSTOM_INSTRUCTIONS);
});
it("oss defaults to undefined", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.oss).toBeUndefined();
});
it("skills defaults to undefined", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.skills).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — mode parsing
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — mode parsing", () => {
it('"oss" is normalized to "open-source"', () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss" });
expect(cfg.mode).toBe("open-source");
});
it('"open-source" stays as "open-source"', () => {
const cfg = mem0ConfigSchema.parse({ mode: "open-source" });
expect(cfg.mode).toBe("open-source");
});
it("any other string defaults to 'platform'", () => {
const cfg = mem0ConfigSchema.parse({ mode: "something-else", apiKey: "k" });
expect(cfg.mode).toBe("platform");
});
it("undefined mode defaults to 'platform'", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k" });
expect(cfg.mode).toBe("platform");
});
it("numeric mode defaults to 'platform'", () => {
const cfg = mem0ConfigSchema.parse({ mode: 42, apiKey: "k" });
expect(cfg.mode).toBe("platform");
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — userId precedence
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — userId", () => {
it("userId from config takes precedence over os.userInfo() fallback", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "test-key",
userId: "custom-user",
});
expect(cfg.userId).toBe("custom-user");
});
it("empty string userId falls back to os.userInfo()", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key", userId: "" });
// Empty string is falsy, so the fallback should kick in
expect(typeof cfg.userId).toBe("string");
expect(cfg.userId.length).toBeGreaterThan(0);
});
it("non-string userId falls back to os.userInfo()", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key", userId: 123 });
expect(typeof cfg.userId).toBe("string");
expect(cfg.userId.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — needsSetup
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — needsSetup", () => {
// Note: needsSetup = (mode === "platform" && !resolvedApiKey).
// resolvedApiKey can come from the config OR from ~/.mem0/config.json fallback.
// When no apiKey is provided and no config file exists, needsSetup is true.
// When ~/.mem0/config.json has a key, the fallback populates resolvedApiKey.
it("needsSetup is consistent: false when apiKey resolves, true otherwise (no apiKey in config)", () => {
const cfg = mem0ConfigSchema.parse({ mode: "platform" });
// needsSetup should be true only if NO apiKey was resolved (including from ~/.mem0/config.json)
if (cfg.apiKey) {
expect(cfg.needsSetup).toBe(false);
} else {
expect(cfg.needsSetup).toBe(true);
}
});
it("needsSetup is consistent with empty config", () => {
const cfg = mem0ConfigSchema.parse({});
if (cfg.apiKey) {
expect(cfg.needsSetup).toBe(false);
} else {
expect(cfg.needsSetup).toBe(true);
}
});
it("is false when apiKey is explicitly provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "my-api-key" });
expect(cfg.needsSetup).toBe(false);
});
it("is false when mode is open-source (no apiKey needed)", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss" });
expect(cfg.needsSetup).toBe(false);
});
it("is false when mode is 'open-source' (no apiKey needed)", () => {
const cfg = mem0ConfigSchema.parse({ mode: "open-source" });
expect(cfg.needsSetup).toBe(false);
});
it("needsSetup is always false when apiKey is a valid string", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key-123" });
expect(cfg.apiKey).toBe("test-key-123");
expect(cfg.needsSetup).toBe(false);
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — error cases
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — error cases", () => {
it("throws on unknown keys", () => {
expect(() =>
mem0ConfigSchema.parse({ apiKey: "k", unknownKey: "value" }),
).toThrow(/unknown keys.*unknownKey/);
});
it("throws when multiple unknown keys are present", () => {
expect(() =>
mem0ConfigSchema.parse({ apiKey: "k", foo: 1, bar: 2 }),
).toThrow(/unknown keys/);
});
it("throws on null input", () => {
expect(() => mem0ConfigSchema.parse(null)).toThrow(
"openclaw-mem0 config required",
);
});
it("throws on undefined input", () => {
expect(() => mem0ConfigSchema.parse(undefined)).toThrow(
"openclaw-mem0 config required",
);
});
it("throws on string input", () => {
expect(() => mem0ConfigSchema.parse("not an object")).toThrow(
"openclaw-mem0 config required",
);
});
it("throws on number input", () => {
expect(() => mem0ConfigSchema.parse(42)).toThrow(
"openclaw-mem0 config required",
);
});
it("throws on array input", () => {
expect(() => mem0ConfigSchema.parse([1, 2, 3])).toThrow(
"openclaw-mem0 config required",
);
});
it("throws on boolean input", () => {
expect(() => mem0ConfigSchema.parse(true)).toThrow(
"openclaw-mem0 config required",
);
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — explicit overrides
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — explicit overrides", () => {
it("autoCapture can be set to false", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
autoCapture: false,
});
expect(cfg.autoCapture).toBe(false);
});
it("autoRecall can be set to false", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
autoRecall: false,
});
expect(cfg.autoRecall).toBe(false);
});
it("enableGraph can be set to true", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
enableGraph: true,
});
expect(cfg.enableGraph).toBe(true);
});
it("custom topK is used when provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", topK: 20 });
expect(cfg.topK).toBe(20);
});
it("custom searchThreshold is used when provided", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
searchThreshold: 0.8,
});
expect(cfg.searchThreshold).toBe(0.8);
});
it("custom customInstructions override defaults", () => {
const custom = "My custom instructions";
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customInstructions: custom,
});
expect(cfg.customInstructions).toBe(custom);
});
it("custom customPrompt overrides defaults", () => {
const custom = "My custom prompt";
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customPrompt: custom,
});
expect(cfg.customPrompt).toBe(custom);
});
it("custom customCategories override defaults", () => {
const cats = { myCategory: "description" };
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customCategories: cats,
});
expect(cfg.customCategories).toEqual(cats);
});
it("baseUrl is passed through when provided", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
baseUrl: "https://custom.api.com",
});
expect(cfg.baseUrl).toBe("https://custom.api.com");
});
it("orgId is passed through when provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", orgId: "org-123" });
expect(cfg.orgId).toBe("org-123");
});
it("projectId is passed through when provided", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
projectId: "proj-456",
});
expect(cfg.projectId).toBe("proj-456");
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — oss config
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — oss config", () => {
it("parses oss object when provided", () => {
const ossConfig = {
embedder: {
provider: "openai",
config: { model: "text-embedding-3-small" },
},
vectorStore: { provider: "qdrant", config: { host: "localhost" } },
llm: { provider: "openai", config: { model: "gpt-4" } },
historyDbPath: "/tmp/history.db",
disableHistory: false,
};
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: ossConfig });
expect(cfg.oss).toEqual(ossConfig);
});
it("ignores oss when it is not a plain object", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: "not-an-object" });
expect(cfg.oss).toBeUndefined();
});
it("ignores oss when it is an array", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: [1, 2, 3] });
expect(cfg.oss).toBeUndefined();
});
it("ignores oss when it is null", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: null });
expect(cfg.oss).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — skills config
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — skills config", () => {
it("parses skills object when provided", () => {
const skillsConfig = {
triage: {
enabled: true,
importanceThreshold: 3,
enableGraph: false,
credentialPatterns: ["sk-", "ghp_"],
},
recall: {
enabled: true,
strategy: "smart" as const,
tokenBudget: 2000,
maxMemories: 10,
},
dream: {
enabled: true,
auto: true,
minHours: 12,
minSessions: 3,
minMemories: 15,
},
domain: "engineering",
customRules: {
include: ["tool configs"],
exclude: ["passwords"],
},
categories: {
identity: {
importance: 5,
ttl: null,
immutable: true,
},
},
};
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
skills: skillsConfig,
});
expect(cfg.skills).toEqual(skillsConfig);
});
it("skills is undefined when not provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k" });
expect(cfg.skills).toBeUndefined();
});
it("skills is undefined when set to a non-object value", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", skills: "invalid" });
expect(cfg.skills).toBeUndefined();
});
it("skills is undefined when set to an array", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", skills: [1, 2] });
expect(cfg.skills).toBeUndefined();
});
it("skills is undefined when set to null", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", skills: null });
expect(cfg.skills).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — customCategories edge cases
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — customCategories edge cases", () => {
it("non-object customCategories falls back to defaults", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customCategories: "not-an-object",
});
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
});
it("array customCategories falls back to defaults", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customCategories: ["a", "b"],
});
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
});
it("null customCategories falls back to defaults", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customCategories: null,
});
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — non-string apiKey
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — apiKey edge cases", () => {
// Note: When a non-string apiKey is provided, the parser treats it as
// undefined. However, readMem0ConfigFile() may still provide a fallback
// apiKey from ~/.mem0/config.json if one exists on the system.
it("non-string apiKey is not used directly from config", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: 12345 });
// The numeric value is not used directly — apiKey comes from fallback or is undefined
// Either way, the non-string value is never the resolved apiKey
expect(cfg.apiKey).not.toBe(12345);
});
it("boolean apiKey is not used directly from config", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: true });
expect(cfg.apiKey).not.toBe(true);
});
it("string apiKey takes precedence over any fallback", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "explicit-key" });
expect(cfg.apiKey).toBe("explicit-key");
expect(cfg.needsSetup).toBe(false);
});
});
+815
View File
@@ -0,0 +1,815 @@
/**
* Tests for the tool factory functions in tools/.
*
* Verifies each factory returns the expected shape (name, label,
* description, parameters, execute) and that execute() delegates
* to the correct provider/backend methods.
*/
import { describe, it, expect, vi } from "vitest";
import type { ToolContext } from "../tools/index.ts";
import { registerAllTools } from "../tools/index.ts";
import { createMemorySearchTool } from "../tools/memory-search.ts";
import { createMemoryStoreTool } from "../tools/memory-store.ts";
import { createMemoryGetTool } from "../tools/memory-get.ts";
import { createMemoryDeleteTool } from "../tools/memory-delete.ts";
import { createMemoryListTool } from "../tools/memory-list.ts";
// ---------------------------------------------------------------------------
// Mock helper
// ---------------------------------------------------------------------------
function createMockToolContext(overrides = {}): ToolContext {
return {
api: {
registerTool: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn() },
} as any,
backend: {
get: vi.fn().mockResolvedValue({ id: "test-id", memory: "test memory" }),
delete: vi.fn().mockResolvedValue(undefined),
status: vi.fn().mockResolvedValue({ connected: true }),
deleteEntities: vi.fn().mockResolvedValue(undefined),
} as any,
cfg: {
mode: "platform",
userId: "testuser",
topK: 5,
enableGraph: false,
autoCapture: true,
autoRecall: true,
searchThreshold: 0.5,
customInstructions: "test",
customCategories: {},
customPrompt: "test",
} as any,
provider: {
search: vi
.fn()
.mockResolvedValue([{ id: "m1", memory: "test memory", score: 0.9 }]),
add: vi.fn().mockResolvedValue({
results: [{ event: "ADD", memory: "stored" }],
}),
getAll: vi.fn().mockResolvedValue([{ id: "m1", memory: "test memory" }]),
update: vi.fn().mockResolvedValue({ memory: "updated" }),
delete: vi.fn().mockResolvedValue(undefined),
deleteAll: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockResolvedValue({
id: "test-id",
memory: "test memory",
created_at: "2026-01-01",
updated_at: "2026-01-02",
}),
history: vi.fn().mockResolvedValue([]),
getHistory: vi.fn().mockResolvedValue([]),
} as any,
resolveUserId: vi.fn().mockReturnValue("testuser"),
effectiveUserId: vi.fn().mockReturnValue("testuser"),
agentUserId: vi.fn().mockReturnValue("testuser:agent:test"),
getCurrentSessionId: vi.fn().mockReturnValue(undefined),
skillsActive: false,
buildAddOptions: vi
.fn()
.mockReturnValue({ user_id: "testuser", source: "OPENCLAW" }),
buildSearchOptions: vi
.fn()
.mockReturnValue({ user_id: "testuser", top_k: 5, source: "OPENCLAW" }),
...overrides,
};
}
// ---------------------------------------------------------------------------
// registerAllTools
// ---------------------------------------------------------------------------
describe("registerAllTools", () => {
it("calls api.registerTool exactly 7 times", () => {
const ctx = createMockToolContext();
registerAllTools(ctx);
expect(ctx.api.registerTool).toHaveBeenCalledTimes(7);
});
it("registers tools with the correct names", () => {
const ctx = createMockToolContext();
registerAllTools(ctx);
const names = (
ctx.api.registerTool as ReturnType<typeof vi.fn>
).mock.calls.map((call: unknown[]) => (call[1] as { name: string }).name);
expect(names).toEqual([
"memory_search",
"memory_store",
"memory_get",
"memory_list",
"memory_update",
"memory_delete",
"memory_history",
]);
});
});
// ---------------------------------------------------------------------------
// Tool factory shape checks
// ---------------------------------------------------------------------------
describe("tool factory shape", () => {
const factories = [
{ fn: createMemorySearchTool, expectedName: "memory_search" },
{ fn: createMemoryStoreTool, expectedName: "memory_store" },
{ fn: createMemoryGetTool, expectedName: "memory_get" },
{ fn: createMemoryDeleteTool, expectedName: "memory_delete" },
{ fn: createMemoryListTool, expectedName: "memory_list" },
];
for (const { fn, expectedName } of factories) {
describe(expectedName, () => {
it("returns an object with name, label, description, parameters, and execute", () => {
const ctx = createMockToolContext();
const tool = fn(ctx);
expect(tool.name).toBe(expectedName);
expect(typeof tool.label).toBe("string");
expect(tool.label.length).toBeGreaterThan(0);
expect(typeof tool.description).toBe("string");
expect(tool.description.length).toBeGreaterThan(0);
expect(tool.parameters).toBeDefined();
expect(typeof tool.execute).toBe("function");
});
});
}
});
// ---------------------------------------------------------------------------
// memory_search execute
// ---------------------------------------------------------------------------
describe("memory_search execute", () => {
it("returns formatted results when provider returns matches", async () => {
const ctx = createMockToolContext();
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-1", {
query: "user preferences",
});
expect(ctx.provider!.search).toHaveBeenCalled();
expect(result.content[0].text).toContain("Found 1 memories");
expect(result.content[0].text).toContain("test memory");
expect(result.content[0].text).toContain("90%");
expect(result.details.count).toBe(1);
expect(result.details.memories).toHaveLength(1);
expect(result.details.memories[0].id).toBe("m1");
});
it("returns 'no relevant memories' when provider returns empty", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-2", { query: "nothing" });
expect(result.content[0].text).toBe("No relevant memories found.");
expect(result.details.count).toBe(0);
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn().mockRejectedValue(new Error("network failure")),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-3", { query: "test" });
expect(result.content[0].text).toContain("Memory search failed");
expect(result.content[0].text).toContain("network failure");
expect(result.details.error).toContain("network failure");
});
it("calls resolveUserId with provided agentId and userId", async () => {
const ctx = createMockToolContext();
const tool = createMemorySearchTool(ctx);
await tool.execute("call-4", {
query: "test",
agentId: "researcher",
userId: "alice",
});
expect(ctx.resolveUserId).toHaveBeenCalledWith({
agentId: "researcher",
userId: "alice",
});
});
it("passes limit to buildSearchOptions", async () => {
const ctx = createMockToolContext();
const tool = createMemorySearchTool(ctx);
await tool.execute("call-5", { query: "test", limit: 10 });
expect(ctx.buildSearchOptions).toHaveBeenCalledWith("testuser", 10);
});
it("searches only session scope when scope='session' and session exists", async () => {
const searchMock = vi
.fn()
.mockResolvedValue([{ id: "s1", memory: "session mem", score: 0.8 }]);
const ctx = createMockToolContext({
getCurrentSessionId: vi.fn().mockReturnValue("session-abc"),
provider: {
search: searchMock,
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-6", {
query: "test",
scope: "session",
});
// Should call buildSearchOptions with session ID
expect(ctx.buildSearchOptions).toHaveBeenCalledWith(
"testuser",
undefined,
"session-abc",
);
expect(result.details.count).toBe(1);
});
it("deduplicates results in 'all' scope", async () => {
const searchMock = vi
.fn()
// First call: long-term
.mockResolvedValueOnce([
{ id: "m1", memory: "shared memory", score: 0.95 },
])
// Second call: session
.mockResolvedValueOnce([
{ id: "m1", memory: "shared memory", score: 0.85 },
{ id: "m2", memory: "session only", score: 0.7 },
]);
const ctx = createMockToolContext({
getCurrentSessionId: vi.fn().mockReturnValue("session-xyz"),
provider: {
search: searchMock,
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-7", {
query: "test",
scope: "all",
});
// m1 appears only once (from long-term), m2 is session-only
expect(result.details.count).toBe(2);
const ids = result.details.memories.map((m: any) => m.id);
expect(ids).toEqual(["m1", "m2"]);
});
});
// ---------------------------------------------------------------------------
// memory_store execute
// ---------------------------------------------------------------------------
describe("memory_store execute", () => {
it("calls provider.add with the text and returns stored result", async () => {
const ctx = createMockToolContext();
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-1", {
text: "User prefers dark mode",
});
expect(ctx.provider!.add).toHaveBeenCalled();
const addCall = (ctx.provider!.add as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(addCall[0]).toEqual([
{ role: "user", content: "User prefers dark mode" },
]);
expect(result.content[0].text).toContain("Stored");
expect(result.details.action).toBe("stored");
});
it("returns error when no text or facts are provided", async () => {
const ctx = createMockToolContext();
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-2", {});
expect(result.content[0].text).toContain("No facts provided");
expect(result.details.error).toBe("missing_facts");
});
it("supports facts array", async () => {
const ctx = createMockToolContext();
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-3", {
facts: ["fact one", "fact two"],
});
expect(ctx.provider!.add).toHaveBeenCalled();
const addCall = (ctx.provider!.add as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(addCall[0]).toEqual([
{ role: "user", content: "fact one\nfact two" },
]);
expect(result.details.action).toBe("stored");
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn().mockRejectedValue(new Error("API error")),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-4", { text: "test" });
expect(result.content[0].text).toContain("Memory store failed");
expect(result.details.error).toContain("API error");
});
it("uses skills mode with infer=false when skillsActive is true", async () => {
const addMock = vi.fn().mockResolvedValue({
results: [{ event: "ADD", memory: "stored in skills mode" }],
});
const ctx = createMockToolContext({
skillsActive: true,
provider: {
search: vi.fn().mockResolvedValue([]),
add: addMock,
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-5", {
text: "skills fact",
category: "preference",
});
expect(addMock).toHaveBeenCalledOnce();
const addOpts = addMock.mock.calls[0][1];
expect(addOpts.infer).toBe(false);
expect(addOpts.source).toBe("OPENCLAW");
expect(result.details.mode).toBe("skills");
expect(result.details.category).toBe("preference");
});
it("blocks subagent sessions from storing", async () => {
const ctx = createMockToolContext({
getCurrentSessionId: vi
.fn()
.mockReturnValue("agent:main:subagent:uuid-123"),
});
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-6", { text: "subagent fact" });
expect(ctx.provider!.add).not.toHaveBeenCalled();
expect(result.details.error).toBe("subagent_blocked");
});
it("performs dedup search before adding in legacy mode", async () => {
const searchMock = vi.fn().mockResolvedValue([]);
const addMock = vi.fn().mockResolvedValue({
results: [{ event: "ADD", memory: "stored" }],
});
const ctx = createMockToolContext({
skillsActive: false,
provider: {
search: searchMock,
add: addMock,
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryStoreTool(ctx);
await tool.execute("call-7", { text: "new fact" });
// Search should be called for dedup before add
expect(searchMock).toHaveBeenCalledOnce();
expect(addMock).toHaveBeenCalledOnce();
});
});
// ---------------------------------------------------------------------------
// memory_get execute
// ---------------------------------------------------------------------------
describe("memory_get execute", () => {
it("calls provider.get with the memoryId and returns formatted result", async () => {
const ctx = createMockToolContext();
const tool = createMemoryGetTool(ctx);
const result = await tool.execute("call-1", { memoryId: "test-id" });
expect(ctx.provider!.get).toHaveBeenCalledWith("test-id");
expect(result.content[0].text).toContain("Memory test-id");
expect(result.content[0].text).toContain("test memory");
expect(result.content[0].text).toContain("Created:");
expect(result.details.memory).toBeDefined();
expect(result.details.memory.id).toBe("test-id");
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn().mockRejectedValue(new Error("not found")),
history: vi.fn(),
},
});
const tool = createMemoryGetTool(ctx);
const result = await tool.execute("call-2", { memoryId: "bad-id" });
expect(result.content[0].text).toContain("Memory get failed");
expect(result.details.error).toContain("not found");
});
});
// ---------------------------------------------------------------------------
// memory_delete execute
// ---------------------------------------------------------------------------
describe("memory_delete execute", () => {
it("deletes by memory_id via provider.delete", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-1", { memory_id: "mem-abc" });
expect(ctx.provider!.delete).toHaveBeenCalledWith("mem-abc");
expect(result.content[0].text).toBe("Memory mem-abc deleted.");
expect(result.details.action).toBe("deleted");
expect(result.details.id).toBe("mem-abc");
});
it("searches and auto-deletes single high-confidence match by query", async () => {
const searchMock = vi
.fn()
.mockResolvedValue([{ id: "m1", memory: "match", score: 0.95 }]);
const deleteMock = vi.fn().mockResolvedValue(undefined);
const ctx = createMockToolContext({
provider: {
search: searchMock,
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: deleteMock,
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-2", {
query: "find and delete",
});
expect(searchMock).toHaveBeenCalled();
expect(deleteMock).toHaveBeenCalledWith("m1");
expect(result.content[0].text).toContain("Deleted:");
expect(result.details.action).toBe("deleted");
});
it("returns candidates when query matches multiple ambiguous results", async () => {
const searchMock = vi.fn().mockResolvedValue([
{ id: "m1", memory: "candidate one", score: 0.7 },
{ id: "m2", memory: "candidate two", score: 0.6 },
]);
const deleteMock = vi.fn();
const ctx = createMockToolContext({
provider: {
search: searchMock,
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: deleteMock,
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-3", {
query: "ambiguous",
});
// Should NOT have called delete
expect(deleteMock).not.toHaveBeenCalled();
expect(result.content[0].text).toContain("Found 2 candidates");
expect(result.details.action).toBe("candidates");
expect(result.details.candidates).toHaveLength(2);
});
it("returns no matching memories when query yields empty results", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-4", { query: "nothing" });
expect(result.content[0].text).toBe("No matching memories found.");
expect(result.details.found).toBe(0);
});
it("requires confirm:true for bulk delete (all)", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-5", { all: true });
expect(result.content[0].text).toContain("confirm: true");
expect(result.details.error).toBe("confirmation_required");
});
it("performs bulk delete when all:true and confirm:true", async () => {
const deleteAllMock = vi.fn().mockResolvedValue(undefined);
const ctx = createMockToolContext({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
deleteAll: deleteAllMock,
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-6", {
all: true,
confirm: true,
});
expect(deleteAllMock).toHaveBeenCalledWith("testuser");
expect(result.content[0].text).toContain("All memories deleted");
expect(result.details.action).toBe("deleted_all");
});
it("requires confirm:true for entity cascade delete", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-7", { entity: true });
expect(result.details.error).toBe("confirmation_required");
});
it("performs entity cascade delete when entity:true and confirm:true", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-8", {
entity: true,
confirm: true,
user_id: "alice",
});
expect(ctx.backend.deleteEntities).toHaveBeenCalledWith({
userId: "alice",
});
expect(result.details.action).toBe("entity_deleted");
});
it("returns error when no mode param is specified", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-9", {});
expect(result.content[0].text).toContain(
"Provide memory_id, query, all, or entity",
);
expect(result.details.error).toBe("missing_param");
});
it("blocks subagent sessions from deleting", async () => {
const ctx = createMockToolContext({
getCurrentSessionId: vi
.fn()
.mockReturnValue("agent:main:subagent:uuid-456"),
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-10", { memory_id: "m1" });
expect(ctx.provider!.delete).not.toHaveBeenCalled();
expect(result.details.error).toBe("subagent_blocked");
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn().mockRejectedValue(new Error("delete failed")),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-11", { memory_id: "m1" });
expect(result.content[0].text).toContain("Memory delete failed");
expect(result.details.error).toContain("delete failed");
});
});
// ---------------------------------------------------------------------------
// memory_list execute
// ---------------------------------------------------------------------------
describe("memory_list execute", () => {
it("calls provider.getAll and returns formatted list", async () => {
const ctx = createMockToolContext();
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-1", {});
expect(ctx.provider!.getAll).toHaveBeenCalled();
expect(result.content[0].text).toContain("1 memories");
expect(result.content[0].text).toContain("test memory");
expect(result.details.count).toBe(1);
expect(result.details.memories).toHaveLength(1);
});
it("returns 'no memories stored' when provider returns empty", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn().mockResolvedValue([]),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-2", {});
expect(result.content[0].text).toBe("No memories stored yet.");
expect(result.details.count).toBe(0);
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn().mockRejectedValue(new Error("list failed")),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-3", {});
expect(result.content[0].text).toContain("Memory list failed");
expect(result.details.error).toContain("list failed");
});
it("resolves userId from agentId", async () => {
const ctx = createMockToolContext();
const tool = createMemoryListTool(ctx);
await tool.execute("call-4", { agentId: "researcher" });
expect(ctx.resolveUserId).toHaveBeenCalledWith({
agentId: "researcher",
userId: undefined,
});
});
it("deduplicates results in 'all' scope", async () => {
const getAllMock = vi
.fn()
// First call: long-term
.mockResolvedValueOnce([{ id: "m1", memory: "shared" }])
// Second call: session
.mockResolvedValueOnce([
{ id: "m1", memory: "shared" },
{ id: "m2", memory: "session only" },
]);
const ctx = createMockToolContext({
getCurrentSessionId: vi.fn().mockReturnValue("session-123"),
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: getAllMock,
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-5", { scope: "all" });
expect(result.details.count).toBe(2);
const ids = result.details.memories.map((m: any) => m.id);
expect(ids).toEqual(["m1", "m2"]);
});
it("only fetches session memories when scope='session'", async () => {
const getAllMock = vi
.fn()
.mockResolvedValue([{ id: "s1", memory: "session mem" }]);
const ctx = createMockToolContext({
getCurrentSessionId: vi.fn().mockReturnValue("sess-abc"),
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: getAllMock,
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-6", { scope: "session" });
// Should call getAll once with run_id
expect(getAllMock).toHaveBeenCalledOnce();
const opts = getAllMock.mock.calls[0][0];
expect(opts.run_id).toBe("sess-abc");
expect(result.details.count).toBe(1);
});
});
+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) },
};
}
},
};
}
+1 -1
View File
@@ -19,6 +19,6 @@
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["index.ts", "types.ts", "providers.ts", "config.ts", "filtering.ts", "isolation.ts", "openclaw-plugin-sdk.d.ts"],
"include": ["index.ts", "types.ts", "providers.ts", "config.ts", "filtering.ts", "isolation.ts", "openclaw-plugin-sdk.d.ts", "backend/**/*.ts", "tools/**/*.ts", "cli/**/*.ts", "skill-loader.ts", "recall.ts", "dream-gate.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+12 -1
View File
@@ -8,6 +8,7 @@ export type Mem0Config = {
mode: Mem0Mode;
// Platform-specific
apiKey?: string;
baseUrl?: string;
orgId?: string;
projectId?: string;
customInstructions: string;
@@ -156,5 +157,15 @@ export interface Mem0Provider {
update(memoryId: string, text: string): Promise<void>;
delete(memoryId: string): Promise<void>;
deleteAll(userId: string): Promise<void>;
history(memoryId: string): Promise<Array<{ id: string; old_memory: string; new_memory: string; event: string; created_at: string }>>;
history(
memoryId: string,
): Promise<
Array<{
id: string;
old_memory: string;
new_memory: string;
event: string;
created_at: string;
}>
>;
}
+1
View File
@@ -4,3 +4,4 @@ pydantic==2.10.4
mem0ai>=0.1.48
python-dotenv==1.0.1
psycopg>=3.2.8
psycopg-pool>=3.2.6,<4.0.0
+304 -8
View File
@@ -1,3 +1,4 @@
import asyncio
import threading
from unittest.mock import MagicMock, patch
@@ -19,11 +20,16 @@ class TestTelemetryDisabled:
assert at.user_id is None
def test_capture_event_noop_when_disabled(self):
"""capture_event() should return immediately without creating AnonymousTelemetry."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
"""capture_event() should return immediately without touching the singleton."""
saved = telemetry_module._oss_telemetry_instance
try:
telemetry_module._oss_telemetry_instance = None
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
telemetry_module.capture_event("test.event", MagicMock())
mock_cls.assert_not_called()
# Singleton must not have been initialised
assert telemetry_module._oss_telemetry_instance is None
finally:
telemetry_module._oss_telemetry_instance = saved
def test_capture_client_event_noop_when_disabled(self):
"""capture_client_event() should return immediately without calling posthog."""
@@ -69,11 +75,10 @@ class TestTelemetryEnabled:
assert at.user_id == "test-user"
def test_capture_event_sends_when_enabled(self):
"""capture_event() should create AnonymousTelemetry and call capture when enabled."""
"""capture_event() should use the singleton and call capture when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
mock_at = MagicMock()
mock_cls.return_value = mock_at
mock_at = MagicMock()
with patch.object(telemetry_module, "_oss_telemetry_instance", mock_at):
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
@@ -91,6 +96,297 @@ class TestTelemetryEnabled:
mock_client_telemetry.capture_event.assert_called_once()
class TestAnonymousTelemetryClose:
"""Verify AnonymousTelemetry.close() edge cases."""
def test_close_calls_posthog_shutdown(self):
"""close() should call posthog.shutdown()."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
at = telemetry_module.AnonymousTelemetry()
mock_ph = mock_posthog_cls.return_value
at.close()
mock_ph.shutdown.assert_called_once()
def test_close_sets_posthog_to_none(self):
"""close() should set posthog to None to prevent double-shutdown."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog"):
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
at = telemetry_module.AnonymousTelemetry()
at.close()
assert at.posthog is None
def test_double_close_is_safe(self):
"""Calling close() twice should not raise."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
at = telemetry_module.AnonymousTelemetry()
mock_ph = mock_posthog_cls.return_value
at.close()
at.close() # should not raise
# shutdown only called once because posthog was set to None after first close
mock_ph.shutdown.assert_called_once()
def test_capture_after_close_is_noop(self):
"""capture_event() should be a no-op after close() (posthog is None)."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
at = telemetry_module.AnonymousTelemetry()
mock_ph = mock_posthog_cls.return_value
at.close()
mock_ph.reset_mock()
at.capture_event("test.event", {"key": "value"})
mock_ph.capture.assert_not_called()
class TestTelemetrySingleton:
"""Verify the OSS telemetry singleton behaviour."""
def setup_method(self):
# Reset singleton state before each test
telemetry_module._oss_telemetry_instance = None
telemetry_module._oss_telemetry_shutting_down = False
def teardown_method(self):
telemetry_module._oss_telemetry_instance = None
telemetry_module._oss_telemetry_shutting_down = False
def test_singleton_reuses_instance(self):
"""_get_oss_telemetry() should return the same instance on repeated calls."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog"):
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register"):
first = telemetry_module._get_oss_telemetry()
second = telemetry_module._get_oss_telemetry()
assert first is second
def test_singleton_created_only_once_across_threads(self):
"""Only one AnonymousTelemetry should be created even under concurrent access."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register"):
instances = []
def grab():
instances.append(telemetry_module._get_oss_telemetry())
threads = [threading.Thread(target=grab) for _ in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(set(id(i) for i in instances)) == 1
assert mock_posthog.call_count == 1
def test_atexit_registered_once(self):
"""atexit.register should be called exactly once for the singleton."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog"):
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register") as mock_atexit:
telemetry_module._get_oss_telemetry()
telemetry_module._get_oss_telemetry()
telemetry_module._get_oss_telemetry()
# Only one atexit registration despite multiple calls
mock_atexit.assert_called_once_with(telemetry_module._shutdown_oss_telemetry)
def test_capture_event_does_not_create_new_instance_each_call(self):
"""capture_event() should not create a new AnonymousTelemetry per call."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog"):
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register"):
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
telemetry_module.capture_event("e1", mock_memory)
first = telemetry_module._oss_telemetry_instance
telemetry_module.capture_event("e2", mock_memory)
second = telemetry_module._oss_telemetry_instance
assert first is second
def test_posthog_constructed_once_across_many_capture_event_calls(self):
"""The core leak fix: Posthog() should only be called once no matter how
many times capture_event() is invoked."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register"):
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
for i in range(50):
telemetry_module.capture_event(f"event_{i}", mock_memory)
# Only ONE Posthog client created, not 50
assert mock_posthog_cls.call_count == 1
def test_shutdown_clears_singleton(self):
"""_shutdown_oss_telemetry() should close and clear the singleton."""
mock_at = MagicMock()
telemetry_module._oss_telemetry_instance = mock_at
telemetry_module._shutdown_oss_telemetry()
mock_at.close.assert_called_once()
assert telemetry_module._oss_telemetry_instance is None
def test_shutdown_idempotent(self):
"""Calling _shutdown_oss_telemetry() twice should not raise."""
mock_at = MagicMock()
telemetry_module._oss_telemetry_instance = mock_at
telemetry_module._shutdown_oss_telemetry()
telemetry_module._shutdown_oss_telemetry() # should not raise
mock_at.close.assert_called_once()
def test_shutdown_noop_when_no_instance(self):
"""_shutdown_oss_telemetry() should be a no-op when singleton was never created."""
assert telemetry_module._oss_telemetry_instance is None
telemetry_module._shutdown_oss_telemetry() # should not raise
def test_capture_event_noop_when_disabled_with_singleton(self):
"""capture_event() should not initialise the singleton when telemetry is off."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
telemetry_module.capture_event("test.event", MagicMock())
assert telemetry_module._oss_telemetry_instance is None
def test_no_new_instance_after_shutdown(self):
"""After _shutdown_oss_telemetry(), _get_oss_telemetry() should not create a new instance."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog"):
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
with patch("atexit.register"):
# Create and then shut down the singleton
telemetry_module._get_oss_telemetry()
telemetry_module._shutdown_oss_telemetry()
# After shutdown, getting telemetry should return None, not a new instance
result = telemetry_module._get_oss_telemetry()
assert result is None
assert telemetry_module._oss_telemetry_instance is None
class TestMemoryLifecycle:
"""Verify Memory.close() and context manager support."""
def _make_mock_memory(self):
"""Create a Memory-like object with a mock db for testing close()."""
from mem0.memory.main import Memory
with patch.object(Memory, "__init__", lambda self: None):
m = Memory.__new__(Memory)
m.db = MagicMock()
return m
def test_close_calls_db_close(self):
m = self._make_mock_memory()
m.close()
m.db.close.assert_called_once()
def test_double_close_is_safe(self):
"""close() should be safe to call twice (SQLiteManager.close sets connection=None)."""
m = self._make_mock_memory()
m.close()
# After first close, db is still the mock but SQLiteManager.close() handles
# the None-connection case internally. Simulate that by making db.close a no-op.
m.close() # should not raise
def test_close_when_db_is_none(self):
"""close() should not raise if db was already None."""
m = self._make_mock_memory()
m.db = None
m.close() # should not raise
def test_close_when_db_not_set(self):
"""close() should not raise if __init__ failed before setting db."""
from mem0.memory.main import Memory
with patch.object(Memory, "__init__", lambda self: None):
m = Memory.__new__(Memory)
# db attribute not set at all
m.close() # should not raise due to hasattr guard
def test_context_manager(self):
"""Memory should support `with` statement and close on exit."""
m = self._make_mock_memory()
with m:
pass
m.db.close.assert_called_once()
def test_context_manager_closes_on_exception(self):
"""Memory should close even if the with-block raises."""
m = self._make_mock_memory()
with pytest.raises(ValueError):
with m:
raise ValueError("boom")
m.db.close.assert_called_once()
class TestAsyncMemoryLifecycle:
"""Verify AsyncMemory.close() and async context manager support."""
def _make_mock_async_memory(self):
from mem0.memory.main import AsyncMemory
with patch.object(AsyncMemory, "__init__", lambda self: None):
m = AsyncMemory.__new__(AsyncMemory)
m.db = MagicMock()
return m
def test_close_calls_db_close(self):
m = self._make_mock_async_memory()
m.close()
m.db.close.assert_called_once()
def test_close_when_db_is_none(self):
m = self._make_mock_async_memory()
m.db = None
m.close() # should not raise
def test_close_when_db_not_set(self):
from mem0.memory.main import AsyncMemory
with patch.object(AsyncMemory, "__init__", lambda self: None):
m = AsyncMemory.__new__(AsyncMemory)
m.close() # should not raise
def test_async_context_manager(self):
"""AsyncMemory should support `async with` and close on exit."""
m = self._make_mock_async_memory()
async def run():
async with m:
pass
asyncio.run(run())
m.db.close.assert_called_once()
def test_async_context_manager_closes_on_exception(self):
"""AsyncMemory should close even if the async with-block raises."""
m = self._make_mock_async_memory()
async def run():
async with m:
raise ValueError("boom")
with pytest.raises(ValueError):
asyncio.run(run())
m.db.close.assert_called_once()
class TestTelemetryEnvVar:
"""Verify the MEM0_TELEMETRY env var parsing logic."""